The LEDs only changed when the user navigated. They were published from the settings snapshot, so they refreshed when that document republished rather than when playback started — which is exactly when the backend and mode are decided, a moment after "now playing" in the log. They publish on the stream edge now, without polling and without republishing settings. The stamp itself was close but not 1:1, so it went back against shell/AudioStamp.slint, primitives/QualityBadgeFull.slint and shell/SongCard.slint line by line; the divergences in sizes, weights, spacing and the inactive LED colour are corrected. Volume is locked while the bit-perfect ALSA direct path is engaged. Software volume there is meaningless and applying it would not be bit-perfect, so the control now reads as inert rather than silently doing nothing. Slint has the same gap and no lock to copy, so this is new: the condition comes from the output state already published, and no audio behaviour changed — only the UI reflects it. cc
281 lines
12 KiB
QML
281 lines
12 KiB
QML
// 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
|
|
}
|
|
|
|
// The bubble is 1:1 with the global shell/TooltipOverlay.slint the
|
|
// Slint stamp feeds: 11px/medium TEXT-PRIMARY (not secondary) on
|
|
// surface-elevated, radius 4 (not radiusSm=8), NO border, 5px/9px
|
|
// padding (not 6px), default line height (Slint sets none), and a
|
|
// downward caret. Centred on the control (TooltipOverlay clamps to
|
|
// the window and keeps the caret on the anchor); the BUBBLE bottom
|
|
// sits 9px above the anchor top (TooltipOverlay.slint:26) with the
|
|
// caret poking 5px below it, hence y = -height - 4.
|
|
ToolTip {
|
|
id: qualityTip
|
|
visible: qualityHover.containsMouse && root.qualityTooltip !== ""
|
|
text: root.qualityTooltip
|
|
delay: 0
|
|
timeout: -1
|
|
padding: 0
|
|
x: Math.round((qualityRow.width - width) / 2)
|
|
y: -height - 4
|
|
contentItem: Text {
|
|
text: qualityTip.text
|
|
color: theme.textPrimary
|
|
font.pixelSize: 11
|
|
font.weight: theme.weightMedium
|
|
leftPadding: 9
|
|
rightPadding: 9
|
|
topPadding: 5
|
|
// 5px bubble padding + the 5px caret zone below it.
|
|
bottomPadding: 10
|
|
}
|
|
background: Item {
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
anchors.bottomMargin: 5
|
|
color: theme.surfaceElevated
|
|
radius: 4
|
|
}
|
|
// Caret (TooltipOverlay.slint:56 draws a 10x6 Path). A
|
|
// 7.07px square rotated 45 deg is a 10px-wide diamond;
|
|
// centred on the bubble's bottom edge its lower half IS
|
|
// the caret. QtQuick.Shapes is not imported anywhere in
|
|
// this port, and shader effects are dead on the software
|
|
// path, so a rotated Rectangle is the way to draw it.
|
|
Rectangle {
|
|
width: 7.07
|
|
height: 7.07
|
|
rotation: 45
|
|
antialiasing: true
|
|
color: theme.surfaceElevated
|
|
x: Math.round((parent.width - width) / 2)
|
|
y: parent.height - 5 - height / 2
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// === 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
|
|
}
|
|
}
|
|
}
|
|
}
|