mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
* 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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user