feat(slint): favorites bulk Play next / Add to queue / Add to playlist

Extend the favorites tracks bulk bar beyond Remove: Play next and Add to
queue build the queue from the already-loaded Track objects (new
playback::enqueue_tracks, no re-fetch; play-next inserts in order), and
Add to playlist opens the picker for the whole selection. The picker
gains track-ids for bulk add (open_multi); on_pick adds every id via the
existing add_tracks_to_playlist. Make-offline stays out (no Slint
offline-cache path yet).

cc
This commit is contained in:
vicrodh committed 2026-05-27 08:41:12 -06:00
1 parent 8355f18e0a
commit 07c71adb65
6 files changed
+120 -6

No files matched your search

+18
View File
@@ -765,6 +765,24 @@ pub fn selected_ids(window: &AppWindow) -> Vec<String> {
.collect()
}
/// The selected favorite tracks as full Track objects (for bulk enqueue),
/// in favorites order.
pub fn selected_tracks(window: &AppWindow) -> Vec<Track> {
let ids = selected_ids(window);
if ids.is_empty() {
return Vec::new();
}
FAV_CURRENT
.lock()
.map(|c| {
c.iter()
.filter(|t| ids.contains(&t.id.to_string()))
.cloned()
.collect()
})
.unwrap_or_default()
}
pub fn reset_loading(window: &AppWindow) {
let state = window.global::<FavoritesState>();
state.set_loading(true);
+38 -6
View File
@@ -3586,16 +3586,26 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let Some(w) = weak.upgrade() else {
return;
};
let track_id = w.global::<PlaylistPickerState>().get_track_id().to_string();
w.global::<PlaylistPickerState>().set_open(false);
let (Ok(pid), Ok(tid)) =
(playlist_id.parse::<u64>(), track_id.parse::<u64>())
else {
let picker = w.global::<PlaylistPickerState>();
// Bulk add (favorites) carries track-ids; single add carries
// track-id.
let ids_model = picker.get_track_ids();
let mut ids: Vec<u64> = (0..ids_model.row_count())
.filter_map(|i| ids_model.row_data(i))
.filter_map(|s| s.parse::<u64>().ok())
.collect();
if ids.is_empty() {
if let Ok(tid) = picker.get_track_id().parse::<u64>() {
ids.push(tid);
}
}
picker.set_open(false);
let (Ok(pid), false) = (playlist_id.parse::<u64>(), ids.is_empty()) else {
return;
};
let runtime = runtime.clone();
handle.spawn(async move {
if let Err(e) = runtime.core().add_tracks_to_playlist(pid, &[tid]).await {
if let Err(e) = runtime.core().add_tracks_to_playlist(pid, &ids).await {
log::error!("[qbz-slint] add to playlist failed: {e}");
}
});
@@ -4563,6 +4573,28 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
match action.as_str() {
"select-all" => favorites::select_all(&w),
"clear" => favorites::clear_selection(&w),
"queue" => {
let tracks = favorites::selected_tracks(&w);
playback::enqueue_tracks(runtime.clone(), handle.clone(), tracks, false);
}
"play-next" => {
let tracks = favorites::selected_tracks(&w);
playback::enqueue_tracks(runtime.clone(), handle.clone(), tracks, true);
}
"add-to-playlist" => {
let ids = favorites::selected_ids(&w);
if !ids.is_empty() {
playlist_picker::open_multi(&w, &ids);
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);
});
});
}
}
"remove-selected" => {
let ids = favorites::selected_ids(&w);
if ids.is_empty() {
+48
View File
@@ -842,6 +842,54 @@ pub fn play_track_next(
});
}
/// Append (or insert-next) a batch of already-fetched tracks to the queue
/// without re-fetching them. Used by the favorites bulk bar.
pub fn enqueue_tracks(
runtime: Runtime,
handle: tokio::runtime::Handle,
tracks: Vec<qbz_models::Track>,
next: bool,
) {
if tracks.is_empty() {
return;
}
handle.spawn(async move {
// For "play next" each insert lands right after the current track,
// so reverse the batch to preserve the selection's order.
let ordered: Vec<qbz_models::Track> = if next {
tracks.into_iter().rev().collect()
} else {
tracks
};
for track in ordered {
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();
let qt =
make_queue_track(&track, &album_id, &album_title, &album_artist, &album_artwork);
if next {
runtime.core().add_track_next(qt).await;
} else {
runtime.core().add_track(qt).await;
}
}
refresh_sidebar(false);
});
}
/// Toggle play / pause on the live player.
pub fn toggle_play_pause(runtime: Runtime, handle: tokio::runtime::Handle) {
handle.spawn(async move {
+12
View File
@@ -18,6 +18,18 @@ pub struct PickPlaylist {
pub fn open(window: &AppWindow, track_id: &str) {
let state = window.global::<PlaylistPickerState>();
state.set_track_id(track_id.into());
state.set_track_ids(ModelRc::new(VecModel::from(Vec::<slint::SharedString>::new())));
state.set_playlists(ModelRc::new(VecModel::from(Vec::<PlaylistPickItem>::new())));
state.set_loading(true);
state.set_open(true);
}
/// Open the picker for a batch of track ids (favorites bulk add). UI thread.
pub fn open_multi(window: &AppWindow, ids: &[String]) {
let state = window.global::<PlaylistPickerState>();
state.set_track_id("".into());
let model: Vec<slint::SharedString> = ids.iter().map(|s| s.clone().into()).collect();
state.set_track_ids(ModelRc::new(VecModel::from(model)));
state.set_playlists(ModelRc::new(VecModel::from(Vec::<PlaylistPickItem>::new())));
state.set_loading(true);
state.set_open(true);
@@ -546,6 +546,9 @@ export component FavoritesView inherits Rectangle {
selected-count: FavoritesState.tracks-selected-count;
actions: [
{ id: "select-all", label: @tr("Select all"), danger: false, needs-selection: false },
{ id: "play-next", label: @tr("Play next"), danger: false, needs-selection: true },
{ id: "queue", label: @tr("Add to queue"), danger: false, needs-selection: true },
{ id: "add-to-playlist", label: @tr("Add to playlist"), danger: false, needs-selection: true },
{ id: "remove-selected", label: @tr("Remove from favorites"), danger: true, needs-selection: true },
{ id: "clear", label: @tr("Clear"), danger: false, needs-selection: true },
];
+1
View File
@@ -1411,6 +1411,7 @@ export struct PlaylistPickItem {
export global PlaylistPickerState {
in-out property <bool> open: false;
in property <string> track-id;
in property <[string]> track-ids: []; // bulk add (favorites multi-select)
in property <[PlaylistPickItem]> playlists: [];
in property <bool> loading: false;
}