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
228 lines
13 KiB
QML
228 lines
13 KiB
QML
// KioskArtwork — THE image primitive of the kiosk shell.
|
|
//
|
|
// Kiosk-only by construction: theme/RoundedImage.qml is the DESKTOP artwork
|
|
// path and is not touched by this file, by any kiosk card, or by anything in
|
|
// this directory. Both can live in the same process because they share the one
|
|
// thing that matters — QQuickPixmapCache and the Rust artwork cache on disk.
|
|
//
|
|
// ── WHY A SECOND IMAGE COMPONENT AT ALL ───────────────────────────────────
|
|
// RoundedImage cannot request a decode size. It says so itself, twice, and for
|
|
// a good reason on the desktop: `sourceSize` is part of the QQuickPixmapCache
|
|
// key, so asking for one there would FORK a second decode of the file its Rust
|
|
// derivative pipeline has already sized. That pipeline is what makes the
|
|
// desktop correct, and it works like this:
|
|
//
|
|
// 1. mount a hidden probe Image on the ORIGINAL to learn its intrinsic size,
|
|
// 2. wait for that probe to reach Image.Ready — i.e. DECODE THE ORIGINAL,
|
|
// 3. ask Rust for a derivative at the drawn device size,
|
|
// 4. swap `source` to the derivative when it lands.
|
|
//
|
|
// Step 2 is the whole problem on a 7" panel. Contract §5.2 is explicit that
|
|
// "usa RoundedImage" does not close the artwork gate precisely because the
|
|
// cold-cache path can download and decode a 600px original into a 96px cell
|
|
// before the correctly-sized file is even requested. On a Pi that is the
|
|
// difference between a grid that settles and one that swaps textures for
|
|
// seconds. Kiosk therefore does the opposite trade: ONE Image, `sourceSize`
|
|
// pinned to the physical box, no probe, no derivative round-trip, no second
|
|
// decode to fork because there is no first one.
|
|
//
|
|
// ── WHAT THIS COMPONENT WILL NOT DO ───────────────────────────────────────
|
|
// - It has NO network pipeline. It never downloads through a path Qt's own
|
|
// Image would not already have taken, never writes a cache, and never
|
|
// calls QbzSession.artScaled / artScaledCached. When the host hands it a
|
|
// resolved local cache path — which is what every kiosk host does today
|
|
// (KioskLocalLibrary.artPathOf, KioskArtist.coverMap, home `artPath`) —
|
|
// that path is used verbatim.
|
|
// - It mounts NO probe Image, so a cell costs one pixmap, not two.
|
|
// - It runs NO animation. No fade, no Behavior, no Timer. Contract §3.3: a
|
|
// dirty item presents the WHOLE window, and the kiosk profile is
|
|
// reduce-motion by default. Art appears.
|
|
//
|
|
// ── PROVIDER BUCKETS (`source` is a url) ──────────────────────────────────
|
|
// Qobuz serves one cover at fixed edges — 50/100/150/230/300/600/max/org — as
|
|
// a `_<edge>.jpg` token on the last path segment. `qbz-source`'s
|
|
// `QobuzSource::small_url` (sources/qobuz.rs:170-187) is the authority on that
|
|
// shape and this file mirrors its token list exactly, including `_org`/`_max`.
|
|
//
|
|
// Cache requests and direct image inputs share assets/kiosk-art.js. It picks
|
|
// the smallest available bucket covering the physical frame, including when
|
|
// an incoming remote thumbnail was too small. Local cache files are unchanged.
|
|
// resolvedSource/requestedPx expose the budgets for runtime instrumentation.
|
|
//
|
|
// ── DECODE SIZE ───────────────────────────────────────────────────────────
|
|
// `sourceSize` is a square whose edge is the LONGER logical side times the
|
|
// screen's device pixel ratio, rounded up to a multiple of 8.
|
|
//
|
|
// square, and that is EXACT for both fits without measuring the source,
|
|
// because QQuickImage picks the aspect policy from `fillMode`: a
|
|
// PreserveAspectCrop image expands the decode until the box is COVERED,
|
|
// a PreserveAspectFit one fits it inside. Measured on this Qt build with
|
|
// an 800x380 banner in a 128 px crop cell: the decode came back 269x128,
|
|
// i.e. covering, not 128x61. A square 600x600 cover in a 96 px cell came
|
|
// back 96x96. Non-square crops are an explicit aspect-ratio exception:
|
|
// 269px exceeds the 256px two-times edge budget by 13px, the minimum
|
|
// width needed to cover 128px vertically without distorting this banner.
|
|
// quantised to 8, because `sourceSize` is part of the pixmap-cache key: a
|
|
// cell whose width jitters by a pixel during layout would otherwise
|
|
// re-request — and re-decode — on every jitter. Quantising also makes
|
|
// neighbouring cells of nearly equal size SHARE one cache entry.
|
|
//
|
|
// ── ROUNDING ──────────────────────────────────────────────────────────────
|
|
// Same fast arm RoundedImage documents and measured on this Qt build: one
|
|
// `layer.enabled` Image + a MultiEffect masked by a rounded Rectangle, with
|
|
// maskThresholdMin 0.5 / maskSpreadAtMin 1.0 — the only pair that maps mask
|
|
// alpha monotonically onto 0..1 (RoundedImage.qml:592-620 carries the fringe
|
|
// counts; do not "restore the defaults").
|
|
//
|
|
// There is deliberately NO canvas arm. On the software/Null renderer, where
|
|
// shaders do not exist, this degrades to an UNROUNDED image inside the host's
|
|
// own rounded tile rather than paying a per-cell CPU raster. A kiosk on the
|
|
// software path is already the worst case; a Canvas per cell is what would
|
|
// make it unusable, and the offscreen harness is the main consumer of that
|
|
// path.
|
|
//
|
|
// ── READINESS ─────────────────────────────────────────────────────────────
|
|
// `ready` is the same contract RoundedImage publishes, so a host can gate a
|
|
// KioskSkeleton on it: true only when the CURRENT source is decoded, MONOTONE
|
|
// within one source, cleared only by `onSourceChanged` so a recycled GridView
|
|
// delegate cannot inherit the previous cell's readiness.
|
|
|
|
import QtQuick
|
|
import QtQuick.Window
|
|
import "../assets/kiosk-art.js" as ArtPolicy
|
|
import QtQuick.Effects
|
|
|
|
Item {
|
|
id: root
|
|
|
|
/// A local cache path, or a remote url. Empty draws nothing and leaves the
|
|
/// host's placeholder tile showing.
|
|
property string source: ""
|
|
/// Corner radius in logical px. `Math.min(w, h) / 2` is a full circle
|
|
/// (KioskArtistCard's avatar). 0 takes neither the layer nor the mask.
|
|
property real radius: 8
|
|
/// "crop" PreserveAspectCrop — covers and avatars, the default.
|
|
/// "contain" PreserveAspectFit — wordmarks and banners.
|
|
property string fit: "crop"
|
|
/// THE handover signal. True only when the current `source` is decoded.
|
|
readonly property bool ready: root.source !== "" && root._imgReady
|
|
/// True once the current source failed. Hosts that show an error tile read
|
|
/// this; it is cleared by `onSourceChanged` like `_imgReady`.
|
|
readonly property bool failed: root.source !== "" && root._imgFailed
|
|
|
|
/// ── EVIDENCE HOOKS (contract §5.2) ────────────────────────────────────
|
|
/// The square decode edge actually requested, in DEVICE pixels, and the
|
|
/// url/path actually handed to the Image after the bucket rewrite. Both
|
|
/// are readable from a harness so the artwork budget can be asserted
|
|
/// rather than asserted-about.
|
|
readonly property int requestedPx: root._px
|
|
readonly property string resolvedSource: root._resolved
|
|
|
|
// ── decode size ────────────────────────────────────────────────────────
|
|
/// Device pixels per logical pixel for the screen this item is on. Held as
|
|
/// a property so a window moved between screens of different scales
|
|
/// re-requests, exactly as RoundedImage does.
|
|
readonly property real _dpr: Screen.devicePixelRatio > 0 ? Screen.devicePixelRatio : 1
|
|
/// The longer logical side is what a crop fit has to cover.
|
|
readonly property real _edge: Math.max(root.width, root.height)
|
|
/// Quantised to 8 so layout jitter cannot re-key the pixmap cache.
|
|
readonly property int _px: root._edge > 0 && root._dpr > 0
|
|
? Math.ceil(root._edge * root._dpr / 8) * 8
|
|
: 0
|
|
|
|
// Shared with the Rust cache-request adapter; one provider-size policy.
|
|
function bucketFor(px) { return ArtPolicy.bucketFor(px) }
|
|
readonly property string _resolved: ArtPolicy.sizedUrl(root.source, root._px)
|
|
|
|
// ── readiness latches ──────────────────────────────────────────────────
|
|
// LATCH, do not mirror: `status` is not monotone (a cache eviction or a
|
|
// re-layout can send it back to Loading), and a mirrored flag would
|
|
// un-retire a host's skeleton over art that is already on screen — the
|
|
// exact regression RoundedImage.qml:555-567 documents.
|
|
property bool _imgReady: false
|
|
property bool _imgFailed: false
|
|
onSourceChanged: {
|
|
root._imgReady = false
|
|
root._imgFailed = false
|
|
}
|
|
|
|
// ── arms ───────────────────────────────────────────────────────────────
|
|
/// Testing Software/Null NEGATIVELY is the correct test under the RHI: an
|
|
/// OpenGL backend reports GraphicsInfo.OpenGL and the `*Rhi` values are
|
|
/// Qt5-era aliases. `api` notifies, so this is a live binding and an item
|
|
/// that has no window yet (Unknown) takes the masked arm.
|
|
readonly property bool _noShaders: GraphicsInfo.api === GraphicsInfo.Software
|
|
|| GraphicsInfo.api === GraphicsInfo.Null
|
|
readonly property bool _masked: root.radius > 0 && !root._noShaders
|
|
readonly property bool _contains: root.fit === "contain"
|
|
|
|
/// PreserveAspectCrop overflows the item. The layer confines it (the layer
|
|
/// texture IS the item rect); with no layer and a crop fit nothing does, so
|
|
/// scissor exactly in that one case — not unconditionally, which would put
|
|
/// a scissor under every masked cover for nothing.
|
|
clip: !root._masked && !root._contains
|
|
|
|
/// The rounded shape, rendered to its own FBO once — it changes only with
|
|
/// width/height/radius, never per frame. `visible: false` + layered is the
|
|
/// documented MultiEffect mask idiom: the layer renders regardless of
|
|
/// visibility, which is the point.
|
|
Item {
|
|
id: maskSrc
|
|
anchors.fill: parent
|
|
visible: false
|
|
layer.enabled: root._masked
|
|
layer.smooth: true
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
radius: root.radius // Rectangle clamps internally
|
|
color: "#ffffff"
|
|
}
|
|
}
|
|
|
|
Image {
|
|
id: img
|
|
anchors.fill: parent
|
|
source: root._resolved
|
|
// THE decode-size request. Square (see the header) and zero-guarded:
|
|
// Qt reads sourceSize 0 as "intrinsic", which is precisely what must
|
|
// not happen, so an item with no geometry yet shows nothing rather
|
|
// than decoding an original.
|
|
sourceSize.width: root._px
|
|
sourceSize.height: root._px
|
|
visible: root._px > 0
|
|
fillMode: root._contains ? Image.PreserveAspectFit : Image.PreserveAspectCrop
|
|
asynchronous: true
|
|
cache: true
|
|
smooth: true
|
|
mipmap: false
|
|
// Qt >= 6.8. Keeps the PIXELS while a size change reloads, so a cell
|
|
// does not blank for the frames the new decode takes.
|
|
retainWhileLoading: true
|
|
|
|
// Called from onStatusChanged AND from Component.onCompleted: an
|
|
// already-cached pixmap can reach Image.Ready DURING the evaluation of
|
|
// the `source` binding, i.e. before the handler is connected, and a
|
|
// missed latch would leave `ready` false forever.
|
|
function _sync() {
|
|
if (status === Image.Ready)
|
|
root._imgReady = true
|
|
if (status === Image.Error)
|
|
root._imgFailed = true
|
|
}
|
|
onStatusChanged: img._sync()
|
|
Component.onCompleted: img._sync()
|
|
|
|
layer.enabled: root._masked
|
|
layer.smooth: true
|
|
layer.effect: MultiEffect {
|
|
maskEnabled: true
|
|
maskSource: maskSrc
|
|
// 0.5 / 1.0 were MEASURED on this Qt build, not reasoned — see
|
|
// theme/RoundedImage.qml:592-620 for the fringe-pixel counts of
|
|
// every alternative. Do not "restore the defaults".
|
|
maskThresholdMin: 0.5
|
|
maskSpreadAtMin: 1.0
|
|
}
|
|
}
|
|
}
|