Files
qbz/crates/qbz-qt/qml/kiosk/KioskLocalGenresTab.qml
T
vicrodh b1dba198c9 feat(qt): kiosk profile release hardening
Integrate the Kiosk profile hardening round: 12 dedicated Kiosk views plus
22 shared opt-in hosts, shared navigation history with Back/Forward, Albums
as the default library tab, native paged Local Library (artists/tracks),
lightweight MyQBZ grid and detail, Ephemeral lifecycle, bounded lists and
artwork sized to the physical frame, static skeletons, 64px transport bar,
Settings touch reflow and a persistent Immersive exit.

Desktop keeps its prior behavior: every shared component defaults to the
desktop path and only opts into Kiosk behavior through explicit properties
(origin-relative ScrollMemory, viewport-bounded artwork requests).

Regression coverage: kiosk navigation and artwork Node tests added to the
runtime gate, scroll-origin fixtures for fixed and grouped rows, i18n
strings for all eight locales.

Verified by 750 release tests, six QML audits, Kiosk smokes at 800x480 and
1280x800 and a Desktop A/B with seven pixel-identical captures.

cc
2026-09-07 08:23:11 -06:00

198 lines
7.3 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Local Library > Genres, kiosk body.
//
// The desktop tab is the Library Explorer: three or four chained facet columns
// (genre / year / artist / album) with their own search boxes. That is a
// pointer instrument and it does not survive 800×480.
//
// The kiosk keeps the FUNCTION — "show me this genre" — with a single facet: a
// bounded, horizontally scrolling chip rail over the genre names, and the
// matching albums in the same grid the other tabs use. Multi-facet chaining
// stays a Full UI capability; nothing is silently removed, because the album
// set a chip produces is the same set the desktop's first column produces.
//
// DOCUMENT. This is the ONE local surface that legitimately reads the legacy
// `localAlbumsJson`: `load_tab("genres")` explicitly routes to
// `load_albums_legacy()` precisely so the browser has a bounded logical-album
// document even while the Albums SURFACE is on the native model. So the
// stale-document rule the other tabs enforce does not apply here — this
// document is republished for this tab by design.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Item {
id: root
property var view: null
QbzTheme { id: theme }
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
readonly property var albums: root.view ? root.view.albums : []
/// "" is the explicit All state.
readonly property string selected: root.view ? root.view.selectedGenre : ""
/// The genre names present in the document, de-duplicated case-insensitively
/// and sorted. One O(n) pass per document change, never per frame.
readonly property var genres: {
var display = ({})
var rows = root.albums
for (var i = 0; i < rows.length; i++) {
var values = rows[i].genres || []
for (var j = 0; j < values.length; j++) {
var name = (values[j] || "").trim()
var key = name.toLowerCase()
if (name !== "" && display[key] === undefined)
display[key] = name
}
}
var out = []
for (var k in display)
out.push({ "key": k, "label": display[k] })
out.sort(function (a, b) { return a.label.localeCompare(b.label) })
return out
}
readonly property var matching: {
var key = root.selected
var rows = root.albums
if (key === "")
return rows
var out = []
for (var i = 0; i < rows.length; i++) {
var values = rows[i].genres || []
for (var j = 0; j < values.length; j++) {
if ((values[j] || "").toLowerCase() === key) {
out.push(rows[i])
break
}
}
}
return out
}
function pick(key) {
if (root.view)
root.view.selectGenre(root.selected === key ? "" : key)
}
// The focus ring reaches the RESULT grid; the chip rail is driven by tap
// and by the horizontal move the shell already owns.
function publishNav() {
if (root.view)
root.view.publishNav(Math.max(1, grid.columns), root.matching.length)
}
onMatchingChanged: root.publishNav()
Component.onCompleted: root.publishNav()
Connections {
target: grid
function onColumnsChanged() { root.publishNav() }
}
// ---------------------------------------------------------------------
// The chip rail. A ListView, not a Repeater: a large library reaches
// hundreds of genres and only the chips on screen may be mounted.
// ---------------------------------------------------------------------
ListView {
id: chips
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: root.view ? root.view.pad : 16
anchors.rightMargin: root.view ? root.view.pad : 16
height: 64
visible: root.genres.length > 0
orientation: ListView.Horizontal
clip: true
spacing: 10
cacheBuffer: 480
reuseItems: true
boundsBehavior: Flickable.StopAtBounds
// The "All" chip is index 0; the genres follow.
model: root.genres.length + 1
delegate: Rectangle {
id: chip
required property int index
readonly property bool isAll: chip.index === 0
readonly property var entry: chip.isAll ? null : root.genres[chip.index - 1]
readonly property string key: chip.isAll ? "" : (chip.entry ? chip.entry.key : "")
readonly property string label: chip.isAll
? root.t("All") : (chip.entry ? chip.entry.label : "")
readonly property bool on: root.selected === chip.key
width: Math.max(88, chipText.implicitWidth + 32)
height: 48
// A horizontal ListView owns `x` and leaves `y` to the delegate.
y: Math.max(0, (chips.height - chip.height) / 2)
radius: 24
color: chip.on
? Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.18)
: theme.surfaceElevated
border.width: chip.on ? 2 : 1
border.color: chip.on ? theme.accent : theme.borderSubtle
Text {
id: chipText
anchors.centerIn: parent
text: chip.label
color: chip.on ? theme.textPrimary : theme.textSecondary
font.pixelSize: 15
font.weight: theme.weightSemibold
}
MouseArea {
anchors.fill: parent
// The rail is 64px tall and the chip 48; the negative margin
// spends the remaining band on the touch target.
anchors.topMargin: -8
anchors.bottomMargin: -8
onClicked: root.pick(chip.key)
}
}
}
KioskLocalAlbumGrid {
id: grid
anchors.left: parent.left
anchors.right: parent.right
anchors.top: chips.visible ? chips.bottom : parent.top
anchors.bottom: parent.bottom
visible: !QbzLocal.localAlbumsLoading && root.matching.length > 0
view: root.view
surface: "genres"
scrollScope: "local:genres"
pad: root.view ? root.view.pad : 16
nativeActive: false
rows: root.matching
onOpen: function (id) {
if (root.view)
root.view.openAlbum(id)
}
}
KioskSkeleton {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: chips.visible ? chips.bottom : parent.top
anchors.bottom: parent.bottom
kind: "grid"
columns: Math.max(2, grid.columns)
pad: grid.pad
gap: grid.gap
loading: QbzLocal.localAlbumsLoading
error: QbzLocal.localAlbumsError
empty: !QbzLocal.localAlbumsLoading && QbzLocal.localAlbumsError === ""
&& root.matching.length === 0
emptyText: root.albums.length === 0
? root.t("No albums in your local library yet.")
: root.t("No albums match your search.")
}
// NO ScrollMemory on the chip rail: the shared component restores
// `contentY`, and this rail scrolls on X. The grid below owns the tab's
// scroll scope, which is the offset a Back/Forward has to bring back.
}