Files
qbz/crates/qbz-qt/qml/views/PurchasesView.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

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.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
// §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
}
}