diff --git a/rust/include/qobuz_backend.h b/rust/include/qobuz_backend.h index 4938459..959ac0f 100644 --- a/rust/include/qobuz_backend.h +++ b/rust/include/qobuz_backend.h @@ -79,6 +79,11 @@ void qobuz_backend_set_replaygain(QobuzBackendOpaque *backend, bool enabled); void qobuz_backend_set_gapless(QobuzBackendOpaque *backend, bool enabled); void qobuz_backend_prefetch_track(QobuzBackendOpaque *backend, int64_t track_id, int32_t format_id); +// Visualizer PCM access +uint32_t qobuz_backend_viz_read(QobuzBackendOpaque *backend, float *buf, uint32_t max_samples); +uint32_t qobuz_backend_viz_sample_rate(const QobuzBackendOpaque *backend); +uint32_t qobuz_backend_viz_channels(const QobuzBackendOpaque *backend); + // Playlist management void qobuz_backend_create_playlist(QobuzBackendOpaque *backend, const char *name); void qobuz_backend_delete_playlist(QobuzBackendOpaque *backend, int64_t playlist_id); diff --git a/rust/src/api/client.rs b/rust/src/api/client.rs index fbfa3d8..035f4e0 100644 --- a/rust/src/api/client.rs +++ b/rust/src/api/client.rs @@ -258,7 +258,10 @@ impl QobuzClient { pub async fn get_artist_page(&self, artist_id: i64) -> Result { let resp = self .get_request("artist/page") - .query(&[("artist_id", artist_id.to_string())]) + .query(&[ + ("artist_id", artist_id.to_string()), + ("extra", "topTracks".to_string()), + ]) .send() .await?; Self::check_response(resp).await diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 82c8f12..938b1b5 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -590,6 +590,34 @@ pub unsafe extern "C" fn qobuz_backend_get_user(ptr: *mut Backend) { }); } +// ---------- Visualizer PCM access ---------- + +/// Read up to `max_samples` f32 PCM values into `buf`. +/// Returns the number of samples actually read. +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_viz_read( + ptr: *mut Backend, + buf: *mut f32, + max_samples: u32, +) -> u32 { + let consumer = &(*ptr).0.player.status.viz_consumer; + let Ok(mut lock) = consumer.try_lock() else { return 0 }; + let slice = std::slice::from_raw_parts_mut(buf, max_samples as usize); + rb::RbConsumer::read(&mut *lock, slice).unwrap_or(0) as u32 +} + +/// Returns current sample rate of the audio stream (0 if idle). +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_viz_sample_rate(ptr: *const Backend) -> u32 { + (*ptr).0.player.status.viz_sample_rate.load(std::sync::atomic::Ordering::Relaxed) +} + +/// Returns current channel count (0 if idle). +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_viz_channels(ptr: *const Backend) -> u32 { + (*ptr).0.player.status.viz_channels.load(std::sync::atomic::Ordering::Relaxed) +} + // ---------- Playlist management ---------- pub const EV_PLAYLIST_CREATED: c_int = 20; diff --git a/rust/src/player/decoder.rs b/rust/src/player/decoder.rs index a4f77db..7d9599f 100644 --- a/rust/src/player/decoder.rs +++ b/rust/src/player/decoder.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use rb::RB; use std::io::{self, Read, Seek, SeekFrom}; use std::sync::{ atomic::{AtomicBool, Ordering}, @@ -176,8 +177,12 @@ pub fn play_track_inline( } } if audio_output.is_none() { - *audio_output = Some(AudioOutput::try_open(sample_rate, channels)?); + let mut ao = AudioOutput::try_open(sample_rate, channels)?; + ao.set_viz_producer(status.viz_ring.producer()); + *audio_output = Some(ao); } + status.viz_sample_rate.store(sample_rate, Ordering::Relaxed); + status.viz_channels.store(channels as u32, Ordering::Relaxed); let ao = audio_output.as_mut().unwrap(); let mut stopped = false; diff --git a/rust/src/player/mod.rs b/rust/src/player/mod.rs index c7add6d..99f7c48 100644 --- a/rust/src/player/mod.rs +++ b/rust/src/player/mod.rs @@ -1,14 +1,18 @@ mod decoder; pub mod output; +use rb::{SpscRb, RB}; use std::sync::{ - atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering}, + atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicU8, Ordering}, Arc, }; use std::time::Duration; use crate::api::TrackDto; +/// Size of the visualizer ring buffer in f32 samples (~180ms at 44.1kHz stereo). +const VIZ_RING_SIZE: usize = 16 * 1024; + #[derive(Debug, Clone)] pub enum PlayerCommand { Play(TrackInfo), @@ -50,10 +54,17 @@ pub struct PlayerStatus { pub replaygain_gain: Arc>, /// When false the audio output is torn down after each track, producing a gap. pub gapless: Arc, + /// Visualizer ring buffer (consumer side, read by FFI). + pub viz_ring: Arc>, + pub viz_consumer: Arc>>, + pub viz_sample_rate: Arc, + pub viz_channels: Arc, } impl PlayerStatus { pub fn new() -> Self { + let viz_ring = Arc::new(SpscRb::new(VIZ_RING_SIZE)); + let viz_consumer = Arc::new(std::sync::Mutex::new(viz_ring.consumer())); Self { state: Arc::new(std::sync::Mutex::new(PlayerState::Idle)), position_secs: Arc::new(AtomicU64::new(0)), @@ -65,6 +76,10 @@ impl PlayerStatus { seek_target_secs: Arc::new(AtomicU64::new(0)), replaygain_gain: Arc::new(std::sync::Mutex::new(1.0)), gapless: Arc::new(AtomicBool::new(false)), + viz_ring, + viz_consumer, + viz_sample_rate: Arc::new(AtomicU32::new(0)), + viz_channels: Arc::new(AtomicU32::new(0)), } } diff --git a/rust/src/player/output.rs b/rust/src/player/output.rs index b140df8..bdafde8 100644 --- a/rust/src/player/output.rs +++ b/rust/src/player/output.rs @@ -14,6 +14,7 @@ const RING_BUFFER_SIZE: usize = 32 * 1024; pub struct AudioOutput { ring_buf_producer: rb::Producer, + viz_producer: Option>, _stream: cpal::Stream, pub sample_rate: u32, pub channels: usize, @@ -51,12 +52,17 @@ impl AudioOutput { Ok(Self { ring_buf_producer: producer, + viz_producer: None, _stream: stream, sample_rate, channels, }) } + pub fn set_viz_producer(&mut self, producer: rb::Producer) { + self.viz_producer = Some(producer); + } + pub fn write( &mut self, decoded: AudioBufferRef<'_>, @@ -70,6 +76,11 @@ impl AudioOutput { sample_buf.copy_interleaved_ref(decoded); let samples: Vec = sample_buf.samples().iter().map(|s| s * volume).collect(); + // Best-effort copy for visualizer (non-blocking, ok to drop samples) + if let Some(ref mut viz) = self.viz_producer { + let _ = viz.write(&samples); + } + let mut remaining = &samples[..]; while !remaining.is_empty() { if stop.load(Ordering::SeqCst) { diff --git a/src/backend/qobuzbackend.cpp b/src/backend/qobuzbackend.cpp index 42ca1ff..166816f 100644 --- a/src/backend/qobuzbackend.cpp +++ b/src/backend/qobuzbackend.cpp @@ -186,6 +186,21 @@ quint64 QobuzBackend::duration() const { return qobuz_backend_get_duration(m_bac int QobuzBackend::volume() const { return qobuz_backend_get_volume(m_backend); } int QobuzBackend::state() const { return qobuz_backend_get_state(m_backend); } +quint32 QobuzBackend::vizRead(float *buf, quint32 maxSamples) +{ + return qobuz_backend_viz_read(m_backend, buf, maxSamples); +} + +quint32 QobuzBackend::vizSampleRate() const +{ + return qobuz_backend_viz_sample_rate(m_backend); +} + +quint32 QobuzBackend::vizChannels() const +{ + return qobuz_backend_viz_channels(m_backend); +} + // ---- private slots ---- void QobuzBackend::onPositionTick() diff --git a/src/backend/qobuzbackend.hpp b/src/backend/qobuzbackend.hpp index 0c51d6c..daee721 100644 --- a/src/backend/qobuzbackend.hpp +++ b/src/backend/qobuzbackend.hpp @@ -68,6 +68,11 @@ public: /// 1 = playing, 2 = paused, 0 = idle int state() const; + // --- visualizer PCM --- + quint32 vizRead(float *buf, quint32 maxSamples); + quint32 vizSampleRate() const; + quint32 vizChannels() const; + signals: // auth void loginSuccess(const QString &token, const QJsonObject &user); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 9ca3ad2..f9d6c4d 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -155,6 +155,8 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent) this, &MainWindow::onSearchAlbumSelected); connect(m_content, &MainContent::artistRequested, this, &MainWindow::onSearchArtistSelected); + connect(m_content, &MainContent::playTrackRequested, + this, &MainWindow::onPlayTrackRequested); // ---- Queue panel ---- connect(m_queuePanel, &QueuePanel::skipToTrackRequested, diff --git a/src/view/artistview.cpp b/src/view/artistview.cpp index be823e7..ca5efb9 100644 --- a/src/view/artistview.cpp +++ b/src/view/artistview.cpp @@ -75,7 +75,7 @@ void ArtistSection::updateToggleText(int count) // ArtistView // --------------------------------------------------------------------------- -ArtistView::ArtistView(QWidget *parent) +ArtistView::ArtistView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent) : QWidget(parent) { auto *outerLayout = new QVBoxLayout(this); @@ -105,6 +105,43 @@ ArtistView::ArtistView(QWidget *parent) sectLayout->setContentsMargins(0, 0, 0, 0); sectLayout->setSpacing(8); + // --- Top Tracks section --- + m_topTracksSection = new QWidget(content); + auto *ttLayout = new QVBoxLayout(m_topTracksSection); + ttLayout->setContentsMargins(0, 0, 0, 0); + ttLayout->setSpacing(0); + + // Header row: label + play + shuffle + auto *ttHeader = new QWidget(m_topTracksSection); + auto *ttHeaderLayout = new QHBoxLayout(ttHeader); + ttHeaderLayout->setContentsMargins(6, 4, 6, 4); + ttHeaderLayout->setSpacing(6); + + auto *ttLabel = new QLabel(tr("Popular Tracks"), ttHeader); + QFont ttFont = ttLabel->font(); + ttFont.setBold(true); + ttLabel->setFont(ttFont); + ttHeaderLayout->addWidget(ttLabel, 1); + + auto *playBtn = new QPushButton(tr("▶ Play"), ttHeader); + auto *shuffleBtn = new QPushButton(tr("⇀ Shuffle"), ttHeader); + ttHeaderLayout->addWidget(playBtn); + ttHeaderLayout->addWidget(shuffleBtn); + + ttLayout->addWidget(ttHeader); + + m_topTracks = new List::Tracks(backend, queue, m_topTracksSection); + // Limit visible height so it doesn't swamp the page + m_topTracks->setMaximumHeight(320); + ttLayout->addWidget(m_topTracks); + + connect(playBtn, &QPushButton::clicked, m_topTracks, [this] { m_topTracks->playAll(false); }); + connect(shuffleBtn, &QPushButton::clicked, m_topTracks, [this] { m_topTracks->playAll(true); }); + connect(m_topTracks, &List::Tracks::playTrackRequested, this, &ArtistView::playTrackRequested); + + sectLayout->addWidget(m_topTracksSection); + + // --- Album sections --- m_secAlbums = new ArtistSection(tr("Albums"), content); m_secEps = new ArtistSection(tr("Singles & EPs"), content); m_secLive = new ArtistSection(tr("Live"), content); @@ -153,8 +190,12 @@ void ArtistView::setArtist(const QJsonObject &artist) m_bioLabel->setVisible(false); } + // Top tracks: artist/page?extra=topTracks returns {"topTracks": {"items": [...]}} + const QJsonArray topTracks = artist["topTracks"].toObject()["items"].toArray(); + m_topTracks->loadTracks(topTracks); + m_topTracksSection->setVisible(!topTracks.isEmpty()); + // releases is an array of {type, has_more, items[]} - // types we care about: "album", "epSingle", "live" const QJsonArray releases = artist["releases"].toArray(); QJsonArray albums, eps, live, compilations; for (const QJsonValue &rv : releases) { diff --git a/src/view/artistview.hpp b/src/view/artistview.hpp index 5a600eb..735aa56 100644 --- a/src/view/artistview.hpp +++ b/src/view/artistview.hpp @@ -1,10 +1,14 @@ #pragma once #include "albumlistview.hpp" +#include "../list/tracks.hpp" +#include "../backend/qobuzbackend.hpp" +#include "../playqueue.hpp" #include #include #include +#include #include #include @@ -31,23 +35,29 @@ private: 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. +/// Artist detail page: name, biography, top tracks, and albums split into +/// collapsible sections (Albums / EPs & Singles / Live / Compilations). class ArtistView : public QWidget { Q_OBJECT public: - explicit ArtistView(QWidget *parent = nullptr); + explicit ArtistView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent = nullptr); void setArtist(const QJsonObject &artist); signals: void albumSelected(const QString &albumId); + void playTrackRequested(qint64 trackId); private: QLabel *m_nameLabel = nullptr; QLabel *m_bioLabel = nullptr; + + // Top tracks section + QWidget *m_topTracksSection = nullptr; + List::Tracks *m_topTracks = nullptr; + ArtistSection *m_secAlbums = nullptr; ArtistSection *m_secEps = nullptr; ArtistSection *m_secLive = nullptr; diff --git a/src/view/maincontent.cpp b/src/view/maincontent.cpp index 62a3130..5e2cd61 100644 --- a/src/view/maincontent.cpp +++ b/src/view/maincontent.cpp @@ -44,7 +44,7 @@ MainContent::MainContent(QobuzBackend *backend, PlayQueue *queue, QWidget *paren m_albumList = new AlbumListView(this); m_artistList = new ArtistListView(this); - m_artistView = new ArtistView(this); + m_artistView = new ArtistView(backend, queue, this); m_stack->addWidget(m_welcome); // 0 m_stack->addWidget(tracksPage); // 1 @@ -57,6 +57,7 @@ MainContent::MainContent(QobuzBackend *backend, PlayQueue *queue, QWidget *paren connect(m_albumList, &AlbumListView::albumSelected, this, &MainContent::albumRequested); connect(m_artistList, &ArtistListView::artistSelected, this, &MainContent::artistRequested); connect(m_artistView, &ArtistView::albumSelected, this, &MainContent::albumRequested); + connect(m_artistView, &ArtistView::playTrackRequested, this, &MainContent::playTrackRequested); } void MainContent::showWelcome() { m_stack->setCurrentIndex(0); } diff --git a/src/view/maincontent.hpp b/src/view/maincontent.hpp index b3d81c2..f9c7e28 100644 --- a/src/view/maincontent.hpp +++ b/src/view/maincontent.hpp @@ -35,6 +35,7 @@ public: signals: void albumRequested(const QString &albumId); void artistRequested(qint64 artistId); + void playTrackRequested(qint64 trackId); private: QobuzBackend *m_backend = nullptr;