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 tauri::State;
|
||||||
|
|
||||||
use crate::audio::{AlsaPlugin, AudioBackendType};
|
use crate::audio::{AlsaPlugin, AudioBackendType};
|
||||||
|
use crate::audio_device_watch::{check_selected_device_presence, DevicePresence};
|
||||||
use crate::config::audio_settings::{AudioSettings, AudioSettingsState};
|
use crate::config::audio_settings::{AudioSettings, AudioSettingsState};
|
||||||
use crate::core_bridge::CoreBridgeState;
|
use crate::core_bridge::CoreBridgeState;
|
||||||
use crate::runtime::RuntimeError;
|
use crate::runtime::RuntimeError;
|
||||||
|
|
||||||
use super::{convert_to_qbz_audio_settings, sync_audio_settings_to_player};
|
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) ====================
|
// ==================== Audio Device Commands (V2) ====================
|
||||||
|
|
||||||
/// Reinitialize audio device (V2 - uses CoreBridge.player)
|
/// Reinitialize audio device (V2 - uses CoreBridge.player)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use tokio::sync::RwLock;
|
|||||||
use qbz_models::{Quality, QueueTrack as CoreQueueTrack, RepeatMode};
|
use qbz_models::{Quality, QueueTrack as CoreQueueTrack, RepeatMode};
|
||||||
|
|
||||||
use crate::audio::{AlsaPlugin, AudioBackendType};
|
use crate::audio::{AlsaPlugin, AudioBackendType};
|
||||||
|
use crate::audio_device_watch::{emit_missing_if_needed, DeviceMissingThrottle};
|
||||||
use crate::cache::AudioCache;
|
use crate::cache::AudioCache;
|
||||||
use crate::config::audio_settings::AudioSettingsState;
|
use crate::config::audio_settings::AudioSettingsState;
|
||||||
use crate::core_bridge::CoreBridgeState;
|
use crate::core_bridge::CoreBridgeState;
|
||||||
@@ -269,8 +270,11 @@ pub async fn v2_pause_playback(
|
|||||||
/// Resume playback (V2)
|
/// Resume playback (V2)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn v2_resume_playback(
|
pub async fn v2_resume_playback(
|
||||||
|
app: tauri::AppHandle,
|
||||||
bridge: State<'_, CoreBridgeState>,
|
bridge: State<'_, CoreBridgeState>,
|
||||||
app_state: State<'_, AppState>,
|
app_state: State<'_, AppState>,
|
||||||
|
audio_settings: State<'_, AudioSettingsState>,
|
||||||
|
device_watch: State<'_, std::sync::Arc<DeviceMissingThrottle>>,
|
||||||
runtime: State<'_, RuntimeManagerState>,
|
runtime: State<'_, RuntimeManagerState>,
|
||||||
) -> Result<(), RuntimeError> {
|
) -> Result<(), RuntimeError> {
|
||||||
runtime
|
runtime
|
||||||
@@ -278,6 +282,11 @@ pub async fn v2_resume_playback(
|
|||||||
.check_requirements(CommandRequirement::RequiresUserSession)
|
.check_requirements(CommandRequirement::RequiresUserSession)
|
||||||
.await?;
|
.await?;
|
||||||
log::info!("[V2] Command: resume_playback");
|
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);
|
app_state.media_controls.set_playback(true);
|
||||||
let bridge = bridge.get().await;
|
let bridge = bridge.get().await;
|
||||||
bridge.resume().map_err(RuntimeError::Internal)
|
bridge.resume().map_err(RuntimeError::Internal)
|
||||||
@@ -665,6 +674,7 @@ pub struct V2PlayTrackResult {
|
|||||||
/// 5. Caches for future playback
|
/// 5. Caches for future playback
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn v2_play_track(
|
pub async fn v2_play_track(
|
||||||
|
app: tauri::AppHandle,
|
||||||
track_id: u64,
|
track_id: u64,
|
||||||
quality: Option<String>,
|
quality: Option<String>,
|
||||||
force_lowest_quality: Option<bool>,
|
force_lowest_quality: Option<bool>,
|
||||||
@@ -672,6 +682,7 @@ pub async fn v2_play_track(
|
|||||||
bridge: State<'_, CoreBridgeState>,
|
bridge: State<'_, CoreBridgeState>,
|
||||||
offline_cache: State<'_, OfflineCacheState>,
|
offline_cache: State<'_, OfflineCacheState>,
|
||||||
audio_settings: State<'_, AudioSettingsState>,
|
audio_settings: State<'_, AudioSettingsState>,
|
||||||
|
device_watch: State<'_, std::sync::Arc<DeviceMissingThrottle>>,
|
||||||
offline_state: State<'_, crate::offline::OfflineState>,
|
offline_state: State<'_, crate::offline::OfflineState>,
|
||||||
app_state: State<'_, AppState>,
|
app_state: State<'_, AppState>,
|
||||||
runtime: State<'_, RuntimeManagerState>,
|
runtime: State<'_, RuntimeManagerState>,
|
||||||
@@ -682,6 +693,9 @@ pub async fn v2_play_track(
|
|||||||
.check_requirements(CommandRequirement::RequiresCoreBridgeAuth)
|
.check_requirements(CommandRequirement::RequiresCoreBridgeAuth)
|
||||||
.await?;
|
.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());
|
let preferred_quality = parse_quality(quality.as_deref());
|
||||||
|
|
||||||
// Apply per-device sample rate limit if enabled
|
// Apply per-device sample rate limit if enabled
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ pub mod api_server;
|
|||||||
pub mod artist_blacklist;
|
pub mod artist_blacklist;
|
||||||
pub mod artist_vectors;
|
pub mod artist_vectors;
|
||||||
pub mod audio;
|
pub mod audio;
|
||||||
|
pub mod audio_device_watch;
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod cast;
|
pub mod cast;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
@@ -1199,6 +1200,7 @@ pub fn run() {
|
|||||||
.manage(api_cache_state)
|
.manage(api_cache_state)
|
||||||
.manage(session_store_state)
|
.manage(session_store_state)
|
||||||
.manage(audio_settings_state)
|
.manage(audio_settings_state)
|
||||||
|
.manage(audio_device_watch::DeviceMissingThrottle::new())
|
||||||
.manage(download_settings_state)
|
.manage(download_settings_state)
|
||||||
.manage(subscription_state)
|
.manage(subscription_state)
|
||||||
.manage(offline_state)
|
.manage(offline_state)
|
||||||
@@ -1615,6 +1617,7 @@ pub fn run() {
|
|||||||
commands_v2::v2_play_next_gapless,
|
commands_v2::v2_play_next_gapless,
|
||||||
commands_v2::v2_prefetch_track,
|
commands_v2::v2_prefetch_track,
|
||||||
commands_v2::v2_reinit_audio_device,
|
commands_v2::v2_reinit_audio_device,
|
||||||
|
commands_v2::v2_check_audio_device_presence,
|
||||||
commands_v2::v2_get_audio_settings,
|
commands_v2::v2_get_audio_settings,
|
||||||
commands_v2::v2_set_audio_output_device,
|
commands_v2::v2_set_audio_output_device,
|
||||||
commands_v2::v2_set_audio_exclusive_mode,
|
commands_v2::v2_set_audio_exclusive_mode,
|
||||||
|
|||||||
@@ -1687,7 +1687,8 @@
|
|||||||
},
|
},
|
||||||
"laodingDevices": "Geräte werden geladen...",
|
"laodingDevices": "Geräte werden geladen...",
|
||||||
"findingSimilarArtists": "Suche nach ähnlichen Künstlern...",
|
"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": {
|
"offline": {
|
||||||
"title": "Offline-Modus",
|
"title": "Offline-Modus",
|
||||||
|
|||||||
@@ -1710,7 +1710,8 @@
|
|||||||
"addedToPlaylist": "Added to playlist",
|
"addedToPlaylist": "Added to playlist",
|
||||||
"copied": "Copied!",
|
"copied": "Copied!",
|
||||||
"findingSimilarArtists": "Finding similar artists...",
|
"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": {
|
"offline": {
|
||||||
"title": "Offline Mode",
|
"title": "Offline Mode",
|
||||||
|
|||||||
@@ -1710,7 +1710,8 @@
|
|||||||
"loadingCredits": "Cargando créditos...",
|
"loadingCredits": "Cargando créditos...",
|
||||||
"laodingDevices": "Cargando dispositivos...",
|
"laodingDevices": "Cargando dispositivos...",
|
||||||
"findingSimilarArtists": "Encontrar artistas similares...",
|
"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": {
|
"offline": {
|
||||||
"title": "Modo Offline",
|
"title": "Modo Offline",
|
||||||
|
|||||||
@@ -1687,7 +1687,8 @@
|
|||||||
"loadingDevices": "Chargement des appareils...",
|
"loadingDevices": "Chargement des appareils...",
|
||||||
"playlistUpdated": "Playlist mise à jour",
|
"playlistUpdated": "Playlist mise à jour",
|
||||||
"findingSimilarArtists": "Trouver des artistes similaires...",
|
"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": {
|
"offline": {
|
||||||
"title": "Mode hors ligne",
|
"title": "Mode hors ligne",
|
||||||
|
|||||||
@@ -1710,7 +1710,8 @@
|
|||||||
},
|
},
|
||||||
"laodingDevices": "Carregando dispositivos...",
|
"laodingDevices": "Carregando dispositivos...",
|
||||||
"findingSimilarArtists": "Encontrando artistas semelhantes...",
|
"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": {
|
"offline": {
|
||||||
"title": "Modo Offline",
|
"title": "Modo Offline",
|
||||||
|
|||||||
@@ -4607,6 +4607,7 @@
|
|||||||
let unlistenQconnectAdmissionBlocked: UnlistenFn | null = null;
|
let unlistenQconnectAdmissionBlocked: UnlistenFn | null = null;
|
||||||
let unlistenQconnectDiagnostic: UnlistenFn | null = null;
|
let unlistenQconnectDiagnostic: UnlistenFn | null = null;
|
||||||
let unlistenQconnectRendererReportDebug: UnlistenFn | null = null;
|
let unlistenQconnectRendererReportDebug: UnlistenFn | null = null;
|
||||||
|
let unlistenAudioDeviceMissing: UnlistenFn | null = null;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const unlisten1 = await listen('tray:play_pause', () => {
|
const unlisten1 = await listen('tray:play_pause', () => {
|
||||||
@@ -4798,6 +4799,23 @@
|
|||||||
});
|
});
|
||||||
if (disposed) { unlisten10(); return; }
|
if (disposed) { unlisten10(); return; }
|
||||||
unlistenQconnectRendererReportDebug = unlisten10;
|
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 () => {
|
return () => {
|
||||||
@@ -4814,6 +4832,7 @@
|
|||||||
unlistenQconnectAdmissionBlocked?.();
|
unlistenQconnectAdmissionBlocked?.();
|
||||||
unlistenQconnectDiagnostic?.();
|
unlistenQconnectDiagnostic?.();
|
||||||
unlistenQconnectRendererReportDebug?.();
|
unlistenQconnectRendererReportDebug?.();
|
||||||
|
unlistenAudioDeviceMissing?.();
|
||||||
// Save session before cleanup
|
// Save session before cleanup
|
||||||
saveSessionBeforeClose();
|
saveSessionBeforeClose();
|
||||||
cleanupBootstrap();
|
cleanupBootstrap();
|
||||||
|
|||||||
Reference in new issue
Block a user