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:
7 files changed
+580
-48
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"),
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user