From e8a821dd9e9eb233cfddcffc29023cf11cf8cda3 Mon Sep 17 00:00:00 2001
From: vicrodh <103399283+vicrodh@users.noreply.github.com>
Date: Tue, 28 Jul 2026 15:24:18 -0600
Subject: [PATCH] feat(qbz-qt): the quality stamp, ported from Slint per bar
mode
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
crates/qbz-qt/build.rs | 3 +
.../assets/icons/amber/monitor-speaker.svg | 19 +
crates/qbz-qt/qml/assets/icons/muted/mp3.svg | 2 +
crates/qbz-qt/qml/controls/QualityBadge.qml | 477 ++++++------------
.../qbz-qt/qml/controls/QualityBadgeFull.qml | 136 +++++
crates/qbz-qt/qml/shell/AudioStamp.qml | 255 ++++++++++
.../qbz-qt/qml/shell/NowPlayingBarSmall.qml | 38 +-
crates/qbz-qt/qml/shell/PlayerBar.qml | 119 ++---
crates/qbz-qt/qml/shell/SongCardStamp.qml | 272 ++++++++++
9 files changed, 876 insertions(+), 445 deletions(-)
create mode 100644 crates/qbz-qt/qml/assets/icons/amber/monitor-speaker.svg
create mode 100644 crates/qbz-qt/qml/assets/icons/muted/mp3.svg
create mode 100644 crates/qbz-qt/qml/controls/QualityBadgeFull.qml
create mode 100644 crates/qbz-qt/qml/shell/AudioStamp.qml
create mode 100644 crates/qbz-qt/qml/shell/SongCardStamp.qml
diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs
index fbeea7a66..fd097d6f2 100644
--- a/crates/qbz-qt/build.rs
+++ b/crates/qbz-qt/build.rs
@@ -97,6 +97,9 @@ fn main() {
"qml/views/PlaylistView.qml",
"qml/views/SearchView.qml",
"qml/views/SectionRail.qml",
+ "qml/controls/QualityBadgeFull.qml",
+ "qml/shell/AudioStamp.qml",
+ "qml/shell/SongCardStamp.qml",
"qml/shell/SpectrumBand.qml",
"qml/shell/VizSettle.qml",
],
diff --git a/crates/qbz-qt/qml/assets/icons/amber/monitor-speaker.svg b/crates/qbz-qt/qml/assets/icons/amber/monitor-speaker.svg
new file mode 100644
index 000000000..6a79ef1b1
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/amber/monitor-speaker.svg
@@ -0,0 +1,19 @@
+
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/mp3.svg b/crates/qbz-qt/qml/assets/icons/muted/mp3.svg
new file mode 100644
index 000000000..96e1373aa
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/mp3.svg
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/crates/qbz-qt/qml/controls/QualityBadge.qml b/crates/qbz-qt/qml/controls/QualityBadge.qml
index b487048e2..7452bb4ec 100644
--- a/crates/qbz-qt/qml/controls/QualityBadge.qml
+++ b/crates/qbz-qt/qml/controls/QualityBadge.qml
@@ -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
}
}
diff --git a/crates/qbz-qt/qml/controls/QualityBadgeFull.qml b/crates/qbz-qt/qml/controls/QualityBadgeFull.qml
new file mode 100644
index 000000000..ac157b5cb
--- /dev/null
+++ b/crates/qbz-qt/qml/controls/QualityBadgeFull.qml
@@ -0,0 +1,136 @@
+// QualityBadgeFull — 1:1 port of
+// crates/qbz-ui/ui/primitives/QualityBadgeFull.slint.
+//
+// The contained pill: format icon + the tier label stacked over the exact
+// bit-depth / sample-rate line. This is what the New and Classic now-playing
+// bars mount (inside the song card's audio stamp) and what album/track rows
+// use to advertise stored quality. The Small and Large bars mount
+// shell/AudioStamp.qml instead — a different component entirely.
+//
+// Numbers, read off the .slint (sf = scaleFactor):
+// height (compact 29 | 36) * sf
+// min width (compact 108 | 136) * sf, or content width when `bare`
+// radius 4, surface-hover fill, 1px border-subtle (all dropped when `bare`)
+// padding (compact 8 | 10) * sf spacing (compact 5 | 6) * sf
+// icon hires (compact 19 | 24) * sf cd/mp3/lossless (compact 13 | 16) * sf
+// tier text (compact 7 | 8) * sf, weight 100, letter-spacing 0.5, secondary
+// detail (compact 8 | 9) * sf, weight 100, muted
+
+import QtQuick
+import com.blitzfc.qbz
+import "../theme"
+
+Item {
+ id: root
+
+ QbzTheme { id: theme }
+
+ /// "hires" | "cd" | "mp3" | "lossless" | "".
+ property string tier: ""
+ /// Exact quality, e.g. "24-bit / 96 kHz".
+ property string detail: ""
+ /// Show the format mark. The track-row variant turns it off.
+ property bool showIcon: true
+ /// No chip background or border, text centred — blends into a track row.
+ property bool bare: false
+ /// ~20%-smaller variant for the now-playing stamp.
+ property bool compact: false
+ /// Extra proportional scale on top of `compact` (1.0 = none).
+ property real scaleFactor: 1.0
+
+ readonly property string tierLabel: tier === "hires" ? "HI-RES"
+ : (tier === "mp3" ? "MP3"
+ : (tier === "lossless" ? "LOSSLESS" : "CD"))
+
+ readonly property bool isHiRes: tier === "hires"
+ readonly property real sf: scaleFactor
+ readonly property real padH: bare ? 0 : (compact ? 8 : 10) * sf
+ readonly property real gap: bare ? 0 : (compact ? 5 : 6) * sf
+ readonly property real iconSide: isHiRes ? (compact ? 19 : 24) * sf
+ : (compact ? 13 : 16) * sf
+ // The row's own width (no paddings) — the icon slot plus the text column.
+ readonly property real innerWidth: (showIcon ? iconSide + gap : 0) + textCol.width
+
+ visible: tier !== ""
+ implicitHeight: (compact ? 29 : 36) * sf
+ implicitWidth: bare ? innerWidth
+ : Math.max(innerWidth + 2 * padH, (compact ? 108 : 136) * sf)
+ width: implicitWidth
+ height: implicitHeight
+
+ Rectangle {
+ anchors.fill: parent
+ radius: 4
+ color: root.bare ? "transparent" : theme.surfaceHover
+ border.width: root.bare ? 0 : 1
+ border.color: theme.borderSubtle
+ }
+
+ Row {
+ id: content
+ // `alignment: start` past the left padding; `alignment: center` when
+ // bare (no padding to start from).
+ x: root.bare ? Math.round((root.width - root.innerWidth) / 2) : root.padH
+ height: parent.height
+ spacing: root.showIcon ? root.gap : 0
+
+ // Format mark. The Hi-Res brand mark keeps its own colours; the CD /
+ // MP3 / LOSSLESS marks render in the themed muted tier.
+ Item {
+ visible: root.showIcon
+ width: root.showIcon ? root.iconSide : 0
+ height: parent.height
+
+ Image {
+ visible: root.isHiRes
+ anchors.centerIn: parent
+ source: "qrc:/qt/qml/com/blitzfc/qbz/qml/assets/hi-res.svg"
+ width: root.iconSide
+ height: root.iconSide
+ fillMode: Image.PreserveAspectFit
+ sourceSize: Qt.size(Math.round(root.iconSide * 2), Math.round(root.iconSide * 2))
+ }
+ // Un-hydrated lossless reuses the neutral disc mark; the detail
+ // line carries "FLAC".
+ QbzIcon {
+ visible: !root.isHiRes
+ anchors.centerIn: parent
+ name: root.tier === "mp3" ? "mp3" : "cd"
+ tintName: "muted"
+ width: root.iconSide
+ height: root.iconSide
+ }
+ }
+
+ Column {
+ id: textCol
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 1
+ // Explicit (not implicit) so the children may bind to it: a Text
+ // with `width: parent.width` inside an implicitly-sized Column is
+ // a binding loop. Both Texts are unwrapped, so `implicitWidth` is
+ // the natural content width and does not depend on `width`.
+ width: Math.max(tierText.implicitWidth, detailText.implicitWidth)
+
+ Text {
+ id: tierText
+ width: parent.width
+ text: root.tierLabel
+ color: theme.textSecondary
+ font.pixelSize: Math.max(1, Math.round((root.compact ? 7 : 8) * root.sf))
+ font.weight: Font.Thin
+ font.letterSpacing: 0.5
+ horizontalAlignment: root.bare ? Text.AlignHCenter : Text.AlignLeft
+ }
+ Text {
+ id: detailText
+ width: parent.width
+ text: root.detail
+ color: theme.textMuted
+ font.pixelSize: Math.max(1, Math.round((root.compact ? 8 : 9) * root.sf))
+ font.weight: Font.Thin
+ horizontalAlignment: root.bare ? Text.AlignHCenter : Text.AlignLeft
+ }
+ }
+ }
+}
diff --git a/crates/qbz-qt/qml/shell/AudioStamp.qml b/crates/qbz-qt/qml/shell/AudioStamp.qml
new file mode 100644
index 000000000..ddb087c13
--- /dev/null
+++ b/crates/qbz-qt/qml/shell/AudioStamp.qml
@@ -0,0 +1,255 @@
+// AudioStamp — 1:1 port of crates/qbz-ui/ui/shell/AudioStamp.slint.
+//
+// The inline 2-row now-playing stamp mounted by the SMALL and LARGE bars (the
+// New and Classic bars mount the song card's QualityBadgeFull stamp instead):
+//
+// Row 1 (focus): [TIER] [↓] [detail] e.g. HI-RES 24-bit / 96 kHz
+// Row 2 (secondary): [BACKEND] [MODE] the two LEDs, smaller, below
+//
+// Row 1 is the brighter line (detail = text-primary 9px/600, tier =
+// text-secondary 9px/700) so the 7px LEDs never steal focus. No pills
+// (ADR-008): the backend LED goes blue #5b8def when active, the mode LED green
+// #3fae6a, both text-muted otherwise. Everything is right-aligned and clamped
+// by `maxWidth` so it can never blow out the control row.
+//
+// Row 2 has three mutually exclusive variants, exactly as in the .slint:
+// local — backend + mode LEDs (only while neither remote nor casting)
+// cast — one purple #a855f7 "DLNA"/"CAST" LED
+// remote — amber #e0b341 monitor-speaker icon + the QConnect renderer name
+//
+// While the stream is downgraded, row 1 reports the DELIVERED tier/detail with
+// an amber #eab308 down arrow and the catalog max moves into the tooltip
+// ("Source" / "Output" + the cause line).
+//
+// The tier label is built MANUALLY (hires→HI-RES, mp3→MP3, lossless→LOSSLESS,
+// else CD) — this component deliberately does NOT use QualityBadgeFull, which
+// carries the format icon and stacks tier over detail.
+
+import QtQuick
+import QtQuick.Controls
+import com.blitzfc.qbz
+import "../theme"
+
+Item {
+ id: root
+
+ QbzTheme { id: theme }
+
+ /// Width clamp (Slint: the VerticalLayout's max-width at the call site).
+ property int maxWidth: 150
+
+ // Manual tier label: hires→HI-RES, mp3→MP3, lossless→LOSSLESS, else→CD.
+ readonly property string tierLabel: QbzPlayer.npQualityTier === "hires" ? "HI-RES"
+ : (QbzPlayer.npQualityTier === "mp3" ? "MP3"
+ : (QbzPlayer.npQualityTier === "lossless" ? "LOSSLESS" : "CD"))
+
+ // DELIVERED-first main line: while downgraded, row 1 reports what is
+ // actually playing and the catalog max moves to the tooltip's "Source".
+ readonly property bool showDelivered: QbzPlayer.npQualityDowngraded
+ && QbzPlayer.npQualityTrueDetail !== ""
+ readonly property string deliveredTierLabel: QbzPlayer.npQualityEffectiveTier === "hires"
+ ? "HI-RES"
+ : (QbzPlayer.npQualityEffectiveTier === "mp3" ? "MP3" : "CD")
+
+ // WHY the stream is below the catalog max, keyed by the QualityLimit
+ // discriminant. Informative, not alarming: a downgrade caused by the
+ // user's own setting is the app doing what it was told. Cause 3 only ever
+ // fires while casting, so it NAMES the renderer (its sibling cause 2 says
+ // "device" for the local DAC).
+ readonly property string causeLine: {
+ 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 ""
+ }
+
+ // Tooltip for row 1: the catalog max ("Source") vs the truly delivered
+ // stream ("Output") plus the cause when known; the plain tier + detail
+ // otherwise. Empty when no detail exists (never a bare "CD: ").
+ readonly property string qualityTooltip: {
+ if (QbzPlayer.npQualityDetail === "") return ""
+ if (!showDelivered) return tierLabel + ": " + QbzPlayer.npQualityDetail
+ var t = QbzSession.tr("Source: {}", QbzSession.trRev).replace("{}", QbzPlayer.npQualityDetail)
+ + "\n"
+ + QbzSession.tr("Output: {}", QbzSession.trRev).replace("{}", QbzPlayer.npQualityTrueDetail)
+ return causeLine === "" ? t : t + "\n" + causeLine
+ }
+
+ readonly property bool localOutput: !QbzPlayer.npIsRemote && !QbzPlayer.npCastActive
+ readonly property bool castOnly: QbzPlayer.npCastActive && !QbzPlayer.npIsRemote
+ readonly property bool remoteNamed: QbzPlayer.npIsRemote && QbzPlayer.npCastTarget !== ""
+
+ implicitWidth: Math.min(maxWidth, Math.max(qualityRow.implicitWidth,
+ ledRow.visible ? ledRow.implicitWidth : 0,
+ castRow.visible ? castRow.implicitWidth : 0,
+ remoteRow.visible ? remoteRow.implicitWidth : 0))
+ implicitHeight: stack.implicitHeight
+ width: implicitWidth
+ height: implicitHeight
+
+ Column {
+ id: stack
+ width: root.width
+ spacing: 1
+
+ // === Row 1 — quality, INLINE + BRIGHT (the focus) ================
+ // Wrapped in an Item so the hover MouseArea can `anchors.fill` it —
+ // an anchored Item inside a Row is a QML error ("Cannot specify
+ // anchors for items inside Row").
+ Item {
+ id: qualityRow
+ anchors.right: parent.right
+ implicitWidth: qualityLine.implicitWidth
+ implicitHeight: qualityLine.implicitHeight
+ width: implicitWidth
+ height: implicitHeight
+
+ Row {
+ id: qualityLine
+ anchors.right: parent.right
+ spacing: 5
+
+ Text {
+ id: tierText
+ // Delivered tier while downgraded; catalog tier otherwise.
+ text: root.showDelivered ? root.deliveredTierLabel : root.tierLabel
+ font.pixelSize: 9
+ font.weight: Font.Bold
+ font.letterSpacing: 0.3
+ color: theme.textSecondary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ // Downgrade arrow (glyph, not a pill — ADR-008). Amber so it
+ // reads as "adjusted", not as an error.
+ Text {
+ id: arrowText
+ visible: QbzPlayer.npQualityDowngraded
+ text: "↓"
+ font.pixelSize: 9
+ font.weight: Font.Bold
+ color: "#eab308"
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ Text {
+ // Delivered detail while downgraded; catalog otherwise.
+ text: root.showDelivered ? QbzPlayer.npQualityTrueDetail : QbzPlayer.npQualityDetail
+ font.pixelSize: 9
+ font.weight: Font.DemiBold
+ color: theme.textPrimary
+ elide: Text.ElideRight
+ // Elide needs an explicit width. Derived from the clamp
+ // minus the sibling widths — none of which depend on this
+ // one, so there is no binding loop.
+ width: Math.max(0, Math.min(implicitWidth,
+ root.maxWidth - tierText.implicitWidth - 5
+ - (arrowText.visible ? arrowText.implicitWidth + 5 : 0)))
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ MouseArea {
+ id: qualityHover
+ anchors.fill: parent
+ hoverEnabled: true
+ }
+
+ ToolTip {
+ id: qualityTip
+ visible: qualityHover.containsMouse && root.qualityTooltip !== ""
+ text: root.qualityTooltip
+ delay: 0
+ timeout: -1
+ padding: 0
+ x: qualityRow.width - width
+ y: -height - 6
+ contentItem: Text {
+ text: qualityTip.text
+ color: theme.textSecondary
+ font.pixelSize: 11
+ font.weight: theme.weightMedium
+ lineHeight: 1.25
+ leftPadding: 9
+ rightPadding: 9
+ topPadding: 6
+ bottomPadding: 6
+ }
+ background: Rectangle {
+ color: theme.surfaceElevated
+ radius: theme.radiusSm
+ border.width: 1
+ border.color: theme.borderSubtle
+ }
+ }
+ }
+
+ // === Row 2a — the LEDs (local output) ============================
+ Row {
+ id: ledRow
+ visible: root.localOutput
+ anchors.right: parent.right
+ spacing: 5
+
+ Text {
+ text: QbzPlayer.npOutputBackendLabel
+ font.pixelSize: 7
+ font.weight: Font.Bold
+ color: QbzPlayer.npOutputBackendActive ? "#5b8def" : theme.textMuted
+ elide: Text.ElideRight
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ Text {
+ text: QbzPlayer.npOutputModeLabel
+ font.pixelSize: 7
+ font.weight: Font.Bold
+ color: QbzPlayer.npOutputModeActive ? "#3fae6a" : theme.textMuted
+ elide: Text.ElideRight
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ // === Row 2b — local-network cast (Chromecast / DLNA) =============
+ Row {
+ id: castRow
+ visible: root.castOnly
+ anchors.right: parent.right
+
+ Text {
+ text: QbzPlayer.npCastProtocol === "dlna" ? "DLNA" : "CAST"
+ font.pixelSize: 7
+ font.weight: Font.Bold
+ color: "#a855f7"
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ // === Row 2c — QConnect renderer-name caption =====================
+ Row {
+ id: remoteRow
+ visible: root.remoteNamed
+ anchors.right: parent.right
+ spacing: 3
+
+ QbzIcon {
+ name: "monitor-speaker"
+ // Baked #e0b341 variant (assets/icons/amber/) — the software
+ // path cannot recolour at render time.
+ tintName: "amber"
+ width: 10
+ height: 10
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ Text {
+ text: QbzPlayer.npCastTarget
+ font.pixelSize: 8
+ font.weight: Font.DemiBold
+ color: "#e0b341"
+ elide: Text.ElideRight
+ width: Math.min(implicitWidth, 220)
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+ }
+}
diff --git a/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml b/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml
index 22b8e1d85..7a7f6cc6f 100644
--- a/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml
+++ b/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml
@@ -17,7 +17,8 @@
// next · repeat · "+"), and the right cluster keeps PlayerBarSmall's mount
// order (Connect · Cast · Settings · ViewMode · Lyrics · Volume · Queue).
// The "+" opens the same "Add to…" flyout as the full bar; the quality stamp
-// is the shared QualityBadge in "compact" mode, not hand-drawn text.
+// is AudioStamp.qml — the inline 2-row stamp (quality line over the backend /
+// mode LEDs) that PlayerBarSmall.slint mounts, shared with the Large bar.
//
// Connect / Cast / Settings / ViewMode are still inert visual replicas where
// no Qt invokable exists (TODO comments at the call sites); Volume, Queue,
@@ -61,20 +62,6 @@ Rectangle {
}
}
- // np_quality_label is "24-bit / 96 kHz" (playback_qt::quality_badge);
- // QualityBadge wants the raw numbers.
- readonly property int npBitDepth: {
- var l = QbzPlayer.npQualityLabel
- var i = l.indexOf("-bit")
- return i > 0 ? parseInt(l.substring(0, i)) : 0
- }
- readonly property real npSampleRate: {
- var l = QbzPlayer.npQualityLabel
- var i = l.indexOf("/")
- var j = l.indexOf("kHz")
- return (i >= 0 && j > i) ? parseFloat(l.substring(i + 1, j)) : 0
- }
-
function fmt(secs) {
var m = Math.floor(secs / 60)
var s = Math.floor(secs % 60)
@@ -368,18 +355,17 @@ Rectangle {
height: parent.height
spacing: 1
- // AudioStamp — the shared QualityBadge in its narrow-bar
- // "compact" mode (icon + "24/96"), same component the full
- // bar uses. POC-NOTE: row 2 (backend/mode LEDs) is not
- // rendered here — the Small stamp has no room for it.
- QualityBadge {
- visible: QbzPlayer.npHasTrack && QbzPlayer.npQualityLabel !== ""
+ // AudioStamp — the inline 2-row stamp (quality line over
+ // the backend/mode LEDs), FIRST in the right set.
+ // PlayerBarSmall.slint clamps it at 150px, widening to
+ // 280px when a QConnect renderer name is shown (it grows
+ // leftward into the empty space before the centre
+ // transport; the right zone's clip is the real guard).
+ AudioStamp {
+ visible: QbzPlayer.npHasTrack
anchors.verticalCenter: parent.verticalCenter
- mode: "compact"
- tierOverride: QbzPlayer.npQualityTier === "hires" ? "hires"
- : (QbzPlayer.npQualityLabel !== "" ? "cd" : "")
- bitDepth: root.npBitDepth
- samplingRate: root.npSampleRate
+ maxWidth: (QbzPlayer.npIsRemote && QbzPlayer.npCastTarget !== "")
+ ? 280 : 150
}
// Separator: AudioStamp | icon cluster (10px toward the
diff --git a/crates/qbz-qt/qml/shell/PlayerBar.qml b/crates/qbz-qt/qml/shell/PlayerBar.qml
index bd9e7abc0..95b59e65b 100644
--- a/crates/qbz-qt/qml/shell/PlayerBar.qml
+++ b/crates/qbz-qt/qml/shell/PlayerBar.qml
@@ -17,7 +17,15 @@
// Classic (1): LEFT transport · CENTER glass SongCard (<=560px) · RIGHT cluster
// Large (3): LEFT SongCard WITHOUT the cover (it lives in the sidebar
// dock, shifted right to clear it) · CENTER transport ·
-// RIGHT small AudioStamp + cluster.
+// RIGHT inline AudioStamp + cluster.
+//
+// QUALITY STACK (which bar mounts which component — SongCard.slint /
+// PlayerBar.slint / PlayerBarSmall.slint):
+// New (0), Classic (1) -> shell/SongCardStamp.qml, mounted INSIDE the song
+// card: QualityBadgeFull (icon + stacked
+// tier/detail) over the dot LEDs.
+// Large (3), Small (2) -> shell/AudioStamp.qml, the inline 2-row stamp.
+// They are DIFFERENT components in Slint and they stay different here.
//
// BUTTON SET (phase 24 — 1:1 with the Tauri NowPlayingBar.svelte, via the
// Slint TransportControls/PlayerBar that descend from it):
@@ -70,8 +78,10 @@ Rectangle {
return ({})
}
}
- readonly property bool backendPipewire: settingsDoc.backendIsPipewire === true
- readonly property bool dacPassthrough: settingsDoc.dacPassthrough === true
+ // (The old backendPipewire / dacPassthrough LED sources are gone: the
+ // stamp's LEDs now read np_output_backend_* / np_output_mode_* off
+ // QbzPlayer, which is where Slint reads them too — the settings document
+ // never knew whether the backend was actually ENGAGED.)
// AppearanceState.show-volume-steppers (PlayerBar.slint gates the −/+
// pair on it; Tauri always showed them).
readonly property bool showVolumeSteppers: settingsDoc.showVolumeSteppers === true
@@ -104,21 +114,6 @@ Rectangle {
QbzBridge.settingsBool("normalization", normLocal)
}
- // np_quality_label is "24-bit / 96 kHz" (playback_qt::quality_badge);
- // QualityBadge wants the raw numbers, so split the published string
- // instead of duplicating the tier logic.
- readonly property int npBitDepth: {
- var l = QbzPlayer.npQualityLabel
- var i = l.indexOf("-bit")
- return i > 0 ? parseInt(l.substring(0, i)) : 0
- }
- readonly property real npSampleRate: {
- var l = QbzPlayer.npQualityLabel
- var i = l.indexOf("/")
- var j = l.indexOf("kHz")
- return (i >= 0 && j > i) ? parseFloat(l.substring(i + 1, j)) : 0
- }
-
function fmt(secs) {
var m = Math.floor(secs / 60)
var s = Math.floor(secs % 60)
@@ -127,66 +122,6 @@ Rectangle {
// --- Shared bits -------------------------------------------------------
- // Square compact icon button (BarControls.IconButton).
-
-
- // Dot LED (circle-dot on / circle off) + constant-colour label — the
- // New-mode output indicators (SongCard dot-leds).
- component DotLed: Row {
- property string label: ""
- property bool on: false
- spacing: 4
- height: 12
- Rectangle {
- width: 8
- height: 8
- radius: 4
- anchors.verticalCenter: parent.verticalCenter
- color: parent.on ? theme.accent : "transparent"
- border.width: 1
- border.color: parent.on ? theme.accent : theme.textMuted
- }
- Text {
- text: parent.label
- color: theme.textMuted
- font.pixelSize: 9
- font.weight: theme.weightSemibold
- font.letterSpacing: 0.5
- verticalAlignment: Text.AlignVCenter
- }
- }
-
- // The audio stamp (quality badge + dot LEDs) — New mode keeps the full
- // stamp inside the SongCard; Large mounts a compact one on the right
- // column's left wall.
- component AudioStamp: Row {
- property bool showLeds: true
- spacing: 8
- height: 30
- layoutDirection: Qt.RightToLeft
-
- // The badge itself is the Tauri QualityBadge in its narrow-bar
- // "compact" mode (icon + "24/96"), NOT a hand-drawn glyph+label pair.
- QualityBadge {
- visible: QbzPlayer.npQualityLabel !== ""
- mode: "compact"
- // The stamp shows the cd glyph for ANY non-hires tier with a
- // label (not just "cd") — map that exactly.
- tierOverride: QbzPlayer.npQualityTier === "hires" ? "hires"
- : (QbzPlayer.npQualityLabel !== "" ? "cd" : "")
- bitDepth: root.npBitDepth
- samplingRate: root.npSampleRate
- anchors.verticalCenter: parent.verticalCenter
- }
- Column {
- visible: showLeds
- anchors.verticalCenter: parent.verticalCenter
- spacing: 1
- DotLed { label: "PIPEWIRE"; on: root.backendPipewire }
- DotLed { label: "DACPASS"; on: root.dacPassthrough }
- }
- }
-
// SongCard (SongCard.slint): cover + title + context meta + the in-card
// audio stamp. `glass` renders the Classic contained card.
component SongCard: Rectangle {
@@ -201,8 +136,12 @@ Rectangle {
Row {
anchors.left: parent.left
- anchors.right: stamp.left
- anchors.rightMargin: 4
+ // SongCard.slint's inner-row width formula: the card minus the
+ // stamp + a 4px gap ONLY when the stamp is rendered (Large hides
+ // it — the AudioStamp moves to the right column — so the text
+ // column must reclaim those 132px).
+ anchors.right: stamp.visible ? stamp.left : parent.right
+ anchors.rightMargin: stamp.visible ? 4 : 0
height: parent.height
spacing: 0
@@ -283,7 +222,7 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
- onClicked: if (QbzPlayer.npAlbumId !== "") QbzBridge.openAlbum(QbzPlayer.npAlbumId)
+ onClicked: if (QbzPlayer.npAlbumId !== "") QbzAlbum.openAlbum(QbzPlayer.npAlbumId)
}
}
Text {
@@ -297,7 +236,7 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
cursorShape: QbzPlayer.npArtistId !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
- onClicked: if (QbzPlayer.npArtistId !== "") QbzBridge.openArtist(QbzPlayer.npArtistId)
+ onClicked: if (QbzPlayer.npArtistId !== "") QbzArtist.openArtist(QbzPlayer.npArtistId)
}
}
Rectangle {
@@ -318,16 +257,19 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
cursorShape: QbzPlayer.npAlbumId !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
- onClicked: if (QbzPlayer.npAlbumId !== "") QbzBridge.openAlbum(QbzPlayer.npAlbumId)
+ onClicked: if (QbzPlayer.npAlbumId !== "") QbzAlbum.openAlbum(QbzPlayer.npAlbumId)
}
}
}
}
}
- AudioStamp {
+ SongCardStamp {
id: stamp
visible: showBadges && QbzPlayer.npHasTrack
anchors.right: parent.right
+ // Classic (glass) insets the stamp by pad + stamp-right-margin
+ // (2px each) so it clears the visible card border.
+ anchors.rightMargin: glass ? 4 : 0
anchors.verticalCenter: parent.verticalCenter
}
}
@@ -602,12 +544,13 @@ Rectangle {
anchors.verticalCenter: parent.verticalCenter
spacing: 1
- // Large: the SMALL AudioStamp just LEFT of the icon
- // cluster at a fixed 12px gap (the badges move out of the
- // song card in Large).
+ // Large: the inline 2-row AudioStamp just LEFT of the icon
+ // cluster at a fixed 12px gap (the badges move OUT of the
+ // song card in Large). PlayerBar.slint mounts it with
+ // max-width: 140px.
AudioStamp {
visible: root.largeActive && QbzPlayer.npHasTrack
- showLeds: false
+ maxWidth: 140
anchors.verticalCenter: parent.verticalCenter
}
Item { visible: root.largeActive; width: 12; height: 1 }
diff --git a/crates/qbz-qt/qml/shell/SongCardStamp.qml b/crates/qbz-qt/qml/shell/SongCardStamp.qml
new file mode 100644
index 000000000..3b1674ca4
--- /dev/null
+++ b/crates/qbz-qt/qml/shell/SongCardStamp.qml
@@ -0,0 +1,272 @@
+// 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
+ }
+
+ // Dot LED (circle-dot on / circle off) + constant-colour label —
+ // SongCard.slint's DotLed. 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. A 1px ring plus a 4px
+ // centre dot is the same figure.
+ 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
+ Rectangle {
+ width: 9
+ height: 9
+ radius: 4.5
+ anchors.verticalCenter: parent.verticalCenter
+ color: "transparent"
+ border.width: 1
+ border.color: led.on ? led.onColor : theme.textMuted
+ Rectangle {
+ visible: led.on
+ width: 4
+ height: 4
+ radius: 2
+ anchors.centerIn: parent
+ 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
+ }
+ 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 - 6
+ contentItem: Text {
+ text: ledTip.text
+ color: theme.textSecondary
+ font.pixelSize: 11
+ font.weight: theme.weightMedium
+ leftPadding: 9
+ rightPadding: 9
+ topPadding: 5
+ bottomPadding: 5
+ }
+ background: Rectangle {
+ color: theme.surfaceElevated
+ radius: theme.radiusSm
+ border.width: 1
+ border.color: theme.borderSubtle
+ }
+ }
+ }
+
+ // --- 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 - 6
+ contentItem: Text {
+ text: stampTip.text
+ color: theme.textSecondary
+ font.pixelSize: 11
+ font.weight: theme.weightMedium
+ lineHeight: 1.25
+ leftPadding: 9
+ rightPadding: 9
+ topPadding: 6
+ bottomPadding: 6
+ }
+ background: Rectangle {
+ color: theme.surfaceElevated
+ radius: theme.radiusSm
+ border.width: 1
+ border.color: theme.borderSubtle
+ }
+ }
+ }
+
+ // --- 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
+ }
+ }
+ }
+}