feat(qt): artist scene, musician page and the shared scene-discovery engine

Both views were absent from the Qt frontend, not partial. This lands them
together with the core engine they need and the two entry points that reach
them.

- qbz-core gains scene discovery ("artists from the same place"): a
  cancellable run with a phase for the progress label and a typed
  SceneDiscoveryError, so a view can tell offline, all-upstreams-failed and
  an honest empty result apart. CancelToken, ScenePhase and SceneDiscoveryError
  are re-exported for frontends.
- qbz-integrations: MusicBrainz location resolution plus its cache layer.
- qbz-qt: ArtistSceneView with its three scene components, MusicianPageView
  and MusicianModal, backed by artist_scene_qt.rs, musician_qt.rs and the two
  bridges. The five-branch musician dispatcher is wired to all six of its
  consumers. Both scene doors are live: the sidebar location link and the
  overflow row that shipped grey with action: "stub".
- Translations: the new msgids in all eight locales.

Three defects fixed on the way, all with the same signature — a comment
asserting a mechanism that does not exist:

- Volume opened at 100 %. The comment claimed the poll loop mirrored it from
  the engine; now_playing::set_volume has two callers and neither runs in a
  local session.
- mbAvailable was documented as "MusicBrainz enabled AND resolved to an MBID"
  while being assigned from the opt-in alone. Consumers now test the MBID.
- Session restore could not resume from a cold cursor ("cannot resume - no
  audio data available") for want of a has_loaded_audio() guard. Investigation
  in qbz-nix-docs/qt-frontend/2026-08-14-cold-cursor-resume.md.

Contract and evidence:
qbz-nix-docs/qt-frontend/2026-08-14-artist-scene-musician/00-CONTRACT.md,
which wins over specs 01..09 and records 20 deliberate divergences from the
reference in section 6.

cc
This commit is contained in:
vicrodh committed 2026-08-14 19:25:11 -06:00
1 parent b045f7023b
commit 016eb3cd23
37 files changed
+9203 -83

No files matched your search

@@ -0,0 +1,135 @@
// ArtistScene › the A-Z jump rail — 1:1 with Tauri's `.alpha-index`
// (ArtistsByLocationView.svelte markup :699-711, CSS :1372-1413), per
// 01-tauri-artist-scene.md §11.
//
// NOT controls/QbzAlphaStrip.qml. That one is the Library/Local rail: an 18px
// gutter of 15px rows in 9px muted type, driven by a { letter, index } list of
// the jumps that EXIST. This one is Tauri's: a fixed 27-slot pill — `#` first,
// then A-Z, every slot always drawn — 20×20 letters at 11/600 on a translucent
// black card, where a letter with no members stays visible at opacity 0.2.
// Different alphabet contract, different metrics, different host; the
// reference has two components as well.
//
// STATIC, NOT STICKY. §11 and §18 of the spec both call this rail "sticky"
// (`position:sticky; top:0`), and 08-critique-findings settles that the sticky
// never engages: `.alpha-index` is a flex child of a row that does not scroll,
// and the real scroller is a descendant of its SIBLING. What pins it to the
// top is `align-self: flex-start`. So: the host anchors this item's TOP and
// lets it keep its implicit height. Do not wire it to a Flickable's contentY.
//
// DELIBERATE DIVERGENCE (recorded, small): Tauri styles a memberless letter
// `.disabled` — opacity 0.2, `cursor: default` — but leaves the <button> live
// and early-returns inside `scrollToGroup` (V:310). Here the letter is
// genuinely inert: no hover paint, no pointer cursor, no signal. Same
// user-visible result with less surprise, and one fewer no-op code path.
//
// MOUNTING — the root IS the 28px pill, and `.alpha-index`'s 4px `margin-left`
// is the HOST's (ArtistSceneView.qml:1123 already writes it as
// `x: gridPane.width + 4`). Do not bake it in here as well.
//
// AlphaIndexRail {
// x: gridPane.width + 4 // the CSS margin-left
// y: 0 // align-self: flex-start
// activeKeys: root.alphaActiveKeys // ["#","A","D",…] or {"A": true}
// onJumped: function (letter) { … } // host scrolls its own view
// }
import QtQuick
import com.blitzfc.qbz
import "../../theme"
Item {
id: root
// --- the frozen mount API (00-CONTRACT.md, scene lane) ---------------
/// Which of the 27 slots have members. Accepts an array (["#","A","D"])
/// or a set-shaped object ({"A": true}) — the host may hold either.
property var activeKeys: []
signal jumped(string letter)
QbzTheme { id: theme }
// `ALPHA_LETTERS = '#ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')` (V:271) —
// 27 entries, `#` FIRST. Built once, never re-derived: this is a constant.
readonly property var letters: [
"#", "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M",
"N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"
]
function hasMembers(letter) {
var k = root.activeKeys
if (!k)
return false
if (Array.isArray(k))
return k.indexOf(letter) >= 0
return k[letter] === true
}
// 20px letters inside 4px of side padding — `padding: 6px 4px`.
implicitWidth: 28
implicitHeight: pill.height
width: implicitWidth
height: implicitHeight
Rectangle {
id: pill
width: 28
height: col.height + 2 * 6
radius: 10
// `background: rgba(0,0,0,0.2)`. Qt.rgba with 0..1 floats, NEVER an
// 8-digit hex — Qt reads #AARRGGBB where CSS writes #RRGGBBAA, so
// "#00000033" would be a fully transparent black here.
color: Qt.rgba(0, 0, 0, 0.2)
Column {
id: col
x: 4
y: 6
spacing: 2
Repeater {
model: root.letters
delegate: Rectangle {
id: slot
required property var modelData
readonly property bool live: root.hasMembers(slot.modelData)
width: 20
height: 20
radius: 4
color: (slot.live && slotArea.containsMouse) ? theme.surfaceElevated
: "transparent"
// `.disabled { opacity: 0.2 }` / `opacity: 0.9` -> 1 on
// hover. `transition: all 100ms ease` — hover-bounded, the
// one animation class the pulse law leaves alone.
opacity: !slot.live ? 0.2 : (slotArea.containsMouse ? 1.0 : 0.9)
Behavior on opacity { NumberAnimation { duration: 100 } }
Behavior on color { ColorAnimation { duration: 100 } }
Text {
anchors.fill: parent
text: slot.modelData
color: theme.textPrimary
font.pixelSize: 11
font.weight: theme.weightSemibold
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
MouseArea {
id: slotArea
anchors.fill: parent
// Inert when the letter has no members: an enabled
// MouseArea would still eat the press and paint a
// cursor for a jump that cannot happen.
enabled: slot.live
hoverEnabled: slot.live
cursorShape: Qt.PointingHandCursor
onClicked: root.jumped(String(slot.modelData))
}
}
}
}
}
}
@@ -0,0 +1,544 @@
// ArtistScene › the genre-filter popup — 1:1 with Tauri's `.genre-popup`
// (ArtistsByLocationView.svelte markup :495-549, CSS :1128-1347), per
// 00-CONTRACT.md §0 (Tauri wins on every number) and 01-tauri-artist-scene.md
// §6, which is normative here.
//
// NOT the same component as controls/GenreFilterPopup.qml. That one is the
// Discover/Library overlay: 470px wide, driven end-to-end by
// QbzBridge.genreFilterJson, with a remember/advanced toggle pair and a
// 3-level genre TREE. This one is a dumb, self-contained box over the genre
// strings the scene's own candidates carry — no bridge, no tree, no
// persistence, and a fixed 3-column grid of 36px cards. Same job title, two
// different surfaces; the reference has two files as well.
//
// ⚠ TWO COMPONENTS NOW SHARE THE NAME `GenreFilterPopup`, and ArtistSceneView
// imports BOTH directories ("../controls" and "scene"). QML resolves a type by
// walking the imports in reverse, so "scene" — imported last — wins today and
// the right box is drawn. It is still one reordered import away from silently
// mounting the 470px Discover tree instead. The fix belongs to the integration
// lane and is three tokens: `import "scene" as Scene`, then `Scene.` on the
// three mounts. Flagged in this lane's report.
//
// MOUNTING — the root IS the 530px card, positioned by the host, which is what
// ArtistSceneView.qml:1236-1242 already does:
//
// GenreFilterPopup {
// z: 200
// open: root.genrePopupOpen
// x: … genreBtn.x + genreBtn.width - width // `right: 0`
// y: … genreBtn.y + genreBtn.height + 6 // `calc(100% + 6px)`
// genres: root.availableGenres // ["post-punk", …]
// selected: root.activeGenres // array OR {g: true}
// onToggled: function (g) { … }
// onCleared: function () { … }
// onCloseRequested: root.genrePopupOpen = false
// }
//
// Tauri anchors the card to the 36×36 funnel button (`.genre-filter-container
// {position:relative}` :1129-1131, which 01-…md §6 omits and 08-critique
// records), i.e. `top: calc(100% + 6px); right: 0` of THAT button — NOT of the
// nav bar and NOT of the view.
//
// OUTSIDE CLICK IS THE HOST'S. Tauri closes on a document-level handler
// (V:240-246); the QML equivalent is a full-surface scrim, which this card
// cannot be and still be positioned as a 530px box. ArtistSceneView already
// owns that scrim (`outsideScrim`, :1168-1177, one scrim for this popup and the
// group menu — its DV-4 fix). What this component contributes is the swallow
// MouseArea below, without which a press INSIDE the card would fall through to
// that scrim and close the popup on every genre click.
//
// SELECTION IS THE HOST'S. This box only reports `toggled(genre)` and
// `cleared()`; the AND semantics of the filter chain (V:250-262 — an artist is
// kept only when it carries EVERY selected genre, case-sensitive) live in the
// consumer.
import QtQuick
import com.blitzfc.qbz
import "../../theme"
Item {
id: root
// --- the frozen mount API (00-CONTRACT.md, scene lane) ---------------
/// Every genre string the scene's candidates carry, already deduped by
/// the host. Order is the host's; this box does not sort.
property var genres: []
/// The active selection. Accepts an array (["post-punk"]) or a
/// set-shaped object ({"post-punk": true}) — Tauri's is a JS Set and the
/// host may hold either.
property var selected: []
/// Visibility. Named `open` to match the mount contract.
property bool open: false
signal toggled(string genre)
signal cleared()
signal closeRequested()
QbzTheme { id: theme }
// `width: 530px; max-height: 440px` (:1136-1137). The card is a flex
// column whose only flexible band is the grid, so it is as tall as its
// content up to 440.
readonly property int headerH: 52
readonly property int searchH: 41
readonly property int footerH: 57
width: 530
height: Math.min(440, root.headerH + (root.searchShown ? root.searchH : 0)
+ gridBand.contentH + root.footerH)
// Tauri's z-index:200 (`.genre-popup` :1145). This is a view-local popup,
// not a modal, so ADR-009's ≥3000 floor does not apply — that floor is for
// surfaces that must clear the shell chrome, and this one must not. The
// host sets it too; this is the default for any other mount.
z: 200
visible: root.open
enabled: root.open
// The search field is rendered only above 9 genres (V:508) — below that
// the 3-column grid shows everything without scrolling and the row would
// be dead chrome.
readonly property bool searchShown: (root.genres || []).length > 9
/// Selection test that survives both shapes of `selected`.
function isSelected(g) {
var s = root.selected
if (!s)
return false
if (Array.isArray(s))
return s.indexOf(g) >= 0
return s[g] === true
}
readonly property int selectedCount: {
var s = root.selected
if (!s)
return 0
if (Array.isArray(s))
return s.length
var n = 0
for (var k in s)
if (s[k] === true)
n++
return n
}
// `.genre-name::first-letter { text-transform: capitalize }` (:1276-1278)
// — the FIRST LETTER ONLY, not CSS `capitalize`, which would title-case
// every word ("post punk" -> "Post punk", never "Post Punk"). QML has no
// text-transform at all, so it happens here.
function capFirst(s) {
var t = String(s || "")
return t.length === 0 ? t : t.charAt(0).toUpperCase() + t.slice(1)
}
// Closing resets the needle, as Tauri does on every close path
// (V:242-245, V:519, V:542).
onOpenChanged: if (!root.open) searchInput.text = ""
readonly property var shownGenres: {
var all = root.genres || []
var q = searchInput.text.toLowerCase()
if (!root.searchShown || q === "")
return all
var out = []
for (var i = 0; i < all.length; i++)
if (String(all[i]).toLowerCase().indexOf(q) >= 0)
out.push(all[i])
return out
}
// ============================== the card =============================
// `box-shadow: 0 8px 32px rgba(0,0,0,0.4)` (:1144), approximated by four
// concentric frames that fade outward. Declared BEFORE the card so the
// card's own fill covers them — a negative-z CHILD would not work: Qt
// Quick draws an item's own geometry before any child, whatever its z, so
// the frames would have painted dark bars across the card's top edge.
//
// Qt.rgba with 0..1 floats, NEVER an 8-digit hex: Qt reads #AARRGGBB
// where CSS and Slint write #RRGGBBAA, so "#00000066" is a fully
// TRANSPARENT black here. That trap has landed twice in this port.
Repeater {
model: 4
delegate: Rectangle {
required property int index
x: -(index + 1) * 2
y: 8 - (index + 1) * 2
width: root.width + (index + 1) * 4
height: root.height + (index + 1) * 4
radius: card.radius + (index + 1) * 2
color: "transparent"
border.width: 2
border.color: Qt.rgba(0, 0, 0, 0.4 / (index + 2))
}
}
Rectangle {
id: card
anchors.fill: parent
radius: 10
color: theme.surfaceMain
border.width: 1
border.color: theme.borderSubtle
// Swallow presses on the card's own chrome (Tauri's
// `onclick={e => e.stopPropagation()}`, V:498). Declared FIRST so the
// content below keeps its own clicks.
MouseArea { anchors.fill: parent }
// ---------------------------- header -----------------------------
Item {
id: header
width: parent.width
height: root.headerH
Row {
x: 16
anchors.verticalCenter: parent.verticalCenter
spacing: 8
QbzIcon {
name: "sliders-horizontal"
width: 16
height: 16
anchors.verticalCenter: parent.verticalCenter
tintName: "textPrimary"
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Filter by genre", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: 14
font.weight: theme.weightSemibold
}
}
Rectangle {
x: parent.width - width - 16
anchors.verticalCenter: parent.verticalCenter
width: 28
height: 28
radius: 6
color: closeArea.containsMouse ? theme.bgHover : "transparent"
// Hover-only, gesture-bounded — the one animation class the
// pulse law leaves alone (cards/ArtistCard.qml:170).
Behavior on color { ColorAnimation { duration: 150 } }
QbzIcon {
anchors.centerIn: parent
name: "x"
width: 16
height: 16
tintName: closeArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: closeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.closeRequested()
}
}
Rectangle {
y: parent.height - 1
width: parent.width
height: 1
color: theme.borderSubtle
}
}
// -------------------------- search row ---------------------------
Item {
id: searchRow
visible: root.searchShown
y: header.height
width: parent.width
height: root.searchH
Row {
x: 16
width: parent.width - 32
anchors.verticalCenter: parent.verticalCenter
spacing: 8
QbzIcon {
name: "search"
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: "muted"
}
TextInput {
id: searchInput
width: parent.width - 22 - (clearBtn.visible ? clearBtn.width + 8 : 0)
height: 21
anchors.verticalCenter: parent.verticalCenter
color: theme.textPrimary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
clip: true
// Reusing the catalogue's existing needle msgid rather
// than Tauri's "Search..." — 00-CONTRACT.md §7: reuse the
// translated entry unless the owner asks otherwise.
Text {
visible: searchInput.text === ""
anchors.fill: parent
text: QbzSession.tr("Search…", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
}
}
Rectangle {
id: clearBtn
visible: searchInput.text !== ""
width: 20
height: 20
radius: 10
anchors.verticalCenter: parent.verticalCenter
color: clearArea.containsMouse ? theme.bgHover : theme.surfaceElevated
Behavior on color { ColorAnimation { duration: 150 } }
QbzIcon {
anchors.centerIn: parent
name: "x"
width: 12
height: 12
tintName: clearArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: clearArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: searchInput.text = ""
}
}
}
Rectangle {
y: parent.height - 1
width: parent.width
height: 1
color: theme.borderSubtle
}
}
// ------------------------------ grid ------------------------------
// `repeat(3, 1fr)`, gap 6, padding 12 — a FIXED 3 columns with no
// breakpoint (:1228-1235), so the cell width is arithmetic, not a
// layout negotiation.
Item {
id: gridBand
readonly property int pad: 12
readonly property int gap: 6
readonly property int cellW: Math.floor((root.width - 2 * gridBand.pad
- 2 * gridBand.gap) / 3)
readonly property int contentH: 2 * gridBand.pad
+ (root.shownGenres.length > 0 ? grid.height : emptyLabel.height)
y: header.height + (root.searchShown ? searchRow.height : 0)
width: parent.width
height: root.height - y - root.footerH
clip: true
Flickable {
anchors.fill: parent
contentWidth: width
contentHeight: gridBand.contentH
boundsBehavior: Flickable.StopAtBounds
clip: true
Grid {
id: grid
visible: root.shownGenres.length > 0
x: gridBand.pad
y: gridBand.pad
columns: 3
columnSpacing: gridBand.gap
rowSpacing: gridBand.gap
Repeater {
model: root.shownGenres
delegate: Rectangle {
id: genreCard
required property var modelData
readonly property bool picked: root.isSelected(genreCard.modelData)
width: gridBand.cellW
height: 36
radius: 6
border.width: 1
color: genreCard.picked
? (cardArea.containsMouse ? theme.accentHover : theme.accent)
: (cardArea.containsMouse ? theme.bgHover : theme.surfaceElevated)
border.color: genreCard.picked
? (cardArea.containsMouse ? theme.accentHover : theme.accent)
: (cardArea.containsMouse ? theme.textMuted : theme.borderSubtle)
// `transition: color/background-color/border-color
// 150ms` (:1244-1247). Both arms of this colour are
// gesture-bounded — hover, and a click on THIS card
// (or the footer's Clear) is the only thing that can
// flip `picked`. It is not a data feed in the sense
// the pulse law forbids.
Behavior on color { ColorAnimation { duration: 150 } }
Behavior on border.color { ColorAnimation { duration: 150 } }
Text {
x: 10
width: parent.width - 10 - 14 - 10 - 8
height: parent.height
text: root.capFirst(genreCard.modelData)
// On the accent fill the label is the measured
// on-accent colour, not Tauri's
// `--btn-primary-text`: the same call the
// sibling popup already makes
// (controls/GenreFilterPopup.qml:429-430) —
// accent-text on 34 of the 35 palettes, and a
// legible colour on the one where it is not.
color: genreCard.picked ? theme.accentGlyphColor : theme.textPrimary
font.pixelSize: 11
font.weight: theme.weightMedium
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
// `.check-circle` (:1284-1310) — 14px ring that
// fills white and grows a CSS-drawn tick. The tick
// is a 4×7 box wearing only its right and bottom
// borders, rotated 45°: two Rectangles here,
// because QML has no border-width shorthand and an
// icon glyph would not be the same mark.
Item {
id: checkCircle
x: parent.width - 10 - width
anchors.verticalCenter: parent.verticalCenter
width: 14
height: 14
Rectangle {
anchors.fill: parent
radius: 7
// INVERTED host: the disc is the card's
// foreground punched out of the accent
// fill and the tick inside it is `accent`,
// so the pair that has to clear the
// contrast floor is (accent, this fill) —
// exactly the swap
// controls/GenreFilterPopup.qml:436-455
// documents. Tauri paints it literal white.
color: genreCard.picked ? theme.accentGlyphColor : "transparent"
border.width: 1.5
border.color: genreCard.picked
? theme.accentGlyphColor : theme.textMuted
Behavior on color { ColorAnimation { duration: 150 } }
}
// `transform: translate(-50%,-60%) rotate(45deg)`
// off `top:50%; left:50%` — the translate is of
// the tick's OWN 4×7 box, the rotation is about
// its centre.
Item {
visible: genreCard.picked
x: parent.width * 0.5 - width * 0.5
y: parent.height * 0.5 - height * 0.6
width: 4
height: 7
rotation: 45
transformOrigin: Item.Center
// border-width: 0 1.5px 1.5px 0 — right
// edge…
Rectangle {
x: parent.width - 1.5
width: 1.5
height: parent.height
color: theme.accent
}
// …and bottom edge.
Rectangle {
y: parent.height - 1.5
width: parent.width
height: 1.5
color: theme.accent
}
}
}
MouseArea {
id: cardArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.toggled(String(genreCard.modelData))
}
}
}
}
// `.genre-popup-empty` (:1341-1347) — spans the whole grid,
// centred, 12px muted, 16px of air above and below.
Text {
id: emptyLabel
visible: root.shownGenres.length === 0
x: gridBand.pad
y: gridBand.pad
width: root.width - 2 * gridBand.pad
height: 16 + 16 + 16
text: QbzSession.tr("No results found", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 12
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
// ----------------------------- footer -----------------------------
Item {
id: footer
y: root.height - root.footerH
width: parent.width
height: root.footerH
Rectangle {
width: parent.width
height: 1
color: theme.borderSubtle
}
Rectangle {
x: 16
y: 12
width: parent.width - 32
height: 32
radius: 6
// `:disabled { opacity: 0.4 }` (:1337-1339).
opacity: root.selectedCount > 0 ? 1.0 : 0.4
color: (clearFilterArea.containsMouse && root.selectedCount > 0)
? theme.bgHover : theme.surfaceElevated
Behavior on color { ColorAnimation { duration: 150 } }
Text {
anchors.centerIn: parent
text: QbzSession.tr("Clear filter", QbzSession.trRev)
color: (clearFilterArea.containsMouse && root.selectedCount > 0)
? theme.textPrimary : theme.textSecondary
font.pixelSize: 12
font.weight: theme.weightMedium
}
MouseArea {
id: clearFilterArea
anchors.fill: parent
hoverEnabled: true
cursorShape: root.selectedCount > 0 ? Qt.PointingHandCursor : Qt.ArrowCursor
// Tauri clears AND closes (V:542).
onClicked: {
if (root.selectedCount === 0)
return
root.cleared()
root.closeRequested()
}
}
}
}
}
}
@@ -0,0 +1,961 @@
// ArtistScene › sidepanel mode — the two-column artist→albums body, 1:1 with
// Tauri's `.artist-two-column-layout` (ArtistsByLocationView.svelte markup
// :715-817, CSS :1415-1509; the rail is VirtualizedFavoritesArtistList.svelte),
// per 01-tauri-artist-scene.md §9 and §13.
//
// EDGE TO EDGE. Tauri's layout carries `margin: 0 -8px 0 -18px`, which exactly
// cancels `.scene-view`'s 18px/8px padding, so this body starts at the pane's
// left edge and the 18px re-appears INSIDE the rail as its own padding-left.
// The host must therefore mount this component full-bleed — negating its own
// padding if it has any — or the rail lands 18px too far right and the right
// column loses its 8px tail.
//
// NOT views/library/LibraryArtistsPanel.qml, and deliberately so
// (00-CONTRACT.md §4.4). That panel is bound to QbzLibrary and a Library host,
// with a 300px rail, 56px rows, 40px portraits and 200×246 AlbumCards carrying
// follow / play / pin / context-menu affordances the scene must not have.
// Tauri's scene needs 240 / 64 / 48 and a 220px card with two actions. That is
// far more than a 10 % variation, which is the justification TRACK-RULE 5 asks
// for; the artwork-window mechanics ARE reused, verbatim, from that file.
//
// WINDOWED, because the reference is not. Tauri requests 500 albums, and the
// nearest Qt panel walks every album url the moment the document lands
// (LibraryArtistsPanel.qml:182-193) and instantiates every card through nested
// Repeaters (:383-445) — 500 live delegates and 500 artwork downloads for a
// pane showing eight. Here BOTH columns are single ListViews over a flat entry
// model (the LocalArtistsTab / LibraryArtistsPanel rail shape, extended to
// carry grid ROWS), and covers are requested only for the mounted band plus
// one screen of buffer, capped at `coverBatchCap` urls per report. Geometry and
// ordering are Tauri's; only the instantiation is bounded.
//
// PULSE LAW: the only continuous animation on this surface is the rail's name
// ticker, and it rides QbzShell.pulseMs through a Connections that is DISABLED
// while the row is not hovered — a row at rest writes nothing at all. There is
// no local Timer, no NumberAnimation and no Behavior on a data-fed property;
// the hover fades are gesture-bounded, which is the one class the law allows
// (cards/ArtistCard.qml:170).
//
// NO SPINNER for the loading state: QbzSpinner owns an infinite
// RotationAnimation at display rate and QbzSkeleton a repeating 900ms Timer,
// and 00-CONTRACT.md §4.5 closes that door for new code. The three-dot
// QbzLoadingDots is host-driven by design, so its phase is cycled here off the
// shell pulse — three writes a second, only while the pane is actually loading.
import QtQuick
import com.blitzfc.qbz
import "../../controls"
import "../../theme"
Item {
id: root
// --- the frozen mount API (00-CONTRACT.md §13.1, scene lane) ---------
/// `sceneJson.panel` — { state, artistId, error, discography, epsSingles,
/// liveAlbums }.
property var doc: ({})
/// `sceneJson.artists` — the FILTERED list the host is showing. The rail
/// is always A-Z grouped over it, whatever the view's own grouping toggle
/// says (Tauri passes `groupArtists(filteredArtists)` unconditionally,
/// V:718, which is why its Group select is hidden in this mode).
property var artists: []
/// The selected artist's Qobuz id, "" for none.
property string selectedId: ""
signal artistSelected(string qobuzId)
signal albumActivated(string albumId)
signal albumPlayed(string albumId)
QbzTheme { id: theme }
// ------------------------------ metrics ------------------------------
// Every number is Tauri's; none is a theme token, because the theme's type
// scale (fontBody 15, fontLegal 13) does not line up with this surface's
// 14/13/12/11 and the contract makes Tauri normative for geometry.
readonly property int railW: 240 // .artist-column width
readonly property int railPadLeft: 18 // …its padding-left
readonly property int rowH: 64 // ROW_HEIGHT
readonly property int groupHeadH: 32 // HEADER_HEIGHT
readonly property int cardW: 220 // AlbumCardLite .card width
readonly property int coverS: 220 // …and its square cover
// 220 cover + the card's 4px column gap + the meta row (a 14/1.3 title
// over a 13/1.3 artist, 2px apart — 38 — against a 30px quality badge).
readonly property int cardH: 262
readonly property int gridGap: 22 // .artist-albums-grid gap
readonly property int trackMin: 210 // …minmax(210px, 1fr)
// Section header: a 16/600 title over 12px of air, a 1px rule, and 16px of
// margin under it.
readonly property int sectionHeadH: 51
/// Hard bound on urls asked for in one report — see the windowing note.
readonly property int coverBatchCap: 48
// ========================= artwork, url-keyed =========================
// The mechanism LibraryArtistsPanel.qml:158-193 already uses for its right
// pane and views/ArtistView.qml for its sidebar: ask QbzShell for a list of
// urls, take (url, path) pairs back on QbzLibrary.libraryArtworkReady.
// Arrivals are coalesced into ONE rebind per frame — per-arrival rebinding
// is O(n²) over a big discography.
//
// `_asked` is what makes this bounded rather than merely lazy:
// sidebar_artwork_window (main.rs:968) DOWNLOADS every url it is handed
// that is not already on disk, so re-reporting the same band on every
// scroll pixel would re-queue the same fetches. Never pruned — a cache
// path that has already been resolved costs one map entry, and dropping it
// would only make the cover flicker back to the placeholder.
property var coverMap: ({})
property var _coverInbox: ({})
property var _asked: ({})
Timer {
id: coverFlush
interval: 16
repeat: false
onTriggered: {
var m = Object.assign({}, root.coverMap, root._coverInbox)
root._coverInbox = ({})
root.coverMap = m
}
}
Connections {
target: QbzLibrary
function onLibraryArtworkReady(key, path) {
root._coverInbox[key] = path
if (!coverFlush.running)
coverFlush.start()
}
}
function requestCovers(urls) {
var out = []
for (var i = 0; i < urls.length && out.length < root.coverBatchCap; i++) {
var u = urls[i]
if (u === "" || root._asked[u] === true || root.coverMap[u])
continue
root._asked[u] = true
out.push(u)
}
if (out.length > 0)
QbzShell.sidebarArtworkWindow(JSON.stringify(out))
}
// ------------------- field readers (the lane seam) -------------------
// 00-CONTRACT.md §13.1 freezes the panel's SHAPE but not the album row's
// field NAMES. The Rust lane landed them as `albumId` / `artworkUrl` /
// `year` (4-digit string, already sliced) / `qualityTier` /
// `qualityDetail` — src/artist_scene_qt.rs:233-279. The alternates below
// are kept as one-token insurance across that seam: an `id`/`artUrl` row
// from any other producer still draws, instead of a grid of blank cards
// with no error anywhere.
/// The artist's display name, whatever the producer called it.
///
/// Same seam as the album readers above, and it has already bitten once:
/// the swap to the standard ArtistCard (owner ruling R8) moved the artist
/// row's `name` to the house `title`, which silently collapsed the host
/// grid's A-Z grouping into a single "#" bucket — every step stayed a
/// legal operation on an empty string, so nothing errored.
function artistNameOf(a) { return String((a && (a.title || a.name)) || "") }
function albumIdOf(a) { return String(a.albumId || a.id || "") }
function albumArtOf(a) { return String(a.artworkUrl || a.artUrl || a.imageUrl || "") }
function albumYearOf(a) {
if (a.year !== undefined && a.year !== null && String(a.year) !== "")
return String(a.year)
// Tauri: `Number(release_date_original?.slice(0,4)) || undefined`.
var d = String(a.releaseDate || "")
return d.length >= 4 ? d.slice(0, 4) : ""
}
// ============================== the rail ==============================
// Entry model — { t: 0, label } | { t: 1, item } — so ONE ListView windows
// both the letter headers and the rows.
property var railEntries: []
/// Group keys that have members, for the host's alpha rail if it wants
/// them. `alphaGroupKey(name)` is Tauri's: the first character uppercased,
/// kept when /[A-Z]/, else '#' — so accented and non-Latin initials all
/// collapse into '#' (V:271-276). That is design, not defect: the contract
/// lists it under "deliberately NOT diverged".
function alphaKey(name) {
var c = String(name || "").charAt(0).toUpperCase()
return (c >= "A" && c <= "Z") ? c : "#"
}
function rebuildRail() {
var src = (root.artists || []).slice()
// `groupArtists` sorts a COPY by localeCompare, buckets by key, then
// orders the keys with '#' forced first (V:278-296).
src.sort(function (a, b) {
return root.artistNameOf(a).localeCompare(root.artistNameOf(b))
})
var buckets = {}
var order = []
for (var i = 0; i < src.length; i++) {
var k = root.alphaKey(root.artistNameOf(src[i]))
if (buckets[k] === undefined) {
buckets[k] = []
order.push(k)
}
buckets[k].push(src[i])
}
order.sort(function (a, b) {
if (a === "#") return -1
if (b === "#") return 1
return a.localeCompare(b)
})
var out = []
for (var g = 0; g < order.length; g++) {
out.push({ "t": 0, "label": order[g] })
var rows = buckets[order[g]]
for (var r = 0; r < rows.length; r++)
out.push({ "t": 1, "item": rows[r] })
}
root.railEntries = out
}
onArtistsChanged: { root.rebuildRail(); root.reportRailSoon() }
Component.onCompleted: { root.rebuildRail(); root.reportRailSoon() }
function reportRail() {
if (!root.visible || root.railEntries.length === 0)
return
var first = railList.indexAt(4, railList.contentY + 1)
var last = railList.indexAt(4, railList.contentY + Math.max(1, railList.height) - 1)
if (first < 0)
first = 0
if (last < 0)
last = Math.min(root.railEntries.length - 1, first + 12)
first = Math.max(0, first - 4)
last = Math.min(root.railEntries.length - 1, last + 4)
var urls = []
for (var i = first; i <= last; i++) {
var e = root.railEntries[i]
if (e.t === 1 && String(e.item.imageUrl || "") !== "")
urls.push(String(e.item.imageUrl))
}
root.requestCovers(urls)
}
/// Report now, then again once the ListView has laid out — `indexAt`
/// answers -1 before that, which is the state of a rail that has just been
/// rebuilt or just been mounted (views/local/LocalArtistsTab.qml:330).
Timer {
id: railSettle
interval: 50
repeat: false
onTriggered: root.reportRail()
}
function reportRailSoon() {
root.reportRail()
railSettle.restart()
}
Item {
id: rail
width: root.railW
height: parent.height
clip: true
ListView {
id: railList
x: root.railPadLeft
width: parent.width - root.railPadLeft
height: parent.height
// `.virtual-container { padding: 4px 12px 4px 0 }` — the 12px is
// taken off the DELEGATE width below, not off the Flickable's
// rightMargin, which on a vertical list only moves the horizontal
// content bounds.
topMargin: 4
bottomMargin: 4
clip: true
cacheBuffer: root.rowH * 6
boundsBehavior: Flickable.StopAtBounds
model: root.railEntries
onContentYChanged: root.reportRail()
onModelChanged: root.reportRail()
onHeightChanged: root.reportRail()
onVisibleChanged: if (visible) root.reportRailSoon()
delegate: Loader {
required property var modelData
width: railList.width - 12
height: modelData.t === 0 ? root.groupHeadH : root.rowH
sourceComponent: modelData.t === 0 ? groupHeadComp : artistRowComp
// `.group-header` — 11/600 uppercase muted, letter-spacing
// 0.5, padding 12px 8px 4px, and NO count (the grid's header
// has one; this one does not).
Component {
id: groupHeadComp
Item {
Text {
x: 8
y: parent.height - 4 - height
text: String(modelData.label).toUpperCase()
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
}
}
Component {
id: artistRowComp
Rectangle {
id: artistRow
readonly property var item: modelData.item
readonly property string qobuzId: String(artistRow.item.qobuzId || "")
readonly property bool picked: root.selectedId !== ""
&& root.selectedId === artistRow.qobuzId
width: parent.width
height: root.rowH
radius: 8
color: artistRow.picked ? theme.surfaceElevated
: (rowArea.containsMouse ? theme.bgHover : "transparent")
// `transition: background-color 150ms ease`. Both arms
// are gesture-bounded — hover, and a click on this very
// row is the only thing that moves the selection.
Behavior on color { ColorAnimation { duration: 150 } }
// --- ticker state, all of it local to the hovered row
// `--ticker-offset: -(overflow + 16)px`,
// `--ticker-duration: (overflow + 16) / 40` seconds.
readonly property real overflow:
Math.max(0, nameText.implicitWidth - nameClip.width)
readonly property bool ticking: rowArea.containsMouse && artistRow.overflow > 0
readonly property real tickerOffset: -(artistRow.overflow + 16)
readonly property real tickerMs: (artistRow.overflow + 16) * 25
/// Loop phase 0..1, advanced on the SHELL PULSE. Must
/// match settings_qt::shell_pulse_ms (default 33) — a
/// mismatch only mis-speeds the ticker, never the
/// present rate (immersive/EqualizerBars.qml:55).
readonly property int _tickMs: 33
property real phase: 0
property real tickerX: 0
// Disabled while the row is not ticking, so a row at
// rest writes NOTHING — an invisible or frozen
// component that keeps assigning still dirties the
// whole window, which is the entire GPU bill on this
// shell.
Connections {
target: QbzShell
enabled: artistRow.ticking
function onPulseMsChanged() {
artistRow.phase = (artistRow.phase
+ artistRow._tickMs / artistRow.tickerMs) % 1.0
artistRow.tickerX = artistRow.tickerAt(artistRow.phase)
}
}
onTickingChanged: if (!artistRow.ticking) {
// One write on the way out, bounded by the gesture.
artistRow.phase = 0
artistRow.tickerX = 0
}
// `@keyframes artist-name-ticker`, linear:
// 0%,20% -> 0 · 70%,80% -> offset · 90%,100% -> 0.
function tickerAt(u) {
var o = artistRow.tickerOffset
if (u < 0.2)
return 0
if (u < 0.7)
return o * (u - 0.2) / 0.5
if (u < 0.8)
return o
if (u < 0.9)
return o * (1.0 - (u - 0.8) / 0.1)
return 0
}
Row {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 12
// 48px circle, `MicVocal size={20}` placeholder.
Rectangle {
width: 48
height: 48
anchors.verticalCenter: parent.verticalCenter
radius: 24
color: theme.surfaceElevated
clip: true
QbzIcon {
visible: portrait.source === ""
anchors.centerIn: parent
name: "mic-vocal"
width: 20
height: 20
tintName: "muted"
}
RoundedImage {
id: portrait
anchors.fill: parent
// `artUrl` since the card swap (owner ruling R8): the row is
// published in the house card shape now, and the old
// `imageUrl` no longer exists. The `|| imageUrl` tail is
// the same one-token seam insurance this file documents
// for the album row's field names — a row from any other
// producer still draws instead of a blank rail.
source: root.coverMap[String(artistRow.item.artUrl
|| artistRow.item.imageUrl || "")] || ""
radius: 24
}
}
Item {
id: nameClip
width: parent.width - 48 - 12
height: parent.height
clip: true
Text {
id: nameText
x: artistRow.tickerX
width: artistRow.ticking ? implicitWidth : nameClip.width
height: parent.height
text: root.artistNameOf(artistRow.item)
color: theme.textPrimary
font.pixelSize: 14
font.weight: theme.weightMedium
verticalAlignment: Text.AlignVCenter
// `text-overflow: clip` while ticking.
elide: artistRow.ticking ? Text.ElideNone : Text.ElideRight
}
}
}
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.artistSelected(artistRow.qobuzId)
}
}
}
}
}
}
// `border-right: 1px solid var(--bg-tertiary)` — the reference's rule is
// the ELEVATED surface, not a border token.
Rectangle {
x: root.railW
width: 1
height: parent.height
color: theme.surfaceElevated
}
// ========================= the albums column =========================
Item {
id: pane
// `.artist-albums-column { padding: 0 8px 0 24px }` plus the scroll
// area's own `padding-right: 8px`.
readonly property int padLeft: 24
readonly property int padRight: 16
readonly property int gridW: Math.max(root.trackMin, pane.width - pane.padLeft - pane.padRight)
readonly property int columns: Math.max(
1, Math.floor((pane.gridW + root.gridGap) / (root.trackMin + root.gridGap)))
// `auto-fill minmax(210px, 1fr)`: the TRACK stretches to 1fr, while the
// card stays 220 wide and may overflow it. 00-CONTRACT.md §6 lists that
// mismatch under "deliberately NOT diverged" — reproduce it, do not fix
// it silently.
readonly property real trackW: (pane.gridW - (pane.columns - 1) * root.gridGap) / pane.columns
// NOT `state`: every Item already owns a `state` string for QML states,
// and redeclaring it is a hard parse error.
readonly property string panelState: String(root.doc.state || "idle")
readonly property bool showIdle: root.selectedId === "" || pane.panelState === "idle"
readonly property bool showLoading: !pane.showIdle && pane.panelState === "loading"
readonly property bool showError: !pane.showIdle && pane.panelState === "error"
readonly property bool showEmpty: !pane.showIdle && pane.panelState !== "loading"
&& pane.panelState !== "error" && root.albumEntries.length === 0
readonly property bool showList: !pane.showIdle && pane.panelState !== "loading"
&& pane.panelState !== "error" && root.albumEntries.length > 0
x: root.railW + 1
y: 0
width: parent.width - x
height: parent.height
clip: true
// --- 1 · nothing selected (`MicVocal size={48}`) ------------------
// The four pre-list states share `.artist-albums-*`: a centred column,
// gap 12, 14px muted.
Column {
visible: pane.showIdle
anchors.centerIn: parent
spacing: 12
QbzIcon {
anchors.horizontalCenter: parent.horizontalCenter
name: "mic-vocal"
width: 48
height: 48
tintName: "muted"
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzSession.tr("Select an artist to see their albums", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 14
}
}
// --- 2 · loading --------------------------------------------------
Column {
visible: pane.showLoading
anchors.centerIn: parent
spacing: 12
QbzLoadingDots {
id: dots
anchors.horizontalCenter: parent.horizontalCenter
// Cycled off the shell pulse instead of a local Timer, and only
// while this column is up: ~300ms per step, three property
// writes a second, zero when idle.
property int accMs: 0
Connections {
target: QbzShell
enabled: pane.showLoading
function onPulseMsChanged() {
dots.accMs += 33
if (dots.accMs >= 300) {
dots.accMs = 0
dots.phase = (dots.phase + 1) % 3
}
}
}
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzSession.tr("Loading albums...", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 14
}
}
// --- 3 · error, with the raw detail under it ----------------------
Column {
visible: pane.showError
anchors.centerIn: parent
spacing: 12
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzSession.tr("Failed to load albums", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 14
}
// `.error-detail { font-size: 12px; opacity: 0.7 }` — the raw
// String(err), untranslated by construction.
Text {
anchors.horizontalCenter: parent.horizontalCenter
width: Math.min(implicitWidth, pane.width - 48)
text: String(root.doc.error || "")
color: theme.textMuted
opacity: 0.7
font.pixelSize: 12
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
}
// --- 4 · loaded, nothing to show (`Disc3 size={48}`) --------------
Column {
visible: pane.showEmpty
anchors.centerIn: parent
spacing: 12
QbzIcon {
anchors.horizontalCenter: parent.horizontalCenter
name: "disc-3"
width: 48
height: 48
tintName: "muted"
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzSession.tr("No albums found", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 14
}
}
// --- 5 · the three sections ---------------------------------------
ListView {
id: albumList
visible: pane.showList
x: pane.padLeft
width: pane.gridW
height: parent.height
clip: true
cacheBuffer: root.cardH
boundsBehavior: Flickable.StopAtBounds
model: root.albumEntries
onContentYChanged: root.reportAlbums()
onModelChanged: root.reportAlbums()
onHeightChanged: root.reportAlbums()
onVisibleChanged: if (visible) root.reportAlbumsSoon()
delegate: Loader {
required property var modelData
width: albumList.width
height: modelData.h
sourceComponent: modelData.t === 0 ? sectionHeadComp : albumRowComp
Component {
id: sectionHeadComp
Item {
// `align-items: baseline; gap: 12px`, then 12px of air,
// a 1px rule and 16px of margin.
Text {
id: secTitle
y: 0
text: modelData.title
color: theme.textPrimary
font.pixelSize: 16
font.weight: theme.weightSemibold
}
Text {
anchors.left: secTitle.right
anchors.leftMargin: 12
anchors.baseline: secTitle.baseline
text: modelData.count
color: theme.textMuted
font.pixelSize: 12
}
Rectangle {
y: secTitle.height + 12
width: parent.width
height: 1
color: theme.surfaceElevated
}
}
}
Component {
id: albumRowComp
Item {
Repeater {
model: modelData.albums
delegate: SceneAlbumCard {
required property var modelData
required property int index
x: index * (pane.trackW + root.gridGap)
albumId: root.albumIdOf(modelData)
title: String(modelData.title || "")
artist: String(modelData.artist || modelData.artistName || "")
genre: String(modelData.genre || "")
year: root.albumYearOf(modelData)
artSource: root.coverMap[root.albumArtOf(modelData)] || ""
bitDepth: Number(modelData.bitDepth || 0)
samplingRate: Number(modelData.samplingRate || 0)
qualityTier: String(modelData.qualityTier || "")
qualityDetail: String(modelData.qualityDetail || "")
onActivated: root.albumActivated(albumId)
onPlayed: root.albumPlayed(albumId)
}
}
}
}
}
}
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: albumList
visible: albumList.visible && albumList.contentHeight > albumList.height
}
}
// ===================== the album entry model =========================
// Flat rows so ONE ListView windows headers AND grid rows:
// { t: 0, title, count, h } | { t: 1, albums: [...], h }
// The trailing margins live in `h` rather than in ListView.spacing,
// because the three gaps differ: 22 between grid rows, 32 under a section,
// 16 under the LAST one.
property var albumEntries: []
function rebuildAlbums() {
var secs = [
{ "title": QbzSession.tr("Discography", QbzSession.trRev),
"albums": root.doc.discography || [] },
{ "title": QbzSession.tr("EPs & Singles", QbzSession.trRev),
"albums": root.doc.epsSingles || [] },
{ "title": QbzSession.tr("Live Albums", QbzSession.trRev),
"albums": root.doc.liveAlbums || [] }
]
// Fixed order, each rendered only when non-empty (V:748-813).
var live = []
for (var s = 0; s < secs.length; s++)
if (secs[s].albums.length > 0)
live.push(secs[s])
var out = []
var cols = pane.columns
for (s = 0; s < live.length; s++) {
out.push({ "t": 0, "title": live[s].title,
"count": live[s].albums.length, "h": root.sectionHeadH })
var al = live[s].albums
var rows = Math.ceil(al.length / cols)
for (var r = 0; r < rows; r++) {
var lastRow = (r === rows - 1)
var tail = lastRow ? (s === live.length - 1 ? 16 : 32) : root.gridGap
out.push({ "t": 1, "albums": al.slice(r * cols, (r + 1) * cols),
"h": root.cardH + tail })
}
}
root.albumEntries = out
}
onDocChanged: {
// A new document is a new artist: forget which covers were asked for,
// or the next pane starts with an `_asked` map full of the previous
// artist's urls and re-requests nothing it needs.
root._asked = ({})
root.rebuildAlbums()
root.reportAlbumsSoon()
}
// The column count is width-derived, so a resize re-slices the rows.
Connections {
target: pane
function onColumnsChanged() { root.rebuildAlbums() }
}
// A live language switch must re-slice too. `rebuildAlbums` calls
// `QbzSession.tr(...)` for the three section titles and stores the RESULT
// in `albumEntries`, and the delegate renders that snapshot verbatim — so
// the strings are data, not bindings, and `trRev` cannot reach them. Its
// two other triggers (a new document, a resize) may never fire again after
// the user changes language, which would leave the previous language's
// headings on screen indefinitely. Same shape as the house pattern that
// threads the revision into a builder (NavFlyout.qml:111,
// ArtistView.qml:1363).
Connections {
target: QbzSession
function onTrRevChanged() { root.rebuildAlbums() }
}
function reportAlbums() {
if (!root.visible || !albumList.visible || root.albumEntries.length === 0)
return
var first = albumList.indexAt(4, albumList.contentY + 1)
var last = albumList.indexAt(4, albumList.contentY + Math.max(1, albumList.height) - 1)
if (first < 0)
first = 0
if (last < 0)
last = Math.min(root.albumEntries.length - 1, first + 3)
// One screen of buffer either side, which is what keeps a fast scroll
// from showing placeholders — and the cap above is what keeps a fast
// scroll from queueing the whole discography.
first = Math.max(0, first - 1)
last = Math.min(root.albumEntries.length - 1, last + 1)
var urls = []
for (var i = first; i <= last; i++) {
var e = root.albumEntries[i]
if (e.t !== 1)
continue
for (var a = 0; a < e.albums.length; a++) {
var u = root.albumArtOf(e.albums[a])
if (u !== "")
urls.push(u)
}
}
root.requestCovers(urls)
}
Timer {
id: albumSettle
interval: 50
repeat: false
onTriggered: root.reportAlbums()
}
function reportAlbumsSoon() {
root.reportAlbums()
albumSettle.restart()
}
// ============================ the album card =========================
// AlbumCardLite, not cards/AlbumCard.qml: 220 wide over a 220×220 cover at
// radius 6, a 4px gap, then a meta row of the title/artist stack against
// the quality badge. The shared card is 200×246 with a pin badge, a heart,
// an award ribbon, a source badge and a nine-entry context menu — none of
// which this surface has.
//
// Declared as an inline component, which QML requires at the file's top
// level, and it reads NOTHING off an outer id: every input arrives as a
// property (the rule rows/TrackRow.qml:359-363 records).
component SceneAlbumCard: Item {
id: acard
property string albumId: ""
property string title: ""
property string artist: ""
property string genre: ""
property string year: ""
property string artSource: ""
property int bitDepth: 0
property real samplingRate: 0
/// The tier the RUST side already resolved (artist_scene_qt.rs), not a
/// number for QML to re-derive. See the badge mount below.
property string qualityTier: ""
property string qualityDetail: ""
signal activated()
signal played()
QbzTheme { id: cardTheme }
width: 220
height: 262
Rectangle {
id: cover
width: 220
height: 220
radius: 6
color: cardTheme.surfaceElevated
clip: true
RoundedImage {
anchors.fill: parent
source: acard.artSource
radius: 6
}
// `.cover-wrap::after` — a full scrim at 0.6, 150ms.
Rectangle {
anchors.fill: parent
radius: 6
color: Qt.rgba(0, 0, 0, 0.6)
opacity: coverArea.containsMouse ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
}
// `.meta` — top-left, and rendered ONLY when there is something to
// say (`{#if genre || releaseLabel}`); a card with neither shows no
// overlay text at all.
Column {
visible: acard.genre !== "" || acard.year !== ""
x: 12
y: 12
width: parent.width * 0.7
spacing: 2
opacity: coverArea.containsMouse ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
Text {
visible: acard.genre !== ""
width: parent.width
text: acard.genre
// Fixed light-on-scrim by design, like every other overlay
// in this port: the host is a 0.6 black wash under every
// theme (theme/QbzIcon.qml, "FIXED (theme-independent)").
color: Qt.rgba(1, 1, 1, 0.92)
font.pixelSize: 14
elide: Text.ElideRight
}
Text {
visible: acard.year !== ""
width: parent.width
text: acard.year
color: Qt.rgba(1, 1, 1, 0.92)
opacity: 0.8
font.pixelSize: 13
elide: Text.ElideRight
}
}
// Card-open + hover detector, before the buttons so they win the
// pointer (cards/ArtistCard.qml:172-174).
MouseArea {
id: coverArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: acard.activated()
}
// `.actions` — bottom-centre, `bottom: 44px`, so the 44px row's top
// sits at 220 - 44 - 44.
//
// ONE button, where Tauri draws three. Its Heart is inert by
// construction (this view never passes `onFavorite`), and its kebab
// opens AlbumQuickMenu with exactly one live item, "Go to album" —
// which is what a click on the card already does. The frozen mount
// API for this component carries no favourite and no menu signal,
// and 00-CONTRACT.md's DV-2 rationale ("dead UI is neither pretty
// nor functional") settles the rest. Recorded as a deviation.
CardOverlayRow {
y: 132
width: parent.width
shown: coverArea.containsMouse
CardOverlayButton {
name: "play-fill"
primary: true
anchors.verticalCenter: parent.verticalCenter
onClicked: acard.played()
}
}
}
// `.meta-row { align-items: center; gap: 8px }` over
// `.text-stack { gap: 2px }`, 4px under the cover.
Item {
y: 224
width: parent.width
height: 38
Column {
id: textStack
// The badge hides itself on an unknown tier (DV-9), and an
// invisible Item still HAS a width — so the 8px gap and the
// badge's own box only come off the stack when it is drawn.
width: parent.width - (badge.visible ? badge.width + 8 : 0)
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: acard.title
color: cardTheme.textPrimary
font.pixelSize: 14
font.weight: cardTheme.weightMedium
elide: Text.ElideRight
}
Text {
width: parent.width
text: acard.artist
color: cardTheme.textMuted
font.pixelSize: 13
elide: Text.ElideRight
}
}
// DV-9: Tauri passes no quality at all here, so QualityBadgeStatic
// falls through its tier ladder and EVERY scene album card claims
// CD. Misinformation about audio quality is load-bearing in this
// app, so the real numbers ride the row and the badge hides itself
// when the depth is unknown (the home_qt.rs:1183-1200 rule, which
// QualityBadge already implements through its own
// `visible: resolvedTier !== ""` — do NOT override that binding).
QualityBadge {
id: badge
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
// THE TIER COMES FROM RUST. Passing only the raw numbers let
// QualityBadge re-derive it in QML, and its ladder ends in
// `if (samplingRate >= 44.1) return "cd"` — so every album
// whose depth was unknown fell out as CD. That is precisely
// the reference defect DV-9 exists to remove: Tauri passed no
// quality at all and stamped a CD badge on the entire grid.
// `artist_scene_qt.rs` resolves the tier from the real depth
// and BLANKS it when the depth is unknown, and the badge hides
// itself on an empty tier — so an album we cannot vouch for
// now shows nothing rather than a confident lie.
//
// Same shape as every other card in the app
// (cards/AlbumCard.qml:687, local/LocalAlbumRow.qml:162,
// library/FeedListRow.qml:278, cards/TrackCard.qml:223).
tierOverride: acard.qualityTier
label: acard.qualityDetail
// Kept for the hover tooltip only, never for tier resolution.
bitDepth: acard.bitDepth
samplingRate: acard.samplingRate
}
}
}
}