Two screens, a controller and a bridge. `QbzPurchases` publishes one JSON document per screen plus a revision counter, so QML re-parses even when the text is byte-identical. - The list: two tabs with per-type counters, a server-side search on a 300 ms debounce, and a toolbar that DIFFERS by tab — Albums gets group, sort, direction, two filters and grid/list; Tracks gets group and the filters only. Building the Albums toolbar on both would have invented two controls. - The detail: a format dropdown whose selection re-scopes every downloaded mark, the progress section and Add-to-Library together; a determinate bar counted in TRACKS, never bytes; goodies as an album-level action counted separately. - The album download is the frontend loop, not the Rust command the reference registered and never called. Sequential, concurrency 1, cancellable, and a failed track does not abort the album. - Both prefs are read at runtime, not merely written. `show_purchases` is the master gate and `nav_tb_purchases` only relocates the entry to the title bar; both default false, so Purchases still ships hidden. - Scanned local rows keep their raw `qobuz_purchase` source so the gold badge survives in list mode as well as grid, while still classifying under the Offline source filter. Remote feed rows deliberately stay `qobuz`: stamping them would badge every purchase the user merely OWNS as locally downloaded. Defects fixed after the screens were written, none of which any test could have caught: - `QbzPurchases.boot()` was called nowhere. Without the Qt-thread hop every publish is a silent no-op, so both screens would have stayed permanently empty with nothing logged. The cause was an ownership gap rather than the code: `Main.qml` belonged to no lane because the plan listed the files each lane CREATES rather than the files it TOUCHES, and "belongs to nobody" is indistinguishable from "done" until something runs. - A second download of the same album nested inside the first. The store rewrites its destination to the album subfolder after the first successful track so that Add-to-Library adds the album rather than the whole music root, and the download planner read that value first — producing `.../Album [q]/Artist/Album [q]/...`, one level deeper each time. The picked ROOT and the resolved ALBUM FOLDER are now separate fields with names that cannot be confused, with a regression test. - A tab counter could stick at zero forever with its list plainly full: the two ids calls each swallow their error, and the reference's backfill from the list response was missing. - Cancelling made the whole progress block vanish instead of showing the cancelled state, whose msgid was already translated in all eight catalogs. - Add-to-Library re-walked the entire library when the folder id it needed was already in hand. - A live language switch and a logout left the previous state behind. Deliberate divergences from the contract, each recorded with its reason: the page header is a plain `fontSection` title with no icon tile, because nothing else in this application announces itself over a large coloured square; purchase download status travels inside the album document rather than on the shared cache channel, which is keyed by track id alone and would let the two features overwrite each other on the same row. Gate: release build clean, QML audits 278 files / 37 bridges, `cargo test -p qbz-qt` 398 passing, offscreen smoke green with zero QML complaints. Not verified, and cannot be from here: every populated screen. This account owns nothing, so only the empty states have ever rendered. cc
156 lines
6.1 KiB
QML
156 lines
6.1 KiB
QML
// PurchaseGridCard — one album cell of the Purchases grid. 1:1 port of
|
|
// purchases/PurchasesView.slint's `PurchaseGridCard` (:230-300).
|
|
//
|
|
// SELF-CONTAINED ON PURPOSE, and the .slint records the same call in its own
|
|
// header: the Purchases card is "card-lite" — cover / title / artist / quality,
|
|
// NO play overlay, no heart, no pin badge, no ⋯ menu — and it carries an
|
|
// UNAVAILABLE state that `cards/AlbumCard.qml` has no arm for at all.
|
|
// Threading two Purchases-only flags through the shared card would also have to
|
|
// go through `views/AlbumCollection.qml`, which mounts AlbumCard with ~15
|
|
// hardcoded properties, exposes no signals, and routes every click to
|
|
// `QbzAlbum.openAlbum` — i.e. straight to the CATALOG album page, which would
|
|
// leave the purchase-album route (§G.1: `QbzPurchases.openAlbum` then
|
|
// `QbzShell.navigateTo("purchase-album")`) unreachable. So the cell is its own
|
|
// file, exactly as the .slint decided for exactly these reasons.
|
|
//
|
|
// 162x232: a 1:1 cover (Radius.sm well, surface-elevated, disc-3 placeholder)
|
|
// over the title / artist / quality lines.
|
|
//
|
|
// UNAVAILABLE (`downloadable == false`): the cell dims to .45 (.55 on hover)
|
|
// and an alpha-60 scrim with the "Unavailable" label covers the artwork. The
|
|
// click is gated on it too — an unavailable purchase must not open a detail
|
|
// page that has nothing to offer. No `Behavior` on the opacity: it is fed by
|
|
// data and by hover, which is precisely the shape the repaint-pulse rule
|
|
// forbids animating (the .slint animates neither).
|
|
//
|
|
// There is deliberately NO "downloaded" mark here. Neither Tauri nor the
|
|
// .slint draws one on an album cell; `downloaded` feeds the "Hide downloaded"
|
|
// filter and the detail screen, and inventing a badge would be a behaviour
|
|
// this screen never had.
|
|
|
|
import QtQuick
|
|
import com.blitzfc.qbz
|
|
import "../../theme"
|
|
|
|
Item {
|
|
id: root
|
|
|
|
/// One row of `list_json.albums` (§G.2).
|
|
property var album: ({})
|
|
signal clicked()
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
readonly property bool avail: root.album.downloadable !== false
|
|
/// `artPath`, NEVER `artworkUrl`. The url is the REMOTE cover and this port
|
|
/// does not fetch covers from a delegate — Rust resolves them onto disk and
|
|
/// publishes the `file://` path, `""` until it lands. A cell handed only the
|
|
/// url draws nothing at all (purchases_qt.rs::AlbumRow::art_path, and the
|
|
/// same trap documented in artist_releases_qt.rs and musician_qt.rs).
|
|
readonly property string artUrl: root.album.artPath || ""
|
|
|
|
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
|
|
|
|
// .slint:238 — .45 / .55 on hover / 1.0.
|
|
opacity: root.avail ? 1.0 : (cardArea.containsMouse ? 0.55 : 0.45)
|
|
|
|
Column {
|
|
width: parent.width
|
|
spacing: 8
|
|
|
|
// --- Cover, 1:1 ---------------------------------------------------
|
|
Rectangle {
|
|
id: cover
|
|
width: parent.width
|
|
height: parent.width
|
|
radius: theme.radiusSm
|
|
color: theme.surfaceElevated
|
|
// No clip: RoundedImage confines its own crop and the scrim below
|
|
// is geometrically contained (the AlbumCard rule — one batch root
|
|
// per cell, no scissor that rounds nothing).
|
|
|
|
RoundedImage {
|
|
id: art
|
|
anchors.fill: parent
|
|
source: root.artUrl
|
|
radius: theme.radiusSm
|
|
}
|
|
// .slint:252 — the empty-well glyph. Gated on `ready`, not on "the
|
|
// path is non-empty": a url that is still decoding (or that failed)
|
|
// must not leave a bare square (RoundedImage's readiness contract).
|
|
QbzIcon {
|
|
visible: !art.ready
|
|
name: "disc-3"
|
|
width: 36
|
|
height: 36
|
|
anchors.centerIn: parent
|
|
tintName: "muted"
|
|
}
|
|
|
|
// .slint:263-278 — the unavailable scrim over the artwork.
|
|
Rectangle {
|
|
visible: !root.avail
|
|
anchors.fill: parent
|
|
radius: theme.radiusSm
|
|
color: theme.alphaTier(60)
|
|
Text {
|
|
anchors.centerIn: parent
|
|
width: parent.width - 12
|
|
text: root.t("Unavailable")
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontLegal
|
|
font.weight: theme.weightSemibold
|
|
horizontalAlignment: Text.AlignHCenter
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Title / artist / quality ------------------------------------
|
|
Column {
|
|
width: parent.width
|
|
spacing: 2
|
|
|
|
Text {
|
|
width: parent.width
|
|
height: 20
|
|
text: root.album.title || ""
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontBody
|
|
font.weight: theme.weightMedium
|
|
verticalAlignment: Text.AlignVCenter
|
|
elide: Text.ElideRight
|
|
}
|
|
Text {
|
|
width: parent.width
|
|
height: 17
|
|
text: root.album.artist || ""
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLegal
|
|
verticalAlignment: Text.AlignVCenter
|
|
elide: Text.ElideRight
|
|
}
|
|
// .slint:288 — the quality line is dropped entirely when the row
|
|
// carries no tier, rather than reserving an empty band.
|
|
Text {
|
|
visible: (root.album.qualityTier || "") !== ""
|
|
width: parent.width
|
|
height: visible ? 17 : 0
|
|
text: root.album.qualityTier || ""
|
|
color: theme.textSecondary
|
|
font.pixelSize: theme.fontLegal
|
|
verticalAlignment: Text.AlignVCenter
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
id: cardArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: root.avail ? Qt.PointingHandCursor : Qt.ArrowCursor
|
|
onClicked: if (root.avail) root.clicked()
|
|
}
|
|
}
|