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
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||