Files
qbz/crates/qbz-qt/qml/views/LabelReleasesView.qml
T
vicrodh e816436fd0 feat(qt): remember where each page was scrolled, across back and forward
The port of the block Slint repeats in 27 views (NavState.restore-scope /
scroll-restore / report-scroll). A .slint file cannot hand a component a
reference to a Flickable, which is why the reference inlines it; QML can, so
controls/ScrollMemory.qml exists once and 31 containers mount it.

Two divergences from the reference, both forced by the same fact — tabs are not
history entries in this port:

1. THE SCOPE IS NAMED BY THE VIEW, not derived from the entry by a scope_for
   table. Slint can derive it because its entries carry the tab
   (Favorites { tab }); here an entry is a bare route id, so a derived scope
   would read "library" for all five Library tabs and pour the Albums grid's
   offset into whatever tab mounted. Letting the reporter name its own scope
   buys tab granularity for free, and the mismatch case degrades the right way:
   an unmatched scope restores NOTHING instead of restoring the wrong thing.

2. THE RESTORE RE-ASSERTS instead of firing once. A Qt item view resets
   contentY to 0 whenever its model is replaced, and several pages assign their
   model from a binding whose inputs arrive over the network after the mount —
   the album page rebuilds listCells from three deferred rails, so it hands its
   ListView a new array up to four times in its first seconds and snaps back to
   the top over a restore that already ran correctly. The offset is held and
   re-applied until a 2.5 s deadline, or until the user takes over (a wheel, a
   drag or a flick raises `moving`, and from then the position is theirs).

Diagnostics are off by default: QT_LOGGING_RULES="qbz.nav.scroll.info=true" for
the QML half, RUST_LOG=qbz=debug for the stamp/arm half.

cc
2026-08-19 23:54:46 -06:00

347 lines
14 KiB
QML

// "See all releases" — QML port of label/LabelReleasesView.slint: the
// sub-view reached from the label landing's Releases carousel. A circular
// label header over the full album catalog, with a toolbar (search /
// group-by-artist / Hi-Res filter / sort / grid-list) and load-more paging.
//
// NO FIXED HEADER: everything scrolls in one Flickable (unlike the browse
// pages). The scrolling-page convention applies — Column padding 32 / 32 /
// top 11 / bottom 100, the .slint's NavButtons row dropped but its 22px
// spacer kept (see LabelView.qml).
//
// The rendered set (`visible` / `grouped`) is derived in RUST
// (src/label_qt.rs::derive_releases, the port of label.rs:177-249), including
// the fast path that reuses the live catalog when nothing is filtered.
// `hiresCount` is computed over the FULL loaded catalog BEFORE filtering
// (label.rs:190-193) — the count line reports the catalog's Hi-Res total,
// not the filtered one.
//
// Load more is driven by `hasMore`, NEVER by `total`: /label/getAlbums caps
// each page well below the catalog, so `total` is an unreliable gate
// (.slint:296-298).
//
// View mode is UI-only in the .slint (`LabelState.view-mode` is written
// straight from the toggle), so it stays purely in QML here too.
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: theme.ambientOn
radius: 12
QbzTheme { id: theme }
readonly property var doc: {
try {
return JSON.parse(QbzHome.labelReleasesJson)
} catch (e) {
return {}
}
}
readonly property var visibleAlbums: doc.visible || []
readonly property var grouped: doc.grouped || []
readonly property bool groupBy: doc.groupBy === true
readonly property int shown: doc.shown || 0
readonly property int total: doc.total || 0
readonly property int hiresCount: doc.hiresCount || 0
/// UI-only (see the header note).
property string viewMode: "grid"
readonly property var sortLabels: [
QbzSession.tr("Newest", QbzSession.trRev),
QbzSession.tr("Oldest", QbzSession.trRev),
QbzSession.tr("Title A-Z", QbzSession.trRev),
QbzSession.tr("Title Z-A", QbzSession.trRev),
QbzSession.tr("Artist A-Z", QbzSession.trRev),
]
readonly property var sortKeys: ["newest", "oldest", "title-asc", "title-desc", "artist-asc"]
readonly property int sortIndex: Math.max(0, root.sortKeys.indexOf(root.doc.sortBy || "newest"))
QbzOfflinePlaceholder {
visible: QbzSession.offline
anchors.centerIn: parent
showSettingsAction: true
onSettingsClicked: QbzShell.navigateTo("settings")
}
Flickable {
id: flick
anchors.fill: parent
visible: !QbzSession.offline
clip: true
contentWidth: width
contentHeight: page.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Column {
id: page
width: parent.width
leftPadding: 32
rightPadding: 32
topPadding: 11
bottomPadding: 100
spacing: 0
Item { width: 1; height: 22 }
// --- Label header (row spacing 24, right column spacing 6) ----
Row {
width: parent.width - 64
spacing: 24
Rectangle {
width: 180
height: 180
radius: 90
color: theme.surfaceElevated
clip: true
QbzIcon {
visible: (root.doc.imageUrl || "") === ""
name: "disc-3"
width: 60
height: 60
anchors.centerIn: parent
tintName: "muted"
}
// Contain-fit, same conscious deviation as LabelView.qml
// (both .slint label views use cover).
RoundedImage {
anchors.fill: parent
anchors.margins: 26
source: root.doc.artPath || ""
radius: 0
fit: "contain"
}
}
Column {
width: parent.width - 180 - 24
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Text {
text: QbzSession.tr("LABEL", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 10
font.weight: theme.weightSemibold
font.letterSpacing: 1.5
}
Text {
width: parent.width
text: root.doc.name || ""
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
elide: Text.ElideRight
}
Text {
visible: root.total > 0
text: root.total + " " + QbzSession.tr("albums", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontBody
}
}
}
Item { width: 1; height: 24 }
// --- Toolbar ---------------------------------------------------
Item {
width: parent.width - 64
height: 44
Column {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: Math.max(0, parent.width - tools.width - 12)
spacing: 2
Text {
width: parent.width
text: QbzSession.tr("Releases", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
elide: Text.ElideRight
}
Text {
width: parent.width
text: root.shown + " " + QbzSession.tr("albums", QbzSession.trRev)
+ (root.hiresCount > 0 ? " · " + root.hiresCount + " Hi-Res" : "")
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
Row {
id: tools
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
QbzLineEdit {
searchMode: true
expandable: true
anchors.verticalCenter: parent.verticalCenter
placeholder: QbzSession.tr("Search this label…", QbzSession.trRev)
onEdited: function (v) { QbzHome.labelReleasesSearch(v) }
}
QbzToggleButton {
name: "user"
active: root.groupBy
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzHome.labelReleasesSetGroup(!root.groupBy)
}
QbzToggleButton {
name: "list-filter"
active: root.doc.filterHires === true
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzHome.labelReleasesSetHires(root.doc.filterHires !== true)
}
QbzSelect {
anchors.verticalCenter: parent.verticalCenter
menuWidth: 168
options: root.sortLabels
currentIndex: root.sortIndex
onSelected: function (i) { QbzHome.labelReleasesSetSort(root.sortKeys[i]) }
}
ViewModeToggle {
anchors.verticalCenter: parent.verticalCenter
mode: root.viewMode
onSetMode: function (m) { root.viewMode = m }
}
}
}
Item { width: 1; height: 24 }
// --- States ----------------------------------------------------
QbzSpinner {
visible: QbzHome.labelReleasesLoading && root.shown === 0
&& root.grouped.length === 0
size: 36
anchors.horizontalCenter: parent.horizontalCenter
}
// Search / filter matched nothing (the catalog itself is loaded).
Column {
visible: !QbzHome.labelReleasesLoading && root.total > 0 && root.shown === 0
anchors.horizontalCenter: parent.horizontalCenter
spacing: 10
QbzIcon {
name: "search"
width: 32
height: 32
anchors.horizontalCenter: parent.horizontalCenter
tintName: "muted"
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzSession.tr("No results", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 14
}
}
// --- Album collection -----------------------------------------
AlbumCollection {
id: collection
width: parent.width - 64
// Identity of the catalog on screen. Navigating to ANOTHER
// label disarms a tail fade that was armed by a click whose
// page never landed, so the new catalog's first paint is
// never coloured by it (AlbumCollection.collectionKey).
collectionKey: root.doc.id || ""
albums: root.visibleAlbums
grouped: root.grouped
isGrouped: root.groupBy
viewMode: root.viewMode
cardWidth: 200
cardHeight: 266
cardGap: 24
listRowGap: 4
flick: flick
// .slint:280 — 11 padding-top + 22 spacer + 180 header + 24
// + ~42 toolbar + 24 ~= 303.
contentOffset: 303
}
// --- Load more (gated on hasMore, NEVER on total) -------------
// Ported to the shared controls/QbzLoadMore.qml (2026-08-02).
// This site is the ONE that already had a real busy arm — the
// "Loading…" label swap and the disarmed MouseArea this block used
// to spell out by hand are what the control's `busy` property was
// modelled on, so the wiring is a straight rename and the idle
// pixels are unchanged (plain arm, 32-tall box, same 13px label,
// same `implicitWidth + 24` hit box).
//
// The wrapper Item STAYS: `- 64` is the page Column's own
// left+right padding, and a Column's padding does NOT narrow
// `parent.width` for its children, so the wrapper is what keeps
// this block inside the gutters. Its collapsed height is still 0.
Item {
visible: root.shown > 0 && root.doc.hasMore === true
width: parent.width - 64
// Was a literal `visible ? 32 : 0`. The box now also has to
// make room for the skeleton block the control drops UNDER
// the button while a page is in flight; idle that block is
// 0-tall, so this is still exactly 32 (and still 0 hidden).
height: visible ? loadMore.height : 0
QbzLoadMore {
id: loadMore
width: parent.width
// 32 here, not the 28 of the plain sites — this block was
// always a 32-tall box.
buttonHeight: 32
busy: root.doc.loadMoreLoading === true
// The placeholder must be the shape of what will land, and
// this page has both arms: `viewMode` is the UI-only grid /
// list toggle (see the header note), read here exactly as
// AlbumCollection reads it ("list" vs everything else).
skeleton: root.viewMode === "list" ? "rows" : "cards"
// The grid PITCH, not the card size: the collection above
// is mounted with 200x266 cards and a 24px gap, so the
// pitch is 224 x 290 and the control derives the drawn
// card back as pitch - 24 = the same 200x266.
cellW: 224
cellH: 290
// List arm: AlbumListRow is 64 tall and this page mounts
// the collection with listRowGap 4; its art cell is the
// 44px thumb (AlbumListRow.qml:144-150), not a full-height
// square.
rowH: 64
rowGap: 4
rowCount: 2
rowArtSize: 44
onClicked: {
// Snapshot the album ids already on screen so ONLY the
// page that lands fades in. BEFORE the bridge call:
// labelReleasesLoadMore() may republish synchronously.
collection.armTailFade()
QbzHome.labelReleasesLoadMore()
}
}
}
}
}
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
// this container's offset while it is the live page, and restores it
// when a back/forward step arms this route.
ScrollMemory { target: flick; scope: "labelreleases" }
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: flick
}
}