From e9836184e75c4480b6118ddc4d22339efe07ba3a Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Tue, 26 May 2026 22:40:06 -0600 Subject: [PATCH] feat(slint): favorites tracks multi-select + bulk remove Add a select toggle to the Tracks header that enters multi-select mode (per-row checkboxes via the shared TrackRow) and shows the shared MultiSelectBar with Select all / Remove from favorites / Clear. Selection lives on the rendered tracks-visible rows (mirrors playlist.rs); the toggle-select router arm and recount gain a Favorites case. Bulk remove loops remove_favorite, syncs fav_cache, then reloads the tab. Play next / queue / add-to-playlist / make-offline are deferred (each needs a backend path: batch enqueue / multi-id picker / offline cache). cc --- crates/qbz-slint/src/favorites.rs | 66 ++++++++++++++++ crates/qbz-slint/src/main.rs | 75 ++++++++++++++++++- .../ui/favorites/FavoritesView.slint | 26 +++++++ crates/qbz-slint/ui/state.slint | 4 + 4 files changed, 169 insertions(+), 2 deletions(-) diff --git a/crates/qbz-slint/src/favorites.rs b/crates/qbz-slint/src/favorites.rs index 4cfbf8ac2..9f68b6c3f 100644 --- a/crates/qbz-slint/src/favorites.rs +++ b/crates/qbz-slint/src/favorites.rs @@ -447,6 +447,72 @@ pub fn shuffled_tracks() -> Vec { tracks } +// ---- Tracks multi-select (mirrors playlist.rs) ------------------------- +// Selection lives on TrackItem.selected in the rendered `tracks-visible` +// model (which shares `tracks` when no search filter is active). + +/// Enter/leave multi-select mode; leaving clears the selection. +pub fn set_multi_select(window: &AppWindow, on: bool) { + window.global::().set_tracks_multi_select(on); + if !on { + clear_selection(window); + } +} + +/// Recount selected rows into `tracks-selected-count`. +pub fn recount_selected(window: &AppWindow) { + let state = window.global::(); + let model = state.get_tracks_visible(); + let count = (0..model.row_count()) + .filter(|&i| model.row_data(i).map(|t| t.selected).unwrap_or(false)) + .count(); + state.set_tracks_selected_count(count as i32); +} + +/// Select-all toggle: select every visible row, or clear if all selected. +pub fn select_all(window: &AppWindow) { + let model = window.global::().get_tracks_visible(); + 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; + 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); +} + +/// Deselect every visible row. +pub fn clear_selection(window: &AppWindow) { + let state = window.global::(); + let model = state.get_tracks_visible(); + 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_tracks_selected_count(0); +} + +/// The ids of the currently-selected visible rows. +pub fn selected_ids(window: &AppWindow) -> Vec { + let model = window.global::().get_tracks_visible(); + (0..model.row_count()) + .filter_map(|i| model.row_data(i)) + .filter(|t| t.selected) + .map(|t| t.id.to_string()) + .collect() +} + pub fn reset_loading(window: &AppWindow) { window.global::().set_loading(true); } diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index 7a298d278..eff931abc 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -2361,6 +2361,9 @@ fn main() -> Result<(), Box> { let model = match w.global::().get_view() { ContentView::Playlist => w.global::().get_tracks(), ContentView::Label => w.global::().get_top_tracks(), + ContentView::Favorites => { + w.global::().get_tracks_visible() + } _ => w.global::().get_top_tracks(), }; if let Some(vm) = model @@ -2377,8 +2380,10 @@ fn main() -> Result<(), Box> { } } } - if w.global::().get_view() == ContentView::Playlist { - playlist::recount_selected(&w); + match w.global::().get_view() { + ContentView::Playlist => playlist::recount_selected(&w), + ContentView::Favorites => favorites::recount_selected(&w), + _ => {} } } } @@ -4306,6 +4311,72 @@ fn main() -> Result<(), Box> { ); }); } + { + // Enter / leave the tracks multi-select edit mode. + let weak = window.as_weak(); + window + .global::() + .on_toggle_multi_select(move || { + if let Some(w) = weak.upgrade() { + let on = w.global::().get_tracks_multi_select(); + favorites::set_multi_select(&w, !on); + } + }); + } + { + // Bulk bar actions over the selected favorite tracks. + let runtime = app_runtime.clone(); + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + let image_cache = image_cache.clone(); + window + .global::() + .on_bulk_action(move |action| { + let Some(w) = weak.upgrade() else { + return; + }; + match action.as_str() { + "select-all" => favorites::select_all(&w), + "clear" => favorites::clear_selection(&w), + "remove-selected" => { + let ids = favorites::selected_ids(&w); + if ids.is_empty() { + return; + } + let runtime = runtime.clone(); + let weak = weak.clone(); + let handle = handle.clone(); + let image_cache = image_cache.clone(); + handle.clone().spawn(async move { + for id in &ids { + if let Err(e) = + runtime.core().remove_favorite("track", id).await + { + log::error!( + "[qbz-slint] bulk remove favorite {id} failed: {e}" + ); + } + if let Ok(tid) = id.parse::() { + crate::fav_cache::set(tid, false); + } + } + let _ = weak.upgrade_in_event_loop(|w| { + favorites::set_multi_select(&w, false); + }); + navigate_favorites( + runtime.clone(), + weak.clone(), + &handle, + image_cache.clone(), + favorites::FavTab::Tracks, + "tracks", + ); + }); + } + _ => {} + } + }); + } // Artwork right-click menu wiring — Open in browser / Save as / // Add custom / Remove custom. Mirrors the v2_library_* + native diff --git a/crates/qbz-slint/ui/favorites/FavoritesView.slint b/crates/qbz-slint/ui/favorites/FavoritesView.slint index 704a57cfd..a7036ce86 100644 --- a/crates/qbz-slint/ui/favorites/FavoritesView.slint +++ b/crates/qbz-slint/ui/favorites/FavoritesView.slint @@ -22,6 +22,7 @@ import { AlbumCard } from "../discover/AlbumCard.slint"; import { TrackRow } from "../primitives/TrackRow.slint"; import { ExpandableSearch } from "../primitives/ExpandableSearch.slint"; import { CircleAction } from "../primitives/CircleAction.slint"; +import { MultiSelectBar } from "../primitives/MultiSelectBar.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; import { LoadingSpinner } from "../primitives/LoadingSpinner.slint"; import { ListScrollbar } from "../primitives/ListScrollbar.slint"; @@ -312,6 +313,16 @@ export component FavoritesView inherits Rectangle { } } } + VerticalLayout { + alignment: center; + CircleAction { + icon: @image-url("../assets/icons/square-check-big.svg"); + active: FavoritesState.tracks-multi-select; + clicked => { + FavoritesActions.toggle-multi-select(); + } + } + } } } @@ -396,6 +407,20 @@ export component FavoritesView inherits Rectangle { } if FavoritesState.tracks.length > 0: Rectangle { height: 12px; } + // Bulk action bar — visible in multi-select edit mode. + if FavoritesState.tracks-multi-select: MultiSelectBar { + selected-count: FavoritesState.tracks-selected-count; + actions: [ + { id: "select-all", label: @tr("Select all"), danger: false, needs-selection: false }, + { id: "remove-selected", label: @tr("Remove from favorites"), danger: true, needs-selection: true }, + { id: "clear", label: @tr("Clear"), danger: false, needs-selection: true }, + ]; + action(id) => { + FavoritesActions.bulk-action(id); + } + } + if FavoritesState.tracks-multi-select: Rectangle { height: 8px; } + if FavoritesState.tracks.length == 0: Text { text: @tr("No favorite tracks yet."); color: Theme.text-muted; @@ -414,6 +439,7 @@ export component FavoritesView inherits Rectangle { show-artwork: true; show-album: true; show-favorite: true; + multi-select-mode: FavoritesState.tracks-multi-select; // Album / artist name links open the respective page; the // row drops `kind`, so route those explicitly (the // track-action path forces kind="track" and would treat an diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index d24c4d2b3..e4999d3da 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -1454,6 +1454,8 @@ export global FavoritesState { in property <[TrackItem]> tracks: []; in property <[TrackItem]> tracks-visible: []; in-out property tracks-search: ""; + in-out property tracks-multi-select: false; + in property tracks-selected-count: 0; in property <[AlbumCardItem]> albums: []; in property <[FavoriteArtistItem]> artists: []; in property <[FavoritePlaylistItem]> playlists: []; @@ -1479,6 +1481,8 @@ export global FavoritesActions { callback search-tracks(string /* query */); callback play-all-tracks(); callback shuffle-tracks(); + callback toggle-multi-select(); + callback bulk-action(string /* select-all | remove-selected | clear */); } // A Qobuz mix detail page (DailyQ / WeeklyQ / FavQ / TopQ), opened