Files
qbz/crates/qbz-qt/qml/cards/AlbumCard.qml
T
vicrodh 9a4fec6b70 refactor(qbz-qt): phase 23 part 1 — QML module layout + first five domain bridges
Two fronts of the modularization, both gated green before this commit.

QML leaves the flat qml/ directory for seven modules: cards, controls,
rows, settings, shell, theme, views. Only Main.qml and LoginScreen.qml
stay at the root. build.rs matches the tree exactly (55 files), relative
imports and asset paths were re-anchored for the new depth.

The QbzBridge God-object starts breaking up: QbzSession, QbzShell,
QbzPlayer, QbzQueue and QbzHome are now their own #[cxx_qt::bridge]
singletons, each with its own OnceLock<CxxQtThread> and ui() hop, booted
in order from Main.qml. Bridge files stay directly under src/ because
cxx-qt-build only accepts rust_files from one directory (QTBUG-93443).

bridge.rs keeps 5.5 domains (library, album/artist, settings,
integrations, search, playlist) and dies once they are split out.

cc
2026-07-28 11:08:20 -06:00

404 lines
16 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 (Play / Play next /
// Add to queue wired through playback_qt; favorite wired; Open album +
// Block inert — POC-NOTEs: no album page / blacklist store).
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: ""
QbzTheme { id: theme }
width: 200
height: 246
color: "transparent"
readonly property bool overlayOn: artArea.containsMouse || pinArea.containsMouse
|| favArea.containsMouse || playArea.containsMouse || moreArea.containsMouse
function toggleFavorite() {
root.isFavorite = !root.isFavorite
QbzBridge.libraryToggleFavorite("album", root.albumId)
}
function togglePin() {
root.isPinned = !root.isPinned
QbzBridge.togglePin("album", root.albumId, root.title, root.artist, "")
}
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
// Phase 8: the card opens the album view (the overlay play
// button carries the play affordance).
onClicked: QbzBridge.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 {
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
tintName: root.isPinned ? "accent" : "primary"
}
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).
Row {
y: 120
width: parent.width
height: 44
anchors.horizontalCenter: parent.horizontalCenter
spacing: 12
opacity: root.overlayOn ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
Item { width: (200 - 44 - 2 * 36 - 2 * 12) / 2; height: 1 }
Rectangle {
width: 36
height: 36
radius: 18
anchors.verticalCenter: parent.verticalCenter
color: favArea.containsMouse ? "#3dffffff" : "#24ffffff"
border.width: 1.5
border.color: "#ccffffff"
QbzIcon {
name: root.isFavorite ? "heart-filled" : "heart"
width: 16
height: 16
anchors.centerIn: parent
tintName: root.isFavorite ? "favorite" : "primary"
}
MouseArea {
id: favArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.toggleFavorite()
}
}
Rectangle {
width: 44
height: 44
radius: 22
color: playArea.containsMouse ? "#d6ffffff" : "#ffffff"
QbzIcon {
name: "play-fill"
width: 18
height: 18
anchors.centerIn: parent
tintName: "black"
}
MouseArea {
id: playArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzPlayer.playAlbum(root.albumId)
}
}
Rectangle {
width: 36
height: 36
radius: 18
anchors.verticalCenter: parent.verticalCenter
color: moreArea.containsMouse ? "#3dffffff" : "#24ffffff"
border.width: 1.5
border.color: "#ccffffff"
QbzIcon {
name: "ellipsis"
width: 16
height: 16
anchors.centerIn: parent
tintName: "primary"
}
MouseArea {
id: moreArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: function (mouse) { albumMenu.openAtCursor(moreArea, mouse.x, mouse.y) }
}
}
Item { width: (200 - 44 - 2 * 36 - 2 * 12) / 2; height: 1 }
}
// Context menu (AlbumCard.slint's album-menu): 196px, items
// 33px, icon 15 + label 13.
QbzContextMenu {
id: albumMenu
menuWidth: 196
Repeater {
model: [
{ "label": QbzSession.tr("Open album", QbzSession.trRev), "icon": "library-big", "action": "open" },
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-plus", "action": "next" },
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
{ "label": root.isFavorite ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev), "icon": root.isFavorite ? "heart-filled" : "heart", "action": "favorite" },
{ "label": QbzSession.tr("Block this album", QbzSession.trRev), "icon": "blind-eye", "action": "block" },
]
delegate: Rectangle {
required property var modelData
width: parent ? parent.width : 0
height: 33
radius: 5
color: miArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
spacing: 8
QbzIcon {
name: modelData.icon
width: 15
height: 15
anchors.verticalCenter: parent.verticalCenter
tintName: "secondary"
}
Text {
height: parent.height
width: parent.width - 23
text: modelData.label
color: theme.textSecondary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
MouseArea {
id: miArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
albumMenu.close()
var a = modelData.action
if (a === "open") QbzBridge.openAlbum(root.albumId)
else if (a === "play") QbzPlayer.playAlbum(root.albumId)
else if (a === "next") QbzPlayer.enqueueAlbum(root.albumId, "next")
else if (a === "queue") QbzPlayer.enqueueAlbum(root.albumId, "later")
else if (a === "favorite") root.toggleFavorite()
// "block" (no blacklist store): inert —
// POC-NOTE.
}
}
}
}
}
// 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"
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
tintName: root.source === "plex" ? "accent" : "primary"
}
}
}
Item { width: 1; height: 6 }
// --- Title / artist + quality badge ------------------------------
Row {
width: 200
height: 40
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
onClicked: QbzBridge.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 !== "") QbzBridge.openArtist(root.artistId)
}
}
}
// Icon-only quality badge (QualityBadge.slint).
QualityMini {
id: qBadge
tier: root.qualityTier
anchors.verticalCenter: parent.verticalCenter
}
}
}
}