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

346 lines
13 KiB
QML

// Artists tab body (LocalLibraryView.slint:2213) — two-column
// master/detail: the merged, A-Z grouped artist rail on the left (280px,
// with its own AlphaStrip), that artist's albums on the right.
//
// The Slint rail is a plain Flickable with a nested for-per-section; here
// the sections are flattened into one entry array so the rail is a WINDOWING
// ListView — a 4k-artist library mounts a viewport, not 4k rows.
import QtQuick
import com.blitzfc.qbz
import "../../controls"
import "../../theme"
Item {
id: root
property var view: null
QbzTheme { id: theme }
readonly property int headerH: 22
readonly property int rowH: 62
// --------------------------- entry model ------------------------------
property var entries: []
property var alphaJumps: []
/// The rail's rows in DISPLAY order — the A-Z grouping reorders them, so
/// this is NOT `view.artistsVisible` and the window report must index
/// into it instead. (Reporting entry indices against `artistsVisible`
/// asked for the wrong avatars by the number of letter headers scrolled
/// past — up to 27 rows off at the bottom of the rail.)
property var flat: []
/// entry index -> index into `flat`, -1 for a letter header.
property var flatIndex: []
function rebuild() {
var groups = view ? view.artistsGrouped : []
var out = []
var jumps = []
var flatOut = []
var idx = []
for (var i = 0; i < groups.length; i++) {
var g = groups[i]
if (!g.items || g.items.length === 0) continue
jumps.push({ "letter": g.letter, "index": out.length })
out.push({ "t": 0, "label": g.letter })
idx.push(-1)
for (var j = 0; j < g.items.length; j++) {
out.push({ "t": 1, "item": g.items[j] })
idx.push(flatOut.length)
flatOut.push(g.items[j])
}
}
entries = out
alphaJumps = jumps
flat = flatOut
flatIndex = idx
report()
}
Component.onCompleted: { rebuild(); reportSoon() }
Component.onDestruction: if (view) view.releaseWindow("artists-rail")
Connections {
target: root.view
function onArtistsGroupedChanged() { root.rebuild() }
function onArtworkRefresh() { root.reportSoon() }
}
// Loading = the shape of the master/detail pair that is coming: 60px
// rail rows with a ROUND 48px avatar on the left, the album grid on the
// right. Two composites, two animators (see QbzSkeleton COST).
Row {
visible: QbzLocal.localArtistsLoading
anchors.fill: parent
anchors.topMargin: 12
spacing: 0
// The rail's own left inset (24), so the placeholder rows land where
// the real ones do.
Item { width: 24; height: 1 }
QbzSkeleton {
variant: "rowList"
width: 256 - 22
height: parent.height
rowH: 60
rowGap: 2
rowArtSize: 48
rowArtRound: true
phase: root.view.skelPhase
}
Item { width: 22 + 1; height: 1 }
QbzSkeleton {
variant: "cardGrid"
width: parent.width - 281
height: parent.height
cellW: 220
cellH: 266
phase: root.view.skelPhase
}
}
// Empty library — icon + line (Slint :2222).
Column {
visible: !QbzLocal.localArtistsLoading && root.view.artists.length === 0
anchors.centerIn: parent
spacing: 10
QbzIcon {
anchors.horizontalCenter: parent.horizontalCenter
name: "user"
width: 44
height: 44
tintName: "muted"
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzSession.tr("No artists in your local library yet.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontBody
}
}
Row {
anchors.fill: parent
visible: !QbzLocal.localArtistsLoading && root.view.artists.length > 0
spacing: 0
// ---------------------- master rail ------------------------------
Item {
width: 280
height: parent.height
clip: true
Column {
anchors.fill: parent
anchors.leftMargin: 24
anchors.topMargin: 12
spacing: 8
Text {
text: root.view.artistsVisibleCount + " "
+ QbzSession.tr("artists", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
}
Text {
visible: root.view.artistsVisibleCount === 0
text: QbzSession.tr("No artists match your search.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
Item {
visible: root.view.artistsVisibleCount > 0
width: parent.width
height: parent.height - 30
// Back/forward scroll memory (controls/ScrollMemory.qml).
ScrollMemory { target: rail; scope: "local:artists" }
ListView {
id: rail
anchors.fill: parent
anchors.rightMargin: 22
clip: true
cacheBuffer: 62 * 8
boundsBehavior: Flickable.StopAtBounds
model: root.entries
onContentYChanged: root.report()
onModelChanged: root.report()
onHeightChanged: root.report()
onVisibleChanged: {
if (visible) root.reportSoon()
else if (root.view) root.view.releaseWindow("artists-rail")
}
delegate: Loader {
required property var modelData
width: rail.width
height: modelData.t === 0 ? root.headerH : root.rowH
sourceComponent: modelData.t === 0 ? letterComp : artistComp
Component {
id: letterComp
Item {
Text {
anchors.verticalCenter: parent.verticalCenter
text: modelData.label
color: theme.textSecondary
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
}
}
}
Component {
id: artistComp
LocalArtistRow {
width: rail.width
view: root.view
item: modelData.item
artSource: root.view.artMap[modelData.item.artKey] || ""
selected: root.view.selectedArtist === modelData.item.name
onPicked: function (name) { root.view.selectArtist(name) }
}
}
}
}
QbzAlphaStrip {
visible: root.alphaJumps.length > 0
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
jumps: root.alphaJumps
onJump: function (ordinal, index) {
rail.positionViewAtIndex(index, ListView.Beginning)
}
}
}
}
}
Rectangle {
width: 1
height: parent.height
color: theme.borderSubtle
}
// ---------------------- detail pane ------------------------------
Item {
width: parent.width - 281
height: parent.height
clip: true
Column {
visible: root.view.selectedArtist === ""
anchors.centerIn: parent
spacing: 10
QbzIcon {
anchors.horizontalCenter: parent.horizontalCenter
name: "user"
width: 44
height: 44
tintName: "muted"
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzSession.tr("Select an artist to view their albums", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontBody
}
}
Column {
anchors.fill: parent
anchors.leftMargin: 28
anchors.rightMargin: 8
anchors.topMargin: 14
spacing: 12
visible: root.view.selectedArtist !== ""
Column {
width: parent.width
spacing: 2
Text {
width: parent.width
text: root.view.selectedArtist
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
elide: Text.ElideRight
}
Text {
text: root.view.artistAlbums.length + " "
+ QbzSession.tr("albums", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
}
Text {
visible: root.view.artistAlbums.length === 0
width: parent.width
topPadding: 24
text: QbzSession.tr("No albums found", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontBody
horizontalAlignment: Text.AlignHCenter
}
LocalAlbumCollection {
visible: root.view.artistAlbums.length > 0
width: parent.width
height: parent.height - 60
view: root.view
surface: "artist-albums"
rows: root.view.artistAlbums
grouped: false
viewMode: "grid"
showSource: true
onOpenRequested: function (id) { root.view.openAlbum(id) }
onPlayRequested: function (id) { QbzLocal.playAlbum(id, false) }
onEnqueueRequested: function (id, m) { QbzLocal.enqueue("album", id, m) }
}
}
}
}
// Artist avatars ride the same id-keyed artwork window as every other
// cover; the rail reports its mounted band.
//
// TRIGGERS: the rail used to report on `contentY` and the model swap
// only, and both of those fire while the pane is still behind
// `localArtistsLoading` — the report was made against a ListView that had
// not laid out, and nothing re-fired once the rows appeared. It now also
// reports on mount, on the rebuild, when the pane becomes visible and
// when the viewport resizes.
function report() {
if (!view || !rail) return
if (!rail.visible || root.entries.length === 0) {
view.releaseWindow("artists-rail")
return
}
var first = rail.indexAt(4, rail.contentY + 1)
var last = rail.indexAt(4, rail.contentY + Math.max(1, rail.height) - 1)
if (first < 0) first = 0
if (last < 0) last = Math.min(root.entries.length - 1, first + 12)
// Entry band -> rail-row band (letter headers do not carry a cover).
var lo = -1
var hi = -1
for (var i = first; i <= last; i++) {
var n = root.flatIndex[i]
if (n === undefined || n < 0) continue
if (lo < 0) lo = n
hi = n
}
if (lo < 0) { lo = 0; hi = Math.min(root.flat.length - 1, 12) }
view.queueWindowReport(root.flat, Math.max(0, lo - 4), hi + 4, "artists-rail")
}
/// Report now, then again once the ListView has laid out — `indexAt`
/// answers -1 before that, which is exactly the state of a pane that has
/// just been shown.
Timer {
id: reportSettle
interval: 50
repeat: false
onTriggered: root.report()
}
function reportSoon() {
report()
reportSettle.restart()
}
}