Files
qbz/crates/qbz-qt/qml/views/AwardAlbumsView.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

219 lines
7.8 KiB
QML

// Award "See all" listing — QML port of award/AwardAlbumsView.slint. Route
// "awardalbums".
//
// A search box over the LOADED set, the award name as the page title, and an
// infinite-scroll grid that asks for the next `/award/getAlbums` page as the
// tail comes into view. Same four exclusive body branches as the landing
// page, in the reference's order: loading · error+retry · no-results (a search
// that matched nothing) · the grid.
//
// The search is CLIENT-SIDE over what has been paginated in — 1:1 with the
// reference, which does not re-query for this box either. That is also why
// load-more is suppressed while a query is active: appending pages the user
// cannot see, to filter them out again, is work with no visible result.
//
// Every number, filter and page in the document comes from src/award_qt.rs;
// this file renders and reports scroll, nothing else.
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../theme"
Rectangle {
id: root
readonly property bool ambientOn: theme.ambientOn
color: root.ambientOn ? "transparent" : theme.surfaceMain
radius: 12
QbzTheme { id: theme }
readonly property var doc: {
try {
return JSON.parse(QbzHome.awardAlbumsJson)
} catch (e) {
return ({})
}
}
readonly property var albums: root.doc.albums || []
readonly property bool loading: QbzHome.awardAlbumsLoading === true
readonly property bool loadError: root.doc.loadError === true
readonly property string query: root.doc.searchQuery || ""
Flickable {
id: page
anchors.fill: parent
contentWidth: width
contentHeight: col.implicitHeight
boundsBehavior: Flickable.StopAtBounds
clip: true
// Infinite scroll. Gated on the SAME four conditions the reference
// gates on — not loading, not already loading more, there IS more, and
// no active query — so a filtered view never pages in behind it.
onContentYChanged: {
if (contentHeight - (contentY + height) < 600
&& root.doc.hasMore === true
&& root.doc.loadingMore !== true
&& !root.loading
&& root.query === "")
QbzHome.awardAlbumsLoadMore()
}
Column {
id: col
width: page.width - 64
x: 32
topPadding: 11
bottomPadding: 100
spacing: 0
Item { width: 1; height: 22 }
// ---- Search ------------------------------------------------------
QbzLineEdit {
width: 320
searchMode: true
placeholder: QbzSession.tr("Search albums & tracks…", QbzSession.trRev)
text: root.query
onEdited: function (v) { QbzHome.awardAlbumsSearch(v) }
}
Item { width: 1; height: 20 }
// ---- Title -------------------------------------------------------
Text {
width: parent.width
text: root.doc.name || ""
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
elide: Text.ElideRight
}
Item { width: 1; height: 4 }
Row {
spacing: 10
Text {
text: QbzSession.tr("Acclaimed Releases", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: theme.fontBody
}
Text {
visible: (root.doc.total || 0) > 0
text: root.doc.total || 0
color: theme.textMuted
font.pixelSize: theme.fontBody
font.weight: theme.weightMedium
}
}
Item { width: 1; height: 16 }
// ---- Body: loading / error+retry / no-results / grid ------------
Item {
visible: root.loading
width: parent.width
height: visible ? 280 : 0
Column {
anchors.centerIn: parent
spacing: 18
QbzSpinner {
anchors.horizontalCenter: parent.horizontalCenter
size: 36
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzSession.tr("Loading…", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 13
}
}
}
Item {
visible: !root.loading && root.loadError
width: parent.width
height: visible ? 240 : 0
Column {
anchors.centerIn: parent
spacing: 16
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzSession.tr("Failed to load Library", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 14
}
SettingsButton {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzSession.tr("Retry", QbzSession.trRev)
onClicked: QbzHome.awardRetry()
}
}
}
// A search that matched nothing is NOT the same as an award with
// no releases, and the reference keeps them as two strings.
Item {
visible: !root.loading && !root.loadError
&& root.albums.length === 0 && root.query !== ""
width: parent.width
height: visible ? 200 : 0
Text {
anchors.centerIn: parent
text: QbzSession.tr("No results found.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 14
}
}
Item {
visible: !root.loading && !root.loadError
&& root.albums.length === 0 && root.query === ""
width: parent.width
height: visible ? 200 : 0
Text {
anchors.centerIn: parent
text: QbzSession.tr("No award-winning releases yet.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 14
}
}
AlbumCollection {
visible: !root.loading && !root.loadError && root.albums.length > 0
width: parent.width
collectionKey: root.doc.id || ""
albums: root.albums
viewMode: "grid"
isGrouped: false
cardWidth: 200
cardHeight: 266
cardGap: 24
}
// Tail spinner for the next page.
Item {
visible: root.doc.loadingMore === true
width: parent.width
height: visible ? 72 : 0
QbzSpinner {
anchors.centerIn: parent
size: 28
}
}
}
// 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: page; scope: "awardalbums" }
QbzScrollBar {
target: page
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
}