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

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

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

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

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

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

No files matched your search

+255
View File
@@ -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
}
}
}
}
+12 -26
View File
@@ -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
+31 -88
View File
@@ -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 }
+272
View File
@@ -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
}
}
}
}