From f304589ea165adeebfb52096272d538d8f906cc7 Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Fri, 10 Apr 2026 00:15:55 +0200 Subject: [PATCH 1/3] fix(audio): suppress unused variable warning in DecodeError match arm Co-Authored-By: Claude Sonnet 4.6 --- src-tauri/src/audio.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index f85cb24d..efd196f9 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -1149,7 +1149,7 @@ impl Iterator for SizedDecoder { self.current_frame_offset = 0; break; } - Err(symphonia::core::errors::Error::DecodeError(ref msg)) => { + Err(symphonia::core::errors::Error::DecodeError(ref _msg)) => { self.consecutive_decode_errors += 1; // Log sparingly: first drop, then every 10th to avoid spam. #[cfg(debug_assertions)] From 15dc970f5398b7f1a113bba5d872305c4daad35e Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Fri, 10 Apr 2026 00:28:12 +0200 Subject: [PATCH 2/3] fix(audio): restore msg binding in DecodeError arm, use SlidersVertical for EQ icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Revert _msg → msg in DecodeError match arm; the variable is used in the debug_assertions eprintln! block so the underscore prefix caused a compile error - Replace SlidersHorizontal with SlidersVertical (lucide-react) in PlayerBar, LiveSearch, and AdvancedSearch so the EQ/filter icon shows vertical bars Co-Authored-By: Claude Sonnet 4.6 --- src-tauri/src/audio.rs | 2 +- src/components/LiveSearch.tsx | 4 ++-- src/components/PlayerBar.tsx | 4 ++-- src/pages/AdvancedSearch.tsx | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index efd196f9..f85cb24d 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -1149,7 +1149,7 @@ impl Iterator for SizedDecoder { self.current_frame_offset = 0; break; } - Err(symphonia::core::errors::Error::DecodeError(ref _msg)) => { + Err(symphonia::core::errors::Error::DecodeError(ref msg)) => { self.consecutive_decode_errors += 1; // Log sparingly: first drop, then every 10th to avoid spam. #[cfg(debug_assertions)] 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')}

From 5f0fb5dcbdf4015065de50d96f66d7b128fcd4ae Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Fri, 10 Apr 2026 00:40:12 +0200 Subject: [PATCH 3/3] 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 --- src-tauri/src/audio.rs | 72 ++++++++++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 7 ++++ src/App.tsx | 22 ++++++++++++ src/store/playerStore.ts | 5 +++ 4 files changed, 106 insertions(+) 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/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);