refactor: UI polish — lock sidebar, remove nav buttons, uniform artist tables, deep shuffle

- Lock sidebar width (setFixedWidth) so it doesn't jump between views
- Remove back/forward navigation buttons and all NavPage history code
- Uniform column layout on artist page: hide Artist column from both
  Popular Tracks and release sections, set matching fixed column widths
  so columns align vertically across all sections
- Deep shuffle: new Rust FFI endpoint fetches tracks from all albums
  in parallel, combines them, and returns via EV_DEEP_SHUFFLE_OK
- Auto-paginate artist releases in Rust (loop until has_more=false)
  so all releases load at once sorted newest-first

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
joren
2026-03-25 23:42:00 +01:00
parent 333a620be2
commit a21d0c8a33
13 changed files with 217 additions and 161 deletions

View File

@@ -35,6 +35,8 @@ enum QobuzEvent {
EV_PLAYLIST_DELETED = 21, EV_PLAYLIST_DELETED = 21,
EV_PLAYLIST_TRACK_ADDED = 22, EV_PLAYLIST_TRACK_ADDED = 22,
EV_USER_OK = 23, EV_USER_OK = 23,
EV_ARTIST_RELEASES_OK = 24,
EV_DEEP_SHUFFLE_OK = 25,
}; };
// Callback signature // Callback signature
@@ -84,9 +86,12 @@ uint32_t qobuz_backend_viz_read(QobuzBackendOpaque *backend, float *buf, uint32_
uint32_t qobuz_backend_viz_sample_rate(const QobuzBackendOpaque *backend); uint32_t qobuz_backend_viz_sample_rate(const QobuzBackendOpaque *backend);
uint32_t qobuz_backend_viz_channels(const QobuzBackendOpaque *backend); uint32_t qobuz_backend_viz_channels(const QobuzBackendOpaque *backend);
// Artist releases (full paginated list per release type) // Artist releases (auto-paginates to fetch all)
void qobuz_backend_get_artist_releases(QobuzBackendOpaque *backend, int64_t artist_id, const char *release_type, uint32_t limit, uint32_t offset); void qobuz_backend_get_artist_releases(QobuzBackendOpaque *backend, int64_t artist_id, const char *release_type, uint32_t limit, uint32_t offset);
// Deep shuffle: fetch tracks from multiple albums (album_ids_json is a JSON array of strings)
void qobuz_backend_get_albums_tracks(QobuzBackendOpaque *backend, const char *album_ids_json);
// Playlist management // Playlist management
void qobuz_backend_create_playlist(QobuzBackendOpaque *backend, const char *name); void qobuz_backend_create_playlist(QobuzBackendOpaque *backend, const char *name);
void qobuz_backend_delete_playlist(QobuzBackendOpaque *backend, int64_t playlist_id); void qobuz_backend_delete_playlist(QobuzBackendOpaque *backend, int64_t playlist_id);

View File

@@ -69,6 +69,7 @@ pub const EV_TRACK_URL_OK: c_int = 17;
pub const EV_TRACK_URL_ERR: c_int = 18; pub const EV_TRACK_URL_ERR: c_int = 18;
pub const EV_GENERIC_ERR: c_int = 19; pub const EV_GENERIC_ERR: c_int = 19;
pub const EV_ARTIST_RELEASES_OK: c_int = 24; pub const EV_ARTIST_RELEASES_OK: c_int = 24;
pub const EV_DEEP_SHUFFLE_OK: c_int = 25;
// ---------- Callback ---------- // ---------- Callback ----------
@@ -263,7 +264,7 @@ pub unsafe extern "C" fn qobuz_backend_get_artist_releases(
artist_id: i64, artist_id: i64,
release_type: *const c_char, release_type: *const c_char,
limit: u32, limit: u32,
offset: u32, _offset: u32,
) { ) {
let inner = &(*ptr).0; let inner = &(*ptr).0;
let client = inner.client.clone(); let client = inner.client.clone();
@@ -271,19 +272,90 @@ pub unsafe extern "C" fn qobuz_backend_get_artist_releases(
let rtype = CStr::from_ptr(release_type).to_string_lossy().into_owned(); let rtype = CStr::from_ptr(release_type).to_string_lossy().into_owned();
spawn(inner, async move { spawn(inner, async move {
let result = client.lock().await // Auto-paginate: fetch all pages until has_more is false.
.get_artist_releases_list(artist_id, &rtype, limit, offset) let mut all_items: Vec<serde_json::Value> = Vec::new();
.await; let mut offset: u32 = 0;
let (ev, json) = match result { loop {
Ok(r) => { let result = client.lock().await
// Wrap with the release_type so Qt can route to the right section .get_artist_releases_list(artist_id, &rtype, limit, offset)
let mut obj = r.as_object().cloned().unwrap_or_default(); .await;
obj.insert("release_type".to_string(), serde_json::Value::String(rtype)); match result {
(EV_ARTIST_RELEASES_OK, serde_json::to_string(&obj).unwrap_or_default()) Ok(r) => {
let obj = r.as_object().cloned().unwrap_or_default();
if let Some(items) = obj.get("items").and_then(|v| v.as_array()) {
all_items.extend(items.iter().cloned());
}
let has_more = obj.get("has_more").and_then(|v| v.as_bool()).unwrap_or(false);
if !has_more {
break;
}
offset += limit;
}
Err(e) => {
call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string()));
return;
}
} }
Err(e) => (EV_GENERIC_ERR, err_json(&e.to_string())), }
}; let result = serde_json::json!({
call_cb(cb, ud, ev, &json); "release_type": rtype,
"items": all_items,
"has_more": false,
"offset": 0
});
call_cb(cb, ud, EV_ARTIST_RELEASES_OK, &serde_json::to_string(&result).unwrap_or_default());
});
}
// ---------- Deep shuffle (fetch tracks from multiple albums) ----------
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_get_albums_tracks(
ptr: *mut Backend,
album_ids_json: *const c_char,
) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb; let ud = inner.ud;
let ids_str = CStr::from_ptr(album_ids_json).to_string_lossy().into_owned();
let album_ids: Vec<String> = match serde_json::from_str(&ids_str) {
Ok(v) => v,
Err(e) => {
call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string()));
return;
}
};
spawn(inner, async move {
let mut all_tracks: Vec<serde_json::Value> = Vec::new();
for id in &album_ids {
let result = client.lock().await.get_album(id).await;
if let Ok(album) = result {
if let Some(tracks) = album.tracks.as_ref().and_then(|t| t.items.as_ref()) {
for t in tracks {
// Serialize track and inject album info for playback context
if let Ok(mut tv) = serde_json::to_value(t) {
if let Some(obj) = tv.as_object_mut() {
// Ensure album context is present on each track
if obj.get("album").is_none() || obj["album"].is_null() {
obj.insert("album".to_string(), serde_json::json!({
"id": album.id,
"title": album.title,
"artist": album.artist,
"image": album.image,
}));
}
}
all_tracks.push(tv);
}
}
}
}
// Skip albums that fail — don't abort the whole operation
}
let result = serde_json::json!({ "tracks": all_tracks });
call_cb(cb, ud, EV_DEEP_SHUFFLE_OK, &serde_json::to_string(&result).unwrap_or_default());
}); });
} }

View File

@@ -67,6 +67,13 @@ void QobuzBackend::getArtistReleases(qint64 artistId, const QString &releaseType
releaseType.toUtf8().constData(), limit, offset); releaseType.toUtf8().constData(), limit, offset);
} }
void QobuzBackend::getAlbumsTracks(const QStringList &albumIds)
{
const QJsonArray arr = QJsonArray::fromStringList(albumIds);
const QByteArray json = QJsonDocument(arr).toJson(QJsonDocument::Compact);
qobuz_backend_get_albums_tracks(m_backend, json.constData());
}
void QobuzBackend::getPlaylist(qint64 playlistId, quint32 offset, quint32 limit) void QobuzBackend::getPlaylist(qint64 playlistId, quint32 offset, quint32 limit)
{ {
qobuz_backend_get_playlist(m_backend, playlistId, offset, limit); qobuz_backend_get_playlist(m_backend, playlistId, offset, limit);
@@ -266,6 +273,9 @@ void QobuzBackend::onEvent(int eventType, const QString &json)
obj["offset"].toInt() obj["offset"].toInt()
); );
break; break;
case 25: // EV_DEEP_SHUFFLE_OK
emit deepShuffleTracksLoaded(obj["tracks"].toArray());
break;
case EV_ARTIST_ERR: case EV_ARTIST_ERR:
emit error(obj["error"].toString()); emit error(obj["error"].toString());
break; break;

View File

@@ -31,6 +31,7 @@ public:
void getAlbum(const QString &albumId); void getAlbum(const QString &albumId);
void getArtist(qint64 artistId); void getArtist(qint64 artistId);
void getArtistReleases(qint64 artistId, const QString &releaseType, quint32 limit = 50, quint32 offset = 0); void getArtistReleases(qint64 artistId, const QString &releaseType, quint32 limit = 50, quint32 offset = 0);
void getAlbumsTracks(const QStringList &albumIds);
void getPlaylist(qint64 playlistId, quint32 offset = 0, quint32 limit = 500); void getPlaylist(qint64 playlistId, quint32 offset = 0, quint32 limit = 500);
// --- favorites --- // --- favorites ---
@@ -88,6 +89,7 @@ signals:
void albumLoaded(const QJsonObject &album); void albumLoaded(const QJsonObject &album);
void artistLoaded(const QJsonObject &artist); void artistLoaded(const QJsonObject &artist);
void artistReleasesLoaded(const QString &releaseType, const QJsonArray &items, bool hasMore, int offset); void artistReleasesLoaded(const QString &releaseType, const QJsonArray &items, bool hasMore, int offset);
void deepShuffleTracksLoaded(const QJsonArray &tracks);
void playlistLoaded(const QJsonObject &playlist); void playlistLoaded(const QJsonObject &playlist);
void playlistCreated(const QJsonObject &playlist); void playlistCreated(const QJsonObject &playlist);
void playlistDeleted(const QJsonObject &result); void playlistDeleted(const QJsonObject &result);

View File

@@ -40,7 +40,8 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
m_libraryDock->setObjectName(QStringLiteral("libraryDock")); m_libraryDock->setObjectName(QStringLiteral("libraryDock"));
m_libraryDock->setFeatures(QDockWidget::DockWidgetMovable); m_libraryDock->setFeatures(QDockWidget::DockWidgetMovable);
m_libraryDock->setWidget(m_library); m_libraryDock->setWidget(m_library);
m_libraryDock->setMinimumWidth(200); m_libraryDock->setMinimumWidth(180);
m_library->setFixedWidth(220);
addDockWidget(Qt::LeftDockWidgetArea, m_libraryDock); addDockWidget(Qt::LeftDockWidgetArea, m_libraryDock);
// ---- Now-playing context dock (left, below library) ---- // ---- Now-playing context dock (left, below library) ----
@@ -86,6 +87,8 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
connect(m_backend, &QobuzBackend::artistLoaded, this, &MainWindow::onArtistLoaded); connect(m_backend, &QobuzBackend::artistLoaded, this, &MainWindow::onArtistLoaded);
connect(m_backend, &QobuzBackend::artistReleasesLoaded, connect(m_backend, &QobuzBackend::artistReleasesLoaded,
m_content, &MainContent::updateArtistReleases); m_content, &MainContent::updateArtistReleases);
connect(m_backend, &QobuzBackend::deepShuffleTracksLoaded,
m_content, &MainContent::onDeepShuffleTracks);
connect(m_backend, &QobuzBackend::playlistLoaded, this, &MainWindow::onPlaylistLoaded); connect(m_backend, &QobuzBackend::playlistLoaded, this, &MainWindow::onPlaylistLoaded);
connect(m_backend, &QobuzBackend::playlistCreated, this, &MainWindow::onPlaylistCreated); connect(m_backend, &QobuzBackend::playlistCreated, this, &MainWindow::onPlaylistCreated);
connect(m_backend, &QobuzBackend::playlistDeleted, this, [this](const QJsonObject &) { connect(m_backend, &QobuzBackend::playlistDeleted, this, [this](const QJsonObject &) {
@@ -170,22 +173,6 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
connect(m_toolBar, &MainToolBar::queueToggled, connect(m_toolBar, &MainToolBar::queueToggled,
this, [this](bool v) { m_queuePanel->setVisible(v); }); this, [this](bool v) { m_queuePanel->setVisible(v); });
connect(m_toolBar->backAction(), &QAction::triggered, this, [this] {
if (m_navIndex <= 0) return;
--m_navIndex;
m_navFromHistory = true;
navigateTo(m_navHistory[m_navIndex]);
m_navFromHistory = false;
updateNavButtons();
});
connect(m_toolBar->forwardAction(), &QAction::triggered, this, [this] {
if (m_navIndex >= m_navHistory.size() - 1) return;
++m_navIndex;
m_navFromHistory = true;
navigateTo(m_navHistory[m_navIndex]);
m_navFromHistory = false;
updateNavButtons();
});
connect(m_toolBar, &MainToolBar::albumRequested, this, &MainWindow::onSearchAlbumSelected); connect(m_toolBar, &MainToolBar::albumRequested, this, &MainWindow::onSearchAlbumSelected);
connect(m_toolBar, &MainToolBar::artistRequested, this, &MainWindow::onSearchArtistSelected); connect(m_toolBar, &MainToolBar::artistRequested, this, &MainWindow::onSearchArtistSelected);
@@ -394,16 +381,12 @@ void MainWindow::onPlayTrackRequested(qint64 trackId)
void MainWindow::onSearchAlbumSelected(const QString &albumId) void MainWindow::onSearchAlbumSelected(const QString &albumId)
{ {
NavPage p; p.type = NavPage::Album; p.albumId = albumId;
pushNav(p.type, p.albumId);
m_backend->getAlbum(albumId); m_backend->getAlbum(albumId);
statusBar()->showMessage(tr("Loading album…")); statusBar()->showMessage(tr("Loading album…"));
} }
void MainWindow::onSearchArtistSelected(qint64 artistId) void MainWindow::onSearchArtistSelected(qint64 artistId)
{ {
NavPage p; p.type = NavPage::Artist; p.artistId = artistId;
pushNav(p.type, {}, p.artistId);
m_backend->getArtist(artistId); m_backend->getArtist(artistId);
statusBar()->showMessage(tr("Loading artist…")); statusBar()->showMessage(tr("Loading artist…"));
} }
@@ -429,38 +412,3 @@ void MainWindow::onUserPlaylistsChanged(const QVector<QPair<qint64, QString>> &p
m_content->tracksList()->setUserPlaylists(playlists); m_content->tracksList()->setUserPlaylists(playlists);
} }
void MainWindow::pushNav(NavPage::Type type, const QString &albumId, qint64 artistId)
{
if (m_navFromHistory) return;
// Truncate any forward history
while (m_navHistory.size() > m_navIndex + 1)
m_navHistory.removeLast();
NavPage p;
p.type = type;
p.albumId = albumId;
p.artistId = artistId;
m_navHistory.push_back(p);
m_navIndex = m_navHistory.size() - 1;
updateNavButtons();
}
void MainWindow::navigateTo(const NavPage &page)
{
switch (page.type) {
case NavPage::Album:
m_backend->getAlbum(page.albumId);
statusBar()->showMessage(tr("Loading album…"));
break;
case NavPage::Artist:
m_backend->getArtist(page.artistId);
statusBar()->showMessage(tr("Loading artist…"));
break;
default: break;
}
}
void MainWindow::updateNavButtons()
{
m_toolBar->backAction()->setEnabled(m_navIndex > 0);
m_toolBar->forwardAction()->setEnabled(m_navIndex < m_navHistory.size() - 1);
}

View File

@@ -64,19 +64,6 @@ private:
QDockWidget *m_libraryDock = nullptr; QDockWidget *m_libraryDock = nullptr;
LastFmScrobbler *m_scrobbler = nullptr; LastFmScrobbler *m_scrobbler = nullptr;
// Navigation history (browser-style Back / Forward)
struct NavPage {
enum Type { None, Album, Artist } type = None;
QString albumId;
qint64 artistId = 0;
};
QVector<NavPage> m_navHistory;
int m_navIndex = -1;
bool m_navFromHistory = false;
void setupMenuBar(); void setupMenuBar();
void tryRestoreSession(); void tryRestoreSession();
void pushNav(NavPage::Type type, const QString &albumId = {}, qint64 artistId = 0);
void navigateTo(const NavPage &page);
void updateNavButtons();
}; };

View File

@@ -44,6 +44,20 @@ public:
addAlbums(albums); addAlbums(albums);
} }
/// Configure for artist page: hide Artist column, set fixed column widths
/// that match the Popular Tracks list for perfect vertical alignment.
void setArtistPageMode()
{
setColumnHidden(2, true); // Artist — redundant on artist page
header()->setSectionResizeMode(0, QHeaderView::Fixed);
header()->setSectionResizeMode(1, QHeaderView::Stretch);
header()->setSectionResizeMode(3, QHeaderView::Fixed);
header()->setSectionResizeMode(4, QHeaderView::Fixed);
header()->resizeSection(0, 40);
header()->resizeSection(3, 120);
header()->resizeSection(4, 70);
}
void addAlbums(const QJsonArray &albums) void addAlbums(const QJsonArray &albums)
{ {
QFont hiResFont; QFont hiResFont;

View File

@@ -1,9 +1,11 @@
#include "artistview.hpp" #include "artistview.hpp"
#include "albumlistview.hpp" #include "albumlistview.hpp"
#include "../model/tracklistmodel.hpp"
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QScrollArea> #include <QScrollArea>
#include <QHeaderView>
#include <QNetworkAccessManager> #include <QNetworkAccessManager>
#include <QNetworkReply> #include <QNetworkReply>
#include <QNetworkRequest> #include <QNetworkRequest>
@@ -49,50 +51,18 @@ ArtistSection::ArtistSection(const QString &title, const QString &releaseType, Q
m_list = new AlbumListView(this); m_list = new AlbumListView(this);
layout->addWidget(m_list); layout->addWidget(m_list);
// "Load more" button (shown when has_more is true)
m_loadMoreBtn = new QPushButton(tr("Load more…"), this);
m_loadMoreBtn->setStyleSheet(QStringLiteral(
"QPushButton { text-align: left; color: #FFB232; background: transparent;"
" border: none; padding: 6px 8px; }"
"QPushButton:hover { background: #1e1e1e; }"));
m_loadMoreBtn->setCursor(Qt::PointingHandCursor);
m_loadMoreBtn->setVisible(false);
layout->addWidget(m_loadMoreBtn);
connect(m_toggle, &QPushButton::toggled, this, [this](bool checked) { connect(m_toggle, &QPushButton::toggled, this, [this](bool checked) {
m_list->setVisible(checked); m_list->setVisible(checked);
m_loadMoreBtn->setVisible(checked && m_hasMore);
updateToggleText(); updateToggleText();
}); });
connect(m_list, &AlbumListView::albumSelected, this, &ArtistSection::albumSelected); connect(m_list, &AlbumListView::albumSelected, this, &ArtistSection::albumSelected);
connect(m_loadMoreBtn, &QPushButton::clicked, this, [this] {
m_loadMoreBtn->setEnabled(false);
m_loadMoreBtn->setText(tr("Loading…"));
emit loadMoreRequested(m_releaseType, m_loadedCount);
});
updateToggleText(); updateToggleText();
} }
void ArtistSection::setAlbums(const QJsonArray &albums, bool hasMore) void ArtistSection::setAlbums(const QJsonArray &albums)
{ {
m_list->setAlbums(albums); m_list->setAlbums(albums);
m_loadedCount = albums.size();
m_hasMore = hasMore;
m_loadMoreBtn->setVisible(hasMore && m_toggle->isChecked());
m_loadMoreBtn->setEnabled(true);
m_loadMoreBtn->setText(tr("Load more…"));
updateToggleText();
}
void ArtistSection::appendAlbums(const QJsonArray &albums, bool hasMore)
{
m_list->addAlbums(albums);
m_loadedCount += albums.size();
m_hasMore = hasMore;
m_loadMoreBtn->setVisible(hasMore && m_toggle->isChecked());
m_loadMoreBtn->setEnabled(true);
m_loadMoreBtn->setText(tr("Load more…"));
updateToggleText(); updateToggleText();
} }
@@ -101,11 +71,28 @@ bool ArtistSection::isEmpty() const
return m_list->topLevelItemCount() == 0; return m_list->topLevelItemCount() == 0;
} }
QStringList ArtistSection::albumIds() const
{
QStringList ids;
for (int i = 0; i < m_list->topLevelItemCount(); ++i) {
const QString id = m_list->topLevelItem(i)->data(1, Qt::UserRole).toString();
if (!id.isEmpty())
ids.append(id);
}
return ids;
}
void ArtistSection::setArtistPageMode()
{
m_list->setArtistPageMode();
}
void ArtistSection::updateToggleText() void ArtistSection::updateToggleText()
{ {
const int count = m_list->topLevelItemCount();
const QString arrow = m_toggle->isChecked() ? QStringLiteral("") : QStringLiteral(""); const QString arrow = m_toggle->isChecked() ? QStringLiteral("") : QStringLiteral("");
const QString text = m_loadedCount > 0 const QString text = count > 0
? QStringLiteral("%1%2 (%3)").arg(arrow, m_baseTitle).arg(m_loadedCount) ? QStringLiteral("%1%2 (%3)").arg(arrow, m_baseTitle).arg(count)
: arrow + m_baseTitle; : arrow + m_baseTitle;
m_toggle->setText(text); m_toggle->setText(text);
} }
@@ -117,6 +104,7 @@ void ArtistSection::updateToggleText()
ArtistView::ArtistView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent) ArtistView::ArtistView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
: QWidget(parent) : QWidget(parent)
, m_backend(backend) , m_backend(backend)
, m_queue(queue)
{ {
auto *outerLayout = new QVBoxLayout(this); auto *outerLayout = new QVBoxLayout(this);
outerLayout->setContentsMargins(0, 0, 0, 0); outerLayout->setContentsMargins(0, 0, 0, 0);
@@ -165,7 +153,7 @@ ArtistView::ArtistView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
QStringLiteral("QPushButton { background: #FFB232; color: #000; }" QStringLiteral("QPushButton { background: #FFB232; color: #000; }"
"QPushButton:pressed { background: #e09e28; }")); "QPushButton:pressed { background: #e09e28; }"));
m_shuffleBtn = new QPushButton(tr("⇄ Shuffle"), info); m_shuffleBtn = new QPushButton(tr("⇄ Shuffle All"), info);
m_shuffleBtn->setStyleSheet(kBtnBase + m_shuffleBtn->setStyleSheet(kBtnBase +
QStringLiteral("QPushButton { background: #2a2a2a; color: #FFB232; border: 1px solid #FFB232; }" QStringLiteral("QPushButton { background: #2a2a2a; color: #FFB232; border: 1px solid #FFB232; }"
"QPushButton:pressed { background: #333; }")); "QPushButton:pressed { background: #333; }"));
@@ -222,6 +210,14 @@ ArtistView::ArtistView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
m_topTracks = new List::Tracks(backend, queue, m_topTracksSection); m_topTracks = new List::Tracks(backend, queue, m_topTracksSection);
m_topTracks->setMaximumHeight(320); m_topTracks->setMaximumHeight(320);
// Artist page column layout: hide Artist & Album, match album-section widths
m_topTracks->setColumnHidden(TrackListModel::ColArtist, true);
m_topTracks->setColumnHidden(TrackListModel::ColAlbum, true);
m_topTracks->header()->setSectionResizeMode(TrackListModel::ColNumber, QHeaderView::Fixed);
m_topTracks->header()->setSectionResizeMode(TrackListModel::ColTitle, QHeaderView::Stretch);
m_topTracks->header()->setSectionResizeMode(TrackListModel::ColDuration, QHeaderView::Fixed);
m_topTracks->header()->resizeSection(TrackListModel::ColNumber, 40);
m_topTracks->header()->resizeSection(TrackListModel::ColDuration, 70);
ttLayout->addWidget(m_topTracks); ttLayout->addWidget(m_topTracks);
connect(m_topTracksToggle, &QPushButton::toggled, m_topTracks, &QWidget::setVisible); connect(m_topTracksToggle, &QPushButton::toggled, m_topTracks, &QWidget::setVisible);
@@ -236,6 +232,10 @@ ArtistView::ArtistView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
m_secCompilations = new ArtistSection(tr("Compilations"), QStringLiteral("compilation"), content); m_secCompilations = new ArtistSection(tr("Compilations"), QStringLiteral("compilation"), content);
m_secOther = new ArtistSection(tr("Other"), QStringLiteral("other"), content); m_secOther = new ArtistSection(tr("Other"), QStringLiteral("other"), content);
// Uniform column layout: hide Artist column, match fixed widths across all sections
for (ArtistSection *sec : {m_secAlbums, m_secEps, m_secLive, m_secCompilations, m_secOther})
sec->setArtistPageMode();
sectLayout->addWidget(m_secAlbums); sectLayout->addWidget(m_secAlbums);
sectLayout->addWidget(m_secEps); sectLayout->addWidget(m_secEps);
sectLayout->addWidget(m_secLive); sectLayout->addWidget(m_secLive);
@@ -246,9 +246,21 @@ ArtistView::ArtistView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
scroll->setWidget(content); scroll->setWidget(content);
outerLayout->addWidget(scroll, 1); outerLayout->addWidget(scroll, 1);
// Playback connections // Play top tracks
connect(m_playBtn, &QPushButton::clicked, m_topTracks, [this] { m_topTracks->playAll(false); }); connect(m_playBtn, &QPushButton::clicked, m_topTracks, [this] { m_topTracks->playAll(false); });
connect(m_shuffleBtn, &QPushButton::clicked, m_topTracks, [this] { m_topTracks->playAll(true); });
// Deep shuffle: fetch all album tracks, combine, shuffle, play
connect(m_shuffleBtn, &QPushButton::clicked, this, [this] {
const QStringList ids = allAlbumIds();
if (ids.isEmpty()) {
// Fallback: just shuffle popular tracks
m_topTracks->playAll(true);
return;
}
m_shuffleBtn->setEnabled(false);
m_shuffleBtn->setText(tr("Loading…"));
m_backend->getAlbumsTracks(ids);
});
// Favourite button // Favourite button
connect(m_favBtn, &QPushButton::clicked, this, [this] { connect(m_favBtn, &QPushButton::clicked, this, [this] {
@@ -270,20 +282,6 @@ ArtistView::ArtistView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
connect(m_secLive, &ArtistSection::albumSelected, this, &ArtistView::albumSelected); connect(m_secLive, &ArtistSection::albumSelected, this, &ArtistView::albumSelected);
connect(m_secCompilations, &ArtistSection::albumSelected, this, &ArtistView::albumSelected); connect(m_secCompilations, &ArtistSection::albumSelected, this, &ArtistView::albumSelected);
connect(m_secOther, &ArtistSection::albumSelected, this, &ArtistView::albumSelected); connect(m_secOther, &ArtistSection::albumSelected, this, &ArtistView::albumSelected);
// Load-more connections
auto connectLoadMore = [this](ArtistSection *sec) {
connect(sec, &ArtistSection::loadMoreRequested, this,
[this](const QString &releaseType, int nextOffset) {
if (m_artistId > 0)
m_backend->getArtistReleases(m_artistId, releaseType, 50, static_cast<quint32>(nextOffset));
});
};
connectLoadMore(m_secAlbums);
connectLoadMore(m_secEps);
connectLoadMore(m_secLive);
connectLoadMore(m_secCompilations);
connectLoadMore(m_secOther);
} }
void ArtistView::setArtist(const QJsonObject &artist) void ArtistView::setArtist(const QJsonObject &artist)
@@ -352,6 +350,10 @@ void ArtistView::setArtist(const QJsonObject &artist)
: QStringLiteral("▼ Popular Tracks")); : QStringLiteral("▼ Popular Tracks"));
m_topTracksSection->setVisible(!topTracks.isEmpty()); m_topTracksSection->setVisible(!topTracks.isEmpty());
// Reset shuffle button state
m_shuffleBtn->setEnabled(true);
m_shuffleBtn->setText(tr("⇄ Shuffle All"));
// Clear release sections // Clear release sections
for (ArtistSection *sec : {m_secAlbums, m_secEps, m_secLive, m_secCompilations, m_secOther}) { for (ArtistSection *sec : {m_secAlbums, m_secEps, m_secLive, m_secCompilations, m_secOther}) {
sec->setAlbums({}); sec->setAlbums({});
@@ -360,7 +362,7 @@ void ArtistView::setArtist(const QJsonObject &artist)
} }
void ArtistView::setReleases(const QString &releaseType, const QJsonArray &items, void ArtistView::setReleases(const QString &releaseType, const QJsonArray &items,
bool hasMore, int offset) bool /*hasMore*/, int /*offset*/)
{ {
ArtistSection *sec = nullptr; ArtistSection *sec = nullptr;
if (releaseType == QStringLiteral("album")) sec = m_secAlbums; if (releaseType == QStringLiteral("album")) sec = m_secAlbums;
@@ -369,22 +371,42 @@ void ArtistView::setReleases(const QString &releaseType, const QJsonArray &items
else if (releaseType == QStringLiteral("compilation")) sec = m_secCompilations; else if (releaseType == QStringLiteral("compilation")) sec = m_secCompilations;
else sec = m_secOther; else sec = m_secOther;
if (offset == 0) // Rust auto-paginates, so we always get the full list at once
sec->setAlbums(items, hasMore); sec->setAlbums(items);
else
sec->appendAlbums(items, hasMore);
sec->setVisible(!sec->isEmpty()); sec->setVisible(!sec->isEmpty());
} }
void ArtistView::setFavArtistIds(const QSet<qint64> &ids) void ArtistView::setFavArtistIds(const QSet<qint64> &ids)
{ {
m_favArtistIds = ids; m_favArtistIds = ids;
// Update current state if we're showing an artist
if (m_artistId > 0) if (m_artistId > 0)
setFaved(ids.contains(m_artistId)); setFaved(ids.contains(m_artistId));
} }
void ArtistView::onDeepShuffleTracks(const QJsonArray &tracks)
{
m_shuffleBtn->setEnabled(true);
m_shuffleBtn->setText(tr("⇄ Shuffle All"));
if (tracks.isEmpty()) return;
m_queue->setContext(tracks, 0);
m_queue->shuffleNow();
const QJsonObject first = m_queue->current();
const qint64 id = static_cast<qint64>(first["id"].toDouble());
if (id > 0)
emit playTrackRequested(id);
}
QStringList ArtistView::allAlbumIds() const
{
QStringList ids;
for (const ArtistSection *sec : {m_secAlbums, m_secEps, m_secLive, m_secCompilations, m_secOther})
ids.append(sec->albumIds());
return ids;
}
void ArtistView::setFaved(bool faved) void ArtistView::setFaved(bool faved)
{ {
m_isFaved = faved; m_isFaved = faved;

View File

@@ -23,22 +23,19 @@ class ArtistSection : public QWidget
public: public:
explicit ArtistSection(const QString &title, const QString &releaseType, QWidget *parent = nullptr); explicit ArtistSection(const QString &title, const QString &releaseType, QWidget *parent = nullptr);
void setAlbums(const QJsonArray &albums, bool hasMore = false); void setAlbums(const QJsonArray &albums);
void appendAlbums(const QJsonArray &albums, bool hasMore = false);
bool isEmpty() const; bool isEmpty() const;
QStringList albumIds() const;
void setArtistPageMode();
signals: signals:
void albumSelected(const QString &albumId); void albumSelected(const QString &albumId);
void loadMoreRequested(const QString &releaseType, int nextOffset);
private: private:
QString m_baseTitle; QString m_baseTitle;
QString m_releaseType; QString m_releaseType;
QPushButton *m_toggle = nullptr; QPushButton *m_toggle = nullptr;
AlbumListView *m_list = nullptr; AlbumListView *m_list = nullptr;
QPushButton *m_loadMoreBtn = nullptr;
bool m_hasMore = false;
int m_loadedCount = 0;
void updateToggleText(); void updateToggleText();
}; };
@@ -55,6 +52,7 @@ public:
void setReleases(const QString &releaseType, const QJsonArray &items, void setReleases(const QString &releaseType, const QJsonArray &items,
bool hasMore = false, int offset = 0); bool hasMore = false, int offset = 0);
void setFavArtistIds(const QSet<qint64> &ids); void setFavArtistIds(const QSet<qint64> &ids);
void onDeepShuffleTracks(const QJsonArray &tracks);
signals: signals:
void albumSelected(const QString &albumId); void albumSelected(const QString &albumId);
@@ -62,6 +60,7 @@ signals:
private: private:
QobuzBackend *m_backend = nullptr; QobuzBackend *m_backend = nullptr;
PlayQueue *m_queue = nullptr;
qint64 m_artistId = 0; qint64 m_artistId = 0;
// Header widgets // Header widgets
@@ -88,5 +87,6 @@ private:
ArtistSection *m_secCompilations = nullptr; ArtistSection *m_secCompilations = nullptr;
ArtistSection *m_secOther = nullptr; ArtistSection *m_secOther = nullptr;
QStringList allAlbumIds() const;
void setFaved(bool faved); void setFaved(bool faved);
}; };

View File

@@ -117,3 +117,8 @@ void MainContent::setFavArtistIds(const QSet<qint64> &ids)
{ {
m_artistView->setFavArtistIds(ids); m_artistView->setFavArtistIds(ids);
} }
void MainContent::onDeepShuffleTracks(const QJsonArray &tracks)
{
m_artistView->onDeepShuffleTracks(tracks);
}

View File

@@ -33,6 +33,9 @@ public:
void showArtist(const QJsonObject &artist); void showArtist(const QJsonObject &artist);
void updateArtistReleases(const QString &releaseType, const QJsonArray &items, bool hasMore, int offset); void updateArtistReleases(const QString &releaseType, const QJsonArray &items, bool hasMore, int offset);
void setFavArtistIds(const QSet<qint64> &ids); void setFavArtistIds(const QSet<qint64> &ids);
void onDeepShuffleTracks(const QJsonArray &tracks);
ArtistView *artistView() const { return m_artistView; }
signals: signals:
void albumRequested(const QString &albumId); void albumRequested(const QString &albumId);

View File

@@ -16,13 +16,6 @@ MainToolBar::MainToolBar(QobuzBackend *backend, PlayQueue *queue, QWidget *paren
setContextMenuPolicy(Qt::PreventContextMenu); setContextMenuPolicy(Qt::PreventContextMenu);
setIconSize(QSize(22, 22)); setIconSize(QSize(22, 22));
// ---- Back / Forward navigation ----
m_back = addAction(QIcon::fromTheme(QStringLiteral("go-previous")), tr("Back"));
m_back->setEnabled(false);
m_fwd = addAction(QIcon::fromTheme(QStringLiteral("go-next")), tr("Forward"));
m_fwd->setEnabled(false);
addSeparator();
m_nam = new QNetworkAccessManager(this); m_nam = new QNetworkAccessManager(this);
connect(m_nam, &QNetworkAccessManager::finished, this, &MainToolBar::onAlbumArtReady); connect(m_nam, &QNetworkAccessManager::finished, this, &MainToolBar::onAlbumArtReady);

View File

@@ -24,9 +24,6 @@ public:
void setCurrentTrack(const QJsonObject &track); void setCurrentTrack(const QJsonObject &track);
void updateProgress(quint64 position, quint64 duration); void updateProgress(quint64 position, quint64 duration);
QAction *backAction() const { return m_back; }
QAction *forwardAction() const { return m_fwd; }
signals: signals:
void searchToggled(bool visible); void searchToggled(bool visible);
void queueToggled(bool visible); void queueToggled(bool visible);
@@ -57,8 +54,6 @@ private:
QobuzBackend *m_backend = nullptr; QobuzBackend *m_backend = nullptr;
PlayQueue *m_queue = nullptr; PlayQueue *m_queue = nullptr;
QAction *m_back = nullptr;
QAction *m_fwd = nullptr;
QLabel *m_artLabel = nullptr; QLabel *m_artLabel = nullptr;
QLabel *m_trackLabel = nullptr; QLabel *m_trackLabel = nullptr;
QAction *m_previous = nullptr; QAction *m_previous = nullptr;