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
This commit is contained in:
4 files changed
+169
-2
No files matched your search
@@ -447,6 +447,72 @@ pub fn shuffled_tracks() -> Vec<Track> {
|
||||
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::<FavoritesState>().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::<FavoritesState>();
|
||||
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::<FavoritesState>().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::<FavoritesState>();
|
||||
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<String> {
|
||||
let model = window.global::<FavoritesState>().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::<FavoritesState>().set_loading(true);
|
||||
}
|
||||
|
||||
@@ -2361,6 +2361,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let model = match w.global::<NavState>().get_view() {
|
||||
ContentView::Playlist => w.global::<PlaylistState>().get_tracks(),
|
||||
ContentView::Label => w.global::<LabelState>().get_top_tracks(),
|
||||
ContentView::Favorites => {
|
||||
w.global::<FavoritesState>().get_tracks_visible()
|
||||
}
|
||||
_ => w.global::<ArtistState>().get_top_tracks(),
|
||||
};
|
||||
if let Some(vm) = model
|
||||
@@ -2377,8 +2380,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if w.global::<NavState>().get_view() == ContentView::Playlist {
|
||||
playlist::recount_selected(&w);
|
||||
match w.global::<NavState>().get_view() {
|
||||
ContentView::Playlist => playlist::recount_selected(&w),
|
||||
ContentView::Favorites => favorites::recount_selected(&w),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4306,6 +4311,72 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
);
|
||||
});
|
||||
}
|
||||
{
|
||||
// Enter / leave the tracks multi-select edit mode.
|
||||
let weak = window.as_weak();
|
||||
window
|
||||
.global::<FavoritesActions>()
|
||||
.on_toggle_multi_select(move || {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
let on = w.global::<FavoritesState>().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::<FavoritesActions>()
|
||||
.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::<u64>() {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1454,6 +1454,8 @@ export global FavoritesState {
|
||||
in property <[TrackItem]> tracks: [];
|
||||
in property <[TrackItem]> tracks-visible: [];
|
||||
in-out property <string> tracks-search: "";
|
||||
in-out property <bool> tracks-multi-select: false;
|
||||
in property <int> 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
|
||||
|
||||
Reference in new issue
Block a user