Buttons that rest on surfaceElevated used surfaceHover for their hover fill: a 6% white wash meant for rows that rest transparent. Over an elevated button it lands DARKER than the rest fill — on a modal's dark ground the Cancel/Save/footer buttons went near-black under the pointer. QbzTheme gains surfaceElevatedHover (a lift of surfaceElevated, derived so every palette has it), its ambient half-alpha twin, and the elevatedFill / elevatedHoverFill pair an ambient-aware control paints. Every site with the old pairing (65 across modals, toolbars, selects, circle actions, chips and cards) now hovers to the lift. cc
1637 lines
81 KiB
QML
1637 lines
81 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 QtQuick.Window
|
|
import com.blitzfc.qbz
|
|
import "../controls"
|
|
import "../rows"
|
|
import "../theme"
|
|
import "../kiosk"
|
|
import "../assets/kiosk-art.js" as KioskArt
|
|
|
|
Rectangle {
|
|
id: root
|
|
property bool kioskHost: false
|
|
|
|
// 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) }
|
|
|
|
readonly property var kioskTrackRows: {
|
|
if (!root.kioskHost) return []
|
|
var result = []
|
|
root.discs.forEach(function(d, discIndex) {
|
|
var tracks = (d.tracks || []).filter(root.matches)
|
|
if (root.multiDisc && tracks.length) result.push({kind: "disc", number: d.number || discIndex + 1})
|
|
tracks.forEach(function(track, i) { result.push({kind: "track", track: track, ordinal: i}) })
|
|
})
|
|
return result
|
|
}
|
|
|
|
// ---- 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 var copies: doc.copies || []
|
|
readonly property var batchResult: doc.batchResult || ({})
|
|
readonly property bool downloadable: doc.downloadable !== false
|
|
property int shownBatchSerial: 0
|
|
property var copyMenuTarget: ({})
|
|
|
|
function copyStateLabel(state) {
|
|
if (state === "complete") return root.t("Complete")
|
|
if (state === "partial") return root.t("Partial")
|
|
if (state === "missing") return root.t("Missing files")
|
|
if (state === "changed") return root.t("Changed files")
|
|
if (state === "unreachable") return root.t("Unavailable")
|
|
if (state === "unreadable") return root.t("Unreadable files")
|
|
return state || ""
|
|
}
|
|
function copyFormatLabel(formatId) {
|
|
for (var i = 0; i < root.formats.length; i++)
|
|
if (Number(root.formats[i].id) === Number(formatId))
|
|
return root.formats[i].label || ("#" + String(formatId))
|
|
return "#" + String(formatId)
|
|
}
|
|
function copyTitle(copy) {
|
|
var folder = copy.folderLabel || ""
|
|
var format = root.copyFormatLabel(copy.formatId)
|
|
if (folder === "") return format
|
|
if (format === "" || folder.toLowerCase().indexOf(format.toLowerCase()) >= 0)
|
|
return folder
|
|
return folder + " · " + format
|
|
}
|
|
function batchResultBody() {
|
|
var result = root.batchResult
|
|
var text = root.t("{} of {} tracks available in this folder.")
|
|
.replace("{}", result.available || 0)
|
|
.replace("{}", result.total || 0)
|
|
if (Number(result.failed || 0) > 0)
|
|
text += "\n" + root.t("{} failed").replace("{}", result.failed)
|
|
if (Number(result.cancelled || 0) > 0)
|
|
text += "\n" + root.t("{} cancelled").replace("{}", result.cancelled)
|
|
return text
|
|
}
|
|
|
|
Connections {
|
|
target: QbzPurchases
|
|
function onPurchasesRevChanged() {
|
|
var result = root.doc.batchResult || ({})
|
|
var serial = Number(result.serial || 0)
|
|
if (serial > 0 && serial !== root.shownBatchSerial) {
|
|
root.shownBatchSerial = serial
|
|
copiesModal.close()
|
|
batchModal.open()
|
|
}
|
|
if (copiesModal.opened && root.copies.length === 0)
|
|
copiesModal.close()
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
readonly property bool anyCompleteHealthyCopy: {
|
|
for (var i = 0; i < root.copies.length; i++)
|
|
if (root.copies[i].state === "complete") return true
|
|
return false
|
|
}
|
|
readonly property bool anyPlayable: root.anyStreamable || root.anyCompleteHealthyCopy
|
|
|
|
// ---- 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.
|
|
readonly property string coverUrl: root.kioskHost ? KioskArt.sizedUrl(root.doc.artworkUrl || "", Math.ceil(112 * Screen.devicePixelRatio)) : (root.doc.artworkUrl || "")
|
|
property var coverMap: ({})
|
|
property string dispatchedCover: ""
|
|
function dispatchCover() {
|
|
var url = root.coverUrl
|
|
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()
|
|
onCoverUrlChanged: 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.surfaceElevatedHover : 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: root.kioskHost ? 112 : 224
|
|
height: width
|
|
radius: 12
|
|
color: theme.surfaceElevated
|
|
// No clip: RoundedImage confines itself on both arms, and
|
|
// a clip is an unconditional batch root.
|
|
Loader {
|
|
anchors.fill: parent
|
|
active: root.downloadable
|
|
sourceComponent: root.kioskHost ? kioskCover : desktopCover
|
|
}
|
|
Component { id: kioskCover; KioskArtwork { source: root.coverMap[root.coverUrl] || ""; radius: 12 } }
|
|
Component { id: desktopCover; RoundedImage { source: root.coverMap[root.coverUrl] || ""; 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 - (root.kioskHost ? 112 : 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 --------------------------------------
|
|
// Remote download actions follow the current entitlement,
|
|
// but an existing local copy remains playable/manageable
|
|
// if that catalog edition later becomes unavailable.
|
|
Row {
|
|
visible: !root.kioskHost && (root.downloadable || root.copies.length > 0)
|
|
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.anyPlayable
|
|
primary: false
|
|
diameterOverride: 38
|
|
name: "play-fill"
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: QbzPurchases.playAlbum()
|
|
}
|
|
IconTextButton {
|
|
visible: root.downloadable
|
|
label: root.t("Download another copy")
|
|
iconName: "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()
|
|
}
|
|
}
|
|
IconTextButton {
|
|
visible: root.copies.length > 0
|
|
label: root.t("Local purchase copies") + " (" + root.copies.length + ")"
|
|
iconName: "hard-drive"
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: copiesModal.open()
|
|
}
|
|
// 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.downloadable && 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.downloadable && root.formats.length === 0
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.t("No downloadable formats available")
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLegal
|
|
}
|
|
// 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.downloadable
|
|
&& 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
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Action row --------------------------------------
|
|
// Remote download actions follow the current entitlement,
|
|
// but an existing local copy remains playable/manageable
|
|
// if that catalog edition later becomes unavailable.
|
|
Flow {
|
|
visible: root.kioskHost && (root.downloadable || root.copies.length > 0)
|
|
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.anyPlayable
|
|
primary: false
|
|
diameterOverride: 64
|
|
name: "play-fill"
|
|
anchors.verticalCenter: undefined
|
|
onClicked: QbzPurchases.playAlbum()
|
|
}
|
|
IconTextButton {
|
|
height: 64
|
|
visible: root.downloadable
|
|
label: root.t("Download another copy")
|
|
iconName: "cloud-download"
|
|
btnEnabled: !(root.progress.active === true)
|
|
&& root.formats.length > 0
|
|
anchors.verticalCenter: undefined
|
|
onClicked: {
|
|
if (root.progress.active !== true && root.formats.length > 0)
|
|
QbzPurchases.downloadAlbum()
|
|
}
|
|
}
|
|
IconTextButton {
|
|
height: 64
|
|
visible: root.copies.length > 0
|
|
label: root.t("Local purchase copies") + " (" + root.copies.length + ")"
|
|
iconName: "hard-drive"
|
|
anchors.verticalCenter: undefined
|
|
onClicked: copiesModal.open()
|
|
}
|
|
// 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 {
|
|
kioskHost: true
|
|
visible: root.downloadable && root.formats.length > 0
|
|
anchors.verticalCenter: undefined
|
|
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.downloadable && root.formats.length === 0
|
|
anchors.verticalCenter: undefined
|
|
text: root.t("No downloadable formats available")
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLegal
|
|
}
|
|
// 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 {
|
|
height: 64
|
|
visible: (root.doc.localAlbumId || "") !== ""
|
|
anchors.verticalCenter: undefined
|
|
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 {
|
|
height: 64
|
|
visible: root.downloadable
|
|
&& root.goodies.length > 0
|
|
&& !kioskGoodieCount.visible
|
|
anchors.verticalCenter: undefined
|
|
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: kioskGoodieCount
|
|
readonly property var gp: root.doc.goodiesProgress || ({})
|
|
visible: kioskGoodieCount.gp.active === true
|
|
anchors.verticalCenter: undefined
|
|
spacing: 6
|
|
QbzIcon {
|
|
anchors.verticalCenter: undefined
|
|
name: "loader-circle"
|
|
width: 14
|
|
height: 14
|
|
tintName: "accent"
|
|
rotation: root.spinDeg
|
|
}
|
|
Text {
|
|
anchors.verticalCenter: undefined
|
|
text: root.t("Downloading {}/{}...")
|
|
.replace("{}", kioskGoodieCount.gp.completed || 0)
|
|
.replace("{}", kioskGoodieCount.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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- 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
|
|
kioskHost: root.kioskHost
|
|
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 {
|
|
kioskHost: root.kioskHost
|
|
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"
|
|
}
|
|
}
|
|
|
|
Item {
|
|
id: kioskTracks
|
|
visible: root.kioskHost
|
|
width: parent.width
|
|
height: root.kioskHost ? root.kioskTrackRows.length * 64 : 0
|
|
readonly property real viewTop: {
|
|
var probe = pageFlick.contentHeight
|
|
return pageFlick.contentY - kioskTracks.mapToItem(pageFlick.contentItem, 0, 0).y
|
|
}
|
|
readonly property int first: Math.max(0, Math.floor(viewTop / 64) - 1)
|
|
readonly property int last: Math.min(root.kioskTrackRows.length, Math.ceil((viewTop + pageFlick.height) / 64) + 1)
|
|
Repeater {
|
|
model: root.kioskHost ? root.kioskTrackRows.slice(kioskTracks.first, Math.max(kioskTracks.first, kioskTracks.last)) : []
|
|
delegate: Item {
|
|
id: purchaseRow
|
|
required property var modelData
|
|
required property int index
|
|
x: 0
|
|
y: (kioskTracks.first + index) * 64
|
|
width: kioskTracks.width
|
|
height: 64
|
|
readonly property var track: modelData.track || ({})
|
|
readonly property int status: track.status || (track.downloaded ? 3 : 0)
|
|
Text { visible: purchaseRow.modelData.kind === "disc"; anchors.verticalCenter: parent.verticalCenter; text: root.t("Disc") + " " + purchaseRow.modelData.number; color: theme.textMuted; font.pixelSize: 18 }
|
|
Loader {
|
|
anchors.fill: parent
|
|
active: purchaseRow.modelData.kind === "track"
|
|
sourceComponent: Component {
|
|
Item {
|
|
TrackRow {
|
|
kioskHost: true
|
|
width: parent.width - rowDownload.width - 8
|
|
item: ({id: purchaseRow.track.id || "", title: root.formatTrackTitle(purchaseRow.track.title, purchaseRow.track.version), artist: purchaseRow.track.artist || "", duration: root.fmtTrackDuration(purchaseRow.track.duration), qualityTier: purchaseRow.track.qualityTier || "", qualityDetail: purchaseRow.track.qualityDetail || ""})
|
|
number: purchaseRow.track.trackNumber || (purchaseRow.modelData.ordinal + 1)
|
|
showFavorite: false; showDownload: false; showMenu: false; draggable: false
|
|
clickPlays: purchaseRow.track.streamable === true
|
|
onPlayRequested: { if (purchaseRow.track.streamable) QbzPlayer.playAlbumFrom(root.doc.id || "", purchaseRow.track.id || "") }
|
|
}
|
|
SettingsButton {
|
|
id: rowDownload
|
|
anchors.right: parent.right
|
|
kioskHost: true
|
|
btnHeight: 64
|
|
minWidth: 96
|
|
text: purchaseRow.status === 4 ? root.t("Retry") : purchaseRow.status === 3 ? "✓" : purchaseRow.status === 1 || purchaseRow.status === 2 ? "…" : root.t("Download")
|
|
enabled: root.downloadable && root.formats.length > 0 && purchaseRow.status !== 1 && purchaseRow.status !== 2
|
|
onClicked: { if (purchaseRow.status === 4) QbzPurchases.retryTrack(purchaseRow.track.id || ""); else QbzPurchases.downloadTrack(purchaseRow.track.id || "") }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.kioskHost ? [] : 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
|
|
}
|
|
|
|
Popup {
|
|
id: copiesModal
|
|
parent: Overlay.overlay
|
|
x: 0
|
|
y: 0
|
|
width: parent ? parent.width : 0
|
|
height: parent ? parent.height : 0
|
|
padding: 0
|
|
z: 3100
|
|
modal: true
|
|
dim: false
|
|
closePolicy: Popup.CloseOnEscape
|
|
|
|
background: Rectangle { color: "#bf000000" }
|
|
|
|
contentItem: Item {
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: copiesModal.close()
|
|
onWheel: function (wheel) { wheel.accepted = true }
|
|
}
|
|
|
|
Rectangle {
|
|
id: copiesCard
|
|
width: Math.min(copiesModal.width - 48, 840)
|
|
height: Math.min(copiesModal.height - 80,
|
|
Math.max(180, 96 + root.copies.length * 84))
|
|
anchors.centerIn: parent
|
|
radius: theme.radiusMd
|
|
color: theme.surfaceCard
|
|
border.width: 1
|
|
border.color: theme.borderSubtle
|
|
clip: true
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onWheel: function (wheel) { wheel.accepted = true }
|
|
}
|
|
|
|
Text {
|
|
id: copiesTitle
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 24
|
|
anchors.right: copiesClose.left
|
|
anchors.rightMargin: 16
|
|
anchors.top: parent.top
|
|
anchors.topMargin: 20
|
|
text: root.t("Local purchase copies")
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontSection
|
|
font.weight: theme.weightBold
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
Rectangle {
|
|
id: copiesClose
|
|
width: 32
|
|
height: 32
|
|
radius: theme.radiusSm
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 16
|
|
anchors.top: parent.top
|
|
anchors.topMargin: 14
|
|
color: copiesCloseArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
Accessible.role: Accessible.Button
|
|
Accessible.name: root.t("Close")
|
|
Accessible.onPressAction: copiesModal.close()
|
|
QbzIcon {
|
|
anchors.centerIn: parent
|
|
name: "x"
|
|
width: 16
|
|
height: 16
|
|
tintName: "muted"
|
|
}
|
|
MouseArea {
|
|
id: copiesCloseArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: copiesModal.close()
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
anchors.topMargin: 64
|
|
height: 1
|
|
color: theme.borderSubtle
|
|
}
|
|
|
|
ListView {
|
|
id: copiesList
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
anchors.bottom: parent.bottom
|
|
anchors.leftMargin: 20
|
|
anchors.rightMargin: 20
|
|
anchors.topMargin: 76
|
|
anchors.bottomMargin: 16
|
|
spacing: 6
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
model: root.copies
|
|
|
|
delegate: Rectangle {
|
|
id: copyRow
|
|
required property var modelData
|
|
width: copiesList.width - 8
|
|
height: 78
|
|
radius: theme.radiusSm
|
|
color: theme.surfaceElevated
|
|
border.width: 1
|
|
border.color: theme.borderSubtle
|
|
|
|
Column {
|
|
anchors.left: parent.left
|
|
anchors.right: copyActions.left
|
|
anchors.leftMargin: 12
|
|
anchors.rightMargin: 12
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 4
|
|
Text {
|
|
width: parent.width
|
|
text: root.copyTitle(copyRow.modelData)
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontBody
|
|
font.weight: theme.weightSemibold
|
|
elide: Text.ElideMiddle
|
|
}
|
|
Text {
|
|
width: parent.width
|
|
text: copyRow.modelData.folderPath
|
|
|| copyRow.modelData.folderLabel || ""
|
|
color: theme.textSecondary
|
|
font.pixelSize: theme.fontLegal
|
|
elide: Text.ElideMiddle
|
|
}
|
|
Text {
|
|
width: parent.width
|
|
text: root.copyStateLabel(copyRow.modelData.state) + " · "
|
|
+ root.t("{} of {} tracks")
|
|
.replace("{}", copyRow.modelData.healthyTracks || 0)
|
|
.replace("{}", copyRow.modelData.totalTracks || 0)
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLegal
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
|
|
Row {
|
|
id: copyActions
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 8
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 8
|
|
Text {
|
|
visible: copyRow.modelData.inLocalLibrary === true
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.t("In Local Library")
|
|
color: theme.success
|
|
font.pixelSize: theme.fontLegal
|
|
}
|
|
IconTextButton {
|
|
visible: copyRow.modelData.continuable === true
|
|
&& Number(copyRow.modelData.formatId) === Number(root.doc.selectedFormatId)
|
|
label: root.t("Continue copy")
|
|
iconName: "cloud-download"
|
|
btnEnabled: root.progress.active !== true
|
|
onClicked: {
|
|
copiesModal.close()
|
|
QbzPurchases.continueCopy(copyRow.modelData.copyId || "")
|
|
}
|
|
}
|
|
IconTextButton {
|
|
visible: copyRow.modelData.inLocalLibrary !== true
|
|
&& Number(copyRow.modelData.libraryFolderId || 0) <= 0
|
|
&& Number(copyRow.modelData.downloadedTracks || 0) > 0
|
|
label: root.t("Add to Local Library")
|
|
iconName: "hard-drive"
|
|
onClicked: {
|
|
copiesModal.close()
|
|
QbzPurchases.addCopyToLibrary(copyRow.modelData.copyId || "")
|
|
}
|
|
}
|
|
Rectangle {
|
|
width: 32
|
|
height: 32
|
|
radius: theme.radiusSm
|
|
color: copyMoreArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
Accessible.role: Accessible.Button
|
|
Accessible.name: root.t("More options")
|
|
Accessible.onPressAction: {
|
|
root.copyMenuTarget = copyRow.modelData
|
|
copyMenu.openBelowRight(copyMoreArea)
|
|
}
|
|
QbzIcon {
|
|
anchors.centerIn: parent
|
|
name: "ellipsis"
|
|
width: 16
|
|
height: 16
|
|
tintName: "muted"
|
|
}
|
|
MouseArea {
|
|
id: copyMoreArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
root.copyMenuTarget = copyRow.modelData
|
|
copyMenu.openBelowRight(copyMoreArea)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
QbzScrollBar {
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 4
|
|
anchors.top: copiesList.top
|
|
anchors.bottom: copiesList.bottom
|
|
target: copiesList
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
QbzContextMenu {
|
|
id: copyMenu
|
|
z: 3300
|
|
menuWidth: 230
|
|
|
|
Rectangle {
|
|
width: parent.width
|
|
height: 33
|
|
radius: theme.radiusSm
|
|
color: showLocationArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
QbzIcon { x: 9; anchors.verticalCenter: parent.verticalCenter; name: "folder-open"; width: 15; height: 15; tintName: "muted" }
|
|
Text { x: 32; anchors.verticalCenter: parent.verticalCenter; text: root.t("Show location"); color: theme.textPrimary; font.pixelSize: theme.fontLegal }
|
|
MouseArea {
|
|
id: showLocationArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
copyMenu.close()
|
|
QbzPurchases.showCopyLocation(root.copyMenuTarget.copyId || "")
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
visible: Number(root.copyMenuTarget.libraryFolderId || 0) > 0
|
|
width: parent.width
|
|
height: visible ? 33 : 0
|
|
radius: theme.radiusSm
|
|
color: removeLibraryArea.containsMouse ? theme.dangerHover : "transparent"
|
|
QbzIcon { x: 9; anchors.verticalCenter: parent.verticalCenter; name: "hard-drive"; width: 15; height: 15; tintName: "danger" }
|
|
Text { x: 32; anchors.verticalCenter: parent.verticalCenter; text: root.t("Remove from Local Library"); color: theme.danger; font.pixelSize: theme.fontLegal }
|
|
MouseArea {
|
|
id: removeLibraryArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
copyMenu.close()
|
|
QbzPurchases.removeCopyFromLibrary(root.copyMenuTarget.copyId || "")
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
visible: root.copyMenuTarget.inLocalLibrary !== true
|
|
&& Number(root.copyMenuTarget.libraryFolderId || 0) <= 0
|
|
width: parent.width
|
|
height: visible ? 33 : 0
|
|
radius: theme.radiusSm
|
|
color: forgetRecordArea.containsMouse ? theme.dangerHover : "transparent"
|
|
QbzIcon { x: 9; anchors.verticalCenter: parent.verticalCenter; name: "trash-2"; width: 15; height: 15; tintName: "danger" }
|
|
Text { x: 32; anchors.verticalCenter: parent.verticalCenter; text: root.t("Forget download record"); color: theme.danger; font.pixelSize: theme.fontLegal }
|
|
MouseArea {
|
|
id: forgetRecordArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
copyMenu.close()
|
|
QbzPurchases.forgetCopyRecord(root.copyMenuTarget.copyId || "")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
QbzConfirmModal {
|
|
id: batchModal
|
|
anchors.fill: parent
|
|
title: root.t("Download finished")
|
|
body: root.batchResultBody()
|
|
confirmLabel: root.t("Add to Local Library")
|
|
cancelLabel: root.t("Not now")
|
|
danger: false
|
|
onConfirmed: {
|
|
var copyId = root.batchResult.copyId || ""
|
|
QbzPurchases.dismissBatchResult(copyId)
|
|
QbzPurchases.addCopyToLibrary(copyId)
|
|
}
|
|
onCancelled: QbzPurchases.dismissBatchResult(root.batchResult.copyId || "")
|
|
}
|
|
}
|