Files
qbz/crates/qbz-qt/qml/shell/SongCard.qml
T
vicrodh 8af04c5272 fix(qt): NPB Small mounts the shared song card
The Small bar hand-rolled a reduced copy of the song card inline: album
art plus a two-line title/artist block. All three features the owner
reported missing live in SongCard.qml and only there -- the "layers"
playback-context glyph with open-context(), the album name in the meta
row, and the hover area that publishes the floating art preview. The
overlay itself was already mounted shell-level with no bar-mode gate; it
simply never had a writer while Small was loaded.

PlayerBarSmall.slint mounts the shared SongCard with text-center, so
this is the port catching up rather than a redesign: art-size 37,
art-border 3px surface-card, show-badges false, title 13px, art-text-gap
9. The 18px meta row fits the 39px controls row with ~1px clearance, and
the loading scrim on the cover comes along with it, as it does in Slint.

Also folds the responsive tier arms into shared helpers so the four bar
modes cannot drift apart on the 1366/1920 breakpoints.

cc
2026-07-29 10:30:27 -06:00

353 lines
16 KiB
QML

// SongCard — the now-playing card of the full bar (cover + title + context
// meta + the in-card audio stamp), extracted VERBATIM from PlayerBar.qml
// (phase 25 size split). 1:1 with crates/qbz-ui/ui/shell/SongCard.slint.
//
// `glass` renders the Classic contained card (60px art, 64px tall, radius 8,
// surface-elevated fill); New uses the flush 74px variant. Large mounts it
// with showArt/showBadges false — the cover lives in the sidebar dock and the
// AudioStamp moves to the right cluster, so the text column reclaims the
// stamp's 132px. `textCenter` is the SMALL-bar variant (SongCard.slint:329,
// mounted by PlayerBarSmall.slint:239-251): a 37px bordered cover, a 13px
// title and the title+meta collapsed into a tight centred group, no in-card
// stamp.
//
// The title click opens Track Info (SongCard.slint: `root.track-info()` from
// the title TouchArea, Qobuz-only) — the host bar owns the modal, this just
// emits `trackInfoRequested()`.
//
// META ROW (SongCard.slint:20-49 `MetaLink` + :486-540): [layers] artist · album.
// The two labels share the leftover width on the Slint's stretch budget —
// artist 2, album 1 — each capped at its natural width and free to shrink to
// nothing. A plain Row of unbounded Texts is what let the album slide past the
// card wall and vanish under the audio stamp / the bar column's `clip: true`
// (PlayerBar.qml:249): `elide` does NOTHING on a Text with no explicit width.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
property bool showArt: true
property bool showBadges: true
property bool glass: false
property int artSize: glass ? 60 : 74
// Optional cover border (SongCard.slint art-border-width / -color). OFF by
// default so New/Classic/Large are untouched; Small passes 3px surface-card.
property int artBorderWidth: 0
property color artBorderColor: "transparent"
// Cover -> text gap (SongCard.slint art-text-gap: 11px default, 9px Small).
property int artTextGap: 11
// Title size (SongCard.slint title-font-size: Typography.body default,
// 13px on the Small bar).
property real titleFontSize: theme.fontBody
// Small bar: title+meta as a TIGHT centred group instead of the
// full-height, space-distributed column.
property bool textCenter: false
/// Title clicked — the host opens the Track Info modal.
signal trackInfoRequested()
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
// Track Info is a Qobuz-only surface (Slint gates it on
// NowPlayingState.source == "qobuz"). The Qt port publishes no np_source,
// so a numeric track id is the proxy — local / Plex / ephemeral rows do
// not carry one. TODO(glue): publish np_source (see the report).
readonly property bool qobuzTrack: QbzPlayer.npHasTrack
&& /^[0-9]+$/.test(QbzPlayer.npTrackId)
// "Playing from" origin, 1:1 with SongCard.slint:151-156: the CURRENT
// track's own stamped container, falling back to its album when it carries
// none (the Slint fallback in playback.rs:1959-1965).
readonly property string ctxKind: QbzPlayer.npContextId !== ""
? QbzPlayer.npContextKind : "album"
readonly property string ctxId: QbzPlayer.npContextId !== ""
? QbzPlayer.npContextId : QbzPlayer.npAlbumId
// SongCard.slint fires `open-context(kind, id)`, which the bar routes to
// `media-action(kind, id, "open")` — qbz/src/main.rs:12695 (artist), :12701
// (album), :12707 (playlist), :13206 (label).
function openContext() {
if (root.ctxId === "")
return
if (root.ctxKind === "artist") {
QbzArtist.openArtist(root.ctxId)
} else if (root.ctxKind === "playlist") {
QbzBridge.openPlaylist(root.ctxId)
} else if (root.ctxKind === "album") {
QbzAlbum.openAlbum(root.ctxId)
} else if (QbzPlayer.npAlbumId !== "") {
// "label" (and anything added later) has no landing page in this
// port — LabelCard.qml documents the same gap. Fall back to the
// track's album so the glyph is never a dead control.
QbzAlbum.openAlbum(QbzPlayer.npAlbumId)
}
}
QbzTheme { id: theme }
// SongCard.slint:504 tints the context-stack glyph text-primary on hover,
// text-muted idle. Both are runtime-tinted (see QbzIconButton's header),
// so this is the live token, not a fixed bake picked by lightness.
readonly property string tintStrong: "textPrimary"
width: 200
height: glass ? 64 : 74
radius: glass ? 8 : 0
color: ambientOn ? "transparent" : (glass ? theme.surfaceElevated : "transparent")
Row {
anchors.left: parent.left
// SongCard.slint's inner-row width formula: the card minus the
// stamp + a 4px gap ONLY when the stamp is rendered (Large and Small
// hide 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
Item {
visible: root.showArt
width: root.showArt ? root.artSize : 0
height: parent.height
Rectangle {
width: root.artSize
height: root.artSize
anchors.centerIn: parent
radius: 6
color: theme.surfaceElevated
border.width: root.artBorderWidth
border.color: root.artBorderColor
clip: true
RoundedImage {
visible: QbzPlayer.npHasTrack
anchors.fill: parent
source: QbzPlayer.npArtworkPath
radius: 6
}
QbzIcon {
visible: !QbzPlayer.npHasTrack
name: "music"
width: root.artSize * 0.5
height: root.artSize * 0.5
anchors.centerIn: parent
tintName: "muted"
}
// Fetch overlay (resolving/downloading). DELIBERATE literal,
// not a theme token: this scrim lies over ALBUM ARTWORK, not
// over a themed surface, so it must darken in BOTH polarities
// — theme.alphaTier() is white-based on dark themes and would
// wash the cover out instead of dimming it, and cardShadow is
// a per-theme shadow colour, not a scrim. 1:1 with
// SongCard.slint:278 (`background: #000000aa`).
Rectangle {
visible: QbzPlayer.npHasTrack && QbzPlayer.npLoading
anchors.fill: parent
radius: 6
color: "#aa000000"
QbzSpinner {
width: root.artSize * 0.5
height: root.artSize * 0.5
anchors.centerIn: parent
size: root.artSize * 0.5
}
}
// Hover trigger for the floating 220px preview
// (SongCard.slint:296). Topmost so the cover, the idle glyph
// and the fetch scrim below it stay reachable; NoButton so it
// reports hover only and every click passes through to them.
// The anchor is published in WINDOW coordinates because the
// overlay lives at the shell root.
MouseArea {
id: artHover
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
onContainsMouseChanged: {
if (containsMouse && QbzPlayer.npHasTrack) {
var pt = mapToItem(null, width / 2, 0)
QbzShell.artPreviewX = pt.x
QbzShell.artPreviewY = pt.y
QbzShell.artPreviewShow = true
} else if (!containsMouse) {
QbzShell.artPreviewShow = false
}
}
}
}
}
Item { width: root.showArt ? root.artTextGap : 0; height: 1 }
Column {
width: parent.width - (root.showArt ? root.artSize + root.artTextGap : 0)
anchors.verticalCenter: parent.verticalCenter
// textCenter tightens the inter-line gap to 1px so the two lines
// read as one block beside the small cover (SongCard.slint:333);
// the default column keeps its 4px.
spacing: root.textCenter ? 1 : 4
Text {
width: parent.width
text: QbzPlayer.npHasTrack ? QbzPlayer.npTitle
: QbzSession.tr("Nothing playing", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: root.titleFontSize
font.weight: theme.weightMedium
elide: Text.ElideRight
// Title -> Track Info (SongCard.slint). The MouseArea is a
// CHILD of the Text so it can't take layout space in the
// Column.
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: root.qobuzTrack ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (root.qobuzTrack) root.trackInfoRequested()
}
}
// --- Meta row: [layers] artist · album ------------------------
Item {
id: metaRow
visible: QbzPlayer.npHasTrack
width: parent.width
// Row height is driven by the 16x18 context box (SongCard.slint).
height: 18
// Natural (unelided) label widths. `implicitWidth` on a Text
// that already HAS an explicit width is not a safe source here
// — TextMetrics measures the string itself and never feeds the
// width binding back into the layout.
TextMetrics {
id: artistMetrics
font: artistText.font
text: artistText.text
}
TextMetrics {
id: albumMetrics
font: albumText.font
text: albumText.text
}
// Fixed furniture: the context box (+ its 7px gap when shown)
// and the separator dot with a 7px gap on each side.
readonly property real fixedW: (ctxBox.visible ? 16 + 7 : 0) + 3 + 14
readonly property real avail: Math.max(0, metaRow.width - metaRow.fixedW)
// Slint stretch 2 (artist) : 1 (album), each capped at its own
// natural width and free to shrink to 0 (SongCard.slint:22-31):
// a long album elides before the artist does, and both keep a
// readable slice instead of one pushing the other off the card.
readonly property var budget: {
var a = artistMetrics.width
var b = albumMetrics.width
var av = metaRow.avail
if (a + b <= av)
return [a, b]
var qa = av * 2 / 3
var qb = av - qa
if (a < qa) {
qa = a
qb = av - a
} else if (b < qb) {
qb = b
qa = av - b
}
return [Math.floor(qa), Math.floor(qb)]
}
readonly property bool artistLinked: QbzPlayer.npArtistId !== ""
readonly property bool albumLinked: QbzPlayer.npAlbumId !== ""
Row {
anchors.fill: parent
spacing: 7
// Context-stack icon ("Show track playing context" pref) —
// opens the container playback was launched from.
Rectangle {
id: ctxBox
visible: QbzPlayer.showContextIcon
width: 16
height: 18
radius: 3
color: ctxArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: "layers"
width: 13
height: 13
// Flush LEFT (SongCard.slint pins x: 0) so the meta
// line starts on the title's column.
x: 0
anchors.verticalCenter: parent.verticalCenter
tintName: ctxArea.containsMouse ? root.tintStrong : "muted"
}
MouseArea {
id: ctxArea
anchors.fill: parent
hoverEnabled: true
cursorShape: root.ctxId !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: root.openContext()
}
}
Text {
id: artistText
width: metaRow.budget[0]
height: parent.height
verticalAlignment: Text.AlignVCenter
text: QbzPlayer.npArtist
color: artistArea.containsMouse && metaRow.artistLinked
? theme.textPrimary : theme.textMuted
font.pixelSize: 11
elide: Text.ElideRight
MouseArea {
id: artistArea
anchors.fill: parent
hoverEnabled: true
cursorShape: metaRow.artistLinked ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (metaRow.artistLinked) QbzArtist.openArtist(QbzPlayer.npArtistId)
}
}
Rectangle {
width: 3
height: 3
radius: 1.5
color: theme.textMuted
anchors.verticalCenter: parent.verticalCenter
}
Text {
id: albumText
width: metaRow.budget[1]
height: parent.height
verticalAlignment: Text.AlignVCenter
text: QbzPlayer.npAlbum
color: albumArea.containsMouse && metaRow.albumLinked
? theme.textPrimary : theme.textMuted
font.pixelSize: 11
elide: Text.ElideRight
MouseArea {
id: albumArea
anchors.fill: parent
hoverEnabled: true
cursorShape: metaRow.albumLinked ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (metaRow.albumLinked) QbzAlbum.openAlbum(QbzPlayer.npAlbumId)
}
}
}
}
}
}
SongCardStamp {
id: stamp
visible: root.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: root.glass ? 4 : 0
anchors.verticalCenter: parent.verticalCenter
}
}