Under the ambient field some controls still painted the opaque theme token while their neighbours used the half-alpha one: the album track filter and its select toggle, the Library tab strip's host, the Local Library icon selects, the header's active nav items and open playlists button, the queue sidebar's now-playing card and artwork placeholders, the Listen list's floating actions, and the shared SettingsButton, IconTextButton, multi-select bar, text area, load-more pill, tree row selection, sidebar count chips, Purchases tab strip, play-history filter box and Orbit banner. Each now takes the ambient-aware fill the header search and QbzTabBar already use (theme.ambientOn ? …A50 : …). Tooltips, popups and modals stay opaque on purpose: they are read over content, not chrome. cc
592 lines
28 KiB
QML
592 lines
28 KiB
QML
// PurchasesView — the My-Purchases library surface, route id "purchases".
|
|
// Port of purchases/PurchasesView.slint (:1300-1470 is the page itself), which
|
|
// is in turn the 1:1 port of `PurchasesView.svelte`.
|
|
//
|
|
// Region by region (contract §15.1):
|
|
// 1. header 94x94 accent-gradient tile + the `shopping-bag` glyph at 32
|
|
// 2. region notice dismissible, per user, and its wording is COMMERCIAL
|
|
// 3. sticky nav QbzTabBar (Albums / Tracks, with counts) + expandable search
|
|
// 4. toolbar NOT the same on both tabs — see views/purchases/PurchasesToolbar
|
|
// 5. body albums grid/list, or the track rows
|
|
// 6. states loading · error + retry · empty
|
|
//
|
|
// ── WHAT IS DELIBERATELY NOT HERE ────────────────────────────────────────
|
|
// No pagination, no infinite scroll, no track context menu, no double-click,
|
|
// no multi-select, no drag, no Escape handling: none of it exists in the
|
|
// reference (§1). Two CSS classes in the Tauri original (`.playing` on list
|
|
// rows, `.track-row.failed`) have NO rule in the only stylesheet — they are
|
|
// dead, and porting them as visible states would add behaviour that never
|
|
// existed. There is also NO offline placeholder: §15.1-6 is explicit that
|
|
// Tauri has none and that none was ruled in.
|
|
//
|
|
// ── STATE LIFETIME (§10-F) ───────────────────────────────────────────────
|
|
// Tab, sort, direction, grouping, grid/list and the search text all die when
|
|
// the screen is left. `QbzPurchases.leave()` fires from this component's
|
|
// destruction, which is the moment the route arm unloads it. The four
|
|
// PERSISTED preferences (hide-unavailable, hide-downloaded, quality filter,
|
|
// region-notice-dismissed) are the only survivors, and they live in Rust.
|
|
//
|
|
// ── THE SEARCH DEBOUNCE IS OURS ──────────────────────────────────────────
|
|
// §G.1: QML owns the 300 ms debounce and `search()` is the fire. It is a
|
|
// ONE-SHOT Timer, which the repaint-pulse rule allows — a repeating one would
|
|
// not be. The call REFETCHES BOTH types from the server (§5); it is not a
|
|
// local filter over the active tab.
|
|
//
|
|
// ── WHY THE BODY IS NOT AlbumCollection ──────────────────────────────────
|
|
// See the header of views/purchases/PurchaseGridCard.qml. In one line: the
|
|
// shared card/row pair hardwires its click to `QbzAlbum.openAlbum`, which would
|
|
// make the purchase-album route unreachable, and neither has an arm for the
|
|
// UNAVAILABLE state this screen is built around. The .slint reached the same
|
|
// conclusion for the same reasons and drew its own cells too.
|
|
|
|
import QtQuick
|
|
import com.blitzfc.qbz
|
|
import "../controls"
|
|
import "../theme"
|
|
import "purchases"
|
|
|
|
Rectangle {
|
|
id: root
|
|
property bool kioskHost: false
|
|
|
|
// Round to the AppShell content-frame bezel (the literal in all painting
|
|
// view roots — QML clips are rectangular, so the frame's rounding never
|
|
// reaches the view and the view's own fill must round).
|
|
radius: 12
|
|
color: root.ambientOn ? "transparent" : theme.surfaceMain
|
|
readonly property bool ambientOn: theme.ambientOn
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
|
|
|
|
// ── the document ──────────────────────────────────────────────────────
|
|
// GUARDED: a raw JSON.parse in a binding throws on the pre-publish frame.
|
|
// `purchasesRev` is a pure binding dependency — it is bumped on EVERY
|
|
// publish so this re-parses even when the JSON text is byte-identical.
|
|
function _parse(json, rev) {
|
|
try {
|
|
return JSON.parse(json)
|
|
} catch (e) {
|
|
return ({})
|
|
}
|
|
}
|
|
readonly property var doc: root._parse(QbzPurchases.listJson,
|
|
QbzPurchases.purchasesRev)
|
|
|
|
readonly property string tab: root.doc.tab || "albums"
|
|
readonly property var toolbarDoc: root.doc.toolbar || ({})
|
|
readonly property string group: root.toolbarDoc.group || "off"
|
|
readonly property string viewMode: root.toolbarDoc.viewMode || "grid"
|
|
readonly property var albums: root.doc.albums || []
|
|
readonly property var tracks: root.doc.tracks || []
|
|
readonly property var counts: root.doc.counts || ({})
|
|
readonly property string search: root.doc.search || ""
|
|
readonly property bool loading: root.doc.loading === true
|
|
readonly property string loadError: root.doc.error || ""
|
|
|
|
// NOT `onAlbums`: with a sibling property called `albums` on this same
|
|
// object, QML files `onAlbums: …` as a signal HANDLER — no error, no
|
|
// warning, the initializer never binds and the property sits at `false`
|
|
// forever. That single name blanked the Albums tab for every account with
|
|
// real purchases (2026-09-01; reproduced standalone with the `qml`
|
|
// runtime, the identical expression under another name gives `true`).
|
|
// scripts/qml-audits/qml_on_prefixed_property_audit.py now fails the
|
|
// build on any `on<Upper>` property name.
|
|
readonly property bool albumsTab: root.tab !== "tracks"
|
|
readonly property var rows: root.albumsTab ? root.albums : root.tracks
|
|
readonly property bool grouped: root.group !== "off" && root.group !== ""
|
|
|
|
// ── entry / exit ──────────────────────────────────────────────────────
|
|
// `openList()` carries the `metadataLoaded` guard on the Rust side (§5), so
|
|
// a re-entry — or a second caller — costs nothing.
|
|
Component.onCompleted: QbzPurchases.openList()
|
|
Component.onDestruction: QbzPurchases.leave()
|
|
|
|
// ── search: 300 ms, one shot ──────────────────────────────────────────
|
|
property string pendingQuery: ""
|
|
Timer {
|
|
id: searchDebounce
|
|
interval: 300
|
|
repeat: false
|
|
onTriggered: QbzPurchases.search(root.pendingQuery)
|
|
}
|
|
|
|
// ── grouping ──────────────────────────────────────────────────────────
|
|
// `list_json` publishes FLAT arrays only — §G.2 has no grouped section, and
|
|
// `purchases_qt.rs::build_list_doc` says so in as many words ("GROUPING IS
|
|
// NOT DONE HERE … the QML host owns the bucketing"). The rules below are
|
|
// the ones that comment spells out, so the two halves cannot drift:
|
|
//
|
|
// album key artist name, or alphaGroupKey(title)
|
|
// track key alphaGroupKey(title) / performer / album title, else "Unknown"
|
|
// order by key, ascending
|
|
//
|
|
// It is presentation-only: search, sort and filtering all stay in Rust, and
|
|
// the rows inside a bucket keep the order Rust handed them, so the toolbar's
|
|
// sort still decides what comes first WITHIN a section.
|
|
function _alphaKey(s) {
|
|
var v = (s || "").trim()
|
|
if (v === "")
|
|
return "#"
|
|
var c = v.charAt(0).toUpperCase()
|
|
// The reference's exact test: anything that is not A-Z — a digit, a
|
|
// bracket, Cyrillic, kana — shares the "#" bucket. Widening it here
|
|
// would silently disagree with the Rust-side rule this mirrors.
|
|
return (c >= "A" && c <= "Z") ? c : "#"
|
|
}
|
|
function _bucket(list, keyOf) {
|
|
var byKey = {}
|
|
var keys = []
|
|
for (var i = 0; i < list.length; i++) {
|
|
var k = keyOf(list[i])
|
|
if (byKey[k] === undefined) {
|
|
byKey[k] = []
|
|
keys.push(k)
|
|
}
|
|
byKey[k].push(list[i])
|
|
}
|
|
keys.sort(function (a, b) { return a.localeCompare(b) })
|
|
var out = []
|
|
for (var j = 0; j < keys.length; j++)
|
|
out.push({ "title": keys[j], "items": byKey[keys[j]] })
|
|
return out
|
|
}
|
|
/// Albums: "alpha" (by album title) | "artist".
|
|
function albumGroups() {
|
|
if (!root.grouped)
|
|
return []
|
|
if (root.group === "artist")
|
|
return root._bucket(root.albums, function (a) {
|
|
return (a.artist || "").trim() || root.t("Unknown")
|
|
})
|
|
return root._bucket(root.albums, function (a) { return root._alphaKey(a.title) })
|
|
}
|
|
/// Tracks: "album" | "artist" | "name" (by track title initial).
|
|
function trackGroups() {
|
|
if (!root.grouped)
|
|
return []
|
|
if (root.group === "album")
|
|
return root._bucket(root.tracks, function (tr) {
|
|
return (tr.album || "").trim() || root.t("Unknown")
|
|
})
|
|
if (root.group === "artist")
|
|
return root._bucket(root.tracks, function (tr) {
|
|
return (tr.artist || "").trim() || root.t("Unknown")
|
|
})
|
|
return root._bucket(root.tracks, function (tr) { return root._alphaKey(tr.title) })
|
|
}
|
|
|
|
function openAlbum(id) {
|
|
if (!id || id === "")
|
|
return
|
|
// §G.1, in THIS order: the controller records the detail first, then
|
|
// the route changes.
|
|
QbzPurchases.openAlbum(id)
|
|
QbzShell.navigateTo("purchase-album")
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
Flickable {
|
|
id: flick
|
|
anchors.fill: parent
|
|
clip: true
|
|
contentWidth: width
|
|
contentHeight: page.implicitHeight
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
|
|
Column {
|
|
id: page
|
|
width: parent.width
|
|
leftPadding: 24
|
|
rightPadding: 24
|
|
topPadding: 24
|
|
// The port's scroll runway: the last row must clear the player bar.
|
|
bottomPadding: 100
|
|
spacing: 16
|
|
|
|
readonly property int contentW: Math.max(0, page.width - 48)
|
|
|
|
// ── 1. Header ─────────────────────────────────────────────────
|
|
//
|
|
// OWNER RULING, 2026-08-16: plain title, no icon tile.
|
|
//
|
|
// §15.1-1 specified the reference's 94x94 accent-gradient tile with
|
|
// a 32px shopping-bag in it, and a title at `fontTitle` (25). That
|
|
// was a faithful port of Tauri's own header — and faithful is what
|
|
// made it wrong here, because NOTHING else in this application looks
|
|
// like that. Library opens straight into its toolbar with no page
|
|
// title at all; My QBZ and the label header title themselves at
|
|
// `fontSection` (18) / bold. Purchases stood out as the one page
|
|
// shouting its own name over a large coloured square.
|
|
//
|
|
// So this diverges from the contract deliberately: app-internal
|
|
// consistency beats fidelity to a header the reference happened to
|
|
// draw that way. The `shopping-bag` glyph still identifies the
|
|
// destination in the sidebar and the title bar, which is where a
|
|
// section icon belongs in this shell.
|
|
Text {
|
|
width: page.contentW
|
|
text: root.t("My Purchases")
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontSection
|
|
font.weight: theme.weightBold
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
// ── 2. Region notice ──────────────────────────────────────────
|
|
// The copy must NOT be reworded to blame the network: §2.5b proved
|
|
// the endpoint answers 200 in a blocked region, so the limitation
|
|
// this strip describes is COMMERCIAL.
|
|
Rectangle {
|
|
id: regionNotice
|
|
visible: root.doc.regionNoticeVisible === true
|
|
width: page.contentW
|
|
height: visible ? noticeRow.implicitHeight + 24 : 0
|
|
radius: 8
|
|
// The WarningBanner "info" recipe: the accent at 10 % over a
|
|
// 30 % border, derived from the live token rather than frozen
|
|
// into a literal.
|
|
color: Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.10)
|
|
border.width: 1
|
|
border.color: Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.30)
|
|
|
|
Row {
|
|
id: noticeRow
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
anchors.margins: 12
|
|
spacing: 10
|
|
|
|
QbzIcon {
|
|
name: "info"
|
|
width: 18
|
|
height: 18
|
|
tintName: "accent"
|
|
}
|
|
Text {
|
|
width: Math.max(0, parent.width - 18 - 24 - 20)
|
|
text: root.t("Purchases are downloaded as DRM-free files. Availability depends on your Qobuz region.")
|
|
color: theme.textSecondary
|
|
font.pixelSize: theme.fontBody
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
Rectangle {
|
|
width: 24
|
|
height: 24
|
|
radius: 4
|
|
color: dismissArea.containsMouse ? theme.surfaceElevated : "transparent"
|
|
QbzIcon {
|
|
name: "x"
|
|
width: 14
|
|
height: 14
|
|
anchors.centerIn: parent
|
|
tintName: "muted"
|
|
}
|
|
MouseArea {
|
|
id: dismissArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: QbzPurchases.dismissRegionNotice()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 3. Sticky nav bar ─────────────────────────────────────────
|
|
// STICKY, and done with a `transform` rather than a `y` write: the
|
|
// Column owns this item's `y`, so pinning it by assignment would
|
|
// fight the positioner. A Translate leaves the layout alone and
|
|
// costs one binding on `contentY` — ONE item, not one per delegate,
|
|
// and the window is already being repainted while a scroll is in
|
|
// flight, so it adds no present of its own.
|
|
Item {
|
|
id: navRow
|
|
width: page.contentW
|
|
height: root.kioskHost ? 64 : 34
|
|
// Above the toolbar and the body, which scroll UNDER it.
|
|
z: 5
|
|
|
|
readonly property bool stuck: flick.contentY > navRow.y
|
|
|
|
transform: Translate {
|
|
y: Math.max(0, flick.contentY - navRow.y)
|
|
}
|
|
|
|
// The backdrop, and only once pinned: at rest it would paint an
|
|
// opaque band over the page for nothing. It bleeds into the
|
|
// page's 24px gutters and over the 16px Column gap above so
|
|
// nothing peeks around it.
|
|
Rectangle {
|
|
x: -24
|
|
y: -16
|
|
width: parent.width + 48
|
|
height: parent.height + 24
|
|
color: navRow.stuck
|
|
? (theme.ambientOn ? theme.surfaceMainA50 : theme.surfaceMain)
|
|
: "transparent"
|
|
}
|
|
|
|
QbzTabBar {
|
|
visible: !root.kioskHost
|
|
anchors.left: parent.left
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
counts: true
|
|
underline: true
|
|
activeId: root.tab
|
|
tabs: [
|
|
{ "id": "albums", "label": root.t("Albums"),
|
|
"count": root.counts.albums || 0 },
|
|
{ "id": "tracks", "label": root.t("Tracks"),
|
|
"count": root.counts.tracks || 0 },
|
|
]
|
|
onSelected: function (id) { QbzPurchases.setTab(id) }
|
|
}
|
|
|
|
Row {
|
|
visible: root.kioskHost
|
|
height: 64
|
|
spacing: 8
|
|
Repeater {
|
|
model: root.kioskHost ? [{id: "albums", label: root.t("Albums")}, {id: "tracks", label: root.t("Tracks")}] : []
|
|
delegate: Rectangle {
|
|
required property var modelData
|
|
width: Math.max(96, kioskTabLabel.implicitWidth + 24)
|
|
height: 64
|
|
color: root.tab === modelData.id ? (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) : "transparent"
|
|
Text { id: kioskTabLabel; anchors.centerIn: parent; text: parent.modelData.label; color: theme.textPrimary; font.pixelSize: 18 }
|
|
MouseArea { anchors.fill: parent; onClicked: QbzPurchases.setTab(parent.modelData.id) }
|
|
}
|
|
}
|
|
}
|
|
|
|
QbzLineEdit {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
searchMode: true
|
|
kioskHost: root.kioskHost
|
|
expandable: !root.kioskHost
|
|
width: root.kioskHost ? Math.min(280, navRow.width * 0.42) : collapsedSize
|
|
openWidth: 220
|
|
// Bound, never assigned: the control's own clear latch
|
|
// depends on the host re-seeding through this binding.
|
|
text: root.search
|
|
placeholder: root.t("Search purchases…")
|
|
onEdited: function (v) {
|
|
root.pendingQuery = v
|
|
searchDebounce.restart()
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 4. Toolbar ────────────────────────────────────────────────
|
|
PurchasesToolbar {
|
|
kioskHost: root.kioskHost
|
|
width: page.contentW
|
|
tab: root.tab
|
|
toolbar: root.toolbarDoc
|
|
}
|
|
|
|
// ── 5/6. Body and its states ──────────────────────────────────
|
|
|
|
// Loading — one shared composite skeleton shaped like the active
|
|
// collection. The bridge marks loading before its first publish,
|
|
// so the account-empty state can never flash ahead of this block.
|
|
Item {
|
|
visible: root.loading
|
|
width: page.contentW
|
|
height: visible ? Math.max(260, Math.min(620, flick.height - 180)) : 0
|
|
QbzSkeleton {
|
|
anchors.fill: parent
|
|
visible: parent.visible
|
|
selfDrive: true
|
|
variant: root.albumsTab && root.viewMode !== "list"
|
|
? "cardGrid" : "rowList"
|
|
cellW: root.albumsTab ? 178 : parent.width
|
|
cellH: root.albumsTab ? 248 : 58
|
|
cardW: 162
|
|
cardH: 232
|
|
rowH: root.albumsTab ? 60 : 56
|
|
rowGap: root.albumsTab ? 4 : 2
|
|
rowArtSize: root.albumsTab ? 48 : 40
|
|
}
|
|
}
|
|
|
|
// Search keeps the settled rows in place while its server-side
|
|
// refetch runs; a compact progress indicator avoids replacing the
|
|
// whole collection with a second first-load skeleton.
|
|
Item {
|
|
visible: !root.loading && root.doc.searching === true
|
|
width: page.contentW
|
|
height: visible ? 52 : 0
|
|
QbzSpinner {
|
|
size: 28
|
|
anchors.centerIn: parent
|
|
}
|
|
}
|
|
|
|
// Error + retry (the LibraryView.qml:920-959 block).
|
|
Column {
|
|
visible: !root.loading && root.loadError !== ""
|
|
width: page.contentW
|
|
spacing: 10
|
|
Item { width: 1; height: 48 }
|
|
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
|
|
horizontalAlignment: Text.AlignHCenter
|
|
}
|
|
Rectangle {
|
|
width: retryText.implicitWidth + 28
|
|
height: 32
|
|
radius: 6
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
color: retryArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
|
border.width: 1
|
|
border.color: theme.borderSubtle
|
|
Text {
|
|
id: retryText
|
|
anchors.centerIn: parent
|
|
text: root.t("Retry")
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontLegal
|
|
}
|
|
MouseArea {
|
|
id: retryArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
// §G.1 has no dedicated reload; `openList()` is the one
|
|
// entry point that re-runs the active tab's load.
|
|
onClicked: QbzPurchases.openList()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Empty. Two different sentences on purpose: with a query in the
|
|
// box "No purchases yet" would tell the user they own nothing,
|
|
// which is a lie about their account rather than about the filter.
|
|
// Neither line accuses the network (§15.1-6).
|
|
Column {
|
|
visible: !root.loading && root.doc.searching !== true
|
|
&& root.loadError === "" && root.rows.length === 0
|
|
width: page.contentW
|
|
spacing: 0
|
|
Item { width: 1; height: 60 }
|
|
QbzEmptyState {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
iconName: root.search !== ""
|
|
? "search"
|
|
: (root.albumsTab ? "shopping-bag" : "music")
|
|
iconSize: 48
|
|
titleMuted: true
|
|
titleWeight: theme.weightRegular
|
|
title: root.search !== ""
|
|
? root.t("No results")
|
|
: (root.albumsTab ? root.t("No purchases yet")
|
|
: root.t("No purchased tracks yet"))
|
|
}
|
|
}
|
|
|
|
// --- ALBUMS, flat -------------------------------------------------
|
|
PurchaseAlbumsCollection {
|
|
visible: !root.loading && root.loadError === "" && root.albumsTab
|
|
&& !root.grouped && root.albums.length > 0
|
|
width: page.contentW
|
|
albums: (root.albumsTab && !root.grouped) ? root.albums : []
|
|
viewMode: root.viewMode
|
|
flick: flick
|
|
onOpenAlbum: function (id) { root.openAlbum(id) }
|
|
}
|
|
|
|
// --- ALBUMS, grouped ----------------------------------------------
|
|
Column {
|
|
visible: !root.loading && root.loadError === "" && root.albumsTab
|
|
&& root.grouped && root.albums.length > 0
|
|
width: page.contentW
|
|
spacing: 16
|
|
Repeater {
|
|
model: (root.albumsTab && root.grouped) ? root.albumGroups() : []
|
|
delegate: Column {
|
|
required property var modelData
|
|
width: page.contentW
|
|
spacing: 12
|
|
Text {
|
|
width: parent.width
|
|
text: modelData.title
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontHeading
|
|
font.weight: theme.weightSemibold
|
|
elide: Text.ElideRight
|
|
}
|
|
PurchaseAlbumsCollection {
|
|
width: parent.width
|
|
albums: modelData.items
|
|
viewMode: root.viewMode
|
|
flick: flick
|
|
onOpenAlbum: function (id) { root.openAlbum(id) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- TRACKS, flat -------------------------------------------------
|
|
PurchaseTracksCollection {
|
|
visible: !root.loading && root.loadError === "" && !root.albumsTab
|
|
&& !root.grouped && root.tracks.length > 0
|
|
width: page.contentW
|
|
tracks: (!root.albumsTab && !root.grouped) ? root.tracks : []
|
|
flick: flick
|
|
onPlayRequested: function (id) { QbzPlayer.playTrack(id) }
|
|
}
|
|
|
|
// --- TRACKS, grouped ----------------------------------------------
|
|
Column {
|
|
visible: !root.loading && root.loadError === "" && !root.albumsTab
|
|
&& root.grouped && root.tracks.length > 0
|
|
width: page.contentW
|
|
spacing: 16
|
|
Repeater {
|
|
model: (!root.albumsTab && root.grouped) ? root.trackGroups() : []
|
|
delegate: Column {
|
|
required property var modelData
|
|
width: page.contentW
|
|
spacing: 8
|
|
Text {
|
|
width: parent.width
|
|
text: modelData.title
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontHeading
|
|
font.weight: theme.weightSemibold
|
|
elide: Text.ElideRight
|
|
}
|
|
PurchaseTracksCollection {
|
|
width: parent.width
|
|
tracks: modelData.items
|
|
flick: flick
|
|
onPlayRequested: function (id) { QbzPlayer.playTrack(id) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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: flick; scope: "purchases" }
|
|
QbzScrollBar {
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 4
|
|
anchors.top: parent.top
|
|
anchors.bottom: parent.bottom
|
|
target: flick
|
|
}
|
|
}
|