Files
qbz/crates/qbz-qt/qml/shell/AlbumInfoModal.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

633 lines
26 KiB
QML

// Album Info (Credits / Review) modal — Qt port of AlbumCreditsModal.slint
// (itself 1:1 with Tauri's AlbumCreditsModal.svelte: left fixed meta column,
// right scrollable tabs). Opened from the AlbumView header info button.
//
// DATA: QbzAlbum.openAlbumInfo(albumId) publishes QbzAlbum.albumInfoJson
// while albumInfoLoading is true (album_info_qt.rs). Document shape:
// { "error": "", "albumId": "", "title": "", "artist": "",
// "artUrl": "", "customCoverPath": "", "label": "", "labelId": "",
// "releaseDate": "September 2, 2021",
// "metaLine": "Hard Rock · 10 tracks · 1h 21m",
// "quality": "24-Bit / 96 kHz", "review": "", "hasReview": true,
// "tracks": [ { "id", "number", "title", "artist", "hasCredits",
// "copyright", "performers":
// [ { "name", "roles", "primaryRole" } ] } ] }
// Tab switching and close are QML-local; the bridge carries data only.
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../controls"
import "../theme"
Popup {
id: root
parent: Overlay.overlay
x: 0
y: 0
width: parent ? parent.width : 0
height: parent ? parent.height : 0
padding: 0
z: 3000
modal: true
// Our own scrim (the reference's #000000bf) — the default modal dimmer
// would darken it twice.
dim: false
closePolicy: Popup.CloseOnEscape
QbzTheme { id: theme }
// --- Data ------------------------------------------------------------
readonly property var doc: parseDoc()
function parseDoc() {
try {
return JSON.parse(QbzAlbum.albumInfoJson || "{}")
} catch (e) {
return ({})
}
}
readonly property bool loading: QbzAlbum.albumInfoLoading === true
readonly property string errorText: QbzAlbum.albumInfoError || ""
property string activeTab: "credits"
// The album the user ASKED for. The data overlay holds until the
// published document answers for this id — belt-and-braces next to the
// Rust generation guard (album_info_qt.rs): a late doc for a previous
// album must never render inside this one's page.
//
// This is an EXACT string comparison, so it can only ever be safe because
// the publisher stamps `albumId` with the id it was asked for rather than
// the one /album/get echoed back (album_info_qt.rs::map). Do not "fix"
// that to the API's id: any divergence blanks this card with no log.
property string requestedId: ""
// --- Copy (2026-09-13) --------------------------------------------------
// Basic = "{Album} - {Artist}"; full = the header lines plus the ACTIVE
// tab as plain text: every track with its performers (Credits) or the
// review. The glyph turns into a check for a moment as the receipt.
QbzClipboard { id: clipboard }
property bool justCopied: false
Timer {
id: copiedTimer
interval: 1400
onTriggered: root.justCopied = false
}
function nonEmpty(s) { return (s || "") !== "" }
function basicText() {
return [root.doc.title, root.doc.artist].filter(root.nonEmpty).join(" - ")
}
function fullText() {
var d = root.doc
var lines = [d.title, d.artist].filter(root.nonEmpty)
var meta = [d.label, d.releaseDate, d.metaLine, d.quality].filter(root.nonEmpty)
if (meta.length > 0) lines.push(meta.join(" · "))
if (root.activeTab === "review") {
if (root.nonEmpty(d.review)) { lines.push(""); lines.push(d.review) }
return lines.join("\n")
}
var tracks = d.tracks || []
if (tracks.length > 0) lines.push("")
for (var i = 0; i < tracks.length; i++) {
var tr = tracks[i]
var num = (tr.number === undefined || tr.number === null) ? "" : String(tr.number)
var head = (num !== "" ? num + ". " : "") + (tr.title || "")
if (root.nonEmpty(tr.artist)) head += " — " + tr.artist
lines.push(head)
var perf = tr.performers || []
for (var j = 0; j < perf.length; j++) {
var p = perf[j]
lines.push(" " + (p.name || "") + (root.nonEmpty(p.roles) ? ": " + p.roles : ""))
}
if (root.nonEmpty(tr.copyright)) lines.push(" " + tr.copyright)
}
return lines.join("\n")
}
function copy(which) {
var text = which === "full" ? root.fullText() : root.basicText()
if (clipboard.copy(text)) {
root.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) { root.copy(a) }
}
// --- Actions ---------------------------------------------------------
/// Header info button. The view gates local/Plex albums (no button);
/// the Rust side re-checks catalog ids.
function openFor(albumId) {
if (!albumId || albumId === "")
return
root.activeTab = "credits"
root.requestedId = albumId
QbzAlbum.openAlbumInfo(albumId)
open()
}
function playTrack(trackId) {
if (!trackId || trackId === "")
return
// Album context: the queue keeps the album, the cursor starts at the
// tapped row (same seam as the view's own track rows).
QbzPlayer.playAlbumFrom(doc.albumId || "", trackId)
}
function openLabel(labelId) {
if (!labelId || labelId === "")
return
close()
QbzHome.openLabel(labelId)
}
// Musician click — consumer #4 of six (contract §2.2).
//
// ORDER: dispatch, THEN close — the reverse of what stood here, and it
// matches the reference (AlbumCreditsModal.svelte:20-28). It matters now
// that the click has a destination: a `weak`/`none` result opens the
// global MusicianModal, and closing this one first hands focus back to the
// shell a moment before that modal takes it, which is precisely the
// unstable-focus window the contract flags (§5.2). The neighbouring
// openArtist/openLabel keep close-first because they navigate the shell
// underneath rather than summoning another overlay.
function openMusician(name, primaryRole) {
if (!name || name === "")
return
QbzArtist.resolveMusician(name, primaryRole || "")
close()
}
// Cover source: the custom override beats the remote best() URL, same
// rule as the AlbumView header.
readonly property string coverSource: (doc.customCoverPath || "") !== ""
? "file://" + doc.customCoverPath
: (doc.artUrl || "")
background: Rectangle { color: "#bf000000" }
// One Credits row: number/play-on-hover, title + artist, expand chevron;
// the expanded panel lists performers (name links) + copyright.
component CreditTrackRow: Column {
id: row
property var track: ({})
property bool isLast: false
property bool expanded: false
width: parent ? parent.width : 0
Rectangle {
width: parent.width
height: 46
color: "transparent"
opacity: (rowArea.containsMouse && row.track.hasCredits) ? 0.8 : 1.0
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
cursorShape: row.track.hasCredits ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (row.track.hasCredits) row.expanded = !row.expanded
}
Row {
anchors.fill: parent
spacing: 12
// Number / play-on-hover cell.
Item {
width: 28
height: parent.height
Text {
anchors.centerIn: parent
visible: !rowArea.containsMouse && !playCell.containsMouse
text: row.track.number || ""
color: theme.textMuted
font.pixelSize: 14
}
Rectangle {
anchors.centerIn: parent
visible: rowArea.containsMouse || playCell.containsMouse
width: 28
height: 28
radius: 14
color: theme.accent
QbzIcon {
anchors.centerIn: parent
name: "play-fill"
width: 14
height: 14
tintName: theme.accentGlyphTint
}
}
MouseArea {
id: playCell
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.playTrack(row.track.id)
}
}
Column {
width: parent.width - 28 - 18 - (credIcon.visible ? 18 : 0) - 36
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: row.track.title || ""
color: theme.textPrimary
font.pixelSize: 14
font.weight: theme.weightMedium
elide: Text.ElideRight
}
Text {
width: parent.width
text: row.track.artist || ""
color: theme.textMuted
font.pixelSize: 12
elide: Text.ElideRight
}
}
QbzIcon {
id: credIcon
visible: row.track.hasCredits === true
anchors.verticalCenter: parent.verticalCenter
name: row.expanded ? "chevron-up" : "chevron-down"
width: 18
height: 18
tintName: row.expanded ? "primary" : "muted"
}
}
}
// Expanded credits.
Column {
visible: row.expanded && row.track.hasCredits
width: parent.width
leftPadding: 40
bottomPadding: 12
spacing: 0
Repeater {
model: row.track.performers || []
delegate: Row {
required property var modelData
width: parent ? parent.width : 0
spacing: 0
topPadding: 4
bottomPadding: 4
QbzSelectableText {
id: perfName
text: modelData.name
color: theme.textPrimary
linkHref: "musician"
pixelSize: 13
weight: theme.weightMedium
onLinkActivated: root.openMusician(modelData.name, modelData.primaryRole)
}
QbzSelectableText {
width: Math.max(0, parent.width - perfName.width)
text: String(modelData.roles || "")
color: theme.textMuted
pixelSize: 13
}
}
}
Item { visible: (row.track.copyright || "") !== ""; width: 1; height: 8 }
Rectangle {
visible: (row.track.copyright || "") !== ""
width: parent.width
height: 1
color: theme.borderSubtle
}
Item { visible: (row.track.copyright || "") !== ""; width: 1; height: 8 }
Text {
visible: (row.track.copyright || "") !== ""
width: parent.width
text: row.track.copyright || ""
color: theme.textMuted
font.pixelSize: 12
wrapMode: Text.WordWrap
}
}
Rectangle {
visible: !row.isLast
width: parent.width
height: 1
color: theme.borderSubtle
}
}
contentItem: Item {
// Scrim — click outside dismisses.
MouseArea {
anchors.fill: parent
onClicked: root.close()
}
Rectangle {
id: card
width: Math.min(root.width - 40, 850)
height: Math.min(root.height - 80, bodyCol.implicitHeight + 24)
x: Math.round((parent.width - width) / 2)
y: Math.round((parent.height - height) / 2)
radius: theme.radiusMd
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
clip: true
// Swallow clicks so they don't reach the scrim.
MouseArea { anchors.fill: parent }
Column {
id: bodyCol
width: parent.width
spacing: 0
// ---- Header ----------------------------------------------
Row {
width: parent.width
height: 62
spacing: 16
Item { width: 8; height: 1 }
Column {
width: parent.width - 130
anchors.verticalCenter: parent.verticalCenter
spacing: 4
// Selectable (2026-09-13), like every field below.
QbzSelectableText {
width: parent.width
text: root.doc.title || ""
color: theme.textPrimary
pixelSize: 16
weight: theme.weightSemibold
}
QbzSelectableText {
width: parent.width
text: root.doc.artist || ""
color: theme.textMuted
pixelSize: theme.fontLegal
}
}
// Copy: basic line or the active tab, from the flyout.
Rectangle {
id: copyBtn
width: 32
height: 32
anchors.verticalCenter: parent.verticalCenter
color: "transparent"
QbzIcon {
anchors.centerIn: parent
name: root.justCopied ? "check" : "copy"
width: 17
height: 17
tintName: root.justCopied ? "accent"
: (copyArea.containsMouse ? "primary" : "muted")
}
MouseArea {
id: copyArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: copyMenu.openBelowLeft(copyBtn)
}
}
Rectangle {
width: 32
height: 32
anchors.verticalCenter: parent.verticalCenter
color: "transparent"
QbzIcon {
anchors.centerIn: parent
name: "x"
width: 18
height: 18
tintName: closeArea.containsMouse ? "primary" : "muted"
}
MouseArea {
id: closeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.close()
}
}
Item { width: 8; height: 1 }
}
// ---- Body (two columns) ----------------------------------
Row {
width: parent.width - 48
x: 24
spacing: 24
// LEFT — album meta (fixed 260px).
Column {
width: 260
spacing: 0
Rectangle {
width: 200
height: 200
radius: 8
color: theme.surfaceElevated
clip: true
RoundedImage {
anchors.fill: parent
source: root.coverSource
radius: 8
}
}
Item { width: 1; height: 16 }
Column {
visible: (root.doc.label || "") !== ""
spacing: 0
Text {
text: QbzSession.tr("Released by", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 13
}
QbzSelectableText {
width: parent.width
text: root.doc.label || ""
color: theme.textPrimary
linkHref: (root.doc.labelId || "") !== "" ? "label" : ""
pixelSize: 13
weight: theme.weightSemibold
onLinkActivated: root.openLabel(root.doc.labelId || "")
}
QbzSelectableText {
visible: (root.doc.releaseDate || "") !== ""
width: parent.width
text: QbzSession.tr("on", QbzSession.trRev) + " " + (root.doc.releaseDate || "")
color: theme.textMuted
pixelSize: 13
}
}
Item { visible: (root.doc.label || "") !== ""; width: 1; height: 8 }
QbzSelectableText {
visible: (root.doc.metaLine || "") !== ""
width: parent.width
text: root.doc.metaLine || ""
color: theme.textMuted
pixelSize: 13
}
Item { visible: (root.doc.quality || "") !== ""; width: 1; height: 12 }
QbzSelectableText {
visible: (root.doc.quality || "") !== ""
width: parent.width
text: root.doc.quality || ""
color: theme.textSecondary
pixelSize: 13
}
}
// RIGHT — tabs + scrollable content.
Column {
width: parent.width - 260 - 24
spacing: 0
// Tab switcher (only when a review exists).
Column {
visible: root.doc.hasReview === true
width: parent.width
spacing: 0
Row {
spacing: 16
Text {
text: QbzSession.tr("Credits", QbzSession.trRev)
color: root.activeTab === "credits" ? theme.accent : theme.textMuted
font.pixelSize: 13
font.weight: theme.weightMedium
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.activeTab = "credits"
}
}
Text {
text: QbzSession.tr("Review", QbzSession.trRev)
color: root.activeTab === "review" ? theme.accent : theme.textMuted
font.pixelSize: 13
font.weight: theme.weightMedium
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.activeTab = "review"
}
}
}
Item { width: 1; height: 12 }
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
Item { width: 1; height: 12 }
}
// Content. Fixed height so the card's height binding
// stays acyclic: the Flickable scrolls what overflows.
Rectangle {
width: parent.width
height: 320
color: "transparent"
clip: true
Flickable {
id: flick
anchors.fill: parent
contentWidth: width
contentHeight: contentCol.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Column {
id: contentCol
width: flick.width - 18
spacing: 0
// Loader-gated, not `visible:`-gated: a
// hidden Column is still BUILT, so opening
// this modal on the Description tab was
// constructing one CreditTrackRow per
// track of the album regardless — the
// whole point of a credits tab being that
// a box set has a lot of them.
Loader {
width: parent.width
active: root.activeTab === "credits"
visible: active
sourceComponent: Column {
width: parent.width
spacing: 0
Repeater {
model: root.doc.tracks || []
delegate: CreditTrackRow {
required property var modelData
required property int index
track: modelData
isLast: index === (root.doc.tracks || []).length - 1
}
}
}
}
QbzSelectableText {
visible: root.activeTab === "review"
width: parent.width
text: root.doc.review || ""
color: theme.textSecondary
pixelSize: 14
}
}
}
QbzScrollBar {
target: flick
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
}
}
Item { width: 1; height: 24 }
}
// ---- Loading / error / stale-doc overlay ----------------------
Rectangle {
anchors.fill: parent
visible: root.loading || root.errorText !== ""
|| (root.doc.albumId || "") !== root.requestedId
color: theme.surfaceCard
radius: theme.radiusMd
MouseArea { anchors.fill: parent }
Column {
anchors.centerIn: parent
spacing: 8
QbzSpinner {
anchors.horizontalCenter: parent.horizontalCenter
visible: root.loading && root.errorText === ""
}
Text {
visible: root.errorText !== ""
text: QbzSession.tr("Failed to load album credits", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontBody
}
Text {
visible: root.errorText !== ""
width: Math.min(card.width - 96, implicitWidth)
text: root.errorText
color: theme.textMuted
font.pixelSize: theme.fontLegal
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHCenter
}
}
}
}
}
}