From 10461bb49c46c01e8a7515cd38452e8d08e61419 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:48:05 -0600 Subject: [PATCH] =?UTF-8?q?fix(qbz-qt):=20playlist=20artwork=20=E2=80=94?= =?UTF-8?q?=20own=20graphic=20for=20Qobuz,=20collage=20for=20the=20rest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs with one root: the card bound the playlist's `images*` lists, which are MEMBER-ALBUM covers, not the playlist's own artwork. So a Qobuz editorial playlist showed one of its albums' sleeves, and a user playlist — which has no `images*` at all — showed nothing. Qobuz playlists carry their graphic in `image_rectangle`, which the model did not deserialize; the field is added (additive, `serde(default)`, and Playlist is only ever built through serde) and the card prefers it, contain-fitted so the artwork is not cropped. Playlists without one fall back to the member-cover mosaic, reusing the collage the sidebar already draws rather than a second implementation. Both the All-tab grid and its list mode are covered — the 44px list thumb binds the same map and would otherwise have gone blank. cc --- crates/qbz-models/src/types.rs | 8 + crates/qbz-qt/build.rs | 1 + crates/qbz-qt/qml/cards/PlaylistCard.qml | 40 ++- crates/qbz-qt/qml/cards/PlaylistCollage.qml | 276 ++++++++++++++++++++ crates/qbz-qt/qml/views/LibraryView.qml | 17 +- crates/qbz-qt/src/library_qt.rs | 71 ++++- 6 files changed, 405 insertions(+), 8 deletions(-) create mode 100644 crates/qbz-qt/qml/cards/PlaylistCollage.qml diff --git a/crates/qbz-models/src/types.rs b/crates/qbz-models/src/types.rs index e5c35ab46..e566395d0 100644 --- a/crates/qbz-models/src/types.rs +++ b/crates/qbz-models/src/types.rs @@ -604,6 +604,14 @@ pub struct Playlist { pub genres: Option>, pub images150: Option>, pub images300: Option>, + /// The playlist's OWN Qobuz artwork (editorial playlists). The `images*` + /// lists above are MEMBER-ALBUM covers, which is why a Qobuz playlist + /// card binding them shows an album sleeve instead of the playlist + /// graphic. Absent on user playlists, which fall back to the collage. + #[serde(default)] + pub image_rectangle: Option>, + #[serde(default)] + pub image_rectangle_mini: Option>, pub slug: Option, pub users_count: Option, } diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index 48011f0d4..16fa93086 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -43,6 +43,7 @@ fn main() { "qml/cards/ArtistCard.qml", "qml/cards/LabelCard.qml", "qml/cards/PlaylistCard.qml", + "qml/cards/PlaylistCollage.qml", "qml/cards/SlimCard.qml", "qml/cards/TrackCard.qml", "qml/controls/CardMenu.qml", diff --git a/crates/qbz-qt/qml/cards/PlaylistCard.qml b/crates/qbz-qt/qml/cards/PlaylistCard.qml index 81e90f7ac..3b8587d91 100644 --- a/crates/qbz-qt/qml/cards/PlaylistCard.qml +++ b/crates/qbz-qt/qml/cards/PlaylistCard.qml @@ -13,6 +13,16 @@ // 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). @@ -27,8 +37,21 @@ Rectangle { 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: "" + // 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 @@ -51,10 +74,23 @@ Rectangle { 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 diff --git a/crates/qbz-qt/qml/cards/PlaylistCollage.qml b/crates/qbz-qt/qml/cards/PlaylistCollage.qml new file mode 100644 index 000000000..f81b1e00c --- /dev/null +++ b/crates/qbz-qt/qml/cards/PlaylistCollage.qml @@ -0,0 +1,276 @@ +// PlaylistCollage — the playlist cover mosaic (discover/PlaylistCollage.slint, +// itself the port of Tauri's PlaylistCollage.svelte). +// +// Qobuz hands back up to four MEMBER-ALBUM covers per playlist +// (images300 / images150 / images); the layout is picked by how many there +// are — single, dual (side by side), triple (one large left + two stacked +// right), quad (three stacked left + one large right), and a list-music +// glyph when the playlist has none. Tiles are always crop-fitted; the +// playlist's OWN Qobuz graphic is NOT drawn here (PlaylistCard renders that +// one contain-fitted through RoundedImage). +// +// Canvas rather than a Grid of Images for the reason theme/RoundedImage.qml +// documents: QML `clip` is rectangular, so a Rectangle with `radius` does +// NOT round its children, and shader masks render nothing on the +// software/offscreen path. One Canvas per card, CPU raster, same cost +// profile as the RoundedImage every AlbumCard already mounts. +// +// Artwork is URL-keyed, not artKey-keyed (a playlist row has ONE artKey but +// up to four covers): the component hands its urls to the shared url-keyed +// resolver — `QbzShell.sidebarArtworkWindow`, the same entry point the +// sidebar tree's micro-collage uses — and listens for the +// `libraryArtworkReady` echo. Disk hits come back synchronously; misses are +// downloaded once and cached. The dispatch is debounced so flinging through +// a windowed grid never fetches covers for delegates that are already gone. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Item { + id: root + + // Remote cover urls (feed row `covers`); only the first four distinct + // entries are drawn. + property var urls: [] + property real radius: 8 + // Mosaic seam, 1:1 with PlaylistCollage.slint. + property real gap: 2 + + QbzTheme { id: theme } + + // De-duplicated + capped at four (Tauri dedupes the same way: repeated + // albums in a playlist must not paint the same tile twice). + readonly property var tiles: root.dedup(root.urls) + // url -> local cached path, filled by the artwork signal. + property var pathMap: ({}) + + function dedup(list) { + var out = [] + var seen = ({}) + var src = list || [] + for (var i = 0; i < src.length && out.length < 4; i++) { + var u = src[i] + if (!u || seen[u] === true) + continue + seen[u] = true + out.push(u) + } + return out + } + + function pathAt(index) { + var u = root.tiles[index] + return u ? (root.pathMap[u] || "") : "" + } + + // Placeholder surface. A Rectangle's OWN rounded fill paints correctly — + // only its children need the Canvas clip. + Rectangle { + anchors.fill: parent + radius: root.radius + color: theme.surfaceElevated + } + + // No covers at all — the list-music glyph (PlaylistCollage.slint n <= 0). + QbzIcon { + visible: root.tiles.length === 0 + name: "list-music" + width: Math.round(root.width * 0.32) + height: width + anchors.centerIn: parent + tintName: "muted" + } + + // Debounced url dispatch: one call per settled delegate, only for tiles + // whose path is still unknown. + Timer { + id: dispatchDelay + interval: 120 + onTriggered: { + var pending = [] + for (var i = 0; i < root.tiles.length; i++) { + var u = root.tiles[i] + if (!root.pathMap[u]) + pending.push(u) + } + if (pending.length > 0) + QbzShell.sidebarArtworkWindow(JSON.stringify(pending)) + } + } + + onTilesChanged: { + if (root.tiles.length > 0) + dispatchDelay.restart() + } + Component.onCompleted: { + if (root.tiles.length > 0) + dispatchDelay.restart() + } + + Connections { + target: QbzBridge + // Shared with the feed's artKey-keyed emissions — ignore anything + // that is not one of OUR urls. + function onLibraryArtworkReady(key, path) { + if (root.pathMap[key] === path) + return + var mine = false + for (var i = 0; i < root.tiles.length; i++) { + if (root.tiles[i] === key) { + mine = true + break + } + } + if (!mine) + return + var m = root.pathMap + m[key] = path + // Rebind requires a NEW object reference. + root.pathMap = Object.assign({}, m) + } + } + + Canvas { + id: canvas + anchors.fill: parent + renderTarget: Canvas.Image + renderStrategy: Canvas.Cooperative + + // Dimension probes: Canvas cannot query a loaded image's intrinsic + // size and the crop fit needs it (RoundedImage's pattern, one per + // tile). They double as the async-load notification. + Image { + id: p0 + source: root.pathAt(0) + visible: false + asynchronous: true + cache: true + onSourceChanged: canvas.prime(source) + onStatusChanged: canvas.requestPaint() + } + Image { + id: p1 + source: root.pathAt(1) + visible: false + asynchronous: true + cache: true + onSourceChanged: canvas.prime(source) + onStatusChanged: canvas.requestPaint() + } + Image { + id: p2 + source: root.pathAt(2) + visible: false + asynchronous: true + cache: true + onSourceChanged: canvas.prime(source) + onStatusChanged: canvas.requestPaint() + } + Image { + id: p3 + source: root.pathAt(3) + visible: false + asynchronous: true + cache: true + onSourceChanged: canvas.prime(source) + onStatusChanged: canvas.requestPaint() + } + + function prime(src) { + if (src) + canvas.loadImage(src) + canvas.requestPaint() + } + + function probeAt(index) { + return index === 0 ? p0 : index === 1 ? p1 : index === 2 ? p2 : p3 + } + + // One crop-fitted tile, clipped to its own rect (uniform scale + // covering the cell, centered — Image.PreserveAspectCrop). + function tile(ctx, index, x, y, tw, th) { + var src = root.pathAt(index) + if (!src || tw <= 0 || th <= 0) + return + var probe = canvas.probeAt(index) + if (probe.status !== Image.Ready || probe.sourceSize.width <= 0 + || probe.sourceSize.height <= 0) + return + if (!canvas.isImageLoaded(src)) { + canvas.loadImage(src) + return + } + var iw = probe.sourceSize.width + var ih = probe.sourceSize.height + var scale = Math.max(tw / iw, th / ih) + var dw = iw * scale + var dh = ih * scale + ctx.save() + ctx.beginPath() + ctx.rect(x, y, tw, th) + ctx.clip() + ctx.drawImage(src, x + (tw - dw) / 2, y + (th - dh) / 2, dw, dh) + ctx.restore() + } + + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + onImageLoaded: requestPaint() + + onPaint: { + var ctx = getContext("2d") + ctx.reset() + var w = width + var h = height + if (w <= 0 || h <= 0) + return + var n = root.tiles.length + if (n <= 0) + return + + var r = Math.max(0, Math.min(root.radius, w / 2, h / 2)) + if (r > 0) { + ctx.beginPath() + ctx.moveTo(r, 0) + ctx.lineTo(w - r, 0) + ctx.arcTo(w, 0, w, r, r) + ctx.lineTo(w, h - r) + ctx.arcTo(w, h, w - r, h, r) + ctx.lineTo(r, h) + ctx.arcTo(0, h, 0, h - r, r) + ctx.lineTo(0, r) + ctx.arcTo(0, 0, r, 0, r) + ctx.closePath() + ctx.clip() + } + + var g = root.gap + if (n === 1) { + canvas.tile(ctx, 0, 0, 0, w, h) + } else if (n === 2) { + // Side-by-side halves. + var half = (w - g) / 2 + canvas.tile(ctx, 0, 0, 0, half, h) + canvas.tile(ctx, 1, half + g, 0, half, h) + } else if (n === 3) { + // One large left (62%), two stacked right (38%). + var big3 = (w - g) * 0.62 + var small3 = (w - g) * 0.38 + var half3 = (h - g) / 2 + canvas.tile(ctx, 0, 0, 0, big3, h) + canvas.tile(ctx, 1, big3 + g, 0, small3, half3) + canvas.tile(ctx, 2, big3 + g, half3 + g, small3, half3) + } else { + // Three stacked left (34%), one large right (66%). + var col4 = (w - g) * 0.34 + var big4 = (w - g) * 0.66 + var row4 = (h - 2 * g) / 3 + canvas.tile(ctx, 0, 0, 0, col4, row4) + canvas.tile(ctx, 1, 0, row4 + g, col4, row4) + canvas.tile(ctx, 2, 0, 2 * (row4 + g), col4, row4) + canvas.tile(ctx, 3, col4 + g, 0, big4, h) + } + } + } +} diff --git a/crates/qbz-qt/qml/views/LibraryView.qml b/crates/qbz-qt/qml/views/LibraryView.qml index 0b74586e6..0a2b3b4b6 100644 --- a/crates/qbz-qt/qml/views/LibraryView.qml +++ b/crates/qbz-qt/qml/views/LibraryView.qml @@ -356,9 +356,24 @@ Rectangle { clip: true RoundedImage { anchors.fill: parent + visible: !lrCollage.visible source: root.artMap[item.artKey] || "" radius: item.kind === "artist" ? 22 : 6 - fit: item.kind === "label" ? "contain" : "crop" + // A Qobuz playlist's own graphic is contain-fitted like a + // label logo — cropping it cuts the wordmark. + fit: (item.kind === "label" || item.playlistOwnImage === true) + ? "contain" : "crop" + } + // User playlists have no graphic of their own, so they show the + // member-cover mosaic instead of a blank tile. + PlaylistCollage { + id: lrCollage + anchors.fill: parent + visible: item.kind === "playlist" + && item.playlistOwnImage !== true + && (root.artMap[item.artKey] || "") === "" + urls: item.covers || [] + radius: 6 } Rectangle { visible: item.kind === "track" || item.kind === "album" || item.kind === "playlist" diff --git a/crates/qbz-qt/src/library_qt.rs b/crates/qbz-qt/src/library_qt.rs index c12e1a83e..66b22741d 100644 --- a/crates/qbz-qt/src/library_qt.rs +++ b/crates/qbz-qt/src/library_qt.rs @@ -79,10 +79,32 @@ pub struct FeedItem { pub playlist_following: bool, #[serde(rename = "isPinned", default)] pub is_pinned: bool, + /// Playlist rows only: up to four de-duplicated member-track covers — + /// the 2x2 mosaic the card renders when the playlist has no artwork of + /// its OWN (Tauri `FavoritePlaylistCard` -> `PlaylistCollage`, Slint + /// `PlaylistCollage.slint`). Skipped when empty so the ~10k-row feed + /// JSON does not grow an `"covers":[]` per non-playlist row. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub covers: Vec, + /// True when `image_url` is the playlist's OWN Qobuz artwork + /// (`image_rectangle`) instead of a member-track cover. The card then + /// renders it CONTAIN — those graphics are landscape and cropping + /// butchers them (Tauri `QobuzPlaylistCard`: `object-fit: contain`). + #[serde( + rename = "playlistOwnImage", + default, + skip_serializing_if = "is_false" + )] + pub playlist_own_image: bool, /// Recency proxy in [0,1]; 0 = most-recently added (per source). pub added_rank: f32, } +/// `skip_serializing_if` predicate for the default-false flags above. +fn is_false(value: &bool) -> bool { + !*value +} + impl FeedItem { fn keyed(mut self) -> Self { self.art_key = feed_key(&self.kind, &self.id); @@ -324,13 +346,50 @@ fn map_artist(artist: Artist) -> FeedItem { .keyed() } -fn map_playlist_row(playlist: &Playlist, is_following: bool) -> FeedItem { - let cover_url = [&playlist.images300, &playlist.images150, &playlist.images] +/// Up to four de-duplicated member-track covers (`images300` > `images150` +/// > `images`) — the collage source. Same picker as `sidebar_qt::load` and +/// `qbz::search::playlist_cover_urls`; these lists are the covers of the +/// playlist's MEMBER ALBUMS, never the playlist's own artwork. +fn playlist_cover_urls(playlist: &Playlist) -> Vec { + let source = [&playlist.images300, &playlist.images150, &playlist.images] .into_iter() .flatten() - .find(|v| !v.is_empty()) - .and_then(|list| list.iter().find(|u| !u.is_empty()).cloned()) - .unwrap_or_default(); + .find(|v| !v.is_empty()); + let mut out: Vec = Vec::new(); + if let Some(list) = source { + for url in list { + if url.is_empty() || out.contains(url) { + continue; + } + out.push(url.clone()); + if out.len() == 4 { + break; + } + } + } + out +} + +/// The playlist's OWN Qobuz artwork: the editorial `image_rectangle` +/// graphic (`image_rectangle_mini` as the lighter fallback). Only Qobuz +/// editorial playlists carry one — user-created playlists have none and +/// fall back to the member-cover collage, exactly the Tauri split between +/// `QobuzPlaylistCard` (`image.rectangle`, contain-fitted) and +/// `FavoritePlaylistCard` (`PlaylistCollage`). +fn playlist_own_image(playlist: &Playlist) -> String { + [&playlist.image_rectangle, &playlist.image_rectangle_mini] + .into_iter() + .flatten() + .find_map(|list| list.iter().find(|u| !u.is_empty()).cloned()) + .unwrap_or_default() +} + +fn map_playlist_row(playlist: &Playlist, is_following: bool) -> FeedItem { + // The card's single image is the playlist's own Qobuz artwork ONLY. + // Falling back to `images300[0]` here is what put a member ALBUM cover + // on every playlist card; the mosaic is fed separately via `covers`. + let cover_url = playlist_own_image(playlist); + let covers = playlist_cover_urls(playlist); let mut subtitle = playlist.owner.name.clone(); if playlist.tracks_count > 0 { let count = playlist.tracks_count; @@ -350,7 +409,9 @@ fn map_playlist_row(playlist: &Playlist, is_following: bool) -> FeedItem { id: playlist.id.to_string(), title: playlist.name.clone(), subtitle, + playlist_own_image: !cover_url.is_empty(), image_url: cover_url, + covers, is_favorite: !is_following, playlist_owned: owned, playlist_following: is_following,