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:
vicrodh committed 2026-06-10 02:10:33 -06:00
1 parent 7d5f6658df
commit 23129cb98e
6 files changed
+433 -21

No files matched your search

+111
View File
@@ -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,
+21
View File
@@ -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)> {
+50 -13
View File
@@ -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() {
+4
View File
@@ -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,