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
354 lines
17 KiB
QML
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
|
|
}
|
|
}
|