From 963c9ad2324fd127fd41754e7b172f387730a9a6 Mon Sep 17 00:00:00 2001 From: joren Date: Wed, 25 Mar 2026 18:03:47 +0100 Subject: [PATCH] =?UTF-8?q?refactor:=20uniform=20artist=20page=20layout=20?= =?UTF-8?q?=E2=80=94=20header=20+=20collapsible=20sections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Artist header now mirrors TrackContextHeader: 120×120 portrait with rounded rect background, large bold name, compact scrollable bio, Play/Shuffle buttons in the same row (gold #FFB232 style) - Play/Shuffle moved out of Popular Tracks into the header area - Popular Tracks becomes a collapsible toggle section identical in appearance to Albums/EPs/Live/Compilations/Other - All section headers share kToggleStyle: left-aligned bold text, ▼/▶ arrow, subtle bottom border, hover highlight - Portrait loading tries hash-based CDN URL first, falls back to image.large / image.small direct URL fields Co-Authored-By: Claude Sonnet 4.6 --- src/view/artistview.cpp | 184 +++++++++++++++++++++++----------------- src/view/artistview.hpp | 12 ++- 2 files changed, 114 insertions(+), 82 deletions(-) diff --git a/src/view/artistview.cpp b/src/view/artistview.cpp index bfa77f5..2469650 100644 --- a/src/view/artistview.cpp +++ b/src/view/artistview.cpp @@ -14,6 +14,18 @@ #include #include +// Shared button style (mirrors TrackContextHeader) +static const QString kBtnBase = QStringLiteral( + "QPushButton { padding: 5px 16px; border-radius: 4px; font-weight: bold; }" +); + +// Shared section-toggle style (used by both ArtistSection and the Popular Tracks toggle) +static const QString kToggleStyle = QStringLiteral( + "QToolButton { text-align: left; font-weight: bold; padding: 4px 6px;" + " border: none; border-bottom: 1px solid #333; }" + "QToolButton:hover { background: #1e1e1e; }" +); + // --------------------------------------------------------------------------- // ArtistSection // --------------------------------------------------------------------------- @@ -31,11 +43,7 @@ ArtistSection::ArtistSection(const QString &title, QWidget *parent) m_toggle->setChecked(true); m_toggle->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); m_toggle->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - m_toggle->setStyleSheet(QStringLiteral( - "QToolButton { text-align: left; font-weight: bold; padding: 4px 6px;" - " border: none; border-bottom: 1px solid #333; }" - "QToolButton:hover { background: #1e1e1e; }" - )); + m_toggle->setStyleSheet(kToggleStyle); layout->addWidget(m_toggle); m_list = new AlbumListView(this); @@ -65,7 +73,6 @@ void ArtistSection::updateToggleText(int count) : arrow + m_baseTitle; m_toggle->setText(text); - // Keep arrow in sync when toggled disconnect(m_toggle, &QToolButton::toggled, nullptr, nullptr); connect(m_toggle, &QToolButton::toggled, m_list, &AlbumListView::setVisible); connect(m_toggle, &QToolButton::toggled, this, [this, count](bool open) { @@ -81,51 +88,71 @@ void ArtistSection::updateToggleText(int count) // ArtistView // --------------------------------------------------------------------------- -static const QString kBtnBase = QStringLiteral( - "QPushButton { padding: 5px 16px; border-radius: 4px; font-weight: bold; }" -); - ArtistView::ArtistView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent) : QWidget(parent) { auto *outerLayout = new QVBoxLayout(this); - outerLayout->setContentsMargins(8, 8, 8, 8); - outerLayout->setSpacing(6); + outerLayout->setContentsMargins(0, 0, 0, 0); + outerLayout->setSpacing(0); - // --- Artist header: portrait + name + bio --- - auto *headerRow = new QHBoxLayout; - headerRow->setSpacing(12); + // --- Artist header (same structure as TrackContextHeader) --- + auto *header = new QWidget(this); + header->setFixedHeight(148); + auto *hlay = new QHBoxLayout(header); + hlay->setContentsMargins(12, 8, 12, 8); + hlay->setSpacing(14); - m_artLabel = new QLabel(this); - m_artLabel->setFixedSize(100, 100); + m_artLabel = new QLabel(header); + m_artLabel->setFixedSize(120, 120); m_artLabel->setScaledContents(true); m_artLabel->setAlignment(Qt::AlignCenter); - m_artLabel->setStyleSheet(QStringLiteral("background: #1a1a1a; border-radius: 50px;")); - headerRow->addWidget(m_artLabel, 0, Qt::AlignTop); + m_artLabel->setStyleSheet(QStringLiteral("background: #1a1a1a; border-radius: 4px;")); + hlay->addWidget(m_artLabel, 0, Qt::AlignVCenter); - auto *headerInfo = new QWidget(this); - auto *headerInfoLayout = new QVBoxLayout(headerInfo); - headerInfoLayout->setContentsMargins(0, 0, 0, 0); - headerInfoLayout->setSpacing(4); + auto *info = new QWidget(header); + auto *vlay = new QVBoxLayout(info); + vlay->setContentsMargins(0, 0, 0, 0); + vlay->setSpacing(4); - m_nameLabel = new QLabel(headerInfo); + m_nameLabel = new QLabel(info); QFont f = m_nameLabel->font(); - f.setPointSize(f.pointSize() + 4); + f.setPointSize(f.pointSize() + 5); f.setBold(true); m_nameLabel->setFont(f); - headerInfoLayout->addWidget(m_nameLabel); + vlay->addWidget(m_nameLabel); - m_bioEdit = new QTextEdit(headerInfo); + m_bioEdit = new QTextEdit(info); m_bioEdit->setReadOnly(true); m_bioEdit->setFrameShape(QFrame::NoFrame); - m_bioEdit->setMaximumHeight(80); + m_bioEdit->setMaximumHeight(56); m_bioEdit->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); m_bioEdit->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - headerInfoLayout->addWidget(m_bioEdit); + vlay->addWidget(m_bioEdit); - headerRow->addWidget(headerInfo, 1); - outerLayout->addLayout(headerRow); + auto *btnRow = new QHBoxLayout; + btnRow->setSpacing(8); + btnRow->setContentsMargins(0, 4, 0, 0); + m_playBtn = new QPushButton(tr("▶ Play"), info); + m_playBtn->setStyleSheet(kBtnBase + + QStringLiteral("QPushButton { background: #FFB232; color: #000; }" + "QPushButton:pressed { background: #e09e28; }")); + + m_shuffleBtn = new QPushButton(tr("⇄ Shuffle"), info); + m_shuffleBtn->setStyleSheet(kBtnBase + + QStringLiteral("QPushButton { background: #2a2a2a; color: #FFB232; border: 1px solid #FFB232; }" + "QPushButton:pressed { background: #333; }")); + + btnRow->addWidget(m_playBtn); + btnRow->addWidget(m_shuffleBtn); + btnRow->addStretch(); + vlay->addLayout(btnRow); + vlay->addStretch(1); + + hlay->addWidget(info, 1); + outerLayout->addWidget(header); + + // --- Network manager for portrait --- m_nam = new QNetworkAccessManager(this); QObject::connect(m_nam, &QNetworkAccessManager::finished, this, [this](QNetworkReply *reply) { @@ -136,7 +163,7 @@ ArtistView::ArtistView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent) m_artLabel->setPixmap(pix); }); - // Scrollable sections area + // --- Scrollable sections area --- auto *scroll = new QScrollArea(this); scroll->setWidgetResizable(true); scroll->setFrameShape(QFrame::NoFrame); @@ -144,53 +171,32 @@ ArtistView::ArtistView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent) auto *content = new QWidget(scroll); auto *sectLayout = new QVBoxLayout(content); sectLayout->setContentsMargins(0, 0, 0, 0); - sectLayout->setSpacing(8); + sectLayout->setSpacing(0); - // --- Top Tracks section --- + // Popular Tracks — same collapsible header style as ArtistSection m_topTracksSection = new QWidget(content); auto *ttLayout = new QVBoxLayout(m_topTracksSection); ttLayout->setContentsMargins(0, 0, 0, 0); ttLayout->setSpacing(0); - // Header row: label + play + shuffle - auto *ttHeader = new QWidget(m_topTracksSection); - auto *ttHeaderLayout = new QHBoxLayout(ttHeader); - ttHeaderLayout->setContentsMargins(6, 4, 6, 4); - ttHeaderLayout->setSpacing(6); - - auto *ttLabel = new QLabel(tr("Popular Tracks"), ttHeader); - QFont ttFont = ttLabel->font(); - ttFont.setBold(true); - ttLabel->setFont(ttFont); - ttHeaderLayout->addWidget(ttLabel, 1); - - auto *playBtn = new QPushButton(tr("▶ Play"), ttHeader); - playBtn->setStyleSheet(kBtnBase + - QStringLiteral("QPushButton { background: #FFB232; color: #000; }" - "QPushButton:pressed { background: #e09e28; }")); - - auto *shuffleBtn = new QPushButton(tr("⇄ Shuffle"), ttHeader); - shuffleBtn->setStyleSheet(kBtnBase + - QStringLiteral("QPushButton { background: #2a2a2a; color: #FFB232; border: 1px solid #FFB232; }" - "QPushButton:pressed { background: #333; }")); - - ttHeaderLayout->addWidget(playBtn); - ttHeaderLayout->addWidget(shuffleBtn); - - ttLayout->addWidget(ttHeader); + m_topTracksToggle = new QToolButton(m_topTracksSection); + m_topTracksToggle->setCheckable(true); + m_topTracksToggle->setChecked(true); + m_topTracksToggle->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + m_topTracksToggle->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + m_topTracksToggle->setStyleSheet(kToggleStyle); + ttLayout->addWidget(m_topTracksToggle); m_topTracks = new List::Tracks(backend, queue, m_topTracksSection); - // Limit visible height so it doesn't swamp the page m_topTracks->setMaximumHeight(320); ttLayout->addWidget(m_topTracks); - connect(playBtn, &QPushButton::clicked, m_topTracks, [this] { m_topTracks->playAll(false); }); - connect(shuffleBtn, &QPushButton::clicked, m_topTracks, [this] { m_topTracks->playAll(true); }); + connect(m_topTracksToggle, &QToolButton::toggled, m_topTracks, &List::Tracks::setVisible); connect(m_topTracks, &List::Tracks::playTrackRequested, this, &ArtistView::playTrackRequested); sectLayout->addWidget(m_topTracksSection); - // --- Album sections --- + // Release sections m_secAlbums = new ArtistSection(tr("Albums"), content); m_secEps = new ArtistSection(tr("Singles & EPs"), content); m_secLive = new ArtistSection(tr("Live"), content); @@ -207,6 +213,9 @@ ArtistView::ArtistView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent) scroll->setWidget(content); outerLayout->addWidget(scroll, 1); + connect(m_playBtn, &QPushButton::clicked, m_topTracks, [this] { m_topTracks->playAll(false); }); + connect(m_shuffleBtn, &QPushButton::clicked, m_topTracks, [this] { m_topTracks->playAll(true); }); + connect(m_secAlbums, &ArtistSection::albumSelected, this, &ArtistView::albumSelected); connect(m_secEps, &ArtistSection::albumSelected, this, &ArtistView::albumSelected); connect(m_secLive, &ArtistSection::albumSelected, this, &ArtistView::albumSelected); @@ -216,10 +225,9 @@ ArtistView::ArtistView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent) void ArtistView::setArtist(const QJsonObject &artist) { - // artist/page: name is {"display": "..."} m_nameLabel->setText(artist["name"].toObject()["display"].toString()); - // biography.content is HTML — strip tags for safe plain-text display + // Biography: strip HTML tags for plain-text display const QString bioHtml = artist["biography"].toObject()["content"].toString(); if (!bioHtml.isEmpty()) { QString plain = bioHtml; @@ -237,28 +245,48 @@ void ArtistView::setArtist(const QJsonObject &artist) m_bioEdit->setVisible(false); } - // Artist portrait: images.portrait.hash → CDN URL + // Artist portrait: try hash-based CDN URL, then direct image fields const QString hash = artist["images"].toObject()["portrait"].toObject()["hash"].toString(); + QString artUrl; if (!hash.isEmpty() && hash.length() >= 4) { - const QString p1 = hash.right(2); - const QString p2 = hash.mid(hash.length() - 4, 2); - const QString url = QStringLiteral("https://static.qobuz.com/images/artists/%1/%2/%3_600.jpg") - .arg(p1, p2, hash); - if (url != m_currentArtUrl) { - m_currentArtUrl = url; - m_nam->get(QNetworkRequest(QUrl(url))); - } + const QString p1 = hash.right(2); + const QString p2 = hash.mid(hash.length() - 4, 2); + artUrl = QStringLiteral("https://static.qobuz.com/images/artists/%1/%2/%3_600.jpg") + .arg(p1, p2, hash); } else { + const QJsonObject img = artist["image"].toObject(); + 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))); + } else if (artUrl.isEmpty()) { m_artLabel->setPixmap(QPixmap()); } - // top_tracks is a flat array in the artist/page response + // Top tracks (flat array in artist/page response) const QJsonArray topTracks = artist["top_tracks"].toArray(); m_topTracks->loadTracks(topTracks); + + // Update Popular Tracks section toggle text and arrow + const int ttCount = topTracks.size(); + disconnect(m_topTracksToggle, &QToolButton::toggled, nullptr, nullptr); + connect(m_topTracksToggle, &QToolButton::toggled, m_topTracks, &List::Tracks::setVisible); + connect(m_topTracksToggle, &QToolButton::toggled, this, [this, ttCount](bool open) { + const QString a = open ? QStringLiteral("▼ ") : QStringLiteral("▶ "); + m_topTracksToggle->setText(ttCount > 0 + ? QStringLiteral("%1Popular Tracks (%2)").arg(a).arg(ttCount) + : a + tr("Popular Tracks")); + }); + m_topTracksToggle->setChecked(true); + m_topTracks->setVisible(true); + m_topTracksToggle->setText(ttCount > 0 + ? QStringLiteral("▼ Popular Tracks (%1)").arg(ttCount) + : QStringLiteral("▼ Popular Tracks")); m_topTracksSection->setVisible(!topTracks.isEmpty()); - // Release sections are populated asynchronously via setReleases(). - // Clear them now so stale data from a previous artist isn't shown. + // Clear release sections (populated asynchronously via setReleases) m_secAlbums->setAlbums({}); m_secEps->setAlbums({}); m_secLive->setAlbums({}); diff --git a/src/view/artistview.hpp b/src/view/artistview.hpp index 205c404..0291218 100644 --- a/src/view/artistview.hpp +++ b/src/view/artistview.hpp @@ -37,8 +37,8 @@ private: void updateToggleText(int count); }; -/// Artist detail page: name, biography, top tracks, and albums split into -/// collapsible sections (Albums / EPs & Singles / Live / Compilations). +/// Artist detail page: portrait header (matching album view), then collapsible +/// sections for popular tracks, albums, EPs, live, compilations, and other. class ArtistView : public QWidget { Q_OBJECT @@ -55,15 +55,19 @@ signals: void playTrackRequested(qint64 trackId); private: + // Header QLabel *m_artLabel = nullptr; QLabel *m_nameLabel = nullptr; QTextEdit *m_bioEdit = nullptr; + QPushButton *m_playBtn = nullptr; + QPushButton *m_shuffleBtn = nullptr; QNetworkAccessManager *m_nam = nullptr; QString m_currentArtUrl; // Top tracks section - QWidget *m_topTracksSection = nullptr; - List::Tracks *m_topTracks = nullptr; + QWidget *m_topTracksSection = nullptr; + QToolButton *m_topTracksToggle = nullptr; + List::Tracks *m_topTracks = nullptr; ArtistSection *m_secAlbums = nullptr; ArtistSection *m_secEps = nullptr;