Files
qbz/crates/qbz-qt/qml/shell/TrackInfoBody.qml
T
vicrodh 3f3c4ad548 feat(qt): Track info and Album info are selectable and copy basic or full text
Every field of both modals is a QbzSelectableText — artist, label and
musician names stay links. A copy button beside the close cross opens a
flyout: "Copy basic data" puts "{Track} - {Album} - {Artist}" (or
"{Album} - {Artist}") on the clipboard; "Copy full" formats the whole sheet
as plain text — the track's metadata, credits and copyright, or the album's
header plus the ACTIVE tab: credits per track with performers, or the
review. Album credit row heads stay plain because their click expands the
row. The two new strings ship in all eight catalogs.

cc
2026-09-13 20:03:36 -06:00

440 lines
18 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 "../controls"
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"
// Links follow the ALBUM palette over ambient — the theme accent has no
// contrast guarantee there (owner, 2026-08-31 round 2).
AmbientAccent { id: bodyAmbientAccent }
readonly property color cAccent: overAmbient ? bodyAmbientAccent.value : theme.accent
readonly property var doc: host ? host.doc : ({})
// --- Copy (2026-09-13) -------------------------------------------------
// Basic = "{Track} - {Album} - {Artist}"; full = everything the modal
// shows, as plain text. The glyph turns into a check for a moment.
QbzClipboard { id: clipboard }
property bool justCopied: false
Timer {
id: copiedTimer
interval: 1400
onTriggered: body.justCopied = false
}
function nonEmpty(s) { return (s || "") !== "" }
function basicText() {
return [body.doc.title, body.doc.album, body.doc.artist].filter(body.nonEmpty).join(" - ")
}
function fullText() {
var t = QbzSession.tr
var r = QbzSession.trRev
var d = body.doc
var lines = [d.title, d.album, d.artist].filter(body.nonEmpty)
var meta = []
if (body.nonEmpty(d.duration)) meta.push(t("Duration", r) + ": " + d.duration)
if (body.nonEmpty(d.quality)) meta.push(t("Quality", r) + ": " + d.quality)
if (body.nonEmpty(d.isrc)) meta.push("ISRC: " + d.isrc)
if (body.nonEmpty(d.label)) meta.push(t("Label", r) + ": " + d.label)
if (meta.length > 0) { lines.push(""); lines = lines.concat(meta) }
var credits = body.host ? body.host.credits : []
if (credits.length > 0) {
lines.push("")
for (var i = 0; i < credits.length; i++) {
var c = credits[i]
lines.push((c.role || c.roleRaw || "") + ": " + (c.names || []).join(", "))
}
}
if (body.nonEmpty(d.copyright)) { lines.push(""); lines.push(d.copyright) }
return lines.join("\n")
}
function copy(which) {
var text = which === "full" ? body.fullText() : body.basicText()
if (clipboard.copy(text)) {
body.justCopied = true
copiedTimer.restart()
}
}
CardMenu {
id: copyMenu
menuWidth: 200
entries: [
{ "label": QbzSession.tr("Copy basic data", QbzSession.trRev), "icon": "copy", "action": "basic" },
{ "label": QbzSession.tr("Copy full", QbzSession.trRev), "icon": "clipboard", "action": "full" }
]
onPicked: function (a) { body.copy(a) }
}
// ---- 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 - 8 - 32)
spacing: 4
// Every field is selectable (2026-09-13): QbzSelectableText,
// raised (plain, shadowed) on the ambient host.
QbzSelectableText {
width: parent.width
text: body.doc.title || ""
color: body.cPrimary
raised: body.overAmbient
raisedShadow: body.cShadow
// The immersive panel reads at a distance — its header
// steps up (owner, 2026-08-31 round 2).
pixelSize: body.overAmbient ? 24 : 16
weight: theme.weightSemibold
}
QbzSelectableText {
visible: (body.doc.album || "") !== ""
width: parent.width
text: body.doc.album || ""
color: body.cMuted
raised: body.overAmbient
raisedShadow: body.cShadow
pixelSize: body.overAmbient ? 15 : theme.fontLegal
}
// Artist — a link only when an id exists.
QbzSelectableText {
visible: (body.doc.artist || "") !== ""
width: parent.width
text: body.doc.artist || ""
color: (body.doc.artistId || "") !== "" ? body.cAccent : body.cPrimary
linkHref: (body.doc.artistId || "") !== "" ? "artist" : ""
linkHoverColor: body.cAccent
raised: body.overAmbient
raisedShadow: body.cShadow
pixelSize: body.overAmbient ? 20 : 16
weight: theme.weightSemibold
onLinkActivated: body.host.openArtist(body.doc.artistId || "")
}
}
// Copy — basic line or the full sheet, from the flyout; the
// glyph turns into a check for a moment as the receipt.
Item {
id: copyBtn
width: 32
height: 32
x: parent.width - 24 - 32 - 8 - 32
y: 16
QbzIcon {
name: body.justCopied ? "check" : "copy"
width: 17
height: 17
anchors.centerIn: parent
tintName: body.justCopied ? "accent"
: body.overAmbient
? (copyArea.containsMouse ? "white" : "muted")
: (copyArea.containsMouse ? "textPrimary" : "muted")
}
MouseArea {
id: copyArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: copyMenu.openBelowLeft(copyBtn)
}
}
// 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)
QbzSelectableText {
width: loadedCol.metaColW
text: body.doc.duration || ""
color: body.cPrimary
raised: body.overAmbient
raisedShadow: body.cShadow
pixelSize: 14
}
}
InfoMetaCell {
cellWidth: loadedCol.metaColW
overAmbient: body.overAmbient
label: QbzSession.tr("Quality", QbzSession.trRev)
QbzSelectableText {
width: loadedCol.metaColW
text: body.doc.quality || ""
color: body.cPrimary
raised: body.overAmbient
raisedShadow: body.cShadow
pixelSize: 14
}
}
// 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"
QbzSelectableText {
width: loadedCol.metaColW
text: body.doc.isrc || ""
color: body.cMuted
raised: body.overAmbient
raisedShadow: body.cShadow
pixelSize: 14
}
}
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)
QbzSelectableText {
width: loadedCol.metaColW
text: body.doc.label || ""
color: body.cPrimary
linkHref: (body.doc.labelId || "") !== "" ? "label" : ""
linkHoverColor: body.cAccent
raised: body.overAmbient
raisedShadow: body.cShadow
pixelSize: 14
onLinkActivated: 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
accentColor: body.cAccent
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
accentColor: body.cAccent
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
}
QbzSelectableText {
visible: (body.doc.copyright || "") !== ""
width: parent.width
text: body.doc.copyright || ""
color: body.cMuted
raised: body.overAmbient
raisedShadow: body.cShadow
pixelSize: 12
}
}
}
}
}