From 05c5799192ed986a504deb6586f408493f65b6fe Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:09:37 -0600 Subject: [PATCH] feat(qt): Detected device limit row, its refresh triggers and cache drop The last unported row of Settings > Audio. Read-only by design: a Hz dropdown is the documented root-cause trap, because it promises rates the app cannot request when Qobuz sells four discrete tiers. The value is Rust-composed from the probe and the row hides on an empty summary; the fallback caveat renders only when the probe fell back to the common set. No new msgids -- both strings already exist, translated, in all 8 catalogs. Six refresh triggers, one more than the reference: startup, the toggle, a backend switch, an output-device change, reset-to-defaults, and the device refresh/release button. That last one is Qt-only and the easiest to miss -- it is the hotplug case, where the hardware behind a "System default" selection changes without the selection changing. Ordering is persist -> refresh -> publish, and the refresh goes BEFORE apply_audio rather than merely before the handler's closing publish: apply_audio publishes on its own way out, an earlier serialize that would ship a stale summary. The symptom of getting this wrong is a row that is only right after reopening Settings. The boot trigger publishes the snapshot itself after awaiting the refresh. The boot snapshot is spawned separately and can serialize first, which left the row absent for a user who already had the toggle on until some unrelated mutation happened to republish. A cap change drops the tier-keyed audio cache, symmetric with the streaming -quality change: the cache is quality-blind, so bytes fetched under the old tier would keep serving and the feature would be inert on anything already played. The first refresh of a process is exempt -- see the CapChange classification, which exists because clearing there wiped the disk cache on every launch. cc --- crates/qbz-qt/qml/settings/AudioSettings.qml | 39 ++++++- crates/qbz-qt/src/main.rs | 19 ++++ crates/qbz-qt/src/settings_qt.rs | 105 ++++++++++++++++++- 3 files changed, 157 insertions(+), 6 deletions(-) diff --git a/crates/qbz-qt/qml/settings/AudioSettings.qml b/crates/qbz-qt/qml/settings/AudioSettings.qml index 6110ff19a..3dda07b23 100644 --- a/crates/qbz-qt/qml/settings/AudioSettings.qml +++ b/crates/qbz-qt/qml/settings/AudioSettings.qml @@ -5,10 +5,10 @@ // truth. The audio path itself is PROTECTED: this panel only calls the // settings keys settings_qt.rs already dispatches. // -// Not shipped (no backend seam in this port — see settings_qt.rs): -// - "Detected device limit" + its fallback disclosure (#638): the device-cap -// probe (`device_cap.rs`) is Slint glue that was never ported, so there is -// no measured value to show. +// "Detected device limit" + its fallback disclosure (#638 fix 3) ARE shipped +// (2026-08-17): the probe moved out of the Slint binary crate into +// `qbz-app::device_cap`, and both rows read the cap cache off the settings +// document. They are read-only by design — see the comment at the rows. // // The HiFi Wizard IS shipped (2026-08-11): the last row of OUTPUT opens // settings/DacWizardModal.qml, mounted at the SettingsView root. Its logic @@ -50,6 +50,37 @@ Column { onToggled: function (v) { QbzBridge.settingsBool("limit-quality-to-device", v) } } } + // Read-only detected line — deliberately NOT a control (#638 fix 3): a Hz + // dropdown was the documented root-cause trap (it promised rates the app + // cannot request; Qobuz sells four discrete tiers), and with real detection + // there is nothing left to pick. The value is Rust-composed from the probe + // ("192 kHz · Hi-Res+") and is empty while nothing is cached, which is what + // hides the row rather than showing a blank value. + SettingRow { + visible: root.doc.limitQualityToDevice === true + && (root.doc.deviceCapSummary || "") !== "" + label: QbzSession.tr("Detected device limit", QbzSession.trRev) + Text { + text: root.doc.deviceCapSummary || "" + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightMedium + verticalAlignment: Text.AlignVCenter + } + } + // Fallback disclosure (#638 Decision B): plain informative copy — no + // warning icon, no error styling. The cap still applies on the assumed + // common set; it just is not measured truth for this hardware. + Text { + visible: root.doc.limitQualityToDevice === true + && (root.doc.deviceCapSummary || "") !== "" + && root.doc.deviceCapDetected === false + width: parent.width + text: QbzSession.tr("Your device's exact limits could not be read on this system, so a common set is assumed. The cap still applies, but may not match your hardware exactly.", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: 12 + wrapMode: Text.WordWrap + } SettingsSpacer { } SettingsDivider { } diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index f9dba4a2d..fb2a4d8dc 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -556,6 +556,25 @@ fn on_session_entered() { // Every caller of this function is one genuine session entry, so this is // the one place that sees them all. reset_session_latches(); + // Phase 1b: seed the local device-cap cache (#638 fix 3) as early in the + // session as possible, so the first governed play resolves against the cap + // and the Settings "Detected device limit" row is filled on first open. + // Instant no-op while the toggle is off, which is the default. + // + // Spawned, not awaited — the probe shells out to `pw-dump` and this + // function runs on the Qt thread. The documented consequence (same trade + // the reference accepts): a session-restore play that beats the refresh + // gets ONE uncapped track. It self-heals on the next play. + // + // The republish is NOT optional. The boot snapshot is spawned separately + // and can serialize BEFORE this probe lands, so without a publish of our + // own the Settings row stays absent for a user who already had the toggle + // on — until some unrelated mutation happens to republish. Nothing else + // re-runs on probe completion. + spawn(async { + settings_qt::refresh_device_cap(&app()).await; + settings_qt::publish_snapshot().await; + }); // Phase 2: the shell mounts on the (only) "home" view; seed the nav // history and push the current now-playing model onto the bar. nav_qt::record(&nav_qt::shell_entry_view()); diff --git a/crates/qbz-qt/src/settings_qt.rs b/crates/qbz-qt/src/settings_qt.rs index 4c15f8545..c6127ecbf 100644 --- a/crates/qbz-qt/src/settings_qt.rs +++ b/crates/qbz-qt/src/settings_qt.rs @@ -12,10 +12,13 @@ //! streaming-only, backend switch). //! //! Not wired yet: -//! - Detected device limit row (#638): the probe + its cache live in -//! `crate::device_cap` (Slint glue) — not ported; the row stays hidden. //! - The bit-perfect force-100 volume cascade. //! +//! The Detected device limit row (#638 fix 3) IS wired (2026-08-17): the probe +//! and its cache moved out of the Slint binary crate into +//! `qbz_app::device_cap`, `refresh_device_cap` below owns the six explicit +//! triggers, and the row reads the cache off the snapshot. +//! //! (The HiFi Wizard, the JACK banner and settings export/import were on this //! list and are all shipped now — `qml/settings/DacWizardModal.qml`, //! `AudioSettings.qml:70-78` and `settings_qt/devtools.rs`.) @@ -85,6 +88,60 @@ pub fn audio_settings() -> qbz_audio::settings::AudioSettings { with_audio(|s| s.get_settings()).unwrap_or_default() } +/// Re-probe the local output device and refresh the #638 fix-3 cap cache. +/// +/// **ORDERING — persist → refresh → publish, and Qt has a hazard Slint does +/// not.** The refresh must re-READ the settings (hence no arguments: it reads +/// the store itself), so every caller persists FIRST. It must also run +/// **before `apply_audio`**, not merely before the handler's closing +/// `publish_snapshot`: `apply_audio` ends with its own `publish_settings` +/// (see below), an earlier serialize that would ship the STALE summary and +/// win the race. The symptom of getting this wrong is "the row is right, but +/// only after you reopen Settings". +/// +/// **The cache drop (contract D3), and its one hard-won exception.** A cap +/// change moves the effective request tier by exactly the mechanism a +/// streaming-quality change does, and the audio cache is quality-BLIND: local +/// play accepts any cached entry that is not below the request. So bytes +/// fetched before the cap must not keep serving, or the feature is observably +/// inert on every track already played this session. +/// +/// The exception is the FIRST refresh of the process, and skipping it is not +/// an optimisation. `clear_audio_cache` is not an L1 drop — it reaches +/// `PlaybackCache::clear`, which unlinks every `.audio` file on disk +/// (hundreds of MB to GBs). The cap cache starts empty, so at boot every +/// `None -> Some(tier)` reads as a change, and a naive comparison here wiped +/// the entire disk cache on EVERY launch with the toggle on, logging it as a +/// legitimate tier change. `device_cap` classifies the refresh instead +/// (`CapChange`), which also stops a superseded concurrent refresh from being +/// misattributed to this one. +/// +/// The probe itself (`pw-dump` subprocess + `/proc/asound` reads) runs inside +/// `spawn_blocking` down in `device_cap`, and is an instant no-op while the +/// toggle is off — which is the default, so the common path costs nothing. +pub(crate) async fn refresh_device_cap(runtime: &Arc>) { + use qbz_app::device_cap::CapChange; + let audio = audio_settings(); + let change = qbz_app::device_cap::refresh( + audio.limit_quality_to_device, + audio.output_device.clone(), + // D7: enumerate the "System default" sink through the backend that is + // actually playing, not always through PipeWire. + audio.backend_type, + ) + .await; + match change { + CapChange::Changed => { + log::info!("[qbz-qt] device cap: request tier changed — clearing audio cache"); + runtime.core().player().clear_audio_cache(); + } + CapChange::Seeded => { + log::info!("[qbz-qt] device cap: seeded for this process — audio cache left intact"); + } + CapChange::Unchanged => {} + } +} + /// Whether `InfiniteRadio` autoplay is on — the queue footer's ∞ state and the /// gate on the end-of-track refill engine. Exposed here (rather than reopening /// the store from `queue_qt`) so both readers see the SAME process-wide handle: @@ -1333,6 +1390,16 @@ pub struct SettingsDoc { pub dsd_mode_index: i32, #[serde(rename = "limitQualityToDevice")] pub limit_quality_to_device: bool, + /// #638 fix 3 — the composed "Detected device limit" value, e.g. + /// `"192 kHz · Hi-Res+"`. EMPTY means no cap is active, and the row hides + /// on that rather than rendering a blank value. + #[serde(rename = "deviceCapSummary")] + pub device_cap_summary: String, + /// False = the probe fell back to the common rate set, so the row shows + /// its caveat. True while no cap is active, which keeps that caveat from + /// flashing before the first refresh lands. + #[serde(rename = "deviceCapDetected")] + pub device_cap_detected: bool, #[serde(rename = "exclusiveMode")] pub exclusive_mode: bool, #[serde(rename = "reserveDac")] @@ -1711,6 +1778,12 @@ pub async fn publish_snapshot() { // funnels through here, so they refresh with the settings and never poll. crate::output_labels::publish(&audio_settings); + // #638 fix 3 — read the cap cache, never probe here. `publish_snapshot` + // runs on every settings mutation and on every Settings open; the probe is + // a `pw-dump` subprocess and belongs only on the six explicit triggers + // (`refresh_device_cap`). This is two uncontended lock reads. + let (device_cap_summary, device_cap_detected) = qbz_app::device_cap::summary(); + let doc = tokio::task::spawn_blocking(move || { let backend_types = BackendManager::available_backends(); let current_backend = audio_settings.backend_type.unwrap_or_default(); @@ -1782,6 +1855,8 @@ pub async fn publish_snapshot() { .position(|v| *v == audio_settings.dsd_mode) .unwrap_or(0) as i32, limit_quality_to_device: audio_settings.limit_quality_to_device, + device_cap_summary, + device_cap_detected, exclusive_mode: audio_settings.exclusive_mode, reserve_dac: audio_settings.reserve_dac_while_running, dac_passthrough: audio_settings.dac_passthrough, @@ -2297,6 +2372,13 @@ pub async fn settings_bool(runtime: &Arc>, key: &str, match outcome { Ok(apply) => { let apply = if cascaded { Apply::Reinit } else { apply }; + // #638 fix 3, trigger 2 — the "Limit quality to device" toggle. + // AFTER the persist above (the probe re-reads the flag) and BEFORE + // `apply_audio`, which publishes the document on its own way out; + // see `refresh_device_cap` for why that ordering is load-bearing. + if key == "limit-quality-to-device" { + refresh_device_cap(runtime).await; + } apply_audio(runtime, apply); publish_snapshot().await; } @@ -2377,6 +2459,11 @@ pub async fn settings_select(runtime: &Arc>, key: &st // transition that renegotiates the stream mid-album (a rate change); // the format-match guard above should already refuse those. let _ = with_audio(|s| s.set_output_device(None)); + // #638 fix 3, trigger 3 — a backend switch resets the device to + // the system default, so the cap's subject changed even though the + // device SELECTION did not. D7 also makes the new backend the one + // that resolves that default. + refresh_device_cap(runtime).await; apply_audio(runtime, Apply::Reinit); } "device" => { @@ -2392,6 +2479,8 @@ pub async fn settings_select(runtime: &Arc>, key: &st log::error!("[qbz-qt] persist output device failed: {e}"); return; } + // #638 fix 3, trigger 4 — a different DAC has a different ceiling. + refresh_device_cap(runtime).await; apply_audio(runtime, Apply::Reinit); } "dsd-mode" => { @@ -2737,6 +2826,11 @@ pub async fn settings_reset(runtime: &Arc>) { // untouched"). This port used to force it back to `hires_plus`, so a // user on a metered connection who reset their AUDIO settings silently // got hi-res streaming again. + // + // #638 fix 3, trigger 5 — the reset turns "Limit quality to device" off, + // so this refresh is the one that CLEARS the cap (and, through the + // before/after comparison, drops the tier-keyed cache the old cap filled). + refresh_device_cap(runtime).await; apply_audio(runtime, Apply::Reinit); publish_snapshot().await; } @@ -2751,6 +2845,13 @@ pub async fn refresh_devices(runtime: &Arc>) { log::warn!("[qbz-qt] release device failed: {e}"); } invalidate_device_cache(); + // #638 fix 3, trigger 6 — Qt-only, and the easiest of the six to miss. + // This button exists precisely for hotplug: the hardware behind the + // selection can change without the SELECTION changing, which is the case + // a device-change trigger cannot see. It matters most under "System + // default". Placed after the re-enumeration so the probe reads the new + // device list, and before the publish so the row lands settled. + refresh_device_cap(runtime).await; publish_snapshot().await; }