diff --git a/CHANGELOG.md b/CHANGELOG.md index f6e4ad4e..001faccd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -191,6 +191,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Fixed +### Windows — idle app no longer blocks system sleep + +**By [@cucadmuh](https://github.com/cucadmuh), reported by [@Thraka](https://github.com/Thraka), PR [#1073](https://github.com/Psychotoxical/psysonic/pull/1073)** + +* Psysonic no longer keeps the audio output device open while the app is idle — the CPAL stream opens on first playback and closes after **60 seconds** without active audio (pause), or **immediately** on Stop / natural queue end, so Windows `powercfg` no longer reports an in-use audio stream when music is not playing ([#1071](https://github.com/Psychotoxical/psysonic/issues/1071)). +* Resume after a long pause uses the existing cold path (`audio:output-released` resets the warm-pause flag); post-sleep recovery skips reopening the stream when nothing is playing. +* **Cold start while paused:** after `getPlayQueue` restores position, the seekbar shows the saved time immediately; the current track is hot-cache prefetched and the engine loads silently (`audio_play` with `startPaused`) at that position so the next Play is a warm `audio_resume` without an audible blip at the start of the track. +* Rodio `Dropping DeviceSink` warnings on stream release are suppressed unless logging is in debug mode. +* **Stop keeps the waveform:** stopping no longer wipes the seekbar waveform for the still-shown track — the cached analysis bins are kept and re-hydrated from the database, so the real waveform stays mounted instead of falling back to flat bars. + ### Internet radio — no more duplicate now-playing on Linux **By [@Psychotoxical](https://github.com/Psychotoxical), reported by agriffit79, PR [#1069](https://github.com/Psychotoxical/psysonic/pull/1069)** diff --git a/src-tauri/crates/psysonic-audio/src/commands.rs b/src-tauri/crates/psysonic-audio/src/commands.rs index f07eee43..598dddec 100644 --- a/src-tauri/crates/psysonic-audio/src/commands.rs +++ b/src-tauri/crates/psysonic-audio/src/commands.rs @@ -15,9 +15,12 @@ use super::engine::{audio_http_client, AudioEngine}; use super::helpers::*; use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload}; use super::play_input::{ - build_playback_source_with_probe_fallback, select_play_input, - spawn_legacy_stream_start_when_armed, swap_in_new_sink, url_format_hint, BuildSourceArgs, - PlayInputContext, SinkSwapInputs, + build_playback_source_with_probe_fallback, select_play_input, url_format_hint, BuildSourceArgs, + PlayInputContext, +}; +use super::sink_swap::{ + spawn_legacy_stream_start_when_armed, swap_in_new_sink, LegacyStreamStartWhenArmed, + SinkSwapInputs, }; use super::playback_rate::preserve_pitch_will_run; use super::preview::preview_clear_for_new_main_playback; @@ -53,9 +56,13 @@ pub async fn audio_play( analysis_track_id: Option, server_id: Option, stream_format_suffix: Option, + // Silent load: no `audio:playing`, sink stays paused. Optional + defaults to + // `false` so older/external `audio_play` callers that omit it still work. + start_paused: Option, app: AppHandle, state: State<'_, AudioEngine>, ) -> Result<(), String> { + let start_paused = start_paused.unwrap_or(false); let gapless = state.gapless_enabled.load(Ordering::Relaxed); // ── Ghost-command guard ─────────────────────────────────────────────────── @@ -311,23 +318,25 @@ pub async fn audio_play( }; let needs_switch = target_rate > 0 && target_rate != current_stream_rate; if needs_switch { - let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::>(0); let dev = state.selected_device.lock().unwrap().clone(); - if state.stream_reopen_tx.send((target_rate, hi_res_enabled, dev, reply_tx)).is_ok() { - match reply_rx.recv_timeout(std::time::Duration::from_secs(5)) { - Ok(new_handle) => { - *state.stream_handle.lock().unwrap() = new_handle; - state.stream_sample_rate.store(target_rate, Ordering::Relaxed); - // Give PipeWire time to reconfigure at the new rate before - // we open a Sink — only needed for large hi-res quanta. - if hi_res_enabled && target_rate > 48_000 { - tokio::time::sleep(Duration::from_millis(150)).await; - } - } - Err(_) => { - crate::app_eprintln!("[psysonic] stream rate switch timed out, keeping {current_stream_rate} Hz"); + match super::engine::open_output_stream_blocking( + &state, + target_rate, + hi_res_enabled, + dev, + ) { + Ok(_) => { + // Give PipeWire time to reconfigure at the new rate before + // we open a Sink — only needed for large hi-res quanta. + if hi_res_enabled && target_rate > 48_000 { + tokio::time::sleep(Duration::from_millis(150)).await; } } + Err(_) => { + crate::app_eprintln!( + "[psysonic] stream rate switch timed out, keeping {current_stream_rate} Hz" + ); + } } } @@ -337,7 +346,8 @@ pub async fn audio_play( } } - let sink = Arc::new(Player::connect_new(state.stream_handle.lock().unwrap().mixer())); + let stream = super::engine::ensure_output_stream_open(&state)?; + let sink = Arc::new(Player::connect_new(stream.mixer())); sink.set_volume(effective_volume); // ── Sink pre-fill for hi-res tracks ────────────────────────────────────── @@ -401,7 +411,7 @@ pub async fn audio_play( if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); // skipped during pre-fill — abort silently } - if !defer_playback_start { + if !defer_playback_start && !start_paused { sink.play(); } } @@ -415,24 +425,26 @@ pub async fn audio_play( fadeout_samples: built.fadeout_samples, crossfade_enabled, actual_fade_secs, + start_paused, }); if defer_playback_start { - { + if !start_paused { let mut cur = state.current.lock().unwrap(); cur.play_started = None; cur.paused_at = Some(0.0); } - spawn_legacy_stream_start_when_armed( + spawn_legacy_stream_start_when_armed(LegacyStreamStartWhenArmed { gen, - state.generation.clone(), - state.stream_playback_armed.clone(), - state.samples_played.clone(), - state.current.clone(), - app.clone(), + gen_arc: state.generation.clone(), + playback_armed: state.stream_playback_armed.clone(), + samples_played: state.samples_played.clone(), + current: state.current.clone(), + app: app.clone(), duration_secs, - ); - } else { + hold_paused: start_paused, + }); + } else if !start_paused { app.emit("audio:playing", duration_secs).ok(); } diff --git a/src-tauri/crates/psysonic-audio/src/device_commands.rs b/src-tauri/crates/psysonic-audio/src/device_commands.rs index 577ee6ec..0f4e108a 100644 --- a/src-tauri/crates/psysonic-audio/src/device_commands.rs +++ b/src-tauri/crates/psysonic-audio/src/device_commands.rs @@ -2,9 +2,7 @@ //! `commands.rs` so playback / radio / EQ aren't entangled with the device //! enumeration + reopen path. -use std::sync::Arc; use std::sync::atomic::Ordering; -use std::time::Duration; use tauri::{Emitter, State}; @@ -78,16 +76,13 @@ pub async fn audio_set_device( *state.selected_device.lock().unwrap() = device_name.clone(); let rate = state.stream_sample_rate.load(Ordering::Relaxed); - let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::>(0); - state.stream_reopen_tx - .send((rate, false, device_name, reply_tx)) - .map_err(|e| e.to_string())?; - - let new_handle = tauri::async_runtime::spawn_blocking(move || { - reply_rx.recv_timeout(Duration::from_secs(5)).ok() - }).await.unwrap_or(None).ok_or("device open timed out")?; - - *state.stream_handle.lock().unwrap() = new_handle; + let open_rate = if rate > 0 { + rate + } else { + state.device_default_rate + }; + super::engine::open_output_stream_blocking(&state, open_rate, false, device_name.clone()) + .map_err(|_| "device open timed out".to_string())?; // Capture position and drop the active sink atomically so the position // reading is still valid (play_started / paused_at intact before take). diff --git a/src-tauri/crates/psysonic-audio/src/device_resume.rs b/src-tauri/crates/psysonic-audio/src/device_resume.rs index 481ba617..8e1c52a3 100644 --- a/src-tauri/crates/psysonic-audio/src/device_resume.rs +++ b/src-tauri/crates/psysonic-audio/src/device_resume.rs @@ -24,9 +24,10 @@ use tauri::Manager; use super::engine::AudioEngine; use super::play_input::{ - build_playback_source_with_probe_fallback, swap_in_new_sink, url_format_hint, - BuildSourceArgs, PlayInput, PlaybackSource, SinkSwapInputs, + build_playback_source_with_probe_fallback, url_format_hint, BuildSourceArgs, PlayInput, + PlaybackSource, }; +use super::sink_swap::{swap_in_new_sink, SinkSwapInputs}; use super::progress_task::spawn_progress_task; use super::stream::LocalFileSource; @@ -187,9 +188,14 @@ pub(crate) async fn try_resume_after_device_change( .current_channels .store(ps.built.output_channels as u32, Ordering::Relaxed); - let sink = Arc::new(Player::connect_new( - engine.stream_handle.lock().unwrap().mixer(), - )); + let stream = match super::engine::ensure_output_stream_open(&engine) { + Ok(s) => s, + Err(e) => { + crate::app_eprintln!("[device-resume] output stream open failed: {e}"); + return false; + } + }; + let sink = Arc::new(Player::connect_new(stream.mixer())); let effective_volume = (snap.base_volume * snap.gain_linear).clamp(0.0, 1.0); sink.set_volume(effective_volume); sink.append(ps.built.source); @@ -205,6 +211,7 @@ pub(crate) async fn try_resume_after_device_change( fadeout_samples: ps.built.fadeout_samples, crossfade_enabled: false, actual_fade_secs: 0.0, + start_paused: false, }, ); diff --git a/src-tauri/crates/psysonic-audio/src/device_watcher.rs b/src-tauri/crates/psysonic-audio/src/device_watcher.rs index 2f83b2fb..7c55b544 100644 --- a/src-tauri/crates/psysonic-audio/src/device_watcher.rs +++ b/src-tauri/crates/psysonic-audio/src/device_watcher.rs @@ -1,6 +1,5 @@ //! Poll default output device and pinned-device presence; reopen stream when needed. use std::sync::atomic::Ordering; -use std::sync::Arc; use std::time::{Duration, Instant}; use tauri::Emitter; @@ -41,8 +40,11 @@ pub(crate) async fn reopen_output_stream( }; let rate = engine.stream_sample_rate.load(Ordering::Relaxed); - let reopen_tx = engine.stream_reopen_tx.clone(); - let stream_handle = engine.stream_handle.clone(); + let open_rate = if rate > 0 { + rate + } else { + engine.device_default_rate + }; let current = engine.current.clone(); let fading_out = engine.fading_out_sink.clone(); @@ -62,25 +64,24 @@ pub(crate) async fn reopen_output_stream( } }; - let new_handle = tauri::async_runtime::spawn_blocking(move || { - let (reply_tx, reply_rx) = - std::sync::mpsc::sync_channel::>(0); - if reopen_tx - .send((rate, false, device_name, reply_tx)) - .is_err() - { - return None; - } - reply_rx.recv_timeout(Duration::from_secs(5)).ok() + let app_for_open = app.clone(); + let device_name_for_open = device_name.clone(); + let opened = tauri::async_runtime::spawn_blocking(move || { + let engine = app_for_open.state::(); + super::engine::open_output_stream_blocking( + &engine, + open_rate, + false, + device_name_for_open, + ) + .is_ok() }) .await - .unwrap_or(None); + .unwrap_or(false); - let Some(handle) = new_handle else { + if !opened { return false; - }; - - *stream_handle.lock().unwrap() = handle; + } if let Some(s) = current.lock().unwrap().sink.take() { s.stop(); } diff --git a/src-tauri/crates/psysonic-audio/src/engine.rs b/src-tauri/crates/psysonic-audio/src/engine.rs index 4a1c30ef..76d35891 100644 --- a/src-tauri/crates/psysonic-audio/src/engine.rs +++ b/src-tauri/crates/psysonic-audio/src/engine.rs @@ -7,21 +7,32 @@ use rodio::Player; use super::state::{ChainedInfo, PreloadedTrack, StreamCompletedSpill}; -/// Reply channel handed back to the audio-stream thread once a re-open finishes. -pub type StreamReopenReply = std::sync::mpsc::SyncSender>; -/// Stream-thread re-open request: `(desired_rate, is_hi_res, device_name, reply_tx)`. -pub type StreamReopenRequest = (u32, bool, Option, StreamReopenReply); +/// Reply channel handed back to the audio-stream thread once an open finishes. +pub type StreamOpenReply = + std::sync::mpsc::SyncSender<(Arc, u32)>; + +/// Requests handled on the dedicated audio-stream thread (open / idle release). +pub enum StreamThreadMsg { + Open { + desired_rate: u32, + is_hi_res: bool, + device_name: Option, + reply: StreamOpenReply, + }, + Release { + reply: std::sync::mpsc::SyncSender<()>, + }, +} pub struct AudioEngine { - pub stream_handle: Arc>>, + pub stream_handle: Arc>>>, /// Sample rate the output stream was last opened at (updated on every re-open). pub stream_sample_rate: Arc, /// The rate the device was opened at on cold start — used to restore the /// stream when Hi-Res is toggled off while a hi-res rate is active. pub device_default_rate: u32, - /// Sends `(desired_rate, is_hi_res, device_name, reply_tx)` to the audio-stream - /// thread to re-open the output device. `device_name = None` → system default. - pub stream_reopen_tx: std::sync::mpsc::SyncSender, + /// Open or release the CPAL output stream on the audio-stream thread. + pub stream_thread_tx: std::sync::mpsc::SyncSender, /// User-selected output device name (None = follow system default). pub selected_device: Arc>>, pub current: Arc>, @@ -147,6 +158,15 @@ impl AudioCurrent { /// 3. Device default. /// 4. System default (last resort). /// +/// Rodio prints a stderr line on every intentional stream drop. Keep that only +/// when runtime logging is in **debug** mode; normal/off silence the noise. +fn finalize_mixer_device_sink(mut handle: rodio::MixerDeviceSink) -> Arc { + if !crate::logging::should_log_debug() { + handle.log_on_drop(false); + } + Arc::new(handle) +} + /// Returns `(stream_handle, actual_sample_rate)`. fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32) -> (Arc, u32) { use rodio::cpal::traits::{DeviceTrait, HostTrait}; @@ -214,7 +234,7 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32) .and_then(|b| b.with_sample_rate(std::num::NonZeroU32::new(desired_rate).unwrap_or(std::num::NonZeroU32::MIN)).open_stream()) { crate::app_eprintln!("[psysonic] audio stream opened at {} Hz (exact)", desired_rate); - return (Arc::new(handle), desired_rate); + return (finalize_mixer_device_sink(handle), desired_rate); } } @@ -231,7 +251,7 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32) "[psysonic] audio stream opened at {} Hz (highest, wanted {})", rate, desired_rate ); - return (Arc::new(handle), rate); + return (finalize_mixer_device_sink(handle), rate); } } } @@ -244,7 +264,7 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32) .map(|c| c.sample_rate()) .unwrap_or(44100); crate::app_eprintln!("[psysonic] audio stream opened at {} Hz (device default)", rate); - return (Arc::new(handle), rate); + return (finalize_mixer_device_sink(handle), rate); } } @@ -257,7 +277,78 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32) .and_then(|d| d.default_output_config().ok()) .map(|c| c.sample_rate()) .unwrap_or(44100); - (Arc::new(handle), rate) + (finalize_mixer_device_sink(handle), rate) +} + +fn probe_device_default_rate() -> u32 { + use rodio::cpal::traits::{DeviceTrait, HostTrait}; + + rodio::cpal::default_host() + .default_output_device() + .and_then(|d| d.default_output_config().ok()) + .map(|c| c.sample_rate()) + .unwrap_or(44_100) +} + +/// Open the output stream (blocking). Updates `stream_handle` and `stream_sample_rate`. +pub(crate) fn open_output_stream_blocking( + engine: &AudioEngine, + desired_rate: u32, + is_hi_res: bool, + device_name: Option, +) -> Result, String> { + let rate = if desired_rate > 0 { + desired_rate + } else { + engine.device_default_rate + }; + let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel(0); + engine + .stream_thread_tx + .send(StreamThreadMsg::Open { + desired_rate: rate, + is_hi_res, + device_name, + reply: reply_tx, + }) + .map_err(|e| e.to_string())?; + let (handle, actual_rate) = reply_rx + .recv_timeout(Duration::from_secs(5)) + .map_err(|_| "audio stream open timed out".to_string())?; + engine + .stream_sample_rate + .store(actual_rate, std::sync::atomic::Ordering::Relaxed); + *engine.stream_handle.lock().unwrap() = Some(handle.clone()); + Ok(handle) +} + +/// Ensure a live output stream exists; lazy-opens on first playback. +pub(crate) fn ensure_output_stream_open( + engine: &AudioEngine, +) -> Result, String> { + if let Some(handle) = engine.stream_handle.lock().unwrap().clone() { + return Ok(handle); + } + let rate = engine.stream_sample_rate.load(std::sync::atomic::Ordering::Relaxed); + let open_rate = if rate > 0 { + rate + } else { + engine.device_default_rate + }; + let device = engine.selected_device.lock().unwrap().clone(); + open_output_stream_blocking(engine, open_rate, false, device) +} + +pub(crate) fn request_stream_release(engine: &AudioEngine) -> Result<(), String> { + let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel(0); + engine + .stream_thread_tx + .send(StreamThreadMsg::Release { reply: reply_tx }) + .map_err(|e| e.to_string())?; + reply_rx + .recv_timeout(Duration::from_secs(5)) + .map_err(|_| "audio stream release timed out".to_string())?; + Ok(()) } pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) { @@ -269,13 +360,12 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) { } } - // Channels: main thread ←→ audio-stream thread. - // init_tx/rx : (Arc, actual_rate) sent once at startup. - // reopen_tx/rx: (desired_rate, reply_tx) — triggers a stream re-open. - let (init_tx, init_rx) = - std::sync::mpsc::sync_channel::<(Arc, u32)>(0); - let (reopen_tx, reopen_rx) = - std::sync::mpsc::sync_channel::<(u32, bool, Option, std::sync::mpsc::SyncSender>)>(4); + // Channel: main thread ←→ audio-stream thread (lazy open + idle release). + let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel::<()>(0); + let (stream_thread_tx, stream_thread_rx) = + std::sync::mpsc::sync_channel::(4); + + let device_default_rate = probe_device_default_rate(); let thread = std::thread::Builder::new() .name("psysonic-audio-stream".into()) @@ -296,52 +386,63 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) { // Thread priority is kept at default during standard-mode playback. // It is escalated to Max only when a Hi-Res stream reopen is requested, // to prevent PipeWire underruns at high quantum sizes (8192 frames). - let (mut _stream, rate) = open_stream_for_device_and_rate(None, 0); - let handle = _stream.clone(); - init_tx.send((handle, rate)).ok(); + let mut _stream: Option> = None; + ready_tx.send(()).ok(); - // Keep the stream alive and handle sample-rate / device-switch requests. - while let Ok((desired_rate, is_hi_res, device_name, reply_tx)) = reopen_rx.recv() { - // Escalate to Max for Hi-Res reopens (large PipeWire quanta need - // real-time scheduling to avoid underruns). No escalation for - // standard mode — the thread blocks on recv() between reopens so - // elevated priority would only waste scheduler budget. - if is_hi_res { - thread_priority::set_current_thread_priority( - thread_priority::ThreadPriority::Max - ).ok(); + while let Ok(msg) = stream_thread_rx.recv() { + match msg { + StreamThreadMsg::Release { reply } => { + _stream = None; + let _ = reply.send(()); + } + StreamThreadMsg::Open { + desired_rate, + is_hi_res, + device_name, + reply, + } => { + // Escalate to Max for Hi-Res reopens (large PipeWire quanta need + // real-time scheduling to avoid underruns). No escalation for + // standard mode — the thread blocks on recv() between reopens so + // elevated priority would only waste scheduler budget. + if is_hi_res { + thread_priority::set_current_thread_priority( + thread_priority::ThreadPriority::Max, + ) + .ok(); + } + + _stream = None; + + // Scale the PipeWire quantum with the sample rate so wall-clock + // latency stays roughly constant (≈93 ms) at all rates. + #[cfg(target_os = "linux")] + if desired_rate > 0 { + let frames: u32 = if desired_rate > 48_000 { 8192 } else { 4096 }; + std::env::set_var("PIPEWIRE_LATENCY", format!("{frames}/{desired_rate}")); + let latency_ms = + (frames as f64 / desired_rate as f64 * 1000.0).round() as u64; + std::env::set_var("PULSE_LATENCY_MSEC", latency_ms.to_string()); + } + + let (new_stream, actual_rate) = + open_stream_for_device_and_rate(device_name.as_deref(), desired_rate); + let new_handle = new_stream.clone(); + _stream = Some(new_stream); + let _ = reply.send((new_handle, actual_rate)); + } } - - drop(_stream); // close old stream before opening new one - - // Scale the PipeWire quantum with the sample rate so wall-clock - // latency stays roughly constant (≈93 ms) at all rates. - // 8192 frames at 88200 Hz ≈ 92.9 ms (same as 4096 at 48000 Hz). - #[cfg(target_os = "linux")] - { - let frames: u32 = if desired_rate > 48_000 { 8192 } else { 4096 }; - std::env::set_var("PIPEWIRE_LATENCY", format!("{frames}/{desired_rate}")); - // Keep PULSE_LATENCY_MSEC in sync so PulseAudio-based setups - // get the same wall-clock quantum as PipeWire. - let latency_ms = (frames as f64 / desired_rate as f64 * 1000.0).round() as u64; - std::env::set_var("PULSE_LATENCY_MSEC", latency_ms.to_string()); - } - - let (new_stream, _actual) = open_stream_for_device_and_rate(device_name.as_deref(), desired_rate); - let new_handle = new_stream.clone(); - _stream = new_stream; - reply_tx.send(new_handle).ok(); } }) .expect("spawn audio stream thread"); - let (initial_handle, initial_rate) = init_rx.recv().expect("audio stream handle"); + ready_rx.recv().expect("audio stream thread ready"); let engine = AudioEngine { - stream_handle: Arc::new(std::sync::Mutex::new(initial_handle)), - stream_sample_rate: Arc::new(AtomicU32::new(initial_rate)), - device_default_rate: initial_rate, - stream_reopen_tx: reopen_tx, + stream_handle: Arc::new(std::sync::Mutex::new(None)), + stream_sample_rate: Arc::new(AtomicU32::new(0)), + device_default_rate, + stream_thread_tx, selected_device: Arc::new(Mutex::new(None)), current: Arc::new(Mutex::new(AudioCurrent { sink: None, diff --git a/src-tauri/crates/psysonic-audio/src/lib.rs b/src-tauri/crates/psysonic-audio/src/lib.rs index 1fca2d38..22d06ff1 100644 --- a/src-tauri/crates/psysonic-audio/src/lib.rs +++ b/src-tauri/crates/psysonic-audio/src/lib.rs @@ -16,6 +16,7 @@ pub mod device_commands; pub mod mix_commands; mod play_input; pub mod playback_rate; +mod sink_swap; mod preserve_worker; pub mod preload_commands; pub(crate) mod progress_task; @@ -24,6 +25,7 @@ pub mod transport_commands; mod device_resume; mod device_watcher; mod engine; +mod stream_idle; #[cfg(any(target_os = "windows", target_os = "linux"))] mod power_resume; #[cfg(target_os = "windows")] @@ -39,6 +41,7 @@ mod stream; pub use device_commands::{audio_default_output_device_name, audio_list_devices_for_engine}; pub use device_watcher::start_device_watcher; +pub use stream_idle::start_stream_idle_watcher; pub use engine::{create_engine, refresh_http_user_agent, AudioEngine}; pub use helpers::{ cleanup_orphan_stream_spill_dir, take_stream_completed_for_url, diff --git a/src-tauri/crates/psysonic-audio/src/play_input.rs b/src-tauri/crates/psysonic-audio/src/play_input.rs index bd426f12..b87b0f8d 100644 --- a/src-tauri/crates/psysonic-audio/src/play_input.rs +++ b/src-tauri/crates/psysonic-audio/src/play_input.rs @@ -401,47 +401,6 @@ async fn open_ranged_or_streaming_input( })) } -/// Legacy `AudioStreamReader`: keep the sink paused until the download task arms -/// playback, then reset counters and emit `audio:playing` so the UI does not -/// extrapolate ahead of audible output. -pub(super) fn spawn_legacy_stream_start_when_armed( - gen: u64, - gen_arc: Arc, - playback_armed: Arc, - samples_played: Arc, - current: Arc>, - app: AppHandle, - duration_secs: f64, -) { - tokio::spawn(async move { - loop { - if gen_arc.load(Ordering::SeqCst) != gen { - return; - } - if playback_armed.load(Ordering::Relaxed) { - break; - } - tokio::time::sleep(Duration::from_millis(50)).await; - } - if gen_arc.load(Ordering::SeqCst) != gen { - return; - } - samples_played.store(0, Ordering::Relaxed); - let sink = current.lock().unwrap().sink.clone(); - if let Some(sink) = sink { - { - let mut cur = current.lock().unwrap(); - cur.play_started = Some(std::time::Instant::now()); - cur.paused_at = None; - cur.seek_offset = 0.0; - } - sink.play(); - app.emit("audio:playing", duration_secs).ok(); - crate::app_deprintln!("[stream] legacy track-stream: playback started after buffer ready"); - } - }); -} - /// Pulled out of the format_hint extraction block in `audio_play` — strip the /// query string first so Subsonic-style URLs (`stream.view?...&v=1.16.1&...`) /// don't latch onto random query-param substrings; only accept short @@ -486,84 +445,6 @@ pub(crate) struct PlaybackSource { pub(crate) is_seekable: bool, } -/// State + decisions audio_play computed before the sink swap. -pub(crate) struct SinkSwapInputs { - pub(crate) sink: Arc, - pub(crate) duration_secs: f64, - pub(crate) volume: f32, - pub(crate) gain_linear: f32, - pub(crate) fadeout_trigger: Arc, - pub(crate) fadeout_samples: Arc, - pub(crate) crossfade_enabled: bool, - pub(crate) actual_fade_secs: f32, -} - -/// Atomically swap the new sink into `state.current`, then handle the old -/// sink: trigger sample-level fade-out (crossfade enabled) or stop it -/// immediately (hard cut). The fade-out is handed off to a small spawned -/// task that drops the old sink ~`actual_fade_secs + 0.5 s` later. -pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapInputs) { - use std::time::Instant; - - let SinkSwapInputs { - sink, - duration_secs, - volume, - gain_linear, - fadeout_trigger: new_fadeout_trigger, - fadeout_samples: new_fadeout_samples, - crossfade_enabled, - actual_fade_secs, - } = inputs; - - let (old_sink, old_fadeout_trigger, old_fadeout_samples) = { - let mut cur = state.current.lock().unwrap(); - let old = cur.sink.take(); - let old_fo_trigger = cur.fadeout_trigger.take(); - let old_fo_samples = cur.fadeout_samples.take(); - cur.sink = Some(sink); - cur.duration_secs = duration_secs; - cur.seek_offset = 0.0; - cur.play_started = Some(Instant::now()); - cur.paused_at = None; - cur.replay_gain_linear = gain_linear; - cur.base_volume = volume.clamp(0.0, 1.0); - cur.fadeout_trigger = Some(new_fadeout_trigger); - cur.fadeout_samples = Some(new_fadeout_samples); - (old, old_fo_trigger, old_fo_samples) - }; - - if crossfade_enabled { - if let Some(old) = old_sink { - // Trigger sample-level fade-out on Track A via TriggeredFadeOut. - // Calculate total fade samples from the measured actual_fade_secs. - let rate = state.current_sample_rate.load(Ordering::Relaxed); - let ch = state.current_channels.load(Ordering::Relaxed); - let fade_total = (actual_fade_secs as f64 * rate as f64 * ch as f64) as u64; - - if let (Some(trigger), Some(samples)) = (old_fadeout_trigger, old_fadeout_samples) { - samples.store(fade_total.max(1), Ordering::SeqCst); - trigger.store(true, Ordering::SeqCst); - } - - // Keep old sink alive until the fade completes + small margin, - // then drop it. No volume stepping needed — the fade-out runs - // at sample level inside the audio thread. - *state.fading_out_sink.lock().unwrap() = Some(old); - let fo_arc = state.fading_out_sink.clone(); - let cleanup_dur = Duration::from_secs_f32(actual_fade_secs + 0.5); - tokio::spawn(async move { - tokio::time::sleep(cleanup_dur).await; - if let Some(s) = fo_arc.lock().unwrap().take() { - s.stop(); - } - }); - } - } else if let Some(old) = old_sink { - old.stop(); - } -} - fn play_media_format_hint(input: &PlayInput) -> Option { match input { PlayInput::SeekableMedia { format_hint, .. } | PlayInput::Streaming { format_hint, .. } => { diff --git a/src-tauri/crates/psysonic-audio/src/power_resume.rs b/src-tauri/crates/psysonic-audio/src/power_resume.rs index 196a25ea..7eed6580 100644 --- a/src-tauri/crates/psysonic-audio/src/power_resume.rs +++ b/src-tauri/crates/psysonic-audio/src/power_resume.rs @@ -4,11 +4,11 @@ use std::sync::Mutex; use std::time::{Duration, Instant}; -use tauri::AppHandle; -use tauri::Manager; +use tauri::{AppHandle, Emitter, Manager}; use super::device_watcher::{reopen_output_stream, ReopenNotify}; -use super::engine::AudioEngine; +use super::engine::{request_stream_release, AudioEngine}; +use super::stream_idle::{output_stream_is_needed, teardown_playback_sinks_for_idle_release}; static RESUME_REOPEN_DEBOUNCE: Mutex> = Mutex::new(None); const DEBOUNCE: Duration = Duration::from_millis(900); @@ -30,10 +30,22 @@ pub(crate) fn debounce_allow_resume_reopen() -> bool { pub(crate) async fn reopen_audio_after_system_resume(app: &AppHandle) { tokio::time::sleep(Duration::from_millis(400)).await; - let device_name = match app.try_state::() { - Some(e) => e.selected_device.lock().unwrap().clone(), - None => return, + let Some(state) = app.try_state::() else { + return; }; + let engine = state.inner(); + + if !output_stream_is_needed(engine) { + if engine.stream_handle.lock().unwrap().is_some() { + teardown_playback_sinks_for_idle_release(engine); + let _ = request_stream_release(engine); + *engine.stream_handle.lock().unwrap() = None; + let _ = app.emit("audio:output-released", ()); + } + return; + } + + let device_name = engine.selected_device.lock().unwrap().clone(); if reopen_output_stream(app, device_name, ReopenNotify::DeviceChanged).await { crate::app_eprintln!("[psysonic] audio output reopened after system resume"); diff --git a/src-tauri/crates/psysonic-audio/src/preview.rs b/src-tauri/crates/psysonic-audio/src/preview.rs index 08ce62f2..610c1ffd 100644 --- a/src-tauri/crates/psysonic-audio/src/preview.rs +++ b/src-tauri/crates/psysonic-audio/src/preview.rs @@ -399,7 +399,8 @@ pub async fn audio_preview_play( let source = PriorityBoostSource::new(source); // ── Build secondary sink on the existing OutputStream ──────────────────── - let sink = Arc::new(Player::connect_new(state.stream_handle.lock().unwrap().mixer())); + let stream = super::engine::ensure_output_stream_open(&state)?; + let sink = Arc::new(Player::connect_new(stream.mixer())); sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0)); sink.append(source); diff --git a/src-tauri/crates/psysonic-audio/src/radio_commands.rs b/src-tauri/crates/psysonic-audio/src/radio_commands.rs index 13cf4f48..d84cf0fd 100644 --- a/src-tauri/crates/psysonic-audio/src/radio_commands.rs +++ b/src-tauri/crates/psysonic-audio/src/radio_commands.rs @@ -150,7 +150,8 @@ pub async fn audio_play_radio( if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); } - let sink = Arc::new(Player::connect_new(state.stream_handle.lock().unwrap().mixer())); + let stream = super::engine::ensure_output_stream_open(&state)?; + let sink = Arc::new(Player::connect_new(stream.mixer())); sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0)); sink.append(boosted); diff --git a/src-tauri/crates/psysonic-audio/src/sink_swap.rs b/src-tauri/crates/psysonic-audio/src/sink_swap.rs new file mode 100644 index 00000000..e8ab355e --- /dev/null +++ b/src-tauri/crates/psysonic-audio/src/sink_swap.rs @@ -0,0 +1,165 @@ +//! Sink-lifecycle glue for `audio_play`: atomically swap a freshly built sink +//! into `state.current` (handing off the old one to a crossfade tail or a hard +//! stop), and the legacy non-seekable path that holds a sink paused until the +//! download task arms playback. Split out of `play_input.rs` so source +//! selection and source building stay focused on their own concerns. + +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use tauri::{AppHandle, Emitter, State}; + +use super::engine::{AudioCurrent, AudioEngine}; + +/// Args for [`spawn_legacy_stream_start_when_armed`]. +pub(super) struct LegacyStreamStartWhenArmed { + pub gen: u64, + pub gen_arc: Arc, + pub playback_armed: Arc, + pub samples_played: Arc, + pub current: Arc>, + pub app: AppHandle, + pub duration_secs: f64, + pub hold_paused: bool, +} + +/// Legacy `AudioStreamReader`: keep the sink paused until the download task arms +/// playback, then reset counters and emit `audio:playing` so the UI does not +/// extrapolate ahead of audible output. +pub(super) fn spawn_legacy_stream_start_when_armed(args: LegacyStreamStartWhenArmed) { + let LegacyStreamStartWhenArmed { + gen, + gen_arc, + playback_armed, + samples_played, + current, + app, + duration_secs, + hold_paused, + } = args; + tokio::spawn(async move { + loop { + if gen_arc.load(Ordering::SeqCst) != gen { + return; + } + if playback_armed.load(Ordering::Relaxed) { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + if gen_arc.load(Ordering::SeqCst) != gen { + return; + } + samples_played.store(0, Ordering::Relaxed); + let sink = current.lock().unwrap().sink.clone(); + if let Some(sink) = sink { + if hold_paused { + sink.pause(); + let mut cur = current.lock().unwrap(); + cur.play_started = None; + if cur.paused_at.is_none() { + cur.paused_at = Some(0.0); + } + cur.seek_offset = 0.0; + crate::app_deprintln!( + "[stream] legacy track-stream: buffer ready, holding paused (silent prepare)" + ); + } else { + { + let mut cur = current.lock().unwrap(); + cur.play_started = Some(Instant::now()); + cur.paused_at = None; + cur.seek_offset = 0.0; + } + sink.play(); + app.emit("audio:playing", duration_secs).ok(); + crate::app_deprintln!("[stream] legacy track-stream: playback started after buffer ready"); + } + } + }); +} + +/// State + decisions audio_play computed before the sink swap. +pub(crate) struct SinkSwapInputs { + pub(crate) sink: Arc, + pub(crate) duration_secs: f64, + pub(crate) volume: f32, + pub(crate) gain_linear: f32, + pub(crate) fadeout_trigger: Arc, + pub(crate) fadeout_samples: Arc, + pub(crate) crossfade_enabled: bool, + pub(crate) actual_fade_secs: f32, + pub(crate) start_paused: bool, +} + +/// Atomically swap the new sink into `state.current`, then handle the old +/// sink: trigger sample-level fade-out (crossfade enabled) or stop it +/// immediately (hard cut). The fade-out is handed off to a small spawned +/// task that drops the old sink ~`actual_fade_secs + 0.5 s` later. +pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapInputs) { + let SinkSwapInputs { + sink, + duration_secs, + volume, + gain_linear, + fadeout_trigger: new_fadeout_trigger, + fadeout_samples: new_fadeout_samples, + crossfade_enabled, + actual_fade_secs, + start_paused, + } = inputs; + + let (old_sink, old_fadeout_trigger, old_fadeout_samples) = { + let mut cur = state.current.lock().unwrap(); + let old = cur.sink.take(); + let old_fo_trigger = cur.fadeout_trigger.take(); + let old_fo_samples = cur.fadeout_samples.take(); + cur.sink = Some(sink.clone()); + cur.duration_secs = duration_secs; + cur.seek_offset = 0.0; + if start_paused { + sink.pause(); + cur.play_started = None; + cur.paused_at = Some(0.0); + } else { + cur.play_started = Some(Instant::now()); + cur.paused_at = None; + } + cur.replay_gain_linear = gain_linear; + cur.base_volume = volume.clamp(0.0, 1.0); + cur.fadeout_trigger = Some(new_fadeout_trigger); + cur.fadeout_samples = Some(new_fadeout_samples); + (old, old_fo_trigger, old_fo_samples) + }; + + if crossfade_enabled { + if let Some(old) = old_sink { + // Trigger sample-level fade-out on Track A via TriggeredFadeOut. + // Calculate total fade samples from the measured actual_fade_secs. + let rate = state.current_sample_rate.load(Ordering::Relaxed); + let ch = state.current_channels.load(Ordering::Relaxed); + let fade_total = (actual_fade_secs as f64 * rate as f64 * ch as f64) as u64; + + if let (Some(trigger), Some(samples)) = (old_fadeout_trigger, old_fadeout_samples) { + samples.store(fade_total.max(1), Ordering::SeqCst); + trigger.store(true, Ordering::SeqCst); + } + + // Keep old sink alive until the fade completes + small margin, + // then drop it. No volume stepping needed — the fade-out runs + // at sample level inside the audio thread. + *state.fading_out_sink.lock().unwrap() = Some(old); + let fo_arc = state.fading_out_sink.clone(); + let cleanup_dur = Duration::from_secs_f32(actual_fade_secs + 0.5); + tokio::spawn(async move { + tokio::time::sleep(cleanup_dur).await; + if let Some(s) = fo_arc.lock().unwrap().take() { + s.stop(); + } + }); + } + } else if let Some(old) = old_sink { + old.stop(); + } +} diff --git a/src-tauri/crates/psysonic-audio/src/stream_idle.rs b/src-tauri/crates/psysonic-audio/src/stream_idle.rs new file mode 100644 index 00000000..347b5b04 --- /dev/null +++ b/src-tauri/crates/psysonic-audio/src/stream_idle.rs @@ -0,0 +1,306 @@ +//! Release the CPAL/rodio output stream after playback has been idle so the OS +//! can sleep (issue #1071 — Windows `powercfg` "audio stream in use"). + +use std::sync::atomic::Ordering; +use std::time::{Duration, Instant}; + +use tauri::{AppHandle, Emitter, Manager}; + +use super::engine::AudioEngine; + +/// Wall-clock idle period before closing the output device handle. +pub(crate) const OUTPUT_STREAM_IDLE_RELEASE_SECS: u64 = 60; + +const IDLE_POLL_SECS: u64 = 5; + +/// Returns true while the app must keep an open output stream (playing, preview, crossfade). +pub(crate) fn output_stream_is_needed(engine: &AudioEngine) -> bool { + if engine.preview_sink.lock().unwrap().is_some() { + return true; + } + if engine.fading_out_sink.lock().unwrap().is_some() { + return true; + } + + let cur = engine.current.lock().unwrap(); + if let Some(sink) = &cur.sink { + if sink.empty() { + return false; + } + if cur.play_started.is_some() && cur.paused_at.is_none() { + return true; + } + } + + if let Some(rs) = engine.radio_state.lock().unwrap().as_ref() { + if !rs.flags.is_paused.load(Ordering::Relaxed) { + return true; + } + } + + false +} + +/// Stop sinks tied to the open stream; keep pause position / URLs for cold resume. +pub(crate) fn teardown_playback_sinks_for_idle_release(engine: &AudioEngine) { + if let Some(s) = engine.preview_sink.lock().unwrap().take() { + s.stop(); + } + if let Some(s) = engine.fading_out_sink.lock().unwrap().take() { + s.stop(); + } + let mut cur = engine.current.lock().unwrap(); + if let Some(s) = cur.sink.take() { + s.stop(); + } + cur.play_started = None; +} + +fn close_output_device_handle(engine: &AudioEngine, app: &AppHandle) -> Result<(), String> { + super::engine::request_stream_release(engine)?; + *engine.stream_handle.lock().unwrap() = None; + let _ = app.emit("audio:output-released", ()); + Ok(()) +} + +/// Release the output device after the idle timer (pause with no other active audio). +pub(crate) fn release_output_stream( + engine: &AudioEngine, + app: &AppHandle, +) -> Result<(), String> { + if engine.stream_handle.lock().unwrap().is_none() { + return Ok(()); + } + teardown_playback_sinks_for_idle_release(engine); + close_output_device_handle(engine, app)?; + crate::app_eprintln!( + "[psysonic] audio output stream released after {}s idle", + OUTPUT_STREAM_IDLE_RELEASE_SECS + ); + Ok(()) +} + +/// Release immediately on explicit stop / queue end — do not wait for the idle timer. +pub(crate) fn release_output_stream_on_stop( + engine: &AudioEngine, + app: &AppHandle, +) -> Result<(), String> { + if engine.stream_handle.lock().unwrap().is_none() { + return Ok(()); + } + // `audio_stop` already tore down the main sink; clear any crossfade/preview tail + // still tied to the open device before closing the handle. + if engine.preview_sink.lock().unwrap().is_some() + || engine.fading_out_sink.lock().unwrap().is_some() + { + teardown_playback_sinks_for_idle_release(engine); + } + close_output_device_handle(engine, app)?; + crate::app_eprintln!("[psysonic] audio output stream released on stop"); + Ok(()) +} + +/// Resolves the engine from `app` each poll (the engine is managed Tauri state), +/// so it takes only the `AppHandle` — no engine reference is needed here. +pub fn start_stream_idle_watcher(app: AppHandle) { + tauri::async_runtime::spawn(async move { + let mut idle_since: Option = None; + loop { + tokio::time::sleep(Duration::from_secs(IDLE_POLL_SECS)).await; + let Some(state) = app.try_state::() else { + idle_since = None; + continue; + }; + let engine = state.inner(); + let stream_open = engine.stream_handle.lock().unwrap().is_some(); + if !stream_open { + idle_since = None; + continue; + } + if output_stream_is_needed(engine) { + idle_since = None; + continue; + } + let since = *idle_since.get_or_insert_with(Instant::now); + if since.elapsed() < Duration::from_secs(OUTPUT_STREAM_IDLE_RELEASE_SECS) { + continue; + } + let _ = release_output_stream(engine, &app); + idle_since = None; + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64}; + use std::sync::{Arc, Mutex, RwLock}; + + use ringbuf::HeapCons; + use rodio::source::Zero; + use rodio::{ChannelCount, Player, SampleRate}; + + use super::super::engine::AudioCurrent; + use super::super::playback_rate::PlaybackRateAtomics; + use super::super::stream::{RadioLiveState, RadioSharedFlags}; + + /// A device-less rodio `Player` with one infinite source appended, so + /// `empty()` reports `false` without ever opening an output device. + /// Returns the queue output too — keep it alive for the test's duration. + fn nonempty_player() -> (Arc, rodio::queue::SourcesQueueOutput) { + let (player, out) = Player::new(); + player.append(Zero::new( + ChannelCount::new(2).unwrap(), + SampleRate::new(44_100).unwrap(), + )); + (Arc::new(player), out) + } + + fn radio_session(is_paused: bool) -> RadioLiveState { + let (tx, _rx) = std::sync::mpsc::channel::>(); + RadioLiveState { + url: "http://example.test/stream".to_string(), + gen: 0, + // Detached no-op task; never polled. Drop just aborts it. + task: tokio::spawn(async {}), + flags: Arc::new(RadioSharedFlags { + is_paused: AtomicBool::new(is_paused), + is_hard_paused: AtomicBool::new(false), + new_cons_tx: Mutex::new(tx), + }), + } + } + + fn minimal_engine() -> AudioEngine { + let (stream_thread_tx, _) = std::sync::mpsc::sync_channel(0); + AudioEngine { + stream_handle: Arc::new(Mutex::new(None)), + stream_sample_rate: Arc::new(AtomicU32::new(0)), + device_default_rate: 48_000, + stream_thread_tx, + selected_device: Arc::new(Mutex::new(None)), + current: Arc::new(Mutex::new(AudioCurrent { + sink: None, + duration_secs: 0.0, + seek_offset: 0.0, + play_started: None, + paused_at: None, + replay_gain_linear: 1.0, + base_volume: 0.8, + fadeout_trigger: None, + fadeout_samples: None, + })), + generation: Arc::new(AtomicU64::new(0)), + http_client: Arc::new(RwLock::new(reqwest::Client::new())), + eq_gains: Arc::new(std::array::from_fn(|_| AtomicU32::new(0))), + eq_enabled: Arc::new(AtomicBool::new(false)), + eq_pre_gain: Arc::new(AtomicU32::new(0)), + playback_rate: PlaybackRateAtomics::new(), + preloaded: Arc::new(Mutex::new(None)), + stream_completed_cache: Arc::new(Mutex::new(None)), + stream_completed_spill: Arc::new(Mutex::new(None)), + current_is_seekable: Arc::new(AtomicBool::new(true)), + stream_playback_armed: Arc::new(AtomicBool::new(true)), + crossfade_enabled: Arc::new(AtomicBool::new(false)), + crossfade_secs: Arc::new(AtomicU32::new(0)), + fading_out_sink: Arc::new(Mutex::new(None)), + gapless_enabled: Arc::new(AtomicBool::new(false)), + normalization_engine: Arc::new(AtomicU32::new(0)), + normalization_target_lufs: Arc::new(AtomicU32::new(0)), + loudness_pre_analysis_attenuation_db: Arc::new(AtomicU32::new(0)), + chained_info: Arc::new(Mutex::new(None)), + samples_played: Arc::new(AtomicU64::new(0)), + current_sample_rate: Arc::new(AtomicU32::new(44_100)), + current_channels: Arc::new(AtomicU32::new(2)), + gapless_switch_at: Arc::new(AtomicU64::new(0)), + radio_state: Mutex::new(None), + current_playback_url: Arc::new(Mutex::new(None)), + current_analysis_track_id: Arc::new(Mutex::new(None)), + current_playback_server_id: Arc::new(Mutex::new(None)), + ranged_loudness_seed_hold: Arc::new(Mutex::new(None)), + preview_sink: Arc::new(Mutex::new(None)), + preview_gen: Arc::new(AtomicU64::new(0)), + preview_main_resume: Arc::new(AtomicBool::new(false)), + preview_song_id: Arc::new(Mutex::new(None)), + } + } + + #[test] + fn idle_when_no_sink_and_no_preview() { + let engine = minimal_engine(); + assert!(!output_stream_is_needed(&engine)); + } + + #[test] + fn idle_when_sink_empty() { + // A live but drained main sink (track finished) must not pin the device. + let (player, _out) = Player::new(); // no source appended → empty() + let player = Arc::new(player); + let engine = minimal_engine(); + { + let mut cur = engine.current.lock().unwrap(); + cur.sink = Some(player); + cur.play_started = Some(Instant::now()); + cur.paused_at = None; + } + assert!(!output_stream_is_needed(&engine)); + } + + #[test] + fn needed_when_sink_playing() { + let (sink, _out) = nonempty_player(); + let engine = minimal_engine(); + { + let mut cur = engine.current.lock().unwrap(); + cur.sink = Some(sink); + cur.play_started = Some(Instant::now()); + cur.paused_at = None; // actively playing + } + assert!(output_stream_is_needed(&engine)); + } + + #[test] + fn idle_when_sink_paused() { + // Non-empty sink but paused (paused_at set) — the idle watcher may release. + let (sink, _out) = nonempty_player(); + let engine = minimal_engine(); + { + let mut cur = engine.current.lock().unwrap(); + cur.sink = Some(sink); + cur.play_started = Some(Instant::now()); + cur.paused_at = Some(12.0); + } + assert!(!output_stream_is_needed(&engine)); + } + + #[test] + fn needed_when_preview_sink_present() { + let (sink, _out) = nonempty_player(); + let engine = minimal_engine(); + *engine.preview_sink.lock().unwrap() = Some(sink); + assert!(output_stream_is_needed(&engine)); + } + + #[test] + fn needed_when_fading_out_sink_present() { + let (sink, _out) = nonempty_player(); + let engine = minimal_engine(); + *engine.fading_out_sink.lock().unwrap() = Some(sink); + assert!(output_stream_is_needed(&engine)); + } + + #[tokio::test] + async fn needed_when_radio_playing() { + let engine = minimal_engine(); + *engine.radio_state.lock().unwrap() = Some(radio_session(false)); + assert!(output_stream_is_needed(&engine)); + } + + #[tokio::test] + async fn idle_when_radio_paused() { + let engine = minimal_engine(); + *engine.radio_state.lock().unwrap() = Some(radio_session(true)); + assert!(!output_stream_is_needed(&engine)); + } +} diff --git a/src-tauri/crates/psysonic-audio/src/transport_commands.rs b/src-tauri/crates/psysonic-audio/src/transport_commands.rs index 47266300..2ea9b520 100644 --- a/src-tauri/crates/psysonic-audio/src/transport_commands.rs +++ b/src-tauri/crates/psysonic-audio/src/transport_commands.rs @@ -130,6 +130,8 @@ pub fn audio_stop(state: State<'_, AudioEngine>, app: AppHandle) { cur.seek_offset = 0.0; cur.play_started = None; cur.paused_at = None; + drop(cur); + let _ = super::stream_idle::release_output_stream_on_stop(state.inner(), &app); } #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3762e5f0..3fcc7b06 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -562,6 +562,7 @@ pub fn run() { use tauri::Manager; let engine = app.state::(); audio::start_device_watcher(&engine, app.handle().clone()); + audio::start_stream_idle_watcher(app.handle().clone()); } // ── Reopen output after system sleep/resume (WASAPI / PipeWire etc.) diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 5f57ae9e..6f86be8b 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -162,6 +162,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Navidrome Now Playing and scrobble with hot cache, offline pins, and mixed-server playback reachability (PR #1055)', 'What\'s New: remote WHATS_NEW.md from release assets, dev workspace mode, Highlights vs changelog tabs (PR #1058)', 'Local library index: multi-genre browse, filters, and counts via track_genre table and blocking backfill (PR #1059)', + 'Audio: lazy-open output stream, 60s idle release (#1071), cold-start paused restore with silent engine prepare (PR #1073)', ], }, { diff --git a/src/hooks/tauriBridge/useAudioDeviceBridge.test.ts b/src/hooks/tauriBridge/useAudioDeviceBridge.test.ts index e13926af..d9e5253c 100644 --- a/src/hooks/tauriBridge/useAudioDeviceBridge.test.ts +++ b/src/hooks/tauriBridge/useAudioDeviceBridge.test.ts @@ -96,6 +96,20 @@ describe('audio:device-changed', () => { // ─── audio:device-reset ───────────────────────────────────────────────────── +describe('audio:output-released', () => { + it('calls resetAudioPause so the next resume uses the cold path', () => { + const resetAudioPause = vi.fn(); + usePlayerStore.setState({ resetAudioPause } as never); + mountBridge(); + + emitTauriEvent('audio:output-released', null); + + expect(resetAudioPause).toHaveBeenCalled(); + }); +}); + +// ─── audio:device-reset ───────────────────────────────────────────────────── + describe('audio:device-reset', () => { it('always clears the stored output device', () => { useAuthStore.setState({ audioOutputDevice: 'My DAC' } as never); diff --git a/src/hooks/tauriBridge/useAudioDeviceBridge.ts b/src/hooks/tauriBridge/useAudioDeviceBridge.ts index 22455693..4f95a415 100644 --- a/src/hooks/tauriBridge/useAudioDeviceBridge.ts +++ b/src/hooks/tauriBridge/useAudioDeviceBridge.ts @@ -70,4 +70,13 @@ export function useAudioDeviceBridge() { }).then(u => { unlisten = u; }); return () => { unlisten?.(); }; }, []); + + // Output stream was released after idle — next resume must use the cold path. + useEffect(() => { + let unlisten: (() => void) | undefined; + listen('audio:output-released', () => { + usePlayerStore.getState().resetAudioPause(); + }).then(u => { unlisten = u; }); + return () => { unlisten?.(); }; + }, []); } diff --git a/src/hotCachePrefetch.ts b/src/hotCachePrefetch.ts index bc6bf0af..5e06cc10 100644 --- a/src/hotCachePrefetch.ts +++ b/src/hotCachePrefetch.ts @@ -55,6 +55,16 @@ function scheduleEvictAfterPreviousGrace(): void { }, ms); } +/** Prefetch the current (paused) track so cold resume can hit disk instead of HTTP. */ +export function scheduleHotCachePrefetchForTrack(track: { id: string; suffix?: string }, serverId: string | null): void { + const auth = useAuthStore.getState(); + if (!auth.isLoggedIn || !auth.hotCacheEnabled || !serverId) return; + if (hasLocalPersistentPlaybackBytes(track.id, serverId)) return; + const hotIndex = selectHotCacheEntries(useLocalPlaybackStore.getState().entries); + if (hotIndex[entryKey(serverId, track.id)]) return; + enqueueJobs([{ trackId: track.id, serverId, suffix: track.suffix || 'mp3' }]); +} + function enqueueJobs(jobs: PrefetchJob[]) { const seen = new Set(pendingQueue.map(j => `${j.serverId}:${j.trackId}`)); let merged = 0; diff --git a/src/store/engineLoadTrackAtPosition.ts b/src/store/engineLoadTrackAtPosition.ts new file mode 100644 index 00000000..7ea7da76 --- /dev/null +++ b/src/store/engineLoadTrackAtPosition.ts @@ -0,0 +1,100 @@ +import type { Track } from './playerStoreTypes'; +import { invoke } from '@tauri-apps/api/core'; +import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate'; +import { + getPlaybackIndexKey, + playbackCacheKeyForTrack, +} from '../utils/playback/playbackServer'; +import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl'; +import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb'; +import { useAuthStore } from './authStore'; +import { getPlayGeneration, setIsAudioPaused } from './engineState'; +import { touchHotCacheOnPlayback } from './hotCacheTouch'; +import { isReplayGainActive, loudnessGainDbForEngineBind } from './loudnessGainCache'; +import { playbackSourceHintForResolvedUrl, recordEnginePlayUrl } from './playbackUrlRouting'; +import { usePlayerStore } from './playerStore'; + +/** + * Load a track into the Rust engine at `atSeconds`, optionally leaving transport + * playing or paused. Shared by queue-undo restore and cold-start paused prepare. + */ +export function engineLoadTrackAtPosition(opts: { + generation: number; + track: Track; + queue: Track[]; + queueIndex: number; + atSeconds: number; + wantPlaying: boolean; +}): void { + const { generation, track, queue, queueIndex, atSeconds, wantPlaying } = opts; + const authState = useAuthStore.getState(); + const vol = usePlayerStore.getState().volume; + const coldPrev = queueIndex > 0 ? queue[queueIndex - 1] : null; + const coldNext = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null; + const replayGainDb = resolveReplayGainDb( + track, coldPrev, coldNext, + isReplayGainActive(), authState.replayGainMode, + ); + const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null; + const playbackCacheSid = playbackCacheKeyForTrack(track); + const playbackIndexKey = playbackCacheKeyForTrack(track) || getPlaybackIndexKey(); + const url = resolvePlaybackUrl(track.id, playbackCacheSid); + recordEnginePlayUrl(track.id, url); + usePlayerStore.setState({ + currentPlaybackSource: playbackSourceHintForResolvedUrl(track.id, playbackCacheSid, url), + }); + const keepPreloadHint = usePlayerStore.getState().enginePreloadedTrackId === track.id; + const startPaused = !wantPlaying; + setDeferHotCachePrefetch(true); + invoke('audio_play', { + url, + volume: vol, + durationHint: track.duration, + replayGainDb, + replayGainPeak, + loudnessGainDb: loudnessGainDbForEngineBind(track.id), + preGainDb: authState.replayGainPreGainDb, + fallbackDb: authState.replayGainFallbackDb, + manual: false, + hiResEnabled: authState.enableHiRes, + analysisTrackId: track.id, + serverId: playbackIndexKey || null, + streamFormatSuffix: track.suffix ?? null, + startPaused, + }) + .then(() => { + if (getPlayGeneration() !== generation) return; + if (keepPreloadHint) { + usePlayerStore.setState({ enginePreloadedTrackId: null }); + } + const dur = track.duration && track.duration > 0 ? track.duration : null; + const seekTo = Math.max(0, atSeconds); + const canSeek = seekTo > 0.05 && (dur == null || seekTo < dur - 0.05); + const afterSeek = () => { + if (getPlayGeneration() !== generation) return; + if (!wantPlaying) { + if (!startPaused) { + invoke('audio_pause').catch(console.error); + } + setIsAudioPaused(true); + usePlayerStore.setState({ isPlaying: false }); + } else { + setIsAudioPaused(false); + } + }; + if (canSeek) { + void invoke('audio_seek', { seconds: seekTo }).then(afterSeek).catch(afterSeek); + } else { + afterSeek(); + } + }) + .catch((err: unknown) => { + if (getPlayGeneration() !== generation) return; + console.error('[psysonic] engineLoadTrackAtPosition failed:', err); + usePlayerStore.setState({ isPlaying: false }); + }) + .finally(() => { + setDeferHotCachePrefetch(false); + }); + touchHotCacheOnPlayback(track.id, playbackCacheSid); +} diff --git a/src/store/miscActions.ts b/src/store/miscActions.ts index f16a6f8e..c1da0071 100644 --- a/src/store/miscActions.ts +++ b/src/store/miscActions.ts @@ -30,6 +30,7 @@ import { setSeekFallbackVisualTarget, } from './seekFallbackState'; import { clearSeekTarget } from './seekTargetState'; +import { preparePausedRestoreOnStartup } from './pausedRestorePrepare'; import { refreshWaveformForTrack } from './waveformRefresh'; type SetState = ( @@ -167,13 +168,16 @@ export function createMiscActions(set: SetState, get: GetState): Pick< // Seed the resolver with the restored tracks so the queue UI / hot // paths resolve them without a network round-trip. if (sid) seedQueueResolver(sid, mappedTracks); + const atSeconds = serverTime > 0 ? serverTime : localTime; + const queueItems = toQueueItemRefs(sid, mappedTracks); set({ - queueItems: toQueueItemRefs(sid, mappedTracks), + queueItems, queueIndex, currentTrack, - currentTime: serverTime > 0 ? serverTime : localTime, + currentTime: atSeconds, }); void refreshWaveformForTrack(currentTrack.id); + preparePausedRestoreOnStartup(currentTrack, queueItems, queueIndex, atSeconds); } } catch (e) { console.error('Failed to initialize queue from server', e); diff --git a/src/store/pausedRestorePrepare.test.ts b/src/store/pausedRestorePrepare.test.ts new file mode 100644 index 00000000..e727f23a --- /dev/null +++ b/src/store/pausedRestorePrepare.test.ts @@ -0,0 +1,124 @@ +import type { Track } from './playerStoreTypes'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const hoisted = vi.hoisted(() => ({ + invokeMock: vi.fn(async () => undefined), + emitMock: vi.fn(), + setSeekMock: vi.fn(), + prefetchMock: vi.fn(), + promoteMock: vi.fn(async () => undefined), + engineLoadMock: vi.fn(), + bumpGenMock: vi.fn(() => 7), + getGenMock: vi.fn(() => 7), + playerState: { + isPlaying: false, + currentRadio: null as { streamUrl: string } | null, + volume: 0.8, + }, + authState: { + hotCacheEnabled: true, + hotCacheDownloadDir: '/tmp/hot', + }, +})); + +vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock })); +vi.mock('./playbackProgress', () => ({ emitPlaybackProgress: hoisted.emitMock })); +vi.mock('./seekFallbackState', () => ({ setSeekFallbackVisualTarget: hoisted.setSeekMock })); +vi.mock('../hotCachePrefetch', () => ({ scheduleHotCachePrefetchForTrack: hoisted.prefetchMock })); +vi.mock('./promoteStreamCache', () => ({ promoteCompletedStreamToHotCache: hoisted.promoteMock })); +vi.mock('./engineLoadTrackAtPosition', () => ({ engineLoadTrackAtPosition: hoisted.engineLoadMock })); +vi.mock('./engineState', () => ({ + bumpPlayGeneration: hoisted.bumpGenMock, + getPlayGeneration: hoisted.getGenMock, +})); +vi.mock('./authStore', () => ({ useAuthStore: { getState: () => hoisted.authState } })); +vi.mock('./playerStore', () => ({ + usePlayerStore: { + getState: () => hoisted.playerState, + setState: vi.fn((partial: Record) => { + Object.assign(hoisted.playerState, partial); + }), + }, +})); +vi.mock('../utils/library/queueTrackView', () => ({ + getQueueTracksView: vi.fn((refs: { trackId: string }[]) => + refs.map(r => ({ + id: r.trackId, + title: r.trackId, + artist: 'A', + album: 'B', + albumId: 'B', + duration: 200, + })), + ), +})); +vi.mock('../utils/playback/playbackServer', () => ({ + getPlaybackCacheServerKey: () => 'srv-key', +})); + +import { + applyRestoredPlaybackVisual, + preparePausedRestoreOnStartup, +} from './pausedRestorePrepare'; + +function track(id: string, duration = 200): Track { + return { id, title: id, artist: 'A', album: 'B', albumId: 'B', duration }; +} + +beforeEach(() => { + hoisted.invokeMock.mockClear(); + hoisted.emitMock.mockClear(); + hoisted.setSeekMock.mockClear(); + hoisted.prefetchMock.mockClear(); + hoisted.promoteMock.mockClear(); + hoisted.engineLoadMock.mockClear(); + hoisted.bumpGenMock.mockClear(); + hoisted.getGenMock.mockReturnValue(7); + hoisted.playerState.isPlaying = false; + hoisted.playerState.currentRadio = null; +}); + +describe('applyRestoredPlaybackVisual', () => { + it('updates store progress and emits playback progress', () => { + applyRestoredPlaybackVisual(track('t1'), 50); + expect(hoisted.emitMock).toHaveBeenCalledWith({ + currentTime: 50, + progress: 0.25, + buffered: 0, + buffering: false, + }); + expect(hoisted.setSeekMock).toHaveBeenCalledWith({ + trackId: 't1', + seconds: 50, + setAtMs: expect.any(Number), + }); + }); +}); + +describe('preparePausedRestoreOnStartup', () => { + it('prefetches, promotes stream cache, and loads the engine paused', async () => { + preparePausedRestoreOnStartup( + track('t1'), + [{ serverId: 'srv', trackId: 't1' }], + 0, + 42, + ); + expect(hoisted.prefetchMock).toHaveBeenCalled(); + expect(hoisted.bumpGenMock).toHaveBeenCalled(); + await vi.waitFor(() => { + expect(hoisted.promoteMock).toHaveBeenCalled(); + expect(hoisted.engineLoadMock).toHaveBeenCalledWith(expect.objectContaining({ + generation: 7, + atSeconds: 42, + wantPlaying: false, + })); + }); + }); + + it('skips when transport is already playing', () => { + hoisted.playerState.isPlaying = true; + preparePausedRestoreOnStartup(track('t1'), [], 0, 10); + expect(hoisted.engineLoadMock).not.toHaveBeenCalled(); + expect(hoisted.prefetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/store/pausedRestorePrepare.ts b/src/store/pausedRestorePrepare.ts new file mode 100644 index 00000000..e8bbc1bc --- /dev/null +++ b/src/store/pausedRestorePrepare.ts @@ -0,0 +1,75 @@ +import type { QueueItemRef, Track } from './playerStoreTypes'; +import { getQueueTracksView } from '../utils/library/queueTrackView'; +import { scheduleHotCachePrefetchForTrack } from '../hotCachePrefetch'; +import { getPlaybackCacheServerKey } from '../utils/playback/playbackServer'; +import { useAuthStore } from './authStore'; +import { bumpPlayGeneration, getPlayGeneration } from './engineState'; +import { engineLoadTrackAtPosition } from './engineLoadTrackAtPosition'; +import { emitPlaybackProgress } from './playbackProgress'; +import { promoteCompletedStreamToHotCache } from './promoteStreamCache'; +import { usePlayerStore } from './playerStore'; +import { setSeekFallbackVisualTarget } from './seekFallbackState'; + +/** Push restored position into the store + progress channel so the seekbar paints immediately. */ +export function applyRestoredPlaybackVisual(track: Track, atSeconds: number): void { + const dur = track.duration > 0 ? track.duration : 0; + const seconds = Math.max(0, atSeconds); + const progress = dur > 0 ? Math.min(1, seconds / dur) : 0; + usePlayerStore.setState({ currentTime: seconds, progress, buffered: 0 }); + emitPlaybackProgress({ + currentTime: seconds, + progress, + buffered: 0, + buffering: false, + }); + if (seconds > 0.05) { + setSeekFallbackVisualTarget({ + trackId: track.id, + seconds, + setAtMs: Date.now(), + }); + } +} + +/** + * After `getPlayQueue` restores a paused session: show the saved seek position, + * prefetch bytes for the current track, and load the engine paused at that spot + * so the next Play is a warm `audio_resume`. + */ +export function preparePausedRestoreOnStartup( + track: Track, + queueItems: QueueItemRef[], + queueIndex: number, + atSeconds: number, +): void { + const player = usePlayerStore.getState(); + if (player.isPlaying || player.currentRadio) return; + + applyRestoredPlaybackVisual(track, atSeconds); + scheduleHotCachePrefetchForTrack(track, getPlaybackCacheServerKey()); + + const generation = bumpPlayGeneration(); + void (async () => { + const auth = useAuthStore.getState(); + const promoteSid = getPlaybackCacheServerKey(); + if (auth.hotCacheEnabled && promoteSid) { + await promoteCompletedStreamToHotCache( + track, + promoteSid, + auth.hotCacheDownloadDir || null, + ); + } + if (getPlayGeneration() !== generation) return; + if (usePlayerStore.getState().isPlaying) return; + + const queue = getQueueTracksView(queueItems, [track]); + engineLoadTrackAtPosition({ + generation, + track, + queue, + queueIndex, + atSeconds, + wantPlaying: false, + }); + })(); +} diff --git a/src/store/playTrackAction.ts b/src/store/playTrackAction.ts index 83dfad62..cc64b1d3 100644 --- a/src/store/playTrackAction.ts +++ b/src/store/playTrackAction.ts @@ -375,6 +375,7 @@ export function runPlayTrack( analysisTrackId: scopedTrack.id, serverId: getPlaybackIndexKey() || null, streamFormatSuffix: scopedTrack.suffix ?? null, + startPaused: false, }) .then(() => { if (getPlayGeneration() !== gen) return; diff --git a/src/store/playerStore.misc.test.ts b/src/store/playerStore.misc.test.ts index ac3ddc5a..376ee933 100644 --- a/src/store/playerStore.misc.test.ts +++ b/src/store/playerStore.misc.test.ts @@ -230,6 +230,20 @@ describe('stop', () => { expect(s.progress).toBe(0); expect(s.currentTime).toBe(0); }); + + it('keeps the waveform of the still-shown track and re-hydrates it from the DB', () => { + const track = makeTrack({ id: 'wf-keep' }); + seedQueue([track], { index: 0, currentTrack: track }); + usePlayerStore.setState({ isPlaying: true, waveformBins: [10, 20, 30] }); + onInvoke('analysis_get_waveform_for_track', () => null); + usePlayerStore.getState().stop(); + // currentTrack survives a stop, so its waveform bins must not be wiped. + expect(usePlayerStore.getState().waveformBins).toEqual([10, 20, 30]); + expect(invokeMock).toHaveBeenCalledWith( + 'analysis_get_waveform_for_track', + expect.objectContaining({ trackId: 'wf-keep' }), + ); + }); }); describe('shuffleQueue', () => { diff --git a/src/store/queueUndoAudioRestore.test.ts b/src/store/queueUndoAudioRestore.test.ts index 8e1a8463..628493b1 100644 --- a/src/store/queueUndoAudioRestore.test.ts +++ b/src/store/queueUndoAudioRestore.test.ts @@ -130,7 +130,7 @@ describe('queueUndoRestoreAudioEngine', () => { expect(seekCall).toBeUndefined(); }); - it('issues audio_pause when wantPlaying=false', async () => { + it('loads with startPaused and skips audio_pause when wantPlaying=false', async () => { queueUndoRestoreAudioEngine({ generation: 1, track: track('t1'), @@ -141,7 +141,11 @@ describe('queueUndoRestoreAudioEngine', () => { }); await Promise.resolve(); await Promise.resolve(); - expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_pause'); + expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_play', expect.objectContaining({ + startPaused: true, + })); + const pauseCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'audio_pause'); + expect(pauseCall).toBeUndefined(); expect(hoisted.setIsAudioPausedMock).toHaveBeenCalledWith(true); }); diff --git a/src/store/queueUndoAudioRestore.ts b/src/store/queueUndoAudioRestore.ts index 84d5a537..1ec67839 100644 --- a/src/store/queueUndoAudioRestore.ts +++ b/src/store/queueUndoAudioRestore.ts @@ -1,19 +1,6 @@ import type { Track } from './playerStoreTypes'; -import { invoke } from '@tauri-apps/api/core'; -import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate'; -import { - getPlaybackIndexKey, - playbackCacheKeyForTrack, - playbackProfileIdForTrack, -} from '../utils/playback/playbackServer'; -import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl'; -import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb'; -import { useAuthStore } from './authStore'; -import { getPlayGeneration, setIsAudioPaused } from './engineState'; -import { touchHotCacheOnPlayback } from './hotCacheTouch'; -import { isReplayGainActive, loudnessGainDbForEngineBind } from './loudnessGainCache'; -import { playbackSourceHintForResolvedUrl, recordEnginePlayUrl } from './playbackUrlRouting'; -import { usePlayerStore } from './playerStore'; +import { engineLoadTrackAtPosition } from './engineLoadTrackAtPosition'; + /** * Reload the Rust audio engine to match a queue-undo snapshot. Zustand * alone can rewrite the queue + currentTrack, but the engine is still @@ -33,72 +20,5 @@ export function queueUndoRestoreAudioEngine(opts: { atSeconds: number; wantPlaying: boolean; }): void { - const { generation, track, queue, queueIndex, atSeconds, wantPlaying } = opts; - const authState = useAuthStore.getState(); - const vol = usePlayerStore.getState().volume; - const coldPrev = queueIndex > 0 ? queue[queueIndex - 1] : null; - const coldNext = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null; - const replayGainDb = resolveReplayGainDb( - track, coldPrev, coldNext, - isReplayGainActive(), authState.replayGainMode, - ); - const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null; - const playbackSid = playbackProfileIdForTrack(track); - const playbackCacheSid = playbackCacheKeyForTrack(track); - const playbackIndexKey = playbackCacheKeyForTrack(track) || getPlaybackIndexKey(); - const url = resolvePlaybackUrl(track.id, playbackCacheSid); - recordEnginePlayUrl(track.id, url); - usePlayerStore.setState({ - currentPlaybackSource: playbackSourceHintForResolvedUrl(track.id, playbackCacheSid, url), - }); - const keepPreloadHint = usePlayerStore.getState().enginePreloadedTrackId === track.id; - setDeferHotCachePrefetch(true); - invoke('audio_play', { - url, - volume: vol, - durationHint: track.duration, - replayGainDb, - replayGainPeak, - loudnessGainDb: loudnessGainDbForEngineBind(track.id), - preGainDb: authState.replayGainPreGainDb, - fallbackDb: authState.replayGainFallbackDb, - manual: false, - hiResEnabled: authState.enableHiRes, - analysisTrackId: track.id, - serverId: playbackIndexKey || null, - streamFormatSuffix: track.suffix ?? null, - }) - .then(() => { - if (getPlayGeneration() !== generation) return; - if (keepPreloadHint) { - usePlayerStore.setState({ enginePreloadedTrackId: null }); - } - const dur = track.duration && track.duration > 0 ? track.duration : null; - const seekTo = Math.max(0, atSeconds); - const canSeek = seekTo > 0.05 && (dur == null || seekTo < dur - 0.05); - const afterSeek = () => { - if (getPlayGeneration() !== generation) return; - if (!wantPlaying) { - invoke('audio_pause').catch(console.error); - setIsAudioPaused(true); - usePlayerStore.setState({ isPlaying: false }); - } else { - setIsAudioPaused(false); - } - }; - if (canSeek) { - void invoke('audio_seek', { seconds: seekTo }).then(afterSeek).catch(afterSeek); - } else { - afterSeek(); - } - }) - .catch((err: unknown) => { - if (getPlayGeneration() !== generation) return; - console.error('[psysonic] queue-undo audio_play failed:', err); - usePlayerStore.setState({ isPlaying: false }); - }) - .finally(() => { - setDeferHotCachePrefetch(false); - }); - touchHotCacheOnPlayback(track.id, playbackCacheSid); + engineLoadTrackAtPosition(opts); } diff --git a/src/store/resumeAction.ts b/src/store/resumeAction.ts index 905f5f39..5c34e14d 100644 --- a/src/store/resumeAction.ts +++ b/src/store/resumeAction.ts @@ -180,6 +180,7 @@ export function runResume(set: SetState, get: GetState): void { analysisTrackId: trackToPlay.id, serverId: coldServerId || null, streamFormatSuffix: trackToPlay.suffix ?? null, + startPaused: false, }).then(() => { if (getPlayGeneration() === gen && currentTime > 1) { invoke('audio_seek', { seconds: currentTime }).catch(console.error); @@ -220,6 +221,7 @@ export function runResume(set: SetState, get: GetState): void { analysisTrackId: currentTrack.id, serverId: coldServerId || null, streamFormatSuffix: currentTrack.suffix ?? null, + startPaused: false, }).catch((err: unknown) => { if (getPlayGeneration() !== gen) return; setDeferHotCachePrefetch(false); diff --git a/src/store/transportLightActions.ts b/src/store/transportLightActions.ts index 0c14af35..d0e2c7f4 100644 --- a/src/store/transportLightActions.ts +++ b/src/store/transportLightActions.ts @@ -9,6 +9,7 @@ import { clearSeekDebounce } from './seekDebounce'; import { clearSeekFallbackRetry } from './seekFallbackState'; import { clearSeekTarget } from './seekTargetState'; import { tryAcquireTogglePlayLock } from './togglePlayLock'; +import { refreshWaveformForTrack } from './waveformRefresh'; type SetState = ( partial: Partial | ((state: PlayerState) => Partial), @@ -31,7 +32,8 @@ export function createTransportLightActions(set: SetState, get: GetState): Pick< stop: () => { void playListenSessionFinalize('stop'); clearAllPlaybackScheduleTimers(); - if (get().currentRadio) { + const wasRadio = !!get().currentRadio; + if (wasRadio) { stopRadio(); } else { invoke('audio_stop').catch(console.error); @@ -39,13 +41,16 @@ export function createTransportLightActions(set: SetState, get: GetState): Pick< setIsAudioPaused(false); clearSeekFallbackRetry(); clearSeekDebounce(); clearSeekTarget(); + // Stop keeps `currentTrack` (the bar still shows the stopped song), so its + // waveform stays valid. Radio has no analysis waveform — drop the bins. + const keptTrackId = wasRadio ? null : get().currentTrack?.id ?? null; set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0, currentRadio: null, - waveformBins: null, + ...(keptTrackId ? {} : { waveformBins: null }), normalizationNowDb: null, normalizationTargetLufs: null, normalizationEngineLive: 'off', @@ -56,6 +61,9 @@ export function createTransportLightActions(set: SetState, get: GetState): Pick< scheduledResumeAtMs: null, scheduledResumeStartMs: null, }); + // Re-hydrate from the analysis DB in case the bins were never loaded or + // only partially filled while the (now stopped) track was playing. + if (keptTrackId) void refreshWaveformForTrack(keptTrackId); }, pause: () => {