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

545 lines
22 KiB
QML

// Award landing page — QML port of award/AwardView.slint (itself a 1:1 port
// of Tauri's AwardView.svelte). Route "award".
//
// A circular gold/laurel hero with the award name + magazine + a Follow
// heart, a section header ("Acclaimed Releases" + count + "See all" when more
// exist), a bounded PREVIEW grid, and an "Other awards" carousel.
//
// SCROLLING-PAGE CONVENTION (AlbumView / ArtistView / LabelView): one
// Flickable whose Column pads 32 / 32 / top 11 / bottom 100. The .slint's
// NavButtons row is dropped — back/forward live in the shell header — but its
// 22px spacer is KEPT so the hero sits where every other detail page's does.
//
// Composition: the grid is views/AlbumCollection.qml (the Discover/Label
// grid) and the heart is controls/QbzCircleAction.qml. The ONE local
// component is the "Other awards" card — see AwardRailCard below for why the
// shared ArtistCard could not be reused here.
//
// THE ONE ADDITION over the reference: the all-awards dropdown, pinned to the
// page's top-right. The Slint can only reach an award by finding an album that
// won it;
// this lists every award the `/award/explore` crawl found, so you can jump
// straight to any of them. The crawl already existed to resolve names to ids —
// the control is new, the data is not.
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 }
// One "Other awards" card. Local, and deliberately NOT cards/ArtistCard:
// that one calls `QbzArtist.openArtist(item.id)` INSIDE its own MouseArea
// and again from its menu (ArtistCard.qml:197, :294, :331) with no signal
// to override — handing it an award id would open an ARTIST page for a
// number that means something else entirely. The Slint could reuse its
// carousel because the Slint one emits `artist-clicked(id)` outward; this
// port's card swallows the click, so reuse here is not the same trade.
//
// It also drops the deviation the reference had to accept: an award with
// no image gets a LAUREL on gold, not the person glyph ArtistCard falls
// back to.
component AwardRailCard: Item {
id: railCard
property var entry: ({})
width: 160
height: 200
Column {
anchors.horizontalCenter: parent.horizontalCenter
spacing: 10
Rectangle {
width: 140
height: 140
radius: 70
clip: true
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: "#b45309" }
GradientStop { position: 1.0; color: "#eab308" }
}
QbzIcon {
anchors.centerIn: parent
visible: (railCard.entry.imageUrl || "") === ""
name: "award"
width: 56
height: 56
tintName: theme.accentGlyphTint
}
RoundedImage {
anchors.fill: parent
visible: (railCard.entry.imageUrl || "") !== ""
radius: 70
source: railCard.entry.imageUrl || ""
}
}
Text {
width: 140
horizontalAlignment: Text.AlignHCenter
text: railCard.entry.title || ""
color: railArea.containsMouse ? theme.accent : theme.textPrimary
font.pixelSize: theme.fontLegal
font.weight: theme.weightMedium
elide: Text.ElideRight
}
Text {
width: 140
visible: (railCard.entry.artist || "") !== ""
horizontalAlignment: Text.AlignHCenter
text: railCard.entry.artist || ""
color: theme.textMuted
font.pixelSize: 11
elide: Text.ElideRight
}
}
MouseArea {
id: railArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzHome.openAward(railCard.entry.id || "",
railCard.entry.title || "")
}
}
readonly property var doc: {
try {
return JSON.parse(QbzHome.awardJson)
} catch (e) {
return ({})
}
}
readonly property var albums: root.doc.albums || []
readonly property var otherAwards: root.doc.otherAwards || []
readonly property var catalog: root.doc.catalog || []
readonly property bool loading: QbzHome.awardLoading === true
readonly property bool loadError: root.doc.loadError === true
Flickable {
id: page
anchors.fill: parent
contentWidth: width
contentHeight: col.implicitHeight
boundsBehavior: Flickable.StopAtBounds
clip: true
Column {
id: col
width: page.width - 64
x: 32
topPadding: 11
bottomPadding: 100
spacing: 0
// Reserves exactly the pinned bar's height. Without it the hero
// would jump up by that height the moment the bar was lifted out
// of the flow (the ArtistView jumpSlot rule).
Item {
id: navSlot
width: 1
height: navBar.height
}
// The .slint's NavButtons row is not ported; its spacer is, so the
// hero lands at the same y as every other detail page's header.
Item { width: 1; height: 22 }
// ---- Hero -------------------------------------------------------
Row {
width: parent.width
spacing: 32
Rectangle {
width: 180
height: 180
radius: 90
clip: true
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: "#b45309" }
GradientStop { position: 1.0; color: "#eab308" }
}
QbzIcon {
anchors.centerIn: parent
visible: (root.doc.imageUrl || "") === ""
name: "award"
width: 84
height: 84
// The glyph sits on a saturated gold fill, which is the
// on-accent case the tint selector exists for.
tintName: theme.accentGlyphTint
}
RoundedImage {
anchors.fill: parent
visible: (root.doc.imageUrl || "") !== ""
radius: 90
source: root.doc.imageUrl || ""
}
}
Column {
width: parent.width - 180 - 32
spacing: 0
Item { width: 1; height: 8 }
Text {
text: QbzSession.tr("Award", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 10
font.weight: theme.weightSemibold
font.letterSpacing: 1.5
}
Item { width: 1; height: 6 }
Text {
width: parent.width
text: root.doc.name || ""
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
elide: Text.ElideRight
}
Item {
visible: (root.doc.magazineName || "") !== ""
width: 1
height: 10
}
Text {
visible: (root.doc.magazineName || "") !== ""
width: parent.width
// Server text — never translated.
text: root.doc.magazineName || ""
color: theme.textSecondary
font.pixelSize: theme.fontBody
elide: Text.ElideRight
}
Item { width: 1; height: 18 }
QbzCircleAction {
name: root.doc.isFollowing === true ? "heart-filled" : "heart"
active: root.doc.isFollowing === true
btnEnabled: root.doc.followToggling !== true
onClicked: QbzHome.awardFollow()
}
}
}
Item { width: 1; height: 32 }
// ---- Section header ---------------------------------------------
// Anchored, not a Row: the "See all" chip pins right and the
// title group pins left, so neither has to know the other's width
// (a `parent.children[0].width` subtraction is how a header ends
// up mis-measuring itself the first time a child is hidden).
Item {
width: parent.width
height: 26
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 10
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Acclaimed Releases", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
}
Text {
anchors.verticalCenter: parent.verticalCenter
visible: (root.doc.total || 0) > 0
text: root.doc.total || 0
color: theme.textMuted
font.pixelSize: theme.fontBody
font.weight: theme.weightMedium
}
}
// "See all" — only when there is more than is loaded. The
// search and the shuffle used to sit beside it; they live in
// the pinned nav bar now, next to the award picker.
Rectangle {
id: seeAll
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: root.doc.hasMore === true
width: visible ? seeAllText.implicitWidth + 16 : 0
height: 26
radius: 4
color: seeAllArea.containsMouse ? theme.surfaceHover : "transparent"
Text {
id: seeAllText
anchors.centerIn: parent
// "See All" is the msgid; the arrow is decoration
// appended OUTSIDE it (the reference is explicit).
text: QbzSession.tr("See All", QbzSession.trRev) + " →"
color: seeAllArea.containsMouse ? theme.textPrimary : theme.textSecondary
font.pixelSize: 14
font.weight: theme.weightMedium
}
MouseArea {
id: seeAllArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzHome.awardOpenAlbums()
}
}
}
Item { width: 1; height: 16 }
// ---- Body: loading / error+retry / empty / grid (exclusive) -----
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()
}
}
}
Item {
visible: !root.loading && !root.loadError && root.albums.length === 0
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
// Identity of the catalog on screen — navigating to ANOTHER
// award must not inherit the previous one's tail fade
// (AlbumCollection.collectionKey, the LabelReleasesView note).
collectionKey: root.doc.id || ""
albums: root.albums
viewMode: "grid"
isGrouped: false
cardWidth: 200
cardHeight: 266
cardGap: 24
}
// ---- Load more ---------------------------------------------------
// A BUTTON, not infinite scroll: the listing sub-view owns the
// scroll-driven paging, and this page has a rail under the grid —
// auto-loading here would keep pushing that rail out of reach.
// Suppressed while a query is active, like the listing: paging in
// rows the filter then hides is work with no visible result.
Item {
visible: root.doc.hasMore === true && (root.doc.searchQuery || "") === ""
width: parent.width
height: visible ? 64 : 0
SettingsButton {
anchors.centerIn: parent
enabled: root.doc.loadingMore !== true
text: root.doc.loadingMore === true
? QbzSession.tr("Loading…", QbzSession.trRev)
: QbzSession.tr("Load more", QbzSession.trRev)
onClicked: QbzHome.awardAlbumsLoadMore()
}
}
// ---- Other awards -----------------------------------------------
Item {
visible: root.otherAwards.length > 0
width: 1
height: visible ? 32 : 0
}
Column {
visible: root.otherAwards.length > 0
width: parent.width
spacing: 12
Text {
text: QbzSession.tr("Other awards", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
}
Flickable {
width: parent.width
height: 210
contentWidth: rail.width
contentHeight: height
flickableDirection: Flickable.HorizontalFlick
boundsBehavior: Flickable.StopAtBounds
clip: true
Row {
id: rail
height: parent.height
spacing: 16
Repeater {
model: root.otherAwards
delegate: AwardRailCard {
required property var modelData
entry: modelData
}
}
}
}
}
}
// 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: "award" }
QbzScrollBar {
target: page
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
// ---- The award nav bar (this port only) ---------------------------------
// Pinned at the top of the page, OUTSIDE the Flickable, given the
// ArtistView jump-nav treatment: 44px, its own fill, a 1px bottom
// hairline, and the page's 32px horizontal rhythm.
//
// THE ROUNDED-CORNER RULE, and it is a real defect this codebase has hit
// twice (ArtistView.qml:2795, the Discover full-bleed toolbar): pinned at
// the pane's top edge the bar's SQUARE corners poke out past the shell's
// rounded bezel. Round them — but only WHILE it is actually at the top.
// Mid-page the bar is surrounded by content and a notch there is worse
// than the square corner it fixes, so the radius is driven off the live y.
//
// The fill is not transparent: pinned, the page scrolls underneath, so it
// needs enough body to stay readable. Under the dynamic background that is
// surface-main at the thin-bar alpha tier, the same one ArtistView uses.
Rectangle {
id: navBar
x: 0
width: root.width
height: 44
y: Math.max(page.y + navSlot.y - page.contentY, 0)
z: 5
color: root.ambientOn ? theme.surfaceMainA30 : theme.surfaceMain
topLeftRadius: y <= 0 ? theme.radiusMd : 0
topRightRadius: y <= 0 ? theme.radiusMd : 0
Rectangle {
x: 0
y: parent.height - 1
width: parent.width
height: 1
color: theme.borderSubtle
}
// All three controls in one cluster, right-aligned, with the
// navigation dropdown LAST — the search and the shuffle act on the
// page you are on, the dropdown takes you off it, so it reads
// left-to-right from "narrow this" to "leave here".
Row {
anchors.right: parent.right
anchors.rightMargin: 32
anchors.verticalCenter: parent.verticalCenter
spacing: 10
// Filters the loaded set. Shares the LISTING's query — one award,
// one filter — so it is still in force behind "See all".
QbzLineEdit {
anchors.verticalCenter: parent.verticalCenter
width: 220
searchMode: true
sm: true
placeholder: QbzSession.tr("Search albums & tracks…", QbzSession.trRev)
text: root.doc.searchQuery || ""
onEdited: function (v) { QbzHome.awardAlbumsSearch(v) }
}
// Play a RANDOM album out of what is LOADED (the LibraryToolbar
// precedent). Not the whole award: the rest is not here to pick
// from, and reaching for it would put a blocking fetch behind a
// button that should feel instant.
QbzToolButton {
anchors.verticalCenter: parent.verticalCenter
visible: root.albums.length > 0
name: "shuffle"
label: ""
onClicked: {
var pick = root.albums[
Math.floor(Math.random() * root.albums.length)]
if (pick && pick.id)
QbzPlayer.playAlbum(pick.id)
}
}
// Jump to ANY award. Hidden until the background crawl has both a
// list and THIS award's place in it: `currentIndex` tracks the
// open page, so navigating away and arriving somewhere else moves
// the selection with you instead of stranding it on the award you
// came from.
QbzSelect {
id: awardPicker
anchors.verticalCenter: parent.verticalCenter
visible: root.catalog.length > 1
&& (root.doc.catalogIndex !== undefined)
&& root.doc.catalogIndex >= 0
menuWidth: 320
popupWidth: 320
searchable: true
options: {
var out = []
for (var i = 0; i < root.catalog.length; i++)
out.push(root.catalog[i].name)
return out
}
currentIndex: root.doc.catalogIndex || 0
onSelected: function (i) {
var entry = root.catalog[i]
if (entry && entry.id !== (root.doc.id || ""))
QbzHome.openAward(entry.id, entry.name || "")
}
}
}
}
}