359 lines
16 KiB
QML
359 lines
16 KiB
QML
// ArtistCard — THE artist card (discover/ArtistGridCard.slint), shared by
|
|
// every surface since phase 21: Home/Editor/ForYou artist rails, the
|
|
// Pinned rail, the Library artist grids + All feed, the Search all-tab
|
|
// carousels + artists grid + most-popular artist hero. (The Slint mounts
|
|
// ArtistGridCard at 200x246 in ALL of these — the POC's old 160x220
|
|
// Home/Search cards replicated the legacy ArtistCard the Slint only uses
|
|
// on surfaces the POC does not have.)
|
|
//
|
|
// 200x246: 200px surface-card frame + 190px circle (gradient + user glyph
|
|
// until the artwork resolves), hover scrim 0.55 CLIPPED TO THE CIRCLE,
|
|
// overlay row at y=113 (follow? / play / more), pin badge top-right of
|
|
// the frame, then the meta block (centered name, optional subtitle).
|
|
//
|
|
// item contract: { id, title, subtitle, following | isFavorite } plus the
|
|
// scalar artSource / artworkUrl / isPinned props (the AlbumCard pattern).
|
|
// The follow flag arrives under TWO names because the producers spell it
|
|
// differently and both mean the same row state: `following` (search_qt
|
|
// ArtistRow, derived from fav_cache) and `isFavorite` (home_qt HomeCard /
|
|
// library_qt FeedItem — for an artist, "in the library" IS "followed", they
|
|
// are the same `favorite/create?artist_ids=` write).
|
|
//
|
|
// Arms:
|
|
// - followMode: "toggle" (default, = ArtistGridCard.slint's own default) |
|
|
// "none" — the overlay follow button + the menu Follow row. The Slint
|
|
// reference passes "none" from exactly two places: the label page's
|
|
// artist carousel (LabelPageView.slint:524) and the Library artist grids
|
|
// (FavoritesView.slint:1831/1865). Everything else shows it.
|
|
// - followKind: "artist" (default) | "label" — ArtistGridCard.slint's
|
|
// `follow-kind`, the kind the follow write is sent as.
|
|
// - subtitle: meta switches to 1-line name + muted subtitle (the Slint
|
|
// "Similar to…"/search arm); empty = wrap-2 name.
|
|
//
|
|
// --- Menu inventory vs discover/ArtistGridCard.slint -------------------
|
|
// Open artist · Play · Follow/Following (show-follow) · Not interested
|
|
// All four are live; "Not interested" rides `hasNotInterestedSeam` (default
|
|
// on) and writes the reco-dismissal store through QbzBlacklist.
|
|
// ⋯ and a right press on the portrait or the name open the same menu.
|
|
|
|
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: ""
|
|
// Pinned state (AlbumCard pattern: scalar prop, optimistic flip on
|
|
// click — the model re-publish re-creates the delegate).
|
|
property bool isPinned: false
|
|
property string followMode: "toggle"
|
|
property string followKind: "artist"
|
|
|
|
// --- "Not interested" (the reco-scoped dismissal) --------------------
|
|
// `dismissArtist(id, name, imageUrl)` writes the reco-dismissal store (NOT
|
|
// the artist blacklist). Kept as a property, not inlined, so a host whose
|
|
// `item.id` is not a Qobuz artist id can turn it off.
|
|
//
|
|
// The card now LEAVES THE RAIL ON THE CLICK: `blacklist_qt::dismiss_artist`
|
|
// calls `recommendations_qt::apply_dismissal`, which drops it from the
|
|
// cached rails and republishes. What this comment used to promise — "the
|
|
// card leaves the rails on the next publish" — never happened, because
|
|
// nothing read the dismissal store on publish either; the artist came
|
|
// straight back. The ids are part of the reco exclusion set now.
|
|
//
|
|
// OWNER RULING (2026-08-16): the live drop is for the RECOMMENDATIONS
|
|
// rails and nothing else. "This is not a blacklist or a general hide,
|
|
// only for recommendations." This card is also mounted by Search, Label,
|
|
// Artist Scene, Home and the Library grid; dismissing from one of those
|
|
// persists and takes effect in Recommendations, but the card you clicked
|
|
// stays where it is — deliberately. Do NOT "finish" this by making the
|
|
// artist vanish from Search or Library; hiding an artist app-wide is the
|
|
// BLACKLIST, a different store and a different row.
|
|
//
|
|
// `hasFollowSeam` is GONE, and the header comment it carried ("There is
|
|
// NO artist-follow invokable on the Qt bridge") was simply wrong:
|
|
// `library_bridge.rs` declares `library_toggle_favorite(kind, id)`,
|
|
// `library_qt::toggle_favorite` routes "artist" (and "label") straight to
|
|
// `add_favorite` / `remove_favorite`, and ArtistView's own header button
|
|
// has been calling it all along. The constant made the follow affordance
|
|
// disappear from EVERY artist card in the app.
|
|
property bool hasNotInterestedSeam: true
|
|
|
|
color: "transparent"
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
// ArtistGridCard.slint:89 — `show-follow: follow-mode != "none"`.
|
|
readonly property bool showFollow: followMode !== "none"
|
|
readonly property bool overlayOn: agArea.containsMouse || pinArea.containsMouse
|
|
|| agFollow.hovered || agPlay.hovered || agMore.hovered
|
|
|
|
implicitWidth: 200
|
|
implicitHeight: 246
|
|
|
|
// --- Follow state (a real QML property, never `item.following`) ------
|
|
// Same reason as rows/TrackRow.qml's `favorite`: `item` is a plain JS
|
|
// object, so mutating a field on it fires no notifier and re-evaluates no
|
|
// binding — and `item: modelData` is a COPY, so the write does not reach
|
|
// the model either (both measured under qml6 6.11.1). The seed reads both
|
|
// spellings the producers publish (see the header note).
|
|
property bool following: root.item.following === true
|
|
|| root.item.isFavorite === true
|
|
onItemChanged: root.following = Qt.binding(function () {
|
|
return root.item.following === true || root.item.isFavorite === true
|
|
})
|
|
|
|
function toggleFollow() {
|
|
root.following = !root.following
|
|
QbzLibrary.libraryToggleFavorite(root.followKind, root.item.id)
|
|
}
|
|
|
|
function openMenu(anchor, x, y) {
|
|
agMenuLoader.active = true
|
|
agMenuLoader.item.openAtCursor(anchor, x, y)
|
|
}
|
|
function releaseForReuse() {
|
|
if (agMenuLoader.item)
|
|
agMenuLoader.item.close()
|
|
agMenuLoader.active = false
|
|
}
|
|
|
|
Connections {
|
|
target: QbzLibrary
|
|
// Pin fan-out — the AlbumCard contract, artist key (see AlbumCard.qml:
|
|
// the store has no change-notify, so this signal is what keeps every
|
|
// OTHER card showing this artist honest without a republish).
|
|
function onPinChanged(key, value) {
|
|
var aid = (root.item && root.item.id !== undefined) ? root.item.id : ""
|
|
if (aid !== "" && key === "artist:" + aid)
|
|
root.isPinned = value
|
|
}
|
|
// Follow fan-out + rollback, the favourite twin of the above. The key
|
|
// is `{followKind}:{id}` so a label-follow card settles on its own
|
|
// key rather than on an artist one that happens to share the number.
|
|
function onLibraryFavoriteChanged(key, value) {
|
|
var aid = (root.item && root.item.id !== undefined) ? root.item.id : ""
|
|
if (aid !== "" && key === root.followKind + ":" + aid)
|
|
root.following = value
|
|
}
|
|
}
|
|
|
|
Column {
|
|
spacing: 0
|
|
Rectangle {
|
|
width: 200
|
|
height: 200
|
|
radius: theme.radiusSm
|
|
color: theme.surfaceCard
|
|
|
|
// 190px round portrait, centered (5px surround = the
|
|
// album-tile frame). All overlay content lives inside so it
|
|
// clips to the circle.
|
|
Rectangle {
|
|
x: 5
|
|
y: 5
|
|
width: 190
|
|
height: 190
|
|
radius: 95
|
|
// No clip. It never made the circle — QML's clip is a
|
|
// rectangular scissor and ignores `radius`
|
|
// (qquickclipnode.cpp:13-22); the round shape comes from each
|
|
// child's own `radius` / RoundedImage mask. Geometrically
|
|
// nothing leaves the 190x190 box.
|
|
gradient: Gradient {
|
|
orientation: Gradient.Horizontal
|
|
GradientStop { position: 0.0; color: theme.surfaceElevated }
|
|
GradientStop { position: 1.0; color: theme.surfaceCard }
|
|
}
|
|
// Placeholder glyph until the artwork resolves on top.
|
|
QbzIcon {
|
|
name: "user"
|
|
width: 54
|
|
height: 54
|
|
anchors.centerIn: parent
|
|
tintName: "muted"
|
|
}
|
|
RoundedImage {
|
|
anchors.fill: parent
|
|
source: root.artSource
|
|
radius: 95
|
|
}
|
|
// Hover scrim (clipped to the circle by the parent's clip).
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
radius: 95
|
|
color: "#000000"
|
|
opacity: root.overlayOn ? 0.55 : 0.0
|
|
Behavior on opacity { NumberAnimation { duration: 150 } }
|
|
}
|
|
// Card-open + hover detector (before the buttons so they
|
|
// win the pointer).
|
|
MouseArea {
|
|
id: agArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
|
onClicked: function (mouse) {
|
|
if (mouse.button === Qt.RightButton)
|
|
root.openMenu(agArea, mouse.x, mouse.y)
|
|
else
|
|
QbzArtist.openArtist(root.item.id)
|
|
}
|
|
}
|
|
// Hover overlay — follow? / play / more (y=113).
|
|
CardOverlayRow {
|
|
y: 113
|
|
width: parent.width
|
|
shown: root.overlayOn
|
|
CardOverlayButton {
|
|
id: agFollow
|
|
visible: root.showFollow
|
|
name: root.following ? "check" : "user-plus"
|
|
active: root.following
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: root.toggleFollow()
|
|
}
|
|
CardOverlayButton {
|
|
id: agPlay
|
|
name: "play-fill"
|
|
primary: true
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: QbzPlayer.playArtistCard(root.item.id)
|
|
}
|
|
CardOverlayButton {
|
|
id: agMore
|
|
name: "ellipsis"
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: function (mouse) { root.openMenu(agMore, mouse.x, mouse.y) }
|
|
}
|
|
}
|
|
Loader {
|
|
id: agMenuLoader
|
|
active: false
|
|
sourceComponent: CardMenu {
|
|
menuWidth: 196
|
|
entries: root.menuModel()
|
|
onPicked: function (a) { root.menuAction(a) }
|
|
}
|
|
}
|
|
}
|
|
// Pin badge — top-right of the FRAME (outside the circle clip),
|
|
// opacity follows overlay-on (the AlbumCard convention).
|
|
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
|
|
// 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.isPinned = !root.isPinned
|
|
QbzLibrary.togglePin("artist", root.item.id, root.item.title,
|
|
root.item.subtitle || "", root.artworkUrl)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Item { width: 1; height: 6 }
|
|
// Meta: centered name; subtitle arm switches to 1-line + muted row.
|
|
Column {
|
|
width: 200
|
|
height: 40
|
|
spacing: 2
|
|
Text {
|
|
width: parent.width
|
|
height: root.item.subtitle ? 20 : 40
|
|
text: root.item.title || ""
|
|
color: agNameArea.containsMouse ? theme.accent : theme.textPrimary
|
|
font.pixelSize: theme.fontBody - 2
|
|
font.weight: theme.weightMedium
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
wrapMode: root.item.subtitle ? Text.NoWrap : Text.WordWrap
|
|
maximumLineCount: 2
|
|
elide: Text.ElideRight
|
|
MouseArea {
|
|
id: agNameArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
|
onClicked: function (mouse) {
|
|
if (mouse.button === Qt.RightButton)
|
|
root.openMenu(agNameArea, mouse.x, mouse.y)
|
|
else
|
|
QbzArtist.openArtist(root.item.id)
|
|
}
|
|
}
|
|
}
|
|
Text {
|
|
visible: !!root.item.subtitle
|
|
width: parent.width
|
|
height: 16
|
|
text: root.item.subtitle || ""
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLink - 1
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
}
|
|
|
|
// ArtistGridCard.slint's artist-menu, in its order.
|
|
function menuModel() {
|
|
var t = QbzSession.tr
|
|
var r = QbzSession.trRev
|
|
var m = [
|
|
{ "label": t("Open artist", r), "icon": "user", "action": "open" },
|
|
{ "label": t("Play", r), "icon": "play-fill", "action": "play" },
|
|
]
|
|
if (root.showFollow)
|
|
m.push({ "label": root.following ? t("Following", r) : t("Follow", r),
|
|
"icon": root.following ? "check" : "user-plus", "action": "follow" })
|
|
// Reco-scoped dismissal (NOT the blacklist) — the artist leaves the
|
|
// Recommendations rails only.
|
|
if (root.hasNotInterestedSeam)
|
|
m.push({ "label": t("Not interested", r), "icon": "thumbs-down", "action": "not-interested" })
|
|
return m
|
|
}
|
|
|
|
function menuAction(a) {
|
|
if (a === "open") QbzArtist.openArtist(root.item.id)
|
|
else if (a === "play") QbzPlayer.playArtistCard(root.item.id)
|
|
else if (a === "follow") root.toggleFollow()
|
|
// THREE args: the store persists (id, name, image_url) and only
|
|
// backfills a field that is EMPTY, so a blank first write can never be
|
|
// repaired later. `artworkUrl` first because that is the host-
|
|
// normalized REMOTE url (some producers spell the key `imageUrl`, e.g.
|
|
// LibraryView) — `item.artUrl` is the HomeCard fallback, and never
|
|
// `artSource`, which is a local file:// cache path.
|
|
else if (a === "not-interested")
|
|
QbzBlacklist.dismissArtist(root.item.id,
|
|
root.item.name || root.item.title || "",
|
|
root.artworkUrl !== "" ? root.artworkUrl : (root.item.artUrl || ""))
|
|
}
|
|
}
|