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:
37 files changed
+9203
-83
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user