From 23129cb98e36e04096c64a173b58a4dc0d643d8f Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 10 Jun 2026 02:10:33 -0600 Subject: [PATCH] feat(slint): local playlist reorder and favorite/hide organization Reorder: repo gains reorder(from, to) with remove-then-insert semantics and contiguity preservation (5 tests); the local detail wires the existing per-row chevron path (shown under Default sort - the repo order IS the editable order; the Custom sort entry hides for locals since the custom- order sidecar is u64-keyed) with optimistic model swap and off-thread persistence. Organization: additive favorite/hidden columns on local_playlists (pragma-guarded migration + 2 tests incl. pre-migration shape), manager cards render real heart/eye state and locals participate in the visible/hidden toolbar filter, hidden locals drop from the sidebar while staying reachable through the manager's hidden filter. Folder membership for locals stays out of scope (folder tables are Qobuz-keyed). main.rs wiring for these actions rides the next commit (shared file with the in-flight favorites-rail batch). cc --- crates/qbz-library/src/local_playlists.rs | 242 +++++++++++++++++- crates/qbz-slint/src/local_playlist.rs | 111 ++++++++ crates/qbz-slint/src/playlist.rs | 21 ++ crates/qbz-slint/src/playlist_manager.rs | 63 ++++- crates/qbz-slint/src/sidebar.rs | 4 + .../qbz-slint/ui/playlist/PlaylistView.slint | 13 +- 6 files changed, 433 insertions(+), 21 deletions(-) diff --git a/crates/qbz-library/src/local_playlists.rs b/crates/qbz-library/src/local_playlists.rs index a40e45d63..539fcd9bb 100644 --- a/crates/qbz-library/src/local_playlists.rs +++ b/crates/qbz-library/src/local_playlists.rs @@ -56,6 +56,13 @@ pub struct LocalPlaylist { /// D8: never offered for upload, never reaches any Qobuz call or /// QConnect queue push. pub offline_only: bool, + /// B3: manager organization flags — the local twin of + /// `playlist_settings.is_favorite` / `.hidden` (those tables are + /// keyed by the Qobuz u64 id, unrepresentable for `local:` ids). + pub favorite: bool, + /// B3: hidden playlists drop from the sidebar and group under the + /// manager's "hidden" filter. + pub hidden: bool, pub custom_artwork_path: Option, pub created_at: i64, pub updated_at: i64, @@ -103,6 +110,8 @@ pub fn init_schema(conn: &Connection) -> Result<()> { name TEXT NOT NULL, description TEXT, offline_only INTEGER NOT NULL DEFAULT 0, + favorite INTEGER NOT NULL DEFAULT 0, + hidden INTEGER NOT NULL DEFAULT 0, custom_artwork_path TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL @@ -121,7 +130,24 @@ pub fn init_schema(conn: &Connection) -> Result<()> { CREATE INDEX IF NOT EXISTS idx_local_playlist_tracks_playlist ON local_playlist_tracks(playlist_id, position); "#, - ) + )?; + // Additive migration (B3): DBs created before the favorite/hidden + // columns existed. Pragma-guarded ALTER, the database.rs idiom. + let has_favorite: bool = conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('local_playlists') WHERE name = 'favorite'", + [], + |r| r.get::<_, i32>(0), + ) + .map(|count| count > 0) + .unwrap_or(false); + if !has_favorite { + conn.execute_batch( + "ALTER TABLE local_playlists ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0; + ALTER TABLE local_playlists ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0;", + )?; + } + Ok(()) } // ──────────────────────────── Playlist CRUD ──────────────────────────── @@ -167,6 +193,26 @@ pub fn set_offline_only(conn: &Connection, id: &str, offline_only: bool) -> Resu Ok(()) } +/// B3: flip the manager's favorite flag (local twin of +/// `playlist_settings.is_favorite`). +pub fn set_favorite(conn: &Connection, id: &str, favorite: bool) -> Result<()> { + conn.execute( + "UPDATE local_playlists SET favorite = ?1, updated_at = ?2 WHERE id = ?3", + params![favorite as i64, now_ms(), id], + )?; + Ok(()) +} + +/// B3: flip the manager's hidden flag (local twin of +/// `playlist_settings.hidden`). Hidden playlists drop from the sidebar. +pub fn set_hidden(conn: &Connection, id: &str, hidden: bool) -> Result<()> { + conn.execute( + "UPDATE local_playlists SET hidden = ?1, updated_at = ?2 WHERE id = ?3", + params![hidden as i64, now_ms(), id], + )?; + Ok(()) +} + pub fn set_custom_artwork(conn: &Connection, id: &str, path: Option<&str>) -> Result<()> { conn.execute( "UPDATE local_playlists SET custom_artwork_path = ?1, updated_at = ?2 WHERE id = ?3", @@ -193,6 +239,8 @@ fn row_to_playlist(r: &rusqlite::Row) -> Result { name: r.get("name")?, description: r.get("description")?, offline_only: r.get::<_, i64>("offline_only")? != 0, + favorite: r.get::<_, i64>("favorite")? != 0, + hidden: r.get::<_, i64>("hidden")? != 0, custom_artwork_path: r.get("custom_artwork_path")?, created_at: r.get("created_at")?, updated_at: r.get("updated_at")?, @@ -227,8 +275,8 @@ fn hydrate_counts(conn: &Connection, p: &mut LocalPlaylist) -> Result<()> { /// All local playlists (counts hydrated), newest first. pub fn list(conn: &Connection) -> Result> { let mut stmt = conn.prepare( - "SELECT id, name, description, offline_only, custom_artwork_path, - created_at, updated_at + "SELECT id, name, description, offline_only, favorite, hidden, + custom_artwork_path, created_at, updated_at FROM local_playlists ORDER BY created_at DESC", )?; @@ -246,8 +294,8 @@ pub fn list(conn: &Connection) -> Result> { pub fn get(conn: &Connection, id: &str) -> Result> { let maybe = conn .query_row( - "SELECT id, name, description, offline_only, custom_artwork_path, - created_at, updated_at + "SELECT id, name, description, offline_only, favorite, hidden, + custom_artwork_path, created_at, updated_at FROM local_playlists WHERE id = ?1", params![id], row_to_playlist, @@ -349,6 +397,55 @@ pub fn add_tracks( Ok(inserted) } +/// Move the row at `from` to `to` (both repo positions), shifting the rows +/// in between by one — remove-then-insert list semantics, so the moved row +/// lands at exactly position `to` (B2: the local detail's custom reorder). +/// `from == to`, or either position not naming an existing row, is a no-op. +pub fn reorder(conn: &Connection, playlist_id: &str, from: i32, to: i32) -> Result<()> { + if from == to { + return Ok(()); + } + // Both endpoints must name existing rows, or the shift below would + // corrupt the order (and -1 is the in-flight parking slot). + let mut exists_stmt = conn.prepare( + "SELECT 1 FROM local_playlist_tracks + WHERE playlist_id = ?1 AND position = ?2 LIMIT 1", + )?; + if !exists_stmt.exists(params![playlist_id, from])? + || !exists_stmt.exists(params![playlist_id, to])? + { + return Ok(()); + } + conn.execute( + "UPDATE local_playlist_tracks SET position = -1 + WHERE playlist_id = ?1 AND position = ?2", + params![playlist_id, from], + )?; + if from < to { + conn.execute( + "UPDATE local_playlist_tracks SET position = position - 1 + WHERE playlist_id = ?1 AND position > ?2 AND position <= ?3", + params![playlist_id, from, to], + )?; + } else { + conn.execute( + "UPDATE local_playlist_tracks SET position = position + 1 + WHERE playlist_id = ?1 AND position >= ?2 AND position < ?3", + params![playlist_id, to, from], + )?; + } + conn.execute( + "UPDATE local_playlist_tracks SET position = ?2 + WHERE playlist_id = ?1 AND position = -1", + params![playlist_id, to], + )?; + conn.execute( + "UPDATE local_playlists SET updated_at = ?1 WHERE id = ?2", + params![now_ms(), playlist_id], + )?; + Ok(()) +} + /// Remove the row at `position` and compact the positions above it. pub fn remove_track(conn: &Connection, playlist_id: &str, position: i32) -> Result<()> { conn.execute( @@ -490,6 +587,141 @@ mod tests { assert_eq!(rows[1].position, 1); } + /// The repo positions, in row order, with the qobuz id per row — the + /// shape every reorder assertion checks. + fn qobuz_order(conn: &Connection, id: &str) -> Vec<(i32, u64)> { + get_tracks(conn, id) + .unwrap() + .iter() + .map(|r| (r.position, r.qobuz_track_id.unwrap())) + .collect() + } + + fn seeded_playlist(conn: &Connection, ids: &[u64]) -> String { + let id = create(conn, "Reorder", None, false).unwrap(); + let entries: Vec = ids + .iter() + .map(|&tid| LocalPlaylistTrackInput::Qobuz(tid)) + .collect(); + add_tracks(conn, &id, &entries).unwrap(); + id + } + + #[test] + fn reorder_moves_down_with_compaction() { + let conn = fresh_db(); + let id = seeded_playlist(&conn, &[1, 2, 3, 4]); + // Move the first row to slot 2: [1,2,3,4] -> [2,3,1,4]. + reorder(&conn, &id, 0, 2).unwrap(); + assert_eq!( + qobuz_order(&conn, &id), + vec![(0, 2), (1, 3), (2, 1), (3, 4)] + ); + } + + #[test] + fn reorder_moves_up_with_compaction() { + let conn = fresh_db(); + let id = seeded_playlist(&conn, &[1, 2, 3, 4]); + // Move the last row to slot 1: [1,2,3,4] -> [1,4,2,3]. + reorder(&conn, &id, 3, 1).unwrap(); + assert_eq!( + qobuz_order(&conn, &id), + vec![(0, 1), (1, 4), (2, 2), (3, 3)] + ); + } + + #[test] + fn reorder_adjacent_swap() { + let conn = fresh_db(); + let id = seeded_playlist(&conn, &[1, 2, 3]); + reorder(&conn, &id, 1, 2).unwrap(); + assert_eq!(qobuz_order(&conn, &id), vec![(0, 1), (1, 3), (2, 2)]); + reorder(&conn, &id, 2, 1).unwrap(); + assert_eq!(qobuz_order(&conn, &id), vec![(0, 1), (1, 2), (2, 3)]); + } + + #[test] + fn reorder_noop_on_same_or_missing_positions() { + let conn = fresh_db(); + let id = seeded_playlist(&conn, &[1, 2, 3]); + let before = qobuz_order(&conn, &id); + reorder(&conn, &id, 1, 1).unwrap(); // same slot + reorder(&conn, &id, 7, 0).unwrap(); // from doesn't exist + reorder(&conn, &id, 0, 7).unwrap(); // to doesn't exist + reorder(&conn, &id, 0, -1).unwrap(); // negative target + assert_eq!(qobuz_order(&conn, &id), before); + } + + #[test] + fn reorder_scoped_to_its_playlist() { + let conn = fresh_db(); + let a = seeded_playlist(&conn, &[1, 2, 3]); + let b = seeded_playlist(&conn, &[10, 20, 30]); + reorder(&conn, &a, 0, 2).unwrap(); + assert_eq!(qobuz_order(&conn, &a), vec![(0, 2), (1, 3), (2, 1)]); + // The sibling playlist's rows are untouched. + assert_eq!( + qobuz_order(&conn, &b), + vec![(0, 10), (1, 20), (2, 30)] + ); + } + + #[test] + fn favorite_and_hidden_default_false_and_flip() { + let conn = fresh_db(); + let id = create(&conn, "Flags", None, false).unwrap(); + let p = get(&conn, &id).unwrap().unwrap(); + assert!(!p.favorite); + assert!(!p.hidden); + set_favorite(&conn, &id, true).unwrap(); + set_hidden(&conn, &id, true).unwrap(); + let p = get(&conn, &id).unwrap().unwrap(); + assert!(p.favorite); + assert!(p.hidden); + set_favorite(&conn, &id, false).unwrap(); + let p = get(&conn, &id).unwrap().unwrap(); + assert!(!p.favorite); + assert!(p.hidden, "flags flip independently"); + // `list` carries the flags too. + let all = list(&conn).unwrap(); + assert!(all.iter().any(|p| p.id == id && p.hidden && !p.favorite)); + } + + #[test] + fn init_schema_migrates_pre_b3_table() { + // A DB created with the pre-B3 shape (no favorite/hidden columns) + // plus an existing row: init_schema adds the columns with their + // defaults and leaves the row readable. + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + r#" + CREATE TABLE local_playlists ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + offline_only INTEGER NOT NULL DEFAULT 0, + custom_artwork_path TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + INSERT INTO local_playlists (id, name, offline_only, created_at, updated_at) + VALUES ('local:pre-b3', 'Old Row', 0, 1, 1); + "#, + ) + .unwrap(); + init_schema(&conn).unwrap(); + let p = get(&conn, "local:pre-b3").unwrap().unwrap(); + assert_eq!(p.name, "Old Row"); + assert!(!p.favorite); + assert!(!p.hidden); + // The migrated columns are writable. + set_hidden(&conn, "local:pre-b3", true).unwrap(); + assert!(get(&conn, "local:pre-b3").unwrap().unwrap().hidden); + // Idempotent: a second init_schema doesn't re-ALTER. + init_schema(&conn).unwrap(); + } + #[test] fn delete_cascades_membership_rows() { let conn = fresh_db(); diff --git a/crates/qbz-slint/src/local_playlist.rs b/crates/qbz-slint/src/local_playlist.rs index 39fb50827..495a39824 100644 --- a/crates/qbz-slint/src/local_playlist.rs +++ b/crates/qbz-slint/src/local_playlist.rs @@ -96,6 +96,25 @@ pub fn delete_blocking(id: &str) -> bool { .unwrap_or(false) } +/// B3: persist the manager's favorite flag for a local playlist. +pub fn set_favorite_blocking(id: &str, favorite: bool) -> bool { + crate::library_db::with_db(|db| { + Ok(db.with_connection(|conn| repo::set_favorite(conn, id, favorite))) + }) + .map(|r| r.is_ok()) + .unwrap_or(false) +} + +/// B3: persist the manager's hidden flag for a local playlist (hidden +/// locals drop from the sidebar list). +pub fn set_hidden_blocking(id: &str, hidden: bool) -> bool { + crate::library_db::with_db(|db| { + Ok(db.with_connection(|conn| repo::set_hidden(conn, id, hidden))) + }) + .map(|r| r.is_ok()) + .unwrap_or(false) +} + /// Append Qobuz track ids. Returns inserted count. pub fn add_qobuz_tracks_blocking(id: &str, track_ids: &[u64]) -> usize { let entries: Vec = track_ids @@ -983,6 +1002,98 @@ pub fn enqueue_by_id( }); } +// ──────────────────────── reorder (B2) ──────────────────────── + +/// Move the clicked row one slot up/down within the open LOCAL playlist's +/// natural (repo) order — the local branch of the detail view's reorder +/// chevrons (`("track","move-up"/"move-down")`). Optimistic on the UI +/// thread (FULL_ITEMS swap + position-map transform + queue-snapshot +/// reorder), then the direct repo write (`repo::reorder`) off-thread — no +/// custom-order sidecar, the repo `position` IS the order. UI thread entry. +pub fn move_row( + window: &AppWindow, + handle: &tokio::runtime::Handle, + clicked_id: &str, + up: bool, +) { + let playlist_id = window.global::().get_id().to_string(); + if !is_local_id(&playlist_id) { + return; + } + // Neighbor in the FULL natural order (mirrors `playlist::move_track`, + // which also moves within the full list while a search is active). + let mut ids = crate::playlist::full_item_ids(); + let Some(idx) = ids.iter().position(|id| id == clicked_id) else { + return; + }; + let neighbor = if up { + idx.checked_sub(1) + } else { + (idx + 1 < ids.len()).then_some(idx + 1) + }; + let Some(nidx) = neighbor else { + return; // already first / last + }; + // Repo positions of both rows. Hidden (unresolvable) rows keep their + // own positions in the DB; remove-then-insert semantics make the move + // land exactly at the neighbor's slot even across those gaps. + let (from, to) = { + let map = ROW_POSITIONS.lock().map(|m| m.clone()).unwrap_or_default(); + match (map.get(clicked_id), map.get(&ids[nidx])) { + (Some(&f), Some(&t)) => (f, t), + _ => return, + } + }; + if from == to { + return; + } + + // Optimistic UI: swap the FULL rows (re-renders through search/sort). + crate::playlist::swap_full_items(window, idx, nidx); + ids.swap(idx, nidx); + + // Keep the cached position map consistent with what `repo::reorder` + // writes: the moved row lands at `to`, the in-between rows shift one. + if let Ok(mut map) = ROW_POSITIONS.lock() { + for (id, pos) in map.iter_mut() { + if id == clicked_id { + *pos = to; + } else if from < to && *pos > from && *pos <= to { + *pos -= 1; + } else if from > to && *pos >= to && *pos < from { + *pos += 1; + } + } + } + // Keep the playable queue snapshot in the rows' new order (Plex rows + // aren't in it; relative order of the rest follows the row order). + if let Ok(mut queue) = CURRENT_QUEUE.lock() { + let order: HashMap<&str, usize> = ids + .iter() + .enumerate() + .map(|(i, id)| (id.as_str(), i)) + .collect(); + queue.sort_by_key(|q| { + let qid = q.id.to_string(); + order.get(qid.as_str()).copied().unwrap_or(usize::MAX) + }); + } + + let pid = playlist_id.clone(); + handle.spawn(async move { + tokio::task::spawn_blocking(move || { + let result = crate::library_db::with_db(|db| { + Ok(db.with_connection(|conn| repo::reorder(conn, &pid, from, to))) + }); + if let Some(Err(e)) = result { + log::error!("[qbz-slint] local playlist reorder {from}->{to}: {e}"); + } + }) + .await + .ok(); + }); +} + // ──────────────────────── removal (multi-select) ──────────────────────── /// Remove the selected rows from the open local playlist (repo positions, diff --git a/crates/qbz-slint/src/playlist.rs b/crates/qbz-slint/src/playlist.rs index 40da2e8f6..f87466fbb 100644 --- a/crates/qbz-slint/src/playlist.rs +++ b/crates/qbz-slint/src/playlist.rs @@ -154,6 +154,27 @@ pub fn current_track_ids() -> Vec { }) } +/// The FULL (unfiltered, natural-order) row ids as strings. The LOCAL +/// detail's reorder works over these — its Plex rows (`plex:`) don't +/// parse as u64, so `current_track_ids` can't serve it. UI thread. +pub fn full_item_ids() -> Vec { + FULL_ITEMS.with(|cell| cell.borrow().iter().map(|t| t.id.to_string()).collect()) +} + +/// Swap the FULL_ITEMS entries at natural-order indexes `a` and `b`, then +/// re-render through the active search/sort. The LOCAL detail's optimistic +/// reorder move (B2) — under its "default" sort the visible order IS the +/// FULL order, so the swap shows immediately. UI thread. +pub fn swap_full_items(window: &AppWindow, a: usize, b: usize) { + FULL_ITEMS.with(|cell| { + let mut items = cell.borrow_mut(); + if a < items.len() && b < items.len() { + items.swap(a, b); + } + }); + refresh_view(window); +} + /// Load the playlist's custom order from library.db, seeding it from /// `current_ids` if none exists. Blocking — run on a worker thread. pub fn load_or_init_custom(playlist_id: u64, current_ids: Vec) -> Vec<(u64, i32)> { diff --git a/crates/qbz-slint/src/playlist_manager.rs b/crates/qbz-slint/src/playlist_manager.rs index 60eebac71..970700b2c 100644 --- a/crates/qbz-slint/src/playlist_manager.rs +++ b/crates/qbz-slint/src/playlist_manager.rs @@ -56,14 +56,18 @@ impl PmPlaylist { } /// Send view of one LOCAL playlist (library.db entity, id `local:`). -/// Listed alongside the Qobuz set with a hard-drive marker; folder -/// membership / favorite / hidden are Qobuz-side concepts and don't apply. +/// Listed alongside the Qobuz set with a hard-drive marker. Favorite / +/// hidden live on the `local_playlists` row itself (B3) and participate in +/// the manager's filter + card actions; folder membership stays a +/// Qobuz-side concept (the folder tables are u64-keyed) and doesn't apply. #[derive(Clone)] struct PmLocalPlaylist { id: String, name: String, offline_only: bool, track_count: u32, + is_favorite: bool, + is_hidden: bool, } #[derive(Clone, Default)] @@ -122,6 +126,8 @@ where name: p.name, offline_only: p.offline_only, track_count: p.track_count, + is_favorite: p.favorite, + is_hidden: p.hidden, }) .collect(); ( @@ -241,9 +247,10 @@ fn sort_playlists(list: &mut Vec, sort: &str) { /// Display-stage union of a Qobuz playlist and a LOCAL (library.db) /// playlist, so locals INTERLEAVE into the active sort instead of being -/// appended after the Qobuz set (B4). Display-only by design: PmPlaylist's -/// u64-keyed mutators (reorder/settings) are untouched — they parse the -/// model ids and skip `local:` ones. +/// appended after the Qobuz set (B4). The u64-keyed mutators (custom-order +/// reorder, move-to-folder) still parse the model ids and skip `local:` +/// ones — the folder tables are Qobuz-keyed; favorite / hide route to the +/// local repo instead (B3, `toggle_local_favorite` / `toggle_local_hidden`). /// /// Sort keys locals don't have: /// - playcount: no local play stat — locals sort as ZERO, which under the @@ -309,17 +316,19 @@ fn sort_entries(list: &mut [PmEntry], sort: &str) { } /// The LOCAL playlists that pass the toolbar filters, name-sorted (their -/// tie/no-stat order inside `sort_entries`). They have no hidden flag, so -/// the "hidden" filter excludes them; folder filtering N/A (root-only). -/// `query` must already be lowercased. +/// tie/no-stat order inside `sort_entries`). The visibility filter applies +/// to their own hidden flag (B3, `local_playlists.hidden`); folder +/// filtering N/A (root-only). `query` must already be lowercased. fn local_entries<'a>(data: &'a PmData, query: &str, filter: &str) -> Vec<&'a PmLocalPlaylist> { - if filter == "hidden" { - return Vec::new(); - } let mut locals: Vec<&PmLocalPlaylist> = data .locals .iter() .filter(|p| query.is_empty() || p.name.to_lowercase().contains(query)) + .filter(|p| match filter { + "visible" => !p.is_hidden, + "hidden" => p.is_hidden, + _ => true, + }) .collect(); locals.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); locals @@ -455,8 +464,8 @@ fn local_playlist_item(p: &PmLocalPlaylist) -> PmPlaylistItem { total_count: p.track_count as i32, play_count: 0, local_status: "".into(), - is_favorite: false, - is_hidden: false, + is_favorite: p.is_favorite, + is_hidden: p.is_hidden, is_local_playlist: true, offline_only: p.offline_only, folder_id: "".into(), @@ -830,6 +839,34 @@ pub fn toggle_hidden_local(window: &AppWindow, playlist_id: u64) -> bool { new_val } +/// Flip a LOCAL playlist's favorite flag in the cache + rebuild (B3). +/// Returns the new value for the repo write. +pub fn toggle_local_favorite(window: &AppWindow, id: &str) -> bool { + let mut new_val = false; + if let Ok(mut c) = CACHE.lock() { + if let Some(p) = c.locals.iter_mut().find(|p| p.id == id) { + p.is_favorite = !p.is_favorite; + new_val = p.is_favorite; + } + } + rebuild(window); + new_val +} + +/// Flip a LOCAL playlist's hidden flag in the cache + rebuild (B3). +/// Returns the new value for the repo write. +pub fn toggle_local_hidden(window: &AppWindow, id: &str) -> bool { + let mut new_val = false; + if let Ok(mut c) = CACHE.lock() { + if let Some(p) = c.locals.iter_mut().find(|p| p.id == id) { + p.is_hidden = !p.is_hidden; + new_val = p.is_hidden; + } + } + rebuild(window); + new_val +} + /// Move a playlist into a folder ("" = root) in the cache + rebuild. pub fn move_to_folder_local(window: &AppWindow, playlist_id: u64, folder_id: &str) { if let Ok(mut c) = CACHE.lock() { diff --git a/crates/qbz-slint/src/sidebar.rs b/crates/qbz-slint/src/sidebar.rs index a75e1b9bc..233051985 100644 --- a/crates/qbz-slint/src/sidebar.rs +++ b/crates/qbz-slint/src/sidebar.rs @@ -182,9 +182,13 @@ where .filter(|(_, s)| s.hidden) .map(|(id, _)| id) .collect(); + // Hidden locals drop from the sidebar (B3) the way hidden Qobuz + // playlists do — they stay reachable via the manager's "hidden" + // filter, which reads the repo list directly. let local_playlists: Vec = crate::local_playlist::list_blocking() .into_iter() + .filter(|p| !p.hidden) .map(|p| LocalSidebarPlaylist { id: p.id, name: p.name, diff --git a/crates/qbz-slint/ui/playlist/PlaylistView.slint b/crates/qbz-slint/ui/playlist/PlaylistView.slint index 20ac00117..95ce4c5dc 100644 --- a/crates/qbz-slint/ui/playlist/PlaylistView.slint +++ b/crates/qbz-slint/ui/playlist/PlaylistView.slint @@ -532,8 +532,11 @@ export component PlaylistView inherits Rectangle { chosen => { PlaylistActions.set-sort("duration"); } } // Manual order — enables the per-row - // up/down reorder controls. - SortOption { + // up/down reorder controls. Hidden on + // LOCAL playlists (B2): their Default + // order IS the editable repo order, so + // a separate custom sidecar is moot. + if !PlaylistState.is-local: SortOption { label: @tr("Custom"); active: PlaylistState.sort-field == "custom"; chosen => { PlaylistActions.set-sort("custom"); } @@ -663,7 +666,11 @@ export component PlaylistView inherits Rectangle { show-artwork: true; show-album: true; show-favorite: true; - show-reorder: PlaylistState.sort-field == "custom"; + // Custom sort (Qobuz sidecar order) — or, on a LOCAL + // playlist, the Default sort: its natural order IS the + // editable repo order (B2), written via repo::reorder. + show-reorder: PlaylistState.sort-field == "custom" + || (PlaylistState.is-local && PlaylistState.sort-field == "default"); multi-select-mode: PlaylistState.multi-select-mode; media-action(kind, id, action) => { if (action == "play") {