From ad6c98ff7d73d48a50978162de4d29059deddcc3 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 26 Jul 2026 21:08:12 -0600 Subject: [PATCH] =?UTF-8?q?feat(queue):=20Play=20later=20everywhere=20?= =?UTF-8?q?=E2=80=94=20every=20context=20menu=20and=20bulk=20bar=20(#442)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends Play later (end of the manual block) from track rows to every surface that already offered Play next / Add to queue: - playback.rs: enqueue_album_later, enqueue_playlist_later (mixed sidecar interleave), enqueue_artist_top_later, enqueue_tracks_later, enqueue_local_tracks_later — all NATURAL order (each add_track_later appends, no reverse), QConnect controller routing via play_later_batch_on_peer_if_active, and post-add queue sync. - local_playlist::enqueue_later_by_id (source-aware rows, offline-only stamp preserved), myqbz_play RowMode::PlayLater + BulkEnqueueMode. - 19 dispatch arms in main.rs (album/playlist/artist/label/mix/ favorites/local-library/album-page/disc/myqbz dialects). - 12 context menus + 10 bulk bars in .slint: list-plus icon between Play next and Add to queue, popup heights bumped. The immersive tap-action dropdown is deliberately untouched (it is a configurable single-tap behavior setting, not a context menu). km --- crates/qbz-ui/ui/album/AlbumPageView.slint | 1 + crates/qbz-ui/ui/artist/ArtistPageView.slint | 1 + crates/qbz-ui/ui/discover/AlbumCard.slint | 11 +- crates/qbz-ui/ui/discover/PlaylistCard.slint | 13 +- crates/qbz-ui/ui/discover/TrackCard.slint | 13 +- .../qbz-ui/ui/favorites/FavoritesView.slint | 17 +- crates/qbz-ui/ui/label/LabelPageView.slint | 25 +- .../ui/locallibrary/LocalLibraryView.slint | 10 + crates/qbz-ui/ui/mix/MixView.slint | 1 + .../qbz-ui/ui/myqbz/MixtapeDetailView.slint | 21 +- crates/qbz-ui/ui/playlist/PlaylistView.slint | 9 +- .../ui/primitives/AlbumContextMenu.slint | 15 +- .../qbz-ui/ui/primitives/AlbumListRow.slint | 13 +- .../ui/primitives/DiscContextMenu.slint | 12 +- .../qbz-ui/ui/primitives/DiscHeaderMenu.slint | 2 +- .../ui/primitives/PlaylistListRow.slint | 10 +- .../qbz-ui/ui/shell/TransportControls.slint | 11 +- crates/qbz-ui/ui/state.slint | 37 +-- crates/qbz/src/local_playlist.rs | 54 +++- crates/qbz/src/main.rs | 222 ++++++++++++++- crates/qbz/src/myqbz_play.rs | 126 +++++++-- crates/qbz/src/playback.rs | 253 +++++++++++++++++- 22 files changed, 785 insertions(+), 92 deletions(-) diff --git a/crates/qbz-ui/ui/album/AlbumPageView.slint b/crates/qbz-ui/ui/album/AlbumPageView.slint index e893740ec..71a7093b9 100644 --- a/crates/qbz-ui/ui/album/AlbumPageView.slint +++ b/crates/qbz-ui/ui/album/AlbumPageView.slint @@ -794,6 +794,7 @@ export component AlbumPageView inherits Rectangle { actions: [ { id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false }, { id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true }, + { id: "play-later", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true }, { id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true }, { id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true }, { id: "add-to-favorites", label: @tr("Add to Library"), icon: @image-url("../assets/icons/heart.svg"), danger: false, needs-selection: true }, diff --git a/crates/qbz-ui/ui/artist/ArtistPageView.slint b/crates/qbz-ui/ui/artist/ArtistPageView.slint index 529fb1674..4bc2090ab 100644 --- a/crates/qbz-ui/ui/artist/ArtistPageView.slint +++ b/crates/qbz-ui/ui/artist/ArtistPageView.slint @@ -817,6 +817,7 @@ export component ArtistPageView inherits Rectangle { actions: [ { id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false }, { id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true }, + { id: "play-later", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true }, { id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true }, { id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true }, { id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true }, diff --git a/crates/qbz-ui/ui/discover/AlbumCard.slint b/crates/qbz-ui/ui/discover/AlbumCard.slint index 267f15a57..c0b5b57c5 100644 --- a/crates/qbz-ui/ui/discover/AlbumCard.slint +++ b/crates/qbz-ui/ui/discover/AlbumCard.slint @@ -382,8 +382,8 @@ export component AlbumCard inherits Rectangle { x: more-btn.absolute-position.x + more-btn.width / 2 - artwork-box.absolute-position.x; y: more-btn.absolute-position.y + more-btn.height / 2 - artwork-box.absolute-position.y; width: 196px; - // 6 items max (Open/Play/Play-next/Queue + favorite + block). - height: 6 * 33px + 10px; + // 7 items max (Open/Play/Play-next/Play-later/Queue + favorite + block). + height: 7 * 33px + 10px; ContextMenu { ContextMenuItem { icon: @image-url("../assets/icons/library-big.svg"); @@ -406,6 +406,13 @@ export component AlbumCard inherits Rectangle { root.media-action("album", root.album.id, "play-next"); } } + ContextMenuItem { + icon: @image-url("../assets/icons/list-plus.svg"); + label: @tr("Play later"); + clicked => { + root.media-action("album", root.album.id, "play-later"); + } + } ContextMenuItem { icon: @image-url("../assets/icons/list-end.svg"); label: @tr("Add to queue"); diff --git a/crates/qbz-ui/ui/discover/PlaylistCard.slint b/crates/qbz-ui/ui/discover/PlaylistCard.slint index 09922b4c1..f8d10809f 100644 --- a/crates/qbz-ui/ui/discover/PlaylistCard.slint +++ b/crates/qbz-ui/ui/discover/PlaylistCard.slint @@ -49,7 +49,7 @@ component OverlayButton inherits Rectangle { export component PlaylistCard inherits Rectangle { in property playlist; // kind ("playlist"), playlist id, action ("play" | "play-next" | - // "queue" | "favorite" | "open"). + // "play-later" | "queue" | "favorite" | "open"). callback media-action(string, string, string); // When true, a click on the card BODY opens the playlist ("open") // instead of playing it — the Favorites grid wants body=open, the play @@ -188,8 +188,8 @@ export component PlaylistCard inherits Rectangle { // + 33px/row) so the panel never clips or over-reserves. playlist-menu := PopupWindow { property item-count: root.playlist.is-owned - ? 4 - : (root.playlist.is-copied ? 4 : 5); + ? 5 + : (root.playlist.is-copied ? 5 : 6); x: 26px; y: 56px; width: 196px; @@ -209,6 +209,13 @@ export component PlaylistCard inherits Rectangle { root.media-action("playlist", root.playlist.id, "play-next"); } } + ContextMenuItem { + icon: @image-url("../assets/icons/list-plus.svg"); + label: @tr("Play later"); + clicked => { + root.media-action("playlist", root.playlist.id, "play-later"); + } + } ContextMenuItem { icon: @image-url("../assets/icons/list-end.svg"); label: @tr("Add to queue"); diff --git a/crates/qbz-ui/ui/discover/TrackCard.slint b/crates/qbz-ui/ui/discover/TrackCard.slint index eea526f36..f07abd307 100644 --- a/crates/qbz-ui/ui/discover/TrackCard.slint +++ b/crates/qbz-ui/ui/discover/TrackCard.slint @@ -79,7 +79,7 @@ export component TrackCard inherits Rectangle { animate opacity { duration: 250ms; } callback clicked(string); // kind ("track"), track id, action ("play" | "queue" | "play-next" | - // "favorite" | "open"). + // "play-later" | "favorite" | "open"). callback media-action(string, string, string); in property card-height: 246px; @@ -202,8 +202,8 @@ export component TrackCard inherits Rectangle { x: 26px; y: 56px; width: 196px; - // Play / Play-next / Queue / Go-to-artist / Go-to-album / favorite. - height: 6 * 33px + 10px; + // Play / Play-next / Play-later / Queue / Go-to-artist / Go-to-album / favorite. + height: 7 * 33px + 10px; ContextMenu { ContextMenuItem { icon: @image-url("../assets/icons/play.svg"); @@ -219,6 +219,13 @@ export component TrackCard inherits Rectangle { root.media-action("track", root.track.id, "play-next"); } } + ContextMenuItem { + icon: @image-url("../assets/icons/list-plus.svg"); + label: @tr("Play later"); + clicked => { + root.media-action("track", root.track.id, "play-later"); + } + } ContextMenuItem { icon: @image-url("../assets/icons/list-end.svg"); label: @tr("Add to queue"); diff --git a/crates/qbz-ui/ui/favorites/FavoritesView.slint b/crates/qbz-ui/ui/favorites/FavoritesView.slint index f2b5982e2..73ba7aa17 100644 --- a/crates/qbz-ui/ui/favorites/FavoritesView.slint +++ b/crates/qbz-ui/ui/favorites/FavoritesView.slint @@ -1467,9 +1467,9 @@ export component FavoritesView inherits Rectangle { x: row-rect.menu-x; y: row-rect.menu-y; width: 196px; - height: (item.kind == "track" ? 6 - : item.kind == "album" ? 6 - : item.kind == "playlist" ? 4 + height: (item.kind == "track" ? 7 + : item.kind == "album" ? 7 + : item.kind == "playlist" ? 5 : 1) * 33px + 10px; ContextMenu { if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem { @@ -1490,6 +1490,15 @@ export component FavoritesView inherits Rectangle { else { FavoritesActions.playlist-action(item.id, "play-next"); } } } + if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem { + icon: @image-url("../assets/icons/list-plus.svg"); + label: @tr("Play later"); + clicked => { + if (item.kind == "track") { FavoritesActions.track-action(item.id, "play-later"); } + else if (item.kind == "album") { FavoritesActions.album-action(item.id, "play-later"); } + else { FavoritesActions.playlist-action(item.id, "play-later"); } + } + } if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem { icon: @image-url("../assets/icons/list-end.svg"); label: @tr("Add to queue"); @@ -1563,6 +1572,7 @@ export component FavoritesView inherits Rectangle { actions: [ { id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false }, { id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true }, + { id: "play-later", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true }, { id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true }, { id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true }, { id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true }, @@ -1727,6 +1737,7 @@ export component FavoritesView inherits Rectangle { actions: [ { id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false }, { id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true }, + { id: "play-later", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true }, { id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true }, { id: "remove-selected", label: @tr("Remove from Library"), icon: @image-url("../assets/icons/heart-crack.svg"), danger: true, needs-selection: true }, { id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true }, diff --git a/crates/qbz-ui/ui/label/LabelPageView.slint b/crates/qbz-ui/ui/label/LabelPageView.slint index 5d45dde85..4f2ab585a 100644 --- a/crates/qbz-ui/ui/label/LabelPageView.slint +++ b/crates/qbz-ui/ui/label/LabelPageView.slint @@ -201,9 +201,9 @@ export component LabelPageView inherits Rectangle { root.media-action("label", LabelState.id, "shuffle"); } } - // ⋯ Overflow — Play next / Add to queue / Share (the - // Qobuz web label menu). Share is Qobuz-link only: - // Song.link/Album.link have no label equivalent. + // ⋯ Overflow — Play next / Play later / Add to queue / + // Share (the Qobuz web label menu). Share is Qobuz-link + // only: Song.link/Album.link have no label equivalent. Rectangle { width: 32px; height: 32px; @@ -222,7 +222,7 @@ export component LabelPageView inherits Rectangle { x: 0px; y: 38px; width: 224px; - height: 3 * 33px + 10px; + height: 4 * 33px + 10px; close-policy: close-on-click; ContextMenu { ContextMenuItem { @@ -232,6 +232,13 @@ export component LabelPageView inherits Rectangle { root.media-action("label", "", "top-play-next"); } } + ContextMenuItem { + icon: @image-url("../assets/icons/list-plus.svg"); + label: @tr("Play later"); + clicked => { + root.media-action("label", "", "top-play-later"); + } + } ContextMenuItem { icon: @image-url("../assets/icons/list-end.svg"); label: @tr("Add to queue"); @@ -363,7 +370,7 @@ export component LabelPageView inherits Rectangle { x: 32px - self.width; y: 38px; width: 224px; - height: 2 * 33px + 10px; + height: 3 * 33px + 10px; close-policy: close-on-click; ContextMenu { ContextMenuItem { @@ -373,6 +380,13 @@ export component LabelPageView inherits Rectangle { root.media-action("label", "", "top-play-next"); } } + ContextMenuItem { + icon: @image-url("../assets/icons/list-plus.svg"); + label: @tr("Play later"); + clicked => { + root.media-action("label", "", "top-play-later"); + } + } ContextMenuItem { icon: @image-url("../assets/icons/list-end.svg"); label: @tr("Add to queue"); @@ -396,6 +410,7 @@ export component LabelPageView inherits Rectangle { actions: [ { id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false }, { id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true }, + { id: "play-later", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true }, { id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true }, { id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true }, { id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true }, diff --git a/crates/qbz-ui/ui/locallibrary/LocalLibraryView.slint b/crates/qbz-ui/ui/locallibrary/LocalLibraryView.slint index c7ab24919..c2612dcf7 100644 --- a/crates/qbz-ui/ui/locallibrary/LocalLibraryView.slint +++ b/crates/qbz-ui/ui/locallibrary/LocalLibraryView.slint @@ -1241,6 +1241,7 @@ export component LocalLibraryView inherits Rectangle { actions: [ { id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false }, { id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true }, + { id: "play-later", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true }, { id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true }, { id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true }, { id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true }, @@ -1407,6 +1408,7 @@ export component LocalLibraryView inherits Rectangle { actions: [ { id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false }, { id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true }, + { id: "play-later", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true }, { id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true }, { id: "remove-favorites", label: @tr("Remove from favorites"), icon: @image-url("../assets/icons/heart-crack.svg"), danger: true, needs-selection: true }, { id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true }, @@ -1808,6 +1810,14 @@ export component LocalLibraryView inherits Rectangle { clicked => { LocalLibraryActions.folders-bulk-action("play-next"); } } } + VerticalLayout { + alignment: center; + RailBulkBtn { + icon: @image-url("../assets/icons/list-plus.svg"); + tooltip: @tr("Play later"); + clicked => { LocalLibraryActions.folders-bulk-action("play-later"); } + } + } VerticalLayout { alignment: center; RailBulkBtn { diff --git a/crates/qbz-ui/ui/mix/MixView.slint b/crates/qbz-ui/ui/mix/MixView.slint index 84f88ac8b..32160ae41 100644 --- a/crates/qbz-ui/ui/mix/MixView.slint +++ b/crates/qbz-ui/ui/mix/MixView.slint @@ -220,6 +220,7 @@ export component MixView inherits Rectangle { actions: [ { id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false }, { id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true }, + { id: "play-later", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true }, { id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true }, { id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true }, { id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true }, diff --git a/crates/qbz-ui/ui/myqbz/MixtapeDetailView.slint b/crates/qbz-ui/ui/myqbz/MixtapeDetailView.slint index 7cd5234bc..ac6b88fbc 100644 --- a/crates/qbz-ui/ui/myqbz/MixtapeDetailView.slint +++ b/crates/qbz-ui/ui/myqbz/MixtapeDetailView.slint @@ -440,9 +440,9 @@ component DetailRow inherits Rectangle { width: 184px; // Explicit height: without it the PopupWindow's box does not // resolve and the in-flow rows below paint over it (the only - // PopupWindow in the codebase that omitted this). 4 items - // (32px) + 1 separator (1px) + padding (8px) + spacing (4px). - height: 145px; + // PopupWindow in the codebase that omitted this). 5 items + // (32px) + 1 separator (1px) + padding (8px) + spacing (5px). + height: 178px; // Act-and-dismiss menu — match the app idiom (close on any // click; items also act before the implicit close). close-policy: PopupClosePolicy.close-on-click; @@ -466,6 +466,11 @@ component DetailRow inherits Rectangle { label: @tr("Play next"); chosen => { MyQbzDetailActions.item-action(root.item.source-item-id, "play-next"); } } + RowMenuItem { + icon: @image-url("../assets/icons/list-plus.svg"); + label: @tr("Play later"); + chosen => { MyQbzDetailActions.item-action(root.item.source-item-id, "play-later"); } + } RowMenuItem { icon: @image-url("../assets/icons/list-end.svg"); label: @tr("Add to queue"); @@ -1233,10 +1238,11 @@ export component MixtapeDetailView inherits Rectangle { // Spec 12 §13: a bottom-docked bar that appears while items are selected // (placement='bottom' default). Mirrors the local-library MultiSelectBar // chrome (a flat [BulkAction] row — the §13.1 split-queue dropdown isn't - // expressible in MultiSelectBar, so its two actions are flattened to the - // adjacent "Add to queue" + "Play next" buttons, same idiom the Local - // Library tracks bar uses). The §13.1 action set, in order: Add to queue - // (play-later), Play next, Add to playlist, Add to Mixtape/Collection, + // expressible in MultiSelectBar, so its queue actions are flattened to + // adjacent buttons, same idiom the Local Library tracks bar uses). The + // action set, in order: Add to queue, Play later (#442 — the manual-block + // tail; §13.1's own "play-later" label mapped to plain append before + // #442 existed), Play next, Add to playlist, Add to Mixtape/Collection, // Remove from this collection (danger), Clear (exit-select). The bar emits // the id; the Rust side routes it. if MyQbzDetailState.select-mode && MyQbzDetailState.selected-count > 0: Rectangle { @@ -1251,6 +1257,7 @@ export component MixtapeDetailView inherits Rectangle { selected-count: MyQbzDetailState.selected-count; actions: [ { id: "add-to-queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true }, + { id: "play-later", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true }, { id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true }, { id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true }, { id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true }, diff --git a/crates/qbz-ui/ui/playlist/PlaylistView.slint b/crates/qbz-ui/ui/playlist/PlaylistView.slint index 1c54c8cfd..5234d56c4 100644 --- a/crates/qbz-ui/ui/playlist/PlaylistView.slint +++ b/crates/qbz-ui/ui/playlist/PlaylistView.slint @@ -310,12 +310,14 @@ export component PlaylistView inherits Rectangle { property show-upload-confirm: false; // Bulk-bar action sets (typed properties — a ternary over bare array - // literals can't coerce to [BulkAction]). Tauri parity (spec §1.5): - // Play next / Add to queue / Add to playlist over the selection, - // Remove owner-only — followed playlists keep the queueing actions. + // literals can't coerce to [BulkAction]). Tauri parity (spec §1.5) + #442: + // Play next / Play later / Add to queue / Add to playlist over the + // selection, Remove owner-only — followed playlists keep the queueing + // actions. property <[BulkAction]> bulk-actions-owner: [ { id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false }, { id: "play-next-selected", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true }, + { id: "play-later-selected", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true }, { id: "queue-selected", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true }, { id: "add-selected-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true }, { id: "remove-selected", label: @tr("Remove"), icon: @image-url("../assets/icons/trash-2.svg"), danger: true, needs-selection: true }, @@ -323,6 +325,7 @@ export component PlaylistView inherits Rectangle { property <[BulkAction]> bulk-actions-follower: [ { id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false }, { id: "play-next-selected", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true }, + { id: "play-later-selected", label: @tr("Play later"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true }, { id: "queue-selected", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true }, { id: "add-selected-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true }, ]; diff --git a/crates/qbz-ui/ui/primitives/AlbumContextMenu.slint b/crates/qbz-ui/ui/primitives/AlbumContextMenu.slint index 94d82ab4a..f79bed498 100644 --- a/crates/qbz-ui/ui/primitives/AlbumContextMenu.slint +++ b/crates/qbz-ui/ui/primitives/AlbumContextMenu.slint @@ -6,6 +6,7 @@ // // Item -> media-action("album", , ) mapping: // Play next -> "play-next" +// Play later -> "play-later" // Add to queue -> "queue" // -- // Add to playlist -> "add-to-playlist" @@ -39,11 +40,11 @@ export component AlbumContextMenu inherits PopupWindow { callback media-action(string, string, string); width: 224px; - // 9 rows x 33px + 4 separators x 1px + ContextMenu's 5/5 padding. The + // 10 rows x 33px + 4 separators x 1px + ContextMenu's 5/5 padding. The // offline row swaps label/action on `fully-cached` (count constant); the // block row swaps label/action on `is-blocked` (count constant); the pin // row swaps label/action on `is-pinned` (count constant). - height: 9 * 33px + 4 * 1px + 10px; + height: 10 * 33px + 4 * 1px + 10px; // Default close-policy is close-on-click, which races the row's TouchArea: // the popup can dismiss on the press before the item's `clicked` fires, so // some rows (e.g. Share Qobuz link) needed a second open+click to register. @@ -60,6 +61,16 @@ export component AlbumContextMenu inherits PopupWindow { root.close(); } } + // #442 "Play later" — the END of the manual block (after every + // play-next / play-later already queued, before the source resumes). + ContextMenuItem { + icon: @image-url("../assets/icons/list-plus.svg"); + label: @tr("Play later"); + clicked => { + root.media-action("album", root.album-id, "play-later"); + root.close(); + } + } ContextMenuItem { icon: @image-url("../assets/icons/list-end.svg"); label: @tr("Add to queue"); diff --git a/crates/qbz-ui/ui/primitives/AlbumListRow.slint b/crates/qbz-ui/ui/primitives/AlbumListRow.slint index 56c21479b..7895934e1 100644 --- a/crates/qbz-ui/ui/primitives/AlbumListRow.slint +++ b/crates/qbz-ui/ui/primitives/AlbumListRow.slint @@ -150,7 +150,7 @@ export component AlbumListRow inherits Rectangle { callback clicked(string); // kind ("album" | "artist"), id, action ("play" | "queue" | - // "play-next" | "favorite" | "open"). + // "play-next" | "play-later" | "favorite" | "open"). callback media-action(string, string, string); // Hover spans the row body + the art / artist / overflow affordances so @@ -382,8 +382,8 @@ export component AlbumListRow inherits Rectangle { x: -170px; y: 34px; width: 196px; - // 6 items max (Open/Play/Play-next/Queue/favorite + block). - height: 6 * 33px + 10px; + // 7 items max (Open/Play/Play-next/Play-later/Queue/favorite + block). + height: 7 * 33px + 10px; ContextMenu { ContextMenuItem { icon: @image-url("../assets/icons/library-big.svg"); @@ -406,6 +406,13 @@ export component AlbumListRow inherits Rectangle { root.media-action("album", root.album.id, "play-next"); } } + ContextMenuItem { + icon: @image-url("../assets/icons/list-plus.svg"); + label: @tr("Play later"); + clicked => { + root.media-action("album", root.album.id, "play-later"); + } + } ContextMenuItem { icon: @image-url("../assets/icons/list-end.svg"); label: @tr("Add to queue"); diff --git a/crates/qbz-ui/ui/primitives/DiscContextMenu.slint b/crates/qbz-ui/ui/primitives/DiscContextMenu.slint index 4e19ee801..3311feb66 100644 --- a/crates/qbz-ui/ui/primitives/DiscContextMenu.slint +++ b/crates/qbz-ui/ui/primitives/DiscContextMenu.slint @@ -11,6 +11,7 @@ // Shuffle -> disc-action(disc, "shuffle") // -- // Add to queue -> disc-action(disc, "queue") +// Play later -> disc-action(disc, "play-later") // Play next -> disc-action(disc, "play-next") import { Theme } from "../foundation/semantic-colors.slint"; @@ -22,8 +23,8 @@ export component DiscContextMenu inherits PopupWindow { callback disc-action(int, string); width: 200px; - // 4 rows x 33px + 1 separator x 1px + ContextMenu's 5/5 padding. - height: 4 * 33px + 1 * 1px + 10px; + // 5 rows x 33px + 1 separator x 1px + ContextMenu's 5/5 padding. + height: 5 * 33px + 1 * 1px + 10px; ContextMenu { ContextMenuItem { @@ -48,6 +49,13 @@ export component DiscContextMenu inherits PopupWindow { root.disc-action(root.disc, "queue"); } } + ContextMenuItem { + icon: @image-url("../assets/icons/list-plus.svg"); + label: @tr("Play later"); + clicked => { + root.disc-action(root.disc, "play-later"); + } + } ContextMenuItem { icon: @image-url("../assets/icons/list-start.svg"); label: @tr("Play next"); diff --git a/crates/qbz-ui/ui/primitives/DiscHeaderMenu.slint b/crates/qbz-ui/ui/primitives/DiscHeaderMenu.slint index 19061d7c9..ac80552a7 100644 --- a/crates/qbz-ui/ui/primitives/DiscHeaderMenu.slint +++ b/crates/qbz-ui/ui/primitives/DiscHeaderMenu.slint @@ -13,7 +13,7 @@ import { DiscContextMenu } from "DiscContextMenu.slint"; export component DiscHeaderMenu inherits Rectangle { in property disc; - // (disc, action) — action: "play" | "shuffle" | "queue" | "play-next". + // (disc, action) — action: "play" | "shuffle" | "queue" | "play-later" | "play-next". callback disc-action(int, string); width: 32px; diff --git a/crates/qbz-ui/ui/primitives/PlaylistListRow.slint b/crates/qbz-ui/ui/primitives/PlaylistListRow.slint index 6aa516543..56c55a311 100644 --- a/crates/qbz-ui/ui/primitives/PlaylistListRow.slint +++ b/crates/qbz-ui/ui/primitives/PlaylistListRow.slint @@ -115,7 +115,8 @@ export component PlaylistListRow inherits Rectangle { x: -166px; y: 32px; width: 196px; - height: 150px; + // 5 rows x 33px + ContextMenu's 5/5 padding. + height: 5 * 33px + 10px; ContextMenu { ContextMenuItem { icon: @image-url("../assets/icons/play.svg"); @@ -131,6 +132,13 @@ export component PlaylistListRow inherits Rectangle { root.media-action("playlist", root.playlist.id, "play-next"); } } + ContextMenuItem { + icon: @image-url("../assets/icons/list-plus.svg"); + label: @tr("Play later"); + clicked => { + root.media-action("playlist", root.playlist.id, "play-later"); + } + } ContextMenuItem { icon: @image-url("../assets/icons/list-end.svg"); label: @tr("Add to queue"); diff --git a/crates/qbz-ui/ui/shell/TransportControls.slint b/crates/qbz-ui/ui/shell/TransportControls.slint index a8f8369f9..ccf1cd7f8 100644 --- a/crates/qbz-ui/ui/shell/TransportControls.slint +++ b/crates/qbz-ui/ui/shell/TransportControls.slint @@ -129,9 +129,9 @@ export component TransportControls inherits HorizontalLayout { } add-menu := PopupWindow { x: 0px; - y: -222px; + y: -255px; width: 232px; - height: 214px; + height: 247px; close-policy: close-on-click; ContextMenu { ContextMenuItem { @@ -155,6 +155,13 @@ export component TransportControls inherits HorizontalLayout { root.media-action("track", NowPlayingState.track-id, "queue"); } } + ContextMenuItem { + icon: @image-url("../assets/icons/list-plus.svg"); + label: @tr("Play later"); + clicked => { + root.media-action("track", NowPlayingState.track-id, "play-later"); + } + } ContextMenuItem { icon: @image-url("../assets/icons/list-start.svg"); label: @tr("Play next"); diff --git a/crates/qbz-ui/ui/state.slint b/crates/qbz-ui/ui/state.slint index 7d2a99851..6c0d7400d 100644 --- a/crates/qbz-ui/ui/state.slint +++ b/crates/qbz-ui/ui/state.slint @@ -1029,12 +1029,12 @@ export global AlbumActions { // Toggle multi-select (track selection) mode on the album track list. callback toggle-multi-select(); // Bulk-bar action over the selected rows (id = "select-all" | "play-next" - // | "queue" | "add-to-playlist" | "add-to-favorites" | "make-offline" | - // "clear"). + // | "play-later" | "queue" | "add-to-playlist" | "add-to-favorites" | + // "make-offline" | "clear"). callback bulk-action(string); // Per-disc context-menu action on a multi-disc album's "Disc N" header — // scoped to that disc's tracks only (action = "play" | "shuffle" | "queue" - // | "play-next"). The whole-album header buttons are unaffected. + // | "play-later" | "play-next"). The whole-album header buttons are unaffected. callback disc-action(int /* disc number */, string /* action */); // Open an external link (Last.fm / Discogs / MusicBrainz) in the system // browser. The url is prebuilt by the controller (AlbumState.*-url). @@ -1365,13 +1365,13 @@ export global LocalLibraryActions { callback open-album(string /* album id */); // Open an artist (lands with the Artists slice). callback open-artist(string /* artist id */); - // Per-card action (play / queue / play-next / toggle-select). The Albums - // grid honors the shared AlbumCollectionView contract. + // Per-card action (play / queue / play-next / play-later / toggle-select). + // The Albums grid honors the shared AlbumCollectionView contract. callback album-action(string /* album id */, string /* action */); // Toggle the Albums-grid multi-select mode. callback albums-toggle-multi-select(); // Albums bulk bar action: select-all | clear | queue | play-next | - // add-to-playlist | add-to-mixtape. + // play-later | add-to-playlist | add-to-mixtape. callback albums-bulk-action(string /* action */); // Windowed grid: the visible row band changed (item indices into // albums-visible, prefetch margin included) — dispatch artwork for it. @@ -1381,7 +1381,7 @@ export global LocalLibraryActions { callback tracks-search(string /* query */); callback tracks-load-more(); callback tracks-retry(); - // Per-row play/queue/toggle-select (action: play|play-next|queue|toggle-select). + // Per-row play/queue/toggle-select (action: play|play-next|play-later|queue|toggle-select). callback track-action(string /* track id */, string /* action */); // Group-by mode (off|album|artist|name), persisted. callback tracks-set-group(string /* mode */); @@ -1389,7 +1389,7 @@ export global LocalLibraryActions { callback tracks-set-sort(string /* key */); // Toggle the multi-select checkbox column. callback tracks-toggle-multi-select(); - // Bulk bar action: select-all | clear | queue | play-next | add-to-playlist. + // Bulk bar action: select-all | clear | queue | play-next | play-later | add-to-playlist. callback tracks-bulk-action(string /* action */); // --- Folders tab --- @@ -1416,7 +1416,7 @@ export global LocalLibraryActions { callback folders-toggle-select-mode(); callback folders-toggle-folder-select(string /* folder path */); callback folders-toggle-track-select(string /* track path */); - callback folders-bulk-action(string /* select-all|clear|queue|play-next|add-to-playlist */); + callback folders-bulk-action(string /* select-all|clear|queue|play-next|play-later|add-to-playlist */); // --- Ephemeral folder (ad-hoc play) --- // Open the native folder picker → scan → show the ephemeral pane. @@ -1501,8 +1501,8 @@ export global LocalAlbumActions { callback add-to-playlist(); callback add-to-mixtape(); callback play-track(string /* track id */); - // Per-row context-menu actions beyond "play" (play-next / queue / - // add-to-playlist) — resolved against the open version's track cache. + // Per-row context-menu actions beyond "play" (play-next / play-later / + // queue / add-to-playlist) — resolved against the open version's track cache. callback track-menu-action(string /* track id */, string /* action */); callback select-version(int); // Client-side track filter (mirrors AlbumActions.search), applied over the @@ -1510,7 +1510,8 @@ export global LocalAlbumActions { callback search(string /* query */); // Per-disc context-menu action on a multi-disc local album's "Disc N" // header — scoped to that disc's tracks only (action = "play" | "shuffle" | - // "queue" | "play-next"). The whole-album header buttons are unaffected. + // "queue" | "play-later" | "play-next"). The whole-album header buttons are + // unaffected. callback disc-action(int /* disc number */, string /* action */); } @@ -3659,8 +3660,8 @@ export global ArtistActions { // Toggle Popular Tracks multi-select mode. callback toggle-top-tracks-select(); // Bulk-bar action over the selected Popular Tracks (id = "select-all" | - // "play-next" | "queue" | "add-to-playlist" | "add-to-favorites" | - // "clear"). + // "play-next" | "play-later" | "queue" | "add-to-playlist" | + // "add-to-favorites" | "clear"). callback top-tracks-bulk-action(string); // Popular Tracks section "more" menu (id = "next-all" | "queue-all" | // "shuffle-all" | "playlist-all"). @@ -5147,8 +5148,8 @@ export global FavoritesState { in property <[DiscoverSection]> albums-grouped: []; // group-by sections in-out property albums-view-mode: "grid"; // "grid" | "list" // Albums LIST-mode multi-select (owner 2026-07-24): checkbox column on - // the rows + the bulk bar (un-favorite / play-next / queue). List only — - // switching to grid forces it off. + // the rows + the bulk bar (un-favorite / play-next / play-later / queue). + // List only — switching to grid forces it off. in-out property albums-multi-select: false; in property albums-selected-count: 0; in-out property albums-search: ""; @@ -5212,7 +5213,7 @@ export global FavoritesActions { callback toggle-multi-select(); callback bulk-action(string /* select-all | remove-selected | clear */); // Albums list-mode multi-select: toggle the edit mode + the bulk bar - // actions (select-all | play-next | queue | remove-selected | clear). + // actions (select-all | play-next | play-later | queue | remove-selected | clear). callback albums-toggle-multi-select(); callback albums-bulk-action(string); callback album-action(string /* album id */, string /* action */); @@ -5227,7 +5228,7 @@ export global FavoritesActions { callback labels-random(); callback search-playlists(string /* query */); callback playlists-set-view(string /* grid | list */); - callback playlist-action(string /* playlist id */, string /* play | play-next | queue | share | favorite | follow | unfollow | copy */); + callback playlist-action(string /* playlist id */, string /* play | play-next | play-later | queue | share | favorite | follow | unfollow | copy */); callback search-artists(string /* query */); callback artists-set-view(string /* grid | sidepanel */); callback artists-set-group(string /* off | alpha */); diff --git a/crates/qbz/src/local_playlist.rs b/crates/qbz/src/local_playlist.rs index 75b2eb9a4..a40fdc7bc 100644 --- a/crates/qbz/src/local_playlist.rs +++ b/crates/qbz/src/local_playlist.rs @@ -20,7 +20,9 @@ use slint::{ComponentHandle, Model}; use crate::adapter::SlintAdapter; use crate::artwork::{self, ArtworkJob, ArtworkTarget, ImageCache}; -use crate::playback::{after_track_change, refresh_sidebar}; +use crate::playback::{ + after_track_change, batch_all_qconnect_castable, refresh_sidebar, sync_qconnect_after_add, +}; use crate::{AppWindow, ContentView, NavState, PlaylistState, TrackItem}; type Runtime = Arc>; @@ -1648,6 +1650,56 @@ pub fn enqueue_by_id( }); } +/// Play-later variant of `enqueue_by_id` (#442): the playlist's rows land at +/// the END of the manual block in NATURAL order (each `add_track_later` +/// appends — no reverse). Rides the playback.rs QConnect pattern (batch +/// routing to the peer's manual-block tail + post-add sync; local/plex rows +/// are refused by the router's all-or-nothing admission, exactly like the +/// other source-typed batch paths). The OFFLINE-ONLY stamp rule is unchanged +/// (D8: nothing from it ever reaches the cloud push). +pub fn enqueue_later_by_id( + runtime: Runtime, + weak: slint::Weak, + handle: tokio::runtime::Handle, + playlist_id: String, +) { + handle.spawn(async move { + let Some(data) = load(&runtime, &playlist_id).await else { + crate::toast::error_weak(&weak, qbz_i18n::t("Couldn't load this playlist")); + return; + }; + let tracks: Vec = data + .rows + .iter() + .filter_map(|r| row_queue_track(&r.item)) + .collect(); + if tracks.is_empty() { + crate::toast::error_weak(&weak, qbz_i18n::t("Nothing playable in this playlist right now")); + return; + } + // QConnect CONTROLLER mode: route the whole playlist to the peer's + // manual-block tail; false when no peer is active, so the local loop + // below runs unchanged. + if let Some(svc) = crate::qconnect_service::service() { + let routed: Vec<(u64, Option)> = + tracks.iter().map(|qt| (qt.id, qt.source.clone())).collect(); + if svc.play_later_batch_on_peer_if_active(&routed).await { + return; + } + } + let added_castable = batch_all_qconnect_castable(&tracks); + for track in tracks { + runtime.core().add_track_later(track).await; + } + sync_qconnect_after_add(added_castable).await; + if data.offline_only { + runtime.core().set_queue_offline_only(true); + } + refresh_sidebar(false); + crate::toast::success_weak(&weak, qbz_i18n::t("Added to play later")); + }); +} + // ──────────────────────── reorder (B2) ──────────────────────── /// Move the clicked row one slot up/down within the open LOCAL playlist's diff --git a/crates/qbz/src/main.rs b/crates/qbz/src/main.rs index 8e114b9e7..2b4c952bb 100644 --- a/crates/qbz/src/main.rs +++ b/crates/qbz/src/main.rs @@ -6430,9 +6430,10 @@ fn wire_myqbz_detail( // --- Bulk action bar (select-mode, spec 12 §13.1) ------------------ // The full §13.1 group set: - // - "add-to-queue" / "play-next": resolve the selected items via the shared - // enqueue resolver + append / insert-next (no replace, no queue-source - // stamp — mirrors the per-row contract). + // - "add-to-queue" / "play-next" / "play-later": resolve the selected + // items via the shared enqueue resolver + append / insert-next / + // manual-block-tail (no replace, no queue-source stamp — mirrors the + // per-row contract). // - "add-to-playlist": resolve the selected items to their Qobuz track ids // and open the existing playlist picker (Qobuz mode) with them. // - "add-to-mixtape": open the global AddToMixtapeModal with the payloads. @@ -6460,6 +6461,19 @@ fn wire_myqbz_detail( id.as_str() == "play-next", ); } + "play-later" => { + // #442 — same resolve, manual-block-tail placement. + let selected = myqbz_detail::selected_full_items(&w); + if selected.is_empty() { + return; + } + myqbz_play::bulk_enqueue_later( + runtime.clone(), + weak.clone(), + handle.clone(), + selected, + ); + } "add-to-playlist" => { let selected = myqbz_detail::selected_full_items(&w); if selected.is_empty() { @@ -6703,7 +6717,7 @@ fn wire_myqbz_detail( }); } - // --- Per-row context menu (play / play-next / add-to-queue) --------- + // --- Per-row context menu (play / play-next / play-later / add-to-queue) --- { let runtime = app_runtime.clone(); let weak = window.as_weak(); @@ -11915,6 +11929,14 @@ fn main() -> Result<(), Box> { handle.clone(), id, ), + // #442 "Play later" — the album lands at the END of the + // manual block (natural order, no reverse). + ("album", "play-later") => playback::enqueue_album_later( + runtime.clone(), + weak.clone(), + handle.clone(), + id, + ), ("album", "shuffle") => playback::play_album_shuffled( runtime.clone(), weak.clone(), @@ -13068,6 +13090,12 @@ fn main() -> Result<(), Box> { playback::enqueue_tracks(runtime.clone(), handle.clone(), tracks, true); } } + ("label", "play-later") => { + if let Some(w) = weak.upgrade() { + let tracks = label::selected_play_tracks(&w); + playback::enqueue_tracks_later(runtime.clone(), handle.clone(), tracks); + } + } // Popular Tracks section menu + header overflow: ALL of the // label's popular tracks play-next / add-to-queue (the cached // list — same source as "play-top"). @@ -13087,6 +13115,16 @@ fn main() -> Result<(), Box> { playback::enqueue_tracks(runtime.clone(), handle.clone(), tracks, false); } } + ("label", "top-play-later") => { + // #442 — all of the label's popular tracks at the END of + // the manual block (same cached source as "top-queue"). + let tracks = label::top_tracks_for_play(); + if tracks.is_empty() { + crate::toast::error_weak(&weak, "No popular tracks for this label"); + } else { + playback::enqueue_tracks_later(runtime.clone(), handle.clone(), tracks); + } + } // Header shuffle: all popular tracks, xorshift-shuffled. ("label", "shuffle") => { let tracks = label::top_tracks_for_play(); @@ -13408,6 +13446,12 @@ fn main() -> Result<(), Box> { playback::enqueue_tracks(runtime.clone(), handle.clone(), tracks, true); } } + ("mix", "play-later") => { + if let Some(w) = weak.upgrade() { + let tracks = mix::selected_play_tracks(&w); + playback::enqueue_tracks_later(runtime.clone(), handle.clone(), tracks); + } + } ("mix", "add-to-playlist") => { if let Some(w) = weak.upgrade() { let ids = mix::selected_ids(&w); @@ -13552,6 +13596,26 @@ fn main() -> Result<(), Box> { true, ) } + ("playlist", "play-later") => { + // #442 — END of the manual block; local playlists ride + // their own resolver (source-aware rows + offline-only + // stamp), Qobuz playlists the sidecar-merging fetch. + if local_playlist::is_local_id(&id) { + local_playlist::enqueue_later_by_id( + runtime.clone(), + weak.clone(), + handle.clone(), + id, + ); + return; + } + playback::enqueue_playlist_later( + runtime.clone(), + weak.clone(), + handle.clone(), + id, + ) + } ("playlist", "upload-to-qobuz") => { // D8: convert a non-offline-only LOCAL playlist into a // real Qobuz playlist (explicit user action, confirmed @@ -13681,6 +13745,24 @@ fn main() -> Result<(), Box> { ); } } + ("playlist", "play-later-selected") => { + // #442 bulk Play later — same source-aware resolution as + // the play-next/queue pair above, manual-block tail. + if let Some(w) = weak.upgrade() { + let tracks = playlist::selected_queue_tracks(&w); + if tracks.is_empty() { + toast::error(&w, "Nothing playable in the selection"); + return; + } + playlist::clear_selection(&w); + playback::enqueue_queue_tracks_later( + runtime.clone(), + weak.clone(), + handle.clone(), + tracks, + ); + } + } ("playlist", "add-selected-to-playlist") => { // Bulk Add to playlist (spec §1.5). The picker is // single-mode (catalog ids XOR local-mode refs), so: @@ -14715,6 +14797,16 @@ fn main() -> Result<(), Box> { ); } } + "play-later" => { + let tracks = album::selected_play_tracks(&w); + if !tracks.is_empty() { + playback::enqueue_tracks_later( + runtime.clone(), + handle.clone(), + tracks, + ); + } + } "make-offline" => { let tracks = album::selected_play_tracks(&w); if !tracks.is_empty() { @@ -14838,6 +14930,13 @@ fn main() -> Result<(), Box> { true, ); } + "play-later" => { + playback::enqueue_tracks_later( + runtime.clone(), + handle.clone(), + tracks, + ); + } other => { log::warn!("[qbz-slint] album disc-action: unknown action {other}"); } @@ -15097,6 +15196,13 @@ fn main() -> Result<(), Box> { artist::selected_ids(&w), true, ), + "play-later" => playback::enqueue_artist_top_later( + runtime.clone(), + weak.clone(), + handle.clone(), + artist_id, + artist::selected_ids(&w), + ), "queue" => playback::enqueue_artist_top_selected( runtime.clone(), weak.clone(), @@ -18516,6 +18622,13 @@ fn main() -> Result<(), Box> { action.as_str() == "play-next", ); } + "play-later" => { + playback::enqueue_local_tracks_later( + runtime.clone(), + handle.clone(), + vec![row.clone()], + ); + } "add-to-playlist" => { playlist_picker::open_multi(&w, &[local_picker_ref(row)], true); let runtime = runtime.clone(); @@ -18664,6 +18777,11 @@ fn main() -> Result<(), Box> { tracks, true, ), + "play-later" => playback::enqueue_local_tracks_later( + runtime.clone(), + handle.clone(), + tracks, + ), other => { log::warn!("[qbz-slint] local disc-action: unknown action {other}"); } @@ -18857,6 +18975,20 @@ fn main() -> Result<(), Box> { playback::enqueue_local_tracks(runtime, handle2, rows, play_next); }); } + "play-later" => { + // #442 — same source-aware resolve, manual-block tail. + let runtime = runtime.clone(); + let handle2 = handle.clone(); + let album_id = id.to_string(); + handle.spawn(async move { + let rows = tokio::task::spawn_blocking(move || { + local_library::fetch_album_tracks_blocking(&album_id) + }) + .await + .unwrap_or_default(); + playback::enqueue_local_tracks_later(runtime, handle2, rows); + }); + } _ => { log::debug!("[qbz-slint] unhandled local album action: {id} {action}"); } @@ -18959,6 +19091,36 @@ fn main() -> Result<(), Box> { }); } } + "play-later" => { + // #442 — same resolution as play-next above (cache + // first, DB fallback), manual-block tail. + if let Some(row) = local_library::local_track_by_id(id.as_str()) { + playback::enqueue_local_tracks_later( + runtime.clone(), + handle.clone(), + vec![row], + ); + } else if let Ok(rid) = id.parse::() { + let runtime = runtime.clone(); + let handle2 = handle.clone(); + handle.spawn(async move { + let row = tokio::task::spawn_blocking(move || { + crate::library_db::with_db(|db| db.get_track(rid)) + .flatten() + }) + .await + .ok() + .flatten(); + if let Some(row) = row { + playback::enqueue_local_tracks_later( + runtime, + handle2, + vec![row], + ); + } + }); + } + } "add-to-playlist" => { // Per-row picker (Tracks tab + folder-detail rows). // Plex rows ride as "plex:"; plain row ids are @@ -19155,6 +19317,11 @@ fn main() -> Result<(), Box> { playback::enqueue_local_tracks(runtime.clone(), handle.clone(), rows, true); local_library::clear_tracks_selection(&w); } + "play-later" => { + let rows = local_library::selected_local_tracks(&w); + playback::enqueue_local_tracks_later(runtime.clone(), handle.clone(), rows); + local_library::clear_tracks_selection(&w); + } "remove-favorites" => { // Bulk local un-favorite: ONE model pass (the first // cut looped the whole model per selected row and @@ -19290,6 +19457,21 @@ fn main() -> Result<(), Box> { }); local_library::clear_albums_selection(&w); } + "play-later" => { + // #442 — same album->tracks resolution, manual-block tail. + let keys = local_library::selected_album_ids(&w); + let runtime = runtime.clone(); + let handle2 = handle.clone(); + handle.spawn(async move { + let rows = tokio::task::spawn_blocking(move || { + local_library::selected_albums_tracks_blocking(&keys) + }) + .await + .unwrap_or_default(); + playback::enqueue_local_tracks_later(runtime, handle2, rows); + }); + local_library::clear_albums_selection(&w); + } "add-to-playlist" => { let keys = local_library::selected_album_ids(&w); if !keys.is_empty() { @@ -19415,6 +19597,11 @@ fn main() -> Result<(), Box> { playback::enqueue_local_tracks(runtime.clone(), handle.clone(), rows, true); local_library::tree_clear_selection(&w); } + "play-later" => { + let rows = local_library::tree_selected_snapshot(); + playback::enqueue_local_tracks_later(runtime.clone(), handle.clone(), rows); + local_library::tree_clear_selection(&w); + } "add-to-playlist" => { // Source-aware refs (Plex rows as "plex:"). let rows = local_library::tree_selected_snapshot(); @@ -22207,8 +22394,8 @@ fn main() -> Result<(), Box> { } } act => { - // play / play-next / queue: fetch the playlist's tracks, - // then play or enqueue. + // play / play-next / play-later / queue: fetch the + // playlist's tracks, then play or enqueue. let Ok(pid) = id.parse::() else { return; }; @@ -22231,6 +22418,9 @@ fn main() -> Result<(), Box> { "play-next" => { playback::enqueue_tracks(runtime, handle2, tracks, true) } + "play-later" => { + playback::enqueue_tracks_later(runtime, handle2, tracks) + } "queue" => { playback::enqueue_tracks(runtime, handle2, tracks, false) } @@ -22449,8 +22639,8 @@ fn main() -> Result<(), Box> { }); } { - // Albums LIST-mode bulk bar: select-all / play-next / queue / - // remove-selected (bulk un-favorite) / clear. + // Albums LIST-mode bulk bar: select-all / play-next / play-later / + // queue / remove-selected (bulk un-favorite) / clear. let runtime = app_runtime.clone(); let weak = window.as_weak(); let handle = tokio_rt.handle().clone(); @@ -22489,6 +22679,18 @@ fn main() -> Result<(), Box> { ); } } + "play-later" => { + // #442 — each album APPENDS to the manual block, so + // NATURAL order (no reverse, unlike play-next). + for id in favorites::selected_album_ids(&w) { + playback::enqueue_album_later( + runtime.clone(), + weak.clone(), + handle.clone(), + id, + ); + } + } "remove-selected" => { // Offline = read-only hearts (same rule as tracks). if offline_mode::engine().is_offline() { @@ -22772,6 +22974,10 @@ fn main() -> Result<(), Box> { let tracks = favorites::selected_tracks(&w); playback::enqueue_tracks(runtime.clone(), handle.clone(), tracks, true); } + "play-later" => { + let tracks = favorites::selected_tracks(&w); + playback::enqueue_tracks_later(runtime.clone(), handle.clone(), tracks); + } "make-offline" => { let tracks = favorites::selected_tracks(&w); offline_cache::cache_tracks( diff --git a/crates/qbz/src/myqbz_play.rs b/crates/qbz/src/myqbz_play.rs index 3aa03fa7d..aa0eda848 100644 --- a/crates/qbz/src/myqbz_play.rs +++ b/crates/qbz/src/myqbz_play.rs @@ -1,9 +1,10 @@ //! My QBZ — Collection / Mixtape DETAIL **playback** (Phase-2 Slice 5). //! //! Wires the detail view's hero Play / Shuffle CTAs, the per-row Play action, -//! and the per-row context menu (play / play-next / add-to-queue) to the -//! shared `qbz-mixtape` ENQUEUE resolver, then drives the already-shared -//! `qbz-core` queue + `qbz-app` `RuntimeManager` queue-source stamp. +//! and the per-row context menu (play / play-next / play-later / +//! add-to-queue) to the shared `qbz-mixtape` ENQUEUE resolver, then drives +//! the already-shared `qbz-core` queue + `qbz-app` `RuntimeManager` +//! queue-source stamp. //! //! Behavior is 1:1 with Tauri's `v2_enqueue_collection` / //! `v2_enqueue_collection_item` (spec 40 §5/§6, gotchas §9): @@ -47,6 +48,9 @@ enum RowMode { Play, /// Insert the item's resolved tracks immediately after the current track. PlayNext, + /// Insert the item's resolved tracks at the END of the manual block + /// (#442 "Play later") — NATURAL order, each insert appends to the block. + PlayLater, /// Append the item's resolved tracks at the end of the queue. AddToQueue, } @@ -56,6 +60,7 @@ impl RowMode { match action { "play" => Some(Self::Play), "play-next" | "play_next" => Some(Self::PlayNext), + "play-later" | "play_later" => Some(Self::PlayLater), "add-to-queue" | "add_to_queue" | "append" => Some(Self::AddToQueue), _ => None, } @@ -381,7 +386,8 @@ pub fn play_item( } /// Per-row context-menu action (`on_item_action`): play / play-next / -/// add-to-queue for the single item identified by `source_item_id`. +/// play-later / add-to-queue for the single item identified by +/// `source_item_id`. pub fn item_action( runtime: Runtime, weak: slint::Weak, @@ -435,6 +441,16 @@ pub fn item_action( refresh_sidebar(false); crate::toast::success_weak(&weak, qbz_i18n::t("Playing next")); } + RowMode::PlayLater => { + // #442: NATURAL order — each insert appends to the END of the + // manual block (after every play-next/play-later already + // queued, before the source resumes). + for track in tracks { + runtime.core().add_track_later(track).await; + } + refresh_sidebar(false); + crate::toast::success_weak(&weak, qbz_i18n::t("Added to play later")); + } RowMode::AddToQueue => { runtime.core().add_tracks(tracks).await; refresh_sidebar(false); @@ -444,24 +460,70 @@ pub fn item_action( }); } +/// The bulk-bar queue placement (spec 12 §13.1 + #442 "Play later"). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BulkEnqueueMode { + /// Append the batch at the end of the queue. + Append, + /// Insert the whole batch immediately after the current track, in REVERSE + /// so the first resolved track lands first (same rule as per-row PlayNext). + Next, + /// Insert the batch at the END of the manual block (#442 "Play later") — + /// NATURAL order, each `add_track_later` appends to the block. + Later, +} + /// **Bulk** enqueue for the detail select-mode bulk bar (spec 12 §13.1 Add to -/// queue / Play next). Resolves EACH selected `MixtapeCollectionItem` through -/// the same `ProdItemResolver` the per-row path uses (so Qobuz albums/tracks/ -/// playlists + local/Plex all resolve), flattens them in selection order, then: -/// - **play_next = true**: insert the whole batch immediately after the current -/// track, in REVERSE so the first resolved track lands first (same rule as the -/// per-row `PlayNext`). -/// - **play_next = false**: append the batch at the end of the queue. -/// -/// Never replaces the queue and never stamps the queue-source collection -/// (append/play-next preserve context, mirroring the per-row contract). Items -/// that resolve to nothing are logged + skipped; an all-empty batch toasts. +/// queue / Play next): `play_next` selects [`BulkEnqueueMode::Next`] over +/// [`BulkEnqueueMode::Append`]. See `bulk_enqueue_inner` for the contract. pub fn bulk_enqueue( runtime: Runtime, weak: slint::Weak, handle: tokio::runtime::Handle, items: Vec, play_next: bool, +) { + let mode = if play_next { + BulkEnqueueMode::Next + } else { + BulkEnqueueMode::Append + }; + bulk_enqueue_inner(runtime, weak, handle, items, mode); +} + +/// #442 "Play later" bulk enqueue (spec 12 §13.1 bar entry): same resolve + +/// flatten as `bulk_enqueue`, placed at the END of the manual block. +pub fn bulk_enqueue_later( + runtime: Runtime, + weak: slint::Weak, + handle: tokio::runtime::Handle, + items: Vec, +) { + bulk_enqueue_inner(runtime, weak, handle, items, BulkEnqueueMode::Later); +} + +/// **Bulk** enqueue for the detail select-mode bulk bar (spec 12 §13.1 Add to +/// queue / Play next / Play later). Resolves EACH selected +/// `MixtapeCollectionItem` through the same `ProdItemResolver` the per-row +/// path uses (so Qobuz albums/tracks/playlists + local/Plex all resolve), +/// flattens them in selection order, then places the batch per `mode`: +/// - **Next**: insert the whole batch immediately after the current track, in +/// REVERSE so the first resolved track lands first (same rule as the per-row +/// `PlayNext`). +/// - **Later**: insert at the END of the manual block in NATURAL order (each +/// `add_track_later` appends — #442). +/// - **Append**: append the batch at the end of the queue. +/// +/// Never replaces the queue and never stamps the queue-source collection +/// (append/play-next/play-later preserve context, mirroring the per-row +/// contract). Items that resolve to nothing are logged + skipped; an +/// all-empty batch toasts. +fn bulk_enqueue_inner( + runtime: Runtime, + weak: slint::Weak, + handle: tokio::runtime::Handle, + items: Vec, + mode: BulkEnqueueMode, ) { if items.is_empty() { return; @@ -510,18 +572,30 @@ pub fn bulk_enqueue( return; } - if play_next { - // REVERSE so the first resolved track lands immediately after the - // current track (spec §9.8). - for track in tracks.into_iter().rev() { - runtime.core().add_track_next(track).await; + match mode { + BulkEnqueueMode::Next => { + // REVERSE so the first resolved track lands immediately after the + // current track (spec §9.8). + for track in tracks.into_iter().rev() { + runtime.core().add_track_next(track).await; + } + refresh_sidebar(false); + crate::toast::success_weak(&weak, qbz_i18n::t("Playing next")); + } + BulkEnqueueMode::Later => { + // #442: NATURAL order — each insert appends to the END of the + // manual block. + for track in tracks { + runtime.core().add_track_later(track).await; + } + refresh_sidebar(false); + crate::toast::success_weak(&weak, qbz_i18n::t("Added to play later")); + } + BulkEnqueueMode::Append => { + runtime.core().add_tracks(tracks).await; + refresh_sidebar(false); + crate::toast::success_weak(&weak, qbz_i18n::t("Added to queue")); } - refresh_sidebar(false); - crate::toast::success_weak(&weak, qbz_i18n::t("Playing next")); - } else { - runtime.core().add_tracks(tracks).await; - refresh_sidebar(false); - crate::toast::success_weak(&weak, qbz_i18n::t("Added to queue")); } }); } diff --git a/crates/qbz/src/playback.rs b/crates/qbz/src/playback.rs index 84de41171..f25874848 100644 --- a/crates/qbz/src/playback.rs +++ b/crates/qbz/src/playback.rs @@ -3035,6 +3035,48 @@ pub fn enqueue_artist_top_selected( }); } +/// Play-later variant of `enqueue_artist_top_selected` (#442): same +/// re-fetch + id filter + page order, but the selection lands at the END of +/// the manual block — NATURAL order (each `add_track_later` appends, no +/// reverse) and the QConnect batch rides `play_later_batch_on_peer_if_active`. +pub fn enqueue_artist_top_later( + runtime: Runtime, + weak: slint::Weak, + handle: tokio::runtime::Handle, + artist_id: String, + ids: Vec, +) { + if ids.is_empty() { + return; + } + handle.spawn(async move { + let Some(all) = fetch_artist_top_for_play(&runtime, &weak, &artist_id).await else { + return; + }; + let want: std::collections::HashSet = + ids.iter().filter_map(|s| s.parse::().ok()).collect(); + let tracks: Vec = + all.into_iter().filter(|qt| want.contains(&qt.id)).collect(); + if tracks.is_empty() { + return; + } + if let Some(svc) = crate::qconnect_service::service() { + let routed: Vec<(u64, Option)> = + tracks.iter().map(|qt| (qt.id, qt.source.clone())).collect(); + if svc.play_later_batch_on_peer_if_active(&routed).await { + return; + } + } + let added_castable = batch_all_qconnect_castable(&tracks); + for track in tracks { + runtime.core().add_track_later(track).await; + } + sync_qconnect_after_add(added_castable).await; + refresh_sidebar(false); + crate::toast::success_weak(&weak, qbz_i18n::t("Added to play later")); + }); +} + /// Shuffle-play ALL of the artist's Popular tracks (section "more" menu). /// Re-fetches, xorshift-shuffles (same seedless mix as `play_album_shuffled`), /// and replaces the queue. @@ -3977,13 +4019,73 @@ pub fn enqueue_album_next( }); } +/// Insert an album's tracks at the END of the manual block ("Play later", +/// #442): after every play-next / play-later already queued, before the +/// source resumes. Unlike play-next there is NO reverse — each +/// `add_track_later` appends to the block, so the natural order is preserved. +pub fn enqueue_album_later( + runtime: Runtime, + weak: slint::Weak, + handle: tokio::runtime::Handle, + album_id: String, +) { + handle.spawn(async move { + let album = match runtime.core().get_album(&album_id).await { + Ok(album) => album, + Err(e) => { + log::error!("[qbz-slint] playback: play-later get_album {album_id} failed: {e}"); + return; + } + }; + let album_title = album.title.clone(); + let album_artist = album.artist.name.clone(); + let album_artwork = album.image.best().cloned().unwrap_or_default(); + crate::recently::remember_album_meta(&album.id, album_card_meta(&album)); + // Drop blacklisted tracks (composer-aware, album-primary fallback) + // before play-later — same predicate as album play-all (D-FIX-b). + let album_primary = Some(album.artist.id); + let tracks: Vec = album + .tracks + .as_ref() + .map(|container| container.items.as_slice()) + .unwrap_or_default() + .iter() + .filter(|track| !track_is_blacklisted_full(track, album_primary)) + .map(|track| { + make_queue_track(track, &album.id, &album_title, &album_artist, &album_artwork, album.version.as_deref()) + }) + .collect(); + if tracks.is_empty() { + return; + } + // QConnect CONTROLLER mode: route the whole album to the peer's + // manual-block tail. All-or-nothing admission inside the router; + // false when no peer is active, so the local loop runs unchanged. + if let Some(svc) = crate::qconnect_service::service() { + let routed: Vec<(u64, Option)> = + tracks.iter().map(|qt| (qt.id, qt.source.clone())).collect(); + if svc.play_later_batch_on_peer_if_active(&routed).await { + return; + } + } + // NATURAL order — each insert appends to the manual block. + let added_castable = batch_all_qconnect_castable(&tracks); + for track in tracks { + runtime.core().add_track_later(track).await; + } + sync_qconnect_after_add(added_castable).await; + refresh_sidebar(false); + crate::toast::success_weak(&weak, qbz_i18n::t("Added to play later")); + }); +} + /// QConnect sync-on-add predicate (#442): true when EVERY track of an added /// batch is Qobuz-castable (mirrors `sync_local_queue_if_changed`'s /// all-or-nothing admission — `local` / `plex` refused, offline /// `qobuz_download` eligible). A non-castable batch skips the post-add push so /// a local/plex add never trips the refusal toast on every click; the next /// track tick already surfaces that state. -fn batch_all_qconnect_castable(tracks: &[QueueTrack]) -> bool { +pub(crate) fn batch_all_qconnect_castable(tracks: &[QueueTrack]) -> bool { tracks.iter().all(|qt| { qt.id > 0 && !matches!( @@ -3999,7 +4101,7 @@ fn batch_all_qconnect_castable(tracks: &[QueueTrack]) -> bool { /// re-synced on the next track transition. `sync_local_queue_if_changed` /// self-gates (no-op when not connected, when a peer owns playback, or when the /// queue is unchanged), so this is cheap in every non-renderer situation. -async fn sync_qconnect_after_add(added_castable: bool) { +pub(crate) async fn sync_qconnect_after_add(added_castable: bool) { if !added_castable { return; } @@ -4290,6 +4392,70 @@ pub fn enqueue_playlist( }); } +/// Insert a whole playlist (by id) at the END of the manual block ("Play +/// later", #442). Mirrors `enqueue_playlist` — same fetch + mixed-sidecar +/// interleave + blacklist filter + QConnect batch routing — but the local +/// insert loops `add_track_later` in NATURAL order (each insert appends to +/// the block; successive later-inserts keep FIFO order, no reverse). +pub fn enqueue_playlist_later( + runtime: Runtime, + weak: slint::Weak, + handle: tokio::runtime::Handle, + playlist_id: String, +) { + let Ok(pid) = playlist_id.parse::() else { + return; + }; + handle.spawn(async move { + let playlist = match runtime.core().get_playlist(pid).await { + Ok(playlist) => playlist, + Err(e) => { + log::error!("[qbz-slint] playback: play-later get_playlist {pid} failed: {e}"); + return; + } + }; + let qobuz_tracks: Vec = playlist.tracks.map(|c| c.items).unwrap_or_default(); + // Same mixed-playlist merge as `enqueue_playlist` (see its MIXED + // comment): interleave the local/Plex sidecar rows at their stored + // slots so every row keeps its source. Pure-Qobuz playlists read an + // empty sidecar. + let qobuz_count = qobuz_tracks.len() as u32; + let sidecar = tokio::task::spawn_blocking(move || { + crate::local_playlist::read_sidecar_rows_blocking(pid, qobuz_count, true) + }) + .await + .unwrap_or_default(); + let rows = crate::playlist::interleave_rows(qobuz_tracks, sidecar); + // Drop blacklisted Qobuz rows (performer; local/Plex rows kept by the + // source guard). Silent early-return when nothing playable remains. + let tracks: Vec = filter_blacklisted_queue( + rows.iter() + .filter_map(|row| crate::local_playlist::row_queue_track(&row.item)) + .collect(), + ); + if tracks.is_empty() { + return; + } + // QConnect CONTROLLER mode: route the whole playlist to the peer's + // manual-block tail. All-or-nothing admission inside the router; + // false when no peer is active, so the local loop runs unchanged. + if let Some(svc) = crate::qconnect_service::service() { + let routed: Vec<(u64, Option)> = + tracks.iter().map(|qt| (qt.id, qt.source.clone())).collect(); + if svc.play_later_batch_on_peer_if_active(&routed).await { + return; + } + } + let added_castable = batch_all_qconnect_castable(&tracks); + for track in tracks { + runtime.core().add_track_later(track).await; + } + sync_qconnect_after_add(added_castable).await; + refresh_sidebar(false); + crate::toast::success_weak(&weak, qbz_i18n::t("Added to play later")); + }); +} + /// Append (or insert-next) a batch of already-fetched tracks to the queue /// without re-fetching them. Used by the favorites bulk bar. /// Resolve a list of Qobuz track ids (order-preserving) and enqueue them at the @@ -4404,6 +4570,65 @@ pub fn enqueue_tracks( }); } +/// Play-later variant of `enqueue_tracks` (#442): the catalog Track batch +/// lands at the END of the manual block in NATURAL order (each +/// `add_track_later` appends to the block — no reverse, unlike play-next). +/// Same blacklist filter + QConnect controller routing (play_later batch). +pub fn enqueue_tracks_later( + runtime: Runtime, + handle: tokio::runtime::Handle, + tracks: Vec, +) { + if tracks.is_empty() { + return; + } + // Drop blacklisted tracks (performer OR composer — D-FEAT) from the bulk + // batch before routing/enqueueing. Silent early-return when 0 remain. + let tracks: Vec = tracks + .into_iter() + .filter(|track| !track_is_blacklisted_full(track, None)) + .collect(); + if tracks.is_empty() { + return; + } + handle.spawn(async move { + // QConnect CONTROLLER mode: same all-or-nothing batch admission as + // `enqueue_tracks`, routed to the peer's manual-block tail. + if let Some(svc) = crate::qconnect_service::service() { + let routed: Vec<(u64, Option)> = + tracks.iter().map(|track| (track.id, None)).collect(); + if svc.play_later_batch_on_peer_if_active(&routed).await { + return; + } + } + let mut added: Vec = Vec::with_capacity(tracks.len()); + for track in tracks { + 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, None); + runtime.core().add_track_later(qt.clone()).await; + added.push(qt); + } + sync_qconnect_after_add(batch_all_qconnect_castable(&added)).await; + refresh_sidebar(false); + }); +} + /// Append (or insert-next) a batch of already-built, SOURCE-AWARE /// QueueTracks — the playlist detail's per-row / bulk Play next + Add to /// queue route their snapshot rows here (local/plex/cached rows keep their @@ -4529,6 +4754,30 @@ pub fn enqueue_local_tracks( }); } +/// Play-later variant of `enqueue_local_tracks` (#442): the LocalLibrary +/// batch lands at the END of the manual block in NATURAL order (each +/// `add_track_later` appends — no reverse). Same `local_queue_track` +/// source-aware build; qobuz_download rows stay Qobuz-castable for the +/// post-add sync, plain `local` rows skip it. +pub fn enqueue_local_tracks_later( + runtime: Runtime, + handle: tokio::runtime::Handle, + tracks: Vec, +) { + if tracks.is_empty() { + return; + } + handle.spawn(async move { + let qts: Vec = tracks.iter().map(local_queue_track).collect(); + let added_castable = batch_all_qconnect_castable(&qts); + for qt in qts { + runtime.core().add_track_later(qt).await; + } + sync_qconnect_after_add(added_castable).await; + refresh_sidebar(false); + }); +} + /// Toggle play / pause on the live player. /// /// Resume is only valid when the audio engine actually holds a loaded stream.