Files
qbz/crates/qbz-qt/qml/controls/QbzLoadMore.qml
T
vicrodh fed0816e9b fix(qt): ambient mode reaches every chrome control
Under the ambient field some controls still painted the opaque theme
token while their neighbours used the half-alpha one: the album track
filter and its select toggle, the Library tab strip's host, the Local
Library icon selects, the header's active nav items and open playlists
button, the queue sidebar's now-playing card and artwork placeholders,
the Listen list's floating actions, and the shared SettingsButton,
IconTextButton, multi-select bar, text area, load-more pill, tree row
selection, sidebar count chips, Purchases tab strip, play-history filter
box and Orbit banner. Each now takes the ambient-aware fill the header
search and QbzTabBar already use (theme.ambientOn ? …A50 : …).

Tooltips, popups and modals stay opaque on purpose: they are read over
content, not chrome.

cc
2026-09-14 00:39:47 -06:00

264 lines
13 KiB
QML

// 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.ambientOn ? theme.surfaceCardA50 : 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
}
}
}
}