feat: pagination, back/forward nav, context menu, artist fav, image fix

**Artist portrait**
- Fix CDN URL: images.portrait.{hash,format} →
  https://static.qobuz.com/images/artists/covers/large/{hash}.{format}

**Section alignment**
- Qt::ToolButtonTextOnly on all section toggles so text is truly left-aligned

**Auth 401 race condition**
- qobuz_backend_set_token now uses blocking_lock() instead of spawning an
  async task, guaranteeing the token is set before any subsequent API call

**Pagination (infinite scroll)**
- Release sections load 50 at a time (was 500)
- ArtistSection tracks has_more + loaded count; scrolling to the bottom
  emits loadMoreRequested → ArtistView calls getArtistReleases(offset=N)
- AlbumListView gains addAlbums() for append; setReleases routes to
  setAlbums (offset=0) or appendAlbums (offset>0)

**Back/Forward navigation**
- MainToolBar exposes Back/Forward QActions (go-previous/go-next icons)
- MainWindow keeps a NavPage vector + index; pushNav() on every album/artist
  navigation; goBack/goForward re-navigate without pushing history

**Context menu on now-playing label**
- Right-click on track label in toolbar → "Go to Album" / "Go to Artist"
- MainToolBar stores current track; emits albumRequested/artistRequested
  signals wired to MainWindow's existing handlers

**Artist favourites button**
- ♡ Favourite / ♥ Favourited toggle in artist header
- Calls new addFavArtist / removeFavArtist (Rust + Qt backend wiring)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
joren
2026-03-25 18:25:12 +01:00
parent 963c9ad232
commit 3e96b6d7a8
157 changed files with 64124 additions and 84 deletions

View File

@@ -155,6 +155,16 @@ void QobuzBackend::removeFavAlbum(const QString &albumId)
qobuz_backend_remove_fav_album(m_backend, albumId.toUtf8().constData());
}
void QobuzBackend::addFavArtist(qint64 artistId)
{
qobuz_backend_add_fav_artist(m_backend, artistId);
}
void QobuzBackend::removeFavArtist(qint64 artistId)
{
qobuz_backend_remove_fav_artist(m_backend, artistId);
}
// ---- playback ----
void QobuzBackend::playTrack(qint64 trackId, int formatId)
@@ -249,7 +259,12 @@ void QobuzBackend::onEvent(int eventType, const QString &json)
emit artistLoaded(obj);
break;
case 24: // EV_ARTIST_RELEASES_OK
emit artistReleasesLoaded(obj["release_type"].toString(), obj["items"].toArray());
emit artistReleasesLoaded(
obj["release_type"].toString(),
obj["items"].toArray(),
obj["has_more"].toBool(),
obj["offset"].toInt()
);
break;
case EV_ARTIST_ERR:
emit error(obj["error"].toString());

View File

@@ -30,7 +30,7 @@ public:
void search(const QString &query, quint32 offset = 0, quint32 limit = 20);
void getAlbum(const QString &albumId);
void getArtist(qint64 artistId);
void getArtistReleases(qint64 artistId, const QString &releaseType, quint32 limit = 500, quint32 offset = 0);
void getArtistReleases(qint64 artistId, const QString &releaseType, quint32 limit = 50, quint32 offset = 0);
void getPlaylist(qint64 playlistId, quint32 offset = 0, quint32 limit = 500);
// --- favorites ---
@@ -55,6 +55,8 @@ public:
void removeFavTrack(qint64 trackId);
void addFavAlbum(const QString &albumId);
void removeFavAlbum(const QString &albumId);
void addFavArtist(qint64 artistId);
void removeFavArtist(qint64 artistId);
// --- playback ---
void playTrack(qint64 trackId, int formatId = 6);
@@ -85,7 +87,7 @@ signals:
void searchResult(const QJsonObject &result);
void albumLoaded(const QJsonObject &album);
void artistLoaded(const QJsonObject &artist);
void artistReleasesLoaded(const QString &releaseType, const QJsonArray &items);
void artistReleasesLoaded(const QString &releaseType, const QJsonArray &items, bool hasMore, int offset);
void playlistLoaded(const QJsonObject &playlist);
void playlistCreated(const QJsonObject &playlist);
void playlistDeleted(const QJsonObject &result);

View File

@@ -169,6 +169,25 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
connect(m_toolBar, &MainToolBar::queueToggled,
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::artistRequested, this, &MainWindow::onSearchArtistSelected);
// Apply playback options from saved settings
m_backend->setReplayGain(AppSettings::instance().replayGainEnabled());
m_backend->setGapless(AppSettings::instance().gaplessEnabled());
@@ -359,12 +378,16 @@ void MainWindow::onPlayTrackRequested(qint64 trackId)
void MainWindow::onSearchAlbumSelected(const QString &albumId)
{
NavPage p; p.type = NavPage::Album; p.albumId = albumId;
pushNav(p.type, p.albumId);
m_backend->getAlbum(albumId);
statusBar()->showMessage(tr("Loading album…"));
}
void MainWindow::onSearchArtistSelected(qint64 artistId)
{
NavPage p; p.type = NavPage::Artist; p.artistId = artistId;
pushNav(p.type, {}, p.artistId);
m_backend->getArtist(artistId);
statusBar()->showMessage(tr("Loading artist…"));
}
@@ -389,3 +412,39 @@ void MainWindow::onUserPlaylistsChanged(const QVector<QPair<qint64, QString>> &p
m_userPlaylists = 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

@@ -62,6 +62,19 @@ private:
QDockWidget *m_libraryDock = 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 tryRestoreSession();
void pushNav(NavPage::Type type, const QString &albumId = {}, qint64 artistId = 0);
void navigateTo(const NavPage &page);
void updateNavButtons();
};

View File

@@ -41,6 +41,11 @@ public:
void setAlbums(const QJsonArray &albums)
{
clear();
addAlbums(albums);
}
void addAlbums(const QJsonArray &albums)
{
QFont hiResFont;
hiResFont.setBold(true);
hiResFont.setPointSizeF(hiResFont.pointSizeF() * 0.85);
@@ -52,20 +57,17 @@ public:
const QString ver = a["version"].toString().trimmed();
const QString title = ver.isEmpty() ? base : base + QStringLiteral(" (") + ver + QLatin1Char(')');
// artist.name is either a plain string (old AlbumDto) or {display: ...} (artist/page)
const QJsonValue artistNameVal = a["artist"].toObject()["name"];
const QString artist = artistNameVal.isObject()
? artistNameVal.toObject()["display"].toString()
: artistNameVal.toString();
// year: release_date_original (old) or dates.original (artist/page)
const QString date = a["release_date_original"].toString();
const QString year = date.isEmpty()
? a["dates"].toObject()["original"].toString().left(4)
: date.left(4);
const int tracks = a["tracks_count"].toInt();
// hires: flat field (old) or rights.hires_streamable (artist/page)
const bool hiRes = a["hires_streamable"].toBool()
|| a["rights"].toObject()["hires_streamable"].toBool();

View File

@@ -4,14 +4,13 @@
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QScrollArea>
#include <QTextEdit>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QPixmap>
#include <QUrl>
#include <QFont>
#include <QJsonValue>
#include <QScrollBar>
#include <QRegularExpression>
// Shared button style (mirrors TrackContextHeader)
@@ -19,7 +18,7 @@ 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)
// Section-toggle style: text-only, left-aligned, bottom separator
static const QString kToggleStyle = QStringLiteral(
"QToolButton { text-align: left; font-weight: bold; padding: 4px 6px;"
" border: none; border-bottom: 1px solid #333; }"
@@ -30,9 +29,10 @@ static const QString kToggleStyle = QStringLiteral(
// ArtistSection
// ---------------------------------------------------------------------------
ArtistSection::ArtistSection(const QString &title, QWidget *parent)
ArtistSection::ArtistSection(const QString &title, const QString &releaseType, QWidget *parent)
: QWidget(parent)
, m_baseTitle(title)
, m_releaseType(releaseType)
{
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
@@ -41,7 +41,7 @@ ArtistSection::ArtistSection(const QString &title, QWidget *parent)
m_toggle = new QToolButton(this);
m_toggle->setCheckable(true);
m_toggle->setChecked(true);
m_toggle->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
m_toggle->setToolButtonStyle(Qt::ToolButtonTextOnly);
m_toggle->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
m_toggle->setStyleSheet(kToggleStyle);
layout->addWidget(m_toggle);
@@ -51,13 +51,33 @@ ArtistSection::ArtistSection(const QString &title, QWidget *parent)
connect(m_toggle, &QToolButton::toggled, m_list, &AlbumListView::setVisible);
connect(m_list, &AlbumListView::albumSelected, this, &ArtistSection::albumSelected);
updateToggleText(0);
// Infinite scroll: emit loadMoreRequested when scrolled to bottom
connect(m_list->verticalScrollBar(), &QScrollBar::valueChanged, this, [this](int val) {
const int max = m_list->verticalScrollBar()->maximum();
if (m_hasMore && max > 0 && val >= max) {
m_hasMore = false; // prevent double-fire until next page arrives
emit loadMoreRequested(m_releaseType, m_loadedCount);
}
});
updateToggleText();
}
void ArtistSection::setAlbums(const QJsonArray &albums)
void ArtistSection::setAlbums(const QJsonArray &albums, bool hasMore)
{
m_list->setAlbums(albums);
updateToggleText(albums.size());
m_loadedCount = albums.size();
m_hasMore = hasMore;
updateToggleText();
}
void ArtistSection::appendAlbums(const QJsonArray &albums, bool hasMore)
{
m_list->addAlbums(albums);
m_loadedCount += albums.size();
m_hasMore = hasMore;
updateToggleText();
}
bool ArtistSection::isEmpty() const
@@ -65,23 +85,17 @@ bool ArtistSection::isEmpty() const
return m_list->topLevelItemCount() == 0;
}
void ArtistSection::updateToggleText(int count)
void ArtistSection::updateToggleText()
{
const QString arrow = m_toggle->isChecked() ? QStringLiteral("") : QStringLiteral("");
const QString text = count > 0
? QStringLiteral("%1%2 (%3)").arg(arrow, m_baseTitle).arg(count)
const QString text = m_loadedCount > 0
? QStringLiteral("%1%2 (%3)").arg(arrow, m_baseTitle).arg(m_loadedCount)
: arrow + m_baseTitle;
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, 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);
});
connect(m_toggle, &QToolButton::toggled, this, [this](bool) { updateToggleText(); });
}
// ---------------------------------------------------------------------------
@@ -90,6 +104,7 @@ void ArtistSection::updateToggleText(int count)
ArtistView::ArtistView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
: QWidget(parent)
, m_backend(backend)
{
auto *outerLayout = new QVBoxLayout(this);
outerLayout->setContentsMargins(0, 0, 0, 0);
@@ -143,8 +158,14 @@ ArtistView::ArtistView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
QStringLiteral("QPushButton { background: #2a2a2a; color: #FFB232; border: 1px solid #FFB232; }"
"QPushButton:pressed { background: #333; }"));
m_favBtn = new QPushButton(tr("♡ Favourite"), info);
m_favBtn->setStyleSheet(kBtnBase +
QStringLiteral("QPushButton { background: #2a2a2a; color: #ccc; border: 1px solid #555; }"
"QPushButton:pressed { background: #333; }"));
btnRow->addWidget(m_playBtn);
btnRow->addWidget(m_shuffleBtn);
btnRow->addWidget(m_favBtn);
btnRow->addStretch();
vlay->addLayout(btnRow);
vlay->addStretch(1);
@@ -182,7 +203,7 @@ ArtistView::ArtistView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
m_topTracksToggle = new QToolButton(m_topTracksSection);
m_topTracksToggle->setCheckable(true);
m_topTracksToggle->setChecked(true);
m_topTracksToggle->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
m_topTracksToggle->setToolButtonStyle(Qt::ToolButtonTextOnly);
m_topTracksToggle->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
m_topTracksToggle->setStyleSheet(kToggleStyle);
ttLayout->addWidget(m_topTracksToggle);
@@ -197,11 +218,11 @@ ArtistView::ArtistView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
sectLayout->addWidget(m_topTracksSection);
// Release sections
m_secAlbums = new ArtistSection(tr("Albums"), content);
m_secEps = new ArtistSection(tr("Singles & EPs"), content);
m_secLive = new ArtistSection(tr("Live"), content);
m_secCompilations = new ArtistSection(tr("Compilations"), content);
m_secOther = new ArtistSection(tr("Other"), content);
m_secAlbums = new ArtistSection(tr("Albums"), QStringLiteral("album"), content);
m_secEps = new ArtistSection(tr("Singles & EPs"), QStringLiteral("epSingle"), content);
m_secLive = new ArtistSection(tr("Live"), QStringLiteral("live"), content);
m_secCompilations = new ArtistSection(tr("Compilations"), QStringLiteral("compilation"), content);
m_secOther = new ArtistSection(tr("Other"), QStringLiteral("other"), content);
sectLayout->addWidget(m_secAlbums);
sectLayout->addWidget(m_secEps);
@@ -213,21 +234,52 @@ ArtistView::ArtistView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
scroll->setWidget(content);
outerLayout->addWidget(scroll, 1);
// Playback connections
connect(m_playBtn, &QPushButton::clicked, m_topTracks, [this] { m_topTracks->playAll(false); });
connect(m_shuffleBtn, &QPushButton::clicked, m_topTracks, [this] { m_topTracks->playAll(true); });
// Favourite button
connect(m_favBtn, &QPushButton::clicked, this, [this] {
if (m_artistId <= 0) return;
m_isFaved = !m_isFaved;
if (m_isFaved)
m_backend->addFavArtist(m_artistId);
else
m_backend->removeFavArtist(m_artistId);
setFaved(m_isFaved);
});
// Album section connections
connect(m_secAlbums, &ArtistSection::albumSelected, this, &ArtistView::albumSelected);
connect(m_secEps, &ArtistSection::albumSelected, this, &ArtistView::albumSelected);
connect(m_secLive, &ArtistSection::albumSelected, this, &ArtistView::albumSelected);
connect(m_secCompilations, &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
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)
{
m_artistId = static_cast<qint64>(artist["id"].toDouble());
m_isFaved = false;
setFaved(false);
m_nameLabel->setText(artist["name"].toObject()["display"].toString());
// Biography: strip HTML tags for plain-text display
// Biography: strip HTML tags
const QString bioHtml = artist["biography"].toObject()["content"].toString();
if (!bioHtml.isEmpty()) {
QString plain = bioHtml;
@@ -245,15 +297,17 @@ void ArtistView::setArtist(const QJsonObject &artist)
m_bioEdit->setVisible(false);
}
// Artist portrait: try hash-based CDN URL, then direct image fields
const QString hash = artist["images"].toObject()["portrait"].toObject()["hash"].toString();
// 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 QString hash = portrait["hash"].toString();
const QString format = portrait["format"].toString();
QString artUrl;
if (!hash.isEmpty() && hash.length() >= 4) {
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);
if (!hash.isEmpty()) {
artUrl = QStringLiteral("https://static.qobuz.com/images/artists/covers/large/%1.%2")
.arg(hash, format.isEmpty() ? QStringLiteral("jpg") : format);
} else {
// Fallback: try direct image field
const QJsonObject img = artist["image"].toObject();
artUrl = img["large"].toString();
if (artUrl.isEmpty()) artUrl = img["small"].toString();
@@ -263,13 +317,13 @@ void ArtistView::setArtist(const QJsonObject &artist)
m_nam->get(QNetworkRequest(QUrl(artUrl)));
} else if (artUrl.isEmpty()) {
m_artLabel->setPixmap(QPixmap());
m_currentArtUrl.clear();
}
// Top tracks (flat array in artist/page response)
// Popular tracks (flat array)
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);
@@ -286,20 +340,15 @@ void ArtistView::setArtist(const QJsonObject &artist)
: QStringLiteral("▼ Popular Tracks"));
m_topTracksSection->setVisible(!topTracks.isEmpty());
// Clear release sections (populated asynchronously via setReleases)
m_secAlbums->setAlbums({});
m_secEps->setAlbums({});
m_secLive->setAlbums({});
m_secCompilations->setAlbums({});
m_secOther->setAlbums({});
m_secAlbums->setVisible(false);
m_secEps->setVisible(false);
m_secLive->setVisible(false);
m_secCompilations->setVisible(false);
m_secOther->setVisible(false);
// Clear release sections populated asynchronously via setReleases
for (ArtistSection *sec : {m_secAlbums, m_secEps, m_secLive, m_secCompilations, m_secOther}) {
sec->setAlbums({});
sec->setVisible(false);
}
}
void ArtistView::setReleases(const QString &releaseType, const QJsonArray &items)
void ArtistView::setReleases(const QString &releaseType, const QJsonArray &items,
bool hasMore, int offset)
{
ArtistSection *sec = nullptr;
if (releaseType == QStringLiteral("album")) sec = m_secAlbums;
@@ -308,6 +357,26 @@ void ArtistView::setReleases(const QString &releaseType, const QJsonArray &items
else if (releaseType == QStringLiteral("compilation")) sec = m_secCompilations;
else sec = m_secOther;
sec->setAlbums(items);
if (offset == 0)
sec->setAlbums(items, hasMore);
else
sec->appendAlbums(items, hasMore);
sec->setVisible(!sec->isEmpty());
}
void ArtistView::setFaved(bool faved)
{
m_isFaved = faved;
if (faved) {
m_favBtn->setText(tr("♥ Favourited"));
m_favBtn->setStyleSheet(kBtnBase +
QStringLiteral("QPushButton { background: #2a2a2a; color: #FFB232; border: 1px solid #FFB232; }"
"QPushButton:pressed { background: #333; }"));
} else {
m_favBtn->setText(tr("♡ Favourite"));
m_favBtn->setStyleSheet(kBtnBase +
QStringLiteral("QPushButton { background: #2a2a2a; color: #ccc; border: 1px solid #555; }"
"QPushButton:pressed { background: #333; }"));
}
}

View File

@@ -11,34 +11,43 @@
#include <QToolButton>
#include <QPushButton>
#include <QNetworkAccessManager>
#include <QScrollBar>
#include <QJsonObject>
#include <QJsonArray>
class AlbumListView;
/// One collapsible section (e.g. "Albums", "EPs & Singles") inside ArtistView.
/// One collapsible section (Albums / EPs / Live / etc.) inside ArtistView.
class ArtistSection : public QWidget
{
Q_OBJECT
public:
explicit ArtistSection(const QString &title, QWidget *parent = nullptr);
/// releaseType is the API string ("album", "epSingle", "live", "compilation").
explicit ArtistSection(const QString &title, const QString &releaseType, QWidget *parent = nullptr);
void setAlbums(const QJsonArray &albums);
void setAlbums(const QJsonArray &albums, bool hasMore = false);
void appendAlbums(const QJsonArray &albums, bool hasMore = false);
bool isEmpty() const;
signals:
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);
private:
QString m_baseTitle;
QToolButton *m_toggle = nullptr;
AlbumListView *m_list = nullptr;
QString m_releaseType;
QToolButton *m_toggle = nullptr;
AlbumListView *m_list = nullptr;
bool m_hasMore = false;
int m_loadedCount = 0;
void updateToggleText(int count);
void updateToggleText();
};
/// Artist detail page: portrait header (matching album view), then collapsible
/// sections for popular tracks, albums, EPs, live, compilations, and other.
/// Artist detail page: portrait header (mirrors TrackContextHeader),
/// then collapsible sections for popular tracks, albums, EPs, live,
/// compilations, and other releases.
class ArtistView : public QWidget
{
Q_OBJECT
@@ -47,31 +56,39 @@ public:
explicit ArtistView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent = nullptr);
void setArtist(const QJsonObject &artist);
/// Update a specific release section when getReleasesList responds.
void setReleases(const QString &releaseType, const QJsonArray &items);
void setReleases(const QString &releaseType, const QJsonArray &items,
bool hasMore = false, int offset = 0);
signals:
void albumSelected(const QString &albumId);
void playTrackRequested(qint64 trackId);
private:
// Header
QobuzBackend *m_backend = nullptr;
qint64 m_artistId = 0;
// Header widgets
QLabel *m_artLabel = nullptr;
QLabel *m_nameLabel = nullptr;
QTextEdit *m_bioEdit = nullptr;
QPushButton *m_playBtn = nullptr;
QPushButton *m_shuffleBtn = nullptr;
QPushButton *m_favBtn = nullptr;
QNetworkAccessManager *m_nam = nullptr;
QString m_currentArtUrl;
bool m_isFaved = false;
// Top tracks section
QWidget *m_topTracksSection = nullptr;
QToolButton *m_topTracksToggle = nullptr;
List::Tracks *m_topTracks = nullptr;
// Popular tracks section
QWidget *m_topTracksSection = nullptr;
QToolButton *m_topTracksToggle = nullptr;
List::Tracks *m_topTracks = nullptr;
ArtistSection *m_secAlbums = nullptr;
ArtistSection *m_secEps = nullptr;
ArtistSection *m_secLive = nullptr;
ArtistSection *m_secCompilations = nullptr;
ArtistSection *m_secOther = nullptr;
// Release sections
ArtistSection *m_secAlbums = nullptr;
ArtistSection *m_secEps = nullptr;
ArtistSection *m_secLive = nullptr;
ArtistSection *m_secCompilations = nullptr;
ArtistSection *m_secOther = nullptr;
void setFaved(bool faved);
};

View File

@@ -108,7 +108,7 @@ void MainContent::showArtist(const QJsonObject &artist)
m_stack->setCurrentIndex(4);
}
void MainContent::updateArtistReleases(const QString &releaseType, const QJsonArray &items)
void MainContent::updateArtistReleases(const QString &releaseType, const QJsonArray &items, bool hasMore, int offset)
{
m_artistView->setReleases(releaseType, items);
m_artistView->setReleases(releaseType, items, hasMore, offset);
}

View File

@@ -31,7 +31,7 @@ public:
void showFavAlbums(const QJsonObject &result);
void showFavArtists(const QJsonObject &result);
void showArtist(const QJsonObject &artist);
void updateArtistReleases(const QString &releaseType, const QJsonArray &items);
void updateArtistReleases(const QString &releaseType, const QJsonArray &items, bool hasMore, int offset);
signals:
void albumRequested(const QString &albumId);

View File

@@ -4,6 +4,7 @@
#include <QNetworkRequest>
#include <QResizeEvent>
#include <QMenu>
MainToolBar::MainToolBar(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
: QToolBar(parent)
@@ -15,6 +16,13 @@ MainToolBar::MainToolBar(QobuzBackend *backend, PlayQueue *queue, QWidget *paren
setContextMenuPolicy(Qt::PreventContextMenu);
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);
connect(m_nam, &QNetworkAccessManager::finished, this, &MainToolBar::onAlbumArtReady);
@@ -34,6 +42,22 @@ MainToolBar::MainToolBar(QobuzBackend *backend, PlayQueue *queue, QWidget *paren
m_trackLabel->setTextFormat(Qt::RichText);
addWidget(m_trackLabel);
m_trackLabel->setContextMenuPolicy(Qt::CustomContextMenu);
connect(m_trackLabel, &QLabel::customContextMenuRequested,
this, [this](const QPoint &pos) {
if (m_currentTrack.isEmpty()) return;
QMenu menu(this);
const QString albumId = m_currentTrack["album"].toObject()["id"].toString();
const qint64 artistId = static_cast<qint64>(
m_currentTrack["performer"].toObject()["id"].toDouble());
if (!albumId.isEmpty())
menu.addAction(tr("Go to Album"), this, [this, albumId] { emit albumRequested(albumId); });
if (artistId > 0)
menu.addAction(tr("Go to Artist"), this, [this, artistId] { emit artistRequested(artistId); });
if (!menu.isEmpty())
menu.exec(m_trackLabel->mapToGlobal(pos));
});
addSeparator();
// ---- Media controls ----
@@ -125,6 +149,7 @@ void MainToolBar::setPlaying(bool playing)
void MainToolBar::setCurrentTrack(const QJsonObject &track)
{
m_currentTrack = track;
const QString title = track["title"].toString();
const QString artist = track["performer"].toObject()["name"].toString().isEmpty()
? track["album"].toObject()["artist"].toObject()["name"].toString()

View File

@@ -24,9 +24,14 @@ public:
void setCurrentTrack(const QJsonObject &track);
void updateProgress(quint64 position, quint64 duration);
QAction *backAction() const { return m_back; }
QAction *forwardAction() const { return m_fwd; }
signals:
void searchToggled(bool visible);
void queueToggled(bool visible);
void albumRequested(const QString &albumId);
void artistRequested(qint64 artistId);
protected:
void resizeEvent(QResizeEvent *event) override;
@@ -52,6 +57,8 @@ private:
QobuzBackend *m_backend = nullptr;
PlayQueue *m_queue = nullptr;
QAction *m_back = nullptr;
QAction *m_fwd = nullptr;
QLabel *m_artLabel = nullptr;
QLabel *m_trackLabel = nullptr;
QAction *m_previous = nullptr;
@@ -67,7 +74,8 @@ private:
QAction *m_search = nullptr;
QNetworkAccessManager *m_nam = nullptr;
QString m_currentArtUrl;
bool m_playing = false;
bool m_seeking = false;
QString m_currentArtUrl;
QJsonObject m_currentTrack;
bool m_playing = false;
bool m_seeking = false;
};

View File

@@ -0,0 +1,163 @@
#include "spectrumwidget.hpp"
#include <QPainter>
#include <QTimerEvent>
#include <cmath>
#include <algorithm>
static constexpr float M_PI_F = static_cast<float>(M_PI);
SpectrumWidget::SpectrumWidget(QobuzBackend *backend, QWidget *parent)
: QWidget(parent)
, m_backend(backend)
, m_pcmBuf(FFT_SIZE * 2, 0.0f) // enough for stereo
, m_fftReal(FFT_SIZE, 0.0f)
, m_fftImag(FFT_SIZE, 0.0f)
, m_bars(NUM_BARS, 0.0f)
, m_window(FFT_SIZE)
{
// Build Hann window
for (int i = 0; i < FFT_SIZE; ++i)
m_window[i] = 0.5f * (1.0f - std::cos(2.0f * M_PI_F * i / (FFT_SIZE - 1)));
setMinimumHeight(48);
setAttribute(Qt::WA_OpaquePaintEvent);
m_timerId = startTimer(33); // ~30 fps
}
// ---- Cooley-Tukey in-place radix-2 DIT FFT --------------------------------
void SpectrumWidget::fft(std::vector<float> &re, std::vector<float> &im)
{
const int n = static_cast<int>(re.size());
// Bit-reversal permutation
for (int i = 1, j = 0; i < n; ++i) {
int bit = n >> 1;
for (; j & bit; bit >>= 1)
j ^= bit;
j ^= bit;
if (i < j) {
std::swap(re[i], re[j]);
std::swap(im[i], im[j]);
}
}
// Butterfly stages
for (int len = 2; len <= n; len <<= 1) {
const float ang = -2.0f * M_PI_F / len;
const float wRe = std::cos(ang);
const float wIm = std::sin(ang);
for (int i = 0; i < n; i += len) {
float curRe = 1.0f, curIm = 0.0f;
for (int k = 0; k < len / 2; ++k) {
const float uRe = re[i + k];
const float uIm = im[i + k];
const float vRe = re[i + k + len/2] * curRe - im[i + k + len/2] * curIm;
const float vIm = re[i + k + len/2] * curIm + im[i + k + len/2] * curRe;
re[i + k] = uRe + vRe;
im[i + k] = uIm + vIm;
re[i + k + len/2] = uRe - vRe;
im[i + k + len/2] = uIm - vIm;
const float nextRe = curRe * wRe - curIm * wIm;
curIm = curRe * wIm + curIm * wRe;
curRe = nextRe;
}
}
}
}
// ---- Sample processing -----------------------------------------------------
void SpectrumWidget::processNewSamples()
{
const quint32 channels = m_backend->vizChannels();
if (channels == 0) return;
const quint32 n = m_backend->vizRead(m_pcmBuf.data(),
static_cast<quint32>(m_pcmBuf.size()));
if (n == 0) return;
// Mix to mono into the FFT real buffer (wrap-around ring fill)
const int ch = static_cast<int>(channels);
const int frames = static_cast<int>(n) / ch;
for (int f = 0; f < frames && f < FFT_SIZE; ++f) {
float mono = 0.0f;
for (int c = 0; c < ch; ++c)
mono += m_pcmBuf[f * ch + c];
m_fftReal[f] = mono / ch * m_window[f];
}
// Zero-pad if we got fewer frames than FFT_SIZE
for (int f = frames; f < FFT_SIZE; ++f)
m_fftReal[f] = 0.0f;
std::fill(m_fftImag.begin(), m_fftImag.end(), 0.0f);
fft(m_fftReal, m_fftImag);
// Map FFT bins to bars using logarithmic frequency spacing
// Bins 1..FFT_SIZE/2 cover 0..Nyquist; skip DC (bin 0)
const int halfBins = FFT_SIZE / 2;
// Log-spaced bin boundaries: bin = 1 * (halfBins)^(i/NUM_BARS)
for (int b = 0; b < NUM_BARS; ++b) {
const float lo = std::pow(static_cast<float>(halfBins), static_cast<float>(b) / NUM_BARS);
const float hi = std::pow(static_cast<float>(halfBins), static_cast<float>(b + 1) / NUM_BARS);
const int binLo = std::max(1, static_cast<int>(lo));
const int binHi = std::min(halfBins - 1, static_cast<int>(hi));
float peak = 0.0f;
for (int k = binLo; k <= binHi; ++k) {
const float mag = std::sqrt(m_fftReal[k] * m_fftReal[k] + m_fftImag[k] * m_fftImag[k]);
if (mag > peak) peak = mag;
}
// Convert to dB-ish (log scale), normalised to [0,1]
float level = std::log1p(peak * 5.0f) / std::log1p(5.0f * FFT_SIZE / 4.0f);
level = std::clamp(level, 0.0f, 1.0f);
// Smooth: fast attack, slow decay
const float alpha = level > m_bars[b] ? 0.6f : 0.15f;
m_bars[b] = m_bars[b] * (1.0f - alpha) + level * alpha;
}
}
// ---- Qt overrides ----------------------------------------------------------
void SpectrumWidget::timerEvent(QTimerEvent *event)
{
if (event->timerId() == m_timerId) {
processNewSamples();
update();
}
}
void SpectrumWidget::paintEvent(QPaintEvent *)
{
QPainter p(this);
p.fillRect(rect(), palette().window());
const int w = width();
const int h = height();
if (w <= 0 || h <= 0) return;
const QColor barColor = palette().highlight().color();
const QColor peakColor = barColor.lighter(150);
const float barW = static_cast<float>(w) / NUM_BARS;
const float gap = std::max(1.0f, barW * 0.15f);
for (int b = 0; b < NUM_BARS; ++b) {
const int x = static_cast<int>(b * barW);
const int bw = static_cast<int>(barW - gap);
const int bh = static_cast<int>(m_bars[b] * h);
if (bh <= 0) continue;
// Gradient-ish: draw in two colours split at 70% height
const int split = static_cast<int>(h * 0.7f);
if (bh <= split) {
p.fillRect(x, h - bh, bw, bh, barColor);
} else {
p.fillRect(x, h - split, bw, split, barColor);
p.fillRect(x, h - bh, bw, bh - split, peakColor);
}
}
}

View File

@@ -0,0 +1,42 @@
#pragma once
#include "../backend/qobuzbackend.hpp"
#include <QWidget>
#include <QTimer>
#include <vector>
/// Dock-friendly spectrum bar visualizer.
/// Reads PCM from the Rust viz ring buffer, runs a simple FFT,
/// and draws frequency bars with QPainter.
class SpectrumWidget : public QWidget
{
Q_OBJECT
public:
explicit SpectrumWidget(QobuzBackend *backend, QWidget *parent = nullptr);
protected:
void paintEvent(QPaintEvent *event) override;
void timerEvent(QTimerEvent *event) override;
private:
void processNewSamples();
QobuzBackend *m_backend = nullptr;
int m_timerId = 0;
// FFT size — power of two
static constexpr int FFT_SIZE = 1024;
static constexpr int NUM_BARS = 32;
std::vector<float> m_pcmBuf; // raw interleaved PCM from ring
std::vector<float> m_fftReal; // FFT input (mono mix)
std::vector<float> m_fftImag;
std::vector<float> m_bars; // smoothed bar heights [0..1]
// Simple Hann window
std::vector<float> m_window;
static void fft(std::vector<float> &re, std::vector<float> &im);
};