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 @@ + +MP3 \ 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 + } + } + } +}