Render only the visible track slice for expanded box sets and share physical version caches without cloning every source row. cx
613 lines
28 KiB
QML
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")
|
|
}
|