Files
qbz/crates/qbz-qt/qml/shell/TrackInfoBody.qml
T
vicrodh 9493993124 feat(qt): overAmbient legibility mode for the quality badge and track info
Immersive surfaces sit directly on the ambient field, where theme tokens
have no contrast guarantee — a light cover washed the split Track Info
panel and the quality badges out completely. QualityBadgeFull,
TrackInfoBody, InfoMetaCell and InfoCreditCell gain an opt-in overAmbient
mode: fixed light text with the restrained native shadow the lyrics
surfaces already use, and the badge swaps the theme surface for its own
translucent dark scrim covering only the chip. Defaults stay off, so the
desktop modal and every themed mount are untouched. The immersive Track
Info panel and song card opt in.

cc
2026-08-31 15:03:09 -06:00

378 lines
16 KiB
QML

// TrackInfoBody — the scrolling content of the Track Info modal (the
// `card-body` VerticalLayout of crates/qbz-ui/ui/album/TrackInfoModal.slint),
// split out of TrackInfoModal.qml for the size rule. `host` is the modal: it
// owns the parsed document, the close() and the nav actions.
//
// Sections, verbatim from the .slint:
// loading -> 60px padding all round, centered muted body text
// error -> 60px padding, 8px gap, headline + the error string (legal)
// loaded -> header (24 L/R, 16 T/B, 16 gap, 32x32 close X with an 18px
// glyph) then the content block (24 L/R, 24 bottom):
// metadata rows 24 apart in three equal columns · credits
// behind 20 / 1px surface-elevated rule / 20, TWO independent
// columns 24 apart with 20 between cells · copyright behind the
// same 20 / rule / 20, 12px muted, wrapped.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Column {
id: body
/// The TrackInfoModal that owns the data + actions.
required property var host
QbzTheme { id: theme }
/// Legibility mode for the immersive split panel (2026-08-31 visual
/// cleanup): there the body sits directly on the ambient field, where
/// theme tokens have no contrast guarantee over a light cover. On, every
/// text uses a fixed light color plus the restrained native shadow the
/// lyrics surfaces use. The desktop modal keeps the theme (default off).
property bool overAmbient: false
readonly property color cPrimary: overAmbient ? "#f2ffffff" : theme.textPrimary
readonly property color cMuted: overAmbient ? "#b3ffffff" : theme.textMuted
readonly property color cRule: overAmbient ? "#2effffff" : theme.surfaceElevated
readonly property int cStyle: overAmbient ? Text.Raised : Text.Normal
readonly property color cShadow: "#b0000000"
readonly property var doc: host ? host.doc : ({})
// ---- Loading ---------------------------------------------------------
Item {
visible: body.host && body.host.loading && body.host.errorText === ""
width: parent.width
height: visible ? 120 + loadingText.implicitHeight : 0
Text {
id: loadingText
anchors.centerIn: parent
width: Math.max(0, parent.width - 120)
text: QbzSession.tr("Loading track info...", QbzSession.trRev)
color: body.cMuted
style: body.cStyle
styleColor: body.cShadow
font.pixelSize: theme.fontBody
horizontalAlignment: Text.AlignHCenter
}
}
// ---- Error -----------------------------------------------------------
Item {
visible: body.host && body.host.errorText !== ""
width: parent.width
height: visible ? 120 + errCol.implicitHeight : 0
Column {
id: errCol
anchors.centerIn: parent
width: Math.max(0, parent.width - 120)
spacing: 8
Text {
width: parent.width
text: QbzSession.tr("Failed to load track info", QbzSession.trRev)
color: body.cMuted
style: body.cStyle
styleColor: body.cShadow
font.pixelSize: theme.fontBody
horizontalAlignment: Text.AlignHCenter
}
Text {
width: parent.width
text: body.host ? body.host.errorText : ""
color: body.cMuted
style: body.cStyle
styleColor: body.cShadow
font.pixelSize: theme.fontLegal
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
}
}
// ---- Loaded ----------------------------------------------------------
Column {
id: loadedCol
visible: body.host && !body.host.loading && body.host.errorText === ""
width: parent.width
// Fixed credits-column width: card minus the 24px L/R content padding
// (48) minus the 24px inter-column gutter, halved. Pinning each credit
// cell to this keeps the right column aligned on every row.
readonly property int contentW: Math.max(0, width - 48)
readonly property int creditsColW: Math.max(0, (contentW - 24) / 2)
readonly property int metaColW: Math.max(0, (contentW - 48) / 3)
// --- Header: title / album / artist + close X ---------------------
Item {
width: parent.width
height: Math.max(headCol.implicitHeight, 32) + 32
Column {
id: headCol
x: 24
y: 16
// 24 L + 24 R padding, 16 gap, 32 close X.
width: Math.max(0, parent.width - 24 - 24 - 16 - 32)
spacing: 4
Text {
width: parent.width
text: body.doc.title || ""
color: body.cPrimary
style: body.cStyle
styleColor: body.cShadow
font.pixelSize: 16
font.weight: theme.weightSemibold
wrapMode: Text.WordWrap
}
Text {
visible: (body.doc.album || "") !== ""
width: parent.width
text: body.doc.album || ""
color: body.cMuted
style: body.cStyle
styleColor: body.cShadow
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
// Artist — a link only when an id exists.
Item {
visible: (body.doc.artist || "") !== ""
width: parent.width
height: visible ? artistText.implicitHeight : 0
Text {
id: artistText
text: body.doc.artist || ""
color: (body.doc.artistId || "") !== ""
? theme.accent : body.cPrimary
style: body.cStyle
styleColor: body.cShadow
font.pixelSize: 16
font.weight: theme.weightSemibold
}
MouseArea {
width: Math.min(artistText.implicitWidth, parent.width)
height: artistText.implicitHeight
cursorShape: (body.doc.artistId || "") !== ""
? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: body.host.openArtist(body.doc.artistId || "")
}
}
}
// Close X — the Slint is a bare 32x32 touch area with an 18px
// glyph, text-muted -> text-primary, with NO hover fill;
// QbzIconButton always paints one and idles at `secondary`, so
// this one stays hand-rolled to keep the port 1:1.
Item {
width: 32
height: 32
x: parent.width - 24 - 32
y: 16
QbzIcon {
name: "x"
width: 18
height: 18
anchors.centerIn: parent
tintName: body.overAmbient
? (closeArea.containsMouse ? "white" : "muted")
: (closeArea.containsMouse ? "textPrimary" : "muted")
}
MouseArea {
id: closeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: body.host.close()
}
}
}
// --- Content block (24 L/R, 24 bottom) ----------------------------
Item {
width: parent.width
height: contentCol.implicitHeight + 24
Column {
id: contentCol
x: 24
width: loadedCol.contentW
// Metadata: fixed 3-column rows.
Row {
width: parent.width
spacing: 24
InfoMetaCell {
cellWidth: loadedCol.metaColW
overAmbient: body.overAmbient
label: QbzSession.tr("Duration", QbzSession.trRev)
Text {
width: loadedCol.metaColW
text: body.doc.duration || ""
color: body.cPrimary
style: body.cStyle
styleColor: body.cShadow
font.pixelSize: 14
elide: Text.ElideRight
}
}
InfoMetaCell {
cellWidth: loadedCol.metaColW
overAmbient: body.overAmbient
label: QbzSession.tr("Quality", QbzSession.trRev)
Text {
width: loadedCol.metaColW
text: body.doc.quality || ""
color: body.cPrimary
style: body.cStyle
styleColor: body.cShadow
font.pixelSize: 14
elide: Text.ElideRight
}
}
// ISRC — the cell is dropped (not blanked) when absent;
// the empty third keeps the columns fixed.
InfoMetaCell {
visible: (body.doc.isrc || "") !== ""
cellWidth: loadedCol.metaColW
overAmbient: body.overAmbient
label: "ISRC"
Text {
width: loadedCol.metaColW
text: body.doc.isrc || ""
color: body.cMuted
style: body.cStyle
styleColor: body.cShadow
font.pixelSize: 14
elide: Text.ElideRight
}
}
Item {
visible: (body.doc.isrc || "") === ""
width: loadedCol.metaColW
height: 1
}
}
Item {
visible: (body.doc.label || "") !== ""
width: 1
height: 24
}
Row {
visible: (body.doc.label || "") !== ""
width: parent.width
spacing: 24
InfoMetaCell {
cellWidth: loadedCol.metaColW
overAmbient: body.overAmbient
label: QbzSession.tr("Label", QbzSession.trRev)
Item {
width: loadedCol.metaColW
height: labelText.implicitHeight
Text {
id: labelText
width: loadedCol.metaColW
text: body.doc.label || ""
color: (labelArea.containsMouse
&& (body.doc.labelId || "") !== "")
? theme.accent : body.cPrimary
style: body.cStyle
styleColor: body.cShadow
font.pixelSize: 14
elide: Text.ElideRight
}
MouseArea {
id: labelArea
width: Math.min(labelText.implicitWidth, loadedCol.metaColW)
height: labelText.implicitHeight
hoverEnabled: true
cursorShape: (body.doc.labelId || "") !== ""
? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: body.host.openLabel(body.doc.labelId || "")
}
}
}
}
// --- Credits: two INDEPENDENT columns ---------------------
Item {
visible: body.host && body.host.credits.length > 0
width: 1
height: 20
}
Rectangle {
visible: body.host && body.host.credits.length > 0
width: parent.width
height: 1
color: body.cRule
}
Item {
visible: body.host && body.host.credits.length > 0
width: 1
height: 20
}
Row {
visible: body.host && body.host.credits.length > 0
width: parent.width
spacing: 24
Column {
width: loadedCol.creditsColW
spacing: 20
Repeater {
model: body.host ? body.host.creditsLeft : []
delegate: InfoCreditCell {
required property var modelData
colW: loadedCol.creditsColW
overAmbient: body.overAmbient
cell: modelData
onNameClicked: function (n, r) { body.host.openMusician(n, r) }
}
}
}
Column {
width: loadedCol.creditsColW
spacing: 20
Repeater {
model: body.host ? body.host.creditsRight : []
delegate: InfoCreditCell {
required property var modelData
colW: loadedCol.creditsColW
overAmbient: body.overAmbient
cell: modelData
onNameClicked: function (n, r) { body.host.openMusician(n, r) }
}
}
}
}
// --- Copyright --------------------------------------------
Item {
visible: (body.doc.copyright || "") !== ""
width: 1
height: 20
}
Rectangle {
visible: (body.doc.copyright || "") !== ""
width: parent.width
height: 1
color: body.cRule
}
Item {
visible: (body.doc.copyright || "") !== ""
width: 1
height: 20
}
Text {
visible: (body.doc.copyright || "") !== ""
width: parent.width
text: body.doc.copyright || ""
color: body.cMuted
style: body.cStyle
styleColor: body.cShadow
font.pixelSize: 12
wrapMode: Text.WordWrap
}
}
}
}
}