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
+ }
+ }
+ }
+}