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:
5 files changed
+348
-5
No files matched your search
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user