feat: initial qobuz-qt source

Lightweight Qt6 desktop client for Qobuz with a Rust audio backend
(Symphonia/CPAL via staticlib FFI). Mirrors the spotify-qt layout:
toolbar with playback controls, library/context docks on the left,
tabbed search side panel on the right, queue panel, now-playing dock.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
joren
2026-03-24 00:41:04 +01:00
parent 35ae649fc9
commit cb2323bc32
85 changed files with 4484 additions and 249 deletions

View File

@@ -6,6 +6,9 @@ target_sources(qobuz-qt PRIVATE
mainwindow.hpp
mainwindow.cpp
# Queue (header-only)
playqueue.hpp
# Backend (Qt wrapper around Rust FFI)
backend/qobuzbackend.hpp
backend/qobuzbackend.cpp
@@ -15,6 +18,10 @@ target_sources(qobuz-qt PRIVATE
view/maintoolbar.cpp
view/maincontent.hpp
view/maincontent.cpp
view/context/view.hpp
view/context/view.cpp
view/queuepanel.hpp
view/queuepanel.cpp
view/sidepanel/view.hpp
view/sidepanel/view.cpp

View File

@@ -3,15 +3,14 @@
#include <QHeaderView>
#include <QMenu>
#include <QAction>
#include <QJsonArray>
#include <QJsonObject>
namespace List
{
Tracks::Tracks(QobuzBackend *backend, QWidget *parent)
Tracks::Tracks(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
: QTreeView(parent)
, m_backend(backend)
, m_queue(queue)
{
m_model = new TrackListModel(this);
setModel(m_model);
@@ -22,54 +21,74 @@ Tracks::Tracks(QobuzBackend *backend, QWidget *parent)
setSelectionBehavior(QAbstractItemView::SelectRows);
setSortingEnabled(true);
setContextMenuPolicy(Qt::CustomContextMenu);
sortByColumn(TrackListModel::ColNumber, Qt::AscendingOrder);
header()->setStretchLastSection(false);
header()->setSectionResizeMode(TrackListModel::ColTitle, QHeaderView::Stretch);
header()->setSectionResizeMode(TrackListModel::ColArtist, QHeaderView::Stretch);
header()->setSectionResizeMode(TrackListModel::ColAlbum, QHeaderView::Stretch);
header()->setSectionResizeMode(TrackListModel::ColTitle, QHeaderView::Stretch);
header()->setSectionResizeMode(TrackListModel::ColArtist, QHeaderView::Stretch);
header()->setSectionResizeMode(TrackListModel::ColAlbum, QHeaderView::Stretch);
header()->setSectionResizeMode(TrackListModel::ColNumber, QHeaderView::ResizeToContents);
header()->setSectionResizeMode(TrackListModel::ColDuration, QHeaderView::ResizeToContents);
connect(this, &QTreeView::doubleClicked, this, &Tracks::onDoubleClicked);
connect(this, &QTreeView::customContextMenuRequested, this, &Tracks::onContextMenu);
connect(this, &QTreeView::doubleClicked,
this, &Tracks::onDoubleClicked);
connect(this, &QTreeView::customContextMenuRequested,
this, &Tracks::onContextMenu);
// Re-sync queue order whenever the model is re-sorted (user clicked header
// or setTracks re-applied an existing sort).
connect(m_model, &TrackListModel::sortApplied,
this, &Tracks::syncQueueToModel);
}
void Tracks::loadTracks(const QJsonArray &tracks)
{
m_model->setTracks(tracks);
m_model->setTracks(tracks, false, /*useSequential=*/true);
m_queue->setContext(tracks, 0);
syncQueueToModel();
}
void Tracks::loadAlbum(const QJsonObject &album)
{
const QJsonObject tracksWrapper = album["tracks"].toObject();
const QJsonArray items = tracksWrapper["items"].toArray();
m_model->setTracks(items);
const QJsonArray items = album["tracks"].toObject()["items"].toArray();
m_model->setTracks(items); // album: use track_number
m_queue->setContext(items, 0);
syncQueueToModel();
}
void Tracks::loadPlaylist(const QJsonObject &playlist)
{
const QJsonObject tracksWrapper = playlist["tracks"].toObject();
const QJsonArray items = tracksWrapper["items"].toArray();
m_model->setTracks(items);
const QJsonArray items = playlist["tracks"].toObject()["items"].toArray();
m_model->setTracks(items, /*usePosition=*/true);
m_queue->setContext(items, 0);
syncQueueToModel();
}
void Tracks::loadSearchTracks(const QJsonArray &tracks)
{
m_model->setTracks(tracks);
m_model->setTracks(tracks, false, /*useSequential=*/true);
m_queue->setContext(tracks, 0);
syncQueueToModel();
}
void Tracks::setCurrentTrackId(qint64 id)
void Tracks::setPlayingTrackId(qint64 id)
{
m_currentTrackId = id;
// Trigger a repaint for the now-playing indicator
viewport()->update();
m_playingId = id;
m_model->setPlayingId(id);
}
void Tracks::syncQueueToModel()
{
m_queue->reorderContext(m_model->currentTracksJson(), m_playingId);
}
void Tracks::onDoubleClicked(const QModelIndex &index)
{
const qint64 trackId = m_model->data(index, TrackListModel::TrackIdRole).toLongLong();
if (trackId > 0)
emit playTrackRequested(trackId);
const qint64 id = m_model->data(index, TrackListModel::TrackIdRole).toLongLong();
if (id > 0) {
m_queue->setCurrentById(id);
emit playTrackRequested(id);
}
}
void Tracks::onContextMenu(const QPoint &pos)
@@ -77,33 +96,55 @@ void Tracks::onContextMenu(const QPoint &pos)
const QModelIndex index = indexAt(pos);
if (!index.isValid()) return;
const qint64 trackId = m_model->data(index, TrackListModel::TrackIdRole).toLongLong();
const qint64 id = m_model->data(index, TrackListModel::TrackIdRole).toLongLong();
const QJsonObject trackJson = m_model->data(index, TrackListModel::TrackJsonRole).toJsonObject();
QMenu menu(this);
QAction *playNow = menu.addAction(tr("Play now"));
auto *playNow = menu.addAction(QIcon(":/res/icons/media-playback-start.svg"), tr("Play now"));
auto *playNext = menu.addAction(QIcon(":/res/icons/media-skip-forward.svg"), tr("Play next"));
auto *addQueue = menu.addAction(QIcon(":/res/icons/media-playlist-append.svg"), tr("Add to queue"));
menu.addSeparator();
QAction *addFav = menu.addAction(tr("Add to favorites"));
QAction *remFav = menu.addAction(tr("Remove from favorites"));
auto *addFav = menu.addAction(QIcon(":/res/icons/starred-symbolic.svg"), tr("Add to favorites"));
auto *remFav = menu.addAction(QIcon(":/res/icons/non-starred-symbolic.svg"), tr("Remove from favorites"));
connect(playNow, &QAction::triggered, this, [this, trackId] {
emit playTrackRequested(trackId);
connect(playNow, &QAction::triggered, this, [this, id] {
m_queue->setCurrentById(id);
emit playTrackRequested(id);
});
connect(addFav, &QAction::triggered, this, [this, trackId] {
m_backend->addFavTrack(trackId);
connect(playNext, &QAction::triggered, this, [this, trackJson] {
m_queue->playNext(trackJson);
});
connect(remFav, &QAction::triggered, this, [this, trackId] {
m_backend->removeFavTrack(trackId);
connect(addQueue, &QAction::triggered, this, [this, trackJson] {
m_queue->addToQueue(trackJson);
});
connect(addFav, &QAction::triggered, this, [this, id] {
m_backend->addFavTrack(id);
});
connect(remFav, &QAction::triggered, this, [this, id] {
m_backend->removeFavTrack(id);
});
// Open artist page
const QJsonObject performer = trackJson["performer"].toObject();
const qint64 artistId = static_cast<qint64>(performer["id"].toDouble());
if (artistId > 0) {
// Open album
const QString albumId = m_model->trackAt(index.row()).albumId;
if (!albumId.isEmpty()) {
menu.addSeparator();
QAction *openArtist = menu.addAction(
tr("Open artist: %1").arg(performer["name"].toString()));
auto *openAlbum = menu.addAction(
QIcon(":/res/icons/view-media-album-cover.svg"),
tr("Open album: %1").arg(m_model->trackAt(index.row()).album));
connect(openAlbum, &QAction::triggered, this, [this, albumId] {
m_backend->getAlbum(albumId);
});
}
// Open artist
const qint64 artistId = static_cast<qint64>(
trackJson["performer"].toObject()["id"].toDouble());
if (artistId > 0) {
const QString artistName = trackJson["performer"].toObject()["name"].toString();
auto *openArtist = menu.addAction(
QIcon(":/res/icons/view-media-artist.svg"),
tr("Open artist: %1").arg(artistName));
connect(openArtist, &QAction::triggered, this, [this, artistId] {
m_backend->getArtist(artistId);
});

View File

@@ -2,6 +2,7 @@
#include "../model/tracklistmodel.hpp"
#include "../backend/qobuzbackend.hpp"
#include "../playqueue.hpp"
#include <QTreeView>
#include <QJsonArray>
@@ -9,32 +10,32 @@
namespace List
{
/// Track list view — mirrors the spotify-qt List::Tracks widget.
/// Displays a flat list of tracks for a playlist, album, search result, etc.
class Tracks : public QTreeView
{
Q_OBJECT
public:
explicit Tracks(QobuzBackend *backend, QWidget *parent = nullptr);
explicit Tracks(QobuzBackend *backend, PlayQueue *queue, QWidget *parent = nullptr);
void loadTracks(const QJsonArray &tracks);
void loadAlbum(const QJsonObject &album);
void loadPlaylist(const QJsonObject &playlist);
void loadSearchTracks(const QJsonArray &tracks);
void setCurrentTrackId(qint64 id);
/// Called when the backend fires EV_TRACK_CHANGED so the playing row is highlighted.
void setPlayingTrackId(qint64 id);
signals:
void playTrackRequested(qint64 trackId);
private:
TrackListModel *m_model = nullptr;
QobuzBackend *m_backend = nullptr;
qint64 m_currentTrackId = 0;
TrackListModel *m_model = nullptr;
QobuzBackend *m_backend = nullptr;
PlayQueue *m_queue = nullptr;
qint64 m_playingId = 0;
void onDoubleClicked(const QModelIndex &index);
void onContextMenu(const QPoint &pos);
void resizeColumnsToContent();
void syncQueueToModel();
};
}

View File

@@ -1,8 +1,8 @@
#include "mainwindow.hpp"
#include "dialog/login.hpp"
#include "dialog/settings.hpp"
#include "util/settings.hpp"
#include "util/icon.hpp"
#include <QApplication>
#include <QMenuBar>
@@ -23,12 +23,15 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
setMinimumSize(800, 500);
resize(defaultSize());
// ---- Queue (owned here, shared with toolbar and track list) ----
m_queue = new PlayQueue(this);
// ---- Toolbar ----
m_toolBar = new MainToolBar(m_backend, this);
m_toolBar = new MainToolBar(m_backend, m_queue, this);
addToolBar(Qt::TopToolBarArea, m_toolBar);
// ---- Central content ----
m_content = new MainContent(m_backend, this);
m_content = new MainContent(m_backend, m_queue, this);
setCentralWidget(m_content);
// ---- Library dock (left) ----
@@ -40,39 +43,46 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
m_libraryDock->setMinimumWidth(200);
addDockWidget(Qt::LeftDockWidgetArea, m_libraryDock);
// ---- Now-playing context dock (left, below library) ----
m_contextView = new Context::View(m_backend, this);
addDockWidget(Qt::LeftDockWidgetArea, m_contextView);
// ---- Queue panel (right) ----
m_queuePanel = new QueuePanel(m_queue, this);
m_queuePanel->hide();
addDockWidget(Qt::RightDockWidgetArea, m_queuePanel);
// ---- Search side panel (right) ----
m_sidePanel = new SidePanel::View(m_backend, this);
m_sidePanel->hide();
addDockWidget(Qt::RightDockWidgetArea, m_sidePanel);
// ---- Menu bar ----
setupMenuBar();
// ---- Status bar ----
statusBar()->showMessage(tr("Ready"));
// ---- Wire signals ----
connect(m_backend, &QobuzBackend::loginSuccess, this, &MainWindow::onLoginSuccess);
connect(m_backend, &QobuzBackend::loginError, this, &MainWindow::onLoginError);
connect(m_backend, &QobuzBackend::favTracksLoaded, this, &MainWindow::onFavTracksLoaded);
connect(m_backend, &QobuzBackend::favAlbumsLoaded, this, &MainWindow::onFavAlbumsLoaded);
connect(m_backend, &QobuzBackend::albumLoaded, this, &MainWindow::onAlbumLoaded);
connect(m_backend, &QobuzBackend::artistLoaded, this, &MainWindow::onArtistLoaded);
connect(m_backend, &QobuzBackend::playlistLoaded, this, &MainWindow::onPlaylistLoaded);
// ---- Backend signals ----
connect(m_backend, &QobuzBackend::loginSuccess, this, &MainWindow::onLoginSuccess);
connect(m_backend, &QobuzBackend::loginError, this, &MainWindow::onLoginError);
connect(m_backend, &QobuzBackend::favTracksLoaded, this, &MainWindow::onFavTracksLoaded);
connect(m_backend, &QobuzBackend::favAlbumsLoaded, this, &MainWindow::onFavAlbumsLoaded);
connect(m_backend, &QobuzBackend::albumLoaded, this, &MainWindow::onAlbumLoaded);
connect(m_backend, &QobuzBackend::artistLoaded, this, &MainWindow::onArtistLoaded);
connect(m_backend, &QobuzBackend::playlistLoaded, this, &MainWindow::onPlaylistLoaded);
connect(m_backend, &QobuzBackend::trackChanged, this, &MainWindow::onTrackChanged);
connect(m_backend, &QobuzBackend::error, this, [this](const QString &msg) {
statusBar()->showMessage(tr("Error: %1").arg(msg), 5000);
statusBar()->showMessage(tr("Error: %1").arg(msg), 6000);
});
// Library → backend
connect(m_library, &List::Library::favTracksRequested, m_backend, [this] {
// ---- Library → backend ----
connect(m_library, &List::Library::favTracksRequested, this, [this] {
m_backend->getFavTracks();
statusBar()->showMessage(tr("Loading favorite tracks…"));
});
connect(m_library, &List::Library::favAlbumsRequested, m_backend, [this] {
connect(m_library, &List::Library::favAlbumsRequested, this, [this] {
m_backend->getFavAlbums();
statusBar()->showMessage(tr("Loading favorite albums…"));
});
connect(m_library, &List::Library::favArtistsRequested, m_backend, [this] {
connect(m_library, &List::Library::favArtistsRequested, this, [this] {
m_backend->getFavArtists();
statusBar()->showMessage(tr("Loading favorite artists…"));
});
@@ -82,11 +92,11 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
statusBar()->showMessage(tr("Loading playlist: %1…").arg(name));
});
// Track list → playback
// ---- Track list → playback ----
connect(m_content->tracksList(), &List::Tracks::playTrackRequested,
this, &MainWindow::onPlayTrackRequested);
// Search panel → backend
// ---- Search panel ----
connect(m_sidePanel, &SidePanel::View::albumSelected,
this, &MainWindow::onSearchAlbumSelected);
connect(m_sidePanel, &SidePanel::View::artistSelected,
@@ -94,32 +104,40 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
connect(m_sidePanel, &SidePanel::View::trackPlayRequested,
this, &MainWindow::onPlayTrackRequested);
// Toolbar search toggle → side panel visibility
// ---- Toolbar toggles ----
connect(m_toolBar, &MainToolBar::searchToggled, this, &MainWindow::onSearchToggled);
connect(m_toolBar, &MainToolBar::queueToggled,
this, [this](bool v) { m_queuePanel->setVisible(v); });
// Restore or prompt login
tryRestoreSession();
}
void MainWindow::setupMenuBar()
{
auto *fileMenu = menuBar()->addMenu(tr("&File"));
fileMenu->addAction(tr("&Sign in…"), this, &MainWindow::showLoginDialog);
fileMenu->addAction(Icon::get("im-user-away"), tr("&Sign in…"),
this, &MainWindow::showLoginDialog);
fileMenu->addSeparator();
fileMenu->addAction(tr("&Settings"), this, &MainWindow::showSettingsDialog);
fileMenu->addAction(Icon::settings(), tr("&Settings"),
this, &MainWindow::showSettingsDialog);
fileMenu->addSeparator();
fileMenu->addAction(tr("&Quit"), qApp, &QApplication::quit, QKeySequence::Quit);
auto *quitAction = fileMenu->addAction(Icon::get("application-exit"), tr("&Quit"),
qApp, &QApplication::quit);
quitAction->setShortcut(QKeySequence::Quit);
auto *viewMenu = menuBar()->addMenu(tr("&View"));
viewMenu->addAction(m_libraryDock->toggleViewAction());
viewMenu->addAction(m_contextView->toggleViewAction());
viewMenu->addAction(m_queuePanel->toggleViewAction());
viewMenu->addAction(m_sidePanel->toggleViewAction());
auto *helpMenu = menuBar()->addMenu(tr("&Help"));
helpMenu->addAction(tr("&About"), this, [this] {
helpMenu->addAction(Icon::get("help-about"), tr("&About"), this, [this] {
QMessageBox::about(this, tr("About Qobuz"),
tr("<h3>qobuz-qt</h3>"
"<p>A lightweight Qt client for the Qobuz streaming service.</p>"
"<p>Audio decoding powered by Symphonia (Rust).</p>"));
"<p>Audio engine: <b>Symphonia</b> (Rust) via CPAL/ALSA.<br>"
"Icons: <b>spotify-qt</b> (dark variant).</p>"));
});
}
@@ -133,7 +151,6 @@ void MainWindow::tryRestoreSession()
statusBar()->showMessage(tr("Signed in as %1").arg(
name.isEmpty() ? AppSettings::instance().userEmail() : name));
} else {
// No saved session — prompt login after a short delay so the window is visible first
QTimer::singleShot(200, this, &MainWindow::showLoginDialog);
}
}
@@ -145,16 +162,11 @@ void MainWindow::showLoginDialog()
auto *dlg = new LoginDialog(this);
dlg->setAttribute(Qt::WA_DeleteOnClose);
// Pre-fill if remembered
if (AppSettings::instance().rememberLogin())
dlg->findChild<QLineEdit *>(); // just show with empty fields
connect(dlg, &LoginDialog::loginRequested,
this, [this, dlg](const QString &email, const QString &password) {
dlg->setBusy(true);
m_backend->login(email, password);
});
connect(m_backend, &QobuzBackend::loginSuccess, dlg, [dlg](const QString &, const QJsonObject &) {
dlg->accept();
});
@@ -178,10 +190,8 @@ void MainWindow::onLoginSuccess(const QString &token, const QJsonObject &user)
const QString email = user["email"].toString();
AppSettings::instance().setDisplayName(displayName);
AppSettings::instance().setUserEmail(email);
statusBar()->showMessage(tr("Signed in as %1").arg(
displayName.isEmpty() ? email : displayName));
m_library->refresh();
}
@@ -190,47 +200,59 @@ void MainWindow::onLoginError(const QString &error)
statusBar()->showMessage(tr("Login failed: %1").arg(error), 6000);
}
void MainWindow::onTrackChanged(const QJsonObject &track)
{
// Update playing row highlight in the track list
const qint64 id = static_cast<qint64>(track["id"].toDouble());
m_content->tracksList()->setPlayingTrackId(id);
// Update status bar with track name
const QString title = track["title"].toString();
const QString artist = track["performer"].toObject()["name"].toString().isEmpty()
? track["album"].toObject()["artist"].toObject()["name"].toString()
: track["performer"].toObject()["name"].toString();
statusBar()->showMessage(
artist.isEmpty() ? title : QStringLiteral("▶ %1 — %2").arg(artist, title));
}
void MainWindow::onFavTracksLoaded(const QJsonObject &result)
{
m_content->showFavTracks(result);
statusBar()->showMessage(tr("Favorite tracks loaded."), 3000);
statusBar()->showMessage(
tr("%1 favorite tracks").arg(result["total"].toInt()), 4000);
}
void MainWindow::onFavAlbumsLoaded(const QJsonObject &result)
{
// Show albums as a track list (each album as a row)
// For now just show the raw tracks from each album
// A proper album grid view would go here in a future iteration
const QJsonArray albums = result["items"].toArray();
statusBar()->showMessage(tr("%1 favorite albums").arg(albums.size()), 3000);
// TODO: show album grid
const int total = result["total"].toInt();
statusBar()->showMessage(tr("%1 favorite albums").arg(total), 4000);
// TODO: album grid view
}
void MainWindow::onAlbumLoaded(const QJsonObject &album)
{
m_content->showAlbum(album);
const QString title = album["title"].toString();
statusBar()->showMessage(tr("Album: %1").arg(title), 3000);
statusBar()->showMessage(
tr("Album: %1").arg(album["title"].toString()), 4000);
}
void MainWindow::onArtistLoaded(const QJsonObject &artist)
{
// TODO: show artist view with top tracks + discography
statusBar()->showMessage(tr("Artist: %1").arg(artist["name"].toString()), 3000);
statusBar()->showMessage(
tr("Artist: %1").arg(artist["name"].toString()), 4000);
// TODO: artist view
}
void MainWindow::onPlaylistLoaded(const QJsonObject &playlist)
{
m_content->showPlaylist(playlist);
const QString name = playlist["name"].toString();
statusBar()->showMessage(tr("Playlist: %1").arg(name), 3000);
statusBar()->showMessage(
tr("Playlist: %1").arg(playlist["name"].toString()), 4000);
}
void MainWindow::onPlayTrackRequested(qint64 trackId)
{
const int format = AppSettings::instance().preferredFormat();
m_backend->playTrack(trackId, format);
statusBar()->showMessage(tr("Loading track…"));
m_backend->playTrack(trackId, AppSettings::instance().preferredFormat());
}
void MainWindow::onSearchAlbumSelected(const QString &albumId)

View File

@@ -1,8 +1,11 @@
#pragma once
#include "backend/qobuzbackend.hpp"
#include "playqueue.hpp"
#include "view/maintoolbar.hpp"
#include "view/maincontent.hpp"
#include "view/context/view.hpp"
#include "view/queuepanel.hpp"
#include "view/sidepanel/view.hpp"
#include "list/library.hpp"
@@ -16,7 +19,6 @@ class MainWindow : public QMainWindow
public:
explicit MainWindow(QobuzBackend *backend, QWidget *parent = nullptr);
static QSize defaultSize() { return {1100, 700}; }
private slots:
@@ -29,23 +31,25 @@ private slots:
void onArtistLoaded(const QJsonObject &artist);
void onPlaylistLoaded(const QJsonObject &playlist);
void onTrackChanged(const QJsonObject &track);
void onPlayTrackRequested(qint64 trackId);
void onSearchAlbumSelected(const QString &albumId);
void onSearchArtistSelected(qint64 artistId);
void onSearchToggled(bool visible);
void showLoginDialog();
void showSettingsDialog();
private:
QobuzBackend *m_backend = nullptr;
MainToolBar *m_toolBar = nullptr;
MainContent *m_content = nullptr;
List::Library *m_library = nullptr;
SidePanel::View *m_sidePanel = nullptr;
QDockWidget *m_libraryDock = nullptr;
QobuzBackend *m_backend = nullptr;
PlayQueue *m_queue = nullptr;
MainToolBar *m_toolBar = nullptr;
MainContent *m_content = nullptr;
List::Library *m_library = nullptr;
Context::View *m_contextView = nullptr;
QueuePanel *m_queuePanel = nullptr;
SidePanel::View *m_sidePanel = nullptr;
QDockWidget *m_libraryDock = nullptr;
void setupMenuBar();
void tryRestoreSession();

View File

@@ -2,45 +2,63 @@
#include <QJsonValue>
#include <QColor>
#include <QIcon>
#include <algorithm>
TrackListModel::TrackListModel(QObject *parent)
: QAbstractTableModel(parent)
{}
void TrackListModel::setTracks(const QJsonArray &tracks)
void TrackListModel::setTracks(const QJsonArray &tracks,
bool usePosition,
bool useSequential)
{
beginResetModel();
m_tracks.clear();
m_tracks.reserve(tracks.size());
int seq = 1;
for (const QJsonValue &v : tracks) {
const QJsonObject t = v.toObject();
TrackItem item;
item.id = static_cast<qint64>(t["id"].toDouble());
item.number = t["track_number"].toInt();
item.title = t["title"].toString();
item.duration = static_cast<qint64>(t["duration"].toDouble());
item.hiRes = t["hires_streamable"].toBool();
item.streamable = t["streamable"].toBool();
item.streamable = t["streamable"].toBool(true);
item.raw = t;
// Performer / artist
const QJsonObject performer = t["performer"].toObject();
item.artist = performer["name"].toString();
if (item.artist.isEmpty()) {
const QJsonObject album = t["album"].toObject();
const QJsonObject artist = album["artist"].toObject();
item.artist = artist["name"].toString();
if (useSequential) {
item.number = seq++;
} else if (usePosition) {
const int pos = t["position"].toInt();
item.number = pos > 0 ? pos : seq;
++seq;
} else {
item.number = t["track_number"].toInt();
}
// Album
const QJsonObject performer = t["performer"].toObject();
item.artist = performer["name"].toString();
if (item.artist.isEmpty())
item.artist = t["album"].toObject()["artist"].toObject()["name"].toString();
const QJsonObject album = t["album"].toObject();
item.album = album["title"].toString();
item.album = album["title"].toString();
item.albumId = album["id"].toString();
m_tracks.append(item);
}
// Re-apply sort silently inside the reset (no layout signals needed here)
if (m_sortColumn >= 0)
sortData(m_sortColumn, m_sortOrder);
endResetModel();
// Tell external listeners the sorted order is ready (e.g. PlayQueue sync)
if (m_sortColumn >= 0)
emit sortApplied();
}
void TrackListModel::clear()
@@ -50,16 +68,22 @@ void TrackListModel::clear()
endResetModel();
}
void TrackListModel::setPlayingId(qint64 id)
{
m_playingId = id;
if (!m_tracks.isEmpty())
emit dataChanged(index(0, 0), index(rowCount() - 1, columnCount() - 1),
{Qt::FontRole, Qt::DecorationRole});
}
int TrackListModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) return 0;
return m_tracks.size();
return parent.isValid() ? 0 : m_tracks.size();
}
int TrackListModel::columnCount(const QModelIndex &parent) const
{
if (parent.isValid()) return 0;
return ColCount;
return parent.isValid() ? 0 : ColCount;
}
QVariant TrackListModel::data(const QModelIndex &index, int role) const
@@ -68,6 +92,7 @@ QVariant TrackListModel::data(const QModelIndex &index, int role) const
return {};
const TrackItem &t = m_tracks.at(index.row());
const bool isPlaying = (t.id == m_playingId && m_playingId != 0);
if (role == Qt::DisplayRole) {
switch (index.column()) {
@@ -79,8 +104,19 @@ QVariant TrackListModel::data(const QModelIndex &index, int role) const
}
}
if (role == Qt::ForegroundRole && !t.streamable) {
return QColor(Qt::gray);
if (role == Qt::FontRole && isPlaying) {
QFont f;
f.setBold(true);
return f;
}
if (role == Qt::ForegroundRole) {
if (!t.streamable) return QColor(Qt::gray);
if (isPlaying) return QColor(0x1d, 0xb9, 0x54); // Qobuz green
}
if (role == Qt::DecorationRole && index.column() == ColNumber && isPlaying) {
return QIcon(QStringLiteral(":/res/icons/media-track-show-active.svg"));
}
if (role == TrackIdRole) return t.id;
@@ -92,19 +128,56 @@ QVariant TrackListModel::data(const QModelIndex &index, int role) const
QVariant TrackListModel::headerData(int section, Qt::Orientation orientation, int role) const
{
if (orientation != Qt::Horizontal || role != Qt::DisplayRole)
return {};
if (orientation != Qt::Horizontal) return {};
switch (section) {
case ColNumber: return tr("#");
case ColTitle: return tr("Title");
case ColArtist: return tr("Artist");
case ColAlbum: return tr("Album");
case ColDuration: return tr("Duration");
if (role == Qt::DisplayRole) {
switch (section) {
case ColNumber: return tr("#");
case ColTitle: return tr("Title");
case ColArtist: return tr("Artist");
case ColAlbum: return tr("Album");
case ColDuration: return tr("Duration");
}
}
if (role == Qt::DecorationRole && section == m_sortColumn) {
return QIcon(QStringLiteral(":/res/icons/view-sort-ascending.svg"));
}
return {};
}
void TrackListModel::sortData(int column, Qt::SortOrder order)
{
auto cmp = [&](const TrackItem &a, const TrackItem &b) -> bool {
bool less = false;
switch (column) {
case ColNumber: less = a.number < b.number; break;
case ColTitle: less = a.title < b.title; break;
case ColArtist: less = a.artist < b.artist; break;
case ColAlbum: less = a.album < b.album; break;
case ColDuration: less = a.duration < b.duration; break;
default: less = false;
}
return order == Qt::AscendingOrder ? less : !less;
};
std::stable_sort(m_tracks.begin(), m_tracks.end(), cmp);
}
void TrackListModel::sort(int column, Qt::SortOrder order)
{
m_sortColumn = column;
m_sortOrder = order;
if (m_tracks.isEmpty()) return;
emit layoutAboutToBeChanged();
sortData(column, order);
emit layoutChanged();
emit sortApplied();
}
QString TrackListModel::formatDuration(qint64 secs)
{
const int m = static_cast<int>(secs / 60);

View File

@@ -4,8 +4,8 @@
#include <QJsonArray>
#include <QJsonObject>
#include <QVector>
#include <QFont>
/// Flat data stored for each row in the track list.
struct TrackItem {
qint64 id = 0;
int number = 0;
@@ -16,7 +16,7 @@ struct TrackItem {
qint64 duration = 0; // seconds
bool hiRes = false;
bool streamable = false;
QJsonObject raw; // full JSON for context menus / playback
QJsonObject raw;
};
class TrackListModel : public QAbstractTableModel
@@ -41,17 +41,45 @@ public:
explicit TrackListModel(QObject *parent = nullptr);
void setTracks(const QJsonArray &tracks);
// usePosition: use tracks[i]["position"] for the # column (playlists)
// useSequential: use 1..n sequential numbering (favourites)
void setTracks(const QJsonArray &tracks,
bool usePosition = false,
bool useSequential = false);
void clear();
void setPlayingId(qint64 id);
qint64 playingId() const { return m_playingId; }
const TrackItem &trackAt(int row) const { return m_tracks.at(row); }
// Returns the current (possibly sorted) raw JSON objects in display order.
QJsonArray currentTracksJson() const
{
QJsonArray out;
for (const auto &t : m_tracks)
out.append(t.raw);
return out;
}
int rowCount(const QModelIndex &parent = {}) const override;
int columnCount(const QModelIndex &parent = {}) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
void sort(int column, Qt::SortOrder order = Qt::AscendingOrder) override;
static QString formatDuration(qint64 secs);
signals:
// Emitted after a sort is applied (including the initial sort after setTracks).
// Lets external observers (e.g. PlayQueue) re-sync their order.
void sortApplied();
private:
QVector<TrackItem> m_tracks;
qint64 m_playingId = 0;
int m_sortColumn = -1;
Qt::SortOrder m_sortOrder = Qt::AscendingOrder;
// Sort m_tracks in-place without emitting any signals.
void sortData(int column, Qt::SortOrder order);
};

225
src/playqueue.hpp Normal file
View File

@@ -0,0 +1,225 @@
#pragma once
#include <QObject>
#include <QVector>
#include <QJsonObject>
#include <QJsonArray>
#include <algorithm>
#include <random>
/// Local playback queue. Holds the ordered list of tracks for the current
/// context (album / playlist / search result / favourites) plus a separate
/// "play-next" prepend queue that mirrors the spotify-qt pattern.
class PlayQueue : public QObject
{
Q_OBJECT
public:
explicit PlayQueue(QObject *parent = nullptr) : QObject(parent) {}
// ---- Loading a new context ----
/// Replace the queue with all tracks from an album/playlist JSON context.
/// @param startIndex Index of the track to start playing (-1 = first).
void setContext(const QJsonArray &tracks, int startIndex = 0)
{
m_queue.clear();
m_playNext.clear();
for (const auto &v : tracks)
m_queue.append(v.toObject());
if (m_shuffle) {
shuffleQueue(startIndex);
// shuffleQueue moves the start track to index 0 and sets m_index = 0
} else {
m_index = qBound(0, startIndex, m_queue.size() - 1);
}
emit queueChanged();
}
// ---- Re-order after a sort (keeps m_playNext, updates m_index) ----
void reorderContext(const QJsonArray &tracks, qint64 currentId)
{
m_queue.clear();
for (const auto &v : tracks)
m_queue.append(v.toObject());
m_index = 0;
for (int i = 0; i < m_queue.size(); ++i) {
if (static_cast<qint64>(m_queue[i]["id"].toDouble()) == currentId) {
m_index = i;
break;
}
}
emit queueChanged();
}
// ---- Clear / remove upcoming ----
/// Remove all "up next" entries (playNext + remaining main queue after current).
void clearUpcoming()
{
m_playNext.clear();
if (m_index < m_queue.size())
m_queue.resize(m_index + 1); // keep up to and including current
emit queueChanged();
}
/// Remove one upcoming track by its index in upcomingTracks().
void removeUpcoming(int upcomingIndex)
{
if (upcomingIndex < m_playNext.size()) {
m_playNext.removeAt(upcomingIndex);
} else {
const int queueIdx = m_index + 1 + (upcomingIndex - m_playNext.size());
if (queueIdx < m_queue.size())
m_queue.removeAt(queueIdx);
}
emit queueChanged();
}
// ---- Shuffle ----
bool shuffleEnabled() const { return m_shuffle; }
void setShuffle(bool enabled)
{
if (m_shuffle == enabled) return;
m_shuffle = enabled;
if (enabled && !m_queue.isEmpty())
shuffleQueue(m_index);
emit queueChanged();
}
// ---- Play-next prepend queue (like "Add to queue" ----
void addToQueue(const QJsonObject &track)
{
m_playNext.append(track);
emit queueChanged();
}
void playNext(const QJsonObject &track)
{
m_playNext.prepend(track);
emit queueChanged();
}
// ---- Navigation ----
bool hasCurrent() const
{
return (!m_playNext.isEmpty()) || (!m_queue.isEmpty());
}
QJsonObject current() const
{
if (!m_playNext.isEmpty()) return m_playNext.first();
if (m_index < m_queue.size()) return m_queue.at(m_index);
return {};
}
qint64 currentId() const
{
return static_cast<qint64>(current()["id"].toDouble());
}
/// Advance and return the new current track. Returns {} at end of queue.
QJsonObject advance()
{
if (!m_playNext.isEmpty()) {
m_playNext.removeFirst();
} else {
++m_index;
}
emit queueChanged();
return current();
}
/// Step backwards in the main queue (play-next is not affected).
QJsonObject stepBack()
{
if (m_index > 0) --m_index;
emit queueChanged();
return current();
}
bool canGoNext() const
{
return !m_playNext.isEmpty() || (m_index + 1 < m_queue.size());
}
bool canGoPrev() const { return m_index > 0; }
// ---- Index lookup ----
/// Set the current position by track id (after user double-clicks a row).
void setCurrentById(qint64 id)
{
m_playNext.clear();
for (int i = 0; i < m_queue.size(); ++i) {
if (static_cast<qint64>(m_queue[i]["id"].toDouble()) == id) {
m_index = i;
emit queueChanged();
return;
}
}
}
// ---- Accessors for queue panel ----
QVector<QJsonObject> upcomingTracks(int maxCount = 200) const
{
QVector<QJsonObject> result;
result.append(m_playNext);
for (int i = m_index + 1; i < m_queue.size() && result.size() < maxCount; ++i)
result.append(m_queue.at(i));
return result;
}
int playNextCount() const { return m_playNext.size(); }
int totalSize() const { return m_playNext.size() + m_queue.size(); }
int currentIndex() const { return m_index; }
/// Move an upcoming item (by its index in upcomingTracks()) to the front of playNext.
void moveUpcomingToTop(int upcomingIndex)
{
if (upcomingIndex < 0) return;
QJsonObject track;
if (upcomingIndex < m_playNext.size()) {
if (upcomingIndex == 0) return; // already at top
track = m_playNext.takeAt(upcomingIndex);
} else {
const int queueIdx = m_index + 1 + (upcomingIndex - m_playNext.size());
if (queueIdx >= m_queue.size()) return;
track = m_queue.takeAt(queueIdx);
}
m_playNext.prepend(track);
emit queueChanged();
}
signals:
void queueChanged();
private:
QVector<QJsonObject> m_queue; // main context (album / playlist)
QVector<QJsonObject> m_playNext; // prepended "play next" tracks
int m_index = 0;
bool m_shuffle = false;
void shuffleQueue(int keepAtFront)
{
if (m_queue.isEmpty()) return;
// Keep the current track at index 0 of the remaining queue
if (keepAtFront >= 0 && keepAtFront < m_queue.size()) {
QJsonObject current = m_queue.takeAt(keepAtFront);
std::mt19937 rng(std::random_device{}());
std::shuffle(m_queue.begin(), m_queue.end(), rng);
m_queue.prepend(current);
} else {
std::mt19937 rng(std::random_device{}());
std::shuffle(m_queue.begin(), m_queue.end(), rng);
}
m_index = 0;
}
};

View File

@@ -1,32 +1,46 @@
#pragma once
#include <QFile>
#include <QIcon>
#include <QString>
namespace Icon
{
/// Load an icon by name. Checks the embedded :/res/icons/ first, then
/// falls back to the system theme. The dark SVGs from spotify-qt are
/// bundled so this always succeeds for known names.
inline QIcon get(const QString &name)
{
// Try theme icon first, fall back to resource
if (QIcon::hasThemeIcon(name))
return QIcon::fromTheme(name);
return QIcon(QStringLiteral(":/icons/%1.svg").arg(name));
const QString path = QStringLiteral(":/res/icons/%1.svg").arg(name);
if (QFile::exists(path))
return QIcon(path);
return QIcon::fromTheme(name);
}
// Convenient aliases for common icons used throughout the app
// Playback
inline QIcon play() { return get("media-playback-start"); }
inline QIcon pause() { return get("media-playback-pause"); }
inline QIcon stop() { return get("media-playback-stop"); }
inline QIcon next() { return get("media-skip-forward"); }
inline QIcon previous() { return get("media-skip-backward"); }
inline QIcon shuffle() { return get("media-playlist-shuffle"); }
inline QIcon repeat() { return get("media-playlist-repeat"); }
// Volume
inline QIcon volumeHigh() { return get("audio-volume-high"); }
inline QIcon volumeMid() { return get("audio-volume-medium"); }
inline QIcon volumeMute() { return get("audio-volume-muted"); }
inline QIcon volumeLow() { return get("audio-volume-low"); }
inline QIcon volumeMute() { return get("audio-volume-low"); }
// UI
inline QIcon search() { return get("edit-find"); }
inline QIcon heart() { return get("emblem-favorite"); }
inline QIcon album() { return get("media-optical"); }
inline QIcon artist() { return get("system-users"); }
inline QIcon playlist() { return get("view-list-symbolic"); }
inline QIcon heart() { return get("starred-symbolic"); }
inline QIcon heartOff() { return get("non-starred-symbolic"); }
inline QIcon album() { return get("view-media-album-cover"); }
inline QIcon artist() { return get("view-media-artist"); }
inline QIcon playlist() { return get("view-media-playlist"); }
inline QIcon track() { return get("view-media-track"); }
inline QIcon queue() { return get("media-playlist-append"); }
inline QIcon refresh() { return get("view-refresh"); }
inline QIcon settings() { return get("configure"); }
inline QIcon sortAsc() { return get("view-sort-ascending"); }
}

88
src/view/context/view.cpp Normal file
View File

@@ -0,0 +1,88 @@
#include "view.hpp"
#include <QWidget>
#include <QVBoxLayout>
#include <QNetworkRequest>
namespace Context
{
static constexpr int ArtSize = 96;
View::View(QobuzBackend *backend, QWidget *parent)
: QDockWidget(tr("Now Playing"), parent)
, m_backend(backend)
{
setObjectName(QStringLiteral("contextDock"));
setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetClosable);
m_nam = new QNetworkAccessManager(this);
connect(m_nam, &QNetworkAccessManager::finished, this, &View::onArtReady);
auto *container = new QWidget(this);
auto *layout = new QVBoxLayout(container);
layout->setContentsMargins(8, 8, 8, 8);
layout->setSpacing(6);
m_albumArt = new QLabel(container);
m_albumArt->setFixedSize(ArtSize, ArtSize);
m_albumArt->setScaledContents(true);
m_albumArt->setAlignment(Qt::AlignCenter);
m_albumArt->setStyleSheet(QStringLiteral(
"background: #1a1a1a; border-radius: 4px;"));
layout->addWidget(m_albumArt, 0, Qt::AlignCenter);
m_title = new QLabel(tr("Not playing"), container);
m_title->setAlignment(Qt::AlignCenter);
m_title->setWordWrap(true);
QFont titleFont = m_title->font();
titleFont.setPointSizeF(titleFont.pointSizeF() * 1.05);
titleFont.setBold(true);
m_title->setFont(titleFont);
layout->addWidget(m_title);
m_artist = new QLabel(QString(), container);
m_artist->setAlignment(Qt::AlignCenter);
m_artist->setWordWrap(true);
layout->addWidget(m_artist);
layout->addStretch();
setWidget(container);
setMinimumWidth(160);
connect(m_backend, &QobuzBackend::trackChanged, this, &View::onTrackChanged);
}
void View::onTrackChanged(const QJsonObject &track)
{
const QString title = track["title"].toString();
const QString artist = track["performer"].toObject()["name"].toString().isEmpty()
? track["album"].toObject()["artist"].toObject()["name"].toString()
: track["performer"].toObject()["name"].toString();
m_title->setText(title.isEmpty() ? tr("Not playing") : title);
m_artist->setText(artist);
// Prefer "large" image, fall back to "small"
const QJsonObject img = track["album"].toObject()["image"].toObject();
QString artUrl = img["large"].toString();
if (artUrl.isEmpty())
artUrl = img["small"].toString();
if (!artUrl.isEmpty() && artUrl != m_currentArtUrl) {
m_currentArtUrl = artUrl;
m_nam->get(QNetworkRequest(QUrl(artUrl)));
}
}
void View::onArtReady(QNetworkReply *reply)
{
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError)
return;
QPixmap pix;
if (pix.loadFromData(reply->readAll()))
m_albumArt->setPixmap(pix);
}
} // namespace Context

32
src/view/context/view.hpp Normal file
View File

@@ -0,0 +1,32 @@
#pragma once
#include "../../backend/qobuzbackend.hpp"
#include <QDockWidget>
#include <QLabel>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QJsonObject>
namespace Context
{
class View : public QDockWidget
{
Q_OBJECT
public:
explicit View(QobuzBackend *backend, QWidget *parent = nullptr);
private slots:
void onTrackChanged(const QJsonObject &track);
void onArtReady(QNetworkReply *reply);
private:
QobuzBackend *m_backend = nullptr;
QLabel *m_albumArt = nullptr;
QLabel *m_title = nullptr;
QLabel *m_artist = nullptr;
QNetworkAccessManager *m_nam = nullptr;
QString m_currentArtUrl;
};
} // namespace Context

View File

@@ -1,9 +1,8 @@
#include "maincontent.hpp"
#include <QVBoxLayout>
#include <QJsonArray>
MainContent::MainContent(QobuzBackend *backend, QWidget *parent)
MainContent::MainContent(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
: QWidget(parent)
, m_backend(backend)
{
@@ -15,22 +14,20 @@ MainContent::MainContent(QobuzBackend *backend, QWidget *parent)
m_welcome = new QLabel(
tr("<h2>Welcome to Qobuz</h2>"
"<p>Select something from the library on the left to get started.</p>"),
"<p>Select something from the library on the left to get started,<br>"
"or use the search panel (🔍) to find music.</p>"),
this);
m_welcome->setAlignment(Qt::AlignCenter);
m_tracks = new List::Tracks(m_backend, this);
m_tracks = new List::Tracks(m_backend, queue, this);
m_stack->addWidget(m_welcome); // index 0
m_stack->addWidget(m_tracks); // index 1
m_stack->addWidget(m_welcome); // 0
m_stack->addWidget(m_tracks); // 1
m_stack->setCurrentIndex(0);
}
void MainContent::showWelcome()
{
m_stack->setCurrentIndex(0);
}
void MainContent::showWelcome() { m_stack->setCurrentIndex(0); }
void MainContent::showAlbum(const QJsonObject &album)
{
@@ -46,8 +43,7 @@ void MainContent::showPlaylist(const QJsonObject &playlist)
void MainContent::showFavTracks(const QJsonObject &result)
{
const QJsonArray items = result["items"].toArray();
m_tracks->loadTracks(items);
m_tracks->loadTracks(result["items"].toArray());
m_stack->setCurrentIndex(1);
}

View File

@@ -2,21 +2,20 @@
#include "../list/tracks.hpp"
#include "../backend/qobuzbackend.hpp"
#include "../playqueue.hpp"
#include <QWidget>
#include <QLabel>
#include <QStackedWidget>
#include <QJsonArray>
#include <QJsonObject>
#include <QJsonArray>
/// Central content widget — mirrors MainContent from spotify-qt.
/// Displays either a track list, an album cover + tracks, or a search result.
class MainContent : public QWidget
{
Q_OBJECT
public:
explicit MainContent(QobuzBackend *backend, QWidget *parent = nullptr);
explicit MainContent(QobuzBackend *backend, PlayQueue *queue, QWidget *parent = nullptr);
List::Tracks *tracksList() const { return m_tracks; }
@@ -27,8 +26,8 @@ public:
void showSearchTracks(const QJsonArray &tracks);
private:
QobuzBackend *m_backend = nullptr;
QStackedWidget *m_stack = nullptr;
QLabel *m_welcome = nullptr;
List::Tracks *m_tracks = nullptr;
QobuzBackend *m_backend = nullptr;
QStackedWidget *m_stack = nullptr;
QLabel *m_welcome = nullptr;
List::Tracks *m_tracks = nullptr;
};

View File

@@ -4,56 +4,69 @@
#include <QWidget>
#include <QHBoxLayout>
#include <QNetworkRequest>
MainToolBar::MainToolBar(QobuzBackend *backend, QWidget *parent)
MainToolBar::MainToolBar(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
: QToolBar(parent)
, m_backend(backend)
, m_queue(queue)
{
setMovable(false);
setFloatable(false);
setContextMenuPolicy(Qt::PreventContextMenu);
setIconSize(QSize(22, 22));
// Previous
m_nam = new QNetworkAccessManager(this);
connect(m_nam, &QNetworkAccessManager::finished, this, &MainToolBar::onAlbumArtReady);
// --- Album art thumbnail ---
m_artLabel = new QLabel(this);
m_artLabel->setFixedSize(36, 36);
m_artLabel->setScaledContents(true);
m_artLabel->setStyleSheet("border: 1px solid #444; background: #1a1a1a;");
m_artLabel->setPixmap(QIcon(":/res/icons/view-media-album-cover.svg")
.pixmap(32, 32));
addWidget(m_artLabel);
addSeparator();
// --- Playback controls ---
m_previous = addAction(Icon::previous(), tr("Previous"));
connect(m_previous, &QAction::triggered, this, &MainToolBar::onPrevious);
// Play/Pause
m_playPause = addAction(Icon::play(), tr("Play"));
m_playPause->setCheckable(false);
connect(m_playPause, &QAction::triggered, this, [this](bool) { onPlayPause(false); });
connect(m_playPause, &QAction::triggered, this, &MainToolBar::onPlayPause);
// Next
m_next = addAction(Icon::next(), tr("Next"));
connect(m_next, &QAction::triggered, this, &MainToolBar::onNext);
addSeparator();
// Track label
// --- Track info label ---
m_trackLabel = new QLabel(tr("Not playing"), this);
m_trackLabel->setMinimumWidth(200);
m_trackLabel->setMaximumWidth(320);
m_trackLabel->setMinimumWidth(180);
m_trackLabel->setMaximumWidth(340);
m_trackLabel->setAlignment(Qt::AlignVCenter | Qt::AlignLeft);
addWidget(m_trackLabel);
addSeparator();
// Progress slider
// --- Progress slider ---
m_progress = new ClickableSlider(Qt::Horizontal, this);
m_progress->setRange(0, 1000);
m_progress->setValue(0);
m_progress->setMinimumWidth(180);
m_progress->setMaximumWidth(400);
m_progress->setMinimumWidth(160);
m_progress->setMaximumWidth(380);
addWidget(m_progress);
connect(m_progress, &QSlider::sliderPressed, this, [this] { m_seeking = true; });
connect(m_progress, &QSlider::sliderReleased, this, &MainToolBar::onProgressReleased);
// Time label
m_timeLabel = new QLabel("0:00 / 0:00", this);
addWidget(m_timeLabel);
addSeparator();
// Volume button
// --- Volume ---
m_volume = new VolumeButton(this);
m_volume->setValue(AppSettings::instance().volume());
addWidget(m_volume);
@@ -61,18 +74,36 @@ MainToolBar::MainToolBar(QobuzBackend *backend, QWidget *parent)
addSeparator();
// Search toggle
// --- Shuffle ---
m_shuffle = addAction(Icon::get(QStringLiteral("media-playlist-shuffle")), tr("Shuffle"));
m_shuffle->setCheckable(true);
connect(m_shuffle, &QAction::toggled, this, &MainToolBar::onShuffleToggled);
addSeparator();
// --- Queue toggle ---
m_queueBtn = addAction(Icon::queue(), tr("Queue"));
m_queueBtn->setCheckable(true);
connect(m_queueBtn, &QAction::toggled, this, &MainToolBar::queueToggled);
// --- Search toggle ---
m_search = addAction(Icon::search(), tr("Search"));
m_search->setCheckable(true);
connect(m_search, &QAction::toggled, this, &MainToolBar::searchToggled);
// Connect to backend signals
connect(m_backend, &QobuzBackend::stateChanged, this, &MainToolBar::onBackendStateChanged);
connect(m_backend, &QobuzBackend::trackChanged, this, &MainToolBar::onTrackChanged);
// --- Backend signals ---
connect(m_backend, &QobuzBackend::stateChanged, this, &MainToolBar::onBackendStateChanged);
connect(m_backend, &QobuzBackend::trackChanged, this, &MainToolBar::onTrackChanged);
connect(m_backend, &QobuzBackend::positionChanged, this, &MainToolBar::onPositionChanged);
connect(m_backend, &QobuzBackend::trackFinished, this, &MainToolBar::onTrackFinished);
connect(m_backend, &QobuzBackend::trackFinished, this, &MainToolBar::onTrackFinished);
// --- Queue signals ---
connect(m_queue, &PlayQueue::queueChanged, this, &MainToolBar::onQueueChanged);
onQueueChanged(); // initialise button states
}
// ---- public ----
void MainToolBar::setPlaying(bool playing)
{
m_playing = playing;
@@ -82,9 +113,10 @@ void MainToolBar::setPlaying(bool playing)
void MainToolBar::setCurrentTrack(const QJsonObject &track)
{
const QString title = track["title"].toString();
const QJsonObject performer = track["performer"].toObject();
const QString artist = performer["name"].toString();
const QString title = track["title"].toString();
const QString artist = track["performer"].toObject()["name"].toString().isEmpty()
? track["album"].toObject()["artist"].toObject()["name"].toString()
: track["performer"].toObject()["name"].toString();
if (title.isEmpty()) {
m_trackLabel->setText(tr("Not playing"));
@@ -93,55 +125,58 @@ void MainToolBar::setCurrentTrack(const QJsonObject &track)
} else {
m_trackLabel->setText(QStringLiteral("%1 — %2").arg(artist, title));
}
// Album art
const QString artUrl = track["album"].toObject()["image"].toObject()["small"].toString();
if (!artUrl.isEmpty() && artUrl != m_currentArtUrl) {
m_currentArtUrl = artUrl;
fetchAlbumArt(artUrl);
}
}
void MainToolBar::updateProgress(quint64 position, quint64 duration)
{
if (m_seeking) return;
const int sliderPos = duration > 0
? static_cast<int>(position * 1000 / duration)
: 0;
? static_cast<int>(position * 1000 / duration) : 0;
m_progress->blockSignals(true);
m_progress->setValue(sliderPos);
m_progress->blockSignals(false);
const QString pos = TrackListModel::formatDuration(static_cast<qint64>(position));
const QString dur = TrackListModel::formatDuration(static_cast<qint64>(duration));
m_timeLabel->setText(QStringLiteral("%1 / %2").arg(pos, dur));
m_timeLabel->setText(
QStringLiteral("%1 / %2")
.arg(TrackListModel::formatDuration(static_cast<qint64>(position)),
TrackListModel::formatDuration(static_cast<qint64>(duration))));
}
void MainToolBar::setVolume(int volume)
{
m_volume->setValue(volume);
}
// ---- private slots ----
// --- private slots ---
void MainToolBar::onPlayPause(bool)
void MainToolBar::onPlayPause()
{
if (m_playing) {
m_backend->pause();
} else {
m_backend->resume();
}
if (m_playing) m_backend->pause();
else m_backend->resume();
}
void MainToolBar::onPrevious()
{
// TODO: wire up to a queue / playlist
if (!m_queue->canGoPrev()) return;
const QJsonObject track = m_queue->stepBack();
const qint64 id = static_cast<qint64>(track["id"].toDouble());
if (id > 0)
m_backend->playTrack(id, AppSettings::instance().preferredFormat());
}
void MainToolBar::onNext()
{
// TODO: wire up to a queue / playlist
if (!m_queue->canGoNext()) return;
const QJsonObject track = m_queue->advance();
const qint64 id = static_cast<qint64>(track["id"].toDouble());
if (id > 0)
m_backend->playTrack(id, AppSettings::instance().preferredFormat());
}
void MainToolBar::onProgressReleased()
{
m_seeking = false;
// Seek is not yet implemented in the Rust player, but we can at least
// update the displayed position optimistically.
}
void MainToolBar::onVolumeChanged(int volume)
@@ -167,7 +202,37 @@ void MainToolBar::onPositionChanged(quint64 position, quint64 duration)
void MainToolBar::onTrackFinished()
{
setPlaying(false);
m_progress->setValue(0);
m_timeLabel->setText("0:00 / 0:00");
// Auto-advance queue
if (m_queue->canGoNext()) {
onNext();
} else {
setPlaying(false);
m_progress->setValue(0);
m_timeLabel->setText("0:00 / 0:00");
}
}
void MainToolBar::onQueueChanged()
{
m_previous->setEnabled(m_queue->canGoPrev());
m_next->setEnabled(m_queue->canGoNext());
}
void MainToolBar::onShuffleToggled(bool checked)
{
m_queue->setShuffle(checked);
}
void MainToolBar::fetchAlbumArt(const QString &url)
{
m_nam->get(QNetworkRequest(QUrl(url)));
}
void MainToolBar::onAlbumArtReady(QNetworkReply *reply)
{
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) return;
QPixmap pix;
if (pix.loadFromData(reply->readAll()))
m_artLabel->setPixmap(pix);
}

View File

@@ -1,6 +1,7 @@
#pragma once
#include "../backend/qobuzbackend.hpp"
#include "../playqueue.hpp"
#include "../widget/volumebutton.hpp"
#include "../widget/clickableslider.hpp"
#include "../util/icon.hpp"
@@ -9,26 +10,27 @@
#include <QToolButton>
#include <QLabel>
#include <QAction>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QJsonObject>
/// Main playback toolbar — mirrors MainToolBar from spotify-qt.
class MainToolBar : public QToolBar
{
Q_OBJECT
public:
explicit MainToolBar(QobuzBackend *backend, QWidget *parent = nullptr);
explicit MainToolBar(QobuzBackend *backend, PlayQueue *queue, QWidget *parent = nullptr);
void setPlaying(bool playing);
void setCurrentTrack(const QJsonObject &track);
void updateProgress(quint64 position, quint64 duration);
void setVolume(int volume);
signals:
void searchToggled(bool visible);
void queueToggled(bool visible);
private slots:
void onPlayPause(bool checked);
void onPlayPause();
void onPrevious();
void onNext();
void onProgressReleased();
@@ -38,26 +40,31 @@ private slots:
void onTrackChanged(const QJsonObject &track);
void onPositionChanged(quint64 position, quint64 duration);
void onTrackFinished();
void onQueueChanged();
void onShuffleToggled(bool checked);
void fetchAlbumArt(const QString &url);
void onAlbumArtReady(QNetworkReply *reply);
private:
QobuzBackend *m_backend = nullptr;
PlayQueue *m_queue = nullptr;
QLabel *m_artLabel = nullptr;
QLabel *m_trackLabel = nullptr;
ClickableSlider *m_progress = nullptr;
QLabel *m_timeLabel = nullptr;
VolumeButton *m_volume = nullptr;
QAction *m_previous = nullptr;
QAction *m_playPause = nullptr;
QAction *m_next = nullptr;
QAction *m_shuffle = nullptr;
QAction *m_queueBtn = nullptr;
QAction *m_search = nullptr;
ClickableSlider *m_progress = nullptr;
QLabel *m_timeLabel = nullptr;
QLabel *m_trackLabel = nullptr;
VolumeButton *m_volume = nullptr;
bool m_playing = false;
bool m_seeking = false;
// Playback queue (track IDs) for next/prev
QVector<qint64> m_queue;
int m_queueIdx = -1;
void addSpacerWidget();
QNetworkAccessManager *m_nam = nullptr;
QString m_currentArtUrl;
bool m_playing = false;
bool m_seeking = false;
};

111
src/view/queuepanel.cpp Normal file
View File

@@ -0,0 +1,111 @@
#include "queuepanel.hpp"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QMenu>
#include <QAction>
static constexpr int UpcomingIndexRole = Qt::UserRole + 1;
static constexpr int IsPlayNextRole = Qt::UserRole + 2;
QueuePanel::QueuePanel(PlayQueue *queue, QWidget *parent)
: QDockWidget(tr("Queue"), parent)
, m_queue(queue)
{
setObjectName(QStringLiteral("queuePanel"));
setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetClosable);
auto *container = new QWidget(this);
auto *layout = new QVBoxLayout(container);
layout->setContentsMargins(4, 4, 4, 4);
layout->setSpacing(4);
// Header row: count label + Clear button
auto *headerRow = new QHBoxLayout;
m_countLabel = new QLabel(tr("Up next: 0 tracks"), container);
m_clearBtn = new QPushButton(tr("Clear"), container);
m_clearBtn->setMaximumWidth(64);
headerRow->addWidget(m_countLabel, 1);
headerRow->addWidget(m_clearBtn);
layout->addLayout(headerRow);
m_list = new QListWidget(container);
m_list->setAlternatingRowColors(true);
m_list->setContextMenuPolicy(Qt::CustomContextMenu);
layout->addWidget(m_list, 1);
setWidget(container);
setMinimumWidth(200);
connect(m_queue, &PlayQueue::queueChanged, this, &QueuePanel::refresh);
connect(m_clearBtn, &QPushButton::clicked, this, [this] {
m_queue->clearUpcoming();
});
connect(m_list, &QListWidget::itemDoubleClicked,
this, &QueuePanel::onItemDoubleClicked);
connect(m_list, &QListWidget::customContextMenuRequested,
this, &QueuePanel::onContextMenu);
refresh();
}
void QueuePanel::refresh()
{
m_list->clear();
const QVector<QJsonObject> upcoming = m_queue->upcomingTracks();
const int playNextCount = m_queue->playNextCount();
m_countLabel->setText(tr("Up next: %1 track(s)").arg(upcoming.size()));
m_clearBtn->setEnabled(!upcoming.isEmpty());
for (int i = 0; i < upcoming.size(); ++i) {
const QJsonObject &t = upcoming.at(i);
const QString title = t["title"].toString();
const QString artist = t["performer"].toObject()["name"].toString().isEmpty()
? t["album"].toObject()["artist"].toObject()["name"].toString()
: t["performer"].toObject()["name"].toString();
const QString text = artist.isEmpty()
? title
: QStringLiteral("%1 — %2").arg(artist, title);
auto *item = new QListWidgetItem(text, m_list);
item->setData(UpcomingIndexRole, i);
item->setData(IsPlayNextRole, i < playNextCount);
// "Play Next" tracks shown slightly differently
if (i < playNextCount) {
QFont f = item->font();
f.setItalic(true);
item->setFont(f);
}
}
}
void QueuePanel::onItemDoubleClicked(QListWidgetItem *item)
{
// Double-clicking an upcoming item is not needed for now (could skip to it later)
Q_UNUSED(item)
}
void QueuePanel::onContextMenu(const QPoint &pos)
{
auto *item = m_list->itemAt(pos);
if (!item) return;
const int idx = item->data(UpcomingIndexRole).toInt();
QMenu menu(this);
auto *removeAct = menu.addAction(tr("Remove from queue"));
auto *toTopAct = menu.addAction(tr("Move to top (play next)"));
connect(removeAct, &QAction::triggered, this, [this, idx] {
m_queue->removeUpcoming(idx);
});
connect(toTopAct, &QAction::triggered, this, [this, idx] {
m_queue->moveUpcomingToTop(idx);
});
menu.exec(m_list->viewport()->mapToGlobal(pos));
}

27
src/view/queuepanel.hpp Normal file
View File

@@ -0,0 +1,27 @@
#pragma once
#include "../playqueue.hpp"
#include <QDockWidget>
#include <QListWidget>
#include <QPushButton>
#include <QLabel>
class QueuePanel : public QDockWidget
{
Q_OBJECT
public:
explicit QueuePanel(PlayQueue *queue, QWidget *parent = nullptr);
private slots:
void refresh();
void onItemDoubleClicked(QListWidgetItem *item);
void onContextMenu(const QPoint &pos);
private:
PlayQueue *m_queue = nullptr;
QLabel *m_countLabel = nullptr;
QListWidget *m_list = nullptr;
QPushButton *m_clearBtn = nullptr;
};