Files
qbz/crates/qbz-qt/qml/views/PurchaseAlbumView.qml
T
vicrodh e816436fd0 feat(qt): remember where each page was scrolled, across back and forward
The port of the block Slint repeats in 27 views (NavState.restore-scope /
scroll-restore / report-scroll). A .slint file cannot hand a component a
reference to a Flickable, which is why the reference inlines it; QML can, so
controls/ScrollMemory.qml exists once and 31 containers mount it.

Two divergences from the reference, both forced by the same fact — tabs are not
history entries in this port:

1. THE SCOPE IS NAMED BY THE VIEW, not derived from the entry by a scope_for
   table. Slint can derive it because its entries carry the tab
   (Favorites { tab }); here an entry is a bare route id, so a derived scope
   would read "library" for all five Library tabs and pour the Albums grid's
   offset into whatever tab mounted. Letting the reporter name its own scope
   buys tab granularity for free, and the mismatch case degrades the right way:
   an unmatched scope restores NOTHING instead of restoring the wrong thing.

2. THE RESTORE RE-ASSERTS instead of firing once. A Qt item view resets
   contentY to 0 whenever its model is replaced, and several pages assign their
   model from a binding whose inputs arrive over the network after the mount —
   the album page rebuilds listCells from three deferred rails, so it hands its
   ListView a new array up to four times in its first seconds and snaps back to
   the top over a restore that already ran correctly. The offset is held and
   re-applied until a 2.5 s deadline, or until the user takes over (a wheel, a
   drag or a flick raises `moving`, and from then the position is theirs).

Diagnostics are off by default: QT_LOGGING_RULES="qbz.nav.scroll.info=true" for
the QML half, RUST_LOG=qbz=debug for the stamp/arm half.

cc
2026-08-19 23:54:46 -06:00

1002 lines
50 KiB
QML

// Purchased-album detail page — screen 2 of Purchases
// (contract §6 / §15.2, recon 02-tauri-frontend §B.2).
//
// Built from views/AlbumView.qml: same page shell (Flickable + 32px-padded
// Column + QbzScrollBar), same 224px cover frame, the same rows/TrackRow +
// rows/TrackListHeader table on rows/TrackCols geometry, the same disc
// separators. What it adds over that template is the five things §15.2 lists:
//
// 1. the FORMAT dropdown (QbzSelect) — and the re-scoping it triggers is
// the most surprising behaviour on this screen: every green check, the
// progress section and Add-to-Library are all recomputed for the newly
// selected format. NONE of that is computed here. The document says
// `downloaded` per track, `progress` and `addToLibraryVisible`; this
// file renders exactly what it is handed, so the re-scope is one
// republish and cannot drift from the controller's own answer.
// 2. a DETERMINATE progress bar, `completed / total` — counted in TRACKS,
// never bytes (§4.2). This is the ONLY place a percentage appears.
// 3. ADD TO LIBRARY, which is a SEQUENCE and not a banner (§6): the click
// is one `addToLibrary()` call and the controller owns adding the
// folder, clearing the download state, the toast and the scan.
// 4. DOWNLOAD GOODIES — album-level only, never per-track, and ABSENT
// (not disabled, not an empty state) when the list is empty (§14.3).
// 5. track titles through `formatTrackTitle` (§10-C).
//
// --- The data seam ------------------------------------------------------
// `QbzPurchases.albumJson`, re-parsed on `QbzPurchases.purchasesRev` (§G.3).
// The rev is read purely as a binding dependency so a byte-identical
// republish still re-parses. Every field is read defensively: the document
// defaults to "{}" and QML parses it on the first frame.
//
// --- Per-track download state -------------------------------------------
// `status` is the existing four-part vocabulary — 0 none · 1 queued ·
// 2 downloading · 3 ready · 4 failed — drawn with the SAME glyph set
// TrackRow's offline column uses (TrackRow.qml:782-835), so a purchase
// download looks identical to a cache download. It does NOT ride the
// `trackCacheStatusChanged` channel (§G.4): that channel is keyed by
// track id alone and drives the OFFLINE-CACHE indicator, and a track can be
// both cached offline and purchase-downloaded. Purchase status travels
// inside `album_json`, which is per-album and cannot collide.
//
// --- Why the status cell lives OUTSIDE the shared TrackRow --------------
// The views/local/LocalTrackRow.qml pattern: the shared row is narrowed by
// exactly the 28px cell + 14px gap it does not draw, and the cell is drawn
// in the gutter that frees. TrackListHeader reserves the same slice through
// `trailingReserve: 42`, so the header and the rows stay the same width by
// construction. The alternative — teaching TrackRow a second, purchase-only
// download column — would fork a file five other surfaces share.
//
// --- Animation ----------------------------------------------------------
// Every spinner on this page is ONE phase advanced on the shared shell pulse
// (QbzShell.pulseMs, ~30 Hz) and frozen when nothing is in flight. Qt Quick
// has no partial redraws, so a component-local RotationAnimation here would
// bill a whole-window present per display frame for the life of a download.
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../controls"
import "../rows"
import "../theme"
Rectangle {
id: root
// Transparent while the ambient background is active — AlbumView's rule.
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: theme.ambientOn
// Rounds to the AppShell content-frame bezel (QML clips are rectangular,
// so the frame's rounding never reaches the view — the fill must round).
radius: 12
QbzTheme { id: theme }
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
// ---- The document (§G.3) --------------------------------------------
// `rev` is a binding dependency ONLY: it is bumped on every publish so a
// republish of byte-identical JSON still re-parses here.
readonly property var doc: root._parse(QbzPurchases.albumJson, QbzPurchases.purchasesRev)
function _parse(raw, rev) {
try { return JSON.parse(raw || "{}") } catch (e) { return ({}) }
}
readonly property bool loading: doc.loading === true
readonly property string errorText: doc.error || ""
readonly property bool ready: !root.loading && root.errorText === ""
readonly property var formats: doc.formats || []
readonly property var goodies: doc.goodies || []
readonly property var discs: doc.discs || []
readonly property var progress: doc.progress || ({})
readonly property bool downloadable: doc.downloadable !== false
// Disc headers render only on a multi-disc album (recon §B.2.2-7:
// `isMultiDisc = discGroups.size > 1`).
readonly property bool multiDisc: root.discs.length > 1
// The dropdown's index. `formats` order IS the dropdown order and index 0
// is the default (§G.3), so an id the document has not caught up with yet
// resolves to 0 rather than to a blank control.
readonly property int formatIndex: {
var want = doc.selectedFormatId
for (var i = 0; i < root.formats.length; i++)
if (root.formats[i].id === want) return i
return 0
}
readonly property var formatLabels: {
var out = []
for (var i = 0; i < root.formats.length; i++)
out.push(root.formats[i].label || "")
return out
}
// 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
// at least one track can actually be played — a hero button that plays
// nothing is worse than no hero button.
readonly property bool anyStreamable: {
for (var d = 0; d < root.discs.length; d++) {
var ts = root.discs[d].tracks || []
for (var i = 0; i < ts.length; i++)
if (ts[i].streamable === true) return true
}
return false
}
// ---- The one animation clock ----------------------------------------
// Advanced on the shared shell pulse and ONLY while something is actually
// in flight; a frozen page writes nothing, so it dirties nothing.
readonly property bool anyTrackBusy: {
for (var d = 0; d < root.discs.length; d++) {
var ts = root.discs[d].tracks || []
for (var i = 0; i < ts.length; i++) {
var st = ts[i].status
if (st === 1 || st === 2) return true
}
}
return false
}
readonly property bool spinning: root.visible
&& (root.loading || root.progress.active === true || root.anyTrackBusy)
property real spinDeg: 0
Connections {
target: QbzShell
function onPulseMsChanged() {
// 12° per ~30Hz tick = one revolution per second, the rate
// theme/QbzSpinner.qml animates at.
if (root.spinning)
root.spinDeg = (root.spinDeg + 12) % 360
}
}
// ---- Track titles (§10-C) -------------------------------------------
/// `formatTrackTitle` — the Tauri helper (src/lib/utils/trackTitle.ts:26-31):
/// the version, when there is one, is appended in parentheses. Hard-coding
/// `title.trim()` here is what made the latent #360 regression unreachable
/// in the reference, so it is a real function even though `version` is
/// empty on most releases.
function formatTrackTitle(title, version) {
var base = (title || "").trim()
var v = (version || "").trim()
return v === "" ? base : base + " (" + v + ")"
}
/// M:SS. The shared TrackRow renders `item.duration` as a STRING and the
/// document carries SECONDS, so a raw hand-off prints "245" in the
/// duration column.
function fmtTrackDuration(secs) {
var s = Math.max(0, Math.floor(secs || 0))
var m = Math.floor(s / 60)
var r = s % 60
return m + ":" + (r < 10 ? "0" : "") + r
}
/// Album total — `1h 12m` / `12m`, the reference's own units
/// (PurchaseAlbumDetailView.svelte:140-145). They are not localised there
/// either; inventing two new msgids for them is not this port's call.
function fmtAlbumDuration(secs) {
var s = Math.max(0, Math.floor(secs || 0))
var h = Math.floor(s / 3600)
var m = Math.floor((s % 3600) / 60)
return h > 0 ? (h + "h " + m + "m") : (m + "m")
}
/// The purchase date, long month — the detail screen's format
/// (`month: 'long'`, recon §B.2.2-3). 0 = never rendered.
function fmtPurchased(ts) {
if (!ts || ts <= 0) return ""
return Qt.formatDate(new Date(ts * 1000), Locale.LongFormat)
}
/// The meta line: purchase date · label · year · genre, joined only over
/// the parts that exist. (The reference emits a leading "·" when the
/// genre is present and the date is not — that is a bug, not a behaviour.)
readonly property string metaLine: {
var parts = []
var p = root.fmtPurchased(doc.purchasedAt)
if (p !== "") parts.push(root.t("Purchased on") + " " + p)
if ((doc.label || "") !== "") parts.push(doc.label)
if ((doc.year || "") !== "") parts.push(doc.year)
if ((doc.genre || "") !== "") parts.push(doc.genre)
return parts.join(" • ")
}
// ---- Artwork ---------------------------------------------------------
// The url-keyed pipeline every other view uses: the url goes out on
// QbzShell.sidebarArtworkWindow and the decoded path comes back on
// QbzLibrary.libraryArtworkReady, keyed by that same url.
property var coverMap: ({})
property string dispatchedCover: ""
function dispatchCover() {
var url = doc.artworkUrl || ""
if (url === "" || url === root.dispatchedCover) return
root.dispatchedCover = url
QbzShell.sidebarArtworkWindow(JSON.stringify([url]))
}
Connections {
target: QbzLibrary
function onLibraryArtworkReady(key, path) {
var m = root.coverMap
m[key] = path
root.coverMap = Object.assign({}, m)
}
}
Component.onCompleted: root.dispatchCover()
onDocChanged: root.dispatchCover()
// ======================== the page ====================================
Flickable {
id: pageFlick
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: page.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Column {
id: page
width: parent.width
leftPadding: 32
rightPadding: 32
topPadding: 11
bottomPadding: 100
spacing: 0
Item { width: 1; height: 22 }
// --- Loading ---------------------------------------------------
// The reference's loading state is a centred 32px spinner, not a
// skeleton (recon §B.2.2-2) — and a spinner is honest here,
// because the detail is ONE request graph that lands in one piece
// rather than the staged passes AlbumView shows placeholders for.
Item {
visible: root.loading
width: parent.width - 64
height: visible ? 220 : 0
QbzIcon {
anchors.centerIn: parent
name: "loader-circle"
width: 32
height: 32
tintName: "accent"
rotation: root.spinDeg
}
}
// --- Error + retry (LibraryView.qml:920-959) -------------------
Column {
visible: !root.loading && root.errorText !== ""
width: parent.width - 64
spacing: 10
Item { width: 1; height: 60 }
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: root.t("Couldn't load purchases. Check your connection.")
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
}
// No detail sub-line. The two-line shape borrowed from
// LibraryView only earns its second line when the document
// carries something MORE SPECIFIC than the headline; this
// controller only ever emits the same generic sentence, so
// printing it twice just says it twice.
Rectangle {
// §G.1 has no dedicated reload; `openAlbum(id)` re-records
// the detail and reloads it, which is the retry. With no
// id there is nothing to retry, so the button is absent
// rather than dead.
visible: (root.doc.id || "") !== ""
width: retryText.implicitWidth + 28
height: 32
radius: 6
anchors.horizontalCenter: parent.horizontalCenter
color: retryArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Text {
id: retryText
anchors.centerIn: parent
text: root.t("Retry")
color: theme.textPrimary
font.pixelSize: theme.fontLegal
}
MouseArea {
id: retryArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzPurchases.openAlbum(root.doc.id)
}
}
}
// --- Album header ---------------------------------------------
Row {
visible: root.ready
width: parent.width - 64
spacing: 32
Rectangle {
width: 224
height: 224
radius: 12
color: theme.surfaceElevated
// No clip: RoundedImage confines itself on both arms, and
// a clip is an unconditional batch root.
RoundedImage {
visible: root.downloadable
anchors.fill: parent
source: root.coverMap[root.doc.artworkUrl || ""] || ""
radius: 12
}
// An album the account cannot download shows the warning
// in place of the cover (recon §B.2.2-3) — the one place
// this screen says "unavailable", and it says it about the
// PURCHASE, never about the network.
Column {
visible: !root.downloadable
anchors.centerIn: parent
spacing: 8
QbzIcon {
anchors.horizontalCenter: parent.horizontalCenter
name: "triangle-alert"
width: 18
height: 18
tintName: "muted"
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: root.t("Unavailable")
color: theme.textMuted
font.pixelSize: 12
}
}
}
Column {
width: parent.width - 224 - 32
anchors.verticalCenter: parent.verticalCenter
spacing: 0
// `fontSection`, matching AlbumView.qml:626 exactly — this
// header is a copy of that one and the size had drifted to
// `fontTitle` (25), which would have made a PURCHASED album
// shout louder than an ordinary one on the very next screen.
Text {
width: parent.width
text: root.doc.title || ""
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
elide: Text.ElideRight
}
Item { width: 1; height: 4 }
Text {
// `width: fit-content` in the reference — the hover
// target must be the NAME, not the whole line, or the
// pointer turns into a link half a screen away from it.
width: Math.min(implicitWidth, parent.width)
text: root.doc.artist || ""
color: (root.doc.artistId || "") !== ""
? (artistArea.containsMouse ? theme.accentHover : theme.accent)
: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightMedium
elide: Text.ElideRight
MouseArea {
id: artistArea
anchors.fill: parent
enabled: (root.doc.artistId || "") !== ""
hoverEnabled: true
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: QbzArtist.openArtist(root.doc.artistId)
}
}
Item { width: 1; height: 8 }
Text {
visible: root.metaLine !== ""
width: parent.width
text: root.metaLine
color: theme.textMuted
font.pixelSize: 14
elide: Text.ElideRight
}
Item { visible: root.metaLine !== ""; width: 1; height: 6 }
// The album's stored quality, through the shared chip
// rather than a hand-rolled "24-bit / 96 kHz" string —
// QualityBadgeFull hides ITSELF on an empty tier, so a
// document that does not carry one costs no empty band.
QualityBadgeFull {
tier: root.doc.qualityTier || ""
detail: root.doc.qualityDetail || ""
}
// Gated on the same tier the badge is: the badge hides
// ITSELF and a Column skips an invisible child, so an
// ungated spacer would leave 6px of nothing behind it.
Item {
visible: (root.doc.qualityTier || "") !== ""
width: 1
height: 6
}
Text {
width: parent.width
text: {
var n = root.doc.tracksCount || 0
var s = root.t("{} tracks").replace("{}", n)
var d = root.fmtAlbumDuration(root.doc.duration)
return (root.doc.duration || 0) > 0 ? (s + " · " + d) : s
}
color: theme.textMuted
font.pixelSize: 14
elide: Text.ElideRight
}
Item { width: 1; height: 20 }
// --- Action row --------------------------------------
// Rendered ONLY for a downloadable album (recon §B.2.2-6):
// on an album the account cannot download, none of these
// has anything to act on.
Row {
visible: root.downloadable
width: parent.width
spacing: 12
QbzCircleAction {
visible: root.anyStreamable
primary: true
name: "play-fill"
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzPlayer.playAlbum(root.doc.id || "")
}
// Download all. Disabled — never hidden — while a
// download is running or when the synthesis produced
// no format, so the control stays where the user left
// it (§4.3: the four formats come from album metadata,
// and an album can legitimately offer none).
QbzCircleAction {
primary: !root.anyStreamable
name: "cloud-download"
btnEnabled: !(root.progress.active === true)
&& root.formats.length > 0
anchors.verticalCenter: parent.verticalCenter
onClicked: {
if (root.progress.active !== true && root.formats.length > 0)
QbzPurchases.downloadAlbum()
}
}
// THE FORMAT DROPDOWN. Options in document order —
// that order IS the dropdown order and index 0 is the
// default. Choosing one re-scopes every downloaded
// mark, the progress section and Add-to-Library; none
// of that is computed here, it arrives in the next
// republish.
QbzSelect {
visible: root.formats.length > 0
anchors.verticalCenter: parent.verticalCenter
menuWidth: 200
options: root.formatLabels
currentIndex: root.formatIndex
enabled: !(root.progress.active === true)
onSelected: function (i) {
if (i >= 0 && i < root.formats.length)
QbzPurchases.setFormat(root.formats[i].id)
}
}
Text {
visible: root.formats.length === 0
anchors.verticalCenter: parent.verticalCenter
text: root.t("No downloadable formats available")
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
IconTextButton {
anchors.verticalCenter: parent.verticalCenter
label: root.t("Choose folder")
iconName: "folder-open"
btnEnabled: !(root.progress.active === true)
onClicked: QbzPurchases.chooseDestination()
}
// 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
// account will never populate this, so a wrong empty
// state would be a permanent wrong nobody can see.
IconTextButton {
visible: root.goodies.length > 0 && !goodieCount.visible
anchors.verticalCenter: parent.verticalCenter
label: root.t("Download goodies")
iconName: "cloud-download"
btnEnabled: !(root.progress.active === true)
onClicked: QbzPurchases.downloadGoodies()
}
// Goodies are counted SEPARATELY from tracks (§14.3), so
// they get their own readout rather than a slot in the
// track bar. Without it a multi-item booklet download is
// indistinguishable from a click that did nothing until
// the final toast lands, and the natural reaction is to
// click again.
Row {
id: goodieCount
readonly property var gp: root.doc.goodiesProgress || ({})
visible: goodieCount.gp.active === true
anchors.verticalCenter: parent.verticalCenter
spacing: 6
QbzIcon {
anchors.verticalCenter: parent.verticalCenter
name: "loader-circle"
width: 14
height: 14
tintName: "accent"
rotation: root.spinDeg
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.t("Downloading {}/{}...")
.replace("{}", goodieCount.gp.completed || 0)
.replace("{}", goodieCount.gp.total || 0)
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
}
}
// The chosen destination, shown so "Choose folder" is not a
// button whose effect is invisible.
Item {
visible: destPath.visible
width: 1
height: 8
}
Text {
id: destPath
visible: root.downloadable && (root.doc.destination || "") !== ""
width: parent.width
text: root.doc.destination || ""
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideMiddle
}
}
}
// --- Download progress (§15.2-2) -------------------------------
// Determinate, `completed / total`, counted in TRACKS and never in
// bytes (§4.2). The ONLY percentage on the whole feature.
Item { visible: progressBox.visible; width: 1; height: 24 }
Rectangle {
id: progressBox
// The reference renders this section while downloading, when the
// album finished, AND after a cancel — `downloadingAll ||
// allComplete || wasCancelled`. Without the third arm a cancel
// makes the whole box VANISH, which reads as "nothing ever
// happened" rather than "you stopped it".
visible: root.ready
&& (root.progress.active === true
|| root.progress.allComplete === true
|| root.progress.cancelled === true)
width: parent.width - 64
height: visible ? 66 : 0
radius: theme.radiusSm
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
readonly property int completed: root.progress.completed || 0
readonly property int total: root.progress.total || 0
readonly property bool done: root.progress.allComplete === true
readonly property bool cancelled: root.progress.cancelled === true && !progressBox.done
readonly property real fraction:
progressBox.total > 0
? Math.max(0, Math.min(1, progressBox.completed / progressBox.total))
: 0
Column {
anchors.fill: parent
anchors.leftMargin: 14
anchors.rightMargin: 14
anchors.topMargin: 12
anchors.bottomMargin: 12
spacing: 6
// 32px, not the label's own line height: the Cancel
// control is an IconTextButton and those are 32 tall, so a
// 20px band would let it hang out of the box.
Item {
width: parent.width
height: 32
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 6
QbzIcon {
visible: progressBox.done
anchors.verticalCenter: parent.verticalCenter
name: "check"
width: 14
height: 14
tintName: "accent"
}
QbzIcon {
visible: progressBox.cancelled
anchors.verticalCenter: parent.verticalCenter
name: "x"
width: 14
height: 14
tintName: "muted"
}
// The spinner turns only while work is actually in
// flight — a cancelled album must not keep spinning.
QbzIcon {
visible: !progressBox.done && !progressBox.cancelled
anchors.verticalCenter: parent.verticalCenter
name: "loader-circle"
width: 14
height: 14
tintName: "accent"
rotation: root.spinDeg
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: progressBox.done
? root.t("Download complete")
: progressBox.cancelled
? root.t("Download cancelled ({}/{})")
.replace("{}", progressBox.completed)
.replace("{}", progressBox.total)
: root.t("Downloading {}/{}...")
.replace("{}", progressBox.completed)
.replace("{}", progressBox.total)
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
}
// Cancel is offered only while the album loop is
// running. A failed track does NOT stop it (§4.2b) —
// it keeps its own retry control and the album carries
// on — so this button means "stop the rest", not
// "something went wrong".
IconTextButton {
visible: root.progress.active === true
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
label: root.t("Cancel")
iconName: "x"
danger: true
onClicked: QbzPurchases.cancelAlbumDownload()
}
}
// The bar. NO `Behavior on width`: it is fed by the
// document, and an animator on a data-fed property turns
// one republish into a display-rate run of whole-window
// presents.
Rectangle {
width: parent.width
height: 4
radius: 2
color: theme.surfaceElevated
Rectangle {
width: Math.round(parent.width * progressBox.fraction)
height: parent.height
radius: parent.radius
color: progressBox.done ? theme.success : theme.accent
}
}
}
}
// --- Add to Library (§15.2-3) ----------------------------------
// A SEQUENCE, not a banner: the click is one call, and the
// controller adds the resolved album subfolder as a library
// folder, clears the download state, raises the toast and starts
// the background scan. Its own visibility is re-scoped by the
// format dropdown, which is why the flag is read off the document
// rather than derived from `allComplete` here.
Item { visible: addRow.visible; width: 1; height: 16 }
Rectangle {
id: addRow
visible: root.ready && root.doc.addToLibraryVisible === true
width: parent.width - 64
height: visible ? 56 : 0
radius: theme.radiusSm
color: theme.surfaceCard
border.width: 1
border.color: theme.success
Row {
anchors.fill: parent
anchors.leftMargin: 14
anchors.rightMargin: 14
spacing: 12
QbzPrimaryButton {
id: addBtn
anchors.verticalCenter: parent.verticalCenter
label: root.t("Add to Library")
btnHeight: 32
labelSize: theme.fontLegal
onClicked: QbzPurchases.addToLibrary()
}
Text {
anchors.verticalCenter: parent.verticalCenter
// Clamped: the button auto-sizes to its label, and a
// long translation must not drive this negative.
width: Math.max(0, parent.width - 12 - addBtn.width)
text: root.t("Add these downloaded tracks to your local library.")
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
}
// --- Divider ---------------------------------------------------
Item { visible: root.ready; width: 1; height: 24 }
Rectangle {
visible: root.ready
width: parent.width - 64
height: 1
color: theme.borderSubtle
}
Item { visible: root.ready; width: 1; height: 8 }
// --- Track table -----------------------------------------------
Column {
visible: root.ready
width: parent.width - 64
spacing: 0
// 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,
// i.e. exactly what the delegate spends outside the shared
// row. The cloud glyph that heads that column is drawn here
// because the reserve slot is, by design, an unlabelled Item.
Item {
// A labelled header over nothing is worse than no header.
visible: root.discs.length > 0
width: parent.width
height: visible ? 40 : 0
TrackListHeader {
anchors.fill: parent
bandHeight: 40
labelSpacing: 0.5
showFavorite: false
showDownload: false
showMenu: false
trailingReserve: 42
}
QbzIcon {
x: parent.width - 40 + 7
anchors.verticalCenter: parent.verticalCenter
name: "cloud-download"
width: 14
height: 14
tintName: "muted"
}
}
// Discs. The document is ALREADY grouped (§G.3), in the order
// the reference's first-seen bucketing produces, so nothing is
// regrouped or re-sorted here.
Repeater {
model: root.discs
delegate: Column {
id: discBlock
required property var modelData
required property int index
width: parent ? parent.width : 0
spacing: 0
// Disc separator — multi-disc albums only, exactly as
// AlbumView and the reference both gate it.
Item {
visible: root.multiDisc
width: parent.width
height: visible ? 40 : 0
Text {
x: 12
anchors.verticalCenter: parent.verticalCenter
text: root.t("Disc") + " "
+ (discBlock.modelData.number || (discBlock.index + 1))
color: theme.textMuted
font.pixelSize: 13
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
}
Repeater {
model: discBlock.modelData.tracks || []
delegate: Item {
id: trackRow
required property var modelData
required property int index
width: parent ? parent.width : 0
height: 50
readonly property bool streamable: modelData.streamable === true
readonly property string trackId: modelData.id || ""
/// The row's live download state. A persisted
/// `downloaded` — which the controller has
/// already re-scoped to the SELECTED format —
/// reads as `ready`, so the green check
/// disappears the moment the dropdown moves to
/// a format this track was not downloaded in.
/// A transient status always wins, because a
/// download in flight is newer than the
/// registry.
readonly property int status: {
var s = modelData.status
if (s === undefined || s === 0)
return modelData.downloaded === true ? 3 : 0
return s
}
// The item the shared row draws. Built rather
// than passed through for three reasons: the
// title needs `formatTrackTitle`, the duration
// is SECONDS here and TrackRow renders it as a
// string, and the artist line is shown only
// when the performer differs from the album
// artist (recon §B.2.2-7).
readonly property var rowItem: ({
"id": trackRow.trackId,
"title": root.formatTrackTitle(trackRow.modelData.title,
trackRow.modelData.version),
"artist": (trackRow.modelData.artist || "") !== ""
&& trackRow.modelData.artist !== (root.doc.artist || "")
? trackRow.modelData.artist : "",
"duration": root.fmtTrackDuration(trackRow.modelData.duration),
"qualityTier": trackRow.modelData.qualityTier || "",
"qualityDetail": trackRow.modelData.qualityDetail || ""
})
TrackRow {
// Narrowed by exactly the 28px status cell
// + the 14px gap this row draws outside
// the shared one (LocalTrackRow's pattern).
width: trackRow.width - 42
item: trackRow.rowItem
number: trackRow.modelData.trackNumber > 0
? trackRow.modelData.trackNumber : (trackRow.index + 1)
zebra: true
showFavorite: false
showDownload: false
showMenu: false
// Not a drag source, and `draggable` is
// also the "this id is a Qobuz catalog id"
// predicate that arms the shared row's own
// OFFLINE-cache column — which must stay
// dark here, because purchase state and
// cache state are different features that
// must not overwrite each other (§G.4).
draggable: false
// Click-to-play only where the catalog
// says the track is streamable. It defaults
// FALSE on this screen; do not invert it.
clickPlays: trackRow.streamable
onPlayRequested: {
if (trackRow.streamable)
QbzPlayer.playAlbumFrom(root.doc.id || "",
trackRow.trackId)
}
}
// --- The purchase download cell ------------
// Same glyph set, sizes and tints as the
// shared row's offline column
// (TrackRow.qml:782-835) so the two features
// read identically — on a DIFFERENT channel.
Item {
x: trackRow.width - 40
width: 28
height: 28
anchors.verticalCenter: parent.verticalCenter
// Gated on the SAME predicate the click is:
// on an album the account cannot download,
// or with no synthesized format, the
// reference renders nothing here at all
// (recon §B.2.3, last row) rather than a
// cloud that does nothing.
QbzIcon {
visible: trackRow.status === 0 && dlArea.enabled
anchors.centerIn: parent
name: "cloud-download"
width: 16
height: 16
tintName: dlArea.containsMouse ? "textPrimary" : "muted"
}
QbzIcon {
visible: trackRow.status === 1 || trackRow.status === 2
anchors.centerIn: parent
name: "loader-circle"
width: 16
height: 16
tintName: "accent"
rotation: root.spinDeg
}
QbzIcon {
visible: trackRow.status === 3
anchors.centerIn: parent
name: "circle-check-big"
width: 16
height: 16
tintName: "accent"
}
// Failed keeps its RETRY control and the
// album keeps going (§4.2b): the glyph is
// the control, exactly as the reference
// makes the warning triangle clickable.
QbzIcon {
visible: trackRow.status === 4
anchors.centerIn: parent
name: "triangle-alert"
width: 16
height: 16
tintName: "favorite"
}
MouseArea {
id: dlArea
anchors.fill: parent
// Nothing to download on an album the
// account cannot download, and nothing
// to download into with no format.
enabled: root.downloadable
&& root.formats.length > 0
&& trackRow.status !== 1
&& trackRow.status !== 2
hoverEnabled: true
cursorShape: enabled ? Qt.PointingHandCursor
: Qt.ArrowCursor
ToolTip.visible: containsMouse && trackRow.status === 4
ToolTip.text: root.t("Retry")
ToolTip.delay: 350
onClicked: {
if (trackRow.status === 4)
QbzPurchases.retryTrack(trackRow.trackId)
else
QbzPurchases.downloadTrack(trackRow.trackId)
}
}
}
}
}
}
}
// Nothing to list. Reached when the album resolved but carries
// no tracks — rare, and the alternative is a labelled header
// over blank space.
// (No `anchors.horizontalCenter` — this is a Column child, and
// the Column owns x. QbzEmptyState centres its own contents.)
QbzEmptyState {
visible: root.ready && root.discs.length === 0
width: parent.width
iconName: "shopping-bag"
title: root.t("No purchased tracks yet")
}
}
}
}
// Thin auto-hiding scrollbar (ListScrollbar).
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
// this container's offset while it is the live page, and restores it
// when a back/forward step arms this route.
ScrollMemory { target: pageFlick; scope: "purchase-album" }
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: pageFlick
}
}