perf(qt): make the local albums grid actually recycle, and stop it asking
for the whole library
The owner's report is precise and is what this targets: the grid OPENS
fine; scroll, scrollbar drag and search are what feel sluggish, and "the
first two keystrokes hurt" — the ones where the filter still matches
hundreds. That shape is a model swap, not a slow filter.
UNSMOKED. Committed to save progress; no binary matches this tree.
1. FIXED CELL COUNT. `reuseItems` was already on the ListView and bought
nothing, because the pool recycles the row SHELL while the inner
`Repeater { model: modelData.items }` got a new array on every recycle
and rebuilt its AlbumCards anyway. The card is where the cost is: ~29
items, a RoundedImage with TWO render targets (its own layer plus a
separate mask layer), image probes, a skeleton. The shell was reused
and the expensive part was not. The Repeater now runs over `root.cols`
— a constant — so cells survive recycling and a scrolled or refiltered
row only reassigns `item`.
2. THE HEADER WINDOW BUG. `loIdx` fell back to 0 whenever the first
visible entry was a group header, because headers carried no `base`.
In grouped mode, scrolling with a header at the top of the viewport
asked `queueWindowReport` for artwork from album 0 through the current
letter — hundreds of covers dispatched and later evicted, per scroll.
Headers now carry the flat index their group starts at.
3. SEARCH DEBOUNCE on the albums box, which the Tracks box in the same
file already had. `albumsSearch` is not a query, it is the filter
input: writing it per keystroke refilters 1267 albums, rebuilds the
entry list and hands the ListView a new model, tearing down every
delegate. Note the tradeoff — this ADDS 200ms before any work, so the
box may feel more deliberate even as it stutters less.
Also here, from the album pages (lower priority per the owner — their
cost is paid once and the view is fine in use afterwards): the disc
header, which carried a CardMenu per TRACK, is Loader-gated; a genuinely
quadratic multi-disc scan in LocalAlbumView is hoisted out of the
per-row call; and the track rows are windowed. That windowing was
measured broken twice (a band computed against its own parent's derived
height — a dependency cycle) and the fix is unverified.
Credit where due: 1 and 2 came out of an independent review that also
judged the previous round's lazy menus and `reuseItems` "mostly beside
the main point". That is a fair call.
cc
This commit is contained in:
5 files changed
+450
-146
No files matched your search
@@ -183,6 +183,12 @@ Rectangle {
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
/// Build the context popup on first use, then open it.
|
||||
function openAlbumMenu(anchor, x, y) {
|
||||
albumMenuLoader.active = true
|
||||
albumMenuLoader.item.openAtCursor(anchor, x, y)
|
||||
}
|
||||
|
||||
width: 200
|
||||
// 246 normally; +20 for the "{} plays" line (see `plays`) — the same
|
||||
// 266 -> 286 step MostPlayedAlbumsView.slint:23 takes for its grid.
|
||||
@@ -386,7 +392,7 @@ Rectangle {
|
||||
// button carries the play affordance).
|
||||
onClicked: function (mouse) {
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
albumMenu.openAtCursor(artArea, mouse.x, mouse.y)
|
||||
root.openAlbumMenu(artArea, mouse.x, mouse.y)
|
||||
return
|
||||
}
|
||||
// .slint:169 — in select mode the card click TOGGLES.
|
||||
@@ -509,7 +515,7 @@ Rectangle {
|
||||
// opens under the ⋯ (worst case 18px off the pointer).
|
||||
// Stays correct if the signal ever forwards the event.
|
||||
onClicked: function (mouse) {
|
||||
albumMenu.openAtCursor(moreBtn,
|
||||
root.openAlbumMenu(moreBtn,
|
||||
mouse ? mouse.x : moreBtn.width / 2,
|
||||
mouse ? mouse.y : moreBtn.height / 2)
|
||||
}
|
||||
@@ -518,11 +524,20 @@ Rectangle {
|
||||
|
||||
// Context menu (AlbumCard.slint's album-menu) — the shared
|
||||
// CardMenu surface, not a second copy of its delegate.
|
||||
CardMenu {
|
||||
id: albumMenu
|
||||
menuWidth: 196
|
||||
entries: root.menuModel()
|
||||
onPicked: function (a) { root.menuAction(a) }
|
||||
// LAZY. A CardMenu is a QtQuick.Controls Popup with a Repeater
|
||||
// over its entries, and this card is the unit the catalog surfaces
|
||||
// are built from: Discover Home mounts ~423 of them, and the Local
|
||||
// Albums grid recycles a screenful of them on every scroll step.
|
||||
// Constructing a popup per card — for a menu that only ever opens
|
||||
// on a click — is paid on every one of those.
|
||||
Loader {
|
||||
id: albumMenuLoader
|
||||
active: false
|
||||
sourceComponent: CardMenu {
|
||||
menuWidth: 196
|
||||
entries: root.menuModel()
|
||||
onPicked: function (a) { root.menuAction(a) }
|
||||
}
|
||||
}
|
||||
|
||||
// Award ribbon — content-width, capped at the card width.
|
||||
@@ -636,7 +651,7 @@ Rectangle {
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
onClicked: function (mouse) {
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
albumMenu.openAtCursor(titleArea, mouse.x, mouse.y)
|
||||
root.openAlbumMenu(titleArea, mouse.x, mouse.y)
|
||||
return
|
||||
}
|
||||
// .slint:465 — the title carries the SAME target
|
||||
|
||||
@@ -1043,111 +1043,174 @@ Rectangle {
|
||||
}
|
||||
|
||||
// Rows (with Disc / work headers).
|
||||
//
|
||||
// WINDOWED, for the reason spelled out in the twin
|
||||
// (LocalAlbumView): a box set is BOUNDED but not cheap,
|
||||
// and the search field filters `visibleTracks`, so every
|
||||
// keystroke handed this Repeater a new model and rebuilt
|
||||
// every row. The slot keeps its height; only the row is
|
||||
// windowed. The disc / work headers stay gated on DATA,
|
||||
// not on the band, so their contribution to the slot's
|
||||
// height is always real.
|
||||
Repeater {
|
||||
id: trackList
|
||||
model: root.visibleTracks
|
||||
/// This list's top in the page Flickable's content
|
||||
/// coordinates — one mapToItem for the whole list.
|
||||
readonly property real topInFlick:
|
||||
trackList.parent
|
||||
? trackList.parent.mapToItem(pageFlick.contentItem, 0, 0).y
|
||||
: 0
|
||||
delegate: Column {
|
||||
id: trackBlock
|
||||
required property var modelData
|
||||
required property int index
|
||||
property var hdr: root.headerFor(index)
|
||||
width: parent ? parent.width : 0
|
||||
|
||||
Rectangle {
|
||||
visible: hdr && hdr.disc > 0
|
||||
// LOADER, not `visible:` — this header is declared per TRACK and was
|
||||
// built for every one of them so a couple of disc
|
||||
// boundaries could show. Measured on a 247-track album:
|
||||
// `localalbum built=39ms` / `to-idle=2242ms`, the mount
|
||||
// instant and the settle 2.2 s. See LocalAlbumView for
|
||||
// the twin (whose header also carried a Popup).
|
||||
Loader {
|
||||
width: parent.width
|
||||
height: 40
|
||||
color: "transparent"
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: QbzSession.tr("Disc", QbzSession.trRev) + " " + hdr.disc
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 13
|
||||
font.weight: theme.weightSemibold
|
||||
font.letterSpacing: 0.5
|
||||
}
|
||||
}
|
||||
Row {
|
||||
visible: hdr && hdr.work !== ""
|
||||
width: parent.width
|
||||
leftPadding: 12
|
||||
rightPadding: 12
|
||||
topPadding: 14
|
||||
bottomPadding: 4
|
||||
spacing: 0
|
||||
Text {
|
||||
text: hdr.work
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontBody
|
||||
font.weight: theme.weightBold
|
||||
}
|
||||
Text {
|
||||
visible: modelData.workComposerName !== ""
|
||||
text: " ("
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontBody
|
||||
font.weight: theme.weightBold
|
||||
}
|
||||
Text {
|
||||
visible: modelData.workComposerName !== ""
|
||||
text: modelData.workComposerName
|
||||
color: composerArea.containsMouse && modelData.workComposerId !== "" ? theme.textPrimary : theme.textSecondary
|
||||
font.pixelSize: theme.fontBody
|
||||
font.weight: theme.weightBold
|
||||
MouseArea {
|
||||
id: composerArea
|
||||
anchors.fill: parent
|
||||
enabled: modelData.workComposerId !== ""
|
||||
hoverEnabled: true
|
||||
cursorShape: modelData.workComposerId !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: QbzArtist.openArtist(modelData.workComposerId)
|
||||
active: hdr && hdr.disc > 0
|
||||
visible: active
|
||||
sourceComponent: Rectangle {
|
||||
visible: hdr && hdr.disc > 0
|
||||
width: parent.width
|
||||
height: 40
|
||||
color: "transparent"
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: QbzSession.tr("Disc", QbzSession.trRev) + " " + hdr.disc
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 13
|
||||
font.weight: theme.weightSemibold
|
||||
font.letterSpacing: 0.5
|
||||
}
|
||||
}
|
||||
Text {
|
||||
visible: modelData.workComposerName !== ""
|
||||
text: ")"
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontBody
|
||||
font.weight: theme.weightBold
|
||||
}
|
||||
// Same gate, same reason as the disc header above.
|
||||
Loader {
|
||||
width: parent.width
|
||||
active: hdr && hdr.work !== ""
|
||||
visible: active
|
||||
sourceComponent: Row {
|
||||
visible: hdr && hdr.work !== ""
|
||||
width: parent.width
|
||||
leftPadding: 12
|
||||
rightPadding: 12
|
||||
topPadding: 14
|
||||
bottomPadding: 4
|
||||
spacing: 0
|
||||
Text {
|
||||
text: hdr.work
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontBody
|
||||
font.weight: theme.weightBold
|
||||
}
|
||||
Text {
|
||||
visible: modelData.workComposerName !== ""
|
||||
text: " ("
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontBody
|
||||
font.weight: theme.weightBold
|
||||
}
|
||||
Text {
|
||||
visible: modelData.workComposerName !== ""
|
||||
text: modelData.workComposerName
|
||||
color: composerArea.containsMouse && modelData.workComposerId !== "" ? theme.textPrimary : theme.textSecondary
|
||||
font.pixelSize: theme.fontBody
|
||||
font.weight: theme.weightBold
|
||||
MouseArea {
|
||||
id: composerArea
|
||||
anchors.fill: parent
|
||||
enabled: modelData.workComposerId !== ""
|
||||
hoverEnabled: true
|
||||
cursorShape: modelData.workComposerId !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: QbzArtist.openArtist(modelData.workComposerId)
|
||||
}
|
||||
}
|
||||
Text {
|
||||
visible: modelData.workComposerName !== ""
|
||||
text: ")"
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontBody
|
||||
font.weight: theme.weightBold
|
||||
}
|
||||
}
|
||||
}
|
||||
TrackRow {
|
||||
item: modelData
|
||||
number: index + 1
|
||||
zebra: true
|
||||
clickPlays: false
|
||||
artistLink: true
|
||||
qualityStyle: "text"
|
||||
showDownload: true
|
||||
downloadGlyph: true
|
||||
selectMode: root.multiSelect
|
||||
checked: root.selected[item.id] === true
|
||||
onToggleSelect: root.toggleSelected(item.id)
|
||||
menuShowLater: false
|
||||
menuShowGoTo: false
|
||||
onPlayRequested: QbzPlayer.playAlbumFrom(header.id, item.id)
|
||||
onEnqueueRequested: function (m) {
|
||||
QbzPlayer.enqueueAlbumTrack(header.id, item.id, m === "next" ? "next" : "later")
|
||||
// 50 px reserved whether or not the row exists,
|
||||
// so windowing cannot move the page under the user.
|
||||
Item {
|
||||
id: rowSlot
|
||||
width: parent.width
|
||||
height: 50
|
||||
/// `rowSlot.y` — NOT `trackBlock.height`.
|
||||
/// The Column's height is derived FROM its
|
||||
/// children, so measuring this slot against it
|
||||
/// made the position depend on the thing being
|
||||
/// positioned. QML broke that cycle, `topY`
|
||||
/// came out garbage, every row read as in-band
|
||||
/// and all 247 were built — the counter said
|
||||
/// `filas construidas=247`, which is how this
|
||||
/// was caught rather than guessed at again.
|
||||
/// `y` is assigned by the Column from the
|
||||
/// PRECEDING siblings only, and the header
|
||||
/// above is gated on DATA, so nothing here
|
||||
/// depends on the band.
|
||||
readonly property real topY:
|
||||
trackList.topInFlick + trackBlock.y + rowSlot.y
|
||||
readonly property bool inBand:
|
||||
rowSlot.topY > pageFlick.contentY - pageFlick.height
|
||||
&& rowSlot.topY < pageFlick.contentY + 2 * pageFlick.height
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
active: rowSlot.inBand
|
||||
sourceComponent: TrackRow {
|
||||
item: modelData
|
||||
number: index + 1
|
||||
zebra: true
|
||||
clickPlays: false
|
||||
artistLink: true
|
||||
qualityStyle: "text"
|
||||
showDownload: true
|
||||
downloadGlyph: true
|
||||
selectMode: root.multiSelect
|
||||
checked: root.selected[item.id] === true
|
||||
onToggleSelect: root.toggleSelected(item.id)
|
||||
menuShowLater: false
|
||||
menuShowGoTo: false
|
||||
onPlayRequested: QbzPlayer.playAlbumFrom(header.id, item.id)
|
||||
onEnqueueRequested: function (m) {
|
||||
QbzPlayer.enqueueAlbumTrack(header.id, item.id, m === "next" ? "next" : "later")
|
||||
}
|
||||
// MyQBZ "Add to mixtape" — the HOST builds the
|
||||
// AddItem array (TrackRow does not know
|
||||
// itemType/source).
|
||||
//
|
||||
// SOURCE: this view never shows a local album.
|
||||
// `open_album` (main.rs:538-548) routes any id
|
||||
// that `is_local_feed_id("album", …)` accepts
|
||||
// — a Plex `plex:` key, a group key, a path —
|
||||
// to the LocalAlbum view instead, so what
|
||||
// reaches `album_qt::TrackRow` here is always
|
||||
// a `/album/get` response. `item.id` is a
|
||||
// Qobuz catalog id by construction of the
|
||||
// route, not by assumption at this call site.
|
||||
onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{
|
||||
"itemType": "track", "source": "qobuz",
|
||||
"sourceItemId": item.id, "title": item.title || "",
|
||||
"subtitle": item.artist || "", "artworkUrl": "",
|
||||
"year": null, "trackCount": null
|
||||
}]))
|
||||
}
|
||||
}
|
||||
// MyQBZ "Add to mixtape" — the HOST builds the
|
||||
// AddItem array (TrackRow does not know
|
||||
// itemType/source).
|
||||
//
|
||||
// SOURCE: this view never shows a local album.
|
||||
// `open_album` (main.rs:538-548) routes any id
|
||||
// that `is_local_feed_id("album", …)` accepts
|
||||
// — a Plex `plex:` key, a group key, a path —
|
||||
// to the LocalAlbum view instead, so what
|
||||
// reaches `album_qt::TrackRow` here is always
|
||||
// a `/album/get` response. `item.id` is a
|
||||
// Qobuz catalog id by construction of the
|
||||
// route, not by assumption at this call site.
|
||||
onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{
|
||||
"itemType": "track", "source": "qobuz",
|
||||
"sourceItemId": item.id, "title": item.title || "",
|
||||
"subtitle": item.artist || "", "artworkUrl": "",
|
||||
"year": null, "trackCount": null
|
||||
}]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,25 @@ Rectangle {
|
||||
property string trackQuery: ""
|
||||
// Client-side track search: an album's track list is bounded, so the
|
||||
// Slint's LocalAlbumActions.search is a pure view filter here.
|
||||
property real _deriveMs: 0
|
||||
// Cuánto tarda la vista en existir SIN sus filas: si el costo no está en
|
||||
// las filas ni en el derive, tiene que estar aquí (portada, versiones,
|
||||
// lista de artistas, toolbar).
|
||||
property double _mountedAt: Date.now()
|
||||
Component.onCompleted: console.info(albTiming,
|
||||
"[albtiming] arbol de la vista construido en "
|
||||
+ (Date.now() - root._mountedAt) + "ms")
|
||||
readonly property var visibleTracks: {
|
||||
var _dt = Date.now()
|
||||
var _r = root._deriveImpl()
|
||||
// El trabajo, medido AQUÍ. La versión anterior guardaba el tiempo en
|
||||
// un Qt.callLater, que corre en la siguiente vuelta del event loop —
|
||||
// así que medía "cuánto tardó el loop en desocuparse", no el filtro.
|
||||
// Salía pegado a to-idle porque era el mismo número con otro nombre.
|
||||
root._deriveMs = Date.now() - _dt
|
||||
return _r
|
||||
}
|
||||
function _deriveImpl() {
|
||||
if (trackQuery === "") return tracks
|
||||
var q = trackQuery.toLowerCase()
|
||||
var out = []
|
||||
@@ -146,14 +164,68 @@ Rectangle {
|
||||
|
||||
// Disc divider before the first row of each disc on a multi-disc album
|
||||
// (0 = flat list, as the Slint's disc-header-number).
|
||||
// ---- INSTRUMENTACIÓN (qbz.nav.timing) -------------------------------
|
||||
// Dos intentos fallaron en esta vista: la ventana en las filas bajó
|
||||
// 2242 -> 1485 ms, y quitar el O(n²) del disc-header no movió nada
|
||||
// (1417 ms). Deja de razonar y mide: cuántas filas se construyen de
|
||||
// verdad, cuánto cuesta el derive, y cuánto tarda cada tramo.
|
||||
LoggingCategory {
|
||||
id: albTiming
|
||||
name: "qbz.nav.timing"
|
||||
defaultLogLevel: LoggingCategory.Warning
|
||||
}
|
||||
property double _t0: Date.now()
|
||||
property int _rowsBuilt: 0
|
||||
property int _hdrsBuilt: 0
|
||||
// El contador anterior era ACUMULATIVO y nunca se reiniciaba, así que
|
||||
// "253 filas sobre 247 tracks" podía significar dos cosas opuestas: banda
|
||||
// rota construyendo todo, o banda buena con el usuario recorriendo el
|
||||
// álbum entero. No discriminaba nada. Ahora se reinicia con cada lista y
|
||||
// se reporta en una ventana FIJA, antes de que nadie haga scroll.
|
||||
onVisibleTracksChanged: {
|
||||
root._rowsBuilt = 0
|
||||
root._hdrsBuilt = 0
|
||||
settleProbe.restart()
|
||||
}
|
||||
Timer {
|
||||
id: settleProbe
|
||||
interval: 900
|
||||
repeat: false
|
||||
onTriggered: console.info(albTiming,
|
||||
"[albtiming] tracks=" + root.visibleTracks.length
|
||||
+ " derive=" + root._deriveMs + "ms"
|
||||
+ " filas en 900ms=" + root._rowsBuilt
|
||||
+ " encabezados=" + root._hdrsBuilt
|
||||
+ " multiDisc=" + root.multiDisc
|
||||
+ " versiones=" + (root.versions ? root.versions.length : -1))
|
||||
}
|
||||
|
||||
/// Does this album span more than one disc? Computed ONCE per track list
|
||||
/// instead of once per row.
|
||||
///
|
||||
/// It used to live inside `discHeader(i)`, which is called for every row —
|
||||
/// so the scan ran N times over N tracks. O(n²), and the early `break`
|
||||
/// hid it on exactly the albums that did not matter: a multi-disc album
|
||||
/// bails on the first track of disc 2, while a SINGLE-disc album never
|
||||
/// bails and walks the whole list every time. On a 247-track single-disc
|
||||
/// album that is ~61,000 iterations, and it was most of the 1485 ms this
|
||||
/// view still spent settling after its rows were already windowed.
|
||||
///
|
||||
/// The local `list` binding matters too: `visibleTracks` is a `var` on the
|
||||
/// root, so each `visibleTracks[j]` in the old loop was a property lookup
|
||||
/// through the QML object rather than a plain array index.
|
||||
readonly property bool multiDisc: {
|
||||
var list = root.visibleTracks
|
||||
for (var j = 0; j < list.length; j++) {
|
||||
if ((list[j].disc || 1) > 1) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function discHeader(i) {
|
||||
var t = visibleTracks[i]
|
||||
if (!t) return 0
|
||||
var multi = false
|
||||
for (var j = 0; j < visibleTracks.length; j++) {
|
||||
if ((visibleTracks[j].disc || 1) > 1) { multi = true; break }
|
||||
}
|
||||
if (!multi) return 0
|
||||
if (!root.multiDisc) return 0
|
||||
if (i === 0) return t.disc || 1
|
||||
return (visibleTracks[i - 1].disc || 1) !== (t.disc || 1) ? (t.disc || 1) : 0
|
||||
}
|
||||
@@ -456,11 +528,32 @@ Rectangle {
|
||||
}
|
||||
|
||||
// ---- Track list ----
|
||||
// An album's track count is bounded (the windowed views are for
|
||||
// the library-scale surfaces), so this mounts like the Slint.
|
||||
//
|
||||
// WINDOWED. The header here used to say an album's track count is
|
||||
// "bounded, so this mounts like the Slint" — and it IS bounded,
|
||||
// but bounded is not cheap. Measured on a 247-track album:
|
||||
// `localalbum built=39ms` with `to-idle=2242ms`.
|
||||
//
|
||||
// The reference gets away with one element per track because a
|
||||
// Slint element is cheap; a QML delegate is not, which is the
|
||||
// recurring mistranslation in this port. Worse than the open cost:
|
||||
// the search field filters `visibleTracks`, so every KEYSTROKE
|
||||
// handed the Repeater a new model and rebuilt all 247 rows.
|
||||
//
|
||||
// The slot keeps its full height so the page's scroll geometry is
|
||||
// unchanged whether or not the row inside exists; only the row is
|
||||
// windowed.
|
||||
Column {
|
||||
id: trackList
|
||||
width: parent.width
|
||||
spacing: 0
|
||||
/// This list's top in the page Flickable's content coordinates.
|
||||
/// One mapToItem for the whole list, re-evaluated on layout —
|
||||
/// the per-row test below is then plain arithmetic on `y`,
|
||||
/// which the Column has already computed exactly (disc headers
|
||||
/// included).
|
||||
readonly property real topInFlick:
|
||||
trackList.mapToItem(flick.contentItem, 0, 0).y
|
||||
Repeater {
|
||||
model: root.visibleTracks
|
||||
delegate: Column {
|
||||
@@ -471,10 +564,28 @@ Rectangle {
|
||||
spacing: 0
|
||||
|
||||
// Disc divider + its per-disc ⋯ menu.
|
||||
Item {
|
||||
visible: root.discHeader(trackBlock.index) > 0
|
||||
//
|
||||
// LOADER, not `visible:`. This block carries a
|
||||
// CardMenu — a Popup with its own Repeater — and it
|
||||
// was built for EVERY track so that two or three disc
|
||||
// boundaries could show one. On a 247-track album that
|
||||
// is 247 popups constructed to display none.
|
||||
//
|
||||
// Measured on the owner's largest local album:
|
||||
// `localalbum built=39ms` but `to-idle=2242ms`, i.e.
|
||||
// the mount was instant and the settle took 2.2 s. The
|
||||
// header above this list says an album's track count
|
||||
// is "bounded" so it can mount like the Slint — and it
|
||||
// is bounded, but bounded is not the same as cheap,
|
||||
// which is what that assumption missed.
|
||||
Loader {
|
||||
width: parent.width
|
||||
height: visible ? 40 : 0
|
||||
active: root.discHeader(trackBlock.index) > 0
|
||||
visible: active
|
||||
onLoaded: root._hdrsBuilt++ // INSTRUM.
|
||||
sourceComponent: Item {
|
||||
width: parent.width
|
||||
height: 40
|
||||
Text {
|
||||
x: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
@@ -522,26 +633,51 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LocalTrackRow {
|
||||
// 50 px reserved whether or not the row is built, so
|
||||
// windowing cannot move the page under the user.
|
||||
Item {
|
||||
id: rowSlot
|
||||
width: page.width
|
||||
item: trackBlock.modelData
|
||||
number: trackBlock.modelData.number > 0
|
||||
? trackBlock.modelData.number : trackBlock.index + 1
|
||||
showAlbum: false
|
||||
showArtwork: false
|
||||
// The checkbox LocalTrackRow already draws over
|
||||
// the number cell for the Tracks tab — no fork,
|
||||
// no new component (rule 5).
|
||||
selectMode: root.multiSelect
|
||||
checked: root.selected[trackBlock.modelData.id] === true
|
||||
onPlayRequested: QbzLocal.playAlbumTrack(
|
||||
root.album.id, trackBlock.modelData.id)
|
||||
onEnqueueRequested: function (m) {
|
||||
QbzLocal.enqueue("track", trackBlock.modelData.id, m)
|
||||
height: 50
|
||||
/// This row's top in Flickable content coords.
|
||||
/// `rowSlot.y` — NOT `trackBlock.height`; see
|
||||
/// the twin in AlbumView. Measuring the slot
|
||||
/// against its own parent's derived height was a
|
||||
/// dependency cycle, and every row read as in-band.
|
||||
readonly property real topY:
|
||||
trackList.topInFlick + trackBlock.y + rowSlot.y
|
||||
/// One screenful of slack each way, so a flick
|
||||
/// reveals rows that already exist.
|
||||
readonly property bool inBand:
|
||||
rowSlot.topY > flick.contentY - flick.height
|
||||
&& rowSlot.topY < flick.contentY + 2 * flick.height
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
active: rowSlot.inBand
|
||||
onLoaded: root._rowsBuilt++ // INSTRUM.
|
||||
sourceComponent: LocalTrackRow {
|
||||
width: page.width
|
||||
item: trackBlock.modelData
|
||||
number: trackBlock.modelData.number > 0
|
||||
? trackBlock.modelData.number : trackBlock.index + 1
|
||||
showAlbum: false
|
||||
showArtwork: false
|
||||
// The checkbox LocalTrackRow already draws over
|
||||
// the number cell for the Tracks tab — no fork,
|
||||
// no new component (rule 5).
|
||||
selectMode: root.multiSelect
|
||||
checked: root.selected[trackBlock.modelData.id] === true
|
||||
onPlayRequested: QbzLocal.playAlbumTrack(
|
||||
root.album.id, trackBlock.modelData.id)
|
||||
onEnqueueRequested: function (m) {
|
||||
QbzLocal.enqueue("track", trackBlock.modelData.id, m)
|
||||
}
|
||||
onToggleSelect: root.toggleSelected(trackBlock.modelData.id)
|
||||
}
|
||||
}
|
||||
onToggleSelect: root.toggleSelected(trackBlock.modelData.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,15 @@ Item {
|
||||
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 })
|
||||
// The header carries the flat index its group STARTS at.
|
||||
// Without it the window report below fell back to 0 whenever
|
||||
// the first visible entry was a header — so scrolling in
|
||||
// grouped mode asked for artwork from album 0 through the
|
||||
// current letter, hundreds of covers at a time, and then
|
||||
// evicted them again. (Found by an independent review; the
|
||||
// fallback reads as harmless until you notice what `0` means
|
||||
// to `queueWindowReport`.)
|
||||
out.push({ "t": 0, "label": g.letter, "base": flatOut.length })
|
||||
pushChunk(g.items)
|
||||
}
|
||||
} else {
|
||||
@@ -127,8 +135,13 @@ Item {
|
||||
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
|
||||
// Both entry kinds now carry `base`, so a header at either edge of
|
||||
// the viewport reports ITS group's position instead of collapsing to
|
||||
// the top of the library.
|
||||
var loIdx = lo ? (lo.base || 0) : 0
|
||||
var hiIdx = hi
|
||||
? (hi.t === 1 ? hi.base + hi.items.length - 1 : Math.max(loIdx, hi.base || 0))
|
||||
: loIdx
|
||||
view.queueWindowReport(flat, loIdx, hiIdx, root.surface)
|
||||
}
|
||||
|
||||
@@ -168,6 +181,12 @@ Item {
|
||||
clip: true
|
||||
cacheBuffer: root.viewMode === "grid" ? root.cellH * 2 : root.listRowH * 10
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
// Recycle rows instead of destroying and rebuilding them on every
|
||||
// scroll step. Safe only NOW: the project's own note (PmListRow /
|
||||
// PmFolderMenu) is that `reuseItems` plus a per-row menu gives every
|
||||
// pooled delegate its own Popup — so the menus above had to become
|
||||
// lazy FIRST. In the other order this would install that bug.
|
||||
reuseItems: true
|
||||
model: root.entries
|
||||
onContentYChanged: root.report()
|
||||
onModelChanged: root.report()
|
||||
@@ -200,12 +219,43 @@ Item {
|
||||
Component {
|
||||
id: cardRowComp
|
||||
Row {
|
||||
id: cardRow
|
||||
/// The row entry from the ListView delegate (the Loader's
|
||||
/// `modelData`), captured here because the Repeater below
|
||||
/// shadows that name inside its own delegates.
|
||||
readonly property var rowEntry: modelData
|
||||
spacing: root.cellW - 200
|
||||
// FIXED CELL COUNT — `root.cols`, a NUMBER, not the row's
|
||||
// item array.
|
||||
//
|
||||
// This is what makes the ListView's `reuseItems` actually
|
||||
// pay. Pooling recycles the row SHELL, but the old
|
||||
// `model: modelData.items` handed this Repeater a new
|
||||
// array on every recycle, so it destroyed and rebuilt its
|
||||
// AlbumCards anyway — and the card is where the cost is:
|
||||
// ~29 items, a RoundedImage with TWO render targets (its
|
||||
// own layer plus the mask), image probes, a skeleton.
|
||||
// The shell was reused and the expensive part was not.
|
||||
//
|
||||
// With a constant count the cells are created once and
|
||||
// survive recycling; a scrolled or re-filtered row only
|
||||
// reassigns `item`, which is a binding update instead of a
|
||||
// construction. Same shape as the reference, where cheap
|
||||
// slots stay alive and only the data moves.
|
||||
Repeater {
|
||||
model: modelData.items
|
||||
model: root.cols
|
||||
delegate: Item {
|
||||
id: cardCell
|
||||
required property var modelData
|
||||
required property int index
|
||||
/// The album for this slot, or `null` on the last
|
||||
/// row when it is not full. A null slot renders
|
||||
/// nothing and keeps its width, so the row's
|
||||
/// spacing does not collapse.
|
||||
readonly property var modelData:
|
||||
(cardRow.rowEntry && cardRow.rowEntry.items)
|
||||
? (cardRow.rowEntry.items[cardCell.index] || null)
|
||||
: null
|
||||
visible: cardCell.modelData !== null
|
||||
width: 200
|
||||
height: 246
|
||||
|
||||
@@ -215,22 +265,36 @@ Item {
|
||||
// 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)
|
||||
// LAZY, like AlbumCard's own. This cell carried a
|
||||
// SECOND popup on top of the card's, so a grid of
|
||||
// 1267 local albums paid two Popup constructions
|
||||
// per cell — and the ListView recycles a screenful
|
||||
// of cells on every scroll step, which is what the
|
||||
// owner reports as heavy scrolling here.
|
||||
Loader {
|
||||
id: cardMenuLoader
|
||||
active: false
|
||||
sourceComponent: 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Build the popup on first use, then open it.
|
||||
function openCardMenu(anchor, x, y) {
|
||||
cardMenuLoader.active = true
|
||||
cardMenuLoader.item.openAtCursor(anchor, x, y)
|
||||
}
|
||||
|
||||
AlbumCard {
|
||||
localMode: true
|
||||
@@ -319,7 +383,7 @@ Item {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.RightButton
|
||||
onClicked: function (mouse) {
|
||||
cardMenu.openAtCursor(cardRc, mouse.x, mouse.y)
|
||||
cardCell.openCardMenu(cardRc, mouse.x, mouse.y)
|
||||
}
|
||||
}
|
||||
// (The multi-select tick that used to sit here is
|
||||
|
||||
@@ -81,9 +81,35 @@ Row {
|
||||
height: parent.height
|
||||
|
||||
LocalSearchBox {
|
||||
id: albumsSearchBox
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
placeholder: QbzSession.tr("Search", QbzSession.trRev)
|
||||
onEdited: function (v) { root.view.albumsSearch = v }
|
||||
// DEBOUNCED, like the Tracks box below — which had it and this one
|
||||
// did not, in the same file.
|
||||
//
|
||||
// `albumsSearch` is not a query, it is the FILTER INPUT: writing it
|
||||
// re-filters 1267 albums, rebuilds the collection's entry list, and
|
||||
// hands the ListView a new model — which tears down every delegate
|
||||
// and builds them again. Per keystroke.
|
||||
//
|
||||
// The owner's report names the shape exactly: "the first two
|
||||
// keystrokes hurt", i.e. while the filter still matches hundreds.
|
||||
// By the time it matches ten there is nothing left to rebuild, so
|
||||
// the tail of the word feels fine. That is a model swap, not a
|
||||
// slow filter.
|
||||
//
|
||||
// The box keeps showing what was typed immediately; only the
|
||||
// filter waits.
|
||||
property string pending: ""
|
||||
onEdited: function (v) {
|
||||
albumsSearchBox.pending = v
|
||||
albumsSearchDebounce.restart()
|
||||
}
|
||||
Timer {
|
||||
id: albumsSearchDebounce
|
||||
interval: 200
|
||||
onTriggered: root.view.albumsSearch = albumsSearchBox.pending
|
||||
}
|
||||
}
|
||||
LocalIconSelect {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
Reference in new issue
Block a user