Files
qbz/crates/qbz-qt/qml/cards/TrackCard.qml
T

327 lines
14 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
// Same pulled-track contract as rows/TrackRow.qml. A catalog withdrawal
// is inert, except when the user already owns a complete offline copy.
// `cacheStatus` is a live scalar because `item` is a plain JS snapshot.
readonly property bool pulled: root.item.qobuzUnavailable === true
property int cacheStatus: root.item.cacheStatus !== undefined
? root.item.cacheStatus : 0
readonly property bool pulledDead: root.pulled && root.cacheStatus !== 3
implicitWidth: 200
implicitHeight: 246
// --- Heart state (a real QML property, never `item.isFavorite`) ------
// Identical reasoning to rows/TrackRow.qml: `item` is a plain JS object
// copied out of `modelData`, so mutating a field on it fires no notifier
// (the glyph never changed) and reaches no model. The binding is
// re-established on every new row object so the document stays
// authoritative.
property bool favorite: root.item.isFavorite === true
onItemChanged: {
root.favorite = Qt.binding(function () {
return root.item.isFavorite === true
})
root.cacheStatus = Qt.binding(function () {
return root.item.cacheStatus !== undefined
? root.item.cacheStatus : 0
})
}
function toggleFavorite() {
root.favorite = !root.favorite
QbzLibrary.libraryToggleFavorite("track", root.item.id)
}
function openMenu(anchor, x, y) {
if (root.menuModel().length === 0)
return
trackMenuLoader.active = true
trackMenuLoader.item.openAtCursor(anchor, x, y)
}
function playNow() {
if (!root.pulledDead)
QbzPlayer.playTrack(root.item.id)
}
function releaseForReuse() {
if (trackMenuLoader.item)
trackMenuLoader.item.close()
trackMenuLoader.active = false
}
// Fan-out + rollback (the shape cards/AlbumCard.qml uses for pin).
Connections {
target: QbzLibrary
function onLibraryFavoriteChanged(key, value) {
var tid = (root.item && root.item.id !== undefined) ? root.item.id : ""
if (tid !== "" && key === "track:" + tid)
root.favorite = value
}
}
Connections {
target: QbzShell
function onTrackCacheStatusChanged(trackId, status, progress) {
if (trackId === (root.item.id || ""))
root.cacheStatus = status
}
}
Column {
spacing: 0
Rectangle {
width: 200
height: 200
radius: theme.radiusSm
color: theme.surfaceElevated
// No clip: the child RoundedImage confines itself on both arms, and
// a rectangular scissor never produced this radius. A clip is an
// unconditional batch root, so this one cost a draw call per item.
RoundedImage {
anchors.fill: parent
source: root.artSource
radius: theme.radiusSm
}
Rectangle {
anchors.fill: parent
radius: theme.radiusSm
color: "#000000"
opacity: root.overlayOn && !root.pulledDead ? 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: root.pulledDead ? Qt.ArrowCursor : Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton)
root.openMenu(tcArtArea, mouse.x, mouse.y)
else
root.playNow()
}
}
// Hover overlay — favorite / play / more (y=120, h=44,
// centered, spacing 12).
CardOverlayRow {
y: 120
width: parent.width
shown: root.overlayOn && !root.pulledDead
CardOverlayButton {
id: favBtn
name: root.favorite ? "heart-filled" : "heart"
active: root.favorite
anchors.verticalCenter: parent.verticalCenter
onClicked: root.toggleFavorite()
}
CardOverlayButton {
id: playBtn
name: "play-fill"
primary: true
anchors.verticalCenter: parent.verticalCenter
onClicked: root.playNow()
}
CardOverlayButton {
id: moreBtn
name: "ellipsis"
anchors.verticalCenter: parent.verticalCenter
onClicked: function (mouse) { root.openMenu(moreBtn, mouse.x, mouse.y) }
}
}
// Source badge (All feed, show-local): bottom-right of the art.
// 24x24 rounded SQUARE (ADR-008: not a pill), 6px inset —
// discover/TrackCard.slint:177-197. Simpler than the album card's:
// ONE chip colour (#000000b3 -> Qt #b3000000) and no purchase arm.
//
// The glyph goes through controls/SourceIcon.qml, never QbzIcon:
// the Plex and Qobuz marks are MULTI-COLOUR and a tint flattens
// them to a silhouette (this card used to draw an accent-tinted
// `hard-drive` for Plex — a blue hard drive).
Rectangle {
visible: root.showSourceBadge && (root.item.source || "") !== ""
x: parent.width - width - 6
y: parent.height - height - 6
width: 24
height: 24
radius: 4
color: "#b3000000"
SourceIcon {
// `sourceRaw` FIRST (contract §D.1): local rows carry
// `qobuz_purchase` there, and `source` folds it into
// "offline" for the source chips. Feed rows publish no
// `sourceRaw` at all, so they fall through unchanged.
kind: root.item.sourceRaw || root.item.source || ""
// .slint:191-193 — plex 16, qobuz 18, local 14. "offline"
// is the Qt word for a Qobuz download (local_rows.rs
// `badge_source`); the .slint's local-library track rows
// reach the same arm spelled "qobuz" (local_library.rs:1047).
glyphSize: 14
plexSize: 16
qobuzSize: 18
// .slint:190 — #ffffff on the near-black chip.
localTint: "white"
// Explicit pixel rounding (.slint:194-195).
x: Math.round((parent.width - width) / 2)
y: Math.round((parent.height - height) / 2)
}
}
// Honest dead-card state. The scrim sits above the hover controls
// (which are not mounted visually in this state) and carries a
// translated label instead of relying on a barely perceptible dim.
Rectangle {
visible: root.pulledDead
anchors.fill: parent
radius: theme.radiusSm
color: theme.alphaTier(60)
Text {
anchors.centerIn: parent
width: parent.width - 20
text: QbzSession.tr("Unavailable", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
}
Loader {
id: trackMenuLoader
active: false
sourceComponent: CardMenu {
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
opacity: root.pulledDead ? 0.5 : 1.0
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 && !root.pulledDead
? 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: root.pulledDead ? Qt.ArrowCursor : Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton)
root.openMenu(tcTitleArea, mouse.x, mouse.y)
else
root.playNow()
}
}
}
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 = []
if (!root.pulledDead) {
m.push({ "label": t("Play", r), "icon": "play-fill", "action": "play" })
m.push({ "label": t("Play next", r), "icon": "list-start", "action": "next" })
m.push({ "label": t("Play later", r), "icon": "list-plus", "action": "later" })
m.push({ "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" })
if (!root.pulledDead)
m.push({ "label": root.favorite ? t("Remove from Library", r) : t("Add to Library", r),
"icon": root.favorite ? "heart-filled" : "heart", "action": "favorite" })
return m
}
function trackAction(a) {
if ((a === "play" || a === "next" || a === "later" || a === "queue")
&& root.pulledDead)
return
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.toggleFavorite()
}
}