Files
qbz/src-tauri/src/player/mod.rs
T
vicrodh 16b35c33fd feat(visualizer): real-time audio FFT visualization
Backend (Rust):
- Add rustfft crate for Fast Fourier Transform
- Create visualizer module with FFT computation (1024 samples, Hann window)
- VisualizerSource wrapper taps into audio stream without modifying playback
- Logarithmic frequency bin mapping for perceptual accuracy
- Emit audio-spectrum events to frontend (~20-30fps)
- Tauri commands: set_visualizer_enabled, get_visualizer_enabled

Frontend (Svelte):
- CSS-based rendering (GPU accelerated via transform/transition)
- Three visualization modes: bars, wave, mirror
- Dynamic colors extracted from album artwork
- Listens to audio-spectrum Tauri events
- ~0% CPU usage (GPU handles all transforms)
2026-01-30 21:51:25 -06:00

1926 lines
84 KiB
Rust

//! Audio player module
//!
//! Handles audio playback with support for:
//! - HTTP streaming from Qobuz
//! - FLAC, MP3 decoding via symphonia
//! - Gapless playback
//! - Volume control
//! - Real-time position tracking via events
//!
//! Uses a dedicated audio thread since rodio's OutputStream is not Send.
//! Supports both rodio (PipeWire/Pulse) and direct ALSA (hw: devices).
mod playback_engine;
mod streaming_source;
pub use streaming_source::{BufferedMediaSource, BufferWriter, StreamingConfig, IncrementalStreamingSource};
use std::io::{BufReader, Cursor, Read, Seek, SeekFrom};
use std::panic::{self, AssertUnwindSafe};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{self, Sender, RecvTimeoutError};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use rodio::{Decoder, OutputStream, Sink, Source};
use rodio::buffer::SamplesBuffer;
use rodio::decoder::Mp4Type;
use rodio::cpal::traits::{DeviceTrait, HostTrait};
use rodio::cpal::{StreamConfig, SampleRate, BufferSize, SupportedStreamConfig, SupportedBufferSize, SampleFormat};
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::DecoderOptions;
use symphonia::core::errors::Error as SymphoniaError;
use symphonia::core::formats::FormatOptions;
use symphonia::core::io::{MediaSource, MediaSourceStream};
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
use symphonia::default::{get_codecs, get_probe};
use crate::api::{client::QobuzClient, models::Quality};
use crate::audio::{AudioBackendType, BackendConfig, BackendManager};
use crate::config::audio_settings::AudioSettings;
use crate::visualizer::VisualizerSource;
use playback_engine::PlaybackEngine;
/// Commands sent to the audio thread
enum AudioCommand {
/// Play audio data with track ID, duration, and audio specs
Play {
data: Vec<u8>,
track_id: u64,
duration_secs: u64,
sample_rate: u32,
channels: u16,
},
/// Play from streaming source (BufferedMediaSource)
/// The download task should already be running and pushing to the source
PlayStreaming {
source: Arc<BufferedMediaSource>,
track_id: u64,
sample_rate: u32,
channels: u16,
duration_secs: u64,
},
/// Pause playback
Pause,
/// Resume playback
Resume,
/// Stop playback
Stop,
/// Set volume (0.0 - 1.0)
SetVolume(f32),
/// Seek to position in seconds
Seek(u64),
/// Reinitialize audio device (releases and re-acquires)
ReinitDevice { device_name: Option<String> },
}
struct CursorMediaSource {
inner: Cursor<Vec<u8>>,
len: u64,
}
impl CursorMediaSource {
fn new(data: Vec<u8>) -> Self {
let len = data.len() as u64;
Self { inner: Cursor::new(data), len }
}
}
impl MediaSource for CursorMediaSource {
fn is_seekable(&self) -> bool {
true
}
fn byte_len(&self) -> Option<u64> {
Some(self.len)
}
}
impl Read for CursorMediaSource {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.inner.read(buf)
}
}
impl Seek for CursorMediaSource {
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
self.inner.seek(pos)
}
}
/// Audio specifications extracted from decoded audio
struct AudioSpecs {
samples: SamplesBuffer<i16>,
sample_rate: u32,
channels: u16,
}
fn decode_with_symphonia(data: &[u8]) -> Result<AudioSpecs, String> {
let source = Box::new(CursorMediaSource::new(data.to_vec())) as Box<dyn MediaSource>;
let mss = MediaSourceStream::new(source, Default::default());
let mut hint = Hint::new();
hint.with_extension("m4a");
let format_opts = FormatOptions {
enable_gapless: true,
..Default::default()
};
let metadata_opts: MetadataOptions = Default::default();
let mut probed = get_probe()
.format(&hint, mss, &format_opts, &metadata_opts)
.map_err(|err| format!("Symphonia probe failed: {}", err))?;
let track = probed
.format
.default_track()
.ok_or_else(|| "Symphonia: no supported audio tracks".to_string())?;
let track_id = track.id;
let codec_params = track.codec_params.clone();
let mut decoder = get_codecs()
.make(&codec_params, &DecoderOptions::default())
.map_err(|err| format!("Symphonia decoder init failed: {}", err))?;
let mut sample_rate = 0;
let mut channels = 0u16;
let mut samples: Vec<i16> = Vec::new();
loop {
let packet = match probed.format.next_packet() {
Ok(packet) => packet,
Err(SymphoniaError::IoError(_)) => break,
Err(err) => return Err(format!("Symphonia read error: {}", err)),
};
if packet.track_id() != track_id {
continue;
}
match decoder.decode(&packet) {
Ok(audio_buf) => {
let spec = *audio_buf.spec();
if sample_rate == 0 {
sample_rate = spec.rate;
channels = spec.channels.count() as u16;
}
let mut sample_buf = SampleBuffer::<i16>::new(audio_buf.frames() as u64, spec);
sample_buf.copy_interleaved_ref(audio_buf);
samples.extend_from_slice(sample_buf.samples());
}
Err(SymphoniaError::DecodeError(_)) => continue,
Err(SymphoniaError::ResetRequired) => {
decoder.reset();
continue;
}
Err(err) => return Err(format!("Symphonia decode error: {}", err)),
}
}
if samples.is_empty() || sample_rate == 0 || channels == 0 {
return Err("Symphonia decode produced no audio".to_string());
}
Ok(AudioSpecs {
samples: SamplesBuffer::new(channels, sample_rate, samples),
sample_rate,
channels,
})
}
fn is_isomp4(data: &[u8]) -> bool {
if data.len() < 12 {
return false;
}
&data[4..8] == b"ftyp"
}
/// Extract audio metadata (sample rate, channels) without full decode.
/// This is much faster than decode_with_symphonia as it only reads headers.
fn extract_audio_metadata(data: &[u8]) -> Result<(u32, u16), String> {
// For non-isomp4 files (FLAC, etc.), try rodio's decoder first - it reads headers quickly
if !is_isomp4(data) {
let result = panic::catch_unwind(AssertUnwindSafe(|| {
Decoder::new(BufReader::new(Cursor::new(data.to_vec())))
}));
if let Ok(Ok(decoder)) = result {
return Ok((decoder.sample_rate(), decoder.channels()));
}
}
// Fallback to symphonia probe for codec params (no decode needed)
let source = Box::new(CursorMediaSource::new(data.to_vec())) as Box<dyn MediaSource>;
let mss = MediaSourceStream::new(source, Default::default());
let mut hint = Hint::new();
hint.with_extension("m4a");
let format_opts = FormatOptions {
enable_gapless: true,
..Default::default()
};
let metadata_opts: MetadataOptions = Default::default();
let probed = get_probe()
.format(&hint, mss, &format_opts, &metadata_opts)
.map_err(|err| format!("Symphonia probe failed: {}", err))?;
let track = probed
.format
.default_track()
.ok_or_else(|| "Symphonia: no supported audio tracks".to_string())?;
let sample_rate = track.codec_params.sample_rate
.ok_or_else(|| "No sample rate in codec params".to_string())?;
// ALAC and some other formats don't include channel info in initial codec params
// Default to stereo (2 channels) which is the most common case
let channels = track.codec_params.channels
.map(|c| c.count() as u16)
.unwrap_or(2);
Ok((sample_rate, channels))
}
fn decode_with_fallback(
data: &[u8],
) -> Result<Box<dyn Source<Item = i16> + Send>, String> {
if is_isomp4(data) {
return decode_with_symphonia(data)
.map(|specs| {
log::info!("Decoded audio using symphonia fallback (isomp4)");
Box::new(specs.samples) as Box<dyn Source<Item = i16> + Send>
});
}
let primary = panic::catch_unwind(AssertUnwindSafe(|| {
Decoder::new(BufReader::new(Cursor::new(data.to_vec())))
}));
match primary {
Ok(Ok(decoder)) => return Ok(Box::new(decoder)),
Ok(Err(err)) => {
log::warn!("Primary decode failed, attempting mp4 fallback: {}", err);
}
Err(_) => {
log::warn!("Primary decode panicked, attempting mp4 fallback");
}
}
let mp4_attempts = [Mp4Type::M4a, Mp4Type::Mp4];
for hint in mp4_attempts {
let hint_label = format!("{:?}", hint);
let attempt = panic::catch_unwind(AssertUnwindSafe(|| {
Decoder::new_mp4(BufReader::new(Cursor::new(data.to_vec())), hint)
}));
match attempt {
Ok(Ok(decoder)) => {
log::info!("Decoded audio using mp4 fallback ({})", hint_label);
return Ok(Box::new(decoder));
}
Ok(Err(err)) => {
log::warn!("mp4 fallback ({}) failed: {}", hint_label, err);
}
Err(_) => {
log::warn!("mp4 fallback ({}) panicked", hint_label);
}
}
}
match decode_with_symphonia(data) {
Ok(specs) => {
log::info!("Decoded audio using symphonia fallback");
Ok(Box::new(specs.samples))
}
Err(err) => Err(err),
}
}
/// Create OutputStream with custom sample rate configuration
fn create_output_stream_with_config(
device: &rodio::cpal::Device,
sample_rate: u32,
channels: u16,
exclusive_mode: bool,
) -> Result<(OutputStream, rodio::OutputStreamHandle), String> {
log::info!(
"Creating OutputStream: {}Hz, {} channels, exclusive: {}",
sample_rate,
channels,
exclusive_mode
);
// Create StreamConfig with desired sample rate
let config = StreamConfig {
channels,
sample_rate: SampleRate(sample_rate),
buffer_size: if exclusive_mode {
BufferSize::Fixed(512) // Lower latency for exclusive mode
} else {
BufferSize::Default
},
};
// Check if device supports this configuration
let supported_configs = device
.supported_output_configs()
.map_err(|e| format!("Failed to get supported configs: {}", e))?;
let mut found_matching = false;
for range in supported_configs {
if range.channels() == channels
&& sample_rate >= range.min_sample_rate().0
&& sample_rate <= range.max_sample_rate().0
{
found_matching = true;
log::info!(
"Device supports {}Hz (range: {}-{}Hz)",
sample_rate,
range.min_sample_rate().0,
range.max_sample_rate().0
);
break;
}
}
if !found_matching {
log::warn!(
"Device may not support {}Hz, attempting anyway",
sample_rate
);
}
// Create SupportedStreamConfig
let supported_config = SupportedStreamConfig::new(
config.channels,
config.sample_rate,
SupportedBufferSize::Range { min: 64, max: 8192 },
SampleFormat::F32,
);
// Create OutputStream with custom config
match OutputStream::try_from_device_config(device, supported_config) {
Ok((stream, handle)) => {
log::info!("OutputStream created successfully at {}Hz", sample_rate);
Ok((stream, handle))
}
Err(e) => {
log::error!("❌ Failed to create OutputStream at {}Hz: {}", sample_rate, e);
Err(format!("Failed to create output stream: {}", e))
}
}
}
/// Output stream type - either rodio or ALSA Direct
enum StreamType {
Rodio(OutputStream, rodio::OutputStreamHandle),
#[cfg(target_os = "linux")]
AlsaDirect(Arc<crate::audio::AlsaDirectStream>),
}
/// Try to create output stream using the backend system (if configured)
/// Returns None if backend system is not configured (backend_type = None)
///
/// For ALSA backend with hw: devices, may return AlsaDirect instead of Rodio stream.
fn try_init_stream_with_backend(
audio_settings: &AudioSettings,
sample_rate: u32,
channels: u16,
) -> Option<Result<StreamType, String>> {
// Check if backend system is configured
let backend_type = audio_settings.backend_type?;
log::info!(
"Using backend system: {:?} (device: {:?}, plugin: {:?})",
backend_type,
audio_settings.output_device,
audio_settings.alsa_plugin
);
// Create backend
let backend = match BackendManager::create_backend(backend_type) {
Ok(b) => b,
Err(e) => {
log::error!("Failed to create backend {:?}: {}", backend_type, e);
return Some(Err(e));
}
};
// Check availability
if !backend.is_available() {
let msg = format!("Backend {:?} is not available on this system", backend_type);
log::error!("{}", msg);
return Some(Err(msg));
}
// Build backend config
let config = BackendConfig {
backend_type,
device_id: audio_settings.output_device.clone(),
sample_rate,
channels,
exclusive_mode: audio_settings.exclusive_mode,
alsa_plugin: audio_settings.alsa_plugin,
};
// For ALSA backend with hw: devices, try direct ALSA first (Linux only)
#[cfg(target_os = "linux")]
if backend_type == AudioBackendType::Alsa {
// Check if device is hw: or plughw:
if let Some(ref device_id) = config.device_id {
if crate::audio::AlsaDirectStream::is_hw_device(device_id) {
log::info!("Detected hw: device, using ALSA Direct for bit-perfect playback");
// Downcast backend to AlsaBackend to access try_create_direct_stream
if let Some(alsa_backend) = backend.as_any().downcast_ref::<crate::audio::alsa_backend::AlsaBackend>() {
if let Some(result) = alsa_backend.try_create_direct_stream(&config) {
return Some(result.map(|stream| StreamType::AlsaDirect(Arc::new(stream))));
}
}
}
}
}
// Fallback to regular rodio stream (PipeWire, Pulse, ALSA via CPAL)
match backend.create_output_stream(&config) {
Ok(stream) => {
log::info!("Stream created via {:?} backend at {}Hz", backend_type, sample_rate);
Some(Ok(StreamType::Rodio(stream.0, stream.1)))
}
Err(e) => {
log::error!("❌ Backend stream creation failed: {}", e);
Some(Err(e))
}
}
}
/// Event payload for playback state updates
#[derive(Debug, Clone, serde::Serialize)]
pub struct PlaybackEvent {
pub is_playing: bool,
pub position: u64,
pub duration: u64,
pub track_id: u64,
pub volume: f32,
}
/// Shared state between main thread and audio thread
#[derive(Clone)]
pub struct SharedState {
/// Is currently playing
is_playing: Arc<AtomicBool>,
/// Current position in seconds
position: Arc<AtomicU64>,
/// Total duration in seconds
duration: Arc<AtomicU64>,
/// Current track ID
current_track_id: Arc<AtomicU64>,
/// Volume (0.0 - 1.0 stored as 0-100)
volume: Arc<AtomicU64>,
/// Playback start time (Unix timestamp millis when started/resumed)
playback_start_millis: Arc<AtomicU64>,
/// Position when playback was started/resumed (in seconds)
position_at_start: Arc<AtomicU64>,
/// Current output device name
current_device: Arc<std::sync::RwLock<Option<String>>>,
/// Stream error flag (set when ALSA/audio errors are detected)
stream_error: Arc<AtomicBool>,
}
impl Default for SharedState {
fn default() -> Self {
Self::new()
}
}
impl SharedState {
pub fn new() -> Self {
Self {
is_playing: Arc::new(AtomicBool::new(false)),
position: Arc::new(AtomicU64::new(0)),
duration: Arc::new(AtomicU64::new(0)),
current_track_id: Arc::new(AtomicU64::new(0)),
volume: Arc::new(AtomicU64::new(75)),
playback_start_millis: Arc::new(AtomicU64::new(0)),
position_at_start: Arc::new(AtomicU64::new(0)),
current_device: Arc::new(std::sync::RwLock::new(None)),
stream_error: Arc::new(AtomicBool::new(false)),
}
}
pub fn set_stream_error(&self, error: bool) {
self.stream_error.store(error, Ordering::SeqCst);
}
pub fn has_stream_error(&self) -> bool {
self.stream_error.load(Ordering::SeqCst)
}
pub fn set_current_device(&self, device: Option<String>) {
if let Ok(mut d) = self.current_device.write() {
*d = device;
}
}
pub fn current_device(&self) -> Option<String> {
self.current_device.read().ok().and_then(|d| d.clone())
}
/// Get current position based on elapsed time since playback started
pub fn current_position(&self) -> u64 {
if !self.is_playing.load(Ordering::SeqCst) {
return self.position.load(Ordering::SeqCst);
}
let start_millis = self.playback_start_millis.load(Ordering::SeqCst);
if start_millis == 0 {
return self.position.load(Ordering::SeqCst);
}
let now_millis = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let elapsed_secs = (now_millis.saturating_sub(start_millis)) / 1000;
let position_at_start = self.position_at_start.load(Ordering::SeqCst);
let duration = self.duration.load(Ordering::SeqCst);
// Clamp to duration
(position_at_start + elapsed_secs).min(duration)
}
/// Mark playback as started/resumed at current position
fn start_playback_timer(&self, position: u64) {
let now_millis = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
self.playback_start_millis.store(now_millis, Ordering::SeqCst);
self.position_at_start.store(position, Ordering::SeqCst);
}
/// Mark playback as paused, saving current position
fn pause_playback_timer(&self) {
let current_pos = self.current_position();
self.position.store(current_pos, Ordering::SeqCst);
self.playback_start_millis.store(0, Ordering::SeqCst);
}
pub fn is_playing(&self) -> bool {
self.is_playing.load(Ordering::SeqCst)
}
pub fn position(&self) -> u64 {
self.position.load(Ordering::SeqCst)
}
pub fn duration(&self) -> u64 {
self.duration.load(Ordering::SeqCst)
}
pub fn current_track_id(&self) -> u64 {
self.current_track_id.load(Ordering::SeqCst)
}
pub fn volume(&self) -> f32 {
self.volume.load(Ordering::SeqCst) as f32 / 100.0
}
}
/// Audio player that handles streaming playback
/// Uses a dedicated thread for audio output
pub struct Player {
/// Channel to send commands to the audio thread
tx: Sender<AudioCommand>,
/// Shared state accessible from any thread
pub state: SharedState,
/// Audio settings (exclusive mode, DAC passthrough, etc.)
audio_settings: Arc<Mutex<AudioSettings>>,
}
impl Default for Player {
fn default() -> Self {
Self::new(None, AudioSettings::default())
}
}
impl Player {
/// Create a new player with an optional specific output device and audio settings
/// If device_name is None, uses the system default device
pub fn new(device_name: Option<String>, audio_settings: AudioSettings) -> Self {
let (tx, rx) = mpsc::channel::<AudioCommand>();
let state = SharedState::new();
let thread_state = state.clone();
// Clone settings for thread
let settings = Arc::new(Mutex::new(audio_settings.clone()));
let thread_settings = settings.clone();
// Spawn dedicated audio thread
thread::spawn(move || {
log::info!("Audio thread starting...");
// Get the audio host
let host = rodio::cpal::default_host();
// Helper to validate a device has supported output configs
let is_device_valid = |d: &rodio::cpal::Device| -> bool {
d.supported_output_configs()
.map(|configs| configs.count() > 0)
.unwrap_or(false)
};
// Helper to find and initialize audio device
// Try backend system first, fall back to legacy CPAL
// Takes desired sample_rate and channels to maintain DAC passthrough
let init_device = |name: &Option<String>, state: &SharedState, sample_rate: u32, channels: u16| -> Option<StreamType> {
// Try backend system if configured
if let Ok(settings) = thread_settings.lock() {
if settings.backend_type.is_some() {
// Use provided sample rate/channels to maintain DAC passthrough
log::info!("Initializing backend system with {}Hz/{}ch", sample_rate, channels);
match try_init_stream_with_backend(&settings, sample_rate, channels) {
Some(Ok(stream_type)) => {
log::info!("Audio output initialized via backend system at {}Hz", sample_rate);
return Some(stream_type);
}
Some(Err(e)) => {
log::warn!("Backend system init failed: {}, falling back to legacy", e);
}
None => {
// Backend not configured, continue to legacy path
}
}
}
}
// Legacy CPAL path
let device = if let Some(ref name) = name {
log::info!("Looking for audio device: {}", name);
let found = host.output_devices()
.ok()
.and_then(|mut devices| {
devices.find(|d| d.name().ok().as_ref() == Some(name))
});
match found {
Some(d) if is_device_valid(&d) => {
log::info!("Found and validated device: {}", name);
Some(d)
}
Some(_) => {
log::warn!("Device '{}' found but has no valid output configs, using default", name);
host.default_output_device()
}
None => {
log::warn!("Device '{}' not found, using default", name);
host.default_output_device()
}
}
} else {
log::info!("Using default audio device");
host.default_output_device()
};
let device = match device {
Some(d) => {
if let Ok(name) = d.name() {
log::info!("Using audio device: {}", name);
state.set_current_device(Some(name));
}
d
}
None => {
log::error!("No audio output device available");
state.set_current_device(None);
return None;
}
};
match OutputStream::try_from_device(&device) {
Ok((stream, handle)) => {
log::info!("Audio output initialized successfully");
Some(StreamType::Rodio(stream, handle))
}
Err(e) => {
log::error!("Failed to create audio output on device: {}. Trying default...", e);
match OutputStream::try_default() {
Ok((stream, handle)) => {
log::info!("Fallback to default audio output succeeded");
Some(StreamType::Rodio(stream, handle))
}
Err(e2) => {
log::error!("Failed to create default audio output: {}", e2);
state.set_current_device(None);
None
}
}
}
}
};
// Initialize audio device lazily on first playback to avoid idle CPU usage.
let mut current_device_name = device_name.clone();
let mut stream_opt: Option<StreamType> = None;
let mut current_sample_rate: Option<u32> = None;
let mut current_channels: Option<u16> = None;
const MAX_INIT_RETRIES: u32 = 5;
const RETRY_DELAY_MS: u64 = 500;
let mut current_engine: Option<PlaybackEngine> = None;
// Store audio data for seeking (we need to re-decode from the beginning)
let mut current_audio_data: Option<Vec<u8>> = None;
// Store streaming source for resume (when download completes, we can get the data)
let mut current_streaming_source: Option<Arc<BufferedMediaSource>> = None;
// Track consecutive sink creation failures to detect broken streams
let mut consecutive_sink_failures: u32 = 0;
const MAX_SINK_FAILURES: u32 = 3;
// Delay dropping the audio stream after pause to reduce CPU usage.
const PAUSE_SUSPEND_DELAY_MS: u64 = 2000;
let mut pause_suspend_deadline: Option<Instant> = None;
let mut last_empty_check = Instant::now();
log::info!("Audio thread ready and waiting for commands");
let mut handle_command = |command: AudioCommand,
current_engine: &mut Option<PlaybackEngine>,
current_audio_data: &mut Option<Vec<u8>>,
current_streaming_source: &mut Option<Arc<BufferedMediaSource>>,
stream_opt: &mut Option<StreamType>,
current_device_name: &mut Option<String>,
consecutive_sink_failures: &mut u32,
pause_suspend_deadline: &mut Option<Instant>,
current_sample_rate: &mut Option<u32>,
current_channels: &mut Option<u16>| {
match command {
AudioCommand::Play { data, track_id, duration_secs, sample_rate, channels } => {
log::info!(
"Audio thread: playing track {} ({}Hz, {} channels)",
track_id,
sample_rate,
channels
);
*pause_suspend_deadline = None;
// Get DAC passthrough setting
let dac_passthrough = thread_settings
.lock()
.ok()
.map(|s| s.dac_passthrough)
.unwrap_or(false);
// Check if we need to recreate the stream
// Recreate on format change if DAC passthrough OR ALSA Direct is enabled (both require bit-perfect)
let format_changed = *current_sample_rate != Some(sample_rate)
|| *current_channels != Some(channels);
// Check if using ALSA Direct backend
let using_alsa_direct = thread_settings
.lock()
.ok()
.and_then(|s| s.backend_type)
.map(|b| b == AudioBackendType::Alsa)
.unwrap_or(false);
let needs_new_stream = stream_opt.is_none()
|| (dac_passthrough && format_changed)
|| (using_alsa_direct && format_changed);
if needs_new_stream {
if stream_opt.is_some() {
if (dac_passthrough || using_alsa_direct) && format_changed {
let mode = if using_alsa_direct { "ALSA Direct" } else { "DAC passthrough" };
log::info!(
"Sample rate/channels changed from {:?}Hz/{:?}ch to {}Hz/{}ch - recreating OutputStream ({})",
*current_sample_rate,
*current_channels,
sample_rate,
channels,
mode
);
} else {
log::info!("Creating initial OutputStream");
}
// Drop old stream
drop(stream_opt.take());
}
log::info!("DAC passthrough: {}, ALSA Direct: {}", dac_passthrough, using_alsa_direct);
// Try backend system first (if configured), then fall back to legacy CPAL
// This avoids unnecessary CPAL device enumeration for PipeWire DAC and ALSA Direct
let stream_result = if let Some(settings) = thread_settings.lock().ok() {
match try_init_stream_with_backend(&settings, sample_rate, channels) {
Some(result) => {
// Backend system handled it - no need for CPAL device search
result
}
None => {
// Backend system not configured, use legacy CPAL path
log::info!("Backend system not configured, using legacy CPAL path");
// Get the audio device via CPAL
let device = if let Some(ref name) = *current_device_name {
log::info!("Looking for audio device: {}", name);
let found = host.output_devices()
.ok()
.and_then(|mut devices| {
devices.find(|d| d.name().ok().as_ref() == Some(name))
});
match found {
Some(d) if is_device_valid(&d) => {
log::info!("Found and validated device: {}", name);
Some(d)
}
Some(_) => {
log::warn!("Device '{}' found but has no valid output configs, using default", name);
host.default_output_device()
}
None => {
log::warn!("Device '{}' not found, using default", name);
host.default_output_device()
}
}
} else {
log::info!("Using default audio device");
host.default_output_device()
};
let Some(device) = device else {
log::error!("No audio output device available");
thread_state.set_current_device(None);
thread_state.set_stream_error(true);
return;
};
// Set current device name
if let Ok(name) = device.name() {
log::info!("Using audio device: {}", name);
thread_state.set_current_device(Some(name));
}
create_output_stream_with_config(
&device,
sample_rate,
channels,
dac_passthrough,
).map(|(stream, handle)| StreamType::Rodio(stream, handle))
}
}
} else {
// Failed to lock settings, use legacy path with CPAL device search
let device = if let Some(ref name) = *current_device_name {
log::info!("Looking for audio device: {}", name);
host.output_devices()
.ok()
.and_then(|mut devices| {
devices.find(|d| d.name().ok().as_ref() == Some(name))
})
.or_else(|| {
log::warn!("Device '{}' not found, using default", name);
host.default_output_device()
})
} else {
host.default_output_device()
};
let Some(device) = device else {
log::error!("No audio output device available");
thread_state.set_current_device(None);
thread_state.set_stream_error(true);
return;
};
if let Ok(name) = device.name() {
thread_state.set_current_device(Some(name));
}
create_output_stream_with_config(
&device,
sample_rate,
channels,
dac_passthrough,
).map(|(stream, handle)| StreamType::Rodio(stream, handle))
};
// Handle stream creation result
match stream_result {
Ok(stream) => {
*stream_opt = Some(stream);
*current_sample_rate = Some(sample_rate);
*current_channels = Some(channels);
thread_state.set_stream_error(false);
// Set current device name from settings (for backend system)
if let Some(settings) = thread_settings.lock().ok() {
if let Some(ref device_name) = settings.output_device {
thread_state.set_current_device(Some(device_name.clone()));
log::info!("Audio stream ready at {}Hz on device: {}", sample_rate, device_name);
} else {
thread_state.set_current_device(Some("Default".to_string()));
log::info!("Audio stream ready at {}Hz on default device", sample_rate);
}
} else {
log::info!("Audio stream ready at {}Hz", sample_rate);
}
// Delay to ensure stream is fully initialized before decoder starts
// This prevents sync gaps and allows hardware to stabilize after sample rate changes
// Extra time needed for large sample rate changes (e.g., 88.2kHz → 44.1kHz)
std::thread::sleep(Duration::from_millis(150));
}
Err(e) => {
log::error!("❌ Failed to create stream at {}Hz: {}", sample_rate, e);
thread_state.set_stream_error(true);
thread_state.set_current_device(None);
return;
}
}
} else if format_changed {
// Format changed but DAC passthrough is disabled - reuse existing stream
log::info!(
"Audio format changed from {:?}Hz/{:?}ch to {}Hz/{}ch - reusing OutputStream (DAC passthrough disabled, gapless enabled)",
*current_sample_rate,
*current_channels,
sample_rate,
channels
);
}
let Some(ref stream) = *stream_opt else {
log::error!("Audio thread: no audio device available");
return;
};
// Stop previous engine and wait for sink to release resources
if let Some(engine) = current_engine.take() {
engine.stop();
// Small delay to allow the audio sink to fully release its
// reference to the OutputStreamHandle before creating a new sink.
// This prevents "resource busy" errors on rapid track switches.
std::thread::sleep(Duration::from_millis(50));
}
*current_audio_data = Some(data.clone());
*current_streaming_source = None; // Clear streaming source for non-streaming playback
// Create PlaybackEngine from StreamType
let mut engine = match stream {
StreamType::Rodio(_stream, handle) => {
match PlaybackEngine::new_rodio(handle) {
Ok(e) => {
*consecutive_sink_failures = 0;
thread_state.set_stream_error(false);
e
}
Err(e) => {
*consecutive_sink_failures += 1;
log::error!(
"Failed to create engine (attempt {}): {}",
*consecutive_sink_failures,
e
);
if *consecutive_sink_failures >= MAX_SINK_FAILURES {
log::warn!(
"Audio stream appears broken after {} failures. Auto-reinitializing...",
*consecutive_sink_failures
);
thread_state.set_stream_error(true);
drop(stream_opt.take());
std::thread::sleep(Duration::from_millis(200));
// Use last known sample rate/channels to maintain DAC passthrough
let sr = current_sample_rate.unwrap_or(48000);
let ch = current_channels.unwrap_or(2);
*stream_opt = init_device(current_device_name, &thread_state, sr, ch);
if stream_opt.is_some() {
log::info!("Audio stream auto-reinitialized successfully at {}Hz", sr);
*consecutive_sink_failures = 0;
thread_state.set_stream_error(false);
} else {
log::error!("Auto-reinit failed. Audio device unavailable.");
thread_state.is_playing.store(false, Ordering::SeqCst);
thread_state.set_current_device(None);
}
}
return;
}
}
}
#[cfg(target_os = "linux")]
StreamType::AlsaDirect(alsa_stream) => {
*consecutive_sink_failures = 0;
thread_state.set_stream_error(false);
let hardware_volume = thread_settings
.lock()
.ok()
.map(|s| s.alsa_hardware_volume)
.unwrap_or(false);
PlaybackEngine::new_alsa_direct(alsa_stream.clone(), hardware_volume)
}
};
let volume = thread_state.volume.load(Ordering::SeqCst) as f32 / 100.0;
engine.set_volume(volume);
let source = match decode_with_fallback(&data) {
Ok(s) => s,
Err(e) => {
log::error!("Failed to decode audio: {}", e);
return;
}
};
let actual_duration = source
.total_duration()
.map(|d| d.as_secs())
.unwrap_or(duration_secs);
thread_state.duration.store(actual_duration, Ordering::SeqCst);
// Wrap with visualizer tap (zero-copy passthrough)
let source = VisualizerSource::new(source);
if let Err(e) = engine.append(source) {
log::error!("Failed to append source to engine: {}", e);
return;
}
thread_state.is_playing.store(true, Ordering::SeqCst);
thread_state.position.store(0, Ordering::SeqCst);
thread_state.current_track_id.store(track_id, Ordering::SeqCst);
thread_state.start_playback_timer(0);
*current_engine = Some(engine);
log::info!(
"Audio thread: playback started, duration: {}s",
actual_duration
);
}
AudioCommand::PlayStreaming { source, track_id, sample_rate, channels, duration_secs } => {
log::info!(
"Audio thread: starting streaming playback for track {} ({}Hz, {} channels, {}s)",
track_id,
sample_rate,
channels,
duration_secs
);
*pause_suspend_deadline = None;
// Store streaming source for resume capability
// When download completes, we can extract the data for resume
*current_streaming_source = Some(source.clone());
*current_audio_data = None; // Clear regular audio data
// Get DAC passthrough setting
let dac_passthrough = thread_settings
.lock()
.ok()
.map(|s| s.dac_passthrough)
.unwrap_or(false);
// Check if we need to recreate the stream
let format_changed = *current_sample_rate != Some(sample_rate)
|| *current_channels != Some(channels);
let using_alsa_direct = thread_settings
.lock()
.ok()
.and_then(|s| s.backend_type)
.map(|b| b == AudioBackendType::Alsa)
.unwrap_or(false);
let needs_new_stream = stream_opt.is_none()
|| (dac_passthrough && format_changed)
|| (using_alsa_direct && format_changed);
if needs_new_stream {
if stream_opt.is_some() {
if (dac_passthrough || using_alsa_direct) && format_changed {
let mode = if using_alsa_direct { "ALSA Direct" } else { "DAC passthrough" };
log::info!(
"Streaming: Sample rate/channels changed to {}Hz/{}ch - recreating OutputStream ({})",
sample_rate,
channels,
mode
);
}
drop(stream_opt.take());
}
let stream_result = if let Some(settings) = thread_settings.lock().ok() {
match try_init_stream_with_backend(&settings, sample_rate, channels) {
Some(result) => result,
None => {
log::info!("Backend system not configured, using legacy CPAL path");
let device = if let Some(ref name) = *current_device_name {
host.output_devices()
.ok()
.and_then(|mut devices| {
devices.find(|d| d.name().ok().as_ref() == Some(name))
})
.or_else(|| host.default_output_device())
} else {
host.default_output_device()
};
let Some(device) = device else {
log::error!("No audio output device available for streaming");
thread_state.set_stream_error(true);
return;
};
if let Ok(name) = device.name() {
thread_state.set_current_device(Some(name));
}
create_output_stream_with_config(
&device,
sample_rate,
channels,
dac_passthrough,
).map(|(stream, handle)| StreamType::Rodio(stream, handle))
}
}
} else {
let device = host.default_output_device();
let Some(device) = device else {
log::error!("No audio output device available for streaming");
thread_state.set_stream_error(true);
return;
};
create_output_stream_with_config(&device, sample_rate, channels, dac_passthrough)
.map(|(stream, handle)| StreamType::Rodio(stream, handle))
};
match stream_result {
Ok(stream) => {
*stream_opt = Some(stream);
*current_sample_rate = Some(sample_rate);
*current_channels = Some(channels);
thread_state.set_stream_error(false);
log::info!("Streaming audio stream ready at {}Hz", sample_rate);
std::thread::sleep(Duration::from_millis(150));
}
Err(e) => {
log::error!("❌ Failed to create stream for streaming at {}Hz: {}", sample_rate, e);
thread_state.set_stream_error(true);
return;
}
}
}
let Some(ref stream) = *stream_opt else {
log::error!("Audio thread: no audio device available for streaming");
return;
};
// Stop previous engine
if let Some(engine) = current_engine.take() {
engine.stop();
std::thread::sleep(Duration::from_millis(50));
}
// Create PlaybackEngine
let mut engine = match stream {
StreamType::Rodio(_stream, handle) => {
match PlaybackEngine::new_rodio(handle) {
Ok(e) => {
*consecutive_sink_failures = 0;
thread_state.set_stream_error(false);
e
}
Err(e) => {
log::error!("Failed to create engine for streaming: {}", e);
return;
}
}
}
#[cfg(target_os = "linux")]
StreamType::AlsaDirect(alsa_stream) => {
let hardware_volume = thread_settings
.lock()
.ok()
.map(|s| s.alsa_hardware_volume)
.unwrap_or(false);
PlaybackEngine::new_alsa_direct(alsa_stream.clone(), hardware_volume)
}
};
let volume = thread_state.volume.load(Ordering::SeqCst) as f32 / 100.0;
engine.set_volume(volume);
// Wait for minimum buffer before starting playback
log::info!("Streaming: waiting for initial buffer...");
let start_wait = Instant::now();
let max_wait = Duration::from_secs(30);
while !source.has_min_buffer() && start_wait.elapsed() < max_wait {
std::thread::sleep(Duration::from_millis(50));
}
if !source.has_min_buffer() {
log::error!("Streaming: timeout waiting for initial buffer");
return;
}
let buffer_wait_ms = start_wait.elapsed().as_millis();
log::info!(
"Streaming: initial buffer ready in {}ms, creating incremental decoder...",
buffer_wait_ms
);
// Create incremental streaming source - this starts playback IMMEDIATELY
// while continuing to decode/download in background
let incremental_source = match IncrementalStreamingSource::new(source.clone()) {
Ok(s) => s,
Err(e) => {
log::error!("Failed to create incremental streaming source: {}", e);
return;
}
};
// Verify sample rate/channels match what we expected
let actual_sr = incremental_source.get_sample_rate();
let actual_ch = incremental_source.get_channels();
if actual_sr != sample_rate || actual_ch != channels {
log::warn!(
"Streaming: detected format {}Hz/{}ch differs from expected {}Hz/{}ch",
actual_sr, actual_ch, sample_rate, channels
);
}
// Set duration from track metadata (passed from frontend)
// This allows the seekbar to show progress even during streaming
thread_state.duration.store(duration_secs, Ordering::SeqCst);
// Wrap with visualizer tap (zero-copy passthrough)
let visualized_source = VisualizerSource::new(incremental_source);
// Box the source to match the expected type
let source_to_play: Box<dyn Source<Item = i16> + Send> = Box::new(visualized_source);
if let Err(e) = engine.append(source_to_play) {
log::error!("Failed to append streaming source to engine: {}", e);
return;
}
thread_state.is_playing.store(true, Ordering::SeqCst);
thread_state.position.store(0, Ordering::SeqCst);
thread_state.current_track_id.store(track_id, Ordering::SeqCst);
thread_state.start_playback_timer(0);
*current_engine = Some(engine);
log::info!(
"Audio thread: streaming playback STARTED in {}ms (incremental decode active)",
start_wait.elapsed().as_millis()
);
}
AudioCommand::Pause => {
if let Some(ref engine) = *current_engine {
engine.pause();
thread_state.pause_playback_timer();
thread_state.is_playing.store(false, Ordering::SeqCst);
*pause_suspend_deadline =
Some(Instant::now() + Duration::from_millis(PAUSE_SUSPEND_DELAY_MS));
log::info!(
"Audio thread: paused at {}s",
thread_state.position.load(Ordering::SeqCst)
);
}
}
AudioCommand::Resume => {
*pause_suspend_deadline = None;
if current_engine.is_none() {
// Try to get audio data from regular storage or streaming source
let audio_data: Vec<u8> = if let Some(ref data) = *current_audio_data {
data.clone()
} else if let Some(ref streaming_src) = *current_streaming_source {
// Try to get complete data from streaming source
if streaming_src.is_complete() {
match streaming_src.take_complete_data() {
Some(data) => {
log::info!("Resume: using complete streaming data ({} bytes)", data.len());
// Store it in current_audio_data for future use
*current_audio_data = Some(data.clone());
data
}
None => {
log::warn!("Audio thread: cannot resume - streaming source complete but data unavailable");
return;
}
}
} else {
log::warn!("Audio thread: cannot resume - streaming not complete yet ({} bytes buffered)",
streaming_src.buffer_size());
return;
}
} else {
log::warn!("Audio thread: cannot resume - no audio data available");
return;
};
if stream_opt.is_none() {
// Use last known sample rate/channels to maintain DAC passthrough
let sr = current_sample_rate.unwrap_or(48000);
let ch = current_channels.unwrap_or(2);
log::info!("Resume: reinitializing stream at {}Hz/{}ch", sr, ch);
*stream_opt = init_device(current_device_name, &thread_state, sr, ch);
}
let Some(ref stream) = *stream_opt else {
log::error!("Audio thread: cannot resume - no audio device available");
return;
};
let mut engine = match stream {
StreamType::Rodio(_stream, handle) => {
match PlaybackEngine::new_rodio(handle) {
Ok(e) => e,
Err(e) => {
log::error!("Failed to create engine for resume: {}", e);
return;
}
}
}
#[cfg(target_os = "linux")]
StreamType::AlsaDirect(alsa_stream) => {
let hardware_volume = thread_settings
.lock()
.ok()
.map(|s| s.alsa_hardware_volume)
.unwrap_or(false);
PlaybackEngine::new_alsa_direct(alsa_stream.clone(), hardware_volume)
}
};
let volume = thread_state.volume.load(Ordering::SeqCst) as f32 / 100.0;
engine.set_volume(volume);
let source = match decode_with_fallback(&audio_data) {
Ok(s) => s,
Err(e) => {
log::error!("Failed to decode audio for resume: {}", e);
return;
}
};
let resume_pos = thread_state.position.load(Ordering::SeqCst);
let skipped_source: Box<dyn Source<Item = i16> + Send> = if resume_pos > 0 {
// Wrap with visualizer tap, then skip
let viz_source = VisualizerSource::new(source);
Box::new(viz_source.skip_duration(Duration::from_secs(resume_pos)))
} else {
Box::new(VisualizerSource::new(source))
};
if let Err(e) = engine.append(skipped_source) {
log::error!("Failed to append source for resume: {}", e);
return;
}
thread_state.start_playback_timer(resume_pos);
thread_state.is_playing.store(true, Ordering::SeqCst);
*current_engine = Some(engine);
log::info!("Audio thread: resumed from {}s", resume_pos);
return;
}
if let Some(ref engine) = *current_engine {
engine.play();
let current_pos = thread_state.position.load(Ordering::SeqCst);
thread_state.start_playback_timer(current_pos);
thread_state.is_playing.store(true, Ordering::SeqCst);
log::info!("Audio thread: resumed");
}
}
AudioCommand::Stop => {
if let Some(engine) = current_engine.take() {
engine.stop();
}
*current_audio_data = None;
*current_streaming_source = None;
thread_state.is_playing.store(false, Ordering::SeqCst);
thread_state.position.store(0, Ordering::SeqCst);
thread_state.playback_start_millis.store(0, Ordering::SeqCst);
thread_state.position_at_start.store(0, Ordering::SeqCst);
// Drop the stream to release the device and stop background CPU use.
drop(stream_opt.take());
*pause_suspend_deadline = None;
log::info!("Audio thread: stopped");
}
AudioCommand::SetVolume(volume) => {
thread_state
.volume
.store((volume * 100.0) as u64, Ordering::SeqCst);
if let Some(ref engine) = *current_engine {
engine.set_volume(volume);
}
log::info!("Audio thread: volume set to {}", volume);
}
AudioCommand::Seek(position_secs) => {
*pause_suspend_deadline = None;
let Some(ref audio_data) = *current_audio_data else {
log::warn!("Audio thread: cannot seek - no audio data available");
return;
};
let Some(ref stream) = *stream_opt else {
log::error!("Audio thread: cannot seek - no audio device available");
return;
};
log::info!("Audio thread: seeking to {}s", position_secs);
if let Some(engine) = current_engine.take() {
engine.stop();
}
let mut engine = match stream {
StreamType::Rodio(_stream, handle) => {
match PlaybackEngine::new_rodio(handle) {
Ok(e) => e,
Err(e) => {
log::error!("Failed to create rodio engine for seek: {}", e);
return;
}
}
}
#[cfg(target_os = "linux")]
StreamType::AlsaDirect(alsa_stream) => {
let hardware_volume = thread_settings
.lock()
.ok()
.map(|s| s.alsa_hardware_volume)
.unwrap_or(false);
PlaybackEngine::new_alsa_direct(alsa_stream.clone(), hardware_volume)
}
};
let volume = thread_state.volume.load(Ordering::SeqCst) as f32 / 100.0;
engine.set_volume(volume);
let source = match decode_with_fallback(audio_data) {
Ok(s) => s,
Err(e) => {
log::error!("Failed to decode audio for seek: {}", e);
return;
}
};
// Wrap with visualizer tap, then skip
let viz_source = VisualizerSource::new(source);
let skip_duration = Duration::from_secs(position_secs);
let skipped_source = viz_source.skip_duration(skip_duration);
if let Err(e) = engine.append(skipped_source) {
log::error!("Failed to append source for seek: {}", e);
return;
}
let was_playing = thread_state.is_playing.load(Ordering::SeqCst);
if !was_playing {
engine.pause();
}
thread_state.position.store(position_secs, Ordering::SeqCst);
if was_playing {
thread_state.start_playback_timer(position_secs);
}
*current_engine = Some(engine);
log::info!(
"Audio thread: seeked to {}s (was_playing: {})",
position_secs,
was_playing
);
}
AudioCommand::ReinitDevice { device_name: new_device } => {
log::info!(
"Audio thread: reinitializing device (new: {:?})",
new_device
);
*pause_suspend_deadline = None;
if let Some(engine) = current_engine.take() {
engine.stop();
}
drop(stream_opt.take());
log::info!("Audio thread: previous stream dropped, device released");
std::thread::sleep(Duration::from_millis(100));
*current_device_name = new_device;
// Use last known sample rate/channels to maintain DAC passthrough
let sr = current_sample_rate.unwrap_or(48000);
let ch = current_channels.unwrap_or(2);
log::info!("ReinitDevice: reinitializing at {}Hz/{}ch", sr, ch);
*stream_opt = init_device(current_device_name, &thread_state, sr, ch);
if stream_opt.is_some() {
log::info!("Audio thread: device reinitialized successfully");
*consecutive_sink_failures = 0;
} else {
log::error!("Audio thread: failed to reinitialize device");
}
thread_state.is_playing.store(false, Ordering::SeqCst);
thread_state.position.store(0, Ordering::SeqCst);
thread_state.playback_start_millis.store(0, Ordering::SeqCst);
*current_audio_data = None;
}
}
};
loop {
if thread_state.is_playing.load(Ordering::SeqCst) {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(command) => handle_command(
command,
&mut current_engine,
&mut current_audio_data,
&mut current_streaming_source,
&mut stream_opt,
&mut current_device_name,
&mut consecutive_sink_failures,
&mut pause_suspend_deadline,
&mut current_sample_rate,
&mut current_channels,
),
Err(RecvTimeoutError::Timeout) => {
let now = Instant::now();
if now.duration_since(last_empty_check) >= Duration::from_millis(500) {
last_empty_check = now;
if let Some(ref engine) = current_engine {
if engine.empty()
&& thread_state.is_playing.load(Ordering::SeqCst)
{
log::info!("Audio thread: track finished (engine empty)");
thread_state.is_playing.store(false, Ordering::SeqCst);
let duration = thread_state.duration.load(Ordering::SeqCst);
thread_state.position.store(duration, Ordering::SeqCst);
thread_state.playback_start_millis.store(0, Ordering::SeqCst);
}
}
}
}
Err(RecvTimeoutError::Disconnected) => {
log::info!("Audio thread: channel closed, exiting");
break;
}
}
} else {
if let Some(deadline) = pause_suspend_deadline {
if stream_opt.is_some() {
let now = Instant::now();
if now >= deadline {
if let Some(engine) = current_engine.take() {
engine.stop();
}
drop(stream_opt.take());
pause_suspend_deadline = None;
log::info!("Audio thread: suspended stream after pause");
continue;
}
let wait = deadline.saturating_duration_since(now);
let wait = std::cmp::min(wait, Duration::from_millis(250));
match rx.recv_timeout(wait) {
Ok(command) => handle_command(
command,
&mut current_engine,
&mut current_audio_data,
&mut current_streaming_source,
&mut stream_opt,
&mut current_device_name,
&mut consecutive_sink_failures,
&mut pause_suspend_deadline,
&mut current_sample_rate,
&mut current_channels,
),
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => {
log::info!("Audio thread: channel closed, exiting");
break;
}
}
continue;
}
pause_suspend_deadline = None;
}
match rx.recv() {
Ok(command) => handle_command(
command,
&mut current_engine,
&mut current_audio_data,
&mut current_streaming_source,
&mut stream_opt,
&mut current_device_name,
&mut consecutive_sink_failures,
&mut pause_suspend_deadline,
&mut current_sample_rate,
&mut current_channels,
),
Err(_) => {
log::info!("Audio thread: channel closed, exiting");
break;
}
}
}
}
});
Self { tx, state, audio_settings: settings }
}
/// Play a track by ID (downloads audio)
pub async fn play_track(
&self,
client: &QobuzClient,
track_id: u64,
quality: Quality,
) -> Result<(), String> {
log::info!("Player: Starting playback for track {} with quality {:?}", track_id, quality);
// Get the stream URL
log::info!("Player: Getting stream URL...");
let stream_url = client
.get_stream_url_with_fallback(track_id, quality)
.await
.map_err(|e| {
log::error!("Player: Failed to get stream URL: {}", e);
format!("Failed to get stream URL: {}", e)
})?;
log::info!("Player: Got stream URL: {} (format: {})", stream_url.url, stream_url.mime_type);
// Download the audio data
log::info!("Player: Starting audio caching...");
let audio_data = self.download_audio(&stream_url.url).await.map_err(|e| {
log::error!("Player: Caching failed: {}", e);
e
})?;
log::info!("Player: Cached {} bytes of audio data", audio_data.len());
// Send to audio thread
self.play_data(audio_data, track_id)
}
/// Play from raw audio data (for cached tracks)
pub fn play_data(&self, data: Vec<u8>, track_id: u64) -> Result<(), String> {
log::info!("Player: Playing {} bytes of audio data for track {}", data.len(), track_id);
// Extract audio metadata (sample rate and channels) - fast header-only read
let (sample_rate, channels) = extract_audio_metadata(&data)
.map_err(|e| format!("Failed to extract audio metadata: {}", e))?;
log::info!(
"Player: Detected audio format - {}Hz, {} channels",
sample_rate,
channels
);
self.tx
.send(AudioCommand::Play {
data,
track_id,
duration_secs: 0, // Will be determined by decoder
sample_rate,
channels,
})
.map_err(|e| {
log::error!("Player: Failed to send to audio thread: {}", e);
format!("Failed to send play command (audio thread may have crashed): {}", e)
})?;
log::info!("Player: Playback initiated successfully");
Ok(())
}
/// Play from streaming source (starts playback before full download)
/// Returns the BufferWriter so caller can push data as it downloads
pub fn play_streaming(
&self,
track_id: u64,
sample_rate: u32,
channels: u16,
content_length: u64,
buffer_seconds: u8,
duration_secs: u64,
) -> Result<BufferWriter, String> {
log::info!(
"Player: Starting streaming playback for track {} ({}Hz, {}ch, {} bytes total, {}s)",
track_id,
sample_rate,
channels,
content_length,
duration_secs
);
// Use StreamingConfig::from_seconds for proper buffer sizing
let config = StreamingConfig::from_seconds(buffer_seconds);
let (source, writer) = BufferedMediaSource::new(config, Some(content_length));
let source = Arc::new(source);
self.tx
.send(AudioCommand::PlayStreaming {
source: source.clone(),
track_id,
sample_rate,
channels,
duration_secs,
})
.map_err(|e| {
log::error!("Player: Failed to send streaming command: {}", e);
format!("Failed to send streaming play command: {}", e)
})?;
log::info!("Player: Streaming playback initiated");
Ok(writer)
}
/// Play from streaming source with dynamic buffer based on measured speed
/// Returns the BufferWriter so caller can push data as it downloads
pub fn play_streaming_dynamic(
&self,
track_id: u64,
sample_rate: u32,
channels: u16,
content_length: u64,
speed_mbps: f64,
duration_secs: u64,
) -> Result<BufferWriter, String> {
log::info!(
"Player: Starting dynamic streaming for track {} ({}Hz, {}ch, {:.2} MB, {:.1} MB/s, {}s)",
track_id,
sample_rate,
channels,
content_length as f64 / (1024.0 * 1024.0),
speed_mbps,
duration_secs
);
// Use StreamingConfig::from_speed_mbps for dynamic buffer sizing
let config = StreamingConfig::from_speed_mbps(speed_mbps);
let (source, writer) = BufferedMediaSource::new(config, Some(content_length));
let source = Arc::new(source);
self.tx
.send(AudioCommand::PlayStreaming {
source: source.clone(),
track_id,
sample_rate,
channels,
duration_secs,
})
.map_err(|e| {
log::error!("Player: Failed to send streaming command: {}", e);
format!("Failed to send streaming play command: {}", e)
})?;
log::info!("Player: Dynamic streaming playback initiated");
Ok(writer)
}
/// Download audio from URL with timeout
async fn download_audio(&self, url: &str) -> Result<Vec<u8>, String> {
use std::time::Duration;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(60))
.connect_timeout(Duration::from_secs(10))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
log::info!("Caching audio from URL...");
let response = client
.get(url)
.header("User-Agent", "Mozilla/5.0")
.send()
.await
.map_err(|e| format!("Failed to fetch audio: {}", e))?;
if !response.status().is_success() {
return Err(format!("HTTP error: {}", response.status()));
}
log::info!("Response received, reading bytes...");
let bytes = response
.bytes()
.await
.map_err(|e| format!("Failed to read audio bytes: {}", e))?;
log::info!("Cached {} bytes", bytes.len());
Ok(bytes.to_vec())
}
/// Pause playback
pub fn pause(&self) -> Result<(), String> {
self.tx
.send(AudioCommand::Pause)
.map_err(|e| format!("Failed to send pause command: {}", e))
}
/// Resume playback
pub fn resume(&self) -> Result<(), String> {
self.tx
.send(AudioCommand::Resume)
.map_err(|e| format!("Failed to send resume command: {}", e))
}
/// Stop playback
pub fn stop(&self) -> Result<(), String> {
self.tx
.send(AudioCommand::Stop)
.map_err(|e| format!("Failed to send stop command: {}", e))
}
/// Set volume (0.0 - 1.0)
pub fn set_volume(&self, volume: f32) -> Result<(), String> {
let clamped = volume.clamp(0.0, 1.0);
self.tx
.send(AudioCommand::SetVolume(clamped))
.map_err(|e| format!("Failed to send volume command: {}", e))
}
/// Seek to position in seconds
pub fn seek(&self, position: u64) -> Result<(), String> {
// Clamp to duration if known
let duration = self.state.duration();
let clamped_position = if duration > 0 {
position.min(duration)
} else {
position
};
self.tx
.send(AudioCommand::Seek(clamped_position))
.map_err(|e| format!("Failed to send seek command: {}", e))
}
/// Reinitialize audio device (releases and re-acquires the device)
/// Use this when changing audio settings like exclusive mode
pub fn reinit_device(&self, device_name: Option<String>) -> Result<(), String> {
self.tx
.send(AudioCommand::ReinitDevice { device_name })
.map_err(|e| format!("Failed to send reinit command: {}", e))
}
/// Reload audio settings from fresh config (e.g., after database update)
/// Call this before reinit_device() to ensure Player uses latest settings
pub fn reload_settings(&self, settings: AudioSettings) -> Result<(), String> {
if let Ok(mut current_settings) = self.audio_settings.lock() {
*current_settings = settings;
Ok(())
} else {
Err("Failed to lock audio settings".to_string())
}
}
/// Get current playback state with real-time position
pub fn get_state(&self) -> Result<PlaybackState, String> {
Ok(PlaybackState {
is_playing: self.state.is_playing(),
position: self.state.current_position(),
duration: self.state.duration(),
track_id: self.state.current_track_id(),
volume: self.state.volume(),
})
}
/// Get playback event for emitting to frontend
pub fn get_playback_event(&self) -> PlaybackEvent {
PlaybackEvent {
is_playing: self.state.is_playing(),
position: self.state.current_position(),
duration: self.state.duration(),
track_id: self.state.current_track_id(),
volume: self.state.volume(),
}
}
}
/// Playback state snapshot
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct PlaybackState {
pub is_playing: bool,
pub position: u64,
pub duration: u64,
pub track_id: u64,
pub volume: f32,
}