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:
25 files changed
+6613
-82
No files matched your search
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 : "")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 || "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 "Далее"
|
||||||
Reference in new issue
Block a user