//! Rust-side seamless replay after an output-device switch. //! //! `try_resume_after_device_change` is called from `reopen_output_stream` //! (device_watcher.rs) after the new CPAL stream is ready and the old sink //! has been stopped. It attempts to restart the current track on the new //! device without any frontend round-trip. //! //! Supported source paths (in order of preference): //! - `psysonic-local://` — opened directly from disk via `LocalFileSource`. //! - HTTP, fully cached in RAM — replayed from `stream_completed_cache`. //! - HTTP, spilled to disk — bytes read from `stream_completed_spill`. //! //! Falls back to the frontend (returns `false`) for: //! - paused playback //! - radio / live stream //! - HTTP track whose download was only partial use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Instant; use rodio::Player; use tauri::Emitter; 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, }; use super::progress_task::spawn_progress_task; use super::stream::LocalFileSource; /// Snapshot of playback state captured before the blocking stream reopen. pub(crate) struct ResumeSnapshot { pub(crate) url: Option, pub(crate) current_time_secs: f64, pub(crate) duration_secs: f64, pub(crate) base_volume: f32, pub(crate) gain_linear: f32, pub(crate) analysis_track_id: Option, pub(crate) is_playing: bool, } /// Try to replay the current track on the new device without involving the /// frontend. Returns `true` if playback was successfully restarted. /// /// Conditions that cause an immediate `false` (frontend fallback): /// - Paused playback — user can press play on the new device via the cold path. /// - Radio stream — live, non-seekable; frontend handles reconnect. /// - No current URL — nothing was playing. /// - HTTP track whose download was only partial (cache/spill absent) — frontend /// re-fetches from the server via the seekFallbackVisualTarget path. pub(crate) async fn try_resume_after_device_change( app: &tauri::AppHandle, snap: &ResumeSnapshot, ) -> bool { // Only resume actively-playing (not paused) tracks. if !snap.is_playing { return false; } let url = match snap.url.as_deref() { Some(u) if !u.is_empty() => u, _ => return false, }; let Some(engine) = app.try_state::() else { return false; }; // Skip radio — live streams don't have a resume position. if engine.radio_state.lock().unwrap().is_some() { return false; } // Build a PlayInput without re-downloading: // - psysonic-local:// → seekable file // - HTTP, fully cached → in-memory bytes (stream_completed_cache) // - HTTP, spilled → bytes read from spill file // - HTTP, partial → return false (frontend will re-fetch) let play_input: PlayInput = if url.starts_with("psysonic-local://") { let path = url.strip_prefix("psysonic-local://").unwrap_or(url); match std::fs::File::open(path) { Ok(file) => { let len = file.metadata().map(|m| m.len()).unwrap_or(0); PlayInput::SeekableMedia { reader: Box::new(LocalFileSource { file, len }), format_hint: url_format_hint(url), tag: "LocalFile[device-resume]", mp4_probe_gate: None, } } Err(e) => { crate::app_eprintln!("[device-resume] cannot open local file: {e}"); return false; } } } else { // HTTP track — use completed in-memory cache or spill file. // If the download was only partial, fall back to the frontend path // which will re-fetch from the server. let ram_bytes = { let guard = engine.stream_completed_cache.lock().unwrap(); guard.as_ref().filter(|t| t.url == url).map(|t| t.data.clone()) }; let bytes = if let Some(b) = ram_bytes { b } else { let spill_path = { let guard = engine.stream_completed_spill.lock().unwrap(); guard.as_ref().filter(|s| s.url == url).map(|s| s.path.clone()) }; match spill_path { Some(p) => match std::fs::read(&p) { Ok(b) => b, Err(e) => { crate::app_eprintln!("[device-resume] spill read failed: {e}"); return false; } }, None => return false, // not fully cached yet — frontend will re-fetch } }; PlayInput::Bytes(bytes) }; // Bump generation so the old progress task exits cleanly. let gen = engine.generation.fetch_add(1, Ordering::SeqCst) + 1; engine.stream_playback_armed.store(true, Ordering::SeqCst); *engine.chained_info.lock().unwrap() = None; *engine.current_playback_url.lock().unwrap() = Some(url.to_owned()); if engine.generation.load(Ordering::SeqCst) != gen { return false; // raced with another audio_play } let format_hint = url_format_hint(url); let stream_format_suffix: Option = url .rsplit('.') .next() .and_then(|e| e.split('?').next()) .map(|s| s.to_lowercase()); let done_flag = Arc::new(AtomicBool::new(false)); engine.samples_played.store(0, Ordering::Relaxed); let hi_res_enabled = engine.current_sample_rate.load(Ordering::Relaxed) > 48_000; // Resume re-plays the current track → scope its analysis writes to the // pinned playback server (empty → legacy ''). let resume_server = crate::helpers::current_playback_server_id_str(&engine); let ps: PlaybackSource = match build_playback_source_with_probe_fallback( play_input, BuildSourceArgs { url, gen, cache_id_for_tasks: snap.analysis_track_id.as_deref(), server_id: Some(resume_server.as_str()), url_format_hint: format_hint.as_deref(), stream_format_suffix: stream_format_suffix.as_deref(), done_flag: done_flag.clone(), fade_in_dur: std::time::Duration::from_millis(5), hi_res_enabled, duration_hint: snap.duration_secs, }, &engine, app, ) .await { Ok(ps) => ps, Err(e) => { crate::app_eprintln!("[device-resume] source build failed: {e}"); return false; } }; if engine.generation.load(Ordering::SeqCst) != gen { return false; } engine .current_is_seekable .store(ps.is_seekable, Ordering::SeqCst); engine .current_sample_rate .store(ps.built.output_rate, Ordering::Relaxed); engine .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 effective_volume = (snap.base_volume * snap.gain_linear).clamp(0.0, 1.0); sink.set_volume(effective_volume); sink.append(ps.built.source); swap_in_new_sink( &engine, SinkSwapInputs { sink, duration_secs: ps.built.duration_secs, volume: snap.base_volume, gain_linear: snap.gain_linear, fadeout_trigger: ps.built.fadeout_trigger, fadeout_samples: ps.built.fadeout_samples, crossfade_enabled: false, actual_fade_secs: 0.0, }, ); // Seek to the saved position for seekable sources (local files, ranged HTTP). if ps.is_seekable && snap.current_time_secs > 0.5 { let seek_sink = engine.current.lock().unwrap().sink.as_ref().map(Arc::clone); if let Some(sk) = seek_sink { let target = std::time::Duration::from_secs_f64(snap.current_time_secs.max(0.0)); let (tx, rx) = std::sync::mpsc::channel::>(); std::thread::spawn(move || { let _ = tx.send(sk.try_seek(target).map_err(|e| e.to_string())); }); match rx.recv_timeout(std::time::Duration::from_millis(700)) { Ok(Ok(())) => { let mut cur = engine.current.lock().unwrap(); cur.seek_offset = snap.current_time_secs; cur.play_started = Some(Instant::now()); engine.samples_played.store( crate::playback_rate::raw_counter_samples_for_content_position( snap.current_time_secs, engine.current_sample_rate.load(Ordering::Relaxed), engine.current_channels.load(Ordering::Relaxed), &engine.playback_rate, ), Ordering::Relaxed, ); } Ok(Err(e)) => { crate::app_eprintln!("[device-resume] seek failed: {e}"); } Err(_) => { crate::app_eprintln!("[device-resume] seek timed out"); } } } } // Inform the frontend of the new duration (keeps seekbar range correct). app.emit("audio:playing", ps.built.duration_secs).ok(); spawn_progress_task( gen, engine.generation.clone(), engine.current.clone(), engine.chained_info.clone(), engine.crossfade_enabled.clone(), engine.crossfade_secs.clone(), done_flag, app.clone(), engine.samples_played.clone(), engine.current_sample_rate.clone(), engine.current_channels.clone(), engine.gapless_switch_at.clone(), engine.current_playback_url.clone(), engine.stream_playback_armed.clone(), engine.playback_rate.clone(), ); crate::app_deprintln!( "[device-resume] internal replay ok — url={url:?} resume_at={:.2}s seekable={}", snap.current_time_secs, ps.is_seekable ); true }