Files
qbz/crates/qbz-qt/qml/views/library/FeedGridCell.qml
T
vicrodh 81c8d3908e feat(qt): the Releases list shows each release's label, and album menus open it
Library feed album items carry the catalog label id next to its name.
Library > Releases (list) gains a LABEL column between the item and its
quality — a link to the label page when the id is known, muted text
otherwise — hidden on the label page itself and on narrow kiosk rows.
AlbumListRow and AlbumCard menus gain "View label" (the card only where its
host passes the id: the library grid does). The library's release-wide
availability helper is shared with the playlist rows for the next change.

cc
2026-09-13 20:26:11 -06:00

238 lines
9.9 KiB
QML

// FeedGridCell — one 200x246 slot of the Library GRID, dispatched by `kind`
// to the SAME card family the Slint mounts (FavoritesView.slint:1084-1187):
// track -> discover/TrackCard, album -> discover/AlbumCard, artist ->
// discover/ArtistGridCard, playlist -> discover/PlaylistCard, label ->
// discover/LabelCard.
//
// EXTRACTED from views/LibraryView.qml's GridView delegate (track rule 2).
// The per-cell cover skeleton rides along, because it is a property of the
// SLOT (it must sit above the card and below nothing) rather than of the view.
import QtQuick
import com.blitzfc.qbz
import "../../cards"
import "../../controls"
import "../../theme"
Item {
id: cell
QbzTheme { id: theme }
/// The LibraryView root (artMap, skelPhase, showLocal, activeTab).
property var view: null
/// The feed row this slot renders.
property var item: ({})
/// Index INSIDE the reported artwork window — the skeleton's 48-instance
/// animation cap counts from the viewport, never from a 10k-row model.
property int cellIndex: 0
readonly property string identity: (cell.item.kind || "") + ":" + (cell.item.id || "")
function releaseLoadedCard() {
if (cardLoader.item
&& typeof cardLoader.item.releaseForReuse === "function")
cardLoader.item.releaseForReuse()
}
function restoreMutableBindings() {
var loaded = cardLoader.item
if (!loaded)
return
if (cell.item.kind === "album") {
loaded.isFavorite = Qt.binding(function () {
return cell.item.isFavorite === true
})
loaded.isPinned = Qt.binding(function () {
return cell.item.isPinned === true
})
} else if (cell.item.kind === "artist"
|| cell.item.kind === "playlist") {
loaded.isPinned = Qt.binding(function () {
return cell.item.isPinned === true
})
}
}
function scheduleMutableRestore() {
mutableRestore.expectedIdentity = cell.identity
mutableRestore.restart()
}
onItemChanged: {
// A popup is a window-overlay child; close it while the old loaded
// card still exists, then restore optimistic scalar bindings after
// Loader has selected the new kind.
cell.releaseLoadedCard()
cell.scheduleMutableRestore()
}
GridView.onPooled: cell.releaseLoadedCard()
GridView.onReused: cell.scheduleMutableRestore()
width: 200
height: 246
// Lifetime-bound counterpart to Qt.callLater: recycling may destroy this
// cell before a global deferred closure runs.
Timer {
id: mutableRestore
interval: 0
repeat: false
property string expectedIdentity: ""
onTriggered: {
if (cell.identity === expectedIdentity)
cell.restoreMutableBindings()
}
}
Component {
id: albumCardComp
AlbumCard {
albumId: cell.item.id
title: cell.item.title
artist: cell.item.artist
artistId: cell.item.artistId
labelId: cell.item.labelId || ""
genre: cell.item.genre
year: cell.item.year
qualityTier: cell.item.qualityTier
qualityDetail: cell.item.qualityDetail || cell.item.qualityLabel || ""
artSource: cell.view.artMap[cell.item.artKey] || ""
isFavorite: cell.item.isFavorite
isPinned: cell.item.isPinned
qobuzUnavailable: cell.item.qobuzUnavailable === true
|| cell.item.sourceUnavailable === true
replacementAffordance: cell.item.source === "qobuz"
&& cell.item.qobuzUnavailable === true
cacheStatus: cell.item.cacheStatus !== undefined
? cell.item.cacheStatus : 0
// The pin payload's display snapshot: the REMOTE url, never
// `artMap` (a local file:// cache path that means nothing after a
// cache wipe). Without it every album pinned from the Library grid
// landed in the Home Pinned rail as a permanent grey placeholder,
// across restarts.
artworkUrl: cell.item.imageUrl || ""
// Local/server rows have no portable catalog URL; retain their
// resolved artwork snapshot for the per-machine Pinned rail.
pinArtworkUrl: artworkUrl !== "" ? artworkUrl : artSource
// Local source badges remain visible on every album. The toolbar
// may additionally show the catalog mark in the mixed feed.
source: cell.item.source
sources: cell.item.sources || []
catalogAffordances: !cell.view.isLocalFeedItem(cell.item)
localMode: cell.view.isLocalFeedItem(cell.item)
localPlaylistAffordance: localMode
hostFavorite: localMode && badgeSources.some(function (source) {
return ["local", "plex", "jellyfin", "subsonic", "navidrome",
"gonic", "airsonic", "astiga"].indexOf(source) >= 0
})
onFavoriteRequested: QbzLocal.albumToggleFavorite(cell.item.id,
cell.item.title || "", cell.item.artist || "", artworkUrl,
JSON.stringify(badgeSources))
onOpenRequested: QbzAlbum.openAlbum(cell.item.id)
onPlayRequested: QbzPlayer.playAlbum(cell.item.id)
onEnqueueRequested: function (mode) { QbzPlayer.enqueueAlbum(cell.item.id, mode) }
showSourceBadge: cell.view.showSourceBadges
}
}
// Group separator. These are pseudo-rows injected into the SAME flat model
// by `visibleItems`, so the list stays virtualized and the windowed
// artwork report is unaffected. Only the tracks LIST produces them today;
// the arm stays so a grouped grid cannot render a card for a header row.
Component {
id: groupHeaderComp
Item {
Text {
anchors.left: parent.left
anchors.bottom: parent.bottom
anchors.bottomMargin: 6
text: cell.item.title
color: theme.textSecondary
font.pixelSize: 13
font.weight: theme.weightSemibold
elide: Text.ElideRight
width: parent.width
}
}
}
Component {
id: trackCardComp
TrackCard {
item: cell.item
artSource: cell.view.artMap[cell.item.artKey] || ""
showSourceBadge: cell.view.showSourceBadges
confirmReleaseRemoval: function (item) {
cell.view.askRemoveReleaseFavorites(item)
}
}
}
Component {
id: artistCardComp
ArtistCard {
item: cell.item
artSource: cell.view.artMap[cell.item.artKey] || ""
isPinned: cell.item.isPinned === true
// Snapshot url for the pin payload (see the album card above).
artworkUrl: cell.item.imageUrl || ""
// The Slint reference SPLITS here, and this one component serves
// both of its call sites: the ARTISTS tab grids pass follow-mode
// "none" (FavoritesView.slint:1831/1865 — every artist in that
// grid is followed by definition, so the chip would be a permanent
// tick), while the mixed ALL feed keeps the default and seeds
// `following` from `is-favorite` (:1143-1147), which is exactly
// the second spelling ArtistCard reads.
followMode: cell.view.activeTab === "artists" ? "none" : "toggle"
}
}
Component {
id: playlistCardComp
PlaylistCard {
// artworkUrl is not passed on purpose: the card defaults it to
// `item.imageUrl`, which IS this row's remote cover.
item: cell.item
artSource: cell.view.artMap[cell.item.artKey] || ""
isPinned: cell.item.isPinned === true
}
}
Component {
id: labelCardComp
LabelCard {
item: cell.item
artSource: cell.view.artMap[cell.item.artKey] || ""
}
}
Loader {
id: cardLoader
anchors.fill: parent
sourceComponent: cell.item.kind === "group-header" ? groupHeaderComp
: cell.item.kind === "album" ? albumCardComp
: cell.item.kind === "track" ? trackCardComp
: cell.item.kind === "artist" ? artistCardComp
: cell.item.kind === "playlist" ? playlistCardComp
: labelCardComp
onLoaded: cell.restoreMutableBindings()
}
// THE fix for "many grey squares that fill in unevenly": each pending
// cover shimmers on its own and stops the moment ITS file:// path lands in
// artMap, so the grid resolves progressively instead of looking like a
// wall of dead tiles. Artists and labels are excluded — their cards
// already draw a designed round gradient+glyph portrait placeholder
// (ArtistGridCard/LabelCard). A bare Rectangle: it does not take pointer
// events, so the card's hover/click areas keep working underneath.
QbzSkeleton {
artworkItem: cell.item.kind !== "group-header" && cardLoader.item
&& cardLoader.item.artworkImmediate ? cardLoader.item : null
variant: "art"
width: 200
height: 200
visible: (cell.item.kind === "album"
|| cell.item.kind === "track"
|| cell.item.kind === "playlist")
&& cell.item.imageUrl !== ""
&& (cardLoader.item && cardLoader.item.artworkImmediate
? opacity > 0.004 : (cell.view.artMap[cell.item.artKey] || "") === "")
// TrackCard's unavailable scrim is semantic content. A pending
// artwork skeleton must never paint over it and turn the honest
// state back into an ambiguous grey tile.
&& !(cardLoader.item && cardLoader.item.pulledDead === true)
phase: cell.view.skelPhase
cellIndex: cell.cellIndex
}
}