fix(audio-watch): stop pre-flight device toast — stored name format never matched cpal enum

Level A of issue #307 was firing 'device no longer available' spuriously
because settings.output_device stores the ALSA-normalized form
(front:CARD=X,DEV=0 per normalize_device_id_to_stable) while cpal's
output_devices() enumeration returns different strings entirely —
DeviceTrait::description() (friendly) and DeviceTrait::name() both
produce values that don't match what the UI persisted. Comparing them
marked every configured device as 'missing' on normal playback.

Removes the pre-flight emit_missing_if_needed() calls from
v2_play_track and v2_resume_playback so the false-positive toast
stops firing. The init_device fallback still routes audio to default
on a truly missing device — we just no longer surface that to the UI,
which is net neutral for the user vs. wrong notifications.

Keeps audio_device_watch module, DeviceMissingThrottle state and the
frontend listener in place so Level B (emit from inside the audio
thread when the fallback actually triggers) can reuse the plumbing
without another refactor. Hardens the presence check with
empty-enumeration = inconclusive and description()-based matching for
whenever Level B actually wires up a call site that has a comparable
device name at hand.

cc
This commit is contained in:
vicrodh committed 2026-04-14 21:12:03 -06:00
1 parent 6094876aa1
commit f7e51b980e
2 files changed
+24 -15

No files matched your search

+24 -1
View File
@@ -77,10 +77,23 @@ pub fn check_selected_device_presence(settings: &AudioSettingsState) -> DevicePr
}
};
// IMPORTANT: match against the same name shape the UI persisted.
// Settings stores what get_audio_devices() wrote, which comes from
// DeviceTrait::description() (richer, stable identifier). The
// legacy DeviceTrait::name() returns a different string on some
// platforms (especially PipeWire-backed Linux) and would cause
// every configured device to look 'missing' here.
fn preferred_name(d: &rodio::cpal::Device) -> Option<String> {
if let Ok(desc) = d.description() {
return Some(desc.name().to_string());
}
d.name().ok()
}
let mut available: Vec<String> = Vec::new();
let mut found = false;
for device in devices {
if let Ok(name) = device.name() {
if let Some(name) = preferred_name(&device) {
if name == wanted {
found = true;
}
@@ -88,6 +101,16 @@ pub fn check_selected_device_presence(settings: &AudioSettingsState) -> DevicePr
}
}
// Empty enumeration is treated as inconclusive, not missing.
// cpal's device list can come back empty right after app launch
// or while PipeWire is still populating its sink graph — that's
// not the same as the user's device genuinely disappearing and
// firing a "device missing" toast under those conditions produces
// false positives (seen when pressing play on a restored session).
if available.is_empty() {
return DevicePresence::Inconclusive;
}
if found {
DevicePresence::Present
} else {
-14
View File
@@ -5,7 +5,6 @@ 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;
@@ -270,11 +269,8 @@ 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
@@ -282,11 +278,6 @@ 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)
@@ -674,7 +665,6 @@ 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>,
@@ -682,7 +672,6 @@ 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>,
@@ -693,9 +682,6 @@ 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