Files
qbz/crates/qbz-qt/qml/views/ArtistReleasesView.qml
T
vicrodh ec8588687b feat(qt): artist page parity - load more, section sort, discography
The release-section "Load more" fetched a page and appended it to a structure
no view ever re-read. The handler pushed IN PLACE into the object behind
`readonly property var artist: JSON.parse(QbzArtist.artistJson)`, and the
releaseSections projection returned that same array on the unfiltered arm, so
both Repeaters were re-handed the identical reference and nothing was dirty.
The tree's own rule, written in cards/PlaylistCollage.qml: rebind requires a
NEW object reference. A second bug sat on the same path - the artist document
republishes progressively (stories, then each MusicBrainz section as it
resolves) and every republish re-runs the parse, wiping anything appended, so
fixing only the first would have left a bug that looks random. Both
pre-existing; the in-flight placeholder added earlier is what made an
always-silent no-op visible.

Appended pages now live in an overlay keyed by release type. It survives a
republish, emits new arrays so the Repeaters rebuild, dedupes by album id,
carries the reference's 4-page cap per bucket (artist.rs:718-720, which hands
off to the discography page beyond it), and is guarded against a page
requested on one artist landing on another's grid - releaseSectionReady
carries no artist id and every artist has an "album" bucket.

"See discography" was a pointer-cursor MouseArea with no onClicked under a
POC-NOTE saying the page was out of scope. ArtistReleasesView.qml +
artist_releases_qt.rs port ArtistReleasesView.slint: infinite scroll rather
than a button, plus the loading, error-with-retry, empty and offline arms.
The album page's "More from <artist>" view-all is a second door to the same
page. Its paging offset counts what the SERVER returned rather than the
rendered row count the reference uses: blacklist filtering and id dedupe make
the rendered count smaller, so the reference re-requests seen rows and skips
the tail whenever the blacklist is non-empty.

The per-section sort was a dimmed Rectangle with no MouseArea, under a
comment asserting no seam existed. It is now a real QbzSelect with the
reference's five options and index mapping, persisted per release type in
artist_prefs. Choosing "Default" after a sort does not restore the server
order until the page reloads - that is the reference's behaviour
(album_map.rs:262 is a bare no-op arm and resort sorts the live model), kept
deliberately rather than diverged.

Also, two visual fixes from the same round that share these files: Popular
Tracks drew its cover at radius 8 where the shared rows/TrackRow.qml uses 4,
and the album page's rail mounts said `width: parent.width` inside a Column
with 32px padding - QML padding shifts children without narrowing
parent.width, so the rails hung 32px off the right and the right page chevron
landed outside the window.

Carries scripts/qt-diag.sh, which has been sitting untracked since the
rendering round: it answers "which backend is this actually running on" from
a real graphical session, refusing ssh and refusing offscreen because either
would answer the wrong question. That refusal is the point - the doctrine it
disproved ("shader effects render nothing on this port's software path") came
from an offscreen observation, and offscreen forces software by definition.

cc
2026-08-02 16:34:58 -06:00

341 lines
16 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Artist discography page — QML port of artist/ArtistReleasesView.slint (257
// lines, read in full for this port): the full, paginated grid for ONE release
// bucket of one artist, reached from the artist page's "See discography" link
// and from the album page's "From the same artist" View all.
//
// (Before this file existed, "See discography" was a fully lit, pointer-cursor
// Text with an EMPTY MouseArea under a POC-NOTE saying the page was out of
// scope. That note and that emptiness are both gone.)
//
// Layout, read off the .slint:
// :36-103 fixed 56px header — artist kicker + bucket title LEFT, the sort
// select RIGHT, both vertically centred on y=25
// :131-138 body Column padding 32 / 32, top 8, bottom 100, spacing 0
// :121-129 infinite scroll: within 600px of the bottom, has-more, and
// neither a first page nor a tail page already in flight
// :140-203 THREE mutually exclusive body states — loading, error+Retry,
// empty — then the grid, then the tail spinner
// :205-231 200x266 cards, gap 24, view-mode hard-wired "grid"
// :242-256 scrollbar below the header
//
// WHAT THIS PAGE DELIBERATELY DOES NOT HAVE (checked against the .slint, not
// against its cousins): no search field, no group-by, no Hi-Res filter, no
// grid/list toggle, and no "Load more" button. LabelReleasesView has all five;
// this is a sibling of DiscoverBrowseView, not of that page. One sort select is
// the entire toolbar.
//
// NAV BUTTONS: the .slint draws a NavButtons pair at x=32 and starts its text
// stack 16px after it. In this port back/forward live in the shell header
// (HeaderBar.qml:251-266), so the stack starts at x=48 — the 32 + 0 + 16 that
// DiscoverBrowseView.qml:14-18 already establishes as the convention. The back
// affordance is present, it is just drawn once for the whole app.
//
// OFFLINE: shell/AppShell.slint:503-511 mounts the whole view under
// `!OfflineState.offline`, so it simply does not exist offline. This port has
// no per-route unmount, so the page honours the same gate with the seam it does
// have — QbzOfflinePlaceholder over a hidden body — and `artist_releases_qt::
// open` refuses to navigate here offline in the first place.
//
// POC-NOTEs (pre-existing and port-wide, NOT deferrals introduced here):
// - no scroll restore. The .slint restores NavState.scroll-restore under the
// "artist-releases" scope (:110-119); this port has no counterpart to
// NavState.report-scroll / restore-scope on ANY page (DiscoverBrowseView.qml
// :20-21 records the same gap). Half a mechanism would be worse than none.
// - back/forward re-mounts this view against whatever document is still on
// QbzArtist.artistReleasesJson rather than re-fetching, because the port's
// nav stack stores no payload (nav_qt.rs:9-23). That is exactly how
// `labelreleases` already behaves; the Rust generation guard is what keeps a
// late page from painting into the wrong bucket.
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
import "../controls"
import "../theme"
Rectangle {
id: root
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
radius: 12
QbzTheme { id: theme }
// ONE document, re-parsed on every publish — a fresh string is a fresh
// object, so the Repeaters below always see a NEW reference ("Rebind
// requires a NEW object reference", cards/PlaylistCollage.qml). Nothing in
// this file ever mutates `doc` in place.
readonly property var doc: {
try {
return JSON.parse(QbzArtist.artistReleasesJson)
} catch (e) {
return {}
}
}
readonly property var albums: doc.albums || []
// .slint:85-99 — the five options in the reference's order, and the index
// <-> wire-key round trip. "A–Z"/"Z–A" carry a U+2013 EN DASH: the msgid in
// the catalogue is the en-dash form, and a hyphen would go untranslated in
// all seven non-English locales. (LibraryToolbar's "Title A-Z" is a
// DIFFERENT, hyphenated msgid — do not copy it here.)
readonly property var sortLabels: [
QbzSession.tr("Default", QbzSession.trRev),
QbzSession.tr("Newest", QbzSession.trRev),
QbzSession.tr("Oldest", QbzSession.trRev),
QbzSession.tr("A–Z", QbzSession.trRev),
QbzSession.tr("Z–A", QbzSession.trRev),
]
// These five strings are BOTH what gets persisted (artist_prefs.rs, the
// store the artist page's per-section picker shares) and what the sort
// functions switch on, on either side of the bridge.
readonly property var sortKeys: ["default", "newest", "oldest", "title-asc", "title-desc"]
// .slint:24-28. A BINDING, never an assignment: QbzSelect does not
// self-assign currentIndex (QbzSelect.qml:299-300), and this is what seats
// the picker on the sort the page opened with — which is the user's
// persisted choice for this bucket, not "Default".
readonly property int sortIndex: Math.max(0, root.sortKeys.indexOf(root.doc.sortBy || "default"))
// ============================ offline gate ============================
QbzOfflinePlaceholder {
visible: QbzSession.offline
anchors.centerIn: parent
showSettingsAction: true
onSettingsClicked: QbzShell.navigateTo("settings")
}
Column {
anchors.fill: parent
spacing: 0
visible: !QbzSession.offline
// --- Fixed 56px header -------------------------------------------
Item {
id: header
width: parent.width
height: 56
Rectangle {
anchors.fill: parent
color: root.ambientOn ? theme.surfaceMainA30 : theme.surfaceMain
}
// .slint:47-70 — a COLUMN, spacing 2. (A Row would top-align its
// children; this stack is vertical precisely so the kicker sits on
// the title.) `y: 25 - height/2` is the header's centring
// convention — 25, not 28, and it is the .slint's own value at :43.
Column {
x: 48
y: 25 - height / 2
spacing: 2
// The artist name. SERVER TEXT — never translated (.slint:54
// says so in as many words).
Text {
text: root.doc.name || ""
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 0.6
elide: Text.ElideRight
width: Math.max(0, sortSel.x - 48 - 16)
}
// The bucket title, translated in RUST
// (artist_qt::release_type_title) because it travels inside the
// JSON document — hence artist_releases_qt::republish on a
// language change.
//
// The width cap is not decoration: "Compilations" in German
// against a narrow window would otherwise run under the sort
// select, which draws after it and therefore on top of it.
Text {
text: root.doc.title || ""
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
elide: Text.ElideRight
width: Math.max(0, sortSel.x - 48 - 16)
}
}
// .slint:74-102. `sm: true` + `menuWidth: 150` are the reference's
// own values verbatim: QbzSelect.qml:34 is `width = menuWidth - 40`
// under `sm`, so 150 lands at the 110 x 30 the reference computes
// (QbzSelect.slint:88). Passing 190 "to get 150" is the exact
// mistake that file's :42-50 note documents.
QbzSelect {
id: sortSel
x: parent.width - width - 32
y: 25 - height / 2
sm: true
menuWidth: 150
options: root.sortLabels
currentIndex: root.sortIndex
onSelected: function (i) {
QbzArtist.releasesSetSort(root.sortKeys[i] || "default")
}
}
}
// --- Scrolling grid ------------------------------------------------
Item {
width: parent.width
height: parent.height - 56
Flickable {
id: flick
anchors.fill: parent
// A rectangular scissor — it ignores `radius`. The root's own
// radius-12 fill is what rounds this pane to the AppShell bezel.
clip: true
contentWidth: width
contentHeight: page.implicitHeight
boundsBehavior: Flickable.StopAtBounds
// Infinite scroll, guarded exactly as the .slint is (:123-126).
// There is NO "Load more" button on this page.
onContentYChanged: {
if (contentY + height >= contentHeight - 600
&& root.doc.hasMore === true
&& root.doc.loadMoreLoading !== true
&& root.doc.loading !== true) {
// Snapshot the ids already on screen so ONLY the page
// that lands fades in. BEFORE the bridge call, which
// may republish synchronously
// (LabelReleasesView.qml:327's rule).
collection.armTailFade()
QbzArtist.releasesLoadMore()
}
}
Column {
id: page
width: parent.width
leftPadding: 32
rightPadding: 32
topPadding: 8
bottomPadding: 100
spacing: 0
// --- State 1: first page in flight (.slint:140-156) ----
Column {
visible: root.doc.loading === true
anchors.horizontalCenter: parent.horizontalCenter
spacing: 12
QbzSpinner {
size: 36
anchors.horizontalCenter: parent.horizontalCenter
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzSession.tr("Loading…", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 14
horizontalAlignment: Text.AlignHCenter
}
}
// --- State 2: the first page failed (.slint:158-193) ---
// Only the FIRST page raises this; a failed tail page keeps
// what is on screen and just stops the tail spinner
// (main.rs:15129 vs :3599).
Column {
visible: root.doc.loading !== true && root.doc.loadError === true
anchors.horizontalCenter: parent.horizontalCenter
spacing: 12
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzSession.tr("Failed to load Library", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: 14
horizontalAlignment: Text.AlignHCenter
}
// .slint:169-191 — 34 tall, radius 6, 16px horizontal
// padding, elevated -> hover fill.
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
width: retryLabel.implicitWidth + 32
height: 34
radius: 6
color: retryArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
Text {
id: retryLabel
anchors.centerIn: parent
text: QbzSession.tr("Retry", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: 13
font.weight: theme.weightMedium
}
MouseArea {
id: retryArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzArtist.releasesRetry()
}
}
}
// --- State 3: loaded, and the bucket is empty ----------
// (.slint:195-203. A sibling of the grid, not an arm of it.)
Text {
visible: root.doc.loading !== true
&& root.doc.loadError !== true
&& root.albums.length === 0
anchors.horizontalCenter: parent.horizontalCenter
text: QbzSession.tr("No releases found.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 14
}
// --- The grid (.slint:205-231) -------------------------
// `viewMode: "grid"` is HARD-WIRED, as in the reference
// (:207): this page has no toggle.
AlbumCollection {
id: collection
visible: root.doc.loading !== true && root.doc.loadError !== true
width: parent.width - 64
// Identity of the catalog on screen. Opening ANOTHER
// bucket (or another artist) disarms a tail fade that
// was armed by a scroll whose page never landed, so the
// new grid's first paint is never coloured by it.
collectionKey: (root.doc.id || "") + ":" + (root.doc.releaseType || "")
albums: root.albums
viewMode: "grid"
cardWidth: 200
cardHeight: 266
cardGap: 24
flick: flick
// The page Column's padding-top. The loading / error
// branches are mutually exclusive with the grid, so
// nothing else sits above it (.slint:213-222).
contentOffset: 8
}
// --- Tail spinner (.slint:233-237) ---------------------
Item { visible: root.doc.loadMoreLoading === true; width: 1; height: 24 }
QbzSpinner {
visible: root.doc.loadMoreLoading === true
size: 28
anchors.horizontalCenter: parent.horizontalCenter
}
}
}
// Tracks only the scrolling grid — the header is a sibling above,
// which is the .slint's `y: 56px` / `height: parent.height - 56px`.
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: flick
}
}
}
}