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
@@ -89,10 +89,19 @@ pub async fn audio_set_device(
*state.stream_handle.lock().unwrap() = new_handle;
// Drop active sinks — they were bound to the old stream.
if let Some(s) = state.current.lock().unwrap().sink.take() { s.stop(); }
// Capture position and drop the active sink atomically so the position
// reading is still valid (play_started / paused_at intact before take).
let current_time = {
let mut cur = state.current.lock().unwrap();
let pos = cur.position();
if let Some(s) = cur.sink.take() { s.stop(); }
pos
};
if let Some(s) = state.fading_out_sink.lock().unwrap().take() { s.stop(); }
app.emit("audio:device-changed", ()).map_err(|e| e.to_string())?;
// Emit the saved position so the frontend can use seekFallbackVisualTarget
// and resume from where the track was, rather than restarting from the beginning.
// null is reserved for "Rust already resumed internally" (see reopen_output_stream).
app.emit("audio:device-changed", current_time).map_err(|e| e.to_string())?;
Ok(())
}
@@ -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
}
@@ -6,6 +6,7 @@ use std::time::{Duration, Instant};
use tauri::Emitter;
use tauri::Manager;
use super::device_resume::{try_resume_after_device_change, ResumeSnapshot};
use super::engine::AudioEngine;
#[cfg(not(target_os = "linux"))]
use super::dev_io::output_enumeration_includes_pinned;
@@ -21,6 +22,15 @@ pub(crate) enum ReopenNotify {
/// Opens a new CPAL/rodio output stream with the given rate and device name (same path as
/// manual device switch). Used by the device watcher and Windows suspend/resume notifications.
///
/// If the interrupted track is a seekable local file or a fully-cached HTTP download
/// (in-memory or spill file), the function replays it internally from the saved position —
/// no frontend round-trip, no audible restart. On success it emits
/// `audio:device-changed` / `audio:device-reset` with a `null` payload so the frontend
/// knows Rust already handled playback.
/// For radio, partially-buffered HTTP tracks, or paused playback, it falls back to the
/// previous behaviour: emit with the captured `current_time_secs` so the frontend calls
/// `playTrack`.
pub(crate) async fn reopen_output_stream(
app: &tauri::AppHandle,
device_name: Option<String>,
@@ -36,6 +46,22 @@ pub(crate) async fn reopen_output_stream(
let current = engine.current.clone();
let fading_out = engine.fading_out_sink.clone();
// Snapshot state we need BEFORE the blocking stream reopen (while the old sink
// is still live and position() is still valid).
let snapshot = {
let cur = current.lock().unwrap();
let is_playing = cur.play_started.is_some() && cur.paused_at.is_none();
ResumeSnapshot {
url: engine.current_playback_url.lock().unwrap().clone(),
current_time_secs: cur.position(),
duration_secs: cur.duration_secs,
base_volume: cur.base_volume,
gain_linear: cur.replay_gain_linear,
analysis_track_id: engine.current_analysis_track_id.lock().unwrap().clone(),
is_playing,
}
};
let new_handle = tauri::async_runtime::spawn_blocking(move || {
let (reply_tx, reply_rx) =
std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
@@ -61,18 +87,34 @@ pub(crate) async fn reopen_output_stream(
if let Some(s) = fading_out.lock().unwrap().take() {
s.stop();
}
// Attempt a Rust-side internal replay (no frontend involvement).
// Falls back gracefully to the frontend path if conditions aren't met.
let resumed = try_resume_after_device_change(app, &snapshot).await;
match notify {
ReopenNotify::DeviceChanged => {
app.emit("audio:device-changed", ()).ok();
// null → Rust already resumed; frontend skips playTrack
// f64 → fallback; frontend calls playTrack + seek
if resumed {
app.emit("audio:device-changed", Option::<f64>::None).ok();
} else {
app.emit("audio:device-changed", snapshot.current_time_secs).ok();
}
}
#[cfg(not(target_os = "linux"))]
ReopenNotify::DeviceReset => {
app.emit("audio:device-reset", ()).ok();
if resumed {
app.emit("audio:device-reset", Option::<f64>::None).ok();
} else {
app.emit("audio:device-reset", snapshot.current_time_secs).ok();
}
}
}
true
}
pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
let selected_device = engine.selected_device.clone();
let samples_played = engine.samples_played.clone();
@@ -18,6 +18,7 @@ pub mod preload_commands;
pub(crate) mod progress_task;
pub mod radio_commands;
pub mod transport_commands;
mod device_resume;
mod device_watcher;
mod engine;
#[cfg(any(target_os = "windows", target_os = "linux"))]
@@ -26,7 +26,7 @@ use super::stream::{
};
/// What `audio_play` will hand to `build_source` / `build_streaming_source`.
pub(super) enum PlayInput {
pub(crate) enum PlayInput {
Bytes(Vec<u8>),
/// Seekable on-demand source — `RangedHttpSource` for HTTP streams,
/// `LocalFileSource` for `psysonic-local://` files. Goes through
@@ -436,7 +436,7 @@ pub(super) fn spawn_legacy_stream_start_when_armed(
/// query string first so Subsonic-style URLs (`stream.view?...&v=1.16.1&...`)
/// don't latch onto random query-param substrings; only accept short
/// alphanumeric tails that look like an actual audio extension.
pub(super) fn url_format_hint(url: &str) -> Option<String> {
pub(crate) fn url_format_hint(url: &str) -> Option<String> {
url.split('?').next()
.and_then(|path| path.rsplit('.').next())
.filter(|ext| {
@@ -455,7 +455,7 @@ pub(super) fn url_format_hint(url: &str) -> Option<String> {
/// Bundles the format-hint inputs, playback-shaping parameters and the shared
/// done flag so that `build_playback_source_with_probe_fallback` stays below
/// the `clippy::too_many_arguments` threshold.
pub(super) struct BuildSourceArgs<'a> {
pub(crate) struct BuildSourceArgs<'a> {
pub url: &'a str,
pub gen: u64,
pub cache_id_for_tasks: Option<&'a str>,
@@ -470,28 +470,28 @@ pub(super) struct BuildSourceArgs<'a> {
/// Output of `build_source_from_play_input`: the wrapped rodio source plus
/// whether the chosen source path is seekable (only the Streaming variant
/// is not).
pub(super) struct PlaybackSource {
pub(super) built: BuiltSource,
pub(super) is_seekable: bool,
pub(crate) struct PlaybackSource {
pub(crate) built: BuiltSource,
pub(crate) is_seekable: bool,
}
/// State + decisions audio_play computed before the sink swap.
pub(super) struct SinkSwapInputs {
pub(super) sink: Arc<rodio::Player>,
pub(super) duration_secs: f64,
pub(super) volume: f32,
pub(super) gain_linear: f32,
pub(super) fadeout_trigger: Arc<AtomicBool>,
pub(super) fadeout_samples: Arc<std::sync::atomic::AtomicU64>,
pub(super) crossfade_enabled: bool,
pub(super) actual_fade_secs: f32,
pub(crate) struct SinkSwapInputs {
pub(crate) sink: Arc<rodio::Player>,
pub(crate) duration_secs: f64,
pub(crate) volume: f32,
pub(crate) gain_linear: f32,
pub(crate) fadeout_trigger: Arc<AtomicBool>,
pub(crate) fadeout_samples: Arc<std::sync::atomic::AtomicU64>,
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(super) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapInputs) {
pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapInputs) {
use std::time::Instant;
let SinkSwapInputs {
@@ -676,7 +676,7 @@ fn is_in_memory_probe_failure(err: &str) -> bool {
/// Like [`build_source_from_play_input`], but on ranged-stream probe failure waits
/// for a full download (or fetches it) and retries from in-memory bytes.
pub(super) async fn build_playback_source_with_probe_fallback(
pub(crate) async fn build_playback_source_with_probe_fallback(
play_input: PlayInput,
args: BuildSourceArgs<'_>,
state: &State<'_, AudioEngine>,
@@ -53,7 +53,7 @@ impl<R: Runtime> ProgressEmitter for AppHandle<R> {
/// • Immediate `audio:track_switched` event at decoder boundary
/// • `audio:ended` only fires when no chained successor exists
#[allow(clippy::too_many_arguments)]
pub(super) fn spawn_progress_task<E: ProgressEmitter>(
pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
gen: u64,
gen_counter: Arc<AtomicU64>,
current_arc: Arc<Mutex<AudioCurrent>>,