Files
qbz/crates/qbz-qt/qml/cards/AlbumCard.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

424 lines
18 KiB
QML

// THE shared album card — QML replica of discover/AlbumCard.slint, used
// by BOTH the Home rails and the Library All grid (the Slint mounts the
// same component in both places).
//
// 200x246: 200px artwork (Radius.sm) + placeholder, hover scrim with
// genre/year meta, pin badge (top-right), favorite / play / more overlay
// buttons, award ribbon, source badge (opt-in), then the title/artist
// lines with the icon-only quality badge.
//
// Live wiring: play (art click + overlay play), favorite heart (optimistic
// + signal), pin badge (pinned store), ⋯ context menu — and the SAME menu
// on a right press anywhere on the artwork or the title.
//
// --- Menu inventory vs discover/AlbumCard.slint ------------------------
// Open album · Play · Play next · Play later · Add to queue ·
// Add to/Remove from Library (show-favorite) · Block this album
// (source != local && source != plex)
// All but the last are live. "Block this album" is gated OFF by
// `hasBlacklistSeam` below: the Qt bridge exposes the blacklist COUNTERS
// (settings_qt/devtools.rs) but no block-album invokable, and the row used
// to render and do nothing at all.
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../controls"
import "../theme"
Rectangle {
id: root
// --- card data (the ONE contract both hosts fill) --------------------
property string albumId: ""
property string title: ""
property string artist: ""
property string artistId: ""
property string genre: ""
property string year: ""
property string qualityTier: ""
property string ribbon: ""
property string ribbonKind: ""
// Artwork image source (file://… or "") — the host's cache lookup.
property string artSource: ""
property bool isFavorite: false
property bool isPinned: false
// Source badge (Library show-local): "local" | "plex" | "" (hidden).
property string source: ""
// Local play count — rendered as the muted "{} plays" line under the
// artist, and ONLY there (discover/AlbumCard.slint:508-513). Every
// surface but Most Played Albums publishes 0, which is why that page's
// card is 286px tall and every other one is 266px: this line IS the
// 20px difference.
property int plays: 0
// --- Local Library mode (additive; default = the Qobuz behaviour) ----
// The Local Library mounts THIS card, but its `albumId` is a group key
// (folder or metadata identity), not a Qobuz catalog id — routing it
// through QbzAlbum.openAlbum / QbzPlayer.playAlbum would fire a
// catalog fetch for a folder path. In localMode every action is emitted
// to the host instead, and the catalog-only affordances (heart, pin,
// "Block this album") are hidden. Nothing else about the card changes,
// so the two surfaces stay pixel-identical.
property bool localMode: false
// Album blacklist ("Block this album"): no write seam on the Qt bridge —
// the entry stays out of the menu until one lands, rather than shipping
// a row that silently no-ops. Flip this and fill the "block" branch in
// `menuAction` together.
readonly property bool hasBlacklistSeam: false
signal openRequested()
signal playRequested()
signal enqueueRequested(string mode)
QbzTheme { id: theme }
width: 200
// 246 normally; +20 for the "{} plays" line (see `plays`) — the same
// 266 -> 286 step MostPlayedAlbumsView.slint:23 takes for its grid.
height: 246 + (root.plays > 0 ? 20 : 0)
color: "transparent"
readonly property bool overlayOn: artArea.containsMouse || pinArea.containsMouse
|| favBtn.hovered || playBtn.hovered || moreBtn.hovered
function toggleFavorite() {
root.isFavorite = !root.isFavorite
QbzLibrary.libraryToggleFavorite("album", root.albumId)
}
function togglePin() {
root.isPinned = !root.isPinned
QbzLibrary.togglePin("album", root.albumId, root.title, root.artist, "")
}
// AlbumCard.slint's album-menu, in its order. localMode drops the
// catalog-only rows (heart + block); the five navigation/playback rows
// are identical and route to the host's signals instead.
function menuModel() {
var t = QbzSession.tr
var r = QbzSession.trRev
var m = [
{ "label": t("Open album", r), "icon": "library-big", "action": "open" },
{ "label": t("Play", r), "icon": "play-fill", "action": "play" },
{ "label": t("Play next", r), "icon": "list-start", "action": "next" },
// #442 "Play later" — end of the manual block.
{ "label": t("Play later", r), "icon": "list-plus", "action": "later" },
{ "label": t("Add to queue", r), "icon": "list-end", "action": "queue" },
]
if (!root.localMode) {
m.push({ "label": root.isFavorite ? t("Remove from Library", r) : t("Add to Library", r),
"icon": root.isFavorite ? "heart-filled" : "heart", "action": "favorite" })
// .slint gates this on a non-local/plex source as well.
if (root.hasBlacklistSeam && root.source !== "local" && root.source !== "plex")
m.push({ "label": t("Block this album", r), "icon": "blind-eye", "action": "block" })
}
return m
}
function menuAction(a) {
if (root.localMode) {
if (a === "open") root.openRequested()
else if (a === "play") root.playRequested()
else if (a === "next") root.enqueueRequested("next")
else if (a === "later") root.enqueueRequested("later")
else if (a === "queue") root.enqueueRequested("queue")
return
}
if (a === "open") QbzAlbum.openAlbum(root.albumId)
else if (a === "play") QbzPlayer.playAlbum(root.albumId)
else if (a === "next") QbzPlayer.enqueueAlbum(root.albumId, "next")
else if (a === "later") QbzPlayer.enqueueAlbum(root.albumId, "later")
else if (a === "queue") QbzPlayer.enqueueAlbum(root.albumId, "queue")
else if (a === "favorite") root.toggleFavorite()
}
Column {
spacing: 0
// --- Artwork + hover overlay -----------------------------------
Rectangle {
width: 200
height: 200
radius: theme.radiusSm
color: theme.surfaceElevated
clip: true
RoundedImage {
anchors.fill: parent
source: root.artSource
radius: theme.radiusSm
}
// Hover scrim.
Rectangle {
anchors.fill: parent
radius: theme.radiusSm
color: "#000000"
opacity: root.overlayOn ? 0.6 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
}
// Hover meta — genre + year, top-left.
Column {
x: 12
y: 12
spacing: 2
opacity: root.overlayOn ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
Text {
visible: root.genre !== ""
text: root.genre
height: 20
color: "#ebffffff"
font.pixelSize: 13
font.weight: theme.weightBold
verticalAlignment: Text.AlignVCenter
}
Text {
visible: root.year !== ""
text: root.year
height: 17
color: "#ccffffff"
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
}
}
// Card-open + hover detector (declared before the action
// buttons so those win the pointer).
MouseArea {
id: artArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// Right press opens the SAME menu as the ⋯ button.
acceptedButtons: Qt.LeftButton | Qt.RightButton
// Phase 8: the card opens the album view (the overlay play
// button carries the play affordance).
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton) {
albumMenu.openAtCursor(artArea, mouse.x, mouse.y)
return
}
if (root.localMode)
root.openRequested()
else
QbzAlbum.openAlbum(root.albumId)
}
}
// Pin badge — top-right. Hover-revealed like the overlay
// buttons (AlbumCard.slint: opacity follows overlay-on even
// when pinned — the pinned state reads in the icon swap only:
// filled accent pin vs outline). Always-mounted (opacity) so
// its hover joins overlayOn.
Rectangle {
visible: !root.localMode
x: parent.width - width - 8
y: 8
width: 26
height: 26
radius: 13
color: pinArea.containsMouse ? "#cc000000" : "#99000000"
opacity: root.overlayOn ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
QbzIcon {
name: root.isPinned ? "pin-filled" : "pin"
width: 14
height: 14
anchors.centerIn: parent
// On a #99000000/#cc000000 scrim — dark under every theme.
tintName: root.isPinned ? "accent" : "white"
}
MouseArea {
id: pinArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.togglePin()
}
}
// Hover action buttons — favorite / play / more (y=120, h=44,
// centered, spacing 12).
CardOverlayRow {
y: 120
width: parent.width
shown: root.overlayOn
CardOverlayButton {
id: favBtn
visible: !root.localMode
name: root.isFavorite ? "heart-filled" : "heart"
active: root.isFavorite
anchors.verticalCenter: parent.verticalCenter
onClicked: root.toggleFavorite()
}
CardOverlayButton {
id: playBtn
name: "play-fill"
primary: true
anchors.verticalCenter: parent.verticalCenter
onClicked: root.localMode ? root.playRequested()
: QbzPlayer.playAlbum(root.albumId)
}
CardOverlayButton {
id: moreBtn
name: "ellipsis"
anchors.verticalCenter: parent.verticalCenter
// CardOverlayButton.clicked() carries no mouse payload,
// so fall back to the disc's centre — the menu still
// opens under the ⋯ (worst case 18px off the pointer).
// Stays correct if the signal ever forwards the event.
onClicked: function (mouse) {
albumMenu.openAtCursor(moreBtn,
mouse ? mouse.x : moreBtn.width / 2,
mouse ? mouse.y : moreBtn.height / 2)
}
}
}
// Context menu (AlbumCard.slint's album-menu) — the shared
// CardMenu surface, not a second copy of its delegate.
CardMenu {
id: albumMenu
menuWidth: 196
entries: root.menuModel()
onPicked: function (a) { root.menuAction(a) }
}
// Award ribbon — content-width, capped at the card width.
Rectangle {
visible: root.ribbon !== ""
x: 0
y: parent.height - height - 8
height: 20
width: Math.min(ribbonRow.width, 200)
color: root.ribbonKind === "press" ? "#d49511" : "#e0000000"
topRightRadius: 3
bottomRightRadius: 3
clip: true
Rectangle {
width: root.ribbonKind === "press" ? 0 : 3
height: parent.height
color: root.ribbonKind === "qobuzissime" ? "#8b5cf6" : "#eab308"
}
Row {
id: ribbonRow
height: parent.height
leftPadding: 10
rightPadding: 10
width: ribbonText.implicitWidth + 20
Text {
id: ribbonText
height: parent.height
text: root.ribbon
color: root.ribbonKind === "press" ? "#1f1407" : "#ffffff"
font.pixelSize: 9
font.weight: theme.weightSemibold
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
}
// Source badge (Library show-local): bottom-right of the art.
Rectangle {
visible: root.source === "local" || root.source === "plex"
|| root.source === "offline"
x: parent.width - width - 6
y: parent.height - height - 6
width: 24
height: 24
radius: 4
color: "#b3000000"
QbzIcon {
// The Local Library's third source: a Qobuz offline copy
// (LocalLibraryView.slint's `show-source-badge` triple).
name: root.source === "offline" ? "cloud-download" : "hard-drive"
width: 14
height: 14
anchors.centerIn: parent
// On the #b3000000 badge — dark under every theme.
tintName: root.source === "plex" ? "accent" : "white"
}
}
}
Item { width: 1; height: 6 }
// --- Title / artist + quality badge ------------------------------
Row {
width: 200
height: 40 + (root.plays > 0 ? 20 : 0)
spacing: theme.spacingSm
Column {
width: parent.width - (qBadge.visible ? qBadge.width + theme.spacingSm : 0)
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
height: 20
text: root.title
color: titleArea.containsMouse ? theme.accent : theme.textPrimary
font.pixelSize: theme.fontBody - 2
font.weight: theme.weightMedium
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
MouseArea {
id: titleArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton) {
albumMenu.openAtCursor(titleArea, mouse.x, mouse.y)
return
}
if (root.localMode)
root.openRequested()
else
QbzAlbum.openAlbum(root.albumId)
}
}
}
Text {
width: parent.width
height: 18
text: root.artist
color: root.artistId !== "" && artistArea.containsMouse
? theme.textPrimary : theme.textMuted
font.pixelSize: theme.fontLink - 1
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
MouseArea {
id: artistArea
anchors.fill: parent
hoverEnabled: true
cursorShape: root.artistId !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (root.artistId !== "") QbzArtist.openArtist(root.artistId)
}
}
// Play count — Most Played Albums only (see `plays`).
Text {
visible: root.plays > 0
width: parent.width
height: visible ? 18 : 0
text: QbzSession.tr("{} plays", QbzSession.trRev).replace("{}", root.plays)
color: theme.textMuted
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
// Icon-only quality badge (QualityBadge.slint).
QualityMini {
id: qBadge
tier: root.qualityTier
anchors.verticalCenter: parent.verticalCenter
}
}
}
}