Files
qbz/crates/qbz-qt/qml/shell/SidebarNowPlayingDock.qml
T
vicrodh d4667f5ea9 perf(qt): rounded covers use a real mask, the spectrum ticks at 62Hz, the derivative cache is bounded
The track's "ShaderEffect / ColorOverlay / MultiEffect render NOTHING on the
software path this port runs on" doctrine was measured FALSE on 2026-07-29:
qbz-qt runs on the GPU (OpenGL RHI, threaded render loop, vsync 5.56ms). The
claim came from an offscreen session, and QT_QPA_PLATFORM=offscreen forces
software by definition. Every design it justified needed re-deciding.

- RoundedImage was a Canvas (renderTarget: Canvas.Image) that rasterised with
  QPainter on the CPU AND uploaded a texture per repaint, for every cover in
  the app, purely to round a corner identical in every frame of that image's
  life. It is now layer.enabled + MultiEffect { maskSource } — an FBO rendered
  once and cached for static content. Rectangle { radius; clip: true } cannot
  do this: clip in Qt Quick is a RECTANGULAR scissor and ignores radius.
  maskThresholdMin/maskSpreadAtMin are 0.5/1.0, NOT the 0.0/0.0 that looks
  natural: 0.0 collapses Qt's mask smoothstep to step(0, alpha) and every
  pixel with any alpha snaps fully opaque — measured 0 antialiased pixels at
  radius 40 against the Canvas's 292. At 0.5/1.0 it is 308, ramp [0,51,219,255].
  A software machine keeps the Canvas arm (GraphicsInfo.api), and
  QBZ_QT_ROUND_MODE=canvas pins it on a GPU machine where the mask misbehaves.
- VizSettle drove the spectrum from FrameAnimation, i.e. every rendered frame:
  180fps on this box for data the producer publishes at TARGET_FPS = 30. Now a
  16ms Timer; the wall-clock maths is unchanged.
- SpectrumBand's per-bar y was a SECOND JS binding derived from height. An
  anchor does the same arithmetic in C++ with no binding at all, and each FFT
  frame is materialised into a plain array once per publish instead of being
  read element by element across the JS/C++ boundary. Bindings per tick: bars
  28 (was 56), waveform 48 (was 96), energy 5 (was 10).
  QQuickPaintedItem was investigated and rejected with evidence: cxx-qt 0.7.3
  emits X(QObject* parent) : base(parent), which does not compile against
  QQuickPaintedItem(QQuickItem*), paint() is pure virtual, and there is zero
  precedent for a custom base class in this workspace. Qt 6 ShaderEffect takes
  no GLSL, only precompiled .qsb, which would make the build depend on a Qt
  tool whose path differs on macOS.
- PlaylistCollage point-sampled its mosaic in its own Canvas
  (Context2D.drawImage does not filter when it scales — measured RMSE 0.000
  against nearest-neighbour). It now draws through RoundedImage.
- The scaled-derivative cache had no eviction at all and the .jpg -> .png key
  change had orphaned 1251 files. Boot housekeeping sweeps the orphans once and
  bounds the directory at 64 MB, LRU by mtime. Verified by effect: first run
  deleted 1259 files / 10.05 MB, every run since reports 0, and the directory
  is 14 MB. The eviction is only survivable because RoundedImage clears
  _scaled on Image.Error and re-requests.
- For You artwork patched the rails in place instead of republishing the
  document, which was resetting contentY. HomeView is destroyed on every
  navigation, so the patch map is re-handed on mount through
  QbzHome.refreshForyouArt() — otherwise the Radio rail and the Spotlight hero
  went blank for the rest of the session after one navigation, and the skeleton
  pulse Timer stayed pinned ON, defeating the fix it belonged to.
- Spotlight's placeholder gated on the local path instead of the remote url, so
  it flashed a glyph on a cold cache where the Slint does not.

The retired doctrine is corrected wherever it was load-bearing for a decision.
The pre-baked icon tints are deliberately NOT reopened and no drop-shadows are
added: both are visual changes, not perf fixes.

cc
2026-07-29 21:23:10 -06:00

169 lines
6.2 KiB
QML

// 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.
//
// The spectrum strip itself lives in SpectrumBand.qml (render modes + motion);
// its perf contract is documented there. This file owns the gating and the
// layout arithmetic only.
//
// 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.Window
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)
// 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
// 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.windowShowing
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 -----------------------------------------------------
SpectrumBand {
x: 0
y: root.padTop
width: root.width
height: root.bandHeight
shown: root.bandOn
capturing: root.vizShouldRun
}
// ---- Album cover -------------------------------------------------------
// Drop-shadow approximation of the Slint dock's 24px blur (two offset
// rects).
// SUPERSEDED (2026-07-29): this used to say "QtQuick.Effects renders
// nothing on the software path". Effects need shaders, and this port runs
// on the GPU (OpenGL RHI, measured); that note came from an offscreen
// session, which forces the software renderer by definition — see
// theme/RoundedImage.qml, which now DETECTS the software path with
// `GraphicsInfo.api`. A real blurred shadow is possible; it is a visual
// change needing its own parity pass, so the approximation stays for now.
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
// On the #80000000 / #2effffff chip over the artwork —
// theme-independent, so a fixed white glyph is correct.
tintName: "white"
}
MouseArea {
id: eyeHover
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzShell.largeToggleVisualizer()
}
}
}
}