From 16cb4f5a6fe4b603fbea1c5d19dcad4cc07f20ce Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Mon, 13 Apr 2026 18:20:23 +0200 Subject: [PATCH] feat(audio): audio output device selection (closes #169) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the ability to choose which audio output device Psysonic plays through — useful for USB DACs, dedicated soundcards, and multi-device setups. Rust (audio.rs): - open_stream_for_device_and_rate(device_name, rate): opens a named device by cpal name, falls back to system default if not found - AudioEngine.selected_device: persists the chosen device across stream reopens so hi-res rate switches stay on the right device - audio_list_devices: Tauri command — returns all output device names - audio_set_device: Tauri command — switches device immediately, drops old sinks, emits audio:device-changed - start_device_watcher: now handles two cases: 1. No pinned device + system default changed → reopen on new default 2. Pinned device disappeared (DAC unplugged) → clear selected_device, fall back to system default, emit audio:device-reset Frontend: - authStore: audioOutputDevice (string | null), persisted - playerStore: applies stored device on cold start - App.tsx: listens to audio:device-reset, clears authStore device and restarts playback on the fallback device - Settings → Audio tab: device dropdown at top (above Hi-Res and EQ), uses CustomSelect (portal-based, styled), all 8 locales - CustomSelect: added disabled prop Note: exclusive mode (WASAPI exclusive, CoreAudio exclusive) is out of scope for now. Co-Authored-By: Claude Sonnet 4.6 --- src-tauri/src/audio.rs | 167 +++++++++++++++++++++++++------- src-tauri/src/lib.rs | 2 + src/App.tsx | 20 ++++ src/components/CustomSelect.tsx | 6 +- src/locales/de.ts | 3 + src/locales/en.ts | 3 + src/locales/es.ts | 3 + src/locales/fr.ts | 3 + src/locales/nb.ts | 3 + src/locales/nl.ts | 3 + src/locales/ru.ts | 3 + src/locales/zh.ts | 3 + src/pages/Settings.tsx | 90 ++++++++++++----- src/store/authStore.ts | 5 + src/store/playerStore.ts | 5 +- 15 files changed, 253 insertions(+), 66 deletions(-) diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index 896465ef..17135f3a 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -1448,9 +1448,11 @@ pub struct AudioEngine { /// The rate the device was opened at on cold start — used to restore the /// stream when Hi-Res is toggled off while a hi-res rate is active. pub device_default_rate: u32, - /// Sends `(desired_rate, is_hi_res, reply_tx)` to the audio-stream thread to - /// re-open the output device. `is_hi_res` controls thread-priority escalation. - pub stream_reopen_tx: std::sync::mpsc::SyncSender<(u32, bool, std::sync::mpsc::SyncSender)>, + /// Sends `(desired_rate, is_hi_res, device_name, reply_tx)` to the audio-stream + /// thread to re-open the output device. `device_name = None` → system default. + pub stream_reopen_tx: std::sync::mpsc::SyncSender<(u32, bool, Option, std::sync::mpsc::SyncSender)>, + /// User-selected output device name (None = follow system default). + pub selected_device: Arc>>, pub current: Arc>, /// Monotonically incremented on each audio_play (non-chain) / audio_stop call. pub generation: Arc, @@ -1511,7 +1513,10 @@ impl AudioCurrent { } } -/// Open the system default output device at `desired_rate` Hz (0 = device default). +/// Open an output device at `desired_rate` Hz (0 = device default). +/// +/// `device_name`: exact name from `audio_list_devices`. `None` → system default. +/// Falls back to the system default if the named device is not found. /// /// Resolution order: /// 1. Exact rate match in the device's supported config ranges. @@ -1520,12 +1525,17 @@ impl AudioCurrent { /// 4. System default (last resort). /// /// Returns `(OutputStream, OutputStreamHandle, actual_sample_rate)`. -fn open_stream_for_rate(desired_rate: u32) -> (rodio::OutputStream, rodio::OutputStreamHandle, u32) { +fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32) -> (rodio::OutputStream, rodio::OutputStreamHandle, u32) { use rodio::cpal::traits::{DeviceTrait, HostTrait}; let host = rodio::cpal::default_host(); - if let Some(device) = host.default_output_device() { + // Resolve the target device: named device first, fall back to system default. + let device = device_name.and_then(|name| { + host.output_devices().ok()?.find(|d| d.name().ok().as_deref() == Some(name)) + }).or_else(|| host.default_output_device()); + + if let Some(device) = device { if desired_rate > 0 { if let Ok(supported) = device.supported_output_configs() { let configs: Vec<_> = supported.collect(); @@ -1608,7 +1618,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) { let (init_tx, init_rx) = std::sync::mpsc::sync_channel::<(rodio::OutputStreamHandle, u32)>(0); let (reopen_tx, reopen_rx) = - std::sync::mpsc::sync_channel::<(u32, bool, std::sync::mpsc::SyncSender)>(4); + std::sync::mpsc::sync_channel::<(u32, bool, Option, std::sync::mpsc::SyncSender)>(4); let thread = std::thread::Builder::new() .name("psysonic-audio-stream".into()) @@ -1627,11 +1637,11 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) { // Thread priority is kept at default during standard-mode playback. // It is escalated to Max only when a Hi-Res stream reopen is requested, // to prevent PipeWire underruns at high quantum sizes (8192 frames). - let (mut _stream, handle, rate) = open_stream_for_rate(0); + let (mut _stream, handle, rate) = open_stream_for_device_and_rate(None, 0); init_tx.send((handle, rate)).ok(); - // Keep the stream alive and handle sample-rate switch requests. - while let Ok((desired_rate, is_hi_res, reply_tx)) = reopen_rx.recv() { + // Keep the stream alive and handle sample-rate / device-switch requests. + while let Ok((desired_rate, is_hi_res, device_name, reply_tx)) = reopen_rx.recv() { // Escalate to Max for Hi-Res reopens (large PipeWire quanta need // real-time scheduling to avoid underruns). No escalation for // standard mode — the thread blocks on recv() between reopens so @@ -1657,7 +1667,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) { std::env::set_var("PULSE_LATENCY_MSEC", latency_ms.to_string()); } - let (new_stream, new_handle, _actual) = open_stream_for_rate(desired_rate); + let (new_stream, new_handle, _actual) = open_stream_for_device_and_rate(device_name.as_deref(), desired_rate); _stream = new_stream; reply_tx.send(new_handle).ok(); } @@ -1671,6 +1681,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) { stream_sample_rate: Arc::new(AtomicU32::new(initial_rate)), device_default_rate: initial_rate, stream_reopen_tx: reopen_tx, + selected_device: Arc::new(Mutex::new(None)), current: Arc::new(Mutex::new(AudioCurrent { sink: None, duration_secs: 0.0, @@ -2018,7 +2029,8 @@ pub async fn audio_play( let needs_switch = target_rate > 0 && target_rate != current_stream_rate; if needs_switch { let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::(0); - if state.stream_reopen_tx.send((target_rate, hi_res_enabled, reply_tx)).is_ok() { + let dev = state.selected_device.lock().unwrap().clone(); + if state.stream_reopen_tx.send((target_rate, hi_res_enabled, dev, reply_tx)).is_ok() { match reply_rx.recv_timeout(std::time::Duration::from_secs(5)) { Ok(new_handle) => { *state.stream_handle.lock().unwrap() = new_handle; @@ -2883,6 +2895,46 @@ pub async fn audio_play_radio( Ok(()) } +/// Returns the names of all available audio output devices on the current host. +#[tauri::command] +pub fn audio_list_devices() -> Vec { + use rodio::cpal::traits::{DeviceTrait, HostTrait}; + let host = rodio::cpal::default_host(); + host.output_devices() + .map(|iter| iter.filter_map(|d| d.name().ok()).collect()) + .unwrap_or_default() +} + +/// 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, + 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::(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; + + // Drop active sinks — they were bound to the old stream. + if let Some(s) = state.current.lock().unwrap().sink.take() { s.stop(); } + if let Some(s) = state.fading_out_sink.lock().unwrap().take() { s.stop(); } + + app.emit("audio:device-changed", ()).map_err(|e| e.to_string())?; + Ok(()) +} + #[tauri::command] pub fn audio_set_crossfade(enabled: bool, secs: f32, state: State<'_, AudioEngine>) { state.crossfade_enabled.store(enabled, Ordering::Relaxed); @@ -2896,21 +2948,23 @@ pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) { // ─── 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. +// Polls every 3 s for two conditions: +// 1. System default device changed (Bluetooth, USB DAC plug/unplug) while no +// device is pinned → reopen on new default, emit audio:device-changed. +// 2. User-pinned device disappeared (DAC unplugged) → fall back to system +// default, clear selected_device, emit audio:device-reset so the frontend +// can reset its dropdown and persist the change. 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(); + 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(); + let selected_device = engine.selected_device.clone(); tauri::async_runtime::spawn(async move { - let mut last_name: Option = tauri::async_runtime::spawn_blocking(|| { + let mut last_default: Option = tauri::async_runtime::spawn_blocking(|| { use rodio::cpal::traits::{DeviceTrait, HostTrait}; rodio::cpal::default_host() .default_output_device() @@ -2920,21 +2974,63 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) { loop { tokio::time::sleep(Duration::from_secs(3)).await; - let current_name: Option = tauri::async_runtime::spawn_blocking(|| { + // Enumerate all available output devices and the current default. + let (current_default, available) = 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); + let host = rodio::cpal::default_host(); + let default = host.default_output_device().and_then(|d| d.name().ok()); + let available: Vec = host + .output_devices() + .map(|iter| iter.filter_map(|d| d.name().ok()).collect()) + .unwrap_or_default(); + (default, available) + }).await.unwrap_or((None, vec![])); - if current_name == last_name { + let pinned = selected_device.lock().unwrap().clone(); + + if let Some(ref dev_name) = pinned { + // ── Case 2: pinned device disappeared ──────────────────────── + if !available.iter().any(|d| d == dev_name) { + eprintln!("[psysonic] device-watcher: pinned device '{dev_name}' disconnected, falling back to system default"); + *selected_device.lock().unwrap() = None; + + // Debounce so the OS finishes reconfiguring. + 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, None, reply_tx)).is_err() { + return None; + } + reply_rx.recv_timeout(Duration::from_secs(5)).ok() + }).await.unwrap_or(None); + + if let Some(handle) = new_handle { + *stream_handle.lock().unwrap() = handle; + if let Some(s) = current.lock().unwrap().sink.take() { s.stop(); } + if let Some(s) = fading_out.lock().unwrap().take() { s.stop(); } + // audio:device-reset tells the frontend to clear its + // audioOutputDevice setting and restart playback. + app.emit("audio:device-reset", ()).ok(); + } + + last_default = current_default; + } + // Pinned device still present — nothing to do. continue; } - last_name = current_name.clone(); + // ── Case 1: no pinned device, system default changed ────────────── + if current_default == last_default { + continue; + } - // Only act if there is actually a device to open. - let Some(_new_name) = current_name else { continue }; + last_default = current_default.clone(); + + let Some(_new_name) = current_default else { continue }; // Debounce: give the OS time to finish configuring the new device. tokio::time::sleep(Duration::from_millis(500)).await; @@ -2944,8 +3040,8 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) { 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 + if reopen_tx2.send((rate, false, None, reply_tx)).is_err() { + return None; } reply_rx.recv_timeout(Duration::from_secs(5)).ok() }).await.unwrap_or(None); @@ -2956,11 +3052,8 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) { }; *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 b79eb752..c2623339 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1713,6 +1713,8 @@ pub fn run() { audio::audio_play_radio, audio::audio_set_crossfade, audio::audio_set_gapless, + audio::audio_list_devices, + audio::audio_set_device, audio::audio_chain_preload, discord::discord_update_presence, discord::discord_clear_presence, diff --git a/src/App.tsx b/src/App.tsx index 4e20cb79..67ec901b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -437,6 +437,26 @@ function TauriEventBridge() { return () => { unlisten?.(); }; }, []); + // Pinned output device was unplugged — Rust already fell back to system default. + // Clear the stored device so the Settings dropdown resets to "System Default". + useEffect(() => { + let unlisten: (() => void) | undefined; + listen('audio:device-reset', () => { + useAuthStore.getState().setAudioOutputDevice(null); + 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 { + 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/CustomSelect.tsx b/src/components/CustomSelect.tsx index 4fc9209e..7c4da2b3 100644 --- a/src/components/CustomSelect.tsx +++ b/src/components/CustomSelect.tsx @@ -15,9 +15,10 @@ interface Props { onChange: (value: string) => void; className?: string; style?: React.CSSProperties; + disabled?: boolean; } -export default function CustomSelect({ value, options, onChange, className = '', style }: Props) { +export default function CustomSelect({ value, options, onChange, className = '', style, disabled }: Props) { const [open, setOpen] = useState(false); const triggerRef = useRef(null); const listRef = useRef(null); @@ -80,7 +81,8 @@ export default function CustomSelect({ value, options, onChange, className = '', type="button" className={`custom-select-trigger ${className}`} style={style} - onClick={() => setOpen(v => !v)} + disabled={disabled} + onClick={() => { if (!disabled) setOpen(v => !v); }} aria-haspopup="listbox" aria-expanded={open} > diff --git a/src/locales/de.ts b/src/locales/de.ts index 32d8d150..7d8534a1 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -499,6 +499,9 @@ export const deTranslation = { hotCacheDebounceImmediate: 'Sofort', hotCacheDebounceSeconds: '{{n}} s', hotCacheClearBtn: 'Hot-Cache leeren', + audioOutputDevice: 'Audio-Ausgabegerät', + audioOutputDeviceDesc: 'Wähle das Audiogerät, über das Psysonic spielt. Änderungen werden sofort übernommen und starten den aktuellen Track neu.', + audioOutputDeviceDefault: 'Systemstandard', hiResTitle: 'Native Hi-Res-Wiedergabe', hiResEnabled: 'Native Hi-Res-Wiedergabe aktivieren', hiResDesc: "Standardmäßig wird auf 44,1 kHz begrenzt (maximale Stabilität). Nur aktivieren, wenn Hardware und Netzwerk zuverlässig hohe Abtastraten (88,2 kHz+) unterstützen.", diff --git a/src/locales/en.ts b/src/locales/en.ts index 23f93b1c..dcf9a94c 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -501,6 +501,9 @@ export const enTranslation = { hotCacheDebounceImmediate: 'Immediate', hotCacheDebounceSeconds: '{{n}} s', hotCacheClearBtn: 'Clear hot cache', + audioOutputDevice: 'Audio Output Device', + audioOutputDeviceDesc: 'Select which audio device Psysonic plays through. Changes take effect immediately and restart the current track.', + audioOutputDeviceDefault: 'System Default', hiResTitle: 'Native Hi-Res Playback', hiResEnabled: 'Enable native hi-res playback', hiResDesc: "Forces 44.1 kHz output by default for maximum stability. Enable only if your hardware and network reliably support high sample rates (88.2 kHz+).", diff --git a/src/locales/es.ts b/src/locales/es.ts index 4162a142..99ce3de6 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -502,6 +502,9 @@ export const esTranslation = { hotCacheDebounceImmediate: 'Inmediato', hotCacheDebounceSeconds: '{{n}} s', hotCacheClearBtn: 'Limpiar caché activa', + audioOutputDevice: 'Dispositivo de salida de audio', + audioOutputDeviceDesc: 'Selecciona el dispositivo de audio que usa Psysonic. Los cambios surten efecto de inmediato y reinician la pista actual.', + audioOutputDeviceDefault: 'Predeterminado del sistema', hiResTitle: 'Reproducción Nativa Hi-Res', hiResEnabled: 'Habilitar reproducción nativa hi-res', hiResDesc: "Fuerza 44.1 kHz de salida por defecto para máxima estabilidad. Habilita solo si tu hardware y red soportan confiablemente altas tasas de muestreo (88.2 kHz+).", diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 64de532b..9eb55284 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -499,6 +499,9 @@ export const frTranslation = { hotCacheDebounceImmediate: 'Immédiat', hotCacheDebounceSeconds: '{{n}} s', hotCacheClearBtn: 'Vider le cache à chaud', + audioOutputDevice: 'Périphérique de sortie audio', + audioOutputDeviceDesc: 'Choisissez le périphérique audio utilisé par Psysonic. Les changements sont immédiats et relancent la piste en cours.', + audioOutputDeviceDefault: 'Défaut système', hiResTitle: 'Lecture haute résolution native', hiResEnabled: 'Activer la lecture haute résolution native', hiResDesc: "Force une sortie à 44,1 kHz par défaut pour une stabilité maximale. N'activer que si le matériel et le réseau prennent en charge les hautes fréquences d'échantillonnage (88,2 kHz+).", diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 163dd018..eb206888 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -500,6 +500,9 @@ export const nbTranslation = { hotCacheDebounceImmediate: 'Umiddelbart', hotCacheDebounceSeconds: '{{n}} sek', hotCacheClearBtn: 'Tøm varm buffer', + audioOutputDevice: 'Lydutgangsenhet', + audioOutputDeviceDesc: 'Velg hvilken lydenhet Psysonic spiller gjennom. Endringer trer i kraft umiddelbart og starter gjeldende spor på nytt.', + audioOutputDeviceDefault: 'Systemstandard', hiResTitle: 'Innebygd hi-res-avspilling', hiResEnabled: 'Aktiver innebygd hi-res-avspilling', hiResDesc: "Begrenser utdata til 44,1 kHz som standard for maksimal stabilitet. Aktiver kun hvis maskinvare og nettverk støtter høye samplingsrater (88,2 kHz+) pålitelig.", diff --git a/src/locales/nl.ts b/src/locales/nl.ts index efe82be4..bf51d6ba 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -498,6 +498,9 @@ export const nlTranslation = { hotCacheDebounceImmediate: 'Direct', hotCacheDebounceSeconds: '{{n}} s', hotCacheClearBtn: 'Warme cache wissen', + audioOutputDevice: 'Audio-uitvoerapparaat', + audioOutputDeviceDesc: 'Kies via welk audioapparaat Psysonic afspeelt. Wijzigingen worden direct toegepast en starten het huidige nummer opnieuw.', + audioOutputDeviceDefault: 'Systeemstandaard', hiResTitle: 'Natieve hi-res-weergave', hiResEnabled: 'Natieve hi-res-weergave inschakelen', hiResDesc: "Beperkt de uitvoer standaard tot 44,1 kHz voor maximale stabiliteit. Alleen inschakelen als hardware en netwerk hoge samplerates (88,2 kHz+) ondersteunen.", diff --git a/src/locales/ru.ts b/src/locales/ru.ts index fe7ff196..87309508 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -516,6 +516,9 @@ export const ruTranslation = { hotCacheDebounceImmediate: 'Сразу', hotCacheDebounceSeconds: '{{n}} с', hotCacheClearBtn: 'Очистить горячий кэш', + audioOutputDevice: 'Устройство вывода звука', + audioOutputDeviceDesc: 'Выберите аудиоустройство для воспроизведения. Изменения применяются немедленно и перезапускают текущий трек.', + audioOutputDeviceDefault: 'Системное по умолчанию', hiResTitle: 'Нативное воспроизведение Hi-Res', hiResEnabled: 'Включить нативное Hi-Res воспроизведение', hiResDesc: "По умолчанию вывод ограничен до 44,1 кГц для максимальной стабильности. Включайте только если оборудование и сеть надёжно поддерживают высокие частоты (88,2 кГц+).", diff --git a/src/locales/zh.ts b/src/locales/zh.ts index c083ccc7..ccfc10fe 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -494,6 +494,9 @@ export const zhTranslation = { hotCacheDebounceImmediate: '立即', hotCacheDebounceSeconds: '{{n}} 秒', hotCacheClearBtn: '清空热缓存', + audioOutputDevice: '音频输出设备', + audioOutputDeviceDesc: '选择 Psysonic 播放音频的设备。更改立即生效并重新开始当前曲目。', + audioOutputDeviceDefault: '系统默认', hiResTitle: '原生高清晰度播放', hiResEnabled: '启用原生高清晰度播放', hiResDesc: "默认强制 44.1 kHz 输出以获得最大稳定性。仅在硬件和网络可靠支持高采样率(88.2 kHz+)时启用。", diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 491829ac..514cba70 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -5,7 +5,7 @@ import { useNavigate, useLocation } from 'react-router-dom'; import { Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown, - GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn, Sparkles, AlertTriangle, Maximize2 + GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn, Sparkles, AlertTriangle, Maximize2, AudioLines } from 'lucide-react'; import i18n from '../i18n'; import { exportBackup, importBackup } from '../utils/backup'; @@ -260,6 +260,8 @@ export default function Settings() { const [imageCacheBytes, setImageCacheBytes] = useState(null); const [offlineCacheBytes, setOfflineCacheBytes] = useState(null); const [hotCacheBytes, setHotCacheBytes] = useState(null); + const [audioDevices, setAudioDevices] = useState([]); + const [deviceSwitching, setDeviceSwitching] = useState(false); const [showClearConfirm, setShowClearConfirm] = useState(false); const [clearing, setClearing] = useState(false); const [contributorsOpen, setContributorsOpen] = useState(false); @@ -276,6 +278,12 @@ export default function Settings() { invoke('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0)); }, [activeTab, auth.offlineDownloadDir, auth.hotCacheDownloadDir]); + // Load available audio output devices when Audio tab is open. + useEffect(() => { + if (activeTab !== 'audio') return; + invoke('audio_list_devices').then(setAudioDevices).catch(() => {}); + }, [activeTab]); + /** Live disk usage for hot cache while Audio settings are open (interval + refresh when index changes). */ useEffect(() => { if (activeTab !== 'audio') return; @@ -490,6 +498,61 @@ export default function Settings() { {/* ── Audio ────────────────────────────────────────────────────────────── */} {activeTab === 'audio' && ( <> + {/* Audio Output Device */} +
+
+ +

{t('settings.audioOutputDevice')}

+
+
+
+ {t('settings.audioOutputDeviceDesc')} +
+ { + const device = val || null; + setDeviceSwitching(true); + try { + await invoke('audio_set_device', { deviceName: device }); + auth.setAudioOutputDevice(device); + } catch { /* device open failed — don't persist */ } + setDeviceSwitching(false); + }} + options={[ + { value: '', label: t('settings.audioOutputDeviceDefault') }, + ...audioDevices.map(d => ({ value: d, label: d })), + ]} + /> +
+
+ + {/* Native Hi-Res Playback */} +
+
+ +

{t('settings.hiResTitle')}

+
+
+
+
+
{t('settings.hiResEnabled')}
+
{t('settings.hiResDesc')}
+
+ +
+
+
+ {/* Equalizer */}
@@ -810,31 +873,6 @@ export default function Settings() {
- {/* Native Hi-Res Playback */} -
-
- -

{t('settings.hiResTitle')}

-
-
-
-
-
{t('settings.hiResEnabled')}
-
{t('settings.hiResDesc')}
-
- -
-
-
- )} diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 19618490..65d6d72c 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -77,6 +77,8 @@ interface AuthState { /** Alpha: native hi-res sample rate output (disabled = safe 44.1 kHz mode) */ enableHiRes: boolean; + /** Selected audio output device name. null = system default. */ + audioOutputDevice: string | null; /** Alpha: ephemeral queue prefetch cache on disk */ hotCacheEnabled: boolean; @@ -197,6 +199,7 @@ interface AuthState { setLastSeenChangelogVersion: (v: string) => void; setSeekbarStyle: (v: SeekbarStyle) => void; setEnableHiRes: (v: boolean) => void; + setAudioOutputDevice: (v: string | null) => void; setHotCacheEnabled: (v: boolean) => void; setHotCacheMaxMb: (v: number) => void; setHotCacheDebounceSec: (v: number) => void; @@ -289,6 +292,7 @@ export const useAuthStore = create()( lastSeenChangelogVersion: '', seekbarStyle: 'waveform', enableHiRes: false, + audioOutputDevice: null, hotCacheEnabled: false, hotCacheMaxMb: 256, hotCacheDebounceSec: 30, @@ -410,6 +414,7 @@ export const useAuthStore = create()( setSeekbarStyle: (v) => set({ seekbarStyle: v }), setEnableHiRes: (v) => set({ enableHiRes: v }), + setAudioOutputDevice: (v) => set({ audioOutputDevice: v }), setHotCacheEnabled: (v) => set({ hotCacheEnabled: v }), setHotCacheMaxMb: (v) => set({ hotCacheMaxMb: v }), setHotCacheDebounceSec: (v) => set({ hotCacheDebounceSec: v }), diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index fb1899a9..6aaf6185 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -575,9 +575,12 @@ export function initAudioListeners(): () => void { usePlayerStore.getState().syncLastfmLovedTracks(); // Initial sync of audio settings to Rust engine on startup. - const { crossfadeEnabled, crossfadeSecs, gaplessEnabled } = useAuthStore.getState(); + const { crossfadeEnabled, crossfadeSecs, gaplessEnabled, audioOutputDevice } = useAuthStore.getState(); invoke('audio_set_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).catch(() => {}); invoke('audio_set_gapless', { enabled: gaplessEnabled }).catch(() => {}); + if (audioOutputDevice) { + invoke('audio_set_device', { deviceName: audioOutputDevice }).catch(() => {}); + } // Keep audio settings in sync whenever auth store changes. const unsubAuth = useAuthStore.subscribe((state) => {