Files
qbz/crates/qbz-qt/qml/views/PurchaseAlbumView.qml
T
vicrodh ed7fa588ea fix(qt): elevated controls lift on hover instead of darkening
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
2026-09-14 00:49:54 -06:00

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 || "")
}
}