mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
d3fc5c91fc
* 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)
108 lines
4.3 KiB
Rust
108 lines
4.3 KiB
Rust
//! Tauri commands for output-device listing + selection. Pulled out of
|
|
//! `commands.rs` so playback / radio / EQ aren't entangled with the device
|
|
//! enumeration + reopen path.
|
|
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::Ordering;
|
|
use std::time::Duration;
|
|
|
|
use tauri::{Emitter, State};
|
|
|
|
use super::dev_io::{
|
|
enumerate_output_device_names, output_devices_logically_same,
|
|
output_enumeration_includes_pinned, with_suppressed_alsa_stderr,
|
|
};
|
|
use super::engine::AudioEngine;
|
|
|
|
/// When the saved `selected_device` no longer literally matches any listed
|
|
/// physical sink (e.g. suffix drift), rewrite `selected_device` to the listed form.
|
|
#[tauri::command]
|
|
pub fn audio_canonicalize_selected_device(state: State<'_, AudioEngine>) -> Option<String> {
|
|
let pinned = state.selected_device.lock().unwrap().clone()?;
|
|
if pinned.is_empty() {
|
|
return None;
|
|
}
|
|
let list = enumerate_output_device_names();
|
|
if list.iter().any(|d| d == &pinned) {
|
|
return None;
|
|
}
|
|
let canon = list
|
|
.iter()
|
|
.find(|d| output_devices_logically_same(d, &pinned))?
|
|
.clone();
|
|
*state.selected_device.lock().unwrap() = Some(canon.clone());
|
|
Some(canon)
|
|
}
|
|
|
|
/// Same device list as [`audio_list_devices`] without the Tauri `State` wrapper (CLI / single-instance).
|
|
pub fn audio_list_devices_for_engine(engine: &AudioEngine) -> Vec<String> {
|
|
let mut list = enumerate_output_device_names();
|
|
if let Some(ref name) = *engine.selected_device.lock().unwrap() {
|
|
if !name.is_empty() && !output_enumeration_includes_pinned(&list, name) {
|
|
list.push(name.clone());
|
|
}
|
|
}
|
|
list
|
|
}
|
|
|
|
/// Returns the names of all available audio output devices on the current host.
|
|
/// On Linux, ALSA probes unavailable backends (JACK, OSS, dmix) and prints errors to
|
|
/// stderr. We suppress fd 2 for the duration of enumeration to keep the terminal clean.
|
|
///
|
|
/// The user-pinned device name is appended when cpal omits it (e.g. HDMI busy while
|
|
/// streaming) so the Settings dropdown still matches `audioOutputDevice`.
|
|
#[tauri::command]
|
|
pub fn audio_list_devices(state: State<'_, AudioEngine>) -> Vec<String> {
|
|
audio_list_devices_for_engine(&state)
|
|
}
|
|
|
|
/// Device id string for the host default output (matches an entry from `audio_list_devices` when present).
|
|
#[tauri::command]
|
|
pub fn audio_default_output_device_name() -> Option<String> {
|
|
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
|
with_suppressed_alsa_stderr(|| {
|
|
let host = rodio::cpal::default_host();
|
|
host.default_output_device()
|
|
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
|
})
|
|
}
|
|
|
|
/// Switch the audio output device. `device_name = null` → follow system default.
|
|
/// Reopens the stream immediately; frontend must restart playback via audio:device-changed.
|
|
#[tauri::command]
|
|
pub async fn audio_set_device(
|
|
device_name: Option<String>,
|
|
state: State<'_, AudioEngine>,
|
|
app: tauri::AppHandle,
|
|
) -> Result<(), String> {
|
|
*state.selected_device.lock().unwrap() = device_name.clone();
|
|
|
|
let rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
|
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
|
|
state.stream_reopen_tx
|
|
.send((rate, false, device_name, reply_tx))
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
let new_handle = tauri::async_runtime::spawn_blocking(move || {
|
|
reply_rx.recv_timeout(Duration::from_secs(5)).ok()
|
|
}).await.unwrap_or(None).ok_or("device open timed out")?;
|
|
|
|
*state.stream_handle.lock().unwrap() = new_handle;
|
|
|
|
// 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(); }
|
|
|
|
// 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(())
|
|
}
|