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
This commit is contained in:
vicrodh committed 2026-08-02 16:34:58 -06:00
1 parent edbc037b0f
commit ec8588687b
15 files changed
+2259 -128

No files matched your search

@@ -0,0 +1,340 @@
// 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
}
}
}
}