perf(qt): window high-cardinality catalog views

cx
This commit is contained in:
vicrodh committed 2026-08-22 16:16:36 -06:00
1 parent 48c6f6854d
commit e0e9566f81
7 files changed
+540 -354

No files matched your search

+120 -63
View File
@@ -16,14 +16,14 @@
// a trap the Rust seeding avoids anyway, but there is no reason to reproduce
// a blank page here.
//
// WINDOWING (flat grid only, opt-in via `flick`): only cards within ~one
// viewport of the visible band mount a real AlbumCard; the rest keep their
// footprint as bare Items, so the grid height — and therefore the scroll
// geometry — never changes. The band is SAMPLED by a timer rather than bound
// to `flick.contentY`: a direct binding re-evaluates every delegate on every
// scroll frame, which is the O(n)-per-frame cost the .slint's own sampler
// exists to avoid (see its "POST-LAYOUT SNAPSHOT SAMPLING" note). Grouped
// sections are never windowed — same call the .slint makes (:265-277).
// WINDOWING (flat grid/list, opt-in via `flick`): only rows within ~one
// viewport of the visible band exist. The collection keeps the FULL footprint
// as one cheap Item, so its height — and therefore the scroll geometry — never
// changes, but there is no longer one Loader shell per off-screen result.
// The band is sampled from scroll events rather than bound to `contentY`: a
// direct binding re-evaluates every delegate on every frame, while a forever
// 80ms Timer spends CPU at rest. Grouped sections remain eager — the same call
// the .slint makes (:265-277).
//
// TAIL FADE (opt-in via armTailFade(), see the block below): the arrival half
// of the Load-more round. OFF unless a host arms it, so the two hosts that do
@@ -70,31 +70,53 @@ Column {
// --- Windowing band (row indices), sampled ---------------------------
property int bandFirst: 0
property int bandLast: 9999
readonly property bool windowed: flick !== null
readonly property bool windowed: flick !== null && !root.isGrouped
function sampleBand() {
if (!root.windowed || flatGrid.columns <= 0)
if (!root.windowed)
return
var pitch = root.cardHeight + root.cardGap
var top = root.flick.contentY - root.contentOffset
var listMode = root.viewMode === "list"
var pitch = listMode ? 64 + root.listRowGap
: root.cardHeight + root.cardGap
// The list rows begin after their 32px column header. The grid starts
// at the collection origin.
var rowsTop = root.contentOffset + (listMode ? 32 : 0)
var top = root.flick.contentY - rowsTop
var h = root.flick.height
// One viewport of prefetch on each side.
root.bandFirst = Math.max(0, Math.floor((top - h) / pitch))
root.bandLast = Math.max(0, Math.ceil((top + 2 * h) / pitch))
}
function scheduleBandSample() {
if (root.windowed && root.visible && !bandSampler.running)
bandSampler.start()
}
Timer {
interval: 80
repeat: true
running: root.windowed && root.visible
id: bandSampler
interval: 40
repeat: false
onTriggered: {
root.sampleBand()
root.reportArtWindow()
}
}
Connections {
target: root.flick
ignoreUnknownSignals: true
function onContentYChanged() { root.scheduleBandSample() }
function onHeightChanged() { root.scheduleBandSample() }
}
Component.onCompleted: {
root.sampleBand()
root.reportArtWindow()
}
onWidthChanged: root.scheduleBandSample()
onViewModeChanged: root.scheduleBandSample()
onVisibleChanged: root.scheduleBandSample()
// --- Windowed artwork -------------------------------------------------
//
@@ -116,8 +138,8 @@ Column {
// local tabs that host this collection are served by the same call.
property var artMap: ({})
/// Urls already handed to the bridge. A NON-notifying holder on purpose:
/// the sampler runs every 80 ms, and without this each unresolved cover
/// would be re-dispatched twelve times a second until it landed.
/// several scroll samples may cover the same window before a download
/// lands, and none should dispatch the same unresolved cover twice.
readonly property var _artAsked: ({ seen: ({}) })
function artOf(m) {
@@ -130,11 +152,15 @@ Column {
}
function reportArtWindow() {
if (!root.windowed || flatGrid.columns <= 0)
if (!root.windowed)
return
var listMode = root.viewMode === "list"
var cols = listMode ? 1 : flatGrid.columns
if (cols <= 0)
return
var cols = flatGrid.columns
var lo = Math.max(0, root.bandFirst * cols)
var hi = Math.min(root.albums.length - 1, (root.bandLast + 1) * cols - 1)
var hi = Math.min(root.albums.length - 1,
(root.bandLast + 1) * cols - 1)
var pending = []
var asked = root._artAsked.seen
for (var i = lo; i <= hi; i++) {
@@ -270,9 +296,10 @@ Column {
onAlbumsChanged: {
root._maybeStartTailFade()
// A new page appended -> new covers to ask for. ONE handler per
// signal: declaring a second `onAlbumsChanged` is a hard qmlcachegen
// error ("Property value set multiple times"), not a silent last-wins.
// A new page appended -> recompute the exact mounted slice before
// asking for its covers. ONE handler per signal: declaring a second
// `onAlbumsChanged` is a hard qmlcachegen error.
root.sampleBand()
root.reportArtWindow()
}
onGroupedChanged: root._maybeStartTailFade()
@@ -411,22 +438,36 @@ Column {
}
}
// --- Flat list --------------------------------------------------------
Column {
// --- Flat list (windowed) ---------------------------------------------
Item {
id: flatList
visible: !root.isGrouped && root.viewMode === "list" && root.albums.length > 0
width: parent.width
spacing: root.listRowGap
readonly property int rowPitch: 64 + root.listRowGap
readonly property int mountedFrom: root.windowed
? Math.min(root.albums.length, Math.max(0, root.bandFirst)) : 0
readonly property int mountedTo: root.windowed
? Math.min(root.albums.length, Math.max(mountedFrom, root.bandLast + 1))
: root.albums.length
readonly property int mountedCount: Math.max(0, mountedTo - mountedFrom)
height: 32 + (root.albums.length > 0
? root.albums.length * 64 + (root.albums.length - 1) * root.listRowGap
: 0)
AlbumListHeader { }
AlbumListHeader { id: flatListHeader }
Repeater {
model: (!root.isGrouped && root.viewMode === "list") ? root.albums : []
model: flatList.visible ? flatList.mountedCount : 0
delegate: AlbumListRow {
required property var modelData
required property int index
item: modelData
artSource: root.artOf(modelData)
rowIndex: index
opacity: root.tailOpacity(modelData ? modelData.id : "")
readonly property int globalIndex: flatList.mountedFrom + index
readonly property var cardData: root.albums[globalIndex] || ({})
x: 0
y: flatListHeader.height + globalIndex * flatList.rowPitch
width: flatList.width
item: cardData
artSource: root.artOf(cardData)
rowIndex: globalIndex
opacity: root.tailOpacity(cardData.id || "")
}
}
}
@@ -439,18 +480,28 @@ Column {
readonly property int columns: Math.max(
1, Math.floor((width + root.cardGap) / (root.cardWidth + root.cardGap)))
readonly property int rows: Math.ceil(root.albums.length / flatGrid.columns)
readonly property int mountedFrom: root.windowed
? Math.min(root.albums.length,
Math.max(0, root.bandFirst * flatGrid.columns)) : 0
readonly property int mountedTo: root.windowed
? Math.min(root.albums.length,
Math.max(mountedFrom,
(root.bandLast + 1) * flatGrid.columns))
: root.albums.length
readonly property int mountedCount: Math.max(0, mountedTo - mountedFrom)
height: flatGrid.rows > 0
? flatGrid.rows * root.cardHeight + (flatGrid.rows - 1) * root.cardGap
: 0
Repeater {
model: (!root.isGrouped && root.viewMode !== "list") ? root.albums : []
model: flatGrid.visible ? flatGrid.mountedCount : 0
delegate: Item {
id: cell
required property var modelData
required property int index
readonly property int rowIndex: Math.floor(cell.index / flatGrid.columns)
x: (cell.index % flatGrid.columns) * (root.cardWidth + root.cardGap)
readonly property int globalIndex: flatGrid.mountedFrom + index
readonly property var cardData: root.albums[globalIndex] || ({})
readonly property int rowIndex: Math.floor(globalIndex / flatGrid.columns)
x: (globalIndex % flatGrid.columns) * (root.cardWidth + root.cardGap)
y: cell.rowIndex * (root.cardHeight + root.cardGap)
width: root.cardWidth
height: root.cardHeight
@@ -461,34 +512,40 @@ Column {
// at the wrong one. Everything the arm saw — and everything,
// on every host that never arms — reads 1.0 and never even
// depends on `_tailReveal`.
opacity: root.tailOpacity(cell.modelData ? cell.modelData.id : "")
opacity: root.tailOpacity(cell.cardData.id || "")
// Component declared in the DELEGATE scope so `modelData`
// resolves (the PinnedRail dispatch pattern).
Component {
id: cardComp
AlbumCard {
albumId: cell.modelData.id
title: cell.modelData.title
artist: cell.modelData.artist
artistId: cell.modelData.artistId
genre: cell.modelData.genre
year: cell.modelData.year
qualityTier: cell.modelData.qualityTier
ribbon: cell.modelData.ribbon || ""
ribbonKind: cell.modelData.ribbonKind || ""
artSource: root.artOf(cell.modelData)
isPinned: cell.modelData.isPinned === true
artworkUrl: cell.modelData.artUrl || ""
plays: root.showPlays ? (cell.modelData.plays || 0) : 0
isFavorite: cell.modelData.isFavorite === true
}
// Numeric slice windowing reuses this viewport slot for a new
// global index. AlbumCard's optimistic heart/pin assignments
// intentionally break their host bindings; explicitly restore
// those bindings when the slot changes identity so state from
// the old album can never leak into the new one.
onCardDataChanged: {
if (!mountedCard)
return
mountedCard.isPinned = Qt.binding(function () {
return cell.cardData.isPinned === true
})
mountedCard.isFavorite = Qt.binding(function () {
return cell.cardData.isFavorite === true
})
}
Loader {
anchors.fill: parent
active: !root.windowed
|| (cell.rowIndex >= root.bandFirst && cell.rowIndex <= root.bandLast)
sourceComponent: cardComp
AlbumCard {
id: mountedCard
albumId: cell.cardData.id
title: cell.cardData.title
artist: cell.cardData.artist
artistId: cell.cardData.artistId
genre: cell.cardData.genre
year: cell.cardData.year
qualityTier: cell.cardData.qualityTier
ribbon: cell.cardData.ribbon || ""
ribbonKind: cell.cardData.ribbonKind || ""
artSource: root.artOf(cell.cardData)
isPinned: cell.cardData.isPinned === true
artworkUrl: cell.cardData.artUrl || ""
plays: root.showPlays ? (cell.cardData.plays || 0) : 0
isFavorite: cell.cardData.isFavorite === true
}
}
}
+30 -22
View File
@@ -2559,13 +2559,15 @@ Rectangle {
Item { visible: topTracks.length > 0; width: 1; height: 10 }
Repeater {
model: topTracks.length
// Do not build the unrevealed tail. Numeric growth keeps
// the preview delegates and adds only the requested rows.
model: root.topTracksExpanded ? topTracks.length
: Math.min(topTracks.length, root.preview)
delegate: PopularTrackRow {
// Smooth reveal (owner, 2026-08-02). This expander is
// CLIENT-SIDE: the model is a COUNT, so every row is
// already instantiated and "Load more" only flipped
// `visible` — twenty rows arriving in one frame, which
// is the "carga de golpe" the owner reported.
// CLIENT-SIDE: numeric model growth now creates only
// the revealed tail. The former full count built every
// row up front and only flipped `visible`.
//
// OPACITY ONLY, deliberately: the row is 50 tall with
// a 36px cover and text anchored to its verticalCenter
@@ -2576,16 +2578,14 @@ Rectangle {
// dissolves into it. Same 220ms / OutCubic the
// appended release cards use.
//
// The gate is this explicit `revealed`, not the item's
// own `visible`: Item.visible is EFFECTIVE visibility,
// so binding opacity to it would re-fade every row
// whenever the whole catalog Column is shown again
// (tab switch, artistLoading clearing).
readonly property bool revealed: root.topTracksExpanded || index < root.preview
visible: revealed
height: revealed ? 50 : 0
// The gate is this explicit local `revealed`, not the
// item's effective visibility, so showing the catalog
// tab again does not re-fade surviving delegates.
property bool revealed: false
height: 50
opacity: revealed ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 220; easing.type: Easing.OutCubic } }
Component.onCompleted: revealed = true
row: topTracks[index]
rowIndex: index
showAlbum: true
@@ -2647,12 +2647,16 @@ Rectangle {
// --- Release sections ------------------------------------
Repeater {
model: releaseSections
// `visible: false` does not prevent delegate construction.
// Feed this arm only regular buckets so the collapsed
// Other bucket is not built once here and again below.
model: releaseSections.filter(function (s) {
return s.releaseType !== "other"
})
delegate: Column {
required property var modelData
width: parent ? parent.width : 0
spacing: 0
visible: modelData.releaseType !== "other"
Item { width: 1; height: 32 }
ReleaseSection { section: modelData; anchorId: modelData.releaseType }
}
@@ -2673,16 +2677,17 @@ Rectangle {
}
Item { width: 1; height: 10 }
Repeater {
model: appearsOn.length
model: root.appearsOnExpanded ? appearsOn.length
: Math.min(appearsOn.length, root.preview)
delegate: PopularTrackRow {
// Same client-side reveal as Popular Tracks above
// — same component, same 50px pitch, same reason
// for fading opacity and letting height snap.
readonly property bool revealed: root.appearsOnExpanded || index < root.preview
visible: revealed
height: revealed ? 50 : 0
property bool revealed: false
height: 50
opacity: revealed ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 220; easing.type: Easing.OutCubic } }
Component.onCompleted: revealed = true
row: appearsOn[index]
rowIndex: index
showAlbum: false
@@ -2727,12 +2732,13 @@ Rectangle {
// --- Other (collapsed) ------------------------------------
Repeater {
model: releaseSections
model: releaseSections.filter(function (s) {
return s.releaseType === "other"
})
delegate: Column {
required property var modelData
width: parent ? parent.width : 0
spacing: 0
visible: modelData.releaseType === "other"
Item { width: 1; height: 32 }
Row {
width: parent.width
@@ -2768,7 +2774,9 @@ Rectangle {
columnSpacing: 24
rowSpacing: 24
Repeater {
model: modelData.cards
// A hidden Grid still constructs its Repeater.
// The collapsed bucket must cost zero cards.
model: root.otherExpanded ? modelData.cards : []
delegate: AlbumCard {
albumId: modelData.id
title: modelData.title
@@ -180,6 +180,7 @@ Rectangle {
}
AlbumCollection {
id: collection
visible: !root.loading && !root.loadError && root.albums.length > 0
width: parent.width
collectionKey: root.doc.id || ""
@@ -189,6 +190,8 @@ Rectangle {
cardWidth: 200
cardHeight: 266
cardGap: 24
flick: page
contentOffset: collection.y
}
// Tail spinner for the next page.
+3
View File
@@ -352,6 +352,7 @@ Rectangle {
}
AlbumCollection {
id: collection
visible: !root.loading && !root.loadError && root.albums.length > 0
width: parent.width
// Identity of the catalog on screen — navigating to ANOTHER
@@ -364,6 +365,8 @@ Rectangle {
cardWidth: 200
cardHeight: 266
cardGap: 24
flick: page
contentOffset: collection.y
}
// ---- Load more ---------------------------------------------------
+24 -37
View File
@@ -583,58 +583,45 @@ Rectangle {
Item { width: 1; height: 10 }
Repeater {
model: root.topTracks
// Numeric growth preserves the five existing delegates
// and creates only the newly revealed rows. The old
// full-array model built up to 50 TrackRows at first
// paint and merely gave 45 of them height zero.
model: Math.min(root.previewCount, root.topTracks.length)
delegate: TrackRow {
required property var modelData
required property int index
width: parent ? parent.width : 0
visible: index < root.previewCount
height: visible ? 50 : 0
height: 50
// SMOOTH REVEAL (owner, 2026-08-02: "que la
// aparicion de lo que se cargue, sea smooth").
// This site is CLIENT-SIDE: every row of
// topTracks is already instantiated by the
// Repeater and only gated on `previewCount`
// (LabelPageView.slint:426, which snaps), so the
// fade goes on the rows themselves — there is no
// appended tail and therefore no `fadeFrom`
// threshold to keep.
//
// It is safe to fade UNCONDITIONALLY here, which
// is NOT true of the network-backed Load-more
// sites: a QML Behavior does not run while its
// object is being created, so when the label
// document is republished (the artwork pass —
// see onDocChanged above) and the Repeater
// rebuilds every delegate, each one takes its
// opacity as a plain initial value and NOTHING
// re-fades. The animation only ever runs when
// `previewCount` moves on delegates that already
// exist, i.e. exactly on the reveal.
// This site is CLIENT-SIDE. Numeric model growth
// adds only the requested rows; each new delegate
// flips `revealed` after creation and dissolves in.
// There is no appended network tail or threshold.
//
// 220ms / OutCubic = the content duration this
// round standardises on (controls/QbzLoadMore
// .qml uses 180ms for its skeleton).
// The COLLAPSE back to 5 stays instant on
// purpose: `visible` is left snapping, so the
// rows leave the layout in the same frame. Held
// open for a 220ms fade-out instead, the page
// would show ~2200px of blank list and then jump
// — worse than the snap, not better.
opacity: index < root.previewCount ? 1.0 : 0.0
// Collapse stays instant because numeric shrink
// removes the tail in the same frame. Holding its
// layout open for a fade-out would leave a large
// blank band and then jump.
property bool revealed: false
opacity: revealed ? 1.0 : 0.0
Behavior on opacity {
NumberAnimation { duration: 220; easing.type: Easing.OutCubic }
}
item: modelData
Component.onCompleted: revealed = true
item: root.topTracks[index] || ({})
number: index + 1
showArtwork: true
showAlbum: true
selectMode: root.multiSelect
checked: root.selected[item.id] === true
onToggleSelect: function (mods) { root.toggleSelected(item.id, mods) }
onPlayRequested: QbzHome.labelPlayTrack(modelData.id)
onPlayRequested: QbzHome.labelPlayTrack(item.id)
onEnqueueRequested: function (mode) {
QbzHome.labelEnqueueTrack(modelData.id, mode)
QbzHome.labelEnqueueTrack(item.id, mode)
}
// MyQBZ "Add to mixtape" — the HOST builds the
// AddItem array (TrackRow does not know
@@ -645,13 +632,13 @@ Rectangle {
// `top_tracks` mapped to track rows
// (label_qt.rs:280-287) — there is no local label
// library and no non-Qobuz row can reach this
// Repeater, so `modelData.id` is a Qobuz catalog
// Repeater, so `item.id` is a Qobuz catalog
// id by construction of the document.
onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{
"itemType": "track", "source": "qobuz",
"sourceItemId": modelData.id,
"title": modelData.title || "",
"subtitle": modelData.artist || "",
"sourceItemId": item.id,
"title": item.title || "",
"subtitle": item.artist || "",
"artworkUrl": item.artUrl || "" // label_qt serializes the shared
// TrackRow, which carries artUrl; only artPath is
// patched on top for this page's 36px art cell., "year": null, "trackCount": null
+85 -57
View File
@@ -282,7 +282,7 @@ Rectangle {
font.pixelSize: 14
}
// --- Grid ----------------------------------------------
// --- Virtualized collection ----------------------------
Item {
id: plGrid
// --- Windowing + windowed artwork -----------------
@@ -293,28 +293,38 @@ Rectangle {
// solved this for the album pages and this one never
// got it.
//
// The band is SAMPLED on a timer rather than bound to
// `flick.contentY`: a direct binding re-evaluates every
// delegate on every scroll frame, which is the
// O(n)-per-frame cost the sampler exists to avoid (same
// reasoning, same 80 ms, as AlbumCollection).
// The band is sampled through a one-shot timer fed by
// scroll events rather than bound to `contentY`: this
// avoids O(n) work per frame and wakes nothing at rest.
//
// Cards outside the band keep their FOOTPRINT as bare
// Items, so the grid height — and with it the scroll
// geometry and the infinite-scroll trigger above —
// never changes.
// The FULL footprint stays on this one Item, while the
// Repeater model is only the sampled slice. This avoids
// both the old card cost and one Loader shell per
// off-screen result. The list arm uses the same band.
property int bandFirst: 0
property int bandLast: 9999
function sampleBand() {
if (plGrid.columns <= 0)
if (plGrid.columns <= 0 || !plGrid.visible)
return
var pitch = plGrid.cardH + plGrid.gap
var listMode = root.viewMode === "list"
var pitch = listMode ? plGrid.listH + plGrid.listGap
: plGrid.cardH + plGrid.gap
var top = flick.contentY - plGrid.y
var h = flick.height
plGrid.bandFirst = Math.max(0, Math.floor((top - h) / pitch))
plGrid.bandLast = Math.max(0, Math.ceil((top + 2 * h) / pitch))
}
function scheduleBandSample() {
if (plGrid.visible && !plBandSampler.running)
plBandSampler.start()
}
function refreshBand() {
plGrid.sampleBand()
plGrid.reportArtWindow()
}
// Covers, one key at a time and only near the viewport
// — the Library > All shape. Rust used to download
// every missing cover of the page in one batch and then
@@ -332,11 +342,12 @@ Rectangle {
return m.artPath || ""
}
function reportArtWindow() {
if (plGrid.columns <= 0)
if (plGrid.columns <= 0 || !plGrid.visible)
return
var lo = Math.max(0, plGrid.bandFirst * plGrid.columns)
var cols = root.viewMode === "list" ? 1 : plGrid.columns
var lo = Math.max(0, plGrid.bandFirst * cols)
var hi = Math.min(root.items.length - 1,
(plGrid.bandLast + 1) * plGrid.columns - 1)
(plGrid.bandLast + 1) * cols - 1)
var pending = []
var asked = plGrid._artAsked.seen
for (var i = lo; i <= hi; i++) {
@@ -354,17 +365,23 @@ Rectangle {
QbzShell.sidebarArtworkWindow(JSON.stringify(pending))
}
Timer {
interval: 80
repeat: true
running: plGrid.visible
onTriggered: {
plGrid.sampleBand()
plGrid.reportArtWindow()
}
id: plBandSampler
interval: 40
repeat: false
onTriggered: plGrid.refreshBand()
}
Connections {
target: flick
function onContentYChanged() { plGrid.scheduleBandSample() }
function onHeightChanged() { plGrid.scheduleBandSample() }
}
Connections {
target: root
function onItemsChanged() { plGrid.refreshBand() }
function onViewModeChanged() { plGrid.refreshBand() }
}
Component.onCompleted: {
plGrid.sampleBand()
plGrid.reportArtWindow()
plGrid.refreshBand()
}
Connections {
target: QbzLibrary
@@ -378,40 +395,58 @@ Rectangle {
}
}
visible: !QbzHome.playlistBrowseLoading && root.viewMode !== "list"
visible: !QbzHome.playlistBrowseLoading
width: parent.width - 64
readonly property int cardW: 200
readonly property int cardH: 246
readonly property int gap: 24
readonly property int listH: 60
readonly property int listGap: 2
readonly property int columns: Math.max(
1, Math.floor((width + gap) / (cardW + gap)))
readonly property int rows: Math.ceil(root.items.length / columns)
height: rows > 0 ? rows * cardH + (rows - 1) * gap : 0
readonly property int gridFrom: Math.min(root.items.length,
Math.max(0, bandFirst * columns))
readonly property int gridTo: Math.min(root.items.length,
Math.max(gridFrom, (bandLast + 1) * columns))
readonly property int listFrom: Math.min(root.items.length,
Math.max(0, bandFirst))
readonly property int listTo: Math.min(root.items.length,
Math.max(listFrom, bandLast + 1))
height: root.viewMode === "list"
? (root.items.length > 0
? root.items.length * listH
+ (root.items.length - 1) * listGap : 0)
: (rows > 0 ? rows * cardH + (rows - 1) * gap : 0)
Repeater {
model: (root.viewMode !== "list") ? root.items : []
model: root.viewMode !== "list"
? Math.max(0, plGrid.gridTo - plGrid.gridFrom) : 0
delegate: Item {
id: plCell
required property var modelData
required property int index
x: (plCell.index % plGrid.columns) * (plGrid.cardW + plGrid.gap)
y: Math.floor(plCell.index / plGrid.columns) * (plGrid.cardH + plGrid.gap)
readonly property int globalIndex: plGrid.gridFrom + index
readonly property var cardData: root.items[globalIndex] || ({})
x: (globalIndex % plGrid.columns) * (plGrid.cardW + plGrid.gap)
y: Math.floor(globalIndex / plGrid.columns) * (plGrid.cardH + plGrid.gap)
width: plGrid.cardW
height: plGrid.cardH
readonly property int rowIndex:
Math.floor(plCell.index / plGrid.columns)
// Component in the DELEGATE scope so
// `modelData` resolves (the AlbumCollection
// pattern).
Component {
id: plCardComp
onCardDataChanged: {
if (!browseCard)
return
browseCard.isPinned = Qt.binding(function () {
return plCell.cardData.isPinned === true
})
}
PlaylistCard {
id: browseCard
// `body-opens: true` in the .slint — the
// card's body click opens the playlist and
// the overlay button plays it.
item: plCell.modelData
artSource: plGrid.artOf(plCell.modelData)
item: plCell.cardData
artSource: plGrid.artOf(plCell.cardData)
// The row carries the pin state
// (home_qt `map_playlist`, which this page
// reuses); the card defaults to false, so
@@ -420,31 +455,24 @@ Rectangle {
// and the first click un-pinned it.
// `artworkUrl` needs no hand-over: the
// card defaults it to `item.artUrl`.
isPinned: plCell.modelData.isPinned === true
}
}
Loader {
anchors.fill: parent
active: plCell.rowIndex >= plGrid.bandFirst
&& plCell.rowIndex <= plGrid.bandLast
sourceComponent: plCardComp
isPinned: plCell.cardData.isPinned === true
}
}
}
}
// --- List ----------------------------------------------
Column {
visible: !QbzHome.playlistBrowseLoading && root.viewMode === "list"
width: parent.width - 64
spacing: 2
Repeater {
model: (root.viewMode === "list") ? root.items : []
model: root.viewMode === "list"
? Math.max(0, plGrid.listTo - plGrid.listFrom) : 0
delegate: PlaylistListRow {
required property var modelData
required property int index
item: modelData
rowIndex: index
readonly property int globalIndex: plGrid.listFrom + index
readonly property var rowData: root.items[globalIndex] || ({})
x: 0
y: globalIndex * (plGrid.listH + plGrid.listGap)
width: plGrid.width
item: rowData
artSource: plGrid.artOf(rowData)
rowIndex: globalIndex
}
}
}
+275 -175
View File
@@ -18,8 +18,9 @@
// primitives/SearchTrackHero.slint, NOT discover/TrackCard.slint —
// the POC variant is 200x246 (centered play, quality as text) to match
// the ArtistGridCard hero slot it shares the row with.
// - The Slint's windowed grid virtualization is not replicated (page size
// is 20 — the whole set mounts).
// - Per-type grids and the track tape are windowed against the outer page
// Flickable; their full footprint remains stable for scrolling, while only
// the viewport runway exists as QML delegates.
//
// LOADING (a deliberate ADDITION over the Slint's bare LoadingSpinner): the
// view mounts QbzSkeleton placeholders in the shape of the tab that is
@@ -86,6 +87,30 @@ Rectangle {
readonly property bool hasResults: albums.length + tracks.length + artists.length + playlists.length > 0
readonly property int previewCap: 6
// The per-type result tabs live inside the page Flickable, so a GridView
// sized to its whole content is not virtualized: from that GridView's
// perspective every row is in its viewport. Sample one shared vertical
// band and feed only that slice to the manual grids/tape below. Event-
// driven and one-shot: no timer wakes while Search is idle.
property real bodyBandTop: 0
property real bodyBandBottom: 1000000
function sampleBodyBand() {
if (!bodyFlick || bodyFlick.height <= 0)
return
root.bodyBandTop = Math.max(0, bodyFlick.contentY - bodyFlick.height)
root.bodyBandBottom = bodyFlick.contentY + 2 * bodyFlick.height
}
function scheduleBodyBand() {
if (root.visible && !searchBandSampler.running)
searchBandSampler.start()
}
Timer {
id: searchBandSampler
interval: 40
repeat: false
onTriggered: root.sampleBodyBand()
}
// ======================= skeleton plumbing ===========================
// ONE 900ms Timer drives EVERY placeholder in this view (QbzSkeleton's
// preferred drive mode). GATING RULE: freeze on NOT VISIBLE — the view
@@ -179,7 +204,10 @@ Rectangle {
// stale threshold must never fade the first page of the next thing.
onQueryChanged: root.clearFade()
onFilterIndexChanged: root.clearFade()
onTabChanged: root.clearFade()
onTabChanged: {
root.clearFade()
root.scheduleBodyBand()
}
function armLoadMore(t) {
root.moreTab = t
@@ -241,6 +269,155 @@ Rectangle {
settleMs: 6000
}
// A flat, fixed-pitch result grid whose footprint covers the full result
// set while only the sampled rows exist as QML objects. This is the same
// ownership boundary as Home's vertical ListView, adapted to the composite
// Search page where the outer Flickable owns scrolling.
component SearchResultGrid: Item {
id: resultGrid
property var rows: []
property string kind: "album" // album | artist | playlist
property int tabId: 1
property int cellW: 224
property int cellH: 270
property int cardW: 200
property int cardH: 246
property real bandTop: 0
property real bandBottom: 1000000
property bool phase: false
property int fadeTab: -1
property int fadeFrom: -1
// The final card has no trailing gutter: six 200px cards on a 224px
// pitch consume 6*200 + 5*24, not 6*224.
readonly property int columns: Math.max(1,
Math.floor((width + cellW - cardW) / cellW))
readonly property int rowCount: Math.ceil(rows.length / columns)
readonly property int firstRow: Math.max(0,
Math.floor((bandTop - y) / cellH))
readonly property int lastRow: Math.max(firstRow,
Math.ceil((bandBottom - y) / cellH))
readonly property int mountedFrom: Math.min(rows.length, firstRow * columns)
readonly property int mountedTo: Math.min(rows.length,
Math.max(mountedFrom, (lastRow + 1) * columns))
height: rowCount > 0 ? rowCount * cellH - (cellH - cardH) : 0
Repeater {
model: resultGrid.visible
? Math.max(0, resultGrid.mountedTo - resultGrid.mountedFrom) : 0
delegate: Item {
id: resultCell
required property int index
readonly property int globalIndex: resultGrid.mountedFrom + index
readonly property var cardData: resultGrid.rows[globalIndex] || ({})
readonly property bool fadeIn: resultGrid.fadeTab === resultGrid.tabId
&& resultGrid.fadeFrom >= 0 && globalIndex >= resultGrid.fadeFrom
x: (globalIndex % resultGrid.columns) * resultGrid.cellW
y: Math.floor(globalIndex / resultGrid.columns) * resultGrid.cellH
width: resultGrid.cardW
height: resultGrid.cardH
opacity: 0
Behavior on opacity {
enabled: resultCell.fadeIn
NumberAnimation { duration: 220; easing.type: Easing.OutCubic }
}
Component.onCompleted: opacity = 1
// The sampled slice reuses viewport slots as its first global
// index moves. Card actions intentionally break optimistic
// scalar bindings, so restore them at the identity edge.
function restoreMutableBindings() {
if (!cardLoader.item)
return
cardLoader.item.isPinned = Qt.binding(function () {
return resultCell.cardData.isPinned === true
})
if (resultGrid.kind === "album") {
cardLoader.item.isFavorite = Qt.binding(function () {
return resultCell.cardData.isFavorite === true
})
}
}
onCardDataChanged: resultCell.restoreMutableBindings()
Component {
id: albumResult
AlbumCard {
albumId: resultCell.cardData.id
title: resultCell.cardData.title
artist: resultCell.cardData.artist
artistId: resultCell.cardData.artistId
genre: resultCell.cardData.genre
year: resultCell.cardData.year
qualityTier: resultCell.cardData.qualityTier
artSource: resultCell.cardData.artPath || ""
isFavorite: resultCell.cardData.isFavorite === true
isPinned: resultCell.cardData.isPinned === true
artworkUrl: resultCell.cardData.artUrl || ""
}
}
Component {
id: artistResult
ArtistCard {
item: resultCell.cardData
artSource: resultCell.cardData.artPath || ""
isPinned: resultCell.cardData.isPinned === true
artworkUrl: resultCell.cardData.artUrl || ""
}
}
Component {
id: playlistResult
PlaylistCard {
item: resultCell.cardData
artSource: resultCell.cardData.artPath || ""
isPinned: resultCell.cardData.isPinned === true
}
}
Loader {
id: cardLoader
anchors.fill: parent
sourceComponent: resultGrid.kind === "artist" ? artistResult
: resultGrid.kind === "playlist" ? playlistResult
: albumResult
onLoaded: resultCell.restoreMutableBindings()
}
CardArtSkeleton {
visible: resultGrid.kind !== "artist"
card: resultCell.cardData
phase: resultGrid.phase
cellIndex: resultCell.globalIndex
}
}
}
}
component SearchTrackResult: TrackRow {
id: searchTrackRow
property int resultIndex: 0
property bool fadeIn: false
number: resultIndex + 1
opacity: 0
Behavior on opacity {
enabled: searchTrackRow.fadeIn
NumberAnimation { duration: 220; easing.type: Easing.OutCubic }
}
Component.onCompleted: opacity = 1
menuShowFavorite: false
showArtwork: true
showAlbum: true
artistLink: true
onPlayRequested: QbzPlayer.playTrack(item.id)
onEnqueueRequested: function (m) { QbzPlayer.enqueueTrack(item.id, m) }
onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{
"itemType": "track", "source": "qobuz",
"sourceItemId": item.id, "title": item.title || "",
"subtitle": item.artist || "", "artworkUrl": item.artUrl || "",
"year": null, "trackCount": null
}]))
}
// Track hero (SearchTrackHero: the 200x246 track card + the quality
// label as TEXT under the meta).
component SearchTrackHero: Rectangle {
@@ -517,6 +694,9 @@ Rectangle {
contentHeight: bodyCol.height + 32
clip: true
boundsBehavior: Flickable.StopAtBounds
onContentYChanged: root.scheduleBodyBand()
onHeightChanged: root.scheduleBodyBand()
Component.onCompleted: root.sampleBodyBand()
Column {
id: bodyCol
@@ -627,7 +807,7 @@ Rectangle {
spacing: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.artistsCarousel
model: root.tab === 0 ? root.artistsCarousel : []
delegate: ArtistCard {
item: modelData
artSource: modelData.artPath || ""
@@ -656,7 +836,7 @@ Rectangle {
spacing: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.artists
model: root.tab === 0 ? root.artists : []
delegate: ArtistCard {
item: modelData
artSource: modelData.artPath || ""
@@ -685,7 +865,7 @@ Rectangle {
spacing: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.albums
model: root.tab === 0 ? root.albums : []
delegate: Item {
required property var modelData
required property int index
@@ -716,55 +896,19 @@ Rectangle {
}
}
// Albums tab: the full grid + Load more.
GridView {
SearchResultGrid {
visible: root.tab === 1
width: parent.width
height: Math.max(0, Math.ceil(count / Math.max(1, Math.floor((width + 24) / 224))) * 270 - 24)
cellWidth: 224
cellHeight: 270
clip: true
boundsBehavior: Flickable.StopAtBounds
interactive: false
model: root.albums
delegate: Item {
id: albumCell
required property var modelData
required property int index
width: 200
height: 246
// The appended tail dissolves in; every other
// delegate is created at 1.0 with the Behavior
// DISABLED, so the republishes that recreate this
// grid (artwork, pins, tab switch) cost no fade at
// all. `opacity` is a plain value, never a
// binding, so the assignment below clobbers
// nothing.
readonly property bool fadeIn: root.fadeAt(1, albumCell.index)
opacity: 0
Behavior on opacity {
enabled: albumCell.fadeIn
NumberAnimation { duration: 220; easing.type: Easing.OutCubic }
}
Component.onCompleted: opacity = 1
AlbumCard {
albumId: modelData.id
title: modelData.title
artist: modelData.artist
artistId: modelData.artistId
genre: modelData.genre
year: modelData.year
qualityTier: modelData.qualityTier
artSource: modelData.artPath || ""
isFavorite: modelData.isFavorite === true
isPinned: modelData.isPinned === true
artworkUrl: modelData.artUrl || ""
}
CardArtSkeleton {
card: modelData
phase: root.skelPhase
cellIndex: index
}
}
rows: root.albums
kind: "album"
tabId: 1
cellW: 224
cellH: 270
bandTop: root.bodyBandTop
bandBottom: root.bodyBandBottom
phase: root.skelPhase
fadeTab: root.fadeTab
fadeFrom: root.fadeFrom
}
// The page that "Load more" asked for, in the shape it
// will arrive in: one row of 224x270 cells on one
@@ -799,76 +943,71 @@ Rectangle {
// ---- Tracks --------------------------------------------
Column {
visible: (root.tab === 0 || root.tab === 2) && root.tracks.length > 0
// The All tab is deliberately bounded to six rows. The
// unbounded type tab has its own windowed tape below.
visible: root.tab === 0 && root.tracks.length > 0
width: parent.width
spacing: 4
QbzSectionHeader {
title: QbzSession.tr("Tracks", QbzSession.trRev)
showViewAll: root.tab === 0 && (root.doc.tracksTotal || 0) > root.previewCap
showViewAll: (root.doc.tracksTotal || 0) > root.previewCap
viewAllAccent: true
showChevrons: false
onViewAllClicked: QbzSearch.searchTabChanged(2)
}
Repeater {
model: root.tab === 2 ? root.tracks : root.tracks.slice(0, root.previewCap)
delegate: TrackRow {
id: trackCell
// Tail fade, tab 2 only: this Repeater also
// draws the All tab's 6-row preview, which has
// no Load more of its own — `fadeAt` keys on
// the tab that armed the threshold, so the
// preview can never inherit it.
readonly property bool fadeIn: root.fadeAt(2, index)
opacity: 0
Behavior on opacity {
enabled: trackCell.fadeIn
NumberAnimation { duration: 220; easing.type: Easing.OutCubic }
}
Component.onCompleted: opacity = 1
model: root.tab === 0
? root.tracks.slice(0, root.previewCap) : []
delegate: SearchTrackResult {
required property var modelData
required property int index
item: modelData
number: index + 1
menuShowFavorite: false
// THE ROW'S OWN AFFORDANCES, which this call
// site had never switched on. TrackRow draws
// an artwork thumb, a clickable artist and a
// clickable album column, and search left all
// three off — so the only way from a result to
// its album was the context menu's "Go to",
// three clicks deep, on the surface where
// people are most often hunting for exactly
// that (owner smoke 2026-08-22).
//
// Nothing here is new machinery: every other
// track list in the app already mounts the row
// this way, and `search_qt::TrackRow` now
// carries the album TITLE the column needs to
// have something to draw.
showArtwork: true
showAlbum: true
artistLink: true
onPlayRequested: QbzPlayer.playTrack(item.id)
onEnqueueRequested: function (m) { QbzPlayer.enqueueTrack(item.id, m) }
// MyQBZ "Add to mixtape" — the HOST builds the
// AddItem array (TrackRow does not know
// itemType/source).
//
// SOURCE: the results page has ONE source.
// `search_qt::TrackRow` carries no source
// word because every row on THIS page is
// mapped from a Qobuz `Track` (`map_track`):
// the results page is still Qobuz-only. The
// CORTINILLA is not — it has on-device
// sections now — but its rows carry their own
// `source` field and are a different document
// entirely.
// `item.id` is a Qobuz catalog id by
// construction, not by assumption here.
onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{
"itemType": "track", "source": "qobuz",
"sourceItemId": item.id, "title": item.title || "",
"subtitle": item.artist || "", "artworkUrl": item.artUrl || "",
"year": null, "trackCount": null
}]))
resultIndex: index
}
}
}
Column {
id: trackResults
visible: root.tab === 2 && root.tracks.length > 0
width: parent.width
spacing: 4
QbzSectionHeader {
title: QbzSession.tr("Tracks", QbzSession.trRev)
showViewAll: false
showChevrons: false
}
Item {
id: trackTape
width: parent.width
readonly property int pitch: 54
readonly property real pageY: trackResults.y + y
readonly property int firstRow: Math.max(0,
Math.floor((root.bodyBandTop - pageY) / pitch))
readonly property int lastRow: Math.max(firstRow,
Math.ceil((root.bodyBandBottom - pageY) / pitch))
readonly property int mountedFrom: Math.min(
root.tracks.length, firstRow)
readonly property int mountedTo: Math.min(
root.tracks.length, Math.max(mountedFrom, lastRow + 1))
height: root.tracks.length > 0
? root.tracks.length * 50 + (root.tracks.length - 1) * 4 : 0
Repeater {
model: root.tab === 2
? Math.max(0, trackTape.mountedTo - trackTape.mountedFrom) : 0
delegate: SearchTrackResult {
required property int index
readonly property int globalIndex: trackTape.mountedFrom + index
x: 0
y: globalIndex * trackTape.pitch
width: trackTape.width
item: root.tracks[globalIndex] || ({})
resultIndex: globalIndex
fadeIn: root.fadeAt(2, globalIndex)
}
}
}
}
@@ -901,36 +1040,19 @@ Rectangle {
}
// ---- Artists grid (per-type tab) ------------------------
GridView {
SearchResultGrid {
visible: root.tab === 3
width: parent.width
height: Math.max(0, Math.ceil(count / Math.max(1, Math.floor((width + 16) / 216))) * 262 - 16)
cellWidth: 216
cellHeight: 262
clip: true
boundsBehavior: Flickable.StopAtBounds
interactive: false
model: root.artists
delegate: ArtistCard {
id: artistCell
// Tail fade — same rule as the album grid. This
// delegate declares no REQUIRED properties, so
// `index` stays the injected context property the
// view provides (adding a required one here would
// switch the whole delegate to required-property
// mode and strand `modelData`).
readonly property bool fadeIn: root.fadeAt(3, index)
opacity: 0
Behavior on opacity {
enabled: artistCell.fadeIn
NumberAnimation { duration: 220; easing.type: Easing.OutCubic }
}
Component.onCompleted: opacity = 1
item: modelData
artSource: modelData.artPath || ""
isPinned: modelData.isPinned === true
artworkUrl: modelData.artUrl || ""
}
rows: root.artists
kind: "artist"
tabId: 3
cellW: 216
cellH: 262
bandTop: root.bodyBandTop
bandBottom: root.bodyBandBottom
phase: root.skelPhase
fadeTab: root.fadeTab
fadeFrom: root.fadeFrom
}
// Artists: this grid's own 216x262 pitch, NOT the 224x270
// the album/playlist grids use — the cells here are the
@@ -974,7 +1096,7 @@ Rectangle {
spacing: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.playlists
model: root.tab === 0 ? root.playlists : []
delegate: Item {
required property var modelData
required property int index
@@ -996,41 +1118,19 @@ Rectangle {
}
}
}
GridView {
SearchResultGrid {
visible: root.tab === 4
width: parent.width
height: Math.max(0, Math.ceil(count / Math.max(1, Math.floor((width + 24) / 224))) * 270 - 24)
cellWidth: 224
cellHeight: 270
clip: true
boundsBehavior: Flickable.StopAtBounds
interactive: false
model: root.playlists
delegate: Item {
id: playlistCell
required property var modelData
required property int index
width: 200
height: 246
// Tail fade — same rule as the album grid.
readonly property bool fadeIn: root.fadeAt(4, playlistCell.index)
opacity: 0
Behavior on opacity {
enabled: playlistCell.fadeIn
NumberAnimation { duration: 220; easing.type: Easing.OutCubic }
}
Component.onCompleted: opacity = 1
PlaylistCard {
item: modelData
artSource: modelData.artPath || ""
isPinned: modelData.isPinned === true
}
CardArtSkeleton {
card: modelData
phase: root.skelPhase
cellIndex: index
}
}
rows: root.playlists
kind: "playlist"
tabId: 4
cellW: 224
cellH: 270
bandTop: root.bodyBandTop
bandBottom: root.bodyBandBottom
phase: root.skelPhase
fadeTab: root.fadeTab
fadeFrom: root.fadeFrom
}
// Playlists share the album grid's 224x270 pitch.
QbzLoadMore {