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:
2 files changed
+114
-45
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user