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

+14 -42
View File
@@ -66,7 +66,7 @@ Rectangle {
anchors.bottom: parent.bottom
// Mode-aware height (AppShell.slint:396): Small collapses to one
// header-tall row; New/Classic/Large keep the full 112px.
height: QbzShell.npbMode === 2 ? theme.npbSmallHeight : 112
height: QbzShell.npbMode === 2 ? theme.npbSmallHeight : theme.npbLargeHeight
}
Sidebar {
@@ -154,51 +154,23 @@ Rectangle {
}
// --- Large NPB (mode 3) cover dock (phase 18) -------------------------
// The L's vertical arm: the square now-playing cover pinned flush to
// the window bottom-left over the sidebar (SidebarNowPlayingDock.slint,
// AppShell.slint:747). Only while Large is ACTIVE (mode 3 + sidebar
// open). The spectrum band above the cover is NOT rendered — it needs
// the FFT visualizer tap, which the POC does not wire (POC-NOTE).
// The L's vertical arm: the square now-playing cover + FFT band, pinned
// flush to the window bottom-left over the sidebar
// (SidebarNowPlayingDock.slint, AppShell.slint:747). Only while Large is
// ACTIVE (mode 3 + sidebar open).
//
// The height comes from QbzShell.largeDockHeight (it changes with the
// band's eye toggle) and Sidebar.qml reserves the same value minus the
// bar height — pinning here with a literal would desync the two.
readonly property bool largeActive: QbzShell.npbMode === 3 && QbzShell.sidebarState === 0
Rectangle {
SidebarNowPlayingDock {
visible: root.largeActive
// Equal 16px gutters inside the 240px sidebar; the cover is square,
// so this width also sets the art size.
x: 16
y: parent.height - 221
width: 208
height: 221
color: "transparent"
// Drop-shadow approximation (the Slint dock's 24px blur).
Rectangle {
x: 0
y: 17
width: 208
height: 208
radius: theme.radiusMd
color: "#66000000"
}
Rectangle {
x: 0
y: 13
width: 208
height: 208
radius: theme.radiusMd
color: 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: 66
height: 66
anchors.centerIn: parent
tintName: "muted"
}
}
y: parent.height - QbzShell.largeDockHeight
ambientOn: root.ambientOn
}
// Search cortinilla (phase 15): the live-search dropdown overlay, LAST
+19 -1
View File
@@ -32,6 +32,21 @@ Rectangle {
property bool searchOpen: false
property bool playlistsCollapsed: false
property string activePlaylistId: ""
// ---- Large-NPB dock space reservation -------------------------------
// The cover+spectrum dock is an AppShell-ROOT overlay pinned flush to the
// WINDOW bottom-left, so it extends the bar's height BELOW this sidebar
// (which stops at npb.top). Only the IN-SIDEBAR portion has to be
// reserved, and it shrinks when the band is hidden — that is why the
// height comes from the bridge and not a literal.
//
// Without this the playlist list keeps running to the sidebar's bottom
// edge and the rows sit UNDER the album art (Sidebar.slint:1145 reserves
// the same way).
readonly property bool largeDockActive: QbzShell.npbMode === 3 && QbzShell.sidebarState === 0
readonly property real dockReserve: largeDockActive
? Math.max(0, QbzShell.largeDockHeight - theme.npbLargeHeight)
: 0
// Flattened entries from the bridge + the url-keyed cover map.
readonly property var entries: parseEntries(QbzShell.sidebarJson)
property var coverMap: ({})
@@ -447,7 +462,10 @@ Rectangle {
Item {
visible: !root.playlistsCollapsed
width: parent.width
height: parent.height - y - 16
// `root.dockReserve` keeps the rows clear of the Large-NPB cover
// (0 in every other mode). Clamped so a short window shrinks the
// list to nothing instead of giving it a negative height.
height: Math.max(0, parent.height - y - 16 - root.dockReserve)
Flickable {
id: plFlick
@@ -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()
}
}
}
}