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

+2 -1
View File
@@ -35,7 +35,7 @@ fn main() {
.qt_module("QuickControls2")
.qml_module(QmlModule {
uri: "com.blitzfc.qbz",
rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs"],
rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs"],
qml_files: &[
"qml/LoginScreen.qml",
"qml/Main.qml",
@@ -79,6 +79,7 @@ fn main() {
"qml/shell/PlayerBar.qml",
"qml/shell/QueuePanel.qml",
"qml/shell/Sidebar.qml",
"qml/shell/SidebarNowPlayingDock.qml",
"qml/shell/ViewModeMenu.qml",
"qml/theme/QbzIcon.qml",
"qml/theme/QbzScrollBar.qml",
+1
View File
@@ -46,6 +46,7 @@ ApplicationWindow {
QbzPlayer.boot()
QbzQueue.boot()
QbzHome.boot()
QbzViz.boot()
QbzBridge.boot()
}
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" />
<path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
<path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" />
<path d="m2 2 20 20" />
</svg>

After

Width:  |  Height:  |  Size: 480 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
<circle cx="12" cy="12" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 336 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" />
<path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
<path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" />
<path d="m2 2 20 20" />
</svg>

After

Width:  |  Height:  |  Size: 480 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
<circle cx="12" cy="12" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 336 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" />
<path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
<path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" />
<path d="m2 2 20 20" />
</svg>

After

Width:  |  Height:  |  Size: 480 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
<circle cx="12" cy="12" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 336 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" />
<path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
<path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" />
<path d="m2 2 20 20" />
</svg>

After

Width:  |  Height:  |  Size: 480 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
<circle cx="12" cy="12" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 336 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" />
<path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
<path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" />
<path d="m2 2 20 20" />
</svg>

After

Width:  |  Height:  |  Size: 480 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
<circle cx="12" cy="12" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 336 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" />
<path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
<path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" />
<path d="m2 2 20 20" />
</svg>

After

Width:  |  Height:  |  Size: 480 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
<circle cx="12" cy="12" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 336 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" />
<path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
<path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" />
<path d="m2 2 20 20" />
</svg>

After

Width:  |  Height:  |  Size: 480 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
<circle cx="12" cy="12" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 336 B

+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()
}
}
}
}
+4
View File
@@ -135,4 +135,8 @@ QtObject {
// Small now-playing bar total height (ShellState.npb-small-height,
// npb-small-extra is 0 on Linux).
readonly property int npbSmallHeight: 42
// Every other bar mode (New / Classic / Large) — AppShell.slint:396.
// The Large dock's sidebar reservation subtracts this, so it must be the
// SAME value the shell pins the bar to.
readonly property int npbLargeHeight: 112
}
+143
View File
@@ -0,0 +1,143 @@
//! QbzLibrary — Library view domain bridge (phase 23 split of the QbzBridge
//! God-object; the pattern is documented in main.rs). Props: the merged-feed
//! document + counts + loading/error. Invokables: reload, windowed artwork
//! dispatch, favorite/pin toggles. Signals: artwork-ready / favorite-changed
//! / pin-changed (emitted through the ui() hop like before).
use std::pin::Pin;
use std::sync::OnceLock;
use cxx_qt::CxxQtThread;
use cxx_qt::Threading as _;
use cxx_qt_lib::QString;
#[cxx_qt::bridge]
pub mod qbz_library {
extern "C++" {
include!("cxx-qt-lib/qstring.h");
type QString = cxx_qt_lib::QString;
}
#[auto_cxx_name]
extern "RustQt" {
#[qobject]
#[qml_element]
#[qml_singleton]
// --- Library view --------------------------------------------------
#[qproperty(bool, library_loading)]
#[qproperty(QString, library_error)]
// One JSON document: the FULL merged feed (tabs/search/sort/source
// filters derive QML-side from the parsed array — see library_qt.rs
// for the rationale + the measured parse cost).
#[qproperty(QString, library_json)]
// {tracks, albums, artists, playlists, labels, all} — tab badges.
#[qproperty(QString, library_counts_json)]
type QbzLibrary = super::QbzLibraryRust;
/// Registers this object's Qt-thread hop (Main.qml boots EVERY
/// domain singleton; only QbzSession.boot also fires crate::on_boot).
#[qinvokable]
fn boot(self: Pin<&mut QbzLibrary>);
/// Library retry button / manual refresh.
#[qinvokable]
fn reload_library(self: Pin<&mut QbzLibrary>);
/// Windowed artwork: the grid/list reports the mounted window as a
/// JSON array of artKeys (tab views filter the feed, so raw indices
/// don't map); Rust dispatches covers for those keys (id-keyed via
/// `libraryArtworkReady`).
#[qinvokable]
fn library_artwork_window(self: Pin<&mut QbzLibrary>, keys_json: QString);
/// Card heart: toggle favorite (Qobuz API or the local store,
/// routed by id shape); the result arrives via
/// `libraryFavoriteChanged`.
#[qinvokable]
fn library_toggle_favorite(self: Pin<&mut QbzLibrary>, kind: QString, id: QString);
/// Emitted when a dispatched cover lands on disk (id-keyed —
/// `{kind}:{id}`); QML updates its artwork map.
#[qsignal]
fn library_artwork_ready(self: Pin<&mut QbzLibrary>, key: QString, path: QString);
/// Emitted after a heart toggle (optimistic flip + rollback).
#[qsignal]
fn library_favorite_changed(self: Pin<&mut QbzLibrary>, key: QString, value: bool);
/// AlbumCard pin badge: toggle pin (album/artist/playlist).
#[qinvokable]
fn toggle_pin(self: Pin<&mut QbzLibrary>, kind: QString, id: QString, title: QString, subtitle: QString, artwork_url: QString);
/// Emitted after a pin toggle (`{kind}:{id}` key like artKey).
#[qsignal]
fn pin_changed(self: Pin<&mut QbzLibrary>, key: QString, value: bool);
}
impl cxx_qt::Threading for QbzLibrary {}
}
use qbz_library::QbzLibrary;
/// Rust side of the library bridge (plain storage, phase-1 pattern).
pub struct QbzLibraryRust {
library_loading: bool,
library_error: QString,
library_json: QString,
library_counts_json: QString,
}
impl Default for QbzLibraryRust {
fn default() -> Self {
Self {
library_loading: false,
library_error: QString::default(),
library_json: QString::from("[]"),
library_counts_json: QString::from("{}"),
}
}
}
// ---------------------------------------------------------------------------
// The UI hop (bridges/mod.rs pattern)
// ---------------------------------------------------------------------------
static QT_THREAD: OnceLock<CxxQtThread<QbzLibrary>> = OnceLock::new();
/// Queue a library-bridge mutation onto the Qt event loop (no-op before
/// boot registers the thread).
pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzLibrary>) + Send + 'static) {
if let Some(thread) = QT_THREAD.get() {
let _ = thread.queue(f);
}
}
impl qbz_library::QbzLibrary {
pub fn boot(self: Pin<&mut Self>) {
if QT_THREAD.set(self.qt_thread()).is_err() {
log::warn!("[qbz-qt] library Qt thread already registered");
}
}
pub fn reload_library(self: Pin<&mut Self>) {
crate::reload_library();
}
pub fn library_artwork_window(self: Pin<&mut Self>, keys_json: QString) {
crate::library_artwork_window(keys_json.to_string());
}
pub fn library_toggle_favorite(self: Pin<&mut Self>, kind: QString, id: QString) {
crate::library_toggle_favorite(kind.to_string(), id.to_string());
}
pub fn toggle_pin(self: Pin<&mut Self>, kind: QString, id: QString, title: QString, subtitle: QString, artwork_url: QString) {
crate::toggle_pin(
kind.to_string(),
id.to_string(),
title.to_string(),
subtitle.to_string(),
artwork_url.to_string(),
);
}
}
+38 -1
View File
@@ -25,6 +25,7 @@ mod shell_bridge;
mod player_bridge;
mod queue_bridge;
mod home_bridge;
mod viz_bridge;
mod artwork_qt;
mod bridge;
mod home_qt;
@@ -45,6 +46,7 @@ mod settings_qt;
mod sidebar_qt;
mod theme_qt;
mod integrations_qt;
mod viz_qt;
use std::pin::Pin;
use std::sync::{Arc, Mutex, OnceLock};
@@ -842,6 +844,29 @@ pub(crate) fn npb_set_mode(mode: i32) {
});
}
/// Large dock, cover eye button: show/hide the FFT band. Persists the pref,
/// republishes the dock height (the Sidebar's reservation and AppShell's pin
/// both read it), and gates the capture tap — the band being hidden is what
/// stops the FFT producer, so an unused visualizer costs zero CPU.
pub(crate) fn large_toggle_visualizer() {
let on = !settings_qt::large_visualizer_on();
settings_qt::set_large_visualizer_on(on);
let height = shell_bridge::large_dock_height(on);
log::info!("[qbz-qt] large visualizer -> {on} (dock height {height})");
viz_qt::set_enabled(on);
shell_bridge::ui(move |mut b| {
b.as_mut().set_large_visualizer_on(on);
b.as_mut().set_large_dock_height(height);
});
}
/// Large dock, band click: cycle Bars -> Waveform -> Energy.
pub(crate) fn large_cycle_spectrum() {
let mode = settings_qt::set_large_spectrum_mode((settings_qt::large_spectrum_mode() + 1) % 3);
log::info!("[qbz-qt] large spectrum mode -> {mode}");
shell_bridge::ui(move |mut b| b.as_mut().set_large_spectrum_mode(mode));
}
/// Artist-card overlay play (ArtistGridCard): Popular tracks with the
/// studio-discography fallback.
pub(crate) fn play_artist_card(artist_id: String) {
@@ -1355,7 +1380,19 @@ fn main() {
let tokio_runtime = Runtime::new().expect("failed to build the tokio runtime");
let _ = TOKIO.set(tokio_runtime);
let runtime = Arc::new(AppRuntime::new(LoggingAdapter::new("[qbz-qt]")));
// `with_visualizer` == `new` plus a VisualizerTap wired into the player.
// The tap starts DISABLED (it captures nothing and the FFT producer idles),
// so this costs nothing until the Large dock's band is shown. It is a
// read-only copy downstream of the bit-perfect stream — no device/stream
// init is touched.
let runtime = Arc::new(AppRuntime::with_visualizer(LoggingAdapter::new(
"[qbz-qt]",
)));
if let Some(tap) = runtime.visualizer_tap().cloned() {
viz_qt::install(tap);
} else {
log::warn!("[qbz-qt] runtime has no visualizer tap; the Large dock band stays empty");
}
let _ = APP.set(runtime);
let mut app = QGuiApplication::new();
+5
View File
@@ -91,6 +91,10 @@ fn mutate(f: impl FnOnce(&mut NowPlayingModel)) {
/// Push the full model onto the bridge (Qt thread hop).
fn publish(m: &NowPlayingModel) {
// Mirror the transport onto the visualizer tap: a paused player parks the
// FFT producer instead of re-analyzing a stale ring buffer (the Slint
// playback.rs does the same). Cheap atomics, safe off the Qt thread.
crate::viz_qt::set_paused(!m.playing);
let m = m.clone();
crate::player_bridge::ui(move |mut b| {
let progress = if m.duration_secs > 0 {
@@ -252,6 +256,7 @@ pub fn set_position(elapsed_secs: i32, duration_secs: i32, playing: bool, cache:
/// Same as `publish` but honors the poll-fed cache value instead of
/// mirroring progress.
fn publish_with_cache(m: &NowPlayingModel) {
crate::viz_qt::set_paused(!m.playing);
let m = m.clone();
crate::player_bridge::ui(move |mut b| {
let progress = if m.duration_secs > 0 {
+68
View File
@@ -278,6 +278,74 @@ pub fn set_npb_mode(index: i32) -> i32 {
index
}
// ---------------------------------------------------------------------------
// Large-NPB dock prefs (the cover's eye toggle + the band's mode cycle)
// ---------------------------------------------------------------------------
/// Whether the Large dock shows its FFT band. Defaults ON (the Slint dock's
/// `large-visualizer-on` default) — the eye button is always visible while it
/// is off, so an accidental hide is one click from being undone.
pub fn large_visualizer_on() -> bool {
let Some(path) = prefs_path() else {
return true;
};
std::fs::read_to_string(path)
.ok()
.and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
.and_then(|v| {
v.get("large_visualizer_on")
.and_then(serde_json::Value::as_bool)
})
.unwrap_or(true)
}
/// Persist the band visibility; returns the stored value.
pub fn set_large_visualizer_on(on: bool) -> bool {
write_pref("large_visualizer_on", serde_json::Value::Bool(on));
on
}
/// The band's render mode: 0 Bars / 1 Waveform / 2 Energy.
pub fn large_spectrum_mode() -> i32 {
let Some(path) = prefs_path() else {
return 0;
};
std::fs::read_to_string(path)
.ok()
.and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
.and_then(|v| v.get("large_spectrum_mode").and_then(serde_json::Value::as_i64))
.map(|m| (m as i32).clamp(0, 2))
.unwrap_or(0)
}
/// Persist the band mode (clamped 0-2); returns the stored index.
pub fn set_large_spectrum_mode(mode: i32) -> i32 {
let mode = mode.clamp(0, 2);
write_pref(
"large_spectrum_mode",
serde_json::Value::Number(mode.into()),
);
mode
}
/// Additive single-key patch of ui_prefs.json (the npb_mode pattern: read,
/// insert, write back — never clobber keys other surfaces own).
fn write_pref(key: &str, value: serde_json::Value) {
let Some(path) = prefs_path() else {
return;
};
let mut doc: serde_json::Value = std::fs::read_to_string(&path)
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_else(|| serde_json::json!({}));
if let Some(obj) = doc.as_object_mut() {
obj.insert(key.to_string(), value);
if let Ok(text) = serde_json::to_string_pretty(&doc) {
let _ = std::fs::write(&path, text);
}
}
}
/// The "Show track playing context" pref (Playback settings; feeds the
/// SongCard layers icon — SettingsState.show-context-icon).
pub fn show_context_icon() -> bool {
+65
View File
@@ -38,6 +38,21 @@ pub mod qbz_shell {
// 3 Large — the ui_prefs npb_mode key, live-switchable.
#[qproperty(i32, npb_mode)]
// --- Large-NPB dock (SidebarNowPlayingDock.slint) -----------------
// The dock is the L's vertical arm: a square cover pinned flush to the
// window bottom-left, with an optional FFT band above it.
// Eye toggle on the cover — persists ui_prefs.large_visualizer_on.
#[qproperty(bool, large_visualizer_on)]
// Band click cycles 0 Bars / 1 Waveform / 2 Energy
// (ui_prefs.large_spectrum_mode).
#[qproperty(i32, large_spectrum_mode)]
// Total dock height, in lockstep with the toggle. The Sidebar reserves
// `largeDockHeight - npb height` so the playlist list stops ABOVE the
// cover instead of running under it, and AppShell pins the dock at
// `height - largeDockHeight`. Both consumers read THIS — never a
// literal (Sidebar.slint:1145 is the same contract).
#[qproperty(f32, large_dock_height)]
// --- Theme (phase 19) --------------------------------------------
// ONE JSON token document (theme_qt.rs ThemeTokens: 30 colors + 24
// alpha tiers + ambient derivations + isDark) — QbzTheme.qml binds
@@ -136,6 +151,15 @@ pub mod qbz_shell {
#[qinvokable]
fn npb_set_mode(self: Pin<&mut QbzShell>, mode: i32);
/// Large dock, cover eye button: show/hide the FFT band. Persists the
/// pref, republishes `largeDockHeight`, and gates the capture tap —
/// hiding the band stops the FFT producer outright.
#[qinvokable]
fn large_toggle_visualizer(self: Pin<&mut QbzShell>);
/// Large dock, band click: cycle Bars -> Waveform -> Energy.
#[qinvokable]
fn large_cycle_spectrum(self: Pin<&mut QbzShell>);
// --- Theme (phase 19) ---------------------------------------------
/// Appearance > Theme row: persist the picked slug + republish
/// `themeJson` (live switch).
@@ -203,6 +227,9 @@ pub struct QbzShellRust {
can_back: bool,
can_forward: bool,
npb_mode: i32,
large_visualizer_on: bool,
large_spectrum_mode: i32,
large_dock_height: f32,
theme_json: QString,
theme_slug: QString,
theme_list_json: QString,
@@ -239,6 +266,9 @@ impl Default for QbzShellRust {
can_back: false,
can_forward: false,
npb_mode: crate::settings_qt::npb_mode_index(),
large_visualizer_on: crate::settings_qt::large_visualizer_on(),
large_spectrum_mode: crate::settings_qt::large_spectrum_mode(),
large_dock_height: large_dock_height(crate::settings_qt::large_visualizer_on()),
theme_json: QString::from(crate::theme_qt::theme_json().as_str()),
theme_slug: QString::from(crate::theme_qt::current_slug().as_str()),
theme_list_json: QString::from(crate::theme_qt::theme_list_json().as_str()),
@@ -269,6 +299,33 @@ impl Default for QbzShellRust {
}
}
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Large-NPB dock geometry
// ---------------------------------------------------------------------------
// SidebarNowPlayingDock.slint's height formula, verbatim. The cover is square
// and as wide as the sidebar's content column (240 sidebar - 16 left - 16
// right gutters = 208), so the totals are 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
// SidebarNowPlayingDock.qml lays itself out from these SAME numbers — if you
// change one, change both, or the cover shifts off the window bottom edge.
pub(crate) const DOCK_ART_SIZE: f32 = 208.0;
pub(crate) const DOCK_BAND_HEIGHT: f32 = 42.0;
pub(crate) const DOCK_PAD_TOP: f32 = 9.0;
pub(crate) const DOCK_PAD_BOTTOM: f32 = 4.0;
pub(crate) const DOCK_BAND_GAP: f32 = 10.0;
/// Total dock height for a visualizer state.
pub(crate) fn large_dock_height(visualizer_on: bool) -> f32 {
let base = DOCK_PAD_TOP + DOCK_ART_SIZE + DOCK_PAD_BOTTOM;
if visualizer_on {
base + DOCK_BAND_HEIGHT + DOCK_BAND_GAP
} else {
base
}
}
// ---------------------------------------------------------------------------
// The UI hop (bridges/mod.rs pattern)
// ---------------------------------------------------------------------------
@@ -320,6 +377,14 @@ impl qbz_shell::QbzShell {
crate::npb_set_mode(mode);
}
pub fn large_toggle_visualizer(self: Pin<&mut Self>) {
crate::large_toggle_visualizer();
}
pub fn large_cycle_spectrum(self: Pin<&mut Self>) {
crate::large_cycle_spectrum();
}
pub fn theme_set(self: Pin<&mut Self>, slug: QString) {
crate::theme_set(slug.to_string());
}
+131
View File
@@ -0,0 +1,131 @@
//! QbzViz — audio-visualizer domain bridge (phase 23 pattern; see main.rs).
//!
//! Publishes the three FFT streams the Large-NPB dock consumes, as typed
//! `QList<f32>` (NOT a JSON document like the other domains): these refresh
//! at 30 Hz, so serializing/parsing per frame would burn CPU for nothing.
//!
//! PERF CONTRACT for the QML side: each drain tick REPLACES the list, so the
//! property changes identity ~30x/s. Never bind a `Repeater.model` straight
//! to one of these — the delegates would be torn down and rebuilt every
//! frame. Give the Repeater a STATIC model (a column count) and bind only
//! each bar's HEIGHT to `QbzViz.bars[i]`; then a tick re-evaluates 28 height
//! bindings and instantiates nothing. This is the QML equivalent of the
//! Slint side's persistent `VecModel` + `set_row_data` (qbz/src/visualizer.rs).
//! The lengths are FIXED (16 / 5 / 512) so an index binding is always safe.
//!
//! Props: bars (16 log-spaced FFT bins), energy (5 semantic bands),
//! waveform (256 L + 256 R samples). Invokables: boot + set_enabled.
//!
//! Protected-audio note: everything here is downstream of `qbz-audio`'s
//! read-only ring buffer. The tap starts DISABLED and captures nothing until
//! `set_enabled(true)`; no device/stream init is touched. See CLAUDE.md
//! "Audio backend — PROTECTED".
use std::pin::Pin;
use std::sync::OnceLock;
use cxx_qt::CxxQtThread;
use cxx_qt::Threading as _;
#[cxx_qt::bridge]
pub mod qbz_viz {
extern "C++" {
include!("cxx-qt-lib/qlist.h");
type QList_f32 = cxx_qt_lib::QList<f32>;
}
#[auto_cxx_name]
extern "RustQt" {
#[qobject]
#[qml_element]
#[qml_singleton]
// --- FFT streams (viz_qt.rs drain, ~30 fps while enabled) ---------
// 16 log-spaced bars. The dock's Bars mode renders 28 MIRRORED
// columns from the 14 ACTIVE bins (the producer leaves {1, 15}
// empty) — SpectrumPanel.slint parity.
#[qproperty(QList_f32, bars)]
// 5 semantic energy bands: sub-bass, bass, mids, presence, air.
#[qproperty(QList_f32, energy)]
// 256 L samples followed by 256 R (VizFrame::Wave256x2). The dock's
// Waveform mode downsamples the L half (indices 0..235 step 5).
#[qproperty(QList_f32, waveform)]
type QbzViz = super::QbzVizRust;
/// Registers this object's Qt-thread hop (Main.qml boots EVERY
/// domain singleton; only QbzSession.boot also fires crate::on_boot).
#[qinvokable]
fn boot(self: Pin<&mut QbzViz>);
/// Capture gate. Drives the tap's `set_enabled` + the 30 fps drain,
/// exactly like the Slint `VisualizerState.set-enabled` handler:
/// OFF means the producer parks and the drain stops, so a hidden or
/// disabled visualizer costs nothing. The dock calls this on its eye
/// toggle, on Large enter/leave, and when the window deactivates.
#[qinvokable]
fn set_enabled(self: Pin<&mut QbzViz>, on: bool);
}
impl cxx_qt::Threading for QbzViz {}
}
use cxx_qt_lib::QList;
use qbz_viz::QbzViz;
type QListF32 = QList<f32>;
/// Fixed stream lengths — the drain overwrites in place and never resizes.
pub(crate) const BARS_LEN: usize = 16;
pub(crate) const ENERGY_LEN: usize = 5;
pub(crate) const WAVEFORM_LEN: usize = 512;
fn zeros(n: usize) -> QListF32 {
let mut list = QListF32::default();
for _ in 0..n {
list.append(0.0);
}
list
}
/// Rust side of the visualizer bridge (plain storage, phase-1 pattern).
pub struct QbzVizRust {
bars: QListF32,
energy: QListF32,
waveform: QListF32,
}
impl Default for QbzVizRust {
fn default() -> Self {
Self {
bars: zeros(BARS_LEN),
energy: zeros(ENERGY_LEN),
waveform: zeros(WAVEFORM_LEN),
}
}
}
// ---------------------------------------------------------------------------
// The UI hop (session_bridge.rs pattern)
// ---------------------------------------------------------------------------
static QT_THREAD: OnceLock<CxxQtThread<QbzViz>> = OnceLock::new();
/// Queue a visualizer-bridge mutation onto the Qt event loop (no-op before
/// boot registers the thread).
pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzViz>) + Send + 'static) {
if let Some(thread) = QT_THREAD.get() {
let _ = thread.queue(f);
}
}
impl qbz_viz::QbzViz {
pub fn boot(self: Pin<&mut Self>) {
if QT_THREAD.set(self.qt_thread()).is_err() {
log::warn!("[qbz-qt] viz Qt thread already registered");
}
}
pub fn set_enabled(self: Pin<&mut Self>, on: bool) {
crate::viz_qt::set_enabled(on);
}
}
+157
View File
@@ -0,0 +1,157 @@
//! Audio-visualizer glue for the Qt shell — the QML counterpart of
//! `qbz/src/visualizer.rs` (Slint).
//!
//! Spawns the frontend-agnostic FFT producer (`qbz_audio::visualizer`) against
//! the runtime's [`VisualizerTap`], latches each [`VizFrame`] into a
//! single-slot cell, and drains the latest frames into the `QbzViz` bridge
//! properties on a ~30 fps thread that hops to the Qt event loop.
//!
//! Cost controls (the Slint install() has the same three, and they are why the
//! ambient field sits under 3% CPU during playback):
//! 1. The tap starts DISABLED — nothing is captured and the producer idles
//! until the dock's eye toggle calls `set_enabled(true)`.
//! 2. While disabled the drain thread PARKS (no timer, no locks); enabling
//! unparks both it and the producer for an instant wake.
//! 3. `set_paused` mirrors the transport, so a paused player parks the
//! producer instead of re-running the FFT over a stale ring buffer.
//!
//! Protected-audio note: this lives entirely downstream of the read-only ring
//! buffer. It touches none of the device/stream init (CLAUDE.md "Audio
//! backend — PROTECTED"); the tap is a passive sample copy.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::thread::Thread;
use std::time::Duration;
use cxx_qt_lib::QList;
use qbz_audio::visualizer::{spawn_visualizer_thread, VizFrame, VizSink};
use qbz_audio::VisualizerTap;
use crate::viz_bridge;
/// ~30 fps, matching the producer's TARGET_FPS and the ambient field's cap.
const DRAIN_INTERVAL: Duration = Duration::from_millis(33);
/// Single-slot, latest-wins frame store shared with the FFT producer thread.
/// A stalled UI drops intermediate frames instead of growing a queue.
#[derive(Default)]
struct VizCells {
bars: Mutex<Option<[f32; 16]>>,
energy: Mutex<Option<[f32; 5]>>,
waveform: Mutex<Option<Box<[f32; 512]>>>,
}
/// Producer-side sink: latches frames into the shared cells. Never touches Qt
/// (this runs on the FFT thread).
struct QtVizSink {
cells: Arc<VizCells>,
}
impl VizSink for QtVizSink {
fn submit(&self, frame: VizFrame) {
match frame {
VizFrame::Viz16(b) => *self.cells.bars.lock().unwrap() = Some(b),
VizFrame::Energy5(b) => *self.cells.energy.lock().unwrap() = Some(b),
VizFrame::Wave256x2(b) => *self.cells.waveform.lock().unwrap() = Some(b),
// The dock renders none of these; ignoring them costs nothing (the
// producer computes the spectral ribbon only for the shader scenes).
VizFrame::Spectral512(_) | VizFrame::Transient1(_) => {}
}
}
}
struct VizHandles {
tap: VisualizerTap,
fft_thread: Thread,
drain_thread: Thread,
}
static HANDLES: OnceLock<VizHandles> = OnceLock::new();
/// Drives the drain loop: false parks it, true runs it at DRAIN_INTERVAL.
static ENABLED: AtomicBool = AtomicBool::new(false);
fn to_qlist(values: &[f32]) -> QList<f32> {
let mut list = QList::<f32>::default();
for v in values {
list.append(*v);
}
list
}
/// Wire the visualizer. Call once at startup, after the runtime is built.
/// No-op when the runtime carries no tap (i.e. it was built with
/// [`qbz_app::shell::AppRuntime::new`] instead of `with_visualizer`).
pub fn install(tap: VisualizerTap) {
if HANDLES.get().is_some() {
log::warn!("[qbz-qt][viz] install called twice; ignoring");
return;
}
let cells = Arc::new(VizCells::default());
let sink = Arc::new(QtVizSink {
cells: cells.clone(),
});
let fft_thread = spawn_visualizer_thread(tap.clone(), sink).thread().clone();
// The drain: park while disabled, otherwise take the latest frames and hop
// them onto the Qt thread. Only streams that actually produced a new frame
// this tick are published, so a paused producer costs three try-locks.
let drain_thread = std::thread::Builder::new()
.name("qbz-qt-viz-drain".to_string())
.spawn(move || loop {
if !ENABLED.load(Ordering::Relaxed) {
std::thread::park();
continue;
}
if let Some(b) = cells.bars.lock().unwrap().take() {
viz_bridge::ui(move |mut v| v.as_mut().set_bars(to_qlist(&b)));
}
if let Some(b) = cells.energy.lock().unwrap().take() {
viz_bridge::ui(move |mut v| v.as_mut().set_energy(to_qlist(&b)));
}
if let Some(b) = cells.waveform.lock().unwrap().take() {
viz_bridge::ui(move |mut v| v.as_mut().set_waveform(to_qlist(b.as_ref())));
}
std::thread::sleep(DRAIN_INTERVAL);
})
.expect("spawn viz drain thread")
.thread()
.clone();
let _ = HANDLES.set(VizHandles {
tap,
fft_thread,
drain_thread,
});
log::info!("[qbz-qt][viz] producer + 30fps drain installed (idle until enabled)");
}
/// Capture gate — the `QbzViz.setEnabled` invokable. Toggles the tap, wakes the
/// parked producer/drain, and (when turning off) leaves the last frame on
/// screen frozen, exactly like the Slint handler.
pub fn set_enabled(on: bool) {
let Some(h) = HANDLES.get() else {
return;
};
ENABLED.store(on, Ordering::Relaxed);
h.tap.set_enabled(on);
if on {
// Both park while idle; unpark for an instant wake instead of waiting
// out the producer's 200ms IDLE_POLL.
h.fft_thread.unpark();
h.drain_thread.unpark();
}
}
/// Mirror the transport onto the tap so a paused player parks the producer.
/// Called from the playback path on every playing-state flip (the Slint side
/// does this from playback.rs).
pub fn set_paused(paused: bool) {
if let Some(h) = HANDLES.get() {
h.tap.set_paused(paused);
if !paused && ENABLED.load(Ordering::Relaxed) {
h.fft_thread.unpark();
}
}
}