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
This commit is contained in:
vicrodh committed 2026-08-18 07:21:03 -06:00
1 parent a6b373cb5a
commit 66ba12bd53
2 files changed
+114 -45

No files matched your search

+77 -37
View File
@@ -69,6 +69,28 @@ Item {
// 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))
@@ -230,27 +252,38 @@ Item {
// (:194-199). Paints only offset in (-3.5, -0.5).
Repeater {
model: root.cfDoc.tracks
delegate: CoverCard {
// 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: index - root.scroll
offset: off
baseArt: root.baseArt
pitch: root.pitch
stageCx: fanStage.cx
stageHeight: fanStage.height
artSource: root.coverFor(modelData)
hasArt: artSource !== ""
// Left side only; hides the center-index card (GHOST
// FIX) and the whole right half (:209-213).
paintSide: off < -0.5 && 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 (:216-221).
onNavigate: {
if (index < root.centerIdx)
QbzQueue.queuePlayHistory(root.centerIdx - 1 - index)
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)
}
}
}
}
}
@@ -262,26 +295,33 @@ Item {
// offset in (0.5, 3.5).
Repeater {
model: root.revTracks
delegate: CoverCard {
// 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 - index
readonly property real off: orig - root.scroll
offset: off
baseArt: root.baseArt
pitch: root.pitch
stageCx: fanStage.cx
stageHeight: fanStage.height
artSource: root.coverFor(modelData)
hasArt: artSource !== ""
paintSide: off > 0.5 && 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 (orig > root.centerIdx)
QbzQueue.queuePlayUpcomingFlat(orig - root.centerIdx - 1)
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)
}
}
}
}
}
+37 -8
View File
@@ -258,10 +258,33 @@ Item {
visible: QbzShaderScene.scene === 0
&& QbzImmersive.viewMode === 0 && QbzImmersive.mode === 1
}
CoverflowPanel {
// LOADER-GATED, and this one is not a nicety — it is the fix for a
// 28-second frozen UI at startup.
//
// This overlay is mounted UNCONDITIONALLY by AppShell (see its comment
// there: while closed it is "an invisible, non-interactive Item"), and
// `visible: false` stops an item from PAINTING, never from being BUILT.
// CoverflowPanel's model is the flat whole-queue document
// (`QbzQueue.coverflowJson` — unlike the queue PANEL's, it is not
// paginated), and it runs TWO Repeaters over it: the forward list and a
// reversed copy. So every launch built two CoverCards per queue entry,
// for a panel nobody had opened.
//
// Measured 2026-08-17 on a real session with 1935 persisted queue rows:
// ~4000 CoverCards on the UI thread at boot. The app came up frozen for
// ~28 s at 120% CPU — deterministic, identical across every build, and
// "fixed" by moving ~/.local/share/qbz aside, which is what made it look
// like corrupted state rather than a queue the UI could not carry. The
// stacks showed the QML engine forcing GC while allocating strings.
//
// `active` repeats the visibility expression on purpose: the panel must
// not exist while it is not the immersive mode being shown.
Loader {
anchors.fill: parent
visible: QbzShaderScene.scene === 0
active: QbzShaderScene.scene === 0
&& QbzImmersive.viewMode === 0 && QbzImmersive.mode === 2
visible: active
sourceComponent: CoverflowPanel { }
}
SpectrumPanel {
anchors.fill: parent
@@ -288,11 +311,14 @@ Item {
}
// FOCUS Queue (mode 5, B4 §6.5): the same QueueTabsPanel as the split
// placement, full-screen (centered: false -> min(vw-96, 720) column).
QueueTabsPanel {
// Loader-gated for the reason spelled out on CoverflowPanel above: it
// reads the same unpaginated whole-queue document.
Loader {
anchors.fill: parent
visible: QbzShaderScene.scene === 0
active: QbzShaderScene.scene === 0
&& QbzImmersive.viewMode === 0 && QbzImmersive.mode === 5
centered: false
visible: active
sourceComponent: QueueTabsPanel { centered: false }
}
// --- The shared immersive lyrics engine + document (§6.6, trap 22) ------
@@ -531,10 +557,13 @@ Item {
// mount, centered: true fills the slot). The panel
// self-fires queuePanelOpened() when it becomes visible
// (:253-255).
QueueTabsPanel {
// Loader-gated like the focus mount above — same
// component, same whole-queue document.
Loader {
anchors.fill: parent
visible: QbzImmersive.splitPanel === 3
centered: true
active: QbzImmersive.splitPanel === 3
visible: active
sourceComponent: QueueTabsPanel { centered: true }
}
}
}