From 6f4fb72f572c3eaac3356e18855c3adb827094b5 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:32:24 -0600 Subject: [PATCH] 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 --- crates/qbz-qt/build.rs | 1 + crates/qbz-qt/qml/controls/QbzLoadMore.qml | 263 +++++++++++++++ crates/qbz-qt/qml/views/AlbumCollection.qml | 150 +++++++++ crates/qbz-qt/qml/views/LabelReleasesView.qml | 78 +++-- crates/qbz-qt/qml/views/SearchView.qml | 315 ++++++++++++------ 5 files changed, 691 insertions(+), 116 deletions(-) create mode 100644 crates/qbz-qt/qml/controls/QbzLoadMore.qml diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index 90ea09f65..07befd393 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -84,6 +84,7 @@ fn main() { "qml/controls/QbzIconButton.qml", "qml/controls/QbzLineEdit.qml", "qml/controls/QbzLoadingDots.qml", + "qml/controls/QbzLoadMore.qml", "qml/controls/QbzMultiSelectBar.qml", "qml/controls/QbzNavButton.qml", "qml/controls/QbzOfflinePlaceholder.qml", diff --git a/crates/qbz-qt/qml/controls/QbzLoadMore.qml b/crates/qbz-qt/qml/controls/QbzLoadMore.qml new file mode 100644 index 000000000..8a4329d75 --- /dev/null +++ b/crates/qbz-qt/qml/controls/QbzLoadMore.qml @@ -0,0 +1,263 @@ +// QbzLoadMore — THE shared "Load more" affordance, app-wide. +// +// Consolidates the FIVE hand-rolled copies this tree grew, in three visually +// distinct shapes. Every one of them is reproduced here PIXEL FOR PIXEL in its +// idle state; the arms are `bordered` and nothing else: +// a. views/ArtistView.qml:1122-1147 — release section. Plain centred Text, +// 13px, 28 tall, hit box `implicitWidth + 24`, hover textPrimary / +// textSecondary. NETWORK-BACKED (QbzArtist.loadReleaseSection). +// b. views/ArtistView.qml:1596-1617 — Popular Tracks expander. Same plain +// shape; the label toggles "Load more" / "View less" and the action is +// CLIENT-SIDE (instant, no fetch) — so it passes `label` and never `busy`. +// c. views/SearchView.qml:249-283 — `LoadMoreButton`: the BORDERED pill +// (radiusSm, 32 tall, `implicitWidth + 36`, 1px borderSubtle, surfaceCard +// idle / surfaceElevated hover) at y:6 inside a 44-tall box. Its label +// carries the "n / total" counter, so the HOST owns the whole string. +// d. views/LabelReleasesView.qml:269-296 — plain shape with a "Loading…" +// arm and the MouseArea disarmed while loading (32 tall there). +// e. views/LabelView.qml:549-583 — client-side 5 -> 20 -> 50 -> 5 +// reveal, plain shape, 28 tall. +// +// ── WHY THIS EXISTS (owner, 2026-08-02) ──────────────────────────────────── +// "Los LoadMore dan una carga de golpe, se ve viejo, carguemos una o dos filas +// skeleton loader debajo del boton y que la aparicion de lo que se cargue, +// sea smooth." +// So: while a fetch is in flight, one row of card placeholders (or one/two row +// placeholders) sits UNDER the button. BELOW, not above — that is what the +// owner asked for, literally, and it is also the honest position: the page +// that is being fetched will be appended BELOW the content that is already on +// screen, and the button is the bottom of that content. (SearchView.qml:668 +// already mounted such a block, but ABOVE its button; the consumer pass moves +// it. The 8000ms settle bound and the 224x270 pitch come from that site.) +// +// ── WHAT THIS CONTROL DOES *NOT* DO ──────────────────────────────────────── +// The other half of "smooth" — the APPEARANCE of what landed — lives in the +// CONSUMERS. Only a host knows which of its delegates are new (it holds the +// index the page started at), so only a host can fade the appended delegates +// in. This control owns the placeholder and the button; it never touches the +// host's model. +// `visible` is likewise NOT decided here: every one of the five sites already +// gates its own instance (`section.hasMore`, `doc.hasMore`, `loaded < total`, +// `topTracks.length > preview`). The only visibility rule below is the +// skeleton block's own. +// +// ── COST ─────────────────────────────────────────────────────────────────── +// The skeleton is the SHARED controls/QbzSkeleton.qml COMPOSITE, never a new +// primitive: ONE instance draws a whole row of cells on ONE animator (see that +// file's COST note). It is mounted through a Loader that is only `active` +// while the block is on screen, so an idle Load-more button holds no +// rectangles and no timer at all. `selfDrive: true` gives that one short-lived +// instance its own 900ms phase (there is no host phase to borrow — the button +// may be the only skeleton on the page), and `settleMs: 8000` bounds it, so a +// fetch that never returns cannot shimmer forever. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Column { + id: root + + // --- API (four consumer views are written against these names) --------- + /// false = the plain centred text (a, b, d, e); true = Search's pill (c). + property bool bordered: false + /// "" = the shared "Load more" msgid. NO new strings: the only labels this + /// control and its hosts may use are the existing "Load more" / "Loading…" + /// / "View less" msgids (all 8 locales already carry them). + property string label: "" + /// A fetch is in flight: the button is disarmed and the skeleton shows. + property bool busy: false + /// "" = the shared "Loading…" msgid. Plain arm only (see `_text`). + property string busyLabel: "" + /// "none" | "cards" | "rows". + property string skeleton: "none" + /// "cards": the HOST's grid pitch (SearchView.qml:673-674 = 224 x 270). + property real cellW: 224 + property real cellH: 270 + /// "cards": ROUND placeholder cells — the artist grids' portrait + /// footprint. The standalone block this control replaced on Search's + /// Artists tab passed QbzSkeleton `roundCells: true`; without the + /// pass-through its placeholders regressed to squares standing in for + /// round portraits. + property bool roundCells: false + /// "cards": pitch minus this = the card drawn inside a cell. 24 is the + /// album grids' gutter (224 - 200); Search's Artists grid packs 200-wide + /// portraits on a 216 pitch, i.e. a 16px gutter, and deriving its cards + /// at -24 drew them 8px small. + property real cardGutter: 24 + /// "rows": row metrics (TrackRow is 50 tall, lists gap 6). + property real rowH: 50 + property real rowGap: 6 + /// "rows": the art tile inside a row, when it is SMALLER than the row — + /// QbzSkeleton's own knob (0 = square, full row height). TrackRow draws + /// 36px art in its 50px row and the standalone block this replaced + /// passed 36; dropping it made the placeholder art a 50px square that + /// does not match what lands. + property real rowArtSize: 0 + /// "rows": how many placeholder rows — the owner asked for "one or two". + property int rowCount: 2 + /// 28 for the plain sites (a, b, e; d passes 32), 44 for the Search box (c). + property real buttonHeight: 28 + + signal clicked() + + // A host may override this; the default is what all five sites do (they + // sit in a Column and span it). NOTE the Column-padding trap: a Column + // with left/rightPadding does NOT narrow parent.width, so a host inside + // one passes `width: parent.width - 64` by hand, exactly as + // LabelReleasesView.qml:272 already does. + width: parent ? parent.width : 0 + // No gap of our own: the "cards" block already carries cellH - cardH of + // slack under its cells and the "rows" block a trailing rowGap, and the + // button box carries its own vertical slack around a 13px label. + spacing: 0 + + QbzTheme { id: theme } + + // The bordered arm's label is host-supplied (it carries the "n / total" + // counter), so it does NOT swap to a busy string — a host that wants one + // passes it in `label`. The plain arm swaps, as LabelReleasesView:282-284. + readonly property string _idleText: root.label !== "" + ? root.label + : QbzSession.tr("Load more", QbzSession.trRev) + readonly property string _busyText: root.busyLabel !== "" + ? root.busyLabel + : QbzSession.tr("Loading…", QbzSession.trRev) + readonly property string _text: (root.busy && !root.bordered) + ? root._busyText + : root._idleText + + // ---- the button, FIRST (the skeleton goes under it) ------------------- + Item { + id: buttonBox + width: root.width + height: root.buttonHeight + + // -- plain arm (a, b, d, e) ----------------------------------------- + // ArtistView centres its hit box with two flexible spacer Items in a + // Row; anchoring the box's horizontalCenter is the same result with + // one item instead of three. The hit box is `implicitWidth + 24` and + // as tall as the box, which is what a/d/e already do — b's MouseArea + // was glued to the Text itself (no +24), so its hit area grows by + // 12px a side; the idle pixels are identical (the Rectangle is + // transparent) and the bigger target is the one the other four use. + Rectangle { + id: plainBtn + visible: !root.bordered + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + width: plainText.implicitWidth + 24 + height: root.buttonHeight + color: "transparent" + Text { + id: plainText + anchors.centerIn: parent + text: root._text + color: plainArea.containsMouse ? theme.textPrimary : theme.textSecondary + font.pixelSize: 13 + } + MouseArea { + id: plainArea + anchors.fill: parent + // Disarmed while the fetch is in flight — LabelReleasesView + // .qml:291 already does this, and a disabled MouseArea also + // stops reporting hover, so the label stays textSecondary. + enabled: !root.busy + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.clicked() + } + } + + // -- bordered arm (c) — SearchView's pill, unharmonised -------------- + // Deliberately NOT folded into the plain arm: the two shapes stay two + // shapes (radiusSm pill on surfaceCard vs bare text), only the plumbing + // is shared. + Rectangle { + id: pillBtn + visible: root.bordered + anchors.horizontalCenter: parent.horizontalCenter + y: 6 + width: pillText.implicitWidth + 36 + height: 32 + radius: theme.radiusSm + color: pillArea.containsMouse ? theme.surfaceElevated : theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + Text { + id: pillText + anchors.centerIn: parent + text: root._text + color: theme.textSecondary + font.pixelSize: 13 + } + MouseArea { + id: pillArea + anchors.fill: parent + enabled: !root.busy + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.clicked() + } + } + } + + // ---- the skeleton, BELOW the button (see WHY THIS EXISTS) ------------- + Item { + id: skelBlock + width: root.width + + readonly property bool wanted: root.busy && root.skeleton !== "none" + readonly property real blockHeight: root.skeleton === "cards" + ? root.cellH // exactly ONE row + : root.rowCount * (root.rowH + root.rowGap) + + // Fade, never pop — 180ms is the duration the rest of the tree uses + // (QbzSkeleton.qml:216, where it is what dissolves a placeholder into + // the art). Height may snap; opacity may not. Staying `visible` while + // the fade-out runs is what lets the exit be seen at all — `visible` + // going false in the same frame would cut it. + opacity: skelBlock.wanted ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: 180 } } + visible: skelBlock.wanted || skelBlock.opacity > 0.004 + // ZERO height when idle, so nothing on the page shifts. + height: visible ? skelBlock.blockHeight : 0 + + Loader { + anchors.fill: parent + // Nothing is instantiated for an idle button: no rectangles, no + // timer (QbzSkeleton's own Loader would otherwise stay active, + // since its gate is `opacity`, which does not inherit). + active: skelBlock.visible + sourceComponent: skelShape + } + + Component { + id: skelShape + QbzSkeleton { + variant: root.skeleton === "cards" ? "cardGrid" : "rowList" + // "cards": the host passes the PITCH; the card drawn inside a + // cell is the pitch minus the gutter, which is how SearchView + // .qml:673-674 lands on QbzSkeleton's 200x246 defaults (224-24, + // 270-24). A grid with a tighter gutter overrides `cardGutter`. + cellW: root.cellW + cellH: root.cellH + cardW: Math.max(0, root.cellW - root.cardGutter) + cardH: Math.max(0, root.cellH - root.cardGutter) + roundCells: root.roundCells + // "rows". + rowH: root.rowH + rowGap: root.rowGap + rowArtSize: root.rowArtSize + // One short-lived instance, its own 900ms phase: there is no + // host phase to borrow here, and one animator is the whole + // cost of the block. + selfDrive: true + // A fetch that never returns must not shimmer forever — the + // same 8s bound SearchView.qml:676 uses on its appended-page + // placeholder. + settleMs: 8000 + } + } + } +} diff --git a/crates/qbz-qt/qml/views/AlbumCollection.qml b/crates/qbz-qt/qml/views/AlbumCollection.qml index c40e46404..c226a8b37 100644 --- a/crates/qbz-qt/qml/views/AlbumCollection.qml +++ b/crates/qbz-qt/qml/views/AlbumCollection.qml @@ -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). diff --git a/crates/qbz-qt/qml/views/LabelReleasesView.qml b/crates/qbz-qt/qml/views/LabelReleasesView.qml index e1f204541..2b0d527d9 100644 --- a/crates/qbz-qt/qml/views/LabelReleasesView.qml +++ b/crates/qbz-qt/qml/views/LabelReleasesView.qml @@ -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() } } } diff --git a/crates/qbz-qt/qml/views/SearchView.qml b/crates/qbz-qt/qml/views/SearchView.qml index 8a23625f7..8d989a3a5 100644 --- a/crates/qbz-qt/qml/views/SearchView.qml +++ b/crates/qbz-qt/qml/views/SearchView.qml @@ -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) } }