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

215 lines
7.9 KiB
QML

// Compact album row for the LIST arm of the Albums / Folders-flat / Artists
// surfaces (AlbumCollectionView's list mode, mounted by
// LocalLibraryView.slint:1255 with show-source / show-source-badge on).
//
// 56px: cover, title + artist, year, track count, quality mark, the source
// column and the trailing ⋯ overflow. Multi-select puts the shared checkbox
// in front of the cover, as the collection view does.
//
// The ⋯ menu is AlbumListRow.slint:381 minus its two Qobuz-only entries: the
// favourite heart (local albums are not catalog favourites) and "Block this
// album" (which the Slint itself hides for source local/plex, :435). What is
// left is the five entries Slint always shows — Open album, Play, Play next,
// Play later, Add to queue — and all five are wired. Right-clicking the row
// opens the same menu at the pointer.
import QtQuick
import com.blitzfc.qbz
import "../../controls"
import "../../theme"
Rectangle {
id: root
property var item: ({})
property string artSource: ""
/// The LocalLibraryView root — the shared skeleton pulse, the per-item
/// artwork gate and the settle bound all live there (never duplicated
/// per row, so the rule cannot drift between surfaces).
property var view: null
property bool showSource: true
property bool selectMode: false
property bool checked: false
signal opened()
signal playRequested()
signal enqueueRequested(string mode)
signal toggleSelect()
QbzTheme { id: theme }
height: 56
radius: 6
color: rowArea.containsMouse ? theme.surfaceHover : "transparent"
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton) {
rowMenu.openAtCursor(rowArea, mouse.x, mouse.y)
return
}
if (root.selectMode) root.toggleSelect()
else root.opened()
}
onDoubleClicked: if (!root.selectMode) root.playRequested()
}
CardMenu {
id: rowMenu
menuWidth: 196
entries: [
{ "label": QbzSession.tr("Open album", QbzSession.trRev), "icon": "library-big", "action": "open" },
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
]
onPicked: function (a) {
if (a === "open") root.opened()
else if (a === "play") root.playRequested()
else root.enqueueRequested(a)
}
}
Row {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 12
spacing: 12
Item {
visible: root.selectMode
width: visible ? 16 : 0
height: parent.height
SelectCheck {
anchors.centerIn: parent
on: root.checked
onToggled: root.toggleSelect()
}
}
Rectangle {
width: 40
height: 40
anchors.verticalCenter: parent.verticalCenter
radius: 4
color: theme.surfaceElevated
clip: true
RoundedImage {
id: rowArt
anchors.fill: parent
source: root.artSource
radius: 4
}
// Per-item: hands over the instant the art is actually ON the
// canvas (`rowArt.ready`, not "the path landed"); settles out
// when the album simply has none (local artwork drops such keys).
QbzSkeleton {
variant: "art"
anchors.fill: parent
blockRadius: 4
pending: root.view ? root.view.artWanted(root.item.artKey) : false
coverReady: rowArt.ready
phase: root.view ? root.view.skelPhase : false
settleMs: root.view ? root.view.artSettleMs : 0
settleHold: root.view ? root.view.artPulse : false
}
}
Column {
width: parent.width - 40 - 70 - 90 - 92 - 32
- (root.showSource ? 34 : 0)
- (root.selectMode ? 28 : 0) - 6 * 12
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: root.item.title || ""
color: theme.textPrimary
font.pixelSize: 14
font.weight: theme.weightMedium
elide: Text.ElideRight
}
Text {
width: parent.width
text: root.item.artist || ""
color: theme.textMuted
font.pixelSize: 12
elide: Text.ElideRight
}
}
Text {
width: 70
anchors.verticalCenter: parent.verticalCenter
text: root.item.year || ""
color: theme.textMuted
font.pixelSize: 12
}
Text {
width: 90
anchors.verticalCenter: parent.verticalCenter
text: (root.item.trackCount || 0) + " "
+ QbzSession.tr("tracks", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 12
}
Item {
width: 92
height: parent.height
QualityMini {
tier: root.item.qualityTier || ""
anchors.verticalCenter: parent.verticalCenter
}
}
Item {
visible: root.showSource
width: visible ? 34 : 0
height: parent.height
// AlbumListRow.slint:308-322 — through controls/SourceIcon.qml,
// never QbzIcon: the Plex and Qobuz marks are MULTI-COLOUR and a
// tint flattens them to a silhouette. This row used to draw an
// accent-tinted `hard-drive` for Plex (a blue hard drive) and
// `cloud-download` for an offline copy.
SourceIcon {
visible: (root.item.source || "") !== ""
// `sourceRaw` FIRST (contract §D.1): it carries
// `qobuz_purchase`, which `source` folds into "offline" for the
// benefit of the source chips. Without this the LIST mode of
// the very same album loses the gold mark the GRID shows.
kind: root.item.sourceRaw || root.item.source || ""
// .slint:319 — local 15, the marks 16; muted local tint.
glyphSize: 15
plexSize: 16
qobuzSize: 16
localTint: "muted"
x: 0 // .slint:321
anchors.verticalCenter: parent.verticalCenter
}
}
// Trailing ⋯ overflow (AlbumListRow.slint:360).
Rectangle {
width: 32
height: 32
radius: 6
anchors.verticalCenter: parent.verticalCenter
color: moreArea.containsMouse ? theme.surfaceElevated : "transparent"
QbzIcon {
name: "ellipsis"
width: 18
height: 18
anchors.centerIn: parent
tintName: moreArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: moreArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: function (mouse) { rowMenu.openAtCursor(moreArea, mouse.x, mouse.y) }
}
}
}
}