feat(qt): shared QbzLoadMore with an appended-page skeleton

Five hand-rolled "Load more" buttons existed in three visually distinct
shapes. One control now carries all three arms, so the idle appearance of
every site is unchanged. While a fetch is in flight it draws a QbzSkeleton
composite BELOW the button - the owner asked for below - which is one
animator for the whole placeholder row, with settleMs 8000 so a fetch that
never returns stops shimmering instead of pulsing forever. Idle it costs
nothing: the block lives behind an inactive Loader, so mounting the control
changes no pixel on a page nobody has clicked.

SearchView had half of this already, and the half it had was on the wrong
side: a skeleton ABOVE the button, on the albums tab only. All four tabs now
use the control, each with its own real cell pitch and row shape rather than
the albums tab's numbers copied across.

AlbumCollection gains an opt-in tail fade (armTailFade) for the arrival half
of the round. It is keyed by the set of ids on screen at click time, not by
an index threshold: the label page sorts its grid, and under any order but
newest a fetched page interleaves, so a threshold would re-fade albums that
never moved - the flicker the fade exists to avoid. It also lets grouped mode
fade, where there is no flat index at all. Inert unless a host arms it, so
Discover Browse and Play History are bit-for-bit unchanged.

cc
This commit is contained in:
vicrodh committed 2026-08-02 16:32:24 -06:00
1 parent 5c2577c7ce
commit 6f4fb72f57
5 files changed
+691 -116

No files matched your search

+150
View File
@@ -24,6 +24,10 @@
// 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).
//
// 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
// not (Discover Browse, Play History) are bit-for-bit unchanged.
import QtQuick
import com.blitzfc.qbz
@@ -86,12 +90,138 @@ Column {
}
Component.onCompleted: root.sampleBand()
// --- Tail fade: the appended page APPEARS, it does not pop -------------
// The owner's second ask for the Load-more round (2026-08-02): "que la
// aparicion de lo que se cargue, sea smooth". controls/QbzLoadMore.qml
// owns the WAITING half (the skeleton under the button); the ARRIVAL half
// can only live here — only the collection knows which of its delegates
// are new. A host asks for it by calling armTailFade() immediately BEFORE
// the bridge call that fetches the next page.
//
// WHY AN ID SET AND NOT "index >= the old count": this collection is
// SORTED by its host (LabelReleasesView's sort control does newest /
// oldest / title / artist, src/label_qt.rs::sort_cards). Only under
// "newest" does a fetched page land as a tail; under any other order the
// new albums INTERLEAVE, and an index threshold would then re-fade albums
// that never moved — the very flicker this exists to avoid. Membership in
// the pre-fetch id set is the honest question, and it costs one JS object
// of N keys per click. It also lets GROUPED mode fade correctly, where
// there is no flat index at all: a fetched album is folded into whichever
// artist section it belongs to, anywhere in the page.
//
// WHY BOTH LISTS: `visible` and `grouped` are mutually exclusive in Rust —
// derive_releases returns `Vec::new()` for the flat list as soon as
// group-by is on (label_qt.rs:923). Snapshotting only `albums` would
// capture an EMPTY set in grouped mode and every card on screen would read
// as "new".
//
// INERT BY DEFAULT: a host that never arms leaves `_tailSeen` null, every
// delegate's opacity short-circuits to 1.0, `_tailReveal` is never read
// (so it is never even a binding dependency) and no animation exists.
property var _tailSeen: null
property real _tailReveal: 1.0
/// Host-supplied identity of the page on screen (a label id, an artist
/// id…). Changing it disarms a pending fade, so a click that never landed
/// cannot colour the NEXT page's first paint. "" = the hosts that do not
/// use the fade at all.
property string collectionKey: ""
onCollectionKeyChanged: root.clearTailFade()
function _addIds(seen, list) {
for (var i = 0; i < list.length; i++) {
var a = list[i]
if (a && a.id !== undefined && a.id !== null && a.id !== "")
seen[a.id] = true
}
}
/// Snapshot what is on screen NOW. Call it BEFORE the bridge call: the
/// bridge may republish synchronously.
function armTailFade() {
var seen = {}
root._addIds(seen, root.albums)
for (var g = 0; g < root.grouped.length; g++)
root._addIds(seen, root.grouped[g].albums || [])
// Arming ALSO parks the reveal at 0. A delegate for a new id can be
// built by a Repeater's model binding BEFORE onAlbumsChanged runs —
// both hang off the same change signal and the order between them is
// not ours to choose — and it must never be painted at full opacity
// for one frame before the fade starts.
root._tailReveal = 0.0
root._tailSeen = seen
}
/// Disarm without fading.
function clearTailFade() {
root._tailSeen = null
root._tailReveal = 1.0
}
/// 1.0 for everything the arm already saw — and for every host that never
/// armed, and for a card with no id at all (an unknown id must fail
/// OPAQUE, never invisible).
function tailOpacity(id) {
if (!root._tailSeen || id === undefined || id === null || id === "")
return 1.0
return root._tailSeen[id] === true ? 1.0 : root._tailReveal
}
function _hasFreshId(list) {
for (var i = 0; i < list.length; i++) {
var a = list[i]
if (a && a.id !== undefined && a.id !== null && a.id !== ""
&& root._tailSeen[a.id] !== true)
return true
}
return false
}
function _maybeStartTailFade() {
if (!root._tailSeen)
return
var fresh = root._hasFreshId(root.albums)
for (var g = 0; !fresh && g < root.grouped.length; g++)
fresh = root._hasFreshId(root.grouped[g].albums || [])
// A republish with no new id — an artwork batch, a favourite toggle, a
// sort flip, a group-by flip — is NOT the page landing. Stay armed and
// stay silent; re-fading what is already on screen is the regression.
if (fresh)
tailFade.restart()
}
onAlbumsChanged: root._maybeStartTailFade()
onGroupedChanged: root._maybeStartTailFade()
NumberAnimation {
id: tailFade
target: root
property: "_tailReveal"
from: 0.0
to: 1.0
// 220ms / OutCubic is the content half of the Load-more round (the
// skeleton half uses the tree's 180ms — QbzSkeleton.qml:216).
duration: 220
easing.type: Easing.OutCubic
// Disarm on the way out, so the NEXT republish finds no arm at all.
// `finished` is emitted only on a natural end — a restart() stops the
// animation without it, which is exactly what we want mid-flight.
onFinished: root.clearTailFade()
}
// One group-by section's card grid (FULL mounts — see the header note).
// Declared before its use for readability; QML registers inline
// components document-wide either way.
component SectionGrid: Item {
id: sg
property var items: []
/// Tail fade, PASSED IN rather than read off `root`: an inline
/// `component` does not share the document's scope the way a plain
/// Component does (controls/QbzSkeleton.qml:269-270 spells this out),
/// so the mount site — which is in file scope — hands it the two
/// values. Null / 1.0 = no fade, which is every other host.
property var seenIds: null
property real reveal: 1.0
width: parent ? parent.width : 0
readonly property int columns: Math.max(
1, Math.floor((width + root.cardGap) / (root.cardWidth + root.cardGap)))
@@ -110,6 +240,12 @@ Column {
y: Math.floor(gcell.index / sg.columns) * (root.cardHeight + root.cardGap)
width: root.cardWidth
height: root.cardHeight
// Same rule as the flat grid, expressed with the passed-in
// pair (see `seenIds`): an album the arm already saw is
// opaque, so only a fetched one fades.
opacity: (sg.seenIds && gcell.modelData && gcell.modelData.id
&& sg.seenIds[gcell.modelData.id] !== true)
? sg.reveal : 1.0
AlbumCard {
albumId: gcell.modelData.id
title: gcell.modelData.title
@@ -170,6 +306,9 @@ Column {
required property int index
item: modelData
rowIndex: index
// Plain Component, file scope — `root` resolves
// here, unlike inside SectionGrid.
opacity: root.tailOpacity(modelData ? modelData.id : "")
}
}
}
@@ -177,6 +316,8 @@ Column {
SectionGrid {
visible: root.viewMode !== "list"
items: sectionCol.modelData.albums || []
seenIds: root._tailSeen
reveal: root._tailReveal
}
}
}
@@ -196,6 +337,7 @@ Column {
required property int index
item: modelData
rowIndex: index
opacity: root.tailOpacity(modelData ? modelData.id : "")
}
}
}
@@ -223,6 +365,14 @@ Column {
y: cell.rowIndex * (root.cardHeight + root.cardGap)
width: root.cardWidth
height: root.cardHeight
// Tail fade (see the block at the top). A LIVE binding, never
// a Component.onCompleted write: this delegate is destroyed
// and rebuilt on every republish, and the binding gives the
// rebuilt cell the right value at creation instead of a frame
// 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 : "")
// Component declared in the DELEGATE scope so `modelData`
// resolves (the PinnedRail dispatch pattern).
+56 -22
View File
@@ -251,7 +251,13 @@ Rectangle {
// --- Album collection -----------------------------------------
AlbumCollection {
id: collection
width: parent.width - 64
// Identity of the catalog on screen. Navigating to ANOTHER
// label disarms a tail fade that was armed by a click whose
// page never landed, so the new catalog's first paint is
// never coloured by it (AlbumCollection.collectionKey).
collectionKey: root.doc.id || ""
albums: root.visibleAlbums
grouped: root.grouped
isGrouped: root.groupBy
@@ -267,31 +273,59 @@ Rectangle {
}
// --- Load more (gated on hasMore, NEVER on total) -------------
// Ported to the shared controls/QbzLoadMore.qml (2026-08-02).
// This site is the ONE that already had a real busy arm — the
// "Loading…" label swap and the disarmed MouseArea this block used
// to spell out by hand are what the control's `busy` property was
// modelled on, so the wiring is a straight rename and the idle
// pixels are unchanged (plain arm, 32-tall box, same 13px label,
// same `implicitWidth + 24` hit box).
//
// The wrapper Item STAYS: `- 64` is the page Column's own
// left+right padding, and a Column's padding does NOT narrow
// `parent.width` for its children, so the wrapper is what keeps
// this block inside the gutters. Its collapsed height is still 0.
Item {
visible: root.shown > 0 && root.doc.hasMore === true
width: parent.width - 64
height: visible ? 32 : 0
Rectangle {
anchors.centerIn: parent
width: loadMoreText.implicitWidth + 24
height: 32
color: "transparent"
Text {
id: loadMoreText
anchors.centerIn: parent
text: root.doc.loadMoreLoading === true
? QbzSession.tr("Loading…", QbzSession.trRev)
: QbzSession.tr("Load more", QbzSession.trRev)
color: loadMoreArea.containsMouse ? theme.textPrimary : theme.textSecondary
font.pixelSize: 13
}
MouseArea {
id: loadMoreArea
anchors.fill: parent
enabled: root.doc.loadMoreLoading !== true
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzHome.labelReleasesLoadMore()
// Was a literal `visible ? 32 : 0`. The box now also has to
// make room for the skeleton block the control drops UNDER
// the button while a page is in flight; idle that block is
// 0-tall, so this is still exactly 32 (and still 0 hidden).
height: visible ? loadMore.height : 0
QbzLoadMore {
id: loadMore
width: parent.width
// 32 here, not the 28 of the plain sites — this block was
// always a 32-tall box.
buttonHeight: 32
busy: root.doc.loadMoreLoading === true
// The placeholder must be the shape of what will land, and
// this page has both arms: `viewMode` is the UI-only grid /
// list toggle (see the header note), read here exactly as
// AlbumCollection reads it ("list" vs everything else).
skeleton: root.viewMode === "list" ? "rows" : "cards"
// The grid PITCH, not the card size: the collection above
// is mounted with 200x266 cards and a 24px gap, so the
// pitch is 224 x 290 and the control derives the drawn
// card back as pitch - 24 = the same 200x266.
cellW: 224
cellH: 290
// List arm: AlbumListRow is 64 tall and this page mounts
// the collection with listRowGap 4; its art cell is the
// 44px thumb (AlbumListRow.qml:144-150), not a full-height
// square.
rowH: 64
rowGap: 4
rowCount: 2
rowArtSize: 44
onClicked: {
// Snapshot the album ids already on screen so ONLY the
// page that lands fades in. BEFORE the bridge call:
// labelReleasesLoadMore() may republish synchronously.
collection.armTailFade()
QbzHome.labelReleasesLoadMore()
}
}
}
+221 -94
View File
@@ -26,6 +26,19 @@
// coming, plus a per-card cover placeholder that clears when THAT card's
// cover lands. See the "skeleton plumbing" block below for the cost.
//
// LOAD MORE: the four per-tab buttons are the shared controls/QbzLoadMore.qml
// on its BORDERED arm (that arm IS this file's old `LoadMoreButton`, pixel for
// pixel — see that file's header, item c). Two things moved with it:
// - the appended-page placeholder now hangs BELOW its button instead of
// above it. Each tab had a standalone QbzSkeleton sitting on top of the
// button; that is the wrong side, because the page being fetched is
// appended UNDER the content already on screen and the button is the
// bottom of that content. The knobs (pitch, settleMs) came across.
// - the appended DELEGATES fade in — the other half of what the owner asked
// for ("que la aparicion de lo que se cargue, sea smooth"). Only this file
// can do that half: only the host knows which delegates are new. See the
// `fadeAt` block below.
//
// SIZE: this file is over the 500-line guideline (it already was at 638
// before the skeleton work). It is NOT split because every section is one
// tab of one document and the split would be arbitrary; the skeleton
@@ -66,6 +79,10 @@ Rectangle {
readonly property bool loading: doc.loading === true
readonly property int tab: doc.tab || 0
readonly property int filterIndex: doc.filterIndex || 0
// The query echo (search_qt::SearchPageDoc.query). Read only as an
// IDENTITY: when it changes, the page on screen is a different result set
// and any pending "fade the tail" threshold is stale (see clearFade).
readonly property string query: doc.query || ""
readonly property bool hasResults: albums.length + tracks.length + artists.length + playlists.length > 0
readonly property int previewCap: 6
@@ -110,7 +127,8 @@ Rectangle {
// placeholder is tracked locally — armed on the click, disarmed the
// moment that tab's array actually grows. A page that comes back empty
// (end of the result set) never grows it, which is why every appended
// placeholder carries settleMs.
// placeholder carries settleMs (QbzLoadMore mounts its own with the same
// 8000ms bound this file used when it drew the block by hand).
property int moreTab: -1
property int moreFrom: 0
function rowsFor(t) {
@@ -120,11 +138,74 @@ Rectangle {
readonly property bool morePending: root.moreTab >= 0
&& root.moreTab === root.tab
&& root.rowsFor(root.moreTab).length === root.moreFrom
// ---- the appended TAIL fades in (the "smooth" half) -------------------
// Only the tail, and only once. Both halves of that sentence are load
// bearing:
// - only the TAIL, because every republish of this document hands the
// views a brand-new array and recreates EVERY delegate. search_qt.rs
// republishes on the artwork pass, on tab_changed, on filter_changed
// and on apply_pin_change (its doc comment at :990 spells this out).
// A blanket fade would therefore re-dissolve the whole grid when a
// cover lands or a pin is clicked — a flicker, not a polish. So a
// delegate fades only when its index is at or past the row count that
// was on screen when the button was pressed.
// - only ONCE, because that threshold would otherwise survive into the
// NEXT republish of the same page (load_more publishes the rows, then
// the artwork pass publishes again ~a second later) and blink the new
// cards a second time. `fadeRetire` drops it a beat after the page
// lands.
// `fadeTab` scopes it to the tab that asked: the four tabs share one
// document, so a threshold armed on Albums must not fade Playlists.
property int fadeTab: -1
property int fadeFrom: -1
function fadeAt(t, i) {
return root.fadeTab === t && root.fadeFrom >= 0 && i >= root.fadeFrom
}
function clearFade() {
root.fadeTab = -1
root.fadeFrom = -1
}
Timer {
id: fadeRetire
// > the 220ms delegate fade, and long enough for the views to have
// built the new delegates (a model swap is applied in the polish
// step, not synchronously).
interval: 300
onTriggered: root.clearFade()
}
onMorePendingChanged: if (!root.morePending && root.fadeFrom >= 0) fadeRetire.restart()
// A new query, a filter re-query or a tab switch is a NEW identity — the
// stale threshold must never fade the first page of the next thing.
onQueryChanged: root.clearFade()
onFilterIndexChanged: root.clearFade()
onTabChanged: root.clearFade()
function armLoadMore(t) {
root.moreTab = t
root.moreFrom = root.rowsFor(t).length
// Armed here, for all four tabs at once: the tail starts exactly
// where the current page ends. BEFORE the bridge call, because the
// reply may republish before this function yields.
root.fadeTab = t
root.fadeFrom = root.moreFrom
moreSettle.restart()
QbzBridge.searchLoadMore(t)
}
// `morePending` clears only when the tab's array GROWS — and search_qt::
// load_more never republishes on a failed fetch (main.rs just logs), nor
// grows the array on a page whose rows were all blacklisted away. Left
// unbounded, a stuck `morePending` now DISARMS the button forever (the
// old LoadMoreButton stayed clickable; QbzLoadMore's busy arm does not)
// and keeps the placeholder block's height reserved after its shimmer
// settles. Same 8s bound ArtistView.releaseSettle uses for the identical
// reason, after which the button comes back armed for a retry.
Timer {
id: moreSettle
interval: 8000
repeat: false
onTriggered: root.moreTab = -1
}
Timer {
interval: 900
@@ -245,42 +326,16 @@ Rectangle {
// Track row (primitives/TrackRow.slint, 50px — number/play, title +
// explicit, artist, duration, quality, ⋯ menu).
// "Load more (loaded / total)" (LoadMoreButton).
component LoadMoreButton: Item {
property int loaded: 0
property int total: 0
// The click is reported OUT (the call site arms the appended-page
// placeholder and then calls the bridge). An inline component does
// not see this file's outer ids, so it must not reach for `root`.
signal loadMore()
width: parent ? parent.width : 0
visible: loaded < total
height: visible ? 44 : 0
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
y: 6
width: lmText.implicitWidth + 36
height: 32
radius: theme.radiusSm
color: lmArea.containsMouse ? theme.surfaceElevated : theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
Text {
id: lmText
anchors.centerIn: parent
text: QbzSession.tr("Load more", QbzSession.trRev) + " (" + loaded + " / " + total + ")"
color: theme.textSecondary
font.pixelSize: 13
}
MouseArea {
id: lmArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: parent.parent.loadMore()
}
}
}
//
// "Load more (loaded / total)" WAS an inline `LoadMoreButton` here. It is
// now controls/QbzLoadMore.qml with `bordered: true` — the same 32-tall
// radiusSm pill on surfaceCard / surfaceElevated-on-hover, 1px
// borderSubtle, 13px textSecondary label, `implicitWidth + 36` hit box,
// at y:6 inside a 44-tall box. That arm was written FROM this component,
// so the idle pixels are unchanged; what it adds is the placeholder block
// underneath and the busy disarm. The host still owns the whole label
// string (it carries the "n / total" counter) and the whole visibility
// rule — both are passed in at the four mount sites below.
// searchType filter radio (FilterRadio: 14px ring + 8px accent dot).
component FilterRadio: Item {
@@ -637,10 +692,25 @@ Rectangle {
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
@@ -662,24 +732,34 @@ Rectangle {
}
}
// The page that "Load more" asked for, in the shape it
// will arrive in. One row of cells, one animator; it
// disappears the moment the array grows (or settles out
// if the page comes back empty).
QbzSkeleton {
visible: root.tab === 1 && root.morePending
variant: "cardGrid"
// will arrive in: one row of 224x270 cells on one
// animator, now drawn BELOW the button by QbzLoadMore
// (the standalone block that used to sit here was above
// it). It disappears the moment the array grows, or
// settles out after 8s if the page comes back empty.
//
// `visible` merges the two gates that were split before:
// the mount said `visible: root.tab === 1` and that
// binding OVERRODE the component's own `loaded < total`,
// so the pill survived past the end of the result set
// ("Load more (20 / 20)"). Both live in one expression
// now, which is what the component always meant.
QbzLoadMore {
visible: root.tab === 1
&& root.albums.length < (root.doc.albumsTotal || 0)
width: parent.width
height: visible ? 270 : 0
bordered: true
buttonHeight: 44
label: QbzSession.tr("Load more", QbzSession.trRev)
+ " (" + root.albums.length + " / " + (root.doc.albumsTotal || 0) + ")"
// morePending is already scoped to the active tab
// (moreTab === tab), so the other three mounts cannot
// see a busy that is not theirs.
busy: root.morePending
skeleton: "cards"
cellW: 224
cellH: 270
phase: root.skelPhase
settleMs: 8000
}
LoadMoreButton {
visible: root.tab === 1
loaded: root.albums.length
total: root.doc.albumsTotal || 0
onLoadMore: root.armLoadMore(1)
onClicked: root.armLoadMore(1)
}
// ---- Tracks --------------------------------------------
@@ -697,6 +777,19 @@ Rectangle {
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
item: modelData
number: index + 1
menuShowFavorite: false
@@ -725,22 +818,32 @@ Rectangle {
}
}
}
QbzSkeleton {
visible: root.tab === 2 && root.morePending
variant: "rowList"
width: parent.width
height: visible ? 200 : 0
rowH: 50
rowGap: 0
rowArtSize: 36
phase: root.skelPhase
settleMs: 8000
}
LoadMoreButton {
// Tracks: the appended page arrives as TrackRows, so the
// placeholder is the "rows" arm — 50px, the row height
// TrackRow.qml declares, on the 4px pitch the Column
// above uses (`spacing: 4`). The full-page loading
// skeleton further down passes rowGap: 0 because it
// covers a whole viewport, where the pitch stands next to
// nothing; these two rows stand directly under the last
// real row, so they use the real one. TWO rows — the
// owner's "una o dos filas".
QbzLoadMore {
visible: root.tab === 2
loaded: root.tracks.length
total: root.doc.tracksTotal || 0
onLoadMore: root.armLoadMore(2)
&& root.tracks.length < (root.doc.tracksTotal || 0)
width: parent.width
bordered: true
buttonHeight: 44
label: QbzSession.tr("Load more", QbzSession.trRev)
+ " (" + root.tracks.length + " / " + (root.doc.tracksTotal || 0) + ")"
busy: root.morePending
skeleton: "rows"
rowH: 50
rowGap: 4
rowCount: 2
// TrackRow draws 36px art in its 50px row, and the
// standalone block this replaced said so too.
rowArtSize: 36
onClicked: root.armLoadMore(2)
}
// ---- Artists grid (per-type tab) ------------------------
@@ -755,31 +858,47 @@ Rectangle {
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 || ""
}
}
// Artists: ROUND cells (ArtistGridCard's portrait), and
// no per-item overlay anywhere — a missing artist photo
// has a designed placeholder already.
QbzSkeleton {
visible: root.tab === 3 && root.morePending
variant: "cardGrid"
// Artists: this grid's own 216x262 pitch, NOT the 224x270
// the album/playlist grids use — the cells here are the
// ArtistCard portrait footprint: 200-wide ROUND cells on
// a 16px gutter (the GridView above says (width+16)/216),
// both of which the standalone block this replaces passed
// and QbzLoadMore now passes through.
QbzLoadMore {
visible: root.tab === 3
&& root.artists.length < (root.doc.artistsTotal || 0)
width: parent.width
height: visible ? 262 : 0
bordered: true
buttonHeight: 44
label: QbzSession.tr("Load more", QbzSession.trRev)
+ " (" + root.artists.length + " / " + (root.doc.artistsTotal || 0) + ")"
busy: root.morePending
skeleton: "cards"
cellW: 216
cellH: 262
roundCells: true
phase: root.skelPhase
settleMs: 8000
}
LoadMoreButton {
visible: root.tab === 3
loaded: root.artists.length
total: root.doc.artistsTotal || 0
onLoadMore: root.armLoadMore(3)
cardGutter: 16
onClicked: root.armLoadMore(3)
}
// ---- Playlists ------------------------------------------
@@ -834,10 +953,19 @@ Rectangle {
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 || ""
@@ -850,21 +978,20 @@ Rectangle {
}
}
}
QbzSkeleton {
visible: root.tab === 4 && root.morePending
variant: "cardGrid"
// Playlists share the album grid's 224x270 pitch.
QbzLoadMore {
visible: root.tab === 4
&& root.playlists.length < (root.doc.playlistsTotal || 0)
width: parent.width
height: visible ? 270 : 0
bordered: true
buttonHeight: 44
label: QbzSession.tr("Load more", QbzSession.trRev)
+ " (" + root.playlists.length + " / " + (root.doc.playlistsTotal || 0) + ")"
busy: root.morePending
skeleton: "cards"
cellW: 224
cellH: 270
phase: root.skelPhase
settleMs: 8000
}
LoadMoreButton {
visible: root.tab === 4
loaded: root.playlists.length
total: root.doc.playlistsTotal || 0
onLoadMore: root.armLoadMore(4)
onClicked: root.armLoadMore(4)
}
}