Files
qbz/crates/qbz-qt/qml/cards/PlaylistCard.qml
T
vicrodh 1ba578a1d8 feat(qt): move a playlist to a folder from its editor, delete one from any context menu
The editor gains a Folder row — the same folders the sidebar and the
manager file playlists into, "No folder" for the root — seeded from the
playlist's current folder and applied on Save through the manager's own
move verb, so an existing playlist no longer needs the sidebar's row menu
to be filed (#785). "Delete playlist" joins the sidebar row menu (own and
local playlists; sidebar entries now carry isOwner) and the playlist card
menu (own playlists), behind a shell-level "Delete playlist?" confirmation
that runs the same delete the editor's button runs and leaves the detail
page only when it was the deleted one (#776). Eight catalogs updated.

cc
2026-09-13 14:47:12 -06:00

426 lines
20 KiB
QML

// PlaylistCard — THE playlist card (discover/PlaylistCard.slint), shared
// by every surface since phase 21: Home/Editor/ForYou playlist rails, the
// Pinned rail, the Library playlist grid + All feed, the Search all-tab
// carousel + playlists grid.
//
// 200x246: 200px cover (Radius.sm) + hover scrim 0.6, overlay row at
// y=120 (fav tri-state / play / more), pin badge top-right, then the
// left-aligned meta: semibold title + (category accent eyebrow | muted
// subtitle). Body click OPENS the playlist (the Slint body-opens arm —
// every Slint mount sets it).
//
// item contract: { id, title, subtitle, category, playlistOwned,
// playlistFollowing, isFavorite } plus the scalar artSource / artworkUrl
// / isPinned props (the AlbumCard pattern).
//
// Artwork, two arms (1:1 with Tauri, which has TWO playlist cards):
// - `item.playlistOwnImage` — the playlist carries its OWN Qobuz graphic
// (`image_rectangle`). Those are landscape and cropping butchers them,
// so they render CONTAIN (QobuzPlaylistCard: `object-fit: contain`).
// - otherwise — the mosaic of member-track covers from `item.covers`
// (FavoritePlaylistCard -> PlaylistCollage). Rows without either get
// the collage's list-music placeholder.
// Surfaces that publish neither field (Home rails, Search) keep the single
// crop-fitted cover they already passed through `artSource`.
//
// Ownership tri-state (Slint): owned -> heart (library favorite);
// foreign followed -> check; foreign -> user-plus (Qobuz follow).
//
// SETTLE ASYMMETRY, deliberate and not a hole this file can close: the
// OWNED arm settles on `QbzLibrary.libraryFavoriteChanged` (rollback on a
// failed write included). The FOREIGN follow arm has no signal at all —
// `playlist_qt::set_follow_by_id` logs the error and otherwise settles only
// the caches (`follow_settled`: the ownership snapshot, the sidebar tree and
// the Library feed), so a FAILED subscribe leaves the optimistic tick up
// until the card's document is republished. Closing it needs a Rust-side
// settle emit, not a QML change.
//
// Offline the foreign arm is a WRITE that cannot land (`playlist/subscribe`
// is gate-refused), and this card offers it anyway — the detail header now
// disables its twin (`views/PlaylistView.qml`, `online`). Not fixed here
// because the same control also carries the OWNED heart, which is a local
// library.db write and MUST stay live offline; splitting the enablement is a
// change to this card's contract, not a one-liner.
//
// --- Menu inventory vs discover/PlaylistCard.slint ---------------------
// Play · Play next · Play later · Add to queue ·
// Add to Library (is-owned) | Follow/Unfollow on Qobuz (!is-owned) ·
// Add to mixtape · Make available offline (QoL round additions over the
// .slint inventory) · Copy to your library (!is-owned && !is-copied)
// All live except the last — see `hasCopyByIdSeam`. The ⋯ button and a
// right press on the cover or the title open the same menu.
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../theme"
Rectangle {
id: root
readonly property bool artworkReady: root.artSource !== "" && artworkLoader.item
? artworkLoader.item.ready : false
readonly property bool artworkImmediate: root.artSource !== "" && artworkLoader.item
? artworkLoader.item.immediateGridArtwork : false
property var item: ({})
// Host-resolved artwork path (the AlbumCard artSource pattern).
property string artSource: ""
// Remote cover URL for the pin payload. Defaults to whatever the row
// carries — the playlist's own graphic, else its first member cover —
// so a pinned playlist keeps art in the Pinned rail; hosts that resolve
// it themselves may still override.
// `artUrl` is the Home / Search / Browse row field (home_qt::HomeCard),
// `imageUrl` the Library one — without both arms a playlist pinned from
// a Discover rail stored an EMPTY snapshot url and landed in the Pinned
// rail as a permanent placeholder.
property string artworkUrl: (root.item && root.item.imageUrl)
? root.item.imageUrl
: ((root.item && root.item.artUrl)
? root.item.artUrl
: (root.collageUrls.length > 0 ? root.collageUrls[0] : ""))
// Member-track covers for the mosaic arm ([] on surfaces that publish a
// single pre-resolved cover instead).
readonly property var collageUrls: (root.item && root.item.covers)
? root.item.covers : []
// The row's image is the playlist's own Qobuz graphic -> contain.
readonly property bool ownImage: root.item
? root.item.playlistOwnImage === true : false
// Pinned state (AlbumCard pattern: scalar prop, optimistic flip on
// click — the model re-publish re-creates the delegate).
property bool isPinned: false
// "Copy to your library": QbzBridge.playlistCopy() copies the playlist
// that is currently OPEN — it takes no id, so a card cannot call it. The
// row stays out of the menu until a by-id copy invokable exists (and
// `item.playlistCopied` is published for the .slint's is-copied gate).
readonly property bool hasCopyByIdSeam: false
color: "transparent"
QbzTheme { id: theme }
readonly property bool overlayOn: plArtArea.containsMouse || pinArea.containsMouse
|| plFav.hovered || plPlay.hovered || plMore.hovered
implicitWidth: 200
implicitHeight: 246
// --- The tri-state's two mutable halves, as REAL QML properties ------
// They used to be `root.item.isFavorite` / `root.item.playlistFollowing`,
// mutated in place on click. That is a plain JS object: no notifier, so
// the glyph binding never re-evaluated, and `item: modelData` is a COPY,
// so the write reached neither the delegate's model row nor the host's
// array (both measured under qml6 6.11.1 — see rows/TrackRow.qml). The
// card's overlay therefore never changed on click, on ANY surface.
// `owned` stays a plain read: nothing in the UI can change who owns a
// playlist.
readonly property bool owned: root.item.playlistOwned === true
property bool favorite: root.item.isFavorite === true
property bool following: root.item.playlistFollowing === true
onItemChanged: {
root.favorite = Qt.binding(function () { return root.item.isFavorite === true })
root.following = Qt.binding(function () { return root.item.playlistFollowing === true })
}
/// The overlay button and the menu row — ONE implementation. Owned
/// playlists take the library heart (a qbz-LOCAL flag, library.db); a
/// foreign one takes the Qobuz follow (playlist/subscribe). Different
/// writes, different settle signals — see the Connections below.
function toggleFavorite() {
if (root.owned) {
root.favorite = !root.favorite
QbzLibrary.libraryToggleFavorite("playlist", root.item.id)
} else {
root.following = !root.following
QbzBridge.playlistSetFollowById(root.item.id, root.following)
}
}
function openMenu(anchor, x, y) {
plMenuLoader.active = true
plMenuLoader.item.openAtCursor(anchor, x, y)
}
function releaseForReuse() {
if (plMenuLoader.item)
plMenuLoader.item.close()
plMenuLoader.active = false
}
Connections {
target: QbzLibrary
// Pin fan-out — the AlbumCard contract, playlist key (see AlbumCard.qml).
function onPinChanged(key, value) {
var pid = (root.item && root.item.id !== undefined) ? root.item.id : ""
if (pid !== "" && key === "playlist:" + pid)
root.isPinned = value
}
// Heart fan-out + rollback. Only the OWNED arm settles from here:
// `library_qt::toggle_favorite` routes kind "playlist" to
// `toggle_playlist_favorite` (the library.db flag) and reports the
// result through the shared settle point, so the key is
// `playlist:{id}` exactly like every other kind.
function onLibraryFavoriteChanged(key, value) {
var pid = (root.item && root.item.id !== undefined) ? root.item.id : ""
if (pid !== "" && key === "playlist:" + pid)
root.favorite = value
}
}
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.
// Arm 1 — a resolved single cover.
//
// The playlist's own Qobuz graphic (`image_rectangle`) is a
// 2.11:1 banner — 800x380 on every such file in the local cache.
// Cropping it to this 200px square keeps the middle 380 of 800
// px, i.e. throws away 53% of the width; that is what the owner
// reported, and it is what Slint does too
// (discover/PlaylistCollage.slint's single tile and
// playlist/PlaylistView.slint:481 are both `image-fit: cover`).
// `pad` keeps the ratio and fills the two uncovered bands with a
// gradient sampled from the image's own edges — see
// theme/RoundedImage.qml.
//
// `auto` on the other arm because the flag is not universal:
// only `library_qt.rs` publishes `playlistOwnImage`, while the
// Home / For You / Search / Browse rails map their playlist art
// from `image.rectangle` all the same (`home_qt.rs::map_playlist`)
// with no flag at all. `auto` measures the ratio instead, so
// those surfaces pad too, and a genuinely square cover
// (`image.covers[0]`, the fallback that map picks) still crops.
Loader {
id: artworkLoader
anchors.fill: parent
sourceComponent: root.artSource !== "" ? singleArtwork : collageArtwork
}
Component {
id: singleArtwork
RoundedImage {
gridArtwork: true
source: root.artSource
radius: theme.radiusSm
fit: root.ownImage ? "pad" : "auto"
}
}
// Arm 2 — the member-cover mosaic. A row that HAS its own
// graphic passes no urls, so it shows the placeholder while
// that graphic downloads instead of flashing a mosaic.
Component {
id: collageArtwork
PlaylistCollage {
gridArtwork: true
urls: root.ownImage ? [] : root.collageUrls
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 } }
}
MouseArea {
id: plArtArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
// body-opens (every Slint mount); right press = the ⋯ menu.
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton)
root.openMenu(plArtArea, mouse.x, mouse.y)
else
QbzBridge.openPlaylist(root.item.id)
}
}
// Hover overlay — fav / play / more (y=120, h=44, centered).
CardOverlayRow {
y: 120
width: parent.width
shown: root.overlayOn
CardOverlayButton {
id: plFav
name: root.owned ? "heart"
: root.following ? "check" : "user-plus"
active: root.owned && root.favorite
anchors.verticalCenter: parent.verticalCenter
onClicked: root.toggleFavorite()
}
CardOverlayButton {
id: plPlay
name: "play-fill"
primary: true
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzPlayer.playPlaylistById(root.item.id)
}
CardOverlayButton {
id: plMore
name: "ellipsis"
anchors.verticalCenter: parent.verticalCenter
onClicked: function (mouse) { root.openMenu(plMore, mouse.x, mouse.y) }
}
}
// Pin badge — top-right (opacity follows overlay-on).
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("playlist", root.item.id, root.item.title,
root.item.subtitle || "", root.artworkUrl)
}
}
}
Loader {
id: plMenuLoader
active: false
sourceComponent: CardMenu {
menuWidth: 196
entries: root.menuModel()
onPicked: function (a) { root.menuAction(a) }
}
}
}
Item { width: 1; height: 6 }
// Meta: left-aligned, semibold title + (category eyebrow | subtitle).
Column {
width: 200
height: 40
spacing: 2
leftPadding: theme.spacingXs
Text {
width: parent.width
height: 20
text: root.item.title || ""
color: theme.textPrimary
font.pixelSize: theme.fontBody - 2
font.weight: theme.weightSemibold
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
Text {
visible: !!root.item.category
width: parent.width
height: 16
text: (root.item.category || "").toUpperCase()
color: theme.accent
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
Text {
visible: !root.item.category
width: parent.width
height: 16
text: root.item.subtitle || ""
color: theme.textMuted
font.pixelSize: theme.fontLink - 1
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
}
// Right-press catcher over the meta block (the cover has its own in
// plArtArea). Declared OUTSIDE the Column so it takes no layout space,
// and RightButton-only so left clicks still fall through to the text.
MouseArea {
id: plMetaArea
x: 0
y: 206
width: 200
height: 40
acceptedButtons: Qt.RightButton
onClicked: function (mouse) { root.openMenu(plMetaArea, mouse.x, mouse.y) }
}
// PlaylistCard.slint's menu, in its order.
function menuModel() {
var t = QbzSession.tr
var r = QbzSession.trRev
var owned = root.owned
var following = root.following
var m = [
{ "label": t("Play", r), "icon": "play-fill", "action": "play" },
{ "label": t("Play next", r), "icon": "list-start", "action": "play-next" },
// #442 "Play later" — end of the manual block.
{ "label": t("Play later", r), "icon": "list-plus", "action": "play-later" },
{ "label": t("Add to queue", r), "icon": "list-end", "action": "queue" },
]
if (owned)
m.push({ "label": root.favorite ? t("Remove from Library", r) : t("Add to Library", r),
"icon": root.favorite ? "heart-filled" : "heart", "action": "favorite" })
else
m.push({ "label": following ? t("Unfollow on Qobuz", r) : t("Follow on Qobuz", r),
"icon": following ? "check" : "user-plus", "action": "favorite" })
// QoL round additions over the .slint inventory: the container and
// offline actions every card mount can honour — this card is only
// ever mounted on Qobuz playlist rows (its play/enqueue actions all
// go through the by-id catalog invokables), so neither needs a gate.
m.push({ "label": t("Add to mixtape", r), "icon": "cassette-tape", "action": "mixtape" })
m.push({ "label": t("Make available offline", r), "icon": "cloud-download", "action": "cache" })
if (root.hasCopyByIdSeam && !owned && root.item.playlistCopied !== true)
m.push({ "label": t("Copy to your library", r), "icon": "copy", "action": "copy" })
// Delete (2026-09-13): the fast path from search / library / browse
// cards, behind the shell-level confirmation. Owned playlists only —
// a followed one has Unfollow above.
if (owned)
m.push({ "label": t("Delete playlist", r), "icon": "trash-2", "action": "delete", "danger": true })
return m
}
function menuAction(a) {
if (a === "play") QbzPlayer.playPlaylistById(root.item.id)
else if (a === "play-next") QbzPlayer.enqueuePlaylistById(root.item.id, "next")
else if (a === "play-later") QbzPlayer.enqueuePlaylistById(root.item.id, "later")
else if (a === "queue") QbzPlayer.enqueuePlaylistById(root.item.id, "queue")
else if (a === "favorite") root.toggleFavorite()
else if (a === "mixtape") {
// The payload is assembled at the call site (the SidebarRowMenu /
// PmGridCard idiom). `trackCount` is omitted on purpose — the card
// contract carries no member count and myqbz_add_qt PERSISTS the
// value, so absent (NULL) is the truthful "unknown".
QbzMyQbzAdd.open(JSON.stringify([{
"itemType": "playlist", "source": "qobuz",
"sourceItemId": String(root.item.id),
"title": root.item.title || "",
"subtitle": root.item.subtitle || "",
"artworkUrl": root.artworkUrl || ""
}]))
}
else if (a === "cache") QbzOffline.cachePlaylist(String(root.item.id))
else if (a === "delete") QbzPlaylistEdit.askDelete(String(root.item.id), String(root.item.title || ""))
}
}