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
This commit is contained in:
6 files changed
+433
-21
No files matched your search
@@ -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<String>,
|
||||
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<LocalPlaylist> {
|
||||
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<Vec<LocalPlaylist>> {
|
||||
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<Vec<LocalPlaylist>> {
|
||||
pub fn get(conn: &Connection, id: &str) -> Result<Option<LocalPlaylist>> {
|
||||
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<LocalPlaylistTrackInput> = 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();
|
||||
|
||||
@@ -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<repo::LocalPlaylistTrackInput> = 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::<PlaylistState>().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,
|
||||
|
||||
@@ -154,6 +154,27 @@ pub fn current_track_ids() -> Vec<u64> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The FULL (unfiltered, natural-order) row ids as strings. The LOCAL
|
||||
/// detail's reorder works over these — its Plex rows (`plex:<key>`) don't
|
||||
/// parse as u64, so `current_track_ids` can't serve it. UI thread.
|
||||
pub fn full_item_ids() -> Vec<String> {
|
||||
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<u64>) -> Vec<(u64, i32)> {
|
||||
|
||||
@@ -56,14 +56,18 @@ impl PmPlaylist {
|
||||
}
|
||||
|
||||
/// Send view of one LOCAL playlist (library.db entity, id `local:<uuid>`).
|
||||
/// 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<PmPlaylist>, 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() {
|
||||
|
||||
@@ -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<LocalSidebarPlaylist> =
|
||||
crate::local_playlist::list_blocking()
|
||||
.into_iter()
|
||||
.filter(|p| !p.hidden)
|
||||
.map(|p| LocalSidebarPlaylist {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in new issue
Block a user