diff --git a/crates/qbz-qt/qml/LoginScreen.qml b/crates/qbz-qt/qml/LoginScreen.qml index 754319069..d382ccf16 100644 --- a/crates/qbz-qt/qml/LoginScreen.qml +++ b/crates/qbz-qt/qml/LoginScreen.qml @@ -29,8 +29,15 @@ Rectangle { QbzTheme { id: theme } // Faked drop shadow (blur 32, offset-y 8, #00000066): a translucent - // black rounded rect behind the card. Qt5Compat DropShadow is not - // assumed to be installed on the target. + // black rounded rect behind the card. + // SUPERSEDED (2026-07-29): the justification was "Qt5Compat DropShadow is + // not assumed to be installed on the target", of a piece with the retired + // "effects render nothing" doctrine. Both effect modules ARE installed here + // and this port runs on the GPU (OpenGL RHI, measured); the old note came + // from an offscreen session, which forces the software renderer by + // definition — theme/RoundedImage.qml now DETECTS the software path with + // `GraphicsInfo.api`. A real blurred shadow is a visual change owing its + // own parity pass, so the fake stays for now. Rectangle { anchors.horizontalCenter: card.horizontalCenter y: card.y + 8 diff --git a/crates/qbz-qt/qml/cards/PlaylistCollage.qml b/crates/qbz-qt/qml/cards/PlaylistCollage.qml index 0d5892be3..5f9c32175 100644 --- a/crates/qbz-qt/qml/cards/PlaylistCollage.qml +++ b/crates/qbz-qt/qml/cards/PlaylistCollage.qml @@ -16,11 +16,34 @@ // the 800x380 playlist banner therefore has no case to answer here, and // duplicating it into this file would only fork the one implementation. // -// Canvas rather than a Grid of Images for the reason theme/RoundedImage.qml -// documents: QML `clip` is rectangular, so a Rectangle with `radius` does -// NOT round its children, and shader masks render nothing on the -// software/offscreen path. One Canvas per card, CPU raster, same cost -// profile as the RoundedImage every AlbumCard already mounts. +// ── 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 @@ -31,6 +54,7 @@ // a windowed grid never fetches covers for delegates that are already gone. import QtQuick +import QtQuick.Effects import com.blitzfc.qbz import "../theme" @@ -52,6 +76,28 @@ Item { // 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 = ({}) @@ -71,8 +117,48 @@ Item { 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 >= n) + return Qt.rect(0, 0, 0, 0) + var g = root.gap + 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 the Canvas clip. + // only its children need a mask. Rectangle { anchors.fill: parent radius: root.radius @@ -138,148 +224,69 @@ Item { } } - Canvas { - id: canvas + /// 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 - renderTarget: Canvas.Image - // See RoundedImage.qml: GUI-thread raster. This canvas repaints from - // libraryArtworkReady and from four async image probes, on a card the - // feed recycles. - renderStrategy: Canvas.Immediate + 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" + } + } - // Dimension probes: Canvas cannot query a loaded image's intrinsic - // size and the crop fit needs it (RoundedImage's pattern, one per - // tile). They double as the async-load notification. - Image { - id: p0 - source: root.pathAt(0) - visible: false - asynchronous: true - cache: true - onSourceChanged: canvas.prime(source) - onStatusChanged: canvas.requestPaint() - } - Image { - id: p1 - source: root.pathAt(1) - visible: false - asynchronous: true - cache: true - onSourceChanged: canvas.prime(source) - onStatusChanged: canvas.requestPaint() - } - Image { - id: p2 - source: root.pathAt(2) - visible: false - asynchronous: true - cache: true - onSourceChanged: canvas.prime(source) - onStatusChanged: canvas.requestPaint() - } - Image { - id: p3 - source: root.pathAt(3) - visible: false - asynchronous: true - cache: true - onSourceChanged: canvas.prime(source) - onStatusChanged: canvas.requestPaint() + // 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 + clip: true + 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 } - function prime(src) { - if (src) - canvas.loadImage(src) - canvas.requestPaint() - } - - function probeAt(index) { - return index === 0 ? p0 : index === 1 ? p1 : index === 2 ? p2 : p3 - } - - // One crop-fitted tile, clipped to its own rect (uniform scale - // covering the cell, centered — Image.PreserveAspectCrop). - function tile(ctx, index, x, y, tw, th) { - var src = root.pathAt(index) - if (!src || tw <= 0 || th <= 0) - return - var probe = canvas.probeAt(index) - if (probe.status !== Image.Ready || probe.sourceSize.width <= 0 - || probe.sourceSize.height <= 0) - return - if (!canvas.isImageLoaded(src)) { - canvas.loadImage(src) - return - } - var iw = probe.sourceSize.width - var ih = probe.sourceSize.height - var scale = Math.max(tw / iw, th / ih) - var dw = iw * scale - var dh = ih * scale - ctx.save() - ctx.beginPath() - ctx.rect(x, y, tw, th) - ctx.clip() - ctx.drawImage(src, x + (tw - dw) / 2, y + (th - dh) / 2, dw, dh) - ctx.restore() - } - - onWidthChanged: requestPaint() - onHeightChanged: requestPaint() - onImageLoaded: requestPaint() - - onPaint: { - var ctx = getContext("2d") - ctx.reset() - var w = width - var h = height - if (w <= 0 || h <= 0) - return - var n = root.tiles.length - if (n <= 0) - return - - var r = Math.max(0, Math.min(root.radius, w / 2, h / 2)) - if (r > 0) { - ctx.beginPath() - ctx.moveTo(r, 0) - ctx.lineTo(w - r, 0) - ctx.arcTo(w, 0, w, r, r) - ctx.lineTo(w, h - r) - ctx.arcTo(w, h, w - r, h, r) - ctx.lineTo(r, h) - ctx.arcTo(0, h, 0, h - r, r) - ctx.lineTo(0, r) - ctx.arcTo(0, 0, r, 0, r) - ctx.closePath() - ctx.clip() - } - - var g = root.gap - if (n === 1) { - canvas.tile(ctx, 0, 0, 0, w, h) - } else if (n === 2) { - // Side-by-side halves. - var half = (w - g) / 2 - canvas.tile(ctx, 0, 0, 0, half, h) - canvas.tile(ctx, 1, half + g, 0, half, h) - } else 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 - canvas.tile(ctx, 0, 0, 0, big3, h) - canvas.tile(ctx, 1, big3 + g, 0, small3, half3) - canvas.tile(ctx, 2, big3 + g, half3 + g, small3, half3) - } else { - // 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 - canvas.tile(ctx, 0, 0, 0, col4, row4) - canvas.tile(ctx, 1, 0, row4 + g, col4, row4) - canvas.tile(ctx, 2, 0, 2 * (row4 + g), col4, row4) - canvas.tile(ctx, 3, col4 + g, 0, big4, h) + Repeater { + model: 4 + delegate: RoundedImage { + required property int index + readonly property rect cell: root.cellOf(index) + visible: index < root.tiles.length + 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" } } } diff --git a/crates/qbz-qt/qml/controls/QbzTooltip.qml b/crates/qbz-qt/qml/controls/QbzTooltip.qml index bde3f658d..eed6e3a4d 100644 --- a/crates/qbz-qt/qml/controls/QbzTooltip.qml +++ b/crates/qbz-qt/qml/controls/QbzTooltip.qml @@ -48,11 +48,17 @@ // - MAX WIDTH + ELISION. Neither Slint bubble caps its width; a 200-character // playlist name would run off the window. `maxWidth` (320 default) caps it // and the label elides. Set maxWidth: 0 for the uncapped Slint behaviour. -// - SHADOW. Slint draws `drop-shadow-blur: 16px` / `10px`. Qt Quick has no -// shadow primitive on the software path (QtQuick.Effects renders NOTHING — -// see theme/RoundedImage.qml), so the port's usual approximation is used: -// one offset rectangle behind the bubble, exactly like -// SidebarNowPlayingDock.qml does for the cover. +// - SHADOW. Slint draws `drop-shadow-blur: 16px` / `10px`. The port's usual +// approximation is used instead: one offset rectangle behind the bubble, +// exactly like SidebarNowPlayingDock.qml does for the cover. +// SUPERSEDED (2026-07-29): the justification here used to be "QtQuick. +// Effects renders NOTHING". Effects need shaders, and this port runs on the +// GPU (OpenGL RHI, measured); that note came from an offscreen session, +// which forces the software renderer by definition — see +// theme/RoundedImage.qml, which now detects the software path with +// `GraphicsInfo.api` instead of assuming it. A real MultiEffect shadow is +// therefore possible; it is a VISUAL change needing its own parity pass +// against Slint's `drop-shadow-blur`, so it is deliberately NOT done here. import QtQuick import "../theme" diff --git a/crates/qbz-qt/qml/shell/SidebarNowPlayingDock.qml b/crates/qbz-qt/qml/shell/SidebarNowPlayingDock.qml index b4b149687..cc0bf3888 100644 --- a/crates/qbz-qt/qml/shell/SidebarNowPlayingDock.qml +++ b/crates/qbz-qt/qml/shell/SidebarNowPlayingDock.qml @@ -84,8 +84,14 @@ Item { // ---- Album cover ------------------------------------------------------- // Drop-shadow approximation of the Slint dock's 24px blur (two offset - // rects — QtQuick.Effects renders nothing on the software path, see - // RoundedImage.qml). + // rects). + // SUPERSEDED (2026-07-29): this used to say "QtQuick.Effects renders + // nothing on the software path". Effects need shaders, and this port runs + // on the GPU (OpenGL RHI, measured); that note came from an offscreen + // session, which forces the software renderer by definition — see + // theme/RoundedImage.qml, which now DETECTS the software path with + // `GraphicsInfo.api`. A real blurred shadow is possible; it is a visual + // change needing its own parity pass, so the approximation stays for now. Rectangle { x: 0 y: root.artY + 4 diff --git a/crates/qbz-qt/qml/shell/SongCardStamp.qml b/crates/qbz-qt/qml/shell/SongCardStamp.qml index 3f01fb5d4..a581ea94b 100644 --- a/crates/qbz-qt/qml/shell/SongCardStamp.qml +++ b/crates/qbz-qt/qml/shell/SongCardStamp.qml @@ -63,8 +63,14 @@ Column { // bottomPadding of 10 (5px bubble padding + the 5px caret zone). // // Slint also drop-shadows the bubble (blur 10, #00000099); that needs a - // shader layer effect, which renders nothing on the software path, so it - // is deliberately dropped. + // shader layer effect and is deliberately dropped. + // SUPERSEDED (2026-07-29): the reason used to be "renders nothing on the + // software path". Effects need shaders, and this port runs on the GPU + // (OpenGL RHI, measured); that note came from an offscreen session, which + // forces the software renderer by definition — theme/RoundedImage.qml now + // detects the software path with `GraphicsInfo.api` instead of assuming it. + // Adding the shadow is a visual change owing its own parity pass, so the + // bubble stays flat here. component TipBubble: Item { Rectangle { anchors.fill: parent diff --git a/crates/qbz-qt/qml/shell/SpectrumBand.qml b/crates/qbz-qt/qml/shell/SpectrumBand.qml index 7f1111513..9c117a6b8 100644 --- a/crates/qbz-qt/qml/shell/SpectrumBand.qml +++ b/crates/qbz-qt/qml/shell/SpectrumBand.qml @@ -20,6 +20,16 @@ // one stream the visible mode consumes. // 6. Capture itself is gated by the dock's vizShouldRun — a hidden band costs // zero: the producer parks and the drain thread sleeps. +// 7. ONE JS binding per bar, not two. `y` used to be a DERIVED binding on +// `height`, so every height change evaluated two bindings per bar; a static +// anchor (bottom / verticalCenter) does the same arithmetic in C++ and +// evaluates nothing. Combined with note 8 and VizSettle's 16 ms tick, bars +// mode went from 180 Hz x 56 binding evaluations/s to 62 Hz x 28. +// 8. `at()` indexes a PLAIN JS ARRAY: VizSettle materialises each published +// QList_f32 frame once per publish (30/s) instead of letting every `at(i)` +// cross into the sequence wrapper twice per bar per tick. The waveform arm +// already builds a plain Array in its own `target` binding, so it is not +// copied twice (VizSettle branches on Array.isArray). import QtQuick import com.blitzfc.qbz @@ -83,7 +93,14 @@ Rectangle { x: index * barsMode.slot + 1 width: barsMode.slot - 2 height: Math.max(2, barsSettle.at(bin) * band.height) - y: band.height - height + // `y: band.height - height` was a SECOND JS binding that + // re-evaluated on every height change. An anchor does the + // same arithmetic in C++ and evaluates no JS at all. + // Geometry is identical (Slint's VBar does + // `y: root.area-h - self.height`). Only the VERTICAL axis + // is anchored — `x`/`width` stay positional, which QML + // allows and which keeps the slot arithmetic untouched. + anchors.bottom: parent.bottom radius: 1 gradient: barGradient } @@ -124,7 +141,9 @@ Rectangle { x: index * waveMode.slot + 1 width: waveMode.slot - 2 height: Math.max(2, waveSettle.at(index) * band.height) - y: (band.height - height) / 2 + // Was `y: (band.height - height) / 2` — a second JS binding + // per column (48 of them). Same geometry, no JS. + anchors.verticalCenter: parent.verticalCenter radius: 1 color: band.topColor } @@ -153,7 +172,8 @@ Rectangle { x: index * energyMode.slot + 3 width: energyMode.slot - 6 height: Math.max(2, energySettle.at(index) * band.height) - y: band.height - height + // Was `y: band.height - height` — see the bars delegate. + anchors.bottom: parent.bottom radius: 1 gradient: barGradient } diff --git a/crates/qbz-qt/qml/shell/TrackInfoModal.qml b/crates/qbz-qt/qml/shell/TrackInfoModal.qml index 3a6680690..b478ad700 100644 --- a/crates/qbz-qt/qml/shell/TrackInfoModal.qml +++ b/crates/qbz-qt/qml/shell/TrackInfoModal.qml @@ -15,8 +15,14 @@ // metadata col = (card width - 48 - 2 * 24) / 3 (three equal columns) // scrollbar = 14px, inset 4px from the card's right edge // The Slint card carries drop-shadow-blur 32px; QML shadows need an effect -// (Qt5Compat / MultiEffect) and effects render NOTHING on the software path in -// this port, so the shadow is dropped rather than faked. +// (Qt5Compat / MultiEffect) and it is dropped here rather than faked. +// SUPERSEDED (2026-07-29): the reason used to be "effects render NOTHING on the +// software path in this port". Effects need shaders, and this port runs on the +// GPU (OpenGL RHI, measured); that note came from an offscreen session, which +// forces the software renderer by definition — theme/RoundedImage.qml now +// detects the software path with `GraphicsInfo.api` rather than assuming it. +// The shadow is still not added: it is a visual change owing its own parity +// pass against Slint's blur, not a perf fix. // // DATA — see the report's GLUE NEEDED; the Qt port has no track-info bridge // yet. Contract: QbzAlbum.openTrackInfo(trackId) fetches and publishes diff --git a/crates/qbz-qt/qml/shell/VizSettle.qml b/crates/qbz-qt/qml/shell/VizSettle.qml index 72881df2e..9c8546c7f 100644 --- a/crates/qbz-qt/qml/shell/VizSettle.qml +++ b/crates/qbz-qt/qml/shell/VizSettle.qml @@ -25,13 +25,22 @@ // shared. Interpolating is the correct fix for a step artefact. // // COST CONTRACT. -// - Zero allocation per rendered frame: `from`/`to` are REFERENCE SWAPS of the -// arrays the bridge already marshals once per publish. Only `progress` -// changes per frame. +// - Zero allocation per rendered frame: `from` is a REFERENCE SWAP and only +// `progress` changes per tick. `to` is materialised into a plain JS array +// ONCE per publish (30/s) so `at()` does not cross into the QList_f32 +// sequence wrapper twice per bar per tick — see `onTargetChanged`. +// - The DRIVER IS A 16 ms Timer, not a FrameAnimation. The producer publishes +// at 30/s; a per-frame trigger fired 180x/s on Linux (100x/s on macOS) for +// it. ~62 Hz keeps two interpolated steps per published frame, which is all +// the motion there is to buy. // - SELF-PARKING, exactly like the Rust drain: the driver starts on a publish -// and stops itself the moment `progress` reaches 1. A paused player, a hidden -// band or a disabled tap publishes nothing, so the driver never runs and the -// render loop goes back to sleep. No idle wakeups. +// and stops itself the moment `progress` reaches 1 (within one 33 ms period +// of the last publish). A paused player, a hidden band or a disabled tap +// publishes nothing, so nothing restarts it. A Timer does NOT stop itself +// when the window renders nothing the way a FrameAnimation did, so the +// `onLiveChanged` gate below — fed by the dock's `vizShouldRun`, which drops +// when the window deactivates — is what keeps an occluded window at zero +// wakeups. Do not weaken it. // - Delegates bind ONE call, `at(i)`, which reads `from`, `to` and `progress` // unconditionally on every path — QML captures those three as dependencies, // so a `progress` change re-evaluates the bar heights and instantiates @@ -93,9 +102,34 @@ Item { // 5..125 fps, everything a sane producer can emit. if (dt > 8 && dt < 200) settle.periodMs = settle.periodMs * 0.8 + dt * 0.2; - // Reference swap — no copy, no allocation. + // Plain-JS copy, ONCE per publish (30/s), so `at()` indexes a JS array + // instead of crossing into the QList_f32 sequence wrapper 2x per bar + // per tick (56 crossings for bars). `from = to` stays a reference + // swap — no copy, no allocation per rendered frame (the COST CONTRACT + // above). + // + // The waveform arm ALREADY hands over a plain Array (it folds |sample| + // in its own binding, SpectrumBand.qml:108-117), so skip the copy + // there instead of paying a second 48-element pass per publish. + // `t ? … : 0` is not defensive padding: the pre-fix body was a bare + // `settle.to = settle.target` and never dereferenced `target`, so the + // copy below is the FIRST deref in this file. All three producers + // (QbzViz.bars / .energy sequences, the waveform arm's own Array) + // always hand over a value, but a future fourth arm that publishes + // undefined would now throw inside a 30/s handler instead of degrading + // to a hold — which is the contract stated in the header. + var t = settle.target; + var arr; + if (Array.isArray(t)) { + arr = t; + } else { + var n = t ? t.length : 0; + arr = new Array(n); + for (var i = 0; i < n; ++i) + arr[i] = t[i]; + } settle.from = settle.to; - settle.to = settle.target; + settle.to = arr; settle.lastTickMs = now; settle.progress = 0.0; if (!driver.running) @@ -109,11 +143,28 @@ Item { } } - // Wall clock, not the driver's elapsedTime: elapsedTime only advances on a - // rendered frame, so using it would quantise the segment start to vsync and - // re-introduce the beat we are removing. - FrameAnimation { + // Wall clock, not a frame-driven elapsedTime: the time source must not + // quantise the segment start to vsync (that is the beat this object + // removes). The TRIGGER, however, must not run at the frame rate either — + // the producer publishes at TARGET_FPS = 30 (qbz-audio visualizer/mod.rs:32) + // and FrameAnimation fired 180x/s on Linux and 100x/s on macOS for it, + // i.e. 3-6x the frames the data justifies. 16 ms = ~62 Hz gives two + // interpolated steps per published frame, which is what buys the motion + // (Slint's equivalent is `animate bar-h { duration: 90ms; easing: ease; }`, + // SidebarNowPlayingDock.slint:45). + // + // `repeat: true` is MANDATORY — Timer defaults to false, and without it the + // driver fires once per publish and `progress` freezes at ~0.5. + // `triggeredOnStart` stays default false: `running` is only set from + // `onTargetChanged`, which has just written `progress = 0.0`, so an + // immediate tick would recompute p≈0 and do nothing. + // Coarse timer resolution is irrelevant: `progress` is derived from + // Date.now(), so a late tick lands a correspondingly larger `p` and a + // missed tick degrades to a hold, never a jump. + Timer { id: driver + interval: 16 + repeat: true running: false onTriggered: { var p = (Date.now() - settle.lastTickMs) / settle.periodMs; diff --git a/crates/qbz-qt/qml/theme/QbzIcon.qml b/crates/qbz-qt/qml/theme/QbzIcon.qml index 1314570f7..4e45c2767 100644 --- a/crates/qbz-qt/qml/theme/QbzIcon.qml +++ b/crates/qbz-qt/qml/theme/QbzIcon.qml @@ -5,12 +5,21 @@ // // Slint's QbzIcon is `Image { colorize: }`: the renderer // recolours the glyph every frame, so an icon always matches the current -// theme, all 36 of them, custom accent included. Qt has no equivalent on the -// path this port runs on — ColorOverlay / MultiEffect / shaders draw NOTHING -// on the software renderer (verified with a probe during the POC), which is -// why this file used to point straight at PRE-BAKED variants, +// theme, all 36 of them, custom accent included. Qt has no *free* equivalent, +// which is why this file used to point straight at PRE-BAKED variants, // assets/icons//.svg, each with a fixed hex burned in. // +// SUPERSEDED (2026-07-29): the reason recorded here used to be "ColorOverlay / +// MultiEffect / shaders draw NOTHING", from a POC probe. Effects need shaders, +// and this port runs on the GPU (OpenGL RHI, measured with QSG_INFO=1 in a real +// windowed session); the probe was taken under QT_QPA_PLATFORM=offscreen, which +// forces the software renderer by definition. Where a software path is +// genuinely possible, detect it with `GraphicsInfo.api` rather than assuming it +// — theme/RoundedImage.qml does exactly that. +// This does NOT re-open the design below. The runtime tint generator is cached, +// costs zero per frame, and serves all 36 themes; a per-glyph shader pass would +// be a rewrite, not a fix. Annotation only. +// // Fixed hexes cannot serve 36 themes: the bakes were cut against the dark // palette, so `secondary` (#cccccc) and `muted` (#888888) were near-white // glyphs on near-white surfaces on every LIGHT theme, and `accent` was diff --git a/crates/qbz-qt/qml/theme/RoundedImage.qml b/crates/qbz-qt/qml/theme/RoundedImage.qml index c44ed1c38..e4a695d4b 100644 --- a/crates/qbz-qt/qml/theme/RoundedImage.qml +++ b/crates/qbz-qt/qml/theme/RoundedImage.qml @@ -3,40 +3,69 @@ // Why this exists: QML's `clip` is rectangular — a Rectangle with // `radius` + `clip: true` does NOT clip children to the rounded shape // (proven with an isolated scene on this Qt build: the child paints -// square over the rounded fill). Shader masks (Qt5Compat OpacityMask, -// QtQuick.Effects MultiEffect) render NOTHING on the software/offscreen -// path (the phase-2 icon-tinting probe), and the Pi kiosk can run the -// software path — so rounding is done with a Canvas raster clip -// (QPainter, antialiased, renderTarget Image = CPU). +// square over the rounded fill). So the curve has to come from a mask. // -// Usage mirrors the old pattern: `RoundedImage { anchors.fill: parent; -// source: artPath; radius: theme.radiusSm }` inside the same rounded -// placeholder Rectangle (the placeholder's OWN fill rounds fine — only -// children needed the workaround). +// ── HOW THE ROUNDING IS DONE (and the doctrine this file used to carry) ──── +// Effects need shaders. This port runs on the GPU (OpenGL RHI, measured +// 2026-07-29 with QSG_INFO=1 in a real windowed session); the earlier +// "renders nothing" note in this header came from an offscreen session, which +// forces the software renderer by definition. Where a software path is +// genuinely possible, detect it with `GraphicsInfo.api` rather than assuming +// it. +// +// So there are two arms, chosen per item, at runtime: +// +// FAST ARM (default) one `Image` + ONE `layer.enabled` whose `layer.effect` +// is a `MultiEffect` masked by a rounded `Rectangle`. +// The layer FBO and the mask FBO are pure functions of +// (width, height, radius) — they are rendered once and +// cached, so a static cover costs ZERO CPU raster and +// ZERO texture upload per frame. `radius <= 0` takes +// neither the layer nor the mask, i.e. a plain Image. +// CANVAS ARM the previous CPU raster (QPainter into a QImage, +// `renderTarget: Canvas.Image`), kept verbatim for the +// two cases that genuinely need it: +// * `fit: "pad"` — its band colours come from +// `Context2D.getImageData` on the pixels the canvas +// just drew, and there is no non-Canvas way to read +// those back; +// * a software / Null renderer (offscreen, and any +// platform `qt-diag.sh` shows on the software path) +// — no shaders, so no mask. +// `QbzShell.forceCanvasArt` (env `QBZ_QT_ROUND_MODE= +// canvas`, default OFF) pins this arm by hand. +// +// Usage is unchanged: `RoundedImage { anchors.fill: parent; source: artPath; +// radius: theme.radiusSm }` inside the same rounded placeholder Rectangle +// (the placeholder's OWN fill rounds fine — only children needed a mask). // // ── READINESS CONTRACT (`ready`) ─────────────────────────────────────────── // A host that draws a loading placeholder over this item MUST gate it on // `ready`, never on "the path is non-empty". // -// The path landing means NOTHING here. This is a Canvas: once `source` is -// assigned the probe still has to load the file through QQuickPixmap and then -// a paint pass has to rasterize it. A placeholder gated on the path therefore -// vanishes the instant the path arrives and leaves an EMPTY tile on screen -// until the canvas paints — the reported bug ("the skeleton disappears before -// the art is rendered"). +// The path landing means NOTHING here: once `source` is assigned the file +// still has to be decoded (and, on the canvas arm, rasterized). A placeholder +// gated on the path therefore vanishes the instant the path arrives and leaves +// an EMPTY tile on screen until the pixels exist — the reported bug ("the +// skeleton disappears before the art is rendered"). // -// `ready` is true only when BOTH have happened for the CURRENT source: -// 1. the dimension probe reached Image.Ready (the pixmap is decoded), and -// 2. `onPaint` has run a pass that actually drew THAT source. -// It is derived from `_paintedSource`, so re-assigning `source` drops it back -// to false with no bookkeeping — a recycled list delegate cannot inherit the -// previous row's readiness. +// `ready` is true only when the CURRENT source is on screen: +// fast arm `Image.Ready` — the pixmap is decoded and its texture uploads +// on the next render pass; QbzSkeleton's 180ms fade covers that +// one frame (QbzSkeleton.qml:51-61 documents the same thing for +// its own probe arm, implemented `handedOver` :184 -> `retired` +// :214 -> `Behavior on opacity` :216). +// canvas arm `_paintedSource` — a paint pass actually drew THAT source. +// Both are MONOTONE WITHIN ONE SOURCE and cleared only by `onSourceChanged`, +// so a recycled list delegate cannot inherit the previous row's readiness and +// a mid-life derivative swap cannot un-retire a placeholder that has already +// handed over. // // Hosts that cannot reach this item (the image lives inside AlbumCard, // PlaylistCard, …) use the equivalent self-probing arm of the placeholder // itself: `QbzSkeleton { coverSource: }` — see QbzSkeleton.qml. // -// ── THE RASTER IS IN DEVICE PIXELS ───────────────────────────────────────── +// ── THE CANVAS ARM RASTERS IN DEVICE PIXELS ──────────────────────────────── // `onPaint` lays everything out in DEVICE pixels and pre-divides the context // by the device pixel ratio, instead of drawing in the item's logical pixels. // @@ -75,8 +104,9 @@ // card drawing the derivative RMSE 1.303 // ArtPreviewOverlay's plain Image RMSE 2.832 // -// so the fix below (`_effectiveSource` everywhere in `onPaint`) puts the card -// AHEAD of the preview that was reported as the good one. +// The derivative is kept on BOTH arms: the fast arm's `Image` filters, but a +// file already at the drawn size is still one decode of 200x200 instead of +// 600x600, and it is what makes `pad` 1:1. // // ── NON-SQUARE SOURCES: `fit: "pad"` / `fit: "auto"` ─────────────────────── // Qobuz playlist artwork (`image_rectangle`) is 800x380 — a 2.11:1 banner, @@ -103,15 +133,12 @@ // visible join, and the picture dissolves into the frame instead of ending at // a hard rectangle. // -// Two implementation constraints decided this shape: -// * ShaderEffect / ColorOverlay / MultiEffect render NOTHING on the -// software path, and `layer.enabled` would put a per-frame FBO on a card -// that appears dozens of times in a rail. A Canvas `createLinearGradient` -// is neither — it is the same CPU raster this file already runs. -// * The colours come from `Context2D.getImageData` on the pixels THIS -// canvas just drew, so no second decode, no extra cache dir and no Rust -// round-trip. Measured against a PIL average of the same source strip: -// (11,18,27) read back vs (9,17,25) — faithful. +// The colours come from `Context2D.getImageData` on the pixels THIS canvas +// just drew, so no second decode, no extra cache dir and no Rust round-trip. +// Measured against a PIL average of the same source strip: (11,18,27) read +// back vs (9,17,25) — faithful. That read-back is the reason `pad` keeps the +// Canvas: it is not a renderer limitation, it is that nothing else can hand +// back the pixels it just drew. // // `getImageData` here addresses the DEVICE-pixel backing store and IGNORES // the active transform — the same space `onPaint` already lays everything out @@ -138,9 +165,10 @@ import QtQuick import QtQuick.Window +import QtQuick.Effects import com.blitzfc.qbz -Canvas { +Item { id: root property string source: "" property real radius: 8 @@ -178,13 +206,64 @@ Canvas { /// must agree on this or the scaled file comes back the wrong shape. readonly property bool _contains: root._fit === "contain" || root._fit === "pad" + /// ── ARM SELECTION — detected, not configured ─────────────────────────── + /// Shaders are unavailable on the software/Null renderer (and that is the + /// ONLY case the retired "effects render nothing" doctrine was ever true + /// for: it was observed under QT_QPA_PLATFORM=offscreen, which forces + /// software by definition). GraphicsInfo.api answers it exactly, per + /// window, at runtime — so the offscreen gate keeps drawing covers the way + /// it does today and macOS gets the mask the moment it reports Metal. + /// + /// `api` carries `notify: "apiChanged"`, so this IS a live binding: before + /// the item has a window the value is `Unknown` (fast arm) and it + /// re-evaluates when the window arrives. Under the RHI an OpenGL backend + /// reports `GraphicsInfo.OpenGL` and the `*Rhi` values are Qt5-era + /// aliases, so testing Software/Null NEGATIVELY is the correct test — do + /// not test for OpenGL positively. + readonly property bool _noShaders: GraphicsInfo.api === GraphicsInfo.Software + || GraphicsInfo.api === GraphicsInfo.Null + /// `pad` samples the pixels it just drew (Context2D.getImageData); there + /// is no non-Canvas way to read them back, so that mode keeps the raster. + /// It is confined to Qobuz playlist banners (fit "pad" / a resolved + /// "auto"), i.e. 179 covers in the local cache, not every album cover. + /// + /// `typeof QbzShell !== "undefined"` mirrors the guard `_requestScaled` + /// already uses for QbzSession: a registered singleton is always defined + /// in the built binary, but the guard keeps this file loadable in an + /// isolated qml6 scene, which is how the "clip does not round" finding in + /// the header was obtained. + readonly property bool _useCanvas: root._fit === "pad" || root._noShaders + || (typeof QbzShell !== "undefined" && QbzShell.forceCanvasArt) + + /// PreserveAspectCrop overflows the item. A layer confines it (the layer + /// texture IS the item rect); with no layer and a crop fit, nothing does — + /// and a cropped tile would paint over its neighbour (PlaylistCollage's + /// mosaic, whose Canvas clipped per tile). Cheapest correct rule: scissor + /// exactly in the one case that has neither. NOT `clip: true` + /// unconditionally — that would put a scissor under every layered cover + /// for nothing. + clip: !root._useCanvas && root.radius <= 0 && !root._contains + /// THE handover signal — see the READINESS CONTRACT above. True only once /// the current `source` is on screen, not merely known. - readonly property bool ready: root._paintedSource !== "" - && root._paintedSource === root.source - /// The source of the last paint that actually drew pixels. Internal; it - /// exists as a property (not a bool flag) so that `ready` invalidates - /// itself on every source change. + /// + /// MONOTONE WITHIN A SOURCE, and that is not cosmetic: `QbzSkeleton`'s + /// `retired` is a live binding, so anything that drops `ready` back to + /// false brings the placeholder back over drawn art. Five hosts bind this + /// directly — FolderSubcard.qml:62, LocalAlbumRow.qml:115, + /// LocalAlbumHeader.qml:63, LocalEphemeralPane.qml:168, + /// LocalArtistRow.qml:70 — and every `coverSource` host observes the same + /// shape through its own probe. + readonly property bool ready: root.source !== "" + && (root._useCanvas ? (root._paintedSource === root.source) + : root._imgReady) + /// Fast-arm latch: set true on `Image.Ready`, cleared ONLY by + /// `onSourceChanged`. See the display Image's `onStatusChanged` for why + /// this must not mirror `status`. + property bool _imgReady: false + /// Canvas-arm equivalent: the source of the last paint that actually drew + /// pixels. Internal; it exists as a property (not a bool flag) so that + /// `ready` invalidates itself on every source change. property string _paintedSource: "" /// ── THE SCALED SOURCE ────────────────────────────────────────────────── @@ -242,7 +321,7 @@ Canvas { var w = Math.ceil(root.width * root._dpr) var h = Math.ceil(root.height * root._dpr) if (w <= 0 || h <= 0) return - // The SAME geometry `onPaint` uses, so the answer is the drawn size. + // The SAME geometry the draw uses, so the answer is the drawn size. // `_contains`, not `fit === "contain"`: "pad" and a resolved "auto" // fit INSIDE the cell, and asking for the crop (max) scale there // would hand back a file wider than the band the art occupies — @@ -274,34 +353,189 @@ Canvas { } } - renderTarget: Canvas.Image - // GUI-thread raster. Cooperative/Threaded rasterize on the SCENE GRAPH - // RENDER THREAD, and this item is created and destroyed by list recycling - // while async artwork callbacks schedule repaints — the render thread can - // be mid-draw on a pixmap whose Canvas is already gone. Immediate keeps - // paint and destruction on one thread. renderTarget: Image already meant - // CPU raster, so this costs no visual change. - renderStrategy: Canvas.Immediate - - // Dimension probe: Canvas can't query the loaded image's intrinsic - // size, and PreserveAspectCrop needs it. A hidden Image doubles as - // the async loader notification. + // Dimension probe: the ORIGINAL's intrinsic size is what `_requestScaled` + // and `fit: "auto"` need, and neither a Canvas nor a cropping Image can be + // asked for it. A hidden Image doubles as the async loader notification. + // + // It follows `_effectiveSource`, not `source`: once the derivative lands + // both this probe and the display Image reference the SAME file, so the + // original drops to refcount 0 and becomes evictable — which is the other + // half of the "RoundedImage keeps both the original and the derivative + // loaded" report. (Releasing the probe outright by binding + // `source: _srcW > 0 ? "" : source` is possible but requires reordering + // `_captureSrc` to read into locals first, because assigning `_srcW` + // would zero `sourceSize` mid-function. Deliberately NOT done here.) Image { id: probe source: root._effectiveSource visible: false asynchronous: true cache: true + // NO sourceSize: it is part of the QQuickPixmapCache key, so a + // decode-size request here would fork a SECOND decode of the file the + // derivative pipeline already sized (QbzSkeleton.qml:178-180 documents + // the same trap for its own probe). onStatusChanged: { // While `_scaled` is "" this IS the original, so this is where its // intrinsic size becomes knowable — and the derivative cannot be // requested before it (see `_srcW`). root._captureSrc() - if (status === Image.Ready || status === Image.Error) root.requestPaint() + if (status === Image.Ready || status === Image.Error) root._repaint() } onSourceSizeChanged: root._captureSrc() } + /// 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 whole point. + Item { + id: maskSrc + anchors.fill: parent + visible: false + layer.enabled: root.radius > 0 && !root._useCanvas + layer.smooth: true + Rectangle { + anchors.fill: parent + radius: root.radius // Rectangle clamps internally, exactly + color: "#ffffff" // as onPaint's Math.min(r, w/2, h/2) did + } + } + + // ── The two arms ──────────────────────────────────────────────────────── + // One Loader swap at startup (when GraphicsInfo.api resolves), not per + // frame. `active` is a live binding on `_useCanvas`, which also follows + // `_fit` — a `fit: "auto"` cell that resolves to "pad" moves to the canvas + // arm on the same pass that resolves it. + Loader { + id: fastArm + anchors.fill: parent + active: !root._useCanvas + sourceComponent: imageComp + } + Loader { + id: canvasArm + anchors.fill: parent + active: root._useCanvas + sourceComponent: canvasComp + // A newly created Canvas has loaded no image yet (the root-level + // handlers already fired), so prime it here as well as there. + onLoaded: root._repaint() + } + + Component { + id: imageComp + Image { + anchors.fill: parent + source: root._effectiveSource + // "crop" = PreserveAspectCrop; "contain"/"pad" fit INSIDE. Same + // predicate the derivative request uses, so the file that comes + // back is the size this fillMode draws (see _requestScaled). + fillMode: root._contains ? Image.PreserveAspectFit + : Image.PreserveAspectCrop + asynchronous: true + cache: true + // NO sourceSize: it is part of the QQuickPixmapCache key, so a + // decode-size request here would fork a SECOND decode of the file + // the derivative pipeline already sized. + smooth: true + mipmap: false + // The derivative swaps `source` mid-life. Without this the item + // blanks for the frames the new file takes to decode — the Canvas + // never did (it kept the previous raster), so this is what keeps + // the swap invisible. Qt >= 6.8. + 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. possibly before the handler is + // connected, and a missed latch would leave `ready` false forever — + // a QbzSkeleton shimmering on top of drawn art at five direct + // hosts. Same self-healing discipline as `_captureSrc`. + function _sync() { + // LATCH, do not mirror. `status` goes back to Image.Loading + // when the derivative swaps `_effectiveSource` mid-life + // (retainWhileLoading keeps the PIXELS, not the status), and a + // mirrored `_imgReady` would drop `ready` to false for those + // frames. `ready` false un-retires QbzSkeleton (`handedOver` + // :184 -> `retired` :214 -> opacity 1.0), i.e. the placeholder + // FADES BACK IN over art that is already on screen. The Canvas + // never did that: `_paintedSource` is monotone within one + // source. So: set true on Ready, and clear ONLY from + // onSourceChanged. + if (status === Image.Ready) + root._imgReady = true + // FALLBACK TO THE ORIGINAL, which the Canvas has (`onPaint` + // picks `source` when `_effectiveSource` is not loadable) and + // this arm would otherwise lose. Reachable in production + // because of the derivative-cache eviction: + // `artwork_qt::evict_scaled` can unlink a derivative a live or + // later-recycled delegate still names in `_scaled`, and + // `_reqKey` would block the re-request, leaving the cover + // permanently blank. Dropping both re-arms the pipeline. + if (status === Image.Error && root._scaled !== "") { + root._scaled = "" + root._reqKey = "" + } + } + onStatusChanged: _sync() + Component.onCompleted: _sync() + // Rounding: ONE layer + ONE mask, both static for this item's + // life. radius 0 (LabelCard, LabelView, LabelReleasesView) takes + // neither — those three used to pay a full CPU raster to round + // nothing. + layer.enabled: root.radius > 0 + layer.smooth: true + layer.effect: MultiEffect { + maskEnabled: true + maskSource: maskSrc + // ── 0.5 / 1.0, and BOTH numbers were MEASURED, not reasoned. + // Do not "restore the defaults" here. + // + // Qt's mask maths is a smoothstep CENTRED on + // `maskThresholdMin` whose width is `maskSpreadAtMin`, i.e. + // roughly smoothstep(min - spread/2, min + spread/2, maskAlpha). + // So (0.5, 1.0) is the only pair that maps the mask's alpha + // 0..1 monotonically onto 0..1 — the straight alpha multiply + // `ctx.clip()` gave. Grabbed a 200x200 cover under a + // radius-40 mask on this Qt build (6.11.1, OpenGL RHI) and + // counted the ANTIALIASED pixels (0 < alpha < 255): + // + // Canvas arm (the baseline this replaces) 292 fringe px + // maskThresholdMin 0.0 / maskSpreadAtMin 0.0 0 fringe px + // maskThresholdMin 0.5 / maskSpreadAtMin 1.0 308 fringe px + // Qt5Compat OpacityMask (the sanctioned fallback) 308 + // + // and on a FULL CIRCLE (radius 100 on 200px — ArtistCard 95, + // ArtistView 100, HomeView's Spotlight hero 70): + // + // Canvas 752 fringe px | 0.0/0.0 -> 0 | 0.5/1.0 -> 740 + // + // (0.0, 0.0) collapses the smoothstep to `step(0, alpha)`: + // every mask pixel with any alpha at all snaps to fully + // opaque, so the arc comes back HARD and half a pixel fat — + // a visibly stair-stepped circle at the six circle sites. + // (0.0, anything > 0) is worse still: the low edge goes + // negative, every pixel clamps to 1 and the mask is disabled + // outright (measured: 40000/40000 px opaque, square corners). + maskThresholdMin: 0.5 + maskSpreadAtMin: 1.0 + } + } + } + + // ── Root-level source/geometry handlers ───────────────────────────────── + // The canvas arm needs an explicit loadImage + requestPaint from each of + // these; the fast arm needs neither (its `Image` re-binds itself), so they + // funnel through one guarded helper. + + /// No-op on the fast arm, by construction: `canvasArm.item` is null there. + function _repaint() { + if (canvasArm.item) { + canvasArm.item.loadImage(root._effectiveSource) + canvasArm.item.requestPaint() + } + } + // requestPaint() as well as loadImage(): a delegate recycled onto a row // with NO cover assigns source = "", which emits no imageLoaded, so // without an explicit repaint the canvas would keep showing the previous @@ -315,95 +549,121 @@ Canvas { root._srcW = 0 root._srcH = 0 root._reqKey = "" - loadImage(root._effectiveSource) - requestPaint() + // THE ONLY place the fast-arm latch is cleared — see `_imgReady`. + root._imgReady = false + root._repaint() } - on_EffectiveSourceChanged: { loadImage(root._effectiveSource); requestPaint() } - Component.onCompleted: loadImage(root._effectiveSource) - onImageLoaded: { _captureSrc(); requestPaint() } - onWidthChanged: { requestPaint(); _requestScaled() } - onHeightChanged: { requestPaint(); _requestScaled() } + on_EffectiveSourceChanged: root._repaint() + Component.onCompleted: root._repaint() + onWidthChanged: { root._repaint(); root._requestScaled() } + onHeightChanged: { root._repaint(); root._requestScaled() } /// Device pixels per logical pixel for the screen this item is on. Held as /// a property so moving the window between screens of different scales /// re-rasterizes (see the DEVICE PIXELS note above). readonly property real _dpr: Screen.devicePixelRatio > 0 ? Screen.devicePixelRatio : 1 - on_DprChanged: { requestPaint(); _requestScaled() } + on_DprChanged: { root._repaint(); root._requestScaled() } - onPaint: { - // Last of the self-healing capture hooks (see `_captureSrc`): a paint - // means the cell is on screen, so if the size is knowable at all it is - // knowable now. One integer compare once the latch is set. - _captureSrc() - var ctx = getContext("2d") - ctx.reset() - // Everything below is in DEVICE pixels; the context is pre-divided so - // the item still occupies `width` x `height` logical pixels. - var dpr = root._dpr - var w = Math.round(width * dpr) - var h = Math.round(height * dpr) - if (w <= 0 || h <= 0) return - if (dpr !== 1) ctx.scale(1 / dpr, 1 / dpr) - var r = Math.max(0, Math.min(radius * dpr, w / 2, h / 2)) - if (r > 0) { - ctx.beginPath() - ctx.moveTo(r, 0) - ctx.lineTo(w - r, 0) - ctx.arcTo(w, 0, w, r, r) - ctx.lineTo(w, h - r) - ctx.arcTo(w, h, w - r, h, r) - ctx.lineTo(r, h) - ctx.arcTo(0, h, 0, h - r, r) - ctx.lineTo(0, r) - ctx.arcTo(0, 0, r, 0, r) - ctx.closePath() - ctx.clip() - } - // Geometry comes from the ORIGINAL's intrinsic size, not from the - // probe. The derivative preserves the aspect ratio, so `dw`/`dh` are - // identical either way (they depend only on the cell and the ratio) — - // but `_srcW`/`_srcH` do not go stale for the frame in which the probe - // is re-loading after `_scaled` lands, which would otherwise blank - // every card once, mid-rail, the moment its derivative arrived. - var iw = root._srcW > 0 ? root._srcW : probe.sourceSize.width - var ih = root._srcH > 0 ? root._srcH : probe.sourceSize.height - // Prefer the derivative; fall back to the original while it resolves - // (or if it never does) so a cell is never empty for want of it. - var drawSrc = isImageLoaded(root._effectiveSource) ? root._effectiveSource - : (isImageLoaded(root.source) ? root.source : "") - if (iw > 0 && ih > 0 && drawSrc !== "") { - // "crop" = uniform scale covering the rect; "contain"/"pad" = - // fitting inside it. All centered. Once the derivative is in play - // it is already `dw` x `dh`, so the blit is 1:1 — which is the - // entire point of it, since drawImage does not filter when it - // scales. - var scale = root._contains ? Math.min(w / iw, h / ih) : Math.max(w / iw, h / ih) - var dw = iw * scale - var dh = ih * scale - var ox = (w - dw) / 2 - var oy = (h - dh) / 2 - // `drawSrc`, NOT `source`: drawing the original here is what made - // the derivative dead weight and every card a nearest-neighbour - // downscale. - ctx.drawImage(drawSrc, ox, oy, dw, dh) - // The uncovered area, filled from the image itself. AFTER the - // draw on purpose: the sampler reads the pixels that were just - // laid down, so it needs no second decode and cannot disagree - // with what is on screen. - if (root._fit === "pad") - root._paintPad(ctx, dpr, w, h, ox, oy, dw, dh) - // ONLY here: pixels for `source` are now on the canvas. Anything - // earlier (the path arriving, the probe going Ready) would hand - // the cell over to an empty tile. Assigning the same string twice - // is a no-op in QML, so repeated paints cost no notifications. - root._paintedSource = source + Component { + id: canvasComp + Canvas { + id: canvas + anchors.fill: parent + renderTarget: Canvas.Image + // GUI-thread raster. Cooperative/Threaded rasterize on the SCENE + // GRAPH RENDER THREAD, and this item is created and destroyed by + // list recycling while async artwork callbacks schedule repaints — + // the render thread can be mid-draw on a pixmap whose Canvas is + // already gone. Immediate keeps paint and destruction on one + // thread. renderTarget: Image already meant CPU raster, so this + // costs no visual change. + renderStrategy: Canvas.Immediate + + onImageLoaded: { root._captureSrc(); canvas.requestPaint() } + + onPaint: { + // Last of the self-healing capture hooks (see `_captureSrc`): + // a paint means the cell is on screen, so if the size is + // knowable at all it is knowable now. One integer compare once + // the latch is set. + root._captureSrc() + var ctx = canvas.getContext("2d") + ctx.reset() + // Everything below is in DEVICE pixels; the context is + // pre-divided so the item still occupies `width` x `height` + // logical pixels. + var dpr = root._dpr + var w = Math.round(canvas.width * dpr) + var h = Math.round(canvas.height * dpr) + if (w <= 0 || h <= 0) return + if (dpr !== 1) ctx.scale(1 / dpr, 1 / dpr) + var r = Math.max(0, Math.min(root.radius * dpr, w / 2, h / 2)) + if (r > 0) { + ctx.beginPath() + ctx.moveTo(r, 0) + ctx.lineTo(w - r, 0) + ctx.arcTo(w, 0, w, r, r) + ctx.lineTo(w, h - r) + ctx.arcTo(w, h, w - r, h, r) + ctx.lineTo(r, h) + ctx.arcTo(0, h, 0, h - r, r) + ctx.lineTo(0, r) + ctx.arcTo(0, 0, r, 0, r) + ctx.closePath() + ctx.clip() + } + // Geometry comes from the ORIGINAL's intrinsic size, not from + // the probe. The derivative preserves the aspect ratio, so + // `dw`/`dh` are identical either way (they depend only on the + // cell and the ratio) — but `_srcW`/`_srcH` do not go stale + // for the frame in which the probe is re-loading after + // `_scaled` lands, which would otherwise blank every card + // once, mid-rail, the moment its derivative arrived. + var iw = root._srcW > 0 ? root._srcW : probe.sourceSize.width + var ih = root._srcH > 0 ? root._srcH : probe.sourceSize.height + // Prefer the derivative; fall back to the original while it + // resolves (or if it never does) so a cell is never empty for + // want of it. + var drawSrc = canvas.isImageLoaded(root._effectiveSource) ? root._effectiveSource + : (canvas.isImageLoaded(root.source) ? root.source : "") + if (iw > 0 && ih > 0 && drawSrc !== "") { + // "crop" = uniform scale covering the rect; + // "contain"/"pad" = fitting inside it. All centered. Once + // the derivative is in play it is already `dw` x `dh`, so + // the blit is 1:1 — which is the entire point of it, since + // drawImage does not filter when it scales. + var scale = root._contains ? Math.min(w / iw, h / ih) : Math.max(w / iw, h / ih) + var dw = iw * scale + var dh = ih * scale + var ox = (w - dw) / 2 + var oy = (h - dh) / 2 + // `drawSrc`, NOT `source`: drawing the original here is + // what made the derivative dead weight and every card a + // nearest-neighbour downscale. + ctx.drawImage(drawSrc, ox, oy, dw, dh) + // The uncovered area, filled from the image itself. AFTER + // the draw on purpose: the sampler reads the pixels that + // were just laid down, so it needs no second decode and + // cannot disagree with what is on screen. + if (root._fit === "pad") + root._paintPad(ctx, dpr, w, h, ox, oy, dw, dh) + // ONLY here: pixels for `source` are now on the canvas. + // Anything earlier (the path arriving, the probe going + // Ready) would hand the cell over to an empty tile. + // Assigning the same string twice is a no-op in QML, so + // repeated paints cost no notifications. + root._paintedSource = root.source + } + } } } // ── The image-derived padding ───────────────────────────────────────── // Everything below runs in DEVICE pixels, like the rest of `onPaint` — // `getImageData` included (see the header note; this was measured, not - // assumed, and the HTML-spec answer is the wrong one here). + // assumed, and the HTML-spec answer is the wrong one here). It stays on + // ROOT rather than inside the canvas component: the context is passed in, + // and keeping it here is what makes the moved body a pure relocation. /// Average the strip of drawn art next to a seam into `K` buckets along /// its long axis, so the band can carry the image's colour VARIATION and diff --git a/crates/qbz-qt/qml/views/HomeView.qml b/crates/qbz-qt/qml/views/HomeView.qml index 61925599a..770a1aa57 100644 --- a/crates/qbz-qt/qml/views/HomeView.qml +++ b/crates/qbz-qt/qml/views/HomeView.qml @@ -109,6 +109,73 @@ Rectangle { return ({}) } } + // Per-URL cover patches for the two out-of-document For You rails. The + // documents are NOT republished for artwork (src/foryou_qt.rs), because a + // republish hands `model:` a new JS array and `QQuickItemView::setModel()` + // resets the rail's scroll offset AND tears down the QQmlDelegateModel — + // the "a cover batch landing mid-drag snaps the rail back" report, and this + // build's only crash signature. So this map is what fills the tiles in: the + // `coverMap` pattern AlbumView / ArtistView / QueuePanel already use, on a + // signal only this view listens to. + // + // The map is VIEW-LOCAL and this view is NOT durable: AppShell binds + // `viewLoader.source` to QbzShell.currentView, so opening an album destroys + // HomeView and the map with it, while the rails' documents are published + // once per session. That is what `refreshForyouArt()` below is for — on + // every mount Rust re-hands whatever it has already resolved. Without it the + // Radio rail and the Spotlight came back blank for the rest of the session + // and `anyForYouArtPending` never went false again, pinning the pulse Timer. + property var forYouArt: ({}) + Connections { + target: QbzHome + // ONE emit carries the WHOLE landed batch as a `{artUrl: path}` object + // (src/foryou_qt.rs): merge it, then rebind ONCE. `forYouArt` is a + // `var`, so only a new object REFERENCE notifies its dependents — an + // emit per url paid a copy of the growing map plus a sweep over every + // forYouArtOf(...) binding in both rails, per cover. + function onForyouArtReady(patchJson) { + var patch + try { + patch = JSON.parse(patchJson) + } catch (e) { + return + } + var m = root.forYouArt + var changed = false + for (var url in patch) { + if (m[url] !== patch[url]) { + m[url] = patch[url] + changed = true + } + } + if (changed) + root.forYouArt = Object.assign({}, m) // rebind needs a NEW ref + } + } + // Re-hand the resolved covers to this (possibly brand-new) instance. Cheap + // and idempotent: it re-reads the memoized cache paths off the Rust stores, + // never downloads, never republishes a document, and emits nothing at all + // when nothing is resolved yet — so the first, cold mount is a no-op and the + // in-flight download's own emit still fills the map. + Component.onCompleted: QbzHome.refreshForyouArt() + /// The patch map first, then whatever the document happened to carry (a + /// full Home reload publishes the paths it already has). + function forYouArtOf(item) { + return (item && item.artUrl && root.forYouArt[item.artUrl]) + || (item ? (item.artPath || "") : "") + } + /// Same predicate as anyItemArtPending, plus the patch map — the rails no + /// longer get their `artPath` back through a republish, so "artUrl set and + /// artPath empty" is now true forever and would pin the pulse Timer on. + function anyForYouArtPending(items) { + for (var i = 0; i < items.length; i++) { + var it = items[i] + if ((it.artUrl || "") !== "" && root.forYouArtOf(it) === "") + return true + } + return false + } + property string activeTab: "home" // Slint gates the 4th tab on the persisted `show_recommendations` pref @@ -169,8 +236,15 @@ Rectangle { // reason the pinned rows are: without them a Spotlight or Radio tile // waiting for its cover would sit on a FROZEN placeholder, because // the pulse Timer runs only while something is pending. - || root.anyItemArtPending(root.radioStations) - || root.anyItemArtPending(root.spotlight.albums || []) + // + // They use `anyForYouArtPending`, which consults the per-URL patch map + // as well: their `artPath` never comes back through a republish any + // more, so the plain `artPath === ""` probe would be true forever and + // would pin the pulse Timer on. The third term is NEW coverage — the + // Spotlight DOCUMENT itself (the 140px hero portrait) was never probed. + || root.anyForYouArtPending(root.radioStations) + || root.anyForYouArtPending(root.spotlight.albums || []) + || root.anyForYouArtPending(root.spotlight.visible === true ? [root.spotlight] : []) function anyArtPending(model) { for (var s = 0; s < model.length; s++) { if (root.anyItemArtPending(model[s].items || [])) @@ -726,7 +800,9 @@ Rectangle { seedTitle: modelData.title seedSubtitle: modelData.artist label: QbzSession.tr("RADIO", QbzSession.trRev) - artSource: modelData.artPath || "" + // The patch map, not `artPath`: the rail's document is + // published ONCE and covers arrive per-URL. + artSource: root.forYouArtOf(modelData) // Both the card body and the hover disc start the // album radio (the .slint wires `clicked` and `play` // to the same callback). @@ -743,7 +819,7 @@ Rectangle { y: 29 blockRadius: 4 pending: (modelData.artUrl || "") !== "" - coverSource: modelData.artPath || "" + coverSource: root.forYouArtOf(modelData) phase: root.skelPhase cellIndex: index settleMs: 6000 @@ -793,7 +869,15 @@ Rectangle { color: theme.surfaceElevated clip: true QbzIcon { - visible: (spot.doc.artPath || "") === "" + // The REMOTE url, not the local path — 1:1 with + // Spotlight.slint:91-98 (`spotlight-image-url == ""`). + // Gating on artPath flashed this glyph on every cold + // cache, because the path is empty until the download + // lands; the artist HAS a portrait, it just is not here + // yet, and the placeholder for that is the surface fill + // (Slint's hero is an unconditional Image over the + // surface-elevated box, so it simply draws nothing). + visible: (spot.doc.artUrl || "") === "" name: "user" width: 40 height: 40 @@ -802,7 +886,7 @@ Rectangle { } RoundedImage { anchors.fill: parent - source: spot.doc.artPath || "" + source: root.forYouArtOf(spot.doc) radius: 70 } MouseArea { @@ -891,7 +975,7 @@ Rectangle { seedSubtitle: QbzSession.tr("By {}", QbzSession.trRev) .replace("{}", spot.doc.name || "") label: QbzSession.tr("TOP TRACKS", QbzSession.trRev) - artSource: spot.doc.artPath || "" + artSource: root.forYouArtOf(spot.doc) onActivated: QbzHome.playArtistTopTracks(spot.doc.artistId || "") onPlayRequested: QbzHome.playArtistTopTracks(spot.doc.artistId || "") } @@ -902,7 +986,7 @@ Rectangle { seedTitle: spot.doc.name || "" seedSubtitle: QbzSession.tr("Qobuz Radio Station", QbzSession.trRev) label: QbzSession.tr("RADIO", QbzSession.trRev) - artSource: spot.doc.artPath || "" + artSource: root.forYouArtOf(spot.doc) onActivated: QbzHome.startArtistRadio(spot.doc.artistId || "") onPlayRequested: QbzHome.startArtistRadio(spot.doc.artistId || "") } @@ -917,7 +1001,7 @@ Rectangle { genre: modelData.album.genre year: modelData.album.year qualityTier: modelData.album.qualityTier - artSource: modelData.album.artPath || "" + artSource: root.forYouArtOf(modelData.album) artworkUrl: modelData.album.artUrl || "" isPinned: modelData.album.isPinned === true isFavorite: modelData.album.isFavorite === true diff --git a/crates/qbz-qt/src/artwork_qt.rs b/crates/qbz-qt/src/artwork_qt.rs index efe76364f..2116f6045 100644 --- a/crates/qbz-qt/src/artwork_qt.rs +++ b/crates/qbz-qt/src/artwork_qt.rs @@ -44,8 +44,16 @@ //! POC-NOTE: on download completion the WHOLE section list is republished //! (a `homeSectionsJson` swap) — per-row QAbstractListModel updates are //! the documented follow-up. No RGBA decode in Rust: QML `Image` decodes -//! `file://` asynchronously and natively. Cache eviction (`evict`, 200 MB cap -//! in the Slint app) is still not wired here — POC scale. +//! `file://` asynchronously and natively. +//! +//! ## Cache eviction — scaled derivatives only +//! +//! [`housekeeping`] bounds `~/.cache/qbz/images/scaled` (this module's OWN +//! derivative directory) at [`MAX_SCALED_BYTES`], LRU by mtime, and does the +//! one-time `.jpg` orphan sweep the `.png` key change left behind. The PARENT +//! `~/.cache/qbz/images` is the SHARED cache and keeps its own policy +//! (`qbz_cache::ImageCacheService`, SQLite `last_accessed` + the Slint app's +//! 200 MB `evict`) — do NOT add a second one for it from here. use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; @@ -500,6 +508,15 @@ pub fn scaled_path(path: &str, w: u32, h: u32) -> Option { std::hash::Hasher::finish(&hasher) )); if out.is_file() { + // Mark it used so `evict_scaled`'s mtime order is access order, not + // creation order. Best-effort: a failure only degrades eviction to + // FIFO, which costs one regeneration. (`File::set_modified` is stable + // since Rust 1.75; if `cargo check` disagrees, that is the ONE thing + // to re-check here.) + let _ = std::fs::File::options() + .write(true) + .open(&out) + .and_then(|f| f.set_modified(std::time::SystemTime::now())); return Some(out); } @@ -526,3 +543,107 @@ pub fn scaled_path(path: &str, w: u32, h: u32) -> Option { .ok()?; Some(out) } + +/// Byte ceiling for the scaled-derivative cache. Measured 2026-07-29: a warm +/// cache is 261 files / 13.7 MB at 52.6 KB average, and a fully browsed +/// session projects to ~45 MB. 64 MB leaves headroom for ~1200 derivatives +/// (every card size of ~400 distinct covers) while keeping the directory an +/// order of magnitude under the 260 MB the parent image cache already holds. +const MAX_SCALED_BYTES: u64 = 64 * 1024 * 1024; + +/// Keep `scaled/` bounded: drop the oldest derivatives past +/// [`MAX_SCALED_BYTES`]. Mtime order, oldest first — the same shape as +/// `icon_tint_qt::prune`, with one deliberate difference: `prune` caps on a +/// directory COUNT, this caps on BYTES, because a derivative's size varies 20x +/// with the drawn cell size and a file count says nothing about the footprint. +/// +/// Eviction is free of consequence: a re-request regenerates the file, so a +/// wrong victim costs one `thumbnail()` call and never a wrong pixel. The one +/// case that is NOT free is a live `RoundedImage` whose `_scaled` still names +/// a file this just unlinked — that is handled where it can be, on the QML +/// side, by RoundedImage's `Image.Error` -> clear `_scaled` + `_reqKey` arm. +/// Do not land this without that arm. +/// +/// Returns (files removed, bytes reclaimed) for the log line the verification +/// counts. +fn evict_scaled(dir: &Path) -> (usize, u64) { + let Ok(entries) = std::fs::read_dir(dir) else { + return (0, 0); + }; + let mut files: Vec<(std::time::SystemTime, u64, PathBuf)> = entries + .flatten() + .filter_map(|e| { + let meta = e.metadata().ok()?; + if !meta.is_file() { + return None; + } + Some((meta.modified().ok()?, meta.len(), e.path())) + }) + .collect(); + let total: u64 = files.iter().map(|(_, len, _)| *len).sum(); + if total <= MAX_SCALED_BYTES { + return (0, 0); + } + files.sort_by_key(|(t, _, _)| *t); + let mut over = total - MAX_SCALED_BYTES; + let (mut n, mut freed) = (0usize, 0u64); + for (_, len, path) in files { + if over == 0 { + break; + } + if std::fs::remove_file(&path).is_ok() { + over = over.saturating_sub(len); + freed += len; + n += 1; + } + } + (n, freed) +} + +/// ONE-TIME orphan sweep. `scaled_path` wrote `.jpg` before the lossless +/// change to this module's `{hash}_{w}x{h}.png` key; the extension IS the +/// cache key, so every `.jpg` here is unreachable by construction — 1259 +/// files / 10.05 MB measured 2026-07-29. Nothing can regenerate them and +/// nothing can serve them, so this is a delete, not an eviction policy. +fn sweep_orphan_scaled(dir: &Path) -> (usize, u64) { + let Ok(entries) = std::fs::read_dir(dir) else { + return (0, 0); + }; + let (mut n, mut freed) = (0usize, 0u64); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("jpg") { + continue; + } + let len = entry.metadata().map(|m| m.len()).unwrap_or(0); + if std::fs::remove_file(&path).is_ok() { + n += 1; + freed += len; + } + } + (n, freed) +} + +/// Boot-time housekeeping for the derivative cache: the one-time `.jpg` +/// orphan sweep, then the byte cap. Blocking (`read_dir` + unlinks) — call +/// from `spawn_blocking`. +/// +/// SCOPE: `images/scaled` ONLY. The parent `~/.cache/qbz/images` (260 MB) is +/// the SHARED cache owned by `qbz_cache::ImageCacheService` (SQLite +/// `last_accessed` + the Slint app's 200 MB `evict`); two processes evicting +/// the same directory on different policies is exactly what the memo at the +/// top of this file had to be written about. +pub fn housekeeping() { + let Some(dir) = dirs::cache_dir().map(|d| d.join("qbz").join("images").join("scaled")) else { + return; + }; + if !dir.is_dir() { + return; + } + let (orphans, o_bytes) = sweep_orphan_scaled(&dir); + let (evicted, e_bytes) = evict_scaled(&dir); + log::info!( + "[qbz-qt][perf] scaled cache housekeeping: {orphans} orphans ({o_bytes} B), \ + {evicted} evicted ({e_bytes} B)" + ); +} diff --git a/crates/qbz-qt/src/foryou_qt.rs b/crates/qbz-qt/src/foryou_qt.rs index b66bbf3ed..3139c7dfd 100644 --- a/crates/qbz-qt/src/foryou_qt.rs +++ b/crates/qbz-qt/src/foryou_qt.rs @@ -56,7 +56,7 @@ //! / LabelView precedent), and "Add to playlist" (`list-plus`) is likewise //! inert, exactly as in the Slint header where it carries no `clicked`. -use std::collections::HashSet; +use std::collections::{BTreeMap, HashSet}; use std::sync::{Arc, Mutex}; use cxx_qt_lib::QString; @@ -130,8 +130,103 @@ pub(crate) fn build_radio( out } -/// Store + publish the radio rail, then fetch the covers it is missing and -/// republish (the `label_qt` two-pass artwork shape). +/// The landed covers of ONE batch as a `{artUrl: file://path}` patch, instead +/// of a second full document publish. +/// +/// Two separate decisions live here. +/// +/// (a) PER URL, NOT PER DOCUMENT. The two-pass `label_qt` shape republished the +/// whole JSON when the covers arrived, which hands QML's `model:` a NEW JS +/// array; `QQuickItemView::setModel()` then resets `contentX` to 0 and tears +/// down the QQmlDelegateModel (this build's only crash signature — `main.rs` +/// documents it for the Library feed, and `LibraryView.qml` documents the cure: +/// patch in place, never re-emit the document). +/// +/// (b) ONE EMIT PER BATCH, NOT ONE PER URL. `HomeView.qml`'s `forYouArt` is a +/// `var` property, so only a NEW object reference notifies its dependents: an +/// emit per url paid one copy of the (growing) map AND one invalidation sweep +/// over every `forYouArtOf(...)` binding in both rails plus the `artPending` +/// probe — quadratic in the ~38 covers a cold For You load resolves, for 38 Qt +/// thread hops. Nothing is shown any later for it: `download_missing().await` +/// settles the WHOLE batch before the first path can be read back. +/// +/// The emit shape is the port's existing signal helper (`emit_library_favorite` +/// in main.rs): a cxx-qt signal is emitted by calling the declared Rust method +/// on `Pin<&mut T>`. +fn emit_foryou_art(patch: BTreeMap) { + if patch.is_empty() { + return; + } + let json = patch_json(&patch); + crate::home_bridge::ui(move |mut b| { + b.as_mut().foryou_art_ready(QString::from(json.as_str())); + }); +} + +fn patch_json(patch: &BTreeMap) -> String { + serde_json::to_string(patch).unwrap_or_else(|_| "{}".to_string()) +} + +/// Collect one resolved cover into the patch (blanks are not patches). +fn collect_resolved(patch: &mut BTreeMap, url: &str, path: &str) { + if !url.is_empty() && !path.is_empty() { + patch.insert(url.to_string(), path.to_string()); + } +} + +/// Every For You cover the stores have ALREADY resolved, as the same +/// `{artUrl: file://path}` patch [`emit_foryou_art`] sends — or "" when there +/// is nothing to hand over yet. +/// +/// WHY THIS EXISTS. `AppShell.qml` binds `viewLoader.source` to +/// `QbzShell.currentView`, so HomeView is DESTROYED on every navigation and its +/// per-url `forYouArt` map dies with it, while the rails' documents are +/// deliberately never republished for artwork (see [`emit_foryou_art`]) and +/// `publish_radio` / `publish_spotlight` run ONCE per session (`home_qt:: +/// load_home` <- `reload_home` <- the boot chain in main.rs; otherwise only the +/// error-retry button and a language change). Without a re-hand on mount the +/// Radio rail and the Spotlight hero + album tiles come back BLANK for the rest +/// of the session — and `anyForYouArtPending` stays true forever, which pins +/// HomeView's 900 ms skeleton pulse Timer permanently ON, i.e. it also defeats +/// the perf fix this transport exists for. +/// +/// It re-resolves the cache paths into the stores first, so a cover that landed +/// while Home was unmounted (that emit reached nobody) — or that some other +/// view downloaded — is picked up. `cached_path` is memoized, so the second and +/// later calls touch neither SQLite nor the disk. +/// +/// It NEVER downloads and NEVER republishes a document, and it returns "" when +/// nothing is resolved, so the FIRST (cold) mount emits nothing at all and +/// cannot clear a map the in-flight download is about to fill. +pub(crate) fn resolved_art_patch() -> String { + let mut patch: BTreeMap = BTreeMap::new(); + if let Ok(mut store) = RADIO.lock() { + let _ = attach_radio_art(&mut store); + for row in store.iter() { + collect_resolved(&mut patch, &row.art_url, &row.art_path); + } + } + if let Ok(mut store) = SPOTLIGHT.lock() { + if let Some(doc) = store.as_mut() { + let _ = attach_spotlight_art(doc); + collect_resolved(&mut patch, &doc.art_url, &doc.art_path); + for card in doc.albums.iter() { + collect_resolved(&mut patch, &card.art_url, &card.art_path); + } + } + } + if patch.is_empty() { + return String::new(); + } + log::debug!( + "[qbz-qt] for-you art re-handed to a fresh HomeView: {} covers", + patch.len() + ); + patch_json(&patch) +} + +/// Store + publish the radio rail ONCE, then fetch the covers it is missing +/// and hand them to the rail as a url-keyed patch (see [`emit_foryou_art`]). pub(crate) fn publish_radio(rows: Vec) { let missing = { let Ok(mut store) = RADIO.lock() else { @@ -145,11 +240,22 @@ pub(crate) fn publish_radio(rows: Vec) { return; } crate::spawn(async move { - crate::artwork_qt::download_missing(missing).await; + crate::artwork_qt::download_missing(missing.clone()).await; + // In place: the rows keep the art they already have and the landed + // covers reach the rail as one url-keyed patch. The store is still + // updated so a LATER full publish (a new Home load) carries the + // paths — and so `resolved_art_patch` can re-hand them to a HomeView + // that was destroyed and re-created meanwhile — but nothing + // republishes for artwork alone. if let Ok(mut store) = RADIO.lock() { let _ = attach_radio_art(&mut store); } - push_radio(); + let mut patch: BTreeMap = BTreeMap::new(); + for url in missing { + let path = crate::artwork_qt::cached_path(&url); + collect_resolved(&mut patch, &url, &path); + } + emit_foryou_art(patch); }); } @@ -321,13 +427,21 @@ fn publish_spotlight(doc: Option) { return; } crate::spawn(async move { - crate::artwork_qt::download_missing(missing).await; + crate::artwork_qt::download_missing(missing.clone()).await; + // Same in-place patch as the radio rail: the hero portrait and every + // album tile arrive inside ONE `foryouArtReady`, so the Spotlight + // document is never republished for artwork alone. if let Ok(mut store) = SPOTLIGHT.lock() { if let Some(doc) = store.as_mut() { let _ = attach_spotlight_art(doc); } } - push_spotlight(); + let mut patch: BTreeMap = BTreeMap::new(); + for url in missing { + let path = crate::artwork_qt::cached_path(&url); + collect_resolved(&mut patch, &url, &path); + } + emit_foryou_art(patch); }); } diff --git a/crates/qbz-qt/src/home_bridge.rs b/crates/qbz-qt/src/home_bridge.rs index 20bf240a5..d39ef81d9 100644 --- a/crates/qbz-qt/src/home_bridge.rs +++ b/crates/qbz-qt/src/home_bridge.rs @@ -192,6 +192,20 @@ pub mod qbz_home { /// Spotlight play / TOP TRACKS card — the artist's popular tracks. #[qinvokable] fn play_artist_top_tracks(self: Pin<&mut QbzHome>, artist_id: QString); + /// HomeView MOUNTED: re-hand it every For You cover already resolved. + /// + /// `AppShell.qml` binds `viewLoader.source` to `QbzShell.currentView`, + /// so HomeView is DESTROYED on every navigation and the per-url cover + /// map it keeps (`forYouArt`) dies with it — while the two rails' + /// documents are published ONCE per session and deliberately never + /// republished for artwork (see `foryouArtReady`). Without this the + /// Radio rail and the Spotlight hero + tiles are blank for the rest of + /// the session after the first navigation away, and the view's + /// "something is still pending" probe never goes false, which pins its + /// 900 ms skeleton pulse Timer on. Idempotent, emits nothing when the + /// stores hold no resolved path yet (`foryou_qt::resolved_art_patch`). + #[qinvokable] + fn refresh_foryou_art(self: Pin<&mut QbzHome>); // --- Label releases sub-view -------------------------------------- #[qinvokable] @@ -204,6 +218,28 @@ pub mod qbz_home { fn label_releases_set_group(self: Pin<&mut QbzHome>, on: bool); #[qinvokable] fn label_releases_search(self: Pin<&mut QbzHome>, query: QString); + + /// A batch of For You covers landed: a JSON `{ "": "" }` patch. Per-URL, so the rails patch the TILES instead of + /// taking a new `radioStationsJson` / `spotlightJson` document — a + /// republished document hands `model:` a new JS array and + /// `QQuickItemView::setModel()` resets the rail's scroll offset AND + /// tears down the QQmlDelegateModel, which is this build's only crash + /// signature (a null read at libQt6QmlModels[420c5], reproduced live). + /// Deliberately NOT `QbzLibrary.libraryArtworkReady`: six views listen + /// to that one and each rebuilds its whole cover map per emit. + /// + /// ONE emit per batch, not one per url: the receiving map is a QML + /// `var`, so it only notifies on a NEW object reference — an emit per + /// url copied the growing map and invalidated every cover binding in + /// both rails once per cover (quadratic in the ~38 a cold load + /// resolves) for no earlier paint, since the whole batch settles inside + /// one `download_missing().await`. See `foryou_qt::emit_foryou_art`. + /// + /// `#[auto_cxx_name]` on this block makes the QML spelling + /// `foryouArtReady`, i.e. the handler is `onForyouArtReady(patchJson)`. + #[qsignal] + fn foryou_art_ready(self: Pin<&mut QbzHome>, patch_json: QString); } impl cxx_qt::Threading for QbzHome {} @@ -434,6 +470,19 @@ impl qbz_home::QbzHome { crate::foryou_qt::spotlight_play_top_tracks(artist_id.to_string()); } + /// Emitted SYNCHRONOUSLY here rather than through `home_bridge::ui`: this + /// runs on the Qt thread already (HomeView's `Component.onCompleted`), and + /// the `ui()` hop silently no-ops until `boot()` has registered the thread, + /// which is exactly the failure mode this fix exists to remove. + pub fn refresh_foryou_art(mut self: Pin<&mut Self>) { + let patch = crate::foryou_qt::resolved_art_patch(); + if patch.is_empty() { + return; + } + self.as_mut() + .foryou_art_ready(QString::from(patch.as_str())); + } + // --- Label releases ---------------------------------------------------- pub fn label_releases_load_more(self: Pin<&mut Self>) {