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

File diff suppressed because it is too large. Load diff
+99 -7
View File
@@ -237,6 +237,46 @@ Rectangle {
readonly property var mbRelationships: network.relationships || ({})
readonly property var stories: artist.stories || []
// ---- Artist Scene: the gate BOTH doors share (contract §2.1) ---------
//
// Three terms, and dropping any one of them breaks a door:
//
// 1. `locationClickable` — computed in Rust (artist_qt.rs `map_origin`)
// so QML never re-derives the reference's unparenthesised guard. A
// country-only location has nothing to drill into.
// 2. `mbid !== ""` — and NOT `network.mbAvailable`, which is the trap.
// That flag is the user's MusicBrainz OPT-IN and nothing else
// (artist_qt.rs:982-983, whose doc comment used to claim otherwise);
// it is seeded SYNCHRONOUSLY with the first artist frame while `mbid`
// stays "" until the async publish_network lands. Gating on it alone
// lights both doors up on frame one with no id to hand them, and the
// click fires with an empty parameter.
// 3. `mbAvailable` — the MusicBrainz opt-in, which is now trustworthy at
// THIS point in time: the settings toggle republishes the open artist
// document (settings_qt.rs, "musicbrainz" arm -> republish_open_artist),
// so a stale `locationClickable: true` can no longer survive on a page
// the user navigates back to after switching the integration off.
readonly property bool sceneAvailable:
mbOrigin.locationClickable === true
&& (network.mbid || "") !== ""
&& network.mbAvailable === true
/// Both doors call THIS — never `QbzScene.open` directly — so they can
/// never drift apart in what they pass.
function openArtistScene() {
if (!sceneAvailable)
return
QbzScene.open(network.mbid || "",
mbOrigin.artistName || artist.name || "",
mbOrigin.locationAreaId || "",
mbOrigin.locationCity || "",
mbOrigin.locationCountry || "",
mbOrigin.locationCountryCode || "",
mbOrigin.locationPrecision || "",
(mbOrigin.seedGenres || []).join(","),
(mbOrigin.seedTags || []).join(","))
}
property var coverMap: ({})
property string activeJumpTab: "popular-tracks"
property string artistTab: "catalog"
@@ -1489,8 +1529,14 @@ Rectangle {
// One "KEY value" row of the MB Origin block.
component OriginRow: Item {
id: originRow
property string key: ""
property string value: ""
/// When true the value renders as a link and emits `activated`.
/// Only the location row ever sets it (door D1 to the Artist Scene);
/// born/founded/died/disbanded stay plain text, as in the reference.
property bool clickable: false
signal activated()
// The host section Column carries 14px of left+right padding, and a
// QML Positioner does NOT shrink its children for it — a right-aligned
// value bound to the bare parent.width would run past the sidebar
@@ -1508,16 +1554,38 @@ Rectangle {
font.letterSpacing: 0.5
}
Text {
id: originValue
anchors.right: parent.right
anchors.left: originKey.right
anchors.leftMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: value
color: theme.textPrimary
// The reference's `.origin-location-link` is
// `background:none; border:none; padding:0; font-size:13px;
// color:var(--accent-primary)` with `text-decoration:underline;
// text-underline-offset:2px` on hover
// (ArtistDetailView.svelte:3365-3379). Same 13px as the plain
// rows, so only the colour and the underline change.
color: originRow.clickable ? theme.accent : theme.textPrimary
font.pixelSize: 13
font.underline: originRow.clickable && originLink.containsMouse
horizontalAlignment: Text.AlignRight
elide: Text.ElideRight
}
MouseArea {
id: originLink
// Only the text, not the whole row — the key ("BORN IN") is not
// part of the link in the reference either.
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
width: Math.min(originValue.implicitWidth, originValue.width)
enabled: originRow.clickable
visible: originRow.clickable
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: originRow.activated()
}
}
// One MB relationship sub-group (Members & Former / Member Of /
@@ -1557,7 +1625,20 @@ Rectangle {
// otherwise) — landing the user on a same-name artist is worse
// than the row doing nothing.
navigable: true
onClicked: QbzArtist.resolveMusician(modelData.name, relGroup.roleKey || "")
// `modelData.role`, NOT `relGroup.roleKey`. Rust already built
// the right string per ROW: `group_relations` seeds each group
// with the reference's own default ("Band Member" / "Band" /
// "Collaborator") and then prefers that person's actual first
// credited role (artist_qt.rs `map_relationships`), which is
// exactly what the reference sends
// (ArtistDetailView.svelte:3015,3033,3046). Sending `roleKey`
// instead threw all of that away and shipped a per-GROUP
// lowercase MusicBrainz key shared by every row — and for the
// middle group it is not even a casing difference, it sent
// "member of" where the reference sends "Band". The role is
// echoed back on every appearance card, so it was visible.
onClicked: QbzArtist.resolveMusician(modelData.name,
modelData.role || relGroup.roleKey || "")
}
}
Text {
@@ -2854,11 +2935,13 @@ Rectangle {
visible: network.originLoading !== true && (mbOrigin.locationDisplay || "") !== ""
key: mbOrigin.isPerson ? QbzSession.tr("BORN IN", QbzSession.trRev)
: QbzSession.tr("FOUNDED IN", QbzSession.trRev)
// POC-NOTE: the Slint row is clickable and opens
// ArtistsByLocationView; that view has no port
// here, so the affordance is left out rather than
// rendered dead.
// DOOR D1 to the Artist Scene, and the reference's
// ONLY door (ArtistDetailView.svelte:2904-2935).
// Was plain text under a POC-NOTE until the scene
// view existed to receive it.
value: mbOrigin.locationDisplay || ""
clickable: root.sceneAvailable
onActivated: root.openArtistScene()
}
OriginRow {
visible: network.originLoading !== true && (mbOrigin.endDate || "") !== ""
@@ -3163,7 +3246,14 @@ Rectangle {
// and the menu keeps its shape.
model: [
{ "label": QbzSession.tr("Create Artist Collection", QbzSession.trRev), "icon": "library-big", "action": "disco", "live": true },
{ "label": QbzSession.tr("Artist Scene", QbzSession.trRev), "icon": "map-pin", "action": "stub", "live": false },
// DOOR D2. This row rendered at opacity 0.4 with
// `action: "stub"` — the track's named defect class, a
// control that renders, persists and drives nothing. It is
// a Slint-era invention with no counterpart in the visual
// reference (kept on the owner's explicit instruction,
// contract ruling R4), and it shares D1's gate exactly so
// the two doors can never disagree about availability.
{ "label": QbzSession.tr("Artist Scene", QbzSession.trRev), "icon": "map-pin", "action": "scene", "live": root.sceneAvailable },
{ "label": QbzSession.tr("Share", QbzSession.trRev), "icon": "link", "action": "share", "live": true },
{ "label": root.toggleState("artistPin", artist.isPinned) ? QbzSession.tr("Unpin", QbzSession.trRev) : QbzSession.tr("Pin", QbzSession.trRev), "icon": root.toggleState("artistPin", artist.isPinned) ? "pin-filled" : "pin", "action": "pin", "live": true },
]
@@ -3200,6 +3290,8 @@ Rectangle {
if (modelData.action === "pin") {
root.setToggleState("artistPin", !root.toggleState("artistPin", artist.isPinned))
QbzLibrary.togglePin("artist", artist.id, artist.name, "", artist.artUrl)
} else if (modelData.action === "scene") {
root.openArtistScene()
} else if (modelData.action === "disco") {
// Discography Builder — ArtistPageView.slint
// :505-511 `media-action("artist", id,
@@ -0,0 +1,750 @@
// Musician page — QML port of Tauri's MusicianPageView.svelte (469 lines, read
// in full for this port). The surface a CONTEXTUAL musician click lands on: a
// name, a role, and a grid of Qobuz albums whose title/artist matched the name.
//
// THE REFERENCE IS TAURI, NOT SLINT, for this module only (00-CONTRACT §0, the
// owner's ruling). Slint's musician page is cited nowhere below; where the two
// disagree Tauri wins, and the Slint page's own defects (no error state, so a
// network failure renders "No album appearances found."; a downsized 18px name)
// are not reproduced.
//
// Layout, read off the .svelte (every number is that file's):
// :216-226 sticky header — padding 24/24/16 over a 56px row, 1px bottom
// border, background bg-primary
// :253-263 56x56 circular avatar, surface-elevated, User glyph 32, muted.
// There is NO photo, ever: ResolvedMusician has no image field
// (02-tauri-musician.md §1.3)
// :271-283 name h1 24/700, role 14 muted CAPITALIZED
// :285-311 content padding 24; section margin-bottom 32; section title
// 18/600 with a 14/400 muted "(N)" count
// :314-344 Bands & Projects — chips, flex-wrap, gap 10, padding 10/16
// :347-351 Appears On grid: repeat(auto-fill, minmax(160px, 1fr)), gap 20 —
// a STRETCHING grid, so the cell width is computed, not fixed
// :353-426 card: artwork 1:1 radius 8 (no shadow), title 14/500, artist
// 12 muted, meta row gap 8 margin-top 4 = date + role badge
// :429-440 the three state boxes: column, centred, gap 12, padding 48,
// muted 14
// :443-468 Load more: 10/24 padded button, surface-elevated, radius 8,
// disabled at 0.6
//
// WHAT THIS PAGE DELIBERATELY DIVERGES ON (00-CONTRACT §6, do not "fix" back):
// - DV-15 NO per-view back button. Tauri draws a 36px .back-btn at the head
// of its sticky header; in this port back/forward live permanently in
// the shell HeaderBar, mounted above the router. The header stack
// therefore starts at x=40 — Tauri's 24px padding plus the 16px gap
// that sat between the button and the avatar, with the 36px button
// itself removed. Same arithmetic ArtistReleasesView.qml:27-31 uses
// for its x=48 (32 + 16).
// - DV-19 the role badge (.role-badge, Tauri radius 10) drops to Radius.sm.
// Size, padding, type and colour are Tauri's to the pixel; only the
// corner changes. ADR-008 is Active and scoped to "any future UI
// surface", and a 10px label in a 2/8 box at radius 10 is that ADR's
// literal definition of a pill (owner ruling R5).
// - §5.4 `releaseDate` renders IN FULL — "1994-09-27", not "1994". Tauri's
// field is named `year` and its CSS class is `.year`, but the backend
// assigns `release_date_original` verbatim and the page prints it
// raw. Both backends do this deliberately. Truncating it would be
// inference from a field NAME, which this contract forbids.
// - §5.2 the page is FULLY KEYBOARD-OPERABLE. The Tauri spec's "no keyboard
// handling, no focus management" is true of its BINDINGS and false of
// its BEHAVIOUR: every control there is a native <button> and gets tab
// order, Enter/Space and a focus ring from the browser for free. QML
// grants none of it, so bare MouseAreas here would be a regression
// against the reference, not parity. Every control below is an
// activeFocusOnTab Item with an Enter/Space handler and a focus ring.
// Non-activation keys are left unaccepted so they keep bubbling up to
// the AppShell dispatcher (AppShell.qml:132-139) and the global
// hotkeys still fire under this page.
//
// INHERITED TAURI BEHAVIOUR, kept on purpose (02-tauri-musician.md §1.4, §3.2):
// - `roleOnAlbum` is the SAME string on every card — the backend echoes the
// role the user clicked with (`role.clone()` on every row). It is honest:
// per-album credit data does not exist.
// - `total` is the raw Qobuz album-SEARCH total, which routinely exceeds what
// pagination returns, so Load more can stay armed after the last real page.
// That is the reference's behaviour, not a defect introduced here.
//
// LOAD MORE is drawn inline rather than through controls/QbzLoadMore.qml: that
// control ships exactly two shapes (plain centred text, and Search's bordered
// pill — its own header says "the arms are `bordered` and nothing else"), and
// Tauri's is a third, a filled surface-elevated 10/24 button. Under a visual
// 1:1 mandate the button wins over the reuse.
//
// ANIMATION: the only motion on this page is the card's 150ms hover lift, a
// gesture-bounded Behavior, which is the explicitly allowed class (the GPU
// pulse law, GPU-COST-INVESTIGATION.md §9, and cards/ArtistCard.qml:170,231).
// There is NO spinner and NO skeleton here — Tauri's loading state is a single
// line of muted text, which happens to also be the cheapest possible answer.
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../theme"
Rectangle {
id: root
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: theme.ambientOn
radius: 12
QbzTheme { id: theme }
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
// ONE document, re-parsed on every publish. `musicianRev` is a pure binding
// dependency, exactly like the `rev` argument of QbzSession.tr(msgid, rev):
// the parse ignores it, reading it is what makes this binding re-run when
// Rust republishes a document that happens to be byte-identical.
readonly property var doc: root._parse(QbzMusician.musicianJson,
QbzMusician.musicianRev)
function _parse(s, rev) {
try {
return JSON.parse(s)
} catch (e) {
return ({})
}
}
readonly property var appearances: doc.appearances || []
readonly property var bands: doc.bands || []
readonly property int total: doc.total || 0
// CSS `text-transform: capitalize` — title-case EVERY word, and leave the
// rest of each word alone (it is not "uppercase the first letter"). Tauri
// renders "lead guitar" as "Lead Guitar" and "member of" as "Member Of",
// and the role string is user-visible in two places here plus every badge.
// QML has no text-transform, so it happens on the string.
function _capitalize(s) {
if (!s)
return ""
return String(s).replace(/(^|\s)(\S)/g, function (m, sp, ch) {
return sp + ch.toUpperCase()
})
}
// Body-state precedence, in Tauri's own branch order (:143-204): loading,
// then error, then empty, then the grid. `state` is the document's, so the
// three are mutually exclusive by construction.
readonly property bool isLoading: doc.state === "loading"
readonly property bool isError: doc.state === "error"
readonly property bool isEmpty: !isLoading && !isError && appearances.length === 0
// The keyboard focus ring. Drawn by the control, not by the theme: nothing
// in this tree had one before (grep: theme.focusRing had ONE consumer,
// LogViewerModal.qml:244's search field), and a control that can take Tab
// focus without showing it is worse than one that cannot take it at all.
component FocusRing: Rectangle {
property Item host: null
property real inset: 3
anchors.fill: parent
anchors.margins: -inset
color: "transparent"
border.width: 2
border.color: theme.focusRing
visible: host !== null && host.activeFocus
z: 5
}
// ============================ offline gate ============================
// Every page in this port carries it (ArtistReleasesView.qml:100-111 is the
// model) and this one is 100% network-fed: offline it could only ever paint
// its error state.
QbzOfflinePlaceholder {
visible: QbzSession.offline
anchors.centerIn: parent
showSettingsAction: true
onSettingsClicked: QbzShell.navigateTo("settings")
}
Column {
anchors.fill: parent
spacing: 0
visible: !QbzSession.offline
// --- Sticky header (.page-header, :216-226) ------------------------
// 24 top + 56 row + 16 bottom + the 1px rule = 97. Tauri makes it
// `position: sticky`; here it is simply a fixed sibling above the
// scroller, which is the same thing from scroll 0 onwards and is the
// shape every fixed-header view in this port uses.
//
// NOT ported: `data-tauri-drag-region="deep"`. Window dragging in this
// port belongs to the shell titlebar (HeaderBar), and no view header
// installs a DragHandler of its own — adding one here would be a new
// behaviour, not parity.
Item {
id: header
width: parent.width
height: 97
Rectangle {
anchors.fill: parent
// A full-bleed pane child at y=0 rounds ITSELF: Qt's `clip` is
// a rectangular scissor that ignores `radius`, so the content
// pane's own rounding cannot reach this bar (AppShell.qml says
// "there is no mask that can do it for it here").
topLeftRadius: theme.radiusMd
topRightRadius: theme.radiusMd
color: root.ambientOn ? theme.surfaceMainA30 : theme.surfaceMain
}
// .musician-header: flex, align-items center, gap 16.
Row {
x: 40
y: 24
height: 56
width: Math.max(0, header.width - 40 - 24)
spacing: 16
// .musician-avatar — 56px circle, surface-elevated, muted User
// glyph at 32. No photo branch exists: there is no image field.
Rectangle {
width: 56
height: 56
radius: 28
color: theme.surfaceElevated
QbzIcon {
anchors.centerIn: parent
name: "user"
width: 32
height: 32
tintName: "muted"
}
}
// .musician-info — column, gap 4.
Column {
width: parent.width - 56 - 16
anchors.verticalCenter: parent.verticalCenter
spacing: 4
Text {
width: parent.width
text: root.doc.name || ""
color: theme.textPrimary
font.pixelSize: 24
font.weight: theme.weightBold
elide: Text.ElideRight
}
Text {
width: parent.width
// SERVER TEXT, capitalized by CSS in the reference.
text: root._capitalize(root.doc.role || "")
color: theme.textMuted
font.pixelSize: 14
elide: Text.ElideRight
}
}
}
Rectangle {
anchors.bottom: parent.bottom
width: parent.width
height: 1
color: theme.borderSubtle
}
}
// --- Scrolling content (.page-content, padding 24) -----------------
Item {
width: parent.width
height: parent.height - header.height
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
// Tauri's own padding is a flat 24; the extra 76 is this
// port's shared bottom inset so the last row clears the
// NowPlayingBar (every view uses 100).
bottomPadding: 100
spacing: 0
readonly property real contentWidth: width - 48
// ======== Section 1 — Bands & Projects (:112-132) ======
// DEAD in Tauri (`bands` is hardcoded empty on both return
// paths of the resolver, in BOTH backends) and made real
// here: DV-2 feeds it from the MusicBrainz relationship
// `groups`. The section is ABSENT, not empty, when there is
// nothing to show — no spinner, no empty frame, no error
// frame (00-CONTRACT §5.3's state table).
Column {
visible: root.bands.length > 0
width: page.contentWidth
spacing: 16
bottomPadding: 32
Text {
text: root.t("Bands & Projects")
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightSemibold
}
// .bands-grid — flex-wrap, gap 10.
Flow {
width: parent.width
spacing: 10
Repeater {
model: root.bands
delegate: Item {
id: bandCell
required property var modelData
// Inert-but-styled when the group did not
// resolve to an MBID (00-CONTRACT §5.3):
// Tauri's chip only console.logs a TODO, so
// a dead chip is still 1:1 — a LIVE one is
// the improvement, and it needs an id.
readonly property bool live:
(modelData.mbid || "") !== ""
width: bandChip.width
height: bandChip.height
activeFocusOnTab: live
Keys.onPressed: function (event) {
if (bandCell.live
&& (event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter
|| event.key === Qt.Key_Space)) {
bandCell.open()
event.accepted = true
}
}
function open() {
if (bandCell.live)
QbzMusician.openBand(bandCell.modelData.mbid)
}
// .band-card — 10/16 padding, gap 10,
// surface-elevated, 14px. Tauri's radius is
// 20 (a pill); DV-19 drops it to Radius.sm.
Rectangle {
id: bandChip
width: bandRow.width + 32
height: bandRow.height + 20
radius: theme.radiusSm
color: bandArea.containsMouse
? theme.bgHover : theme.surfaceElevated
Row {
id: bandRow
anchors.centerIn: parent
spacing: 10
QbzIcon {
anchors.verticalCenter: parent.verticalCenter
name: "music"
width: 16
height: 16
tintName: "muted"
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: bandCell.modelData.name || ""
color: theme.textPrimary
font.pixelSize: 14
font.weight: theme.weightMedium
}
}
MouseArea {
id: bandArea
anchors.fill: parent
hoverEnabled: true
cursorShape: bandCell.live
? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: bandCell.open()
}
}
FocusRing {
host: bandCell
radius: theme.radiusSm + 3
}
}
}
}
}
// ======== Section 2 — Appears On (:135-205) ============
// spacing 0, and the title carries its own 16px bottom
// margin: the four bodies and the Load-more block are
// SIBLINGS of the grid in the reference, with no gap of
// their own beyond their padding (`.load-more` is
// `padding: 24px 0`, not 24 on top of a flex gap).
Column {
width: page.contentWidth
spacing: 0
// .section-title + the 14/400 muted count, margin
// 0 0 16.
Row {
spacing: 8
bottomPadding: 16
Text {
id: appearsTitle
text: root.t("Appears On")
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightSemibold
}
// `.section-title` is `align-items: center`, so the
// count rides the title's vertical centre, not its
// baseline.
Text {
visible: root.total > 0
anchors.verticalCenter: appearsTitle.verticalCenter
text: "(" + root.total + ")"
color: theme.textMuted
font.pixelSize: 14
}
}
// --- State 1: loading (:143-146) ------------------
// Text only — the reference mounts no spinner here.
// The msgid is the catalogue's "Loading…" (U+2026), not
// Tauri's ASCII "Loading...": 00-CONTRACT §7 rules that
// an existing msgid is reused rather than adding a
// near-twin to eight catalogues over one glyph.
Item {
visible: root.isLoading
width: parent.width
height: 48 * 2 + loadingLbl.height
Text {
id: loadingLbl
anchors.centerIn: parent
text: root.t("Loading…")
color: theme.textMuted
font.pixelSize: 14
}
}
// --- State 2: error (:147-150) --------------------
// Tauri renders its `error` state variable, which it
// only ever sets to this one hardcoded English string.
// Here the document may carry a real message; the
// translated literal is the fallback so the box is
// never blank.
Item {
visible: root.isError
width: parent.width
height: 48 * 2 + errorLbl.height
Text {
id: errorLbl
anchors.centerIn: parent
width: parent.width
text: (root.doc.error || "") !== ""
? root.doc.error
: root.t("Failed to load appearances")
color: theme.textMuted
font.pixelSize: 14
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
}
// --- State 3: empty (:151-155) --------------------
// Disc glyph 32 over the line, gap 12, padding 48.
// Msgid reused from the catalogue WITH its trailing
// period (Tauri's has none) — §7 again.
Column {
visible: root.isEmpty
width: parent.width
topPadding: 48
bottomPadding: 48
spacing: 12
QbzIcon {
anchors.horizontalCenter: parent.horizontalCenter
name: "disc"
width: 32
height: 32
tintName: "muted"
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: root.t("No album appearances found.")
color: theme.textMuted
font.pixelSize: 14
}
}
// --- The grid (:156-204) --------------------------
// `repeat(auto-fill, minmax(160px, 1fr))` with gap 20:
// as many 160px-minimum tracks as fit, then every track
// STRETCHES to share the row. So the column count and
// the cell width are computed, not fixed — this is not
// one of the port's fixed-pitch card grids.
Grid {
id: grid
visible: !root.isLoading && !root.isError
&& root.appearances.length > 0
width: parent.width
spacing: 20
columns: Math.max(1, Math.floor((width + spacing)
/ (160 + spacing)))
readonly property real cellW:
(width - (columns - 1) * spacing) / columns
Repeater {
model: root.appearances
delegate: Item {
id: cell
required property var modelData
width: grid.cellW
height: cardCol.implicitHeight
activeFocusOnTab: true
Keys.onPressed: function (event) {
if (event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter
|| event.key === Qt.Key_Space) {
cell.open()
event.accepted = true
}
}
function open() {
var id = cell.modelData.albumId || ""
if (id !== "")
QbzMusician.openAlbum(id)
}
// .album-card — column, gap 12, hover
// translateY(-4px) over 150ms. A Behavior
// bounded by a pointer gesture is the
// allowed class under the pulse law; the
// lift is on the CONTENT, not on the cell,
// because the Grid owns the cell's y.
Column {
id: cardCol
width: parent.width
y: cardArea.containsMouse ? -4 : 0
spacing: 12
Behavior on y {
NumberAnimation { duration: 150 }
}
// .album-artwork — 1:1, radius 8, on a
// surface-elevated bed. NO shadow.
Rectangle {
width: parent.width
height: width
radius: theme.radiusSm
color: theme.surfaceElevated
clip: true
// `artPath`, NOT `artworkUrl`.
// The remote url is a Rust-side
// key; the cover that exists on
// disk is what an Image can draw,
// and musician_qt.rs:100-109 says
// in as many words that a delegate
// binding `artworkUrl` into a
// source "draws nothing". The
// field is ADDITIVE to the frozen
// §13.2 shape and its two-pass
// fill needs no QML plumbing.
RoundedImage {
id: cover
anchors.fill: parent
source: cell.modelData.artPath || ""
radius: theme.radiusSm
}
// The placeholder branch is LIVE,
// not defensive: album_artwork is
// `.unwrap_or_default()`, so an
// empty cover is an expected value.
// It doubles as the pre-download
// state, which is what Tauri's
// `<img loading="lazy">` shows
// there anyway. `ready` is the
// shared handover signal — true
// only once THIS source is on
// screen (RoundedImage.qml:247-259).
QbzIcon {
anchors.centerIn: parent
visible: !cover.ready
name: "disc"
width: 24
height: 24
tintName: "muted"
}
}
// .album-info — column, gap 4.
Column {
width: parent.width
spacing: 4
Text {
width: parent.width
text: cell.modelData.title || ""
color: theme.textPrimary
font.pixelSize: 14
font.weight: theme.weightMedium
elide: Text.ElideRight
}
Text {
width: parent.width
text: cell.modelData.artistName || ""
color: theme.textMuted
font.pixelSize: 12
elide: Text.ElideRight
}
// .album-meta — row, gap 8,
// margin-top 4.
Row {
width: parent.width
topPadding: 4
spacing: 8
// The FULL ISO date, verbatim
// (§5.4). Not a year.
Text {
visible: (cell.modelData.releaseDate || "") !== ""
anchors.verticalCenter: parent.verticalCenter
text: cell.modelData.releaseDate || ""
color: theme.textMuted
font.pixelSize: 11
}
// .role-badge — ALWAYS drawn,
// 10px on 2/8 padding. Tauri's
// radius 10 -> Radius.sm
// (DV-19). Same string on every
// card by design.
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: badgeLbl.implicitWidth + 16
height: badgeLbl.implicitHeight + 4
radius: theme.radiusSm
color: theme.surfaceElevated
Text {
id: badgeLbl
anchors.centerIn: parent
text: root._capitalize(
cell.modelData.roleOnAlbum || "")
color: theme.textSecondary
font.pixelSize: 10
}
}
}
}
}
MouseArea {
id: cardArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: cell.open()
}
FocusRing {
host: cell
radius: theme.radiusSm + 3
}
}
}
}
// --- Load more (:193-203) ------------------------
// Only when hasMore. Disabled while a page is in
// flight, label swaps to the busy msgid.
Item {
visible: root.doc.hasMore === true
&& !root.isLoading && !root.isError
width: parent.width
height: 24 * 2 + 40
Item {
id: moreCell
anchors.centerIn: parent
width: moreBtn.width
height: moreBtn.height
activeFocusOnTab: root.doc.loadingMore !== true
Keys.onPressed: function (event) {
if (root.doc.loadingMore !== true
&& (event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter
|| event.key === Qt.Key_Space)) {
QbzMusician.loadMore()
event.accepted = true
}
}
Rectangle {
id: moreBtn
width: moreLbl.implicitWidth + 48
height: moreLbl.implicitHeight + 20
radius: theme.radiusSm
opacity: root.doc.loadingMore === true ? 0.6 : 1.0
color: moreArea.containsMouse
&& root.doc.loadingMore !== true
? theme.bgHover : theme.surfaceElevated
Text {
id: moreLbl
anchors.centerIn: parent
// Both msgids already exist in all
// eight catalogues; Tauri's "Load More"
// capital M is not worth a ninth entry
// (§7).
text: root.doc.loadingMore === true
? root.t("Loading…") : root.t("Load more")
color: theme.textPrimary
font.pixelSize: 14
font.weight: theme.weightMedium
}
MouseArea {
id: moreArea
anchors.fill: parent
hoverEnabled: true
cursorShape: root.doc.loadingMore === true
? Qt.ArrowCursor : Qt.PointingHandCursor
onClicked: {
if (root.doc.loadingMore !== true)
QbzMusician.loadMore()
}
}
}
FocusRing {
host: moreCell
radius: theme.radiusSm + 3
}
}
}
}
}
}
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: flick
}
}
}
}
@@ -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
}
}
}
}