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
320 lines
12 KiB
QML
320 lines
12 KiB
QML
// SongCardStamp — the IN-CARD audio stamp of crates/qbz-ui/ui/shell/
|
|
// SongCard.slint (its `if root.has-stamp` column plus the DotLed it declares).
|
|
//
|
|
// This is what the NEW (0) and CLASSIC (1) now-playing bars mount, pinned to
|
|
// the song card's right edge:
|
|
//
|
|
// [ QualityBadgeFull: icon + TIER over "24-bit / 96 kHz" ] 38px tall
|
|
// (4px)
|
|
// [ (o) PIPEWIRE (o) BITPERF ] 15px tall
|
|
//
|
|
// The SMALL (2) and LARGE (3) bars mount shell/AudioStamp.qml instead — the
|
|
// inline 2-row form with the 7px text LEDs. They are different components in
|
|
// Slint and stay different here; do not merge them.
|
|
//
|
|
// Geometry read off SongCard.slint with `compact-stamp: false` and
|
|
// `narrow: false` (what both mounts pass): stamp-w 128px (dot-leds arm),
|
|
// badge box 38px, LED row 15px, 4px between them.
|
|
//
|
|
// While the stream is downgraded the badge advertises the DELIVERED
|
|
// tier/detail with an amber overlaid arrow, and the catalog max moves into the
|
|
// tooltip's "Source" line next to the cause.
|
|
|
|
import QtQuick
|
|
import QtQuick.Controls
|
|
import com.blitzfc.qbz
|
|
import "../controls"
|
|
import "../theme"
|
|
|
|
Column {
|
|
id: root
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
spacing: 4
|
|
width: 128
|
|
|
|
// SongCard.slint's show-delivered / quality-cause-line /
|
|
// quality-stamp-tooltip, verbatim.
|
|
readonly property bool showDelivered: QbzPlayer.npQualityDowngraded
|
|
&& QbzPlayer.npQualityTrueDetail !== ""
|
|
readonly property string qualityCauseLine: {
|
|
var c = QbzPlayer.npQualityLimitCause
|
|
if (c === 1) return QbzSession.tr("Limited by your streaming quality setting", QbzSession.trRev)
|
|
if (c === 2) return QbzSession.tr("Limited by your output device's limit", QbzSession.trRev)
|
|
if (c === 3) return QbzSession.tr("Limited by the quality cap for {}", QbzSession.trRev)
|
|
.replace("{}", QbzPlayer.npCastTarget)
|
|
if (c === 4) return QbzSession.tr("Qobuz has no higher quality for this track", QbzSession.trRev)
|
|
return ""
|
|
}
|
|
readonly property string qualityStampTooltip: {
|
|
if (!showDelivered) return QbzPlayer.npQualityDetail
|
|
var t = QbzSession.tr("Source: {}", QbzSession.trRev).replace("{}", QbzPlayer.npQualityDetail)
|
|
+ "\n"
|
|
+ QbzSession.tr("Output: {}", QbzSession.trRev).replace("{}", QbzPlayer.npQualityTrueDetail)
|
|
return qualityCauseLine === "" ? t : t + "\n" + qualityCauseLine
|
|
}
|
|
|
|
// The tooltip bubble of shell/TooltipOverlay.slint, 1:1: surface-elevated,
|
|
// radius 4, NO border (the port's earlier bubble had a 1px border-subtle
|
|
// and radiusSm = 8), plus the downward caret. The bubble's bottom edge
|
|
// sits 9px above the anchor and the 5px caret pokes below it, so every
|
|
// ToolTip using this background is positioned at y = -height - 4 with a
|
|
// bottomPadding of 10 (5px bubble padding + the 5px caret zone).
|
|
//
|
|
// Slint also drop-shadows the bubble (blur 10, #00000099); that needs a
|
|
// shader layer effect and is deliberately dropped.
|
|
// SUPERSEDED (2026-07-29): the reason used to be "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 — theme/RoundedImage.qml now
|
|
// detects the software path with `GraphicsInfo.api` instead of assuming it.
|
|
// Adding the shadow is a visual change owing its own parity pass, so the
|
|
// bubble stays flat here.
|
|
component TipBubble: Item {
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
anchors.bottomMargin: 5
|
|
color: theme.surfaceElevated
|
|
radius: 4
|
|
}
|
|
// A 7.07px square rotated 45 deg is a 10px-wide diamond; centred on
|
|
// the bubble's bottom edge, its lower half is the 10x5 caret
|
|
// (TooltipOverlay.slint:56 draws a 10x6 Path). QtQuick.Shapes is not
|
|
// imported anywhere in this port.
|
|
Rectangle {
|
|
width: 7.07
|
|
height: 7.07
|
|
rotation: 45
|
|
antialiasing: true
|
|
color: theme.surfaceElevated
|
|
x: Math.round((parent.width - width) / 2)
|
|
y: parent.height - 5 - height / 2
|
|
}
|
|
}
|
|
|
|
// Dot LED (circle-dot on / circle off) + constant-colour label —
|
|
// SongCard.slint's DotLed (line 57): 9px mark, 4px gap, 8px/600 label at
|
|
// letter-spacing 0.3 that NEVER changes colour (it stays text-muted; only
|
|
// the dot signals state).
|
|
//
|
|
// The mark is DRAWN, not an SVG: the port has no circle / circle-dot
|
|
// assets, and the software path cannot recolour at render time, so every
|
|
// LED colour would need its own baked variant.
|
|
//
|
|
// The figure is measured off the two lucide SVGs Slint tints, scaled to
|
|
// the 9px box the .slint asks for (scale = 9/24 = 0.375):
|
|
// circle.svg <circle r="10" stroke-width="2"> -> painted band
|
|
// r 9..11 => outer diameter 22 * 0.375 = 8.25px,
|
|
// stroke 2 * 0.375 = 0.75px
|
|
// circle-dot.svg adds <circle r="2.5" fill> -> centre dot
|
|
// diameter 5 * 0.375 = 1.875px
|
|
// The previous 9px/1px ring with a 4px dot read as a solid bullseye next
|
|
// to Slint's thin ring + pinpoint — that was the visible mismatch.
|
|
component DotLed: Item {
|
|
id: led
|
|
property string label: ""
|
|
property bool on: false
|
|
property color onColor: "#22c55e"
|
|
property string tooltip: ""
|
|
implicitWidth: ledRow.implicitWidth
|
|
implicitHeight: ledRow.implicitHeight
|
|
width: implicitWidth
|
|
height: implicitHeight
|
|
|
|
Row {
|
|
id: ledRow
|
|
spacing: 4
|
|
// The 9px icon BOX (what QbzIcon occupies in the .slint) — the
|
|
// 4px gap must measure from it, not from the smaller ring.
|
|
Item {
|
|
width: 9
|
|
height: 9
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
Rectangle {
|
|
width: 8.25
|
|
height: 8.25
|
|
radius: width / 2
|
|
anchors.centerIn: parent
|
|
antialiasing: true
|
|
color: "transparent"
|
|
border.width: 0.75
|
|
border.color: led.on ? led.onColor : theme.textMuted
|
|
Rectangle {
|
|
visible: led.on
|
|
width: 1.875
|
|
height: 1.875
|
|
radius: width / 2
|
|
anchors.centerIn: parent
|
|
antialiasing: true
|
|
color: led.onColor
|
|
}
|
|
}
|
|
}
|
|
Text {
|
|
text: led.label
|
|
color: theme.textMuted
|
|
font.pixelSize: 8
|
|
font.weight: theme.weightSemibold
|
|
font.letterSpacing: 0.3
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
id: ledHover
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
}
|
|
// Bubble geometry: see Tip below — same numbers, read off
|
|
// shell/TooltipOverlay.slint.
|
|
ToolTip {
|
|
id: ledTip
|
|
visible: ledHover.containsMouse && led.tooltip !== ""
|
|
text: led.tooltip
|
|
delay: 0
|
|
timeout: -1
|
|
padding: 0
|
|
x: Math.round((led.width - width) / 2)
|
|
y: -height - 4
|
|
contentItem: Text {
|
|
text: ledTip.text
|
|
color: theme.textPrimary
|
|
font.pixelSize: 11
|
|
font.weight: theme.weightMedium
|
|
leftPadding: 9
|
|
rightPadding: 9
|
|
topPadding: 5
|
|
bottomPadding: 10
|
|
}
|
|
background: TipBubble {}
|
|
}
|
|
}
|
|
|
|
// --- the quality badge + the downgrade arrow overlay --------------------
|
|
Item {
|
|
width: parent.width
|
|
height: 38
|
|
|
|
QualityBadgeFull {
|
|
// Explicit width/height (SongCard.slint sets x/y/width/height on
|
|
// the badge), NOT anchors.fill: the component binds its own
|
|
// width/height to its implicit size, and a call-site assignment
|
|
// cleanly replaces that binding where anchors would fight it.
|
|
x: 0
|
|
y: 0
|
|
width: parent.width
|
|
height: parent.height
|
|
compact: false
|
|
showIcon: true
|
|
// While downgraded, advertise the DELIVERED tier/detail (the true
|
|
// stream) rather than the catalog max.
|
|
tier: root.showDelivered ? QbzPlayer.npQualityEffectiveTier
|
|
: QbzPlayer.npQualityTier
|
|
detail: root.showDelivered ? QbzPlayer.npQualityTrueDetail
|
|
: QbzPlayer.npQualityDetail
|
|
}
|
|
// Downgrade arrow (glyph, not a pill — ADR-008), amber so it reads as
|
|
// "adjusted", not an error. Overlaid top-right so it never disturbs
|
|
// the badge's width maths; the tooltip carries the cause.
|
|
Text {
|
|
visible: QbzPlayer.npQualityDowngraded
|
|
text: "↓"
|
|
font.pixelSize: 9
|
|
font.weight: Font.Bold
|
|
color: "#eab308"
|
|
x: parent.width - width - 2
|
|
y: 1
|
|
}
|
|
MouseArea {
|
|
id: stampHover
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
}
|
|
ToolTip {
|
|
id: stampTip
|
|
visible: stampHover.containsMouse && root.qualityStampTooltip !== ""
|
|
text: root.qualityStampTooltip
|
|
delay: 0
|
|
timeout: -1
|
|
padding: 0
|
|
x: Math.round((root.width - width) / 2)
|
|
y: -height - 4
|
|
contentItem: Text {
|
|
text: stampTip.text
|
|
color: theme.textPrimary
|
|
font.pixelSize: 11
|
|
font.weight: theme.weightMedium
|
|
leftPadding: 9
|
|
rightPadding: 9
|
|
topPadding: 5
|
|
bottomPadding: 10
|
|
}
|
|
background: TipBubble {}
|
|
}
|
|
}
|
|
|
|
// --- the LED row (three mutually exclusive variants) -------------------
|
|
Item {
|
|
width: parent.width
|
|
height: 15
|
|
|
|
// A peer/cast device owns playback -> show the render TARGET instead
|
|
// of the (now meaningless) local output LEDs.
|
|
Row {
|
|
visible: QbzPlayer.npIsRemote && QbzPlayer.npCastTarget !== ""
|
|
width: parent.width
|
|
height: parent.height
|
|
spacing: 4
|
|
QbzIcon {
|
|
name: "monitor-speaker"
|
|
// Baked #e0b341 variant (assets/icons/amber/).
|
|
tintName: "amber"
|
|
width: 13
|
|
height: 13
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
Text {
|
|
width: parent.width - 17
|
|
text: QbzPlayer.npCastTarget
|
|
color: "#e0b341"
|
|
font.pixelSize: 9
|
|
font.weight: theme.weightSemibold
|
|
elide: Text.ElideRight
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
}
|
|
|
|
// Local-network cast (Chromecast / DLNA), no QConnect peer.
|
|
DotLed {
|
|
visible: QbzPlayer.npCastActive && !QbzPlayer.npIsRemote
|
|
label: QbzPlayer.npCastProtocol === "dlna" ? "DLNA" : "CAST"
|
|
on: true
|
|
onColor: "#a855f7"
|
|
tooltip: QbzSession.tr("Casting", QbzSession.trRev)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
|
|
// Local output -> backend + mode dot LEDs, 8px apart.
|
|
Row {
|
|
visible: !QbzPlayer.npIsRemote && !QbzPlayer.npCastActive
|
|
height: parent.height
|
|
spacing: 8
|
|
DotLed {
|
|
label: QbzPlayer.npOutputBackendLabel
|
|
on: QbzPlayer.npOutputBackendActive
|
|
onColor: "#5b8def"
|
|
tooltip: QbzSession.tr("Audio backend", QbzSession.trRev)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
DotLed {
|
|
label: QbzPlayer.npOutputModeLabel
|
|
on: QbzPlayer.npOutputModeActive
|
|
onColor: "#22c55e"
|
|
tooltip: QbzSession.tr("Output mode", QbzSession.trRev)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
}
|
|
}
|
|
}
|