perf(qt): window high-cardinality catalog views
cx
This commit is contained in:
7 files changed
+540
-354
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ---------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user