fix(qbz-qt): unresolvable QML imports, focus-based freezing, and dock render cost

Three views instantiated components the phase-23 directory move had left
unimportable: PlaylistView and SearchView use TrackRow (now in rows/) and
SectionRail uses AlbumCard (now in cards/). QML resolves lazily, so these
survived cargo check, the test suite and the offscreen smoke — they only
failed when the user opened a playlist. Adds a repo-wide audit of every
instantiated component against the importing file's visible directories; the
three above were all of them.

The ambient field and the new dock band froze on lost focus. That is wrong on
any tiling or mosaic desktop, where windows stay fully visible while
unfocused — the field would stop while the user is looking at it. Both now
gate on the window not being minimized or hidden, which is the real "nobody
can see this". Freezing on not-visible stays.

Dock band render cost, in descending order of impact: the drain publishes
only the stream the active mode consumes (Bars mode was marshalling a
512-float waveform QList 30 times a second for nothing); each mode marshals
its QList into JS once per tick instead of once per delegate; one shared
Gradient replaces 28 per-delegate instances; and the per-bar Behaviors are
gone since qbz-audio already smooths and clamps the amplitudes upstream.

18/18 tests; offscreen smoke clean; component-resolution audit clean.

cc
This commit is contained in:
vicrodh committed 2026-07-28 12:26:40 -06:00
1 parent 70bd337d16
commit aabde3be63
7 files changed
+95 -40

No files matched your search

+16 -3
View File
@@ -15,10 +15,16 @@
// - No audio breathe (the POC has no FFT tap; level_smooth = 0 → the // - No audio breathe (the POC has no FFT tap; level_smooth = 0 → the
// shader's breathe is a constant 1.0 there anyway) and no grain dither. // shader's breathe is a constant 1.0 there anyway) and no grain dither.
// - Rendered with QPainter (renderTarget Image) so it works on the // - Rendered with QPainter (renderTarget Image) so it works on the
// software/offscreen path too; 30fps cap + paused when the window is // software/offscreen path too; 30fps cap + paused when the window is not
// inactive (the spec's cost controls §6). // being shown (the spec's cost controls §6).
//
// GATING RULE (owner, 2026-07-28): freeze on NOT VISIBLE, never on lost
// focus. A tiling/mosaic desktop keeps windows visible and unfocused all the
// time — pausing on focus loss would stop the field while the user is
// looking straight at it. Minimized/hidden is the real "nobody can see it".
import QtQuick import QtQuick
import QtQuick.Window
import com.blitzfc.qbz import com.blitzfc.qbz
Canvas { Canvas {
@@ -31,6 +37,13 @@ Canvas {
// Set false by the host to freeze the clock (inactive / hidden). // Set false by the host to freeze the clock (inactive / hidden).
property bool running: true property bool running: true
// True unless the window is minimized or hidden — see the GATING RULE at
// the top. Focus is deliberately NOT part of this.
readonly property bool windowShowing: root.Window.window
? (root.Window.window.visibility !== Window.Minimized
&& root.Window.window.visibility !== Window.Hidden)
: true
renderTarget: Canvas.Image renderTarget: Canvas.Image
renderStrategy: Canvas.Cooperative renderStrategy: Canvas.Cooperative
@@ -40,7 +53,7 @@ Canvas {
Timer { Timer {
interval: 33 interval: 33
repeat: true repeat: true
running: root.running && root.visible && root.Window.active running: root.running && root.visible && root.windowShowing
onTriggered: { onTriggered: {
root.t += interval / 1000.0 * 0.75 root.t += interval / 1000.0 * 0.75
root.requestPaint() root.requestPaint()
@@ -17,15 +17,28 @@
// `height - largeDockHeight`. Change the arithmetic in one place and the // `height - largeDockHeight`. Change the arithmetic in one place and the
// cover slides off the window edge. // cover slides off the window edge.
// //
// PERF (the rule the ambient field already proved): the FFT streams replace // PERF — this band redraws 30x/s next to a full-window ambient canvas, so
// their QList ~30x/s, so a Repeater bound to QbzViz.bars would rebuild every // every one of these matters:
// delegate every frame. The Repeaters below use STATIC models (column // 1. The FFT streams REPLACE their QList each tick, so a Repeater bound to
// counts) and bind only each bar's HEIGHT — a tick re-evaluates bindings and // QbzViz.bars would rebuild every delegate every frame. The Repeaters use
// instantiates nothing. Only the ACTIVE mode is instantiated (the modes sit // STATIC models (column counts) and bind only each bar's HEIGHT.
// behind `active:` Loaders), and the capture tap is gated by vizShouldRun, so // 2. Each mode caches the list in ONE `var` property per tick. Reading
// a hidden band costs zero CPU: the producer parks and the drain thread sleeps. // QbzViz.bars straight from 28 delegates would marshal the QList into a
// fresh JS array 28 times a frame (48x512 floats for Waveform — the
// expensive one).
// 3. ONE shared Gradient for every bar, not one object per delegate.
// 4. No per-bar Behavior: the producer already smooths and clamps in Rust
// (qbz-audio), so 28 concurrent QML animations would buy nothing.
// 5. Only the ACTIVE mode is instantiated (`active:` Loaders) and viz_qt.rs
// only publishes the stream that mode consumes.
// 6. The capture tap is gated by vizShouldRun — a hidden band costs zero:
// the producer parks and the drain thread sleeps.
//
// GATING RULE (owner, 2026-07-28): freeze on NOT VISIBLE, never on lost
// focus — a tiling desktop keeps windows visible and unfocused.
import QtQuick import QtQuick
import QtQuick.Window
import com.blitzfc.qbz import com.blitzfc.qbz
import "../theme" import "../theme"
@@ -48,11 +61,17 @@ Item {
// The cover's top edge: below the band when it is shown. // The cover's top edge: below the band when it is shown.
readonly property int artY: padTop + (bandOn ? bandHeight + bandGap : 0) readonly property int artY: padTop + (bandOn ? bandHeight + bandGap : 0)
// True unless the window is minimized or hidden — see the GATING RULE.
readonly property bool windowShowing: root.Window.window
? (root.Window.window.visibility !== Window.Minimized
&& root.Window.window.visibility !== Window.Hidden)
: true
// Capture gate — the Slint AppShell's `viz-should-run`. OFF stops the FFT // 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 // producer outright (viz_qt.rs parks it), so leaving Large or hiding the
// band costs nothing. The transport is mirrored separately (a paused // band costs nothing. The transport is mirrored separately (a paused
// player parks the producer via now_playing.rs). // player parks the producer via now_playing.rs).
readonly property bool vizShouldRun: root.visible && root.bandOn && root.Window.active readonly property bool vizShouldRun: root.visible && root.bandOn && root.windowShowing
onVizShouldRunChanged: QbzViz.setEnabled(root.vizShouldRun) onVizShouldRunChanged: QbzViz.setEnabled(root.vizShouldRun)
Component.onCompleted: QbzViz.setEnabled(root.vizShouldRun) Component.onCompleted: QbzViz.setEnabled(root.vizShouldRun)
Component.onDestruction: QbzViz.setEnabled(false) Component.onDestruction: QbzViz.setEnabled(false)
@@ -84,6 +103,13 @@ Item {
readonly property color topColor: QbzShell.ambientPrimary readonly property color topColor: QbzShell.ambientPrimary
readonly property color bottomColor: QbzShell.ambientSecondary readonly property color bottomColor: QbzShell.ambientSecondary
// ONE gradient instance shared by every bar (perf note 3).
Gradient {
id: barGradient
GradientStop { position: 0.0; color: band.topColor }
GradientStop { position: 1.0; color: band.bottomColor }
}
// Mode 0 — Bars: 28 MIRRORED columns over the 14 ACTIVE bins (the // Mode 0 — Bars: 28 MIRRORED columns over the 14 ACTIVE bins (the
// 16-bin FFT leaves {1, 15} empty; SpectrumPanel.slint parity). // 16-bin FFT leaves {1, 15} empty; SpectrumPanel.slint parity).
Loader { Loader {
@@ -93,6 +119,8 @@ Item {
id: barsMode id: barsMode
readonly property var activeBins: [0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14] 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 readonly property real slot: band.width / 28
// Marshal the QList into JS ONCE per tick (perf note 2).
readonly property var values: QbzViz.bars
Repeater { Repeater {
model: 28 model: 28
delegate: Rectangle { delegate: Rectangle {
@@ -101,19 +129,13 @@ Item {
readonly property int bin: index < 14 readonly property int bin: index < 14
? barsMode.activeBins[index] ? barsMode.activeBins[index]
: barsMode.activeBins[27 - index] : barsMode.activeBins[27 - index]
readonly property real amp: Math.max(0, Math.min(1, QbzViz.bars[bin] || 0)) readonly property real amp: Math.max(0, Math.min(1, barsMode.values[bin] || 0))
x: index * barsMode.slot + 1 x: index * barsMode.slot + 1
width: barsMode.slot - 2 width: barsMode.slot - 2
height: Math.max(2, amp * band.height) height: Math.max(2, amp * band.height)
y: band.height - height y: band.height - height
radius: 1 radius: 1
gradient: Gradient { gradient: barGradient
GradientStop { position: 0.0; color: band.topColor }
GradientStop { position: 1.0; color: band.bottomColor }
}
Behavior on height {
NumberAnimation { duration: 90; easing.type: Easing.OutQuad }
}
} }
} }
} }
@@ -127,20 +149,20 @@ Item {
sourceComponent: Item { sourceComponent: Item {
id: waveMode id: waveMode
readonly property real slot: band.width / 48 readonly property real slot: band.width / 48
// 512 floats — marshalling this per delegate would be the
// single most expensive thing in the dock (perf note 2).
readonly property var values: QbzViz.waveform
Repeater { Repeater {
model: 48 model: 48
delegate: Rectangle { delegate: Rectangle {
required property int index required property int index
readonly property real amp: Math.min(1, Math.abs(QbzViz.waveform[index * 5] || 0)) readonly property real amp: Math.min(1, Math.abs(waveMode.values[index * 5] || 0))
x: index * waveMode.slot + 1 x: index * waveMode.slot + 1
width: waveMode.slot - 2 width: waveMode.slot - 2
height: Math.max(2, amp * band.height) height: Math.max(2, amp * band.height)
y: (band.height - height) / 2 y: (band.height - height) / 2
radius: 1 radius: 1
color: band.topColor color: band.topColor
Behavior on height {
NumberAnimation { duration: 70; easing.type: Easing.OutQuad }
}
} }
} }
} }
@@ -153,23 +175,18 @@ Item {
sourceComponent: Item { sourceComponent: Item {
id: energyMode id: energyMode
readonly property real slot: band.width / 5 readonly property real slot: band.width / 5
readonly property var values: QbzViz.energy
Repeater { Repeater {
model: 5 model: 5
delegate: Rectangle { delegate: Rectangle {
required property int index required property int index
readonly property real amp: Math.max(0, Math.min(1, QbzViz.energy[index] || 0)) readonly property real amp: Math.max(0, Math.min(1, energyMode.values[index] || 0))
x: index * energyMode.slot + 3 x: index * energyMode.slot + 3
width: energyMode.slot - 6 width: energyMode.slot - 6
height: Math.max(2, amp * band.height) height: Math.max(2, amp * band.height)
y: band.height - height y: band.height - height
radius: 1 radius: 1
gradient: Gradient { gradient: barGradient
GradientStop { position: 0.0; color: band.topColor }
GradientStop { position: 1.0; color: band.bottomColor }
}
Behavior on height {
NumberAnimation { duration: 90; easing.type: Easing.OutQuad }
}
} }
} }
} }
+1
View File
@@ -32,6 +32,7 @@ import QtQuick
import QtQuick.Controls import QtQuick.Controls
import com.blitzfc.qbz import com.blitzfc.qbz
import "../controls" import "../controls"
import "../rows"
import "../theme" import "../theme"
Rectangle { Rectangle {
+1
View File
@@ -26,6 +26,7 @@ import QtQuick.Controls
import com.blitzfc.qbz import com.blitzfc.qbz
import "../cards" import "../cards"
import "../controls" import "../controls"
import "../rows"
import "../theme" import "../theme"
Rectangle { Rectangle {
+1
View File
@@ -5,6 +5,7 @@
import QtQuick import QtQuick
import com.blitzfc.qbz import com.blitzfc.qbz
import "../controls" import "../controls"
import "../cards"
import "../theme" import "../theme"
Column { Column {
+4
View File
@@ -864,6 +864,9 @@ pub(crate) fn large_toggle_visualizer() {
pub(crate) fn large_cycle_spectrum() { pub(crate) fn large_cycle_spectrum() {
let mode = settings_qt::set_large_spectrum_mode((settings_qt::large_spectrum_mode() + 1) % 3); let mode = settings_qt::set_large_spectrum_mode((settings_qt::large_spectrum_mode() + 1) % 3);
log::info!("[qbz-qt] large spectrum mode -> {mode}"); log::info!("[qbz-qt] large spectrum mode -> {mode}");
// Point the drain at the new stream BEFORE the UI switches, so the first
// frame the new mode renders already has data.
viz_qt::set_mode(mode);
shell_bridge::ui(move |mut b| b.as_mut().set_large_spectrum_mode(mode)); shell_bridge::ui(move |mut b| b.as_mut().set_large_spectrum_mode(mode));
} }
@@ -1390,6 +1393,7 @@ fn main() {
))); )));
if let Some(tap) = runtime.visualizer_tap().cloned() { if let Some(tap) = runtime.visualizer_tap().cloned() {
viz_qt::install(tap); viz_qt::install(tap);
viz_qt::set_mode(settings_qt::large_spectrum_mode());
} else { } else {
log::warn!("[qbz-qt] runtime has no visualizer tap; the Large dock band stays empty"); log::warn!("[qbz-qt] runtime has no visualizer tap; the Large dock band stays empty");
} }
+27 -9
View File
@@ -19,7 +19,7 @@
//! buffer. It touches none of the device/stream init (CLAUDE.md "Audio //! buffer. It touches none of the device/stream init (CLAUDE.md "Audio
//! backend — PROTECTED"); the tap is a passive sample copy. //! backend — PROTECTED"); the tap is a passive sample copy.
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
use std::sync::{Arc, Mutex, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
use std::thread::Thread; use std::thread::Thread;
use std::time::Duration; use std::time::Duration;
@@ -70,6 +70,10 @@ struct VizHandles {
static HANDLES: OnceLock<VizHandles> = OnceLock::new(); static HANDLES: OnceLock<VizHandles> = OnceLock::new();
/// Drives the drain loop: false parks it, true runs it at DRAIN_INTERVAL. /// Drives the drain loop: false parks it, true runs it at DRAIN_INTERVAL.
static ENABLED: AtomicBool = AtomicBool::new(false); static ENABLED: AtomicBool = AtomicBool::new(false);
/// The band's render mode (0 Bars / 1 Waveform / 2 Energy). The drain only
/// publishes the ONE stream that mode consumes: in Bars mode, marshalling the
/// 512-float waveform into a QList 30 times a second would be pure waste.
static ACTIVE_MODE: AtomicI32 = AtomicI32::new(0);
fn to_qlist(values: &[f32]) -> QList<f32> { fn to_qlist(values: &[f32]) -> QList<f32> {
let mut list = QList::<f32>::default(); let mut list = QList::<f32>::default();
@@ -104,14 +108,22 @@ pub fn install(tap: VisualizerTap) {
std::thread::park(); std::thread::park();
continue; continue;
} }
if let Some(b) = cells.bars.lock().unwrap().take() { match ACTIVE_MODE.load(Ordering::Relaxed) {
viz_bridge::ui(move |mut v| v.as_mut().set_bars(to_qlist(&b))); 1 => {
} if let Some(b) = cells.waveform.lock().unwrap().take() {
if let Some(b) = cells.energy.lock().unwrap().take() { viz_bridge::ui(move |mut v| v.as_mut().set_waveform(to_qlist(b.as_ref())));
viz_bridge::ui(move |mut v| v.as_mut().set_energy(to_qlist(&b))); }
} }
if let Some(b) = cells.waveform.lock().unwrap().take() { 2 => {
viz_bridge::ui(move |mut v| v.as_mut().set_waveform(to_qlist(b.as_ref()))); 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.bars.lock().unwrap().take() {
viz_bridge::ui(move |mut v| v.as_mut().set_bars(to_qlist(&b)));
}
}
} }
std::thread::sleep(DRAIN_INTERVAL); std::thread::sleep(DRAIN_INTERVAL);
}) })
@@ -144,6 +156,12 @@ pub fn set_enabled(on: bool) {
} }
} }
/// Point the drain at the stream the band is actually rendering. Called at
/// install time (from the persisted pref) and on every mode cycle.
pub fn set_mode(mode: i32) {
ACTIVE_MODE.store(mode.clamp(0, 2), Ordering::Relaxed);
}
/// Mirror the transport onto the tap so a paused player parks the producer. /// 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 /// Called from the playback path on every playing-state flip (the Slint side
/// does this from playback.rs). /// does this from playback.rs).