Files
qbz/crates/qbz-qt/qml/views/purchases/PurchaseTrackRow.qml
T
vicrodh 7d98ad044f feat(purchases): the Qt frontend — list, album detail, routing and gating
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
2026-08-16 11:54:05 -06:00

219 lines
8.2 KiB
QML

// PurchaseTrackRow — one row of the Purchases TRACKS tab. 1:1 port of
// purchases/PurchasesView.slint's `PurchaseTrackRow` (:400-560).
//
// NOT rows/TrackRow.qml. The .slint's own header names the reason and it holds
// here too: this row's columns are Purchases-only (a BARE quality string, a
// purchase-date column, a download slot) and it has neither the number/play
// cell, nor the heart, nor the ⋯ menu that the shared row is built around —
// §15.1 explicitly forbids a track context menu on this screen. It also has no
// column header above it, so `rows/TrackCols.qml`'s whole reason to exist (a
// header and a row that cannot disagree) does not apply.
//
// 56px, Radius.sm: 40x40 art · title over "artist · album" · a 64px quality
// column · a 44px duration column · a 100px purchase-date column · a 30px
// download slot.
//
// STATE, and one deliberate reduction from the .slint. The reference's download
// slot is a FOUR-state control (idle / downloading / complete / failed) that
// opens an anchored format picker. §G carries no picker seam — `list_json`
// tracks publish `downloaded` and nothing else about a download, and §G.1 has
// no picker invokable — so the slot here is a read-only INDICATOR: a success
// check on a downloaded row, an empty reserve otherwise. The column geometry is
// the reference's, so the control can be armed later without moving a pixel.
// (Flagged to the orchestrator rather than invented.)
//
// The row body plays, and only when `streamable` — the purchases-list default
// is TRUE (§G.2), so an unplayable row is one the API really did mark that way.
import QtQuick
import com.blitzfc.qbz
import "../../theme"
Rectangle {
id: root
/// One row of `list_json.tracks` (§G.2).
property var track: ({})
signal playRequested()
QbzTheme { id: theme }
readonly property bool streamable: root.track.streamable !== false
readonly property bool downloaded: root.track.downloaded === true
/// The now-playing row, read from the shared player state — the .slint's
/// `PurchasesState.active-track-id` has no equivalent in `list_json`, and
/// `QbzPlayer.npTrackId` is what every other row in this port asks
/// (rows/TrackRow.qml:324).
readonly property bool active: QbzPlayer.npTrackId === (root.track.id || "")
&& (root.track.id || "") !== ""
/// §10-C / §G.3: `title (version)`. Published empty today on the list
/// document, so the call is inert until the producer fills it — which is
/// exactly why it must be here and not hard-coded away.
function displayTitle() {
var ttl = root.track.title || ""
var ver = root.track.version || ""
return ver !== "" ? ttl + " (" + ver + ")" : ttl
}
function fmtDuration(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
}
function purchaseDate() {
var ts = root.track.purchasedAt || 0
if (ts <= 0)
return ""
var ms = ts > 100000000000 ? ts : ts * 1000
return new Date(ms).toLocaleDateString(Qt.locale(), Locale.ShortFormat)
}
width: parent ? parent.width : 0
height: 56
radius: theme.radiusSm
// .slint:414 — the active row takes alpha-10; hover only applies to a row
// the click can actually act on.
color: root.active ? theme.alphaTier(10)
: ((rowArea.containsMouse && root.streamable) ? theme.surfaceHover : "transparent")
// .slint:418 — a downloaded row reads as "already dealt with".
opacity: root.downloaded ? 0.75 : 1.0
// Declared FIRST so the cells that want their own pointer (none today, but
// the download slot lands here) are hit-tested after it, exactly like the
// .slint's ordering note.
MouseArea {
id: rowArea
anchors.fill: parent
enabled: root.streamable
hoverEnabled: true
cursorShape: root.streamable ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: root.playRequested()
}
Row {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 12
spacing: 12
// 40x40 artwork.
Item {
width: 40
height: parent.height
Rectangle {
width: 40
height: 40
anchors.verticalCenter: parent.verticalCenter
radius: theme.radiusSm
color: theme.surfaceElevated
RoundedImage {
id: art
anchors.fill: parent
// `artPath`, never `artworkUrl` — see PurchaseGridCard.
source: root.track.artPath || ""
radius: theme.radiusSm
}
QbzIcon {
visible: !art.ready
name: "music"
width: 16
height: 16
anchors.centerIn: parent
tintName: "muted"
}
}
}
// Title over the "artist · album" meta line — the stretch column.
Column {
width: Math.max(0, parent.width - 40
- (qualityCell.visible ? 64 + 12 : 0)
- 44 - 100 - 30 - 4 * 12)
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: root.displayTitle()
color: root.active ? theme.accent : theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightMedium
elide: Text.ElideRight
}
// `artist · album`. The .slint makes the artist a link; the list
// document carries no `artistId` (§G.2), so there is no page to
// route to and a pointer that promises one would lie. Plain text
// until the seam grows the field.
Text {
width: parent.width
text: {
var a = root.track.artist || ""
var al = root.track.album || ""
return al !== "" ? (a + " · " + al) : a
}
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
// Quality column — BARE, and dropped entirely when the row has none
// (.slint:498).
Text {
id: qualityCell
visible: (root.track.qualityTier || "") !== ""
width: 64
height: parent.height
text: root.track.qualityTier || ""
color: theme.textSecondary
font.pixelSize: theme.fontLegal
horizontalAlignment: Text.AlignRight
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
// Duration.
Text {
width: 44
height: parent.height
text: root.fmtDuration(root.track.duration)
color: theme.textMuted
font.pixelSize: theme.fontLegal
horizontalAlignment: Text.AlignRight
verticalAlignment: Text.AlignVCenter
}
// Purchase date.
Text {
width: 100
height: parent.height
text: root.purchaseDate()
color: theme.textMuted
font.pixelSize: theme.fontLegal
horizontalAlignment: Text.AlignRight
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
// Download slot — see the header: the reference's 4-state control,
// reduced to its "complete" arm because §G publishes no other state.
Item {
width: 30
height: parent.height
QbzIcon {
visible: root.downloaded
name: "check"
width: 16
height: 16
anchors.centerIn: parent
// The .slint paints this one `Theme.success`; QbzIcon's tint
// vocabulary has no success arm, and "accent" is the token this
// port uses for a positive glyph on a theme surface.
tintName: "accent"
}
}
}
}