From b8b493bd59712935c12e0867a3497cc558a7709a Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:46:19 -0600 Subject: [PATCH] fix(qt): persist the player volume, so a launch stops starting at 100% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PARITY-DEBT #3 — the cheapest AUDIBLE row in the inventory. Volume was never written and never restored: `setVolume` reached the engine and stopped there. Set it to 15%, quit, relaunch, and playback came back at full scale. On an exclusive-mode DAC at night that is not a cosmetic default. 1:1 with the Slint: persist on drag-END only (`NowPlayingState.persist-volume`, PlayerBar.slint:864-866 -> qbz/src/main.rs:14413-14417), restore at shell entry (qbz/src/main.rs:220-227), and let the poll loop mirror the engine back onto the slider so nothing else has to publish it. Drag-end matters here rather than being a nicety: ui_prefs.json is co-owned with the running Slint app and both processes do a whole-document read-modify-write, so a write per drag tick is a write per pixel into a shared file. `QbzSlider` only had `changed`, which fires on every tick, so it gains a `released` signal carrying the settled value — both now-playing bars use it. Two supporting bits: `QbzPlayer.persistVolume` (the engine setter stays per-tick, only the persist is debounced by the signal), and `settings_qt::read_pref_f32` — the crate had no public ui_prefs reader, theme_qt kept a private one, and a third private copy is exactly what the write discipline at the top of settings_qt exists to prevent. Verified the key name against the live file: `volume` is already present in the shared ui_prefs.json alongside 60 other keys, so this reads and writes what the Slint build reads and writes. cc --- crates/qbz-qt/qml/controls/QbzSlider.qml | 12 +++++++++++- crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml | 2 ++ crates/qbz-qt/qml/shell/PlayerBar.qml | 2 ++ crates/qbz-qt/src/main.rs | 9 +++++++++ crates/qbz-qt/src/player_bridge.rs | 10 ++++++++++ crates/qbz-qt/src/settings_qt.rs | 11 +++++++++++ 6 files changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/qbz-qt/qml/controls/QbzSlider.qml b/crates/qbz-qt/qml/controls/QbzSlider.qml index 0f37f3b2c..1a9018b3a 100644 --- a/crates/qbz-qt/qml/controls/QbzSlider.qml +++ b/crates/qbz-qt/qml/controls/QbzSlider.qml @@ -12,6 +12,13 @@ Item { property int maximum: 10 property int value: 0 signal changed(int newValue) + /// Fired ONCE on drag-end with the settled value. `changed` fires on every + /// drag tick, so anything that PERSISTS must listen here instead — the + /// volume slider writes ui_prefs.json, a document shared with the running + /// Slint app through a whole-file read-modify-write, and a write per pixel + /// of drag is exactly what `PlayerBar.slint:864-866` avoids ("Persist only + /// the final value on drag-end"). + signal released(int newValue) QbzTheme { id: theme } @@ -73,6 +80,9 @@ Item { parent.commit(parent.dragFraction) } } - onReleased: parent.dragging = false + onReleased: { + parent.dragging = false + parent.released(parent.value) + } } } diff --git a/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml b/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml index 076b5c7fe..aa1f095d2 100644 --- a/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml +++ b/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml @@ -402,6 +402,8 @@ Rectangle { maximum: 1000 value: Math.round(QbzPlayer.npVolume * 1000) onChanged: function (v) { QbzPlayer.setVolume(v / 1000.0) } + // Persist only the settled value, like the full bar. + onReleased: function (v) { QbzPlayer.persistVolume(v / 1000.0) } } Item { width: 4; height: 1 } diff --git a/crates/qbz-qt/qml/shell/PlayerBar.qml b/crates/qbz-qt/qml/shell/PlayerBar.qml index addc978cc..209c6e1b8 100644 --- a/crates/qbz-qt/qml/shell/PlayerBar.qml +++ b/crates/qbz-qt/qml/shell/PlayerBar.qml @@ -433,6 +433,8 @@ Rectangle { maximum: 1000 value: Math.round(QbzPlayer.npVolume * 1000) onChanged: function (v) { QbzPlayer.setVolume(v / 1000.0) } + // Persist only the settled value (PlayerBar.slint:864-866). + onReleased: function (v) { QbzPlayer.persistVolume(v / 1000.0) } } QbzIconButton { visible: root.showVolumeSteppers diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index 5bd52befd..52240567a 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -267,6 +267,15 @@ fn enter_shell(session: auth_qt::SessionInfo) { // first interaction wrote the WRONG value back. Slint has no equivalent // hole because `SettingsState` is a global seeded at startup. publish_settings(); + + // Restore the persisted player volume so audio starts at the SAVED level + // (1:1 with qbz/src/main.rs:220-227). Without it every launch started at + // 100% — on an exclusive-mode DAC that is not a cosmetic default. The poll + // loop mirrors it onto the bar's slider from the engine, so nothing else + // has to publish it. + let restored = crate::settings_qt::read_pref_f32("volume").unwrap_or(1.0).clamp(0.0, 1.0); + let rt = app(); + spawn(async move { playback_qt::set_volume(&rt, restored).await }); } /// Login screen primary button / recovery banner: the system-browser OAuth diff --git a/crates/qbz-qt/src/player_bridge.rs b/crates/qbz-qt/src/player_bridge.rs index f6b297b80..55b469d80 100644 --- a/crates/qbz-qt/src/player_bridge.rs +++ b/crates/qbz-qt/src/player_bridge.rs @@ -124,6 +124,12 @@ pub mod qbz_player { fn seek(self: Pin<&mut QbzPlayer>, frac: f32); #[qinvokable] fn set_volume(self: Pin<&mut QbzPlayer>, volume: f32); + /// Persist the SETTLED volume (drag-end only — see QbzSlider.released). + /// `set_volume` drives the engine on every drag tick; this writes the + /// shared ui_prefs.json once, 1:1 with NowPlayingState.persist-volume + /// (qbz/src/main.rs:14413-14417). + #[qinvokable] + fn persist_volume(self: Pin<&mut QbzPlayer>, fraction: f32); #[qinvokable] fn toggle_mute(self: Pin<&mut QbzPlayer>); #[qinvokable] @@ -313,6 +319,10 @@ impl qbz_player::QbzPlayer { crate::transport_seek(frac); } + pub fn persist_volume(self: Pin<&mut Self>, fraction: f32) { + crate::settings_qt::save_pref("volume", serde_json::json!(fraction.clamp(0.0, 1.0))); + } + pub fn set_volume(self: Pin<&mut Self>, volume: f32) { crate::transport_set_volume(volume); } diff --git a/crates/qbz-qt/src/settings_qt.rs b/crates/qbz-qt/src/settings_qt.rs index 240c8d957..c13c0d2d4 100644 --- a/crates/qbz-qt/src/settings_qt.rs +++ b/crates/qbz-qt/src/settings_qt.rs @@ -871,6 +871,17 @@ pub fn pref_str(key: &str, default: &str) -> String { /// Additive single-key patch of ui_prefs.json — THE writer every other pref /// setter in this file funnels through, so they all inherit `update_prefs`' /// atomic rename and its refusal to rebuild an unparsable document. +/// Read one ui_prefs key as an f32. The crate had no public reader — theme_qt +/// kept a private one — so the volume restore would otherwise have grown a +/// third private copy of the same read (the exact duplication the write +/// discipline above exists to prevent). +pub(crate) fn read_pref_f32(key: &str) -> Option { + let path = prefs_path()?; + let text = std::fs::read_to_string(path).ok()?; + let doc: serde_json::Value = serde_json::from_str(&text).ok()?; + doc.get(key)?.as_f64().map(|v| v as f32) +} + pub fn save_pref(key: &str, value: serde_json::Value) { update_prefs(|doc| { doc.insert(key.to_string(), value);