Files
qbz/crates/qbz-qt/qml/immersive/CoverflowPanel.qml
T
vicrodh 66ba12bd53 fix(qt): stop a long queue freezing the app at launch
A session with 1935 persisted queue rows came up frozen for ~28s at
120% CPU, every launch, deterministically. It looked like corrupted
local state — moving ~/.local/share/qbz aside "fixed" it — but nothing
was corrupt: that is simply a queue the UI could not carry.

The chain. AppShell mounts ImmersiveView unconditionally (its own
comment: while closed it is "an invisible, non-interactive Item"), and
`visible: false` stops an item painting, never building. CoverflowPanel
reads the flat WHOLE-queue document — unlike the queue panel's, which
paginates at 40 — and runs two Repeaters over it, the forward list and
a reversed copy. So every launch built ~4000 CoverCards, each an image
with a masked layer, for a panel nobody had opened.

Two gates, because either alone leaves it broken:

  - CoverflowPanel and both QueueTabsPanel mounts move behind a Loader
    keyed on the condition they already carried in `visible:`. That is
    the launch freeze.
  - The coverflow delegates become zero-size, non-clipping slots whose
    CoverCard exists only within |offset| < 6.5 (the fan paints 3.5;
    the margin covers the 320ms ease). That is opening it, which the
    Loader alone would still freeze.

The reference does create one element per row and hides |offset| > 3
(CoverflowPanel.slint), and that is fine there: a Slint element is
cheap where a QML delegate is not. Copying its shape literally is what
cost the 28 seconds.

Verified against the real session state, queue restored, not a
synthetic one: 120% CPU -> 2.9%, and a screenshot of Discover fully
painted where it used to sit on the skeleton.

cc
2026-08-18 07:21:03 -06:00

362 lines
17 KiB
QML

// CoverflowPanel — FOCUS mode 2, the cheap classic-iPhone coverflow (§6.2 of
// the 2026-08-02 immersive-port contract), port of
// crates/qbz-ui/ui/immersive/CoverflowPanel.slint:125-285.
//
// ARCHITECTURE (Slint header :4-28, binding):
// 1. PERSISTENT covers. QbzQueue.coverflowJson is the WHOLE flat queue
// [history oldest..newest, NOW, upcoming...], rebuilt by Rust ONLY on
// id-sequence change (§4.4) — a track advance moves only `index`, so it
// NEVER rebuilds the Repeaters and NEVER re-decodes a visible cover.
// 2. ANIMATED SCROLL. One float `scroll` bound to the doc's int `index`
// with a 320ms ease-in-out Behavior (:152-153). When Rust moves the
// index, `scroll` EASES to it and the fan repaints only during the
// 320ms, then idles. NO continuous tick, NO Timer.
// 3. TRANSFORM-ONLY layout. Each card derives offset = i - scroll;
// x = center + offset*pitch; size = baseArt * scale(|offset|); a
// per-depth black overlay dims on an INNER Rectangle (never root
// opacity). |offset| > 3.2 drops the card (:75). FLAT scaled cards —
// no rotateY/skew/perspective (owner accepted flat, :24).
// 4. NAVIGATION. Left = history -> QbzQueue.queuePlayHistory(center-1-i)
// (:219-221); right = upcoming -> QbzQueue.queuePlayUpcomingFlat(
// orig-center-1) (:249-251, §4.4 — the queue-wide flat invokable, NEVER
// the page-local queuePlayUpcoming, trap 23). The center card is
// non-interactive, full-res cover (:259-273).
//
// GEOMETRY (:144-147): baseArt = max(160, min(vh-372, vw*0.52, 560))
// (reserve 372 = pad-top 52 + info ~160 + spacing 28 + player clearance
// 132); pitch = baseArt*0.42. Card scale max(0.5, 1-0.18*|off|) (:66), dim
// min(0.55, |off|*0.18) (:69), y = (stageH-slot)/2 - 12 (:82), radius 8,
// near-center shadow 28/#00000066/8 (:88-90).
//
// Z-ORDER: LEFT Repeater iterates the FORWARD model (near-left declared
// last -> paints on top); RIGHT iterates the REVERSED model (near-upcoming
// declared last, :225-233); the CENTER card is a separate topmost instance
// (a Repeater can't reorder by data, :255-258). The paint-side windows
// ((-3.5,-0.5) / (0.5,3.5)) hide each Repeater's center-index GHOST copy —
// the dedicated center card is the ONLY center cover (:209-213).
//
// COVERS: the doc carries artUrl only (§4.4); resolution rides the shared
// artwork pipeline — QbzShell.sidebarArtworkWindow(urls) dispatch +
// QbzLibrary.libraryArtworkReady -> coverMap, the QueuePanel.qml:154-196
// idiom. The center card prefers QbzPlayer.npArtworkPath (the unified
// now-playing cover, sharper), falling back to the model row's cover while
// it resolves (:158-161,:266-271).
import QtQuick
import QtQuick.Effects
import QtQuick.Window
import com.blitzfc.qbz
import "../theme"
Item {
id: root
// --- Data: the FLAT coverflow document (§4.4) ---------------------------
// Guarded try/catch (trap 15); the bridge seeds a full-shape default
// ({"index":0,"tracks":[]}, queue_bridge.rs:143) so the fallback is
// paranoia, not the steady state.
property var cfDoc: {
try {
var d = JSON.parse(QbzQueue.coverflowJson)
if (d && d.tracks)
return d
} catch (e) {
}
return { "index": 0, "tracks": [] }
}
readonly property int centerIdx: root.cfDoc.index
readonly property int cfLen: root.cfDoc.tracks.length
// Reversed copy for the RIGHT Repeater (:234).
readonly property var revTracks: root.cfDoc.tracks.slice().reverse()
/// CREATION band. The fan PAINTS |offset| < 3.5 (~7 covers); this is the
/// wider band in which a CoverCard is allowed to EXIST.
///
/// Why it exists: the model is the whole flat queue, and a CoverCard is an
/// Image + a masked layer + text + a hit area. On a real session with 1935
/// persisted queue rows the two Repeaters below were building ~4000 of them
/// on the UI thread, which froze the app for ~28 s at launch — the panel
/// did not even have to be open, because an unmounted-but-declared overlay
/// is still BUILT (AppShell mounts ImmersiveView unconditionally).
///
/// The reference does create one element per row (CoverflowPanel.slint
/// hides |offset| > 3 rather than skipping it), and that is fine THERE: a
/// Slint element is cheap where a QML delegate is not. Copying its shape
/// literally is what cost the 28 s.
///
/// Wider than the paint band on purpose: `scroll` eases over 320 ms on a
/// track change, so the cards it is about to reveal must already exist or
/// they would pop in mid-animation.
function inBand(off) {
return off > -6.5 && off < 6.5
}
// --- Geometry (:144-147) ------------------------------------------------
readonly property real baseArt: Math.max(160,
Math.min(Math.min(root.height - 372, root.width * 0.52), 560))
readonly property real pitch: root.baseArt * 0.42
// D2 (contract 04 §4): the CENTER card is a large slot (up to 560 CSS
// px) — report its size so the large-art feed re-resolves per bucket;
// the card below binds large-feed-first with the row cover as last
// fallback. Bucketed in Rust; invisible panel writes nothing (pulse law).
function requestArtSize() {
if (root.visible)
QbzPlayer.requestNpArtworkSize(Math.round(root.baseArt))
}
onBaseArtChanged: requestArtSize()
onVisibleChanged: requestArtSize()
// --- Animated scroll — the ONLY motion (:152-153) -----------------------
property real scroll: root.cfDoc.index
Behavior on scroll { NumberAnimation { duration: 320; easing.type: Easing.InOutQuad } }
// --- Cover resolution (shared artwork pipeline) -------------------------
property var coverMap: ({})
Connections {
target: QbzLibrary
function onLibraryArtworkReady(key, path) {
var m = root.coverMap
m[key] = path
root.coverMap = Object.assign({}, m)
}
}
Component.onCompleted: {
root.dispatchCovers()
requestArtSize() // D2 initial large-art request
}
onCfDocChanged: root.dispatchCovers()
function dispatchCovers() {
var tracks = root.cfDoc.tracks
var urls = []
for (var i = 0; i < tracks.length; i++)
if (tracks[i].artUrl)
urls.push(tracks[i].artUrl)
if (urls.length > 0)
QbzShell.sidebarArtworkWindow(JSON.stringify(urls))
}
function coverFor(row) {
return (row && row.artUrl) ? (root.coverMap[row.artUrl] || "") : ""
}
// No-shader renderer detection, verbatim from SpectrumBand.qml (the
// near-center drop shadow is a MultiEffect blur; no shaders -> plain
// offset rect).
readonly property bool _noShaders: GraphicsInfo.api === GraphicsInfo.Software
|| GraphicsInfo.api === GraphicsInfo.Null
// --- One flat coverflow card (:46-123) ----------------------------------
// ALL geometry derives from `offset` + the stage props — when `scroll`
// eases, every card eases to its new transform with NO model change and
// NO source reassignment. `dim` is on an INNER Rectangle, never root
// opacity (:109-116).
component CoverCard: Item {
id: card
property string artSource: ""
property bool hasArt: false
property real offset: 0 // signed distance from center (i - scroll)
property real baseArt: 0 // depth-0 (center) edge length
property real pitch: 0 // x spacing between adjacent depths
property real stageCx: 0 // window-x of the center
property real stageHeight: 0
property bool interactive: false
// Whether THIS card paints its slot (:55-60). The fan is split into
// a LEFT Repeater, a RIGHT Repeater and the dedicated center card;
// each Repeater card sets this true only inside its own side's
// offset window so a slot is never double-drawn (GHOST FIX).
property bool paintSide: true
signal navigate()
readonly property real ao: Math.abs(card.offset)
// Step-down scale: center 1.0 -> ~0.5 at |offset| >= ~2.8 (:66).
readonly property real scale: Math.max(0.5, 1.0 - 0.18 * card.ao)
readonly property real slot: card.baseArt * card.scale
// Per-depth black overlay: 0 at center, up to 0.55 outward (:69).
readonly property real dim: Math.min(0.55, card.ao * 0.18)
// Only ~7 cards near the center paint (:75).
visible: card.ao <= 3.2 && card.paintSide
width: card.slot
height: card.slot
// Centered on (stageCx + offset*pitch); small upward bias to leave
// room for the song card below (:81-82).
x: card.stageCx + card.offset * card.pitch - card.slot / 2
y: (card.stageHeight - card.slot) / 2 - 12
// Static drop shadow on (near-)center cards only — 28/#00000066/8,
// NEVER animated (:88-90).
Rectangle {
visible: card.ao < 0.5
anchors.fill: parent
anchors.topMargin: 8
radius: 8
color: "#66000000"
layer.enabled: !root._noShaders
layer.effect: MultiEffect {
blurEnabled: true
blurMax: 32
blur: 0.875 // 28/32
}
}
// Neutral placeholder while the cover is unresolved (:93-99).
Rectangle {
visible: !card.hasArt
anchors.fill: parent
radius: 8
color: "#14ffffff"
}
RoundedImage {
visible: card.hasArt
anchors.fill: parent
radius: 8
source: card.artSource
}
// Per-depth attenuation on an INNER Rectangle (:110-116).
Rectangle {
anchors.fill: parent
radius: 8
color: "#000000"
opacity: card.dim
}
// Side cards navigate; the center is non-interactive (:119-122).
MouseArea {
anchors.fill: parent
enabled: card.interactive && card.ao > 0.5
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: card.navigate()
}
}
// --- Balanced composition: fan stage + track-info in ONE centered
// column (:163-178): pad-top 52, pad-bottom 132 (player clearance),
// spacing 28, center-aligned at any window size.
Item {
anchors.fill: parent
anchors.topMargin: 52
anchors.bottomMargin: 132
Column {
anchors.centerIn: parent
width: root.width
spacing: 28
// --- Cover fan stage (:181-192) --------------------------------
Item {
id: fanStage
width: root.width
// Fixed height holds the center cover footprint + headroom.
height: root.baseArt + 56
readonly property real cx: root.width / 2
// LEFT (history) Repeater — forward model, ascending index,
// so the near-left cover is declared last and paints ON TOP
// (:194-199). Paints only offset in (-3.5, -0.5).
Repeater {
model: root.cfDoc.tracks
// The delegate is a ZERO-SIZE, NON-CLIPPING slot: the card
// inside places itself absolutely off `stageCx`, so the
// wrapper must stay at (0,0) and must not clip or the fan
// would collapse. Declaration order still decides z, so
// the near-left cover is still declared last.
delegate: Item {
id: lSlot
required property int index
required property var modelData
readonly property real off: lSlot.index - root.scroll
Loader {
active: root.inBand(lSlot.off)
sourceComponent: CoverCard {
offset: lSlot.off
baseArt: root.baseArt
pitch: root.pitch
stageCx: fanStage.cx
stageHeight: fanStage.height
artSource: root.coverFor(lSlot.modelData)
hasArt: artSource !== ""
// Left side only; hides the center-index card
// (GHOST FIX) and the whole right half (:209-213).
paintSide: lSlot.off < -0.5 && lSlot.off > -3.5
interactive: true
// i < centerIdx -> a history cover. The flat
// model is oldest-first while queuePlayHistory
// is most-recent-first: hist = center-1-i.
onNavigate: {
if (lSlot.index < root.centerIdx)
QbzQueue.queuePlayHistory(root.centerIdx - 1 - lSlot.index)
}
}
}
}
}
// RIGHT (upcoming) Repeater — REVERSED model: rev index j is
// original flat index orig = cfLen-1-j, so the loop walks
// far-upcoming -> near-upcoming and the near-upcoming cover
// is declared last (paints ON TOP, :225-233). Paints only
// offset in (0.5, 3.5).
Repeater {
model: root.revTracks
// Same zero-size, non-clipping slot as the left side.
delegate: Item {
id: rSlot
required property int index
required property var modelData
readonly property int orig: root.cfLen - 1 - rSlot.index
readonly property real off: rSlot.orig - root.scroll
Loader {
active: root.inBand(rSlot.off)
sourceComponent: CoverCard {
offset: rSlot.off
baseArt: root.baseArt
pitch: root.pitch
stageCx: fanStage.cx
stageHeight: fanStage.height
artSource: root.coverFor(rSlot.modelData)
hasArt: artSource !== ""
paintSide: rSlot.off > 0.5 && rSlot.off < 3.5
interactive: true
// orig > centerIdx -> an upcoming cover:
// up_index = orig - center - 1 (:246-251), the
// QUEUE-WIDE flat invokable (trap 23).
onNavigate: {
if (rSlot.orig > root.centerIdx)
QbzQueue.queuePlayUpcomingFlat(rSlot.orig - root.centerIdx - 1)
}
}
}
}
}
// CENTER card — drawn LAST so it paints over its neighbors
// (:255-273). Bound to the current index's offset (eases to
// 0 with `scroll`). Prefers the size-resolved large
// now-playing cover (D2), then the small feed, then the
// model row's cover while they resolve. Non-interactive.
CoverCard {
readonly property string rowCover: root.centerIdx >= 0
&& root.centerIdx < root.cfLen
? root.coverFor(root.cfDoc.tracks[root.centerIdx]) : ""
offset: root.centerIdx - root.scroll
baseArt: root.baseArt
pitch: root.pitch
stageCx: fanStage.cx
stageHeight: fanStage.height
artSource: QbzPlayer.npArtworkPathLarge !== ""
? QbzPlayer.npArtworkPathLarge
: (QbzPlayer.npArtworkPath !== ""
? QbzPlayer.npArtworkPath : rowCover)
hasArt: artSource !== ""
interactive: false
}
}
// --- Track info (shared, homologated across the panels) --------
// Sits directly below the fan in the SAME centered column so the
// fan + metadata read as one balanced group (:276-283).
ImmersiveTrackMeta {
width: root.width
equalizerTint: "#7c3aed"
}
}
}
}