diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index c42a01d70..d766a7e68 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -1435,12 +1435,15 @@ fn main() -> Result<(), Box> { }); } ("track", "toggle-select") => { - // Multi-select foundation — flips `selected` on the - // matching row in ArtistState.top-tracks. Other - // track-row contexts (album, search) will be wired as - // selection lands there. + // Flip `selected` on the matching row, in whichever + // multi-select surface is showing: the playlist + // detail or the artist Popular Tracks. if let Some(w) = weak.upgrade() { - let model = w.global::().get_top_tracks(); + let model = if w.global::().get_view() == ContentView::Playlist { + w.global::().get_tracks() + } else { + w.global::().get_top_tracks() + }; if let Some(vm) = model .as_any() .downcast_ref::>() @@ -1455,6 +1458,9 @@ fn main() -> Result<(), Box> { } } } + if w.global::().get_view() == ContentView::Playlist { + playlist::recount_selected(&w); + } } } // The mix tile sends id = mix kind, action = "open". @@ -1551,6 +1557,53 @@ fn main() -> Result<(), Box> { }); } } + ("playlist", "select-toggle") => { + if let Some(w) = weak.upgrade() { + let on = w.global::().get_multi_select_mode(); + playlist::set_multi_select(&w, !on); + } + } + ("playlist", "select-all") => { + if let Some(w) = weak.upgrade() { + playlist::select_all(&w); + } + } + ("playlist", "remove-selected") => { + if let Some(w) = weak.upgrade() { + let pid = w.global::().get_id().to_string(); + let ids = playlist::selected_ids(&w); + if let (Ok(pid), false) = (pid.parse::(), ids.is_empty()) { + let runtime = runtime.clone(); + let weak = weak.clone(); + let handle = handle.clone(); + let image_cache = image_cache.clone(); + handle.clone().spawn(async move { + match runtime + .core() + .remove_tracks_from_playlist(pid, &ids) + .await + { + Ok(()) => { + // Reload the playlist + leave edit mode. + let _ = weak.upgrade_in_event_loop(|w| { + playlist::set_multi_select(&w, false); + }); + navigate_playlist( + runtime.clone(), + weak.clone(), + &handle, + image_cache.clone(), + pid.to_string(), + ); + } + Err(e) => log::error!( + "[qbz-slint] remove tracks from playlist failed: {e}" + ), + } + }); + } + } + } ("playlist", "edit") => { // Open the edit modal, prefilled from the open playlist. if let Some(w) = weak.upgrade() { diff --git a/crates/qbz-slint/src/playlist.rs b/crates/qbz-slint/src/playlist.rs index 0b93438d7..647da4d79 100644 --- a/crates/qbz-slint/src/playlist.rs +++ b/crates/qbz-slint/src/playlist.rs @@ -204,3 +204,63 @@ pub fn index_of(track_id: &str) -> usize { .and_then(|c| c.iter().position(|t| t.id.to_string() == track_id)) .unwrap_or(0) } + +// ==================== Multi-select edit mode ==================== + +use slint::Model; + +/// Recount selected rows into PlaylistState.selected-count. +pub fn recount_selected(window: &AppWindow) { + let model = window.global::().get_tracks(); + let count = (0..model.row_count()) + .filter(|&i| model.row_data(i).map(|t| t.selected).unwrap_or(false)) + .count() as i32; + window.global::().set_selected_count(count); +} + +/// 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); + } + } + } + state.set_selected_count(0); + } + state.set_multi_select_mode(on); +} + +/// Toggle select-all: select every row, or clear if all are selected. +pub fn select_all(window: &AppWindow) { + let model = window.global::().get_tracks(); + let total = model.row_count(); + let selected = (0..total) + .filter(|&i| model.row_data(i).map(|t| t.selected).unwrap_or(false)) + .count(); + let target = selected != total; // if not all selected -> select all + for i in 0..total { + if let Some(mut item) = model.row_data(i) { + if item.selected != target { + item.selected = target; + model.set_row_data(i, item); + } + } + } + recount_selected(window); +} + +/// The ids of the currently-selected rows. +pub fn selected_ids(window: &AppWindow) -> Vec { + let model = window.global::().get_tracks(); + (0..model.row_count()) + .filter_map(|i| model.row_data(i)) + .filter(|t| t.selected) + .filter_map(|t| t.id.parse::().ok()) + .collect() +} diff --git a/crates/qbz-slint/ui/playlist/PlaylistView.slint b/crates/qbz-slint/ui/playlist/PlaylistView.slint index 28cbb4820..a24363da0 100644 --- a/crates/qbz-slint/ui/playlist/PlaylistView.slint +++ b/crates/qbz-slint/ui/playlist/PlaylistView.slint @@ -184,6 +184,17 @@ export component PlaylistView inherits Rectangle { } } } + // Select tracks (multi-select edit mode). + if PlaylistState.is-owner && PlaylistState.tracks.length > 0: VerticalLayout { + alignment: center; + CircleAction { + icon: @image-url("../assets/icons/square-check-big.svg"); + active: PlaylistState.multi-select-mode; + clicked => { + root.media-action("playlist", "", "select-toggle"); + } + } + } } } } @@ -248,12 +259,77 @@ export component PlaylistView inherits Rectangle { } Rectangle { height: 6px; } + // Bulk action bar — visible in edit mode. + if PlaylistState.multi-select-mode: Rectangle { + height: 44px; + border-radius: 8px; + background: Theme.surface-elevated; + HorizontalLayout { + padding-left: 14px; + padding-right: 10px; + spacing: 12px; + Text { + text: PlaylistState.selected-count + " " + @tr("selected"); + color: Theme.text-secondary; + font-size: Typography.body; + vertical-alignment: center; + horizontal-stretch: 1; + } + pill-all := Rectangle { + width: all-label.preferred-width + 24px; + height: 30px; + border-radius: Radius.sm; + background: all-ta.has-hover ? Theme.surface-hover : transparent; + all-label := Text { + text: @tr("Select all"); + color: Theme.text-secondary; + font-size: Typography.legal; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + } + all-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.media-action("playlist", "", "select-all"); + } + } + } + pill-remove := Rectangle { + width: rm-label.preferred-width + 24px; + height: 30px; + border-radius: Radius.sm; + opacity: PlaylistState.selected-count > 0 ? 1.0 : 0.5; + background: rm-ta.has-hover ? #ef444433 : transparent; + border-width: 1px; + border-color: #ef4444; + rm-label := Text { + text: @tr("Remove"); + color: #ef4444; + font-size: Typography.legal; + font-weight: Typography.medium; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + } + rm-ta := TouchArea { + mouse-cursor: PlaylistState.selected-count > 0 ? pointer : default; + clicked => { + if (PlaylistState.selected-count > 0) { + root.media-action("playlist", "", "remove-selected"); + } + } + } + } + } + } + if PlaylistState.multi-select-mode: Rectangle { height: 8px; } + for track[i] in PlaylistState.tracks: TrackRow { track: track; index: i; show-artwork: true; show-album: true; show-favorite: true; + multi-select-mode: PlaylistState.multi-select-mode; media-action(kind, id, action) => { if (action == "play") { root.play-track(id); diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index 998597119..6008f1325 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -245,6 +245,10 @@ export global PlaylistState { in property is-favorite: false; // True when the playlist is one of the user's own (editable). in property is-owner: false; + // Track edit mode — rows show a selection checkbox and a bulk bar + // appears (remove selected). Only available to the owner. + in-out property multi-select-mode: false; + in property selected-count: 0; in property <[TrackItem]> tracks: []; in property loading: false; }