Playlist management: - Add/remove tracks from playlists via right-click context menu - Create new playlists (right-click Playlists sidebar header) - Delete playlists with confirmation dialog (right-click playlist item) - Playlist view removes track immediately on delete (optimistic) - Deleting currently-open playlist clears the track view Gapless playback: - Single long-running audio thread owns AudioOutput; CPAL stream stays open between tracks eliminating device teardown/startup gap - Decode runs inline on the audio thread; command channel polled via try_recv() so Pause/Resume/Seek/Stop/Play all work without spawning - New Play command arriving mid-decode is handled immediately, reusing the same audio output for zero-gap transition - Position timer reduced from 500 ms to 50 ms for faster track-end detection - URL/metadata prefetch: when gapless is enabled Qt pre-fetches the next track while the current one is still playing ReplayGain: - Toggled in Settings → Playback - replaygain_track_gain (dB) from track audio_info converted to linear gain factor and applied per-sample alongside volume Qobuz dark theme: - Background #191919, base #141414, accent #FFB232 (yellow-orange) - Selection highlight, slider fill, scrollbar hover all use #FFB232 - Links use Qobuz blue #46B3EE - Hi-res H badges updated to #FFB232 (from #FFD700) - Now-playing row uses #FFB232 (was Spotify green) - QSS stylesheet for scrollbars, menus, inputs, buttons, groups Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
86 lines
2.3 KiB
Rust
86 lines
2.3 KiB
Rust
use anyhow::{anyhow, Result};
|
|
use cpal::{
|
|
traits::{DeviceTrait, HostTrait, StreamTrait},
|
|
StreamConfig,
|
|
};
|
|
use rb::{RbConsumer, RbProducer, SpscRb, RB};
|
|
use std::sync::{
|
|
atomic::{AtomicBool, Ordering},
|
|
Arc,
|
|
};
|
|
use symphonia::core::audio::AudioBufferRef;
|
|
|
|
const RING_BUFFER_SIZE: usize = 32 * 1024;
|
|
|
|
pub struct AudioOutput {
|
|
ring_buf_producer: rb::Producer<f32>,
|
|
_stream: cpal::Stream,
|
|
pub sample_rate: u32,
|
|
pub channels: usize,
|
|
}
|
|
|
|
impl AudioOutput {
|
|
pub fn try_open(sample_rate: u32, channels: usize) -> Result<Self> {
|
|
let host = cpal::default_host();
|
|
let device = host
|
|
.default_output_device()
|
|
.ok_or_else(|| anyhow!("no output device"))?;
|
|
|
|
let config = StreamConfig {
|
|
channels: channels as u16,
|
|
sample_rate: cpal::SampleRate(sample_rate),
|
|
buffer_size: cpal::BufferSize::Default,
|
|
};
|
|
|
|
let ring = SpscRb::new(RING_BUFFER_SIZE);
|
|
let (producer, consumer) = (ring.producer(), ring.consumer());
|
|
|
|
let stream = device.build_output_stream(
|
|
&config,
|
|
move |data: &mut [f32], _| {
|
|
let n = consumer.read(data).unwrap_or(0);
|
|
for s in &mut data[n..] {
|
|
*s = 0.0;
|
|
}
|
|
},
|
|
|e| eprintln!("audio stream error: {e}"),
|
|
None,
|
|
)?;
|
|
|
|
stream.play()?;
|
|
|
|
Ok(Self {
|
|
ring_buf_producer: producer,
|
|
_stream: stream,
|
|
sample_rate,
|
|
channels,
|
|
})
|
|
}
|
|
|
|
pub fn write(
|
|
&mut self,
|
|
decoded: AudioBufferRef<'_>,
|
|
volume: f32,
|
|
stop: &Arc<AtomicBool>,
|
|
) -> Result<()> {
|
|
let mut sample_buf = symphonia::core::audio::SampleBuffer::<f32>::new(
|
|
decoded.capacity() as u64,
|
|
*decoded.spec(),
|
|
);
|
|
sample_buf.copy_interleaved_ref(decoded);
|
|
let samples: Vec<f32> = sample_buf.samples().iter().map(|s| s * volume).collect();
|
|
|
|
let mut remaining = &samples[..];
|
|
while !remaining.is_empty() {
|
|
if stop.load(Ordering::SeqCst) {
|
|
return Ok(());
|
|
}
|
|
match self.ring_buf_producer.write_blocking(remaining) {
|
|
Some(n) => remaining = &remaining[n..],
|
|
None => break,
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|