// 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 // AppShell's single topmost QbzTooltip. Desktop NPB modes pass it through // SongCard so long device labels are measured by the shared bubble and // cannot be clipped by the player bar. property Item tooltipHost: null // 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 } // The tooltip bubble of shell/TooltipOverlay.slint, 1:1: surface-elevated, // radius 4, NO border (the port's earlier bubble had a 1px border-subtle // and radiusSm = 8), plus the downward caret. The bubble's bottom edge // sits 9px above the anchor and the 5px caret pokes below it, so every // ToolTip using this background is positioned at y = -height - 4 with a // bottomPadding of 10 (5px bubble padding + the 5px caret zone). // // Slint also drop-shadows the bubble (blur 10, #00000099); that needs a // shader layer effect and is deliberately dropped. // SUPERSEDED (2026-07-29): the reason used to be "renders nothing on the // software path". Effects need shaders, and this port runs on the GPU // (OpenGL RHI, measured); that note came from an offscreen session, which // forces the software renderer by definition — theme/RoundedImage.qml now // detects the software path with `GraphicsInfo.api` instead of assuming it. // Adding the shadow is a visual change owing its own parity pass, so the // bubble stays flat here. component TipBubble: Item { Rectangle { anchors.fill: parent anchors.bottomMargin: 5 color: theme.surfaceElevated radius: 4 } // A 7.07px square rotated 45 deg is a 10px-wide diamond; centred on // the bubble's bottom edge, its lower half is the 10x5 caret // (TooltipOverlay.slint:56 draws a 10x6 Path). QtQuick.Shapes is not // imported anywhere in this port. 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 } } // Dot LED (circle-dot on / circle off) + constant-colour label — // SongCard.slint's DotLed (line 57): 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. // // The figure is measured off the two lucide SVGs Slint tints, scaled to // the 9px box the .slint asks for (scale = 9/24 = 0.375): // circle.svg -> painted band // r 9..11 => outer diameter 22 * 0.375 = 8.25px, // stroke 2 * 0.375 = 0.75px // circle-dot.svg adds -> centre dot // diameter 5 * 0.375 = 1.875px // The previous 9px/1px ring with a 4px dot read as a solid bullseye next // to Slint's thin ring + pinpoint — that was the visible mismatch. component DotLed: Item { id: led property string label: "" property bool on: false property color litColor: "#22c55e" property string tooltip: "" implicitWidth: ledRow.implicitWidth implicitHeight: ledRow.implicitHeight width: implicitWidth height: implicitHeight Row { id: ledRow spacing: 4 // The 9px icon BOX (what QbzIcon occupies in the .slint) — the // 4px gap must measure from it, not from the smaller ring. Item { width: 9 height: 9 anchors.verticalCenter: parent.verticalCenter Rectangle { width: 8.25 height: 8.25 radius: width / 2 anchors.centerIn: parent antialiasing: true color: "transparent" border.width: 0.75 border.color: led.on ? led.litColor : theme.textMuted Rectangle { visible: led.on width: 1.875 height: 1.875 radius: width / 2 anchors.centerIn: parent antialiasing: true color: led.litColor } } } 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 onContainsMouseChanged: { if (root.tooltipHost && led.tooltip !== "") root.tooltipHost.hover(containsMouse, led, "npb-led-" + led.label, led.tooltip) } } // Bubble geometry: see Tip below — same numbers, read off // shell/TooltipOverlay.slint. ToolTip { id: ledTip visible: !root.tooltipHost && ledHover.containsMouse && led.tooltip !== "" text: led.tooltip delay: 0 timeout: -1 // Geometry pinned to the text (see controls/QualityInline.qml). padding: 0 leftPadding: 0 rightPadding: 0 topPadding: 0 bottomPadding: 0 leftInset: 0 rightInset: 0 topInset: 0 bottomInset: 0 margins: 0 width: contentItem ? contentItem.implicitWidth : 0 height: contentItem ? contentItem.implicitHeight : 0 x: Math.round((led.width - width) / 2) y: -height - 4 contentItem: Text { text: ledTip.text color: theme.textPrimary font.pixelSize: 11 font.weight: theme.weightMedium leftPadding: 9 rightPadding: 9 topPadding: 5 bottomPadding: 10 } background: TipBubble {} } } // --- 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 // Geometry pinned to the text (see controls/QualityInline.qml). padding: 0 leftPadding: 0 rightPadding: 0 topPadding: 0 bottomPadding: 0 leftInset: 0 rightInset: 0 topInset: 0 bottomInset: 0 margins: 0 width: contentItem ? contentItem.implicitWidth : 0 height: contentItem ? contentItem.implicitHeight : 0 x: Math.round((root.width - width) / 2) y: -height - 4 contentItem: Text { text: stampTip.text color: theme.textPrimary font.pixelSize: 11 font.weight: theme.weightMedium leftPadding: 9 rightPadding: 9 topPadding: 5 bottomPadding: 10 } background: TipBubble {} } } // --- 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 litColor: "#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 litColor: "#5b8def" tooltip: QbzPlayer.npOutputBackendTooltip anchors.verticalCenter: parent.verticalCenter } DotLed { label: QbzPlayer.npOutputModeLabel on: QbzPlayer.npOutputModeActive litColor: "#22c55e" tooltip: QbzSession.tr("Output mode", QbzSession.trRev) anchors.verticalCenter: parent.verticalCenter } } } }