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; }