fix(audio): resume playback seamlessly on output device switch (#743) (#765)

* fix(audio): resume playback seamlessly on output device switch (#743)

When the OS default output device changed (Bluetooth, USB DAC, HDMI),
rodio/cpal had to reopen the stream, which silently stopped the active
sink and left the engine with no playback — causing the track to restart
from the beginning (or not restart at all after the null-payload bug).

Root cause (null-payload):
  audio_set_device emitted () (unit), which Tauri serialises to JSON
  null. The null-guard added for the "Rust handled replay internally"
  signal was therefore also triggered by manual device switches, so
  playTrack was never called and the engine stayed silent.
Fix: audio_set_device now emits the current playback position as f64
(null remains the exclusive "Rust handled" sentinel).

Rust-side seamless replay (device watcher path):
  reopen_output_stream captures a ResumeSnapshot before the blocking
  stream reopen, then calls try_resume_after_device_change, which:
    - local files (psysonic-local://): reopens the file, builds a new
      seekable source, seeks to the saved position — zero frontend
      round-trip, no audible restart.
    - fully-cached HTTP tracks (stream_completed_cache / spill file):
      replays from the in-memory or on-disk bytes — no re-download.
    - partial downloads / radio / paused: returns false → falls back to
      the existing frontend path (seekFallbackVisualTarget + playTrack).

Frontend (useAudioDeviceBridge):
  null payload  → Rust already resumed; skip playTrack.
  number payload → call playTrack + seekFallbackVisualTarget(position).

Visibility: pub(super) → pub(crate) on the play_input / progress_task
helpers that device_watcher.rs needs to call directly.

* refactor(audio): split device_resume module; add bridge tests

Split the 551-line device_watcher.rs (above the ~500-line soft ceiling)
by extracting ResumeSnapshot and try_resume_after_device_change into a
dedicated device_resume.rs module. device_watcher.rs is now 320 lines,
device_resume.rs 258 lines.

Add useAudioDeviceBridge.test.ts: 9 characterisation tests covering the
null-payload guard ("Rust replayed, skip playTrack"), the seek-fallback
path (position > 0.5 s sets seekFallbackVisualTarget), paused-device
branch (resetAudioPause), and the device-reset event path.

* chore(changelog): add entry for #765 (device switch seamless resume)
This commit is contained in:
cucadmuh
2026-05-18 01:01:23 +03:00
committed by GitHub
parent 33a1b8709d
commit d3fc5c91fc
9 changed files with 509 additions and 31 deletions
@@ -0,0 +1,258 @@
//! 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<String>,
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<String>,
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::<AudioEngine>() 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<String> = 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;
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(),
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::<Result<(), String>>();
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());
}
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(),
);
crate::app_deprintln!(
"[device-resume] internal replay ok — url={url:?} resume_at={:.2}s seekable={}",
snap.current_time_secs,
ps.is_seekable
);
true
}