feat(audio): notify user when selected output device goes missing (issue #307)
Level A of issue #307. When the configured output device has disappeared from the host device list at the time of play/resume (KVM switched away, USB sink unplugged, PipeWire sink removed…) the backend emits an 'audio:device-missing' event and the frontend shows a warning toast: 'Output device "XYZ" is no longer available. Using system default.' The existing init_device fallback already routes audio to the system default, so this is purely informational — the user now understands why audio came out of a different sink instead of staring at a silent progress bar. Does NOT touch the sacred audio init path (pipewire_backend.rs, init_device, audio_settings.rs). New module audio_device_watch.rs adds a pure presence check, a new-state throttle so we don't emit on every resume once the user's been notified, and a v2 command v2_check_audio_device_presence so the frontend can poll on demand. Ready for Level B (background watchdog) — the same helpers plug into a timer without further refactor; Level B only needs to add a tokio task that calls emit_missing_if_needed() every N seconds while playback is active. cc
This commit is contained in:
10 files changed
+243
-5
No files matched your search
@@ -0,0 +1,158 @@
|
||||
//! Audio output device availability watch.
|
||||
//!
|
||||
//! Pure helpers to check whether the user's currently-selected output
|
||||
//! device is still present in the system's device list, plus a Tauri
|
||||
//! event emitter so the frontend can surface a toast when the device
|
||||
//! has gone missing (USB unplugged, KVM switched, PipeWire sink
|
||||
//! removed, etc.).
|
||||
//!
|
||||
//! Intentionally does NOT touch pipewire_backend, init_device or
|
||||
//! audio_settings — the sacred audio init path is preserved. We just
|
||||
//! run a lightweight check before play/resume and emit an event on
|
||||
//! mismatch. Existing init-time fallback to default still takes over
|
||||
//! automatically, this just tells the user it happened.
|
||||
//!
|
||||
//! Level A of issue #307 (notification). The same helpers are reused
|
||||
//! verbatim by a future background watchdog (Level B).
|
||||
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use crate::config::audio_settings::AudioSettingsState;
|
||||
|
||||
/// Reason payload for the `audio:device-missing` Tauri event.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DeviceMissingPayload {
|
||||
/// The device the user configured but that is no longer present.
|
||||
pub wanted: String,
|
||||
/// Snapshot of device names still available — lets the UI suggest
|
||||
/// a replacement without doing its own enumeration round-trip.
|
||||
pub available: Vec<String>,
|
||||
}
|
||||
|
||||
/// Result of a presence check.
|
||||
pub enum DevicePresence {
|
||||
/// User has no explicit output device configured — always present
|
||||
/// (we're using whatever the system default is).
|
||||
UsingDefault,
|
||||
/// Configured device was found in the enumerated list.
|
||||
Present,
|
||||
/// Configured device is NOT in the enumerated list.
|
||||
Missing {
|
||||
wanted: String,
|
||||
available: Vec<String>,
|
||||
},
|
||||
/// We failed to enumerate devices (e.g. host was shut down) —
|
||||
/// treat as inconclusive rather than missing to avoid false alarms.
|
||||
Inconclusive,
|
||||
}
|
||||
|
||||
/// Enumerate output devices and compare against the configured name.
|
||||
/// Does not lock any of the audio state — cheap to call before every
|
||||
/// play/resume.
|
||||
pub fn check_selected_device_presence(settings: &AudioSettingsState) -> DevicePresence {
|
||||
let wanted = {
|
||||
let guard = match settings.store.lock() {
|
||||
Ok(g) => g,
|
||||
Err(_) => return DevicePresence::Inconclusive,
|
||||
};
|
||||
let store = match guard.as_ref() {
|
||||
Some(s) => s,
|
||||
None => return DevicePresence::Inconclusive,
|
||||
};
|
||||
match store.get_settings().ok().and_then(|s| s.output_device) {
|
||||
Some(name) if !name.is_empty() => name,
|
||||
_ => return DevicePresence::UsingDefault,
|
||||
}
|
||||
};
|
||||
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
let host = rodio::cpal::default_host();
|
||||
let devices = match host.output_devices() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
log::warn!("[audio-watch] failed to enumerate devices: {}", e);
|
||||
return DevicePresence::Inconclusive;
|
||||
}
|
||||
};
|
||||
|
||||
let mut available: Vec<String> = Vec::new();
|
||||
let mut found = false;
|
||||
for device in devices {
|
||||
if let Ok(name) = device.name() {
|
||||
if name == wanted {
|
||||
found = true;
|
||||
}
|
||||
available.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
if found {
|
||||
DevicePresence::Present
|
||||
} else {
|
||||
DevicePresence::Missing { wanted, available }
|
||||
}
|
||||
}
|
||||
|
||||
/// Rate-limiter for the missing-device toast. We don't want to fire
|
||||
/// the event on every play/resume once the user has already been
|
||||
/// notified — `true` means "this is new info, emit".
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DeviceMissingThrottle {
|
||||
last_wanted: std::sync::Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
impl DeviceMissingThrottle {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
|
||||
/// Returns true only the first time we observe this missing name.
|
||||
/// Clears when the device reappears so the next loss re-notifies.
|
||||
pub fn should_emit(&self, presence: &DevicePresence) -> bool {
|
||||
let mut last = match self.last_wanted.lock() {
|
||||
Ok(l) => l,
|
||||
Err(_) => return false,
|
||||
};
|
||||
match presence {
|
||||
DevicePresence::Missing { wanted, .. } => {
|
||||
if last.as_deref() == Some(wanted.as_str()) {
|
||||
false
|
||||
} else {
|
||||
*last = Some(wanted.clone());
|
||||
true
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
*last = None;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit `audio:device-missing` if the user's chosen output device has
|
||||
/// disappeared. Silent when the device is present / using default /
|
||||
/// enumeration inconclusive. Rate-limited via the throttle.
|
||||
pub fn emit_missing_if_needed(
|
||||
app: &AppHandle,
|
||||
settings: &AudioSettingsState,
|
||||
throttle: &DeviceMissingThrottle,
|
||||
) {
|
||||
let presence = check_selected_device_presence(settings);
|
||||
if !throttle.should_emit(&presence) {
|
||||
return;
|
||||
}
|
||||
if let DevicePresence::Missing { wanted, available } = presence {
|
||||
log::warn!(
|
||||
"[audio-watch] configured output device '{}' is no longer present ({} devices available)",
|
||||
wanted,
|
||||
available.len()
|
||||
);
|
||||
let payload = DeviceMissingPayload { wanted, available };
|
||||
if let Err(e) = app.emit("audio:device-missing", &payload) {
|
||||
log::warn!("[audio-watch] failed to emit device-missing event: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,51 @@
|
||||
use tauri::State;
|
||||
|
||||
use crate::audio::{AlsaPlugin, AudioBackendType};
|
||||
use crate::audio_device_watch::{check_selected_device_presence, DevicePresence};
|
||||
use crate::config::audio_settings::{AudioSettings, AudioSettingsState};
|
||||
use crate::core_bridge::CoreBridgeState;
|
||||
use crate::runtime::RuntimeError;
|
||||
|
||||
use super::{convert_to_qbz_audio_settings, sync_audio_settings_to_player};
|
||||
|
||||
/// Frontend-facing shape: serializable snapshot of the presence check.
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
#[serde(tag = "status", rename_all = "snake_case")]
|
||||
pub enum AudioDevicePresence {
|
||||
UsingDefault,
|
||||
Present { wanted: String },
|
||||
Missing { wanted: String, available: Vec<String> },
|
||||
Inconclusive,
|
||||
}
|
||||
|
||||
/// Snapshot the presence of the user's currently-selected output
|
||||
/// device. Frontend calls this on demand (e.g. when a
|
||||
/// `audio:device-missing` toast button fires Retry).
|
||||
#[tauri::command]
|
||||
pub fn v2_check_audio_device_presence(
|
||||
audio_settings: State<'_, AudioSettingsState>,
|
||||
) -> Result<AudioDevicePresence, RuntimeError> {
|
||||
let presence = check_selected_device_presence(&audio_settings);
|
||||
Ok(match presence {
|
||||
DevicePresence::UsingDefault => AudioDevicePresence::UsingDefault,
|
||||
DevicePresence::Present => {
|
||||
// Re-read the wanted name so we can echo it back.
|
||||
let wanted = audio_settings
|
||||
.store
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|g| g.as_ref().and_then(|s| s.get_settings().ok()))
|
||||
.and_then(|s| s.output_device)
|
||||
.unwrap_or_default();
|
||||
AudioDevicePresence::Present { wanted }
|
||||
}
|
||||
DevicePresence::Missing { wanted, available } => {
|
||||
AudioDevicePresence::Missing { wanted, available }
|
||||
}
|
||||
DevicePresence::Inconclusive => AudioDevicePresence::Inconclusive,
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== Audio Device Commands (V2) ====================
|
||||
|
||||
/// Reinitialize audio device (V2 - uses CoreBridge.player)
|
||||
|
||||
@@ -5,6 +5,7 @@ use tokio::sync::RwLock;
|
||||
use qbz_models::{Quality, QueueTrack as CoreQueueTrack, RepeatMode};
|
||||
|
||||
use crate::audio::{AlsaPlugin, AudioBackendType};
|
||||
use crate::audio_device_watch::{emit_missing_if_needed, DeviceMissingThrottle};
|
||||
use crate::cache::AudioCache;
|
||||
use crate::config::audio_settings::AudioSettingsState;
|
||||
use crate::core_bridge::CoreBridgeState;
|
||||
@@ -269,8 +270,11 @@ pub async fn v2_pause_playback(
|
||||
/// Resume playback (V2)
|
||||
#[tauri::command]
|
||||
pub async fn v2_resume_playback(
|
||||
app: tauri::AppHandle,
|
||||
bridge: State<'_, CoreBridgeState>,
|
||||
app_state: State<'_, AppState>,
|
||||
audio_settings: State<'_, AudioSettingsState>,
|
||||
device_watch: State<'_, std::sync::Arc<DeviceMissingThrottle>>,
|
||||
runtime: State<'_, RuntimeManagerState>,
|
||||
) -> Result<(), RuntimeError> {
|
||||
runtime
|
||||
@@ -278,6 +282,11 @@ pub async fn v2_resume_playback(
|
||||
.check_requirements(CommandRequirement::RequiresUserSession)
|
||||
.await?;
|
||||
log::info!("[V2] Command: resume_playback");
|
||||
// Pre-flight: if the user's selected output device has disappeared
|
||||
// (KVM switched away, USB unplugged, sink removed…) surface a
|
||||
// toast. Playback continues — init_device already has a safe
|
||||
// fallback to the system default.
|
||||
emit_missing_if_needed(&app, &audio_settings, &device_watch);
|
||||
app_state.media_controls.set_playback(true);
|
||||
let bridge = bridge.get().await;
|
||||
bridge.resume().map_err(RuntimeError::Internal)
|
||||
@@ -665,6 +674,7 @@ pub struct V2PlayTrackResult {
|
||||
/// 5. Caches for future playback
|
||||
#[tauri::command]
|
||||
pub async fn v2_play_track(
|
||||
app: tauri::AppHandle,
|
||||
track_id: u64,
|
||||
quality: Option<String>,
|
||||
force_lowest_quality: Option<bool>,
|
||||
@@ -672,6 +682,7 @@ pub async fn v2_play_track(
|
||||
bridge: State<'_, CoreBridgeState>,
|
||||
offline_cache: State<'_, OfflineCacheState>,
|
||||
audio_settings: State<'_, AudioSettingsState>,
|
||||
device_watch: State<'_, std::sync::Arc<DeviceMissingThrottle>>,
|
||||
offline_state: State<'_, crate::offline::OfflineState>,
|
||||
app_state: State<'_, AppState>,
|
||||
runtime: State<'_, RuntimeManagerState>,
|
||||
@@ -682,6 +693,9 @@ pub async fn v2_play_track(
|
||||
.check_requirements(CommandRequirement::RequiresCoreBridgeAuth)
|
||||
.await?;
|
||||
|
||||
// Pre-flight device check — see v2_resume_playback for rationale.
|
||||
emit_missing_if_needed(&app, &audio_settings, &device_watch);
|
||||
|
||||
let preferred_quality = parse_quality(quality.as_deref());
|
||||
|
||||
// Apply per-device sample rate limit if enabled
|
||||
|
||||
@@ -21,6 +21,7 @@ pub mod api_server;
|
||||
pub mod artist_blacklist;
|
||||
pub mod artist_vectors;
|
||||
pub mod audio;
|
||||
pub mod audio_device_watch;
|
||||
pub mod cache;
|
||||
pub mod cast;
|
||||
pub mod commands;
|
||||
@@ -1199,6 +1200,7 @@ pub fn run() {
|
||||
.manage(api_cache_state)
|
||||
.manage(session_store_state)
|
||||
.manage(audio_settings_state)
|
||||
.manage(audio_device_watch::DeviceMissingThrottle::new())
|
||||
.manage(download_settings_state)
|
||||
.manage(subscription_state)
|
||||
.manage(offline_state)
|
||||
@@ -1615,6 +1617,7 @@ pub fn run() {
|
||||
commands_v2::v2_play_next_gapless,
|
||||
commands_v2::v2_prefetch_track,
|
||||
commands_v2::v2_reinit_audio_device,
|
||||
commands_v2::v2_check_audio_device_presence,
|
||||
commands_v2::v2_get_audio_settings,
|
||||
commands_v2::v2_set_audio_output_device,
|
||||
commands_v2::v2_set_audio_exclusive_mode,
|
||||
|
||||
Reference in new issue
Block a user