feat(slint): playlist detail track menus 1:1 (remove, source routing, bulk parity)
Per-row Remove from playlist lands (danger styling, owner-gated by design - Tauri renders it un-gated and lets the API reject; we port the intent, commented) working across qobuz/local/plex rows through the namespace split. Per-row Play next / Add to queue route by source: local rows enqueue their snapshot QueueTrack instead of mis-parsing row ids as catalog ids, and plex rows enqueue too (deliberate deviation from Tauri's silent no-op - the machinery exists). Hero Play next/later of mixed playlists merge the sidecar before enqueueing (Tauri T2 fix-forward: their hero arms drop source and crash plex auto-advance). Bulk bar reaches parity: select mode on followed playlists, source-aware bulk Play next / Add to queue / Add to playlist (mixed selections send Qobuz rows to the catalog picker, all-sidecar selections to the local picker), Remove stays owner-only. The offline-cache action is now cache-state aware app-wide (Make available / Refresh / Remove with a sequenced recache arm), and Track info hides on this surface until the feature exists. cc
This commit is contained in:
9 files changed
+564
-103
No files matched your search
@@ -304,6 +304,16 @@ pub fn plex_key_for_row(id: &str) -> Option<String> {
|
||||
.and_then(|q| q.source_item_id_hint.clone())
|
||||
}
|
||||
|
||||
/// The ready, SOURCE-AWARE QueueTrack of an open-detail row by display id
|
||||
/// (any source — snapshot rows are built to enqueue as-is). `None` for
|
||||
/// rows not in the open snapshot: unplayable rows (file:/broken:/
|
||||
/// unresolved) and any id while no snapshot-backed detail is open. The
|
||||
/// per-row / bulk Play next + Add to queue routing reads this (spec §3.2).
|
||||
pub fn queue_track_for_row(id: &str) -> Option<QueueTrack> {
|
||||
let queue = CURRENT_QUEUE.lock().ok()?;
|
||||
queue.iter().find(|q| q.id.to_string() == id).cloned()
|
||||
}
|
||||
|
||||
/// Local-mode picker ref for an open-detail row id: `"plex:<key>"` for
|
||||
/// resolved Plex rows, `"<library row id>"` for local file rows. `None`
|
||||
/// for Qobuz/offline-copy rows (those ride the catalog-id flow) and for
|
||||
@@ -765,7 +775,7 @@ pub async fn load(runtime: &Runtime, playlist_id: &str) -> Option<LocalPlaylistD
|
||||
}
|
||||
|
||||
/// Build the queue track for a resolved row, if it is playable.
|
||||
fn row_queue_track(item: &RowItem) -> Option<QueueTrack> {
|
||||
pub(crate) fn row_queue_track(item: &RowItem) -> Option<QueueTrack> {
|
||||
match item {
|
||||
RowItem::Qobuz(track) => {
|
||||
let (album_id, album_title, album_artwork) = track
|
||||
@@ -1585,9 +1595,8 @@ pub fn move_row(
|
||||
|
||||
// ──────────────────────── removal (multi-select) ────────────────────────
|
||||
|
||||
/// Remove the selected rows from the open local playlist (repo positions,
|
||||
/// highest first so each removal's compaction never shifts a pending one),
|
||||
/// then reload the detail. UI thread entry; DB work off-thread.
|
||||
/// Remove the selected rows from the open local playlist, then reload the
|
||||
/// detail. UI thread entry; DB work off-thread.
|
||||
pub fn remove_selected(
|
||||
window: &AppWindow,
|
||||
runtime: Runtime,
|
||||
@@ -1595,10 +1604,6 @@ pub fn remove_selected(
|
||||
handle: tokio::runtime::Handle,
|
||||
image_cache: ImageCache,
|
||||
) {
|
||||
let playlist_id = window.global::<PlaylistState>().get_id().to_string();
|
||||
if !is_local_id(&playlist_id) {
|
||||
return;
|
||||
}
|
||||
let model = window.global::<PlaylistState>().get_tracks();
|
||||
let selected: Vec<String> = (0..model.row_count())
|
||||
.filter_map(|i| model.row_data(i))
|
||||
@@ -1608,15 +1613,35 @@ pub fn remove_selected(
|
||||
if selected.is_empty() {
|
||||
return;
|
||||
}
|
||||
crate::playlist::set_multi_select(window, false);
|
||||
remove_rows_by_ids(window, runtime, weak, handle, image_cache, selected);
|
||||
}
|
||||
|
||||
/// Remove rows from the open LOCAL playlist by display id (repo positions
|
||||
/// through the open snapshot's position map, removed highest first so each
|
||||
/// removal's compaction never shifts a pending one), then reload. Shared
|
||||
/// by the bulk Remove and the per-row "Remove from playlist" menu entry
|
||||
/// (spec §3.1 step 4). UI thread entry; DB work off-thread.
|
||||
pub fn remove_rows_by_ids(
|
||||
window: &AppWindow,
|
||||
runtime: Runtime,
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
image_cache: ImageCache,
|
||||
ids: Vec<String>,
|
||||
) {
|
||||
let playlist_id = window.global::<PlaylistState>().get_id().to_string();
|
||||
if !is_local_id(&playlist_id) {
|
||||
return;
|
||||
}
|
||||
let mut positions: Vec<i32> = {
|
||||
let map = ROW_POSITIONS.lock().map(|m| m.clone()).unwrap_or_default();
|
||||
selected.iter().filter_map(|id| map.get(id).copied()).collect()
|
||||
ids.iter().filter_map(|id| map.get(id).copied()).collect()
|
||||
};
|
||||
positions.sort_unstable_by(|a, b| b.cmp(a));
|
||||
if positions.is_empty() {
|
||||
return;
|
||||
}
|
||||
crate::playlist::set_multi_select(window, false);
|
||||
let handle2 = handle.clone();
|
||||
handle.spawn(async move {
|
||||
let pid = playlist_id.clone();
|
||||
|
||||
@@ -1687,6 +1687,19 @@ fn playlist_remove_rows(
|
||||
});
|
||||
}
|
||||
|
||||
/// True while the OPEN view is a playlist detail whose rows ride the merged
|
||||
/// queue snapshot (LOCAL detail / offline subset / ONLINE mixed detail) —
|
||||
/// the guard for consulting snapshot row ids from the universal track arms.
|
||||
/// Only then may a row id be a library row id / synthetic Plex id; a stale
|
||||
/// snapshot id could otherwise collide with a genuine Qobuz catalog id from
|
||||
/// another surface (both are small integers).
|
||||
fn snapshot_detail_open(w: &AppWindow) -> bool {
|
||||
w.global::<NavState>().get_view() == ContentView::Playlist
|
||||
&& (w.global::<PlaylistState>().get_is_local()
|
||||
|| w.global::<PlaylistState>().get_offline_subset()
|
||||
|| playlist::is_mixed())
|
||||
}
|
||||
|
||||
/// Type a LocalLibrary row for the drag payload: Plex rows carry their
|
||||
/// rating key (their row id is synthetic — never resolvable in
|
||||
/// `local_tracks`), everything else its real library row id.
|
||||
@@ -4781,6 +4794,36 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
id,
|
||||
),
|
||||
("track", "queue") => {
|
||||
// SOURCE-TYPED routing first (spec §3.2, mirrors the
|
||||
// add-to-playlist arm): on a snapshot-backed playlist
|
||||
// detail a local row's id is a library row id and a plex
|
||||
// row's a synthetic 2^40 id — the catalog path below
|
||||
// would mis-resolve them (wrong-track hazard / silent
|
||||
// failure). The merged snapshot carries the ready,
|
||||
// source-aware QueueTrack; enqueue it directly.
|
||||
// DELIBERATE Tauri deviation for plex rows: Tauri renders
|
||||
// Play Next / Add to Queue as silent no-ops there (spec
|
||||
// §1.6.2) — Slint's queue carries plex rows fine, so we
|
||||
// wire them instead of porting the dead entries.
|
||||
if let Some(w) = weak.upgrade() {
|
||||
if snapshot_detail_open(&w) {
|
||||
if let Some(qt) = local_playlist::queue_track_for_row(&id) {
|
||||
if matches!(qt.source.as_deref(), Some("local") | Some("plex")) {
|
||||
playback::enqueue_queue_tracks(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
handle.clone(),
|
||||
vec![qt],
|
||||
false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Qobuz rows (incl. offline copies with real catalog
|
||||
// ids): the existing path — QConnect single-track
|
||||
// admission + fresh fetch.
|
||||
if let Ok(track_id) = id.parse::<u64>() {
|
||||
playback::enqueue_track(
|
||||
runtime.clone(),
|
||||
@@ -4912,6 +4955,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
id,
|
||||
),
|
||||
("track", "play-next") => {
|
||||
// Source-typed routing — see the ("track","queue") arm
|
||||
// (same seam, insert-next instead of append).
|
||||
if let Some(w) = weak.upgrade() {
|
||||
if snapshot_detail_open(&w) {
|
||||
if let Some(qt) = local_playlist::queue_track_for_row(&id) {
|
||||
if matches!(qt.source.as_deref(), Some("local") | Some("plex")) {
|
||||
playback::enqueue_queue_tracks(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
handle.clone(),
|
||||
vec![qt],
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(track_id) = id.parse::<u64>() {
|
||||
playback::play_track_next(
|
||||
runtime.clone(),
|
||||
@@ -4955,6 +5016,57 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
);
|
||||
}
|
||||
}
|
||||
("track", "recache") => {
|
||||
// "Refresh offline copy" (cached-state menu entry, spec
|
||||
// §3.5): remove + re-download, sequenced.
|
||||
if let Ok(track_id) = id.parse::<u64>() {
|
||||
offline_cache::refresh_cached(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
handle.clone(),
|
||||
track_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
("track", "remove-from-playlist") => {
|
||||
// Per-row removal on the playlist detail (spec §3.1).
|
||||
// Ownership-gated in the UI (PlaylistState.is-owner —
|
||||
// DELIBERATE: Tauri's available branch renders it
|
||||
// un-gated on followed playlists where the owner-only
|
||||
// API rejects, §1.6.1; we port the intent, not the
|
||||
// hole). One-row ride on the same namespace-split seam
|
||||
// as the bulk removal; the reload re-merges the sidecar.
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if w.global::<NavState>().get_view() != ContentView::Playlist {
|
||||
return;
|
||||
}
|
||||
if w.global::<PlaylistState>().get_is_local() {
|
||||
local_playlist::remove_rows_by_ids(
|
||||
&w,
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
handle.clone(),
|
||||
image_cache.clone(),
|
||||
vec![id.to_string()],
|
||||
);
|
||||
return;
|
||||
}
|
||||
let pid = w.global::<PlaylistState>().get_id().to_string();
|
||||
let Some(row) = playlist::row_for_id(&id) else {
|
||||
log::warn!("[qbz-slint] remove-from-playlist: row {id} not loaded");
|
||||
return;
|
||||
};
|
||||
if let Ok(pid) = pid.parse::<u64>() {
|
||||
playlist_remove_rows(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
handle.clone(),
|
||||
image_cache.clone(),
|
||||
pid,
|
||||
vec![row],
|
||||
);
|
||||
}
|
||||
}
|
||||
("track", "create-radio") => playback::play_track_radio(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
@@ -4978,11 +5090,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// surface (both are small integers). The ONLINE mixed
|
||||
// Qobuz detail shares the snapshot (E11), so its
|
||||
// local/plex rows type their refs the same way.
|
||||
let in_local_detail = w.global::<NavState>().get_view()
|
||||
== ContentView::Playlist
|
||||
&& (w.global::<PlaylistState>().get_is_local()
|
||||
|| w.global::<PlaylistState>().get_offline_subset()
|
||||
|| playlist::is_mixed());
|
||||
let in_local_detail = snapshot_detail_open(&w);
|
||||
let local_ref: Option<String> = if id.starts_with("plex:") {
|
||||
// Unresolved Plex row in a playlist detail — the id
|
||||
// already carries the rating key.
|
||||
@@ -5526,6 +5634,91 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
playlist::select_all(&w);
|
||||
}
|
||||
}
|
||||
("playlist", "play-next-selected") | ("playlist", "queue-selected") => {
|
||||
// Bulk Play next / Add to queue over the selection
|
||||
// (Tauri's BulkActionBar split-button, spec §1.5) —
|
||||
// source-aware: rows resolve through the merged queue
|
||||
// snapshot (local/plex/cached keep their source — the
|
||||
// T2 fix-forward) or the pure-Qobuz Track cache.
|
||||
if let Some(w) = weak.upgrade() {
|
||||
let next = action == "play-next-selected";
|
||||
let tracks = playlist::selected_queue_tracks(&w);
|
||||
if tracks.is_empty() {
|
||||
toast::error(&w, "Nothing playable in the selection");
|
||||
return;
|
||||
}
|
||||
// Selection clears, mode stays on (LL precedent).
|
||||
playlist::clear_selection(&w);
|
||||
playback::enqueue_queue_tracks(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
handle.clone(),
|
||||
tracks,
|
||||
next,
|
||||
);
|
||||
}
|
||||
}
|
||||
("playlist", "add-selected-to-playlist") => {
|
||||
// Bulk Add to playlist (spec §1.5). The picker is
|
||||
// single-mode (catalog ids XOR local-mode refs), so:
|
||||
// Qobuz rows ride the catalog flow; a selection with NO
|
||||
// Qobuz rows rides the local-mode flow ("plex:<key>" /
|
||||
// library row ids — per-row parity for sidecar rows); a
|
||||
// MIXED selection follows Tauri (Qobuz rows only,
|
||||
// sidecar rows skipped + logged).
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let rows = playlist::selected_rows(&w);
|
||||
if rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut qobuz_ids: Vec<String> = Vec::new();
|
||||
let mut local_refs: Vec<String> = Vec::new();
|
||||
for row in &rows {
|
||||
match row.source.as_str() {
|
||||
"local" => local_refs.push(row.id.clone()),
|
||||
"plex" => {
|
||||
if row.id.starts_with("plex:") {
|
||||
local_refs.push(row.id.clone());
|
||||
} else if let Some(key) =
|
||||
local_playlist::plex_key_for_row(&row.id)
|
||||
{
|
||||
local_refs.push(format!("plex:{key}"));
|
||||
} else {
|
||||
log::warn!(
|
||||
"[qbz-slint] bulk add-to-playlist: no rating key for plex row {} — skipped",
|
||||
row.id
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if row.id.parse::<u64>().is_ok() {
|
||||
qobuz_ids.push(row.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !qobuz_ids.is_empty() {
|
||||
if !local_refs.is_empty() {
|
||||
log::info!(
|
||||
"[qbz-slint] bulk add-to-playlist: mixed selection — {} sidecar row(s) skipped (single-mode picker; Tauri §1.5 behavior)",
|
||||
local_refs.len()
|
||||
);
|
||||
}
|
||||
playlist_picker::open_multi(&w, &qobuz_ids, false);
|
||||
} else if !local_refs.is_empty() {
|
||||
playlist_picker::open_multi(&w, &local_refs, true);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
let runtime = runtime.clone();
|
||||
let weak = weak.clone();
|
||||
handle.spawn(async move {
|
||||
let playlists = playlist_picker::load(&runtime).await;
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
playlist_picker::apply(&w, playlists);
|
||||
});
|
||||
});
|
||||
}
|
||||
("playlist", "remove-selected") => {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
// LOCAL playlist — remove the selected rows from the
|
||||
|
||||
@@ -525,42 +525,68 @@ pub fn remove_cached(
|
||||
id: u64,
|
||||
) {
|
||||
handle.spawn(async move {
|
||||
let Some(off) = crate::offline::get().await else {
|
||||
return;
|
||||
};
|
||||
let removed_path = {
|
||||
let guard = off.db.lock().await;
|
||||
match guard.as_ref() {
|
||||
Some(db) => db.delete_track(id).ok().flatten(),
|
||||
None => return,
|
||||
}
|
||||
};
|
||||
if let Some(p) = removed_path {
|
||||
let path = std::path::Path::new(&p);
|
||||
// v2 bundles live in `tracks-cmaf/<id>/` — remove the whole dir.
|
||||
let looks_v2 = path
|
||||
.parent()
|
||||
.and_then(|pp| pp.parent())
|
||||
.and_then(|r| r.file_name())
|
||||
.and_then(|n| n.to_str())
|
||||
== Some("tracks-cmaf");
|
||||
if looks_v2 {
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
} else {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
{
|
||||
let guard = off.library_db.lock().await;
|
||||
if let Some(db) = guard.as_ref() {
|
||||
let _ = db.remove_qobuz_cached_track(id);
|
||||
}
|
||||
}
|
||||
mark_cached(id, false);
|
||||
push_status(&weak, id, 0, 0.0);
|
||||
crate::toast::success_weak(&weak, "Removed from offline");
|
||||
crate::offline_manager::rebuild(weak.clone()).await;
|
||||
remove_cached_inner(&weak, id, true).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Refresh a track's offline copy ("Refresh offline copy", the cached-state
|
||||
/// menu entry — Tauri's ready-submenu re-download): drop the cached copy,
|
||||
/// then re-queue the download. Sequenced in ONE task — `cache_track`'s
|
||||
/// `insert_track` is not an upsert, so the delete must land first.
|
||||
pub fn refresh_cached(
|
||||
runtime: Runtime,
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
id: u64,
|
||||
) {
|
||||
let handle2 = handle.clone();
|
||||
handle.spawn(async move {
|
||||
// No "Removed from offline" toast mid-refresh; cache_track's own
|
||||
// status pushes take over from here.
|
||||
remove_cached_inner(&weak, id, false).await;
|
||||
cache_track(runtime, weak, handle2, id);
|
||||
});
|
||||
}
|
||||
|
||||
/// The removal body shared by [`remove_cached`] and [`refresh_cached`].
|
||||
async fn remove_cached_inner(weak: &slint::Weak<AppWindow>, id: u64, toast: bool) {
|
||||
let Some(off) = crate::offline::get().await else {
|
||||
return;
|
||||
};
|
||||
let removed_path = {
|
||||
let guard = off.db.lock().await;
|
||||
match guard.as_ref() {
|
||||
Some(db) => db.delete_track(id).ok().flatten(),
|
||||
None => return,
|
||||
}
|
||||
};
|
||||
if let Some(p) = removed_path {
|
||||
let path = std::path::Path::new(&p);
|
||||
// v2 bundles live in `tracks-cmaf/<id>/` — remove the whole dir.
|
||||
let looks_v2 = path
|
||||
.parent()
|
||||
.and_then(|pp| pp.parent())
|
||||
.and_then(|r| r.file_name())
|
||||
.and_then(|n| n.to_str())
|
||||
== Some("tracks-cmaf");
|
||||
if looks_v2 {
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
} else {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
{
|
||||
let guard = off.library_db.lock().await;
|
||||
if let Some(db) = guard.as_ref() {
|
||||
let _ = db.remove_qobuz_cached_track(id);
|
||||
}
|
||||
}
|
||||
mark_cached(id, false);
|
||||
push_status(weak, id, 0, 0.0);
|
||||
if toast {
|
||||
crate::toast::success_weak(weak, "Removed from offline");
|
||||
}
|
||||
crate::offline_manager::rebuild(weak.clone()).await;
|
||||
}
|
||||
@@ -2580,31 +2580,23 @@ pub fn enqueue_playlist(
|
||||
return;
|
||||
}
|
||||
};
|
||||
let tracks: Vec<QueueTrack> = playlist
|
||||
.tracks
|
||||
.as_ref()
|
||||
.map(|container| container.items.as_slice())
|
||||
.unwrap_or_default()
|
||||
let qobuz_tracks: Vec<Track> = playlist.tracks.map(|c| c.items).unwrap_or_default();
|
||||
// MIXED playlists (T2 fix-forward, spec §1.3): merge the local/Plex
|
||||
// sidecar rows at their stored slots so a card/hero enqueue carries
|
||||
// EVERY row WITH its source — Tauri's hero arms rebuild catalog-only
|
||||
// tracks and drop `source`, crashing plex auto-advance; our merged
|
||||
// rows enqueue as the source-aware QueueTracks the detail plays.
|
||||
// Pure-Qobuz playlists read an empty sidecar and are unchanged.
|
||||
let qobuz_count = qobuz_tracks.len() as u32;
|
||||
let sidecar = tokio::task::spawn_blocking(move || {
|
||||
crate::local_playlist::read_sidecar_rows_blocking(pid, qobuz_count, true)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let rows = crate::playlist::interleave_rows(qobuz_tracks, sidecar);
|
||||
let tracks: Vec<QueueTrack> = rows
|
||||
.iter()
|
||||
.map(|track| {
|
||||
let (album_id, album_title, album_artwork) = track
|
||||
.album
|
||||
.as_ref()
|
||||
.map(|a| {
|
||||
(
|
||||
a.id.clone(),
|
||||
a.title.clone(),
|
||||
a.image.best().cloned().unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let album_artist = track
|
||||
.performer
|
||||
.as_ref()
|
||||
.map(|p| p.name.clone())
|
||||
.unwrap_or_default();
|
||||
make_queue_track(track, &album_id, &album_title, &album_artist, &album_artwork)
|
||||
})
|
||||
.filter_map(|row| crate::local_playlist::row_queue_track(&row.item))
|
||||
.collect();
|
||||
if tracks.is_empty() {
|
||||
return;
|
||||
@@ -2702,6 +2694,50 @@ pub fn enqueue_tracks(
|
||||
});
|
||||
}
|
||||
|
||||
/// Append (or insert-next) a batch of already-built, SOURCE-AWARE
|
||||
/// QueueTracks — the playlist detail's per-row / bulk Play next + Add to
|
||||
/// queue route their snapshot rows here (local/plex/cached rows keep their
|
||||
/// source, so `play_audible` resolves each through its own path). QConnect
|
||||
/// CONTROLLER mode rides the same batch admission as `enqueue_playlist`:
|
||||
/// all-or-nothing — a non-castable (local/plex) row refuses the whole batch
|
||||
/// with a toast while a peer owns playback, exactly like the other
|
||||
/// source-typed batch paths.
|
||||
pub fn enqueue_queue_tracks(
|
||||
runtime: Runtime,
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
tracks: Vec<QueueTrack>,
|
||||
next: bool,
|
||||
) {
|
||||
if tracks.is_empty() {
|
||||
return;
|
||||
}
|
||||
handle.spawn(async move {
|
||||
if let Some(svc) = crate::qconnect_service::service() {
|
||||
let routed: Vec<(u64, Option<String>)> =
|
||||
tracks.iter().map(|qt| (qt.id, qt.source.clone())).collect();
|
||||
let handled = if next {
|
||||
svc.play_next_batch_on_peer_if_active(&routed).await
|
||||
} else {
|
||||
svc.add_to_queue_batch_on_peer_if_active(&routed).await
|
||||
};
|
||||
if handled {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if next {
|
||||
// Reverse so the inserted block keeps the selection's order.
|
||||
for track in tracks.into_iter().rev() {
|
||||
runtime.core().add_track_next(track).await;
|
||||
}
|
||||
} else {
|
||||
runtime.core().add_tracks(tracks).await;
|
||||
}
|
||||
refresh_sidebar(false);
|
||||
crate::toast::success_weak(&weak, if next { "Playing next" } else { "Added to queue" });
|
||||
});
|
||||
}
|
||||
|
||||
/// Append (or insert-next) a batch of already-loaded LocalLibrary rows to the
|
||||
/// queue. Mirrors `enqueue_tracks` but for `LocalTrack`: `local_queue_track`
|
||||
/// builds source-aware QueueTracks (is_local=true; "local"/"qobuz_download")
|
||||
|
||||
@@ -11,7 +11,7 @@ use std::sync::{LazyLock, Mutex};
|
||||
|
||||
use qbz_app::shell::AppRuntime;
|
||||
use qbz_core::FrontendAdapter;
|
||||
use qbz_models::Track;
|
||||
use qbz_models::{QueueTrack, Track};
|
||||
use slint::{ComponentHandle, ModelRc, VecModel};
|
||||
|
||||
use crate::artwork::{ArtworkJob, ArtworkTarget};
|
||||
@@ -354,7 +354,7 @@ pub struct PlaylistData {
|
||||
/// ALL claimants — local first, then plex, in stable claim order — instead
|
||||
/// of Tauri's Map collapse (E1/E2 fix-forward; healing repairs the stored
|
||||
/// data separately). Display numbering is the emit order (contiguous).
|
||||
fn interleave_rows(qobuz: Vec<Track>, sidecar: Vec<LoadedRow>) -> Vec<LoadedRow> {
|
||||
pub(crate) fn interleave_rows(qobuz: Vec<Track>, sidecar: Vec<LoadedRow>) -> Vec<LoadedRow> {
|
||||
let qobuz_to_row = |(i, t): (usize, Track)| LoadedRow {
|
||||
position: i as i32,
|
||||
item: RowItem::Qobuz(Box::new(t)),
|
||||
@@ -744,20 +744,26 @@ pub fn recount_selected(window: &AppWindow) {
|
||||
|
||||
/// Enter/leave edit mode. Leaving clears any selection.
|
||||
pub fn set_multi_select(window: &AppWindow, on: bool) {
|
||||
let state = window.global::<PlaylistState>();
|
||||
if !on {
|
||||
let model = state.get_tracks();
|
||||
for i in 0..model.row_count() {
|
||||
if let Some(mut item) = model.row_data(i) {
|
||||
if item.selected {
|
||||
item.selected = false;
|
||||
model.set_row_data(i, item);
|
||||
}
|
||||
clear_selection(window);
|
||||
}
|
||||
window.global::<PlaylistState>().set_multi_select_mode(on);
|
||||
}
|
||||
|
||||
/// Clear the selection WITHOUT leaving multi-select mode — the bulk
|
||||
/// queueing actions keep the mode active (LocalLibrary bulk precedent).
|
||||
pub fn clear_selection(window: &AppWindow) {
|
||||
let state = window.global::<PlaylistState>();
|
||||
let model = state.get_tracks();
|
||||
for i in 0..model.row_count() {
|
||||
if let Some(mut item) = model.row_data(i) {
|
||||
if item.selected {
|
||||
item.selected = false;
|
||||
model.set_row_data(i, item);
|
||||
}
|
||||
}
|
||||
state.set_selected_count(0);
|
||||
}
|
||||
state.set_multi_select_mode(on);
|
||||
state.set_selected_count(0);
|
||||
}
|
||||
|
||||
/// Toggle select-all: select every row, or clear if all are selected.
|
||||
@@ -804,6 +810,83 @@ pub fn selected_rows(window: &AppWindow) -> Vec<SelectedRow> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A single row (id + source) by display id — the per-row "Remove from
|
||||
/// playlist" menu entry rides the same namespace-split seam as the bulk
|
||||
/// selection, with a one-row set. UI thread.
|
||||
pub fn row_for_id(id: &str) -> Option<SelectedRow> {
|
||||
FULL_ITEMS.with(|cell| {
|
||||
cell.borrow()
|
||||
.iter()
|
||||
.find(|item| item.id.as_str() == id)
|
||||
.map(|item| SelectedRow {
|
||||
id: item.id.to_string(),
|
||||
source: item.source.to_string(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The selected rows as ready-to-enqueue, SOURCE-AWARE QueueTracks in
|
||||
/// visible order — the bulk Play next / Add to queue (spec §1.5). Rows of
|
||||
/// a snapshot-backed detail (local / offline subset / online mixed)
|
||||
/// resolve through `local_playlist`'s merged queue snapshot, which keeps
|
||||
/// each row's source (local/plex/cached — the T2 fix-forward: Tauri's
|
||||
/// bulk path rebuilds catalog tracks and drops `source`); pure-Qobuz
|
||||
/// details resolve through the loaded `CURRENT` Track cache. Unplayable
|
||||
/// rows (file:/broken:/unresolved) drop out. UI thread.
|
||||
pub fn selected_queue_tracks(window: &AppWindow) -> Vec<QueueTrack> {
|
||||
let model = window.global::<PlaylistState>().get_tracks();
|
||||
let qobuz = current_tracks();
|
||||
let mut out: Vec<QueueTrack> = Vec::new();
|
||||
for i in 0..model.row_count() {
|
||||
let Some(item) = model.row_data(i) else {
|
||||
continue;
|
||||
};
|
||||
if !item.selected {
|
||||
continue;
|
||||
}
|
||||
let id = item.id.to_string();
|
||||
// Snapshot first (covers ALL sources of the mixed/local detail;
|
||||
// empty for pure-Qobuz details, see clear_open_snapshot).
|
||||
if let Some(qt) = crate::local_playlist::queue_track_for_row(&id) {
|
||||
out.push(qt);
|
||||
continue;
|
||||
}
|
||||
// Pure-Qobuz detail: build from the loaded Track cache.
|
||||
if let Some(track) = id
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.and_then(|tid| qobuz.iter().find(|t| t.id == tid))
|
||||
{
|
||||
let (album_id, album_title, album_artwork) = track
|
||||
.album
|
||||
.as_ref()
|
||||
.map(|a| {
|
||||
(
|
||||
a.id.clone(),
|
||||
a.title.clone(),
|
||||
a.image.best().cloned().unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let album_artist = track
|
||||
.performer
|
||||
.as_ref()
|
||||
.map(|p| p.name.clone())
|
||||
.unwrap_or_default();
|
||||
out.push(crate::playback::make_queue_track(
|
||||
track,
|
||||
&album_id,
|
||||
&album_title,
|
||||
&album_artist,
|
||||
&album_artwork,
|
||||
));
|
||||
} else {
|
||||
log::warn!("[qbz-slint] bulk queue: row {id} not resolvable — skipped");
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The removal split of a row set, by id namespace.
|
||||
#[derive(Default)]
|
||||
pub struct RemovalSplit {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { PlaylistState, PlaylistActions, DragState, NavState, OfflineState } from "../state.slint";
|
||||
import { PlaylistState, PlaylistActions, DragState, NavState, OfflineState, BulkAction } from "../state.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { TrackRow } from "../primitives/TrackRow.slint";
|
||||
import { ExpandableSearch } from "../primitives/ExpandableSearch.slint";
|
||||
@@ -115,6 +115,24 @@ export component PlaylistView inherits Rectangle {
|
||||
// "Upload to Qobuz" confirm modal (local playlists, D8).
|
||||
property <bool> show-upload-confirm: false;
|
||||
|
||||
// Bulk-bar action sets (typed properties — a ternary over bare array
|
||||
// literals can't coerce to [BulkAction]). Tauri parity (spec §1.5):
|
||||
// Play next / Add to queue / Add to playlist over the selection,
|
||||
// Remove owner-only — followed playlists keep the queueing actions.
|
||||
property <[BulkAction]> bulk-actions-owner: [
|
||||
{ id: "select-all", label: @tr("Select all"), danger: false, needs-selection: false },
|
||||
{ id: "play-next-selected", label: @tr("Play next"), danger: false, needs-selection: true },
|
||||
{ id: "queue-selected", label: @tr("Add to queue"), danger: false, needs-selection: true },
|
||||
{ id: "add-selected-to-playlist", label: @tr("Add to playlist"), danger: false, needs-selection: true },
|
||||
{ id: "remove-selected", label: @tr("Remove"), danger: true, needs-selection: true },
|
||||
];
|
||||
property <[BulkAction]> bulk-actions-follower: [
|
||||
{ id: "select-all", label: @tr("Select all"), danger: false, needs-selection: false },
|
||||
{ id: "play-next-selected", label: @tr("Play next"), danger: false, needs-selection: true },
|
||||
{ id: "queue-selected", label: @tr("Add to queue"), danger: false, needs-selection: true },
|
||||
{ id: "add-selected-to-playlist", label: @tr("Add to playlist"), danger: false, needs-selection: true },
|
||||
];
|
||||
|
||||
// Label for the active sort field.
|
||||
property <string> sort-label:
|
||||
PlaylistState.sort-field == "title" ? @tr("Title")
|
||||
@@ -418,8 +436,11 @@ export component PlaylistView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Select tracks (multi-select edit mode).
|
||||
if PlaylistState.is-owner && PlaylistState.tracks.length > 0: VerticalLayout {
|
||||
// Select tracks (multi-select edit mode). NOT
|
||||
// ownership-gated (Tauri parity, spec §1.5: followed
|
||||
// playlists get select mode for bulk queueing too);
|
||||
// only the bar's Remove action is owner-gated.
|
||||
if PlaylistState.tracks.length > 0: VerticalLayout {
|
||||
horizontal-stretch: 0;
|
||||
alignment: center;
|
||||
CircleAction {
|
||||
@@ -620,13 +641,13 @@ export component PlaylistView inherits Rectangle {
|
||||
}
|
||||
Rectangle { height: 6px; }
|
||||
|
||||
// Bulk action bar — visible in edit mode.
|
||||
// Bulk action bar — visible in edit mode (action sets defined
|
||||
// as bulk-actions-owner / bulk-actions-follower above).
|
||||
if PlaylistState.multi-select-mode: MultiSelectBar {
|
||||
selected-count: PlaylistState.selected-count;
|
||||
actions: [
|
||||
{ id: "select-all", label: @tr("Select all"), danger: false, needs-selection: false },
|
||||
{ id: "remove-selected", label: @tr("Remove"), danger: true, needs-selection: true },
|
||||
];
|
||||
actions: PlaylistState.is-owner
|
||||
? root.bulk-actions-owner
|
||||
: root.bulk-actions-follower;
|
||||
action(id) => {
|
||||
root.media-action("playlist", "", id);
|
||||
}
|
||||
@@ -684,6 +705,19 @@ export component PlaylistView inherits Rectangle {
|
||||
// editable repo order (B2), written via repo::reorder.
|
||||
show-reorder: PlaylistState.sort-field == "custom"
|
||||
|| (PlaylistState.is-local && PlaylistState.sort-field == "default");
|
||||
// Per-row "Remove from playlist" — ownership-gated
|
||||
// BY DESIGN (spec §1.6.1: Tauri's available branch
|
||||
// renders it un-gated on followed playlists and the
|
||||
// owner-only API rejects; we port the intent, not
|
||||
// the hole). Local details set is-owner true; the
|
||||
// read-only offline subset sets it false.
|
||||
remove-from-playlist-action: PlaylistState.is-owner;
|
||||
// Track Info is DEAD on this surface (no
|
||||
// ("track","track-info") arm yet — backlog
|
||||
// project_track_info); hidden per spec §3.3 instead
|
||||
// of rendering a no-op entry. Other surfaces keep
|
||||
// the default until their own audit.
|
||||
track-info-action: false;
|
||||
multi-select-mode: PlaylistState.multi-select-mode;
|
||||
media-action(kind, id, action) => {
|
||||
if (action == "play") {
|
||||
|
||||
@@ -16,6 +16,10 @@ export component ContextMenuItem inherits Rectangle {
|
||||
in property <bool> enabled: true;
|
||||
// Optional trailing check, for single-choice menus (the selected row).
|
||||
in property <bool> selected: false;
|
||||
// Destructive action (Remove from playlist / Remove offline copy) —
|
||||
// red icon + label, mirroring Tauri's TrackMenu danger rows. Same red
|
||||
// as the MultiSelectBar danger button.
|
||||
in property <bool> danger: false;
|
||||
callback clicked();
|
||||
|
||||
height: root.shown ? 33px : 0px;
|
||||
@@ -34,12 +38,16 @@ export component ContextMenuItem inherits Rectangle {
|
||||
source: root.icon;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
tint: ta.has-hover ? Theme.text-primary : Theme.text-secondary;
|
||||
tint: root.danger
|
||||
? #ef4444
|
||||
: (ta.has-hover ? Theme.text-primary : Theme.text-secondary);
|
||||
}
|
||||
}
|
||||
Text {
|
||||
text: root.label;
|
||||
color: ta.has-hover ? Theme.text-primary : Theme.text-secondary;
|
||||
color: root.danger
|
||||
? #ef4444
|
||||
: (ta.has-hover ? Theme.text-primary : Theme.text-secondary);
|
||||
font-size: 13px;
|
||||
vertical-alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
|
||||
@@ -17,21 +17,37 @@ export component TrackContextMenu inherits PopupWindow {
|
||||
// §3.1/§3.2); they default to the block so Qobuz surfaces are unchanged.
|
||||
// `playlist-action` = Add to playlist — on for Qobuz + offline + local +
|
||||
// Plex (local playlists store each source's own ref), off for ephemeral.
|
||||
// `remove-from-playlist-action` = the playlist-detail per-row removal
|
||||
// (every row source — the arm splits by id namespace); the surface
|
||||
// gates it on ownership (spec §3.1).
|
||||
// `track-info-action` lets a surface HIDE the Track Info entry while
|
||||
// its target feature is missing there (spec §3.3) — defaults on so
|
||||
// other surfaces are untouched.
|
||||
// The transient actions (play / next / queue) always show.
|
||||
in property <bool> qobuz-actions: true;
|
||||
in property <bool> favorite-action: root.qobuz-actions;
|
||||
in property <bool> mixtape-action: root.qobuz-actions;
|
||||
in property <bool> playlist-action: true;
|
||||
in property <bool> remove-from-playlist-action: false;
|
||||
in property <bool> track-info-action: true;
|
||||
// The row's offline-cache status (TrackItem.cache-status): when cached
|
||||
// (3) the single "Make available offline" entry swaps for the
|
||||
// Refresh / Remove pair — Tauri's ready-state submenu, rendered flat
|
||||
// (cosmetic-flat per spec §3.5).
|
||||
in property <int> cache-status: 0;
|
||||
callback media-action(string, string, string);
|
||||
|
||||
width: 224px;
|
||||
// 3 transient + optional favorite/mixtape/add-to-playlist + the
|
||||
// 7-item Qobuz block.
|
||||
// 3 transient + optional favorite/mixtape/add-to-playlist/remove + the
|
||||
// Qobuz block (5 fixed + 1-or-2 offline entries + optional track info).
|
||||
height: (3
|
||||
+ (root.favorite-action ? 1 : 0)
|
||||
+ (root.mixtape-action ? 1 : 0)
|
||||
+ (root.playlist-action ? 1 : 0)
|
||||
+ (root.qobuz-actions ? 7 : 0)) * 30px;
|
||||
+ (root.remove-from-playlist-action ? 1 : 0)
|
||||
+ (root.qobuz-actions
|
||||
? 5 + (root.cache-status == 3 ? 2 : 1) + (root.track-info-action ? 1 : 0)
|
||||
: 0)) * 30px;
|
||||
|
||||
ContextMenu {
|
||||
ContextMenuItem {
|
||||
@@ -83,6 +99,16 @@ export component TrackContextMenu inherits PopupWindow {
|
||||
root.media-action("track", root.track-id, "add-to-playlist");
|
||||
}
|
||||
}
|
||||
// Remove from playlist (danger) — playlist detail only, after Add
|
||||
// to playlist (Tauri group order, spec §1.3 #8).
|
||||
if root.remove-from-playlist-action: ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/trash-2.svg");
|
||||
label: @tr("Remove from playlist");
|
||||
danger: true;
|
||||
clicked => {
|
||||
root.media-action("track", root.track-id, "remove-from-playlist");
|
||||
}
|
||||
}
|
||||
if root.qobuz-actions: ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/link.svg");
|
||||
label: @tr("Share Qobuz link");
|
||||
@@ -97,13 +123,32 @@ export component TrackContextMenu inherits PopupWindow {
|
||||
root.media-action("track", root.track-id, "share-songlink");
|
||||
}
|
||||
}
|
||||
if root.qobuz-actions: ContextMenuItem {
|
||||
// Offline-cache block — cached-state-aware (Tauri's ready-state
|
||||
// submenu, flat per spec §3.5): not cached -> Make available
|
||||
// offline; cached -> Refresh + Remove (icons 1:1 with Tauri's
|
||||
// RefreshCw / Trash2).
|
||||
if root.qobuz-actions && root.cache-status != 3: ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/cloud-download.svg");
|
||||
label: @tr("Make available offline");
|
||||
clicked => {
|
||||
root.media-action("track", root.track-id, "download");
|
||||
}
|
||||
}
|
||||
if root.qobuz-actions && root.cache-status == 3: ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/refresh-cw.svg");
|
||||
label: @tr("Refresh offline copy");
|
||||
clicked => {
|
||||
root.media-action("track", root.track-id, "recache");
|
||||
}
|
||||
}
|
||||
if root.qobuz-actions && root.cache-status == 3: ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/trash-2.svg");
|
||||
label: @tr("Remove offline copy");
|
||||
danger: true;
|
||||
clicked => {
|
||||
root.media-action("track", root.track-id, "uncache");
|
||||
}
|
||||
}
|
||||
if root.qobuz-actions: ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/disc-3.svg");
|
||||
label: @tr("Go to album");
|
||||
@@ -118,7 +163,7 @@ export component TrackContextMenu inherits PopupWindow {
|
||||
root.media-action("track", root.track-id, "go-to-artist");
|
||||
}
|
||||
}
|
||||
if root.qobuz-actions: ContextMenuItem {
|
||||
if root.qobuz-actions && root.track-info-action: ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/info.svg");
|
||||
label: @tr("Track info");
|
||||
clicked => {
|
||||
|
||||
@@ -57,6 +57,12 @@ export component TrackRow inherits Rectangle {
|
||||
// on library row ids (spec §3.4). The SourceGlyph keeps reading the
|
||||
// unchanged source field.
|
||||
in property <bool> force-local-menu: false;
|
||||
// Playlist-detail per-row "Remove from playlist" — the surface gates
|
||||
// it (PlaylistView: ownership, spec §3.1). Default off everywhere else.
|
||||
in property <bool> remove-from-playlist-action: false;
|
||||
// Track Info menu entry visibility — a surface whose Track Info is
|
||||
// dead hides it (spec §3.3); default on keeps other surfaces unchanged.
|
||||
in property <bool> track-info-action: true;
|
||||
// Custom-order reorder controls (up/down chevrons) at the leading
|
||||
// edge — used by the playlist custom sort.
|
||||
in property <bool> show-reorder: false;
|
||||
@@ -623,6 +629,11 @@ export component TrackRow inherits Rectangle {
|
||||
// playlists accept tracks offline (D7/D8) and the picker already
|
||||
// hides the Qobuz set offline.
|
||||
playlist-action: root.track.source != "ephemeral";
|
||||
remove-from-playlist-action: root.remove-from-playlist-action;
|
||||
track-info-action: root.track-info-action;
|
||||
// Cached-state-aware offline entry (Refresh/Remove when cached) —
|
||||
// straight from the row model.
|
||||
cache-status: root.track.cache-status;
|
||||
media-action(k, i, a) => {
|
||||
root.media-action(k, i, a);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user