fix(qbz-qt): playlist artwork — own graphic for Qobuz, collage for the rest

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
This commit is contained in:
vicrodh committed 2026-07-28 13:48:05 -06:00
1 parent 9618bfe137
commit 10461bb49c
6 files changed
+405 -8

No files matched your search

+8
View File
@@ -604,6 +604,14 @@ pub struct Playlist {
pub genres: Option<Vec<PlaylistGenre>>,
pub images150: Option<Vec<String>>,
pub images300: Option<Vec<String>>,
/// 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<Vec<String>>,
#[serde(default)]
pub image_rectangle_mini: Option<Vec<String>>,
pub slug: Option<String>,
pub users_count: Option<u32>,
}
+1
View File
@@ -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",
+38 -2
View File
@@ -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
+276
View File
@@ -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)
}
}
}
}
+16 -1
View File
@@ -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"
+66 -5
View File
@@ -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<String>,
/// 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<String> {
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<String> = 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,