Files
qbz/crates/qbz-qt/qml/views/local/LocalGenreDetails.qml
T
vicrodh e0dc288659 perf(library): virtualize large album details
Render only the visible track slice for expanded box sets and share physical version caches without cloning every source row.

cx
2026-08-25 18:22:32 -06:00

613 lines
28 KiB
QML

// Expanded-album list for the Genres browser. Only visible albums request
// tracks; every album starts expanded, matching the classic column browser
// while keeping the delegate count proportional to the viewport.
import QtQuick
import com.blitzfc.qbz
import "../../controls"
import "../../theme"
Item {
id: root
property var view: null
property var albums: []
// album id -> {tracks, discs, versions, versionIndex}. Only the selected
// version carries heavy rows; the picker metadata is compact.
property var detailCache: ({})
// album id -> mediaFilterJson used to build the cached document. Keeping
// this separate lets a filter change retain the mounted track geometry
// while the replacement picker document is derived from Rust's raw cache.
property var detailFilters: ({})
property var pending: ({})
property var cacheOrder: []
// A cold viewport can contain eight compact album headers. Starting one
// cross-source detail query for every header at once contends on the same
// SQLite caches, then most results move off-screen as the first expanded
// album acquires its real height. Keep a small foreground window instead:
// the first result reshapes the ListView before we decide what is useful
// to fetch next.
readonly property int maxConcurrentDetailRequests: 2
function stableFilterJson(value) {
var source = value || ({})
var keys = Object.keys(source).sort()
var normalized = {}
for (var i = 0; i < keys.length; i++)
if (source[keys[i]] === true) normalized[keys[i]] = true
return JSON.stringify(normalized)
}
readonly property string mediaFilterJson:
stableFilterJson(view ? (view.genresFilter || {}) : {})
// Absence means expanded: albums are open by default, while a sparse map
// remembers only the rows the user explicitly folded.
property var collapsed: ({})
// "album id\ndisc number" -> true. Discs, like albums, start open and
// only explicit folds occupy this sparse map.
property var collapsedDiscs: ({})
readonly property bool initialDetailLoading:
Object.keys(detailCache).length === 0
&& Object.keys(pending).length > 0
QbzTheme { id: theme }
// Model/filter changes can invalidate this Loader before a deferred QML
// closure runs. Object-owned timers are cancelled with the view, and the
// short report debounce also coalesces the layout churn from a large box
// set into one visible-window update.
Timer {
id: ensureVisibleTimer
interval: 16
repeat: false
onTriggered: root.ensureVisible()
}
Timer {
id: reportTimer
interval: 16
repeat: false
onTriggered: root.report()
}
function scheduleEnsureVisible() {
ensureVisibleTimer.restart()
}
function scheduleReport() {
reportTimer.restart()
}
function ensure(albumId) {
if (!albumId) return false
if (detailCache[albumId] !== undefined
&& detailFilters[albumId] === mediaFilterJson) return false
// A quick source A -> A+B -> A sequence may leave an older worker in
// flight. Dedupe only the exact funnel; Rust generation-checks and
// suppresses any older result before it reaches this signal.
if (pending[albumId] === mediaFilterJson) return false
var next = Object.assign({}, pending)
next[albumId] = mediaFilterJson
pending = next
QbzLocal.genreAlbumTracks(albumId, mediaFilterJson)
return true
}
function currentPendingCount() {
var count = 0
var keys = Object.keys(pending)
for (var i = 0; i < keys.length; i++)
if (pending[keys[i]] === mediaFilterJson) count++
return count
}
function ensureVisible() {
if (albums.length === 0 || !list.visible) return
var first = list.indexAt(2, list.contentY + 1)
var last = list.indexAt(2, list.contentY + list.height - 1)
if (first < 0) first = 0
if (last < 0) last = Math.min(albums.length - 1, first + 4)
var available = maxConcurrentDetailRequests - currentPendingCount()
if (available <= 0) return
for (var i = first; i <= last && i < albums.length; i++) {
if (ensure(albums[i].id) && --available === 0) break
}
}
onMediaFilterJsonChanged: {
// Do not collapse a loaded 247-track box merely because another
// source was enabled. Its visible delegate asks for the new funnel
// below; the old rows remain stable until the picker/selection update
// arrives, avoiding a full destroy/recreate cycle and scroll jump.
// Old-generation workers are suppressed in Rust and publish no signal;
// do not let their QML bookkeeping occupy the new funnel's two slots.
pending = ({})
scheduleEnsureVisible()
}
function toggle(albumId) {
var next = Object.assign({}, collapsed)
if (next[albumId]) delete next[albumId]
else next[albumId] = true
collapsed = next
}
function discKey(albumId, disc) { return albumId + "\n" + disc }
function discCollapsed(albumId, disc) {
return collapsedDiscs[discKey(albumId, disc)] === true
}
function toggleDisc(albumId, disc) {
var key = discKey(albumId, disc)
var next = Object.assign({}, collapsedDiscs)
if (next[key]) delete next[key]
else next[key] = true
collapsedDiscs = next
}
Connections {
target: QbzLocal
function onLocalGenreAlbumReady(albumId, json, filterJson) {
// A queued cache hit can cross a source-chip change even though
// the worker paths are generation-checked. Never label that old
// physical-version document as belonging to the current funnel.
if (filterJson !== root.mediaFilterJson) return
var doc = { "artKey": "", "tracks": [], "discs": [],
"versions": [], "versionIndex": 0 }
try {
var parsed = JSON.parse(json || "{}")
// Tolerate an in-flight publish from an older process during
// development; release builds always send the object shape.
doc = Array.isArray(parsed)
? { "artKey": "", "tracks": parsed, "discs": [],
"versions": [], "versionIndex": 0 }
: {
"artKey": parsed.artKey || "",
"tracks": parsed.tracks || [],
"discs": parsed.discs || [],
"versions": parsed.versions || [],
"versionIndex": Number(parsed.versionIndex || 0)
}
}
catch (e) { console.warn("[qbz-qt] genres: bad album rows — " + e) }
var cache = Object.assign({}, root.detailCache)
var filters = Object.assign({}, root.detailFilters)
var order = root.cacheOrder.slice()
var existing = order.indexOf(albumId)
if (existing >= 0) order.splice(existing, 1)
order.push(albumId)
while (order.length > 32) {
var evicted = order.shift()
delete cache[evicted]
delete filters[evicted]
}
// If the selected physical version itself did not change, retain
// the exact track/disc arrays. The picker can gain a second source
// without resetting hundreds of nested delegates that are already
// correct. `key` fingerprints the filtered physical row set, so a
// format/quality change with the same directory cannot reuse it.
var previous = cache[albumId]
if (previous) {
var previousVersions = previous.versions || []
var previousIndex = Number(previous.versionIndex || 0)
var previousVersion = previousVersions[previousIndex] || ({})
var nextVersion = doc.versions[doc.versionIndex] || ({})
if ((previousVersion.key || "") !== ""
&& previousVersion.key === (nextVersion.key || "")) {
doc.artKey = previous.artKey || doc.artKey
doc.tracks = previous.tracks || doc.tracks
doc.discs = previous.discs || doc.discs
}
}
cache[albumId] = doc
filters[albumId] = root.mediaFilterJson
root.cacheOrder = order
root.detailCache = cache
root.detailFilters = filters
var live = Object.assign({}, root.pending)
if (live[albumId] === root.mediaFilterJson) delete live[albumId]
root.pending = live
// The selected version can name a different artwork key. Report
// after the cache publish so the artwork window requests it even
// when the outer album list itself did not change.
root.scheduleReport()
root.scheduleEnsureVisible()
}
}
function report() {
if (!view || albums.length === 0 || !list.visible) return
var first = list.indexAt(2, list.contentY + 1)
var last = list.indexAt(2, list.contentY + list.height - 1)
if (first < 0) first = 0
if (last < 0) last = Math.min(albums.length - 1, first + 4)
// Only build the visible slice. Mapping all 1,800 albums on every
// scroll tick would undo the virtualization this view relies on.
var artworkRows = []
for (var i = first; i <= last && i < albums.length; i++) {
var album = albums[i]
var detail = detailCache[album.id]
artworkRows.push({ "artKey": detail && detail.artKey
? detail.artKey : (album.artKey || "") })
var discs = detail ? (detail.discs || []) : []
for (var d = 0; d < discs.length; d++)
if (discs[d].artKey)
artworkRows.push({ "artKey": discs[d].artKey })
}
if (artworkRows.length > 0)
view.queueWindowReport(artworkRows, 0, artworkRows.length - 1,
"genres-details")
}
ListView {
id: list
anchors.fill: parent
anchors.rightMargin: 14
clip: true
reuseItems: true
// One expanded album may be thousands of pixels tall. A 600px outer
// cache eagerly queried whole albums above and below the viewport;
// the row-level window below supplies the useful look-ahead instead.
cacheBuffer: 240
boundsBehavior: Flickable.StopAtBounds
model: root.albums
onContentYChanged: {
root.scheduleReport()
root.scheduleEnsureVisible()
}
onHeightChanged: {
root.scheduleReport()
root.scheduleEnsureVisible()
}
onModelChanged: {
root.scheduleReport()
root.scheduleEnsureVisible()
}
delegate: Item {
id: albumBlock
required property var modelData
required property int index
readonly property var detail: root.detailCache[modelData.id]
|| ({ "artKey": "", "tracks": [], "discs": [],
"versions": [], "versionIndex": 0 })
readonly property var tracks: detail.tracks || []
readonly property var discs: detail.discs || []
readonly property var versions: detail.versions || []
readonly property int versionIndex: Number(detail.versionIndex || 0)
readonly property bool loaded: root.detailCache[modelData.id] !== undefined
readonly property bool expanded: root.collapsed[modelData.id] !== true
readonly property int discHeaders: loaded ? discs.length : 0
function startsDisc(rowIndex) {
if (albumBlock.discHeaders === 0) return false
if (rowIndex === 0) return true
return Number(tracks[rowIndex - 1].disc || 1)
!== Number(tracks[rowIndex].disc || 1)
}
function discInfo(number) {
for (var i = 0; i < discs.length; i++)
if (Number(discs[i].disc || 0) === number) return discs[i]
return null
}
function discIsCollapsed(number) {
return root.discCollapsed(modelData.id, number)
}
function discArt(info) {
if (!info) return ""
return (root.view && info.artKey
&& root.view.artMap[info.artKey])
|| info.cover || ""
}
readonly property bool discArtDistinct: {
if (discs.length < 2) return false
var seen = {}
for (var i = 0; i < discs.length; i++) {
var path = discArt(discs[i])
if (path === "" || seen[path]) return false
seen[path] = true
}
return true
}
// The server-backed track count is useful metadata, not geometry.
// Reserving `trackCount * rowHeight` before the detail arrives made
// a cold 13-track album look like 650 px of empty content, and a
// large box set could consume several whole screens with only a
// spinner at its top. Keep cold rows compact; once the visible
// album resolves, its real track rows become its real height.
readonly property int visibleTracks: {
if (!loaded) return 0
var count = 0
for (var i = 0; i < tracks.length; i++) {
if (!discIsCollapsed(Number(tracks[i].disc || 1))) count++
}
return count
}
readonly property int headerH: 72
readonly property int discHeaderH: 46
readonly property int loadingBodyH: 50
readonly property real bodyHeight: loaded
? visibleTracks * 50 + discHeaders * discHeaderH : 0
// Keep the album itself as one variable-height outer delegate, but
// give its tracks a real viewport. The former nested Repeater
// constructed one wrapper and two Loaders for all 247 tracks in a
// box set before the first frame could paint. Moving this small
// ListView through the visible slice preserves the exact outer
// geometry while Qt only instantiates the rows on screen.
readonly property real trackViewportStart: Math.max(
0, list.contentY - y - headerH - 300)
readonly property real trackViewportEnd: Math.min(
bodyHeight, list.contentY + list.height - y - headerH + 300)
readonly property real trackViewportHeight: Math.max(
0, trackViewportEnd - trackViewportStart)
function ensureCurrent() {
// Delegate creation is only a visibility hint. Let the view's
// bounded scheduler choose foreground albums after ListView
// has settled their current geometry.
root.scheduleEnsureVisible()
}
width: list.width
height: headerH + (expanded
? (loaded
? bodyHeight + 12
: loadingBodyH)
: 0)
// The Rust bridge always publishes cache hits on the next Qt
// event-loop turn, so requesting synchronously is safe and leaves
// no delegate-owned callback behind when ListView recycles it.
Component.onCompleted: ensureCurrent()
onModelDataChanged: ensureCurrent()
LocalAlbumRow {
x: 4
width: parent.width - 8
view: root.view
item: albumBlock.modelData
artSource: root.view
? ((albumBlock.detail.artKey
&& root.view.artMap[albumBlock.detail.artKey])
|| albumBlock.modelData.artPath
|| root.view.artMap[albumBlock.modelData.artKey] || "") : ""
isFavorite: root.view
? root.view.albumFavorite(albumBlock.modelData)
: albumBlock.modelData.isFavorite === true
showSource: true
expandable: true
expanded: albumBlock.expanded
detailsMode: true
versions: albumBlock.versions.length > 0
? albumBlock.versions
: [{
"version": "",
"trackCount": albumBlock.modelData.trackCount || 0,
"quality": albumBlock.modelData.qualityDetail || "",
"source": albumBlock.modelData.sourceRaw
|| albumBlock.modelData.source || "local"
}]
versionIndex: albumBlock.versionIndex
onOpened: root.view.openAlbum(albumBlock.modelData.id)
onPlayRequested: QbzLocal.genreAlbumAction(
albumBlock.modelData.id, "play", "")
onShuffleRequested: QbzLocal.genreAlbumAction(
albumBlock.modelData.id, "shuffle", "")
onEnqueueRequested: function(mode) {
QbzLocal.genreAlbumAction(albumBlock.modelData.id, mode, "")
}
onFavoriteRequested: if (root.view) {
root.view.toggleAlbumFavorite(albumBlock.modelData, artSource)
}
onToggleExpanded: root.toggle(albumBlock.modelData.id)
onVersionPicked: function(index) {
QbzLocal.genreAlbumSelectVersion(albumBlock.modelData.id, index)
}
}
QbzSpinner {
visible: albumBlock.expanded && !albumBlock.loaded
anchors.horizontalCenter: parent.horizontalCenter
y: albumBlock.headerH
+ (albumBlock.loadingBodyH - height) / 2
size: 18
}
ListView {
id: trackList
visible: albumBlock.expanded && albumBlock.loaded
&& albumBlock.trackViewportHeight > 0
x: 4
y: albumBlock.headerH + albumBlock.trackViewportStart
width: parent.width - 8
height: albumBlock.trackViewportHeight
model: albumBlock.tracks
contentY: albumBlock.trackViewportStart
interactive: false
clip: true
reuseItems: true
cacheBuffer: 300
boundsBehavior: Flickable.StopAtBounds
delegate: Item {
id: trackBlock
required property var modelData
required property int index
width: parent ? parent.width : 0
readonly property bool showDisc: albumBlock.startsDisc(index)
readonly property int discNumber: Number(modelData.disc || 1)
readonly property bool discFolded:
albumBlock.discIsCollapsed(discNumber)
height: (showDisc ? albumBlock.discHeaderH : 0)
+ (discFolded ? 0 : 50)
// A divider exists only on disc boundaries and only
// while this virtualized delegate is near the viewport.
Loader {
width: parent.width
height: trackBlock.showDisc ? albumBlock.discHeaderH : 0
active: trackBlock.showDisc
sourceComponent: Item {
id: discHeader
width: parent.width
height: albumBlock.discHeaderH
readonly property var info:
albumBlock.discInfo(trackBlock.discNumber)
QbzIconButton {
x: 8
anchors.verticalCenter: parent.verticalCenter
btnSize: 26
iconSize: 12
name: trackBlock.discFolded ? "plus" : "minus"
onClicked: root.toggleDisc(
albumBlock.modelData.id, trackBlock.discNumber)
}
RoundedImage {
id: discThumb
x: 44
width: 30
height: 30
radius: theme.radiusSm
anchors.verticalCenter: parent.verticalCenter
visible: albumBlock.discArtDistinct && source !== ""
source: albumBlock.discArt(parent.info)
}
Text {
x: discThumb.visible ? 84 : 44
width: Math.max(0, discActions.x - x - 12)
anchors.verticalCenter: parent.verticalCenter
text: {
var base = QbzSession.tr("Disc", QbzSession.trRev)
+ " " + trackBlock.discNumber
return parent.info && parent.info.title
? base + " — " + parent.info.title : base
}
color: theme.textSecondary
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
elide: Text.ElideRight
}
// Smaller than AlbumView's 32/44px header actions:
// these float right, before the trailing table
// columns, and do not become a centred hero rail.
Row {
id: discActions
x: parent.width - 176
anchors.verticalCenter: parent.verticalCenter
spacing: 6
QbzIconButton {
btnSize: 26
iconSize: 13
name: "play-fill"
onClicked: QbzLocal.genreAlbumDiscAction(
albumBlock.modelData.id,
trackBlock.discNumber,
"play")
}
QbzIconButton {
btnSize: 26
iconSize: 13
name: "shuffle"
onClicked: QbzLocal.genreAlbumDiscAction(
albumBlock.modelData.id,
trackBlock.discNumber,
"shuffle")
}
}
QbzIconButton {
id: discMenuButton
x: parent.width - 70
anchors.verticalCenter: parent.verticalCenter
btnSize: 32
name: "ellipsis"
iconSize: 16
onClicked: {
discMenuLoader.active = true
discMenuLoader.item.openBelowRight(discMenuButton)
}
}
// A popup with five delegated rows is useful only
// after the click. Keep it out of every box-set
// divider's steady-state scene tree.
Loader {
id: discMenuLoader
active: false
sourceComponent: CardMenu {
menuWidth: 200
entries: [
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "label": QbzSession.tr("Shuffle", QbzSession.trRev), "icon": "shuffle", "action": "shuffle" },
{ "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 (action) {
QbzLocal.genreAlbumDiscAction(
albumBlock.modelData.id,
trackBlock.discNumber,
action)
}
}
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
color: theme.borderSubtle
}
}
}
Loader {
y: trackBlock.showDisc ? albumBlock.discHeaderH : 0
width: parent.width
height: trackBlock.discFolded ? 0 : 50
active: !trackBlock.discFolded
sourceComponent: LocalTrackRow {
view: root.view
item: trackBlock.modelData
number: trackBlock.modelData.number > 0
? trackBlock.modelData.number : trackBlock.index + 1
showAlbum: false
showArtwork: false
zebra: true
onPlayRequested: QbzLocal.genreAlbumAction(
albumBlock.modelData.id, "play",
trackBlock.modelData.id)
onEnqueueRequested: function(mode) {
QbzLocal.enqueue("track", trackBlock.modelData.id, mode)
}
}
}
}
}
}
}
// The per-album spinner can sit below a restored scroll offset while its
// first document is cold. This viewport-level affordance stays visible
// until any detail document lands, then never flashes for ordinary
// look-ahead requests during scrolling.
Rectangle {
visible: root.initialDetailLoading
z: 3
anchors.centerIn: parent
width: 44
height: 44
radius: 8
color: theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated
border.width: 1
border.color: theme.ambientOn ? theme.frostBorder : theme.borderSubtle
QbzSpinner {
anchors.centerIn: parent
size: 20
}
}
ScrollMemory { target: list; scope: "local:genres" }
QbzScrollBar {
target: list
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
}
Component.onCompleted: scheduleReport()
Component.onDestruction: if (view) view.releaseWindow("genres-details")
}