feat(qt): the Albums funnel survives navigation and restarts
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
This commit is contained in:
4 files changed
+113
-3
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1180,6 +1180,15 @@ pub(crate) fn read_pref_f32(key: &str) -> Option<f32> {
|
||||
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<serde_json::Value> {
|
||||
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.
|
||||
|
||||
Reference in new issue
Block a user