fix: section alignment, pagination, fav state, playback error handling

**Section toggles left-aligned**
- Replace QToolButton with flat QPushButton for all section headers;
  QPushButton properly respects text-align: left in stylesheets

**Pagination via "Load More" button**
- QTreeWidget expands to fit all items so the scrollbar-based infinite
  scroll never triggered; replaced with an explicit "Load more…" button
  that appears when has_more is true and emits loadMoreRequested

**Favourite button reflects actual state**
- MainWindow preloads fav artist IDs on session restore (getFavArtists)
  and caches them in m_favArtistIds
- ArtistView receives the full set via setFavArtistIds() and checks
  it on every setArtist() call so the button starts gold if already faved
- Toggling updates the local cache immediately for back/forward nav

**Playback error → queue advances**
- player_loop now sets track_finished on Err (was only set on Ok(None)),
  so the toolbar's onTrackFinished handler advances to the next track
  instead of stalling on an unplayable track

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
joren
2026-03-25 23:22:30 +01:00
parent b3cc2e3def
commit 333a620be2
8 changed files with 98 additions and 52 deletions

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
build/ build/
build-*/ build-*/
target/ target/
src/visualizer/
.cache/ .cache/
*.user *.user
*.autosave *.autosave

View File

@@ -215,6 +215,9 @@ fn player_loop(rx: std::sync::mpsc::Receiver<PlayerCommand>, status: PlayerStatu
Err(e) => { Err(e) => {
eprintln!("playback error: {e}"); eprintln!("playback error: {e}");
*status.state.lock().unwrap() = PlayerState::Error(e.to_string()); *status.state.lock().unwrap() = PlayerState::Error(e.to_string());
// Signal track end so the queue advances to the next track
// instead of stalling on an unplayable track.
status.track_finished.store(true, Ordering::SeqCst);
} }
} }
} }

View File

@@ -121,6 +121,7 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
statusBar()->showMessage(tr("Loading favorite albums…")); statusBar()->showMessage(tr("Loading favorite albums…"));
}); });
connect(m_library, &List::Library::favArtistsRequested, this, [this] { connect(m_library, &List::Library::favArtistsRequested, this, [this] {
m_showFavArtistsOnLoad = true;
m_backend->getFavArtists(); m_backend->getFavArtists();
statusBar()->showMessage(tr("Loading favorite artists…")); statusBar()->showMessage(tr("Loading favorite artists…"));
}); });
@@ -233,6 +234,8 @@ void MainWindow::tryRestoreSession()
m_backend->getUser(); // userLoaded will call m_library->refresh() m_backend->getUser(); // userLoaded will call m_library->refresh()
else else
m_library->refresh(); m_library->refresh();
// Preload fav artists so the artist page fav button works immediately
m_backend->getFavArtists();
const QString name = AppSettings::instance().displayName(); const QString name = AppSettings::instance().displayName();
statusBar()->showMessage(tr("Signed in as %1").arg( statusBar()->showMessage(tr("Signed in as %1").arg(
name.isEmpty() ? AppSettings::instance().userEmail() : name)); name.isEmpty() ? AppSettings::instance().userEmail() : name));
@@ -339,9 +342,22 @@ void MainWindow::onFavAlbumsLoaded(const QJsonObject &result)
void MainWindow::onFavArtistsLoaded(const QJsonObject &result) void MainWindow::onFavArtistsLoaded(const QJsonObject &result)
{ {
// Always cache fav artist IDs (needed by the artist page fav button)
m_favArtistIds.clear();
const QJsonArray items = result["items"].toArray();
for (const QJsonValue &v : items) {
const qint64 id = static_cast<qint64>(v.toObject()["id"].toDouble());
if (id > 0) m_favArtistIds.insert(id);
}
m_content->setFavArtistIds(m_favArtistIds);
// Only navigate to the fav artists page if the user explicitly requested it
if (m_showFavArtistsOnLoad) {
m_showFavArtistsOnLoad = false;
m_content->showFavArtists(result); m_content->showFavArtists(result);
statusBar()->showMessage( statusBar()->showMessage(
tr("%1 favorite artists").arg(result["total"].toInt()), 4000); tr("%1 favorite artists").arg(result["total"].toInt()), 4000);
}
} }
void MainWindow::onAlbumLoaded(const QJsonObject &album) void MainWindow::onAlbumLoaded(const QJsonObject &album)

View File

@@ -53,6 +53,8 @@ private:
QobuzBackend *m_backend = nullptr; QobuzBackend *m_backend = nullptr;
PlayQueue *m_queue = nullptr; PlayQueue *m_queue = nullptr;
QVector<QPair<qint64, QString>> m_userPlaylists; QVector<QPair<qint64, QString>> m_userPlaylists;
QSet<qint64> m_favArtistIds;
bool m_showFavArtistsOnLoad = false;
MainToolBar *m_toolBar = nullptr; MainToolBar *m_toolBar = nullptr;
MainContent *m_content = nullptr; MainContent *m_content = nullptr;
List::Library *m_library = nullptr; List::Library *m_library = nullptr;

View File

@@ -10,7 +10,6 @@
#include <QPixmap> #include <QPixmap>
#include <QUrl> #include <QUrl>
#include <QFont> #include <QFont>
#include <QScrollBar>
#include <QRegularExpression> #include <QRegularExpression>
// Shared button style (mirrors TrackContextHeader) // Shared button style (mirrors TrackContextHeader)
@@ -18,11 +17,12 @@ static const QString kBtnBase = QStringLiteral(
"QPushButton { padding: 5px 16px; border-radius: 4px; font-weight: bold; }" "QPushButton { padding: 5px 16px; border-radius: 4px; font-weight: bold; }"
); );
// Section-toggle style: text-only, left-aligned, bottom separator // Section-toggle style: flat QPushButton, truly left-aligned
static const QString kToggleStyle = QStringLiteral( static const QString kToggleStyle = QStringLiteral(
"QToolButton { text-align: left; font-weight: bold; padding: 4px 6px;" "QPushButton { text-align: left; font-weight: bold; font-size: 13px;"
" border: none; border-bottom: 1px solid #333; }" " padding: 6px 8px; border: none; border-bottom: 1px solid #333;"
"QToolButton:hover { background: #1e1e1e; }" " background: transparent; }"
"QPushButton:hover { background: #1e1e1e; }"
); );
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -38,10 +38,10 @@ ArtistSection::ArtistSection(const QString &title, const QString &releaseType, Q
layout->setContentsMargins(0, 0, 0, 0); layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(0); layout->setSpacing(0);
m_toggle = new QToolButton(this); m_toggle = new QPushButton(this);
m_toggle->setCheckable(true); m_toggle->setCheckable(true);
m_toggle->setChecked(true); m_toggle->setChecked(true);
m_toggle->setToolButtonStyle(Qt::ToolButtonTextOnly); m_toggle->setFlat(true);
m_toggle->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); m_toggle->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
m_toggle->setStyleSheet(kToggleStyle); m_toggle->setStyleSheet(kToggleStyle);
layout->addWidget(m_toggle); layout->addWidget(m_toggle);
@@ -49,16 +49,26 @@ 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);
connect(m_toggle, &QToolButton::toggled, m_list, &AlbumListView::setVisible); // "Load more" button (shown when has_more is true)
connect(m_list, &AlbumListView::albumSelected, this, &ArtistSection::albumSelected); 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);
// Infinite scroll: emit loadMoreRequested when scrolled to bottom connect(m_toggle, &QPushButton::toggled, this, [this](bool checked) {
connect(m_list->verticalScrollBar(), &QScrollBar::valueChanged, this, [this](int val) { m_list->setVisible(checked);
const int max = m_list->verticalScrollBar()->maximum(); m_loadMoreBtn->setVisible(checked && m_hasMore);
if (m_hasMore && max > 0 && val >= max) { updateToggleText();
m_hasMore = false; // prevent double-fire until next page arrives });
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); emit loadMoreRequested(m_releaseType, m_loadedCount);
}
}); });
updateToggleText(); updateToggleText();
@@ -69,6 +79,9 @@ void ArtistSection::setAlbums(const QJsonArray &albums, bool hasMore)
m_list->setAlbums(albums); m_list->setAlbums(albums);
m_loadedCount = albums.size(); m_loadedCount = albums.size();
m_hasMore = hasMore; m_hasMore = hasMore;
m_loadMoreBtn->setVisible(hasMore && m_toggle->isChecked());
m_loadMoreBtn->setEnabled(true);
m_loadMoreBtn->setText(tr("Load more…"));
updateToggleText(); updateToggleText();
} }
@@ -77,6 +90,9 @@ void ArtistSection::appendAlbums(const QJsonArray &albums, bool hasMore)
m_list->addAlbums(albums); m_list->addAlbums(albums);
m_loadedCount += albums.size(); m_loadedCount += albums.size();
m_hasMore = hasMore; m_hasMore = hasMore;
m_loadMoreBtn->setVisible(hasMore && m_toggle->isChecked());
m_loadMoreBtn->setEnabled(true);
m_loadMoreBtn->setText(tr("Load more…"));
updateToggleText(); updateToggleText();
} }
@@ -92,10 +108,6 @@ void ArtistSection::updateToggleText()
? QStringLiteral("%1%2 (%3)").arg(arrow, m_baseTitle).arg(m_loadedCount) ? QStringLiteral("%1%2 (%3)").arg(arrow, m_baseTitle).arg(m_loadedCount)
: arrow + m_baseTitle; : arrow + m_baseTitle;
m_toggle->setText(text); m_toggle->setText(text);
disconnect(m_toggle, &QToolButton::toggled, nullptr, nullptr);
connect(m_toggle, &QToolButton::toggled, m_list, &AlbumListView::setVisible);
connect(m_toggle, &QToolButton::toggled, this, [this](bool) { updateToggleText(); });
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -194,16 +206,16 @@ ArtistView::ArtistView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
sectLayout->setContentsMargins(0, 0, 0, 0); sectLayout->setContentsMargins(0, 0, 0, 0);
sectLayout->setSpacing(0); sectLayout->setSpacing(0);
// Popular Tracks — same collapsible header style as ArtistSection // Popular Tracks section — same toggle style as release sections
m_topTracksSection = new QWidget(content); m_topTracksSection = new QWidget(content);
auto *ttLayout = new QVBoxLayout(m_topTracksSection); auto *ttLayout = new QVBoxLayout(m_topTracksSection);
ttLayout->setContentsMargins(0, 0, 0, 0); ttLayout->setContentsMargins(0, 0, 0, 0);
ttLayout->setSpacing(0); ttLayout->setSpacing(0);
m_topTracksToggle = new QToolButton(m_topTracksSection); m_topTracksToggle = new QPushButton(m_topTracksSection);
m_topTracksToggle->setCheckable(true); m_topTracksToggle->setCheckable(true);
m_topTracksToggle->setChecked(true); m_topTracksToggle->setChecked(true);
m_topTracksToggle->setToolButtonStyle(Qt::ToolButtonTextOnly); m_topTracksToggle->setFlat(true);
m_topTracksToggle->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); m_topTracksToggle->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
m_topTracksToggle->setStyleSheet(kToggleStyle); m_topTracksToggle->setStyleSheet(kToggleStyle);
ttLayout->addWidget(m_topTracksToggle); ttLayout->addWidget(m_topTracksToggle);
@@ -212,7 +224,7 @@ ArtistView::ArtistView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
m_topTracks->setMaximumHeight(320); m_topTracks->setMaximumHeight(320);
ttLayout->addWidget(m_topTracks); ttLayout->addWidget(m_topTracks);
connect(m_topTracksToggle, &QToolButton::toggled, m_topTracks, &List::Tracks::setVisible); connect(m_topTracksToggle, &QPushButton::toggled, m_topTracks, &QWidget::setVisible);
connect(m_topTracks, &List::Tracks::playTrackRequested, this, &ArtistView::playTrackRequested); connect(m_topTracks, &List::Tracks::playTrackRequested, this, &ArtistView::playTrackRequested);
sectLayout->addWidget(m_topTracksSection); sectLayout->addWidget(m_topTracksSection);
@@ -242,10 +254,13 @@ ArtistView::ArtistView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
connect(m_favBtn, &QPushButton::clicked, this, [this] { connect(m_favBtn, &QPushButton::clicked, this, [this] {
if (m_artistId <= 0) return; if (m_artistId <= 0) return;
m_isFaved = !m_isFaved; m_isFaved = !m_isFaved;
if (m_isFaved) if (m_isFaved) {
m_backend->addFavArtist(m_artistId); m_backend->addFavArtist(m_artistId);
else m_favArtistIds.insert(m_artistId);
} else {
m_backend->removeFavArtist(m_artistId); m_backend->removeFavArtist(m_artistId);
m_favArtistIds.remove(m_artistId);
}
setFaved(m_isFaved); setFaved(m_isFaved);
}); });
@@ -256,7 +271,7 @@ ArtistView::ArtistView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
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: when a section scrolls to bottom, request next page // Load-more connections
auto connectLoadMore = [this](ArtistSection *sec) { auto connectLoadMore = [this](ArtistSection *sec) {
connect(sec, &ArtistSection::loadMoreRequested, this, connect(sec, &ArtistSection::loadMoreRequested, this,
[this](const QString &releaseType, int nextOffset) { [this](const QString &releaseType, int nextOffset) {
@@ -274,8 +289,7 @@ ArtistView::ArtistView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
void ArtistView::setArtist(const QJsonObject &artist) void ArtistView::setArtist(const QJsonObject &artist)
{ {
m_artistId = static_cast<qint64>(artist["id"].toDouble()); m_artistId = static_cast<qint64>(artist["id"].toDouble());
m_isFaved = false; setFaved(m_favArtistIds.contains(m_artistId));
setFaved(false);
m_nameLabel->setText(artist["name"].toObject()["display"].toString()); m_nameLabel->setText(artist["name"].toObject()["display"].toString());
@@ -298,7 +312,6 @@ void ArtistView::setArtist(const QJsonObject &artist)
} }
// Artist portrait: images.portrait.hash + format → CDN URL // Artist portrait: images.portrait.hash + format → CDN URL
// URL format: https://static.qobuz.com/images/artists/covers/large/{hash}.{format}
const QJsonObject portrait = artist["images"].toObject()["portrait"].toObject(); const QJsonObject portrait = artist["images"].toObject()["portrait"].toObject();
const QString hash = portrait["hash"].toString(); const QString hash = portrait["hash"].toString();
const QString format = portrait["format"].toString(); const QString format = portrait["format"].toString();
@@ -307,7 +320,6 @@ void ArtistView::setArtist(const QJsonObject &artist)
artUrl = QStringLiteral("https://static.qobuz.com/images/artists/covers/large/%1.%2") artUrl = QStringLiteral("https://static.qobuz.com/images/artists/covers/large/%1.%2")
.arg(hash, format.isEmpty() ? QStringLiteral("jpg") : format); .arg(hash, format.isEmpty() ? QStringLiteral("jpg") : format);
} else { } else {
// Fallback: try direct image field
const QJsonObject img = artist["image"].toObject(); const QJsonObject img = artist["image"].toObject();
artUrl = img["large"].toString(); artUrl = img["large"].toString();
if (artUrl.isEmpty()) artUrl = img["small"].toString(); if (artUrl.isEmpty()) artUrl = img["small"].toString();
@@ -325,9 +337,9 @@ void ArtistView::setArtist(const QJsonObject &artist)
m_topTracks->loadTracks(topTracks); m_topTracks->loadTracks(topTracks);
const int ttCount = topTracks.size(); const int ttCount = topTracks.size();
disconnect(m_topTracksToggle, &QToolButton::toggled, nullptr, nullptr); disconnect(m_topTracksToggle, &QPushButton::toggled, nullptr, nullptr);
connect(m_topTracksToggle, &QToolButton::toggled, m_topTracks, &List::Tracks::setVisible); connect(m_topTracksToggle, &QPushButton::toggled, m_topTracks, &QWidget::setVisible);
connect(m_topTracksToggle, &QToolButton::toggled, this, [this, ttCount](bool open) { connect(m_topTracksToggle, &QPushButton::toggled, this, [this, ttCount](bool open) {
const QString a = open ? QStringLiteral("") : QStringLiteral(""); const QString a = open ? QStringLiteral("") : QStringLiteral("");
m_topTracksToggle->setText(ttCount > 0 m_topTracksToggle->setText(ttCount > 0
? QStringLiteral("%1Popular Tracks (%2)").arg(a).arg(ttCount) ? QStringLiteral("%1Popular Tracks (%2)").arg(a).arg(ttCount)
@@ -340,7 +352,7 @@ void ArtistView::setArtist(const QJsonObject &artist)
: QStringLiteral("▼ Popular Tracks")); : QStringLiteral("▼ Popular Tracks"));
m_topTracksSection->setVisible(!topTracks.isEmpty()); m_topTracksSection->setVisible(!topTracks.isEmpty());
// Clear release sections — populated asynchronously via setReleases // 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({});
sec->setVisible(false); sec->setVisible(false);
@@ -365,6 +377,14 @@ void ArtistView::setReleases(const QString &releaseType, const QJsonArray &items
sec->setVisible(!sec->isEmpty()); sec->setVisible(!sec->isEmpty());
} }
void ArtistView::setFavArtistIds(const QSet<qint64> &ids)
{
m_favArtistIds = ids;
// Update current state if we're showing an artist
if (m_artistId > 0)
setFaved(ids.contains(m_artistId));
}
void ArtistView::setFaved(bool faved) void ArtistView::setFaved(bool faved)
{ {
m_isFaved = faved; m_isFaved = faved;

View File

@@ -8,12 +8,11 @@
#include <QWidget> #include <QWidget>
#include <QLabel> #include <QLabel>
#include <QTextEdit> #include <QTextEdit>
#include <QToolButton>
#include <QPushButton> #include <QPushButton>
#include <QNetworkAccessManager> #include <QNetworkAccessManager>
#include <QScrollBar>
#include <QJsonObject> #include <QJsonObject>
#include <QJsonArray> #include <QJsonArray>
#include <QSet>
class AlbumListView; class AlbumListView;
@@ -22,7 +21,6 @@ class ArtistSection : public QWidget
{ {
Q_OBJECT Q_OBJECT
public: public:
/// releaseType is the API string ("album", "epSingle", "live", "compilation").
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, bool hasMore = false);
@@ -31,23 +29,21 @@ public:
signals: signals:
void albumSelected(const QString &albumId); void albumSelected(const QString &albumId);
/// Emitted when the user scrolls to the bottom and has_more is true.
void loadMoreRequested(const QString &releaseType, int nextOffset); void loadMoreRequested(const QString &releaseType, int nextOffset);
private: private:
QString m_baseTitle; QString m_baseTitle;
QString m_releaseType; QString m_releaseType;
QToolButton *m_toggle = nullptr; QPushButton *m_toggle = nullptr;
AlbumListView *m_list = nullptr; AlbumListView *m_list = nullptr;
QPushButton *m_loadMoreBtn = nullptr;
bool m_hasMore = false; bool m_hasMore = false;
int m_loadedCount = 0; int m_loadedCount = 0;
void updateToggleText(); void updateToggleText();
}; };
/// Artist detail page: portrait header (mirrors TrackContextHeader), /// Artist detail page.
/// then collapsible sections for popular tracks, albums, EPs, live,
/// compilations, and other releases.
class ArtistView : public QWidget class ArtistView : public QWidget
{ {
Q_OBJECT Q_OBJECT
@@ -58,6 +54,7 @@ public:
void setArtist(const QJsonObject &artist); void setArtist(const QJsonObject &artist);
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);
signals: signals:
void albumSelected(const QString &albumId); void albumSelected(const QString &albumId);
@@ -77,10 +74,11 @@ private:
QNetworkAccessManager *m_nam = nullptr; QNetworkAccessManager *m_nam = nullptr;
QString m_currentArtUrl; QString m_currentArtUrl;
bool m_isFaved = false; bool m_isFaved = false;
QSet<qint64> m_favArtistIds;
// Popular tracks section // Popular tracks section
QWidget *m_topTracksSection = nullptr; QWidget *m_topTracksSection = nullptr;
QToolButton *m_topTracksToggle = nullptr; QPushButton *m_topTracksToggle = nullptr;
List::Tracks *m_topTracks = nullptr; List::Tracks *m_topTracks = nullptr;
// Release sections // Release sections

View File

@@ -112,3 +112,8 @@ void MainContent::updateArtistReleases(const QString &releaseType, const QJsonAr
{ {
m_artistView->setReleases(releaseType, items, hasMore, offset); m_artistView->setReleases(releaseType, items, hasMore, offset);
} }
void MainContent::setFavArtistIds(const QSet<qint64> &ids)
{
m_artistView->setFavArtistIds(ids);
}

View File

@@ -32,6 +32,7 @@ public:
void showFavArtists(const QJsonObject &result); void showFavArtists(const QJsonObject &result);
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);
signals: signals:
void albumRequested(const QString &albumId); void albumRequested(const QString &albumId);