feat(qbz-qt): Large NPB dock — sidebar space reservation + FFT spectrum band

The Large dock was a bare cover overlay: the sidebar knew nothing about it,
so the playlist rows kept running to the sidebar's bottom edge and sat
underneath the album art. Sidebar.slint reserves the in-sidebar portion of
the dock; the port had dropped that.

Reserve it the same way, from a single source of truth. QbzShell gains
largeDockHeight (shell_bridge.rs owns the arithmetic: 9 top + band + gap +
208 art + 4 bottom), the sidebar subtracts largeDockHeight - npbLargeHeight
from the playlist list, and AppShell pins the overlay at height -
largeDockHeight. The 112px bar height becomes a theme token so the shell and
the reservation cannot drift apart.

The band the cover was missing needs the FFT tap, so wire it: the runtime is
built with_visualizer, viz_qt.rs runs the frontend-agnostic producer from
qbz-audio and drains it at 30fps into a new QbzViz domain bridge as typed
QList<f32>. Bars (28 mirrored columns over the 14 active bins), Waveform (48
downsampled L columns) and Energy (5 bands) render as Slint does, the cover's
eye button toggles the band, clicking it cycles the mode, and both persist to
ui_prefs.

Cost is zero when unused: the tap starts disabled, the eye toggle gates
capture, the drain thread parks while off, and the transport is mirrored so a
paused player parks the producer instead of re-analyzing a stale buffer. The
QML binds bar HEIGHTS against static-model Repeaters — the lists are replaced
per frame, so a model binding would rebuild every delegate 30 times a second.

Adds the eye/eye-off icon pair to the seven baked tint variants.

18/18 tests; offscreen smoke clean (0 QML errors, home published).

cc
This commit is contained in:
vicrodh committed 2026-07-28 12:05:37 -06:00
1 parent 9a4fec6b70
commit 70bd337d16
27 files changed
+978 -45

No files matched your search

@@ -0,0 +1,261 @@
// SidebarNowPlayingDock — the Large-NPB (mode 3) cover + spectrum dock: the
// L's vertical arm. QML port of crates/qbz-ui/ui/shell/SidebarNowPlayingDock.slint.
//
// Mounted as a ROOT overlay by AppShell, pinned flush to the window
// bottom-left (NOT inside the sidebar — that floated it above the bar).
// Layout, bottom-up: a square album cover (= the fed width) at the very
// bottom; ABOVE it a compact spectrum band the user can hide with the
// top-right eye button on the cover.
//
// HEIGHT IS LOAD-BEARING. `QbzShell.largeDockHeight` (shell_bridge.rs) is the
// single source of truth and the layout below is derived from the SAME
// constants:
// ON = 9 top pad + 42 band + 10 gap + 208 art + 4 bottom gap = 273
// OFF = 9 top pad + 208 art + 4 bottom gap = 221
// Sidebar.qml reserves `largeDockHeight - npb height` so the playlist list
// stops ABOVE the cover, and AppShell pins this overlay at
// `height - largeDockHeight`. Change the arithmetic in one place and the
// cover slides off the window edge.
//
// PERF (the rule the ambient field already proved): the FFT streams replace
// their QList ~30x/s, so a Repeater bound to QbzViz.bars would rebuild every
// delegate every frame. The Repeaters below use STATIC models (column
// counts) and bind only each bar's HEIGHT — a tick re-evaluates bindings and
// instantiates nothing. Only the ACTIVE mode is instantiated (the modes sit
// behind `active:` Loaders), and the capture tap is gated by vizShouldRun, so
// a hidden band costs zero CPU: the producer parks and the drain thread sleeps.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Item {
id: root
QbzTheme { id: theme }
// Fed by AppShell (sidebar content width). The cover is square.
// height comes from the bridge — never recompute it here.
height: QbzShell.largeDockHeight
// Fed by AppShell so the ambient-vs-solid surface rule lives in ONE place.
property bool ambientOn: false
readonly property bool bandOn: QbzShell.largeVisualizerOn
readonly property int bandHeight: 42
readonly property int padTop: 9
readonly property int bandGap: 10
// The cover's top edge: below the band when it is shown.
readonly property int artY: padTop + (bandOn ? bandHeight + bandGap : 0)
// Capture gate — the Slint AppShell's `viz-should-run`. OFF stops the FFT
// producer outright (viz_qt.rs parks it), so leaving Large or hiding the
// band costs nothing. The transport is mirrored separately (a paused
// player parks the producer via now_playing.rs).
readonly property bool vizShouldRun: root.visible && root.bandOn && root.Window.active
onVizShouldRunChanged: QbzViz.setEnabled(root.vizShouldRun)
Component.onCompleted: QbzViz.setEnabled(root.vizShouldRun)
Component.onDestruction: QbzViz.setEnabled(false)
// Top hairline between the playlists and the dock.
Rectangle {
x: 0
y: 0
width: root.width
height: 1
color: theme.borderSubtle
}
// ---- Spectrum band -----------------------------------------------------
Rectangle {
id: band
visible: root.bandOn
x: 0
y: root.padTop
width: root.width
height: root.bandHeight
radius: theme.radiusMd
color: "#59000000"
clip: true
// Album-derived gradient, reusing the ambient triad (ambient_qt.rs)
// instead of a second album-color pipeline — the Slint dock reads
// ImmersiveState.spectrum-primary/secondary, which is the same idea.
readonly property color topColor: QbzShell.ambientPrimary
readonly property color bottomColor: QbzShell.ambientSecondary
// Mode 0 — Bars: 28 MIRRORED columns over the 14 ACTIVE bins (the
// 16-bin FFT leaves {1, 15} empty; SpectrumPanel.slint parity).
Loader {
anchors.fill: parent
active: QbzShell.largeSpectrumMode === 0
sourceComponent: Item {
id: barsMode
readonly property var activeBins: [0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
readonly property real slot: band.width / 28
Repeater {
model: 28
delegate: Rectangle {
required property int index
// Mirror: columns 14..27 replay 13..0.
readonly property int bin: index < 14
? barsMode.activeBins[index]
: barsMode.activeBins[27 - index]
readonly property real amp: Math.max(0, Math.min(1, QbzViz.bars[bin] || 0))
x: index * barsMode.slot + 1
width: barsMode.slot - 2
height: Math.max(2, amp * band.height)
y: band.height - height
radius: 1
gradient: Gradient {
GradientStop { position: 0.0; color: band.topColor }
GradientStop { position: 1.0; color: band.bottomColor }
}
Behavior on height {
NumberAnimation { duration: 90; easing.type: Easing.OutQuad }
}
}
}
}
}
// Mode 1 — Waveform: 48 downsampled L columns, mirrored about the
// band's vertical centre (the L half is samples 0..255).
Loader {
anchors.fill: parent
active: QbzShell.largeSpectrumMode === 1
sourceComponent: Item {
id: waveMode
readonly property real slot: band.width / 48
Repeater {
model: 48
delegate: Rectangle {
required property int index
readonly property real amp: Math.min(1, Math.abs(QbzViz.waveform[index * 5] || 0))
x: index * waveMode.slot + 1
width: waveMode.slot - 2
height: Math.max(2, amp * band.height)
y: (band.height - height) / 2
radius: 1
color: band.topColor
Behavior on height {
NumberAnimation { duration: 70; easing.type: Easing.OutQuad }
}
}
}
}
}
// Mode 2 — Energy: the 5 semantic bands (sub-bass .. air).
Loader {
anchors.fill: parent
active: QbzShell.largeSpectrumMode === 2
sourceComponent: Item {
id: energyMode
readonly property real slot: band.width / 5
Repeater {
model: 5
delegate: Rectangle {
required property int index
readonly property real amp: Math.max(0, Math.min(1, QbzViz.energy[index] || 0))
x: index * energyMode.slot + 3
width: energyMode.slot - 6
height: Math.max(2, amp * band.height)
y: band.height - height
radius: 1
gradient: Gradient {
GradientStop { position: 0.0; color: band.topColor }
GradientStop { position: 1.0; color: band.bottomColor }
}
Behavior on height {
NumberAnimation { duration: 90; easing.type: Easing.OutQuad }
}
}
}
}
}
// Click the band → cycle Bars -> Waveform -> Energy (persisted).
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: QbzShell.largeCycleSpectrum()
}
}
// ---- Album cover -------------------------------------------------------
// Drop-shadow approximation of the Slint dock's 24px blur (two offset
// rects — QtQuick.Effects renders nothing on the software path, see
// RoundedImage.qml).
Rectangle {
x: 0
y: root.artY + 4
width: root.width
height: root.width
radius: theme.radiusMd
color: "#66000000"
}
Rectangle {
id: art
x: 0
y: root.artY
width: root.width
height: root.width
radius: theme.radiusMd
color: root.ambientOn ? theme.surfaceMainA22 : theme.surfaceMain
clip: true
RoundedImage {
visible: QbzPlayer.npHasTrack
anchors.fill: parent
source: QbzPlayer.npArtworkPath
radius: theme.radiusMd
}
QbzIcon {
visible: !QbzPlayer.npHasTrack
name: "music"
width: root.width * 0.32
height: root.width * 0.32
anchors.centerIn: parent
tintName: "muted"
}
// Hover tracker over the whole cover — reveals the toggle. The toggle
// ALSO stays visible while the band is hidden, so it can be brought back.
MouseArea {
id: coverHover
anchors.fill: parent
hoverEnabled: true
}
// Single top-right toggle: show/hide the spectrum band.
Rectangle {
width: 28
height: 28
x: parent.width - width - 8
y: 8
radius: 8
color: eyeHover.containsMouse ? "#2effffff" : "#80000000"
border.width: 1
border.color: "#33ffffff"
opacity: (coverHover.containsMouse || eyeHover.containsMouse || !root.bandOn) ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 140 } }
QbzIcon {
anchors.centerIn: parent
name: root.bandOn ? "eye" : "eye-off"
width: 15
height: 15
tintName: "primary"
}
MouseArea {
id: eyeHover
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzShell.largeToggleVisualizer()
}
}
}
}