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:
2 files changed
+24
-15
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user