fix(qt): bound the queue panels, and make the gate catch what it missed
Follow-up to 66ba12bd5, which fixed the coverflow but left two of its
neighbours and a gate that could not see either of them.
QueueTabsPanel built one ImmQueueRow per queue track when opened —
including every collapsed 0-height history slot, which on the reported
session was 1162 of 1935. The slot stays (its height is what makes the
collapsed-history arithmetic work); the row inside is now built only
within a viewport of the visible band.
SuggestionsPanel and AppShell's QueuePanel move behind Loaders. The
queue panel was never the freeze — its rows are paginated at 40 in
Rust — but a closed panel should not be constructed, and the audit
cannot know about a cap that lives on the other side of the bridge.
Gating it also let SuggestionsPanel drop the onVisibleChanged half of
its load hook: mounting IS becoming visible now.
The audit is the real content of this commit. Written narrow — only a
model fed by unbounded user data counts, because the wide rule reports
145 sites and a gate that noisy gets switched off in a day — it then
reported the pre-fix tree CLEAN. A gate that cannot catch the bug it
was written for is worth nothing, so it was tested against that tree
and fixed on two counts:
- CROSS-FILE. The `visible:` sat in ImmersiveView.qml while the two
Repeaters over the whole queue lived in CoverflowPanel.qml. There
is now a transitive index: a component is heavy if its own file has
an unbounded view or if it mounts something that does.
- MULTI-LINE. The coverflow's gate spans two lines, so a first-line
read saw `QbzShaderScene.scene === 0`, matched no known gate, and
skipped it.
With both, it finds all five mounts on the pre-fix tree, including the
two that froze the app. Known gates also grew past tab predicates: the
coverflow was keyed on QbzImmersive.mode.
Verified on a copy of the real session state with the 1935-row queue
restored: launches to a painted Discover, idle at 1.0% CPU.
cc
This commit is contained in:
6 files changed
+174
-90
No files matched your search
@@ -545,11 +545,18 @@ Item {
|
||||
// splitPanel 2: the live Suggestions panel (§6.4). Entry
|
||||
// load at mount + reload on track change below
|
||||
// (:1574-1577, §5.5).
|
||||
SuggestionsPanel {
|
||||
// Loader-gated like the queue panels: a closed panel has
|
||||
// no business being built. Mounting IS becoming visible
|
||||
// now, so the load hook is a plain Component.onCompleted
|
||||
// — the `onVisibleChanged` pair existed only because the
|
||||
// panel was built eagerly and its mount meant nothing.
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
visible: QbzImmersive.splitPanel === 2
|
||||
onVisibleChanged: if (visible)
|
||||
QbzSuggestions.load(QbzPlayer.npTrackId)
|
||||
active: QbzImmersive.splitPanel === 2
|
||||
visible: active
|
||||
sourceComponent: SuggestionsPanel {
|
||||
Component.onCompleted: QbzSuggestions.load(QbzPlayer.npTrackId)
|
||||
}
|
||||
}
|
||||
|
||||
// splitPanel 3: the Queue + History panel, centered in
|
||||
|
||||
@@ -531,26 +531,54 @@ Item {
|
||||
// adds NO phantom top space
|
||||
// (:414-421).
|
||||
spacing: 0
|
||||
// The model is the WHOLE flat queue,
|
||||
// so the slot stays (its height is
|
||||
// what makes the collapsed-history
|
||||
// arithmetic above work) but the ROW
|
||||
// inside is built only near the
|
||||
// viewport. Without this, opening this
|
||||
// tab on a long queue built one
|
||||
// ImmQueueRow per track — including
|
||||
// every 0-height history slot, which
|
||||
// is the majority of them. See
|
||||
// qbz-nix-docs/qt-frontend/
|
||||
// 2026-08-18-unbounded-build-sweep.
|
||||
Repeater {
|
||||
model: root.cfDoc.tracks
|
||||
delegate: Item {
|
||||
id: upSlot
|
||||
required property int index
|
||||
required property var modelData
|
||||
width: upList.width
|
||||
height: index > root.cfDoc.index ? 60 : 0
|
||||
visible: index > root.cfDoc.index
|
||||
ImmQueueRow {
|
||||
height: upSlot.index > root.cfDoc.index ? 60 : 0
|
||||
visible: upSlot.index > root.cfDoc.index
|
||||
// y of this row inside upList,
|
||||
// derived the same way the
|
||||
// heights above stack it.
|
||||
readonly property real slotY:
|
||||
(upSlot.index - root.cfDoc.index - 1) * 60
|
||||
// One screenful of margin each
|
||||
// way, so a flick reveals rows
|
||||
// that already exist.
|
||||
readonly property bool inBand:
|
||||
upSlot.visible
|
||||
&& upSlot.slotY > upFlick.contentY - upFlick.height
|
||||
&& upSlot.slotY < upFlick.contentY + 2 * upFlick.height
|
||||
Loader {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
item: parent.modelData
|
||||
artSource: root.coverFor(parent.modelData)
|
||||
// Flat index f ->
|
||||
// queuePlayUpcomingFlat(
|
||||
// f - index - 1)
|
||||
// (:429-441, trap 23).
|
||||
onPlay: QbzQueue.queuePlayUpcomingFlat(
|
||||
parent.index - root.cfDoc.index - 1)
|
||||
active: upSlot.inBand
|
||||
sourceComponent: ImmQueueRow {
|
||||
item: upSlot.modelData
|
||||
artSource: root.coverFor(upSlot.modelData)
|
||||
// Flat index f ->
|
||||
// queuePlayUpcomingFlat(
|
||||
// f - index - 1)
|
||||
// (:429-441, trap 23).
|
||||
onPlay: QbzQueue.queuePlayUpcomingFlat(
|
||||
upSlot.index - root.cfDoc.index - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1055,11 +1055,26 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
CardMenu {
|
||||
id: rowMenu
|
||||
menuWidth: 224
|
||||
entries: root.menuModel()
|
||||
onPicked: function (a) { root.menuAction(a) }
|
||||
// LAZY. This is a QtQuick.Controls Popup with a Repeater over its
|
||||
// entries, and it was built EAGERLY for every row — so a long list
|
||||
// constructed one whole popup per visible row, and rebuilt them all
|
||||
// on every scroll step, to show a menu only ever opened on click.
|
||||
// Same lazy-Loader idiom this file already uses for the clipboard
|
||||
// helper and the info modal.
|
||||
Loader {
|
||||
id: rowMenuLoader
|
||||
active: false
|
||||
sourceComponent: CardMenu {
|
||||
menuWidth: 224
|
||||
entries: root.menuModel()
|
||||
onPicked: function (a) { root.menuAction(a) }
|
||||
}
|
||||
}
|
||||
/// Build the popup on first use, then open it. Called by `openRowMenu`
|
||||
/// below, which owns the empty-menu guard.
|
||||
function openRowMenuLazy(anchor, x, y) {
|
||||
rowMenuLoader.active = true
|
||||
rowMenuLoader.item.openAtCursor(anchor, x, y)
|
||||
}
|
||||
|
||||
// TrackContextMenu.slint, in its order. Every row here reaches a live
|
||||
@@ -1074,7 +1089,7 @@ Rectangle {
|
||||
function openRowMenu(anchor, x, y) {
|
||||
if (root.menuModel().length === 0)
|
||||
return
|
||||
rowMenu.openAtCursor(anchor, x, y)
|
||||
root.openRowMenuLazy(anchor, x, y)
|
||||
}
|
||||
|
||||
function menuModel() {
|
||||
|
||||
@@ -248,14 +248,20 @@ Rectangle {
|
||||
NumberAnimation { duration: 160; easing.type: Easing.InOutQuad }
|
||||
}
|
||||
|
||||
QueuePanel {
|
||||
// Loader-gated: while the drawer is closed the panel is not built.
|
||||
// Its rows are paginated (PAGE_SIZE 40) so this one was never the
|
||||
// freeze, but a closed panel should not be constructed on principle —
|
||||
// and the audit cannot know about a cap that lives in Rust.
|
||||
Loader {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
height: QbzShell.lyricsOpen
|
||||
? (QbzShell.queueOpen ? parent.height / 2 : 0)
|
||||
: parent.height
|
||||
visible: QbzShell.queueOpen
|
||||
active: QbzShell.queueOpen
|
||||
visible: active
|
||||
sourceComponent: QueuePanel { }
|
||||
}
|
||||
Rectangle {
|
||||
visible: QbzShell.queueOpen && QbzShell.lyricsOpen
|
||||
|
||||
@@ -301,42 +301,56 @@ Rectangle {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: function (mouse) { lrMenu.openAtCursor(lrMenuArea, mouse.x, mouse.y) }
|
||||
onClicked: function (mouse) { feedRow.openLrMenu(lrMenuArea, mouse.x, mouse.y) }
|
||||
}
|
||||
}
|
||||
}
|
||||
CardMenu {
|
||||
id: lrMenu
|
||||
menuWidth: 196
|
||||
entries: feedRow.item.kind === "track"
|
||||
? feedRow.view.trackMenuModel(feedRow.item, feedRow.favorite)
|
||||
: feedRow.item.kind === "album" ? [
|
||||
{ "label": QbzSession.tr("Open album", QbzSession.trRev), "icon": "library-big", "action": "open" },
|
||||
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
|
||||
{ "label": feedRow.favorite ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev),
|
||||
"icon": feedRow.favorite ? "heart-filled" : "heart", "action": "favorite" },
|
||||
]
|
||||
: feedRow.item.kind === "artist" ? [
|
||||
{ "label": QbzSession.tr("Go to artist", QbzSession.trRev), "icon": "user", "action": "go-artist" },
|
||||
]
|
||||
: [
|
||||
{ "label": QbzSession.tr("Open", QbzSession.trRev), "icon": "list-music", "action": "open" },
|
||||
{ "label": feedRow.favorite ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev),
|
||||
"icon": feedRow.favorite ? "heart-filled" : "heart", "action": "favorite" },
|
||||
]
|
||||
onPicked: function (a) {
|
||||
if (feedRow.item.kind === "track") { feedRow.view.trackAction(feedRow, a); return }
|
||||
if (a === "open") {
|
||||
if (feedRow.item.kind === "album") QbzAlbum.openAlbum(feedRow.item.id)
|
||||
else if (feedRow.item.kind === "playlist") QbzBridge.openPlaylist(feedRow.item.id)
|
||||
else if (feedRow.item.kind === "label") QbzHome.openLabel(feedRow.item.id)
|
||||
} else if (a === "play") {
|
||||
if (feedRow.item.kind === "album") QbzPlayer.playAlbum(feedRow.item.id)
|
||||
} else if (a === "go-artist") {
|
||||
QbzArtist.openArtist(feedRow.item.id)
|
||||
} else if (a === "favorite") {
|
||||
feedRow.toggleFavorite()
|
||||
// LAZY. This is a QtQuick.Controls Popup with a Repeater over its
|
||||
// entries, and it was built EAGERLY for every row — so a long list
|
||||
// constructed one whole popup per visible row, and rebuilt them all
|
||||
// on every scroll step, to show a menu only ever opened on click.
|
||||
// Same lazy-Loader idiom this file already uses for the clipboard
|
||||
// helper and the info modal.
|
||||
Loader {
|
||||
id: lrMenuLoader
|
||||
active: false
|
||||
sourceComponent: CardMenu {
|
||||
menuWidth: 196
|
||||
entries: feedRow.item.kind === "track"
|
||||
? feedRow.view.trackMenuModel(feedRow.item, feedRow.favorite)
|
||||
: feedRow.item.kind === "album" ? [
|
||||
{ "label": QbzSession.tr("Open album", QbzSession.trRev), "icon": "library-big", "action": "open" },
|
||||
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
|
||||
{ "label": feedRow.favorite ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev),
|
||||
"icon": feedRow.favorite ? "heart-filled" : "heart", "action": "favorite" },
|
||||
]
|
||||
: feedRow.item.kind === "artist" ? [
|
||||
{ "label": QbzSession.tr("Go to artist", QbzSession.trRev), "icon": "user", "action": "go-artist" },
|
||||
]
|
||||
: [
|
||||
{ "label": QbzSession.tr("Open", QbzSession.trRev), "icon": "list-music", "action": "open" },
|
||||
{ "label": feedRow.favorite ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev),
|
||||
"icon": feedRow.favorite ? "heart-filled" : "heart", "action": "favorite" },
|
||||
]
|
||||
onPicked: function (a) {
|
||||
if (feedRow.item.kind === "track") { feedRow.view.trackAction(feedRow, a); return }
|
||||
if (a === "open") {
|
||||
if (feedRow.item.kind === "album") QbzAlbum.openAlbum(feedRow.item.id)
|
||||
else if (feedRow.item.kind === "playlist") QbzBridge.openPlaylist(feedRow.item.id)
|
||||
else if (feedRow.item.kind === "label") QbzHome.openLabel(feedRow.item.id)
|
||||
} else if (a === "play") {
|
||||
if (feedRow.item.kind === "album") QbzPlayer.playAlbum(feedRow.item.id)
|
||||
} else if (a === "go-artist") {
|
||||
QbzArtist.openArtist(feedRow.item.id)
|
||||
} else if (a === "favorite") {
|
||||
feedRow.toggleFavorite()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Build the popup on first use, then open it.
|
||||
function openLrMenu(anchor, x, y) {
|
||||
lrMenuLoader.active = true
|
||||
lrMenuLoader.item.openAtCursor(anchor, x, y)
|
||||
}
|
||||
}
|
||||
@@ -144,7 +144,7 @@ Item {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: function (mouse) { rowMenu.openAtCursor(moreArea, mouse.x, mouse.y) }
|
||||
onClicked: function (mouse) { root.openRowMenu(moreArea, mouse.x, mouse.y) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,44 +187,58 @@ Item {
|
||||
anchors.fill: parent
|
||||
enabled: !root.selectMode
|
||||
acceptedButtons: Qt.RightButton
|
||||
onClicked: function (mouse) { rowMenu.openAtCursor(rcArea, mouse.x, mouse.y) }
|
||||
onClicked: function (mouse) { root.openRowMenu(rcArea, mouse.x, mouse.y) }
|
||||
}
|
||||
|
||||
CardMenu {
|
||||
id: rowMenu
|
||||
menuWidth: 220
|
||||
entries: {
|
||||
var m = [
|
||||
{ "label": QbzSession.tr("Play now", QbzSession.trRev), "icon": "play-fill", "action": "play" },
|
||||
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
|
||||
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
|
||||
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
|
||||
{ "label": QbzSession.tr("Add to mixtape", QbzSession.trRev), "icon": "cassette-tape", "action": "add-to-mixtape" },
|
||||
{ "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "action": "add-to-playlist" },
|
||||
]
|
||||
if (root.canGoAlbum) m.push({ "label": QbzSession.tr("Go to album", QbzSession.trRev), "icon": "disc-3", "action": "go-album" })
|
||||
if (root.canGoArtist) m.push({ "label": QbzSession.tr("Go to artist", QbzSession.trRev), "icon": "user", "action": "go-artist" })
|
||||
return m
|
||||
}
|
||||
onPicked: function (a) {
|
||||
if (a === "play") root.playRequested()
|
||||
// LOCAL-mode adds: one-element selection through the bulk entry
|
||||
// point, which owns the only source-aware row -> ref resolver
|
||||
// (see the file header).
|
||||
else if (a === "add-to-playlist" || a === "add-to-mixtape") {
|
||||
QbzLocal.bulkAction("track", JSON.stringify([String(root.item.id)]), a)
|
||||
// LAZY. This is a QtQuick.Controls Popup with a Repeater over its
|
||||
// entries, and it was built EAGERLY for every row — so a long list
|
||||
// constructed one whole popup per visible row, and rebuilt them all
|
||||
// on every scroll step, to show a menu only ever opened on click.
|
||||
// Same lazy-Loader idiom this file already uses for the clipboard
|
||||
// helper and the info modal.
|
||||
Loader {
|
||||
id: rowMenuLoader
|
||||
active: false
|
||||
sourceComponent: CardMenu {
|
||||
menuWidth: 220
|
||||
entries: {
|
||||
var m = [
|
||||
{ "label": QbzSession.tr("Play now", QbzSession.trRev), "icon": "play-fill", "action": "play" },
|
||||
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
|
||||
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
|
||||
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
|
||||
{ "label": QbzSession.tr("Add to mixtape", QbzSession.trRev), "icon": "cassette-tape", "action": "add-to-mixtape" },
|
||||
{ "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "action": "add-to-playlist" },
|
||||
]
|
||||
if (root.canGoAlbum) m.push({ "label": QbzSession.tr("Go to album", QbzSession.trRev), "icon": "disc-3", "action": "go-album" })
|
||||
if (root.canGoArtist) m.push({ "label": QbzSession.tr("Go to artist", QbzSession.trRev), "icon": "user", "action": "go-artist" })
|
||||
return m
|
||||
}
|
||||
else if (a === "go-album") {
|
||||
QbzLocal.openAlbum(root.item.albumId)
|
||||
QbzShell.navigateTo("localalbum")
|
||||
} else if (a === "go-artist") {
|
||||
// Local/Plex artists have no catalog id — a NAME route into
|
||||
// the Local Library Artists tab (local_album_actions.rs).
|
||||
QbzLocal.openArtistByName(root.item.artist)
|
||||
QbzShell.navigateTo("local")
|
||||
} else {
|
||||
root.enqueueRequested(a)
|
||||
onPicked: function (a) {
|
||||
if (a === "play") root.playRequested()
|
||||
// LOCAL-mode adds: one-element selection through the bulk entry
|
||||
// point, which owns the only source-aware row -> ref resolver
|
||||
// (see the file header).
|
||||
else if (a === "add-to-playlist" || a === "add-to-mixtape") {
|
||||
QbzLocal.bulkAction("track", JSON.stringify([String(root.item.id)]), a)
|
||||
}
|
||||
else if (a === "go-album") {
|
||||
QbzLocal.openAlbum(root.item.albumId)
|
||||
QbzShell.navigateTo("localalbum")
|
||||
} else if (a === "go-artist") {
|
||||
// Local/Plex artists have no catalog id — a NAME route into
|
||||
// the Local Library Artists tab (local_album_actions.rs).
|
||||
QbzLocal.openArtistByName(root.item.artist)
|
||||
QbzShell.navigateTo("local")
|
||||
} else {
|
||||
root.enqueueRequested(a)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Build the popup on first use, then open it.
|
||||
function openRowMenu(anchor, x, y) {
|
||||
rowMenuLoader.active = true
|
||||
rowMenuLoader.item.openAtCursor(anchor, x, y)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user