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

+1
View File
@@ -144,6 +144,7 @@ fn main() {
"qml/theme/QbzTheme.qml", "qml/theme/QbzTheme.qml",
"qml/theme/RoundedImage.qml", "qml/theme/RoundedImage.qml",
"qml/views/AlbumView.qml", "qml/views/AlbumView.qml",
"qml/views/ArtistReleasesView.qml",
"qml/views/ArtistView.qml", "qml/views/ArtistView.qml",
"qml/views/BlacklistManagerView.qml", "qml/views/BlacklistManagerView.qml",
"qml/views/HomeView.qml", "qml/views/HomeView.qml",
@@ -11,9 +11,11 @@
// pageLeft()/pageRight() (the host owns the page-step math); and, since the // pageLeft()/pageRight() (the host owns the page-step math); and, since the
// Playlist Manager landed, `collapsible` + `collapsed` + toggled() and // Playlist Manager landed, `collapsible` + `collapsed` + toggled() and
// `titleSecondary`. // `titleSecondary`.
// Deliberately NOT absorbed: ArtistView.ReleaseSection's header (adds a // Deliberately NOT absorbed: ArtistView.ReleaseSection's header (adds a sort
// sort dropdown + "See discography" — distinct arms, still stubs) and the // dropdown and a "See discography" link — two arms this header does not have,
// uppercase 10-11px eyebrow mini-headers (different type scale). // and both are now LIVE controls rather than the stubs this note used to
// describe) and the uppercase 10-11px eyebrow mini-headers (different type
// scale).
// //
// ── THE `id: root` REFACTOR CAME FIRST, AND IT WAS NOT COSMETIC ─────────── // ── THE `id: root` REFACTOR CAME FIRST, AND IT WAS NOT COSMETIC ───────────
// This file used to have no root id: its Text read `text: parent.title` and // This file used to have no root id: its Text read `text: parent.title` and
+8
View File
@@ -180,6 +180,14 @@ Rectangle {
: QbzShell.currentView === "mostplayedalbums" ? "../views/PlayHistoryView.qml" : QbzShell.currentView === "mostplayedalbums" ? "../views/PlayHistoryView.qml"
: QbzShell.currentView === "label" ? "../views/LabelView.qml" : QbzShell.currentView === "label" ? "../views/LabelView.qml"
: QbzShell.currentView === "labelreleases" ? "../views/LabelReleasesView.qml" : QbzShell.currentView === "labelreleases" ? "../views/LabelReleasesView.qml"
// Artist page > a release section > "See discography", and the
// album page's "From the same artist" View all. Same two-file
// contract as the rest of this chain (nav_qt.rs:9-16):
// artist_releases_qt::open records the id, this arm mounts it.
// Forgetting the arm is NOT a crash — the ternary falls through
// to "" and the pane goes blank with nothing logged, i.e. the
// dead "See discography" in a new costume.
: QbzShell.currentView === "artistreleases" ? "../views/ArtistReleasesView.qml"
// For You > Qobuz Mixes. The whole chain existed — // For You > Qobuz Mixes. The whole chain existed —
// HomeView's tile -> QbzHome.openMix -> foryou_qt -> nav_qt // HomeView's tile -> QbzHome.openMix -> foryou_qt -> nav_qt
// records the "mix" view — and MixView.qml is written and // records the "mix" view — and MixView.qml is written and
+29
View File
@@ -1059,30 +1059,57 @@ Rectangle {
// (album_qt.rs spawn_deferred_rows). While a fetch is out the // (album_qt.rs spawn_deferred_rows). While a fetch is out the
// rail's placeholder holds its band; when it resolves to nothing // rail's placeholder holds its band; when it resolves to nothing
// both the placeholder and the rail are gone — no empty frame. // 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 } Item { visible: moreRail.visible || moreRailSk.visible; width: 1; height: 40 }
RailSkeleton { RailSkeleton {
id: moreRailSk id: moreRailSk
width: parent.width - 64
visible: root.moreLoading visible: root.moreLoading
phase: skeletonPhase.on phase: skeletonPhase.on
cardCount: root.railSkeletonCount cardCount: root.railSkeletonCount
} }
SectionRail { SectionRail {
id: moreRail id: moreRail
width: parent.width - 64
visible: (album.moreFromArtist || []).length > 0 visible: (album.moreFromArtist || []).length > 0
title: QbzSession.tr("From the same artist", QbzSession.trRev) title: QbzSession.tr("From the same artist", QbzSession.trRev)
items: album.moreFromArtist || [] items: album.moreFromArtist || []
coverMap: root.coverMap 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 } Item { visible: sugRail.visible || sugRailSk.visible; width: 1; height: 40 }
RailSkeleton { RailSkeleton {
id: sugRailSk id: sugRailSk
width: parent.width - 64
visible: root.suggestionsLoading visible: root.suggestionsLoading
phase: skeletonPhase.on phase: skeletonPhase.on
cardCount: root.railSkeletonCount cardCount: root.railSkeletonCount
} }
SectionRail { SectionRail {
id: sugRail id: sugRail
width: parent.width - 64
visible: (album.suggestions || []).length > 0 visible: (album.suggestions || []).length > 0
title: QbzSession.tr("Listening suggestions", QbzSession.trRev) title: QbzSession.tr("Listening suggestions", QbzSession.trRev)
items: album.suggestions || [] items: album.suggestions || []
@@ -1096,12 +1123,14 @@ Rectangle {
Item { visible: simRail.visible || simRailSk.visible; width: 1; height: 40 } Item { visible: simRail.visible || simRailSk.visible; width: 1; height: 40 }
RailSkeleton { RailSkeleton {
id: simRailSk id: simRailSk
width: parent.width - 64
visible: root.similarLoading visible: root.similarLoading
phase: skeletonPhase.on phase: skeletonPhase.on
cardCount: root.railSkeletonCount cardCount: root.railSkeletonCount
} }
SectionRail { SectionRail {
id: simRail id: simRail
width: parent.width - 64
visible: (album.similarAlbums || []).length > 0 visible: (album.similarAlbums || []).length > 0
title: QbzSession.tr("Similar albums", QbzSession.trRev) title: QbzSession.tr("Similar albums", QbzSession.trRev)
items: album.similarAlbums || [] 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 // MusicBrainz is off in Settings or the artist has no confident MB match, is
// simply ABSENT — never an error frame, and nothing is requested. // 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 // engines (dropdown inert), multi-select, the sticky behavior of the JUMP TO
// bar (it scrolls with the page). // 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 // Header atmosphere (ArtistPageView.slint:120-147, 211-247): wired through
// the shared controls/HeaderGradient.qml — the SAME component AlbumView // the shared controls/HeaderGradient.qml — the SAME component AlbumView
// mounts, because the .slint paints both headers from identical blocks. It // 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 // A section whose albums all filter out DISAPPEARS (artist.rs:1033), and
// Load more is suppressed while a filter is active (:1042 — appending // Load more is suppressed while a filter is active (:1042 — appending
// would bring back unfiltered items). // 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: { readonly property var releaseSections: {
var all = artist.releaseSections || [] var all = artist.releaseSections || []
if (root.needle === "") return all
var out = [] var out = []
for (var i = 0; i < all.length; i++) { 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 kept = []
var cards = all[i].cards || []
for (var j = 0; j < cards.length; j++) for (var j = 0; j < cards.length; j++)
if ((cards[j].title || "").toLowerCase().indexOf(root.needle) >= 0) if ((cards[j].title || "").toLowerCase().indexOf(root.needle) >= 0)
kept.push(cards[j]) kept.push(cards[j])
if (kept.length === 0) continue if (kept.length === 0) continue
out.push({ "releaseType": all[i].releaseType, "title": all[i].title, out.push({ "releaseType": src.releaseType, "title": src.title,
"cards": kept, "hasMore": false }) "cards": kept, "hasMore": false, "sortBy": sortBy })
} }
return out 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 labels: artist.labels || []
readonly property var similarArtists: artist.similarArtists || [] readonly property var similarArtists: artist.similarArtists || []
readonly property var playlists: artist.playlists || [] readonly property var playlists: artist.playlists || []
@@ -150,6 +278,253 @@ Rectangle {
property bool appearsOnExpanded: false property bool appearsOnExpanded: false
property bool otherExpanded: 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 ------------------------------------- // ---- Network sidebar open/closed -------------------------------------
// ShellState.content-constrained (state.slint:4114): window under the NPB // ShellState.content-constrained (state.slint:4114): window under the NPB
// breakpoint AND a right panel (Queue / Lyrics) open. NOT a raw // breakpoint AND a right panel (Queue / Lyrics) open. NOT a raw
@@ -357,23 +732,29 @@ Rectangle {
Connections { Connections {
target: QbzArtist target: QbzArtist
function onReleaseSectionReady(releaseType, cardsJson, hasMore) { 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) var cards = JSON.parse(cardsJson)
// The document, not root.releaseSections: that one is a FILTERED root.releaseMoreLanded(releaseType, cards, hasMore)
// projection and may hand back fresh objects, so a push into it // The appended covers are not in the document (artist_qt.rs folds
// would be dropped on the next re-evaluation. // them into the STASHED copy, which only reaches us on the next
var sections = root.artist.releaseSections || [] // enrichment republish), so the document-driven dispatch would
for (var i = 0; i < sections.length; i++) { // leave the new row as blank tiles until an unrelated pass
if (sections[i].releaseType === releaseType) { // happened to land. dispatchCovers() reads the overlay too, and
var seen = {} // `dispatchedCovers` makes the re-scan free.
for (var j = 0; j < sections[i].cards.length; j++) seen[sections[i].cards[j].id] = true root.dispatchCovers()
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()
} }
} }
Component.onCompleted: { Component.onCompleted: {
@@ -407,6 +788,18 @@ Rectangle {
networkOpen = !contentConstrained networkOpen = !contentConstrained
dismissedDiscovery = ({}) dismissedDiscovery = ({})
localToggles = ({}) 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 membersExpanded = false
groupsExpanded = false groupsExpanded = false
collabsExpanded = false collabsExpanded = false
@@ -473,6 +866,17 @@ Rectangle {
for (i = 0; i < rawSections.length; i++) for (i = 0; i < rawSections.length; i++)
for (j = 0; j < (rawSections[i].cards || []).length; j++) for (j = 0; j < (rawSections[i].cards || []).length; j++)
if (rawSections[i].cards[j].artUrl) urls.push(rawSections[i].cards[j].artUrl) 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++) for (i = 0; i < rawPlaylists.length; i++)
if (rawPlaylists[i].artUrl) urls.push(rawPlaylists[i].artUrl) if (rawPlaylists[i].artUrl) urls.push(rawPlaylists[i].artUrl)
// Magazine story thumbnails ride the same pipeline (arc-cdn URLs). // Magazine story thumbnails ride the same pipeline (arc-cdn URLs).
@@ -557,11 +961,21 @@ Rectangle {
elide: Text.ElideRight elide: Text.ElideRight
} }
// Cover with hover play overlay. // 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 { Rectangle {
width: showAlbum ? cols.colArt : cols.colNumber width: showAlbum ? cols.colArt : cols.colNumber
height: showAlbum ? cols.colArt : 28 height: showAlbum ? cols.colArt : 28
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
radius: theme.radiusSm radius: 4
color: theme.surfaceElevated color: theme.surfaceElevated
clip: true clip: true
QbzIcon { QbzIcon {
@@ -574,11 +988,11 @@ Rectangle {
RoundedImage { RoundedImage {
anchors.fill: parent anchors.fill: parent
source: root.coverMap[row.artUrl] || "" source: root.coverMap[row.artUrl] || ""
radius: theme.radiusSm radius: 4
} }
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
radius: theme.radiusSm radius: 4
color: "#000000" color: "#000000"
opacity: trArea.containsMouse || isActive ? 0.6 : 0.0 opacity: trArea.containsMouse || isActive ? 0.6 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } } Behavior on opacity { NumberAnimation { duration: 150 } }
@@ -1018,6 +1432,10 @@ Rectangle {
// Release section (ReleaseGrid). // Release section (ReleaseGrid).
component ReleaseSection: Column { 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 var section: ({})
property string anchorId: "" property string anchorId: ""
width: parent ? parent.width : 0 width: parent ? parent.width : 0
@@ -1048,35 +1466,95 @@ Rectangle {
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor 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: // Per-section sort — ReleaseGrid.slint:70-90, the SAME control the
// `set-section-sort` persists per release_type and re-sorts // reference mounts (primitives/QbzSelect.slint), here through the
// server-side, and neither exists on this bridge — so the control // port's existing replica controls/QbzSelect.qml.
// is DIMMED and carries no MouseArea at all instead of offering a //
// pointer cursor over a menu that never opens. // `sm: true` + `menuWidth: 150` are the .slint's own values,
Rectangle { // 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 id: sortBtn
width: sortRow.width
height: 28
radius: 5
opacity: 0.4
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceElevated sm: true
Row { menuWidth: 150
id: sortRow // The .slint's five options in its order (:74-79). "A–Z"/"Z–A"
height: parent.height // carry a U+2013 EN DASH — the msgid in the catalogue is the
leftPadding: 10 // en-dash form, and a hyphen would silently go untranslated in
rightPadding: 10 // all seven non-English locales. (LibraryToolbar's "Title A-Z"
spacing: 6 // is a DIFFERENT, hyphenated msgid — do not copy it here.)
Text { options: [QbzSession.tr("Default", QbzSession.trRev),
anchors.verticalCenter: parent.verticalCenter QbzSession.tr("Newest", QbzSession.trRev),
text: QbzSession.tr("Newest", QbzSession.trRev) QbzSession.tr("Oldest", QbzSession.trRev),
color: theme.textSecondary QbzSession.tr("A–Z", QbzSession.trRev),
font.pixelSize: 12 QbzSession.tr("Z–A", QbzSession.trRev)]
} // ReleaseGrid.slint:34-39 — the wire key back to an index. This
QbzIcon { name: "chevron-down"; width: 12; height: 12; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" } // 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 { Repeater {
model: section.cards model: section.cards
delegate: AlbumCard { delegate: AlbumCard {
id: relCard
albumId: modelData.id albumId: modelData.id
title: modelData.title title: modelData.title
// The subtitle slot carries the YEAR on the artist page, // 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 // artist_qt::map_release stamps this the same way it
// stamps the pin; false inverted the first click. // stamps the pin; false inverted the first click.
isFavorite: modelData.isFavorite === true 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 visible: section.hasMore
width: parent.width width: parent.width
Item { width: (parent.width - loadMoreBtn.width) / 2; height: 1 } // The grid above has a 224 x 270 PITCH: cards/AlbumCard.qml:186-189
Rectangle { // is 200 x 246 and the Grid adds 24px of column/row spacing — the
id: loadMoreBtn // same pitch SearchView's block was built around, so the
width: loadMoreText.implicitWidth + 24 // placeholder lands exactly where the first appended row will.
height: 28 skeleton: "cards"
color: "transparent" cellW: 224
Text { cellH: 270
id: loadMoreText // There is NO per-section loading flag to bind to: the bridge
anchors.centerIn: parent // publishes one artist document plus the `releaseSectionReady`
text: QbzSession.tr("Load more", QbzSession.trRev) // signal (artist_bridge.rs:60-66) and nothing in between. So the
color: loadMoreArea.containsMouse ? theme.textPrimary : theme.textSecondary // in-flight state is driven locally, off root's map, cleared by
font.pixelSize: 13 // that signal and capped by root's 8s settle timer (the error path
} // emits nothing at all — main.rs:872).
MouseArea { busy: root.releasePending[section.releaseType] === true
id: loadMoreArea onClicked: {
anchors.fill: parent // BEFORE the call: the threshold is the count on screen now
hoverEnabled: true // (`section.cards` is the MERGED array — document page 1 plus
cursorShape: Qt.PointingHandCursor // every page the overlay holds — so the fade threshold lines
onClicked: QbzArtist.loadReleaseSection(artist.id, section.releaseType, section.cards.length) // 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 { Repeater {
model: topTracks.length model: topTracks.length
delegate: PopularTrackRow { delegate: PopularTrackRow {
visible: root.topTracksExpanded || index < root.preview // Smooth reveal (owner, 2026-08-02). This expander is
height: visible ? 50 : 0 // 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] row: topTracks[index]
rowIndex: index rowIndex: index
showAlbum: true showAlbum: true
} }
} }
Item { visible: topTracks.length > root.preview; width: 1; height: 4 } 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 visible: topTracks.length > root.preview
width: parent.width width: parent.width
height: 28 label: root.topTracksExpanded ? QbzSession.tr("View less", QbzSession.trRev) : QbzSession.tr("Load more", QbzSession.trRev)
color: "transparent" onClicked: root.topTracksExpanded = !root.topTracksExpanded
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
}
}
} }
// --- Latest release -------------------------------------- // --- Latest release --------------------------------------
@@ -1676,32 +2219,26 @@ Rectangle {
Repeater { Repeater {
model: appearsOn.length model: appearsOn.length
delegate: PopularTrackRow { delegate: PopularTrackRow {
visible: root.appearsOnExpanded || index < root.preview // Same client-side reveal as Popular Tracks above
height: visible ? 50 : 0 // — 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] row: appearsOn[index]
rowIndex: index rowIndex: index
showAlbum: false showAlbum: false
} }
} }
Item { visible: appearsOn.length > root.preview; width: 1; height: 4 } 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 visible: appearsOn.length > root.preview
width: parent.width width: parent.width
height: 28 label: root.appearsOnExpanded ? QbzSession.tr("View less", QbzSession.trRev) : QbzSession.tr("Load more", QbzSession.trRev)
color: "transparent" onClicked: root.appearsOnExpanded = !root.appearsOnExpanded
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
}
}
} }
} }
@@ -2390,7 +2927,7 @@ Rectangle {
model: [ model: [
{ "label": QbzSession.tr("Create Artist Collection", QbzSession.trRev), "icon": "library-big", "action": "disco", "live": true }, { "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("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 }, { "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 { delegate: Rectangle {
@@ -2432,6 +2969,23 @@ Rectangle {
// "build-collection")`. This is the ONLY route // "build-collection")`. This is the ONLY route
// to it; the nav flyout has no builder entry. // to it; the nav flyout has no builder entry.
QbzDisco.open(artist.id) 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: [] property var items: []
// The host's url-keyed cover map ({url: file://path}). // The host's url-keyed cover map ({url: file://path}).
property var coverMap: ({}) 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 } QbzTheme { id: theme }
width: parent ? parent.width : 0 width: parent ? parent.width : 0
@@ -27,6 +34,10 @@ Column {
QbzSectionHeader { QbzSectionHeader {
title: root.title 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 leftEnabled: rail.contentX > 1
rightEnabled: rail.contentX < root.maxScroll - 1 rightEnabled: rail.contentX < root.maxScroll - 1
onPageLeft: rail.contentX = Math.max(0, rail.contentX - root.step) onPageLeft: rail.contentX = Math.max(0, rail.contentX - root.step)
+10 -2
View File
@@ -36,7 +36,7 @@ use cxx_qt_lib::QString;
use qbz_app::shell::AppRuntime; use qbz_app::shell::AppRuntime;
use qbz_core::LoggingAdapter; use qbz_core::LoggingAdapter;
use qbz_models::{Album, Track}; use qbz_models::{Album, Track};
use serde::Serialize; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use crate::home_qt; use crate::home_qt;
@@ -98,7 +98,15 @@ pub struct TrackRow {
pub is_favorite: bool, pub is_favorite: bool,
} }
#[derive(Clone, Serialize)] /// `Deserialize` rides along with `Serialize` so a card can be read BACK out of
/// the stashed artist document: `artist_qt::sort_release_values` re-sorts a
/// release bucket that already lives on disk-shaped JSON (the stash the
/// progressive enrichment passes republish), and doing that through the typed
/// struct keeps ONE copy of the sort arms — `artist_qt::sort_release_cards` —
/// instead of a second comparator written against raw `serde_json::Value`.
/// The `#[serde(rename)]`s below are bidirectional, so the camelCase the QML
/// document carries round-trips unchanged.
#[derive(Clone, Serialize, Deserialize)]
pub struct AlbumCardData { pub struct AlbumCardData {
pub id: String, pub id: String,
pub title: String, pub title: String,
+133 -2
View File
@@ -6,8 +6,13 @@
//! documented precedent). The QML type name comes from the QObject //! documented precedent). The QML type name comes from the QObject
//! (`QbzArtist`), not from the module, so QML is unaffected. //! (`QbzArtist`), not from the module, so QML is unaffected.
//! //!
//! Props: the one artist-view JSON document + its loading flag. //! Props: the one artist-view JSON document + its loading flag, and the one
//! Invokables: openArtist, loadReleaseSection (per-bucket "Load more"). //! discography-page document (artistReleasesJson — the sub-view reached from
//! "See discography", state and all).
//! Invokables: openArtist, resolveMusician, share, loadReleaseSection
//! (per-bucket "Load more"), setSectionSort (per-bucket release sort, persisted
//! by release_type), and the discography page's four —
//! openReleases / releasesLoadMore / releasesSetSort / releasesRetry.
//! Signal: releaseSectionReady (the next page of one releases bucket). //! Signal: releaseSectionReady (the next page of one releases bucket).
//! //!
//! `view_param_id` is NOT here and NOT in album_bridge.rs: it was a //! `view_param_id` is NOT here and NOT in album_bridge.rs: it was a
@@ -41,6 +46,12 @@ pub mod qbz_artist_bridge {
// ONE JSON document (artist_qt.rs ArtistViewData: header + top // ONE JSON document (artist_qt.rs ArtistViewData: header + top
// tracks + releases buckets + labels/similar/playlists). // tracks + releases buckets + labels/similar/playlists).
#[qproperty(QString, artist_json)] #[qproperty(QString, artist_json)]
// --- Discography sub-view ------------------------------------------
// ONE JSON document too (artist_releases_qt.rs ArtistReleasesDoc:
// header + the paged grid + loading / error / has-more / sort). Its
// `loading` rides INSIDE the document rather than as a second
// property — the reason is written down on that struct.
#[qproperty(QString, artist_releases_json)]
type QbzArtist = super::QbzArtistRust; type QbzArtist = super::QbzArtistRust;
@@ -57,10 +68,79 @@ pub mod qbz_artist_bridge {
#[qinvokable] #[qinvokable]
fn resolve_musician(self: Pin<&mut QbzArtist>, name: QString, role: QString); fn resolve_musician(self: Pin<&mut QbzArtist>, name: QString, role: QString);
/// Header ⋯ → Share. `ArtistPageView.slint:530-538` fires
/// `media-action("artist", ArtistState.id, "share")`; the arm at
/// `main.rs:12749` copies `share::qobuz_artist_url(&id)` and raises
/// the "Link copied" success toast. Link-only — artists have no
/// Song.link/Album.link path (see share_qt.rs).
///
/// `#[auto_cxx_name]` on this block makes the QML name
/// `QbzArtist.share(...)`; renaming the Rust fn renames the QML call,
/// which QML only discovers when the menu opens.
#[qinvokable]
fn share(self: Pin<&mut QbzArtist>, artist_id: QString);
/// ArtistView per-section "Load more" — the next releases page. /// ArtistView per-section "Load more" — the next releases page.
#[qinvokable] #[qinvokable]
fn load_release_section(self: Pin<&mut QbzArtist>, artist_id: QString, release_type: QString, offset: i32); fn load_release_section(self: Pin<&mut QbzArtist>, artist_id: QString, release_type: QString, offset: i32);
/// ArtistView per-section sort — `ReleaseGrid.slint:26`
/// `set-section-sort(release-type, sort)`, fired by that view's
/// QbzSelect. `sort` is one of `default` | `newest` | `oldest` |
/// `title-asc` | `title-desc` (the .slint's index mapping at :81-89),
/// and those five strings are BOTH the persisted values and the
/// sort-function keys.
///
/// Persists by release_type and re-sorts the LOADED bucket in place —
/// the reference never refetches (main.rs:14997-15007 is the whole
/// handler; see the wire note on `artist_qt::resort_section`).
///
/// `#[auto_cxx_name]` makes the QML name `QbzArtist.setSectionSort`.
#[qinvokable]
fn set_section_sort(self: Pin<&mut QbzArtist>, release_type: QString, sort: QString);
// --- Discography page ----------------------------------------------
// `#[auto_cxx_name]` makes the QML names QbzArtist.openReleases /
// .releasesLoadMore / .releasesSetSort / .releasesRetry. QML resolves
// singleton members LAZILY: a call with no matching invokable here
// compiles clean and throws the first time a user clicks, so these four
// and the QML that calls them are one edit.
/// "See discography" (artist/ReleaseGrid.slint:63-68 ->
/// ArtistPageView.slint:928-929 -> main.rs:15055-15087) and the album
/// page's "From the same artist" View all
/// (album/AlbumPageView.slint:1131 -> main.rs:11839-11865, which pins
/// `release_type` to "album").
///
/// The NAME travels with the call rather than being read out of the
/// artist document, because the second door has no artist document
/// open — it passes `AlbumState.artist`, exactly as main.rs:11844 does.
#[qinvokable]
fn open_releases(
self: Pin<&mut QbzArtist>,
artist_id: QString,
artist_name: QString,
release_type: QString,
);
/// Infinite-scroll tail (ArtistReleasesView.slint:121-129). The page
/// has no "Load more" button — the view fires this when the flick gets
/// within 600px of the bottom, and every guard lives in Rust as well so
/// a burst of scroll frames cannot queue two pages.
#[qinvokable]
fn releases_load_more(self: Pin<&mut QbzArtist>);
/// The header's sort select. Client-side only, persisted per
/// release_type in the SAME store as the artist page's per-section
/// picker (artist_prefs.rs) — see artist_releases_qt::set_sort.
#[qinvokable]
fn releases_set_sort(self: Pin<&mut QbzArtist>, sort: QString);
/// The error state's Retry button (main.rs:15158-15176) — a full reset
/// + refetch that records NO nav entry.
#[qinvokable]
fn releases_retry(self: Pin<&mut QbzArtist>);
/// Emitted with the next page of a releases bucket. /// Emitted with the next page of a releases bucket.
#[qsignal] #[qsignal]
fn release_section_ready(self: Pin<&mut QbzArtist>, release_type: QString, cards_json: QString, has_more: bool); fn release_section_ready(self: Pin<&mut QbzArtist>, release_type: QString, cards_json: QString, has_more: bool);
@@ -75,6 +155,7 @@ use qbz_artist_bridge::QbzArtist;
pub struct QbzArtistRust { pub struct QbzArtistRust {
artist_loading: bool, artist_loading: bool,
artist_json: QString, artist_json: QString,
artist_releases_json: QString,
} }
impl Default for QbzArtistRust { impl Default for QbzArtistRust {
@@ -82,6 +163,9 @@ impl Default for QbzArtistRust {
Self { Self {
artist_loading: false, artist_loading: false,
artist_json: QString::from("{}"), artist_json: QString::from("{}"),
// "{}" and not "": the views JSON.parse this straight, and an empty
// string throws into the catch arm on the very first frame.
artist_releases_json: QString::from("{}"),
} }
} }
} }
@@ -114,8 +198,55 @@ impl qbz_artist_bridge::QbzArtist {
pub fn load_release_section(self: Pin<&mut Self>, artist_id: QString, release_type: QString, offset: i32) { pub fn load_release_section(self: Pin<&mut Self>, artist_id: QString, release_type: QString, offset: i32) {
crate::load_release_section(artist_id.to_string(), release_type.to_string(), offset); crate::load_release_section(artist_id.to_string(), release_type.to_string(), offset);
} }
/// Straight through to `artist_qt`, with no `crate::` forwarder in main.rs:
/// unlike `load_release_section` (main.rs:856) this path is SYNCHRONOUS and
/// touches no `AppRuntime` — it persists a small json and patches the
/// already-stashed document. Wrapping it in an async hop would only add a
/// frame of latency between the click and the re-ordered grid.
pub fn set_section_sort(self: Pin<&mut Self>, release_type: QString, sort: QString) {
crate::artist_qt::resort_section(&release_type.to_string(), &sort.to_string());
}
pub fn resolve_musician(self: Pin<&mut Self>, name: QString, role: QString) { pub fn resolve_musician(self: Pin<&mut Self>, name: QString, role: QString) {
crate::resolve_musician(name.to_string(), role.to_string()); crate::resolve_musician(name.to_string(), role.to_string());
} }
/// Straight through to `share_qt` — no nav and no bridge state is touched,
/// so there is no `crate::` forwarder in main.rs for this one (the
/// `crate::open_artist` style exists because that one mutates both).
pub fn share(self: Pin<&mut Self>, artist_id: QString) {
crate::share_qt::share_artist(artist_id.to_string());
}
// --- Discography page ---------------------------------------------------
// One-line delegates into `artist_releases_qt`, with no `crate::` forwarder
// in main.rs: that indirection exists for the paths that mutate main.rs's
// own state (LAST_DETAIL, the nav latch). This page owns its state in its
// own module and records its own nav entry, so there is nothing for main.rs
// to coordinate.
pub fn open_releases(
self: Pin<&mut Self>,
artist_id: QString,
artist_name: QString,
release_type: QString,
) {
crate::artist_releases_qt::open(
artist_id.to_string(),
artist_name.to_string(),
release_type.to_string(),
);
}
pub fn releases_load_more(self: Pin<&mut Self>) {
crate::artist_releases_qt::load_more();
}
pub fn releases_set_sort(self: Pin<&mut Self>, sort: QString) {
crate::artist_releases_qt::set_sort(sort.to_string());
}
pub fn releases_retry(self: Pin<&mut Self>) {
crate::artist_releases_qt::retry();
}
} }
+156
View File
@@ -0,0 +1,156 @@
//! Artist page per-section release sort — the Qt half of
//! `crates/qbz/src/artist_prefs.rs` (78 lines, read in full for this port).
//!
//! SAME document, SAME disk key: `<data-dir>/qbz/artist_ui.json`, an object
//! whose `section_sort` member maps `release_type` -> sort key
//! (`newest` | `oldest` | `title-asc` | `title-desc`). That is deliberate and
//! is the whole point of the reference's design note — the choice is keyed by
//! release_type ONLY, never by artist, "so a chosen sort sticks across artists
//! and restarts". A user who alternates between the shipping Slint build and
//! this one must find the same sort, so the snake_case key and the five exact
//! wire strings are not ours to rename.
//!
//! Like `library_prefs.rs` (favorites_ui.json) and `local_state.rs`
//! (locallibrary_ui.json), this file follows the PORT's writer, not the
//! Slint's:
//!
//! - the Slint `set_sort()` serializes its whole `Prefs` struct through
//! `fs::write` (O_TRUNC — a reader that lands mid-write sees an empty file
//! and answers `Prefs::default()`), and in doing so drops any key the file
//! carries that its struct does not know about;
//! - here a write is a read-modify-write of the parsed OBJECT, published
//! through `settings_qt::write_json_object_atomic` (temp file + `rename(2)`),
//! so a concurrent reader sees the whole old document or the whole new one,
//! and any key `artist_ui.json` gains later survives our writes untouched.
//!
//! There is deliberately NO process-local cache here. The Slint keeps a
//! `thread_local!` one (`artist_prefs.rs:19-22`); this file is co-owned with a
//! second process, and it is read at most once per artist page load (one
//! `read_all()` for every bucket), so a stale cache is the worse trade.
use std::collections::HashMap;
use std::path::PathBuf;
use serde_json::{Map, Value};
/// The on-disk member name. SNAKE_CASE because the Slint's
/// `struct Prefs { section_sort: HashMap<String, String> }` (artist_prefs.rs:16)
/// reads this exact name — renaming it silently splits the two frontends'
/// stored sorts instead of erroring.
const SECTION_SORT_KEY: &str = "section_sort";
/// The value stored for "leave the order the server sent". Never written to
/// disk: see `apply_sort`.
pub(crate) const DEFAULT_SORT: &str = "default";
fn store_path() -> Option<PathBuf> {
Some(dirs::data_dir()?.join("qbz").join("artist_ui.json"))
}
/// Every persisted bucket sort, `release_type -> sort`. Read ONCE per artist
/// page build (`artist_qt::map_artist`) rather than once per bucket.
///
/// Any failure — no data dir, unreadable file, a `section_sort` that is not an
/// object — yields an empty map, i.e. every bucket falls back to "default".
/// That matches the Slint's `unwrap_or_default()` on a bad parse.
pub(crate) fn read_all() -> HashMap<String, String> {
let Some(path) = store_path() else {
return HashMap::new();
};
let Some(doc) = crate::settings_qt::read_json_object(&path) else {
return HashMap::new();
};
let Some(map) = doc.get(SECTION_SORT_KEY).and_then(|v| v.as_object()) else {
return HashMap::new();
};
map.iter()
.filter_map(|(rt, v)| v.as_str().map(|s| (rt.clone(), s.to_string())))
.collect()
}
/// ONE bucket's persisted sort — the port of `qbz/src/artist_prefs.rs:45-52`
/// `get_sort`, which `artist_releases::reset` (`artist_releases.rs:25`) calls to
/// seat the discography page's picker on the choice the user already made on
/// the artist page. The two surfaces deliberately SHARE the store: the pref is
/// keyed by release_type only, so opening "See discography" on a bucket sorted
/// A–Z lands on an A–Z page.
///
/// The artist page itself does NOT go through here — `map_artist` reads the
/// whole map once per page build (`read_all`) rather than once per bucket. This
/// accessor exists for the single-bucket callers.
pub(crate) fn get_sort(release_type: &str) -> String {
read_all()
.get(release_type)
.cloned()
.unwrap_or_else(|| DEFAULT_SORT.to_string())
}
/// The `section_sort` edit, factored out of `set_sort` so it can be tested
/// without a data dir.
///
/// "default" REMOVES the entry instead of storing the string (the Slint's
/// `artist_prefs.rs:61-67`, "'default' removes the entry to keep the file
/// small"). Storing it would round-trip correctly today but would diverge from
/// the file the Slint writes, which is the one thing this module exists to
/// avoid.
fn apply_sort(map: &mut Map<String, Value>, release_type: &str, sort: &str) {
if sort == DEFAULT_SORT {
map.remove(release_type);
} else {
map.insert(release_type.to_string(), Value::String(sort.to_string()));
}
}
/// Persist one bucket's sort. Read-modify-write of the WHOLE document so the
/// `section_sort` sub-object is merged, never rebuilt, and no sibling key is
/// lost (see the header).
pub(crate) fn set_sort(release_type: &str, sort: &str) {
let Some(path) = store_path() else {
return;
};
// `None` means the file exists but did not parse: the caller must not
// write, or it would replace a document it never managed to read
// (settings_qt::read_json_object).
let Some(mut doc) = crate::settings_qt::read_json_object(&path) else {
return;
};
let mut sorts = match doc.get(SECTION_SORT_KEY) {
Some(Value::Object(m)) => m.clone(),
_ => Map::new(),
};
apply_sort(&mut sorts, release_type, sort);
doc.insert(SECTION_SORT_KEY.to_string(), Value::Object(sorts));
crate::settings_qt::write_json_object_atomic(&path, &doc);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_disk_key_stays_snake_case_for_the_slint() {
// qbz/src/artist_prefs.rs:16 reads this exact member name.
assert_eq!(SECTION_SORT_KEY, "section_sort");
}
#[test]
fn default_removes_the_entry_rather_than_storing_it() {
let mut map = Map::new();
apply_sort(&mut map, "album", "title-asc");
assert_eq!(map.get("album").and_then(|v| v.as_str()), Some("title-asc"));
apply_sort(&mut map, "album", "default");
assert!(map.get("album").is_none(), "'default' must delete the key");
}
#[test]
fn buckets_are_independent() {
// The pref is keyed by release_type ONLY — one artist's choice for
// "album" must not disturb "live" (and applies to every artist).
let mut map = Map::new();
apply_sort(&mut map, "album", "newest");
apply_sort(&mut map, "live", "oldest");
apply_sort(&mut map, "album", "default");
assert!(map.get("album").is_none());
assert_eq!(map.get("live").and_then(|v| v.as_str()), Some("oldest"));
}
}
+240 -9
View File
@@ -67,6 +67,33 @@ const RELEASE_SECTION_ORDER: &[(&str, &str)] = &[
("next", "Upcoming"), ("next", "Upcoming"),
]; ];
/// Title-case a raw release_type key — the fallback for a bucket the table
/// above does not know (`qbz/src/artist.rs:117-123` `title_case`).
fn title_case(key: &str) -> String {
let mut chars = key.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
/// Display title for one release_type — the discography page's header
/// (`qbz/src/artist.rs:107-114` `release_type_title`).
///
/// Same table, same fallback, as the per-section headers `map_artist` builds
/// below: the page the user lands on after "See discography" must be titled
/// exactly like the section they clicked from.
///
/// Translated HERE rather than in QML because it travels inside a JSON
/// document — which is also why `artist_releases_qt::republish` exists.
pub(crate) fn release_type_title(release_type: &str) -> String {
RELEASE_SECTION_ORDER
.iter()
.find(|(rt, _)| *rt == release_type)
.map(|(_, title)| qbz_i18n::t(title))
.unwrap_or_else(|| title_case(release_type))
}
#[derive(Clone, Serialize)] #[derive(Clone, Serialize)]
pub struct ArtistReleaseSection { pub struct ArtistReleaseSection {
#[serde(rename = "releaseType")] #[serde(rename = "releaseType")]
@@ -75,6 +102,20 @@ pub struct ArtistReleaseSection {
#[serde(rename = "hasMore")] #[serde(rename = "hasMore")]
pub has_more: bool, pub has_more: bool,
pub cards: Vec<AlbumCardData>, pub cards: Vec<AlbumCardData>,
/// The bucket's persisted sort (`default` | `newest` | `oldest` |
/// `title-asc` | `title-desc`) — the Slint's `ArtistReleaseSection.sort-by`.
///
/// It travels WITH the data because that is what makes the picker come
/// back showing the user's choice: `ReleaseGrid.slint:34-39` maps this
/// string back to a `current-index` for its QbzSelect, and the QML port
/// does the same (`ArtistView.qml`, the ReleaseSection header). The value
/// is stamped here at page build from `artist_prefs::read_all()`, so the
/// FIRST paint is already in the right order — the cards below are sorted
/// before this struct exists (`artist.rs:786-812`: "Apply the persisted
/// per-bucket sort up front so the first paint already honors the user's
/// choice").
#[serde(rename = "sortBy")]
pub sort_by: String,
} }
#[derive(Clone, Serialize)] #[derive(Clone, Serialize)]
@@ -293,6 +334,77 @@ pub(crate) fn map_release(release: &PageArtistRelease) -> AlbumCardData {
} }
} }
/// `album_map::sort_album_items` (crates/qbz/src/album_map.rs:244-264) for the
/// artist page's release buckets — the SAME function `label_qt::sort_cards`
/// already ports for `HomeCard`, here for `AlbumCardData`.
///
/// `year` on these cards is the PLAIN 4-digit year (`map_release` slices
/// `dates.original[..4]`), so the lexicographic compare IS a chronological one.
/// `title`/`artist` are case-insensitive, per the reference's doc comment.
///
/// "default" — and any key this function does not know — falls through
/// UNSORTED (`album_map.rs:262` is a bare `_ => {}`). That is not an oversight:
/// "Default" in the picker means "the order Qobuz sent", which is exactly what
/// leaving the vector alone produces, and it is why `artist_prefs::set_sort`
/// DELETES the entry for it instead of storing a no-op.
///
/// `artist-asc` / `artist-desc` are unreachable from the artist page's picker
/// (its five options stop at title-desc) but are carried anyway so this stays
/// 1:1 with `sort_album_items`.
///
/// The discography page has its OWN copy of these arms
/// (`artist_releases_qt::sort_cards`) rather than reusing this one: its rows
/// are `home_qt::HomeCard` (they must carry `artPath` for `AlbumCollection`),
/// not `AlbumCardData`, so the two differ in their item type and nothing else.
/// `label_qt::sort_cards` is the third copy, for the same reason.
pub(crate) fn sort_release_cards(items: &mut [AlbumCardData], sort: &str) {
match sort {
"oldest" | "year-asc" => items.sort_by(|a, b| a.year.cmp(&b.year)),
"newest" | "year-desc" => items.sort_by(|a, b| b.year.cmp(&a.year)),
"title-asc" => items.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())),
"title-desc" => items.sort_by(|a, b| b.title.to_lowercase().cmp(&a.title.to_lowercase())),
"artist-asc" => items.sort_by(|a, b| a.artist.to_lowercase().cmp(&b.artist.to_lowercase())),
"artist-desc" => {
items.sort_by(|a, b| b.artist.to_lowercase().cmp(&a.artist.to_lowercase()))
}
_ => {}
}
}
/// `sort_release_cards` over the STASHED document's `cards` array, which is raw
/// JSON rather than typed rows (the stash is a `serde_json::Value` — see
/// `ARTIST_DOC`).
///
/// The round-trip through `AlbumCardData` is deliberate: it keeps ONE copy of
/// the sort arms above instead of a second comparator written against
/// `Value`, and these arrays are at most a few dozen rows. A row that fails to
/// deserialize leaves the whole bucket untouched rather than half-sorted — the
/// document is what the view paints, and a partial re-order would be worse
/// than none.
fn sort_release_values(items: &mut [serde_json::Value], sort: &str) {
if sort == crate::artist_prefs::DEFAULT_SORT {
return;
}
let parsed: Result<Vec<AlbumCardData>, _> = items
.iter()
.map(|v| serde_json::from_value::<AlbumCardData>(v.clone()))
.collect();
let mut cards = match parsed {
Ok(cards) => cards,
Err(e) => {
log::warn!("[qbz-qt] release bucket not sortable ({e}) — leaving its order alone");
return;
}
};
sort_release_cards(&mut cards, sort);
for (slot, card) in items.iter_mut().zip(cards.iter()) {
match serde_json::to_value(card) {
Ok(value) => *slot = value,
Err(e) => log::warn!("[qbz-qt] release card re-serialize failed: {e}"),
}
}
}
/// artist.rs `map_track` (Popular Tracks / Appears On rows — album title + /// artist.rs `map_track` (Popular Tracks / Appears On rows — album title +
/// cover ride the nested album object). /// cover ride the nested album object).
fn map_track(index: usize, track: PageArtistTrack) -> TrackRow { fn map_track(index: usize, track: PageArtistTrack) -> TrackRow {
@@ -488,6 +600,17 @@ fn map_artist(page: PageArtistResponse) -> ArtistViewData {
.map(|(index, track)| map_track(index, track)) .map(|(index, track)| map_track(index, track))
.collect(); .collect();
// The persisted per-bucket sorts, read ONCE for the whole page (the Slint
// calls `artist_prefs::get_sort` per bucket off a thread_local cache;
// this port has no cache by design — see artist_prefs.rs's header).
let saved_sorts = crate::artist_prefs::read_all();
let sort_for = |rt: &str| {
saved_sorts
.get(rt)
.cloned()
.unwrap_or_else(|| crate::artist_prefs::DEFAULT_SORT.to_string())
};
// One section per non-empty bucket in the official order; "download" // One section per non-empty bucket in the official order; "download"
// drains but never renders (Purchase Only is hidden upstream too). // drains but never renders (Purchase Only is hidden upstream too).
let mut release_sections: Vec<ArtistReleaseSection> = Vec::new(); let mut release_sections: Vec<ArtistReleaseSection> = Vec::new();
@@ -496,35 +619,44 @@ fn map_artist(page: PageArtistResponse) -> ArtistViewData {
bucket_cards.remove(rt); bucket_cards.remove(rt);
continue; continue;
} }
if let Some(cards) = bucket_cards.remove(rt) { if let Some(mut cards) = bucket_cards.remove(rt) {
if cards.is_empty() { if cards.is_empty() {
continue; continue;
} }
// artist.rs:786-812 — the sort is applied at page BUILD, not on
// first click, "so the first paint already honors the user's
// choice", and `sort_by` is stamped so the picker seats itself on
// that choice instead of snapping back to Default on a revisit.
let sort = sort_for(rt);
sort_release_cards(&mut cards, &sort);
release_sections.push(ArtistReleaseSection { release_sections.push(ArtistReleaseSection {
release_type: rt.to_string(), release_type: rt.to_string(),
title: qbz_i18n::t(title), title: qbz_i18n::t(title),
has_more: bucket_has_more.get(rt).copied().unwrap_or(false), has_more: bucket_has_more.get(rt).copied().unwrap_or(false),
cards, cards,
sort_by: sort,
}); });
} }
} }
// Leftovers: server buckets unknown to the table, appended (title-cased). // Leftovers: server buckets unknown to the table, appended (title-cased).
for (rt, cards) in bucket_cards { // These render a full ReleaseSection header, sort picker included, so they
// must round-trip the pref exactly like the known buckets.
for (rt, mut cards) in bucket_cards {
if cards.is_empty() { if cards.is_empty() {
continue; continue;
} }
let title = { // Factored into `title_case` (above) when the discography page needed
let mut chars = rt.chars(); // the same fallback — one copy, so a leftover bucket's section header
match chars.next() { // and its discography page header cannot drift apart.
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(), let title = title_case(&rt);
None => rt.clone(), let sort = sort_for(&rt);
} sort_release_cards(&mut cards, &sort);
};
release_sections.push(ArtistReleaseSection { release_sections.push(ArtistReleaseSection {
has_more: false, has_more: false,
release_type: rt, release_type: rt,
title, title,
cards, cards,
sort_by: sort,
}); });
} }
@@ -710,6 +842,15 @@ fn merge_release_page(
return; return;
}; };
section["hasMore"] = json!(has_more); section["hasMore"] = json!(has_more);
// The bucket's live sort, read off the row itself — artist.rs:1245-1258
// `append_release_page` does exactly this (`let sort = row.sort_by...`)
// before re-sorting, because under a custom sort a page appended at the
// TAIL is simply in the wrong place.
let sort = section
.get("sortBy")
.and_then(|v| v.as_str())
.unwrap_or(crate::artist_prefs::DEFAULT_SORT)
.to_string();
let Some(existing) = section.get_mut("cards").and_then(|v| v.as_array_mut()) else { let Some(existing) = section.get_mut("cards").and_then(|v| v.as_array_mut()) else {
return; return;
}; };
@@ -725,6 +866,96 @@ fn merge_release_page(
existing.push(value); existing.push(value);
} }
} }
// No-op under "default" (the whole point of that key), so the common path
// pays nothing.
sort_release_values(existing, &sort);
}
/// Whether the stashed artist document still belongs to `artist_id` — the
/// SAME test `merge_release_page` opens with, exported so main.rs's
/// `load_release_section` can apply it to the OTHER half of a landed page.
///
/// The page travels on two legs: `merge_release_page` folds it into the stash
/// (guarded — a stash for a different artist drops it), and the
/// `releaseSectionReady` signal hands it to the view. The signal carries NO
/// artist id, and ArtistView.qml folds whatever arrives into
/// `root.releaseOverlay` keyed by release_type alone — keys every artist
/// shares ("album" exists on all of them). So a page requested on artist A
/// that lands after artist B's document has been published would graft A's
/// albums onto B's album grid, permanently (the overlay is only reset when the
/// artist ID changes again). Emitting only while the stash still names the
/// requesting artist closes that leak at its source; the view cannot, because
/// the signal does not tell it whose page this is.
pub(crate) fn stash_is_for(artist_id: &str) -> bool {
match ARTIST_DOC.lock() {
Ok(guard) => guard
.as_ref()
.map(|(_, doc)| doc.get("id").and_then(|v| v.as_str()) == Some(artist_id))
.unwrap_or(false),
Err(_) => false,
}
}
/// Artist page per-section sort — the port of `crates/qbz/src/artist.rs:1178-1210`
/// `resort_section` ("Re-sort one release bucket in place […] and persist the
/// choice"), reached from `QbzArtist.setSectionSort` (artist_bridge.rs).
///
/// THE SORT NEVER LEAVES THIS PROCESS. `main.rs:14997-15007` — the whole Slint
/// handler — calls `artist::resort_section` and nothing else: no refetch, no
/// query param. Every artist-page `get_releases_grid` call in BOTH trees passes
/// the constant `Some("release_date")` (here: `load_release_page`), and the five
/// picker keys have no server equivalent; `album_map::sort_album_items` applies
/// them locally over the cards already loaded. So `load_release_page` is
/// deliberately left untouched by this feature.
///
/// Persist FIRST (so the choice survives even if the page is being torn down),
/// then patch the stashed document and republish it through the existing
/// generation-guarded `publish_patch` — the port's ONE transport for the artist
/// view. Re-serializing the whole document is also what makes QML notice: a JS
/// array handed back by the same reference re-triggers nothing
/// (cards/PlaylistCollage.qml's rule), whereas a fresh `artistJson` string
/// re-runs the parse.
///
/// Reading the generation off the stash rather than off `ARTIST_GEN` is what
/// keeps a click aimed at the page ON SCREEN: if the user has already navigated
/// away, the stash belongs to another artist and `publish_patch` drops the
/// edit.
///
/// 1:1 note — picking "Default" after "A–Z" does NOT restore the server order
/// until the page is reloaded, because "default" is a no-op sort over the
/// vector as it stands. The reference behaves identically (`sort_album_items`
/// falls through on that key); restoring it would need an untouched copy of the
/// server order that neither tree keeps.
pub(crate) fn resort_section(release_type: &str, sort: &str) {
crate::artist_prefs::set_sort(release_type, sort);
// The guard is dropped at the end of this statement — `publish_patch`
// takes the same non-reentrant lock.
let generation = ARTIST_DOC
.lock()
.ok()
.and_then(|guard| guard.as_ref().map(|(gen, _)| *gen));
let Some(generation) = generation else {
return;
};
let release_type = release_type.to_string();
let sort = sort.to_string();
publish_patch(generation, move |doc| {
let Some(sections) = doc.get_mut("releaseSections").and_then(|v| v.as_array_mut()) else {
return;
};
let Some(section) = sections.iter_mut().find(|s| {
s.get("releaseType").and_then(|v| v.as_str()) == Some(release_type.as_str())
}) else {
return;
};
// Stamp the key BEFORE the cards: `sortBy` is what seats the picker
// (and what `merge_release_page` reads for the next page), so it has to
// be written even when the sort itself is a no-op.
section["sortBy"] = json!(sort);
if let Some(cards) = section.get_mut("cards").and_then(|v| v.as_array_mut()) {
sort_release_values(cards, &sort);
}
});
} }
/// Fetch + publish (perf-marked like phase 5), then kick the progressive /// Fetch + publish (perf-marked like phase 5), then kick the progressive
+495
View File
@@ -0,0 +1,495 @@
//! Dedicated artist discography page — the Qt half of
//! `crates/qbz/src/artist_releases.rs` (99 lines, the whole file) plus the four
//! `main.rs` handlers that drive it:
//!
//! - `main.rs:15055-15087` `.on_open_releases` -> `open`
//! - `main.rs:3570-3607` `navigate_artist_releases` (the fetch half)
//! - `main.rs:15098-15135` `.on_load_more` -> `load_more`
//! - `main.rs:15143-15148` `.on_set_sort` -> `set_sort`
//! - `main.rs:15158-15176` `.on_retry` -> `retry`
//!
//! The page is the full, paginated grid for ONE release bucket of ONE artist,
//! reached from the artist page's "See discography" link
//! (`artist/ReleaseGrid.slint:54-69`) and from the album page's "From the same
//! artist" View-all (`album/AlbumPageView.slint:1123-1136`, which pins the
//! bucket to `"album"`). Two doors, one page — hence `open` takes the artist
//! NAME as a parameter instead of reading it off the artist document: the album
//! page supplies its own (`main.rs:11844-11848`).
//!
//! # Why this file does NOT call `artist_qt::load_release_page`
//!
//! That function exists for the artist page's per-section "Load more" and it
//! ends in `merge_release_page`, which folds every fetched page into the
//! STASHED ARTIST DOCUMENT (`artist_qt::ARTIST_DOC`). Paging this page ten deep
//! would silently grow the artist page's own "Albums" grid to 200 cards behind
//! the user's back. The reference keeps `ArtistState.release-sections` and
//! `ArtistReleasesState.albums` strictly disjoint (two states, one endpoint),
//! so this file issues the identical core call itself:
//!
//! ```text
//! get_releases_grid(id, release_type, RELEASE_PAGE_SIZE, offset, Some("release_date"))
//! ```
//!
//! Same 20-row page size, same server sort — there is no separate discography
//! endpoint, and `Some("release_date")` is what makes the picker's "Default"
//! option mean something (see `sort_cards`).
//!
//! # Why the rows are `home_qt::HomeCard`, not `album_qt::AlbumCardData`
//!
//! The view mounts the shared `views/AlbumCollection.qml`, whose delegates read
//! `modelData.artPath` (`AlbumCollection.qml:391`) — a Rust-resolved cache path.
//! `AlbumCardData` carries `artUrl` and NO `artPath`, which is why the artist
//! page resolves its covers in QML through `QbzShell.sidebarArtworkWindow` ->
//! `QbzLibrary.libraryArtworkReady` -> its own `coverMap`. Publishing
//! `AlbumCardData` into `AlbumCollection` compiles, mounts, and draws every
//! cover BLANK. So the fetch runs the `label_qt::fetch_releases` two-pass
//! (`label_qt.rs:800-847`): `home_qt::attach_card_art` for what is already on
//! disk, publish, `artwork_qt::download_missing`, re-attach, publish again.
//!
//! # The subtitle slot carries the YEAR
//!
//! `qbz/src/artist.rs:670-698` `card_to_item`: "the card subtitle slot should
//! show the release year — the artist is redundant since we're already on their
//! page". It routes `year` through the card's `artist` field and blanks
//! `artist_id` so the line is inert, while `plain_year` keeps the real value
//! for the sort. `HomeCard` has no `plain_year`, so here `artist` AND `year`
//! both carry the year and `sort_cards` reads `year` — never `artist`, which
//! only happens to work while every release has one.
use std::collections::HashSet;
use std::sync::Mutex;
use cxx_qt_lib::QString;
use qbz_models::PageArtistRelease;
use serde::Serialize;
use crate::home_qt::HomeCard;
/// Page state. ONE bucket of ONE artist at a time — navigating to another
/// bucket resets it wholesale, exactly as `artist_releases::reset` does.
#[derive(Default)]
struct State {
/// Bumped by every `open` / `retry`. A page that lands for a previous
/// bucket (or a previous artist) is DROPPED rather than painted — the
/// `label_qt.rs:697` guard. It matters more here than there: the nav stack
/// stores no payload (`nav_qt.rs:9-23`), so back/forward re-mounts this
/// view against whatever document is still on the bridge, and a late page
/// painting into the wrong bucket would be indistinguishable from data.
generation: u64,
artist_id: String,
name: String,
release_type: String,
/// The translated bucket title (`artist_qt::release_type_title`). Built in
/// RUST, so it cannot re-translate through `trRev` — see `republish`.
title: String,
cards: Vec<HomeCard>,
/// Rows the SERVER has handed over, which is the offset of the next page.
///
/// Deliberately NOT `cards.len()` (the reference's `loaded_count`,
/// `artist_releases.rs:94-99`): blacklist filtering and id-dedup both make
/// the rendered count smaller than the fetched one, and feeding the smaller
/// number back as an offset re-requests rows already seen and skips the
/// tail. Invisible with an empty blacklist, which is why the reference gets
/// away with it.
offset: u32,
has_more: bool,
loading: bool,
load_more_loading: bool,
load_error: bool,
/// `default` | `newest` | `oldest` | `title-asc` | `title-desc` — the same
/// five wire strings the artist page's per-section picker persists, and the
/// same store (`artist_prefs`). Seeded on open from the persisted value
/// (`artist_releases.rs:25`), so a bucket the user sorted A–Z opens A–Z.
sort_by: String,
}
static RELEASES: Mutex<Option<State>> = Mutex::new(None);
fn with_state<R>(f: impl FnOnce(&mut State) -> R) -> R {
let mut guard = RELEASES.lock().unwrap();
f(guard.get_or_insert_with(State::default))
}
/// The ONE document this page paints from (`ui/state.slint:3996-4019`
/// `ArtistReleasesState`, camelCased for QML).
///
/// `loading` lives INSIDE the document rather than on a second bridge property.
/// `label_qt` publishes `labelReleasesLoading` separately only because it
/// predates the single-document convention; this page has no cross-view reader,
/// so one property is one fewer bridge member and one fewer thing to keep in
/// sync. QML re-parses on every change, so a fresh string is a fresh object —
/// the "Rebind requires a NEW object reference" rule
/// (`cards/PlaylistCollage.qml`) is satisfied by construction.
#[derive(Serialize)]
struct ArtistReleasesDoc<'a> {
id: &'a str,
name: &'a str,
#[serde(rename = "releaseType")]
release_type: &'a str,
title: &'a str,
albums: &'a [HomeCard],
#[serde(rename = "hasMore")]
has_more: bool,
loading: bool,
#[serde(rename = "loadMoreLoading")]
load_more_loading: bool,
#[serde(rename = "loadError")]
load_error: bool,
#[serde(rename = "sortBy")]
sort_by: &'a str,
}
fn publish() {
let json = with_state(|s| {
let doc = ArtistReleasesDoc {
id: &s.artist_id,
name: &s.name,
release_type: &s.release_type,
title: &s.title,
albums: &s.cards,
has_more: s.has_more,
loading: s.loading,
load_more_loading: s.load_more_loading,
load_error: s.load_error,
sort_by: &s.sort_by,
};
serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string())
});
crate::artist_bridge::ui(move |mut b| {
b.as_mut()
.set_artist_releases_json(QString::from(json.as_str()));
});
}
// ---------------------------------------------------------------------------
// Mapping
// ---------------------------------------------------------------------------
/// One server release -> one `AlbumCollection` row, or `None` when the album is
/// blacklisted.
///
/// The blacklist test runs on the release's REAL artist id — i.e. BEFORE the
/// subtitle transposition blanks it (`qbz/src/artist.rs:167` and
/// `artist_releases.rs:50-52` both filter; `artist_qt::load_release_page` does
/// NOT, which is a separate gap on the artist page's own Load-more path).
/// Getting the order wrong here would leave the album axis working and silently
/// disable the artist axis.
fn map_card(release: &PageArtistRelease) -> Option<HomeCard> {
// Reuse the artist page's mapper for every field value (pin badge, heart,
// quality tier + detail, genre, the 4-digit year, the best cover url).
let card = crate::artist_qt::map_release(release);
if crate::artist_blacklist::card_blacklisted(&card.id, &card.artist_id) {
return None;
}
Some(HomeCard {
id: card.id,
title: card.title,
// The YEAR, not the artist — see the file header.
artist: card.year.clone(),
artist_id: String::new(),
genre: card.genre,
year: card.year,
quality_tier: card.quality_tier,
quality_detail: card.quality_detail,
art_url: card.art_url,
is_pinned: card.is_pinned,
is_favorite: card.is_favorite,
// ribbon / ribbon_kind / rank / plays / category / playlist_* stay
// empty: this grid is albums only and mounts no ribbon.
..Default::default()
})
}
/// `album_map::sort_album_items` (`qbz/src/album_map.rs:246-264`) — the twin of
/// `label_qt::sort_cards`, minus the artist arms (this page's picker has five
/// options and stops at title-desc, and `artist` holds the year here anyway).
///
/// `year` is the PLAIN 4-digit year, so the lexicographic compare IS a
/// chronological one.
///
/// `"default"` falls through UNSORTED, and that is the whole point of the key:
/// the fetch asks the server for `release_date` order, so leaving the vector
/// alone IS "the order Qobuz sent". Applying any "sensible default" here is a
/// visible parity break on the very first frame.
fn sort_cards(items: &mut [HomeCard], sort: &str) {
match sort {
"oldest" | "year-asc" => items.sort_by(|a, b| a.year.cmp(&b.year)),
"newest" | "year-desc" => items.sort_by(|a, b| b.year.cmp(&a.year)),
"title-asc" => items.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())),
"title-desc" => items.sort_by(|a, b| b.title.to_lowercase().cmp(&a.title.to_lowercase())),
_ => {}
}
}
// ---------------------------------------------------------------------------
// Entry points (QbzArtist invokables)
// ---------------------------------------------------------------------------
/// `artist_releases::reset` (`artist_releases.rs:14-26`) — the header is seeded
/// and the grid enters its loading state BEFORE anything is fetched, which is
/// also the order `navigate_artist_releases` uses (`main.rs:3583-3591`: the view
/// flips first, so the page mounts already spinning).
fn reset(artist_id: &str, name: &str, release_type: &str) -> u64 {
with_state(|s| {
s.generation = s.generation.wrapping_add(1);
s.artist_id = artist_id.to_string();
s.name = name.to_string();
s.release_type = release_type.to_string();
s.title = crate::artist_qt::release_type_title(release_type);
s.cards.clear();
s.offset = 0;
s.has_more = false;
s.loading = true;
s.load_more_loading = false;
s.load_error = false;
s.sort_by = crate::artist_prefs::get_sort(release_type);
s.generation
})
}
/// "See discography" / the album page's "From the same artist" View all.
///
/// The two guards are the reference's own: the whole view is mounted under
/// `!OfflineState.offline` (`shell/AppShell.slint:503-511`), and
/// `.on_open_releases` returns silently on an empty artist id
/// (`main.rs:15065`). Neither logs — an empty id means the caller has no artist
/// on screen, not that something failed.
pub fn open(artist_id: String, artist_name: String, release_type: String) {
if crate::offline_fwd::engine().status().is_offline() {
return;
}
if artist_id.is_empty() {
return;
}
let generation = reset(&artist_id, &artist_name, &release_type);
// The route is a TWO-FILE contract (nav_qt.rs:9-16): this records the id,
// AppShell.qml's Loader ternary mounts it. `QbzShell.navigateTo` is NOT
// usable — it carries no payload and clears LAST_DETAIL (main.rs:1607).
crate::nav_qt::record("artistreleases");
publish();
fetch(generation, artist_id, release_type, 0, true);
}
/// Infinite-scroll tail (`main.rs:15098-15135`). Bails when a page is already
/// in flight or the server said there is nothing left; the first page's
/// `loading` counts as in-flight too (the .slint's own guard,
/// `ArtistReleasesView.slint:123-128`, tests all three).
pub fn load_more() {
let Some((generation, artist_id, release_type, offset)) = with_state(|s| {
if s.loading || s.load_more_loading || !s.has_more {
return None;
}
s.load_more_loading = true;
Some((
s.generation,
s.artist_id.clone(),
s.release_type.clone(),
s.offset,
))
}) else {
return;
};
publish();
fetch(generation, artist_id, release_type, offset, false);
}
/// The sort picker (`main.rs:15143-15148`) — persist, re-order the LOADED set
/// in place, republish. NO fetch: none of the five keys has a server
/// equivalent, every `get_releases_grid` call in both trees passes the constant
/// `Some("release_date")`, and `sort_album_items` applies the picker locally.
///
/// The store is `artist_prefs`, SHARED with the artist page's per-section
/// picker: sorting here also re-seats that one on the next visit, and vice
/// versa. That is the reference's design, not a coincidence — the pref is keyed
/// by release_type alone.
///
/// 1:1 note carried over from `artist_qt::resort_section`: picking "Default"
/// after "A–Z" does NOT restore the server order until the page is reloaded,
/// because "default" is a no-op over the vector as it stands. The reference
/// behaves identically.
pub fn set_sort(sort: String) {
// Persist FIRST and OUTSIDE the lock — it is a read-modify-write of a file
// shared with the Slint build, and there is no reason to hold the page
// state across it.
let release_type = with_state(|s| s.release_type.clone());
crate::artist_prefs::set_sort(&release_type, &sort);
with_state(|s| {
s.sort_by = sort;
let key = s.sort_by.clone();
sort_cards(&mut s.cards, &key);
});
publish();
}
/// The error state's Retry button (`main.rs:15158-15176`) — a full re-run of
/// the open body from the state's OWN id/name/release_type, and deliberately
/// WITHOUT `nav_qt::record`: the user is already standing on this entry, and
/// pushing a second one would make Back a no-op.
pub fn retry() {
let Some((artist_id, name, release_type)) = with_state(|s| {
if s.artist_id.is_empty() {
return None;
}
Some((
s.artist_id.clone(),
s.name.clone(),
s.release_type.clone(),
))
}) else {
return;
};
if crate::offline_fwd::engine().status().is_offline() {
return;
}
let generation = reset(&artist_id, &name, &release_type);
publish();
fetch(generation, artist_id, release_type, 0, true);
}
/// Live language switch. The bucket title is a Rust-translated string sitting
/// INSIDE a JSON document, so `QbzSession.trRev` cannot re-translate it — the
/// same reason `myqbz_qt::republish_all` is called unconditionally from
/// `apply_language`. A no-op on an empty document (nothing has been opened).
///
/// This is wired (main.rs `apply_language`), unlike
/// `label_qt::republish_for_language`, which is `#[allow(dead_code)]` and whose
/// own doc comment says "GLUE NEEDED: nothing calls this yet".
pub fn republish() {
let has_page = with_state(|s| {
if s.artist_id.is_empty() {
return false;
}
s.title = crate::artist_qt::release_type_title(&s.release_type);
true
});
if has_page {
publish();
}
}
// ---------------------------------------------------------------------------
// Fetch
// ---------------------------------------------------------------------------
/// One page. `first` distinguishes the two error arms the reference keeps
/// apart: the FIRST page failing raises the retryable error state
/// (`main.rs:3599-3605`), a later page failing only clears
/// `load_more_loading` and leaves what is already on screen alone
/// (`main.rs:15129`).
fn fetch(generation: u64, artist_id: String, release_type: String, offset: u32, first: bool) {
let runtime = crate::app();
crate::spawn(async move {
let Ok(id) = artist_id.parse::<u64>() else {
log::warn!("[qbz-qt] artist releases: invalid artist id {artist_id}");
fail(generation, first);
return;
};
let resp = match runtime
.core()
.get_releases_grid(
id,
&release_type,
crate::artist_qt::RELEASE_PAGE_SIZE,
offset,
Some("release_date"),
)
.await
{
Ok(resp) => resp,
Err(e) => {
log::warn!("[qbz-qt] artist releases page failed ({artist_id}/{release_type}): {e}");
fail(generation, first);
return;
}
};
let returned = resp.items.len() as u32;
let server_has_more = resp.has_more;
let mut cards: Vec<HomeCard> = resp.items.iter().filter_map(map_card).collect();
let missing = crate::home_qt::attach_card_art(&mut cards);
{
let mut guard = RELEASES.lock().unwrap();
let Some(s) = guard.as_mut() else {
return;
};
if s.generation != generation {
return;
}
// Dedupe by id against what is already loaded — Qobuz can repeat a
// row across pages, and the reference dedupes on the same axis
// (`artist_releases.rs:44-57`, its `seen` set).
let seen: HashSet<String> = s.cards.iter().map(|c| c.id.clone()).collect();
s.cards
.extend(cards.into_iter().filter(|c| !seen.contains(&c.id)));
s.offset = offset.saturating_add(returned);
// `&& returned > 0` on top of the server flag: with the raw flag a
// bucket that answers "has_more" with an empty page pins
// `load_more` in a loop against an offset that never advances
// (label_qt.rs:820 guards the same way).
s.has_more = server_has_more && returned > 0;
s.loading = false;
s.load_more_loading = false;
s.load_error = false;
// Re-sort the FULL set, not just the arrival: under a custom sort a
// page appended at the tail is simply in the wrong place
// (`artist_releases.rs:62` sorts everything after the append).
let sort = s.sort_by.clone();
sort_cards(&mut s.cards, &sort);
}
publish();
if missing.is_empty() {
return;
}
// Second pass — covers that were not on disk yet. Same shape as
// `label_qt::fetch_releases`: download, re-attach over the WHOLE set
// (the page just landed sorted into the middle of it), republish.
crate::artwork_qt::download_missing(missing).await;
let mut all = {
let mut guard = RELEASES.lock().unwrap();
let Some(s) = guard.as_mut() else {
return;
};
if s.generation != generation {
return;
}
std::mem::take(&mut s.cards)
};
let _ = crate::home_qt::attach_card_art(&mut all);
{
let mut guard = RELEASES.lock().unwrap();
let Some(s) = guard.as_mut() else {
return;
};
if s.generation != generation {
// The take above already emptied the state for a page the user
// has navigated away from; `reset` has since refilled it, so
// dropping `all` on the floor is correct.
return;
}
s.cards = all;
}
publish();
});
}
/// The two error arms (see `fetch`). Generation-guarded like every other write.
fn fail(generation: u64, first: bool) {
let changed = with_state(|s| {
if s.generation != generation {
return false;
}
if first {
s.loading = false;
s.load_error = true;
s.has_more = false;
} else {
s.load_more_loading = false;
}
true
});
if changed {
publish();
}
}
+33
View File
@@ -113,10 +113,24 @@ mod myqbz_builder_fetch_qt;
mod artist_blacklist; mod artist_blacklist;
mod reco_dismiss_qt; mod reco_dismiss_qt;
mod blacklist_qt; mod blacklist_qt;
// Artist page per-section release sort, persisted by release_type. Co-owns
// `<data-dir>/qbz/artist_ui.json` with the Slint's crates/qbz/src/artist_prefs.rs
// exactly the way library_prefs co-owns favorites_ui.json.
mod artist_prefs;
// The dedicated discography page — one release bucket of one artist, paged.
// Reached from the artist page's "See discography" and from the album page's
// "From the same artist" View all. A plain module, NOT a bridge: it publishes
// onto QbzArtist.artistReleasesJson (artist_bridge.rs) and records its own nav
// entry, the label_qt shape.
mod artist_releases_qt;
// Shared in-app toast publisher (the port of qbz-slint-common's toast.rs). // Shared in-app toast publisher (the port of qbz-slint-common's toast.rs).
// A plain module, NOT a bridge — it publishes onto QbzShell.toastJson, and // A plain module, NOT a bridge — it publishes onto QbzShell.toastJson, and
// `controls/QbzToast.qml` owns the auto-hide timer. // `controls/QbzToast.qml` owns the auto-hide timer.
mod toast_qt; mod toast_qt;
// Share links + the system clipboard (the port of crates/qbz/src/share.rs).
// A plain module, NOT a bridge — the artist header's ⋯ → Share reaches it
// through QbzArtist.share (artist_bridge.rs).
mod share_qt;
mod nav_qt; mod nav_qt;
mod browse_qt; mod browse_qt;
mod cast_qt; mod cast_qt;
@@ -860,6 +874,20 @@ pub(crate) fn load_release_section(artist_id: String, release_type: String, offs
.await .await
{ {
Ok((cards, has_more)) => { Ok((cards, has_more)) => {
// The user may have opened ANOTHER artist while this page was
// in flight. `merge_release_page` already dropped the stash
// merge in that case (its id guard), but the signal carries no
// artist id, and ArtistView.qml keys its append overlay by
// release_type alone — emitting here would graft this artist's
// page onto the new artist's same-named bucket. Same test the
// merge used (artist_qt::stash_is_for), applied to the second
// leg of the same delivery.
if !artist_qt::stash_is_for(&artist_id) {
log::info!(
"[qbz-qt] dropping stale release page ({release_type}): artist changed"
);
return;
}
let json = serde_json::to_string(&cards).unwrap_or_else(|_| "[]".into()); let json = serde_json::to_string(&cards).unwrap_or_else(|_| "[]".into());
artist_bridge::ui(move |mut b| { artist_bridge::ui(move |mut b| {
b.as_mut().release_section_ready( b.as_mut().release_section_ready(
@@ -1668,6 +1696,11 @@ pub(crate) fn apply_language(code: String) {
myqbz_qt::republish_all(); myqbz_qt::republish_all();
myqbz_detail_qt::republish(); myqbz_detail_qt::republish();
myqbz_builder_qt::republish(); myqbz_builder_qt::republish();
// Same reason, same unconditional call: the discography page's header
// title is `artist_qt::release_type_title(...)`, a Rust-translated string
// living inside a JSON document, and LAST_DETAIL only ever latches "album"
// or "artist" — it cannot reach this page. No-op when nothing is open.
artist_releases_qt::republish();
let (view, id) = LAST_DETAIL.lock().unwrap().clone(); let (view, id) = LAST_DETAIL.lock().unwrap().clone();
if !id.is_empty() { if !id.is_empty() {
match view.as_str() { match view.as_str() {
+4 -4
View File
@@ -16,10 +16,10 @@
//! which is exactly why it is easy to miss. //! which is exactly why it is easy to miss.
//! //!
//! Ids in use: `home`, `library`, `local`, `localalbum`, `album`, `artist`, //! Ids in use: `home`, `library`, `local`, `localalbum`, `album`, `artist`,
//! `settings`, `search`, `playlist`, `discoverbrowse`, `playlistbrowse`, //! `artistreleases`, `settings`, `search`, `playlist`, `discoverbrowse`,
//! `recentalbums`, `mostplayedalbums`, `label`, `labelreleases`, `mix`, //! `playlistbrowse`, `recentalbums`, `mostplayedalbums`, `label`,
//! `mixtapes`, `collections`, `mixtapedetail`, `discobuilder`, `blacklist`, //! `labelreleases`, `mix`, `mixtapes`, `collections`, `mixtapedetail`,
//! `playlistmanager`. //! `discobuilder`, `blacklist`, `playlistmanager`.
use std::sync::Mutex; use std::sync::Mutex;
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# QBZ Qt — RENDERING DIAGNOSTIC. Launches the app with Qt's scene-graph and RHI
# logging on, samples the process's CPU while you drive it, and leaves ONE log
# file you can copy off the machine.
#
# Why this exists: the whole "this port runs on the software renderer" doctrine
# — repeated across ~8 QML file headers and used to justify pre-baked icon
# tints, Canvas-based rounded covers and a blanket no-effects rule — came from
# an observation made OFFSCREEN, and QT_QPA_PLATFORM=offscreen forces software
# by definition. On Linux, QSG_INFO in a real window says otherwise: OpenGL RHI,
# threaded render loop, Intel Arc. macOS is still unmeasured. So: measure, in a
# REAL graphical session, never over ssh and never offscreen.
#
# WHAT TO DO WHILE IT RUNS (the log is only as good as the session):
# 1. Let it sit idle on Home for ~15s.
# 2. Play a track. Let it settle ~15s.
# 3. Switch the now-playing bar through its modes (New / Classic / Small /
# Large), pausing ~15s on each — Large is the one that mounts the FFT
# spectrum, which is the suspect.
# 4. Hover a few album cards.
# 5. Close the app normally. The summary is written on exit.
#
# Usage: ./scripts/qt-diag.sh # release binary, must already be built
# DEBUG=1 ./scripts/qt-diag.sh # debug binary instead
# INTERVAL=2 ./scripts/qt-diag.sh # CPU sample period in seconds (default 2)
set -uo pipefail
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.."
UNAME_S="$(uname -s)"
PROFILE=$([[ "${DEBUG:-0}" == 1 ]] && echo debug || echo release)
BIN="crates/target/${PROFILE}/qbz-qt"
INTERVAL="${INTERVAL:-2}"
[[ -x "${BIN}" ]] || { echo "[qt-diag] no binary at ${BIN} — build first (./scripts/qt-run.sh NORUN=1)" >&2; exit 1; }
# Refuse to produce a log that answers the wrong question. Offscreen forces
# software, and a headless ssh session has no compositor to talk to — either
# would make the backend line meaningless, which is the one line we are here for.
if [[ "${QT_QPA_PLATFORM:-}" == offscreen ]]; then
echo "[qt-diag] QT_QPA_PLATFORM=offscreen forces the software backend — that is the mistake this script exists to correct. Unset it." >&2
exit 1
fi
if [[ "${UNAME_S}" != "Darwin" && -z "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]]; then
echo "[qt-diag] no DISPLAY / WAYLAND_DISPLAY — run this from the machine's own graphical session, not over ssh." >&2
exit 1
fi
stamp="$(date +%Y%m%d-%H%M%S)"
host="$(hostname -s 2>/dev/null || hostname)"
LOG="${PWD}/qt-diag-${host}-${stamp}.log"
{
echo "=============================================================="
echo " QBZ Qt rendering diagnostic"
echo " host : ${host}"
echo " when : $(date '+%Y-%m-%d %H:%M:%S %Z')"
echo " os : $(uname -srm)"
[[ "${UNAME_S}" == "Darwin" ]] && echo " macos : $(sw_vers -productVersion)"
echo " binary : ${BIN} ($(date -r "${BIN}" '+%Y-%m-%d %H:%M' 2>/dev/null || stat -c %y "${BIN}" 2>/dev/null))"
echo " profile : ${PROFILE}"
echo "=============================================================="
echo
echo "---- display ----"
if [[ "${UNAME_S}" == "Darwin" ]]; then
system_profiler SPDisplaysDataType 2>/dev/null | grep -iE 'resolution|ui looks like|chipset|vram|metal' || true
else
xrandr 2>/dev/null | grep -E ' connected|\*' || echo "(xrandr unavailable — wayland?)"
echo "session type: ${XDG_SESSION_TYPE:-unknown}"
fi
echo
echo "---- qt ----"
(qmake6 -query QT_VERSION 2>/dev/null || /opt/homebrew/opt/qt/bin/qmake -query QT_VERSION 2>/dev/null || echo unknown) | sed 's/^/qt version: /'
echo "effects modules present:"
for d in /usr/lib64/qt6/qml /usr/lib/qt6/qml /opt/homebrew/share/qt/qml; do
[[ -d "$d/QtQuick/Effects" ]] && echo " QtQuick.Effects -> $d"
[[ -d "$d/Qt5Compat/GraphicalEffects" ]] && echo " Qt5Compat.GraphicalEffects -> $d"
done
echo "backend forced by env: QT_QUICK_BACKEND='${QT_QUICK_BACKEND:-}' QSG_RHI_BACKEND='${QSG_RHI_BACKEND:-}' QSG_RENDER_LOOP='${QSG_RENDER_LOOP:-}'"
echo
echo "---- app log (QSG_INFO + scenegraph/rhi categories) ----"
} > "${LOG}"
echo "[qt-diag] log -> ${LOG}"
echo "[qt-diag] drive the app as described in this script's header, then close it normally."
# The two lines that answer the question are 'Creating QRhi with backend X' and
# 'Prefer software device: N'. The rhi/scenegraph categories carry them.
QSG_INFO=1 \
QT_LOGGING_RULES="qt.scenegraph.general=true;qt.rhi.general=true" \
"./${BIN}" >> "${LOG}" 2>&1 &
app_pid=$!
# CPU sampler. ps reports a per-process percentage on both platforms; on macOS
# it is a share of ONE core (so >100 is possible), same as Linux.
samples="$(mktemp)"
(
while kill -0 "${app_pid}" 2>/dev/null; do
cpu=$(ps -o %cpu= -p "${app_pid}" 2>/dev/null | tr -d ' ')
rss=$(ps -o rss= -p "${app_pid}" 2>/dev/null | tr -d ' ')
[[ -n "${cpu}" ]] && printf '%s %s %s\n' "$(date '+%H:%M:%S')" "${cpu}" "${rss:-0}" >> "${samples}"
sleep "${INTERVAL}"
done
) &
sampler=$!
trap 'kill "${app_pid}" 2>/dev/null || true' INT TERM
wait "${app_pid}" 2>/dev/null
kill "${sampler}" 2>/dev/null || true
wait "${sampler}" 2>/dev/null || true
{
echo
echo "---- cpu samples (time, %cpu of one core, rss KB) ----"
cat "${samples}" 2>/dev/null
echo
echo "---- cpu summary ----"
awk '{n++; s+=$2; if($2>mx)mx=$2; if(n==1||$2<mn)mn=$2}
END{ if(n) printf "samples=%d min=%.1f%% avg=%.1f%% max=%.1f%%\n", n, mn, s/n, mx;
else print "(no samples — the process exited immediately)" }' "${samples}" 2>/dev/null
echo
echo "---- what to look for ----"
echo " * 'Creating QRhi with backend <X>' -> OpenGL/Metal/Vulkan = GPU; 'software' = not."
echo " * 'Prefer software device: 1' -> something asked for software explicitly."
echo " * 'Animation Driver: using vsync' -> ms per frame; 5.56 = 180Hz, 10.0 = 100Hz."
echo " * a max% that tracks the NPB Large window is the FFT spectrum band."
} >> "${LOG}"
rm -f "${samples}"
echo
echo "[qt-diag] done. Backend line:"
grep -aE 'Creating QRhi|Prefer software|using vsync' "${LOG}" | sed 's/^/ /' || echo " (none found — did the window open?)"
echo "[qt-diag] full log: ${LOG}"