merge: integrate the visual polish batch (text modal, selectable info modals, copy flyouts) into pre-release

cc
This commit is contained in:
vicrodh committed 2026-09-13 20:03:36 -06:00
commit 4db8962c3c
18 files changed
+612 -189

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 "Копировать всё"
+2
View File
@@ -575,6 +575,7 @@ fn main() {
"qml/controls/PlaylistPickerModal.qml",
"qml/controls/PmFolderIcon.qml",
"qml/controls/QbzCircleAction.qml",
"qml/controls/QbzClipboard.qml",
"qml/controls/QbzColorPicker.qml",
"qml/controls/QbzConfirmModal.qml",
"qml/controls/QbzContextMenu.qml",
@@ -597,6 +598,7 @@ fn main() {
"qml/controls/ScopePanel.qml",
"qml/controls/QbzSegToggle.qml",
"qml/controls/QbzSelect.qml",
"qml/controls/QbzSelectableText.qml",
"qml/controls/QbzSplitButton.qml",
"qml/controls/QbzSlider.qml",
"qml/controls/QbzTabBar.qml",
@@ -0,0 +1,34 @@
// QbzClipboard — the one clipboard write QML offers without a C++ seam: an
// off-screen TextEdit's copy(). Mount one where a copy action lives and call
// `copy(text)`; the carrier is emptied right after, so the text never lingers
// in an invisible editor. One primitive instead of the carriers CommandBlock,
// SandboxSettings and the lyrics flyout each grew on their own.
import QtQuick
Item {
id: root
visible: false
width: 0
height: 0
/// Put `text` on the system clipboard. False for an empty text.
function copy(text) {
var s = String(text || "")
if (s === "")
return false
carrier.text = s
carrier.selectAll()
carrier.copy()
carrier.deselect()
carrier.text = ""
return true
}
TextEdit {
id: carrier
visible: false
width: 0
height: 0
}
}
@@ -0,0 +1,110 @@
// QbzSelectableText — a Text-shaped label the user can select and copy.
//
// Qt Quick's Text cannot be selected; a read-only TextEdit can, so the
// default arm is one: mouse and keyboard selection, the right-click Copy
// menu (QbzTextEditMenu) and a rich-text link for the fields that navigate
// (artist, label, musician). The `raised` arm keeps a plain Text with the
// Raised shadow style for the hosts that sit on the ambient field (the
// immersive TrackInfoPanel): TextEdit has no `style`, and legibility there
// beats selection. Same props on both arms, so a host switches with one flag.
//
// Sizing: hosts bind `width`; the height follows the laid-out text through
// implicitHeight, exactly like the Text it replaces.
import QtQuick
import "../theme"
Item {
id: root
property string text: ""
property color color: theme.textPrimary
property int pixelSize: theme.fontBody
property int weight: theme.weightRegular
property real letterSpacing: 0
property int wrapMode: Text.WordWrap
property int horizontalAlignment: Text.AlignLeft
/// `text` is already HTML (paragraph and line-height markup). Not
/// combined with `linkHref`.
property bool rich: false
/// Plain Text with the Raised shadow style, no selection (ambient hosts).
property bool raised: false
property color raisedShadow: "#b0000000"
/// When set, the whole text is one link: a click raises linkActivated
/// with it, hovering paints linkHoverColor and the pointing cursor.
property string linkHref: ""
property color linkHoverColor: theme.accent
signal linkActivated(string href)
QbzTheme { id: theme }
implicitWidth: raised ? plain.implicitWidth : edit.implicitWidth
implicitHeight: raised ? plain.implicitHeight : edit.implicitHeight
function esc(s) {
return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;")
}
readonly property bool linkHovered: root.linkHref !== ""
&& (root.raised ? plainHover.hovered : editHover.hovered)
readonly property color liveColor: linkHovered ? root.linkHoverColor : root.color
TextEdit {
id: edit
visible: !root.raised
width: root.width
readOnly: true
selectByMouse: true
selectByKeyboard: true
cursorVisible: false
persistentSelection: false
activeFocusOnTab: false
textFormat: (root.linkHref !== "" || root.rich) ? TextEdit.RichText : TextEdit.PlainText
// The link colour rides in the markup (TextEdit has no linkColor);
// it is rebuilt only when the hover flips, not per mouse move.
text: root.linkHref !== ""
? "<a href=\"" + root.esc(root.linkHref) + "\" style=\"text-decoration:none; color:"
+ root.liveColor + "\">" + root.esc(root.text) + "</a>"
: root.text
color: root.color
font.pixelSize: root.pixelSize
font.weight: root.weight
font.letterSpacing: root.letterSpacing
wrapMode: root.wrapMode
horizontalAlignment: root.horizontalAlignment
selectionColor: Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.35)
selectedTextColor: theme.textPrimary
onLinkActivated: function (link) { root.linkActivated(link) }
HoverHandler {
id: editHover
cursorShape: (root.linkHref !== "" && edit.hoveredLink !== "")
? Qt.PointingHandCursor : Qt.IBeamCursor
}
QbzTextEditMenu { }
}
Text {
id: plain
visible: root.raised
width: root.width
text: root.text
textFormat: root.rich ? Text.RichText : Text.PlainText
color: root.liveColor
style: Text.Raised
styleColor: root.raisedShadow
font.pixelSize: root.pixelSize
font.weight: root.weight
font.letterSpacing: root.letterSpacing
wrapMode: root.wrapMode
horizontalAlignment: root.horizontalAlignment
HoverHandler {
id: plainHover
enabled: root.linkHref !== ""
cursorShape: Qt.PointingHandCursor
}
TapHandler {
enabled: root.linkHref !== ""
onTapped: root.linkActivated(root.linkHref)
}
}
}
+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
}
}
}
+93 -17
View File
@@ -687,6 +687,32 @@ Rectangle {
textModalOpen = true
}
// Bodies arrive as plain text with blank lines between paragraphs (the
// biography and review sources). Rendered as rich text so the line height
// can breathe — TextEdit has no lineHeight — and the paragraphs keep
// their gap; single newlines stay line breaks.
function textModalHtml(body) {
var esc = function (s) {
return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
}
var paras = String(body || "").replace(/\r\n/g, "\n").split(/\n{2,}/)
var out = []
for (var i = 0; i < paras.length; i++) {
var p = paras[i].trim()
if (p === "") continue
out.push("<p style=\"line-height:150%; margin-top:0; margin-bottom:14px\">"
+ esc(p).replace(/\n/g, "<br>") + "</p>")
}
return out.join("")
}
property bool textModalCopied: false
Timer {
id: textModalCopiedTimer
interval: 1400
onTriggered: root.textModalCopied = false
}
QbzClipboard { id: textModalClipboard }
Rectangle {
visible: root.textModalOpen
anchors.fill: parent
@@ -699,8 +725,11 @@ Rectangle {
}
Rectangle {
anchors.centerIn: parent
width: Math.min(root.width - 80, 560)
height: Math.min(root.height - 120, 460)
// At least 75% x 65% of the window (2026-09-13): the old 560 x 460
// card crammed a biography into a column the reader had to squint
// at. Floors for small windows, ceilings that keep a margin.
width: Math.min(root.width - 32, Math.max(Math.round(root.width * 0.75), 560))
height: Math.min(root.height - 40, Math.max(Math.round(root.height * 0.65), 400))
radius: theme.radiusMd
color: theme.surfaceCard
border.width: 1
@@ -712,23 +741,55 @@ Rectangle {
}
Column {
anchors.fill: parent
anchors.margins: 24
spacing: 14
anchors.margins: 28
spacing: 16
Row {
width: parent.width
spacing: 8
Text {
width: parent.width - 28
width: parent.width - 28 - 28 - 16
text: root.textModalTitle
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
elide: Text.ElideRight
anchors.verticalCenter: parent.verticalCenter
}
// Copy the whole body (plain text) to the clipboard; the
// glyph turns into a check for a moment as the receipt.
Rectangle {
width: 28
height: 28
color: tmCopyArea.containsMouse ? theme.surfaceHover : "transparent"
radius: 6
anchors.verticalCenter: parent.verticalCenter
QbzIcon {
name: root.textModalCopied ? "check" : "copy"
width: 16
height: 16
anchors.centerIn: parent
tintName: root.textModalCopied ? "accent"
: (tmCopyArea.containsMouse ? "textPrimary" : "muted")
}
MouseArea {
id: tmCopyArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (textModalClipboard.copy(root.textModalBody)) {
root.textModalCopied = true
textModalCopiedTimer.restart()
}
}
}
}
Rectangle {
width: 28
height: 28
color: tmCloseArea.containsMouse ? theme.surfaceHover : "transparent"
radius: 6
anchors.verticalCenter: parent.verticalCenter
QbzIcon {
name: "x"
width: 18
@@ -745,19 +806,34 @@ Rectangle {
}
}
}
Flickable {
Item {
width: parent.width
height: parent.height - 42
clip: true
contentWidth: width
contentHeight: tmText.implicitHeight
Text {
id: tmText
width: parent.width
text: root.textModalBody
color: theme.textSecondary
font.pixelSize: theme.fontBody
wrapMode: Text.WordWrap
height: parent.height - 28 - 16
Flickable {
id: tmFlick
anchors.fill: parent
anchors.rightMargin: 22
clip: true
contentWidth: width
contentHeight: tmText.implicitHeight
boundsBehavior: Flickable.StopAtBounds
// Selectable, one size up from body text, paragraphs
// with room between them.
QbzSelectableText {
id: tmText
width: tmFlick.width
rich: true
text: root.textModalHtml(root.textModalBody)
color: theme.textPrimary
pixelSize: theme.fontBody + 1
}
}
QbzScrollBar {
target: tmFlick
alwaysShown: true
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
}
+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
}
}
}
+4 -1
View File
@@ -25,7 +25,10 @@ Item {
readonly property real travel: Math.max(0, height - thumbH)
// Shown while scrolling, hovering, or dragging (auto-hide).
property bool scrollActive: false
readonly property bool shown: scrollActive || barArea.containsMouse || barArea.pressed
/// Keep the track and thumb painted while there is anything to scroll
/// (the shared text modal: a long biography must read as scrollable).
property bool alwaysShown: false
readonly property bool shown: alwaysShown || scrollActive || barArea.containsMouse || barArea.pressed
Connections {
target: root.target
+38 -15
View File
@@ -41,6 +41,7 @@ import com.blitzfc.qbz
import "../cards"
import "../controls"
import "../rows"
import "../shell"
import "../theme"
Rectangle {
@@ -865,6 +866,28 @@ Rectangle {
// paint. Arrivals are coalesced into ONE rebind per frame — the same fix
// LocalLibraryView carries, at O(n) instead of O(n²), with the covers
// still appearing progressively (16ms granularity is invisible).
// --- Track info for the Popular Tracks rows (2026-09-13) ---------------
// ONE lazy modal per artist view (the full Popup tree must not exist per
// row). Activated on demand, torn down on close (Qt.callLater so the
// Popup is not destroyed mid-signal) — the same shape rows/TrackRow.qml
// carries per row.
Loader {
id: artistTrackInfo
active: false
sourceComponent: TrackInfoModal { }
}
Connections {
target: artistTrackInfo.item
ignoreUnknownSignals: true
function onClosed() { Qt.callLater(function () { artistTrackInfo.active = false }) }
}
function openTrackInfo(trackId) {
if (!trackId || trackId === "")
return
artistTrackInfo.active = true
artistTrackInfo.item.openFor(trackId)
}
property var _coverInbox: ({})
Timer {
id: coverFlush
@@ -1688,11 +1711,11 @@ Rectangle {
// `controls/CardMenu.qml`, the same primitive rows/TrackRow.qml opens.
//
// ABSENT, not dead (the same discipline TrackRow applies): the radio
// pair, Share Qobuz link / Song.link and Track info. The first two
// need bridge seams that do not exist; the last needs the shared
// row's lazy Loader (a TrackInfoModal), and duplicating those per
// artist row is exactly the fork-drift this file already paid for
// once. (The offline block IS wired — it needs no Loader.)
// pair and Share Qobuz link / Song.link, which need bridge seams that
// do not exist. Track info IS wired (2026-09-13) through ONE lazy
// TrackInfoModal per artist view (`artistTrackInfo` below), not one
// per row — the per-row Loader rows/TrackRow.qml carries is what this
// comment used to refuse to duplicate.
CardMenu {
id: popMenu
menuWidth: 224
@@ -1749,6 +1772,8 @@ Rectangle {
m.push({ "label": t("Go to album", r), "icon": "disc-3", "action": "go-album" })
if ((popRow.row.artistId || "") !== "")
m.push({ "label": t("Go to artist", r), "icon": "user", "action": "go-artist" })
if (!popRow.pulled)
m.push({ "label": t("Track info", r), "icon": "info", "action": "track-info" })
return m
}
onPicked: function (a) {
@@ -1781,6 +1806,7 @@ Rectangle {
else if (a === "recache") QbzPlayer.recacheTrack(id)
else if (a === "go-album") QbzAlbum.openAlbum(popRow.row.albumId)
else if (a === "go-artist") QbzArtist.openArtist(popRow.row.artistId)
else if (a === "track-info") root.openTrackInfo(id)
}
}
@@ -2759,29 +2785,26 @@ Rectangle {
onAction: function (id) { root.bulkAction(id) }
}
// Air between the sticky JUMP TO bar's rule and the row.
Item { visible: topTracks.length > 0; width: 1; height: 8 }
Row {
property string anchorId: "popular-tracks"
visible: topTracks.length > 0
width: parent.width
spacing: 12
Text {
width: parent.width - 44 - 32 - 32 - 3 * 12
width: parent.width - 32 - 32 - 32 - 3 * 12
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Popular Tracks", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
}
// ArtistPageView.slint:732-737 mounts the SHARED
// CircleAction here — `primary: true` plus an explicit
// `on-surface: true` with the .slint's own reason on the
// line above it: "Plain page background (below the header
// divider) — theme-aware variant so it reads on light
// themes." The port hand-rolled a 44px accent disc
// instead, which duplicated the control AND bypassed that
// arm. `overlay` defaults false = the on-surface arm.
// The SHARED CircleAction, in the same plain 32px outlined
// form as its two siblings (2026-09-13): the filled 44px
// accent disc the .slint carried here clashed with them
// and butted the JUMP TO bar's rule.
QbzCircleAction {
primary: true
name: "play-fill"
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzPlayer.playArtistTop(false)