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
@@ -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>,