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

@@ -7639,3 +7639,11 @@ msgstr "Playlist löschen?"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "“{}” will be deleted. This cannot be undone."
msgstr "„{}“ wird gelöscht. Das lässt sich nicht rückgängig machen."
#: crates/qbz-qt/qml/shell/TrackInfoBody.qml
msgid "Copy basic data"
msgstr "Basisdaten kopieren"
#: crates/qbz-qt/qml/shell/TrackInfoBody.qml
msgid "Copy full"
msgstr "Alles kopieren"
@@ -3294,3 +3294,11 @@ msgstr "Delete"
#: crates/qbz-qt/qml/settings/NavigationSettings.qml
msgid "Always use the icon-only section navigation in the header. When off, the text tabs turn into icons only when they no longer fit next to the search box."
msgstr "Always use the icon-only section navigation in the header. When off, the text tabs turn into icons only when they no longer fit next to the search box."
#: crates/qbz-qt/qml/shell/TrackInfoBody.qml
msgid "Copy basic data"
msgstr "Copy basic data"
#: crates/qbz-qt/qml/shell/TrackInfoBody.qml
msgid "Copy full"
msgstr "Copy full"
@@ -7645,3 +7645,11 @@ msgstr "¿Eliminar la lista de reproducción?"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "“{}” will be deleted. This cannot be undone."
msgstr "«{}» se eliminará. Esto no se puede deshacer."
#: crates/qbz-qt/qml/shell/TrackInfoBody.qml
msgid "Copy basic data"
msgstr "Copiar datos básicos"
#: crates/qbz-qt/qml/shell/TrackInfoBody.qml
msgid "Copy full"
msgstr "Copiar todo"
@@ -7637,3 +7637,11 @@ msgstr "Supprimer la playlist ?"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "“{}” will be deleted. This cannot be undone."
msgstr "« {} » sera supprimée. Cette action est irréversible."
#: crates/qbz-qt/qml/shell/TrackInfoBody.qml
msgid "Copy basic data"
msgstr "Copier les données de base"
#: crates/qbz-qt/qml/shell/TrackInfoBody.qml
msgid "Copy full"
msgstr "Tout copier"
@@ -7620,3 +7620,11 @@ msgstr "プレイリストを削除しますか?"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "“{}” will be deleted. This cannot be undone."
msgstr "「{}」を削除します。この操作は取り消せません。"
#: crates/qbz-qt/qml/shell/TrackInfoBody.qml
msgid "Copy basic data"
msgstr "基本情報をコピー"
#: crates/qbz-qt/qml/shell/TrackInfoBody.qml
msgid "Copy full"
msgstr "すべてコピー"
@@ -7627,3 +7627,11 @@ msgstr "Afspeellijst verwijderen?"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "“{}” will be deleted. This cannot be undone."
msgstr "“{}” wordt verwijderd. Dit kan niet ongedaan worden gemaakt."
#: crates/qbz-qt/qml/shell/TrackInfoBody.qml
msgid "Copy basic data"
msgstr "Basisgegevens kopiëren"
#: crates/qbz-qt/qml/shell/TrackInfoBody.qml
msgid "Copy full"
msgstr "Alles kopiëren"
@@ -7637,3 +7637,11 @@ msgstr "Excluir a playlist?"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "“{}” will be deleted. This cannot be undone."
msgstr "“{}” será excluída. Isso não pode ser desfeito."
#: crates/qbz-qt/qml/shell/TrackInfoBody.qml
msgid "Copy basic data"
msgstr "Copiar dados básicos"
#: crates/qbz-qt/qml/shell/TrackInfoBody.qml
msgid "Copy full"
msgstr "Copiar tudo"
@@ -7658,3 +7658,11 @@ msgstr "Удалить плейлист?"
#: qbz-qt/qml/controls/PlaylistDeleteConfirm.qml
msgid "“{}” will be deleted. This cannot be undone."
msgstr "«{}» будет удалён. Это действие нельзя отменить."
#: crates/qbz-qt/qml/shell/TrackInfoBody.qml
msgid "Copy basic data"
msgstr "Копировать основные данные"
#: crates/qbz-qt/qml/shell/TrackInfoBody.qml
msgid "Copy full"
msgstr "Копировать всё"
+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
}
}
}