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
440 lines
18 KiB
QML
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|