The port of the block Slint repeats in 27 views (NavState.restore-scope /
scroll-restore / report-scroll). A .slint file cannot hand a component a
reference to a Flickable, which is why the reference inlines it; QML can, so
controls/ScrollMemory.qml exists once and 31 containers mount it.
Two divergences from the reference, both forced by the same fact — tabs are not
history entries in this port:
1. THE SCOPE IS NAMED BY THE VIEW, not derived from the entry by a scope_for
table. Slint can derive it because its entries carry the tab
(Favorites { tab }); here an entry is a bare route id, so a derived scope
would read "library" for all five Library tabs and pour the Albums grid's
offset into whatever tab mounted. Letting the reporter name its own scope
buys tab granularity for free, and the mismatch case degrades the right way:
an unmatched scope restores NOTHING instead of restoring the wrong thing.
2. THE RESTORE RE-ASSERTS instead of firing once. A Qt item view resets
contentY to 0 whenever its model is replaced, and several pages assign their
model from a binding whose inputs arrive over the network after the mount —
the album page rebuilds listCells from three deferred rails, so it hands its
ListView a new array up to four times in its first seconds and snaps back to
the top over a restore that already ran correctly. The offset is held and
re-applied until a 2.5 s deadline, or until the user takes over (a wheel, a
drag or a flick raises `moving`, and from then the position is theirs).
Diagnostics are off by default: QT_LOGGING_RULES="qbz.nav.scroll.info=true" for
the QML half, RUST_LOG=qbz=debug for the stamp/arm half.
cc
755 lines
38 KiB
QML
755 lines
38 KiB
QML
// 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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
|
// this container's offset while it is the live page, and restores it
|
|
// when a back/forward step arms this route.
|
|
ScrollMemory { target: flick; scope: "musician" }
|
|
QbzScrollBar {
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 4
|
|
anchors.top: parent.top
|
|
anchors.bottom: parent.bottom
|
|
target: flick
|
|
}
|
|
}
|
|
}
|
|
}
|