Files
qbz/crates/qbz-qt/qml/kiosk/KioskLocalLibrary.qml
T
vicrodh 63e36c7543 feat(qt): the kiosk shell, its NavRail, the 4 card primitives and the 8 views
Contract sections 4, 5, 6 and the QML half of 7.

ContentRouter.qml is the D3 extraction: ONE view-mount chain, shared by both
shells. The Slint kiosk's content block is a verbatim copy of AppShell's and
KioskShell.slint:10-17 names this extraction as the follow-up it was waiting
for. 23 desktop arms carried over with their comments, 8 kiosk overrides, and
the kiosk-only nowplaying route. AppShell's four viewLoader.item sites now go
through contentRouter.currentItem; a tree-wide grep confirmed no other
reference (LESSONS L4 -- grep, do not trust memory about "both places").

KioskShell.qml carries the back bar with the full search focus lifecycle, the
content-height arithmetic including the now-playing exception, the offline
gate, the forced small transport, the player-zone focus ring, the root key
handler and the 30ms initial focus grab. Two findings worth keeping:

- the NavRail tiles fire header-menu-navigate("favorites-tracks") in the
  reference, which switches the view AND selects a landing tab; navigateTo
  carries only the route, so the tiles reuse the port's existing pending-tab
  handshake rather than landing on the wrong tab.
- the visualizer tile's action is a sanctioned no-op (R2). Wiring
  QbzImmersive would be worse than nothing: `open` would go true over an
  empty mount and the key guard would then reject every key against a
  surface that renders nothing.

Three fractional font sizes had to be rounded. Qt's font.pixelSize is an INT
property, so the reference's 11.5px (NavRail.slint:47) and 12.5px
(KioskNowPlaying.slint:59,67) are not expressible; font.pointSize is the only
real-valued alternative and is DPI-relative, which would make those three
labels scale differently from every other in the tree.

The NavRail one was caught by the offscreen smoke run IN THE KIOSK PROFILE --
it made the whole rail unavailable, i.e. a kiosk with no footer. The build,
both QML audits and qmllint were all green while that was true, and the
desktop smoke was clean because it never mounts KioskShell. The other two
were still latent: KioskNowPlaying is not on the smoke's route.

Gate: build 9 warnings = baseline, 291 tests, both audits OK over 229 QML
files (+15), qmllint filtered = the same 20 pre-existing ArtistView lines and
nothing else, kiosk-profile offscreen smoke clean.

cc
2026-08-03 23:23:24 -06:00

567 lines
21 KiB
QML

// KioskLocalLibrary — the lightweight kiosk Local Library.
// QML port of crates/qbz-ui/ui/shell/KioskLocalLibrary.slint (234 lines).
//
// Reads the SAME local documents the desktop LocalLibraryView reads (three
// sources unified in Rust: local / offline / plex), rendered with the shared
// kiosk cards. Tabs Albums / Artists / Folders / Tracks; a card tap drives
// QbzLocal directly (openAlbum routes to the local-album view in Rust).
//
// The reference's v1 shape, reproduced exactly (KioskLocalLibrary.slint:5-7):
// - the album artwork window is reported ONCE per tab entry and never on
// scroll, so only the first band of covers ever resolves — the Slint's
// ALBUMS_WINDOW stays at its initial (0, 59) because the kiosk grid never
// fires the window hook (qbz/src/local_library.rs:289);
// - the tracks tab shows the first page only; QbzLocal.tracksLoadMore is
// never called, exactly as the Slint kiosk never drives the load-more
// hook (the two frontends' page sizes differ — 200 in
// qbz/src/local_library.rs:980, 500 in src/local_state.rs:20);
// - Folders is the FLAT album-grid representation (localFoldersJson), never
// the tree — no rail, no folder detail pane.
//
// Track rows carry no artwork: the reference builds every local track row
// with an empty image and spawns no per-row artwork job
// (qbz/src/local_library.rs:976-978 and :1028), which is the same default the
// desktop Qt tracks tab ships with. So the tracks tab reports no artwork
// window and its 46px art tiles stay bare.
//
// Keyboard/gamepad nav: same model as KioskLibrary — the index space is the 4
// tabs followed by the active tab's items as a uniform grid (tracks = a
// 1-column list). This view PUBLISHES that geometry into QbzKioskNav; each
// container mirrors the focus index (ring + scroll-into-view) and answers the
// Enter pulse. contentY is only ever WRITTEN from a callback on a settled
// layout, never read by a layout-feeding binding (the AlbumCollectionView
// "Recursion detected" panic class).
//
// Blank is a state here. The reference reads no loading flag, no error and no
// availability flag in this file, and has no empty-tab copy — so a loading,
// failed, unavailable or empty tab renders the strip and nothing else.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
color: "transparent"
// KioskLocalLibrary.slint:58.
property real pad: 16
QbzTheme { id: theme }
function t(s) {
return QbzSession.tr(s, QbzSession.trRev)
}
// ---------------------------- tab state -------------------------------
// The Slint tab is the global LocalLibraryState.active-tab; the Qt port
// holds it view-locally, exactly like the desktop LocalLibraryView
// (qml/views/LocalLibraryView.qml:63), and drives QbzLocal.loadTab on
// mount and on every switch (LocalLibraryView.qml:224, :229).
property string activeTab: "albums"
readonly property var tabIds: ["albums", "artists", "folders", "tracks"]
function tabLabel(id) {
if (id === "albums")
return root.t("Albums")
if (id === "artists")
return root.t("Artists")
if (id === "folders")
return root.t("Folders")
return root.t("Tracks")
}
function selectTab(id) {
root.activeTab = id
}
onActiveTabChanged: {
QbzLocal.loadTab(root.activeTab)
root.publishNav()
root.requestArtwork()
}
Component.onCompleted: {
QbzLocal.loadTab(root.activeTab)
root.publishNav()
root.requestArtwork()
}
// ---------------------------- documents -------------------------------
// A raw JSON.parse in a binding throws on the pre-publish frame and takes
// the view down with it; every document goes through the guarded parse.
function parseDoc(json, fallback) {
if (json === "")
return fallback
try {
return JSON.parse(json)
} catch (e) {
console.warn("[qbz-qt] kiosk local: bad document — " + e)
return fallback
}
}
readonly property var albums: root.parseDoc(QbzLocal.localAlbumsJson, [])
readonly property var artists: root.parseDoc(QbzLocal.localArtistsJson, [])
readonly property var folders: root.parseDoc(QbzLocal.localFoldersJson, [])
readonly property var tracks: root.parseDoc(QbzLocal.localTracksJson, [])
onAlbumsChanged: root.requestArtwork()
onArtistsChanged: root.requestArtwork()
onFoldersChanged: root.requestArtwork()
// ---------------------------- artwork ---------------------------------
// Covers are id-keyed: the view reports the artKeys it wants and Rust
// answers one localArtworkReady per resolved key.
//
// The band is 60 because that is what the reference resolves: both
// windows are initialised to (0, 59) and the kiosk moves neither
// (qbz/src/favorites.rs:929, qbz/src/local_library.rs:289). Cards past it
// keep the bare surfaceElevated square, as they do on the panel today.
readonly property int coverBand: 60
property var artMap: ({})
property var _artInbox: ({})
function artPathOf(key) {
return root.artMap[key] || ""
}
// Arrivals stream in one at a time. Unlike the desktop grids — whose
// cells bind an artSource per row and simply re-evaluate — the kiosk card
// takes its cover INSIDE the album object, so every rebind rebuilds the
// grid's model array and remounts its cards. Arrivals are therefore
// coalesced into one rebind per 120ms band instead of one per frame: the
// covers still appear progressively, at a fraction of the remount cost a
// Pi would otherwise pay during exactly the seconds the grid is filling.
Timer {
id: artFlush
interval: 120
repeat: false
onTriggered: {
// A rebind needs a NEW object reference — a same-ref assignment
// is not a change in QML.
root.artMap = Object.assign({}, root.artMap, root._artInbox)
root._artInbox = ({})
}
}
Connections {
target: QbzLocal
function onLocalArtworkReady(key, path) {
root._artInbox[key] = path
if (!artFlush.running)
artFlush.start()
}
}
/// Report the active tab's artwork window. Called on mount, on every tab
/// switch and when the active document lands — never on scroll, which is
/// the whole of the "no album window" limit.
function requestArtwork() {
var rows = root.activeTab === "albums" ? root.albums : root.activeTab === "folders" ? root.folders : root.activeTab === "artists" ? root.artists : []
// Artist portraits are push-for-all in the reference (every merged
// row goes through the image pass, qbz/src/local_library.rs:3694),
// so the artists tab asks for its whole set; the two album grids ask
// for the first band only.
var cap = root.activeTab === "artists" ? rows.length : Math.min(rows.length, root.coverBand)
var keys = []
var seen = ({})
for (var i = 0; i < cap; i++) {
var k = rows[i] ? rows[i].artKey : ""
// A key already resolved (or already in flight) costs Rust a stat
// per re-request, so it is never sent twice.
if (!k || seen[k] || root.artMap[k] !== undefined || root._artInbox[k] !== undefined)
continue
seen[k] = true
keys.push(k)
}
if (keys.length > 0)
QbzLocal.artworkWindow(JSON.stringify(keys))
}
/// AlbumRow (src/local_rows.rs:26) -> the KioskCard album object, with the
/// remote artKey already resolved to a local cover path.
function cardsFrom(rows) {
var out = []
for (var i = 0; i < rows.length; i++) {
var r = rows[i]
out.push({
"id": r.id,
"title": r.title,
"artist": r.artist,
"qualityTier": r.qualityTier,
"artwork": root.artPathOf(r.artKey)
})
}
return out
}
// Only the mounted tab's cards are materialised: the inactive arm stays
// an empty array so an artMap rebind cannot walk a list nobody shows.
readonly property var albumCards: root.activeTab === "albums" ? root.cardsFrom(root.albums) : []
readonly property var folderCards: root.activeTab === "folders" ? root.cardsFrom(root.folders) : []
// ---------------------------- nav publish -----------------------------
// KioskLocalLibrary.slint:61-78. The index clamp lives in the model
// (publishNav applies it), so this only carries the geometry.
function publishNav() {
var columns
var count
if (root.activeTab === "albums") {
columns = 6
count = 4 + root.albums.length
} else if (root.activeTab === "artists") {
columns = 7
count = 4 + root.artists.length
} else if (root.activeTab === "folders") {
columns = 6
count = 4 + root.folders.length
} else {
columns = 1
count = 4 + root.tracks.length
}
QbzKioskNav.publishNav(4, columns, count, false)
}
// KioskLocalLibrary.slint:86-92 — ONE probe over the four lengths, not
// four handlers.
readonly property int navLenProbe: root.albums.length + root.artists.length + root.folders.length + root.tracks.length
onNavLenProbeChanged: root.publishNav()
// Enter on a tab → the same switch the tab taps drive
// (KioskLocalLibrary.slint:95-109). The `index < tabs` test is what keeps
// this handler and the mounted container's disjoint: both see the pulse.
Connections {
target: QbzKioskNav
function onActivateSeqChanged() {
if (!QbzKioskNav.navActive || QbzKioskNav.zone !== "content")
return
if (QbzKioskNav.index >= QbzKioskNav.tabs)
return
var id = root.tabIds[QbzKioskNav.index]
if (id !== undefined)
root.selectTab(id)
}
}
// ------------------------------ tab strip -----------------------------
Rectangle {
id: tabStrip
anchors.left: root.left
anchors.right: root.right
anchors.top: root.top
height: 48
color: theme.surfaceMain
Row {
id: tabsRow
anchors.left: tabStrip.left
anchors.leftMargin: root.pad
anchors.top: tabStrip.top
anchors.bottom: tabStrip.bottom
spacing: 22
Repeater {
model: root.tabIds
delegate: Rectangle {
id: tab
required property string modelData
required property int index
readonly property bool active: root.activeTab === tab.modelData
readonly property bool navFocused: QbzKioskNav.navActive && QbzKioskNav.zone === "content" && QbzKioskNav.index === tab.index
width: tabText.implicitWidth + 4
height: tabsRow.height
color: "transparent"
radius: theme.radiusSm
border.width: tab.navFocused ? 2 : 0
border.color: theme.accent
Text {
id: tabText
x: 2
anchors.top: tab.top
anchors.bottom: underline.top
text: root.tabLabel(tab.modelData)
color: tab.active ? theme.textPrimary : theme.textMuted
font.pixelSize: 15
font.weight: theme.weightSemibold
verticalAlignment: Text.AlignVCenter
}
Rectangle {
id: underline
anchors.left: tab.left
anchors.leftMargin: 2
anchors.right: tab.right
anchors.rightMargin: 2
anchors.bottom: tab.bottom
height: 2
radius: 2
color: tab.active ? theme.accent : "transparent"
}
MouseArea {
anchors.fill: parent
onClicked: root.selectTab(tab.modelData)
}
}
}
}
// KioskLocalLibrary.slint:127-131 — full-width hairline on the last
// row of the strip.
Rectangle {
anchors.left: tabStrip.left
anchors.right: tabStrip.right
y: tabStrip.height - 1
height: 1
color: theme.borderSubtle
}
}
// ------------------------------- content ------------------------------
Item {
id: content
anchors.left: root.left
anchors.right: root.right
anchors.top: tabStrip.bottom
anchors.bottom: root.bottom
clip: true
Loader {
anchors.fill: parent
active: root.activeTab === "albums"
sourceComponent: albumsComponent
}
Loader {
anchors.fill: parent
active: root.activeTab === "folders"
sourceComponent: foldersComponent
}
Loader {
anchors.fill: parent
active: root.activeTab === "artists"
sourceComponent: artistsComponent
}
Loader {
anchors.fill: parent
active: root.activeTab === "tracks"
sourceComponent: tracksComponent
}
}
// KioskLocalLibrary.slint:138-143. The grid owns its own ring, its
// scroll-into-view and the Enter pulse for its cards.
Component {
id: albumsComponent
KioskAlbumGrid {
albums: root.albumCards
pad: root.pad
onOpen: function (id) {
QbzLocal.openAlbum(id)
}
}
}
// KioskLocalLibrary.slint:145-150 — the SAME grid over the flat folder
// document, opening through the same album route.
Component {
id: foldersComponent
KioskAlbumGrid {
albums: root.folderCards
pad: root.pad
onOpen: function (id) {
QbzLocal.openAlbum(id)
}
}
}
// KioskLocalLibrary.slint:152-198 — absolute-positioned round cards in a
// vertical Flickable, 7 columns, 14px gap, a 26px name band under each
// avatar.
Component {
id: artistsComponent
Flickable {
id: arts
readonly property int cols: 7
readonly property real gap: 14
readonly property real cell: (arts.width - 2 * root.pad - (arts.cols - 1) * arts.gap) / arts.cols
readonly property real itemH: arts.cell + 26
readonly property real pitch: arts.itemH + arts.gap
contentWidth: arts.width
contentHeight: Math.ceil(root.artists.length / arts.cols) * arts.pitch + root.pad
clip: true
boundsBehavior: Flickable.StopAtBounds
readonly property int focusedItem: QbzKioskNav.index - QbzKioskNav.tabs
readonly property bool itemFocused: QbzKioskNav.navActive && QbzKioskNav.zone === "content" && arts.focusedItem >= 0 && arts.focusedItem < root.artists.length
readonly property real focusTop: root.pad + Math.floor(arts.focusedItem / arts.cols) * arts.pitch
// contentY is WRITTEN here on a settled layout (Qt.callLater
// defers it past the current binding pass), never read by a
// binding that feeds layout.
function scrollFocusIntoView() {
if (!arts.itemFocused)
return
if (arts.focusTop < arts.contentY)
arts.contentY = arts.focusTop - 8
else if (arts.focusTop + arts.itemH > arts.contentY + arts.height)
arts.contentY = arts.focusTop + arts.itemH - arts.height + 8
}
Connections {
target: QbzKioskNav
function onIndexChanged() {
Qt.callLater(arts.scrollFocusIntoView)
}
// The tap path passes the artist NAME as the id, and so does
// this one — local rows have no catalog id.
function onActivateSeqChanged() {
if (arts.itemFocused)
QbzLocal.openArtistByName(root.artists[arts.focusedItem].name)
}
}
Item {
width: arts.contentWidth
height: arts.contentHeight
Repeater {
model: root.artists
// Same in-window mounting as KioskAlbumGrid
// (qml/kiosk/KioskAlbumGrid.qml:6-12): cheap positioned
// slots for every artist, the card itself only for the
// band around the viewport. A local library reaches
// thousands of artists, and mounting every avatar on
// entry is the CPU spike the kiosk cards exist to avoid.
delegate: Item {
id: slot
required property var modelData
required property int index
x: root.pad + (slot.index % arts.cols) * (arts.cell + arts.gap)
y: root.pad + Math.floor(slot.index / arts.cols) * arts.pitch
width: arts.cell
height: arts.itemH
readonly property bool inWindow: (slot.y + slot.height >= arts.contentY - 240) && (slot.y <= arts.contentY + arts.height + 240)
Component {
id: artistCardComponent
// ArtistRow (src/local_rows.rs:91) carries a name
// and no id — the reference's display-name has no
// Qt counterpart, so the name is both the label
// and the route key.
KioskArtistCard {
artSize: arts.cell
artist: ({
"id": slot.modelData.name,
"name": slot.modelData.name,
"artwork": root.artPathOf(slot.modelData.artKey)
})
navFocused: arts.itemFocused && arts.focusedItem === slot.index
onClicked: function (id) {
QbzLocal.openArtistByName(id)
}
}
}
Loader {
active: slot.inWindow
sourceComponent: artistCardComponent
}
}
}
}
}
}
// KioskLocalLibrary.slint:200-231 — a windowing list of 62px rows.
Component {
id: tracksComponent
ListView {
id: tracksList
model: root.tracks
clip: true
spacing: 0
boundsBehavior: Flickable.StopAtBounds
readonly property int focusedItem: QbzKioskNav.index - QbzKioskNav.tabs
readonly property bool itemFocused: QbzKioskNav.navActive && QbzKioskNav.zone === "content" && tracksList.focusedItem >= 0 && tracksList.focusedItem < root.tracks.length
function scrollFocusIntoView() {
if (!tracksList.itemFocused)
return
var top = tracksList.focusedItem * 62
if (top < tracksList.contentY)
tracksList.contentY = top - 8
else if (top + 62 > tracksList.contentY + tracksList.height)
tracksList.contentY = top + 62 - tracksList.height + 8
}
Connections {
target: QbzKioskNav
function onIndexChanged() {
Qt.callLater(tracksList.scrollFocusIntoView)
}
function onActivateSeqChanged() {
if (tracksList.itemFocused)
root.playTrack(root.tracks[tracksList.focusedItem].id)
}
}
delegate: KioskTrackRow {
id: trackRow
required property var modelData
required property int index
width: tracksList.width
track: trackRow.modelData
navFocused: tracksList.itemFocused && tracksList.focusedItem === trackRow.index
onClicked: root.playTrack(trackRow.modelData.id)
}
}
}
/// The loaded page becomes the queue in render order, starting at the
/// clicked row — the Qt shape of the reference's track-action "play".
function playTrack(id) {
var ids = []
for (var i = 0; i < root.tracks.length; i++)
ids.push(root.tracks[i].id)
QbzLocal.playTracksVisible(JSON.stringify(ids), id)
}
}