Files
qobuz-qt/rust/src/player/mod.rs
joren 1e4c234b5c fix: gapless toggle now actually controls audio output lifecycle
When gapless is off, the AudioOutput is dropped after each track ends
naturally, producing a real gap on the next play. When on, the output
stays alive so tracks transition seamlessly. Also re-adds URL prefetch
gating behind the same toggle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:39:24 +01:00

217 lines
7.2 KiB
Rust

mod decoder;
pub mod output;
use std::sync::{
atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering},
Arc,
};
use std::time::Duration;
use crate::api::{Format, TrackDto};
#[derive(Debug, Clone)]
pub enum PlayerCommand {
Play(TrackInfo),
Pause,
Resume,
Stop,
Seek(u64),
SetVolume(u8),
}
#[derive(Debug, Clone)]
pub struct TrackInfo {
pub track: TrackDto,
pub url: String,
pub format: Format,
/// ReplayGain track gain in dB, if enabled and available.
pub replaygain_db: Option<f64>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum PlayerState {
Idle,
Playing,
Paused,
Stopped,
Error(String),
}
#[derive(Clone)]
pub struct PlayerStatus {
pub state: Arc<std::sync::Mutex<PlayerState>>,
pub position_secs: Arc<AtomicU64>,
pub duration_secs: Arc<AtomicU64>,
pub volume: Arc<AtomicU8>,
pub current_track: Arc<std::sync::Mutex<Option<TrackDto>>>,
/// Set to true by the decode thread when a track finishes naturally.
pub track_finished: Arc<AtomicBool>,
/// Set by the player loop when a seek command arrives; cleared by the decode thread.
pub seek_requested: Arc<AtomicBool>,
pub seek_target_secs: Arc<AtomicU64>,
/// Linear gain factor to apply (1.0 = unity). Updated each time a new track starts.
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>,
}
impl PlayerStatus {
pub fn new() -> Self {
Self {
state: Arc::new(std::sync::Mutex::new(PlayerState::Idle)),
position_secs: Arc::new(AtomicU64::new(0)),
duration_secs: Arc::new(AtomicU64::new(0)),
volume: Arc::new(AtomicU8::new(80)),
current_track: Arc::new(std::sync::Mutex::new(None)),
track_finished: Arc::new(AtomicBool::new(false)),
seek_requested: Arc::new(AtomicBool::new(false)),
seek_target_secs: Arc::new(AtomicU64::new(0)),
replaygain_gain: Arc::new(std::sync::Mutex::new(1.0)),
gapless: Arc::new(AtomicBool::new(false)),
}
}
pub fn get_state(&self) -> PlayerState {
self.state.lock().unwrap().clone()
}
pub fn get_position(&self) -> u64 {
self.position_secs.load(Ordering::Relaxed)
}
pub fn get_duration(&self) -> u64 {
self.duration_secs.load(Ordering::Relaxed)
}
pub fn get_volume(&self) -> u8 {
self.volume.load(Ordering::Relaxed)
}
pub fn get_current_track(&self) -> Option<TrackDto> {
self.current_track.lock().unwrap().clone()
}
}
pub struct Player {
pub cmd_tx: std::sync::mpsc::SyncSender<PlayerCommand>,
pub status: PlayerStatus,
}
impl Player {
pub fn new() -> Self {
let (cmd_tx, cmd_rx) = std::sync::mpsc::sync_channel(32);
let status = PlayerStatus::new();
let status_clone = status.clone();
std::thread::spawn(move || {
player_loop(cmd_rx, status_clone);
});
Self { cmd_tx, status }
}
pub fn send(&self, cmd: PlayerCommand) {
self.cmd_tx.send(cmd).ok();
}
pub fn pause(&self) {
self.send(PlayerCommand::Pause);
}
pub fn resume(&self) {
self.send(PlayerCommand::Resume);
}
pub fn stop(&self) {
self.send(PlayerCommand::Stop);
}
pub fn set_volume(&self, vol: u8) {
self.status.volume.store(vol, Ordering::Relaxed);
self.send(PlayerCommand::SetVolume(vol));
}
pub fn seek(&self, secs: u64) {
self.status.seek_target_secs.store(secs, Ordering::Relaxed);
self.status.seek_requested.store(true, Ordering::SeqCst);
}
}
/// The player loop runs on a single dedicated OS thread.
/// It owns the `AudioOutput` locally so there are no Send constraints.
/// Decoding is performed inline; the command channel is polled via try_recv
/// inside the decode loop to handle Pause/Resume/Seek/Stop/Play without
/// tearng down and re-opening the audio device between tracks.
fn player_loop(rx: std::sync::mpsc::Receiver<PlayerCommand>, status: PlayerStatus) {
use std::sync::mpsc::RecvTimeoutError;
let mut audio_output: Option<output::AudioOutput> = None;
let paused = Arc::new(AtomicBool::new(false));
// pending_info holds a Play command that interrupted an ongoing decode
let mut pending_info: Option<TrackInfo> = None;
'outer: loop {
// Wait for a Play command (or use one that was interrupted)
let info = if let Some(p) = pending_info.take() {
p
} else {
loop {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(PlayerCommand::Play(info)) => break info,
Ok(PlayerCommand::Stop) => {
audio_output = None;
paused.store(false, Ordering::SeqCst);
*status.state.lock().unwrap() = PlayerState::Idle;
*status.current_track.lock().unwrap() = None;
status.position_secs.store(0, Ordering::Relaxed);
status.duration_secs.store(0, Ordering::Relaxed);
}
Ok(PlayerCommand::SetVolume(v)) => {
status.volume.store(v, Ordering::Relaxed);
}
Ok(PlayerCommand::Seek(s)) => {
status.seek_target_secs.store(s, Ordering::Relaxed);
status.seek_requested.store(true, Ordering::SeqCst);
}
Ok(_) => {} // Pause/Resume ignored when idle
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => break 'outer,
}
}
};
// Compute ReplayGain factor
let rg_factor = info.replaygain_db
.map(|db| 10f32.powf(db as f32 / 20.0))
.unwrap_or(1.0);
*status.replaygain_gain.lock().unwrap() = rg_factor;
*status.state.lock().unwrap() = PlayerState::Playing;
*status.current_track.lock().unwrap() = Some(info.track.clone());
if let Some(dur) = info.track.duration {
status.duration_secs.store(dur as u64, Ordering::Relaxed);
}
status.position_secs.store(0, Ordering::Relaxed);
paused.store(false, Ordering::SeqCst);
match decoder::play_track_inline(&info.url, &status, &paused, &mut audio_output, &rx) {
Ok(Some(next_info)) => {
// Interrupted by a new Play — loop immediately with reused audio output
pending_info = Some(next_info);
}
Ok(None) => {
// Track finished naturally — tear down audio output if gapless is off
if !status.gapless.load(Ordering::Relaxed) {
audio_output = None;
}
*status.state.lock().unwrap() = PlayerState::Idle;
status.track_finished.store(true, Ordering::SeqCst);
}
Err(e) => {
eprintln!("playback error: {e}");
*status.state.lock().unwrap() = PlayerState::Error(e.to_string());
}
}
}
}