fix(queue): wire the Remove all after and Add to playlist row actions
The per-track context menu in the Queue sidebar had two placeholder items with no click handler: "Remove all after" and "Add to playlist". Wire both end to end. - QueueState gains remove-all-after(int) and add-to-playlist(int), resolved from the page-local row index like remove-upcoming (search-filter aware). - Core/player gain remove_upcoming_after(upcoming_index): truncates the upcoming list in PLAY order (shuffle-aware, reuses remove_upcoming_track), clears the stop-after marker if it lands in the removed range, and emits a single QueueUpdated. The pre-existing remove_after (absolute-index, not shuffle-aware) was dead code and stays only for its unit coverage. - Add to playlist reuses the save-as-playlist picker handoff seeded with the single row's track id. cc
This commit is contained in:
6 files changed
+220
-1
No files matched your search
@@ -634,6 +634,21 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
||||
removed
|
||||
}
|
||||
|
||||
/// Remove every upcoming track after `upcoming_index` in the current play
|
||||
/// order; the track at `upcoming_index` is kept. Shuffle-aware. Emits a
|
||||
/// single `QueueUpdated` when anything was removed. Returns the count.
|
||||
pub async fn remove_upcoming_after(&self, upcoming_index: usize) -> usize {
|
||||
let queue = self.queue.write().await;
|
||||
let removed = queue.remove_upcoming_after(upcoming_index);
|
||||
if removed > 0 {
|
||||
self.emit(CoreEvent::QueueUpdated {
|
||||
state: queue.get_state(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// Move a track from one position to another
|
||||
pub async fn move_track(&self, from_index: usize, to_index: usize) -> bool {
|
||||
let queue = self.queue.write().await;
|
||||
|
||||
@@ -379,6 +379,54 @@ impl QueueManager {
|
||||
Some(removed)
|
||||
}
|
||||
|
||||
/// Number of upcoming tracks (those after the current one) in the current
|
||||
/// play order. Mirrors `get_state_full`'s upcoming computation: shuffle-aware
|
||||
/// when shuffle is on, otherwise the tail of `tracks` after `current_index`.
|
||||
fn upcoming_len(state: &InternalState) -> usize {
|
||||
match state.current_index {
|
||||
Some(curr) => {
|
||||
if state.shuffle {
|
||||
state
|
||||
.shuffle_order
|
||||
.len()
|
||||
.saturating_sub(state.shuffle_position + 1)
|
||||
} else {
|
||||
state.tracks.len().saturating_sub(curr + 1)
|
||||
}
|
||||
}
|
||||
None => state.tracks.len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove every upcoming track positioned AFTER `upcoming_index` in the
|
||||
/// current play order; the track at `upcoming_index` is kept. Works in
|
||||
/// UPCOMING space (not absolute `tracks` indices), so it stays correct under
|
||||
/// shuffle by reusing `remove_upcoming_track`, which resolves upcoming
|
||||
/// positions through `shuffle_order`. Peels positions off the tail inward so
|
||||
/// the surviving positions never shift under it. Returns the count removed.
|
||||
///
|
||||
/// This is the wired "Remove all after" queue action. (`remove_after`, below,
|
||||
/// truncates by absolute `tracks` index and is NOT play-order-aware under
|
||||
/// shuffle — it is kept only for its existing unit coverage.)
|
||||
pub fn remove_upcoming_after(&self, upcoming_index: usize) -> usize {
|
||||
let mut upcoming_len = {
|
||||
let state = self.state.lock().unwrap();
|
||||
Self::upcoming_len(&state)
|
||||
};
|
||||
if upcoming_index + 1 >= upcoming_len {
|
||||
return 0;
|
||||
}
|
||||
let mut removed = 0usize;
|
||||
while upcoming_len > upcoming_index + 1 {
|
||||
if self.remove_upcoming_track(upcoming_len - 1).is_none() {
|
||||
break;
|
||||
}
|
||||
removed += 1;
|
||||
upcoming_len -= 1;
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// Remove all tracks at indices greater than `index`. The track at
|
||||
/// `index` is preserved. Returns the number of tracks removed.
|
||||
/// If the marker referenced a track in the removed range, the marker
|
||||
@@ -2085,6 +2133,86 @@ mod tests {
|
||||
assert_eq!(queue.get_stop_after(), Some(102));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_upcoming_after_linear() {
|
||||
let queue = QueueManager::new();
|
||||
// 101 playing, upcoming = [102, 103, 104, 105].
|
||||
queue.set_queue(
|
||||
(101..=105).map(create_test_track).collect(),
|
||||
Some(0),
|
||||
);
|
||||
|
||||
// Keep upcoming positions 0..=1 (102, 103); drop 2, 3 (104, 105).
|
||||
let removed = queue.remove_upcoming_after(1);
|
||||
|
||||
assert_eq!(removed, 2);
|
||||
let state = queue.get_state_full();
|
||||
assert_eq!(state.current_track.map(|t| t.id), Some(101));
|
||||
assert_eq!(
|
||||
state.upcoming.iter().map(|t| t.id).collect::<Vec<_>>(),
|
||||
vec![102, 103]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_upcoming_after_on_last_upcoming_is_noop() {
|
||||
let queue = QueueManager::new();
|
||||
queue.set_queue((101..=103).map(create_test_track).collect(), Some(0));
|
||||
// Upcoming = [102, 103]; position 1 is the last, nothing after it.
|
||||
let removed = queue.remove_upcoming_after(1);
|
||||
assert_eq!(removed, 0);
|
||||
assert_eq!(queue.get_state().total_tracks, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_upcoming_after_no_current_track() {
|
||||
let queue = QueueManager::new();
|
||||
// No current index -> every track is upcoming.
|
||||
queue.set_queue((101..=104).map(create_test_track).collect(), None);
|
||||
let removed = queue.remove_upcoming_after(0);
|
||||
assert_eq!(removed, 3);
|
||||
let state = queue.get_state_full();
|
||||
assert_eq!(
|
||||
state.upcoming.iter().map(|t| t.id).collect::<Vec<_>>(),
|
||||
vec![101]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_upcoming_after_respects_shuffle_play_order() {
|
||||
let queue = QueueManager::new();
|
||||
// tracks: 101..105 at indices 0..4. Shuffle play order = [2,0,4,1,3]
|
||||
// -> 103(current), then upcoming 101, 105, 102, 104.
|
||||
queue.set_queue_with_order(
|
||||
(101..=105).map(create_test_track).collect(),
|
||||
Some(2),
|
||||
true,
|
||||
Some(vec![2, 0, 4, 1, 3]),
|
||||
);
|
||||
|
||||
// Keep upcoming positions 0..=1 (101, 105); drop 2, 3 (102, 104).
|
||||
let removed = queue.remove_upcoming_after(1);
|
||||
|
||||
assert_eq!(removed, 2);
|
||||
let state = queue.get_state_full();
|
||||
assert_eq!(state.current_track.map(|t| t.id), Some(103));
|
||||
assert_eq!(
|
||||
state.upcoming.iter().map(|t| t.id).collect::<Vec<_>>(),
|
||||
vec![101, 105]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_upcoming_after_invalidates_marker_in_removed_range() {
|
||||
let queue = QueueManager::new();
|
||||
queue.set_queue((101..=105).map(create_test_track).collect(), Some(0));
|
||||
queue.set_stop_after(104); // upcoming position 2 -> removed by after(1)
|
||||
|
||||
queue.remove_upcoming_after(1);
|
||||
|
||||
assert_eq!(queue.get_stop_after(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_state_includes_stop_after() {
|
||||
let queue = QueueManager::new();
|
||||
|
||||
@@ -124,6 +124,8 @@ component QueueRow inherits Rectangle {
|
||||
in property <bool> removable: false;
|
||||
callback clicked();
|
||||
callback remove();
|
||||
callback remove-all-after();
|
||||
callback add-to-playlist();
|
||||
|
||||
// Drag-reorder (UP NEXT only). `page-local-index` is the 0-based row within
|
||||
// the visible page (NOT the page-global display `index`). The parent owns the
|
||||
@@ -305,7 +307,7 @@ component QueueRow inherits Rectangle {
|
||||
}
|
||||
|
||||
// Per-track context menu — 1:1 with the Tauri queue track menu.
|
||||
// Only "Remove from queue" is wired; the rest are placeholders.
|
||||
// "Track info" is the only remaining placeholder (no app-wide panel yet).
|
||||
track-menu := PopupWindow {
|
||||
x: root.width - 200px;
|
||||
y: 44px;
|
||||
@@ -333,10 +335,18 @@ component QueueRow inherits Rectangle {
|
||||
ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/list-x.svg");
|
||||
label: @tr("Remove all after");
|
||||
clicked => {
|
||||
root.remove-all-after();
|
||||
track-menu.close();
|
||||
}
|
||||
}
|
||||
if !root.item.is-ephemeral: ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/list-plus.svg");
|
||||
label: @tr("Add to playlist");
|
||||
clicked => {
|
||||
root.add-to-playlist();
|
||||
track-menu.close();
|
||||
}
|
||||
}
|
||||
if !root.item.is-ephemeral: ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/info.svg");
|
||||
@@ -608,6 +618,12 @@ export component QueueSidebar inherits Rectangle {
|
||||
remove => {
|
||||
QueueState.remove-upcoming(idx);
|
||||
}
|
||||
remove-all-after => {
|
||||
QueueState.remove-all-after(idx);
|
||||
}
|
||||
add-to-playlist => {
|
||||
QueueState.add-to-playlist(idx);
|
||||
}
|
||||
drag-begin(local-idx, row-top) => {
|
||||
root.reorder-active = true;
|
||||
root.reorder-from = local-idx;
|
||||
|
||||
@@ -4418,6 +4418,12 @@ export global QueueState {
|
||||
callback play-history(int);
|
||||
// Remove the upcoming entry at `index` within the current page slice.
|
||||
callback remove-upcoming(int);
|
||||
// Remove every upcoming entry after the page-local `index` (that row is
|
||||
// kept). The Rust controller resolves it to a queue-wide upcoming index.
|
||||
callback remove-all-after(int);
|
||||
// Open the Add-to-Playlist picker seeded with the upcoming row at the
|
||||
// page-local `index`.
|
||||
callback add-to-playlist(int);
|
||||
// Reorder the upcoming list: move the page-local row `from` to the insertion
|
||||
// slot `to` (0 = before the first upcoming row, N = after the last). The Rust
|
||||
// controller resolves the page-local indices to queue-wide upcoming indices
|
||||
|
||||
@@ -12169,6 +12169,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let c = controller.clone();
|
||||
qs.on_remove_upcoming(move |index| c.remove_upcoming(index.max(0) as usize));
|
||||
}
|
||||
{
|
||||
let c = controller.clone();
|
||||
qs.on_remove_all_after(move |index| c.remove_all_after(index.max(0) as usize));
|
||||
}
|
||||
{
|
||||
let c = controller.clone();
|
||||
qs.on_add_to_playlist(move |index| c.add_to_playlist(index.max(0) as usize));
|
||||
}
|
||||
{
|
||||
let c = controller.clone();
|
||||
qs.on_reorder(move |from, to| {
|
||||
|
||||
@@ -596,6 +596,25 @@ impl QueueController {
|
||||
});
|
||||
}
|
||||
|
||||
/// Remove every upcoming track after the row at page-local `page_index`
|
||||
/// (that row is kept). Resolves the page-local index to a queue-wide
|
||||
/// upcoming index first (honoring the search filter), then truncates the
|
||||
/// upcoming list in play order. Mirrors `remove_upcoming`'s handling.
|
||||
pub fn remove_all_after(&self, page_index: usize) {
|
||||
let this = self.clone();
|
||||
self.handle.spawn(async move {
|
||||
let Some(upcoming_index) = this.resolve_upcoming_index(page_index).await else {
|
||||
log::warn!("[qbz-slint] queue: remove_all_after {page_index} out of range");
|
||||
return;
|
||||
};
|
||||
this.runtime
|
||||
.core()
|
||||
.remove_upcoming_after(upcoming_index)
|
||||
.await;
|
||||
this.refresh_async().await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Reorder the upcoming list: move the page-local row `from_page` to the
|
||||
/// insertion slot `to_slot` (0..=page_len). Resolves BOTH to queue-wide
|
||||
/// upcoming indices (honoring the search filter), then routes: connected ->
|
||||
@@ -861,6 +880,33 @@ impl QueueController {
|
||||
});
|
||||
}
|
||||
|
||||
/// Open the Add-to-Playlist picker seeded with a single upcoming row (the
|
||||
/// track at page-local `page_index`). Reuses `save_as_playlist`'s picker
|
||||
/// handoff with just that one track — matching the per-track "Add to
|
||||
/// playlist" action in the album/track menus.
|
||||
pub fn add_to_playlist(&self, page_index: usize) {
|
||||
let this = self.clone();
|
||||
self.handle.spawn(async move {
|
||||
let Some(upcoming_index) = this.resolve_upcoming_index(page_index).await else {
|
||||
log::warn!("[qbz-slint] queue: add_to_playlist {page_index} out of range");
|
||||
return;
|
||||
};
|
||||
let state = this.runtime.core().get_queue_state_full().await;
|
||||
let Some(track) = state.upcoming.get(upcoming_index) else {
|
||||
log::warn!(
|
||||
"[qbz-slint] queue: add_to_playlist {page_index} -> no upcoming track"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let ids_str = vec![track.id.to_string()];
|
||||
let runtime = this.runtime.clone();
|
||||
let handle = this.handle.clone();
|
||||
let _ = this.weak.upgrade_in_event_loop(move |w| {
|
||||
crate::playlist_picker::open_for_ids(&w, runtime, &handle, ids_str, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Move to the previous paginator page.
|
||||
pub fn prev_page(&self) {
|
||||
if let Ok(mut view) = self.view.lock() {
|
||||
|
||||
Reference in new issue
Block a user