Files
qbz/crates/qbz-qt/qml/kiosk/KioskLibrary.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

749 lines
28 KiB
QML

// KioskLibrary — lightweight kiosk Library (Favorites) view
// (shell/KioskLibrary.slint). The reference header's v1 limits, verbatim:
//
// Reads the SAME FavoritesState the desktop FavoritesView reads, rendered
// with the simple kiosk cards. Tabs Tracks/Albums/Artists/Playlists/Labels
// switch via FavoritesActions.select-tab (Rust lazy-loads each tab). Covers
// are push-only except album covers (windowed — v1 shows the first ~60; the
// window hook is a follow-up).
//
// Keyboard/gamepad nav: the nav index space is the 5 tabs (indices 0-4)
// followed by the active tab's items as a uniform grid (tracks = a
// 1-column list). The view publishes that geometry into KioskNavState; each
// container mirrors the focus index (ring + scroll-into-view) and answers
// the Enter pulse. viewport-y is only ever WRITTEN from callbacks on a
// settled layout, never read by layout-feeding bindings (the
// AlbumCollectionView "Recursion detected" panic class).
//
// The "first ~60" cap is literal and is reproduced HERE: Slint's cap is the
// Rust-side FAV_ALBUMS_WINDOW = (0, 59) (qbz/src/favorites.rs:929) and the
// kiosk grid never fires the window-changed hook, so cards 61+ keep a bare
// surfaceElevated square forever. In Qt the cap is whatever this file passes
// to libraryArtworkWindow — hence `albumCoverCap` below, dispatched once per
// Albums-tab entry and never on scroll. Non-album tabs are push-only, i.e.
// every row's key is dispatched.
//
// Three differences from the Slint that come from the Qt data model, not from
// a design choice:
// * Qt has ONE merged feed document (QbzLibrary.libraryJson) instead of five
// lazily-loaded models, so the five lists are JS filters over it — the
// same predicates views/LibraryView.qml:422-427 already uses. There is no
// FavoritesActions.select-tab invokable, so `activeTab` is view-local and
// no per-tab history entry is recorded.
// * The Slint lists are the `-visible` (searched + sorted) models; this view
// exposes NO search, sort or filter UI, so it takes the raw feed subsets in
// feed order. There is no sort/filter chrome to add here.
// * Covers are id-keyed through libraryArtworkReady into `artMap`. The kiosk
// cards take the RESOLVED path inside their row object, so the tracks /
// artists / labels rows are re-wrapped per DELEGATE (only mounted rows pay
// for a cover arrival) and only the album array — whose consumer owns its
// own Repeater — is rebuilt whole. The Playlists tab does not use this path
// at all: its cover is covers[0], the first MEMBER-album cover, which is
// what Slint's `cover1` (collage slot 0) holds; artMap[artKey] would be the
// playlist's OWN graphic instead, and blank for every playlist that has none.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
color: "transparent"
QbzTheme { id: theme }
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
readonly property real pad: 16
// No FavoritesActions.select-tab in Qt: the tab is view state, and the
// Slint default is "tracks" (state.slint:5133).
property string activeTab: "tracks"
readonly property var tabIds: ["tracks", "albums", "artists", "playlists", "labels"]
function tabLabel(id) {
return id === "tracks" ? root.t("Tracks")
: id === "albums" ? root.t("Albums")
: id === "artists" ? root.t("Artists")
: id === "playlists" ? root.t("Playlists")
: root.t("Labels")
}
// ---- the merged feed and its five subsets ---------------------------
// ONE guarded parse per document: a raw JSON.parse inside a binding throws
// on the pre-publish frame and takes the whole view down.
readonly property var feed: root.parseFeed()
function parseFeed() {
try {
var f = JSON.parse(QbzLibrary.libraryJson)
return (f instanceof Array) ? f : []
} catch (e) {
return []
}
}
// `group` empty = the kind alone decides (artists, labels).
function subset(kind, group) {
var out = []
var f = root.feed
for (var i = 0; i < f.length; i++) {
var x = f[i]
if (x.kind === kind && (group === "" || x.group === group))
out.push(x)
}
return out
}
readonly property var trackRows: root.subset("track", "favorites")
readonly property var albumRows: root.subset("album", "favorites")
readonly property var artistRows: root.subset("artist", "")
readonly property var playlistRows: root.subset("playlist", "favorites")
readonly property var labelRows: root.subset("label", "")
// ---- covers ----------------------------------------------------------
// {artKey: file://path}. A NEW object per arrival — a same-reference
// assignment is not a change in QML.
property var artMap: ({})
Connections {
target: QbzLibrary
function onLibraryArtworkReady(key, path) {
var m = root.artMap
m[key] = path
root.artMap = Object.assign({}, m)
}
}
// qbz/src/favorites.rs:929 — FAV_ALBUMS_WINDOW = (0, 59).
readonly property int albumCoverCap: 60
function dispatchArtwork() {
var rows = root.activeTab === "tracks" ? root.trackRows
: root.activeTab === "albums" ? root.albumRows
: root.activeTab === "artists" ? root.artistRows
: root.activeTab === "labels" ? root.labelRows
: []
var n = root.activeTab === "albums"
? Math.min(rows.length, root.albumCoverCap)
: rows.length
var keys = []
for (var i = 0; i < n; i++)
if (rows[i].artKey)
keys.push(rows[i].artKey)
if (keys.length > 0)
QbzLibrary.libraryArtworkWindow(JSON.stringify(keys))
}
// The album array is the ONE consumer that owns its Repeater, so the
// resolved path has to live in the row object.
readonly property var albumCards: root.buildAlbumCards()
function buildAlbumCards() {
var rows = root.albumRows
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,
// Past the cap no cover is ever dispatched, so the tile stays
// a bare surfaceElevated square (the reference's behaviour).
"artwork": i < root.albumCoverCap ? (root.artMap[r.artKey] || "") : ""
})
}
return out
}
function trackCard(row) {
if (!row)
return ({})
return {
"id": row.id,
"title": row.title,
"artist": row.artist,
"duration": row.duration,
"artwork": root.artMap[row.artKey] || ""
}
}
// Artists and labels take the same card; both carry the display name in
// `title` (src/library_qt.rs:424, :695).
function personCard(row) {
if (!row)
return ({})
return {
"id": row.id,
"name": row.title,
"artwork": root.artMap[row.artKey] || ""
}
}
// ---- activation ------------------------------------------------------
// Slint's FavoritesActions.play-track queues the whole VISIBLE list from
// the clicked row; libraryPlayVisible is the Qt member that reproduces it.
function playTrack(id) {
var rows = root.trackRows
var ids = []
for (var i = 0; i < rows.length; i++)
ids.push(rows[i].id)
QbzLibrary.libraryPlayVisible(JSON.stringify(ids), id)
}
// ---- nav geometry ----------------------------------------------------
// 5 tabs + the active tab's items. The clamp of `index` into the new count
// is the bridge's (src/kiosk_nav_qt.rs:271-273).
function publishNav() {
var columns = root.activeTab === "tracks" ? 1
: root.activeTab === "albums" ? 6
: root.activeTab === "artists" ? 7
: root.activeTab === "playlists" ? 6
: 7
var items = root.activeTab === "tracks" ? root.trackRows.length
: root.activeTab === "albums" ? root.albumRows.length
: root.activeTab === "artists" ? root.artistRows.length
: root.activeTab === "playlists" ? root.playlistRows.length
: root.labelRows.length
QbzKioskNav.publishNav(5, columns, 5 + items, false)
}
Component.onCompleted: {
root.publishNav()
root.dispatchArtwork()
}
onActiveTabChanged: {
root.publishNav()
root.dispatchArtwork()
}
// ONE probe over the SUM of the five lengths, as the reference has — the
// feed publishes once for all five lists.
readonly property int navLenProbe: root.trackRows.length
+ root.albumRows.length
+ root.artistRows.length
+ root.playlistRows.length
+ root.labelRows.length
onNavLenProbeChanged: {
root.publishNav()
root.dispatchArtwork()
}
// Enter on a TAB → the same switch the tab taps drive. Items are activated
// by their own container, which watches the same pulse; the `index < tabs`
// test is what keeps the two disjoint.
Connections {
target: QbzKioskNav
function onActivateSeqChanged() {
if (QbzKioskNav.navActive && QbzKioskNav.zone === "content"
&& QbzKioskNav.index >= 0
&& QbzKioskNav.index < QbzKioskNav.tabs
&& QbzKioskNav.index < root.tabIds.length)
root.activeTab = root.tabIds[QbzKioskNav.index]
}
}
// ============================ tab strip ==============================
Rectangle {
id: tabStrip
anchors.left: root.left
anchors.right: root.right
anchors.top: root.top
height: 48
color: theme.surfaceMain
Row {
anchors.fill: tabStrip
anchors.leftMargin: root.pad
anchors.rightMargin: root.pad
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: tabStrip.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
x: 2
width: Math.max(0, tab.width - 4)
anchors.bottom: tab.bottom
height: 2
radius: 2
color: tab.active ? theme.accent : "transparent"
}
MouseArea {
anchors.fill: tab
onClicked: root.activeTab = tab.modelData
}
}
}
}
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: content
active: root.activeTab === "tracks"
sourceComponent: tracksComponent
}
Loader {
anchors.fill: content
active: root.activeTab === "albums"
sourceComponent: albumsComponent
}
Loader {
anchors.fill: content
active: root.activeTab === "artists"
sourceComponent: artistsComponent
}
Loader {
anchors.fill: content
active: root.activeTab === "playlists"
sourceComponent: playlistsComponent
}
Loader {
anchors.fill: content
active: root.activeTab === "labels"
sourceComponent: labelsComponent
}
}
// ---- Tracks ----------------------------------------------------------
// A ListView windows the rows (only the visible band is mounted) — a plain
// column of every track spiked CPU on entry, which a Pi can't afford.
Component {
id: tracksComponent
ListView {
id: tracksList
model: root.trackRows
clip: true
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.trackRows.length
// contentY is WRITTEN from this function on a settled layout
// (Qt.callLater defers it past the current binding pass), never
// read by a binding that feeds layout. Row pitch is 62.
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.trackRows[tracksList.focusedItem].id)
}
}
delegate: KioskTrackRow {
id: trackDelegate
required property var modelData
required property int index
width: tracksList.width
track: root.trackCard(trackDelegate.modelData)
navFocused: tracksList.itemFocused
&& tracksList.focusedItem === trackDelegate.index
onClicked: root.playTrack(trackDelegate.modelData.id)
}
}
}
// ---- Albums ----------------------------------------------------------
// The grid owns its own ring, scroll-into-view and Enter.
Component {
id: albumsComponent
KioskAlbumGrid {
albums: root.albumCards
columns: 6
gap: 16
pad: root.pad
onOpen: function (id) {
QbzAlbum.openAlbum(id)
}
}
}
// ---- Artists ---------------------------------------------------------
Component {
id: artistsComponent
Flickable {
id: artistsFlick
readonly property int cols: 7
readonly property real gap: 14
readonly property real cell: (artistsFlick.width - 2 * root.pad
- (artistsFlick.cols - 1) * artistsFlick.gap) / artistsFlick.cols
// The +26 is the name line under the round avatar.
readonly property real itemH: artistsFlick.cell + 26
readonly property real pitch: artistsFlick.itemH + artistsFlick.gap
contentWidth: artistsFlick.width
contentHeight: Math.ceil(root.artistRows.length / artistsFlick.cols)
* artistsFlick.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"
&& artistsFlick.focusedItem >= 0
&& artistsFlick.focusedItem < root.artistRows.length
readonly property real focusTop: root.pad
+ Math.floor(artistsFlick.focusedItem / artistsFlick.cols) * artistsFlick.pitch
function scrollFocusIntoView() {
if (!artistsFlick.itemFocused)
return
if (artistsFlick.focusTop < artistsFlick.contentY)
artistsFlick.contentY = artistsFlick.focusTop - 8
else if (artistsFlick.focusTop + artistsFlick.itemH
> artistsFlick.contentY + artistsFlick.height)
artistsFlick.contentY = artistsFlick.focusTop + artistsFlick.itemH
- artistsFlick.height + 8
}
Connections {
target: QbzKioskNav
function onIndexChanged() {
Qt.callLater(artistsFlick.scrollFocusIntoView)
}
function onActivateSeqChanged() {
if (artistsFlick.itemFocused)
QbzArtist.openArtist(root.artistRows[artistsFlick.focusedItem].id)
}
}
Item {
width: artistsFlick.contentWidth
height: artistsFlick.contentHeight
Repeater {
model: root.artistRows
delegate: KioskArtistCard {
id: artistCard
required property var modelData
required property int index
x: root.pad + (artistCard.index % artistsFlick.cols)
* (artistsFlick.cell + artistsFlick.gap)
y: root.pad + Math.floor(artistCard.index / artistsFlick.cols)
* artistsFlick.pitch
artSize: artistsFlick.cell
artist: root.personCard(artistCard.modelData)
navFocused: artistsFlick.itemFocused
&& artistsFlick.focusedItem === artistCard.index
onClicked: function (id) {
QbzArtist.openArtist(id)
}
}
}
}
}
}
// ---- Labels ----------------------------------------------------------
// Identical geometry to Artists, same card. Slint opens the label with
// (id, name); Qt's openLabel takes the id alone and resolves the page from
// it, so the name argument is dropped.
Component {
id: labelsComponent
Flickable {
id: labelsFlick
readonly property int cols: 7
readonly property real gap: 14
readonly property real cell: (labelsFlick.width - 2 * root.pad
- (labelsFlick.cols - 1) * labelsFlick.gap) / labelsFlick.cols
readonly property real itemH: labelsFlick.cell + 26
readonly property real pitch: labelsFlick.itemH + labelsFlick.gap
contentWidth: labelsFlick.width
contentHeight: Math.ceil(root.labelRows.length / labelsFlick.cols)
* labelsFlick.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"
&& labelsFlick.focusedItem >= 0
&& labelsFlick.focusedItem < root.labelRows.length
readonly property real focusTop: root.pad
+ Math.floor(labelsFlick.focusedItem / labelsFlick.cols) * labelsFlick.pitch
function scrollFocusIntoView() {
if (!labelsFlick.itemFocused)
return
if (labelsFlick.focusTop < labelsFlick.contentY)
labelsFlick.contentY = labelsFlick.focusTop - 8
else if (labelsFlick.focusTop + labelsFlick.itemH
> labelsFlick.contentY + labelsFlick.height)
labelsFlick.contentY = labelsFlick.focusTop + labelsFlick.itemH
- labelsFlick.height + 8
}
Connections {
target: QbzKioskNav
function onIndexChanged() {
Qt.callLater(labelsFlick.scrollFocusIntoView)
}
function onActivateSeqChanged() {
if (labelsFlick.itemFocused)
QbzHome.openLabel(root.labelRows[labelsFlick.focusedItem].id)
}
}
Item {
width: labelsFlick.contentWidth
height: labelsFlick.contentHeight
Repeater {
model: root.labelRows
delegate: KioskArtistCard {
id: labelCard
required property var modelData
required property int index
x: root.pad + (labelCard.index % labelsFlick.cols)
* (labelsFlick.cell + labelsFlick.gap)
y: root.pad + Math.floor(labelCard.index / labelsFlick.cols)
* labelsFlick.pitch
artSize: labelsFlick.cell
artist: root.personCard(labelCard.modelData)
navFocused: labelsFlick.itemFocused
&& labelsFlick.focusedItem === labelCard.index
onClicked: function (id) {
QbzHome.openLabel(id)
}
}
}
}
}
}
// ---- Playlists -------------------------------------------------------
// A bespoke inline card, not KioskCard and not the desktop PlaylistCard:
// square cover + one title line, nothing else. The cover is covers[0] —
// the FIRST member-album cover, which is what Slint's `cover1` (collage
// slot 0) holds — drawn as ONE crop-fitted tile, not a 2x2 mosaic.
Component {
id: playlistsComponent
Flickable {
id: plsFlick
readonly property int cols: 6
readonly property real gap: 16
readonly property real cell: (plsFlick.width - 2 * root.pad
- (plsFlick.cols - 1) * plsFlick.gap) / plsFlick.cols
readonly property real itemH: plsFlick.cell + 42
readonly property real pitch: plsFlick.itemH + plsFlick.gap
contentWidth: plsFlick.width
contentHeight: Math.ceil(root.playlistRows.length / plsFlick.cols)
* plsFlick.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"
&& plsFlick.focusedItem >= 0
&& plsFlick.focusedItem < root.playlistRows.length
readonly property real focusTop: root.pad
+ Math.floor(plsFlick.focusedItem / plsFlick.cols) * plsFlick.pitch
function scrollFocusIntoView() {
if (!plsFlick.itemFocused)
return
if (plsFlick.focusTop < plsFlick.contentY)
plsFlick.contentY = plsFlick.focusTop - 8
else if (plsFlick.focusTop + plsFlick.itemH
> plsFlick.contentY + plsFlick.height)
plsFlick.contentY = plsFlick.focusTop + plsFlick.itemH
- plsFlick.height + 8
}
Connections {
target: QbzKioskNav
function onIndexChanged() {
Qt.callLater(plsFlick.scrollFocusIntoView)
}
function onActivateSeqChanged() {
if (plsFlick.itemFocused)
QbzBridge.openPlaylist(root.playlistRows[plsFlick.focusedItem].id)
}
}
Item {
width: plsFlick.contentWidth
height: plsFlick.contentHeight
Repeater {
model: root.playlistRows
delegate: Rectangle {
id: plCard
required property var modelData
required property int index
readonly property bool focused: plsFlick.itemFocused
&& plsFlick.focusedItem === plCard.index
readonly property string cover: plCard.modelData
&& plCard.modelData.covers
&& plCard.modelData.covers.length > 0
? plCard.modelData.covers[0] : ""
x: root.pad + (plCard.index % plsFlick.cols)
* (plsFlick.cell + plsFlick.gap)
y: root.pad + Math.floor(plCard.index / plsFlick.cols)
* plsFlick.pitch
width: plsFlick.cell
height: plsFlick.itemH
radius: theme.radiusSm
border.width: plCard.focused ? 3 : 0
border.color: theme.accent
color: plCard.focused
? Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.12)
: "transparent"
Column {
width: plCard.width
spacing: 7
Rectangle {
id: coverTile
width: plCard.width
height: plCard.width
radius: theme.radiusSm
color: theme.surfaceElevated
clip: true
RoundedImage {
anchors.fill: coverTile
source: plCard.cover
radius: coverTile.radius
fit: "crop"
}
}
Text {
width: plCard.width
text: plCard.modelData && plCard.modelData.title
? plCard.modelData.title : ""
color: theme.textPrimary
font.pixelSize: 14
font.weight: theme.weightSemibold
elide: Text.ElideRight
wrapMode: Text.NoWrap
maximumLineCount: 1
}
}
MouseArea {
anchors.fill: plCard
onClicked: QbzBridge.openPlaylist(plCard.modelData.id)
}
}
}
}
}
}
}