Files
qbz/crates/qbz-qt/qml/cards/TrackCard.qml
T
vicrodh 81c43a114e fix(qt): glyph colours follow the theme, on light palettes and on accent fills
Two defects with one cause: a tint name that means a frozen hex.

"primary" is the legacy alias of literal #ffffff, not Theme.text-primary,
so every control passing it drew a white glyph. On the eleven light
themes that is white-on-light -- the header back/forward chevrons, the
sidebar toggle and the carousel arrows the owner marked. They now pass
the theme-following names, which icon_tint_qt bakes at runtime.

The stale `hslLightness > 0.5 ? "primary" : "black"` workarounds that
predate the runtime tinter collapse into those names, and the qrc floor
underneath them becomes polarity-aware: dir_for() returns "" whenever
the cache dir is unavailable, a bake fails or the theme document has not
been published, and that path used to resolve textPrimary straight back
to the white bake -- reproducing the bug it was fixing, app-wide, in the
degraded state.

On accent fills the fix is a deviation from Slint, approved by the owner
after measurement. A fixed white glyph on Theme.accent falls under 2.6:1
on 16 of the 35 shipped palettes, including high-contrast at 1.70:1 and
wcag-dark at 1.82:1 -- the two themes whose reason to exist is contrast.
Slint hardcodes #ffffff there (CircleAction.slint:72 and three others),
so the bug is upstream. accentText alone is not the answer either: it is
picked for the theme's text ramp and gives 2.56:1 on Rose Pine Dawn,
worse than the black it replaces. So QbzTheme picks the best-contrast
tint by real sRGB luminance against the live accent, and the labels
beside those glyphs use its colour twin -- BrowseGenreButton and
LibraryView were drawing a white glyph next to an accentText label
inside the same pill.

Rounds out the two parity items the same files owe: QbzNavButton gains
the ambient arm from Carousel.slint (wired at the six carousel sites,
not left dead), and QbzCircleAction rounds its glyph to whole pixels the
way CircleAction.slint does.

cc
2026-07-29 10:30:43 -06:00

221 lines
9.4 KiB
QML

// TrackCard — THE track card (discover/TrackCard.slint), promoted from
// LibraryView.LibTrackCard in phase 21: Library Tracks grid + All feed.
// 200x246: 200px cover + hover scrim 0.6, overlay row (heart / play /
// more) at y=120, optional source badge, then the meta row: title +
// "Track • Artist" (artist links out) + the icon-only quality badge.
// Body click PLAYS (the TrackCard convention, unlike album/playlist).
//
// item contract: { id, title, artist, artistId, albumId, qualityTier,
// isFavorite, source } plus the host-resolved artSource string prop.
//
// NOTE the Search most-popular track hero is NOT this card (Slint:
// primitives/SearchTrackHero.slint — a distinct 160x220 filled-chrome
// hero); the POC keeps its own SearchTrackHero variant (200x246, centered
// play, quality as text) in SearchView.qml with a justifying comment.
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../theme"
Rectangle {
id: root
property var item: ({})
// Host-resolved artwork path (the AlbumCard artSource pattern).
property string artSource: ""
// Remote cover URL for the pin payload ("" when the host has none).
property string artworkUrl: ""
// ADR-008 source glyph (local/Plex) — the Library All-feed arm.
property bool showSourceBadge: false
color: "transparent"
QbzTheme { id: theme }
readonly property bool overlayOn: tcArtArea.containsMouse || favBtn.hovered
|| playBtn.hovered || moreBtn.hovered
implicitWidth: 200
implicitHeight: 246
Column {
spacing: 0
Rectangle {
width: 200
height: 200
radius: theme.radiusSm
color: theme.surfaceElevated
clip: true
RoundedImage {
anchors.fill: parent
source: root.artSource
radius: theme.radiusSm
}
Rectangle {
anchors.fill: parent
radius: theme.radiusSm
color: "#000000"
opacity: root.overlayOn ? 0.6 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
}
// Body click PLAYS the track (TrackCard hover); right press opens
// the SAME menu as the ⋯ button.
MouseArea {
id: tcArtArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton)
trackMenu.openAtCursor(tcArtArea, mouse.x, mouse.y)
else
QbzPlayer.playTrack(root.item.id)
}
}
// Hover overlay — favorite / play / more (y=120, h=44,
// centered, spacing 12).
CardOverlayRow {
y: 120
width: parent.width
shown: root.overlayOn
CardOverlayButton {
id: favBtn
name: root.item.isFavorite ? "heart-filled" : "heart"
active: root.item.isFavorite
anchors.verticalCenter: parent.verticalCenter
onClicked: {
root.item.isFavorite = !root.item.isFavorite
QbzLibrary.libraryToggleFavorite("track", root.item.id)
}
}
CardOverlayButton {
id: playBtn
name: "play-fill"
primary: true
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzPlayer.playTrack(root.item.id)
}
CardOverlayButton {
id: moreBtn
name: "ellipsis"
anchors.verticalCenter: parent.verticalCenter
onClicked: function (mouse) { trackMenu.openAtCursor(moreBtn, mouse.x, mouse.y) }
}
}
// Source badge (All feed, show-local): bottom-right.
Rectangle {
visible: root.showSourceBadge && (root.item.source === "local" || root.item.source === "plex")
x: parent.width - width - 6
y: parent.height - height - 6
width: 24
height: 24
radius: 4
color: "#b3000000"
QbzIcon {
name: "hard-drive"
width: 14
height: 14
anchors.centerIn: parent
// On the #b3000000 badge — dark under every theme.
tintName: root.item.source === "plex" ? "accent" : "white"
}
}
CardMenu {
id: trackMenu
menuWidth: 196
entries: root.menuModel()
onPicked: function (a) { root.trackAction(a) }
}
}
Item { width: 1; height: 6 }
// Title / "Track • Artist" + quality badge.
Row {
width: 200
height: 40
spacing: theme.spacingSm
Column {
width: parent.width - (tcQ.visible ? tcQ.width + theme.spacingSm : 0)
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
height: 20
text: root.item.title || ""
color: tcTitleArea.containsMouse ? theme.accent : theme.textPrimary
font.pixelSize: theme.fontBody - 2
font.weight: theme.weightMedium
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
MouseArea {
id: tcTitleArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton)
trackMenu.openAtCursor(tcTitleArea, mouse.x, mouse.y)
else
QbzPlayer.playTrack(root.item.id)
}
}
}
Text {
width: parent.width
height: 18
text: QbzSession.tr("Track", QbzSession.trRev) + " • " + (root.item.artist || "")
color: root.item.artistId && tcArtistArea.containsMouse
? theme.textPrimary : theme.textMuted
font.pixelSize: theme.fontLink - 1
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
MouseArea {
id: tcArtistArea
anchors.fill: parent
hoverEnabled: true
cursorShape: root.item.artistId ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (root.item.artistId) QbzArtist.openArtist(root.item.artistId)
}
}
}
QualityMini { id: tcQ; tier: root.item.qualityTier || ""; anchors.verticalCenter: parent.verticalCenter }
}
}
// Track context-menu model (TrackCard.slint track-menu) + dispatch.
// COMPLETE against the .slint: Play · Play next · Play later (#442, end
// of the manual block) · Add to queue · Go to artist (artist-id != "") ·
// Go to album (album-id != "") · favorite. The card menu is deliberately
// shorter than the row menu — the .slint card has no radio / share /
// offline / track-info rows.
function menuModel() {
var t = QbzSession.tr
var r = QbzSession.trRev
var m = [
{ "label": t("Play", r), "icon": "play-fill", "action": "play" },
{ "label": t("Play next", r), "icon": "list-start", "action": "next" },
{ "label": t("Play later", r), "icon": "list-plus", "action": "later" },
{ "label": t("Add to queue", r), "icon": "list-end", "action": "queue" },
]
if (root.item.artistId) m.push({ "label": t("Go to artist", r), "icon": "user", "action": "go-artist" })
if (root.item.albumId) m.push({ "label": t("Go to album", r), "icon": "disc", "action": "go-album" })
m.push({ "label": root.item.isFavorite ? t("Remove from Library", r) : t("Add to Library", r),
"icon": root.item.isFavorite ? "heart-filled" : "heart", "action": "favorite" })
return m
}
function trackAction(a) {
if (a === "play") QbzPlayer.playTrack(root.item.id)
else if (a === "next") QbzPlayer.enqueueTrack(root.item.id, "next")
else if (a === "later") QbzPlayer.enqueueTrack(root.item.id, "later")
else if (a === "queue") QbzPlayer.enqueueTrack(root.item.id, "queue")
else if (a === "go-artist") QbzArtist.openArtist(root.item.artistId)
else if (a === "go-album") QbzAlbum.openAlbum(root.item.albumId)
else if (a === "favorite") {
root.item.isFavorite = !root.item.isFavorite
QbzLibrary.libraryToggleFavorite("track", root.item.id)
}
}
}