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) } }