feat: artist sections, fav indicator, art scaling fix, volume popup fix

- Artist profile: collapsible Albums / EPs & Singles / Other sections
  keyed on release_type; fetches up to 200 albums per artist
- Favorites: starred icon on favorited tracks, context menu shows
  Add or Remove (not both); IDs cached when fav tracks are loaded
- Shuffle button: one-time shuffle via shuffleNow() without touching
  global shuffle flag, so double-click still plays in order
- Now-playing art: replaced setFixedHeight hack with ArtWidget that
  overrides hasHeightForWidth() — scales smoothly up and down, no min-size
- Volume popup: replaced QMenu (laggy, broken drag) with Qt::Popup QFrame;
  appears below button; fixed size locked at 100% label width

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
joren
2026-03-24 17:56:47 +01:00
parent 75429faffe
commit 56473cae6f
15 changed files with 370 additions and 109 deletions

View File

@@ -25,6 +25,7 @@ target_sources(qobuz-qt PRIVATE
view/albumlistview.hpp
view/artistlistview.hpp
view/artistview.hpp
view/artistview.cpp
view/sidepanel/view.hpp
view/sidepanel/view.cpp

View File

@@ -84,12 +84,30 @@ void Tracks::setPlayingTrackId(qint64 id)
m_model->setPlayingId(id);
}
void Tracks::setFavTrackIds(const QSet<qint64> &ids)
{
m_model->setFavIds(ids);
}
void Tracks::addFavTrackId(qint64 id)
{
m_model->addFavId(id);
}
void Tracks::removeFavTrackId(qint64 id)
{
m_model->removeFavId(id);
}
void Tracks::playAll(bool shuffle)
{
const QJsonArray tracks = m_model->currentTracksJson();
if (tracks.isEmpty()) return;
m_queue->setShuffle(shuffle);
m_queue->setContext(tracks, 0);
// Shuffle once without touching the global shuffle flag — so a subsequent
// double-click on a track plays in normal order (unless global shuffle is on).
if (shuffle && !m_queue->shuffleEnabled())
m_queue->shuffleNow();
const qint64 firstId = static_cast<qint64>(m_queue->current()["id"].toDouble());
if (firstId > 0)
emit playTrackRequested(firstId);
@@ -119,8 +137,21 @@ void Tracks::onContextMenu(const QPoint &pos)
auto *playNext = menu.addAction(QIcon(":/res/icons/media-skip-forward.svg"), tr("Play next"));
auto *addQueue = menu.addAction(QIcon(":/res/icons/media-playlist-append.svg"), tr("Add to queue"));
menu.addSeparator();
auto *addFav = menu.addAction(QIcon(":/res/icons/starred-symbolic.svg"), tr("Add to favorites"));
auto *remFav = menu.addAction(QIcon(":/res/icons/non-starred-symbolic.svg"), tr("Remove from favorites"));
const bool isFav = m_model->isFav(id);
if (isFav) {
auto *remFav = menu.addAction(QIcon(":/res/icons/non-starred-symbolic.svg"), tr("Remove from favorites"));
connect(remFav, &QAction::triggered, this, [this, id] {
m_backend->removeFavTrack(id);
m_model->removeFavId(id);
});
} else {
auto *addFav = menu.addAction(QIcon(":/res/icons/starred-symbolic.svg"), tr("Add to favorites"));
connect(addFav, &QAction::triggered, this, [this, id] {
m_backend->addFavTrack(id);
m_model->addFavId(id);
});
}
const int row = index.row();
connect(playNow, &QAction::triggered, this, [this, id, row] {
@@ -133,12 +164,6 @@ void Tracks::onContextMenu(const QPoint &pos)
connect(addQueue, &QAction::triggered, this, [this, trackJson] {
m_queue->addToQueue(trackJson);
});
connect(addFav, &QAction::triggered, this, [this, id] {
m_backend->addFavTrack(id);
});
connect(remFav, &QAction::triggered, this, [this, id] {
m_backend->removeFavTrack(id);
});
// Open album
const QString albumId = m_model->trackAt(index.row()).albumId;

View File

@@ -10,6 +10,7 @@
#include <QVector>
#include <QPair>
#include <QString>
#include <QSet>
namespace List
{
@@ -28,6 +29,11 @@ namespace List
/// Called when the backend fires EV_TRACK_CHANGED so the playing row is highlighted.
void setPlayingTrackId(qint64 id);
/// Populate favorite track IDs so the star indicator and context menu reflect fav status.
void setFavTrackIds(const QSet<qint64> &ids);
void addFavTrackId(qint64 id);
void removeFavTrackId(qint64 id);
/// Start playing all tracks in the current view from the beginning.
/// If shuffle is true, enables shuffle mode before starting.
void playAll(bool shuffle = false);

View File

@@ -283,6 +283,15 @@ void MainWindow::onTrackChanged(const QJsonObject &track)
void MainWindow::onFavTracksLoaded(const QJsonObject &result)
{
// Cache fav IDs so the star indicator and context menu stay in sync
QSet<qint64> ids;
const QJsonArray items = result["items"].toArray();
for (const QJsonValue &v : items) {
const qint64 id = static_cast<qint64>(v.toObject()["id"].toDouble());
if (id > 0) ids.insert(id);
}
m_content->tracksList()->setFavTrackIds(ids);
m_content->showFavTracks(result);
statusBar()->showMessage(
tr("%1 favorite tracks").arg(result["total"].toInt()), 4000);

View File

@@ -13,7 +13,9 @@
#include <QMainWindow>
#include <QDockWidget>
#include <QJsonObject>
#include <QJsonArray>
#include <QVector>
#include <QSet>
#include <QPair>
#include <QString>

View File

@@ -77,6 +77,36 @@ void TrackListModel::removeTrack(int row)
endRemoveRows();
}
void TrackListModel::setFavIds(const QSet<qint64> &ids)
{
m_favIds = ids;
if (!m_tracks.isEmpty())
emit dataChanged(index(0, ColTitle), index(rowCount() - 1, ColTitle),
{Qt::DecorationRole});
}
void TrackListModel::addFavId(qint64 id)
{
m_favIds.insert(id);
for (int r = 0; r < m_tracks.size(); ++r) {
if (m_tracks[r].id == id) {
const auto idx = index(r, ColTitle);
emit dataChanged(idx, idx, {Qt::DecorationRole});
}
}
}
void TrackListModel::removeFavId(qint64 id)
{
m_favIds.remove(id);
for (int r = 0; r < m_tracks.size(); ++r) {
if (m_tracks[r].id == id) {
const auto idx = index(r, ColTitle);
emit dataChanged(idx, idx, {Qt::DecorationRole});
}
}
}
void TrackListModel::setPlayingId(qint64 id)
{
m_playingId = id;
@@ -128,6 +158,10 @@ QVariant TrackListModel::data(const QModelIndex &index, int role) const
return QIcon(QStringLiteral(":/res/icons/media-track-show-active.svg"));
}
if (role == Qt::DecorationRole && index.column() == ColTitle && m_favIds.contains(t.id)) {
return QIcon(QStringLiteral(":/res/icons/starred-symbolic.svg"));
}
if (role == TrackIdRole) return t.id;
if (role == TrackJsonRole) return t.raw;
if (role == HiResRole) return t.hiRes;

View File

@@ -4,6 +4,7 @@
#include <QJsonArray>
#include <QJsonObject>
#include <QVector>
#include <QSet>
#include <QFont>
struct TrackItem {
@@ -52,6 +53,11 @@ public:
void setPlayingId(qint64 id);
qint64 playingId() const { return m_playingId; }
void setFavIds(const QSet<qint64> &ids);
void addFavId(qint64 id);
void removeFavId(qint64 id);
bool isFav(qint64 id) const { return m_favIds.contains(id); }
/// Optimistically remove a row (e.g. after deleting from playlist).
void removeTrack(int row);
@@ -81,6 +87,7 @@ signals:
private:
QVector<TrackItem> m_tracks;
QSet<qint64> m_favIds;
qint64 m_playingId = 0;
int m_sortColumn = -1;
Qt::SortOrder m_sortOrder = Qt::AscendingOrder;

View File

@@ -107,6 +107,14 @@ public:
emit queueChanged();
}
/// Shuffle the current queue once without changing the global shuffle flag.
void shuffleNow()
{
if (m_queue.isEmpty()) return;
shuffleQueue(m_index);
emit queueChanged();
}
// ---- Play-next prepend queue (like "Add to queue" ----
void addToQueue(const QJsonObject &track)

153
src/view/artistview.cpp Normal file
View File

@@ -0,0 +1,153 @@
#include "artistview.hpp"
#include "albumlistview.hpp"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QScrollArea>
#include <QFont>
#include <QJsonValue>
// ---------------------------------------------------------------------------
// ArtistSection
// ---------------------------------------------------------------------------
ArtistSection::ArtistSection(const QString &title, QWidget *parent)
: QWidget(parent)
, m_baseTitle(title)
{
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(0);
m_toggle = new QToolButton(this);
m_toggle->setCheckable(true);
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; }"
));
updateToggleText(0);
layout->addWidget(m_toggle);
m_list = new AlbumListView(this);
layout->addWidget(m_list);
connect(m_toggle, &QToolButton::toggled, m_list, &AlbumListView::setVisible);
connect(m_list, &AlbumListView::albumSelected, this, &ArtistSection::albumSelected);
}
void ArtistSection::setAlbums(const QJsonArray &albums)
{
m_list->setAlbums(albums);
updateToggleText(albums.size());
}
bool ArtistSection::isEmpty() const
{
return m_list->topLevelItemCount() == 0;
}
void ArtistSection::updateToggleText(int count)
{
const QString arrow = m_toggle->isChecked() ? QStringLiteral("") : QStringLiteral("");
const QString text = count > 0
? QStringLiteral("%1%2 (%3)").arg(arrow, m_baseTitle).arg(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) {
const QString a = open ? QStringLiteral("") : QStringLiteral("");
const QString t = count > 0
? QStringLiteral("%1%2 (%3)").arg(a, m_baseTitle).arg(count)
: a + m_baseTitle;
m_toggle->setText(t);
});
}
// ---------------------------------------------------------------------------
// ArtistView
// ---------------------------------------------------------------------------
ArtistView::ArtistView(QWidget *parent)
: QWidget(parent)
{
auto *outerLayout = new QVBoxLayout(this);
outerLayout->setContentsMargins(8, 8, 8, 8);
outerLayout->setSpacing(6);
m_nameLabel = new QLabel(this);
QFont f = m_nameLabel->font();
f.setPointSize(f.pointSize() + 4);
f.setBold(true);
m_nameLabel->setFont(f);
outerLayout->addWidget(m_nameLabel);
m_bioLabel = new QLabel(this);
m_bioLabel->setWordWrap(true);
m_bioLabel->setAlignment(Qt::AlignTop | Qt::AlignLeft);
m_bioLabel->setMaximumHeight(80);
outerLayout->addWidget(m_bioLabel);
// Scrollable sections area
auto *scroll = new QScrollArea(this);
scroll->setWidgetResizable(true);
scroll->setFrameShape(QFrame::NoFrame);
auto *content = new QWidget(scroll);
auto *sectLayout = new QVBoxLayout(content);
sectLayout->setContentsMargins(0, 0, 0, 0);
sectLayout->setSpacing(8);
m_secAlbums = new ArtistSection(tr("Albums"), content);
m_secEps = new ArtistSection(tr("EPs & Singles"), content);
m_secOther = new ArtistSection(tr("Other"), content);
sectLayout->addWidget(m_secAlbums);
sectLayout->addWidget(m_secEps);
sectLayout->addWidget(m_secOther);
sectLayout->addStretch();
scroll->setWidget(content);
outerLayout->addWidget(scroll, 1);
connect(m_secAlbums, &ArtistSection::albumSelected, this, &ArtistView::albumSelected);
connect(m_secEps, &ArtistSection::albumSelected, this, &ArtistView::albumSelected);
connect(m_secOther, &ArtistSection::albumSelected, this, &ArtistView::albumSelected);
}
void ArtistView::setArtist(const QJsonObject &artist)
{
m_nameLabel->setText(artist["name"].toString());
const QString summary = artist["biography"].toObject()["summary"].toString();
m_bioLabel->setText(summary);
m_bioLabel->setVisible(!summary.isEmpty());
const QJsonArray allAlbums = artist["albums"].toObject()["items"].toArray();
QJsonArray albums, eps, other;
for (const QJsonValue &v : allAlbums) {
const QJsonObject a = v.toObject();
const QString rt = a["release_type"].toString();
if (rt == QStringLiteral("album"))
albums.append(a);
else if (rt == QStringLiteral("epSingle"))
eps.append(a);
else
other.append(a);
}
m_secAlbums->setAlbums(albums);
m_secEps->setAlbums(eps);
m_secOther->setAlbums(other);
m_secAlbums->setVisible(!m_secAlbums->isEmpty());
m_secEps->setVisible(!m_secEps->isEmpty());
m_secOther->setVisible(!m_secOther->isEmpty());
}

View File

@@ -3,62 +3,52 @@
#include "albumlistview.hpp"
#include <QWidget>
#include <QVBoxLayout>
#include <QLabel>
#include <QFont>
#include <QToolButton>
#include <QJsonObject>
#include <QJsonArray>
/// Artist detail page: name, biography summary, and their album list.
class ArtistView : public QWidget
class AlbumListView;
/// One collapsible section (e.g. "Albums", "EPs & Singles") inside ArtistView.
class ArtistSection : public QWidget
{
Q_OBJECT
public:
explicit ArtistView(QWidget *parent = nullptr) : QWidget(parent)
{
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(8, 8, 8, 8);
layout->setSpacing(6);
explicit ArtistSection(const QString &title, QWidget *parent = nullptr);
m_nameLabel = new QLabel(this);
QFont f = m_nameLabel->font();
f.setPointSize(f.pointSize() + 4);
f.setBold(true);
m_nameLabel->setFont(f);
m_bioLabel = new QLabel(this);
m_bioLabel->setWordWrap(true);
m_bioLabel->setAlignment(Qt::AlignTop | Qt::AlignLeft);
m_bioLabel->setMaximumHeight(80);
m_albums = new AlbumListView(this);
layout->addWidget(m_nameLabel);
layout->addWidget(m_bioLabel);
layout->addWidget(m_albums, 1);
connect(m_albums, &AlbumListView::albumSelected,
this, &ArtistView::albumSelected);
}
void setArtist(const QJsonObject &artist)
{
m_nameLabel->setText(artist["name"].toString());
const QString summary = artist["biography"].toObject()["summary"].toString();
m_bioLabel->setText(summary);
m_bioLabel->setVisible(!summary.isEmpty());
const QJsonArray albums = artist["albums"].toObject()["items"].toArray();
m_albums->setAlbums(albums);
}
void setAlbums(const QJsonArray &albums);
bool isEmpty() const;
signals:
void albumSelected(const QString &albumId);
private:
QLabel *m_nameLabel = nullptr;
QLabel *m_bioLabel = nullptr;
AlbumListView *m_albums = nullptr;
QString m_baseTitle;
QToolButton *m_toggle = nullptr;
AlbumListView *m_list = nullptr;
void updateToggleText(int count);
};
/// Artist detail page: name, biography, and albums split into collapsible sections
/// (Albums / EPs & Singles / Other) keyed on the release_type field.
class ArtistView : public QWidget
{
Q_OBJECT
public:
explicit ArtistView(QWidget *parent = nullptr);
void setArtist(const QJsonObject &artist);
signals:
void albumSelected(const QString &albumId);
private:
QLabel *m_nameLabel = nullptr;
QLabel *m_bioLabel = nullptr;
ArtistSection *m_secAlbums = nullptr;
ArtistSection *m_secEps = nullptr;
ArtistSection *m_secOther = nullptr;
};

View File

@@ -22,11 +22,7 @@ View::View(QobuzBackend *backend, QWidget *parent)
layout->setContentsMargins(8, 8, 8, 8);
layout->setSpacing(6);
m_albumArt = new QLabel(container);
m_albumArt->setAlignment(Qt::AlignCenter);
m_albumArt->setStyleSheet(QStringLiteral(
"background: #1a1a1a; border-radius: 4px;"));
m_albumArt->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
m_albumArt = new ArtWidget(container);
layout->addWidget(m_albumArt);
m_title = new QLabel(tr("Not playing"), container);
@@ -45,7 +41,6 @@ View::View(QobuzBackend *backend, QWidget *parent)
layout->addStretch();
setWidget(container);
setMinimumWidth(160);
connect(m_backend, &QobuzBackend::trackChanged, this, &View::onTrackChanged);
}
@@ -60,7 +55,6 @@ void View::onTrackChanged(const QJsonObject &track)
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())
@@ -77,26 +71,9 @@ void View::onArtReady(QNetworkReply *reply)
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError)
return;
if (m_artPixmap.loadFromData(reply->readAll()))
scaleArtToWidth();
}
void View::resizeEvent(QResizeEvent *event)
{
QDockWidget::resizeEvent(event);
if (m_artPixmap.isNull()) return;
// Use the new dock width from the event so we don't lag behind the layout
const int side = qMax(32, event->size().width() - 16);
m_albumArt->setFixedHeight(side);
m_albumArt->setPixmap(m_artPixmap.scaled(side, side, Qt::KeepAspectRatio, Qt::SmoothTransformation));
}
void View::scaleArtToWidth()
{
if (m_artPixmap.isNull()) return;
const int side = qMax(32, width() - 16);
m_albumArt->setFixedHeight(side);
m_albumArt->setPixmap(m_artPixmap.scaled(side, side, Qt::KeepAspectRatio, Qt::SmoothTransformation));
QPixmap pix;
if (pix.loadFromData(reply->readAll()))
m_albumArt->setPixmap(pix);
}
} // namespace Context

View File

@@ -3,15 +3,49 @@
#include "../../backend/qobuzbackend.hpp"
#include <QDockWidget>
#include <QWidget>
#include <QLabel>
#include <QPixmap>
#include <QResizeEvent>
#include <QPainter>
#include <QPaintEvent>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QJsonObject>
namespace Context
{
/// Square art widget: always as wide as its parent allows, height follows width.
class ArtWidget : public QWidget
{
public:
explicit ArtWidget(QWidget *parent = nullptr) : QWidget(parent)
{
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
}
void setPixmap(const QPixmap &px) { m_pix = px; update(); }
bool hasHeightForWidth() const override { return true; }
int heightForWidth(int w) const override { return w; }
protected:
void paintEvent(QPaintEvent *) override
{
QPainter p(this);
if (m_pix.isNull()) {
p.fillRect(rect(), QColor(0x1a, 0x1a, 0x1a));
return;
}
const QPixmap scaled = m_pix.scaled(size(), Qt::KeepAspectRatio, Qt::SmoothTransformation);
p.fillRect(rect(), QColor(0x1a, 0x1a, 0x1a));
p.drawPixmap((width() - scaled.width()) / 2,
(height() - scaled.height()) / 2,
scaled);
}
private:
QPixmap m_pix;
};
class View : public QDockWidget
{
Q_OBJECT
@@ -23,18 +57,12 @@ namespace Context
void onTrackChanged(const QJsonObject &track);
void onArtReady(QNetworkReply *reply);
protected:
void resizeEvent(QResizeEvent *event) override;
private:
void scaleArtToWidth();
QobuzBackend *m_backend = nullptr;
QLabel *m_albumArt = nullptr;
ArtWidget *m_albumArt = nullptr;
QLabel *m_title = nullptr;
QLabel *m_artist = nullptr;
QNetworkAccessManager *m_nam = nullptr;
QString m_currentArtUrl;
QPixmap m_artPixmap;
};
} // namespace Context

View File

@@ -4,10 +4,9 @@
#include "../util/icon.hpp"
#include <QToolButton>
#include <QWidgetAction>
#include <QMenu>
#include <QLabel>
#include <QFrame>
#include <QVBoxLayout>
#include <QLabel>
/// A toolbar button that shows a volume slider popup when clicked.
class VolumeButton : public QToolButton
@@ -17,33 +16,36 @@ class VolumeButton : public QToolButton
public:
explicit VolumeButton(QWidget *parent = nullptr) : QToolButton(parent)
{
setPopupMode(QToolButton::InstantPopup);
setIcon(Icon::volumeHigh());
auto *menu = new QMenu(this);
auto *widget = new QWidget(menu);
widget->setMinimumWidth(72);
auto *layout = new QVBoxLayout(widget);
layout->setContentsMargins(6, 6, 6, 6);
// Qt::Popup closes automatically when the user clicks outside.
m_popup = new QFrame(this, Qt::Popup);
m_popup->setFrameShape(QFrame::StyledPanel);
m_popup->setFrameShadow(QFrame::Raised);
m_label = new QLabel("80%", widget);
auto *layout = new QVBoxLayout(m_popup);
layout->setContentsMargins(10, 10, 10, 10);
layout->setSpacing(6);
m_label = new QLabel(QStringLiteral("80%"), m_popup);
m_label->setAlignment(Qt::AlignCenter);
layout->addWidget(m_label);
m_slider = new ClickableSlider(Qt::Vertical, widget);
m_slider = new ClickableSlider(Qt::Vertical, m_popup);
m_slider->setRange(0, 100);
m_slider->setValue(80);
m_slider->setFixedHeight(120);
layout->addWidget(m_slider, 0, Qt::AlignHCenter);
layout->addWidget(m_label);
layout->addWidget(m_slider);
auto *action = new QWidgetAction(menu);
action->setDefaultWidget(widget);
menu->addAction(action);
setMenu(menu);
// Size the popup at its maximum (label = "100%") and lock it
m_label->setText(QStringLiteral("100%"));
m_popup->adjustSize();
m_popup->setFixedSize(m_popup->sizeHint());
m_label->setText(QStringLiteral("80%"));
connect(this, &QToolButton::clicked, this, &VolumeButton::togglePopup);
connect(m_slider, &QSlider::valueChanged, this, [this](int v) {
m_label->setText(QString::number(v) + "%");
m_label->setText(QString::number(v) + QStringLiteral("%"));
updateIcon(v);
emit volumeChanged(v);
});
@@ -56,14 +58,31 @@ public:
m_slider->blockSignals(true);
m_slider->setValue(v);
m_slider->blockSignals(false);
m_label->setText(QString::number(v) + "%");
m_label->setText(QString::number(v) + QStringLiteral("%"));
updateIcon(v);
}
signals:
void volumeChanged(int volume);
private slots:
void togglePopup()
{
if (m_popup->isVisible()) {
m_popup->hide();
return;
}
// Centre popup horizontally over button, place below it
const QPoint global = mapToGlobal(
QPoint(width() / 2 - m_popup->width() / 2,
height() + 4));
m_popup->move(global);
m_popup->show();
m_popup->raise();
}
private:
QFrame *m_popup = nullptr;
ClickableSlider *m_slider = nullptr;
QLabel *m_label = nullptr;