From 021b5609e1fd24eba7e11b591e7f04cd125f4170 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:38:28 -0600 Subject: [PATCH] feat(qt): the Albums funnel survives navigation and restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The filter worked but forgot itself twice over: leaving Local Library reset it, and so did closing the app. The first one is not a bug in the view, it is what this port does — navigating away DESTROYS the view (project_qt_nav_eager_tabs), so a `property var filter: ({})` cannot outlive a trip to Discover. Both follow from one move: the funnel lives on the BRIDGE, mirrored into the shared ui_prefs.json. The bridge outlives the view, and the file outlives the process, so one property buys both kinds of persistence. `setFilter()` is the only writer, so a mutator cannot change the funnel and forget to persist it, and the view re-seeds from a Connections on the qproperty rather than only at construction — a republish has to reach a view that is already open. The saved funnel is pruned where the source GATES are published, not somewhere else, so the two can never disagree: a restored `jellyfin` tick with no Jellyfin configured would match zero rows while the chip that clears it is hidden by the same gate — an empty grid with a badge counting a filter the user cannot see. Only the media-server keys are pruned; local/offline/plex are always meaningful words. Verified: ticked Jellyfin, went to Discover and back (badge and grid held), restarted the process (held), pressed Clear (ui_prefs went to {}). The prune is reasoned, not exercised — both servers are configured on this box, so no gate is off to test it against. cc --- crates/qbz-qt/qml/views/LocalLibraryView.qml | 35 ++++++++++++-- crates/qbz-qt/src/local_bridge.rs | 24 ++++++++++ crates/qbz-qt/src/local_bridge_ops.rs | 48 ++++++++++++++++++++ crates/qbz-qt/src/settings_qt.rs | 9 ++++ 4 files changed, 113 insertions(+), 3 deletions(-) diff --git a/crates/qbz-qt/qml/views/LocalLibraryView.qml b/crates/qbz-qt/qml/views/LocalLibraryView.qml index 48045aab5..6afbd4d5a 100644 --- a/crates/qbz-qt/qml/views/LocalLibraryView.qml +++ b/crates/qbz-qt/qml/views/LocalLibraryView.qml @@ -102,19 +102,48 @@ Rectangle { // Albums quality/format/source filter (LibAlbumFilterState). property bool filterOpen: false - property var filter: ({}) + // SEEDED FROM THE BRIDGE, not defaulted to `{}`. This view is DESTROYED on + // every navigation away (see the bridge's `albums_filter` doc), so a + // view-local default meant the funnel reset the moment the user visited + // Discover — and never survived a restart at all. `QbzLocal.albumsFilter` + // outlives the view and mirrors `ui_prefs.json`. + property var filter: root.parseFilter(QbzLocal.albumsFilter) + function parseFilter(json) { + if (!json || json === "") return ({}) + try { return JSON.parse(json) || ({}) } catch (e) { return ({}) } + } + // A LATER republish still wins: the gates and the saved funnel are + // published together when a media server is connected or removed, and the + // pruning that happens there (a tick whose chip is now hidden) has to + // reach a view that is already open. Guarded on a real difference so + // writing the property back does not bounce. + Connections { + target: QbzLocal + function onAlbumsFilterChanged() { + var next = root.parseFilter(QbzLocal.albumsFilter) + if (JSON.stringify(next) !== JSON.stringify(root.filter)) + root.filter = next + } + } readonly property int filterCount: { var n = 0 for (var k in filter) if (filter[k]) n++ return n } + /// The ONE writer. Both mutators funnel through it, so neither can change + /// the funnel and forget to persist it. + function setFilter(f) { + filter = f + QbzLocal.setAlbumsFilterJson( + Object.keys(f).length === 0 ? "" : JSON.stringify(f)) + } function toggleFilter(key) { var f = Object.assign({}, filter) f[key] = !f[key] if (!f[key]) delete f[key] - filter = f + setFilter(f) } - function clearFilter() { filter = ({}) } + function clearFilter() { setFilter({}) } /// The funnel's state as the applied-filters tooltip wants it: three groups /// in the popup's own order, each holding the labels of the keys that are diff --git a/crates/qbz-qt/src/local_bridge.rs b/crates/qbz-qt/src/local_bridge.rs index 01bc84685..c68e7deee 100644 --- a/crates/qbz-qt/src/local_bridge.rs +++ b/crates/qbz-qt/src/local_bridge.rs @@ -135,6 +135,17 @@ pub mod qbz_local { /// so the chip only exists when its server does. #[qproperty(bool, media_has_jellyfin)] #[qproperty(bool, media_has_subsonic)] + /// The Albums funnel, as a JSON object of the ticked keys + /// (`{"hires":true,"jellyfin":true}`). Empty string = no filter. + /// + /// It lives on the BRIDGE, not on `LocalLibraryView`, for the reason + /// the owner reported: navigating away DESTROYS the view (see + /// `project_qt_nav_eager_tabs` — Qt, Slint and Tauri all do), so a + /// view-local `property var filter` cannot survive a round trip to + /// Discover, let alone a restart. The bridge outlives the view and + /// mirrors `ui_prefs.json`, so one property buys BOTH kinds of + /// persistence the owner asked for. + #[qproperty(QString, albums_filter)] #[qproperty(bool, media_syncing)] #[qproperty(QString, media_sync_progress)] /// Tracks written by the last sync; -1 = never synced this session. @@ -323,6 +334,9 @@ pub mod qbz_local { /// user fixing a typo and a user thinking the feature is broken. #[qinvokable] fn media_test(self: Pin<&mut QbzLocal>, server: QString, url: QString); + /// Persist the Albums funnel. See the `albums_filter` qproperty. + #[qinvokable] + fn set_albums_filter_json(self: Pin<&mut QbzLocal>, json: QString); /// Authenticate and persist. Runs a first sweep on success. #[qinvokable] fn media_connect( @@ -449,6 +463,7 @@ pub struct QbzLocalRust { plex_syncing: bool, media_has_jellyfin: bool, media_has_subsonic: bool, + albums_filter: QString, media_syncing: bool, media_sync_progress: QString, plex_last_sync_tracks: i32, @@ -496,6 +511,7 @@ impl Default for QbzLocalRust { plex_syncing: false, media_has_jellyfin: false, media_has_subsonic: false, + albums_filter: QString::default(), media_syncing: false, media_sync_progress: QString::default(), plex_last_sync_tracks: -1, @@ -738,6 +754,14 @@ impl qbz_local::QbzLocal { // ── Media servers (Jellyfin / Subsonic) ───────────────────────────── + pub fn set_albums_filter_json(mut self: Pin<&mut Self>, json: QString) { + let text = json.to_string(); + // The qproperty is what the view re-seeds from on its next mount, so + // it is written on EVERY toggle, not only on close. + self.as_mut().set_albums_filter(QString::from(text.as_str())); + crate::local_bridge_ops::save_albums_filter(&text); + } + pub fn media_test(self: Pin<&mut Self>, server: QString, url: QString) { let (Some(kind), url) = (media_kind(&server), url.to_string()) else { return; diff --git a/crates/qbz-qt/src/local_bridge_ops.rs b/crates/qbz-qt/src/local_bridge_ops.rs index a81deb073..c3a59a908 100644 --- a/crates/qbz-qt/src/local_bridge_ops.rs +++ b/crates/qbz-qt/src/local_bridge_ops.rs @@ -87,12 +87,60 @@ pub(crate) fn publish_media_gates() { let words = crate::media_servers_qt::configured_words(); let jf = words.contains(&"jellyfin"); let sub = words.contains(&"subsonic"); + // The saved funnel is seeded HERE, with the gates, so the two can never + // disagree: a filter restored from disk must not tick a source whose chip + // the popup is about to hide. That combination is invisible — the grid + // comes back empty and the funnel badge counts a filter the user cannot + // see, let alone clear. + let filter = pruned_albums_filter(jf, sub); ui(move |mut b| { b.as_mut().set_media_has_jellyfin(jf); b.as_mut().set_media_has_subsonic(sub); + b.as_mut().set_albums_filter(cxx_qt_lib::QString::from(filter.as_str())); }); } +/// Key the Albums funnel is stored under in the shared `ui_prefs.json`. +const ALBUMS_FILTER_KEY: &str = "local_albums_filter"; + +/// Persist the funnel. Called from the bridge on every toggle. +pub(crate) fn save_albums_filter(json: &str) { + let value = if json.trim().is_empty() { + serde_json::Value::Object(serde_json::Map::new()) + } else { + serde_json::from_str(json).unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new())) + }; + crate::settings_qt::save_pref(ALBUMS_FILTER_KEY, value); +} + +/// The stored funnel with any source key its server cannot serve removed. +/// +/// Only the MEDIA-SERVER keys are pruned. `local` / `offline` / `plex` are +/// always meaningful words (an empty result there is an honest answer about +/// the library), but a `jellyfin` tick with no Jellyfin configured can only +/// ever match zero rows, and the chip that would let the user untick it is +/// hidden by the same gate. +fn pruned_albums_filter(has_jellyfin: bool, has_subsonic: bool) -> String { + let Some(serde_json::Value::Object(mut map)) = + crate::settings_qt::read_pref(ALBUMS_FILTER_KEY) + else { + return String::new(); + }; + if !has_jellyfin { + map.remove("jellyfin"); + } + if !has_subsonic { + map.remove("subsonic"); + } + // Only the TRUE keys travel: the view stores a key by deleting it when it + // goes false, but a hand-edited prefs file need not honour that. + map.retain(|_, v| v.as_bool() == Some(true)); + if map.is_empty() { + return String::new(); + } + serde_json::to_string(&map).unwrap_or_default() +} + pub(crate) fn reload_browse() { publish_media_gates(); // The cortinilla's instant-paint cache embeds LOCAL rows and their artwork diff --git a/crates/qbz-qt/src/settings_qt.rs b/crates/qbz-qt/src/settings_qt.rs index 71b3bf1f4..ea79cbd84 100644 --- a/crates/qbz-qt/src/settings_qt.rs +++ b/crates/qbz-qt/src/settings_qt.rs @@ -1180,6 +1180,15 @@ pub(crate) fn read_pref_f32(key: &str) -> Option { doc.get(key)?.as_f64().map(|v| v as f32) } +/// One key of ui_prefs.json, as raw JSON. `None` when the file, the key or +/// the parse is missing — every caller treats that as "not set". +pub(crate) fn read_pref(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).cloned() +} + /// 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.