feat(audio): auto-switch to new audio output device at runtime

Adds a background device-watcher that polls the OS default output device
every 3 s via CPAL. When the device changes (Bluetooth headphones connecting,
USB DAC plugging in, etc.) the stream is reopened on the new device, the old
Sink is dropped (it was bound to the now-closed OutputStream), and
audio:device-changed is emitted to the frontend.

Frontend handler (TauriEventBridge): if playing, restarts the current track
from the saved position; if paused, clears the warm-pause flag so the next
resume uses the cold path (audio_play + seek) which creates a new Sink on
the new device.

Fixes #143 (audio through speakers after connecting Bluetooth headphones).
Also covers the intermittently reported single-channel output after long idle,
which can be caused by the OS reconfiguring the audio device in the background.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-10 00:40:12 +02:00
parent 15dc970f53
commit 5f0fb5dcbd
4 changed files with 106 additions and 0 deletions
+72
View File
@@ -2883,3 +2883,75 @@ pub fn audio_set_crossfade(enabled: bool, secs: f32, state: State<'_, AudioEngin
pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) { pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
state.gapless_enabled.store(enabled, Ordering::Relaxed); state.gapless_enabled.store(enabled, Ordering::Relaxed);
} }
// ─── Device-change watcher ────────────────────────────────────────────────────
//
// Polls the OS default output device every 3 s. When it changes (Bluetooth
// headphones connecting, USB DAC plugging in, etc.) the stream is reopened on
// the new device and `audio:device-changed` is emitted so the frontend can
// restart playback. The old Sink is dropped here — it was bound to the
// now-closed OutputStream and can no longer produce audio on any device.
pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
let reopen_tx = engine.stream_reopen_tx.clone();
let stream_handle = engine.stream_handle.clone();
let stream_rate = engine.stream_sample_rate.clone();
let current = engine.current.clone();
let fading_out = engine.fading_out_sink.clone();
tauri::async_runtime::spawn(async move {
let mut last_name: Option<String> = tauri::async_runtime::spawn_blocking(|| {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
rodio::cpal::default_host()
.default_output_device()
.and_then(|d| d.name().ok())
}).await.unwrap_or(None);
loop {
tokio::time::sleep(Duration::from_secs(3)).await;
let current_name: Option<String> = tauri::async_runtime::spawn_blocking(|| {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
rodio::cpal::default_host()
.default_output_device()
.and_then(|d| d.name().ok())
}).await.unwrap_or(None);
if current_name == last_name {
continue;
}
last_name = current_name.clone();
// Only act if there is actually a device to open.
let Some(_new_name) = current_name else { continue };
// Debounce: give the OS time to finish configuring the new device.
tokio::time::sleep(Duration::from_millis(500)).await;
let rate = stream_rate.load(Ordering::Relaxed);
let reopen_tx2 = reopen_tx.clone();
let new_handle = tauri::async_runtime::spawn_blocking(move || {
let (reply_tx, reply_rx) =
std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
if reopen_tx2.send((rate, false, reply_tx)).is_err() {
return None; // audio thread exited
}
reply_rx.recv_timeout(Duration::from_secs(5)).ok()
}).await.unwrap_or(None);
let Some(handle) = new_handle else {
eprintln!("[psysonic] device-watcher: stream reopen timed out");
continue;
};
*stream_handle.lock().unwrap() = handle;
// Drop the old Sink — it was bound to the now-closed OutputStream.
if let Some(s) = current.lock().unwrap().sink.take() { s.stop(); }
if let Some(s) = fading_out.lock().unwrap().take() { s.stop(); }
app.emit("audio:device-changed", ()).ok();
}
});
}
+7
View File
@@ -1386,6 +1386,13 @@ pub fn run() {
} }
} }
// ── Audio device-change watcher ───────────────────────────────
{
use tauri::Manager;
let engine = app.state::<audio::AudioEngine>();
audio::start_device_watcher(&engine, app.handle().clone());
}
Ok(()) Ok(())
}) })
.on_window_event(|window, event| { .on_window_event(|window, event| {
+22
View File
@@ -413,6 +413,28 @@ function TauriEventBridge() {
return () => { unlisten?.(); }; return () => { unlisten?.(); };
}, []); }, []);
// Audio output device changed (Bluetooth headphones, USB DAC, etc.)
// The Rust device-watcher has already reopened the stream on the new device
// and dropped the old Sink, so we just need to restart playback.
useEffect(() => {
let unlisten: (() => void) | undefined;
listen('audio:device-changed', () => {
const { currentTrack, currentTime, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
if (!currentTrack) return;
if (isPlaying) {
const pos = currentTime;
const dur = currentTrack.duration || 1;
playTrack(currentTrack);
setTimeout(() => usePlayerStore.getState().seek(pos / dur), 600);
} else {
// Paused: clear warm-pause flag so the next resume uses the cold path
// (audio_play + seek) which creates a new Sink on the new device.
resetAudioPause();
}
}).then(u => { unlisten = u; });
return () => { unlisten?.(); };
}, []);
// Sync tray-icon visibility with the user's stored setting. // Sync tray-icon visibility with the user's stored setting.
// Runs once on mount (initial sync) and again whenever the setting changes. // Runs once on mount (initial sync) and again whenever the setting changes.
const showTrayIcon = useAuthStore(s => s.showTrayIcon); const showTrayIcon = useAuthStore(s => s.showTrayIcon);
+5
View File
@@ -117,6 +117,7 @@ interface PlayerState {
setLastfmLovedForSong: (title: string, artist: string, v: boolean) => void; setLastfmLovedForSong: (title: string, artist: string, v: boolean) => void;
syncLastfmLovedTracks: () => Promise<void>; syncLastfmLovedTracks: () => Promise<void>;
resetAudioPause: () => void;
initializeFromServerQueue: () => Promise<void>; initializeFromServerQueue: () => Promise<void>;
contextMenu: { contextMenu: {
@@ -863,6 +864,10 @@ export const usePlayerStore = create<PlayerState>()(
set({ isPlaying: false }); set({ isPlaying: false });
}, },
resetAudioPause: () => {
isAudioPaused = false;
},
resume: () => { resume: () => {
if (get().currentRadio) { if (get().currentRadio) {
radioAudio.play().catch(console.error); radioAudio.play().catch(console.error);