fix(myqbz): per-collection view-prefs persistence (§18) + shuffle persists play_mode

Phase-2 Slice 12e (final fidelity touch-up).
- src/myqbz_view_prefs.rs: per-user JSON store (users/<id>/collection_view_prefs.json),
  keyed by collection id, persisting the five §18 fields (view_mode, sort_by, sort_dir,
  type_filter, source flags qobuz/plex/local). searchQuery + selectMode stay transient.
  Restore-on-open with a PREFS_HYDRATED gate (early setters can't clobber the load);
  persist on every toolbar change (sort/type/source/reset/view-mode); clear on delete.
  3 unit tests. 1:1 with Tauri's collection-view-prefs.{id} + prefsHydrated.
- myqbz_play::shuffle now persists play_mode='album_shuffle' (best-effort set_play_mode +
  reload so the overflow play-mode label flips) ONLY when not already album_shuffle --
  mirrors handleShuffle; normal Play never persists.

Backend crates untouched. cargo check -p qbz-slint green.

cc
This commit is contained in:
vicrodh committed 2026-06-09 09:08:05 -06:00
1 parent 4e0d1a005e
commit 838085a492
5 files changed
+348 -5

No files matched your search

+16 -2
View File
@@ -44,6 +44,7 @@ mod myqbz_edit;
mod myqbz_mix;
mod myqbz_play;
mod myqbz_prefs;
mod myqbz_view_prefs;
mod nav;
mod play_history;
mod strip_html;
@@ -136,6 +137,11 @@ async fn enter_shell(
// the sidebar row + Settings row reflect the persisted values on entry.
myqbz_prefs::init_for_user(session.user_id);
// Bind per-collection DETAIL view-prefs (toolbar viewMode/sort/filter) to
// this user (per-user collection_view_prefs.json). Restored on collection
// open, cleared on delete (spec 12 §18).
myqbz_view_prefs::init_for_user(session.user_id);
// Create the system tray from this user's persisted settings (gated by
// enable_tray). Reflects the chosen icon variant. On Linux the ksni
// service runs on its own thread; macOS/Windows are no-ops until the
@@ -2471,7 +2477,8 @@ fn wire_myqbz_detail(
let handle = tokio_rt.handle().clone();
window.global::<Act>().on_set_view_mode(move |mode| {
if let Some(w) = weak.upgrade() {
w.global::<MyQbzDetailState>().set_view_mode(mode.clone());
// Sets view-mode + persists the per-collection prefs (spec §18).
myqbz_detail::set_view_mode(&w, mode.as_str());
// Entering expanded mode: fetch every expandable item's tracks
// (spec §8 — tracks render directly under each row).
if mode == "expanded" {
@@ -2583,13 +2590,20 @@ fn wire_myqbz_detail(
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window.global::<Act>().on_shuffle(move || {
let Some(w) = weak.upgrade() else { return };
let id = w.global::<MyQbzDetailState>().get_id().to_string();
if id.is_empty() {
return;
}
myqbz_play::shuffle(runtime.clone(), weak.clone(), handle.clone(), id);
myqbz_play::shuffle(
runtime.clone(),
weak.clone(),
handle.clone(),
image_cache.clone(),
id,
);
});
}
+67 -1
View File
@@ -53,6 +53,38 @@ thread_local! {
/// that survives the client-side re-derive.
static INLINE_CACHE: std::cell::RefCell<std::collections::HashMap<String, Vec<TrackItem>>> =
std::cell::RefCell::new(std::collections::HashMap::new());
/// Per-collection view-prefs "hydrated" gate (mirrors Tauri's
/// `prefsHydrated`). `false` from `reset` until `apply` has restored the
/// stored prefs; while `false` every toolbar persist is suppressed so an
/// early setter can't clobber the about-to-be-restored prefs. UI thread.
static PREFS_HYDRATED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Persist the open collection's current toolbar prefs (spec 12 §18), gated on
/// the hydrated flag so a setter firing before `apply` restores the stored
/// prefs cannot clobber them. The persisted fields are the five §18 fields
/// (view-mode / sort / sort-dir / type-filter / source flags); search +
/// select-mode stay transient. UI thread.
pub fn persist_prefs(window: &AppWindow) {
if !PREFS_HYDRATED.with(|c| c.get()) {
return;
}
let state = window.global::<MyQbzDetailState>();
let id = state.get_id().to_string();
if id.is_empty() {
return;
}
let prefs = crate::myqbz_view_prefs::Prefs {
view_mode: state.get_view_mode().to_string(),
sort_by: state.get_sort().to_string(),
sort_dir: state.get_sort_dir().to_string(),
type_filter: state.get_type_filter().to_string(),
src_qobuz: state.get_src_qobuz(),
src_plex: state.get_src_plex(),
src_local: state.get_src_local(),
};
crate::myqbz_view_prefs::save(&id, &prefs);
}
/// Stable per-item key for the inline-tracks cache (`source|source_item_id`).
@@ -364,12 +396,14 @@ pub fn set_sort(window: &AppWindow, field: &str) {
state.set_sort(field.into());
state.set_sort_dir("asc".into());
}
persist_prefs(window);
refresh_view(window);
}
/// Single-select the type filter.
pub fn set_type_filter(window: &AppWindow, value: &str) {
window.global::<MyQbzDetailState>().set_type_filter(value.into());
persist_prefs(window);
refresh_view(window);
}
@@ -382,6 +416,7 @@ pub fn toggle_source_filter(window: &AppWindow, kind: &str) {
"local" => state.set_src_local(!state.get_src_local()),
_ => {}
}
persist_prefs(window);
refresh_view(window);
}
@@ -396,9 +431,19 @@ pub fn reset_filters(window: &AppWindow) {
state.set_src_local(false);
state.set_sort("position".into());
state.set_sort_dir("asc".into());
persist_prefs(window);
refresh_view(window);
}
/// Set the view-mode (list|grid|expanded) + persist it (spec 12 §18). The
/// expanded-mode inline-track fetch stays in `main.rs` (it needs the runtime +
/// handle); this only updates state + persists so the per-collection prefs
/// remember the chosen mode.
pub fn set_view_mode(window: &AppWindow, mode: &str) {
window.global::<MyQbzDetailState>().set_view_mode(mode.into());
persist_prefs(window);
}
/// Toggle multi-select edit mode. Leaving clears any selection.
pub fn toggle_select_mode(window: &AppWindow) {
let state = window.global::<MyQbzDetailState>();
@@ -665,6 +710,10 @@ pub fn reset(window: &AppWindow) {
// Drop the inline-tracks cache — a different collection's tracks must not
// leak into the freshly-opened one.
INLINE_CACHE.with(|cell| cell.borrow_mut().clear());
// Close the persist gate until `apply` restores this collection's prefs —
// any toolbar setter that fires meanwhile must NOT overwrite stored prefs
// with the in-flight defaults (mirrors Tauri's prefsHydrated).
PREFS_HYDRATED.with(|c| c.set(false));
let state = window.global::<MyQbzDetailState>();
state.set_loading(true);
state.set_found(true);
@@ -678,7 +727,9 @@ pub fn reset(window: &AppWindow) {
state.set_cover_count(0);
state.set_selected_count(0);
state.set_select_mode(false);
// Toolbar is session-scoped per the slice spec: reset to defaults on open.
// Toolbar -> defaults during load; `apply` then restores this collection's
// persisted view-prefs (spec 12 §18) over these. Search + select-mode stay
// transient (never persisted) so they always start fresh.
state.set_search("".into());
state.set_sort("position".into());
state.set_sort_dir("asc".into());
@@ -725,6 +776,21 @@ pub fn apply(window: &AppWindow, c: MixtapeCollection) {
apply_hero_mosaic(&state, &c);
// Restore this collection's persisted view-prefs over the reset defaults
// (spec 12 §18). `load` returns the §18 defaults when nothing is stored, so
// a never-opened collection lands on list/position/asc/all/empty exactly as
// before. Open the persist gate AFTER applying so the restore itself isn't
// re-persisted (and so subsequent setter-driven persists are live).
let prefs = crate::myqbz_view_prefs::load(c.id.as_str());
state.set_view_mode(prefs.view_mode.into());
state.set_sort(prefs.sort_by.into());
state.set_sort_dir(prefs.sort_dir.into());
state.set_type_filter(prefs.type_filter.into());
state.set_src_qobuz(prefs.src_qobuz);
state.set_src_plex(prefs.src_plex);
state.set_src_local(prefs.src_local);
PREFS_HYDRATED.with(|cell| cell.set(true));
FULL_ITEMS.with(|cell| *cell.borrow_mut() = c.items);
refresh_view(window);
state.set_loading(false);
+3
View File
@@ -224,6 +224,9 @@ pub fn delete(
Ok(()) => {
es.set_open(false);
es.set_mode("".into());
// Clean up the deleted collection's persisted view-prefs key
// so it doesn't orphan in the store (spec 12 §18 / §11.3).
crate::myqbz_view_prefs::remove(&id);
// Navigate back: re-applies the previous grid entry, which
// re-lists collections from the DB (the deleted one is gone).
w.global::<NavState>().invoke_request_back();
+59 -2
View File
@@ -277,20 +277,77 @@ pub fn play_all(
/// Hero **Shuffle** (`on_shuffle`): resolve with forced `AlbumShuffle`
/// ordering, then replace-play (same queue-source stamp + touch_play as Play —
/// it is a replace).
/// it is a replace). As a SIDE EFFECT (1:1 with Tauri `handleShuffle`, spec 12
/// §20), Shuffle also persists `play_mode='album_shuffle'` so the collection
/// remembers it; if THIS is the open collection, the detail is reloaded so the
/// overflow play-mode-toggle label flips to the other mode ("Play in order").
/// Persist runs ONLY on Shuffle — a normal hero Play never changes play_mode.
pub fn shuffle(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
image_cache: crate::artwork::ImageCache,
collection_id: String,
) {
handle.spawn(async move {
handle.clone().spawn(async move {
let Some(collection) = load_collection(&collection_id).await else {
crate::toast::error_weak(&weak, "Couldn't load this collection");
return;
};
let tracks = resolve_collection(&runtime, &collection, true).await;
play_all_tracks(&runtime, &weak, &collection_id, tracks).await;
// Persist play_mode='album_shuffle' as a side effect (Tauri's
// `setPlayMode` in handleShuffle). Best-effort: only persist when the
// collection wasn't already album_shuffle, and reload the open detail so
// the overflow toggle label reflects the new mode.
if collection.play_mode != CollectionPlayMode::AlbumShuffle {
persist_album_shuffle(&weak, &handle, &image_cache, &collection_id);
}
});
}
/// Best-effort `repo::set_play_mode(id, AlbumShuffle)` (spec 12 §20 shuffle
/// side effect), then reload the detail IF `id` is the currently-open
/// collection (so the hero overflow play-mode-toggle label flips). DB work runs
/// on a blocking worker (no `&Connection` crosses an `.await`); the reload hops
/// back to the event loop. Failures are logged, not surfaced — the shuffle
/// already played, so a failed persist must not toast an error.
fn persist_album_shuffle(
weak: &slint::Weak<AppWindow>,
handle: &tokio::runtime::Handle,
image_cache: &crate::artwork::ImageCache,
collection_id: &str,
) {
let write_id = collection_id.to_string();
let persisted = crate::library_db::with_db(|db| {
Ok(db.with_connection(|conn| {
qbz_mixtape::repo::set_play_mode(conn, &write_id, CollectionPlayMode::AlbumShuffle)
}))
});
match persisted {
Some(Ok(())) => {}
Some(Err(e)) => {
log::warn!("[qbz-slint] myqbz_play: persist album_shuffle({collection_id}) failed: {e}");
return;
}
None => {
log::warn!("[qbz-slint] myqbz_play: persist album_shuffle: library db unavailable");
return;
}
}
// Reload the detail only when this collection is the one on screen, so the
// overflow toggle label flips to the OTHER mode. Off-screen collections need
// no reload — their next open restores from the DB.
let handle = handle.clone();
let image_cache = image_cache.clone();
let id = collection_id.to_string();
let _ = weak.upgrade_in_event_loop(move |w| {
use slint::ComponentHandle;
if w.global::<crate::MyQbzDetailState>().get_id().as_str() == id {
crate::myqbz_detail::navigate(w.as_weak(), handle, image_cache, id);
}
});
}
+203
View File
@@ -0,0 +1,203 @@
//! Per-collection view-prefs persistence for the My QBZ DETAIL view
//! (spec 12 §18).
//!
//! Mirrors the Tauri `userStorage` key `collection-view-prefs.{collectionId}`:
//! each collection remembers its own toolbar state across opens. The persisted
//! shape (five fields) is exactly the Tauri set:
//!
//! { viewMode, sortBy, sortDir, typeFilter, sourceFilter:[SourceKind] }
//!
//! `searchQuery` and `selectMode` are intentionally TRANSIENT — never persisted
//! (same as Tauri).
//!
//! Storage is per-user JSON (so different Qobuz accounts keep independent
//! prefs), scoped the same way as `myqbz_prefs.rs`:
//!
//! <data_dir>/qbz/users/<user_id>/collection_view_prefs.json
//!
//! Rather than one file per collection, the whole map lives in one tiny JSON
//! (`{ "<collection-id>": { … } }`) — read-modify-write on every set. The store
//! is keyed by collection id, which is the §18 contract.
//!
//! Lifecycle (driven from `myqbz_detail` + `myqbz_edit`):
//! - **restore on open**: `load(id)` → apply each field, else defaults.
//! - **persist on change**: `save(id, prefs)` after a toolbar setter mutates a
//! persisted field — gated behind a `hydrated` flag so the restore is not
//! clobbered by an early persist (mirrors Tauri's `prefsHydrated`).
//! - **clear on delete**: `remove(id)` drops the orphaned key.
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{LazyLock, Mutex};
use serde::{Deserialize, Serialize};
/// The active user id, set by `init_for_user`. `None` before login (the store
/// degrades to defaults — there is no pre-login detail view).
static USER_ID: LazyLock<Mutex<Option<u64>>> = LazyLock::new(|| Mutex::new(None));
/// The five persisted view-pref fields for one collection (spec 12 §18). Source
/// filter is the three independent flags the Slint toolbar uses (Slint has no
/// Set); together they round-trip the Tauri `sourceFilter:[SourceKind]` array.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Prefs {
#[serde(default = "d_list")]
pub view_mode: String,
#[serde(default = "d_position")]
pub sort_by: String,
#[serde(default = "d_asc")]
pub sort_dir: String,
#[serde(default = "d_all")]
pub type_filter: String,
#[serde(default)]
pub src_qobuz: bool,
#[serde(default)]
pub src_plex: bool,
#[serde(default)]
pub src_local: bool,
}
fn d_list() -> String {
"list".to_string()
}
fn d_position() -> String {
"position".to_string()
}
fn d_asc() -> String {
"asc".to_string()
}
fn d_all() -> String {
"all".to_string()
}
impl Default for Prefs {
/// The §18 defaults: list / position / asc / all / empty source set.
fn default() -> Self {
Self {
view_mode: d_list(),
sort_by: d_position(),
sort_dir: d_asc(),
type_filter: d_all(),
src_qobuz: false,
src_plex: false,
src_local: false,
}
}
}
/// `<data_dir>/qbz/users/<user_id>/collection_view_prefs.json` for the active
/// user. `None` before login or when the data dir is unavailable.
fn store_path() -> Option<PathBuf> {
let user_id = (*USER_ID.lock().ok()?)?;
Some(
dirs::data_dir()?
.join("qbz")
.join("users")
.join(user_id.to_string())
.join("collection_view_prefs.json"),
)
}
/// Read the whole `{ collection-id -> Prefs }` map. A missing / unreadable /
/// unparseable file degrades to an empty map.
fn read_all() -> HashMap<String, Prefs> {
let Some(path) = store_path() else {
return HashMap::new();
};
match std::fs::read(&path) {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
Err(_) => HashMap::new(),
}
}
/// Persist the whole map (best-effort — failures are logged).
fn write_all(map: &HashMap<String, Prefs>) {
let Some(path) = store_path() else {
log::warn!("[qbz-slint] collection view-prefs: no active user, not saving");
return;
};
if let Some(parent) = path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
log::error!("[qbz-slint] collection view-prefs: create dir failed: {e}");
return;
}
}
match serde_json::to_vec_pretty(map) {
Ok(json) => {
if let Err(e) = std::fs::write(&path, json) {
log::error!("[qbz-slint] collection view-prefs: write failed: {e}");
}
}
Err(e) => log::error!("[qbz-slint] collection view-prefs: serialize failed: {e}"),
}
}
/// Bind the store to `user_id` on shell entry. Subsequent reads/writes target
/// that user's JSON file.
pub fn init_for_user(user_id: u64) {
if let Ok(mut guard) = USER_ID.lock() {
*guard = Some(user_id);
}
}
/// Load the stored prefs for `id`, or the §18 defaults when none are stored.
pub fn load(id: &str) -> Prefs {
read_all().remove(id).unwrap_or_default()
}
/// Persist the prefs for `id` (read-modify-write the whole map). Writing the
/// default set is harmless (re-open restores the same defaults), so the caller
/// need not special-case it.
pub fn save(id: &str, prefs: &Prefs) {
if id.is_empty() {
return;
}
let mut map = read_all();
map.insert(id.to_string(), prefs.clone());
write_all(&map);
}
/// Remove the stored prefs for `id` (cleanup on collection delete, spec §18 /
/// §11.3). No-op when the key is absent.
pub fn remove(id: &str) {
if id.is_empty() {
return;
}
let mut map = read_all();
if map.remove(id).is_some() {
write_all(&map);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_match_spec_18() {
let p = Prefs::default();
assert_eq!(p.view_mode, "list");
assert_eq!(p.sort_by, "position");
assert_eq!(p.sort_dir, "asc");
assert_eq!(p.type_filter, "all");
assert!(!p.src_qobuz && !p.src_plex && !p.src_local);
}
#[test]
fn legacy_json_without_fields_deserializes_to_defaults() {
let p: Prefs = serde_json::from_str("{}").expect("empty object deserializes");
assert_eq!(p, Prefs::default());
}
#[test]
fn partial_json_keeps_present_fields() {
let p: Prefs = serde_json::from_str(r#"{"view_mode":"grid","src_plex":true}"#)
.expect("partial object deserializes");
assert_eq!(p.view_mode, "grid");
assert!(p.src_plex);
// Absent fields fall back to defaults.
assert_eq!(p.sort_by, "position");
assert_eq!(p.type_filter, "all");
assert!(!p.src_qobuz);
}
}