feat(qbz-qt): the quality stamp, ported from Slint per bar mode

The port had one hand-made badge taken from the archived Tauri tree. Slint has
three distinct components and each bar mounts a specific one, so the wrong tree
had cost the stamp its LEDs entirely — they exist only in Slint, derived from
the output backend and mode, and no amount of polishing the Svelte port would
have produced them.

QualityBadge becomes Slint's icon-only mark with its hover tooltip, for cards
and rows. QualityBadgeFull is the icon plus stacked tier and detail. AudioStamp
is the inline two-row form that carries the LEDs: backend blue when engaged,
mode green, purple for a cast session, amber with the renderer name for a peer
Qobuz Connect. New and Classic mount the SongCard stamp column, Large and Small
the inline stamp, matching the .slint mount sites.

The downgrade case follows Slint too: row one reports what is actually being
delivered with an amber arrow, and the catalog maximum moves into the tooltip
next to the reason it was limited.

PlayerBar's LEDs stop reading the settings document, which never knew whether a
backend was engaged, and read the now-playing output state instead — where
Slint reads them.

cc
This commit is contained in:
vicrodh committed 2026-07-28 15:24:18 -06:00
1 parent 942f408fe1
commit e8a821dd9e
9 files changed
+876 -445

No files matched your search

+146 -331
View File
@@ -1,21 +1,25 @@
// QualityBadge — 1:1 port of the Tauri build's QualityBadge.svelte +
// QualityBadgeStatic.svelte (qbz-worktrees/legacy-tauri/src/lib/components/).
// QualityBadge — 1:1 port of crates/qbz-ui/ui/primitives/QualityBadge.slint.
//
// Every number here comes from those two files' CSS. Do not "improve" them:
// the owner's requirement is visual parity with Tauri, and the port had
// previously reinvented this badge.
// The ICON-ONLY badge: a compact square/rect mark that buys layout space on
// cards and list rows, with the exact quality ("24-bit / 96 kHz") revealed in
// a hover tooltip. Every number below is read off the .slint:
//
// FOUR MODES (the union of both Svelte components):
// "full" — 136x36 pill: icon + [TIER / bit-rate], state border, tooltip.
// "compact" — narrow bar variant: icon + "24/96", no tier row (issue #303).
// "bare" — full's content with the pill chrome stripped, for toolbars.
// "iconOnly" — Qobuz's title-row mark: the Hi-Res logo alone, or a framed
// square for CD/MP3 so both line up in a grid.
// width = hires 42 | lossless 36 | (cd, mp3) 30 height = 30
// hires -> the brand mark, 42x28, centred, NOT tinted
// cd/mp3 -> 30x30 chip: radius 3, surface-elevated, 1px border-subtle,
// 16x16 glyph tinted text-muted
// lossless -> 36x30 chip with the bare filetype text (9px, medium, muted)
// tooltip -> right-anchored 6px above the badge, 24px tall, 9px side
// padding, 11px medium text-secondary on surface-elevated
//
// STATIC vs REACTIVE: QualityBadgeStatic is this component with the four
// state flags left false — it never polls, so list/catalog rows advertise
// their own stored quality without tracking playback. Only the now-playing
// surfaces set downgraded/adjusted/plughw/bitPerfect.
// The previous file was a port of the Tauri QualityBadge.svelte with four
// modes (full/compact/bare/iconOnly) and three "state" borders. Slint has no
// such component: the stacked tier/detail form is QualityBadgeFull.qml and the
// now-playing 2-row form is shell/AudioStamp.qml. This is only the mark.
//
// COMPAT SURFACE (unchanged so QualityMini.qml and its call sites keep
// working): `tierOverride` (aliased as `tier` by QualityMini), `bitDepth`,
// `samplingRate`, `format`, and an inert `mode`.
import QtQuick
import QtQuick.Controls
@@ -27,348 +31,159 @@ Item {
QbzTheme { id: theme }
// ---- Source data ------------------------------------------------------
property string quality: ""
// ---- Slint inputs -----------------------------------------------------
/// "hires" | "cd" | "mp3" | "lossless" | "" — empty hides the badge.
/// Named `tierOverride` (not `tier`) because QualityMini declares
/// `property alias tier: ...tierOverride`; see `resolvedTier` below.
property string tierOverride: ""
/// Exact quality string for the hover tooltip, e.g. "24-bit / 96 kHz".
property string label: ""
/// For the "lossless" tier (un-hydrated filetype, e.g. an un-hydrated Plex
/// FLAC): the bare filetype to print, e.g. "FLAC".
property string detail: ""
// ---- Compat inputs (rows that only carry raw numbers) -----------------
property int bitDepth: 0
property real samplingRate: 0
property string format: ""
/// A tier already resolved in Rust (np_quality_tier). Wins over the
/// derivation below, which exists for rows that only carry raw numbers.
property string tierOverride: ""
/// Inert. QualityMini sets it to "iconOnly"; this component only ever
/// renders the icon-only form now.
property string mode: "iconOnly"
/// "full" | "compact" | "bare" | "iconOnly"
property string mode: "full"
// Tier, normalised to the FOUR names the Slint component draws. The Rust
// side still emits "max" for >96 kHz hi-res (local_library_qt::tier_of)
// and the old JS derivation produced "lossy" — both would have rendered a
// blank 30px box, so they are folded into hires / mp3 here.
//
// Called `resolvedTier`, NOT `tier`: QualityMini declares
// `property alias tier: ...tierOverride`, and a property redeclared in a
// derived type SHADOWS the base one for every lookup on that instance —
// including the bindings in this file. Naming it `tier` would silently
// feed the raw, un-normalised value back into the drawing code for every
// QualityMini (a local "max" album would render a blank chip).
readonly property string resolvedTier: {
var t = tierOverride !== "" ? tierOverride : derivedTier
if (t === "max") return "hires"
if (t === "lossy") return "mp3"
return t
}
// ---- Reactive state (now-playing surfaces only) -----------------------
/// The system/pipewire path is resampling: hardware rate < decoded rate.
property bool downgraded: false
/// QBZ deliberately fetched a lower quality for hardware compatibility —
/// the hardware then plays it natively. Informational, not a fault.
property bool adjusted: false
/// ALSA plughw fallback: the resample happens inside ALSA because the DAC
/// refuses the source rate (issue #288). Its own failure mode.
property bool plughw: false
/// Confirmed bit-perfect (BitPerfectMode::DirectHardware).
property bool bitPerfect: false
/// Effective output rate in kHz, for the tooltip's "output" row.
property real hardwareRateKHz: 0
// ---- Tier derivation (QualityBadge.svelte's `tier`, same order) -------
readonly property string tier: {
if (tierOverride !== "") return tierOverride
var fmt = (format || quality || "").toLowerCase()
readonly property string derivedTier: {
var fmt = (format || "").toLowerCase()
// MP3 is checked FIRST — it must never resolve as "CD".
if (fmt.indexOf("mp3") >= 0) return "mp3"
if (bitDepth >= 24 && samplingRate > 96) return "max"
if (bitDepth >= 24) return "hires"
if (bitDepth === 16 || (samplingRate >= 44.1 && samplingRate <= 48)) return "cd"
var q = quality.toLowerCase()
if (q.indexOf("mp3") >= 0 || q.indexOf("320") >= 0) return "lossy"
if (q.indexOf("hi-res") >= 0 || q.indexOf("hires") >= 0 || q.indexOf("24") >= 0) return "hires"
if (q.indexOf("cd") >= 0 || q.indexOf("flac") >= 0
|| q.indexOf("lossless") >= 0 || q.indexOf("16") >= 0) return "cd"
if (bitDepth === 16) return "cd"
if (samplingRate >= 44.1) return "cd"
return "cd"
return ""
}
readonly property bool isHiRes: tier === "max" || tier === "hires"
readonly property bool isHiRes: resolvedTier === "hires"
readonly property string tierLabel: {
if (tier === "max" || tier === "hires") return "Hi-Res"
if (tier === "mp3") return "MP3"
return "CD"
// Tooltip copy. Slint passes the model's own quality-detail as `label` and
// shows NOTHING when it is empty. The Qt card/row models do not carry a
// detail field yet (see the report), so a row that hands over raw numbers
// still gets a truthful string; nothing is fabricated when neither exists.
readonly property string tipText: {
if (label !== "") return label
if (bitDepth > 0 && samplingRate > 0) return bitDepth + "-bit / " + samplingRate + " kHz"
if (samplingRate > 0) return samplingRate + " kHz"
return ""
}
readonly property string iconPath: {
var base = "qrc:/qt/qml/com/blitzfc/qbz/qml/assets/"
if (isHiRes) return base + "hi-res.svg"
if (tier === "mp3") return base + "mp3.svg"
return base + "cd.svg"
}
readonly property string displayText: {
if (tier === "max") return (bitDepth || 24) + "-bit / " + (samplingRate || 192) + " kHz"
if (tier === "hires") return (bitDepth || 24) + "-bit / " + (samplingRate || 96) + " kHz"
if (tier === "cd") return (bitDepth || 16) + "-bit / " + (samplingRate || 44.1) + " kHz"
if (tier === "mp3") return (samplingRate || 44.1) + " kHz"
return "16-bit / 44.1 kHz"
}
// Short form for the narrow bar: drops the unit suffixes so the badge
// fits ~70px. The tooltip still carries the full string.
readonly property string compactText: {
if (tier === "max" || tier === "hires")
return (bitDepth || 24) + "/" + Math.round(samplingRate || (tier === "max" ? 192 : 96))
if (tier === "cd") return (bitDepth || 16) + "/" + Math.round(samplingRate || 44.1)
if (tier === "mp3") return "MP3"
return QbzSession.tr("CD Quality", QbzSession.trRev)
}
readonly property bool isCompact: mode === "compact"
readonly property bool isBare: mode === "bare"
readonly property bool isIconOnly: mode === "iconOnly"
readonly property bool anyIndicator: downgraded || adjusted || plughw
readonly property string fontFamily: "LINE Seed JP"
implicitWidth: isIconOnly ? (isHiRes ? 44 : 34) : shell.implicitWidth
implicitHeight: isIconOnly ? (isHiRes ? 29 : 30) : shell.implicitHeight
visible: resolvedTier !== ""
implicitWidth: resolvedTier === "hires" ? 42 : (resolvedTier === "lossless" ? 36 : 30)
implicitHeight: 30
width: implicitWidth
height: implicitHeight
// ---- icon-only ---------------------------------------------------------
// Hi-Res renders the brand mark bare (its own rounded rectangle is part of
// the artwork); CD/MP3 sit in a small framed square of matching outer
// size, plus the 4px right margin that compensates the Hi-Res SVG's
// built-in transparent gutter so both variants terminate at the same X.
Loader {
active: root.isIconOnly
sourceComponent: Item {
width: root.width
height: root.height
Rectangle {
visible: !root.isHiRes
width: 30
height: 30
radius: 3
color: theme.alphaTier(8)
border.width: 1
border.color: theme.alphaTier(12)
}
Image {
source: root.iconPath
width: root.isHiRes ? 44 : 16
height: root.isHiRes ? 29 : 16
x: root.isHiRes ? 0 : (30 - width) / 2
y: root.isHiRes ? 0 : (30 - height) / 2
fillMode: Image.PreserveAspectFit
sourceSize: Qt.size(Math.round(width * 2), Math.round(height * 2))
}
ToolTip.visible: iconHover.containsMouse
ToolTip.text: root.tierLabel + ": " + root.displayText
MouseArea {
id: iconHover
anchors.fill: parent
hoverEnabled: true
}
}
// --- hires: the brand mark, un-tinted (it carries its own colours) -----
Image {
visible: root.resolvedTier === "hires"
source: "qrc:/qt/qml/com/blitzfc/qbz/qml/assets/hi-res.svg"
width: 42
height: 28
x: Math.round((root.width - width) / 2)
y: Math.round((root.height - height) / 2)
fillMode: Image.PreserveAspectFit
sourceSize: Qt.size(84, 56)
}
// ---- full / compact / bare --------------------------------------------
// --- cd / mp3 / lossless: the framed chip ------------------------------
Rectangle {
id: shell
visible: !root.isIconOnly
// full: 136x36 pill. compact: hugs its content. bare: no chrome.
implicitWidth: root.isBare
? contentRow.implicitWidth
: Math.max(root.isCompact ? 0 : 136, contentRow.implicitWidth + 2 * hPad)
implicitHeight: root.isBare ? contentRow.implicitHeight
: (root.isCompact ? contentRow.implicitHeight + 2 * vPad : 36)
readonly property int hPad: root.isBare ? 0 : (root.isCompact ? 6 : 10)
readonly property int vPad: root.isBare ? 0 : (root.isCompact ? 4 : 5)
radius: root.isBare ? 0 : 4
color: root.isBare ? "transparent" : theme.alphaTier(6)
border.width: root.isBare ? 0 : 1
// The state borders: each failure mode gets its own hue so the user
// knows where to look for the fix.
border.color: root.downgraded ? "#4ceab308"
: root.plughw ? "#4ca855f7"
: root.bitPerfect ? "#4022c55e"
: theme.alphaTier(10)
id: chip
visible: root.resolvedTier === "cd" || root.resolvedTier === "mp3" || root.resolvedTier === "lossless"
width: root.resolvedTier === "lossless" ? 36 : 30
height: 30
radius: 3
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Row {
id: contentRow
x: shell.hPad
y: shell.vPad
spacing: root.isCompact ? 4 : 6
// Icon container — fixed width so the text column never shifts
// between tiers.
Item {
width: root.isCompact ? 20 : 24
height: root.isCompact ? 20 : 24
anchors.verticalCenter: undefined
Image {
anchors.centerIn: parent
source: root.iconPath
// Hi-Res fills the container; CD/MP3 are the smaller glyph.
width: root.isHiRes ? (root.isCompact ? 20 : 24) : (root.isCompact ? 14 : 16)
height: width
fillMode: Image.PreserveAspectFit
sourceSize: Qt.size(Math.round(width * 2), Math.round(height * 2))
}
}
// Text block
Column {
spacing: 0
// Compact drops the tier row entirely.
Row {
visible: !root.isCompact
spacing: 2
Text {
text: root.tierLabel.toUpperCase()
font.family: root.fontFamily
font.pixelSize: 8
font.weight: Font.Thin
font.letterSpacing: 0.5
color: "#b0b0b0"
}
Text {
visible: root.anyIndicator
text: "↓"
font.family: root.fontFamily
font.pixelSize: 8
font.bold: true
color: root.adjusted ? "#60a5fa" : root.plughw ? "#a855f7" : "#eab308"
}
Text {
visible: root.bitPerfect
text: "✓"
font.family: root.fontFamily
font.pixelSize: 8
font.bold: true
color: "#22c55e"
}
}
Row {
spacing: 2
Text {
text: root.isCompact ? root.compactText : root.displayText
font.family: root.fontFamily
font.pixelSize: root.isCompact ? 11 : 9
font.weight: root.isCompact ? Font.Medium : Font.Thin
font.letterSpacing: root.isCompact ? 0.2 : 0
color: root.isCompact ? theme.textPrimary : "#999999"
}
// Compact carries the indicators on the value row, since
// it has no tier row to hang them from.
Text {
visible: root.isCompact && root.anyIndicator
text: "↓"
font.family: root.fontFamily
font.pixelSize: 11
font.bold: true
color: root.adjusted ? "#60a5fa" : root.plughw ? "#a855f7" : "#eab308"
}
Text {
visible: root.isCompact && root.bitPerfect
text: "✓"
font.family: root.fontFamily
font.pixelSize: 11
font.bold: true
color: "#22c55e"
}
}
}
// Muted-tier mark — themed (a fixed mid-grey vanished on light
// themes). QbzIcon serves the pre-baked muted variant; hi-res above
// keeps its brand colours.
QbzIcon {
visible: root.resolvedTier === "cd" || root.resolvedTier === "mp3"
name: root.resolvedTier === "mp3" ? "mp3" : "cd"
tintName: "muted"
width: 16
height: 16
x: Math.round((parent.width - width) / 2)
y: Math.round((parent.height - height) / 2)
}
MouseArea {
id: shellHover
anchors.fill: parent
hoverEnabled: true
// `cursor: help` in the CSS; Qt's nearest is WhatsThis.
cursorShape: Qt.WhatsThisCursor
// Un-hydrated lossless: bit-depth/rate unknown, filetype known — a
// neutral text chip ("FLAC") instead of disappearing.
Text {
visible: root.resolvedTier === "lossless"
text: root.detail
color: theme.textMuted
font.pixelSize: 9
font.weight: theme.weightMedium
x: Math.round((parent.width - width) / 2)
y: Math.round((parent.height - height) / 2)
}
// Plain hover title when there is nothing to explain. The explained
// states get the rich tooltip below instead.
ToolTip.visible: shellHover.containsMouse && !root.anyIndicator
ToolTip.text: root.tierLabel + ": " + root.displayText
}
// ---- Rich tooltip for the three explained states -----------------------
// Source/output rows, then the reason and the fix. Same precedence as the
// Svelte: plughw wins, then adjusted, then downgraded.
Loader {
active: !root.isIconOnly && shellHover.containsMouse && root.anyIndicator
sourceComponent: Rectangle {
parent: root
z: 9999
x: (root.width - width) / 2
y: -height - 8
width: Math.max(180, Math.min(240, col.implicitWidth + 24))
height: col.implicitHeight + 16
color: theme.surfaceCard
border.width: 1
border.color: theme.alphaTier(10)
radius: 6
opacity: 0
Component.onCompleted: opacity = 1
Behavior on opacity { NumberAnimation { duration: 150 } }
Column {
id: col
x: 12
y: 8
width: parent.width - 24
spacing: 4
Column {
spacing: 1
Text {
text: QbzSession.tr("SOURCE", QbzSession.trRev)
font.pixelSize: 9
font.letterSpacing: 0.5
color: theme.textMuted
}
Text {
text: root.adjusted && root.samplingRate > 0
? root.samplingRate + " kHz"
: root.displayText
font.pixelSize: 12
font.weight: Font.Medium
color: theme.textPrimary
}
}
Column {
spacing: 1
Text {
text: QbzSession.tr("OUTPUT", QbzSession.trRev)
font.pixelSize: 9
font.letterSpacing: 0.5
color: theme.textMuted
}
Text {
text: root.hardwareRateKHz > 0
? root.hardwareRateKHz + " kHz"
: root.displayText
font.pixelSize: 12
font.weight: Font.Medium
color: theme.textPrimary
}
}
Rectangle {
width: parent.width
height: 1
color: theme.alphaTier(10)
}
Text {
width: parent.width
wrapMode: Text.WordWrap
font.pixelSize: 11
font.weight: Font.Medium
color: root.adjusted ? "#60a5fa" : "#eab308"
text: root.plughw
? QbzSession.tr("ALSA is resampling this stream", QbzSession.trRev)
: root.adjusted
? QbzSession.tr("Quality adjusted for your hardware", QbzSession.trRev)
: QbzSession.tr("This stream is being resampled", QbzSession.trRev)
}
Text {
visible: !root.adjusted
width: parent.width
wrapMode: Text.WordWrap
font.pixelSize: 10
lineHeight: 1.3
color: theme.textMuted
text: root.plughw
? QbzSession.tr("Your DAC refused the source rate, so ALSA converted it. Pick a rate the device supports in Settings > Audio.", QbzSession.trRev)
: QbzSession.tr("Check that exclusive mode is on and no other app holds the device.", QbzSession.trRev)
}
}
// --- Hover tooltip -----------------------------------------------------
// Right-anchored 6px above the badge so it extends LEFTWARD into the card
// and is never clipped by the next one. Rendered as a Controls ToolTip
// (a popup) rather than a child Rectangle: several call sites sit inside
// `clip: true` containers, where Slint's negative-y child would vanish.
ToolTip {
id: tip
visible: hover.containsMouse && root.tipText !== ""
text: root.tipText
delay: 0
timeout: -1
padding: 0
x: root.width - width
y: -height - 6
implicitHeight: 24
contentItem: Text {
text: tip.text
color: theme.textSecondary
font.pixelSize: 11
font.weight: theme.weightMedium
verticalAlignment: Text.AlignVCenter
leftPadding: 9
rightPadding: 9
}
background: Rectangle {
color: theme.surfaceElevated
radius: theme.radiusSm
border.width: 1
border.color: theme.borderSubtle
}
}
MouseArea {
id: hover
anchors.fill: parent
hoverEnabled: true
// Slint: mouse-cursor: default. Its TouchArea absorbs the press too,
// so the default acceptedButtons is kept (1:1).
cursorShape: Qt.ArrowCursor
}
}