Restore tray-aware close and quit confirmation, circular label covers, and missing label artist portraits. Publish artwork progressively, preload two grid rows, and skip redundant motion reports while preserving data refreshes. Include regression tests and isolated artwork benchmarks. Verified Linux build, QML checks, and scrolling smoke tests. cx
330 lines
14 KiB
QML
330 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
|
||
property bool gridArtwork: false
|
||
|
||
// 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 {
|
||
gridArtwork: root.gridArtwork
|
||
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"
|
||
}
|
||
}
|
||
}
|
||
}
|