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
1106 lines
55 KiB
QML
1106 lines
55 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
|
|
}
|
|
// "✓" 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 || "")
|
|
+ (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
|
|
// 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
|
|
|
|
// 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: false
|
|
diameterOverride: 38
|
|
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: true
|
|
compactPrimary: true
|
|
diameterOverride: 38
|
|
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()
|
|
}
|
|
// 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
|
|
// 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
|
|
}
|
|
|
|
// What Play actually does. A DSD purchase streams as CD
|
|
// quality (the catalog has no DSD stream), so the first
|
|
// smoke saw a "downgrade" badge on a Hi-Res album and
|
|
// wondered which one was lying: neither — Play is the
|
|
// stream until the file is on disk, and the purchased
|
|
// copy after that (purchase_playback_qt).
|
|
Item {
|
|
visible: streamNote.visible
|
|
width: 1
|
|
height: 8
|
|
}
|
|
Text {
|
|
id: streamNote
|
|
visible: root.downloadable && root.anyStreamable
|
|
width: parent.width
|
|
text: root.t("Until the purchased file is downloaded, Play streams the best quality Qobuz offers for this album. Once it is on disk, Play uses your copy.")
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLegal
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- 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
|
|
|
|
// 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,
|
|
// 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
|
|
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 || ""
|
|
/// 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
|
|
}
|
|
}
|