feat: show top tracks on artist profile with play/shuffle

- Adds extra=topTracks to artist/page API request
- Embeds a List::Tracks widget at the top of ArtistView showing
  the artist's most popular tracks, with Play and Shuffle buttons
- Bubbles playTrackRequested through MainContent up to MainWindow
- Also adds the viz PCM ring buffer FFI infrastructure (for future
  spectrum widget) to the Rust backend

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
joren
2026-03-25 13:45:19 +01:00
parent 4ba6d00748
commit 6f11b364aa
13 changed files with 151 additions and 9 deletions

View File

@@ -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;

View File

@@ -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<std::sync::Mutex<f32>>,
/// When false the audio output is torn down after each track, producing a gap.
pub gapless: Arc<AtomicBool>,
/// Visualizer ring buffer (consumer side, read by FFI).
pub viz_ring: Arc<SpscRb<f32>>,
pub viz_consumer: Arc<std::sync::Mutex<rb::Consumer<f32>>>,
pub viz_sample_rate: Arc<AtomicU32>,
pub viz_channels: Arc<AtomicU32>,
}
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)),
}
}

View File

@@ -14,6 +14,7 @@ const RING_BUFFER_SIZE: usize = 32 * 1024;
pub struct AudioOutput {
ring_buf_producer: rb::Producer<f32>,
viz_producer: Option<rb::Producer<f32>>,
_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<f32>) {
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<f32> = 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) {