diff --git a/src-tauri/src/audio/commands.rs b/src-tauri/src/audio/commands.rs index 12809046..fe1cf6ae 100644 --- a/src-tauri/src/audio/commands.rs +++ b/src-tauri/src/audio/commands.rs @@ -12,7 +12,6 @@ use symphonia::core::io::MediaSource; use tauri::{AppHandle, Emitter, Manager, State}; use super::decode::{build_source, build_streaming_source, SizedDecoder}; -use super::dev_io::*; use super::engine::{audio_http_client, AudioCurrent, AudioEngine}; use super::helpers::*; use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload}; @@ -1776,88 +1775,6 @@ pub async fn audio_play_radio( Ok(()) } -/// If the pinned id is missing from cpal's list but another listed id is the same -/// physical sink (e.g. suffix drift), rewrite `selected_device` to the listed form. -#[tauri::command] -pub fn audio_canonicalize_selected_device(state: State<'_, AudioEngine>) -> Option { - let pinned = state.selected_device.lock().unwrap().clone()?; - if pinned.is_empty() { - return None; - } - let list = enumerate_output_device_names(); - if list.iter().any(|d| d == &pinned) { - return None; - } - let canon = list - .iter() - .find(|d| output_devices_logically_same(d, &pinned))? - .clone(); - *state.selected_device.lock().unwrap() = Some(canon.clone()); - Some(canon) -} - -/// Same device list as [`audio_list_devices`] without the Tauri `State` wrapper (CLI / single-instance). -pub fn audio_list_devices_for_engine(engine: &AudioEngine) -> Vec { - let mut list = enumerate_output_device_names(); - if let Some(ref name) = *engine.selected_device.lock().unwrap() { - if !name.is_empty() && !output_enumeration_includes_pinned(&list, name) { - list.push(name.clone()); - } - } - list -} - -/// Returns the names of all available audio output devices on the current host. -/// On Linux, ALSA probes unavailable backends (JACK, OSS, dmix) and prints errors to -/// stderr. We suppress fd 2 for the duration of enumeration to keep the terminal clean. -/// -/// The user-pinned device name is appended when cpal omits it (e.g. HDMI busy while -/// streaming) so the Settings dropdown still matches `audioOutputDevice`. -#[tauri::command] -pub fn audio_list_devices(state: State<'_, AudioEngine>) -> Vec { - audio_list_devices_for_engine(&state) -} - -/// Device id string for the host default output (matches an entry from `audio_list_devices` when present). -#[tauri::command] -pub fn audio_default_output_device_name() -> Option { - use rodio::cpal::traits::{DeviceTrait, HostTrait}; - with_suppressed_alsa_stderr(|| { - let host = rodio::cpal::default_host(); - host.default_output_device() - .and_then(|d| d.description().ok().map(|desc| desc.name().to_string())) - }) -} - -/// 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>) { diff --git a/src-tauri/src/audio/device_commands.rs b/src-tauri/src/audio/device_commands.rs new file mode 100644 index 00000000..7a6cb134 --- /dev/null +++ b/src-tauri/src/audio/device_commands.rs @@ -0,0 +1,98 @@ +//! Tauri commands for output-device listing + selection. Pulled out of +//! `commands.rs` so playback / radio / EQ aren't entangled with the device +//! enumeration + reopen path. + +use std::sync::Arc; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use tauri::{Emitter, State}; + +use super::dev_io::{ + enumerate_output_device_names, output_devices_logically_same, + output_enumeration_includes_pinned, with_suppressed_alsa_stderr, +}; +use super::engine::AudioEngine; + +/// When the saved `selected_device` no longer literally matches any listed +/// physical sink (e.g. suffix drift), rewrite `selected_device` to the listed form. +#[tauri::command] +pub fn audio_canonicalize_selected_device(state: State<'_, AudioEngine>) -> Option { + let pinned = state.selected_device.lock().unwrap().clone()?; + if pinned.is_empty() { + return None; + } + let list = enumerate_output_device_names(); + if list.iter().any(|d| d == &pinned) { + return None; + } + let canon = list + .iter() + .find(|d| output_devices_logically_same(d, &pinned))? + .clone(); + *state.selected_device.lock().unwrap() = Some(canon.clone()); + Some(canon) +} + +/// Same device list as [`audio_list_devices`] without the Tauri `State` wrapper (CLI / single-instance). +pub fn audio_list_devices_for_engine(engine: &AudioEngine) -> Vec { + let mut list = enumerate_output_device_names(); + if let Some(ref name) = *engine.selected_device.lock().unwrap() { + if !name.is_empty() && !output_enumeration_includes_pinned(&list, name) { + list.push(name.clone()); + } + } + list +} + +/// Returns the names of all available audio output devices on the current host. +/// On Linux, ALSA probes unavailable backends (JACK, OSS, dmix) and prints errors to +/// stderr. We suppress fd 2 for the duration of enumeration to keep the terminal clean. +/// +/// The user-pinned device name is appended when cpal omits it (e.g. HDMI busy while +/// streaming) so the Settings dropdown still matches `audioOutputDevice`. +#[tauri::command] +pub fn audio_list_devices(state: State<'_, AudioEngine>) -> Vec { + audio_list_devices_for_engine(&state) +} + +/// Device id string for the host default output (matches an entry from `audio_list_devices` when present). +#[tauri::command] +pub fn audio_default_output_device_name() -> Option { + use rodio::cpal::traits::{DeviceTrait, HostTrait}; + with_suppressed_alsa_stderr(|| { + let host = rodio::cpal::default_host(); + host.default_output_device() + .and_then(|d| d.description().ok().map(|desc| desc.name().to_string())) + }) +} + +/// 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(()) +} diff --git a/src-tauri/src/audio/mod.rs b/src-tauri/src/audio/mod.rs index e43f7b57..3d950707 100644 --- a/src-tauri/src/audio/mod.rs +++ b/src-tauri/src/audio/mod.rs @@ -7,6 +7,7 @@ mod codec; pub mod commands; mod decode; mod dev_io; +pub mod device_commands; mod device_watcher; mod engine; mod power_resume; @@ -21,7 +22,7 @@ mod sources; mod state; mod stream; -pub use commands::{audio_default_output_device_name, audio_list_devices_for_engine}; +pub use device_commands::{audio_default_output_device_name, audio_list_devices_for_engine}; pub use device_watcher::start_device_watcher; pub use engine::{create_engine, refresh_http_user_agent, AudioEngine}; pub use helpers::take_stream_completed_for_url; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 119cb658..54508bd7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -406,10 +406,10 @@ pub fn run() { audio::commands::audio_set_crossfade, audio::commands::audio_set_gapless, audio::commands::audio_set_normalization, - audio::commands::audio_list_devices, - audio::commands::audio_canonicalize_selected_device, - audio::commands::audio_default_output_device_name, - audio::commands::audio_set_device, + audio::device_commands::audio_list_devices, + audio::device_commands::audio_canonicalize_selected_device, + audio::device_commands::audio_default_output_device_name, + audio::device_commands::audio_set_device, audio::commands::audio_chain_preload, discord::discord_update_presence, discord::discord_clear_presence,