mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
feat(audio): audio output device selection (closes #169)
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 <noreply@anthropic.com>
This commit is contained in:
+130
-37
@@ -1448,9 +1448,11 @@ pub struct AudioEngine {
|
|||||||
/// The rate the device was opened at on cold start — used to restore the
|
/// 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.
|
/// stream when Hi-Res is toggled off while a hi-res rate is active.
|
||||||
pub device_default_rate: u32,
|
pub device_default_rate: u32,
|
||||||
/// Sends `(desired_rate, is_hi_res, reply_tx)` to the audio-stream thread to
|
/// Sends `(desired_rate, is_hi_res, device_name, reply_tx)` to the audio-stream
|
||||||
/// re-open the output device. `is_hi_res` controls thread-priority escalation.
|
/// thread to re-open the output device. `device_name = None` → system default.
|
||||||
pub stream_reopen_tx: std::sync::mpsc::SyncSender<(u32, bool, std::sync::mpsc::SyncSender<rodio::OutputStreamHandle>)>,
|
pub stream_reopen_tx: std::sync::mpsc::SyncSender<(u32, bool, Option<String>, std::sync::mpsc::SyncSender<rodio::OutputStreamHandle>)>,
|
||||||
|
/// User-selected output device name (None = follow system default).
|
||||||
|
pub selected_device: Arc<Mutex<Option<String>>>,
|
||||||
pub current: Arc<Mutex<AudioCurrent>>,
|
pub current: Arc<Mutex<AudioCurrent>>,
|
||||||
/// Monotonically incremented on each audio_play (non-chain) / audio_stop call.
|
/// Monotonically incremented on each audio_play (non-chain) / audio_stop call.
|
||||||
pub generation: Arc<AtomicU64>,
|
pub generation: Arc<AtomicU64>,
|
||||||
@@ -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:
|
/// Resolution order:
|
||||||
/// 1. Exact rate match in the device's supported config ranges.
|
/// 1. Exact rate match in the device's supported config ranges.
|
||||||
@@ -1520,12 +1525,17 @@ impl AudioCurrent {
|
|||||||
/// 4. System default (last resort).
|
/// 4. System default (last resort).
|
||||||
///
|
///
|
||||||
/// Returns `(OutputStream, OutputStreamHandle, actual_sample_rate)`.
|
/// 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};
|
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||||
|
|
||||||
let host = rodio::cpal::default_host();
|
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 desired_rate > 0 {
|
||||||
if let Ok(supported) = device.supported_output_configs() {
|
if let Ok(supported) = device.supported_output_configs() {
|
||||||
let configs: Vec<_> = supported.collect();
|
let configs: Vec<_> = supported.collect();
|
||||||
@@ -1608,7 +1618,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
|||||||
let (init_tx, init_rx) =
|
let (init_tx, init_rx) =
|
||||||
std::sync::mpsc::sync_channel::<(rodio::OutputStreamHandle, u32)>(0);
|
std::sync::mpsc::sync_channel::<(rodio::OutputStreamHandle, u32)>(0);
|
||||||
let (reopen_tx, reopen_rx) =
|
let (reopen_tx, reopen_rx) =
|
||||||
std::sync::mpsc::sync_channel::<(u32, bool, std::sync::mpsc::SyncSender<rodio::OutputStreamHandle>)>(4);
|
std::sync::mpsc::sync_channel::<(u32, bool, Option<String>, std::sync::mpsc::SyncSender<rodio::OutputStreamHandle>)>(4);
|
||||||
|
|
||||||
let thread = std::thread::Builder::new()
|
let thread = std::thread::Builder::new()
|
||||||
.name("psysonic-audio-stream".into())
|
.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.
|
// Thread priority is kept at default during standard-mode playback.
|
||||||
// It is escalated to Max only when a Hi-Res stream reopen is requested,
|
// It is escalated to Max only when a Hi-Res stream reopen is requested,
|
||||||
// to prevent PipeWire underruns at high quantum sizes (8192 frames).
|
// 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();
|
init_tx.send((handle, rate)).ok();
|
||||||
|
|
||||||
// Keep the stream alive and handle sample-rate switch requests.
|
// Keep the stream alive and handle sample-rate / device-switch requests.
|
||||||
while let Ok((desired_rate, is_hi_res, reply_tx)) = reopen_rx.recv() {
|
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
|
// Escalate to Max for Hi-Res reopens (large PipeWire quanta need
|
||||||
// real-time scheduling to avoid underruns). No escalation for
|
// real-time scheduling to avoid underruns). No escalation for
|
||||||
// standard mode — the thread blocks on recv() between reopens so
|
// 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());
|
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;
|
_stream = new_stream;
|
||||||
reply_tx.send(new_handle).ok();
|
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)),
|
stream_sample_rate: Arc::new(AtomicU32::new(initial_rate)),
|
||||||
device_default_rate: initial_rate,
|
device_default_rate: initial_rate,
|
||||||
stream_reopen_tx: reopen_tx,
|
stream_reopen_tx: reopen_tx,
|
||||||
|
selected_device: Arc::new(Mutex::new(None)),
|
||||||
current: Arc::new(Mutex::new(AudioCurrent {
|
current: Arc::new(Mutex::new(AudioCurrent {
|
||||||
sink: None,
|
sink: None,
|
||||||
duration_secs: 0.0,
|
duration_secs: 0.0,
|
||||||
@@ -2018,7 +2029,8 @@ pub async fn audio_play(
|
|||||||
let needs_switch = target_rate > 0 && target_rate != current_stream_rate;
|
let needs_switch = target_rate > 0 && target_rate != current_stream_rate;
|
||||||
if needs_switch {
|
if needs_switch {
|
||||||
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
|
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(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)) {
|
match reply_rx.recv_timeout(std::time::Duration::from_secs(5)) {
|
||||||
Ok(new_handle) => {
|
Ok(new_handle) => {
|
||||||
*state.stream_handle.lock().unwrap() = new_handle;
|
*state.stream_handle.lock().unwrap() = new_handle;
|
||||||
@@ -2883,6 +2895,46 @@ pub async fn audio_play_radio(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the names of all available audio output devices on the current host.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn audio_list_devices() -> Vec<String> {
|
||||||
|
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<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::<rodio::OutputStreamHandle>(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]
|
#[tauri::command]
|
||||||
pub fn audio_set_crossfade(enabled: bool, secs: f32, state: State<'_, AudioEngine>) {
|
pub fn audio_set_crossfade(enabled: bool, secs: f32, state: State<'_, AudioEngine>) {
|
||||||
state.crossfade_enabled.store(enabled, Ordering::Relaxed);
|
state.crossfade_enabled.store(enabled, Ordering::Relaxed);
|
||||||
@@ -2896,21 +2948,23 @@ pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
|
|||||||
|
|
||||||
// ─── Device-change watcher ────────────────────────────────────────────────────
|
// ─── Device-change watcher ────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Polls the OS default output device every 3 s. When it changes (Bluetooth
|
// Polls every 3 s for two conditions:
|
||||||
// headphones connecting, USB DAC plugging in, etc.) the stream is reopened on
|
// 1. System default device changed (Bluetooth, USB DAC plug/unplug) while no
|
||||||
// the new device and `audio:device-changed` is emitted so the frontend can
|
// device is pinned → reopen on new default, emit audio:device-changed.
|
||||||
// restart playback. The old Sink is dropped here — it was bound to the
|
// 2. User-pinned device disappeared (DAC unplugged) → fall back to system
|
||||||
// now-closed OutputStream and can no longer produce audio on any device.
|
// 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) {
|
pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||||
let reopen_tx = engine.stream_reopen_tx.clone();
|
let reopen_tx = engine.stream_reopen_tx.clone();
|
||||||
let stream_handle = engine.stream_handle.clone();
|
let stream_handle = engine.stream_handle.clone();
|
||||||
let stream_rate = engine.stream_sample_rate.clone();
|
let stream_rate = engine.stream_sample_rate.clone();
|
||||||
let current = engine.current.clone();
|
let current = engine.current.clone();
|
||||||
let fading_out = engine.fading_out_sink.clone();
|
let fading_out = engine.fading_out_sink.clone();
|
||||||
|
let selected_device = engine.selected_device.clone();
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
let mut last_name: Option<String> = tauri::async_runtime::spawn_blocking(|| {
|
let mut last_default: Option<String> = tauri::async_runtime::spawn_blocking(|| {
|
||||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||||
rodio::cpal::default_host()
|
rodio::cpal::default_host()
|
||||||
.default_output_device()
|
.default_output_device()
|
||||||
@@ -2920,21 +2974,63 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
|||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let current_name: Option<String> = 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};
|
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||||
rodio::cpal::default_host()
|
let host = rodio::cpal::default_host();
|
||||||
.default_output_device()
|
let default = host.default_output_device().and_then(|d| d.name().ok());
|
||||||
.and_then(|d| d.name().ok())
|
let available: Vec<String> = host
|
||||||
}).await.unwrap_or(None);
|
.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::<rodio::OutputStreamHandle>(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;
|
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.
|
last_default = current_default.clone();
|
||||||
let Some(_new_name) = current_name else { continue };
|
|
||||||
|
let Some(_new_name) = current_default else { continue };
|
||||||
|
|
||||||
// Debounce: give the OS time to finish configuring the new device.
|
// Debounce: give the OS time to finish configuring the new device.
|
||||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
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 new_handle = tauri::async_runtime::spawn_blocking(move || {
|
||||||
let (reply_tx, reply_rx) =
|
let (reply_tx, reply_rx) =
|
||||||
std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
|
std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
|
||||||
if reopen_tx2.send((rate, false, reply_tx)).is_err() {
|
if reopen_tx2.send((rate, false, None, reply_tx)).is_err() {
|
||||||
return None; // audio thread exited
|
return None;
|
||||||
}
|
}
|
||||||
reply_rx.recv_timeout(Duration::from_secs(5)).ok()
|
reply_rx.recv_timeout(Duration::from_secs(5)).ok()
|
||||||
}).await.unwrap_or(None);
|
}).await.unwrap_or(None);
|
||||||
@@ -2956,11 +3052,8 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
*stream_handle.lock().unwrap() = handle;
|
*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) = current.lock().unwrap().sink.take() { s.stop(); }
|
||||||
if let Some(s) = fading_out.lock().unwrap().take() { s.stop(); }
|
if let Some(s) = fading_out.lock().unwrap().take() { s.stop(); }
|
||||||
|
|
||||||
app.emit("audio:device-changed", ()).ok();
|
app.emit("audio:device-changed", ()).ok();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1713,6 +1713,8 @@ pub fn run() {
|
|||||||
audio::audio_play_radio,
|
audio::audio_play_radio,
|
||||||
audio::audio_set_crossfade,
|
audio::audio_set_crossfade,
|
||||||
audio::audio_set_gapless,
|
audio::audio_set_gapless,
|
||||||
|
audio::audio_list_devices,
|
||||||
|
audio::audio_set_device,
|
||||||
audio::audio_chain_preload,
|
audio::audio_chain_preload,
|
||||||
discord::discord_update_presence,
|
discord::discord_update_presence,
|
||||||
discord::discord_clear_presence,
|
discord::discord_clear_presence,
|
||||||
|
|||||||
+20
@@ -437,6 +437,26 @@ function TauriEventBridge() {
|
|||||||
return () => { unlisten?.(); };
|
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.
|
// 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);
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ interface Props {
|
|||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
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 [open, setOpen] = useState(false);
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -80,7 +81,8 @@ export default function CustomSelect({ value, options, onChange, className = '',
|
|||||||
type="button"
|
type="button"
|
||||||
className={`custom-select-trigger ${className}`}
|
className={`custom-select-trigger ${className}`}
|
||||||
style={style}
|
style={style}
|
||||||
onClick={() => setOpen(v => !v)}
|
disabled={disabled}
|
||||||
|
onClick={() => { if (!disabled) setOpen(v => !v); }}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -499,6 +499,9 @@ export const deTranslation = {
|
|||||||
hotCacheDebounceImmediate: 'Sofort',
|
hotCacheDebounceImmediate: 'Sofort',
|
||||||
hotCacheDebounceSeconds: '{{n}} s',
|
hotCacheDebounceSeconds: '{{n}} s',
|
||||||
hotCacheClearBtn: 'Hot-Cache leeren',
|
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',
|
hiResTitle: 'Native Hi-Res-Wiedergabe',
|
||||||
hiResEnabled: 'Native Hi-Res-Wiedergabe aktivieren',
|
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.",
|
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.",
|
||||||
|
|||||||
@@ -501,6 +501,9 @@ export const enTranslation = {
|
|||||||
hotCacheDebounceImmediate: 'Immediate',
|
hotCacheDebounceImmediate: 'Immediate',
|
||||||
hotCacheDebounceSeconds: '{{n}} s',
|
hotCacheDebounceSeconds: '{{n}} s',
|
||||||
hotCacheClearBtn: 'Clear hot cache',
|
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',
|
hiResTitle: 'Native Hi-Res Playback',
|
||||||
hiResEnabled: 'Enable 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+).",
|
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+).",
|
||||||
|
|||||||
@@ -502,6 +502,9 @@ export const esTranslation = {
|
|||||||
hotCacheDebounceImmediate: 'Inmediato',
|
hotCacheDebounceImmediate: 'Inmediato',
|
||||||
hotCacheDebounceSeconds: '{{n}} s',
|
hotCacheDebounceSeconds: '{{n}} s',
|
||||||
hotCacheClearBtn: 'Limpiar caché activa',
|
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',
|
hiResTitle: 'Reproducción Nativa Hi-Res',
|
||||||
hiResEnabled: 'Habilitar 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+).",
|
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+).",
|
||||||
|
|||||||
@@ -499,6 +499,9 @@ export const frTranslation = {
|
|||||||
hotCacheDebounceImmediate: 'Immédiat',
|
hotCacheDebounceImmediate: 'Immédiat',
|
||||||
hotCacheDebounceSeconds: '{{n}} s',
|
hotCacheDebounceSeconds: '{{n}} s',
|
||||||
hotCacheClearBtn: 'Vider le cache à chaud',
|
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',
|
hiResTitle: 'Lecture haute résolution native',
|
||||||
hiResEnabled: 'Activer la 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+).",
|
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+).",
|
||||||
|
|||||||
@@ -500,6 +500,9 @@ export const nbTranslation = {
|
|||||||
hotCacheDebounceImmediate: 'Umiddelbart',
|
hotCacheDebounceImmediate: 'Umiddelbart',
|
||||||
hotCacheDebounceSeconds: '{{n}} sek',
|
hotCacheDebounceSeconds: '{{n}} sek',
|
||||||
hotCacheClearBtn: 'Tøm varm buffer',
|
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',
|
hiResTitle: 'Innebygd hi-res-avspilling',
|
||||||
hiResEnabled: 'Aktiver 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.",
|
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.",
|
||||||
|
|||||||
@@ -498,6 +498,9 @@ export const nlTranslation = {
|
|||||||
hotCacheDebounceImmediate: 'Direct',
|
hotCacheDebounceImmediate: 'Direct',
|
||||||
hotCacheDebounceSeconds: '{{n}} s',
|
hotCacheDebounceSeconds: '{{n}} s',
|
||||||
hotCacheClearBtn: 'Warme cache wissen',
|
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',
|
hiResTitle: 'Natieve hi-res-weergave',
|
||||||
hiResEnabled: 'Natieve hi-res-weergave inschakelen',
|
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.",
|
hiResDesc: "Beperkt de uitvoer standaard tot 44,1 kHz voor maximale stabiliteit. Alleen inschakelen als hardware en netwerk hoge samplerates (88,2 kHz+) ondersteunen.",
|
||||||
|
|||||||
@@ -516,6 +516,9 @@ export const ruTranslation = {
|
|||||||
hotCacheDebounceImmediate: 'Сразу',
|
hotCacheDebounceImmediate: 'Сразу',
|
||||||
hotCacheDebounceSeconds: '{{n}} с',
|
hotCacheDebounceSeconds: '{{n}} с',
|
||||||
hotCacheClearBtn: 'Очистить горячий кэш',
|
hotCacheClearBtn: 'Очистить горячий кэш',
|
||||||
|
audioOutputDevice: 'Устройство вывода звука',
|
||||||
|
audioOutputDeviceDesc: 'Выберите аудиоустройство для воспроизведения. Изменения применяются немедленно и перезапускают текущий трек.',
|
||||||
|
audioOutputDeviceDefault: 'Системное по умолчанию',
|
||||||
hiResTitle: 'Нативное воспроизведение Hi-Res',
|
hiResTitle: 'Нативное воспроизведение Hi-Res',
|
||||||
hiResEnabled: 'Включить нативное Hi-Res воспроизведение',
|
hiResEnabled: 'Включить нативное Hi-Res воспроизведение',
|
||||||
hiResDesc: "По умолчанию вывод ограничен до 44,1 кГц для максимальной стабильности. Включайте только если оборудование и сеть надёжно поддерживают высокие частоты (88,2 кГц+).",
|
hiResDesc: "По умолчанию вывод ограничен до 44,1 кГц для максимальной стабильности. Включайте только если оборудование и сеть надёжно поддерживают высокие частоты (88,2 кГц+).",
|
||||||
|
|||||||
@@ -494,6 +494,9 @@ export const zhTranslation = {
|
|||||||
hotCacheDebounceImmediate: '立即',
|
hotCacheDebounceImmediate: '立即',
|
||||||
hotCacheDebounceSeconds: '{{n}} 秒',
|
hotCacheDebounceSeconds: '{{n}} 秒',
|
||||||
hotCacheClearBtn: '清空热缓存',
|
hotCacheClearBtn: '清空热缓存',
|
||||||
|
audioOutputDevice: '音频输出设备',
|
||||||
|
audioOutputDeviceDesc: '选择 Psysonic 播放音频的设备。更改立即生效并重新开始当前曲目。',
|
||||||
|
audioOutputDeviceDefault: '系统默认',
|
||||||
hiResTitle: '原生高清晰度播放',
|
hiResTitle: '原生高清晰度播放',
|
||||||
hiResEnabled: '启用原生高清晰度播放',
|
hiResEnabled: '启用原生高清晰度播放',
|
||||||
hiResDesc: "默认强制 44.1 kHz 输出以获得最大稳定性。仅在硬件和网络可靠支持高采样率(88.2 kHz+)时启用。",
|
hiResDesc: "默认强制 44.1 kHz 输出以获得最大稳定性。仅在硬件和网络可靠支持高采样率(88.2 kHz+)时启用。",
|
||||||
|
|||||||
+64
-26
@@ -5,7 +5,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
|
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
|
||||||
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
|
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';
|
} from 'lucide-react';
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
import { exportBackup, importBackup } from '../utils/backup';
|
import { exportBackup, importBackup } from '../utils/backup';
|
||||||
@@ -260,6 +260,8 @@ export default function Settings() {
|
|||||||
const [imageCacheBytes, setImageCacheBytes] = useState<number | null>(null);
|
const [imageCacheBytes, setImageCacheBytes] = useState<number | null>(null);
|
||||||
const [offlineCacheBytes, setOfflineCacheBytes] = useState<number | null>(null);
|
const [offlineCacheBytes, setOfflineCacheBytes] = useState<number | null>(null);
|
||||||
const [hotCacheBytes, setHotCacheBytes] = useState<number | null>(null);
|
const [hotCacheBytes, setHotCacheBytes] = useState<number | null>(null);
|
||||||
|
const [audioDevices, setAudioDevices] = useState<string[]>([]);
|
||||||
|
const [deviceSwitching, setDeviceSwitching] = useState(false);
|
||||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||||
const [clearing, setClearing] = useState(false);
|
const [clearing, setClearing] = useState(false);
|
||||||
const [contributorsOpen, setContributorsOpen] = useState(false);
|
const [contributorsOpen, setContributorsOpen] = useState(false);
|
||||||
@@ -276,6 +278,12 @@ export default function Settings() {
|
|||||||
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
|
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
|
||||||
}, [activeTab, auth.offlineDownloadDir, auth.hotCacheDownloadDir]);
|
}, [activeTab, auth.offlineDownloadDir, auth.hotCacheDownloadDir]);
|
||||||
|
|
||||||
|
// Load available audio output devices when Audio tab is open.
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab !== 'audio') return;
|
||||||
|
invoke<string[]>('audio_list_devices').then(setAudioDevices).catch(() => {});
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
/** Live disk usage for hot cache while Audio settings are open (interval + refresh when index changes). */
|
/** Live disk usage for hot cache while Audio settings are open (interval + refresh when index changes). */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab !== 'audio') return;
|
if (activeTab !== 'audio') return;
|
||||||
@@ -490,6 +498,61 @@ export default function Settings() {
|
|||||||
{/* ── Audio ────────────────────────────────────────────────────────────── */}
|
{/* ── Audio ────────────────────────────────────────────────────────────── */}
|
||||||
{activeTab === 'audio' && (
|
{activeTab === 'audio' && (
|
||||||
<>
|
<>
|
||||||
|
{/* Audio Output Device */}
|
||||||
|
<section className="settings-section">
|
||||||
|
<div className="settings-section-header">
|
||||||
|
<AudioLines size={18} />
|
||||||
|
<h2>{t('settings.audioOutputDevice')}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="settings-card">
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||||
|
{t('settings.audioOutputDeviceDesc')}
|
||||||
|
</div>
|
||||||
|
<CustomSelect
|
||||||
|
value={auth.audioOutputDevice ?? ''}
|
||||||
|
disabled={deviceSwitching}
|
||||||
|
onChange={async (val) => {
|
||||||
|
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 })),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Native Hi-Res Playback */}
|
||||||
|
<section className="settings-section">
|
||||||
|
<div className="settings-section-header">
|
||||||
|
<Waves size={18} />
|
||||||
|
<h2>{t('settings.hiResTitle')}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="settings-card">
|
||||||
|
<div className="settings-toggle-row">
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500 }}>{t('settings.hiResEnabled')}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.hiResDesc')}</div>
|
||||||
|
</div>
|
||||||
|
<label className="toggle-switch" aria-label={t('settings.hiResEnabled')}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={auth.enableHiRes}
|
||||||
|
onChange={e => auth.setEnableHiRes(e.target.checked)}
|
||||||
|
id="hires-enabled-toggle"
|
||||||
|
/>
|
||||||
|
<span className="toggle-track" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Equalizer */}
|
{/* Equalizer */}
|
||||||
<section className="settings-section">
|
<section className="settings-section">
|
||||||
<div className="settings-section-header">
|
<div className="settings-section-header">
|
||||||
@@ -810,31 +873,6 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Native Hi-Res Playback */}
|
|
||||||
<section className="settings-section">
|
|
||||||
<div className="settings-section-header">
|
|
||||||
<Waves size={18} />
|
|
||||||
<h2>{t('settings.hiResTitle')}</h2>
|
|
||||||
</div>
|
|
||||||
<div className="settings-card">
|
|
||||||
<div className="settings-toggle-row">
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 500 }}>{t('settings.hiResEnabled')}</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.hiResDesc')}</div>
|
|
||||||
</div>
|
|
||||||
<label className="toggle-switch" aria-label={t('settings.hiResEnabled')}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={auth.enableHiRes}
|
|
||||||
onChange={e => auth.setEnableHiRes(e.target.checked)}
|
|
||||||
id="hires-enabled-toggle"
|
|
||||||
/>
|
|
||||||
<span className="toggle-track" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ interface AuthState {
|
|||||||
|
|
||||||
/** Alpha: native hi-res sample rate output (disabled = safe 44.1 kHz mode) */
|
/** Alpha: native hi-res sample rate output (disabled = safe 44.1 kHz mode) */
|
||||||
enableHiRes: boolean;
|
enableHiRes: boolean;
|
||||||
|
/** Selected audio output device name. null = system default. */
|
||||||
|
audioOutputDevice: string | null;
|
||||||
|
|
||||||
/** Alpha: ephemeral queue prefetch cache on disk */
|
/** Alpha: ephemeral queue prefetch cache on disk */
|
||||||
hotCacheEnabled: boolean;
|
hotCacheEnabled: boolean;
|
||||||
@@ -197,6 +199,7 @@ interface AuthState {
|
|||||||
setLastSeenChangelogVersion: (v: string) => void;
|
setLastSeenChangelogVersion: (v: string) => void;
|
||||||
setSeekbarStyle: (v: SeekbarStyle) => void;
|
setSeekbarStyle: (v: SeekbarStyle) => void;
|
||||||
setEnableHiRes: (v: boolean) => void;
|
setEnableHiRes: (v: boolean) => void;
|
||||||
|
setAudioOutputDevice: (v: string | null) => void;
|
||||||
setHotCacheEnabled: (v: boolean) => void;
|
setHotCacheEnabled: (v: boolean) => void;
|
||||||
setHotCacheMaxMb: (v: number) => void;
|
setHotCacheMaxMb: (v: number) => void;
|
||||||
setHotCacheDebounceSec: (v: number) => void;
|
setHotCacheDebounceSec: (v: number) => void;
|
||||||
@@ -289,6 +292,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
lastSeenChangelogVersion: '',
|
lastSeenChangelogVersion: '',
|
||||||
seekbarStyle: 'waveform',
|
seekbarStyle: 'waveform',
|
||||||
enableHiRes: false,
|
enableHiRes: false,
|
||||||
|
audioOutputDevice: null,
|
||||||
hotCacheEnabled: false,
|
hotCacheEnabled: false,
|
||||||
hotCacheMaxMb: 256,
|
hotCacheMaxMb: 256,
|
||||||
hotCacheDebounceSec: 30,
|
hotCacheDebounceSec: 30,
|
||||||
@@ -410,6 +414,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
setSeekbarStyle: (v) => set({ seekbarStyle: v }),
|
setSeekbarStyle: (v) => set({ seekbarStyle: v }),
|
||||||
setEnableHiRes: (v) => set({ enableHiRes: v }),
|
setEnableHiRes: (v) => set({ enableHiRes: v }),
|
||||||
|
setAudioOutputDevice: (v) => set({ audioOutputDevice: v }),
|
||||||
setHotCacheEnabled: (v) => set({ hotCacheEnabled: v }),
|
setHotCacheEnabled: (v) => set({ hotCacheEnabled: v }),
|
||||||
setHotCacheMaxMb: (v) => set({ hotCacheMaxMb: v }),
|
setHotCacheMaxMb: (v) => set({ hotCacheMaxMb: v }),
|
||||||
setHotCacheDebounceSec: (v) => set({ hotCacheDebounceSec: v }),
|
setHotCacheDebounceSec: (v) => set({ hotCacheDebounceSec: v }),
|
||||||
|
|||||||
@@ -575,9 +575,12 @@ export function initAudioListeners(): () => void {
|
|||||||
usePlayerStore.getState().syncLastfmLovedTracks();
|
usePlayerStore.getState().syncLastfmLovedTracks();
|
||||||
|
|
||||||
// Initial sync of audio settings to Rust engine on startup.
|
// 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_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).catch(() => {});
|
||||||
invoke('audio_set_gapless', { enabled: gaplessEnabled }).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.
|
// Keep audio settings in sync whenever auth store changes.
|
||||||
const unsubAuth = useAuthStore.subscribe((state) => {
|
const unsubAuth = useAuthStore.subscribe((state) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user