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:
vicrodh committed 2026-07-08 18:38:27 -06:00
1 parent a84519c10e
commit 222001a9eb
6 files changed
+220 -1

No files matched your search

+15
View File
@@ -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;
+128
View File
@@ -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();
+17 -1
View File
@@ -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;
+6
View File
@@ -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
+8
View File
@@ -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| {
+46
View File
@@ -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() {