refactor: uniform artist page layout — header + collapsible sections
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,18 @@
|
||||
#include <QJsonValue>
|
||||
#include <QRegularExpression>
|
||||
|
||||
// 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({});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user