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
This commit is contained in:
vicrodh committed 2026-09-13 20:03:36 -06:00
1 parent 9ea290d58b
commit 3f3c4ad548
12 files changed
+331 -156

No files matched your search

+116 -47
View File
@@ -17,6 +17,7 @@
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../controls"
import "../theme"
Popup {
@@ -60,6 +61,64 @@ Popup {
// 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);
@@ -220,26 +279,20 @@ Popup {
spacing: 0
topPadding: 4
bottomPadding: 4
Text {
QbzSelectableText {
id: perfName
text: modelData.name
color: perfArea.containsMouse ? theme.accent : theme.textPrimary
font.pixelSize: 13
font.weight: theme.weightMedium
MouseArea {
id: perfArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.openMusician(modelData.name, modelData.primaryRole)
}
color: theme.textPrimary
linkHref: "musician"
pixelSize: 13
weight: theme.weightMedium
onLinkActivated: root.openMusician(modelData.name, modelData.primaryRole)
}
Text {
width: parent.width - perfName.implicitWidth
text: modelData.roles
QbzSelectableText {
width: Math.max(0, parent.width - perfName.width)
text: String(modelData.roles || "")
color: theme.textMuted
font.pixelSize: 13
wrapMode: Text.WordWrap
pixelSize: 13
}
}
}
@@ -303,23 +356,45 @@ Popup {
spacing: 16
Item { width: 8; height: 1 }
Column {
width: parent.width - 82
width: parent.width - 130
anchors.verticalCenter: parent.verticalCenter
spacing: 4
Text {
// Selectable (2026-09-13), like every field below.
QbzSelectableText {
width: parent.width
text: root.doc.title || ""
color: theme.textPrimary
font.pixelSize: 16
font.weight: theme.weightSemibold
elide: Text.ElideRight
pixelSize: 16
weight: theme.weightSemibold
}
Text {
QbzSelectableText {
width: parent.width
text: root.doc.artist || ""
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
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 {
@@ -376,43 +451,38 @@ Popup {
color: theme.textMuted
font.pixelSize: 13
}
Text {
QbzSelectableText {
width: parent.width
text: root.doc.label || ""
color: (labelArea.containsMouse && (root.doc.labelId || "") !== "")
? theme.accent : theme.textPrimary
font.pixelSize: 13
font.weight: theme.weightSemibold
MouseArea {
id: labelArea
anchors.fill: parent
hoverEnabled: true
cursorShape: (root.doc.labelId || "") !== ""
? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: root.openLabel(root.doc.labelId || "")
}
color: theme.textPrimary
linkHref: (root.doc.labelId || "") !== "" ? "label" : ""
pixelSize: 13
weight: theme.weightSemibold
onLinkActivated: root.openLabel(root.doc.labelId || "")
}
Text {
QbzSelectableText {
visible: (root.doc.releaseDate || "") !== ""
width: parent.width
text: QbzSession.tr("on", QbzSession.trRev) + " " + (root.doc.releaseDate || "")
color: theme.textMuted
font.pixelSize: 13
pixelSize: 13
}
}
Item { visible: (root.doc.label || "") !== ""; width: 1; height: 8 }
Text {
QbzSelectableText {
visible: (root.doc.metaLine || "") !== ""
width: parent.width
text: root.doc.metaLine || ""
color: theme.textMuted
font.pixelSize: 13
wrapMode: Text.WordWrap
pixelSize: 13
}
Item { visible: (root.doc.quality || "") !== ""; width: 1; height: 12 }
Text {
QbzSelectableText {
visible: (root.doc.quality || "") !== ""
width: parent.width
text: root.doc.quality || ""
color: theme.textSecondary
font.pixelSize: 13
pixelSize: 13
}
}
@@ -502,13 +572,12 @@ Popup {
}
}
Text {
QbzSelectableText {
visible: root.activeTab === "review"
width: parent.width
text: root.doc.review || ""
color: theme.textSecondary
font.pixelSize: 14
wrapMode: Text.WordWrap
pixelSize: 14
}
}
}
+17 -28
View File
@@ -9,6 +9,7 @@
// { "role": "PRODUCER", "roleRaw": "Producer", "names": ["A", "B"] }
import QtQuick
import "../controls"
import "../theme"
Column {
@@ -33,46 +34,34 @@ Column {
width: colW
spacing: 6
Text {
QbzSelectableText {
width: cc.colW
text: cc.cell ? (cc.cell.role || "") : ""
color: cc.overAmbient ? "#b3ffffff" : theme.textMuted
style: cc.overAmbient ? Text.Raised : Text.Normal
styleColor: "#b0000000"
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
wrapMode: Text.WordWrap
raised: cc.overAmbient
pixelSize: 11
weight: theme.weightSemibold
letterSpacing: 0.5
}
Column {
spacing: 2
Repeater {
model: cc.cell ? (cc.cell.names || []) : []
delegate: Item {
// Selectable AND a link: a click lands on the musician, a drag
// selects the name (QbzSelectableText, rich-text anchor).
delegate: QbzSelectableText {
id: nameRow
required property string modelData
width: cc.colW
height: nameText.implicitHeight
Text {
id: nameText
width: cc.colW
text: nameRow.modelData
color: nameArea.containsMouse ? cc.accentColor
: (cc.overAmbient ? "#f2ffffff" : theme.textPrimary)
style: cc.overAmbient ? Text.Raised : Text.Normal
styleColor: "#b0000000"
font.pixelSize: 14
wrapMode: Text.WordWrap
}
MouseArea {
id: nameArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: cc.nameClicked(nameRow.modelData,
cc.cell ? (cc.cell.roleRaw || "") : "")
}
text: nameRow.modelData
color: cc.overAmbient ? "#f2ffffff" : theme.textPrimary
linkHref: "musician"
linkHoverColor: cc.accentColor
raised: cc.overAmbient
pixelSize: 14
onLinkActivated: cc.nameClicked(nameRow.modelData,
cc.cell ? (cc.cell.roleRaw || "") : "")
}
}
}
+6 -7
View File
@@ -13,6 +13,7 @@
// }
import QtQuick
import "../controls"
import "../theme"
Column {
@@ -28,15 +29,13 @@ Column {
width: cellWidth
spacing: 6
Text {
QbzSelectableText {
width: mc.cellWidth
text: mc.label
color: mc.overAmbient ? "#b3ffffff" : theme.textMuted
style: mc.overAmbient ? Text.Raised : Text.Normal
styleColor: "#b0000000"
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
elide: Text.ElideRight
raised: mc.overAmbient
pixelSize: 11
weight: theme.weightSemibold
letterSpacing: 0.5
}
}
+128 -74
View File
@@ -15,6 +15,7 @@
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../theme"
Column {
@@ -42,6 +43,59 @@ Column {
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 === ""
@@ -115,53 +169,71 @@ Column {
x: 24
y: 16
// 24 L + 24 R padding, 16 gap, 32 close X.
width: Math.max(0, parent.width - 24 - 24 - 16 - 32)
width: Math.max(0, parent.width - 24 - 24 - 16 - 32 - 8 - 32)
spacing: 4
Text {
// 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
style: body.cStyle
styleColor: body.cShadow
raised: body.overAmbient
raisedShadow: body.cShadow
// The immersive panel reads at a distance — its header
// steps up (owner, 2026-08-31 round 2).
font.pixelSize: body.overAmbient ? 24 : 16
font.weight: theme.weightSemibold
wrapMode: Text.WordWrap
pixelSize: body.overAmbient ? 24 : 16
weight: theme.weightSemibold
}
Text {
QbzSelectableText {
visible: (body.doc.album || "") !== ""
width: parent.width
text: body.doc.album || ""
color: body.cMuted
style: body.cStyle
styleColor: body.cShadow
font.pixelSize: body.overAmbient ? 15 : theme.fontLegal
elide: Text.ElideRight
raised: body.overAmbient
raisedShadow: body.cShadow
pixelSize: body.overAmbient ? 15 : theme.fontLegal
}
// Artist — a link only when an id exists.
Item {
QbzSelectableText {
visible: (body.doc.artist || "") !== ""
width: parent.width
height: visible ? artistText.implicitHeight : 0
Text {
id: artistText
text: body.doc.artist || ""
color: (body.doc.artistId || "") !== ""
? body.cAccent : body.cPrimary
style: body.cStyle
styleColor: body.cShadow
font.pixelSize: body.overAmbient ? 20 : 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 || "")
}
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)
}
}
@@ -211,28 +283,26 @@ Column {
cellWidth: loadedCol.metaColW
overAmbient: body.overAmbient
label: QbzSession.tr("Duration", QbzSession.trRev)
Text {
QbzSelectableText {
width: loadedCol.metaColW
text: body.doc.duration || ""
color: body.cPrimary
style: body.cStyle
styleColor: body.cShadow
font.pixelSize: 14
elide: Text.ElideRight
raised: body.overAmbient
raisedShadow: body.cShadow
pixelSize: 14
}
}
InfoMetaCell {
cellWidth: loadedCol.metaColW
overAmbient: body.overAmbient
label: QbzSession.tr("Quality", QbzSession.trRev)
Text {
QbzSelectableText {
width: loadedCol.metaColW
text: body.doc.quality || ""
color: body.cPrimary
style: body.cStyle
styleColor: body.cShadow
font.pixelSize: 14
elide: Text.ElideRight
raised: body.overAmbient
raisedShadow: body.cShadow
pixelSize: 14
}
}
// ISRC — the cell is dropped (not blanked) when absent;
@@ -242,14 +312,13 @@ Column {
cellWidth: loadedCol.metaColW
overAmbient: body.overAmbient
label: "ISRC"
Text {
QbzSelectableText {
width: loadedCol.metaColW
text: body.doc.isrc || ""
color: body.cMuted
style: body.cStyle
styleColor: body.cShadow
font.pixelSize: 14
elide: Text.ElideRight
raised: body.overAmbient
raisedShadow: body.cShadow
pixelSize: 14
}
}
Item {
@@ -271,30 +340,16 @@ Column {
cellWidth: loadedCol.metaColW
overAmbient: body.overAmbient
label: QbzSession.tr("Label", QbzSession.trRev)
Item {
QbzSelectableText {
width: loadedCol.metaColW
height: labelText.implicitHeight
Text {
id: labelText
width: loadedCol.metaColW
text: body.doc.label || ""
color: (labelArea.containsMouse
&& (body.doc.labelId || "") !== "")
? body.cAccent : 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 || "")
}
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 || "")
}
}
}
@@ -369,15 +424,14 @@ Column {
width: 1
height: 20
}
Text {
QbzSelectableText {
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
raised: body.overAmbient
raisedShadow: body.cShadow
pixelSize: 12
}
}
}