Files
qbz/crates/qbz-qt/qml/views/local/LocalAlbumCollection.qml
T
vicrodh 35508b5f87 fix(qt): the defects found smoking the playlist and My QBZ round
Un-following a Qobuz playlist left it in the sidebar and in My Library.
toggle_follow called unsubscribe_playlist and settled the ownership snapshot
and stopped there, so the row only left at the next full refetch. Both
surfaces are patched directly now, in both directions: following from a card
buys the metadata with one get_playlist rather than re-reading
getUserPlaylists, which lags the write. The un-follow collapses a
hearted-and-followed playlist to the one row the next load will actually
produce instead of dropping it, because the local heart is a library.db flag
that survives the unsubscribe — removing every row made it vanish and come
back, which is the original complaint wearing the other shoe.

The Plex mark on album cards was a hard-drive glyph tinted accent — a blue
hard drive — and the three Qobuz kinds drew no badge at all. The badge now
ports discover/AlbumCard.slint:326-354 exactly: 24x24 at radius 4 pinned
bottom-right with a 6px inset, plex-logo untinted at 16, qobuz-logo at 18
(22 for a download), the purchase arm gold on its amber chip, hard-drive
white at 14 for local only. The brand marks are plain Images: routing them
through QbzIcon floods every pixel with the tint and flattens a logo to a
silhouette. SourceIcon moves to controls/ since it is no longer local-only,
and every other surface drawing a hard-drive was audited against its .slint.

The local album detail loses the two nav chevrons above the art. They are a
Tauri-era leftover from before the app had global navigation.

My QBZ used a bespoke card in the grid. MyQbzDetailCard is deleted and the
standard cards/AlbumCard is mounted with its overlay. localMode bundled four
separate things — action routing, the heart, the pin and Block this album —
so a multi-source container had to switch all four off together; it is split,
and the catalog affordances now follow the ITEM. On a Qobuz cell the heart
works through the shared favourite seam; on a track, playlist, local or Plex
cell it is absent rather than dead. Row covers carry two rungs: the list keeps
_50, the grid gets _600. Not the _230 that would merely cover a 200px card —
the complaint was that the cover reads softer than a normal AlbumCard, and a
normal AlbumCard is fed image.large, which IS _600. _200 is not a rung the
CDN serves at all (it answers 404).

The My QBZ grid sets reuseItems, and AlbumCard assigns to its own isFavorite
and isPinned when toggled, which destroys the binding that fed them. A
recycled delegate therefore showed the previous row's heart and the click
added the new album while the glyph said remove. Restored with the
Qt.binding idiom this port already uses on TrackCard, PlaylistCard and
TrackRow.

The details view no longer force-opens every row on entry, and the per-row
open state persists.

Library > Artists showed placeholders for nearly every portrait. The side
panel never reported its artwork window — the one caller of
libraryArtworkWindow is LibraryView.reportWindow, and the panel had no
report call at all, so no key ever reached the resolver and artMap stayed
empty for its rows. The rail now reports through that same path, with the
section-header sentinel reportWindow already skips. Note the reference has
no portrait fallback either: an artist Qobuz serves no image for is a
placeholder on both sides.

cc
2026-08-01 15:29:03 -06:00

354 lines
17 KiB
QML

// The Local Library album collection — the Qt equivalent of
// discover/AlbumCollectionView.slint as LocalLibraryView.slint mounts it
// (:1255 Albums, :1601 Folders-flat, :2409 Artists detail): grid or list,
// grouped or flat, with the source badge and multi-select.
//
// WHY A CHUNK MODEL AND NOT A GridView + Repeater
// The Slint calls this a "chunked grid" and it is the one surface with the
// documented 16k-row freeze. Here EVERY arm is a single windowing ListView
// over a flat "entry" array:
// { t: 0, label } -> group header (34px)
// { t: 1, items: [...], base: n } -> one visual row of cards, or one
// list row; `base` is the row's
// first index in the flat album array
// so grouping costs one O(n) rebuild when the inputs change (never per
// frame) and the mounted delegate count stays proportional to the viewport,
// not to the library. Artwork is reported from the mounted entry range only,
// via the host's windowing helpers.
import QtQuick
import com.blitzfc.qbz
import "../../cards"
import "../../controls"
import "../../theme"
Item {
id: root
/// The LocalLibraryView root — artMap + queueWindowReport live there.
property var view: null
/// Stable id for this cover surface in the host's window registry. Three
/// instances of this component can be alive at once (Albums, Folders-flat,
/// the Artists detail grid), and they must not share a slot.
property string surface: "collection"
/// Flat album rows (already searched/sorted/filtered by the host).
property var rows: []
/// [{ letter, items: [...] }] — only read when `grouped`.
property var groups: []
property bool grouped: false
property string viewMode: "grid" // "grid" | "list"
property bool showSource: true
property bool selectMode: false
/// { albumId: true } — the host owns the selection set.
property var selected: ({})
signal openRequested(string id)
signal playRequested(string id)
signal enqueueRequested(string id, string mode)
signal toggleSelect(string id)
QbzTheme { id: theme }
readonly property int cellW: 220
readonly property int cellH: 266
readonly property int headerH: 34
readonly property int listRowH: 56
readonly property int cols: viewMode === "grid"
? Math.max(1, Math.floor(width / cellW)) : 1
// ---------------------------------------------------------------------
// Chunk model
// ---------------------------------------------------------------------
// `flat` is the album array in display order (the window report indexes
// into it); `entries` is what the ListView actually mounts.
property var flat: []
property var entries: []
/// [{ letter, index }] with `index` = the ENTRY index of that letter's
/// header — what AlphaStrip needs to scroll here.
property var alphaJumps: []
function rebuild() {
var out = []
var flatOut = []
var jumps = []
var per = cols
var i, j
function pushChunk(items) {
for (j = 0; j < items.length; j += per) {
out.push({ "t": 1, "base": flatOut.length + j,
"items": items.slice(j, j + per) })
}
for (j = 0; j < items.length; j++) flatOut.push(items[j])
}
if (grouped) {
for (i = 0; i < groups.length; i++) {
var g = groups[i]
if (!g.items || g.items.length === 0) continue
jumps.push({ "letter": g.letter, "index": out.length })
out.push({ "t": 0, "label": g.letter })
pushChunk(g.items)
}
} else {
pushChunk(rows)
}
flat = flatOut
entries = out
alphaJumps = jumps
report()
}
onRowsChanged: rebuild()
onGroupsChanged: rebuild()
onGroupedChanged: rebuild()
onViewModeChanged: rebuild()
onColsChanged: rebuild()
Component.onCompleted: { rebuild(); reportSoon() }
Component.onDestruction: if (view) view.releaseWindow(root.surface)
// ---------------------------------------------------------------------
// Window report — the MOUNTED entry band, mapped back to flat indices.
// ---------------------------------------------------------------------
// TRIGGERS. A report used to leave here on `contentY` and on the model
// swap alone, and BOTH of those fire while this surface is still hidden:
// the tab body is mounted behind `localAlbumsLoading` and the rows land
// in the same pass, so the one report that mattered was thrown away by
// the `!visible` guard below and nothing was requested until the user
// moved the list by a pixel. Every state change that can alter the
// mounted band now reports: mount, becoming visible, the model rebuild,
// a viewport resize, and the scroll.
function report() {
if (!view || !list) return
if (!list.visible || entries.length === 0 || width <= 0) {
view.releaseWindow(root.surface)
return
}
var first = list.indexAt(4, list.contentY + 1)
var last = list.indexAt(4, list.contentY + Math.max(1, list.height) - 1)
if (first < 0) first = 0
if (last < 0) last = Math.min(entries.length - 1, first + 8)
var lo = entries[first]
var hi = entries[last]
var loIdx = lo ? (lo.t === 1 ? lo.base : 0) : 0
var hiIdx = hi ? (hi.t === 1 ? hi.base + hi.items.length - 1 : loIdx) : loIdx
view.queueWindowReport(flat, loIdx, hiIdx, root.surface)
}
/// Report NOW, then again once layout has settled. `indexAt` answers -1
/// until the ListView has laid its delegates out, which is exactly the
/// state a just-mounted / just-shown list is in — the immediate call gets
/// the covers moving off the conservative fallback band, the second one
/// corrects it to the real viewport.
Timer {
id: reportSettle
interval: 50
repeat: false
onTriggered: root.report()
}
function reportSoon() {
report()
reportSettle.restart()
}
onVisibleChanged: {
if (visible) reportSoon()
else if (view) view.releaseWindow(root.surface)
}
onHeightChanged: report()
Connections {
target: root.view
function onArtworkRefresh() { root.reportSoon() }
}
function jumpToEntry(entryIndex) {
list.positionViewAtIndex(entryIndex, ListView.Beginning)
}
ListView {
id: list
anchors.fill: parent
clip: true
cacheBuffer: root.viewMode === "grid" ? root.cellH * 2 : root.listRowH * 10
boundsBehavior: Flickable.StopAtBounds
model: root.entries
onContentYChanged: root.report()
onModelChanged: root.report()
onHeightChanged: root.report()
onVisibleChanged: if (visible) root.reportSoon()
delegate: Loader {
required property var modelData
width: list.width
height: modelData.t === 0 ? root.headerH
: root.viewMode === "grid" ? root.cellH : root.listRowH
sourceComponent: modelData.t === 0 ? headerComp
: root.viewMode === "grid" ? cardRowComp : listRowComp
Component {
id: headerComp
Item {
Text {
x: 2
anchors.verticalCenter: parent.verticalCenter
text: modelData.label
color: theme.textSecondary
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
}
}
}
// One visual row of album cards.
Component {
id: cardRowComp
Row {
spacing: root.cellW - 200
Repeater {
model: modelData.items
delegate: Item {
id: cardCell
required property var modelData
width: 200
height: 246
// Right-click menu for the card (AlbumCard.slint's
// album-menu, local arm: no favourite, no Block —
// the Slint hides Block for source local/plex).
// The card's own ⋯ overlay button opens AlbumCard's
// built-in menu; see the GLUE note about aligning
// that one's entries with these.
CardMenu {
id: cardMenu
menuWidth: 196
entries: [
{ "label": QbzSession.tr("Open album", QbzSession.trRev), "icon": "library-big", "action": "open" },
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
]
onPicked: function (a) {
if (a === "open") root.openRequested(cardCell.modelData.id)
else if (a === "play") root.playRequested(cardCell.modelData.id)
else root.enqueueRequested(cardCell.modelData.id, a)
}
}
AlbumCard {
localMode: true
albumId: cardCell.modelData.id
title: cardCell.modelData.title
artist: cardCell.modelData.artist
artistId: ""
genre: ""
year: cardCell.modelData.year
qualityTier: cardCell.modelData.qualityTier
artSource: root.view
? (root.view.artMap[cardCell.modelData.artKey] || "") : ""
// LocalLibraryView.slint:1267 `show-source-badge`
// — the card takes the raw source word and the
// BADGE is what the flag switches (blanking
// `source` would also silence the card's
// source-aware menu gates).
source: cardCell.modelData.source
showSourceBadge: root.showSource
// SELECT MODE IS THE CARD'S (AlbumCard's
// `selectMode`/`selected`/`selectToggled`, the
// port of discover/AlbumCard.slint:83, :179-197,
// :207, :239, :465). This host used to paint its
// OWN 22px tick on top of the card at top-left —
// a THIRD geometry, and one that could only add
// the indicator: the card's hover PLAY button
// stayed live underneath it, so hovering a card
// you meant to tick and hitting play started the
// album. The card hides the whole hover action
// row and the pin badge in select mode and draws
// the reference's 24px tick in the reference's
// corner (top-right); all three move together,
// which is exactly what a host overlay cannot
// do. Do not re-add one.
selectMode: root.selectMode
selected: root.selected[cardCell.modelData.id] === true
onSelectToggled: root.toggleSelect(cardCell.modelData.id)
// Non-select mode only — the card routes a
// select-mode click to `selectToggled` and never
// emits this while ticking.
onOpenRequested: root.openRequested(cardCell.modelData.id)
onPlayRequested: root.playRequested(cardCell.modelData.id)
onEnqueueRequested: function (m) {
root.enqueueRequested(cardCell.modelData.id, m)
}
}
// Per-item cover placeholder, handed over to the
// art itself: AlbumCard seals its RoundedImage
// away, so this uses QbzSkeleton's probe arm —
// `coverSource` loads the SAME pixmap-cache entry
// the card is loading and retires the placeholder
// when that decode completes, not when the path
// appears. A bare Rectangle, so it does not take
// pointer events and the card's own areas keep
// working underneath.
// settleMs is mandatory here: local artwork
// resolution drops keys with no cover, so an
// artless album would otherwise shimmer forever
// (see LocalLibraryView.artSettleMs).
QbzSkeleton {
variant: "art"
width: 200
height: 200
pending: root.view
? root.view.artWanted(cardCell.modelData.artKey) : false
coverSource: root.view
? root.view.artPathOf(cardCell.modelData.artKey) : ""
phase: root.view ? root.view.skelPhase : false
settleMs: root.view ? root.view.artSettleMs : 0
settleHold: root.view ? root.view.artPulse : false
}
// RIGHT-only, declared after the card so every
// left click still reaches the card's own areas.
MouseArea {
id: cardRc
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: function (mouse) {
cardMenu.openAtCursor(cardRc, mouse.x, mouse.y)
}
}
// (The multi-select tick that used to sit here is
// gone — it is AlbumCard's `selectMode` now; see
// the note on the card above.)
}
}
}
}
// One list row.
Component {
id: listRowComp
LocalAlbumRow {
width: list.width
view: root.view
item: modelData.items[0]
artSource: root.view
? (root.view.artMap[modelData.items[0].artKey] || "") : ""
showSource: root.showSource
selectMode: root.selectMode
checked: root.selected[modelData.items[0].id] === true
onOpened: root.openRequested(modelData.items[0].id)
onPlayRequested: root.playRequested(modelData.items[0].id)
onEnqueueRequested: function (m) {
root.enqueueRequested(modelData.items[0].id, m)
}
onToggleSelect: root.toggleSelect(modelData.items[0].id)
}
}
}
}
QbzScrollBar {
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
target: list
visible: list.contentHeight > list.height
}
}