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:
vicrodh committed 2026-06-10 12:15:25 -06:00
1 parent 58b464ea71
commit 065e6be7f4
9 files changed
+564 -103

No files matched your search

+35 -10
View File
@@ -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();
+198 -5
View File
@@ -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
+63 -37
View File
@@ -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;
}
+60 -24
View File
@@ -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")
+95 -12
View File
@@ -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);
}