@@ -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("qobuz-qt
"
"A lightweight Qt client for the Qobuz streaming service.
"
- "Audio decoding powered by Symphonia (Rust).
"));
+ "Audio engine: Symphonia (Rust) via CPAL/ALSA.
"
+ "Icons: spotify-qt (dark variant).
"));
});
}
@@ -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(); // 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(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)
diff --git a/src/mainwindow.hpp b/src/mainwindow.hpp
index 0e90c87..583deda 100644
--- a/src/mainwindow.hpp
+++ b/src/mainwindow.hpp
@@ -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();
diff --git a/src/model/tracklistmodel.cpp b/src/model/tracklistmodel.cpp
index 7076a24..078c987 100644
--- a/src/model/tracklistmodel.cpp
+++ b/src/model/tracklistmodel.cpp
@@ -2,45 +2,63 @@
#include
#include
+#include
+#include
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(t["id"].toDouble());
- item.number = t["track_number"].toInt();
item.title = t["title"].toString();
item.duration = static_cast(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(secs / 60);
diff --git a/src/model/tracklistmodel.hpp b/src/model/tracklistmodel.hpp
index ba3cf44..72f428c 100644
--- a/src/model/tracklistmodel.hpp
+++ b/src/model/tracklistmodel.hpp
@@ -4,8 +4,8 @@
#include
#include
#include
+#include
-/// 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 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);
};
diff --git a/src/playqueue.hpp b/src/playqueue.hpp
new file mode 100644
index 0000000..8727778
--- /dev/null
+++ b/src/playqueue.hpp
@@ -0,0 +1,225 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+/// 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(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(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(m_queue[i]["id"].toDouble()) == id) {
+ m_index = i;
+ emit queueChanged();
+ return;
+ }
+ }
+ }
+
+ // ---- Accessors for queue panel ----
+
+ QVector upcomingTracks(int maxCount = 200) const
+ {
+ QVector 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 m_queue; // main context (album / playlist)
+ QVector 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;
+ }
+};
diff --git a/src/util/icon.hpp b/src/util/icon.hpp
index 38ff42c..04b4b81 100644
--- a/src/util/icon.hpp
+++ b/src/util/icon.hpp
@@ -1,32 +1,46 @@
#pragma once
+#include
#include
#include
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"); }
}
diff --git a/src/view/context/view.cpp b/src/view/context/view.cpp
new file mode 100644
index 0000000..09204e9
--- /dev/null
+++ b/src/view/context/view.cpp
@@ -0,0 +1,88 @@
+#include "view.hpp"
+
+#include
+#include
+#include
+
+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
diff --git a/src/view/context/view.hpp b/src/view/context/view.hpp
new file mode 100644
index 0000000..3ae00ba
--- /dev/null
+++ b/src/view/context/view.hpp
@@ -0,0 +1,32 @@
+#pragma once
+
+#include "../../backend/qobuzbackend.hpp"
+
+#include
+#include
+#include
+#include
+#include
+
+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
diff --git a/src/view/maincontent.cpp b/src/view/maincontent.cpp
index 4b6ae88..7c6a54b 100644
--- a/src/view/maincontent.cpp
+++ b/src/view/maincontent.cpp
@@ -1,9 +1,8 @@
#include "maincontent.hpp"
#include
-#include
-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("Welcome to Qobuz
"
- "Select something from the library on the left to get started.
"),
+ "Select something from the library on the left to get started,
"
+ "or use the search panel (🔍) to find music.
"),
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);
}
diff --git a/src/view/maincontent.hpp b/src/view/maincontent.hpp
index d16044d..fc9490b 100644
--- a/src/view/maincontent.hpp
+++ b/src/view/maincontent.hpp
@@ -2,21 +2,20 @@
#include "../list/tracks.hpp"
#include "../backend/qobuzbackend.hpp"
+#include "../playqueue.hpp"
#include
#include
#include
-#include
#include
+#include
-/// 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;
};
diff --git a/src/view/maintoolbar.cpp b/src/view/maintoolbar.cpp
index bef877b..6a7c30b 100644
--- a/src/view/maintoolbar.cpp
+++ b/src/view/maintoolbar.cpp
@@ -4,56 +4,69 @@
#include
#include
+#include
-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(position * 1000 / duration)
- : 0;
+ ? static_cast(position * 1000 / duration) : 0;
m_progress->blockSignals(true);
m_progress->setValue(sliderPos);
m_progress->blockSignals(false);
-
- const QString pos = TrackListModel::formatDuration(static_cast(position));
- const QString dur = TrackListModel::formatDuration(static_cast(duration));
- m_timeLabel->setText(QStringLiteral("%1 / %2").arg(pos, dur));
+ m_timeLabel->setText(
+ QStringLiteral("%1 / %2")
+ .arg(TrackListModel::formatDuration(static_cast(position)),
+ TrackListModel::formatDuration(static_cast(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(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(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);
}
diff --git a/src/view/maintoolbar.hpp b/src/view/maintoolbar.hpp
index e038bf1..29efae6 100644
--- a/src/view/maintoolbar.hpp
+++ b/src/view/maintoolbar.hpp
@@ -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
#include
#include
+#include
+#include
#include
-/// 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 m_queue;
- int m_queueIdx = -1;
-
- void addSpacerWidget();
+ QNetworkAccessManager *m_nam = nullptr;
+ QString m_currentArtUrl;
+ bool m_playing = false;
+ bool m_seeking = false;
};
diff --git a/src/view/queuepanel.cpp b/src/view/queuepanel.cpp
new file mode 100644
index 0000000..5a4ccfd
--- /dev/null
+++ b/src/view/queuepanel.cpp
@@ -0,0 +1,111 @@
+#include "queuepanel.hpp"
+
+#include
+#include
+#include
+#include
+
+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 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));
+}
diff --git a/src/view/queuepanel.hpp b/src/view/queuepanel.hpp
new file mode 100644
index 0000000..1ab0864
--- /dev/null
+++ b/src/view/queuepanel.hpp
@@ -0,0 +1,27 @@
+#pragma once
+
+#include "../playqueue.hpp"
+
+#include
+#include
+#include
+#include
+
+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;
+};