Files
qbz/crates/qbz-qt/qml/views/library/LibraryArtistsPanel.qml
T
vicrodh ce625e7e28 fix(qt): an album leaving the Library slides the grid instead of rebuilding it
Removing an album (or a track, artist or playlist) from the Library made
Rust republish the feed, the derive produced a new array, and every body
bound to it took a model swap: the grid jumped to the top, every card was
rebuilt and every cover faded in again. All four bodies (grid, list,
albums list, playlists list) now share one QbzKeyedModel over the same
derive: the leaving card shrinks and fades while its neighbours slide
into the gap, the page stays where it was, and surviving cards keep their
delegates and decoded covers. The Artists sidepanel rail gets its own
keyed model with the same page identity.

A different page is still a different page: tab, sort, search, source
filters, genres and grouping form the model's scope and replace the rows
at once, from the top, as before. A heart removed under "Favorites only"
is not a scope change, so that card animates out.

Supporting changes: the artwork window reports read the model's published
rows and count from originY (a removal above the window moves the
origin), scroll memory is origin-relative, the playlists and albums lists
are height-gated like the grid so hidden bodies build nothing, pooled
cells come back opaque, and a background reload with a page already on
screen (a bulk removal, a language switch) keeps that page instead of
blinking through the loading skeleton.

tst_library_keyed mounts the real grid cell over the keyed model and
checks that an album leaving through a republished feed keeps the other
cards, their covers and the page offset; bound to the plain array it
fails.

cc
2026-09-14 05:50:35 -06:00

514 lines
22 KiB
QML

// LibraryArtistsPanel — the Artists tab's SIDEPANEL mode
// (FavoritesView.slint:2014-2185): a 300px A-Z master rail of the favourite
// artists on the left, the selected artist's releases on the right, each
// column scrolling on its own.
//
// LEFT is derived here from the rows the Library feed already carries — no
// fetch. RIGHT is `QbzLibrary.sidepanelJson` (src/library_sidepanel.rs), whose
// sections come from the SAME `/artist/page` classifier the full artist page
// uses, so the two can never disagree about what an artist released. Its five
// states are the reference's: nothing selected · loading · error · no albums ·
// the sections + a "Go to artist page" footer.
//
// The rail row is written here rather than reusing views/local/LocalArtistRow:
// that one is 60px, subtitles "N albums · M tracks" and reads its avatar gate
// off LocalLibraryView. The reference agrees — it declares its own
// `SidepanelArtistRow` (:218) beside the local one for exactly these reasons.
import QtQuick
import com.blitzfc.qbz
import "../../cards"
import "../../controls"
import "../../theme"
Item {
id: root
/// The LibraryView root (visibleRows, artMap, skelPhase).
property var view: null
QbzTheme { id: theme }
readonly property int railW: 300
readonly property int rowH: 56
readonly property int headerH: 26
// ------------------------------- left rail ---------------------------
// Entry model — { t: 0, label } | { t: 1, item } — so ONE ListView
// windows both the letter headers and the rows (the LocalArtistsTab
// shape), instead of a Repeater per section.
property var entries: []
property var alphaJumps: []
// The SAME array, indexed the same way, in the shape LibraryView's
// `reportWindow` reads (`kind` / `artKey` / `imageUrl`): rail rows are the
// feed row objects themselves, letter headers are the group-header
// sentinel that report already knows to skip. It exists because the rail's
// order is NOT `visibleRows` (it re-sorts A-Z and interleaves headers), so
// index N here is not `visibleRows[N]` and the band has to travel with its
// own array — see LibraryView.queueWindowReport's third argument.
property var railRows: []
readonly property var headerRow: ({ "kind": "group-header", "artKey": "", "imageUrl": "" })
/// Held while `entries` and `railRows` are being swapped. Assigning
/// `entries` re-evaluates the ListView's `model` binding SYNCHRONOUSLY,
/// which emits `onModelChanged` -> `report()` one statement BEFORE
/// `railRows` is updated — an index band read off the new entries against
/// the old flat array. The trailing `report()` supersedes it in the common
/// case (the debounce is a `restart()`), but NOT when the rebuild ends
/// empty: `report()` bails on `railRows.length === 0`, leaving the stale
/// band queued and pinning covers for artists the rail no longer shows
/// (the "search with no matches" path). One flag closes both.
property bool _rebuilding: false
function rebuild() {
var rows = root.view ? root.view.visibleRows : []
var artists = []
for (var i = 0; i < rows.length; i++)
if (rows[i].kind === "artist") artists.push(rows[i])
// Preserve the chosen sort; only alphabetical modes need letter headers.
var mode = root.view.artistsSort
var grouped = mode === "default" || mode === "default-reverse"
|| mode.indexOf("title-") === 0
if (mode === "default" || mode === "default-reverse") {
artists.sort(function(a, b) {
var result = (a.title || "").localeCompare(b.title || "")
return mode === "default-reverse" ? -result : result
})
}
var out = []
var flat = []
var jumps = []
var last = null
for (i = 0; i < artists.length; i++) {
var key = root.view.alphaKey(artists[i].title)
if (grouped && key !== last) {
jumps.push({ "letter": key, "index": out.length })
out.push({ "t": 0, "label": key })
flat.push(root.headerRow)
last = key
}
out.push({ "t": 1, "item": artists[i] })
flat.push(artists[i])
}
root._rebuilding = true
root.entries = out
root.railRows = flat
root.alphaJumps = jumps
root._rebuilding = false
root.report()
}
// The rail's own keyed model (2026-09-14): an artist unfollowed from the
// pane fades out of the rail while the rows below slide up, instead of
// the rail being rebuilt from the top. Same page identity as the host's
// bodies, so a search or a sort still replaces it at once.
QbzKeyedModel {
id: railModel
rows: root.entries
keyOf: function (entry) {
return entry.t === 0 ? "letter:" + entry.label : "artist:" + entry.item.id
}
scope: root.view ? root.view.rowsScope : ""
onReconciled: root.report()
}
Component.onCompleted: {
railModel.views = [railList]
root.rebuild()
root.reportSoon()
}
Connections {
target: root.view
function onVisibleRowsChanged() { root.rebuild() }
}
// ------------------------ windowed avatars ---------------------------
// The rail rides the SAME id-keyed artwork window every other Library
// body uses — LibraryView.reportWindow -> QbzLibrary.libraryArtworkWindow
// -> main.rs library_artwork_window -> libraryArtworkReady -> `artMap`,
// which is what the row avatars bind to below. Until this landed the
// panel reported nothing at all, so `artMap` only ever held whatever
// another tab had left in it and practically every rail avatar drew the
// placeholder glyph.
//
// Only the mounted band is asked for (rule 6): the rail is one row per
// followed artist and can run to hundreds.
function report() {
if (root._rebuilding) return
if (!root.view || root.railRows.length === 0) return
// Same gate the sibling rail uses (views/local/LocalArtistsTab.qml:309)
// and the host's own bodies now use: a hidden surface must not report,
// because `artMap` is ONE map and a report PRUNES outside its band.
// `contentY`/`height` keep firing while the whole Library view is off
// screen (another nav destination), and a report made then evicts the
// covers of whatever IS on screen. The `onVisibleChanged` handler on
// `railList` re-reports when the rail comes back.
if (!railList.visible) return
var first = railList.indexAt(4, railList.contentY + 1)
var last = railList.indexAt(4, railList.contentY + Math.max(1, railList.height) - 1)
if (first < 0) first = 0
if (last < 0) last = Math.min(root.railRows.length - 1, first + 12)
root.view.queueWindowReport(Math.max(0, first - 4),
Math.min(root.railRows.length - 1, last + 4),
root.railRows)
}
/// Report now, then again once the ListView has laid out — `indexAt`
/// answers -1 before that, which is the state of a rail that has just been
/// built or just been mounted (views/local/LocalArtistsTab.qml:330).
Timer {
id: reportSettle
interval: 50
repeat: false
onTriggered: root.report()
}
function reportSoon() {
root.report()
reportSettle.restart()
}
// Leaving the mode drops the selection, so re-entering does not paint a
// stale artist's discography under a rail with nothing highlighted.
Component.onDestruction: QbzLibrary.sidepanelClear()
// ------------------------------ right pane ---------------------------
readonly property var panel: {
try {
return JSON.parse(QbzLibrary.sidepanelJson)
} catch (e) {
return {}
}
}
readonly property string selectedId: panel.artistId || ""
// Right-pane covers are URL-keyed, exactly like the artist page's
// (views/ArtistView.qml): `QbzShell.sidebarArtworkWindow` asks for a list
// of urls and `QbzLibrary.libraryArtworkReady` answers with (url, path).
// Arrivals are coalesced into ONE rebind per frame — per-arrival rebinding
// is O(n²) over a big discography.
property var coverMap: ({})
property var _coverInbox: ({})
Timer {
id: coverFlush
interval: 16
repeat: false
onTriggered: {
var m = Object.assign({}, root.coverMap, root._coverInbox)
root._coverInbox = ({})
root.coverMap = m
}
}
Connections {
target: QbzLibrary
function onLibraryArtworkReady(key, path) {
root._coverInbox[key] = path
if (!coverFlush.running) coverFlush.start()
}
}
onPanelChanged: {
var urls = []
var sections = root.panel.sections || []
for (var s = 0; s < sections.length; s++) {
var albums = sections[s].albums || []
for (var i = 0; i < albums.length; i++) {
var u = albums[i].artUrl || ""
if (u !== "" && !root.coverMap[u]) urls.push(u)
}
}
if (urls.length > 0) QbzShell.sidebarArtworkWindow(JSON.stringify(urls))
}
// =========================== layout ==================================
Item {
id: rail
x: 32
y: 16
width: root.railW
height: parent.height - 16
clip: true
ListView {
id: railList
anchors.fill: parent
anchors.rightMargin: 22
clip: true
cacheBuffer: 56 * 8
reuseItems: true
boundsBehavior: Flickable.StopAtBounds
model: railModel
add: QbzRowAdd { enabled: railModel.animate }
remove: QbzRowRemove { enabled: railModel.animate }
move: QbzRowDisplaced { enabled: railModel.animate }
displaced: QbzRowDisplaced { enabled: railModel.animate }
// The trigger set views/local/LocalArtistsTab.qml:161-165 already
// uses for the same shape of rail: scroll, model swap, viewport
// resize, and a settle pass when the pane becomes visible (a
// report made against a ListView that has not laid out gets -1
// from `indexAt`, and nothing re-fires once it has).
onContentYChanged: root.report()
onModelChanged: root.report()
onHeightChanged: root.report()
onVisibleChanged: if (visible) root.reportSoon()
delegate: Loader {
required property string rowKey
required property int rowRev
readonly property var modelData: railModel.row(rowKey, rowRev)
ListView.onReused: { opacity = 1; scale = 1 }
width: railList.width
height: modelData.t === 0 ? root.headerH : root.rowH
sourceComponent: modelData.t === 0 ? letterComp : artistComp
Component {
id: letterComp
Item {
Text {
anchors.verticalCenter: parent.verticalCenter
text: modelData.label
color: theme.textSecondary
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
}
}
}
Component {
id: artistComp
Rectangle {
id: railRow
readonly property bool picked: root.selectedId === modelData.item.id
width: railList.width
height: root.rowH
radius: 8
color: railRow.picked ? theme.surfaceElevated
: railRowArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 10
Rectangle {
width: 40
height: 40
anchors.verticalCenter: parent.verticalCenter
radius: 20
color: theme.surfaceElevated
clip: true
RoundedImage {
anchors.fill: parent
source: root.view.artMap[modelData.item.artKey] || ""
radius: 20
}
QbzIcon {
visible: (root.view.artMap[modelData.item.artKey] || "") === ""
name: "user"
width: 18
height: 18
anchors.centerIn: parent
tintName: "muted"
}
}
Text {
width: parent.width - 40 - 10
height: parent.height
text: modelData.item.title || ""
color: railRow.picked ? theme.textPrimary : theme.textSecondary
font.pixelSize: theme.fontBody
font.weight: railRow.picked ? theme.weightSemibold : theme.weightRegular
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
MouseArea {
id: railRowArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzLibrary.sidepanelSelectArtist(
modelData.item.id, modelData.item.title || "")
}
}
}
}
}
QbzAlphaStrip {
visible: root.entries.length > 0
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
jumps: root.alphaJumps
completeAlphabet: true
onJump: function (ordinal, index) {
railList.positionViewAtIndex(index, ListView.Beginning)
}
}
}
Rectangle {
x: rail.x + rail.width
y: 16
width: 1
height: parent.height - 16
color: theme.borderSubtle
}
Item {
id: pane
x: rail.x + rail.width + 1
y: 16
width: parent.width - x - 32
height: parent.height - 16
clip: true
// 1 — nothing selected.
Column {
visible: root.selectedId === ""
anchors.centerIn: parent
spacing: 10
QbzIcon {
anchors.horizontalCenter: parent.horizontalCenter
name: "user"
width: 44
height: 44
tintName: "muted"
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzSession.tr("Select an artist to see their albums.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontBody
}
}
// 2 — loading.
QbzSpinner {
visible: root.selectedId !== "" && root.panel.loading === true
anchors.centerIn: parent
size: 32
}
// 3 — error.
Text {
visible: root.selectedId !== "" && root.panel.loading !== true
&& (root.panel.error || "") !== ""
anchors.centerIn: parent
text: QbzSession.tr("Couldn't load albums.", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
}
// 4 — nothing to show.
Text {
visible: root.selectedId !== "" && root.panel.loading !== true
&& (root.panel.error || "") === "" && (root.panel.total || 0) === 0
anchors.centerIn: parent
text: QbzSession.tr("No albums found.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontBody
}
// 5 — the sections.
Flickable {
id: paneFlick
visible: root.selectedId !== "" && root.panel.loading !== true
&& (root.panel.error || "") === "" && (root.panel.total || 0) > 0
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: paneCol.height
boundsBehavior: Flickable.StopAtBounds
Column {
id: paneCol
x: 20
width: paneFlick.width - 32
spacing: 18
Repeater {
model: root.panel.sections || []
delegate: Column {
id: sec
required property var modelData
width: paneCol.width
spacing: 8
Text {
text: sec.modelData.title || ""
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
}
// Explicit grid, not AlbumCollection: that component
// reads a baked `artPath` off each row, and these rows
// carry `artUrl` only (the covers arrive url-keyed —
// see coverMap). Same column math, same 200x246 card.
Item {
id: grid
width: parent.width
readonly property int cardW: 200
readonly property int cardH: 246
readonly property int gap: 24
readonly property var albums: sec.modelData.albums || []
readonly property int columns: Math.max(
1, Math.floor((grid.width + grid.gap) / (grid.cardW + grid.gap)))
readonly property int rowCount: Math.ceil(grid.albums.length / grid.columns)
height: grid.rowCount > 0
? grid.rowCount * grid.cardH + (grid.rowCount - 1) * grid.gap
: 0
Repeater {
model: grid.albums
delegate: Item {
id: gcell
required property var modelData
required property int index
x: (gcell.index % grid.columns) * (grid.cardW + grid.gap)
y: Math.floor(gcell.index / grid.columns) * (grid.cardH + grid.gap)
width: grid.cardW
height: grid.cardH
AlbumCard {
albumId: gcell.modelData.id
title: gcell.modelData.title
artist: gcell.modelData.artist
artistId: gcell.modelData.artistId
genre: gcell.modelData.genre
year: gcell.modelData.year
qualityTier: gcell.modelData.qualityTier
qualityDetail: gcell.modelData.qualityDetail || ""
artSource: root.coverMap[gcell.modelData.artUrl] || ""
// The pin payload persists the REMOTE
// url, never the file:// cache path.
artworkUrl: gcell.modelData.artUrl || ""
isPinned: gcell.modelData.isPinned === true
isFavorite: gcell.modelData.isFavorite === true
}
}
}
}
}
}
// Footer — the full artist page (compilations and
// other-artist content live there).
Item {
width: parent.width
height: 40
Text {
id: footerText
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Go to artist page", QbzSession.trRev)
color: theme.accent
font.pixelSize: theme.fontBody
}
MouseArea {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: footerText.implicitWidth
height: 24
cursorShape: Qt.PointingHandCursor
onClicked: QbzArtist.openArtist(root.selectedId)
}
}
Item { width: 1; height: 60 }
}
}
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: paneFlick
visible: paneFlick.visible && paneFlick.contentHeight > paneFlick.height
}
}
}