Accumulated qt-frontend work from the 2026-08-11/12 sessions (cc), uncommitted until now: the app-wide dynamic background in both modes (AmbientField with GPU/Canvas arms, AppShell layering, ambient palette pipeline), the wave-1 clip/batch sweep across the tree (alpha batches 449 -> 327, reproducible), the rounded-cover and icon-tint arms, and scripts/qt-batches.sh (batches + presents/s sampler). The GPU behaviour of these pieces was finalized in the follow-up pulse commits; see qbz-nix-docs/qt-frontend/2026-08-11-scenegraph-batches/ GPU-COST-INVESTIGATION.md for the full campaign. km
328 lines
14 KiB
QML
328 lines
14 KiB
QML
// PlaylistCollage — the playlist cover mosaic (discover/PlaylistCollage.slint,
|
||
// itself the port of Tauri's PlaylistCollage.svelte).
|
||
//
|
||
// Qobuz hands back up to four MEMBER-ALBUM covers per playlist
|
||
// (images300 / images150 / images); the layout is picked by how many there
|
||
// are — single, dual (side by side), triple (one large left + two stacked
|
||
// right), quad (three stacked left + one large right), and a list-music
|
||
// glyph when the playlist has none. Tiles are always crop-fitted; the
|
||
// playlist's OWN Qobuz graphic is NOT drawn here (PlaylistCard and
|
||
// PlaylistView render that one through RoundedImage's `pad` fit).
|
||
//
|
||
// The crop is deliberate and stays: these tiles are member-ALBUM covers,
|
||
// which Qobuz ships SQUARE, and from two tiles up the cells are not square
|
||
// anyway — a mosaic cell is meant to be filled edge to edge, so letterboxing
|
||
// one would put gaps inside the grid. The image-derived padding added for
|
||
// the 800x380 playlist banner therefore has no case to answer here, and
|
||
// duplicating it into this file would only fork the one implementation.
|
||
//
|
||
// ── HOW THE TILES ARE DRAWN (this file used to be a Canvas) ────────────────
|
||
// Each tile is a `RoundedImage { radius: 0; fit: "crop" }`, i.e. a plain
|
||
// filtered `Image` that requests its own scaled derivative at its own device
|
||
// size through the existing `QbzSession.artScaled` seam. The previous body was
|
||
// a per-card `Canvas` whose `Context2D.drawImage` does NOT filter when it
|
||
// scales, so a 600px member cover in a ~100px mosaic cell was EXACTLY
|
||
// nearest-neighbour (measured RMSE 0.000 against `-filter Point`) — the
|
||
// artwork fix that reached RoundedImage never reached this file. Reuse over a
|
||
// second copy: RoundedImage already owns the intrinsic-size latch, the
|
||
// derivative request, the crop fit, the readiness latch and the
|
||
// recycled-delegate invalidation.
|
||
//
|
||
// Only the collage's OUTER shape is rounded; the tiles are square-cornered.
|
||
// That rounding is one `layer.enabled` + one `MultiEffect` mask on the mosaic
|
||
// (NOT one per tile), the same mechanism theme/RoundedImage.qml documents.
|
||
// Effects need shaders: this port runs on the GPU (OpenGL RHI, measured
|
||
// 2026-07-29), and the earlier "shader masks render nothing" note in this
|
||
// header came from an offscreen session, which forces the software renderer by
|
||
// definition. Where a software path is genuinely possible it is DETECTED
|
||
// (`GraphicsInfo.api`) — there the mask is skipped and the mosaic reads
|
||
// square-cornered over the already-rounded placeholder fill, rather than
|
||
// blank.
|
||
//
|
||
// PARITY NOTE (pre-existing gap, not introduced here): Slint's `n == 1` arm
|
||
// (PlaylistCollage.slint:39-49) contain-fits the single cover over a
|
||
// `playlist.dominant-color` letterbox; this port crops it, because the Qt feed
|
||
// publishes no `dominantColor` field. Logged for the UX pass — a perf fix must
|
||
// not invent a producer field.
|
||
//
|
||
// Artwork is URL-keyed, not artKey-keyed (a playlist row has ONE artKey but
|
||
// up to four covers): the component hands its urls to the shared url-keyed
|
||
// resolver — `QbzShell.sidebarArtworkWindow`, the same entry point the
|
||
// sidebar tree's micro-collage uses — and listens for the
|
||
// `libraryArtworkReady` echo. Disk hits come back synchronously; misses are
|
||
// downloaded once and cached. The dispatch is debounced so flinging through
|
||
// a windowed grid never fetches covers for delegates that are already gone.
|
||
|
||
import QtQuick
|
||
import QtQuick.Effects
|
||
import com.blitzfc.qbz
|
||
import "../theme"
|
||
|
||
Item {
|
||
id: root
|
||
|
||
// Remote cover urls (feed row `covers`); only the first four distinct
|
||
// entries are drawn.
|
||
property var urls: []
|
||
property real radius: 8
|
||
// Mosaic seam, 1:1 with PlaylistCollage.slint.
|
||
property real gap: 2
|
||
|
||
/// `"mosaic"` (discover/PlaylistCollage.slint — the default everywhere) or
|
||
/// `"pm"` (primitives/PmCollage.slint, the Playlist Manager's thumbnail).
|
||
///
|
||
/// They are genuinely different layout FUNCTIONS, which is the whole delta:
|
||
/// the mosaic splits 2 and 3 covers into cells, while the manager collapses
|
||
/// anything under four to ONE full-bleed cover and only splits at four, with
|
||
/// a 1 px seam instead of 2. Everything else — the url → path resolution
|
||
/// through QbzShell.sidebarArtworkWindow + QbzLibrary.libraryArtworkReady,
|
||
/// the 120 ms debounce, the MultiEffect rounding with the measured
|
||
/// maskThresholdMin 0.5 / maskSpreadAtMin 1.0, the three-way `_noShaders`
|
||
/// test and the recycled-delegate invalidation — is identical and must not
|
||
/// be forked into a second file.
|
||
property string layout: "mosaic"
|
||
|
||
/// The manager pins its seam at 1 px (PmCollage.slint:27).
|
||
readonly property real effGap: root.layout === "pm" ? 1 : root.gap
|
||
|
||
/// How many of the (up to four) tiles this layout actually draws. The
|
||
/// mosaic draws every cover it has; `"pm"` draws ONE below four.
|
||
readonly property int drawnTiles: root.layout === "pm"
|
||
? (root.tiles.length >= 4 ? 4 : (root.tiles.length >= 1 ? 1 : 0))
|
||
: root.tiles.length
|
||
|
||
QbzTheme { id: theme }
|
||
|
||
// De-duplicated + capped at four (Tauri dedupes the same way: repeated
|
||
// albums in a playlist must not paint the same tile twice).
|
||
readonly property var tiles: root.dedup(root.urls)
|
||
// url -> local cached path, filled by the artwork signal.
|
||
property var pathMap: ({})
|
||
|
||
/// See theme/RoundedImage.qml: the ONLY case the retired "effects render
|
||
/// nothing" doctrine was ever true for is the software/Null renderer, and
|
||
/// `GraphicsInfo.api` answers it per window at runtime (`api` carries
|
||
/// `notify: "apiChanged"`, so this re-evaluates when the window arrives).
|
||
/// Test Software/Null negatively — under the RHI an OpenGL backend reports
|
||
/// `GraphicsInfo.OpenGL`.
|
||
///
|
||
/// `QbzShell.forceCanvasArt` (env `QBZ_QT_ROUND_MODE=canvas`, default OFF)
|
||
/// is honoured HERE TOO, and that is not cosmetic symmetry: the override
|
||
/// exists for a machine where `GraphicsInfo` reports a GPU and the mask
|
||
/// still misbehaves. If the mask draws nothing there, this file's mask
|
||
/// draws nothing either and the whole mosaic disappears — pinning the
|
||
/// tiles onto RoundedImage's canvas arm while leaving the mosaic masked
|
||
/// would defeat the override on exactly the surface it was added for.
|
||
/// Square outer corners (the same degrade the software path already takes,
|
||
/// see the header) is the correct fallback. `typeof` guard mirrors
|
||
/// RoundedImage: eager binding, and the file must stay loadable in an
|
||
/// isolated qml6 scene.
|
||
readonly property bool _noShaders: GraphicsInfo.api === GraphicsInfo.Software
|
||
|| GraphicsInfo.api === GraphicsInfo.Null
|
||
|| (typeof QbzShell !== "undefined" && QbzShell.forceCanvasArt)
|
||
|
||
function dedup(list) {
|
||
var out = []
|
||
var seen = ({})
|
||
var src = list || []
|
||
for (var i = 0; i < src.length && out.length < 4; i++) {
|
||
var u = src[i]
|
||
if (!u || seen[u] === true)
|
||
continue
|
||
seen[u] = true
|
||
out.push(u)
|
||
}
|
||
return out
|
||
}
|
||
|
||
function pathAt(index) {
|
||
var u = root.tiles[index]
|
||
return u ? (root.pathMap[u] || "") : ""
|
||
}
|
||
|
||
/// The mosaic layout, transcribed from the deleted `onPaint` and confirmed
|
||
/// against discover/PlaylistCollage.slint line by line: `gap: 2px` (:19),
|
||
/// n==2 halves `(size - gap) / 2` (:57), n==3 `0.62` / `0.38` with
|
||
/// `(size - gap) / 2` rows (:74, :82-89), n>=4 `0.34` / `0.66` with
|
||
/// `(size - 2*gap) / 3` rows (:102-115, :121). These numbers do not
|
||
/// change. Returns an empty rect for a tile this playlist does not have.
|
||
function cellOf(index) {
|
||
var w = root.width
|
||
var h = root.height
|
||
var n = root.tiles.length
|
||
if (w <= 0 || h <= 0 || n <= 0 || index >= root.drawnTiles)
|
||
return Qt.rect(0, 0, 0, 0)
|
||
var g = root.effGap
|
||
// Playlist Manager: 1-3 covers -> one full-bleed tile, 4+ -> a 2×2 grid
|
||
// (PmCollage.slint:39-80).
|
||
if (root.layout === "pm") {
|
||
if (n < 4)
|
||
return Qt.rect(0, 0, w, h)
|
||
var cwPm = (w - g) / 2
|
||
var chPm = (h - g) / 2
|
||
return Qt.rect((index % 2) * (cwPm + g),
|
||
Math.floor(index / 2) * (chPm + g), cwPm, chPm)
|
||
}
|
||
if (n === 1)
|
||
return Qt.rect(0, 0, w, h)
|
||
if (n === 2) {
|
||
// Side-by-side halves.
|
||
var half = (w - g) / 2
|
||
return index === 0 ? Qt.rect(0, 0, half, h)
|
||
: Qt.rect(half + g, 0, half, h)
|
||
}
|
||
if (n === 3) {
|
||
// One large left (62%), two stacked right (38%).
|
||
var big3 = (w - g) * 0.62
|
||
var small3 = (w - g) * 0.38
|
||
var half3 = (h - g) / 2
|
||
if (index === 0) return Qt.rect(0, 0, big3, h)
|
||
if (index === 1) return Qt.rect(big3 + g, 0, small3, half3)
|
||
return Qt.rect(big3 + g, half3 + g, small3, half3)
|
||
}
|
||
// Three stacked left (34%), one large right (66%).
|
||
var col4 = (w - g) * 0.34
|
||
var big4 = (w - g) * 0.66
|
||
var row4 = (h - 2 * g) / 3
|
||
if (index === 0) return Qt.rect(0, 0, col4, row4)
|
||
if (index === 1) return Qt.rect(0, row4 + g, col4, row4)
|
||
if (index === 2) return Qt.rect(0, 2 * (row4 + g), col4, row4)
|
||
return Qt.rect(col4 + g, 0, big4, h)
|
||
}
|
||
|
||
// Placeholder surface. A Rectangle's OWN rounded fill paints correctly —
|
||
// only its children need a mask.
|
||
Rectangle {
|
||
anchors.fill: parent
|
||
radius: root.radius
|
||
color: theme.surfaceElevated
|
||
}
|
||
|
||
// No covers at all — the list-music glyph (PlaylistCollage.slint n <= 0).
|
||
QbzIcon {
|
||
visible: root.tiles.length === 0
|
||
name: "list-music"
|
||
width: Math.round(root.width * 0.32)
|
||
height: width
|
||
anchors.centerIn: parent
|
||
tintName: "muted"
|
||
}
|
||
|
||
// Debounced url dispatch: one call per settled delegate, only for tiles
|
||
// whose path is still unknown.
|
||
Timer {
|
||
id: dispatchDelay
|
||
interval: 120
|
||
onTriggered: {
|
||
var pending = []
|
||
for (var i = 0; i < root.tiles.length; i++) {
|
||
var u = root.tiles[i]
|
||
if (!root.pathMap[u])
|
||
pending.push(u)
|
||
}
|
||
if (pending.length > 0)
|
||
QbzShell.sidebarArtworkWindow(JSON.stringify(pending))
|
||
}
|
||
}
|
||
|
||
onTilesChanged: {
|
||
if (root.tiles.length > 0)
|
||
dispatchDelay.restart()
|
||
}
|
||
Component.onCompleted: {
|
||
if (root.tiles.length > 0)
|
||
dispatchDelay.restart()
|
||
}
|
||
|
||
Connections {
|
||
target: QbzLibrary
|
||
// Shared with the feed's artKey-keyed emissions — ignore anything
|
||
// that is not one of OUR urls.
|
||
function onLibraryArtworkReady(key, path) {
|
||
if (root.pathMap[key] === path)
|
||
return
|
||
var mine = false
|
||
for (var i = 0; i < root.tiles.length; i++) {
|
||
if (root.tiles[i] === key) {
|
||
mine = true
|
||
break
|
||
}
|
||
}
|
||
if (!mine)
|
||
return
|
||
var m = root.pathMap
|
||
m[key] = path
|
||
// Rebind requires a NEW object reference.
|
||
root.pathMap = Object.assign({}, m)
|
||
}
|
||
}
|
||
|
||
/// The rounded OUTER 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 whole point.
|
||
Item {
|
||
id: maskSrc
|
||
anchors.fill: parent
|
||
visible: false
|
||
// Also gated on `tiles.length` — a layer renders even when its item is
|
||
// invisible (that is the whole mask idiom), so a coverless card would
|
||
// otherwise pay two FBOs to mask a glyph that is not inside them.
|
||
layer.enabled: !root._noShaders && root.radius > 0 && root.tiles.length > 0
|
||
layer.smooth: true
|
||
Rectangle {
|
||
anchors.fill: parent
|
||
radius: root.radius
|
||
color: "#ffffff"
|
||
}
|
||
}
|
||
|
||
// ONE layer + ONE mask for the whole mosaic, not one per tile. `clip: true`
|
||
// bounds the outer rect; each tile's own crop overflow is scissored by
|
||
// RoundedImage itself (`clip` there covers exactly the radius-0 crop case
|
||
// this file creates — the deleted Canvas clipped per tile).
|
||
Item {
|
||
id: mosaic
|
||
anchors.fill: parent
|
||
// No clip: cellOf() tiles the parent rect exactly, and each tile is a
|
||
// RoundedImage that scissors itself when it needs to.
|
||
visible: root.tiles.length > 0
|
||
layer.enabled: !root._noShaders && root.radius > 0 && root.tiles.length > 0
|
||
layer.smooth: true
|
||
layer.effect: MultiEffect {
|
||
maskEnabled: true
|
||
maskSource: maskSrc
|
||
// 0.5 / 1.0 — MEASURED, see theme/RoundedImage.qml for the full
|
||
// table. Qt's mask is a smoothstep CENTRED on maskThresholdMin of
|
||
// width maskSpreadAtMin, so (0.5, 1.0) is the only pair that maps
|
||
// mask alpha 0..1 onto 0..1 and therefore the only one that
|
||
// reproduces `ctx.clip()`'s antialiased corner. (0.0, 0.0)
|
||
// collapses to step(0, alpha) and the outer corners come back
|
||
// HARD (0 antialiased pixels vs the Canvas's 292); (0.0, > 0)
|
||
// clamps everything opaque and disables the mask outright. Do not
|
||
// "restore the defaults" here.
|
||
maskThresholdMin: 0.5
|
||
maskSpreadAtMin: 1.0
|
||
}
|
||
|
||
Repeater {
|
||
model: 4
|
||
delegate: RoundedImage {
|
||
required property int index
|
||
readonly property rect cell: root.cellOf(index)
|
||
visible: index < root.drawnTiles
|
||
x: cell.x
|
||
y: cell.y
|
||
width: cell.width
|
||
height: cell.height
|
||
source: root.pathAt(index)
|
||
// Square-cornered: only the mosaic's outer shape is rounded.
|
||
// radius 0 also means this RoundedImage mounts a plain Image
|
||
// with NO layer and NO mask of its own.
|
||
radius: 0
|
||
fit: "crop"
|
||
}
|
||
}
|
||
}
|
||
}
|