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

+29
View File
@@ -1059,30 +1059,57 @@ Rectangle {
// (album_qt.rs spawn_deferred_rows). While a fetch is out the
// rail's placeholder holds its band; when it resolves to nothing
// both the placeholder and the rail are gone — no empty frame.
//
// WIDTH — every rail below spells `parent.width - 64` on purpose.
// `page` is a Column with leftPadding/rightPadding 32, and a QML
// Column's padding does NOT narrow `parent.width` for its
// children: it only shifts them. A child that says
// `width: parent.width` therefore comes out 64px too wide and
// hangs 32px off the right edge — which put SectionRail's
// right-aligned chevron Row entirely outside the viewport (owner
// report: the bottom rails had no right chevron at all, and the
// left one sat jammed against the window edge). The inset is
// applied at the MOUNT SITES, not inside SectionRail /
// RailSkeleton, exactly as the page's other direct children
// already do it — see :442, :480, :690 and :695.
Item { visible: moreRail.visible || moreRailSk.visible; width: 1; height: 40 }
RailSkeleton {
id: moreRailSk
width: parent.width - 64
visible: root.moreLoading
phase: skeletonPhase.on
cardCount: root.railSkeletonCount
}
SectionRail {
id: moreRail
width: parent.width - 64
visible: (album.moreFromArtist || []).length > 0
title: QbzSession.tr("From the same artist", QbzSession.trRev)
items: album.moreFromArtist || []
coverMap: root.coverMap
// album/AlbumPageView.slint:1124-1132 — this rail is the ONE
// carousel on the album page with `show-view-all: true`, and
// its link is the SECOND door to the artist discography page
// (main.rs:11839-11865 handles it with release_type "album" and
// the name taken from AlbumState.artist, which is why
// openReleases takes the name as a parameter). The other door
// is "See discography" on the artist page itself.
showViewAll: true
onViewAllClicked: QbzArtist.openReleases(root.header.artistId || "",
root.header.artist || "", "album")
}
Item { visible: sugRail.visible || sugRailSk.visible; width: 1; height: 40 }
RailSkeleton {
id: sugRailSk
width: parent.width - 64
visible: root.suggestionsLoading
phase: skeletonPhase.on
cardCount: root.railSkeletonCount
}
SectionRail {
id: sugRail
width: parent.width - 64
visible: (album.suggestions || []).length > 0
title: QbzSession.tr("Listening suggestions", QbzSession.trRev)
items: album.suggestions || []
@@ -1096,12 +1123,14 @@ Rectangle {
Item { visible: simRail.visible || simRailSk.visible; width: 1; height: 40 }
RailSkeleton {
id: simRailSk
width: parent.width - 64
visible: root.similarLoading
phase: skeletonPhase.on
cardCount: root.railSkeletonCount
}
SectionRail {
id: simRail
width: parent.width - 64
visible: (album.similarAlbums || []).length > 0
title: QbzSession.tr("Similar albums", QbzSession.trRev)
items: album.similarAlbums || []
@@ -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
}
}
}
}
+662 -108
View File
@@ -16,10 +16,15 @@
// MusicBrainz is off in Settings or the artist has no confident MB match, is
// simply ABSENT — never an error frame, and nothing is requested.
//
// POC-NOTEs: blacklist banner, artist Scene, Share, Create Collection, radio
// POC-NOTEs: blacklist banner, artist Scene, Create Collection, radio
// engines (dropdown inert), multi-select, the sticky behavior of the JUMP TO
// bar (it scrolls with the page).
//
// "Share" left this list when the seam landed: the ⋯ entry now calls
// QbzArtist.share (artist_bridge.rs -> src/share_qt.rs), which copies
// play.qobuz.com/artist/{id} and raises the same "Link copied" toast as
// ArtistPageView.slint:530-538 / main.rs:12749.
//
// Header atmosphere (ArtistPageView.slint:120-147, 211-247): wired through
// the shared controls/HeaderGradient.qml — the SAME component AlbumView
// mounts, because the .slint paints both headers from identical blocks. It
@@ -82,22 +87,145 @@ Rectangle {
// A section whose albums all filter out DISAPPEARS (artist.rs:1033), and
// Load more is suppressed while a filter is active (:1042 — appending
// would bring back unfiltered items).
//
// ---- THE PAGED TAIL IS MERGED HERE, NOT PUSHED INTO `artist` ----------
// Owner, 2026-08-02: "el error esta en que nunca cargan mas albumes."
// The old handler parsed the page and pushed it into
// `artist.releaseSections[i].cards` IN PLACE, then fired artistChanged().
// Both Repeaters (`model: releaseSections` at :1997/:2114 and
// `model: section.cards` inside ReleaseSection) were then handed the very
// SAME array object they already held, and the unfiltered arm below used
// to `return all` — the identical array again. Nothing was dirty, so
// nothing rebuilt: the page was fetched, parsed and appended to a data
// structure no view ever re-read. This tree already writes the rule down,
// in cards/PlaylistCollage.qml: "Rebind requires a NEW object reference."
// (PRE-EXISTING, not a regression from today's smooth-append work — that
// pass only added the `releaseMoreLanded` call to the handler. What it
// changed is that the button now shows an in-flight placeholder, which is
// what made a silent no-op visible.)
//
// So the appended pages live in `root.releaseOverlay` (see its header)
// and are merged HERE, which builds a NEW section object with a NEW cards
// array whenever the overlay has anything for that bucket — the one thing
// that actually invalidates both Repeaters. `artist` is never mutated, so
// a republish of the document (the progressive Magazine / MusicBrainz
// passes) cannot lose the tail either.
//
// De-dup by album id is MANDATORY on this path and not merely defensive:
// artist_qt.rs `merge_release_page` (:686) folds every landed page into
// the STASHED document too, so the moment the next enrichment pass
// republishes, the document already carries the cards the overlay holds.
// Without the id filter every appended album would appear twice from that
// frame on. (Qobuz can also repeat a row across pages by itself.)
readonly property var releaseSections: {
var all = artist.releaseSections || []
if (root.needle === "") return all
var out = []
for (var i = 0; i < all.length; i++) {
var src = all[i]
var extra = root.releaseOverlay[src.releaseType]
var cards = src.cards || []
// The bucket's persisted sort, stamped on the row by
// artist_qt.rs `map_artist` / `resort_section`. It has to be COPIED
// into every rebuilt literal below: an object literal carries only
// the keys it names, so a `sortBy` left out here makes the header's
// QbzSelect snap back to "Default" the moment this arm is taken.
var sortBy = src.sortBy || "default"
// `hasMore` is only overridden once a page has actually LANDED
// (`extra.hasMore` stays undefined otherwise): the document's copy
// is the truth until then, and blanking it would hide the button —
// and with it the in-flight placeholder QbzLoadMore draws under it.
var hasMore = src.hasMore === true
var k
if (extra !== undefined) {
var seen = {}
var merged = []
for (k = 0; k < cards.length; k++) {
merged.push(cards[k])
seen[cards[k].id] = true
}
var tail = extra.cards || []
for (k = 0; k < tail.length; k++) {
if (seen[tail[k].id]) continue
seen[tail[k].id] = true
merged.push(tail[k])
}
// THE TAIL IS APPENDED, SO IT MUST BE RE-SORTED HERE.
// artist.rs:1245-1258 `append_release_page` re-sorts after
// folding a page in, "before computing the artwork jobs at
// their post-sort positions" — under "A–Z" a page-2 album
// belongs wherever its title puts it, not at the bottom.
// artist_qt.rs `merge_release_page` does that to the STASHED
// document; this does it to the copy actually on screen, which
// is a DIFFERENT array (the overlay never reaches the stash).
// Fixing only one of the two leaves the grid correct until the
// next enrichment republish, or wrong until it — see the
// overlay's own header for why the two copies exist.
cards = root.sortReleaseCards(merged, sortBy)
if (extra.hasMore !== undefined) hasMore = extra.hasMore === true
}
if (root.needle === "") {
// No overlay for this bucket -> hand back the document's own
// object: nothing was appended, so there is nothing to rebind
// and a fresh wrapper would only churn delegates. (The document
// arrives already sorted — artist_qt.rs sorts at page build and
// on every `setSectionSort` — so there is nothing to do to it.)
out.push(extra === undefined ? src
: { "releaseType": src.releaseType, "title": src.title,
"cards": cards, "hasMore": hasMore, "sortBy": sortBy })
continue
}
// Filtered arm: the merge happened FIRST, so an appended album
// that matches the needle is searchable like any other — but
// `hasMore` is still forced false (artist.rs:1042) and only the
// KEPT cards travel, so the overlay can never leak a filtered-out
// card into this arm.
var kept = []
var cards = all[i].cards || []
for (var j = 0; j < cards.length; j++)
if ((cards[j].title || "").toLowerCase().indexOf(root.needle) >= 0)
kept.push(cards[j])
if (kept.length === 0) continue
out.push({ "releaseType": all[i].releaseType, "title": all[i].title,
"cards": kept, "hasMore": false })
out.push({ "releaseType": src.releaseType, "title": src.title,
"cards": kept, "hasMore": false, "sortBy": sortBy })
}
return out
}
/// JS twin of `artist_qt::sort_release_cards`, itself the port of
/// `album_map::sort_album_items` (crates/qbz/src/album_map.rs:244-264).
///
/// Both exist because the bucket exists TWICE: Rust owns the stashed
/// document (page 1 plus every page `merge_release_page` folded in) and
/// sorts it there, while `releaseOverlay` holds the pages Load more brought
/// in on THIS side and is concatenated onto the document's array by the
/// projection above — an array Rust never sees. The two must agree, so the
/// arms are kept identical.
///
/// Returns a NEW array (`slice()` before `sort()`): the projection binds to
/// the result, and sorting the caller's array in place would hand the
/// Repeaters the same reference back — "Rebind requires a NEW object
/// reference" (cards/PlaylistCollage.qml).
///
/// `year` on these cards is the PLAIN 4-digit year (artist_qt.rs
/// `map_release` slices `dates.original[..4]`), so the string compare is
/// chronological. "default" and every unknown key fall through UNSORTED —
/// `album_map.rs:262` is a bare `_ => {}`, because "Default" means "the
/// order Qobuz sent".
function sortReleaseCards(cards, sort) {
function byTitle(a, b) {
var x = (a.title || "").toLowerCase(), y = (b.title || "").toLowerCase()
return x < y ? -1 : (x > y ? 1 : 0)
}
function byYear(a, b) {
var x = a.year || "", y = b.year || ""
return x < y ? -1 : (x > y ? 1 : 0)
}
switch (sort) {
case "oldest": return cards.slice().sort(byYear)
case "newest": return cards.slice().sort(function (a, b) { return byYear(b, a) })
case "title-asc": return cards.slice().sort(byTitle)
case "title-desc": return cards.slice().sort(function (a, b) { return byTitle(b, a) })
default: return cards
}
}
readonly property var labels: artist.labels || []
readonly property var similarArtists: artist.similarArtists || []
readonly property var playlists: artist.playlists || []
@@ -150,6 +278,253 @@ Rectangle {
property bool appearsOnExpanded: false
property bool otherExpanded: false
// ---- Release-section "Load more": in-flight + append-fade state -------
// controls/QbzLoadMore.qml owns the button and the placeholder row under
// it; the APPEARANCE of what lands is the HOST's job (that file's "WHAT
// THIS CONTROL DOES *NOT* DO" note) because only this view knows the index
// the appended page starts at.
//
// Both maps live on `root`, keyed by releaseType, and NOT on the
// ReleaseSection instances: the Repeater at :1787 re-creates every
// delegate whenever `artist` changes (a `var` property notifies on every
// write, and the document is republished once per enrichment pass —
// stories, then each MusicBrainz section), and one of those republishes is
// the very frame the new page lands in. A flag parked on the section would
// be destroyed exactly when it was needed.
//
// `releasePending` is COPY-ON-WRITE because the ReleaseSection BINDS
// `busy` to it, so it has to notify. `releaseFade` is mutated IN PLACE on
// purpose: it is read once, imperatively, by each card's
// Component.onCompleted, and a notify there would re-run bindings for
// nothing.
property var releasePending: ({})
/// releaseType -> { from: <index the appended page starts at>,
/// at: <ms the page LANDED, 0 while in flight> }.
property var releaseFade: ({})
/// ---- The appended pages themselves -----------------------------------
/// releaseType -> { cards: [<pages 2..n, deduped, NEVER page 1>],
/// hasMore: <the newest page's has_more; undefined until
/// a page has landed>,
/// pages: <index pages loaded so far, 1 = the embedded
/// bucket — the LOADED_PAGES twin, see
/// releaseMoreLanded's cap note> }.
///
/// The document owns page 1 and stays the source of truth for it; this
/// overlay owns everything Load more brought in, and `releaseSections`
/// (:115) merges the two into a NEW array so the Repeaters rebuild. It is
/// COPY-ON-WRITE for exactly that reason — the projection binds to it, so
/// a write has to notify.
///
/// Why an overlay and not a push into `artist`: `artist` is
/// `JSON.parse(QbzArtist.artistJson)` (:52), a BINDING. Every progressive
/// republish (Magazine stories, then each MusicBrainz section — see
/// artist_qt.rs) re-runs that parse and yields a brand-new object, so
/// anything written onto the parsed copy is discarded on the next pass.
/// This is the same reasoning `localToggles` (:437) already carries for
/// the heart/pin state.
property var releaseOverlay: ({})
/// releaseType -> { sent: <offset the page in flight asked for>,
/// next: <offset the NEXT page must ask for> }.
///
/// The paging cursor is the count of rows RECEIVED from the server, which
/// is NOT the count on screen: both sides drop duplicate ids (here and in
/// artist_qt.rs `merge_release_page`), so after a page that repeated three
/// rows the on-screen count trails the server cursor by three. Handing the
/// on-screen count back as the offset would re-request rows already held,
/// and the dedup would then swallow them — a Load more that visibly does
/// nothing, which is the very bug this block exists to kill.
///
/// Mutated IN PLACE on purpose, same as `releaseFade`: it is written from
/// the landing handler and read imperatively from the button's onClicked,
/// and no binding reads it. A notifying write here would tear down and
/// rebuild every AlbumCard in every release grid on each click, for
/// nothing.
property var releaseCursor: ({})
/// How long after a landing the threshold stays live. The republish that
/// carries the page re-creates the delegates in the same tick, so this is
/// generous; what it buys is that a LATER, unrelated republish (a
/// MusicBrainz pass) re-creates that tail WITHOUT re-fading it.
readonly property int releaseFadeWindowMs: 1500
/// Called from the section's Load more BEFORE the bridge call, so the
/// threshold is the count that was on screen at click time. RETURNS the
/// offset the caller must pass to the bridge (see `releaseCursor`).
///
/// `sorted` = this bucket carries a non-default sort. The append fade is
/// then SUPPRESSED, deliberately: `releaseFadeFrom` hands back an INDEX and
/// the card delegate fades everything at or past it, which only means "the
/// page that just landed" while new rows sit at the TAIL. Under "A–Z" the
/// projection interleaves them (see `sortReleaseCards`), so that band would
/// dissolve an arbitrary run of cards the user has been looking at all
/// along. The reference has no fade at all here, so no fade is strictly
/// closer to it than the wrong one — and tracking the new ids through a
/// re-sort is not a thing the .slint does either.
function releaseMoreRequested(releaseType, loadedCount, sorted) {
if (sorted === true) delete releaseFade[releaseType]
else releaseFade[releaseType] = { "from": loadedCount, "at": 0 }
var c = releaseCursor[releaseType]
// First page of this bucket: nothing has been fetched through the
// paging endpoint yet, so the document's own row count IS the server
// cursor. Afterwards the cursor is authoritative.
var off = (c !== undefined && c.next !== undefined) ? c.next : loadedCount
releaseCursor[releaseType] = { "sent": off,
"next": (c !== undefined ? c.next : undefined) }
var m = Object.assign({}, root.releasePending)
m[releaseType] = true
root.releasePending = m
// artist_bridge.rs emits releaseSectionReady only on SUCCESS
// (main.rs:872 just logs the error and returns), so a failed page would
// otherwise leave the skeleton up forever. Bound it with the same 8s
// QbzLoadMore's own skeleton settles at, after which the button comes
// back armed.
releaseSettle.restart()
return off
}
/// The page landed (or came back empty): fold it into the overlay, advance
/// the cursor, arm the fade window and disarm the placeholder.
///
/// `cards` is the RAW page as the bridge sent it; `hasMore` is the
/// server's own flag for the bucket.
///
/// ORDER IS LOAD-BEARING. The overlay write is COPY-ON-WRITE, so it
/// notifies, so `releaseSections` re-evaluates and the Repeater at :1997
/// destroys and rebuilds every section delegate — QbzLoadMore and every
/// AlbumCard included — SYNCHRONOUSLY, inside that assignment. Everything
/// those rebuilt delegates read has to be true BEFORE it happens:
/// - `releaseFade[rt].at`, or each appended card's Component.onCompleted
/// asks `releaseFadeFrom()` while the landing still reads "in flight"
/// (at === 0 -> MAX_VALUE) and the page snaps in with no fade at all;
/// - `releasePending`, or the button is re-created with `busy` still
/// true and the skeleton flashes back for a frame UNDER the page that
/// just landed (the note the old handler carried, kept verbatim in
/// spirit).
/// So: cursor + fade + placeholder first, overlay LAST.
function releaseMoreLanded(releaseType, cards, hasMore) {
// ---- 1. the cursor (in place — nothing binds to it) ---------------
// Advance by the RAW page length, not by what survived the dedup: the
// server counted every row it sent.
var c = releaseCursor[releaseType]
var base = (c !== undefined && c.sent !== undefined) ? c.sent : 0
releaseCursor[releaseType] = { "sent": base, "next": base + cards.length }
// ---- 2. fade window + placeholder ---------------------------------
if (releaseFade[releaseType] !== undefined)
releaseFade[releaseType].at = Date.now()
if (root.releasePending[releaseType] !== undefined) {
var m = Object.assign({}, root.releasePending)
delete m[releaseType]
root.releasePending = m
}
// ---- 3. the overlay (COPY-ON-WRITE — the projection binds to it) ---
var prev = root.releaseOverlay[releaseType]
var tail = (prev !== undefined && prev.cards !== undefined)
? prev.cards.slice() : []
var seen = {}
var i
// Page 1 lives in the document, and once artist_qt.rs
// `merge_release_page` has folded an earlier page into the STASHED
// document a republish puts that page there too — so both are checked
// before anything is kept, and the overlay never grows a row the
// document already carries.
var doc = root.artist.releaseSections || []
for (i = 0; i < doc.length; i++) {
if (doc[i].releaseType !== releaseType) continue
var dc = doc[i].cards || []
for (var j = 0; j < dc.length; j++) seen[dc[j].id] = true
break
}
for (i = 0; i < tail.length; i++) seen[tail[i].id] = true
var before = tail.length
for (i = 0; i < cards.length; i++) {
if (seen[cards[i].id]) continue
seen[cards[i].id] = true
tail.push(cards[i])
}
// The reference CAPS the index at 4 pages per bucket and hands off to
// the discography page beyond that: artist.rs:718-720 ("Page 1 is the
// embedded bucket; 3 more loads reach the cap" — MAX_INDEX_PAGES = 4),
// enforced at append_release_page:1268. This port has that page now
// (QbzArtist.openReleases, the section title's own click), so the cap
// carries over 1:1 — an uncapped index button would page a 300-album
// Singles bucket into this grid forever, which is exactly what the
// dedicated page exists to absorb.
var pages = ((prev !== undefined && prev.pages !== undefined) ? prev.pages : 1) + 1
// …and `!appended_ids.is_empty()` is the same line's other clause: a
// page that contributed NOTHING after the dedup (all rows already on
// screen, or empty outright) kills the button no matter what the flag
// says — leaving it up would offer a page that can never change the
// grid, and every further click would burn a request and land here
// again.
var more = (hasMore === true) && (tail.length > before) && (pages < 4)
var o = Object.assign({}, root.releaseOverlay)
o[releaseType] = { "cards": tail, "hasMore": more, "pages": pages }
root.releaseOverlay = o
}
/// Index the appended page of `releaseType` starts at — +inf when this
/// section never paged, when its page has not landed yet, or when the
/// landing is already stale. Fading unconditionally would re-dissolve the
/// whole grid on every enrichment pass, which reads as a flicker, not as
/// polish.
function releaseFadeFrom(releaseType) {
var e = releaseFade[releaseType]
if (e === undefined || e.at === 0)
return Number.MAX_VALUE
if (Date.now() - e.at > root.releaseFadeWindowMs)
return Number.MAX_VALUE
return e.from
}
/// The user picked a sort for one bucket (ReleaseGrid.slint:81-89). Hand it
/// to the bridge — `QbzArtist.setSectionSort` persists it by release_type
/// and re-sorts the STASHED document, which comes back as a republished
/// `artistJson` — and reconcile the local paging state first.
///
/// The OVERLAY's CARDS for this bucket are dropped. Nothing is lost by
/// that: an overlay entry is only ever written when a page LANDS, and by
/// then artist_qt.rs `load_release_page` has already folded that page into
/// the stashed document (`merge_release_page`, called before the signal is
/// emitted) — so every card the overlay holds is in the document the
/// republish is about to carry, now in the new order. Keeping the cards
/// would leave a second, stale copy of those rows whose only remaining job
/// is to be deduped away.
///
/// `hasMore` and `pages` are KEPT (the entry survives as a card-less
/// stub). They are paging state, not row data, and neither is in the
/// document: merge_release_page writes the SERVER's has_more flag into the
/// stash, not the capped one, so dropping the whole entry after the cap
/// closed a bucket would let the document's `hasMore: true` resurrect the
/// button — and the reference's LOADED_PAGES counter survives a sort
/// change too (nothing in artist.rs `resort_section` touches it).
///
/// The CURSOR is deliberately kept. It counts rows the SERVER has sent, and
/// the server's own order (`release_date`, hardcoded on purpose — the five
/// picker keys are applied locally and never reach the API) has not changed
/// just because we re-ordered them here. Resetting it would make the next
/// Load more re-request pages we already hold, which the dedup would then
/// swallow — the "button does nothing" bug the cursor exists to prevent.
///
/// The FADE threshold is dropped for the same reason `releaseMoreRequested`
/// suppresses it under a custom sort: it is an index into an order that no
/// longer exists, and the republish re-creates every delegate.
function releaseSortChanged(releaseType, sortKey) {
delete releaseFade[releaseType]
if (root.releaseOverlay[releaseType] !== undefined) {
var e = Object.assign({}, root.releaseOverlay[releaseType])
delete e.cards
var o = Object.assign({}, root.releaseOverlay)
o[releaseType] = e
root.releaseOverlay = o
}
QbzArtist.setSectionSort(releaseType, sortKey)
}
Timer {
id: releaseSettle
interval: 8000
repeat: false
onTriggered: root.releasePending = ({})
}
// ---- Network sidebar open/closed -------------------------------------
// ShellState.content-constrained (state.slint:4114): window under the NPB
// breakpoint AND a right panel (Queue / Lyrics) open. NOT a raw
@@ -357,23 +732,29 @@ Rectangle {
Connections {
target: QbzArtist
function onReleaseSectionReady(releaseType, cardsJson, hasMore) {
// Hand the page to the OVERLAY (root.releaseOverlay) and let the
// `releaseSections` projection merge it. It used to be pushed
// straight into `root.artist.releaseSections[i].cards` followed by
// a manual `artistChanged()`, and that is why "nunca cargan mas
// albumes": the push mutated the array IN PLACE, so both Repeaters
// were re-handed the identical array reference and QML had nothing
// to invalidate — the fake `artistChanged()` re-ran every OTHER
// binding on the page and left the two that mattered untouched,
// because `releaseSections`' unfiltered arm returned `all`, the
// document's own array, unchanged.
//
// The signal is emitted only on SUCCESS (main.rs:872 logs errors
// and returns), and an empty page comes through here too — that is
// what clears the skeleton when a bucket runs out.
var cards = JSON.parse(cardsJson)
// The document, not root.releaseSections: that one is a FILTERED
// projection and may hand back fresh objects, so a push into it
// would be dropped on the next re-evaluation.
var sections = root.artist.releaseSections || []
for (var i = 0; i < sections.length; i++) {
if (sections[i].releaseType === releaseType) {
var seen = {}
for (var j = 0; j < sections[i].cards.length; j++) seen[sections[i].cards[j].id] = true
for (j = 0; j < cards.length; j++) {
if (!seen[cards[j].id]) sections[i].cards.push(cards[j])
}
sections[i].hasMore = hasMore
break
}
}
root.artistChanged()
root.releaseMoreLanded(releaseType, cards, hasMore)
// The appended covers are not in the document (artist_qt.rs folds
// them into the STASHED copy, which only reaches us on the next
// enrichment republish), so the document-driven dispatch would
// leave the new row as blank tiles until an unrelated pass
// happened to land. dispatchCovers() reads the overlay too, and
// `dispatchedCovers` makes the re-scan free.
root.dispatchCovers()
}
}
Component.onCompleted: {
@@ -407,6 +788,18 @@ Rectangle {
networkOpen = !contentConstrained
dismissedDiscovery = ({})
localToggles = ({})
// A fresh artist must never inherit the previous one's paging state:
// a stale threshold would fade a band of the new artist's first page
// for no reason, a stale pending flag would show a skeleton under a
// button nobody pressed, a stale OVERLAY would graft the previous
// artist's albums onto this one's grids (release types are shared
// keys — every artist has an "album" bucket), and a stale CURSOR would
// page the new artist from the old one's offset.
releasePending = ({})
releaseFade = ({})
releaseOverlay = ({})
releaseCursor = ({})
releaseSettle.stop()
membersExpanded = false
groupsExpanded = false
collabsExpanded = false
@@ -473,6 +866,17 @@ Rectangle {
for (i = 0; i < rawSections.length; i++)
for (j = 0; j < (rawSections[i].cards || []).length; j++)
if (rawSections[i].cards[j].artUrl) urls.push(rawSections[i].cards[j].artUrl)
// …and the pages "Load more" appended, which are NOT in the document:
// they live in root.releaseOverlay until artist_qt.rs's stashed copy
// reaches us through the next enrichment republish. Off the overlay
// rather than off `releaseSections` for the same reason the lists
// above are RAW — the projection is filtered, and a covers dispatch
// must not depend on the search box.
for (var rt in root.releaseOverlay) {
var tail = root.releaseOverlay[rt].cards || []
for (i = 0; i < tail.length; i++)
if (tail[i].artUrl) urls.push(tail[i].artUrl)
}
for (i = 0; i < rawPlaylists.length; i++)
if (rawPlaylists[i].artUrl) urls.push(rawPlaylists[i].artUrl)
// Magazine story thumbnails ride the same pipeline (arc-cdn URLs).
@@ -557,11 +961,21 @@ Rectangle {
elide: Text.ElideRight
}
// Cover with hover play overlay.
//
// RADIUS 4, not `theme.radiusSm` (8): rows/TrackRow.qml:553 — the
// SHARED row this component is a fork of, and the one PlaylistView
// / the album detail / search all draw — rounds its artwork cell
// at 4. The fork had drifted to 8 and the owner saw it ("los album
// arts de Popular tracks esta mas redondo que otros, por ejemplo,
// los de playlistview"). All THREE stacked elements carry the same
// number — the cell, the image and the hover scrim — or the scrim's
// corners stick out past the art. The ROW's own `radius: 8` above
// is correct and matches TrackRow.qml:288; only the cover moved.
Rectangle {
width: showAlbum ? cols.colArt : cols.colNumber
height: showAlbum ? cols.colArt : 28
anchors.verticalCenter: parent.verticalCenter
radius: theme.radiusSm
radius: 4
color: theme.surfaceElevated
clip: true
QbzIcon {
@@ -574,11 +988,11 @@ Rectangle {
RoundedImage {
anchors.fill: parent
source: root.coverMap[row.artUrl] || ""
radius: theme.radiusSm
radius: 4
}
Rectangle {
anchors.fill: parent
radius: theme.radiusSm
radius: 4
color: "#000000"
opacity: trArea.containsMouse || isActive ? 0.6 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
@@ -1018,6 +1432,10 @@ Rectangle {
// Release section (ReleaseGrid).
component ReleaseSection: Column {
// Named because the card delegate below reaches for `section` from a
// nested scope, and an inline component's own root id is the one
// handle that is unambiguous there.
id: relSection
property var section: ({})
property string anchorId: ""
width: parent ? parent.width : 0
@@ -1048,35 +1466,95 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// POC-NOTE: dedicated discography page out of scope.
// ReleaseGrid.slint:63-68 -> ArtistPageView.slint:928-929
// -> ArtistActions.open-releases -> main.rs:15055-15087,
// which records the nav entry and mounts
// ArtistReleasesView on this bucket.
//
// The artist NAME travels with the call: the second door to
// the same page (AlbumView's "From the same artist" View
// all) has no artist document open and supplies its own,
// exactly as main.rs:11844 does.
//
// `relSection.section`, not a bare `section` — the inline
// component's own root id is the one unambiguous handle
// from a nested scope, which is why `relSection` is named
// at all (:1409-1411). `root.` is the same pattern the card
// delegate below already uses for `coverMap` and
// `releaseFadeFrom`.
//
// (What stood here was an empty MouseArea under a POC-NOTE
// reading "dedicated discography page out of scope" — a
// fully lit, pointer-cursor control whose entire effect was
// nothing. The page now exists; the note is gone with it.)
onClicked: QbzArtist.openReleases(root.artist.id || "",
root.artist.name || "",
relSection.section.releaseType || "")
}
}
// Per-section sort (ReleaseGrid.slint:70 QbzSelect). No seam:
// `set-section-sort` persists per release_type and re-sorts
// server-side, and neither exists on this bridge — so the control
// is DIMMED and carries no MouseArea at all instead of offering a
// pointer cursor over a menu that never opens.
Rectangle {
// Per-section sort — ReleaseGrid.slint:70-90, the SAME control the
// reference mounts (primitives/QbzSelect.slint), here through the
// port's existing replica controls/QbzSelect.qml.
//
// `sm: true` + `menuWidth: 150` are the .slint's own values,
// verbatim: QbzSelect.qml:34 is `width = menuWidth - 40` under
// `sm`, so 150 lands at the 110 x 30 the reference computes at
// QbzSelect.slint:88. Passing 190 "to get 150" is the exact mistake
// that file's :42-50 note documents.
//
// `id: sortBtn` is load-bearing beyond this block — the section
// title above measures `parent.width - seeAll.width - sortBtn.width
// - 24`. The control grows 28 -> 30 tall with the swap; that IS the
// reference (QbzSelect.slint `sm` is 30px). Row children are
// top-aligned, hence the explicit verticalCenter.
//
// (What stood here was a dimmed Rectangle with no MouseArea, whose
// note claimed `set-section-sort` "re-sorts server-side" and that
// no seam existed. Both halves were wrong: main.rs:14997-15007 sorts
// the ALREADY-LOADED cards in process and never touches the API —
// see artist_qt::resort_section — and the label lied twice on top of
// that, reading "Newest" over data in server order.)
QbzSelect {
id: sortBtn
width: sortRow.width
height: 28
radius: 5
opacity: 0.4
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceElevated
Row {
id: sortRow
height: parent.height
leftPadding: 10
rightPadding: 10
spacing: 6
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Newest", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: 12
}
QbzIcon { name: "chevron-down"; width: 12; height: 12; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" }
sm: true
menuWidth: 150
// The .slint's five options in its order (:74-79). "A–Z"/"Z–A"
// carry a U+2013 EN DASH — the msgid in the catalogue is the
// en-dash form, and a hyphen would silently go untranslated in
// all seven non-English locales. (LibraryToolbar's "Title A-Z"
// is a DIFFERENT, hyphenated msgid — do not copy it here.)
options: [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)]
// ReleaseGrid.slint:34-39 — the wire key back to an index. This
// is what makes the picker come back showing the sort the user
// actually chose: `sortBy` is stamped on the section by
// artist_qt.rs at page build from the persisted pref, so a
// revisited artist seats the control without a click.
//
// A BINDING, never an assignment: QbzSelect does not
// self-assign `currentIndex` (it closes the popup and emits,
// QbzSelect.qml:299-300), and every enrichment republish
// re-creates this delegate — an assignment would be lost.
currentIndex: {
var s = relSection.section.sortBy || "default"
return s === "newest" ? 1 : s === "oldest" ? 2
: s === "title-asc" ? 3 : s === "title-desc" ? 4 : 0
}
// ReleaseGrid.slint:81-89 — index to wire key. These five
// strings are BOTH what gets persisted and what the sort
// functions switch on, on either side of the bridge.
//
// `relSection.section`, not a bare `section`: this header lives
// inside an inline `component`, which does not see the enclosing
// document's ids — `relSection` exists for exactly this.
onSelected: function (i) {
root.releaseSortChanged(relSection.section.releaseType,
i === 1 ? "newest" : i === 2 ? "oldest"
: i === 3 ? "title-asc" : i === 4 ? "title-desc" : "default")
}
}
}
@@ -1089,6 +1567,7 @@ Rectangle {
Repeater {
model: section.cards
delegate: AlbumCard {
id: relCard
albumId: modelData.id
title: modelData.title
// The subtitle slot carries the YEAR on the artist page,
@@ -1115,35 +1594,84 @@ Rectangle {
// artist_qt::map_release stamps this the same way it
// stamps the pin; false inverted the first click.
isFavorite: modelData.isFavorite === true
// ---- smooth append (owner, 2026-08-02) ---------------
// "que la aparicion de lo que se cargue, sea smooth" —
// fade in ONLY the page Load more just brought in. The
// Repeater re-creates EVERY delegate on any republish (see
// `releasePending` at the top of this file), so an
// unconditional fade would re-dissolve the whole grid on
// an unrelated pass and read as a flicker.
//
// `opacity` carries NO binding on purpose: the assignment
// below would destroy one, and a Behavior would then fire
// for every card (Behaviors are inert only DURING
// creation, and Component.onCompleted runs after it). A
// card below the threshold is never touched at all, so it
// is created — and stays — fully opaque.
NumberAnimation {
id: appendFade
target: relCard
property: "opacity"
from: 0.0
to: 1.0
// 220ms / OutCubic is the content duration; the
// skeleton above it fades at 180ms.
duration: 220
easing.type: Easing.OutCubic
}
Component.onCompleted: {
if (index >= root.releaseFadeFrom(relSection.section.releaseType)) {
relCard.opacity = 0.0
appendFade.start()
}
}
}
}
}
Row {
// The shared affordance (controls/QbzLoadMore.qml) — this site is the
// `a` arm its header cites: plain centred text, 28 tall, and the same
// `section.hasMore` gate and bridge call as before. What is new is the
// placeholder row it draws underneath while the page is in flight.
QbzLoadMore {
visible: section.hasMore
width: parent.width
Item { width: (parent.width - loadMoreBtn.width) / 2; height: 1 }
Rectangle {
id: loadMoreBtn
width: loadMoreText.implicitWidth + 24
height: 28
color: "transparent"
Text {
id: loadMoreText
anchors.centerIn: parent
text: QbzSession.tr("Load more", QbzSession.trRev)
color: loadMoreArea.containsMouse ? theme.textPrimary : theme.textSecondary
font.pixelSize: 13
}
MouseArea {
id: loadMoreArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzArtist.loadReleaseSection(artist.id, section.releaseType, section.cards.length)
}
// The grid above has a 224 x 270 PITCH: cards/AlbumCard.qml:186-189
// is 200 x 246 and the Grid adds 24px of column/row spacing — the
// same pitch SearchView's block was built around, so the
// placeholder lands exactly where the first appended row will.
skeleton: "cards"
cellW: 224
cellH: 270
// There is NO per-section loading flag to bind to: the bridge
// publishes one artist document plus the `releaseSectionReady`
// signal (artist_bridge.rs:60-66) and nothing in between. So the
// in-flight state is driven locally, off root's map, cleared by
// that signal and capped by root's 8s settle timer (the error path
// emits nothing at all — main.rs:872).
busy: root.releasePending[section.releaseType] === true
onClicked: {
// BEFORE the call: the threshold is the count on screen now
// (`section.cards` is the MERGED array — document page 1 plus
// every page the overlay holds — so the fade threshold lines
// up with the tail this request will add).
//
// The OFFSET is not that count: releaseMoreRequested returns
// the server cursor (see `releaseCursor`), which is the number
// of rows the server has actually sent. They differ as soon as
// a page repeats a row, and passing the on-screen count then
// re-requests rows we already have — the dedup swallows them
// and the button appears to do nothing, forever.
//
// The third argument suppresses the append fade when this
// bucket carries a non-default sort: the page will be
// INTERLEAVED, not appended, so an index threshold would fade
// the wrong cards (see releaseMoreRequested).
var off = root.releaseMoreRequested(section.releaseType, section.cards.length,
(section.sortBy || "default") !== "default")
QbzArtist.loadReleaseSection(artist.id, section.releaseType, off)
}
Item { width: (parent.width - loadMoreBtn.width) / 2; height: 1 }
}
}
@@ -1587,32 +2115,47 @@ Rectangle {
Repeater {
model: topTracks.length
delegate: PopularTrackRow {
visible: root.topTracksExpanded || index < root.preview
height: visible ? 50 : 0
// Smooth reveal (owner, 2026-08-02). This expander is
// CLIENT-SIDE: the model is a COUNT, so every row is
// already instantiated and "Load more" only flipped
// `visible` — twenty rows arriving in one frame, which
// is the "carga de golpe" the owner reported.
//
// OPACITY ONLY, deliberately: the row is 50 tall with
// a 36px cover and text anchored to its verticalCenter
// and it does NOT clip, so animating `height` would
// paint those children outside the row box and over
// its neighbours for the whole 220ms. Height still
// snaps (the space opens at once); the content
// dissolves into it. Same 220ms / OutCubic the
// appended release cards use.
//
// The gate is this explicit `revealed`, not the item's
// own `visible`: Item.visible is EFFECTIVE visibility,
// so binding opacity to it would re-fade every row
// whenever the whole catalog Column is shown again
// (tab switch, artistLoading clearing).
readonly property bool revealed: root.topTracksExpanded || index < root.preview
visible: revealed
height: revealed ? 50 : 0
opacity: revealed ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 220; easing.type: Easing.OutCubic } }
row: topTracks[index]
rowIndex: index
showAlbum: true
}
}
Item { visible: topTracks.length > root.preview; width: 1; height: 4 }
Rectangle {
// The shared affordance — the `b` arm of controls/QbzLoadMore
// .qml's header: same plain 28-tall shape, same label pair,
// same client-side toggle. No `busy` and no skeleton: there is
// no fetch, the reveal is instant (the rows above own the
// smoothing).
QbzLoadMore {
visible: topTracks.length > root.preview
width: parent.width
height: 28
color: "transparent"
Text {
anchors.centerIn: parent
text: root.topTracksExpanded ? QbzSession.tr("View less", QbzSession.trRev) : QbzSession.tr("Load more", QbzSession.trRev)
color: loadMoreTopArea.containsMouse ? theme.textPrimary : theme.textSecondary
font.pixelSize: 13
MouseArea {
id: loadMoreTopArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.topTracksExpanded = !root.topTracksExpanded
}
}
label: root.topTracksExpanded ? QbzSession.tr("View less", QbzSession.trRev) : QbzSession.tr("Load more", QbzSession.trRev)
onClicked: root.topTracksExpanded = !root.topTracksExpanded
}
// --- Latest release --------------------------------------
@@ -1676,32 +2219,26 @@ Rectangle {
Repeater {
model: appearsOn.length
delegate: PopularTrackRow {
visible: root.appearsOnExpanded || index < root.preview
height: visible ? 50 : 0
// Same client-side reveal as Popular Tracks above
// — same component, same 50px pitch, same reason
// for fading opacity and letting height snap.
readonly property bool revealed: root.appearsOnExpanded || index < root.preview
visible: revealed
height: revealed ? 50 : 0
opacity: revealed ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 220; easing.type: Easing.OutCubic } }
row: appearsOn[index]
rowIndex: index
showAlbum: false
}
}
Item { visible: appearsOn.length > root.preview; width: 1; height: 4 }
Rectangle {
// Shared affordance, `b` arm again (client-side toggle).
QbzLoadMore {
visible: appearsOn.length > root.preview
width: parent.width
height: 28
color: "transparent"
Text {
anchors.centerIn: parent
text: root.appearsOnExpanded ? QbzSession.tr("View less", QbzSession.trRev) : QbzSession.tr("Load more", QbzSession.trRev)
color: loadMoreAppArea.containsMouse ? theme.textPrimary : theme.textSecondary
font.pixelSize: 13
MouseArea {
id: loadMoreAppArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.appearsOnExpanded = !root.appearsOnExpanded
}
}
label: root.appearsOnExpanded ? QbzSession.tr("View less", QbzSession.trRev) : QbzSession.tr("Load more", QbzSession.trRev)
onClicked: root.appearsOnExpanded = !root.appearsOnExpanded
}
}
@@ -2390,7 +2927,7 @@ Rectangle {
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 },
{ "label": QbzSession.tr("Share", QbzSession.trRev), "icon": "link", "action": "stub", "live": false },
{ "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 },
]
delegate: Rectangle {
@@ -2432,6 +2969,23 @@ Rectangle {
// "build-collection")`. This is the ONLY route
// to it; the nav flyout has no builder entry.
QbzDisco.open(artist.id)
} else if (modelData.action === "share") {
// ArtistPageView.slint:530-538 -> main.rs
// :12749 `media-action("artist", id,
// "share")`. The bridge builds the
// play.qobuz.com/artist/{id} URL, copies it
// and raises the "Link copied" toast, exactly
// as the .slint arm does. Nothing is copied
// QML-side here (unlike TrackRow.qml's
// Loader/TextEdit idiom): cxx-qt-lib exposes
// no QClipboard, and only Rust can publish
// QbzShell.toastJson, so the copy and its
// confirmation stay on one side.
//
// Link-only — artists have no Song.link path
// (share.rs's Song.link resolvers serve the
// track and album menus).
QbzArtist.share(artist.id)
}
}
}
+11
View File
@@ -14,6 +14,13 @@ Column {
property var items: []
// The host's url-keyed cover map ({url: file://path}).
property var coverMap: ({})
/// Carousel.slint's `show-view-all` (default false, as there). The ONE
/// mount that turns it on today is AlbumView's "From the same artist" rail
/// — album/AlbumPageView.slint:1124-1132 — whose link opens the artist's
/// discography. Every other SectionRail in the tree is untouched: the
/// property defaults off and QbzSectionHeader draws nothing for it.
property bool showViewAll: false
signal viewAllClicked()
QbzTheme { id: theme }
width: parent ? parent.width : 0
@@ -27,6 +34,10 @@ Column {
QbzSectionHeader {
title: root.title
// The shared header already implements both halves
// (QbzSectionHeader.qml:42-54, :107-131) — this only forwards them.
showViewAll: root.showViewAll
onViewAllClicked: root.viewAllClicked()
leftEnabled: rail.contentX > 1
rightEnabled: rail.contentX < root.maxScroll - 1
onPageLeft: rail.contentX = Math.max(0, rail.contentX - root.step)