feat(qt): multi-select bulk bar on every Qobuz track listing

The album-page pattern (selection in QML, actions in Rust) generalized:

- New shared engine bulk_tracks_qt.rs: one catalog batch-resolve that
  preserves the selection's visible order, then queue / play-next /
  play-later / add-to-playlist / add-to-mixtape / add-to-favorites /
  make-offline, with the queue context stamped per host.
- PlaylistView: toggle in the header action row, bar inline above the
  column header, selection resets when the playlist changes.
- ArtistView: the Popular Tracks header's dimmed toggle goes live; one
  selection covers Popular Tracks + Appears On + the in-library rows;
  the PopularTrackRow fork gets the select checkbox AND its offline
  cell/menu block stop being inert stubs.
- LabelView: Popular Tracks gets the same treatment.
- AlbumView: the bar moves from a floating overlay to the inline spot
  the Local Library album view uses (bar in flow, content below).

km
This commit is contained in:
vicrodh committed 2026-08-11 09:00:01 -06:00
1 parent d112c726b6
commit 79e3cdc46b
7 files changed
+580 -48

No files matched your search

+24 -28
View File
@@ -888,6 +888,30 @@ Rectangle {
}
}
// Multi-select bulk bar (controls/QbzMultiSelectBar.qml),
// INLINE at the top of the track column — the Local
// Library album view's layout (bar in flow, content below)
// instead of a floating overlay.
QbzMultiSelectBar {
id: bulkBar
visible: root.multiSelect && !root.primaryLoading
width: parent.width
selectedCount: root.selectedCount
// The reference's full AlbumView inventory
// (AlbumPageView.slint:792-807), make-offline included.
actions: [
{ "id": "select-all", "label": QbzSession.tr("Select all", QbzSession.trRev), "icon": "square-check-big", "danger": false, "needsSelection": false },
{ "id": "play-next", "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "danger": false, "needsSelection": true },
{ "id": "play-later", "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "danger": false, "needsSelection": true },
{ "id": "queue", "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "danger": false, "needsSelection": true },
{ "id": "add-to-playlist", "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "danger": false, "needsSelection": true },
{ "id": "add-to-favorites", "label": QbzSession.tr("Add to Library", QbzSession.trRev), "icon": "heart", "danger": false, "needsSelection": true },
{ "id": "make-offline", "label": QbzSession.tr("Make available offline", QbzSession.trRev), "icon": "cloud-download", "danger": false, "needsSelection": true },
{ "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true }
]
onAction: function (id) { root.bulkAction(id) }
}
// Toolbar — quality badge + track search (+ inert select).
Row {
visible: !QbzAlbum.albumLoading
@@ -1276,34 +1300,6 @@ Rectangle {
}
}
// Multi-select bulk bar (primitives/MultiSelectBar.slint port is
// controls/QbzMultiSelectBar.qml). Declared AFTER the scrolling body:
// declaration order IS z-order, and this bar overlays the content top.
QbzMultiSelectBar {
id: bulkBar
visible: root.multiSelect && !root.primaryLoading
anchors.top: parent.top
anchors.topMargin: 10
anchors.left: parent.left
anchors.leftMargin: 32
anchors.right: parent.right
anchors.rightMargin: 32
selectedCount: root.selectedCount
// The reference's full AlbumView inventory (AlbumPageView.slint
// :792-807), make-offline included.
actions: [
{ "id": "select-all", "label": QbzSession.tr("Select all", QbzSession.trRev), "icon": "square-check-big", "danger": false, "needsSelection": false },
{ "id": "play-next", "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "danger": false, "needsSelection": true },
{ "id": "play-later", "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "danger": false, "needsSelection": true },
{ "id": "queue", "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "danger": false, "needsSelection": true },
{ "id": "add-to-playlist", "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "danger": false, "needsSelection": true },
{ "id": "add-to-favorites", "label": QbzSession.tr("Add to Library", QbzSession.trRev), "icon": "heart", "danger": false, "needsSelection": true },
{ "id": "make-offline", "label": QbzSession.tr("Make available offline", QbzSession.trRev), "icon": "cloud-download", "danger": false, "needsSelection": true },
{ "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true }
]
onAction: function (id) { root.bulkAction(id) }
}
// Thin auto-hiding scrollbar (ListScrollbar).
QbzScrollBar {
anchors.right: parent.right
+195 -16
View File
@@ -774,11 +774,67 @@ Rectangle {
// MusicBrainz section). Reset per-artist view state ONLY when the id
// actually changed, or an enrichment pass would yank the sidebar tab back
// under the user mid-read.
// --- Multi-select (Popular Tracks + Appears On) ------------------------
// One selection model for the page's Qobuz track lists. Same shape as
// AlbumView/PlaylistView: state in QML, select-all/clear local, the rest
// via QbzPlayer.bulkTracksAction (bulk_tracks_qt.rs).
property bool multiSelect: false
property var selected: ({})
readonly property int selectedCount: Object.keys(root.selected).length
readonly property bool multiSelectOn: root.multiSelect
function setMultiSelect(on) {
root.multiSelect = on
if (!on) root.selected = ({})
}
function toggleSelected(id) {
var m = root.selected
if (m[id] === true) delete m[id]
else m[id] = true
// A rebind needs a NEW object reference — mutating in place notifies
// nothing.
root.selected = Object.assign({}, m)
}
function selectedIdsInOrder() {
var out = []
var lists = [root.topTracks, root.appearsOn]
for (var l = 0; l < lists.length; l++) {
var rows = lists[l]
for (var i = 0; i < rows.length; i++)
if (root.selected[rows[i].id] === true) out.push(rows[i].id)
}
return out
}
function bulkAction(action) {
if (action === "select-all") {
var m = {}
var lists = [root.topTracks, root.appearsOn]
for (var l = 0; l < lists.length; l++)
for (var i = 0; i < lists[l].length; i++) m[lists[l][i].id] = true
root.selected = m
return
}
if (action === "clear") { root.selected = ({}); return }
var ids = root.selectedIdsInOrder()
if (ids.length === 0) return
QbzPlayer.bulkTracksAction(JSON.stringify(ids), action, "artist", String(artist.id || ""))
if (action !== "add-to-playlist" && action !== "add-to-mixtape")
root.selected = ({})
}
// Ctrl+A / Escape hotkey router seam (AppShell duck-types these).
function selectAll() {
if (!root.multiSelect) root.setMultiSelect(true)
root.bulkAction("select-all")
}
function exitMultiSelectMode() {
if (root.multiSelect) root.setMultiSelect(false)
}
function syncArtistState() {
var id = artist.id || ""
if (id === loadedArtistId)
return
loadedArtistId = id
setMultiSelect(false)
// Slint opens a fresh artist on Network, or on Magazine when
// MusicBrainz is off (an empty Network tab is worse than none).
netTab = (artist.network && artist.network.mbAvailable) ? "network" : "magazine"
@@ -922,6 +978,21 @@ Rectangle {
property var row: ({})
property int rowIndex: 0
property bool showAlbum: true
// Multi-select (the view's bulk bar): in select mode the whole row
// body is the toggle and the leading cell swaps to a round checkbox
// (TrackRow.slint:173-177, 392-417).
property bool selectMode: false
property bool checked: false
signal toggleSelect()
// Live offline status for the row (seeded in the document; updates
// arrive on QbzShell's trackCacheStatusChanged).
property int cacheStatus: row.cacheStatus !== undefined ? row.cacheStatus : 0
Connections {
target: QbzShell
function onTrackCacheStatusChanged(trackId, status, progress) {
if (trackId === (popRow.row.id || "")) popRow.cacheStatus = status
}
}
TrackCols { id: cols }
@@ -960,7 +1031,8 @@ Rectangle {
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
// Cover with hover play overlay.
// Cover with hover play overlay — in select mode this cell is
// the row's round checkbox instead.
//
// RADIUS 4, not `theme.radiusSm` (8): rows/TrackRow.qml:553 — the
// SHARED row this component is a fork of, and the one PlaylistView
@@ -978,7 +1050,27 @@ Rectangle {
radius: 4
color: theme.surfaceElevated
clip: true
// The select-mode checkbox (accent fill when checked).
Rectangle {
visible: popRow.selectMode
anchors.centerIn: parent
width: 14
height: 14
radius: 7
color: popRow.checked ? theme.accent : "transparent"
border.width: popRow.checked ? 0 : 1.5
border.color: theme.textMuted
QbzIcon {
visible: popRow.checked
anchors.centerIn: parent
name: "check"
width: 10
height: 10
tintName: theme.accentGlyphTint
}
}
QbzIcon {
visible: !popRow.selectMode
anchors.centerIn: parent
name: "music"
width: showAlbum ? 16 : 14
@@ -986,11 +1078,13 @@ Rectangle {
tintName: "muted"
}
RoundedImage {
visible: !popRow.selectMode
anchors.fill: parent
source: root.coverMap[row.artUrl] || ""
radius: 4
}
Rectangle {
visible: !popRow.selectMode
anchors.fill: parent
radius: 4
color: "#000000"
@@ -998,7 +1092,7 @@ Rectangle {
Behavior on opacity { NumberAnimation { duration: 150 } }
}
QbzIcon {
visible: trArea.containsMouse || isActive
visible: !popRow.selectMode && (trArea.containsMouse || isActive)
anchors.centerIn: parent
name: isActive && QbzPlayer.npPlaying ? "pause" : "play-fill"
width: 16
@@ -1009,6 +1103,7 @@ Rectangle {
}
MouseArea {
anchors.fill: parent
enabled: !popRow.selectMode
cursorShape: Qt.PointingHandCursor
onClicked: QbzPlayer.playArtistTrack(row.id)
}
@@ -1122,6 +1217,7 @@ Rectangle {
MouseArea {
id: favArea
anchors.fill: parent
enabled: !popRow.selectMode
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
@@ -1130,13 +1226,54 @@ Rectangle {
}
}
}
// Offline download — INERT stub.
// Offline download — live since the offline port (the fork used
// to carry an inert stub). Same status vocabulary as the shared
// TrackRow: 0 none · 1 queued · 2 downloading · 3 ready · 4 failed.
Rectangle {
width: cols.colDownload
height: cols.colDownload
anchors.verticalCenter: parent.verticalCenter
color: "transparent"
QbzIcon { anchors.centerIn: parent; name: "cloud-download"; width: 16; height: 16; tintName: "muted" }
QbzIcon {
visible: popRow.cacheStatus === 0
anchors.centerIn: parent
name: "cloud-download"
width: 16
height: 16
tintName: popDlArea.containsMouse ? root.tintOnSurface : "muted"
}
QbzSpinner {
visible: popRow.cacheStatus === 1 || popRow.cacheStatus === 2
anchors.centerIn: parent
size: 16
}
QbzIcon {
visible: popRow.cacheStatus === 3
anchors.centerIn: parent
name: "circle-check-big"
width: 16
height: 16
tintName: "accent"
}
QbzIcon {
visible: popRow.cacheStatus === 4
anchors.centerIn: parent
name: "triangle-alert"
width: 16
height: 16
tintName: "favorite"
}
MouseArea {
id: popDlArea
anchors.fill: parent
enabled: !popRow.selectMode
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (popRow.cacheStatus === 3) QbzPlayer.uncacheTrack(row.id)
else QbzPlayer.cacheTrack(row.id)
}
}
}
// ⋯ row menu. It used to be a hover-lit button with NO handler at
// all — a control that renders and no-ops, which is the defect
@@ -1151,6 +1288,7 @@ Rectangle {
MouseArea {
id: moreArea
anchors.fill: parent
enabled: !popRow.selectMode
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: function (mouse) { popMenu.openAtCursor(moreArea, mouse.x, mouse.y) }
@@ -1172,11 +1310,11 @@ Rectangle {
// `controls/CardMenu.qml`, the same primitive rows/TrackRow.qml opens.
//
// ABSENT, not dead (the same discipline TrackRow applies): the radio
// pair, Share Qobuz link / Song.link, the offline-cache block and
// Track info. The first two need bridge seams that do not exist; the
// last two need the shared row's lazy Loaders (a TextEdit for the
// clipboard, a TrackInfoModal), and duplicating those per artist row
// is exactly the fork-drift this file already paid for once.
// pair, Share Qobuz link / Song.link and Track info. The first two
// need bridge seams that do not exist; the last needs the shared
// row's lazy Loader (a TrackInfoModal), and duplicating those per
// artist row is exactly the fork-drift this file already paid for
// once. (The offline block IS wired — it needs no Loader.)
CardMenu {
id: popMenu
menuWidth: 224
@@ -1194,6 +1332,12 @@ Rectangle {
{ "label": t("Add to mixtape", r), "icon": "cassette-tape", "action": "mixtape" },
{ "label": t("Add to playlist", r), "icon": "list-music", "action": "add-to-playlist" },
]
if (popRow.cacheStatus === 3) {
m.push({ "label": t("Refresh offline copy", r), "icon": "refresh-cw", "action": "recache" })
m.push({ "label": t("Remove offline copy", r), "icon": "trash-2", "action": "uncache", "danger": true })
} else {
m.push({ "label": t("Make available offline", r), "icon": "cloud-download", "action": "cache" })
}
if ((popRow.row.albumId || "") !== "")
m.push({ "label": t("Go to album", r), "icon": "disc-3", "action": "go-album" })
if ((popRow.row.artistId || "") !== "")
@@ -1225,6 +1369,9 @@ Rectangle {
"year": null, "trackCount": null
}]))
}
else if (a === "cache") QbzPlayer.cacheTrack(id)
else if (a === "uncache") QbzPlayer.uncacheTrack(id)
else if (a === "recache") QbzPlayer.recacheTrack(id)
else if (a === "go-album") QbzAlbum.openAlbum(popRow.row.albumId)
else if (a === "go-artist") QbzArtist.openArtist(popRow.row.artistId)
}
@@ -1235,8 +1382,11 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
propagateComposedEvents: true
onDoubleClicked: QbzPlayer.playArtistTrack(row.id)
onClicked: mouse.accepted = false
onDoubleClicked: if (!popRow.selectMode) QbzPlayer.playArtistTrack(row.id)
onClicked: {
if (popRow.selectMode) popRow.toggleSelect()
else mouse.accepted = false
}
}
// Right press opens the SAME menu at the pointer — the invariant
// controls/QbzContextMenu.qml:20-22 states. Declared last so it sits
@@ -2067,6 +2217,27 @@ Rectangle {
spacing: 0
// --- Popular Tracks -------------------------------------
// Multi-select bulk bar — INLINE above the Popular Tracks
// header (the Local Library album view's layout: bar in flow,
// content below), covering this page's Qobuz track lists.
QbzMultiSelectBar {
visible: root.multiSelect
width: parent.width
selectedCount: root.selectedCount
actions: [
{ "id": "select-all", "label": QbzSession.tr("Select all", QbzSession.trRev), "icon": "square-check-big", "danger": false, "needsSelection": false },
{ "id": "play-next", "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "danger": false, "needsSelection": true },
{ "id": "play-later", "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "danger": false, "needsSelection": true },
{ "id": "queue", "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "danger": false, "needsSelection": true },
{ "id": "add-to-playlist", "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "danger": false, "needsSelection": true },
{ "id": "add-to-mixtape", "label": QbzSession.tr("Add to Mixtape/Collection", QbzSession.trRev), "icon": "cassette-tape", "danger": false, "needsSelection": true },
{ "id": "add-to-favorites", "label": QbzSession.tr("Add to Library", QbzSession.trRev), "icon": "heart", "danger": false, "needsSelection": true },
{ "id": "make-offline", "label": QbzSession.tr("Make available offline", QbzSession.trRev), "icon": "cloud-download", "danger": false, "needsSelection": true },
{ "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true }
]
onAction: function (id) { root.bulkAction(id) }
}
Row {
property string anchorId: "popular-tracks"
visible: topTracks.length > 0
@@ -2094,14 +2265,13 @@ Rectangle {
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzPlayer.playArtistTop(false)
}
// Multi-select has no seam on this bridge. Rendered but
// DIMMED and click-gated (CircleAction's own `enabled`
// treatment) rather than a live button that does nothing —
// same call as the header's Radio / Mixtape / Info.
// Multi-select toggle — live since the bulk-bar port.
QbzCircleAction {
name: "square-check-big"
btnEnabled: false
active: root.multiSelect
btnEnabled: root.topTracks.length > 0
anchors.verticalCenter: parent.verticalCenter
onClicked: root.setMultiSelect(!root.multiSelect)
}
QbzCircleAction {
id: topMenuBtn
@@ -2143,6 +2313,9 @@ Rectangle {
row: topTracks[index]
rowIndex: index
showAlbum: true
selectMode: root.multiSelect
checked: root.selected[row.id] === true
onToggleSelect: root.toggleSelected(row.id)
}
}
Item { visible: topTracks.length > root.preview; width: 1; height: 4 }
@@ -2237,6 +2410,9 @@ Rectangle {
row: appearsOn[index]
rowIndex: index
showAlbum: false
selectMode: root.multiSelect
checked: root.selected[row.id] === true
onToggleSelect: root.toggleSelected(row.id)
}
}
Item { visible: appearsOn.length > root.preview; width: 1; height: 4 }
@@ -2416,6 +2592,9 @@ Rectangle {
})
rowIndex: index
showAlbum: true
selectMode: root.multiSelect
checked: root.selected[row.id] === true
onToggleSelect: root.toggleSelected(row.id)
}
}
Item { visible: libraryTab.libAlbums.length > 0; width: 1; height: 24 }
+78 -2
View File
@@ -83,9 +83,60 @@ Rectangle {
root.loadedLabelId = id
root.previewCount = 5
root.activeJumpTab = "about"
root.setMultiSelect(false)
flick.contentY = 0
}
// --- Multi-select (Popular Tracks) -------------------------------------
// Same shape as AlbumView/PlaylistView: selection in QML, select-all /
// clear local, the rest via QbzPlayer.bulkTracksAction.
property bool multiSelect: false
property var selected: ({})
readonly property int selectedCount: Object.keys(root.selected).length
readonly property bool multiSelectOn: root.multiSelect
function setMultiSelect(on) {
root.multiSelect = on
if (!on) root.selected = ({})
}
function toggleSelected(id) {
var m = root.selected
if (m[id] === true) delete m[id]
else m[id] = true
// A rebind needs a NEW object reference — mutating in place notifies
// nothing.
root.selected = Object.assign({}, m)
}
function selectedIdsInOrder() {
var rows = root.topTracks
var out = []
for (var i = 0; i < rows.length; i++)
if (root.selected[rows[i].id] === true) out.push(rows[i].id)
return out
}
function bulkAction(action) {
if (action === "select-all") {
var m = {}
var rows = root.topTracks
for (var i = 0; i < rows.length; i++) m[rows[i].id] = true
root.selected = m
return
}
if (action === "clear") { root.selected = ({}); return }
var ids = root.selectedIdsInOrder()
if (ids.length === 0) return
QbzPlayer.bulkTracksAction(JSON.stringify(ids), action, "label", String(doc.id || ""))
if (action !== "add-to-playlist" && action !== "add-to-mixtape")
root.selected = ({})
}
// Ctrl+A / Escape hotkey router seam (AppShell duck-types these).
function selectAll() {
if (!root.multiSelect) root.setMultiSelect(true)
root.bulkAction("select-all")
}
function exitMultiSelectMode() {
if (root.multiSelect) root.setMultiSelect(false)
}
function scrollToSection(id) {
root.activeJumpTab = id
if (id === "about") {
@@ -452,6 +503,26 @@ Rectangle {
}
}
// Multi-select bulk bar — INLINE above the Popular Tracks
// header (bar in flow, content below).
QbzMultiSelectBar {
visible: root.multiSelect
width: parent.width
selectedCount: root.selectedCount
actions: [
{ "id": "select-all", "label": QbzSession.tr("Select all", QbzSession.trRev), "icon": "square-check-big", "danger": false, "needsSelection": false },
{ "id": "play-next", "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "danger": false, "needsSelection": true },
{ "id": "play-later", "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "danger": false, "needsSelection": true },
{ "id": "queue", "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "danger": false, "needsSelection": true },
{ "id": "add-to-playlist", "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "danger": false, "needsSelection": true },
{ "id": "add-to-mixtape", "label": QbzSession.tr("Add to Mixtape/Collection", QbzSession.trRev), "icon": "cassette-tape", "danger": false, "needsSelection": true },
{ "id": "add-to-favorites", "label": QbzSession.tr("Add to Library", QbzSession.trRev), "icon": "heart", "danger": false, "needsSelection": true },
{ "id": "make-offline", "label": QbzSession.tr("Make available offline", QbzSession.trRev), "icon": "cloud-download", "danger": false, "needsSelection": true },
{ "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true }
]
onAction: function (id) { root.bulkAction(id) }
}
// --- Popular Tracks -------------------------------------
// The WHOLE block is gated on a non-empty list (.slint:313).
Column {
@@ -478,11 +549,13 @@ Rectangle {
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzHome.labelPlayTop()
}
// Multi-select: DIMMED and inert (see the header note).
// Multi-select toggle — live since the bulk-bar port.
QbzCircleAction {
name: "square-check-big"
btnEnabled: false
active: root.multiSelect
btnEnabled: root.topTracks.length > 0
anchors.verticalCenter: parent.verticalCenter
onClicked: root.setMultiSelect(!root.multiSelect)
}
QbzCircleAction {
id: topMoreBtn
@@ -553,6 +626,9 @@ Rectangle {
number: index + 1
showArtwork: true
showAlbum: true
selectMode: root.multiSelect
checked: root.selected[item.id] === true
onToggleSelect: root.toggleSelected(item.id)
onPlayRequested: QbzHome.labelPlayTrack(modelData.id)
onEnqueueRequested: function (mode) {
QbzHome.labelEnqueueTrack(modelData.id, mode)
+94 -2
View File
@@ -68,6 +68,66 @@ Rectangle {
readonly property var allTracks: doc.tracks || []
readonly property bool isOwner: doc.isOwner === true
readonly property bool loading: doc.loading === true
// --- Multi-select (the Qobuz track rows) ------------------------------
// Same shape as AlbumView: the selection lives in QML, select-all/clear
// never reach Rust, everything else goes down as a JSON id array through
// QbzPlayer.bulkTracksAction (bulk_tracks_qt.rs).
property bool multiSelect: false
property var selected: ({})
readonly property int selectedCount: Object.keys(root.selected).length
readonly property bool multiSelectOn: root.multiSelect
property string loadedPlaylistId: ""
function setMultiSelect(on) {
root.multiSelect = on
if (!on) root.selected = ({})
}
function toggleSelected(id) {
var m = root.selected
if (m[id] === true) delete m[id]
else m[id] = true
// A rebind needs a NEW object reference — mutating in place notifies
// nothing.
root.selected = Object.assign({}, m)
}
function selectedIdsInOrder() {
var rows = root.tracks
var out = []
for (var i = 0; i < rows.length; i++)
if (root.selected[rows[i].id] === true) out.push(rows[i].id)
return out
}
function bulkAction(action) {
if (action === "select-all") {
var m = {}
var rows = root.tracks
for (var i = 0; i < rows.length; i++) m[rows[i].id] = true
root.selected = m
return
}
if (action === "clear") { root.selected = ({}); return }
var ids = root.selectedIdsInOrder()
if (ids.length === 0) return
QbzPlayer.bulkTracksAction(JSON.stringify(ids), action, "playlist", String(doc.id || ""))
if (action !== "add-to-playlist" && action !== "add-to-mixtape")
root.selected = ({})
}
// Ctrl+A / Escape hotkey router seam (AppShell duck-types these).
function selectAll() {
if (!root.multiSelect) root.setMultiSelect(true)
root.bulkAction("select-all")
}
function exitMultiSelectMode() {
if (root.multiSelect) root.setMultiSelect(false)
}
function syncPlaylistState() {
var id = doc.id || ""
if (id === loadedPlaylistId)
return
loadedPlaylistId = id
setMultiSelect(false)
}
onDocChanged: syncPlaylistState()
readonly property string sortField: doc.sortField || "default"
readonly property bool sortAsc: doc.sortAsc === true
readonly property string searchQuery: doc.search || ""
@@ -577,8 +637,17 @@ Rectangle {
// and open the editor on no playlist, silently.
onClicked: QbzPlaylistEdit.open(String(root.doc.id || ""))
}
// Multi-select toggle (AlbumView's button, here for the
// playlist's track list).
QbzCircleAction {
name: "square-check-big"
active: root.multiSelect
btnEnabled: root.tracks.length > 0
anchors.verticalCenter: parent.verticalCenter
onClicked: root.setMultiSelect(!root.multiSelect)
}
Item { width: parent.width - 40 - 32 * 6 - 7 * 12 - 220 - 140; height: 1 }
Item { width: parent.width - 40 - 32 * 7 - 8 * 12 - 220 - 140; height: 1 }
// In-playlist search.
Rectangle {
@@ -737,6 +806,26 @@ Rectangle {
// width, which is inset 14px for the scrollbar gutter — 14px of
// extra stretch pushed Album/Duration/Quality right. The width
// below is the ROW width, which is the component's contract.
// Multi-select bulk bar — INLINE above the column header (the Local
// Library album view's layout: bar in flow, list below).
QbzMultiSelectBar {
visible: root.multiSelect && root.tracks.length > 0
width: parent.width
selectedCount: root.selectedCount
actions: [
{ "id": "select-all", "label": QbzSession.tr("Select all", QbzSession.trRev), "icon": "square-check-big", "danger": false, "needsSelection": false },
{ "id": "play-next", "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "danger": false, "needsSelection": true },
{ "id": "play-later", "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "danger": false, "needsSelection": true },
{ "id": "queue", "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "danger": false, "needsSelection": true },
{ "id": "add-to-playlist", "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "danger": false, "needsSelection": true },
{ "id": "add-to-mixtape", "label": QbzSession.tr("Add to Mixtape/Collection", QbzSession.trRev), "icon": "cassette-tape", "danger": false, "needsSelection": true },
{ "id": "add-to-favorites", "label": QbzSession.tr("Add to Library", QbzSession.trRev), "icon": "heart", "danger": false, "needsSelection": true },
{ "id": "make-offline", "label": QbzSession.tr("Make available offline", QbzSession.trRev), "icon": "cloud-download", "danger": false, "needsSelection": true },
{ "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true }
]
onAction: function (id) { root.bulkAction(id) }
}
TrackListHeader {
visible: root.tracks.length > 0
width: parent.width - 14
@@ -754,7 +843,7 @@ Rectangle {
// --- Track list -------------------------------------------------------
Item {
width: parent.width
height: parent.height - 28 - 150 - 18 - 50
height: parent.height - 28 - 150 - 18 - 50 - (root.multiSelect ? 46 : 0)
// Track-list placeholder: the exact TrackRow footprint (50px
// rows, no gap, 36px art cell), one instance for the whole
@@ -788,6 +877,9 @@ Rectangle {
showArtwork: true
showAlbum: true
showDownload: true
selectMode: root.multiSelect
checked: root.selected[item.id] === true
onToggleSelect: root.toggleSelected(item.id)
menuShowRemove: root.isOwner
// Reorder chevrons (owner findings 5 + 8). `index` is the
// index into `root.tracks`, which under `canReorder` is
+172
View File
@@ -0,0 +1,172 @@
//! Bulk actions over a selection of Qobuz catalog track ids — the shared
//! engine every track-listing multi-select bar calls (Playlist view, Artist
//! Popular Tracks, Label popular tracks; the album page keeps its own
//! album-ordered variant in album_qt.rs because it resolves against the
//! open album, not the catalog).
//!
//! The selection lives in QML; select-all/clear never reach Rust. Ids
//! arrive in VISIBLE order and stay in it (Slint's selection order rule).
use qbz_models::Track;
/// Resolve the selection against the catalog in ONE call, preserving the
/// selection's order (get_tracks_batch answers in arbitrary order).
async fn resolve(ids: &[String]) -> Vec<Track> {
let raw: Vec<u64> = ids.iter().filter_map(|s| s.parse::<u64>().ok()).collect();
if raw.is_empty() {
return Vec::new();
}
let runtime = crate::app();
let mut tracks = match runtime.core().get_tracks_batch(&raw).await {
Ok(t) => t,
Err(e) => {
log::warn!("[qbz-qt] bulk tracks resolve failed: {e}");
return Vec::new();
}
};
let mut ordered: Vec<Track> = Vec::new();
for id in &raw {
if let Some(pos) = tracks.iter().position(|t| t.id == *id) {
ordered.push(tracks.swap_remove(pos));
}
}
ordered
}
/// One QueueTrack per catalog track (the field mapping of
/// `playback_qt::catalog_queue_track`, factored for batch use).
fn queue_track(track: &Track) -> qbz_models::QueueTrack {
let (album_id, album_title, album_artwork) = match track.album.as_ref() {
Some(album) => (
album.id.clone(),
album.title.clone(),
album.image.best().cloned().unwrap_or_default(),
),
None => (String::new(), String::new(), String::new()),
};
let album_key = if album_id.is_empty() {
None
} else {
Some(album_id)
};
qbz_models::QueueTrack {
id: track.id,
title: track.title.clone(),
version: track.version.clone(),
artist: track
.performer
.as_ref()
.map(|p| p.name.clone())
.unwrap_or_default(),
album: album_title,
album_version: None,
duration_secs: track.duration as u64,
artwork_url: if album_artwork.is_empty() {
None
} else {
Some(album_artwork)
},
hires: track.hires,
bit_depth: track.maximum_bit_depth,
sample_rate: track.maximum_sampling_rate,
is_local: false,
album_id: album_key.clone(),
artist_id: track.performer.as_ref().map(|p| p.id),
streamable: track.streamable,
source: Some("qobuz".to_string()),
parental_warning: track.parental_warning,
source_item_id_hint: album_key,
context_kind: None,
context_id: None,
}
}
/// `context_kind`/`context_id` stamp the queue's play context ("playlist",
/// "artist", "label"); empty strings stamp nothing.
pub fn run(ids_json: String, action: String, context_kind: String, context_id: String) {
let ids: Vec<String> = serde_json::from_str(&ids_json).unwrap_or_default();
if ids.is_empty() {
log::debug!("[qbz-qt] bulk tracks {action}: empty selection, ignored");
return;
}
match action.as_str() {
"queue" | "play-next" | "play-later" => {
crate::spawn(async move {
let tracks = resolve(&ids).await;
if tracks.is_empty() {
return;
}
let mut queue: Vec<_> = tracks.iter().map(queue_track).collect();
let mode = match action.as_str() {
"play-next" => "next",
"play-later" => "later",
_ => "queue",
};
// "next" inserts at the cursor — feed REVERSED so the block
// keeps its order (playback_qt::enqueue_album's rule).
if mode == "next" {
queue.reverse();
}
let queue = crate::playback_qt::stamped(
queue,
crate::playback_qt::PlayContext::new(&context_kind, &context_id),
);
let runtime = crate::app();
if let Err(e) =
crate::playback_qt::enqueue_track_list_mode(&runtime, queue, mode).await
{
log::error!("[qbz-qt] bulk tracks {mode} failed: {e}");
}
});
}
"add-to-playlist" => {
crate::playlist_picker_qt::open_for_ids(&crate::app(), ids);
}
"add-to-mixtape" => {
crate::spawn(async move {
let items: Vec<_> = resolve(&ids)
.await
.iter()
.map(|t| crate::myqbz_add_qt::AddItem {
item_type: "track".into(),
source: "qobuz".into(),
source_item_id: t.id.to_string(),
title: t.title.clone(),
subtitle: t.performer.as_ref().map(|p| p.name.clone()),
artwork_url: t
.album
.as_ref()
.and_then(|a| a.image.thumbnail.clone().or(a.image.small.clone())),
year: None,
track_count: None,
})
.collect();
if !items.is_empty() {
crate::myqbz_add_qt::open_items(items);
}
});
}
"add-to-favorites" => {
crate::spawn(async move {
let runtime = crate::app();
for id in &ids {
if let Err(e) = runtime.core().add_favorite("track", id).await {
log::error!("[qbz-qt] bulk favorite {id} failed: {e}");
}
crate::fav_cache_qt::set("track", id, true);
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
}
crate::toast_qt::success(qbz_i18n::t("Added to Library"));
});
}
"make-offline" => {
crate::spawn(async move {
let tracks = resolve(&ids).await;
if !tracks.is_empty() {
crate::offline_cache_qt::cache_tracks(tracks);
}
});
}
other => log::warn!("[qbz-qt] bulk tracks: unknown action {other}"),
}
}
+3
View File
@@ -187,6 +187,9 @@ mod offline_fwd;
// user-managed download cache (see AGENTS.md's caching-model note).
mod offline_qt;
mod offline_cache_qt;
// Shared multi-select bulk actions for Qobuz track listings (playlist,
// artist, label — the album page has its own album-ordered variant).
mod bulk_tracks_qt;
mod album_qt;
mod track_info_qt;
// Album Info (Credits/Review) modal controller — info_modals.rs port.
+14
View File
@@ -178,6 +178,11 @@ pub mod qbz_player {
fn uncache_track(self: Pin<&mut QbzPlayer>, track_id: QString);
#[qinvokable]
fn recache_track(self: Pin<&mut QbzPlayer>, track_id: QString);
/// Multi-select bulk actions for Qobuz track listings
/// (bulk_tracks_qt.rs): ids in visible order + the bar's action id +
/// the queue context to stamp ("" = none).
#[qinvokable]
fn bulk_tracks_action(self: Pin<&mut QbzPlayer>, ids_json: QString, action: QString, context_kind: QString, context_id: QString);
/// ArtistView Popular Tracks row play (whole list as the queue).
#[qinvokable]
fn play_artist_track(self: Pin<&mut QbzPlayer>, track_id: QString);
@@ -412,6 +417,15 @@ impl qbz_player::QbzPlayer {
}
}
pub fn bulk_tracks_action(self: Pin<&mut Self>, ids_json: QString, action: QString, context_kind: QString, context_id: QString) {
crate::bulk_tracks_qt::run(
ids_json.to_string(),
action.to_string(),
context_kind.to_string(),
context_id.to_string(),
);
}
pub fn play_artist_track(self: Pin<&mut Self>, track_id: QString) {
if let Ok(tid) = track_id.to_string().parse::<u64>() {
crate::play_artist_track(tid);