diff --git a/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po index 36e6a4e23..ff56a07cb 100644 --- a/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po @@ -4702,3 +4702,6 @@ msgstr "Kopieren" msgid "Copied!" msgstr "Kopiert!" + +msgid "Limited by your output device's limit" +msgstr "Begrenzt durch das Limit Ihres Ausgabegeräts" diff --git a/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po index da29974cc..7d1a0b739 100644 --- a/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po @@ -507,3 +507,6 @@ msgstr "offline copy — cap not applied" msgid "cached copy — cap not applied" msgstr "cached copy — cap not applied" + +msgid "Limited by your output device's limit" +msgstr "Limited by your output device's limit" diff --git a/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po index 744d3fbce..9e56d9584 100644 --- a/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po @@ -4732,3 +4732,6 @@ msgstr "Copiar" msgid "Copied!" msgstr "¡Copiado!" + +msgid "Limited by your output device's limit" +msgstr "Limitado por el límite de tu dispositivo de salida" diff --git a/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po index 666fea856..86daafccd 100644 --- a/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po @@ -4702,3 +4702,6 @@ msgstr "Copier" msgid "Copied!" msgstr "Copié !" + +msgid "Limited by your output device's limit" +msgstr "Limité par le plafond de votre périphérique de sortie" diff --git a/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po index fc15b56fb..0ae147c56 100644 --- a/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po @@ -4690,3 +4690,6 @@ msgstr "コピー" msgid "Copied!" msgstr "コピーしました!" + +msgid "Limited by your output device's limit" +msgstr "出力デバイスの上限により制限されています" diff --git a/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po index 5921d51cb..22502f1c7 100644 --- a/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po @@ -4714,3 +4714,6 @@ msgstr "Kopiëren" msgid "Copied!" msgstr "Gekopieerd!" + +msgid "Limited by your output device's limit" +msgstr "Beperkt door de limiet van je uitvoerapparaat" diff --git a/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po index 032c0ecdc..33da13af7 100644 --- a/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po @@ -4702,3 +4702,6 @@ msgstr "Copiar" msgid "Copied!" msgstr "Copiado!" + +msgid "Limited by your output device's limit" +msgstr "Limitado pelo limite do seu dispositivo de saída" diff --git a/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po index 6d8e73227..5c3d4f19c 100644 --- a/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po @@ -4716,3 +4716,6 @@ msgstr "Копировать" msgid "Copied!" msgstr "Скопировано!" + +msgid "Limited by your output device's limit" +msgstr "Ограничено пределом вашего устройства вывода" diff --git a/crates/qbz-ui/ui/state.slint b/crates/qbz-ui/ui/state.slint index 4e919e24e..524712061 100644 --- a/crates/qbz-ui/ui/state.slint +++ b/crates/qbz-ui/ui/state.slint @@ -2992,6 +2992,13 @@ export global SettingsState { // Audio — toggles. in-out property limit-quality-to-device: false; + // Detected local device limit (#638 fix 3), shown while the toggle above + // is on. `device-cap-summary` is the Rust-composed value line (e.g. + // "192 kHz · Hi-Res+"; empty = nothing detected yet → row hidden); + // `device-cap-detected` false = the probe fell back to an assumed common + // rate set, which surfaces the plain informative caveat (never an alert). + in-out property device-cap-summary: ""; + in-out property device-cap-detected: true; in-out property alsa-hardware-volume: false; // DSD delivery mode (DSD plan Phases 2-3): Convert to PCM / DoP / // Native DSD. Only meaningful on the ALSA direct backend. diff --git a/crates/qbz/src/device_cap.rs b/crates/qbz/src/device_cap.rs new file mode 100644 index 000000000..ee70d0126 --- /dev/null +++ b/crates/qbz/src/device_cap.rs @@ -0,0 +1,197 @@ +//! Local output-device quality cap (#638 fix 3, spec Phase C). +//! +//! Caches the local DAC's detected rate ceiling, mapped to a Qobuz tier, so +//! the request-time resolution (`playback::local_playback_quality`) can clamp +//! the streaming-quality preference without probing per call. Detection is +//! the proven read-only `qbz_audio::query_dac_capabilities` (reads +//! `/proc/asound` and shells out to `pw-dump`; never opens a stream), so a +//! refresh runs inside `spawn_blocking` on EXPLICIT triggers only — startup, +//! the Settings toggle, an output-device/backend change, reset-to-defaults — +//! never on the playback hot path or the poll tick. A stale cap after a +//! hot-unplug (until the next device change), and an uncapped first track +//! when a session-restore play beats the startup refresh, are the accepted +//! trades — same class as the HiFi wizard's behavior; both self-heal. +//! +//! PRECEDENCE (owner decision, #638): the cap of the device ACTUALLY PLAYING +//! governs. This cache is for LOCAL playback only — the cast path must never +//! read it (the local DAC is not in a cast's signal path). + +use std::sync::RwLock; + +use qbz_models::Quality; + +/// The cached cap. `tier` is the coarse Qobuz-tier mapping of the detected +/// ceiling (`max_rate_hz`); `detected` false = the probe fell back to the +/// common rate set, so the Settings caveat must disclose that the cap may +/// not match the hardware (owner Decision B: it still applies). +#[derive(Clone)] +pub struct CapState { + pub tier: Quality, + pub detected: bool, + pub max_rate_hz: u32, + pub description: String, +} + +/// None = the cap is disabled (toggle off) or not refreshed yet. +static CAP: RwLock> = RwLock::new(None); + +/// Map a detected max rate to the tier we may REQUEST (spec C.3). Coarse by +/// design: Qobuz sells four discrete tiers and no 48 kHz tier exists, so a +/// 48 kHz (or 44.1 kHz) ceiling steps down to CD 16/44.1 — bit depth +/// included; the Settings summary says it plainly instead of letting the +/// user discover it. > 96 kHz keeps Hi-Res+ = no effective cap (still +/// cached so Settings can display what was detected). +fn tier_for_max_rate_hz(max_hz: u32) -> Quality { + if max_hz > 96_000 { + Quality::UltraHiRes + } else if max_hz >= 88_200 { + Quality::HiRes + } else { + Quality::Lossless + } +} + +/// Cheap read for the request-time resolution: `(tier, detected)`. +/// None = no cap configured. +pub fn cap() -> Option<(Quality, bool)> { + CAP.read() + .unwrap_or_else(|e| e.into_inner()) + .as_ref() + .map(|c| (c.tier, c.detected)) +} + +/// The Settings "Detected device limit" value line: `(summary, detected)`, +/// e.g. `("192 kHz · Hi-Res+", true)`. Untranslated data composition — the +/// same convention as the quality badge's "24-bit / 96 kHz" (tier names are +/// product names). `("", true)` when no cap is active: the row hides on the +/// empty summary, and `true` keeps the fallback caveat from flashing before +/// the first refresh lands. +pub fn summary() -> (String, bool) { + match CAP.read().unwrap_or_else(|e| e.into_inner()).as_ref() { + Some(c) => ( + format!( + "{} · {}", + rate_khz_label(c.max_rate_hz), + tier_display(c.tier) + ), + c.detected, + ), + None => (String::new(), true), + } +} + +/// Product-name tier label for the summary line. The CD entry spells out the +/// bit-depth cost (spec C.3: no 48 kHz tier exists, so the step below Hi-Res +/// loses depth too — say it, don't let the user discover it). +fn tier_display(tier: Quality) -> &'static str { + match tier { + Quality::UltraHiRes => "Hi-Res+", + Quality::HiRes => "Hi-Res", + Quality::Lossless => "CD 16-bit / 44.1 kHz", + // Unreachable from tier_for_max_rate_hz; total match for safety. + Quality::Mp3 => "MP3 320", + } +} + +/// "192 kHz" / "44.1 kHz" from Hz — integer when whole, one decimal +/// otherwise (matches `crate::quality::detail`'s rate formatting). +fn rate_khz_label(hz: u32) -> String { + let khz = hz as f64 / 1000.0; + if khz.fract().abs() < f64::EPSILON { + format!("{} kHz", khz as i64) + } else { + format!("{khz} kHz") + } +} + +/// Re-run detection and refresh the cache. `limit_enabled` off clears it +/// immediately (no probe). The probe runs in `spawn_blocking` (pw-dump +/// subprocess + /proc reads — never on the UI thread). Await-able so the +/// Settings controller can re-push the summary row right after. +pub async fn refresh(limit_enabled: bool, output_device: Option) { + if !limit_enabled { + *CAP.write().unwrap_or_else(|e| e.into_inner()) = None; + log::info!("[qbz-slint] device cap: disabled"); + return; + } + let probed = tokio::task::spawn_blocking(move || { + // The configured device id, or the system-default sink when the + // selection is "System default" (None). An unresolvable default + // probes with an empty node name, which lands on the fallback set → + // detected=false → Hi-Res+ no-op cap with the caveat disclosed. + let node = output_device.unwrap_or_else(default_output_node); + qbz_audio::query_dac_capabilities(&node) + }) + .await; + let caps = match probed { + Ok(c) => c, + Err(e) => { + log::warn!("[qbz-slint] device cap: probe task failed: {e}"); + return; + } + }; + let max_rate_hz = caps.sample_rates.iter().copied().max().unwrap_or(0); + // assemble() always yields a non-empty rate list (fallback set), but + // never store a 0 Hz cap if that invariant ever breaks. + if max_rate_hz == 0 { + *CAP.write().unwrap_or_else(|e| e.into_inner()) = None; + return; + } + let state = CapState { + tier: tier_for_max_rate_hz(max_rate_hz), + detected: caps.detected, + max_rate_hz, + description: caps.description.unwrap_or_else(|| caps.node_name.clone()), + }; + log::info!( + "[qbz-slint] device cap: {} -> max {} Hz -> {:?} ({})", + state.description, + state.max_rate_hz, + state.tier, + if state.detected { + "detected" + } else { + "fallback set" + }, + ); + *CAP.write().unwrap_or_else(|e| e.into_inner()) = Some(state); +} + +/// The system-default PipeWire sink's node id, for the "System default" +/// device selection. Empty when nothing enumerates (no PipeWire) — the probe +/// then reports the fallback set honestly. +fn default_output_node() -> String { + qbz_audio::backend::BackendManager::create_backend( + qbz_audio::backend::AudioBackendType::PipeWire, + ) + .ok() + .and_then(|b| b.enumerate_devices().ok()) + .and_then(|devs| devs.into_iter().find(|d| d.is_default)) + .map(|d| d.id) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tier_mapping_matches_spec_c3_table() { + // > 96 kHz → Hi-Res+ (no effective cap). + assert_eq!(tier_for_max_rate_hz(192_000), Quality::UltraHiRes); + assert_eq!(tier_for_max_rate_hz(176_400), Quality::UltraHiRes); + // 96 / 88.2 kHz → Hi-Res. + assert_eq!(tier_for_max_rate_hz(96_000), Quality::HiRes); + assert_eq!(tier_for_max_rate_hz(88_200), Quality::HiRes); + // ≤ 48 kHz → CD 16/44.1 (bit depth lost too — no 48 kHz tier). + assert_eq!(tier_for_max_rate_hz(48_000), Quality::Lossless); + assert_eq!(tier_for_max_rate_hz(44_100), Quality::Lossless); + } + + #[test] + fn rate_label_formats_whole_and_fractional_khz() { + assert_eq!(rate_khz_label(192_000), "192 kHz"); + assert_eq!(rate_khz_label(44_100), "44.1 kHz"); + assert_eq!(rate_khz_label(176_400), "176.4 kHz"); + } +} diff --git a/crates/qbz/src/main.rs b/crates/qbz/src/main.rs index d50f0e09a..2bb7c2187 100644 --- a/crates/qbz/src/main.rs +++ b/crates/qbz/src/main.rs @@ -33,6 +33,7 @@ mod booklet; mod commands; mod custom_artwork; mod custom_theme; +mod device_cap; mod diagnostics; #[cfg(target_os = "linux")] mod glibc_compat; @@ -273,6 +274,11 @@ fn spawn_settings_snapshot_load( settings_ctx: Arc, ) { tokio::spawn(async move { + // Seed the local device-cap cache (#638 fix 3) BEFORE the snapshot + // build so the Settings "Detected device limit" row is filled on + // first open and the first governed play resolves against the cap. + // Instant no-op while the toggle is off (the default). + settings::refresh_device_cap(&settings_ctx, &weak).await; let ctx_for_load = settings_ctx.clone(); match tokio::task::spawn_blocking(move || settings::load_snapshot(&ctx_for_load)).await { Ok(snap) => { diff --git a/crates/qbz/src/playback.rs b/crates/qbz/src/playback.rs index 5c3168a18..86342667e 100644 --- a/crates/qbz/src/playback.rs +++ b/crates/qbz/src/playback.rs @@ -131,7 +131,9 @@ async fn kick_prefetch(runtime: &Runtime) { // prefetch right now"; the semaphore still bounds concurrency otherwise. { use qbz_audio::network_throttle::{self, PlaybackQualityTag}; - let tag = match playback_quality() { + // Device-capped (#638 fix 3): the bandwidth estimate must describe + // the tier prefetch will actually request below, not the raw pref. + let tag = match local_playback_quality().0 { Quality::UltraHiRes => PlaybackQualityTag::UltraHiRes, Quality::HiRes => PlaybackQualityTag::HiRes, Quality::Lossless => PlaybackQualityTag::Lossless, @@ -173,7 +175,7 @@ async fn kick_prefetch(runtime: &Runtime) { }; let player = runtime.core().player(); if let Err(e) = player - .prefetch_into_cache(client, track_id, playback_quality()) + .prefetch_into_cache(client, track_id, local_playback_quality().0) .await { log::debug!("[qbz-slint] prefetch: track {track_id} failed: {e}"); @@ -198,6 +200,36 @@ pub(crate) fn playback_quality() -> Quality { crate::ui_prefs::streaming_quality_for_key(&crate::ui_prefs::load().streaming_quality) } +/// Streaming quality for LOCAL playback plus the request-time cause: the +/// user's preference clamped by the local output device's cap when "Limit +/// quality to device" is on (#638 fix 3; cached in `crate::device_cap`, so +/// this stays as cheap as `playback_quality` plus one RwLock read). A tie +/// between the preference and the cap reports `LocalDeviceCap` — the more +/// specific, more surprising constraint (mirrors the spec's cast tie rule). +/// A resolved Hi-Res+ request reports `None`: nothing constrained it. +/// +/// NEVER call this from the cast path (`cast_service`): the local DAC is not +/// in a cast's signal path, so its cap must not shape a cast request +/// (precedence rule, owner decision) — casts resolve via the pure +/// `playback_quality()` above. +pub(crate) fn local_playback_quality() -> (Quality, QualityLimit) { + let pref = playback_quality(); + match crate::device_cap::cap() { + Some((cap, _)) if cap < pref => (cap, QualityLimit::LocalDeviceCap), + Some((cap, _)) if cap == pref && cap < Quality::UltraHiRes => { + (pref, QualityLimit::LocalDeviceCap) + } + _ => ( + pref, + if pref < Quality::UltraHiRes { + QualityLimit::Preference + } else { + QualityLimit::None + }, + ), + } +} + /// Convenience alias for the runtime handle threaded through every call. type Runtime = Arc>; @@ -642,7 +674,9 @@ async fn play_audible(runtime: &Runtime, weak: &slint::Weak, track_id .core() .play_track_resolved( track_id, - playback_quality(), + // LOCAL playback: the device cap applies (#638 fix 3). The cast + // branch above returned already, so a cast can never reach this. + local_playback_quality().0, offline.as_deref(), Some(&sink), start_position_secs, @@ -2007,21 +2041,14 @@ pub(crate) async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::We // (ui_prefs::load is a disk read + JSON parse — never per poll tick). // Only Qobuz-sourced tracks are governed by the streaming-quality // preference; local / Plex / ephemeral sources store 0 = not governed, - // which keeps the cause line off for them. NOTE(#638 fix 3): when the - // local device cap lands, this resolve switches to the device-capped - // wrapper so the cause can name the output device. + // which keeps the cause line off for them. The device-capped resolve + // (#638 fix 3) names the output device when its cap — not the + // preference — shaped the request, so the tooltip can say which. let governed = !track.is_local && matches!(source.as_str(), "qobuz" | "qobuz_download"); if governed { - let requested = playback_quality(); + let (requested, cause) = local_playback_quality(); REQUESTED_QUALITY_ID.store(requested.id(), std::sync::atomic::Ordering::Relaxed); - REQUESTED_CAUSE.store( - if requested < Quality::UltraHiRes { - QualityLimit::Preference as i32 - } else { - QualityLimit::None as i32 - }, - std::sync::atomic::Ordering::Relaxed, - ); + REQUESTED_CAUSE.store(cause as i32, std::sync::atomic::Ordering::Relaxed); } else { REQUESTED_QUALITY_ID.store(0, std::sync::atomic::Ordering::Relaxed); REQUESTED_CAUSE.store(QualityLimit::None as i32, std::sync::atomic::Ordering::Relaxed); @@ -4885,7 +4912,7 @@ pub fn start_poll_loop( .core() .fetch_for_gapless_resolved( next_id, - playback_quality(), + local_playback_quality().0, offline.as_deref(), Some(&sink), ) diff --git a/crates/qbz/src/settings.rs b/crates/qbz/src/settings.rs index 82e65911d..6c4a52f76 100644 --- a/crates/qbz/src/settings.rs +++ b/crates/qbz/src/settings.rs @@ -159,6 +159,11 @@ pub struct SettingsSnapshot { alsa_plugin_index: i32, // Audio — toggles. limit_quality_to_device: bool, + // Detected local device limit (#638 fix 3): the read-only value line + // ("192 kHz · Hi-Res+"; empty = none) + whether it came from real + // detection (false = fallback set → the Settings caveat shows). + device_cap_summary: String, + device_cap_detected: bool, alsa_hardware_volume: bool, dsd_modes: Vec, dsd_mode_index: i32, @@ -525,6 +530,10 @@ fn build_snapshot( let qconnect_device_name_default = crate::qconnect_transport::resolve_qconnect_friendly_name(None); + // Detected device limit (#638 fix 3): a cheap cache read — the probe + // itself only runs on the explicit refresh triggers, never here. + let (device_cap_summary, device_cap_detected) = crate::device_cap::summary(); + let backend_is_alsa = active_backend == AudioBackendType::Alsa; let backend_is_pipewire = active_backend == AudioBackendType::PipeWire; let backend_is_jack = active_backend == AudioBackendType::Jack; @@ -562,6 +571,8 @@ fn build_snapshot( alsa_plugins: ALSA_PLUGINS.iter().map(|(l, _)| qbz_i18n::t(l)).collect(), alsa_plugin_index: alsa_plugin_index as i32, limit_quality_to_device: audio.limit_quality_to_device, + device_cap_summary, + device_cap_detected, alsa_hardware_volume: audio.alsa_hardware_volume, dsd_modes: DSD_MODES.iter().map(|(l, _)| qbz_i18n::t(l)).collect(), dsd_mode_index: DSD_MODES @@ -645,6 +656,8 @@ pub fn apply_snapshot(window: &AppWindow, snap: SettingsSnapshot) { st.set_alsa_plugin_index(snap.alsa_plugin_index); // Audio — toggles. st.set_limit_quality_to_device(snap.limit_quality_to_device); + st.set_device_cap_summary(snap.device_cap_summary.into()); + st.set_device_cap_detected(snap.device_cap_detected); st.set_alsa_hardware_volume(snap.alsa_hardware_volume); st.set_dsd_modes(string_model(snap.dsd_modes)); st.set_dsd_mode_index(snap.dsd_mode_index); @@ -773,6 +786,29 @@ pub async fn apply_startup_bitperfect_volume( maybe_force_bitperfect_volume(ctx, runtime, weak).await; } +/// Re-detect the local output device's quality cap (#638 fix 3) from the +/// persisted audio settings and re-push the Settings "Detected device limit" +/// row. The probe itself runs off-thread inside `device_cap::refresh`; +/// await-able so callers sequence the UI push after the cache settles. +/// Explicit triggers ONLY — startup, the limit toggle, an output-device or +/// backend change, reset-to-defaults — never the playback path or poll tick. +pub async fn refresh_device_cap(ctx: &SettingsCtx, weak: &slint::Weak) { + let audio = match with_audio(&ctx.audio, |s| s.get_settings()) { + Ok(s) => s, + Err(e) => { + log::error!("[qbz-slint] re-read audio settings for device cap failed: {e}"); + return; + } + }; + crate::device_cap::refresh(audio.limit_quality_to_device, audio.output_device).await; + let (summary, detected) = crate::device_cap::summary(); + let _ = weak.upgrade_in_event_loop(move |w| { + let st = w.global::(); + st.set_device_cap_summary(summary.into()); + st.set_device_cap_detected(detected); + }); +} + /// Recompute the backend/ALSA conditional flags from the current audio /// settings and push them onto `SettingsState`. Called after a backend or /// ALSA-plugin change so the `.slint` panels re-gate the conditional rows. @@ -1000,6 +1036,12 @@ pub async fn handle_bool( } Err(e) => log::error!("[qbz-slint] failed to persist '{key}': {e}"), } + // The local device-cap cache follows the toggle (#638 fix 3): re-probe on + // enable, drop on disable, then re-push the detected-limit row. After the + // persist above so the refresh reads the fresh flag. + if key == "limit-quality-to-device" { + refresh_device_cap(&ctx, &weak).await; + } // After a cascade, rebuild + re-push the full snapshot so the forced // changes and disabled states reach the UI. if cascaded { @@ -1231,6 +1273,10 @@ pub async fn handle_select( // gapless) and the conditional flags all reach the UI in one // consistent push. apply_audio(&ctx, &runtime, Apply::Reinit); + // A backend switch reset the output device to the system default + // above — re-detect the device cap for it (#638 fix 3) BEFORE the + // snapshot rebuild below reads the cache. + refresh_device_cap(&ctx, &weak).await; // Bit-perfect (ALSA + hw) forces local volume to 100%; lifted while // controlling a peer. Mirrors Tauri's playerSetVolume(100). maybe_force_bitperfect_volume(&ctx, &runtime, &weak).await; @@ -1253,6 +1299,9 @@ pub async fn handle_select( return; } apply_audio(&ctx, &runtime, Apply::Reinit); + // The cap is per-device — re-detect for the new output (#638 + // fix 3). No-op while the limit toggle is off. + refresh_device_cap(&ctx, &weak).await; } "dsd-mode" => { let Some((_, mode)) = DSD_MODES.get(index) else { @@ -1322,6 +1371,10 @@ pub async fn handle_reset( if let Err(e) = with_playback(&ctx.playback, |s| s.reset_all()) { log::error!("[qbz-slint] playback reset_all failed: {e}"); } + // Reset turns "Limit quality to device" off — drop the cached cap so the + // next play is uncapped and the snapshot below reads the cleared state + // (#638 fix 3). + refresh_device_cap(&ctx, &weak).await; // Rebuild the snapshot off the UI thread (device enumeration blocks). let snap = { let ctx = ctx.clone();