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
This commit is contained in:
vicrodh committed 2026-08-03 23:23:24 -06:00
1 parent 221f8704d5
commit 63e36c7543
25 files changed
+6613 -82

No files matched your search

+18
View File
@@ -127,6 +127,24 @@ fn main() {
"qml/shell/NowPlayingBarSmall.qml", "qml/shell/NowPlayingBarSmall.qml",
"qml/shell/PlayerBar.qml", "qml/shell/PlayerBar.qml",
"qml/shell/QueuePanel.qml", "qml/shell/QueuePanel.qml",
// Kiosk shell (2026-08-02 kiosk-port contract). The router is
// SHARED with AppShell (contract D3) and lives here rather
// than under kiosk/ for that reason.
"qml/shell/ContentRouter.qml",
"qml/shell/KioskShell.qml",
"qml/kiosk/NavRail.qml",
"qml/kiosk/KioskCard.qml",
"qml/kiosk/KioskAlbumGrid.qml",
"qml/kiosk/KioskTrackRow.qml",
"qml/kiosk/KioskArtistCard.qml",
"qml/kiosk/KioskSearch.qml",
"qml/kiosk/KioskNowPlaying.qml",
"qml/kiosk/KioskDiscover.qml",
"qml/kiosk/KioskLibrary.qml",
"qml/kiosk/KioskLocalLibrary.qml",
"qml/kiosk/KioskMyQBZ.qml",
"qml/kiosk/KioskArtist.qml",
"qml/kiosk/KioskAlbum.qml",
"qml/shell/Sidebar.qml", "qml/shell/Sidebar.qml",
"qml/shell/SidebarFolderFlyout.qml", "qml/shell/SidebarFolderFlyout.qml",
"qml/shell/SidebarRowMenu.qml", "qml/shell/SidebarRowMenu.qml",
+409
View File
@@ -0,0 +1,409 @@
// KioskAlbum — the lightweight kiosk album detail (QML port of
// crates/qbz-ui/ui/shell/KioskAlbum.slint, 217 lines). Reads the SAME
// QbzAlbum.albumJson document the desktop AlbumView reads: a header (big
// cover + title + artist + quality + Play/Shuffle) and a simple track list
// (number + title + duration). Album track rows carry no artwork
// (album_qt.rs:90-92 leaves artUrl empty on this view), so the row shows the
// track number instead.
//
// Keyboard/gamepad nav: the track list IS the nav model — a 1-column list
// with no tabs, so the nav index IS the track index. Enter plays the focused
// track. The header buttons (Play / Shuffle / the artist link) stay
// TOUCH-ONLY: no focus ring, no nav index, unreachable by arrows
// (KioskAlbum.slint:7-8).
import QtQuick
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
// KioskAlbum.slint:38 sets no background — the kiosk content panel
// (KioskShell.qml's surfaceMain card) is what shows through.
color: "transparent"
QbzTheme { id: theme }
// KioskAlbum.slint:42.
readonly property real pad: 22
// ---------------------------------------------------------------------
// Data (spec S5 §3.2: AlbumViewData = header + tracks)
// ---------------------------------------------------------------------
readonly property var albumDoc: {
try {
return JSON.parse(QbzAlbum.albumJson)
} catch (e) {
return ({})
}
}
readonly property var header: root.albumDoc.header || ({})
readonly property var tracks: root.albumDoc.tracks || []
// ---------------------------------------------------------------------
// Artwork (Qt-only: the Slint model carries a resolved `artwork`, the Qt
// document carries only the remote `artUrl`)
// ---------------------------------------------------------------------
// ONE cover on this page, so this is a single resolved path rather than
// the url->path map + 16ms coalescing timer the multi-cover views carry
// (ArtistView.qml:664-681): with a single key there is no per-arrival
// map copy to coalesce, and a map would be a per-row structure the page
// has no rows for.
property string coverPath: ""
property var dispatchedCovers: ({})
property string loadedAlbumId: ""
Connections {
target: QbzLibrary
// Key is the REQUESTED URL on the sidebarArtworkWindow path
// (main.rs:800-823 emits emit_library_artwork(url, path) in both the
// disk-hit loop and the download tail); the `{kind}:{id}` shape in
// the signal's doc comment is the FEED dispatch path.
function onLibraryArtworkReady(key, path) {
if (key === (root.header.artUrl || ""))
root.coverPath = path
}
}
// The document is republished as the deferred rails land, and every
// republish re-fires the dispatch — send only what is new.
function dispatchCover() {
var u = root.header.artUrl || ""
if (u === "" || root.dispatchedCovers[u])
return
var seen = root.dispatchedCovers
seen[u] = true
root.dispatchedCovers = seen
QbzShell.sidebarArtworkWindow(JSON.stringify([u]))
}
// Per-album state is dropped only when the id actually changes, so a
// deferred republish of the SAME album never blanks the cover.
function syncAlbumState() {
var id = root.header.id || ""
if (id === root.loadedAlbumId)
return
root.loadedAlbumId = id
root.dispatchedCovers = ({})
root.coverPath = ""
}
// ---------------------------------------------------------------------
// Nav geometry (KioskAlbum.slint:44-58): no tabs, one index per track,
// not shelf mode.
// ---------------------------------------------------------------------
// The reference clamps `index` right after the publish (:50); the Qt
// model does that clamp inside publish() itself (kiosk_nav_qt.rs:271-273),
// so a QML-side write would only fight it.
function publishNav() {
QbzKioskNav.publishNav(0, 1, root.tracks.length, false)
}
Component.onCompleted: {
root.syncAlbumState()
root.dispatchCover()
root.publishNav()
}
onHeaderChanged: {
root.syncAlbumState()
root.dispatchCover()
}
// The reference re-publishes off a `nav-len-probe` on the track count
// (:55-58); the parsed list is the same probe here.
onTracksChanged: root.publishNav()
// ---------------------------------------------------------------------
// Actions (spec S5 §3.2.6)
// ---------------------------------------------------------------------
// ("track", id, "play") dispatches to play_track_in_context in the
// reference — "queue the current view's VISIBLE tracklist starting at the
// clicked track" — which on an album page is playAlbumFrom. NOT
// playTrack(id), which builds a one-track queue and loses the album.
function playTrack(trackId) {
QbzPlayer.playAlbumFrom(root.header.id || "", trackId)
}
// ---------------------------------------------------------------------
// Zone-nav wiring
// ---------------------------------------------------------------------
Connections {
target: QbzKioskNav
// Enter pulse -> play the focused track (the row's own action). This
// arm checks navActive explicitly (:63), unlike the shelf views where
// it rides the shelf-focus predicate.
function onActivateSeqChanged() {
if (QbzKioskNav.navActive && QbzKioskNav.zone === "content"
&& QbzKioskNav.index >= 0
&& QbzKioskNav.index < root.tracks.length)
root.playTrack(root.tracks[QbzKioskNav.index].id || "")
}
function onIndexChanged() {
root.scrollFocusIntoView()
}
}
// Scroll-into-view (:161-170). The page arithmetic is FIXED, exactly as
// the reference documents at :154-158: 22 pad + 180 header + 22 spacing =
// 224, then a 54px pitch (52px row + 2px spacing).
//
// Written from a handler through Qt.callLater so it lands on a SETTLED
// layout — the reference warns three times that a viewport write which
// feeds layout is the "Recursion detected" class (:156-158). contentY is
// Qt's positive-going twin of Slint's negative viewport-y.
function scrollFocusIntoView() {
if (!QbzKioskNav.navActive || QbzKioskNav.zone !== "content")
return
var i = QbzKioskNav.index
if (i < 0 || i >= root.tracks.length)
return
Qt.callLater(function () {
var top = 224 + i * 54
if (top < pageFlick.contentY)
pageFlick.contentY = top - 8
else if (top + 52 > pageFlick.contentY + pageFlick.height)
pageFlick.contentY = top + 52 - pageFlick.height + 8
})
}
// =====================================================================
// The page (KioskAlbum.slint:69-216)
// =====================================================================
Flickable {
id: pageFlick
anchors.fill: parent
contentWidth: pageFlick.width
contentHeight: pageColumn.height + 2 * root.pad
boundsBehavior: Flickable.StopAtBounds
clip: true
Column {
id: pageColumn
x: root.pad
y: root.pad
width: pageFlick.width - 2 * root.pad
spacing: 22
// --- Header (:78-144) ------------------------------------
Row {
id: headerRow
width: pageColumn.width
spacing: 22
Rectangle {
id: coverTile
width: 180
height: 180
radius: theme.radiusMd
color: theme.surfaceElevated
clip: true
RoundedImage {
anchors.fill: coverTile
source: root.coverPath
radius: theme.radiusMd
fit: "crop"
}
}
// A plain cell so the column can vertically centre itself
// (the reference's `alignment: center`, :94) without
// anchoring a child of the Row itself.
Item {
id: headerCell
width: headerRow.width - coverTile.width - headerRow.spacing
height: Math.max(coverTile.height, headerCol.height)
Column {
id: headerCol
width: headerCell.width
anchors.verticalCenter: headerCell.verticalCenter
spacing: 10
Text {
width: headerCol.width
text: root.header.title || ""
color: theme.textPrimary
font.pixelSize: 25
font.weight: theme.weightBold
wrapMode: Text.WordWrap
}
// Artist link (:103-120). Intrinsic width (the
// reference's `horizontal-stretch: 0`), so the tap
// target is the name and not the whole row. No
// underline, no hover colour, no cursor change — the
// reference has none.
Rectangle {
id: artistRow
width: artistText.implicitWidth
height: 26
color: "transparent"
Text {
id: artistText
anchors.left: artistRow.left
anchors.verticalCenter: artistRow.verticalCenter
text: root.header.artist || ""
color: theme.textSecondary
font.pixelSize: 17
}
MouseArea {
anchors.fill: artistRow
onClicked: QbzArtist.openArtist(root.header.artistId || "")
}
}
Text {
width: headerCol.width
text: root.header.qualityDetail || ""
color: theme.textMuted
font.pixelSize: 13
}
Row {
spacing: 10
PillButton {
label: QbzSession.tr("Play", QbzSession.trRev)
primary: true
onClicked: QbzPlayer.playAlbum(root.header.id || "")
}
PillButton {
label: QbzSession.tr("Shuffle", QbzSession.trRev)
onClicked: QbzPlayer.playAlbumShuffled(root.header.id || "")
}
}
}
}
}
// --- Track list (:147-214) -------------------------------
Column {
id: trackColumn
width: pageColumn.width
spacing: 2
Repeater {
model: root.tracks
delegate: Rectangle {
id: trackRow
required property var modelData
required property int index
width: trackColumn.width
height: 52
radius: theme.radiusSm
readonly property bool current:
QbzPlayer.npTrackId === (trackRow.modelData.id || "")
readonly property bool rowFocused:
QbzKioskNav.navActive
&& QbzKioskNav.zone === "content"
&& QbzKioskNav.index === trackRow.index
// Focus beats hover beats current-track (:171-175);
// note the 0.18 focus tint against the 0.10 current
// one.
color: trackRow.rowFocused
? Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.18)
: rowArea.containsMouse
? theme.alphaTier(6)
: trackRow.current
? Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.10)
: "transparent"
border.width: trackRow.rowFocused ? 2 : 0
border.color: theme.accent
Text {
id: numberCell
anchors.left: trackRow.left
anchors.leftMargin: 12
anchors.verticalCenter: trackRow.verticalCenter
width: 30
text: trackRow.modelData.number || ""
color: theme.textMuted
font.pixelSize: 14
horizontalAlignment: Text.AlignRight
}
Text {
id: durationCell
anchors.right: trackRow.right
anchors.rightMargin: 12
anchors.verticalCenter: trackRow.verticalCenter
text: trackRow.modelData.duration || ""
color: theme.textMuted
font.pixelSize: 13
}
Text {
anchors.left: numberCell.right
anchors.leftMargin: 14
anchors.right: durationCell.left
anchors.rightMargin: 14
anchors.verticalCenter: trackRow.verticalCenter
text: trackRow.modelData.title || ""
color: trackRow.current ? theme.accent : theme.textPrimary
font.pixelSize: 15
elide: Text.ElideRight
wrapMode: Text.NoWrap
maximumLineCount: 1
}
MouseArea {
id: rowArea
anchors.fill: trackRow
hoverEnabled: true
onClicked: root.playTrack(trackRow.modelData.id || "")
}
}
}
}
}
}
// The header's two pills (KioskAlbum.slint:13-36). File-local, as in the
// reference — 132x44 here, not the 150px the artist header uses.
component PillButton: Rectangle {
id: pill
property string label: ""
property bool primary: false
signal clicked
// Its OWN theme instance: an inline component does not carry the
// enclosing document's ids into qmllint's scope, so tokens read
// through the outer `theme` would be unverifiable (LESSONS L3).
QbzTheme { id: pillTheme }
width: 132
height: 44
radius: pillTheme.radiusSm
color: pill.primary
? pillTheme.accent
: (pillArea.containsMouse ? pillTheme.alphaTier(8) : pillTheme.surfaceElevated)
border.width: pill.primary ? 0 : 1
border.color: pillTheme.borderSubtle
Text {
anchors.fill: pill
text: pill.label
color: pill.primary ? pillTheme.accentText : pillTheme.textPrimary
font.pixelSize: 15
font.weight: pillTheme.weightSemibold
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
MouseArea {
id: pillArea
anchors.fill: pill
hoverEnabled: true
onClicked: pill.clicked()
}
}
}
+122
View File
@@ -0,0 +1,122 @@
// KioskAlbumGrid — lightweight wrapping grid of KioskCards for the kiosk
// Search/Library album tabs (shell/KioskAlbumGrid.slint). Absolute-positioned
// cells inside a vertical Flickable; card width derives from the grid width so
// it reflows.
//
// WINDOWED (in-window pattern, mirrors the desktop Carousel): the Repeater
// creates only cheap empty positioned slots for every album, but the heavy
// KioskCard (image + text) mounts ONLY for slots whose band intersects the
// viewport. A plain full grid mounted every card at once and spiked CPU on
// entry — a Pi can't afford that. So this is a Repeater of SLOTS with a gated
// Loader, never a Repeater of cards and never a GridView (whose scroll
// semantics the focus writes below could not drive).
//
// Slint's `viewport-y` is negative as you scroll down; Qt's `contentY` is its
// positive twin — every occurrence is already translated.
//
// The HOSTING VIEW publishes the grid geometry (tabs / columns / count) into
// QbzKioskNav; the grid only mirrors the focus index (ring + scroll-into-view)
// and answers the Enter pulse. The content index space skips `tabs` leading
// tab entries.
import QtQuick
import com.blitzfc.qbz
Flickable {
id: root
property var albums: []
property int columns: 6
property real gap: 16
property real pad: 16
signal open(string id)
readonly property real cardW: (root.width - 2 * root.pad - (root.columns - 1) * root.gap) / root.columns
readonly property real cardH: root.cardW + 46
readonly property int rows: Math.ceil(root.albums.length / root.columns)
contentWidth: root.width
contentHeight: root.rows * (root.cardH + root.gap) + root.pad
clip: true
boundsBehavior: Flickable.StopAtBounds
readonly property int focusedItem: QbzKioskNav.index - QbzKioskNav.tabs
readonly property bool itemFocused: QbzKioskNav.navActive
&& QbzKioskNav.zone === "content"
&& root.focusedItem >= 0
&& root.focusedItem < root.albums.length
readonly property real focusTop: root.pad
+ Math.floor(root.focusedItem / root.columns) * (root.cardH + root.gap)
// Scroll the focused card's row into view. contentY is WRITTEN from this
// function on a settled layout (Qt.callLater defers it past the current
// binding evaluation), never read by a binding that feeds layout — the
// AlbumCollectionView "Recursion detected" panic class.
function scrollFocusIntoView() {
if (!root.itemFocused)
return
if (root.focusTop < root.contentY)
root.contentY = root.focusTop - 8
else if (root.focusTop + root.cardH > root.contentY + root.height)
root.contentY = root.focusTop + root.cardH - root.height + 8
}
Connections {
target: QbzKioskNav
function onIndexChanged() {
Qt.callLater(root.scrollFocusIntoView)
}
// Enter pulse → open the focused album.
function onActivateSeqChanged() {
if (root.itemFocused)
root.open(root.albums[root.focusedItem].id)
}
}
Item {
width: root.contentWidth
height: root.contentHeight
Repeater {
model: root.albums
delegate: Item {
id: slot
required property var modelData
required property int index
x: root.pad + (slot.index % root.columns) * (root.cardW + root.gap)
y: root.pad + Math.floor(slot.index / root.columns) * (root.cardH + root.gap)
width: root.cardW
height: root.cardH
// Visible band (content coords) is [contentY, contentY+height];
// 240px margin keeps a card mounted just off-screen for smooth
// scrolling.
readonly property bool inWindow: (slot.y + slot.height >= root.contentY - 240)
&& (slot.y <= root.contentY + root.height + 240)
Component {
id: cardComponent
KioskCard {
artSize: root.cardW
album: slot.modelData
navFocused: root.itemFocused && root.focusedItem === slot.index
onClicked: function (id) {
root.open(id)
}
}
}
Loader {
active: slot.inWindow
sourceComponent: cardComponent
}
}
}
}
}
+426
View File
@@ -0,0 +1,426 @@
// 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.
function collectArtwork() {
var urls = []
if (root.artist && root.artist.artUrl)
urls.push(root.artist.artUrl)
for (var s = 0; s < root.sections.length; s++) {
var cards = root.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() {
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)
}
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,86 @@
// KioskArtistCard — lightweight round artist card for the kiosk
// Search/Library/LocalLibrary artist tabs (shell/KioskArtistCard.slint).
// Round avatar + centred name, tap to open. No overlay row, no context menu,
// no follow button — the deliberate absences of the whole kiosk card family.
//
// The Slint takes primitive fields so ONE component serves both producer
// shapes; the Qt port takes the row object and reads both spellings for the
// same reason: search artists arrive as { id, title, artwork } (SlimItem) and
// favourite artists as { id, name, image } (FavoriteArtistItem). `artwork` /
// `image` carry the RESOLVED local avatar path the hosting view supplies, not
// the remote url.
//
// The CARD corner is 8px even though the avatar inside is a full circle.
import QtQuick
import "../theme"
Rectangle {
id: root
property var artist: ({})
property real artSize: 128
/// Keyboard/gamepad nav — same ring idiom as KioskCard.
property bool navFocused: false
signal clicked(string id)
QbzTheme { id: theme }
readonly property string _id: root.artist && root.artist.id ? root.artist.id : ""
readonly property string _name: root.artist
? (root.artist.name ? root.artist.name : (root.artist.title ? root.artist.title : ""))
: ""
readonly property string _avatar: root.artist
? (root.artist.artwork ? root.artist.artwork : (root.artist.image ? root.artist.image : ""))
: ""
implicitWidth: root.artSize
width: root.artSize
implicitHeight: body.implicitHeight
border.width: root.navFocused ? 3 : 0
border.color: theme.accent
radius: theme.radiusSm
color: root.navFocused
? Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.12)
: "transparent"
Column {
id: body
width: root.artSize
spacing: 8
Rectangle {
id: avatarTile
width: root.artSize
height: root.artSize
radius: root.artSize / 2
color: theme.surfaceElevated
clip: true
RoundedImage {
anchors.fill: parent
source: root._avatar
radius: avatarTile.radius
fit: "crop"
}
}
Text {
width: root.artSize
text: root._name
color: theme.textPrimary
font.pixelSize: 13
font.weight: theme.weightSemibold
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
wrapMode: Text.NoWrap
maximumLineCount: 1
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: false
onClicked: root.clicked(root._id)
}
}
+128
View File
@@ -0,0 +1,128 @@
// KioskCard — the lightweight album card for the kiosk browse shelves
// (shell/KioskCard.slint). Deliberately SIMPLER than cards/AlbumCard.qml:
// square art + a quality badge + title + artist, tap-to-open. No hover-reveal
// actions, no context menu, no source/ribbon/award chrome, no pin badge, no
// favourite heart, no selection checkbox, no hover scale or scrim — that
// weight is what made the reused desktop views too heavy for a Pi. The only
// interaction is a full-surface tap.
//
// `album` is a plain JS object carrying { id, title, artist, artwork,
// qualityTier }. `artwork` is the RESOLVED local cover path: the Slint reads
// AlbumCardItem.artwork, which the artwork pipeline pushes into the shared
// model in place; the Qt documents carry only the remote `artUrl`, so the
// HOSTING VIEW resolves it and hands the local path down under `artwork`.
//
// Height is INTRINSIC (art + 7 + title line + 7 + artist line). The grid slot
// allocates artSize + 46 and the column's top alignment leaves the slack at
// the bottom, exactly as the Slint VerticalLayout's `alignment: start` does.
import QtQuick
import "../theme"
Rectangle {
id: root
property var album: ({})
property real artSize: 128
/// Circular art + centred text. No kiosk view sets it today; it is a
/// declared parameter of the primitive in the Slint (KioskCard.slint:15)
/// and drives the art radius and both text alignments.
property bool round: false
/// Keyboard/gamepad nav: the hosting view sets this when the shell focus
/// index lands on this card. A 3px accent ring + tint, readable at
/// 10-foot distance.
property bool navFocused: false
signal clicked(string id)
QbzTheme { id: theme }
readonly property string _title: root.album && root.album.title ? root.album.title : ""
readonly property string _artist: root.album && root.album.artist ? root.album.artist : ""
readonly property string _artwork: root.album && root.album.artwork ? root.album.artwork : ""
readonly property string _tier: root.album && root.album.qualityTier ? root.album.qualityTier : ""
implicitWidth: root.artSize
width: root.artSize
implicitHeight: body.implicitHeight
border.width: root.navFocused ? 3 : 0
border.color: theme.accent
radius: theme.radiusSm
color: root.navFocused
? Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.12)
: "transparent"
Column {
id: body
width: root.artSize
spacing: 7
Rectangle {
id: artTile
width: root.artSize
height: root.artSize
radius: root.round ? root.artSize / 2 : theme.radiusSm
color: theme.surfaceElevated
clip: true
RoundedImage {
anchors.fill: parent
source: root._artwork
radius: artTile.radius
fit: "crop"
}
// Quality badge — bottom-left, 6px inset on both axes, only for
// hi-res / lossless.
Rectangle {
id: badge
visible: root._tier !== ""
x: 6
y: artTile.height - badge.height - 6
height: 19
width: badgeLabel.implicitWidth + 12
color: "#04070cd9"
radius: 4
Text {
id: badgeLabel
anchors.left: badge.left
anchors.leftMargin: 6
anchors.verticalCenter: badge.verticalCenter
text: root._tier === "hires" ? "Hi-Res" : "CD"
color: root._tier === "hires" ? "#e6b24d" : theme.textSecondary
font.pixelSize: 10
font.weight: theme.weightBold
}
}
}
Text {
width: root.artSize
text: root._title
color: theme.textPrimary
font.pixelSize: 14
font.weight: theme.weightSemibold
elide: Text.ElideRight
wrapMode: Text.NoWrap
maximumLineCount: 1
horizontalAlignment: root.round ? Text.AlignHCenter : Text.AlignLeft
}
Text {
width: root.artSize
text: root._artist
color: theme.textMuted
font.pixelSize: 13
elide: Text.ElideRight
wrapMode: Text.NoWrap
maximumLineCount: 1
horizontalAlignment: root.round ? Text.AlignHCenter : Text.AlignLeft
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: false
onClicked: root.clicked(root.album && root.album.id ? root.album.id : "")
}
}
+457
View File
@@ -0,0 +1,457 @@
// KioskDiscover — lightweight Discover/Home for the kiosk (QML port of
// crates/qbz-ui/ui/shell/KioskDiscover.slint, 261 lines).
//
// Reads the SAME QbzHome documents the desktop HomeView reads (no data copy),
// but renders simple horizontal shelves of KioskCards instead of the heavy
// Carousel/AlbumCard stack — lighter for a Pi. v1 covers the album-carousel
// rows (newReleases, qobuzissimes, editorPicks, pressAwards, idealDiscography,
// mostStreamed); the non-album rows (playlists, mixes, slim rails) are skipped,
// exactly as KioskDiscover.slint:6-8 declares.
//
// Keyboard/gamepad nav: SHELF mode. The nav index space is the 3 tab pills
// (indices 0-2) followed by one index per SECTION DESCRIPTOR — including the
// ones that render nothing (columns == 1, so Up/Down walk shelves). Left/Right
// cannot move the global index (that would jump shelves), so the shell pulses
// `hmove` and the focused shelf consumes it into its LOCAL card cursor. Rows
// that render nothing are skipped by nudging the index one more step in the
// last travel direction (QbzKioskNav.dir), which is why `count` deliberately
// counts every descriptor (KioskDiscover.slint:10-17, :59-83).
//
// THE SKIP PREDICATE IS NOT THE SLINT ONE. Slint tests
// `desc.section.albums.length == 0`, which works only because a non-carousel
// descriptor is built with an EMPTY DiscoverSection (qbz/src/home.rs:856,
// qbz/src/discover_prefs.rs:203-209). The Qt document carries `items` for
// EVERY kind (src/home_qt.rs:127-143), so the equivalent test is
// `kind === "album"` — see isShelf() below.
//
// Slint's `viewport-y` / `viewport-x` are negative as you scroll; Qt's
// contentY/contentX are their positive twins — every occurrence below is
// already translated, and both are WRITTEN from a change handler on a settled
// layout, never read by a binding that feeds layout (the "Recursion detected"
// panic class).
import QtQuick
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
// KioskDiscover.slint:53 sets no background; the kiosk content panel
// paints surface-main behind it (shell/KioskShell.qml:415).
color: "transparent"
QbzTheme { id: theme }
function trs(s) { return QbzSession.tr(s, QbzSession.trRev) }
// DiscoverState.active-tab (state.slint:338) is a Slint GLOBAL written by
// HomeActions.select-tab; Qt has neither, and the desktop equivalent is a
// view-local property (views/HomeView.qml:179). The kiosk has THREE tabs —
// HomeView's 4th (recommendations) is not part of this view.
property string activeTab: "home"
// A raw JSON.parse in a binding throws on the pre-publish frame and takes
// the view down; the tree's guarded idiom is views/myqbz/MyQbzGridView.qml:94-103.
function parseSections(doc) {
try {
return JSON.parse(doc)
} catch (e) {
return []
}
}
readonly property var homeSections: root.parseSections(QbzHome.homeSectionsJson)
readonly property var editorSections: root.parseSections(QbzHome.editorSectionsJson)
readonly property var forYouSections: root.parseSections(QbzHome.forYouSectionsJson)
// KioskDiscover.slint:146-150.
readonly property var activeSections: root.activeTab === "editorPicks"
? root.editorSections
: root.activeTab === "forYou"
? root.forYouSections
: root.homeSections
/// Does this descriptor render a shelf? Two terms, and the For You gate.
///
/// `kind === "album"` is the Qt translation of the Slint empty-section test
/// (see the header). The For You term reproduces a property of the
/// reference that the Qt documents would otherwise break: Slint's
/// foryou_descriptors (qbz/src/discover_prefs.rs:218-225) maps EVERY
/// enabled id through bare_descriptor, so every For You descriptor carries
/// an empty section and the Slint kiosk's For You body is BLANK — the pills
/// stay focusable, the page below them is empty. The Qt For You document
/// does carry kind:"album" sections with items (src/home_qt.rs:512, :522,
/// :545, :555), so without this term the port would show content where the
/// reference shows none.
function isShelf(sec) {
return root.activeTab !== "forYou"
&& sec !== undefined && sec !== null
&& sec.kind === "album"
&& (sec.items || []).length > 0
}
// ---- Nav geometry publish (KioskDiscover.slint:59-83): 3 tab pills + one
// index per descriptor. `count` counts EVERY descriptor, including the ones
// that render nothing — that is precisely what the skip cascade exists for.
// The index clamp lives inside publishNav (src/kiosk_nav_qt.rs:266-274).
readonly property int navSectionCount: root.activeSections.length
function publishNav() {
QbzKioskNav.publishNav(3, 1, 3 + root.navSectionCount, true)
}
Component.onCompleted: root.publishNav()
onNavSectionCountChanged: root.publishNav()
onActiveTabChanged: root.publishNav()
// Enter on a tab pill → the same tab switch the pills' taps drive
// (KioskDiscover.slint:85-98). The `index < tabs` test is what keeps this
// watcher and the shelves' disjoint: both see the same pulse.
Connections {
target: QbzKioskNav
function onActivateSeqChanged() {
if (!QbzKioskNav.navActive || QbzKioskNav.zone !== "content"
|| QbzKioskNav.index >= QbzKioskNav.tabs)
return
if (QbzKioskNav.index === 0)
root.activeTab = "home"
else if (QbzKioskNav.index === 1)
root.activeTab = "editorPicks"
else if (QbzKioskNav.index === 2)
root.activeTab = "forYou"
}
}
// KioskDiscover.slint:23-51. Height 40, Radius.sm, 16px side padding, the
// label 15px/600, and a 2px accent ring when the shell focus lands here.
component TabPill: Rectangle {
id: pill
property string label: ""
property bool active: false
property bool navFocused: false
signal picked()
// The pre-publish frame carries no alpha ramp, where alphaTier()
// silently returns "transparent" (theme/QbzTheme.qml:97-99); the
// literal is the Slint dark value of Theme.alpha-6 and its
// light-polarity mirror (idiom + reason: rows/TrackRow.qml:264-269).
readonly property color hoverFill: theme.alphaTiers.length > 0
? theme.alphaTier(6) : (theme.isDark ? "#0fffffff" : "#0f000000")
width: pillLabel.implicitWidth + 32
height: 40
radius: theme.radiusSm
color: pill.active ? theme.accent
: (pillArea.containsMouse ? pill.hoverFill : "transparent")
border.width: pill.navFocused ? 2 : 0
border.color: theme.accent
Text {
id: pillLabel
anchors.centerIn: pill
text: pill.label
// KioskDiscover.slint:40 is Theme.accent-text on the accent fill;
// on an accent FILL this port takes the owner-approved glyph
// selector instead (theme/QbzTheme.qml:111-203, :314-317), because
// white/accent-text fails 3:1 on 16 of 35 palettes.
color: pill.active ? theme.accentGlyphColor : theme.textSecondary
font.pixelSize: 15
font.weight: theme.weightSemibold
verticalAlignment: Text.AlignVCenter
}
MouseArea {
id: pillArea
anchors.fill: pill
hoverEnabled: true
onClicked: pill.picked()
}
}
Flickable {
id: pageFlick
anchors.fill: root
contentWidth: pageFlick.width
contentHeight: page.height + 32 // the column's 16px top+bottom pad
clip: true
boundsBehavior: Flickable.StopAtBounds
Column {
id: page
x: 16
y: 16
width: pageFlick.width - 32
spacing: 22
// Tab pills — Home / Editor's Picks / For You
// (KioskDiscover.slint:109-142). The container hugs the pill row.
Rectangle {
width: pills.width + 10 // the row's 5px padding, both sides
height: 50
radius: theme.radiusMd
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
Row {
id: pills
x: 5
y: 5
spacing: 4
TabPill {
label: root.trs("Home")
active: root.activeTab === "home"
navFocused: QbzKioskNav.navActive
&& QbzKioskNav.zone === "content"
&& QbzKioskNav.index === 0
onPicked: root.activeTab = "home"
}
TabPill {
label: root.trs("Editor's Picks")
active: root.activeTab === "editorPicks"
navFocused: QbzKioskNav.navActive
&& QbzKioskNav.zone === "content"
&& QbzKioskNav.index === 1
onPicked: root.activeTab = "editorPicks"
}
TabPill {
label: root.trs("For You")
active: root.activeTab === "forYou"
navFocused: QbzKioskNav.navActive
&& QbzKioskNav.zone === "content"
&& QbzKioskNav.index === 2
onPicked: root.activeTab = "forYou"
}
}
}
// One block per descriptor of the active tab. The Repeater walks
// the RAW array — a descriptor that renders nothing still owns its
// nav index and still carries the skip handler below, which is how
// the cascade walks contiguous gaps (KioskDiscover.slint:146-258).
Repeater {
model: root.activeSections
delegate: Item {
id: shelf
required property var modelData
required property int index
readonly property bool isShelf: root.isShelf(shelf.modelData)
readonly property var items: shelf.isShelf ? (shelf.modelData.items || []) : []
// The focused card INSIDE this shelf.
property int cursor: 0
readonly property bool shelfFocused: QbzKioskNav.navActive
&& QbzKioskNav.zone === "content"
&& QbzKioskNav.index - QbzKioskNav.tabs === shelf.index
width: page.width
// 24px title + 12 + 182px strip. A descriptor that renders
// nothing collapses to 0, and the column's 22px spacing
// still separates it — the Slint VerticalLayout does the
// same with its empty loop item.
height: shelf.isShelf ? 218 : 0
/// Skip over descriptors that render nothing: take one more
/// step in the last travel direction. The step re-fires
/// every shelf's handler, so the cascade walks contiguous
/// gaps and converges (KioskDiscover.slint:158-170).
///
/// The Slint original assigns `KioskNavState.index`
/// directly. Here the step goes through navDown()/navUp()
/// instead, because `QbzKioskNav.index` is a one-way MIRROR
/// of the Rust model (src/kiosk_nav_bridge.rs:109-110,
/// :148-163 sync()): a QML write moves the ring for one
/// frame and the next arrow key resyncs it back off the
/// untouched model — i.e. one dead keypress per skipped
/// row. The movement functions keep the model authoritative,
/// and for a shelf view (columns 1) they take exactly the
/// same +1 / -1 step inside the item range
/// (src/kiosk_nav_qt.rs:184-214, :153-181). At the two ends
/// the movement functions would LEAVE the content zone
/// (navDown() to the player bar, navUp() to the tab strip),
/// where Slint's `Math.max(tabs, Math.min(count - 1, ...))`
/// parks on the empty edge descriptor with no ring visible
/// — spec S2 §7.3 pins that termination as source behavior
/// the port must preserve. Hence the edge guards below:
/// step only while the step stays inside [tabs, count-1],
/// park otherwise.
///
/// Deferred, not immediate: the step is taken from inside
/// the model's own change notification, and re-entering the
/// bridge there would run a nested sync() inside the
/// outer one (:148-163 reads a clone of the model up front).
function skipIfEmpty() {
if (QbzKioskNav.zone !== "content" || !QbzKioskNav.navActive
|| QbzKioskNav.index - QbzKioskNav.tabs !== shelf.index
|| shelf.isShelf || QbzKioskNav.dir === 0)
return
if (QbzKioskNav.dir > 0) {
if (QbzKioskNav.index < QbzKioskNav.count - 1)
QbzKioskNav.navDown()
} else if (QbzKioskNav.index > QbzKioskNav.tabs) {
QbzKioskNav.navUp()
}
}
/// Keep the focused shelf's strip inside the page viewport.
/// Page coords are FIXED ARITHMETIC: 16px pad + 50px tab
/// row + 22px spacing = 88, then a 240px pitch per block
/// (24 title + 12 + 182 strip + 22 spacing), block height
/// 218 (KioskDiscover.slint:193-208). The literals are the
/// source's: they assume every descriptor renders, so a
/// preceding empty one shifts the real geometry — that
/// approximation is part of the reference and is reproduced
/// rather than corrected.
function scrollShelfIntoView() {
if (!shelf.shelfFocused || shelf.items.length === 0)
return
var top = 88 + shelf.index * 240
if (top < pageFlick.contentY)
pageFlick.contentY = top - 8
else if (top + 218 > pageFlick.contentY + pageFlick.height)
pageFlick.contentY = top + 218 - pageFlick.height + 8
}
/// Keep the cursor card inside the strip's horizontal
/// viewport; card pitch = 128px art + 14px spacing
/// (KioskDiscover.slint:232-243). At cursor 0 after a
/// scroll the first arm yields contentX = -8, an 8px left
/// gutter — the source's behaviour, kept (StopAtBounds is
/// what stops Qt animating it away).
function scrollCursorIntoView() {
if (!shelf.shelfFocused)
return
var left = shelf.cursor * 142
if (left < stripFlick.contentX)
stripFlick.contentX = left - 8
else if (left + 128 > stripFlick.contentX + stripFlick.width)
stripFlick.contentX = left + 128 - stripFlick.width + 8
}
onCursorChanged: Qt.callLater(shelf.scrollCursorIntoView)
Connections {
target: QbzKioskNav
function onIndexChanged() {
Qt.callLater(shelf.skipIfEmpty)
Qt.callLater(shelf.scrollShelfIntoView)
}
// Left/Right shelf move: the shell pulses `hmove` and
// the focused shelf consumes it into its cursor.
// takeHmove() reads and resets in one step, so a pulse
// can never be applied twice (src/kiosk_nav_qt.rs:279-281);
// an unfocused or empty shelf must NOT consume it
// (KioskDiscover.slint:172-182).
function onHmoveChanged() {
if (!shelf.shelfFocused || QbzKioskNav.hmove === 0
|| shelf.items.length === 0)
return
var pulse = QbzKioskNav.takeHmove()
shelf.cursor = Math.max(0,
Math.min(shelf.cursor + pulse, shelf.items.length - 1))
}
// Enter pulse → open the cursor album
// (KioskDiscover.slint:184-191).
function onActivateSeqChanged() {
if (shelf.shelfFocused && shelf.items.length > 0
&& shelf.cursor < shelf.items.length)
QbzAlbum.openAlbum(shelf.items[shelf.cursor].id)
}
}
Column {
id: block
visible: shelf.isShelf
width: shelf.width
spacing: 12
// Fixed-height title wrapper (KioskDiscover.slint:213-225):
// it is what makes the block a constant 218px, so the
// arithmetic above stays exact.
//
// The label is CENTRED, both axes. Not a choice: the
// Slint Text sits in a plain Rectangle, and a non-layout
// parent centres a child that does not fill it
// (i-slint-compiler passes/default_geometry.rs,
// maybe_center_in_parent) — a bare Text in the
// VerticalLayout, which the comment at :213 says this
// replaced, would have been left-aligned.
Item {
id: titleWrap
width: block.width
height: 24
Text {
id: titleText
anchors.centerIn: titleWrap
text: shelf.modelData.title || ""
color: theme.textPrimary
font.pixelSize: 18
font.weight: theme.weightSemibold
verticalAlignment: Text.AlignVCenter
}
}
Flickable {
id: stripFlick
width: block.width
height: 182
contentWidth: strip.width
contentHeight: stripFlick.height
clip: true
boundsBehavior: Flickable.StopAtBounds
Row {
id: strip
spacing: 14
Repeater {
model: shelf.items
delegate: KioskCard {
id: card
required property var modelData
required property int index
// artSize stays the primitive's 128
// default, which is what makes the
// 142px pitch above right.
//
// KioskCard reads a RESOLVED cover off
// `album.artwork`; the home documents
// carry the cache path as `artPath`
// (src/home_qt.rs:119-120), the same
// field views/HomeView.qml:385 hands
// its cards.
album: ({
id: card.modelData.id,
title: card.modelData.title,
artist: card.modelData.artist,
artwork: card.modelData.artPath || "",
qualityTier: card.modelData.qualityTier
})
navFocused: shelf.shelfFocused && card.index === shelf.cursor
onClicked: function (id) {
QbzAlbum.openAlbum(id)
}
}
}
}
}
}
}
}
}
}
}
+748
View File
@@ -0,0 +1,748 @@
// 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)
}
}
}
}
}
}
}
@@ -0,0 +1,566 @@
// 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)
}
}
+371
View File
@@ -0,0 +1,371 @@
// KioskMyQBZ — lightweight kiosk My QBZ view (shell/KioskMyQBZ.slint). Reads
// the same two grid documents the desktop MyQbzGridView reads. Tabs Mixtapes /
// Collections, simple cover cards, tap opens the detail. Mounted for BOTH the
// "mixtapes" and "collections" routes (ContentRouter.qml:98).
//
// THE TAB SWITCH IS A ROUTE, NOT A LOCAL ENUM. There is no local tab property:
// the active tab is derived from the current route, and both the tap path and
// the Enter path NAVIGATE (KioskMyQBZ.slint:106-107, :130-141, :158-168). The
// route discriminator arrives as `kind`, which ContentRouter.qml:142 computes
// as exactly `QbzShell.currentView === "collections"`, so reading it here is
// the same test the Slint's `NavState.view == ContentView.collections` is —
// and it is the property the router already assigns, in kiosk too
// (ContentRouter.qml:135-137).
//
// Because ONE live instance serves both routes (unlike Slint, where the two
// `if` mounts re-run `init => publish-nav()`), publishNav() also runs on the
// route flip — the compensation the mount-model difference requires.
//
// Nav geometry (KioskMyQBZ.slint:113-128): 2 tabs followed by the ACTIVE list
// as a 6-column grid. The clamp lives in Rust (kiosk_nav_qt.rs:271-273).
//
// Slint's `viewport-y` is negative as you scroll down; Qt's `contentY` is its
// positive twin — every occurrence below is already translated.
//
// Covers are push-only and the fallback chain has exactly three arms
// (KioskMyQBZ.slint:69-80): custom cover, else the FIRST collage cell, else
// the bare elevated square. No mosaic, no kind glyph, no placeholder — and no
// empty state, no skeleton, no spinner anywhere in this view, because the
// reference has none.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
/// "mixtape" | "collection" — assigned by ContentRouter's Binding
/// (ContentRouter.qml:139-147), which sources it from the route.
property string kind: "mixtape"
readonly property bool onCollections: root.kind === "collection"
readonly property real pad: 16
// The kiosk content panel paints and rounds its own surface
// (KioskShell.qml:409-416), exactly as KioskShell.slint's does, so the
// view root is transparent — as the Slint root Rectangle is.
color: "transparent"
QbzTheme { id: theme }
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
// ONE guarded parse per document. A raw JSON.parse inside a binding throws
// on the pre-publish frame and takes the whole view down
// (MyQbzGridView.qml:91-93).
readonly property var doc: root.parseDoc()
function parseDoc() {
try {
return JSON.parse(root.onCollections ? QbzMyQbz.collectionsJson
: QbzMyQbz.mixtapesJson)
} catch (e) {
return ({})
}
}
readonly property var cards: root.doc.cards || []
// ---- Nav geometry publish: 2 tabs + the active list, 6 columns.
function publishNav() {
QbzKioskNav.publishNav(2, 6, 2 + root.cards.length, false)
}
Component.onCompleted: root.publishNav()
// Slint probes `collections.length + mixtapes.length` because both its
// models are globals; here only the ACTIVE document is parsed, so the
// probe is its length plus the route flip below — together they cover
// every input to `count`.
readonly property int navLenProbe: root.cards.length
onNavLenProbeChanged: root.publishNav()
onOnCollectionsChanged: root.publishNav()
Connections {
target: QbzKioskNav
function onIndexChanged() {
Qt.callLater(grid.scrollFocusIntoView)
}
// The Enter pulse. The two arms are disjoint on `index < tabs`,
// exactly as the Slint's two watchers are (:131-141 and :207-216).
function onActivateSeqChanged() {
if (!QbzKioskNav.navActive || QbzKioskNav.zone !== "content")
return
if (QbzKioskNav.index < QbzKioskNav.tabs) {
// Enter on a tab → the same route the tab taps navigate to.
if (QbzKioskNav.index === 0)
QbzShell.navigateTo("mixtapes")
else if (QbzKioskNav.index === 1)
QbzShell.navigateTo("collections")
return
}
if (grid.itemFocused)
QbzMyQbz.openCard(root.cards[grid.focusedItem].id)
}
}
// =====================================================================
// Tab strip (KioskMyQBZ.slint:146-176)
// =====================================================================
Rectangle {
id: tabStrip
anchors.left: root.left
anchors.right: root.right
anchors.top: root.top
height: 48
color: theme.surfaceMain
Row {
id: tabRow
x: root.pad
height: tabStrip.height
spacing: 22
MyQbzTab {
label: root.t("Mixtapes")
active: !root.onCollections
navFocused: QbzKioskNav.navActive
&& QbzKioskNav.zone === "content"
&& QbzKioskNav.index === 0
onPicked: QbzShell.navigateTo("mixtapes")
}
MyQbzTab {
label: root.t("Collections")
active: root.onCollections
navFocused: QbzKioskNav.navActive
&& QbzKioskNav.zone === "content"
&& QbzKioskNav.index === 1
onPicked: QbzShell.navigateTo("collections")
}
}
Rectangle {
id: hairline
x: 0
y: tabStrip.height - 1
width: tabStrip.width
height: 1
color: theme.borderSubtle
}
}
// =====================================================================
// The grid (KioskMyQBZ.slint:178-231)
// =====================================================================
Flickable {
id: grid
anchors.left: root.left
anchors.right: root.right
anchors.top: tabStrip.bottom
anchors.bottom: root.bottom
clip: true
boundsBehavior: Flickable.StopAtBounds
readonly property int cols: 6
readonly property real gap: 16
readonly property real cell: (grid.width - 2 * root.pad - (grid.cols - 1) * grid.gap) / grid.cols
// The 44px band under the cover is the name + meta lines.
readonly property real itemH: grid.cell + 44
contentWidth: grid.width
contentHeight: Math.ceil(root.cards.length / grid.cols) * (grid.itemH + grid.gap) + root.pad
readonly property int focusedItem: QbzKioskNav.index - QbzKioskNav.tabs
readonly property bool itemFocused: QbzKioskNav.navActive
&& QbzKioskNav.zone === "content"
&& grid.focusedItem >= 0
&& grid.focusedItem < root.cards.length
readonly property real focusTop: root.pad
+ Math.floor(grid.focusedItem / grid.cols) * (grid.itemH + grid.gap)
// contentY is WRITTEN from this function on a settled layout
// (Qt.callLater defers it past the current binding evaluation), never
// read by a binding that feeds layout — the AlbumCollectionView
// "Recursion detected" panic class.
function scrollFocusIntoView() {
if (!grid.itemFocused)
return
if (grid.focusTop < grid.contentY)
grid.contentY = grid.focusTop - 8
else if (grid.focusTop + grid.itemH > grid.contentY + grid.height)
grid.contentY = grid.focusTop + grid.itemH - grid.height + 8
}
Item {
id: canvas
width: grid.contentWidth
height: grid.contentHeight
Repeater {
model: root.cards
delegate: MyQbzCardLite {
id: cardSlot
required property var modelData
required property int index
x: root.pad + (cardSlot.index % grid.cols) * (grid.cell + grid.gap)
y: root.pad + Math.floor(cardSlot.index / grid.cols) * (grid.itemH + grid.gap)
size: grid.cell
item: cardSlot.modelData
navFocused: grid.itemFocused && grid.focusedItem === cardSlot.index
onClicked: function (id) {
QbzMyQbz.openCard(id)
}
}
}
}
}
// =====================================================================
// Inline components (KioskMyQBZ.slint's two private components)
// =====================================================================
// An inline component does NOT share the enclosing document's scope, so
// each one declares its own QbzTheme and takes every string as a property
// — the same reason ViewModeMenu.qml:27 declares its own copy.
/// MyQbzTab (KioskMyQBZ.slint:14-47) — geometrically identical to the
/// Library / Local Library tabs: 2px ring, 2px side padding, 15px/600
/// label, 2px underline.
component MyQbzTab: Rectangle {
id: tab
property string label: ""
property bool active: false
property bool navFocused: false
signal picked()
QbzTheme { id: tabTheme }
width: tabLabel.width + 4
height: 48
color: "transparent"
border.width: tab.navFocused ? 2 : 0
border.color: tabTheme.accent
radius: tabTheme.radiusSm
Text {
id: tabLabel
x: 2
anchors.top: tab.top
anchors.bottom: tabUnderline.top
verticalAlignment: Text.AlignVCenter
text: tab.label
color: tab.active ? tabTheme.textPrimary : tabTheme.textMuted
font.pixelSize: 15
font.weight: tabTheme.weightSemibold
}
Rectangle {
id: tabUnderline
x: 2
anchors.bottom: tab.bottom
width: tabLabel.width
height: 2
radius: 2
color: tab.active ? tabTheme.accent : "transparent"
}
MouseArea {
anchors.fill: tab
hoverEnabled: false
onClicked: tab.picked()
}
}
/// MyQbzCardLite (KioskMyQBZ.slint:49-103). Note the cover square uses
/// Radius.md — md, not sm, unlike every other kiosk card.
component MyQbzCardLite: Rectangle {
id: card
property var item: ({})
property real size: 150
property bool navFocused: false
signal clicked(string id)
QbzTheme { id: cardTheme }
readonly property string _id: card.item && card.item.id ? card.item.id : ""
readonly property string _name: card.item && card.item.name ? card.item.name : ""
readonly property string _label: card.item && card.item.label ? card.item.label : ""
readonly property string _meta: card.item && card.item.meta ? card.item.meta : ""
readonly property bool _hasCustom: card.item ? card.item.hasCustomCover === true : false
readonly property string _customPath: card.item && card.item.customCoverPath
? card.item.customCoverPath : ""
readonly property var _cellUrls: card.item && card.item.cellUrls ? card.item.cellUrls : []
readonly property var _cellPaths: card.item && card.item.cellPaths ? card.item.cellPaths : []
// Arm 2 is gated on the URL and sourced from the PATH, exactly as the
// Slint gates on `url1` and mounts `cover1` (:75-79): gating on the
// path would flip the card to the empty arm while the cover is still
// downloading.
readonly property bool _hasCell: card._cellUrls.length > 0 && card._cellUrls[0] !== ""
readonly property string _cellPath: card._cellPaths.length > 0 ? card._cellPaths[0] : ""
implicitWidth: card.size
width: card.size
implicitHeight: cardBody.implicitHeight
border.width: card.navFocused ? 3 : 0
border.color: cardTheme.accent
radius: cardTheme.radiusSm
color: card.navFocused
? Qt.rgba(cardTheme.accent.r, cardTheme.accent.g, cardTheme.accent.b, 0.12)
: "transparent"
Column {
id: cardBody
width: card.size
spacing: 7
Rectangle {
id: coverTile
width: card.size
height: card.size
radius: cardTheme.radiusMd
color: cardTheme.surfaceElevated
clip: true
RoundedImage {
anchors.fill: coverTile
visible: card._hasCustom || card._hasCell
source: card._hasCustom ? card._customPath
: card._hasCell ? card._cellPath : ""
radius: coverTile.radius
fit: "crop"
}
}
Text {
width: card.size
text: card._name
color: cardTheme.textPrimary
font.pixelSize: 14
font.weight: cardTheme.weightSemibold
elide: Text.ElideRight
wrapMode: Text.NoWrap
maximumLineCount: 1
}
Text {
width: card.size
// The separator carries a space on both sides
// (KioskMyQBZ.slint:91). Both halves are translated in Rust.
text: card._label + " · " + card._meta
color: cardTheme.textMuted
font.pixelSize: 12
elide: Text.ElideRight
wrapMode: Text.NoWrap
maximumLineCount: 1
}
}
MouseArea {
anchors.fill: card
hoverEnabled: false
onClicked: card.clicked(card._id)
}
}
}
File diff suppressed because it is too large. Load diff
+927
View File
@@ -0,0 +1,927 @@
// KioskSearch — the kiosk Search results page (shell/KioskSearch.slint, 579
// lines). Reads the SAME search document the desktop SearchView reads
// (albums / tracks / artists / artistsCarousel / playlists + the tab int),
// rendered with the four lightweight kiosk primitives.
//
// The QUERY is not here: it is typed in the KioskShell back bar
// (SearchActions.submit -> QbzSearch.searchSubmit), and this view is only the
// tabbed results. There is no search field, no filter row, no "Load more" and
// no skeleton/empty/loading copy — KioskSearch.slint has none of them, so a
// query with no results renders the 48px tab strip and a blank pane
// (KioskSearch.slint:181-411: every section is gated on `length > 0` and the
// hero on `kind != ""`).
//
// NAV (KioskSearch.slint:97-144): the index space is the 5 tabs followed by
// the ACTIVE tab's items as a uniform grid. The All dashboard publishes
// TABS ONLY — its hero/preview mosaic is not a uniform grid, so keyboard nav
// on All reaches the 5 tab entries and nothing else. That is deliberate.
//
// Slint's `viewport-y` is negative as you scroll down; Qt's `contentY` is its
// positive twin — every scroll-into-view write below is already translated,
// and each is made from a function deferred with Qt.callLater, never from a
// binding that feeds layout.
//
// ARTWORK: search rows carry their own Rust-resolved local path (`artPath`,
// search_qt.rs CardRow/TrackRow/ArtistRow/PlaylistRow), so this view needs no
// artwork-window plumbing. The kiosk primitives read the resolved path under
// `artwork`, so the row objects are mapped once per document (below) rather
// than per delegate.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
// KioskSearch.slint:90 — no `background:` is set on the exported
// component, i.e. transparent over the shell's own surface.
color: "transparent"
// KioskSearch.slint:95.
property real pad: 16
QbzTheme { id: theme }
// ---------------------------------------------------------------------
// Document (search_qt.rs SearchPageDoc :1774-1796 / MostPopularDoc :1764-1771)
// ---------------------------------------------------------------------
readonly property var doc: root.parseDoc()
function parseDoc() {
try {
return JSON.parse(QbzSearch.searchJson)
} catch (e) {
return ({})
}
}
readonly property var albums: root.doc.albums || []
readonly property var tracks: root.doc.tracks || []
readonly property var artists: root.doc.artists || []
readonly property var artistsCarousel: root.doc.artistsCarousel || []
readonly property var playlists: root.doc.playlists || []
readonly property var mp: root.doc.mostPopular || ({})
readonly property int tab: root.doc.tab || 0
readonly property int tracksTotal: root.doc.tracksTotal || 0
// ---------------------------------------------------------------------
// Row -> primitive mapping. Done ONCE per document: the delegates below
// are handed a finished object, so nothing re-allocates while scrolling.
// ---------------------------------------------------------------------
function cardOf(r) {
return {
"id": r.id || "",
"title": r.title || "",
"artist": r.artist || "",
"artwork": r.artPath || "",
"qualityTier": r.qualityTier || ""
}
}
function trackOf(r) {
return {
"id": r.id || "",
"title": r.title || "",
"artist": r.artist || "",
"duration": r.duration || "",
"artwork": r.artPath || ""
}
}
function artistOf(r) {
// ArtistRow's display name is `title`, NOT `name` (search_qt.rs:214).
return {
"id": r.id || "",
"title": r.title || "",
"artwork": r.artPath || ""
}
}
function playlistOf(r) {
return {
"id": r.id || "",
"title": r.title || "",
"artwork": r.artPath || ""
}
}
function mapRows(rows, fn) {
var out = []
for (var i = 0; i < rows.length; i++)
out.push(fn(rows[i]))
return out
}
readonly property var albumCards: root.mapRows(root.albums, root.cardOf)
readonly property var trackRows: root.mapRows(root.tracks, root.trackOf)
readonly property var artistCards: root.mapRows(root.artists, root.artistOf)
readonly property var artistCarouselCards: root.mapRows(root.artistsCarousel, root.artistOf)
readonly property var playlistCards: root.mapRows(root.playlists, root.playlistOf)
readonly property string heroKind: root.mp.kind || ""
readonly property var heroAlbum: root.mp.album ? root.cardOf(root.mp.album) : ({})
readonly property var heroArtist: root.mp.artist ? root.artistOf(root.mp.artist) : ({})
readonly property var heroTrack: root.mp.track ? root.trackOf(root.mp.track) : ({})
readonly property string heroQualityLabel: root.mp.qualityLabel || ""
// ---------------------------------------------------------------------
// Nav geometry (KioskSearch.slint:103-135). The clamp Slint writes by hand
// at :122 lives inside the model's own publish (kiosk_nav_qt.rs:266-274).
// ---------------------------------------------------------------------
function publishNav() {
var columns = 1
var count = 5
if (root.tab === 1) {
columns = 6
count = 5 + root.albums.length
} else if (root.tab === 2) {
columns = 1
count = 5 + root.tracks.length
} else if (root.tab === 3) {
columns = 7
count = 5 + root.artists.length
} else if (root.tab === 4) {
columns = 6
count = 5 + root.playlists.length
}
QbzKioskNav.publishNav(5, columns, count, false)
}
Component.onCompleted: root.publishNav()
onTabChanged: root.publishNav()
readonly property int navLenProbe: root.albums.length + root.tracks.length
+ root.artists.length + root.playlists.length
onNavLenProbeChanged: root.publishNav()
readonly property bool contentFocus: QbzKioskNav.navActive && QbzKioskNav.zone === "content"
// Enter on a tab entry — the same switch the tab taps drive
// (KioskSearch.slint:137-144). The per-tab containers below answer the
// same pulse for `index >= tabs`, so the two never overlap.
Connections {
target: QbzKioskNav
function onActivateSeqChanged() {
if (root.contentFocus && QbzKioskNav.index < QbzKioskNav.tabs)
QbzSearch.searchTabChanged(QbzKioskNav.index)
}
}
// ---------------------------------------------------------------------
// Local components (SearchTab KioskSearch.slint:17-51,
// SectionHeader :55-88)
// ---------------------------------------------------------------------
component SearchTab: Rectangle {
id: st
property string label: ""
property int tabIndex: 0
property bool navFocused: false
readonly property bool active: root.tab === st.tabIndex
width: stLabel.implicitWidth + 4
height: parent ? parent.height : 0
color: "transparent"
border.width: st.navFocused ? 2 : 0
border.color: theme.accent
radius: theme.radiusSm
Text {
id: stLabel
anchors.left: st.left
anchors.leftMargin: 2
anchors.top: st.top
anchors.bottom: stRule.top
text: st.label
color: st.active ? theme.textPrimary : theme.textMuted
font.pixelSize: 15
font.weight: theme.weightSemibold
verticalAlignment: Text.AlignVCenter
}
Rectangle {
id: stRule
anchors.left: st.left
anchors.leftMargin: 2
anchors.right: st.right
anchors.rightMargin: 2
anchors.bottom: st.bottom
height: 2
radius: 2
color: st.active ? theme.accent : "transparent"
}
MouseArea {
anchors.fill: st
onClicked: QbzSearch.searchTabChanged(st.tabIndex)
}
}
component SectionHeader: Item {
id: sh
property string title: ""
property bool showAll: true
signal viewAll()
height: 34
Text {
anchors.left: sh.left
anchors.verticalCenter: sh.verticalCenter
text: sh.title
color: theme.textPrimary
font.pixelSize: 18
font.weight: theme.weightBold
}
Item {
id: shAll
visible: sh.showAll
anchors.right: sh.right
anchors.top: sh.top
anchors.bottom: sh.bottom
width: 74
Text {
anchors.fill: shAll
text: QbzSession.tr("View all", QbzSession.trRev)
color: theme.accent
font.pixelSize: 13
font.weight: theme.weightSemibold
horizontalAlignment: Text.AlignRight
verticalAlignment: Text.AlignVCenter
}
MouseArea {
anchors.fill: shAll
onClicked: sh.viewAll()
}
}
}
// ---------------------------------------------------------------------
// Tab strip (KioskSearch.slint:150-169). The 1px rule sits on the strip's
// BOTTOM edge (`y: parent.height - 1px`, :165), separating tabs from
// content — not a top border.
// ---------------------------------------------------------------------
Rectangle {
id: tabStrip
anchors.top: root.top
anchors.left: root.left
anchors.right: root.right
height: 48
color: theme.surfaceMain
Row {
id: tabsRow
x: root.pad
anchors.top: tabStrip.top
anchors.bottom: tabStrip.bottom
spacing: 22
SearchTab {
label: QbzSession.tr("All", QbzSession.trRev)
tabIndex: 0
navFocused: root.contentFocus && QbzKioskNav.index === 0
}
SearchTab {
label: QbzSession.tr("Albums", QbzSession.trRev)
tabIndex: 1
navFocused: root.contentFocus && QbzKioskNav.index === 1
}
SearchTab {
label: QbzSession.tr("Tracks", QbzSession.trRev)
tabIndex: 2
navFocused: root.contentFocus && QbzKioskNav.index === 2
}
SearchTab {
label: QbzSession.tr("Artists", QbzSession.trRev)
tabIndex: 3
navFocused: root.contentFocus && QbzKioskNav.index === 3
}
SearchTab {
label: QbzSession.tr("Playlists", QbzSession.trRev)
tabIndex: 4
navFocused: root.contentFocus && QbzKioskNav.index === 4
}
}
Rectangle {
y: tabStrip.height - 1
width: tabStrip.width
height: 1
color: theme.borderSubtle
}
}
// ---------------------------------------------------------------------
// Content (KioskSearch.slint:172-577). One arm mounted at a time, exactly
// as Slint's `if` chain does.
// ---------------------------------------------------------------------
Item {
id: content
anchors.top: tabStrip.bottom
anchors.left: root.left
anchors.right: root.right
anchors.bottom: root.bottom
clip: true
Loader {
anchors.fill: content
active: root.tab === 0
sourceComponent: allDashboard
}
Loader {
anchors.fill: content
active: root.tab === 1
sourceComponent: albumsTab
}
Loader {
anchors.fill: content
active: root.tab === 2
sourceComponent: tracksTab
}
Loader {
anchors.fill: content
active: root.tab === 3
sourceComponent: artistsTab
}
Loader {
anchors.fill: content
active: root.tab === 4
sourceComponent: playlistsTab
}
}
// ---- All — the dashboard (KioskSearch.slint:181-411) -----------------
Component {
id: allDashboard
Flickable {
id: allFlick
contentWidth: allFlick.width
contentHeight: dash.height + 2 * root.pad
clip: true
boundsBehavior: Flickable.StopAtBounds
Column {
id: dash
x: root.pad
y: root.pad
width: allFlick.width - 2 * root.pad
spacing: 26
// ---- Most-popular hero (:190-280), polymorphic ----------
Column {
visible: root.heroKind !== ""
width: dash.width
spacing: 10
Row {
spacing: 8
QbzIcon {
id: crownGlyph
name: "award"
width: 18
height: 18
tintName: "warning"
}
Text {
anchors.verticalCenter: crownGlyph.verticalCenter
text: QbzSession.tr("Most popular", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: 14
font.weight: theme.weightBold
}
}
// The hero carries no nav ring: the All tab publishes no
// items (:118-121).
Loader {
active: root.heroKind === "artist"
sourceComponent: KioskArtistCard {
artSize: 150
artist: root.heroArtist
onClicked: function (id) {
QbzArtist.openArtist(id)
}
}
}
Loader {
active: root.heroKind === "album"
sourceComponent: KioskCard {
artSize: 150
album: root.heroAlbum
onClicked: function (id) {
QbzAlbum.openAlbum(id)
}
}
}
// Track hero — a bespoke 380x96 card, NOT KioskTrackRow
// (:227-278).
Loader {
active: root.heroKind === "track"
sourceComponent: Rectangle {
id: heroTrackCard
width: 380
height: 96
radius: theme.radiusMd
color: theme.surfaceCard
Row {
x: 8
y: 8
width: heroTrackCard.width - 16
height: heroTrackCard.height - 16
spacing: 12
Rectangle {
id: heroArt
width: 80
height: 80
radius: theme.radiusSm
color: theme.surfaceElevated
clip: true
// Mounted only when there is a resolved
// cover (:241) — otherwise the bare
// elevated square shows.
Loader {
anchors.fill: heroArt
active: (root.heroTrack.artwork || "") !== ""
sourceComponent: RoundedImage {
source: root.heroTrack.artwork || ""
radius: theme.radiusSm
fit: "crop"
}
}
}
Column {
id: heroText
width: heroTrackCard.width - 16 - heroArt.width - 12
anchors.verticalCenter: heroArt.verticalCenter
spacing: 3
Text {
width: heroText.width
text: root.heroTrack.title || ""
color: theme.textPrimary
font.pixelSize: 16
font.weight: theme.weightBold
elide: Text.ElideRight
wrapMode: Text.NoWrap
maximumLineCount: 1
}
Text {
width: heroText.width
text: root.heroTrack.artist || ""
color: theme.textSecondary
font.pixelSize: 13
elide: Text.ElideRight
wrapMode: Text.NoWrap
maximumLineCount: 1
}
Text {
visible: root.heroQualityLabel !== ""
width: heroText.width
text: root.heroQualityLabel
color: theme.textMuted
font.pixelSize: 11
}
}
}
MouseArea {
anchors.fill: heroTrackCard
onClicked: QbzPlayer.playTrack(root.heroTrack.id || "")
}
}
}
}
// ---- Artists preview (:283-309), hero-deduped carousel ----
Column {
visible: root.artistCarouselCards.length > 0
width: dash.width
spacing: 10
SectionHeader {
width: dash.width
title: QbzSession.tr("Artists", QbzSession.trRev)
onViewAll: QbzSearch.searchTabChanged(3)
}
Row {
spacing: 14
Repeater {
model: root.artistCarouselCards.slice(0, 6)
delegate: KioskArtistCard {
required property var modelData
artSize: 120
artist: modelData
onClicked: function (id) {
QbzArtist.openArtist(id)
}
}
}
}
}
// ---- Albums preview (:312-336) ---------------------------
Column {
visible: root.albumCards.length > 0
width: dash.width
spacing: 10
SectionHeader {
width: dash.width
title: QbzSession.tr("Albums", QbzSession.trRev)
onViewAll: QbzSearch.searchTabChanged(1)
}
Row {
spacing: 14
Repeater {
model: root.albumCards.slice(0, 6)
delegate: KioskCard {
required property var modelData
artSize: 140
album: modelData
onClicked: function (id) {
QbzAlbum.openAlbum(id)
}
}
}
}
}
// ---- Tracks preview (:339-356) ---------------------------
// The ONLY section with a conditional "View all" (:343).
Column {
visible: root.trackRows.length > 0
width: dash.width
spacing: 6
SectionHeader {
width: dash.width
title: QbzSession.tr("Tracks", QbzSession.trRev)
showAll: root.tracksTotal > 6
onViewAll: QbzSearch.searchTabChanged(2)
}
Repeater {
model: root.trackRows.slice(0, 6)
delegate: KioskTrackRow {
required property var modelData
width: dash.width
track: modelData
onClicked: QbzPlayer.playTrack(modelData.id || "")
}
}
}
// ---- Playlists preview (:359-409) ------------------------
Column {
visible: root.playlistCards.length > 0
width: dash.width
spacing: 10
SectionHeader {
width: dash.width
title: QbzSession.tr("Playlists", QbzSession.trRev)
onViewAll: QbzSearch.searchTabChanged(4)
}
Row {
spacing: 14
Repeater {
model: root.playlistCards.slice(0, 6)
// A bespoke 140px card, not KioskCard (:373-405).
delegate: Item {
id: plPrev
required property var modelData
width: 140
height: plPrevBody.height
Column {
id: plPrevBody
width: 140
spacing: 7
Rectangle {
id: plPrevArt
width: 140
height: 140
radius: theme.radiusSm
color: theme.surfaceElevated
clip: true
RoundedImage {
anchors.fill: plPrevArt
source: plPrev.modelData.artwork || ""
radius: theme.radiusSm
fit: "crop"
}
}
Text {
width: 140
text: plPrev.modelData.title || ""
color: theme.textPrimary
font.pixelSize: 13
font.weight: theme.weightSemibold
elide: Text.ElideRight
wrapMode: Text.NoWrap
maximumLineCount: 1
}
}
MouseArea {
anchors.fill: plPrev
onClicked: QbzBridge.openPlaylist(plPrev.modelData.id || "")
}
}
}
}
}
}
}
}
// ---- Albums tab (KioskSearch.slint:414-419) --------------------------
// Only `albums` is passed; columns/gap/pad keep the primitive's defaults,
// and the grid owns its own ring, Enter pulse and scroll-into-view.
Component {
id: albumsTab
KioskAlbumGrid {
albums: root.albumCards
onOpen: function (id) {
QbzAlbum.openAlbum(id)
}
}
}
// ---- Tracks tab (KioskSearch.slint:422-453) --------------------------
Component {
id: tracksTab
ListView {
id: tracksList
clip: true
boundsBehavior: Flickable.StopAtBounds
spacing: 0
model: root.trackRows
readonly property int focusedItem: QbzKioskNav.index - QbzKioskNav.tabs
readonly property bool itemFocused: root.contentFocus
&& tracksList.focusedItem >= 0
&& tracksList.focusedItem < root.trackRows.length
// 62px pitch = the KioskTrackRow height, no spacing.
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)
QbzPlayer.playTrack(root.trackRows[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: QbzPlayer.playTrack(trackRow.modelData.id || "")
}
}
}
// ---- Artists tab (KioskSearch.slint:456-501) -------------------------
Component {
id: artistsTab
Flickable {
id: agrid
clip: true
boundsBehavior: Flickable.StopAtBounds
readonly property int cols: 7
readonly property real gap: 14
readonly property real cell: (agrid.width - 2 * root.pad - (agrid.cols - 1) * agrid.gap) / agrid.cols
// +26px is the name-label band under the round avatar.
readonly property real band: agrid.cell + 26
contentWidth: agrid.width
contentHeight: Math.ceil(root.artistCards.length / agrid.cols) * (agrid.band + agrid.gap) + root.pad
readonly property int focusedItem: QbzKioskNav.index - QbzKioskNav.tabs
readonly property bool itemFocused: root.contentFocus
&& agrid.focusedItem >= 0
&& agrid.focusedItem < root.artistCards.length
readonly property real focusTop: root.pad
+ Math.floor(agrid.focusedItem / agrid.cols) * (agrid.band + agrid.gap)
function scrollFocusIntoView() {
if (!agrid.itemFocused)
return
if (agrid.focusTop < agrid.contentY)
agrid.contentY = agrid.focusTop - 8
else if (agrid.focusTop + agrid.band > agrid.contentY + agrid.height)
agrid.contentY = agrid.focusTop + agrid.band - agrid.height + 8
}
Connections {
target: QbzKioskNav
function onIndexChanged() {
Qt.callLater(agrid.scrollFocusIntoView)
}
function onActivateSeqChanged() {
if (agrid.itemFocused)
QbzArtist.openArtist(root.artistCards[agrid.focusedItem].id || "")
}
}
Item {
width: agrid.contentWidth
height: agrid.contentHeight
Repeater {
model: root.artistCards
delegate: KioskArtistCard {
id: artistCell
required property var modelData
required property int index
x: root.pad + (artistCell.index % agrid.cols) * (agrid.cell + agrid.gap)
y: root.pad + Math.floor(artistCell.index / agrid.cols) * (agrid.band + agrid.gap)
artSize: agrid.cell
artist: artistCell.modelData
navFocused: agrid.itemFocused && agrid.focusedItem === artistCell.index
onClicked: function (id) {
QbzArtist.openArtist(id)
}
}
}
}
}
}
// ---- Playlists tab (KioskSearch.slint:504-576) -----------------------
Component {
id: playlistsTab
Flickable {
id: pgrid
clip: true
boundsBehavior: Flickable.StopAtBounds
readonly property int cols: 6
readonly property real gap: 16
readonly property real cell: (pgrid.width - 2 * root.pad - (pgrid.cols - 1) * pgrid.gap) / pgrid.cols
// +42px is the title band.
readonly property real band: pgrid.cell + 42
contentWidth: pgrid.width
contentHeight: Math.ceil(root.playlistCards.length / pgrid.cols) * (pgrid.band + pgrid.gap) + root.pad
readonly property int focusedItem: QbzKioskNav.index - QbzKioskNav.tabs
readonly property bool itemFocused: root.contentFocus
&& pgrid.focusedItem >= 0
&& pgrid.focusedItem < root.playlistCards.length
readonly property real focusTop: root.pad
+ Math.floor(pgrid.focusedItem / pgrid.cols) * (pgrid.band + pgrid.gap)
function scrollFocusIntoView() {
if (!pgrid.itemFocused)
return
if (pgrid.focusTop < pgrid.contentY)
pgrid.contentY = pgrid.focusTop - 8
else if (pgrid.focusTop + pgrid.band > pgrid.contentY + pgrid.height)
pgrid.contentY = pgrid.focusTop + pgrid.band - pgrid.height + 8
}
Connections {
target: QbzKioskNav
function onIndexChanged() {
Qt.callLater(pgrid.scrollFocusIntoView)
}
function onActivateSeqChanged() {
if (pgrid.itemFocused)
QbzBridge.openPlaylist(root.playlistCards[pgrid.focusedItem].id || "")
}
}
Item {
width: pgrid.contentWidth
height: pgrid.contentHeight
Repeater {
model: root.playlistCards
// The ring is drawn by the cell itself — there is no shared
// primitive for playlists. Card ring family: 3px + a 0.12
// accent tint (:540-543).
delegate: Rectangle {
id: plCell
required property var modelData
required property int index
readonly property bool focused: pgrid.itemFocused
&& pgrid.focusedItem === plCell.index
x: root.pad + (plCell.index % pgrid.cols) * (pgrid.cell + pgrid.gap)
y: root.pad + Math.floor(plCell.index / pgrid.cols) * (pgrid.band + pgrid.gap)
width: pgrid.cell
height: pgrid.band
border.width: plCell.focused ? 3 : 0
border.color: theme.accent
radius: theme.radiusSm
color: plCell.focused
? Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.12)
: "transparent"
Column {
width: pgrid.cell
spacing: 7
Rectangle {
id: plArt
width: pgrid.cell
height: pgrid.cell
radius: theme.radiusSm
color: theme.surfaceElevated
clip: true
RoundedImage {
anchors.fill: plArt
source: plCell.modelData.artwork || ""
radius: theme.radiusSm
fit: "crop"
}
}
Text {
width: pgrid.cell
text: plCell.modelData.title || ""
color: theme.textPrimary
font.pixelSize: 14
font.weight: theme.weightSemibold
elide: Text.ElideRight
wrapMode: Text.NoWrap
maximumLineCount: 1
}
}
MouseArea {
anchors.fill: plCell
onClicked: QbzBridge.openPlaylist(plCell.modelData.id || "")
}
}
}
}
}
}
}
+131
View File
@@ -0,0 +1,131 @@
// KioskTrackRow — lightweight track row for the kiosk Search/Library track
// tabs (shell/KioskTrackRow.slint). Art + title/artist + duration, tap to
// play. No number cell, no favourite, no quality badge, no menu, no
// checkbox — the desktop rows/TrackRow.qml column model is exactly the weight
// the kiosk shell drops.
//
// `track` is a plain JS object carrying { id, title, artist, duration,
// artwork }; `artwork` is the RESOLVED local cover path the hosting view
// supplies (the Qt documents carry only the remote `artUrl`).
//
// `playing` marks the row whose track is the one on the deck. The Slint
// KioskTrackRow has no such state, so its treatment is taken from the kiosk's
// own current-track idiom on the album page (KioskAlbum.slint:171-175, :195):
// an accent@0.10 fill that sits BELOW focus and hover in the precedence, and
// the title in the accent colour.
import QtQuick
import "../theme"
Rectangle {
id: root
property var track: ({})
/// Keyboard/gamepad nav — same ring idiom as KioskCard, 2px here.
property bool navFocused: false
property bool playing: false
signal clicked()
QbzTheme { id: theme }
readonly property string _title: root.track && root.track.title ? root.track.title : ""
readonly property string _artist: root.track && root.track.artist ? root.track.artist : ""
readonly property string _duration: root.track && root.track.duration ? root.track.duration : ""
readonly property string _artwork: root.track && root.track.artwork ? root.track.artwork : ""
height: 62
radius: theme.radiusSm
color: root.navFocused
? Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.18)
: rowArea.containsMouse
? theme.alphaTier(6)
: root.playing
? Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.10)
: "transparent"
border.width: root.navFocused ? 2 : 0
border.color: theme.accent
Row {
id: cells
anchors.left: root.left
anchors.leftMargin: 8
anchors.right: root.right
anchors.rightMargin: 14
anchors.top: root.top
anchors.bottom: root.bottom
spacing: 12
// Art slot — fixed 46x46 centred in the row height.
Item {
id: artCell
width: 46
height: cells.height
Rectangle {
id: artTile
width: 46
height: 46
y: (artCell.height - artTile.height) / 2
radius: 5
color: theme.surfaceElevated
clip: true
RoundedImage {
anchors.fill: parent
source: root._artwork
radius: 5
fit: "crop"
}
}
}
Item {
id: textCol
width: Math.max(0, cells.width - artCell.width - durationLabel.width - 2 * cells.spacing)
height: cells.height
Column {
anchors.verticalCenter: textCol.verticalCenter
width: textCol.width
spacing: 2
Text {
width: textCol.width
text: root._title
color: root.playing ? theme.accent : theme.textPrimary
font.pixelSize: 15
font.weight: theme.weightMedium
elide: Text.ElideRight
wrapMode: Text.NoWrap
maximumLineCount: 1
}
Text {
width: textCol.width
text: root._artist
color: theme.textMuted
font.pixelSize: 13
elide: Text.ElideRight
wrapMode: Text.NoWrap
maximumLineCount: 1
}
}
}
Text {
id: durationLabel
height: cells.height
text: root._duration
color: theme.textMuted
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
}
}
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
onClicked: root.clicked()
}
}
+233
View File
@@ -0,0 +1,233 @@
// Bottom NavRail — the kiosk's small-panel touch navigation.
// QML port of crates/qbz-ui/ui/shell/NavRail.slint (193 lines).
//
// Seven icon+label tiles (Radius.sm rectangles, NO pills — ADR-008) replace
// the desktop Sidebar and drive the SAME shell routes. Tiles are positioned
// MANUALLY (absolute x + an explicit width derived from the rail's width),
// 1:1 with the reference: NavRail.slint:4-8 records that every layout-based
// attempt left them collapsed to icon width on the left, so the arithmetic
// below is deliberately kept literal rather than handed to a Row.
//
// Search is intentionally absent (NavRail.slint:86-88): the kiosk back bar
// carries a persistent search field, so a footer Search tile is redundant.
//
// The tile ROUTES are the shell's business — every tile emits a signal and
// KioskShell.qml maps it (NavRail.slint:62-68 -> KioskShell.slint:637-657).
import QtQuick
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
// NavRail.slint:70.
color: theme.surfaceMain
// NavRail.slint:75 (`out property <int> tile-count: 7`). The nav model
// carries the same 7 for its rail clamp (src/kiosk_nav_qt.rs `rail_count`).
readonly property int tileCount: 7
// NavRail.slint:62-68.
signal discover()
signal library()
signal localLibrary()
signal myqbz()
signal playing()
signal visualizer()
signal menu()
/// NavRail.slint:76-84. Enter in the rail zone calls this DIRECTLY (the
/// rail is always mounted, so it needs no activate-seq pulse); an index
/// outside 0..6 is silently ignored, exactly like the reference's
/// else-less if-chain.
function activateIndex(i) {
if (i === 0)
root.discover()
else if (i === 1)
root.library()
else if (i === 2)
root.localLibrary()
else if (i === 3)
root.myqbz()
else if (i === 4)
root.playing()
else if (i === 5)
root.visualizer()
else if (i === 6)
root.menu()
}
QbzTheme { id: theme }
function trs(s) { return QbzSession.tr(s, QbzSession.trRev) }
// Tile 3's label is the MyQBZ BRANDING, not a msgid (NavRail.slint:145
// binds `MyQbzBrandingState.label`). Same guarded parse, same `label`
// field and same fallback as the desktop nav catalog
// (shell/NavFlyout.qml:85-103) — the shape is not re-derived here.
readonly property var branding: root.parseBranding()
function parseBranding() {
try {
return JSON.parse(QbzMyQbz.brandingJson)
} catch (e) {
return ({})
}
}
readonly property string brandingLabel: {
var l = root.branding.label || ""
return l !== "" ? l : root.trs("My QBZ")
}
// NavRail.slint:89-90 — 8px outer padding each side, 6 gaps of 6px, so
// tw = (width - 16 - 36) / 7 and th = height - 18 (62px at the shell's
// 80px rail).
readonly property real tw: (root.width - 16 - 6 * 6) / 7
readonly property real th: root.height - 18
// The alpha ramp is empty on the pre-publish frame, where alphaTier()
// silently returns "transparent" — the tree's required guard idiom
// (rows/TrackRow.qml:264-269 writes out the reason).
readonly property color tileHoverBg: theme.alphaTiers.length > 0
? theme.alphaTier(6)
: (theme.isDark ? "#0fffffff" : "#0f000000")
// Tile order == the KioskNavState index inside the rail zone, and the
// argument activateIndex() dispatches on. Icons: NavRail.slint:105, 118,
// 131, 144, 159, 172, 185.
readonly property var tileIcons: ["compass", "library-big", "hard-drive",
"music-library-2", "play-fill", "sliders-horizontal", "settings"]
/// The tile labels. `rev` and `brand` are passed by the call site purely
/// so the delegate's binding re-runs on a language switch and on a
/// branding change (the same reason NavFlyout.qml:107 threads `trRev`).
function tileLabel(i, rev, brand) {
if (i === 0)
return root.trs("Discover")
if (i === 1)
return root.trs("Library")
if (i === 2)
return root.trs("Local Library")
if (i === 3)
return brand
if (i === 4)
return root.trs("Now Playing")
if (i === 5)
return root.trs("Visualizer")
return root.trs("Settings")
}
/// The `active` rules (NavRail.slint:107, 120, 133, 146-148, 161, 174,
/// 187), translated to the Qt route ids (src/nav_qt.rs:18-29). Three of
/// them are COMPOUND and one is a constant:
/// - Local Library is active on the local-album detail too;
/// - MyQBZ is active on mixtapes, collections AND the mixtape detail;
/// - Visualizer is `false` ALWAYS (NavRail.slint:174) — the immersive
/// overlay is not a route, so the tile never lights.
/// `view` is passed in so the binding re-runs on every route change.
function tileIsActive(i, view) {
if (i === 0)
return view === "home"
if (i === 1)
return view === "library"
if (i === 2)
return view === "local" || view === "localalbum"
if (i === 3)
return view === "mixtapes" || view === "collections"
|| view === "mixtapedetail"
if (i === 4)
return view === "nowplaying"
if (i === 5)
return false
return view === "settings"
}
// Top border (NavRail.slint:92-98): 1px, full width, declared FIRST so
// the tiles paint over it.
Rectangle {
x: 0
y: 0
width: root.width
height: 1
color: theme.borderSubtle
}
Repeater {
model: root.tileIcons
delegate: Rectangle {
id: tile
required property int index
required property string modelData
// NavRail.slint:16 / :18 — `active` drives the glyph and label
// colour, `navFocused` drives ONLY the fill and the border. A
// focused-but-inactive tile therefore keeps its muted glyph and
// muted label; that asymmetry is deliberate (NavRail.slint:39,46).
readonly property bool isActive: root.tileIsActive(tile.index, QbzShell.currentView)
readonly property bool navFocused: QbzKioskNav.navActive
&& QbzKioskNav.zone === "rail"
&& QbzKioskNav.index === tile.index
// NavRail.slint:101/114/127/140/155/168/181 (x) + y: 9px.
x: 8 + tile.index * (root.tw + 6)
y: 9
width: root.tw
height: root.th
// The visual matrix, NavRail.slint:21-28, as a strict priority
// chain.
radius: theme.radiusSm
color: tile.navFocused
? Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.22)
: tile.isActive
? Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.12)
: (tileArea.containsMouse ? root.tileHoverBg : "transparent")
border.width: tile.navFocused ? 2 : tile.isActive ? 1 : 0
border.color: tile.navFocused
? theme.accent
: Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.30)
// NavRail.slint:30-52 — centred column, 5px between the 24px
// glyph and the 11.5px/600 label.
Column {
id: tileBody
anchors.centerIn: tile
width: tile.width
spacing: 5
QbzIcon {
anchors.horizontalCenter: tileBody.horizontalCenter
name: tile.modelData
width: 24
height: 24
tintName: tile.isActive ? "accent" : "muted"
}
Text {
width: tileBody.width
text: root.tileLabel(tile.index, QbzSession.trRev, root.brandingLabel)
color: tile.isActive ? theme.accent : theme.textMuted
// NavRail.slint:47 is 11.5px, which Qt cannot express:
// font.pixelSize is an INT property, and font.pointSize —
// the only real-valued alternative — is DPI-relative, so it
// would make this one label scale differently from every
// other in the tree. Rounded up.
font.pixelSize: 12
font.weight: theme.weightSemibold
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
}
// NavRail.slint:54-58 — no enabled gate, no pressed state.
MouseArea {
id: tileArea
anchors.fill: tile
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.activateIndex(tile.index)
}
}
}
}
+24 -82
View File
@@ -114,12 +114,14 @@ Rectangle {
Connections { Connections {
target: QbzShell target: QbzShell
function onSelectAllRequested() { function onSelectAllRequested() {
if (viewLoader.item !== null && typeof viewLoader.item.selectAll === "function") if (contentRouter.currentItem !== null
viewLoader.item.selectAll() && typeof contentRouter.currentItem.selectAll === "function")
contentRouter.currentItem.selectAll()
} }
function onExitMultiSelectRequested() { function onExitMultiSelectRequested() {
if (viewLoader.item !== null && typeof viewLoader.item.exitMultiSelectMode === "function") if (contentRouter.currentItem !== null
viewLoader.item.exitMultiSelectMode() && typeof contentRouter.currentItem.exitMultiSelectMode === "function")
contentRouter.currentItem.exitMultiSelectMode()
} }
} }
// The reporters: their QbzShell mirrors feed the Rust Ctrl+A // The reporters: their QbzShell mirrors feed the Rust Ctrl+A
@@ -130,13 +132,15 @@ Rectangle {
Binding { Binding {
target: QbzShell target: QbzShell
property: "multiSelectCapable" property: "multiSelectCapable"
value: viewLoader.item !== null && typeof viewLoader.item.selectAll === "function" value: contentRouter.currentItem !== null
&& typeof contentRouter.currentItem.selectAll === "function"
restoreMode: Binding.RestoreNone restoreMode: Binding.RestoreNone
} }
Binding { Binding {
target: QbzShell target: QbzShell
property: "multiSelectActive" property: "multiSelectActive"
value: viewLoader.item !== null && viewLoader.item.multiSelectOn === true value: contentRouter.currentItem !== null
&& contentRouter.currentItem.multiSelectOn === true
restoreMode: Binding.RestoreNone restoreMode: Binding.RestoreNone
} }
@@ -245,83 +249,21 @@ Rectangle {
border.color: theme.frostBorder border.color: theme.frostBorder
clip: true clip: true
Loader { // The view-mount chain — every route arm, every hard-won comment and
id: viewLoader // the MyQBZ `kind` discriminator now live in ContentRouter.qml, which
// the KIOSK shell mounts too (kiosk-port contract §4.2 / D3). The
// Slint kiosk copies AppShell's mount block verbatim and calls that
// copy debt in its own header (KioskShell.slint:10-17); this is the
// named fix, so the two shells cannot drift.
//
// `kiosk` defaults false, i.e. this instance routes every id to the
// desktop view it always did. Reached from elsewhere in this document
// through `contentRouter.currentItem` (the multi-select seam above) —
// a QML `id` does not cross documents, so `viewLoader` is no longer
// nameable here and that public property is the replacement.
ContentRouter {
id: contentRouter
anchors.fill: parent anchors.fill: parent
source: QbzShell.currentView === "home" ? "../views/HomeView.qml"
: QbzShell.currentView === "library" ? "../views/LibraryView.qml"
: QbzShell.currentView === "local" ? "../views/LocalLibraryView.qml"
: QbzShell.currentView === "localalbum" ? "../views/LocalAlbumView.qml"
: QbzShell.currentView === "album" ? "../views/AlbumView.qml"
: QbzShell.currentView === "artist" ? "../views/ArtistView.qml"
: QbzShell.currentView === "settings" ? "../settings/SettingsView.qml"
: QbzShell.currentView === "search" ? "../views/SearchView.qml"
: QbzShell.currentView === "playlist" ? "../views/PlaylistView.qml"
: QbzShell.currentView === "discoverbrowse" ? "../views/DiscoverBrowseView.qml"
: QbzShell.currentView === "playlistbrowse" ? "../views/PlaylistBrowseView.qml"
: QbzShell.currentView === "recentalbums" ? "../views/PlayHistoryView.qml"
: QbzShell.currentView === "mostplayedalbums" ? "../views/PlayHistoryView.qml"
: QbzShell.currentView === "label" ? "../views/LabelView.qml"
: QbzShell.currentView === "labelreleases" ? "../views/LabelReleasesView.qml"
// Artist page > a release section > "See discography", and the
// album page's "From the same artist" View all. Same two-file
// contract as the rest of this chain (nav_qt.rs:9-16):
// artist_releases_qt::open records the id, this arm mounts it.
// Forgetting the arm is NOT a crash — the ternary falls through
// to "" and the pane goes blank with nothing logged, i.e. the
// dead "See discography" in a new costume.
: QbzShell.currentView === "artistreleases" ? "../views/ArtistReleasesView.qml"
// For You > Qobuz Mixes. The whole chain existed —
// HomeView's tile -> QbzHome.openMix -> foryou_qt -> nav_qt
// records the "mix" view — and MixView.qml is written and
// registered in build.rs, but this arm was missing, so every
// mix tile navigated to a view nobody mounted and the content
// pane simply went blank (back recovered, which is why it read
// as "nothing happens" rather than a crash).
: QbzShell.currentView === "mix" ? "../views/MixView.qml"
// MyQBZ. Mixtapes and Collections are ONE file — the two
// pages differ only in their filter row and their empty
// state, so MyQbzGridView carries a `kind` discriminator
// (see the Binding below, which is how it gets set).
: QbzShell.currentView === "mixtapes"
|| QbzShell.currentView === "collections" ? "../views/myqbz/MyQbzGridView.qml"
: QbzShell.currentView === "mixtapedetail" ? "../views/myqbz/MyQbzDetailView.qml"
: QbzShell.currentView === "discobuilder" ? "../views/myqbz/DiscoBuilderView.qml"
// Settings > Blacklist > Manage. Not reachable from the
// sidebar — blacklist_qt::open_manager records the route.
: QbzShell.currentView === "blacklist" ? "../views/BlacklistManagerView.qml"
// Sidebar > Playlists ⋯ > Manage playlists. Not a sidebar
// section — playlist_manager_qt::navigate() records the route.
// The route is a TWO-FILE contract (nav_qt.rs:9-16): the caller
// records the id, this arm mounts it, and the failure mode for a
// missing arm is a BLANK content pane, logged nowhere.
: QbzShell.currentView === "playlistmanager" ? "../views/PlaylistManagerView.qml" : ""
}
// MyQbzGridView serves BOTH the "mixtapes" and "collections" routes
// and needs to know which. A Loader cannot pass properties through a
// declarative `source:` ternary, and the view's own default is
// "mixtape" — so without this the Collections route would render the
// Mixtapes page, permanently, with nothing logged.
//
// A Binding rather than `onLoaded`/`setSource`: it applies the moment
// `item` exists (the Loader creates it synchronously, so still before
// the first render pass), it re-applies if the route flips
// mixtapes <-> collections while the same instance is mounted, and it
// keeps the whole router declarative. Same shape as the seeded-field
// pattern at controls/QbzLineEdit.qml:174-179.
//
// RestoreNone: the target is DESTROYED on unload, and the default
// RestoreBindingOrValue would try to write the old value back into a
// dying object.
Binding {
target: viewLoader.item
property: "kind"
value: QbzShell.currentView === "collections" ? "collection" : "mixtape"
when: viewLoader.item !== null
&& (QbzShell.currentView === "mixtapes"
|| QbzShell.currentView === "collections")
restoreMode: Binding.RestoreNone
} }
} }
+148
View File
@@ -0,0 +1,148 @@
// Content router — the ONE view-mount chain, shared by BOTH shells
// (2026-08-02 kiosk-port contract §4.2, divergence D3).
//
// The Slint kiosk's own content block is a VERBATIM copy of AppShell's, and
// KioskShell.slint:10-17 declares that copy to be temporary debt whose named
// follow-up is exactly this extraction. So the chain lives here once:
// AppShell.qml mounts it with `kiosk: false`, KioskShell.qml with
// `kiosk: true`, and the route -> file mapping is stated in a single place.
//
// This file MUST stay in qml/shell/: every `source` below is a relative path
// resolved against this document's directory (`../views/…`, `../settings/…`,
// `../kiosk/…`). Moving it silently breaks all of them.
//
// MOUNT PLUMBING ONLY (D3 guard). No view component is modified by the
// existence of this file, and the desktop chain below is the AppShell chain it
// replaced — same route ids, same files, same order, same comments.
//
// KIOSK OVERRIDES — EIGHT routes, and only these eight, resolve to a kiosk
// view instead (KioskShell.slint:361-608 is the authority):
//
// home -> ../kiosk/KioskDiscover.qml (:367)
// album -> ../kiosk/KioskAlbum.qml (:402)
// artist -> ../kiosk/KioskArtist.qml (:416)
// library -> ../kiosk/KioskLibrary.qml (:481, ContentView.favorites)
// local -> ../kiosk/KioskLocalLibrary.qml (:526, ContentView.local-library)
// mixtapes -> ../kiosk/KioskMyQBZ.qml (:512)
// collections-> ../kiosk/KioskMyQBZ.qml (:517)
// search -> ../kiosk/KioskSearch.qml (:589)
// nowplaying -> ../kiosk/KioskNowPlaying.qml (:598) — KIOSK-ONLY route
//
// Every OTHER route mounts the SAME desktop view in both shells — that is the
// whole point of sharing the router, and KioskShell.slint:381-586 confirms the
// kiosk re-hosts the desktop components for them.
import QtQuick
import com.blitzfc.qbz
Item {
id: root
// false = desktop shell (AppShell), true = kiosk shell (KioskShell).
property bool kiosk: false
// The mounted view. The replacement for AppShell's four `viewLoader.item`
// references (multi-select: the two Ctrl+A / Escape routers and the two
// duck-typed reporters) — a QML `id` is scoped to its own document, so
// those cannot reach into this file and need a public surface.
readonly property var currentItem: viewLoader.item
Loader {
id: viewLoader
anchors.fill: parent
source: QbzShell.currentView === "home"
? (root.kiosk ? "../kiosk/KioskDiscover.qml" : "../views/HomeView.qml")
: QbzShell.currentView === "library"
? (root.kiosk ? "../kiosk/KioskLibrary.qml" : "../views/LibraryView.qml")
: QbzShell.currentView === "local"
? (root.kiosk ? "../kiosk/KioskLocalLibrary.qml" : "../views/LocalLibraryView.qml")
: QbzShell.currentView === "localalbum" ? "../views/LocalAlbumView.qml"
: QbzShell.currentView === "album"
? (root.kiosk ? "../kiosk/KioskAlbum.qml" : "../views/AlbumView.qml")
: QbzShell.currentView === "artist"
? (root.kiosk ? "../kiosk/KioskArtist.qml" : "../views/ArtistView.qml")
: QbzShell.currentView === "settings" ? "../settings/SettingsView.qml"
: QbzShell.currentView === "search"
? (root.kiosk ? "../kiosk/KioskSearch.qml" : "../views/SearchView.qml")
: QbzShell.currentView === "playlist" ? "../views/PlaylistView.qml"
: QbzShell.currentView === "discoverbrowse" ? "../views/DiscoverBrowseView.qml"
: QbzShell.currentView === "playlistbrowse" ? "../views/PlaylistBrowseView.qml"
: QbzShell.currentView === "recentalbums" ? "../views/PlayHistoryView.qml"
: QbzShell.currentView === "mostplayedalbums" ? "../views/PlayHistoryView.qml"
: QbzShell.currentView === "label" ? "../views/LabelView.qml"
: QbzShell.currentView === "labelreleases" ? "../views/LabelReleasesView.qml"
// Artist page > a release section > "See discography", and the
// album page's "From the same artist" View all. Same two-file
// contract as the rest of this chain (nav_qt.rs:9-16):
// artist_releases_qt::open records the id, this arm mounts it.
// Forgetting the arm is NOT a crash — the ternary falls through
// to "" and the pane goes blank with nothing logged, i.e. the
// dead "See discography" in a new costume.
: QbzShell.currentView === "artistreleases" ? "../views/ArtistReleasesView.qml"
// For You > Qobuz Mixes. The whole chain existed —
// HomeView's tile -> QbzHome.openMix -> foryou_qt -> nav_qt
// records the "mix" view — and MixView.qml is written and
// registered in build.rs, but this arm was missing, so every
// mix tile navigated to a view nobody mounted and the content
// pane simply went blank (back recovered, which is why it read
// as "nothing happens" rather than a crash).
: QbzShell.currentView === "mix" ? "../views/MixView.qml"
// MyQBZ. Mixtapes and Collections are ONE file — the two
// pages differ only in their filter row and their empty
// state, so MyQbzGridView carries a `kind` discriminator
// (see the Binding below, which is how it gets set). The kiosk
// does the same with ONE component on both routes
// (KioskShell.slint:512,517).
: QbzShell.currentView === "mixtapes"
|| QbzShell.currentView === "collections"
? (root.kiosk ? "../kiosk/KioskMyQBZ.qml" : "../views/myqbz/MyQbzGridView.qml")
: QbzShell.currentView === "mixtapedetail" ? "../views/myqbz/MyQbzDetailView.qml"
: QbzShell.currentView === "discobuilder" ? "../views/myqbz/DiscoBuilderView.qml"
// Settings > Blacklist > Manage. Not reachable from the
// sidebar — blacklist_qt::open_manager records the route.
: QbzShell.currentView === "blacklist" ? "../views/BlacklistManagerView.qml"
// Sidebar > Playlists ⋯ > Manage playlists. Not a sidebar
// section — playlist_manager_qt::navigate() records the route.
// The route is a TWO-FILE contract (nav_qt.rs:9-16): the caller
// records the id, this arm mounts it, and the failure mode for a
// missing arm is a BLANK content pane, logged nowhere.
: QbzShell.currentView === "playlistmanager" ? "../views/PlaylistManagerView.qml"
// KIOSK-ONLY route (nav_qt.rs:24-29): the NavRail's fifth tile,
// the full-screen player. The desktop shell has no equivalent —
// its transport is the persistent bar — so outside kiosk this id
// falls through to "" and the pane is blank, which is the desktop
// behaviour before this router existed.
: root.kiosk && QbzShell.currentView === "nowplaying" ? "../kiosk/KioskNowPlaying.qml" : ""
}
// MyQbzGridView serves BOTH the "mixtapes" and "collections" routes
// and needs to know which. A Loader cannot pass properties through a
// declarative `source:` ternary, and the view's own default is
// "mixtape" — so without this the Collections route would render the
// Mixtapes page, permanently, with nothing logged.
//
// A Binding rather than `onLoaded`/`setSource`: it applies the moment
// `item` exists (the Loader creates it synchronously, so still before
// the first render pass), it re-applies if the route flips
// mixtapes <-> collections while the same instance is mounted, and it
// keeps the whole router declarative. Same shape as the seeded-field
// pattern at controls/QbzLineEdit.qml:174-179.
//
// RestoreNone: the target is DESTROYED on unload, and the default
// RestoreBindingOrValue would try to write the old value back into a
// dying object.
//
// It applies in KIOSK too, and must: KioskMyQBZ serves the same two
// routes as one component (KioskShell.slint:512,517), so without the
// discriminator Collections would render the Mixtapes page there for
// exactly the same reason.
Binding {
target: viewLoader.item
property: "kind"
value: QbzShell.currentView === "collections" ? "collection" : "mixtape"
when: viewLoader.item !== null
&& (QbzShell.currentView === "mixtapes"
|| QbzShell.currentView === "collections")
restoreMode: Binding.RestoreNone
}
}
+623
View File
@@ -0,0 +1,623 @@
// KioskShell — the small-panel touch shell (QML port of
// crates/qbz-ui/ui/shell/KioskShell.slint, 748 lines).
//
// A SIBLING of AppShell, mounted by Main.qml when QbzSession.screen is
// "kiosk". It reads the SAME bridges as the desktop shell — nothing is
// mirrored — so the two swap live with nothing torn down. The only
// differences are chrome: a thin back bar + a bottom NavRail replace the
// HeaderBar + Sidebar, the transport is forced to NowPlayingBarSmall, and
// there is no queue/lyrics side panel.
//
// Structure, top to bottom (KioskShell.slint:229-747). The last three are
// overlays drawn on top of the layout, and the ordering is load-bearing:
// 1. Column: back bar (42) / content frame / transport (42, conditional) /
// NavRail (80) KioskShell.slint:229-659
// 2. the player-zone focus ring overlay :666-677
// 3. the root key handler + the deferred initial focus grab :684-735
// 4. the kiosk's OWN Immersive mount :741-747
//
// The view mounts live in the SHARED shell/ContentRouter.qml (contract §4.2,
// divergence D3) — the Slint's verbatim copy of AppShell's block declares
// that extraction as its own follow-up (KioskShell.slint:10-17).
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../kiosk"
import "../theme"
Rectangle {
id: root
// KioskShell.slint:58 sets NO background — every visible surface is
// painted by a child, and what shows through is the window's own fill
// (Main.qml:93, the same surface-card the back bar uses).
color: "transparent"
// The host ApplicationWindow. Main.qml:463 assigns this UNCONDITIONALLY
// on load, so the property must exist or the shell fails to mount
// (the desktop declaration is shell/AppShell.qml:59).
property var hostWindow: null
// The shell-root marker six components walk the parent chain for
// (immersive/ImmersiveView.qml:93-99 hands focus BACK to whatever carries
// it). Without it every hotkey after an immersive close is swallowed.
// `focus` + the explicit grab are AppShell.qml:69,78's hard-won pair:
// declarative focus alone did not survive the splash -> shell Loader swap
// in a WM-less session.
readonly property bool isQbzShellRoot: true
focus: true
Component.onCompleted: root.forceActiveFocus()
QbzTheme { id: theme }
// KioskShell.slint:204 — the NavRail is a fat touch target.
readonly property int navRailHeight: 80
// The chrome hover fill (Theme.alpha-8, KioskShell.slint:255,273). The
// ramp is empty on the pre-publish frame, where alphaTier() silently
// returns "transparent" — the tree's required guard idiom, whose reason
// is written out at rows/TrackRow.qml:264-269.
readonly property color chromeHoverBg: theme.alphaTiers.length > 0
? theme.alphaTier(8)
: (theme.isDark ? "#14ffffff" : "#14000000")
// ---------------------------------------------------------------------
// The offline gate (KioskShell.slint:209-227)
// ---------------------------------------------------------------------
// Qobuz-only routes mount only online; the local-DB routes (Local
// Library, local album, My QBZ, Settings, Now Playing, the manager
// views) keep mounting offline and are deliberately absent below.
//
// Two arms of the reference's predicate have no counterpart here:
// - musician / award / award-albums / purchases / purchase-album /
// location are ABSENT-BY-RULING (contract R1) — there is no Qt view
// and no Qt route id for them, so they can never be `currentView`.
// - PlaylistState.offline-subset (the D11.a "mixed Qobuz playlist
// rendered from its local sidecar rows" flag, state.slint:2183) has
// NO Qt member: playlist_qt.rs's PlaylistDoc carries isLocalPlaylist
// (:175) and offlineOnly (:181) and nothing else. The arm below is
// therefore the reference's minus that one disjunct.
readonly property var playlistDoc: root.parsePlaylistDoc()
function parsePlaylistDoc() {
try {
return JSON.parse(QbzBridge.playlistJson)
} catch (e) {
return ({})
}
}
readonly property bool qobuzViewBlocked: QbzSession.offline && (
QbzShell.currentView === "home"
|| QbzShell.currentView === "discoverbrowse"
|| QbzShell.currentView === "search"
|| QbzShell.currentView === "library"
|| QbzShell.currentView === "album"
|| QbzShell.currentView === "artist"
|| QbzShell.currentView === "artistreleases"
|| QbzShell.currentView === "label"
|| QbzShell.currentView === "labelreleases"
|| QbzShell.currentView === "mix"
|| (QbzShell.currentView === "playlist"
&& root.playlistDoc.isLocalPlaylist !== true))
// ---------------------------------------------------------------------
// NavRail routing (KioskShell.slint:637-657)
// ---------------------------------------------------------------------
// The Slint tiles fire `header-menu-navigate("discover-home")` etc., and
// the Rust handler behind that route both switches the view AND selects a
// landing tab (crates/qbz/src/main.rs:18017-18040). Qt has no such
// combined seam — QbzShell.navigateTo carries only the route — so the
// landing tab uses the port's existing pending-tab handshake
// (shell/NavFlyout.qml:263-291): remember the tab, wait for the route to
// land, stamp it on the mounted view. The view is reached through
// ContentRouter's public `currentItem` rather than NavFlyout's depth-
// capped tree walk, which the kiosk's deeper mount chain would outrun.
property string pendingTab: ""
property string pendingView: ""
function navigateWithTab(view, tab) {
if (QbzShell.currentView === view) {
// Already mounted — no currentViewChanged will fire.
root.applyTab(tab)
return
}
root.pendingTab = tab
root.pendingView = view
QbzShell.navigateTo(view)
}
// Duck-typed exactly like NavFlyout.qml:320-321: only the tabbed views
// expose `activeTab`, and a view without one is simply left alone.
function applyTab(tab) {
if (!tab || tab === "")
return
var mounted = contentLoader.item !== null ? contentLoader.item.currentItem : null
if (mounted !== null && typeof mounted.activeTab === "string")
mounted.activeTab = tab
}
Connections {
target: QbzShell
function onCurrentViewChanged() {
// KioskShell.slint:194-200 — every route change parks the focus
// ring on the new view's first item. nav_active is NOT reset: the
// latch is a session-level thing (kiosk_nav_qt.rs:233-242).
QbzKioskNav.resetForView()
if (root.pendingTab === "")
return
var wanted = root.pendingView
var tab = root.pendingTab
root.pendingTab = ""
root.pendingView = ""
// A navigation somewhere else beat us to it — drop the request
// instead of stamping a stale tab later.
if (QbzShell.currentView !== wanted)
return
// The router instantiates the view from its own binding on the
// same property; binding order between it and this handler is
// undefined, so apply after the current pass.
Qt.callLater(function () { root.applyTab(tab) })
}
}
// =====================================================================
// 1. The layout column (KioskShell.slint:229-659)
// =====================================================================
Column {
id: shellColumn
width: root.width
spacing: 0
// --- Back bar (KioskShell.slint:237-319) -------------------------
// Persistent global back/forward (ADR-004) + the kiosk search entry.
// The desktop search box lives in the HeaderBar, which the kiosk
// drops, so this field is what makes Search reachable at all.
Rectangle {
id: backBar
width: shellColumn.width
height: theme.headerHeight
color: theme.surfaceCard
// The kiosk carries no HeaderBar, so it owns the window chrome
// too (KioskShell.slint:240-245): macOS overlay leaves the native
// traffic-light inset, Linux frameless draws the controls.
//
// DIVERGENCE, reported: the reference reads
// AppearanceState.is-macos and .show-window-controls. Neither has
// a Qt bridge member (QbzShell exposes only systemTitleBar,
// shell_bridge.rs:111), and this port must not add Rust here. The
// platform half uses Qt's own Qt.platform.os; the pref half is
// dropped, which puts the kiosk on exactly the predicate the
// desktop chrome already uses (HeaderBar.qml:924).
readonly property real macInset:
(Qt.platform.os === "osx" && !QbzShell.systemTitleBar) ? 78 : 0
readonly property bool linChrome:
Qt.platform.os !== "osx" && !QbzShell.systemTitleBar
// padding-left 6 + macInset, padding-right 118 (controls) or 8,
// padding-top/bottom 4, spacing 6 -> a 34px content row.
// The 118 is not a round number: WindowControls is 34*3 + 2*2 =
// 106 wide, pinned 8px from the right edge, leaving 4px of
// clearance to the search field.
Row {
id: backBarRow
x: 6 + backBar.macInset
y: 4
width: backBar.width - (6 + backBar.macInset)
- (backBar.linChrome ? 118 : 8)
height: backBar.height - 8
spacing: 6
// KioskShell.slint:252-269. The disabled state is expressed
// THREE times — dimmed, un-hoverable and un-clickable; an
// opacity-only port leaves a clickable ghost.
Rectangle {
id: backBtn
width: 44
height: backBarRow.height
radius: theme.radiusSm
color: (backArea.containsMouse && QbzShell.canBack)
? root.chromeHoverBg : "transparent"
opacity: QbzShell.canBack ? 1.0 : 0.32
QbzIcon {
name: "chevron-left"
width: 22
height: 22
anchors.centerIn: backBtn
tintName: "secondary"
}
MouseArea {
id: backArea
anchors.fill: backBtn
enabled: QbzShell.canBack
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzShell.navigateBack()
}
}
// KioskShell.slint:270-287.
Rectangle {
id: fwdBtn
width: 44
height: backBarRow.height
radius: theme.radiusSm
color: (fwdArea.containsMouse && QbzShell.canForward)
? root.chromeHoverBg : "transparent"
opacity: QbzShell.canForward ? 1.0 : 0.32
QbzIcon {
name: "chevron-right"
width: 22
height: 22
anchors.centerIn: fwdBtn
tintName: "secondary"
}
MouseArea {
id: fwdArea
anchors.fill: fwdBtn
enabled: QbzShell.canForward
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzShell.navigateForward()
}
}
// KioskShell.slint:288-312. Submit-only: there is NO live /
// debounced search in the back bar and no cortinilla — do not
// add one.
QbzLineEdit {
id: searchField
width: backBarRow.width - backBtn.width - fwdBtn.width
- 2 * backBarRow.spacing
height: backBarRow.height
placeholder: QbzSession.tr("Search", QbzSession.trRev)
// The full accept sequence, KioskShell.slint:301-311.
// Rust's search_submit navigates to the results view
// itself (search_qt.rs:1971), which is the reference's
// explicit `NavState.view = ContentView.search` (:303).
// Slint then needs TWO more steps — clear-focus() so the
// OS on-screen keyboard closes (:307), then keys.focus()
// to hand the arrows back (:310). In Qt keyboard focus is
// exclusive, so grabbing it at the root does both at once:
// the inner TextInput loses activeFocus (text-input
// DISABLE -> squeekboard hides) and the nav scope has it.
onAccepted: function (value) {
QbzSearch.searchSubmit(value)
root.forceActiveFocus()
}
}
}
// Linux custom window controls at the right edge
// (KioskShell.slint:315-318). Inlined rather than extracted from
// HeaderBar.qml:923-999: a shared component would need a new file
// in build.rs, and touching HeaderBar risks the desktop
// non-regression guard (contract D3). Same three 34x26 buttons,
// same 2px spacing, same fixed #e81123 close hover.
Row {
id: windowControls
visible: backBar.linChrome
x: backBar.width - width - 8
y: (backBar.height - height) / 2
height: 26
spacing: 2
Rectangle {
id: wcMin
width: 34
height: 26
radius: theme.radiusSm
color: wcMinArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: "minus"
width: 14
height: 14
anchors.centerIn: wcMin
tintName: "secondary"
}
MouseArea {
id: wcMinArea
anchors.fill: wcMin
hoverEnabled: true
onClicked: if (root.hostWindow) root.hostWindow.showMinimized()
}
}
Rectangle {
id: wcMax
width: 34
height: 26
radius: theme.radiusSm
color: wcMaxArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
// The glyph swaps while maximized
// (WindowControls.slint:52-54).
name: root.hostWindow
&& root.hostWindow.visibility === Window.Maximized
? "minimize-2" : "maximize-2"
width: 14
height: 14
anchors.centerIn: wcMax
tintName: "secondary"
}
MouseArea {
id: wcMaxArea
anchors.fill: wcMax
hoverEnabled: true
onClicked: {
if (root.hostWindow) {
root.hostWindow.visibility =
root.hostWindow.visibility === Window.Maximized
? Window.Windowed : Window.Maximized
}
}
}
}
Rectangle {
id: wcClose
width: 34
height: 26
radius: theme.radiusSm
color: wcCloseArea.containsMouse ? "#e81123" : "transparent"
QbzIcon {
name: "x"
width: 14
height: 14
anchors.centerIn: wcClose
// The hover fill is a FIXED #e81123 under every
// theme, so the hovered glyph is literal white.
tintName: wcCloseArea.containsMouse ? "white" : "secondary"
}
MouseArea {
id: wcCloseArea
anchors.fill: wcClose
hoverEnabled: true
onClicked: Qt.quit()
}
}
}
}
// --- Content frame (KioskShell.slint:325-611) --------------------
// The outer surface-card frame takes the leftover height; the inner
// surface-main panel is inset 8px left/right/bottom (0 top — it butts
// the back bar) and takes an EXPLICIT height, because a
// conditionally-mounted view does not stretch to fill a layout cell.
//
// THE NOW-PLAYING EXCEPTION (:348-355): the transport row is hidden
// on that view, so its 42px must NOT be subtracted — otherwise the
// content stays short and the NavRail rides up, leaving dead space
// below the footer.
Rectangle {
id: contentFrame
width: shellColumn.width
height: root.height - theme.headerHeight
- (QbzShell.currentView === "nowplaying" ? 0 : theme.headerHeight)
- root.navRailHeight
color: theme.surfaceCard
// Tap anywhere in the content area to dismiss the OS on-screen
// keyboard (KioskShell.slint:328-339): releasing the field's
// focus makes Qt send text-input DISABLE and squeekboard closes.
// Declared FIRST so it sits BEHIND the views — later siblings get
// the event first, so this only catches empty-area taps.
MouseArea {
anchors.fill: contentFrame
onClicked: root.forceActiveFocus()
}
Rectangle {
id: contentPanel
x: 8
y: 0
width: contentFrame.width - 16
height: contentFrame.height - 8
color: theme.surfaceMain
radius: theme.radiusMd
clip: true
// The offline placeholder (KioskShell.slint:362-365). The
// reference's `favorites` variant nests a FavoritesOfflineRail
// inside it; that rail is ABSENT-BY-RULING (contract R1), so
// the plain placeholder is the correct kiosk rendering for
// every blocked route, favourites included.
QbzOfflinePlaceholder {
visible: root.qobuzViewBlocked
anchors.centerIn: contentPanel
showSettingsAction: true
onSettingsClicked: QbzShell.navigateTo("settings")
}
// The shared router, in its kiosk arm. `active` rather than
// `visible`: the reference UNMOUNTS the view while the gate is
// up (every Qobuz route carries its own `&& !offline` guard,
// :367-608), and an invisible view would keep running.
Loader {
id: contentLoader
anchors.fill: contentPanel
active: !root.qobuzViewBlocked
sourceComponent: kioskRouter
}
}
}
// --- The transport (KioskShell.slint:617-631) --------------------
// Forced to the small bar, exactly one header row tall. Hidden on Now
// Playing — that view carries the full transport, so the bar would be
// redundant there.
Loader {
id: transport
width: shellColumn.width
height: transport.active ? theme.headerHeight : 0
active: QbzShell.currentView !== "nowplaying"
visible: transport.active
sourceComponent: transportBar
}
// --- Bottom NavRail (KioskShell.slint:635-658) -------------------
NavRail {
id: navRail
width: shellColumn.width
height: root.navRailHeight
onDiscover: root.navigateWithTab("home", "home")
onLibrary: root.navigateWithTab("library", "tracks")
onLocalLibrary: root.navigateWithTab("local", "albums")
onMyqbz: QbzShell.navigateTo("mixtapes")
// The reference assigns NavState.view directly here (:650), so it
// records NO history entry. Qt's only view-switch path is
// navigateTo, which always records (shell_bridge.rs:645 ->
// main.rs:1682); the extra history entry is the accepted
// divergence rather than a second Rust entry point.
onPlaying: QbzShell.navigateTo("nowplaying")
// R2 (contract §0): the tile's only action in the reference is
// opening the Immersive overlay (:652-654), and the kiosk's
// Immersive mount is the Immersive task's deliverable (the Loader
// at the bottom of this file). Until that task lands the ACTION is
// a sanctioned no-op — the tile's pixels and its focus ring are
// NOT deferred. Wiring QbzImmersive here instead would be worse
// than nothing: `open` would go true over an empty mount and the
// key guard below would then reject every key against a surface
// that renders nothing.
onVisualizer: {
// Intentionally empty — see the note above.
}
onMenu: QbzShell.navigateTo("settings")
}
}
// =====================================================================
// 2. The player-zone focus ring (KioskShell.slint:666-677)
// =====================================================================
// An overlay in ROOT coordinates, outside the column, so it never
// reflows anything. The transport is ONE focusable unit; on Now Playing
// the bar is hidden and that view draws its own in-page ring instead.
Rectangle {
id: playerRing
visible: QbzShell.currentView !== "nowplaying"
&& QbzKioskNav.navActive
&& QbzKioskNav.zone === "player"
x: 0
y: root.height - root.navRailHeight - theme.headerHeight
width: root.width
height: theme.headerHeight
border.width: 3
border.color: theme.accent
radius: theme.radiusSm
color: Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.08)
}
// =====================================================================
// 3. Root key handling (KioskShell.slint:684-723)
// =====================================================================
// One scope drives the whole kiosk: arrows move the ring through the
// three zones (rail -> content -> player -> rail on Down, reversed on
// Up), Enter activates, Esc goes back. MouseAreas never take keyboard
// focus, so this survives every card/tile tap and only yields to the
// search field.
//
// Evaluation order is literal and must be preserved. The reference's two
// guards RETURN REJECT — the key keeps its normal path — and in Qt that
// path is the hotkeys dispatcher at the end of this handler, whose own
// central text-input gate (hotkeys_bridge.rs:295-299) is the counterpart
// of the first guard. The textInputFocused predicate is computed exactly
// as the desktop shell computes it (AppShell.qml:91).
Keys.onPressed: function (event) {
var w = root.Window.window
var afi = w !== null ? w.activeFocusItem : null
var textInputFocused = (afi instanceof TextInput) || (afi instanceof TextEdit)
if (!textInputFocused && !QbzImmersive.open) {
if (event.key === Qt.Key_Left) {
QbzKioskNav.navLeft()
event.accepted = true
return
}
if (event.key === Qt.Key_Right) {
QbzKioskNav.navRight()
event.accepted = true
return
}
if (event.key === Qt.Key_Up) {
QbzKioskNav.navUp()
event.accepted = true
return
}
if (event.key === Qt.Key_Down) {
QbzKioskNav.navDown()
event.accepted = true
return
}
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
// The three-way dispatch, KioskShell.slint:175-190. The rail
// is always mounted, so it is called directly; a content view
// is conditionally mounted and cannot be addressed from here,
// so its request goes out as the activate-seq pulse. Both the
// rail and the player arm still have to latch the ring (:176)
// — bumpActivate() does it for the content arm itself
// (kiosk_nav_qt.rs:222-225).
if (QbzKioskNav.zone === "player") {
QbzKioskNav.markActive()
if (QbzPlayer.npHasTrack)
QbzPlayer.togglePlay()
} else if (QbzKioskNav.zone === "rail") {
QbzKioskNav.markActive()
navRail.activateIndex(QbzKioskNav.index)
} else {
QbzKioskNav.bumpActivate()
}
event.accepted = true
return
}
// Escape only CONSUMES when there is history; otherwise it keeps
// its normal path (:714-720), which here is the dispatcher's own
// Escape stack.
if (event.key === Qt.Key_Escape && QbzShell.canBack) {
QbzShell.navigateBack()
event.accepted = true
return
}
}
event.accepted = QbzHotkeys.keyPressed(event.key, event.modifiers,
event.text, textInputFocused)
}
// Deferred mount focus (KioskShell.slint:728-735): synchronous focus
// during the initial layout races it, so a one-shot timer grabs it a tick
// later. The port already uses this exact 30ms idiom at
// controls/QbzLineEdit.qml:123-128.
Timer {
interval: 30
running: true
repeat: false
onTriggered: root.forceActiveFocus()
}
// =====================================================================
// 4. The kiosk's OWN Immersive mount (KioskShell.slint:737-747)
// =====================================================================
// The desktop hangs its copy inside AppShell, so the kiosk shell must
// mount its own; declared LAST = on top of everything.
//
// SANCTIONED EMPTY MOUNT (contract §4.6 / R2): the kiosk Immersive
// surface is the Immersive task's deliverable and this Loader is the seam
// it lands in. The key-rejection arm above (QbzImmersive.open) ships NOW
// so the two compose the moment this gets a sourceComponent.
Loader {
id: immersiveMount
anchors.fill: root
active: QbzImmersive.open
}
// The two conditionally-mounted children, hoisted out of the column so
// the positioner never sees a non-visual child.
Component {
id: kioskRouter
ContentRouter { kiosk: true }
}
Component {
id: transportBar
NowPlayingBarSmall { }
}
}
+62
View File
@@ -244,11 +244,33 @@ impl NavModel {
/// What a mounted view calls whenever its tab or its data changes — the /// What a mounted view calls whenever its tab or its data changes — the
/// Qt spelling of the `KioskNavState.tabs/columns/count/shelves = …` /// Qt spelling of the `KioskNavState.tabs/columns/count/shelves = …`
/// blocks each Kiosk view runs (e.g. `KioskLibrary.slint:67-86`). /// blocks each Kiosk view runs (e.g. `KioskLibrary.slint:67-86`).
///
/// Every one of those blocks ends in the same clamp
/// (`KioskLibrary.slint:86` and its seven siblings):
///
/// ```slint
/// KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
/// ```
///
/// It is not optional: without it, switching from a tab with 100 items to
/// one with 3 leaves `index` at 90, no item matches, and the focus ring
/// simply vanishes.
///
/// **D7 — the clamp is scoped to the content zone.** Slint applies it
/// unconditionally, so a publish that lands while the focus is on the RAIL
/// (7 tiles, indices 0-6) rewrites the rail's cursor to the content view's
/// `count - 1`. That is reachable in an ordinary session: park on the rail,
/// let an in-flight fetch resolve, and the highlighted tile jumps. `index`
/// is one field shared by three zones, so the clamp only makes sense for
/// the zone it was computed for.
pub fn publish(&mut self, tabs: i32, columns: i32, count: i32, shelves: bool) { pub fn publish(&mut self, tabs: i32, columns: i32, count: i32, shelves: bool) {
self.tabs = tabs.max(0); self.tabs = tabs.max(0);
self.columns = columns.max(1); self.columns = columns.max(1);
self.count = count.max(0); self.count = count.max(0);
self.shelves = shelves; self.shelves = shelves;
if self.zone == Zone::Content {
self.index = self.index.clamp(0, (self.count - 1).max(0));
}
} }
/// The shelf consumer's half of the `hmove` pulse /// The shelf consumer's half of the `hmove` pulse
@@ -491,6 +513,46 @@ mod tests {
assert!(m.nav_active); assert!(m.nav_active);
} }
#[test]
fn a_republish_clamps_the_index_into_the_new_data() {
let mut m = library(); // 5 tabs, 6 columns, 12 albums -> count 17
m.down();
for _ in 0..4 {
m.right();
}
assert_eq!(m.index, 9);
// The user switches to a tab with 3 items.
m.publish(5, 6, 5 + 3, false);
assert_eq!(m.index, 7, "clamped to count - 1, so an item still owns it");
assert!(m.index < m.count);
}
#[test]
fn a_publish_with_no_items_parks_on_the_first_tab() {
let mut m = library();
m.down(); // into the grid
m.publish(5, 6, 0, false);
assert_eq!(m.index, 0, "max(0, -1)");
}
/// D7. Slint clamps unconditionally, so a publish arriving while the focus
/// sits on the rail rewrites the RAIL's cursor to the content view's
/// `count - 1`.
#[test]
fn a_publish_never_disturbs_the_rail_cursor() {
let mut m = library();
m.up(); // -> rail
for _ in 0..3 {
m.right();
}
assert_eq!(m.zone, Zone::Rail);
assert_eq!(m.index, 3, "the MyQBZ tile");
// An in-flight fetch resolves and the mounted view republishes.
m.publish(5, 6, 5 + 1, false);
assert_eq!(m.index, 3, "still the MyQBZ tile");
assert_eq!(m.zone, Zone::Rail);
}
#[test] #[test]
fn a_zero_column_publish_cannot_panic() { fn a_zero_column_publish_cannot_panic() {
let mut m = NavModel::default(); let mut m = NavModel::default();
@@ -5012,3 +5012,14 @@ msgstr "Oberfläche"
msgid "Mini Player" msgid "Mini Player"
msgstr "Mini-Player" msgstr "Mini-Player"
# Kiosk shell (2026-08-02 kiosk-port contract)
msgid "Cover"
msgstr "Cover"
msgid "Radio"
msgstr "Radio"
msgid "Up Next"
msgstr "Als Nächstes"
@@ -5020,3 +5020,14 @@ msgstr "Interfaz"
msgid "Mini Player" msgid "Mini Player"
msgstr "Mini reproductor" msgstr "Mini reproductor"
# Kiosk shell (2026-08-02 kiosk-port contract)
msgid "Cover"
msgstr "Portada"
msgid "Radio"
msgstr "Radio"
msgid "Up Next"
msgstr "A continuación"
@@ -5012,3 +5012,14 @@ msgstr "Interface"
msgid "Mini Player" msgid "Mini Player"
msgstr "Mini-lecteur" msgstr "Mini-lecteur"
# Kiosk shell (2026-08-02 kiosk-port contract)
msgid "Cover"
msgstr "Pochette"
msgid "Radio"
msgstr "Radio"
msgid "Up Next"
msgstr "À suivre"
@@ -4997,3 +4997,14 @@ msgstr "インターフェース"
msgid "Mini Player" msgid "Mini Player"
msgstr "ミニプレーヤー" msgstr "ミニプレーヤー"
# Kiosk shell (2026-08-02 kiosk-port contract)
msgid "Cover"
msgstr "カバー"
msgid "Radio"
msgstr "ラジオ"
msgid "Up Next"
msgstr "次に再生"
@@ -5002,3 +5002,14 @@ msgstr "Interface"
msgid "Mini Player" msgid "Mini Player"
msgstr "Minispeler" msgstr "Minispeler"
# Kiosk shell (2026-08-02 kiosk-port contract)
msgid "Cover"
msgstr "Hoes"
msgid "Radio"
msgstr "Radio"
msgid "Up Next"
msgstr "Hierna"
@@ -5012,3 +5012,14 @@ msgstr "Interface"
msgid "Mini Player" msgid "Mini Player"
msgstr "Mini player" msgstr "Mini player"
# Kiosk shell (2026-08-02 kiosk-port contract)
msgid "Cover"
msgstr "Capa"
msgid "Radio"
msgstr "Rádio"
msgid "Up Next"
msgstr "A seguir"
@@ -5029,3 +5029,14 @@ msgstr "Интерфейс"
msgid "Mini Player" msgid "Mini Player"
msgstr "Мини-плеер" msgstr "Мини-плеер"
# Kiosk shell (2026-08-02 kiosk-port contract)
msgid "Cover"
msgstr "Обложка"
msgid "Radio"
msgstr "Радио"
msgid "Up Next"
msgstr "Далее"