diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index f85cb24d..f85d5593 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -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>) { 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 = 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 = 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::(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(); + } + }); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4c8af217..5df6456b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1386,6 +1386,13 @@ pub fn run() { } } + // ── Audio device-change watcher ─────────────────────────────── + { + use tauri::Manager; + let engine = app.state::(); + audio::start_device_watcher(&engine, app.handle().clone()); + } + Ok(()) }) .on_window_event(|window, event| { diff --git a/src/App.tsx b/src/App.tsx index 427b5e9c..62299fdd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -413,6 +413,28 @@ function TauriEventBridge() { 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. // Runs once on mount (initial sync) and again whenever the setting changes. const showTrayIcon = useAuthStore(s => s.showTrayIcon); diff --git a/src/components/LiveSearch.tsx b/src/components/LiveSearch.tsx index 7efac64a..a371de2a 100644 --- a/src/components/LiveSearch.tsx +++ b/src/components/LiveSearch.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Search, Disc3, Users, Music, SlidersHorizontal } from 'lucide-react'; +import { Search, Disc3, Users, Music, SlidersVertical } from 'lucide-react'; import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic'; import { usePlayerStore, songToTrack } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; @@ -126,7 +126,7 @@ export default function LiveSearch() { data-tooltip-pos="bottom" aria-label={t('search.advanced')} > - + diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index b2677740..e21c3a26 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; import { Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music, - Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, MicVocal, Cast + Square, Repeat, Repeat1, Maximize2, SlidersVertical, X, Heart, MicVocal, Cast } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; @@ -241,7 +241,7 @@ export default function PlayerBar() { aria-label="Equalizer" data-tooltip="Equalizer" > - + {/* Volume */} diff --git a/src/pages/AdvancedSearch.tsx b/src/pages/AdvancedSearch.tsx index 1bb04ca1..dcbb82c9 100644 --- a/src/pages/AdvancedSearch.tsx +++ b/src/pages/AdvancedSearch.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import { useSearchParams, useNavigate } from 'react-router-dom'; -import { Play, SlidersHorizontal } from 'lucide-react'; +import { Play, SlidersVertical } from 'lucide-react'; import { search, getGenres, getAlbumsByGenre, getAlbumList, getRandomSongs, SubsonicGenre, SubsonicArtist, SubsonicAlbum, SubsonicSong, @@ -140,7 +140,7 @@ export default function AdvancedSearch() {

- + {t('search.advanced')}

diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 8f020092..c3716091 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -117,6 +117,7 @@ interface PlayerState { setLastfmLovedForSong: (title: string, artist: string, v: boolean) => void; syncLastfmLovedTracks: () => Promise; + resetAudioPause: () => void; initializeFromServerQueue: () => Promise; contextMenu: { @@ -863,6 +864,10 @@ export const usePlayerStore = create()( set({ isPlaying: false }); }, + resetAudioPause: () => { + isAudioPaused = false; + }, + resume: () => { if (get().currentRadio) { radioAudio.play().catch(console.error);