feat(purchases): detail polish and a Downloads section in the nav flyout

The purchase detail badges what was bought on every row (DSD128 on a
DSD purchase, not the catalog's 24/352.8), marks the formats already on
disk with a check in the dropdown, offers a track filter, links to the
album in the Local Library once its folder is indexed, and gives the
download the accent while Play drops to a plain control of the same
size — on this screen the download is the point.

Downloads are process-wide and survive leaving the page; the nav flyout
now lists the purchases with a download in flight under "Downloads · N"
so the user can find them again, each row reopening its detail.

cc
This commit is contained in:
vicrodh committed 2026-09-02 01:30:12 -06:00
1 parent fd44d38cd3
commit 07e683f5ef
12 files changed
+325 -29

No files matched your search

+87 -4
View File
@@ -104,13 +104,43 @@ Rectangle {
if (root.formats[i].id === want) return i
return 0
}
// "✓" after every format whose download is COMPLETE for the whole album
// (`FormatRow.downloaded`, from the registry): the dropdown then says
// which versions are already on disk without opening each one.
readonly property var formatLabels: {
var out = []
for (var i = 0; i < root.formats.length; i++)
out.push(root.formats[i].label || "")
out.push((root.formats[i].label || "")
+ (root.formats[i].downloaded === true ? " ✓" : ""))
return out
}
// The track filter's text and its matcher (title / performer, case-folded).
property string query: ""
function matches(track) {
var q = root.query.trim().toLowerCase()
if (q === "")
return true
var t = (track.title || "").toLowerCase()
var a = (track.artist || "").toLowerCase()
return t.indexOf(q) >= 0 || a.indexOf(q) >= 0
}
readonly property int trackTotal: {
var n = 0
for (var d = 0; d < root.discs.length; d++)
n += (root.discs[d].tracks || []).length
return n
}
readonly property int matchCount: {
var n = 0
for (var d = 0; d < root.discs.length; d++) {
var ts = root.discs[d].tracks || []
for (var i = 0; i < ts.length; i++)
if (root.matches(ts[i])) n++
}
return n
}
// Click-to-play is gated on `streamable`, which on THIS screen defaults
// FALSE (§G.3 / §2.6): these tracks come from the catalog album, not from
// the purchases list. The album Play button therefore only appears when
@@ -445,9 +475,15 @@ Rectangle {
width: parent.width
spacing: 12
// On THIS screen the download is the point and Play
// is the convenience, so the accent goes to the
// cloud and both share one intermediate size — the
// 44px primary Play of the catalog album page read as
// the main event here (smoke 2026-09-02).
QbzCircleAction {
visible: root.anyStreamable
primary: true
primary: false
diameterOverride: 38
name: "play-fill"
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzPlayer.playAlbum(root.doc.id || "")
@@ -458,7 +494,9 @@ Rectangle {
// it (§4.3: the four formats come from album metadata,
// and an album can legitimately offer none).
QbzCircleAction {
primary: !root.anyStreamable
primary: true
compactPrimary: true
diameterOverride: 38
name: "cloud-download"
btnEnabled: !(root.progress.active === true)
&& root.formats.length > 0
@@ -500,6 +538,20 @@ Rectangle {
btnEnabled: !(root.progress.active === true)
onClicked: QbzPurchases.chooseDestination()
}
// The album is ALREADY in the Local Library: jump to
// it. `localAlbumId` is the download folder the
// library indexed (purchases_qt::resolve_local_album),
// which is exactly the id the local album page opens.
IconTextButton {
visible: (root.doc.localAlbumId || "") !== ""
anchors.verticalCenter: parent.verticalCenter
label: root.t("Open in Local Library")
iconName: "hard-drive"
onClicked: {
QbzLocal.openAlbum(root.doc.localAlbumId)
QbzShell.navigateTo("localalbum")
}
}
// GOODIES — album-level only, never per-track, and
// completely absent when the list is empty (§14.3).
// Not disabled, not an empty state: the owner's
@@ -772,6 +824,35 @@ Rectangle {
width: parent.width - 64
spacing: 0
// Track filter — the Local Library album page's "Filter
// tracks" box, so a 30-track purchase is searchable in place.
// Presentation-only: rows that do not match collapse to 0px.
Item {
visible: root.discs.length > 0
width: parent.width
height: visible ? 44 : 0
QbzLineEdit {
id: trackFilter
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 260
sm: true
searchMode: true
placeholder: root.t("Filter tracks")
text: root.query
onEdited: function (v) { root.query = v }
}
Text {
anchors.right: trackFilter.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
visible: root.query.trim() !== ""
text: root.matchCount + " / " + root.trackTotal
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
}
// Column header. The arms MIRROR the delegate's arms below —
// no heart, no shared offline column, no ⋯ — and
// `trailingReserve` is the 28px status cell plus its 14px gap,
@@ -839,7 +920,9 @@ Rectangle {
required property var modelData
required property int index
width: parent ? parent.width : 0
height: 50
readonly property bool shown: root.matches(modelData)
visible: shown
height: shown ? 50 : 0
readonly property bool streamable: modelData.streamable === true
readonly property string trackId: modelData.id || ""