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

410 lines
15 KiB
QML

// Local Library > the open session ("Open" — a folder outside the index, a CD,
// a SACD image), kiosk body.
//
// WHY NOT views/local/LocalEphemeralPane.qml. The desktop pane was evaluated
// for reuse first, per the hardening brief, and it does not fit:
// - it nests an unbounded `Repeater` over the session's albums inside which
// a second unbounded `Repeater` mounts a desktop `TrackRow` per track. The
// owner's own reference case is a 247-track box set, so the pane mounts
// 247 desktop rows — each with a menu, a favourite, a download affordance
// and a drag handler — to show six;
// - it is laid out at desktop density (a 224px header cover, 32px page
// inset, hover tooltips) and its only Close is a 28px circle;
// - it duck-types a `view` that must answer `artPathOf`, `artWanted`,
// `skelPhase`, `artSettleMs`, `artPulse`, `reportEphemeralWindow` and
// `releaseWindow` — the desktop LocalLibraryView's own private surface.
// So this is the kiosk presentation of the SAME document and the SAME actions
// (`ephemeralPlayAll`, `ephemeralPlayTrack`, `ephemeralClear`): one windowing
// ListView over the session flattened to album bands + 64px track rows.
//
// LIFECYCLE is the desktop's, unchanged: closing the session drops this tab
// and the host falls back to Albums, and a closed session is never resurrected
// by Back/Forward (the host refuses a restored `ephemeral` tab when no session
// is active).
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../theme"
Item {
id: root
property var view: null
QbzTheme { id: theme }
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
readonly property var session: root.view ? root.view.ephemeral : null
readonly property var albums: root.session && root.session.albums
? root.session.albums : []
readonly property bool multiAlbum: root.session
&& root.session.multiAlbum === true
readonly property int trackTotal: root.session && root.session.trackCount
? root.session.trackCount : 0
/// The session flattened to ONE list: an album band per block (only when
/// the session holds more than one), then that block's tracks. Rebuilt
/// once per document, never per frame.
readonly property var entries: {
var out = []
var blocks = root.albums
for (var i = 0; i < blocks.length; i++) {
var block = blocks[i]
if (root.multiAlbum)
out.push({ "t": 0, "block": block })
var tracks = block.tracks || []
for (var j = 0; j < tracks.length; j++)
out.push({ "t": 1, "track": tracks[j], "block": block })
}
return out
}
readonly property real rowH: 64
readonly property real bandH: 56
readonly property real pad: root.view ? root.view.pad : 16
function publishNav() {
if (root.view)
root.view.publishNav(1, root.entries.length)
}
onEntriesChanged: root.publishNav()
// ---------------------------------------------------------------------
// Artwork follows the visible entry band plus one row on either side.
// A multi-album folder can be arbitrarily large; its whole cover set must
// never be requested merely because the session opened.
// ---------------------------------------------------------------------
function report() {
if (!root.view || !list) return
if (!root.visible || root.albums.length === 0) {
root.view.releaseWindow("ephemeral")
return
}
var resident = [], seen = ({})
function keep(album) {
if (album && album.artKey && !seen[album.artKey]) {
seen[album.artKey] = true
resident.push(album)
}
}
if (list.contentY < 0) keep(root.albums[0])
var first = list.indexAt(4, list.contentY + 1)
var last = list.indexAt(4, list.contentY + Math.max(1, list.height) - 1)
if (first < 0) first = 0
if (last < 0) last = first + Math.ceil(list.height / root.rowH)
first = Math.max(0, first - 1)
last = Math.min(root.entries.length - 1, last + 1)
for (var i = first; i <= last; i++) keep(root.entries[i].block)
root.view.queueWindowReport(resident, 0, resident.length - 1, "ephemeral")
}
onAlbumsChanged: root.report()
onVisibleChanged: root.report()
Component.onCompleted: {
root.report()
root.publishNav()
}
Component.onDestruction: if (root.view) root.view.releaseWindow("ephemeral")
Connections {
target: root.view
function onArtworkRefresh() { root.report() }
}
function coverOf(block) {
if (!block || !root.view)
return ""
return root.view.artPathOf(block.artKey)
}
// ---------------------------------------------------------------------
// Focus. The index space is the flattened entries; a band does nothing on
// Enter, exactly as the A-Z bands in the Artists list do.
// ---------------------------------------------------------------------
readonly property int focusedItem: QbzKioskNav.index - QbzKioskNav.tabs
readonly property bool itemFocused: QbzKioskNav.navActive
&& QbzKioskNav.zone === "content"
&& root.focusedItem >= 0
&& root.focusedItem < root.entries.length
Connections {
target: QbzKioskNav
function onIndexChanged() {
if (root.itemFocused)
Qt.callLater(function () {
list.positionViewAtIndex(root.focusedItem, ListView.Contain)
})
}
function onActivateSeqChanged() {
if (!root.itemFocused)
return
var entry = root.entries[root.focusedItem]
if (entry && entry.t === 1 && entry.track)
QbzLocal.ephemeralPlayTrack(entry.track.id)
}
}
// =====================================================================
// Header — the session, and the three actions it has.
// =====================================================================
// =====================================================================
// The session's tracks
// =====================================================================
ListView {
id: list
header: Item {
id: header
width: list.width
// 8 + a 64px identity row + 12 + a 64px action row + 8. Written out
// because both rows are fixed-height touch targets and the block must
// not be able to overlap itself at 480px.
height: 156
Row {
id: identity
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: 8
height: 64
spacing: 12
Rectangle {
id: cover
width: 64
height: 64
radius: theme.radiusSm
color: theme.surfaceElevated
clip: true
KioskArtwork {
anchors.fill: parent
radius: theme.radiusSm
fit: "crop"
source: root.albums.length > 0 ? root.coverOf(root.albums[0]) : ""
}
}
Column {
width: parent.width - 64 - 12
spacing: 4
Text {
width: parent.width
text: QbzLocal.localEphemeralLabel
color: theme.textPrimary
font.pixelSize: 18
font.weight: theme.weightBold
elide: Text.ElideRight
maximumLineCount: 1
}
Text {
width: parent.width
text: root.trackTotal + " " + root.t("tracks")
color: theme.textMuted
font.pixelSize: 13
elide: Text.ElideRight
}
}
}
// Play / Shuffle / Close. 64px tall, the contract's primary target,
// and Close is deliberately the last one in the row rather than
// between the two play actions.
Row {
anchors.left: parent.left
anchors.top: identity.bottom
anchors.topMargin: 12
height: 64
spacing: 10
Repeater {
model: [
{ "id": "play", "label": root.t("Play all") },
{ "id": "shuffle", "label": root.t("Shuffle") },
{ "id": "close", "label": root.t("Close") }
]
delegate: Rectangle {
id: action
required property var modelData
width: Math.max(96, actionText.implicitWidth + 36)
height: 64
radius: theme.radiusSm
color: action.modelData.id === "play"
? Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.18)
: theme.surfaceElevated
border.width: 1
border.color: action.modelData.id === "play"
? theme.accent : theme.borderSubtle
Text {
id: actionText
anchors.centerIn: parent
text: action.modelData.label
color: theme.textPrimary
font.pixelSize: 15
font.weight: theme.weightSemibold
}
MouseArea {
anchors.fill: parent
onClicked: {
if (action.modelData.id === "play")
QbzLocal.ephemeralPlayAll(false)
else if (action.modelData.id === "shuffle")
QbzLocal.ephemeralPlayAll(true)
else
QbzLocal.ephemeralClear()
}
}
}
}
}
}
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.topMargin: 8
anchors.leftMargin: root.pad
anchors.rightMargin: root.pad
visible: !QbzLocal.localEphemeralLoading && root.entries.length > 0
clip: true
topMargin: 4
bottomMargin: 8
cacheBuffer: Math.min(root.rowH, Math.max(0, list.height / 2))
boundsBehavior: Flickable.StopAtBounds
reuseItems: true
model: root.entries
onContentYChanged: root.report()
onCountChanged: Qt.callLater(root.report)
onHeightChanged: root.report()
delegate: Item {
id: entrySlot
required property var modelData
required property int index
readonly property bool band: entrySlot.modelData
&& entrySlot.modelData.t === 0
width: list.width
height: entrySlot.band ? root.bandH : root.rowH
// Album band — only present in a multi-album session.
Row {
anchors.fill: parent
anchors.topMargin: 8
visible: entrySlot.band
spacing: 10
Rectangle {
width: 40
height: 40
radius: theme.radiusSm
color: theme.surfaceElevated
clip: true
KioskArtwork {
anchors.fill: parent
radius: theme.radiusSm
fit: "crop"
source: entrySlot.band
? root.coverOf(entrySlot.modelData.block) : ""
}
}
Column {
width: parent.width - 40 - 10 - 56
spacing: 1
Text {
width: parent.width
text: entrySlot.band && entrySlot.modelData.block
? (entrySlot.modelData.block.title || "") : ""
color: theme.textPrimary
font.pixelSize: 14
font.weight: theme.weightSemibold
elide: Text.ElideRight
maximumLineCount: 1
}
Text {
width: parent.width
text: entrySlot.band && entrySlot.modelData.block
? (entrySlot.modelData.block.artist || "") : ""
color: theme.textMuted
font.pixelSize: 12
elide: Text.ElideRight
maximumLineCount: 1
}
}
// Per-album Play, which only a multi-album session offers.
Rectangle {
width: 48
height: 40
radius: theme.radiusSm
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
QbzIcon {
anchors.centerIn: parent
name: "play-fill"
width: 18
height: 18
tintName: "textPrimary"
}
MouseArea {
anchors.fill: parent
anchors.margins: -8
onClicked: {
if (entrySlot.band && entrySlot.modelData.block)
QbzLocal.ephemeralPlayAlbum(
entrySlot.modelData.block.groupKey)
}
}
}
}
KioskTrackRow {
width: parent.width
height: root.rowH
visible: !entrySlot.band
track: entrySlot.band || !entrySlot.modelData
? ({})
: ({
"id": entrySlot.modelData.track.id,
"title": entrySlot.modelData.track.title || "",
"artist": entrySlot.modelData.track.artist || "",
"duration": entrySlot.modelData.track.duration || "",
"artwork": ""
})
navFocused: root.itemFocused && root.focusedItem === entrySlot.index
onClicked: {
if (!entrySlot.band)
QbzLocal.ephemeralPlayTrack(entrySlot.modelData.track.id)
}
}
}
}
ScrollMemory { target: list; scope: "local:ephemeral" }
KioskSkeleton {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
kind: "list"
rowHeight: root.rowH
rowArtSize: 46
pad: root.pad
loading: QbzLocal.localEphemeralLoading
empty: !QbzLocal.localEphemeralLoading && root.entries.length === 0
emptyText: root.t("No results")
}
}