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:
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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; }"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
163
src/visualizer/spectrumwidget.cpp
Normal file
163
src/visualizer/spectrumwidget.cpp
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
42
src/visualizer/spectrumwidget.hpp
Normal file
42
src/visualizer/spectrumwidget.hpp
Normal 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);
|
||||
};
|
||||
Reference in New Issue
Block a user