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:
6 files changed
+120
-6
No files matched your search
@@ -765,6 +765,24 @@ pub fn selected_ids(window: &AppWindow) -> Vec<String> {
|
|||||||
.collect()
|
.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) {
|
pub fn reset_loading(window: &AppWindow) {
|
||||||
let state = window.global::<FavoritesState>();
|
let state = window.global::<FavoritesState>();
|
||||||
state.set_loading(true);
|
state.set_loading(true);
|
||||||
|
|||||||
@@ -3586,16 +3586,26 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let Some(w) = weak.upgrade() else {
|
let Some(w) = weak.upgrade() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let track_id = w.global::<PlaylistPickerState>().get_track_id().to_string();
|
let picker = w.global::<PlaylistPickerState>();
|
||||||
w.global::<PlaylistPickerState>().set_open(false);
|
// Bulk add (favorites) carries track-ids; single add carries
|
||||||
let (Ok(pid), Ok(tid)) =
|
// track-id.
|
||||||
(playlist_id.parse::<u64>(), track_id.parse::<u64>())
|
let ids_model = picker.get_track_ids();
|
||||||
else {
|
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;
|
return;
|
||||||
};
|
};
|
||||||
let runtime = runtime.clone();
|
let runtime = runtime.clone();
|
||||||
handle.spawn(async move {
|
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}");
|
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() {
|
match action.as_str() {
|
||||||
"select-all" => favorites::select_all(&w),
|
"select-all" => favorites::select_all(&w),
|
||||||
"clear" => favorites::clear_selection(&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" => {
|
"remove-selected" => {
|
||||||
let ids = favorites::selected_ids(&w);
|
let ids = favorites::selected_ids(&w);
|
||||||
if ids.is_empty() {
|
if ids.is_empty() {
|
||||||
|
|||||||
@@ -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.
|
/// Toggle play / pause on the live player.
|
||||||
pub fn toggle_play_pause(runtime: Runtime, handle: tokio::runtime::Handle) {
|
pub fn toggle_play_pause(runtime: Runtime, handle: tokio::runtime::Handle) {
|
||||||
handle.spawn(async move {
|
handle.spawn(async move {
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ pub struct PickPlaylist {
|
|||||||
pub fn open(window: &AppWindow, track_id: &str) {
|
pub fn open(window: &AppWindow, track_id: &str) {
|
||||||
let state = window.global::<PlaylistPickerState>();
|
let state = window.global::<PlaylistPickerState>();
|
||||||
state.set_track_id(track_id.into());
|
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_playlists(ModelRc::new(VecModel::from(Vec::<PlaylistPickItem>::new())));
|
||||||
state.set_loading(true);
|
state.set_loading(true);
|
||||||
state.set_open(true);
|
state.set_open(true);
|
||||||
|
|||||||
@@ -546,6 +546,9 @@ export component FavoritesView inherits Rectangle {
|
|||||||
selected-count: FavoritesState.tracks-selected-count;
|
selected-count: FavoritesState.tracks-selected-count;
|
||||||
actions: [
|
actions: [
|
||||||
{ id: "select-all", label: @tr("Select all"), danger: false, needs-selection: false },
|
{ 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: "remove-selected", label: @tr("Remove from favorites"), danger: true, needs-selection: true },
|
||||||
{ id: "clear", label: @tr("Clear"), danger: false, needs-selection: true },
|
{ id: "clear", label: @tr("Clear"), danger: false, needs-selection: true },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1411,6 +1411,7 @@ export struct PlaylistPickItem {
|
|||||||
export global PlaylistPickerState {
|
export global PlaylistPickerState {
|
||||||
in-out property <bool> open: false;
|
in-out property <bool> open: false;
|
||||||
in property <string> track-id;
|
in property <string> track-id;
|
||||||
|
in property <[string]> track-ids: []; // bulk add (favorites multi-select)
|
||||||
in property <[PlaylistPickItem]> playlists: [];
|
in property <[PlaylistPickItem]> playlists: [];
|
||||||
in property <bool> loading: false;
|
in property <bool> loading: false;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user