feat(qbz-qt): staged loading, skeletons everywhere, lyrics, gestures, richer menus

**Deferred external rows.** The album page awaited the Last.fm row before
publishing anything, so the tracklist waited on an external service plus
MusicBrainz. It publishes in stages now — header and tracks first, then each
Qobuz rail as it lands, then Last.fm — with every patch merged under a
generation counter so a late reply for album A cannot paint onto album B. The
artist page was already staged this way; its views were the part still showing
bare "Loading…" text.

**Skeletons as the standard.** One control, new variants rather than new
components, mounted across the views. Its second job is the one the owner
named: cleanup. A cover that never resolves settles the placeholder once and
reveals the card's own empty state instead of shimmering forever — local albums
with no embedded art produce no artwork entry at all, so a placeholder gated
only on "not arrived yet" would never stop.

**Local artwork streams.** Fifteen seconds to first cover on a 1274-album grid
was one batch emitted after the whole window resolved. Each cover emits as it
resolves now, visible band before prefetch margin.

**Lyrics migrated.** Smooth sync instead of a 1 Hz tick, word-level karaoke
where the document carries it, the surrounding-line dimming, the controls
flyout and the translation layer the v10 backend already supported. Exactly one
row subscribes to the 30 Hz output; the timer drops to 250ms when paused or
hidden.

**Navigation by mouse and touchpad.** Side buttons and a two-finger horizontal
swipe, matching #653 — including the part that matters, which is that a swipe
over a carousel scrolls the carousel. The layer is declared first in the shell
so everything else gets first refusal.

**Context menus.** Brought to the Slint inventory, Play later included, with
right-click opening the same menu as the button everywhere. Entries whose seam
does not exist are hidden rather than shipped inert.

Two controls that rendered and no-opped are now honestly disabled: the My QBZ
nav row, which routes to a view the shell has no case for, and three sidebar
menu entries with no backing call.

The sidebar caret was never a condition bug. The row is a positioner and the
name cell's width ignored the count label, so a folder WITH content pushed the
chevron past the clip — which is why it vanished exactly when it mattered.

cc
This commit is contained in:
vicrodh committed 2026-07-28 18:12:31 -06:00
1 parent 2b31d3866c
commit f3a33245b2
52 files changed
+4962 -719

No files matched your search

+327 -37
View File
@@ -20,9 +20,21 @@
// the ArtistGridCard hero slot it shares the row with.
// - The Slint's windowed grid virtualization is not replicated (page size
// is 20 — the whole set mounts).
//
// LOADING (a deliberate ADDITION over the Slint's bare LoadingSpinner): the
// view mounts QbzSkeleton placeholders in the shape of the tab that is
// coming, plus a per-card cover placeholder that clears when THAT card's
// cover lands. See the "skeleton plumbing" block below for the cost.
//
// 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
// additions are the composite mounts, which are ~8 lines each precisely to
// keep it from growing further.
import QtQuick
import QtQuick.Controls
import QtQuick.Window
import com.blitzfc.qbz
import "../cards"
import "../controls"
@@ -57,6 +69,84 @@ Rectangle {
readonly property bool hasResults: albums.length + tracks.length + artists.length + playlists.length > 0
readonly property int previewCap: 6
// ======================= skeleton plumbing ===========================
// ONE 900ms Timer drives EVERY placeholder in this view (QbzSkeleton's
// preferred drive mode). GATING RULE: freeze on NOT VISIBLE — the view
// hidden, or the window minimized/hidden. NEVER on lost focus (a tiling
// desktop keeps windows visible and unfocused).
property bool skelPhase: false
readonly property bool windowShowing: root.Window.window
? (root.Window.window.visibility !== Window.Minimized
&& root.Window.window.visibility !== Window.Hidden)
: true
// A card is pending while it HAS a cover url and no local path yet.
// Recomputed only when the document is republished (which is also when
// covers land), never per frame.
function anyPending(rows) {
for (var i = 0; i < rows.length; i++) {
if ((rows[i].artUrl || "") !== "" && (rows[i].artPath || "") === "") return true
}
return false
}
readonly property bool heroPending: {
var k = mp.kind || ""
if (k === "" || k === "artist") return false
var c = k === "album" ? (mp.album || ({})) : (mp.track || ({}))
return (c.artUrl || "") !== "" && (c.artPath || "") === ""
}
readonly property bool artPending: anyPending(albums) || anyPending(tracks)
|| anyPending(playlists) || heroPending
// "Load more" has NO bridge-side busy flag: search_qt.rs only sets
// doc.loading in submit(), never in load_more(). So the appended-page
// 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.
property int moreTab: -1
property int moreFrom: 0
function rowsFor(t) {
return t === 1 ? albums : t === 2 ? tracks
: t === 3 ? artists : t === 4 ? playlists : []
}
readonly property bool morePending: root.moreTab >= 0
&& root.moreTab === root.tab
&& root.rowsFor(root.moreTab).length === root.moreFrom
function armLoadMore(t) {
root.moreTab = t
root.moreFrom = root.rowsFor(t).length
QbzBridge.searchLoadMore(t)
}
Timer {
interval: 900
repeat: true
running: (root.loading || root.artPending || root.morePending)
&& root.visible && root.windowShowing
onTriggered: root.skelPhase = !root.skelPhase
}
// Per-item cover placeholder, mounted by every card delegate over the
// 200x200 artwork square. A bare Rectangle, so it does not eat the
// card's hover/click areas underneath; it clears when THIS card's cover
// lands, so a page resolves progressively instead of as a lump.
// ARTISTS are deliberately excluded everywhere: ArtistCard already draws
// a designed round gradient+glyph portrait for a missing cover, which
// reads as a portrait rather than as a hole (same rule as LibraryView).
// NOTE: an inline `component` does NOT see this file's outer ids, so it
// takes NO `root.` reference — every call site passes `phase:` itself.
component CardArtSkeleton: QbzSkeleton {
property var card: ({})
variant: "art"
width: 200
height: 200
visible: ((card.artUrl || "") !== "") && ((card.artPath || "") === "")
// A cover whose download fails republishes the document with an
// empty artPath — without this the tile would shimmer forever.
settleMs: 6000
}
// Track hero (SearchTrackHero: the 200x246 track card + the quality
// label as TEXT under the meta).
component SearchTrackHero: Rectangle {
@@ -146,6 +236,10 @@ Rectangle {
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
@@ -170,7 +264,7 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.searchLoadMore(root.tab)
onClicked: parent.parent.loadMore()
}
}
}
@@ -383,6 +477,16 @@ Rectangle {
card: root.mp.track || ({})
qualityLabel: root.mp.qualityLabel || ""
}
// Hero cover placeholder (album/track arms —
// the artist arm keeps ArtistCard's designed
// round portrait).
CardArtSkeleton {
anchors.horizontalCenter: parent.horizontalCenter
card: root.mp.kind === "album" ? (root.mp.album || ({}))
: root.mp.kind === "track" ? (root.mp.track || ({}))
: ({})
phase: root.skelPhase
}
}
}
// Artists carousel — fills the rest of the row.
@@ -459,17 +563,28 @@ Rectangle {
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.albums
delegate: AlbumCard {
albumId: modelData.id
title: modelData.title
artist: modelData.artist
artistId: modelData.artistId
genre: modelData.genre
year: modelData.year
qualityTier: modelData.qualityTier
artSource: modelData.artPath || ""
isFavorite: false
isPinned: false
delegate: Item {
required property var modelData
required property int index
width: 200
height: 246
AlbumCard {
albumId: modelData.id
title: modelData.title
artist: modelData.artist
artistId: modelData.artistId
genre: modelData.genre
year: modelData.year
qualityTier: modelData.qualityTier
artSource: modelData.artPath || ""
isFavorite: false
isPinned: false
}
CardArtSkeleton {
card: modelData
phase: root.skelPhase
cellIndex: index
}
}
}
}
@@ -484,23 +599,49 @@ Rectangle {
boundsBehavior: Flickable.StopAtBounds
interactive: false
model: root.albums
delegate: AlbumCard {
albumId: modelData.id
title: modelData.title
artist: modelData.artist
artistId: modelData.artistId
genre: modelData.genre
year: modelData.year
qualityTier: modelData.qualityTier
artSource: modelData.artPath || ""
isFavorite: false
isPinned: false
delegate: Item {
required property var modelData
required property int index
width: 200
height: 246
AlbumCard {
albumId: modelData.id
title: modelData.title
artist: modelData.artist
artistId: modelData.artistId
genre: modelData.genre
year: modelData.year
qualityTier: modelData.qualityTier
artSource: modelData.artPath || ""
isFavorite: false
isPinned: false
}
CardArtSkeleton {
card: modelData
phase: root.skelPhase
cellIndex: index
}
}
}
// 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"
width: parent.width
height: visible ? 270 : 0
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)
}
// ---- Tracks --------------------------------------------
@@ -526,10 +667,22 @@ 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 {
visible: root.tab === 2
loaded: root.tracks.length
total: root.doc.tracksTotal || 0
onLoadMore: root.armLoadMore(2)
}
// ---- Artists grid (per-type tab) ------------------------
@@ -548,10 +701,25 @@ Rectangle {
artSource: modelData.artPath || ""
}
}
// 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"
width: parent.width
height: visible ? 262 : 0
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)
}
// ---- Playlists ------------------------------------------
@@ -574,9 +742,20 @@ Rectangle {
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.playlists
delegate: PlaylistCard {
item: modelData
artSource: modelData.artPath || ""
delegate: Item {
required property var modelData
required property int index
width: 200
height: 246
PlaylistCard {
item: modelData
artSource: modelData.artPath || ""
}
CardArtSkeleton {
card: modelData
phase: root.skelPhase
cellIndex: index
}
}
}
}
@@ -590,31 +769,142 @@ Rectangle {
boundsBehavior: Flickable.StopAtBounds
interactive: false
model: root.playlists
delegate: PlaylistCard {
delegate: Item {
required property var modelData
required property int index
width: 200
height: 246
PlaylistCard {
item: modelData
artSource: modelData.artPath || ""
}
CardArtSkeleton {
card: modelData
phase: root.skelPhase
cellIndex: index
}
}
}
QbzSkeleton {
visible: root.tab === 4 && root.morePending
variant: "cardGrid"
width: parent.width
height: visible ? 270 : 0
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)
}
}
// ---- Loading + empty states ---------------------------------
// ---- Loading skeleton ---------------------------------------
// Shape of the tab that is coming, not a spinner. Mounted for
// the WHOLE of `loading`, not only the first search: bodyCol
// fades to 0 while a new query is in flight, so a re-search
// used to show an empty page with no affordance at all.
// COST: at most 8 animators on the All tab (3 title bars, 1
// hero card, 2 card composites, 1 slim-row composite); one
// composite covers a whole viewport row on ONE animator.
Item {
visible: root.loading && !root.hasResults
anchors.fill: parent
id: searchSkel
visible: root.loading
x: 32
y: 0
width: bodyFlick.width - 64
height: bodyFlick.height
// All tab: hero + artists carousel + albums + tracks.
Column {
anchors.centerIn: parent
spacing: 18
QbzSpinner { size: 36; anchors.horizontalCenter: parent.horizontalCenter }
Text {
text: QbzSession.tr("Searching…", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 13
visible: root.tab === 0
width: parent.width
spacing: 28
Row {
width: parent.width
spacing: 24
Column {
width: 200
spacing: 12
QbzSkeleton { variant: "block"; width: 160; height: 22; phase: root.skelPhase }
QbzSkeleton { variant: "card"; width: 200; phase: root.skelPhase }
}
Column {
width: parent.width - 224
spacing: 12
QbzSkeleton { variant: "block"; width: 180; height: 22; phase: root.skelPhase }
QbzSkeleton {
variant: "cardGrid"
width: parent.width
height: 246
cellW: 232
cellH: 246
roundCells: true
phase: root.skelPhase
}
}
}
Column {
width: parent.width
spacing: 12
QbzSkeleton { variant: "block"; width: 180; height: 22; phase: root.skelPhase }
QbzSkeleton {
variant: "cardGrid"
width: parent.width
height: 246
cellW: 232
cellH: 246
phase: root.skelPhase
}
}
Column {
width: parent.width
spacing: 8
QbzSkeleton { variant: "block"; width: 180; height: 22; phase: root.skelPhase }
QbzSkeleton {
variant: "rowList"
width: parent.width
height: 300
rowH: 50
rowGap: 0
rowArtSize: 36
phase: root.skelPhase
}
}
}
// Albums / Playlists tabs: the 224x270 grid.
QbzSkeleton {
visible: root.tab === 1 || root.tab === 4
variant: "cardGrid"
anchors.fill: parent
cellW: 224
cellH: 270
phase: root.skelPhase
}
// Tracks tab: the 50px TrackRow list.
QbzSkeleton {
visible: root.tab === 2
variant: "rowList"
anchors.fill: parent
rowH: 50
rowGap: 0
rowArtSize: 36
phase: root.skelPhase
}
// Artists tab: round cells on the 216x262 pitch.
QbzSkeleton {
visible: root.tab === 3
variant: "cardGrid"
anchors.fill: parent
cellW: 216
cellH: 262
roundCells: true
phase: root.skelPhase
}
}
Text {