Files
qbz/crates/qbz-qt/qml/immersive/QueueTabsPanel.qml
T
vicrodh 47a7641fc2 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
2026-08-18 07:48:56 -06:00

715 lines
31 KiB
QML

// QueueTabsPanel — the immersive Queue/History panel (§6.5 of the
// 2026-08-02 immersive-port contract), port of
// crates/qbz-ui/ui/immersive/ImmersiveQueuePanel.slint:253-543. ONE component
// used in BOTH placements: the FOCUS full-screen mount (mode == 5,
// `centered: false`) and the SPLIT right slot (split-panel == 3,
// `centered: true`) — mirroring Tauri's single QueuePanel.svelte.
//
// Two sub-tabs ("Queue" / "History"). The QUEUE tab shows a highlighted NOW
// PLAYING row plus a scrolling UP NEXT list sliced off the FLAT coverflow
// document; the HISTORY tab shows the most-recent-first history list. The
// playback logic is NOT duplicated — rows only fire QbzQueue invokables:
// up-next row at flat index f -> queuePlayUpcomingFlat(f - index - 1) (the
// QUEUE-WIDE flat invokable, §4.4/trap 23 — NEVER the page-local
// queuePlayUpcoming); history row i -> queuePlayHistory(i) (already
// queue-wide, most-recent-first).
//
// BADGES (verified formulas, §6.5): Queue = max(0, coverflow.tracks.length -
// coverflow.index - 1) — from the FLAT coverflow model, NOT the paged
// queueJson (:267-268); History = queueJson.history.length (:304).
//
// Covers: both documents carry artUrl only; resolution rides the shared
// artwork pipeline (QbzShell.sidebarArtworkWindow dispatch +
// QbzLibrary.libraryArtworkReady -> coverMap), the CoverflowPanel.qml:81-104
// idiom. The Slint `upcoming-count` art-decode window widening is
// Slint-Rust-internal — nothing to port (D9).
//
// The panel re-pulls the queue snapshot when it becomes visible
// (QueueState.panel-opened, :253-255) — with Qt's eager instantiation +
// visible-gating the visible false->true edge IS the conditional mount.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Item {
id: root
// true in the split (centered) placement, false in focus full-screen
// (:244).
property bool centered: true
// --- Data -------------------------------------------------------------
// Guarded try/catch (trap 15); the bridges seed full-shape defaults so
// the fallbacks are paranoia, not the steady state.
readonly property var queueDoc: {
try {
var d = JSON.parse(QbzQueue.queueJson)
if (d)
return d
} catch (e) {
}
return ({})
}
readonly property var cfDoc: {
try {
var d = JSON.parse(QbzQueue.coverflowJson)
if (d && d.tracks)
return d
} catch (e) {
}
return { "index": 0, "tracks": [] }
}
// Doc FIELD reads go through plain functions, NEVER chained readonly
// vars: `readonly property var x: queueDoc.field` stays UNDEFINED in the
// app context (measured 2026-08-03, offscreen probe) — the binding is
// never installed. The TrackInfoModal.qml parseDoc() idiom re-reads the
// doc per call and the binding dependency is captured through the call.
function historyRows() {
return root.queueDoc.history || []
}
function hasCurrentTrack() {
return root.queueDoc.hasCurrent === true
}
function currentTrack() {
return hasCurrentTrack() ? root.queueDoc.current : null
}
// The upcoming entries are the slice of the coverflow tracks AFTER the
// now-playing index; their COUNT drives the tab badge + the section
// header. Floored at 0 (:266-268).
readonly property int upcomingCount: Math.max(0,
root.cfDoc.tracks.length - root.cfDoc.index - 1)
// The active sub-tab. Slint stores this in the shared QueueState.tab;
// panel-local state is the same observable behavior within the panel
// (the desktop QueuePanel's tab state is likewise its own).
property int tab: 0
// Becoming visible = the Slint conditional mount -> re-pull the queue
// (:253-255).
onVisibleChanged: if (visible) QbzQueue.queuePanelOpened()
// --- 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()
onCfDocChanged: root.dispatchCovers()
onQueueDocChanged: root.dispatchCovers()
function dispatchCovers() {
var urls = []
var tracks = root.cfDoc.tracks || []
for (var i = 0; i < tracks.length; i++)
if (tracks[i].artUrl)
urls.push(tracks[i].artUrl)
var hist = root.historyRows()
for (var j = 0; j < hist.length; j++)
if (hist[j].artUrl)
urls.push(hist[j].artUrl)
var cur = root.currentTrack()
if (cur && cur.artUrl)
urls.push(cur.artUrl)
if (urls.length > 0)
QbzShell.sidebarArtworkWindow(JSON.stringify(urls))
}
function coverFor(row) {
return (row && row.artUrl) ? (root.coverMap[row.artUrl] || "") : ""
}
// Content width: focus full-screen caps to a comfortable reading column
// (min(vw-96, 720)); split already lives in the right half and fills it
// (:260-262).
readonly property real contentWidth: root.centered
? root.width : Math.min(root.width - 96, 720)
// --- Inline components -------------------------------------------------
// Explicit "E" badge — the immersive (white-on-glass) variant (:28-44).
component ImmExplicitBadge: Rectangle {
width: 16
height: 16
radius: 3
color: "#29ffffff"
Text {
anchors.centerIn: parent
text: "E"
color: "#d9ffffff"
font.pixelSize: 9
font.weight: Font.DemiBold // 600
}
}
// One Queue / History sub-tab — plain TEXT, NO pill background (ADR-008);
// the active tab is marked by a 2px underline (:51-98).
component SubTab: Rectangle {
id: subTab
property string label: ""
property int count: 0
property bool active: false
signal clicked()
width: tabRow.implicitWidth
height: 34
color: "transparent"
Row {
id: tabRow
height: parent.height
spacing: 5
Text {
text: subTab.label
color: subTab.active ? "#ffffff"
: (tabArea.containsMouse ? "#e6ffffff" : "#99ffffff")
font.pixelSize: 18
font.weight: subTab.active ? Font.Bold : Font.Medium // 700 / 500
Behavior on color { ColorAnimation { duration: 150 } }
}
Text {
visible: subTab.count > 0
text: "(" + subTab.count + ")"
color: subTab.active ? "#b3ffffff" : "#66ffffff"
font.pixelSize: 15
font.weight: Font.Medium
Behavior on color { ColorAnimation { duration: 150 } }
}
}
// Active underline — 2px flush to the bottom (:84-92).
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 2
radius: 1
color: subTab.active ? "#ffffff"
: (tabArea.containsMouse ? "#33ffffff" : "transparent")
Behavior on color { ColorAnimation { duration: 150 } }
}
MouseArea {
id: tabArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: subTab.clicked()
}
}
// Section header — small, uppercase, tracked-out (:102-107). The msgids
// are already caps.
component SectionLabel: Text {
color: "#b3ffffff"
font.pixelSize: 13
font.weight: Font.Bold // 700
font.letterSpacing: 1.2
}
// One UP NEXT / History row (:113-238): 56px, 40x40 cover, title/artist,
// duration, hover-revealed circular play button. Fires play() on click
// OR the play button — the mount maps it to the right QbzQueue call.
component ImmQueueRow: Rectangle {
id: immRow
property var item: null
property bool current: false // NOW PLAYING highlight
property string artSource: ""
signal play()
width: parent ? parent.width : 0
height: 56
radius: 10
// `.track-item.current`: brighter fill + 1px hairline so it reads as
// the anchor over ANY album atmosphere; plain rows lift on hover
// (:122-127).
color: current ? "#24ffffff"
: (rowArea.containsMouse ? "#1affffff" : "transparent")
border.width: current ? 1 : 0
border.color: "#3dffffff"
Behavior on color { ColorAnimation { duration: 150 } }
// The row TouchArea is declared FIRST (Slint :130-133) so the
// content — the hover-revealed play button's own MouseArea — stacks
// ABOVE it; declared last it would swallow the button's clicks.
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: immRow.play()
}
Row {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 8
spacing: 12
// 40x40 cover with a 1px inset hairline (:142-159).
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 40
height: 40
radius: 5
color: "#14ffffff"
border.width: 1
border.color: "#1fffffff"
clip: true
Image {
anchors.fill: parent
source: immRow.artSource
visible: source !== "" && status === Image.Ready
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
}
// Title + artist (:161-190).
Column {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - 40 - 12 - 36 - 12
spacing: 3
Row {
spacing: 6
// The Row's width must be EXPLICIT (the Column's): the
// title's elide width binds against it, so an implicit
// (content-derived) Row width loops the binding and the
// title collapses to 0px — measured over RFB 2026-08-03
// (rows rendered artist-only).
width: parent.width
Text {
text: item ? (item.title || "") : ""
color: "#ffffff"
font.pixelSize: 17
font.weight: current ? Font.DemiBold : Font.Medium // 600 / 500
elide: Text.ElideRight
width: Math.min(implicitWidth,
parent.width - (expBadge.visible ? 22 : 0))
}
ImmExplicitBadge {
id: expBadge
visible: item && item.explicit === true
anchors.verticalCenter: parent.verticalCenter
}
}
Text {
text: item ? (item.artist || "") : ""
color: "#b3ffffff"
font.pixelSize: 15
elide: Text.ElideRight
width: parent.width
}
}
// Duration + hover-revealed play affordance share one trailing
// slot (:192-236).
Item {
anchors.verticalCenter: parent.verticalCenter
width: 36
height: 30
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: item ? (item.duration || "") : ""
color: "#99ffffff"
font.pixelSize: 14
font.weight: Font.Medium
opacity: (!current && rowArea.containsMouse) ? 0.0 : 1.0
Behavior on opacity { NumberAnimation { duration: 150 } }
}
// 30px circular play button; hidden for the current row
// (:213-235).
Rectangle {
visible: !current
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 30
height: 30
radius: 15
color: playArea.containsMouse ? "#59ffffff" : "#33ffffff"
opacity: rowArea.containsMouse || playArea.containsMouse ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
Behavior on color { ColorAnimation { duration: 120 } }
QbzIcon {
name: "play-fill"
width: 13
height: 13
anchors.centerIn: parent
anchors.horizontalCenterOffset: 1
tintName: "white"
}
MouseArea {
id: playArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: immRow.play()
}
}
}
}
}
// --- Centered content column (:271-279) ---------------------------------
// padding-top 56 (clear the top chrome) / padding-bottom 132 (clear the
// floating player) — applied in BOTH placements, as Slint; the 8px side
// padding is focus-only (:277-278).
Item {
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.topMargin: 56
anchors.bottomMargin: 132
width: root.contentWidth
Column {
anchors.fill: parent
anchors.leftMargin: root.centered ? 0 : 8
anchors.rightMargin: root.centered ? 0 : 8
spacing: 16
// --- Sub-tab header (:287-354) ----------------------------------
// Tabs LEFT, Clear flush at the TRAILING edge; a fixed 34px row
// height puts the labels and the button on one baseline.
Item {
width: parent.width
height: 34
Row {
anchors.left: parent.left
height: parent.height
spacing: 18
SubTab {
label: QbzSession.tr("Queue", QbzSession.trRev)
count: root.upcomingCount
active: root.tab === 0
onClicked: root.tab = 0
}
SubTab {
label: QbzSession.tr("History", QbzSession.trRev)
count: root.historyRows().length
active: root.tab === 1
onClicked: root.tab = 1
}
}
// Clear-queue button — Queue tab only, when there is
// something upcoming (:316-353). NO pill — a soft bordered
// rectangle (ADR-008).
Rectangle {
visible: root.tab === 0 && root.upcomingCount > 0
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: clearRow.implicitWidth + 24
height: 32
radius: 8
color: clearArea.containsMouse ? "#29ffffff" : "#14ffffff"
border.width: 1
border.color: clearArea.containsMouse ? "#40ffffff" : "#26ffffff"
Behavior on color { ColorAnimation { duration: 150 } }
Behavior on border.color { ColorAnimation { duration: 150 } }
Row {
id: clearRow
anchors.centerIn: parent
spacing: 7
QbzIcon {
name: "trash-2"
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: "white"
opacity: clearArea.containsMouse ? 1.0 : 0.8
}
Text {
text: QbzSession.tr("Clear", QbzSession.trRev)
color: clearArea.containsMouse ? "#ffffff" : "#ccffffff"
font.pixelSize: 15
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: clearArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzQueue.queueClear()
}
}
}
// Header underline (:356-359).
Rectangle {
width: parent.width
height: 1
color: "#21ffffff"
}
// --- Body (:361-552) --------------------------------------------
Item {
width: parent.width
height: parent.height - 34 - 1 - 32
// ===== QUEUE tab =====
Item {
anchors.fill: parent
visible: root.tab === 0
// NOW PLAYING + UP NEXT, OR the empty state (:368).
Column {
anchors.fill: parent
visible: root.hasCurrentTrack() || root.upcomingCount > 0
spacing: 16
// NOW PLAYING section (:372-381).
Column {
id: nowCol
visible: root.hasCurrentTrack()
width: parent.width
spacing: 10
SectionLabel {
text: QbzSession.tr("NOW PLAYING", QbzSession.trRev)
}
ImmQueueRow {
item: root.currentTrack()
current: true
// The unified now-playing cover is sharper;
// fall back to the row's own art.
artSource: QbzPlayer.npArtworkPath !== ""
? QbzPlayer.npArtworkPath
: root.coverFor(root.currentTrack())
}
}
// UP NEXT section (:383-455). Fills the body left
// under the NOW PLAYING section.
Column {
visible: root.upcomingCount > 0
width: parent.width
height: parent.height
- (nowCol.visible ? nowCol.implicitHeight + 16 : 0)
spacing: 10
Row {
id: upHeader
spacing: 8
SectionLabel {
text: QbzSession.tr("UP NEXT", QbzSession.trRev)
}
SectionLabel {
text: "· " + root.upcomingCount
color: "#80ffffff"
font.weight: Font.Medium
font.letterSpacing: 0.4
}
}
Item {
width: parent.width
height: parent.height - upHeader.implicitHeight - 10
Flickable {
id: upFlick
anchors.fill: parent
// Trailing gutter so rows never tuck
// under the overlaid scrollbar (:413).
contentWidth: width - 16
contentHeight: upList.implicitHeight
boundsBehavior: Flickable.StopAtBounds
clip: true
Column {
id: upList
width: upFlick.contentWidth
// spacing 0 — the inter-row gap is
// baked into each VISIBLE wrapper's
// height (60 = 56 row + 4 gap), with
// the row anchored to the bottom.
// History entries (idx <= index)
// collapse to 0px so a long history
// 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: 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
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)
}
}
}
}
}
}
QbzScrollBar {
target: upFlick
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
}
}
// Empty state — nothing playing and nothing upcoming
// (:459-476).
Column {
visible: !root.hasCurrentTrack() && root.upcomingCount === 0
anchors.centerIn: parent
width: parent.width
spacing: 8
Text {
width: parent.width
text: QbzSession.tr("Your queue is empty", QbzSession.trRev)
color: "#ccffffff"
font.pixelSize: 19
font.weight: Font.DemiBold // 600
horizontalAlignment: Text.AlignHCenter
}
Text {
width: parent.width
text: QbzSession.tr("Play an album or track to get started", QbzSession.trRev)
color: "#80ffffff"
font.pixelSize: 15
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
}
}
// ===== HISTORY tab =====
Item {
anchors.fill: parent
visible: root.tab === 1
Column {
anchors.fill: parent
visible: root.historyRows().length > 0
spacing: 10
// Section header — parity with QueueSidebar
// "RECENTLY PLAYED" (:485-499).
Row {
id: histHeader
spacing: 8
SectionLabel {
text: QbzSession.tr("RECENTLY PLAYED", QbzSession.trRev)
}
SectionLabel {
text: "· " + root.historyRows().length
color: "#80ffffff"
font.weight: Font.Medium
font.letterSpacing: 0.4
}
}
Item {
width: parent.width
height: parent.height - histHeader.implicitHeight - 10
Flickable {
id: histFlick
anchors.fill: parent
contentWidth: width - 16
contentHeight: histList.implicitHeight
boundsBehavior: Flickable.StopAtBounds
clip: true
Column {
id: histList
width: histFlick.contentWidth
spacing: 4
// history is already most-recent-first,
// and queuePlayHistory(i) indexes it
// directly — no coverflow math here
// (:512-518).
Repeater {
model: root.historyRows()
delegate: ImmQueueRow {
required property int index
required property var modelData
item: modelData
artSource: root.coverFor(modelData)
onPlay: QbzQueue.queuePlayHistory(index)
}
}
}
}
QbzScrollBar {
target: histFlick
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
}
Column {
visible: root.historyRows().length === 0
anchors.centerIn: parent
width: parent.width
spacing: 8
Text {
width: parent.width
text: QbzSession.tr("Nothing played yet", QbzSession.trRev)
color: "#ccffffff"
font.pixelSize: 19
font.weight: Font.DemiBold
horizontalAlignment: Text.AlignHCenter
}
Text {
width: parent.width
text: QbzSession.tr("Tracks you play will appear here", QbzSession.trRev)
color: "#80ffffff"
font.pixelSize: 15
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
}
}
}
}
}
}