library_bridge.rs has sat in the tree unreferenced since the phase-23 work stopped: no mod declaration, not in build.rs, zero call sites, and duplicating members that were still live in bridge.rs. It could not have been wired as it stood — its module was named qbz_library, which is ambiguous with the qbz-library crate this binary depends on, so it never compiled. The module is now qbz_library_bridge; the QML type name comes from the QObject, so QML is unaffected. Registering it and deleting the duplicates had to happen together. Wiring it alone would have compiled cleanly and produced two independent Library states — QML reading one object while Rust published to the other — with the view simply frozen and nothing in the log. Moved with it: four properties, five invokables, three signals, the seven library-only ui() hops in main.rs, and thirty-four QML references. The Connections blocks in LibraryView, Sidebar, QueuePanel, AlbumView and PlaylistCollage retarget wholesale because every handler in them is a library signal. ArtistView's does not: it mixes onLibraryArtworkReady with onReleaseSectionReady, so it becomes two blocks — retargeting it whole would have orphaned the releases pager, and QML resolves handlers lazily enough that the discography would just have stopped loading with nothing in the log. bridge.rs is down to 389 lines and five domains. cc
250 lines
11 KiB
QML
250 lines
11 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).
|
|
|
|
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. 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.
|
|
property string artworkUrl: (root.item && root.item.imageUrl)
|
|
? root.item.imageUrl
|
|
: (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
|
|
|
|
color: "transparent"
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
readonly property bool overlayOn: plArtArea.containsMouse || pinArea.containsMouse
|
|
|| plFav.hovered || plPlay.hovered || plMore.hovered
|
|
|
|
implicitWidth: 200
|
|
implicitHeight: 246
|
|
|
|
Column {
|
|
spacing: 0
|
|
Rectangle {
|
|
width: 200
|
|
height: 200
|
|
radius: theme.radiusSm
|
|
color: theme.surfaceElevated
|
|
clip: true
|
|
// Arm 1 — a resolved single cover. Contain for the playlist's
|
|
// own (landscape) Qobuz graphic, crop for everything else.
|
|
RoundedImage {
|
|
anchors.fill: parent
|
|
visible: root.artSource !== ""
|
|
source: root.artSource
|
|
radius: theme.radiusSm
|
|
fit: root.ownImage ? "contain" : "crop"
|
|
}
|
|
// 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.
|
|
PlaylistCollage {
|
|
anchors.fill: parent
|
|
visible: root.artSource === ""
|
|
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
|
|
// body-opens (every Slint mount).
|
|
onClicked: 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.item.playlistOwned ? "heart"
|
|
: root.item.playlistFollowing ? "check" : "user-plus"
|
|
active: root.item.playlistOwned === true && root.item.isFavorite === true
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: {
|
|
if (root.item.playlistOwned) {
|
|
root.item.isFavorite = !root.item.isFavorite
|
|
QbzLibrary.libraryToggleFavorite("playlist", root.item.id)
|
|
} else {
|
|
root.item.playlistFollowing = !root.item.playlistFollowing
|
|
QbzBridge.playlistSetFollowById(root.item.id, root.item.playlistFollowing)
|
|
}
|
|
}
|
|
}
|
|
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) { plMenu.openAtCursor(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
|
|
tintName: root.isPinned ? "accent" : "primary"
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
CardMenu {
|
|
id: plMenu
|
|
menuWidth: 196
|
|
// PlaylistCard.slint's menu: queueing actions + favorite
|
|
// (owned) / follow (foreign).
|
|
entries: [
|
|
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
|
|
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "play-next" },
|
|
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "play-later" },
|
|
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
|
|
{ "label": root.item.playlistOwned
|
|
? (root.item.isFavorite ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev))
|
|
: (root.item.playlistFollowing ? QbzSession.tr("Unfollow on Qobuz", QbzSession.trRev) : QbzSession.tr("Follow on Qobuz", QbzSession.trRev)),
|
|
"icon": root.item.playlistOwned ? (root.item.isFavorite ? "heart-filled" : "heart")
|
|
: (root.item.playlistFollowing ? "check" : "user-plus"),
|
|
"action": "favorite" },
|
|
]
|
|
onPicked: function (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") {
|
|
if (root.item.playlistOwned) {
|
|
root.item.isFavorite = !root.item.isFavorite
|
|
QbzLibrary.libraryToggleFavorite("playlist", root.item.id)
|
|
} else {
|
|
root.item.playlistFollowing = !root.item.playlistFollowing
|
|
QbzBridge.playlistSetFollowById(root.item.id, root.item.playlistFollowing)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|