diff --git a/crates/qbz-slint/src/local_playlist.rs b/crates/qbz-slint/src/local_playlist.rs index a19f5288d..a0191987e 100644 --- a/crates/qbz-slint/src/local_playlist.rs +++ b/crates/qbz-slint/src/local_playlist.rs @@ -304,6 +304,16 @@ pub fn plex_key_for_row(id: &str) -> Option { .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 { + 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:"` for /// resolved Plex rows, `""` 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 Option { +pub(crate) fn row_queue_track(item: &RowItem) -> Option { 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::().get_id().to_string(); - if !is_local_id(&playlist_id) { - return; - } let model = window.global::().get_tracks(); let selected: Vec = (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, + handle: tokio::runtime::Handle, + image_cache: ImageCache, + ids: Vec, +) { + let playlist_id = window.global::().get_id().to_string(); + if !is_local_id(&playlist_id) { + return; + } let mut positions: Vec = { 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(); diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index 11a87ce8f..b24b660bb 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -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::().get_view() == ContentView::Playlist + && (w.global::().get_is_local() + || w.global::().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> { 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::() { playback::enqueue_track( runtime.clone(), @@ -4912,6 +4955,24 @@ fn main() -> Result<(), Box> { 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::() { playback::play_track_next( runtime.clone(), @@ -4955,6 +5016,57 @@ fn main() -> Result<(), Box> { ); } } + ("track", "recache") => { + // "Refresh offline copy" (cached-state menu entry, spec + // §3.5): remove + re-download, sequenced. + if let Ok(track_id) = id.parse::() { + 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::().get_view() != ContentView::Playlist { + return; + } + if w.global::().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::().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::() { + 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> { // 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::().get_view() - == ContentView::Playlist - && (w.global::().get_is_local() - || w.global::().get_offline_subset() - || playlist::is_mixed()); + let in_local_detail = snapshot_detail_open(&w); let local_ref: Option = 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> { 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:" / + // 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 = Vec::new(); + let mut local_refs: Vec = 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::().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 diff --git a/crates/qbz-slint/src/offline_cache.rs b/crates/qbz-slint/src/offline_cache.rs index 05d83df90..6560b22aa 100644 --- a/crates/qbz-slint/src/offline_cache.rs +++ b/crates/qbz-slint/src/offline_cache.rs @@ -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//` — 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, + 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, 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//` — 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; +} diff --git a/crates/qbz-slint/src/playback.rs b/crates/qbz-slint/src/playback.rs index c20a7acd6..4790a2226 100644 --- a/crates/qbz-slint/src/playback.rs +++ b/crates/qbz-slint/src/playback.rs @@ -2580,31 +2580,23 @@ pub fn enqueue_playlist( return; } }; - let tracks: Vec = playlist - .tracks - .as_ref() - .map(|container| container.items.as_slice()) - .unwrap_or_default() + let qobuz_tracks: Vec = 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 = 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, + handle: tokio::runtime::Handle, + tracks: Vec, + next: bool, +) { + if tracks.is_empty() { + return; + } + handle.spawn(async move { + if let Some(svc) = crate::qconnect_service::service() { + let routed: Vec<(u64, Option)> = + 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") diff --git a/crates/qbz-slint/src/playlist.rs b/crates/qbz-slint/src/playlist.rs index 278ec5d61..3ef121e8c 100644 --- a/crates/qbz-slint/src/playlist.rs +++ b/crates/qbz-slint/src/playlist.rs @@ -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, sidecar: Vec) -> Vec { +pub(crate) fn interleave_rows(qobuz: Vec, sidecar: Vec) -> Vec { 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::(); 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::().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::(); + 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 { .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 { + 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 { + let model = window.global::().get_tracks(); + let qobuz = current_tracks(); + let mut out: Vec = 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::() + .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 { diff --git a/crates/qbz-slint/ui/playlist/PlaylistView.slint b/crates/qbz-slint/ui/playlist/PlaylistView.slint index 85329d7b2..79c59ba66 100644 --- a/crates/qbz-slint/ui/playlist/PlaylistView.slint +++ b/crates/qbz-slint/ui/playlist/PlaylistView.slint @@ -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 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 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") { diff --git a/crates/qbz-slint/ui/primitives/ContextMenuItem.slint b/crates/qbz-slint/ui/primitives/ContextMenuItem.slint index b4216f3a4..923f4ba63 100644 --- a/crates/qbz-slint/ui/primitives/ContextMenuItem.slint +++ b/crates/qbz-slint/ui/primitives/ContextMenuItem.slint @@ -16,6 +16,10 @@ export component ContextMenuItem inherits Rectangle { in property enabled: true; // Optional trailing check, for single-choice menus (the selected row). in property 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 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; diff --git a/crates/qbz-slint/ui/primitives/TrackContextMenu.slint b/crates/qbz-slint/ui/primitives/TrackContextMenu.slint index bc2a956ca..8917d55c5 100644 --- a/crates/qbz-slint/ui/primitives/TrackContextMenu.slint +++ b/crates/qbz-slint/ui/primitives/TrackContextMenu.slint @@ -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 qobuz-actions: true; in property favorite-action: root.qobuz-actions; in property mixtape-action: root.qobuz-actions; in property playlist-action: true; + in property remove-from-playlist-action: false; + in property 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 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 => { diff --git a/crates/qbz-slint/ui/primitives/TrackRow.slint b/crates/qbz-slint/ui/primitives/TrackRow.slint index 2baa2b39b..471da1439 100644 --- a/crates/qbz-slint/ui/primitives/TrackRow.slint +++ b/crates/qbz-slint/ui/primitives/TrackRow.slint @@ -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 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 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 track-info-action: true; // Custom-order reorder controls (up/down chevrons) at the leading // edge — used by the playlist custom sort. in property 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); }