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:
vicrodh committed 2026-04-14 17:58:59 -06:00
1 parent 7425d34e27
commit b62a4b01f5
10 files changed
+243 -5

No files matched your search

+158
View File
@@ -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);
}
}
}
+39
View File
@@ -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)
+14
View File
@@ -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
+3
View File
@@ -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,
+2 -1
View File
@@ -1687,7 +1687,8 @@
},
"laodingDevices": "Geräte werden geladen...",
"findingSimilarArtists": "Suche nach ähnlichen Künstlern...",
"awardUnavailable": "Detailansicht für diese Auszeichnung nicht verfügbar."
"awardUnavailable": "Detailansicht für diese Auszeichnung nicht verfügbar.",
"audioDeviceMissing": "Ausgabegerät '{device}' ist nicht mehr verfügbar. Standardgerät wird verwendet."
},
"offline": {
"title": "Offline-Modus",
+2 -1
View File
@@ -1710,7 +1710,8 @@
"addedToPlaylist": "Added to playlist",
"copied": "Copied!",
"findingSimilarArtists": "Finding similar artists...",
"awardUnavailable": "Award detail not available for this entry."
"awardUnavailable": "Award detail not available for this entry.",
"audioDeviceMissing": "Output device '{device}' is no longer available. Using system default."
},
"offline": {
"title": "Offline Mode",
+2 -1
View File
@@ -1710,7 +1710,8 @@
"loadingCredits": "Cargando créditos...",
"laodingDevices": "Cargando dispositivos...",
"findingSimilarArtists": "Encontrar artistas similares...",
"awardUnavailable": "No hay detalle disponible para este premio."
"awardUnavailable": "No hay detalle disponible para este premio.",
"audioDeviceMissing": "El dispositivo '{device}' ya no está disponible. Usando el predeterminado del sistema."
},
"offline": {
"title": "Modo Offline",
+2 -1
View File
@@ -1687,7 +1687,8 @@
"loadingDevices": "Chargement des appareils...",
"playlistUpdated": "Playlist mise à jour",
"findingSimilarArtists": "Trouver des artistes similaires...",
"awardUnavailable": "Détail non disponible pour cette récompense."
"awardUnavailable": "Détail non disponible pour cette récompense.",
"audioDeviceMissing": "Le périphérique '{device}' n'est plus disponible. Utilisation du périphérique par défaut."
},
"offline": {
"title": "Mode hors ligne",
+2 -1
View File
@@ -1710,7 +1710,8 @@
},
"laodingDevices": "Carregando dispositivos...",
"findingSimilarArtists": "Encontrando artistas semelhantes...",
"awardUnavailable": "Detalhe não disponível para este prêmio."
"awardUnavailable": "Detalhe não disponível para este prêmio.",
"audioDeviceMissing": "Dispositivo '{device}' não está mais disponível. Usando o padrão do sistema."
},
"offline": {
"title": "Modo Offline",
+19
View File
@@ -4607,6 +4607,7 @@
let unlistenQconnectAdmissionBlocked: UnlistenFn | null = null;
let unlistenQconnectDiagnostic: UnlistenFn | null = null;
let unlistenQconnectRendererReportDebug: UnlistenFn | null = null;
let unlistenAudioDeviceMissing: UnlistenFn | null = null;
(async () => {
const unlisten1 = await listen('tray:play_pause', () => {
@@ -4798,6 +4799,23 @@
});
if (disposed) { unlisten10(); return; }
unlistenQconnectRendererReportDebug = unlisten10;
// Issue #307 — configured output device vanished mid-session
// (KVM switched away, USB unplugged, sink removed). Backend emits
// this right before play/resume when it detects the mismatch;
// init_device already falls back to default automatically, we
// just surface the fact to the user as a warning toast so they
// understand why audio is now coming out of a different sink.
const unlistenDeviceMissing = await listen<{ wanted: string; available: string[] }>('audio:device-missing', (event) => {
const wanted = event.payload?.wanted ?? '';
showToast(
$t('toast.audioDeviceMissing', { values: { device: wanted } }),
'warning',
6000
);
});
if (disposed) { unlistenDeviceMissing(); return; }
unlistenAudioDeviceMissing = unlistenDeviceMissing;
})();
return () => {
@@ -4814,6 +4832,7 @@
unlistenQconnectAdmissionBlocked?.();
unlistenQconnectDiagnostic?.();
unlistenQconnectRendererReportDebug?.();
unlistenAudioDeviceMissing?.();
// Save session before cleanup
saveSessionBeforeClose();
cleanupBootstrap();