feat(queue): Play later everywhere — every context menu and bulk bar (#442)
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
This commit is contained in:
22 files changed
+785
-92
No files matched your search
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -49,7 +49,7 @@ component OverlayButton inherits Rectangle {
|
||||
export component PlaylistCard inherits Rectangle {
|
||||
in property <SearchPlaylistItem> 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 <int> 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");
|
||||
|
||||
@@ -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 <length> 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");
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -310,12 +310,14 @@ export component PlaylistView inherits Rectangle {
|
||||
property <bool> 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 },
|
||||
];
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
// Item -> media-action("album", <album-id>, <action>) 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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -13,7 +13,7 @@ import { DiscContextMenu } from "DiscContextMenu.slint";
|
||||
|
||||
export component DiscHeaderMenu inherits Rectangle {
|
||||
in property <int> 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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 <string> 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 <bool> albums-multi-select: false;
|
||||
in property <int> albums-selected-count: 0;
|
||||
in-out property <string> 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 */);
|
||||
|
||||
@@ -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<AppRuntime<SlintAdapter>>;
|
||||
@@ -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<AppWindow>,
|
||||
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<QueueTrack> = 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<String>)> =
|
||||
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
|
||||
|
||||
+214
-8
@@ -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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
);
|
||||
}
|
||||
}
|
||||
("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<dyn std::error::Error>> {
|
||||
);
|
||||
}
|
||||
}
|
||||
"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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
});
|
||||
}
|
||||
}
|
||||
"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::<i64>() {
|
||||
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:<key>"; plain row ids are
|
||||
@@ -19155,6 +19317,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
});
|
||||
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<dyn std::error::Error>> {
|
||||
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:<key>").
|
||||
let rows = local_library::tree_selected_snapshot();
|
||||
@@ -22207,8 +22394,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
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::<u64>() else {
|
||||
return;
|
||||
};
|
||||
@@ -22231,6 +22418,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
"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<dyn std::error::Error>> {
|
||||
});
|
||||
}
|
||||
{
|
||||
// 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<dyn std::error::Error>> {
|
||||
);
|
||||
}
|
||||
}
|
||||
"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<dyn std::error::Error>> {
|
||||
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(
|
||||
|
||||
+100
-26
@@ -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<AppWindow>,
|
||||
@@ -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<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
items: Vec<MixtapeCollectionItem>,
|
||||
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<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
items: Vec<MixtapeCollectionItem>,
|
||||
) {
|
||||
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<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
items: Vec<MixtapeCollectionItem>,
|
||||
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"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+251
-2
@@ -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<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
artist_id: String,
|
||||
ids: Vec<String>,
|
||||
) {
|
||||
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<u64> =
|
||||
ids.iter().filter_map(|s| s.parse::<u64>().ok()).collect();
|
||||
let tracks: Vec<QueueTrack> =
|
||||
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<String>)> =
|
||||
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<AppWindow>,
|
||||
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<QueueTrack> = 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<String>)> =
|
||||
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<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
playlist_id: String,
|
||||
) {
|
||||
let Ok(pid) = playlist_id.parse::<u64>() 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<Track> = 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<QueueTrack> = 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<String>)> =
|
||||
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<qbz_models::Track>,
|
||||
) {
|
||||
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<qbz_models::Track> = 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<String>)> =
|
||||
tracks.iter().map(|track| (track.id, None)).collect();
|
||||
if svc.play_later_batch_on_peer_if_active(&routed).await {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let mut added: Vec<QueueTrack> = 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<qbz_library::LocalTrack>,
|
||||
) {
|
||||
if tracks.is_empty() {
|
||||
return;
|
||||
}
|
||||
handle.spawn(async move {
|
||||
let qts: Vec<QueueTrack> = 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.
|
||||
|
||||
Reference in new issue
Block a user