Files
qbz/crates/qbz-qt/qml/kiosk/KioskArtist.qml
T
vicrodh e5f93d6805 fix(qt): the stale-derived-read hazard, swept across the kiosk views
KioskArtist's TypeError was found by luck, not by a method, so an independent
agent swept every on<X>Changed handler in the kiosk tree for the same class:
a handler reading a property that is DERIVED from the very thing it fired for.
Three more, all silent:

- KioskDiscover.publishNav() read `navSectionCount` from inside
  onActiveTabChanged, so it published the PREVIOUS tab's section count (and
  NaN on the creation pass). publish() clamps `index` into the count it is
  handed, so a wrong count is a focus ring parked on nothing, not a cosmetic
  slip.
- KioskMyQBZ.publishNav() had the same shape against `cards` and
  onOnCollectionsChanged, counting the previous route's document.
- KioskArtist's shelf skip-cascade read QbzKioskNav.dir inside onIndexChanged.

That last one was MY defect, not the view's: sync() wrote `index` before
`dir`, and each cxx-qt setter emits its NOTIFY synchronously, so the handler
ran while `dir` still held the previous move. The first Down of a session read
dir == 0 and refused to skip, parking the ring on a shelf of zero height; a
direction reversal skipped back the way it came.

Fixed at the source: sync() now writes every CONTEXT field (zone, columns,
count, tabs, shelves, dir, hmove, nav_active) before the two fields consumers
actually react to (index, activate_seq). The QML keeps its Qt.callLater as
well -- that covers Qt's own binding re-evaluation order, which is a different
hazard from the setter order. Two independent guards, because this class is
invisible to every static check.

The general remedy, in all four sites: compute from the source value at call
time, never read a cached derived property inside its own handler. Written up
at qml/Main.qml:233-247 from the window-geometry work.

Gate: 9 warnings = baseline, 291 tests, both audits OK, qmllint clean on every
touched file, kiosk-profile smoke clean, and the shelf skip cascade re-driven
over VNC (scratchpad/kiosk/s-01-skip-cascade.png).

cc
2026-08-03 23:59:42 -06:00

451 lines
19 KiB
QML

// KioskArtist — lightweight kiosk artist detail (shell/KioskArtist.slint).
// Reads the same document as the desktop ArtistView (`QbzArtist.artistJson`):
// a header (round portrait + name + Play/Radio) and the discography as simple
// KioskCard shelves per release section (the junk "other" bucket is skipped).
//
// Keyboard/gamepad nav: SHELF mode, same model as KioskDiscover — one nav
// index per shelf (columns == 1, no tabs), Left/Right pulses `hmove` consumed
// by the focused shelf's local cursor, skipped shelves ("other" bucket / empty)
// are stepped over in the last travel direction. The header buttons (Play /
// Radio) stay touch-only: they carry no nav index and no focus ring.
//
// `count` counts EVERY section, including the ones this view refuses to
// render; the per-shelf skip cascade is what walks past them.
//
// Slint's `viewport-y` is negative as you scroll down; Qt's `contentY` is its
// positive twin — every occurrence is already translated. Both scroll writes
// run through Qt.callLater so they land on a settled layout, never inside a
// binding evaluation ("Recursion detected" class).
//
// Cover paths: the Slint's AlbumCardItem carries a resolved `artwork` that the
// artwork pipeline pushes into the shared model in place. The Qt documents
// carry only the remote `artUrl`, so this view dispatches the urls and resolves
// them out of `coverMap` when it hands each card down to KioskCard.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Flickable {
id: root
property real pad: 22
readonly property var artist: JSON.parse(QbzArtist.artistJson)
readonly property var sections: root.artist && root.artist.releaseSections
? root.artist.releaseSections
: []
contentWidth: root.width
contentHeight: page.implicitHeight + 2 * root.pad
clip: true
boundsBehavior: Flickable.StopAtBounds
QbzTheme { id: theme }
// ---- Nav geometry publish: no tab strip, one index per shelf ---------
function publishNav() {
QbzKioskNav.publishNav(0, 1, root.sections.length, true)
}
readonly property int navCountProbe: root.sections.length
onNavCountProbeChanged: root.publishNav()
// ---- Artwork (Qt-only): dispatch + coalesce -------------------------
// Covers arrive ONE AT A TIME, and on a warm cache the whole disk-hit set
// lands in a single synchronous loop. Rebinding `coverMap` per arrival is
// quadratic in the page, so arrivals are coalesced into ONE rebind per
// frame (16ms granularity is invisible) — the ArtistView.qml fix.
property var coverMap: ({})
property var _coverInbox: ({})
property var dispatchedCovers: ({})
Timer {
id: coverFlush
interval: 16
repeat: false
onTriggered: {
var m = Object.assign({}, root.coverMap, root._coverInbox)
root._coverInbox = ({})
// A rebind needs a NEW object reference (same-ref assignment is
// not a change in QML).
root.coverMap = m
}
}
Connections {
target: QbzLibrary
// `sidebarArtworkWindow` keys its replies by the REQUESTED URL.
function onLibraryArtworkReady(key, path) {
root._coverInbox[key] = path
if (!coverFlush.running)
coverFlush.start()
}
}
function dispatchArtwork(urls) {
var fresh = []
for (var i = 0; i < urls.length; i++) {
var u = urls[i]
if (!u || root.dispatchedCovers[u])
continue
root.dispatchedCovers[u] = true
fresh.push(u)
}
if (fresh.length > 0)
QbzShell.sidebarArtworkWindow(JSON.stringify(fresh))
}
// The portrait plus every section's cards — INCLUDING the sections this
// view does not render, because a later publish pass can fill one and make
// it renderable, and nothing downstream reports a url that was never sent.
// Reads `artist` and derives the sections HERE rather than reading the
// `sections` property, and that is load-bearing. `onArtistChanged` below
// is connected to the very notify that dirties `sections`, and the handler
// runs BEFORE the dependent binding is re-evaluated — so a read of
// `root.sections` from inside it saw `undefined` and threw
// `Cannot read property 'length' of undefined` on every mount, skipping
// the first artwork dispatch. Same hazard, same remedy, as the note at
// Main.qml:233-247: compute from the value at call time instead of reading
// a cached derived property.
function collectArtwork() {
var urls = []
var a = root.artist
if (a && a.artUrl)
urls.push(a.artUrl)
var sections = (a && a.releaseSections) ? a.releaseSections : []
for (var s = 0; s < sections.length; s++) {
var cards = sections[s].cards || []
for (var c = 0; c < cards.length; c++)
if (cards[c].artUrl)
urls.push(cards[c].artUrl)
}
root.dispatchArtwork(urls)
}
onArtistChanged: root.collectArtwork()
Component.onCompleted: {
root.publishNav()
root.collectArtwork()
}
Column {
id: page
x: root.pad
y: root.pad
width: root.width - 2 * root.pad
spacing: 22
// ---- Header ------------------------------------------------------
Item {
id: headerRow
width: page.width
height: Math.max(140, headerInfo.implicitHeight)
Rectangle {
id: portrait
anchors.left: headerRow.left
anchors.verticalCenter: headerRow.verticalCenter
width: 140
height: 140
radius: 70
color: theme.surfaceElevated
clip: true
RoundedImage {
anchors.fill: portrait
source: root.artist && root.artist.artUrl
? (root.coverMap[root.artist.artUrl] || "")
: ""
radius: 70
fit: "crop"
}
}
Column {
id: headerInfo
anchors.left: portrait.right
anchors.leftMargin: 22
anchors.right: headerRow.right
anchors.verticalCenter: headerRow.verticalCenter
spacing: 12
Text {
width: headerInfo.width
text: root.artist && root.artist.name ? root.artist.name : ""
color: theme.textPrimary
font.pixelSize: 25
font.weight: theme.weightBold
wrapMode: Text.WordWrap
}
Row {
spacing: 10
// TOUCH-ONLY. No nav index, no focus ring — the only state
// either button has is mouse hover.
Rectangle {
id: playBtn
width: 150
height: 44
radius: theme.radiusSm
color: playArea.containsMouse ? theme.accentHover : theme.accent
Text {
anchors.fill: playBtn
text: QbzSession.tr("Play", QbzSession.trRev)
color: theme.accentText
font.pixelSize: 15
font.weight: theme.weightSemibold
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
MouseArea {
id: playArea
anchors.fill: playBtn
hoverEnabled: true
onClicked: {
if (root.artist && root.artist.id)
QbzHome.playArtistTopTracks(root.artist.id)
}
}
}
Rectangle {
id: radioBtn
width: 130
height: 44
radius: theme.radiusSm
color: radioArea.containsMouse
? theme.alphaTier(8)
: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Text {
anchors.fill: radioBtn
text: QbzSession.tr("Radio", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: 15
font.weight: theme.weightSemibold
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
MouseArea {
id: radioArea
anchors.fill: radioBtn
hoverEnabled: true
onClicked: {
if (root.artist && root.artist.id)
QbzHome.startArtistRadio(root.artist.id)
}
}
}
}
}
}
// ---- Discography shelves ----------------------------------------
// One delegate per section, INCLUDING the ones that render nothing:
// their nav handlers are what step the focus over them. A non-rendering
// delegate is invisible, so the Column gives it neither height nor a
// spacing slot — exactly what the Slint's `if` does.
Repeater {
model: root.sections
delegate: Item {
id: shelfSlot
required property var modelData
required property int index
readonly property var cards: shelfSlot.modelData && shelfSlot.modelData.cards
? shelfSlot.modelData.cards
: []
readonly property string releaseType: shelfSlot.modelData && shelfSlot.modelData.releaseType
? shelfSlot.modelData.releaseType
: ""
readonly property bool renders: shelfSlot.releaseType !== "other"
&& shelfSlot.cards.length > 0
property int cursor: 0
readonly property bool shelfFocused: QbzKioskNav.navActive
&& QbzKioskNav.zone === "content"
&& (QbzKioskNav.index - QbzKioskNav.tabs) === shelfSlot.index
visible: shelfSlot.renders
width: page.width
// 24px title + 12px spacing + 182px strip — the constant the
// 218/240 page arithmetic below depends on.
height: shelfSlot.renders ? 218 : 0
// Step over the shelves this view does not render, in the last
// travel direction. NOT a write to `QbzKioskNav.index`: the
// qproperties are a mirror of the Rust NavModel (the single
// source of truth — kiosk_nav_bridge.rs:109-110), so a QML
// property write desyncs them and the next arrow key computes
// from the stale model. Instead the skip re-runs the model's
// own mover one more step; the boundary guards reproduce the
// Slint clamp `max(tabs, min(count-1, index+dir))` — at either
// edge the Slint write is a no-op, so no call is made (which
// also avoids down()'s player escape and up()'s D5 rail wrap).
// Each step re-fires every shelf's handler, so this cascades
// until a renderable shelf or a boundary is hit. `dir === 0`
// (no vertical move yet) deliberately does NOT skip.
function skipIfUnrendered() {
if (QbzKioskNav.zone === "content" && QbzKioskNav.navActive
&& (QbzKioskNav.index - QbzKioskNav.tabs) === shelfSlot.index
&& (shelfSlot.releaseType === "other" || shelfSlot.cards.length === 0)
&& QbzKioskNav.dir !== 0) {
if (QbzKioskNav.dir > 0
&& QbzKioskNav.index < QbzKioskNav.count - 1)
QbzKioskNav.navDown()
else if (QbzKioskNav.dir < 0
&& QbzKioskNav.index > QbzKioskNav.tabs)
QbzKioskNav.navUp()
}
}
// Keep the focused shelf's strip inside the page viewport.
// Fixed page arithmetic: 22px pad + 140px header + 22px
// spacing = 184, then a 240px pitch per shelf block (24 title
// + 12 spacing + 182 strip + 22 spacing) over a 218px block.
function scrollShelfIntoView() {
if (!shelfSlot.shelfFocused || shelfSlot.cards.length === 0)
return
var top = 184 + shelfSlot.index * 240
if (top < root.contentY)
root.contentY = top - 8
else if (top + 218 > root.contentY + root.height)
root.contentY = top + 218 - root.height + 8
}
// Keep the cursor card inside the strip's horizontal viewport
// (card pitch = 128px art + 14px spacing).
function scrollCardIntoView() {
if (!shelfSlot.shelfFocused)
return
var cx = shelfSlot.cursor * 142
if (cx < stripFlick.contentX)
stripFlick.contentX = cx - 8
else if (cx + 128 > stripFlick.contentX + stripFlick.width)
stripFlick.contentX = cx + 128 - stripFlick.width + 8
}
onCursorChanged: Qt.callLater(shelfSlot.scrollCardIntoView)
Connections {
target: QbzKioskNav
function onIndexChanged() {
// DEFERRED, exactly as KioskDiscover.qml:339 defers its
// twin, and that is load-bearing. skipIfUnrendered()
// reads `QbzKioskNav.dir`, which the bridge pushes in
// the SAME sync() pass as `index` but LATER in the field
// order (kiosk_nav_bridge.rs: index 2nd, dir 9th), so
// cxx-qt has already emitted indexChanged while `dir`
// still carries the PREVIOUS move. Run inline, the first
// Down of a session read dir === 0 and refused to skip —
// parking the ring on a shelf whose height is 0 — and a
// direction reversal read the old sign and skipped back
// the way it came. Same hazard, same remedy, as the note
// at Main.qml:233-247: never read a value that is still
// in flight inside the handler that fired for it.
Qt.callLater(shelfSlot.skipIfUnrendered)
Qt.callLater(shelfSlot.scrollShelfIntoView)
}
// Left/Right: consume the shell's pulse. Reading it through
// takeHmove() clears it in the same step, so a pulse can
// never be applied by two shelves.
function onHmoveChanged() {
if (shelfSlot.shelfFocused && QbzKioskNav.hmove !== 0
&& shelfSlot.cards.length > 0) {
var d = QbzKioskNav.takeHmove()
shelfSlot.cursor = Math.max(0,
Math.min(shelfSlot.cursor + d, shelfSlot.cards.length - 1))
}
}
// Enter pulse → open the cursor album.
function onActivateSeqChanged() {
if (shelfSlot.shelfFocused && shelfSlot.cards.length > 0
&& shelfSlot.cursor < shelfSlot.cards.length)
QbzAlbum.openAlbum(shelfSlot.cards[shelfSlot.cursor].id)
}
}
Column {
id: shelfCol
width: shelfSlot.width
spacing: 12
// Fixed-height title wrapper — keeps the shelf block a
// constant 218px for the scroll arithmetic above.
Item {
id: titleBox
width: shelfCol.width
height: 24
Text {
anchors.fill: titleBox
text: shelfSlot.modelData && shelfSlot.modelData.title
? shelfSlot.modelData.title
: ""
color: theme.textPrimary
font.pixelSize: 18
font.weight: theme.weightSemibold
verticalAlignment: Text.AlignVCenter
}
}
Flickable {
id: stripFlick
width: shelfSlot.width
height: 182
contentWidth: strip.implicitWidth
contentHeight: stripFlick.height
clip: true
flickableDirection: Flickable.HorizontalFlick
boundsBehavior: Flickable.StopAtBounds
Row {
id: strip
spacing: 14
Repeater {
// Gated on `renders`, so an "other" bucket or an
// empty section never mounts a card.
model: shelfSlot.renders ? shelfSlot.cards : []
delegate: KioskCard {
id: card
required property var modelData
required property int index
album: ({
"id": card.modelData.id,
"title": card.modelData.title,
"artist": card.modelData.artist,
"artwork": root.coverMap[card.modelData.artUrl] || "",
"qualityTier": card.modelData.qualityTier
})
navFocused: shelfSlot.shelfFocused
&& card.index === shelfSlot.cursor
onClicked: function (id) {
QbzAlbum.openAlbum(id)
}
}
}
}
}
}
}
}
}
}