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

@@ -17,6 +17,8 @@ Rectangle {
property var item: ({})
/// Resolved cover for item.artKey (the host's artMap lookup).
property string artSource: ""
/// The LocalLibraryView root (shared skeleton pulse + artwork gate).
property var view: null
signal opened()
QbzTheme { id: theme }
@@ -48,6 +50,17 @@ Rectangle {
source: root.artSource
radius: 4
}
// Per-item cover placeholder — clears when THIS folder's cover
// lands; settles out when the folder has none (local artwork
// resolution drops keys with no cover).
QbzSkeleton {
variant: "art"
anchors.fill: parent
blockRadius: 4
visible: root.view ? root.view.artPending(root.item.artKey) : false
phase: root.view ? root.view.skelPhase : false
settleMs: root.view ? root.view.artSettleMs : 0
}
}
Text {
width: parent.width
@@ -207,6 +207,25 @@ Item {
root.enqueueRequested(cardCell.modelData.id, m)
}
}
// Per-item cover placeholder: it clears the
// moment THIS album's thumbnail lands in artMap,
// so a scrolled-into page fills in progressively
// instead of as a wall of dead tiles. A bare
// Rectangle — it does not take pointer events, so
// the card's own areas keep working underneath.
// settleMs is mandatory here: local artwork
// resolution drops keys with no cover, so an
// artless album would otherwise shimmer forever
// (see LocalLibraryView.artSettleMs).
QbzSkeleton {
variant: "art"
width: 200
height: 200
visible: root.view
? root.view.artPending(cardCell.modelData.artKey) : false
phase: root.view ? root.view.skelPhase : false
settleMs: root.view ? root.view.artSettleMs : 0
}
// RIGHT-only, declared after the card so every
// left click still reaches the card's own areas.
MouseArea {
@@ -243,6 +262,7 @@ Item {
id: listRowComp
LocalAlbumRow {
width: list.width
view: root.view
item: modelData.items[0]
artSource: root.view
? (root.view.artMap[modelData.items[0].artKey] || "") : ""
@@ -20,6 +20,10 @@ Row {
property string infoLine: ""
property var versions: []
property string coverSource: ""
/// Per-item cover state, owned by the page (LocalAlbumView).
property bool coverPending: false
property bool skelPhase: false
property int artSettleMs: 0
signal openArtist(string name)
QbzTheme { id: theme }
@@ -40,6 +44,16 @@ Row {
source: root.coverSource
radius: 12
}
// Per-item: clears when THIS album's cover lands; settles out when
// the album has none (local artwork drops keys with no cover).
QbzSkeleton {
variant: "art"
anchors.fill: parent
blockRadius: 12
visible: root.coverPending
phase: root.skelPhase
settleMs: root.artSettleMs
}
}
Column {
@@ -23,6 +23,10 @@ Rectangle {
property var item: ({})
property string artSource: ""
/// The LocalLibraryView root — the shared skeleton pulse, the per-item
/// artwork gate and the settle bound all live there (never duplicated
/// per row, so the rule cannot drift between surfaces).
property var view: null
property bool showSource: true
property bool selectMode: false
property bool checked: false
@@ -99,6 +103,16 @@ Rectangle {
source: root.artSource
radius: 4
}
// Per-item: clears when THIS row's cover lands; settles out when
// the album simply has none (local artwork drops such keys).
QbzSkeleton {
variant: "art"
anchors.fill: parent
blockRadius: 4
visible: root.view ? root.view.artPending(root.item.artKey) : false
phase: root.view ? root.view.skelPhase : false
settleMs: root.view ? root.view.artSettleMs : 0
}
}
Column {
width: parent.width - 40 - 70 - 90 - 92 - 32
@@ -22,10 +22,32 @@ Item {
QbzTheme { id: theme }
QbzSpinner {
anchors.centerIn: parent
size: 36
visible: QbzLocal.localAlbumsLoading
// Loading = the SHAPE of the collection that is coming (the Slint mounts
// a bare 36px LoadingSpinner here), matching the tab's current view mode:
// the 220x266 card grid or the 56px list rows. ONE instance for the whole
// viewport — one animator, not one per cell (QbzSkeleton COST note).
QbzSkeleton {
visible: QbzLocal.localAlbumsLoading && root.view.albumsView === "grid"
variant: "cardGrid"
anchors.fill: parent
anchors.leftMargin: 32
anchors.rightMargin: 32
anchors.topMargin: 16
cellW: 220
cellH: 266
phase: root.view.skelPhase
}
QbzSkeleton {
visible: QbzLocal.localAlbumsLoading && root.view.albumsView !== "grid"
variant: "rowList"
anchors.fill: parent
anchors.leftMargin: 32
anchors.rightMargin: 32
anchors.topMargin: 16
rowH: 56
rowGap: 0
rowArtSize: 40
phase: root.view.skelPhase
}
// Load error + retry.
@@ -6,6 +6,7 @@
import QtQuick
import com.blitzfc.qbz
import "../../controls"
import "../../theme"
Rectangle {
@@ -14,8 +15,13 @@ Rectangle {
property var item: ({})
property bool selected: false
property string artSource: ""
/// The LocalLibraryView root (shared pulse + per-item artwork gate).
property var view: null
signal picked(string name)
readonly property bool avatarPending: root.view
? root.view.artPending(root.item.artKey) : false
QbzTheme { id: theme }
height: 60
@@ -36,7 +42,11 @@ Rectangle {
color: theme.surfaceElevated
clip: true
QbzIcon {
// The designed empty state — hidden only while the
// placeholder is actually up, so once it settles the glyph
// is what the user is left with (never a blank disc).
visible: root.artSource === ""
&& (!root.avatarPending || avatarSkel.settled)
name: "user"
width: 22
height: 22
@@ -48,6 +58,15 @@ Rectangle {
source: root.artSource
radius: 24
}
// Round per-item placeholder — the avatar shape, not a square.
QbzSkeleton {
id: avatarSkel
variant: "circle"
anchors.fill: parent
visible: root.avatarPending
phase: root.view ? root.view.skelPhase : false
settleMs: root.view ? root.view.artSettleMs : 0
}
}
Column {
width: parent.width - 60
@@ -8,6 +8,7 @@
import QtQuick
import com.blitzfc.qbz
import "../../controls"
import "../../theme"
Item {
@@ -43,10 +44,36 @@ Item {
function onArtistsGroupedChanged() { root.rebuild() }
}
QbzSpinner {
anchors.centerIn: parent
size: 36
// Loading = the shape of the master/detail pair that is coming: 60px
// rail rows with a ROUND 48px avatar on the left, the album grid on the
// right. Two composites, two animators (see QbzSkeleton COST).
Row {
visible: QbzLocal.localArtistsLoading
anchors.fill: parent
anchors.topMargin: 12
spacing: 0
// The rail's own left inset (24), so the placeholder rows land where
// the real ones do.
Item { width: 24; height: 1 }
QbzSkeleton {
variant: "rowList"
width: 256 - 22
height: parent.height
rowH: 60
rowGap: 2
rowArtSize: 48
rowArtRound: true
phase: root.view.skelPhase
}
Item { width: 22 + 1; height: 1 }
QbzSkeleton {
variant: "cardGrid"
width: parent.width - 281
height: parent.height
cellW: 220
cellH: 266
phase: root.view.skelPhase
}
}
// Empty library — icon + line (Slint :2222).
Column {
@@ -136,6 +163,7 @@ Item {
id: artistComp
LocalArtistRow {
width: rail.width
view: root.view
item: modelData.item
artSource: root.view.artMap[modelData.item.artKey] || ""
selected: root.view.selectedArtist === modelData.item.name
@@ -77,11 +77,17 @@ Item {
}
}
// Scanning.
QbzSpinner {
// Scanning — the shape of the album blocks that are coming
// (56px cover + two text bars per block), not a spinner.
QbzSkeleton {
visible: QbzLocal.localEphemeralLoading
anchors.horizontalCenter: parent.horizontalCenter
size: 28
variant: "rowList"
width: parent.width
height: visible ? 3 * 68 : 0
rowH: 56
rowGap: 12
rowArtSize: 56
phase: root.view ? root.view.skelPhase : false
}
// Empty (no playable tracks).
@@ -129,6 +135,17 @@ Item {
source: root.view.artMap[block.modelData.artKey] || ""
radius: 6
}
// Per-item: clears when THIS album's cover
// lands, settles out when it has none.
QbzSkeleton {
variant: "art"
anchors.fill: parent
blockRadius: 6
visible: root.view
? root.view.artPending(block.modelData.artKey) : false
phase: root.view ? root.view.skelPhase : false
settleMs: root.view ? root.view.artSettleMs : 0
}
}
Column {
width: parent.width - 56 - 12
@@ -28,10 +28,33 @@ Item {
visible: root.view.selectedFolder === ""
text: QbzSession.tr("Select a folder to see its contents.", QbzSession.trRev)
}
QbzSpinner {
anchors.centerIn: parent
size: 28
// The folder detail is header + subfolder cards + track rows; the pane
// fetch replaces a 28px spinner with that shape (a header band, then a
// card grid). Two composites = two animators.
Column {
visible: QbzLocal.localDetailLoading
anchors.fill: parent
anchors.leftMargin: 28
anchors.rightMargin: 28
anchors.topMargin: 16
spacing: 14
QbzSkeleton {
variant: "text"
width: Math.min(parent.width, 420)
lines: 2
lineHeight: 20
phase: root.view ? root.view.skelPhase : false
}
QbzSkeleton {
variant: "cardGrid"
width: parent.width
height: parent.height - 70
cellW: 152
cellH: 194
cardW: 140
cardH: 182
phase: root.view ? root.view.skelPhase : false
}
}
Flickable {
@@ -144,6 +167,7 @@ Item {
width: subgrid.cw
height: subgrid.ch
item: modelData
view: root.view
artSource: root.view.artMap[modelData.artKey] || ""
onOpened: root.view.selectFolder(modelData.path)
}
@@ -245,9 +269,11 @@ Item {
required property var modelData
required property int index
width: page.width
view: root.view
item: modelData
number: modelData.number > 0 ? modelData.number : index + 1
showArtwork: root.view.trackArtwork
artSource: root.view.artMap[modelData.artKey] || ""
onPlayRequested: QbzLocal.playFolderTrack(
root.view.selectedFolder, modelData.id)
onEnqueueRequested: function (m) {
@@ -9,6 +9,7 @@
import QtQuick
import com.blitzfc.qbz
import "../../controls"
import "../../theme"
Item {
@@ -25,10 +26,51 @@ Item {
view: root.view
}
QbzSpinner {
anchors.centerIn: parent
size: 36
// Loading = the shape of the arm that is coming. FLAT mode is the same
// collection the Albums tab renders; TREE mode is the rail + detail pair
// (the rail rows are 34px folder rows with a leading glyph, so the
// placeholder uses the small square art cell).
QbzSkeleton {
visible: QbzLocal.localFoldersLoading && !root.view.ephemeralActive
&& root.view.foldersMode === "flat"
variant: root.view.foldersGridView === "grid" ? "cardGrid" : "rowList"
anchors.fill: parent
anchors.leftMargin: 32
anchors.rightMargin: 32
anchors.topMargin: 16
cellW: 220
cellH: 266
rowH: 56
rowGap: 0
rowArtSize: 40
phase: root.view.skelPhase
}
Row {
visible: QbzLocal.localFoldersLoading && !root.view.ephemeralActive
&& root.view.foldersMode !== "flat"
anchors.fill: parent
anchors.topMargin: 12
spacing: 0
Item { width: 12; height: 1 }
QbzSkeleton {
variant: "rowList"
width: root.view.treeRailWidth - 24
height: parent.height
// TreeRow is 26px with a small leading glyph (TreeRow.qml:34).
rowH: 26
rowGap: 0
rowArtSize: 14
phase: root.view.skelPhase
}
Item { width: 19; height: 1 }
QbzSkeleton {
variant: "cardGrid"
width: parent.width - root.view.treeRailWidth - 7
height: parent.height
cellW: 220
cellH: 266
phase: root.view.skelPhase
}
}
LocalNote {
visible: !QbzLocal.localFoldersLoading && !root.view.ephemeralActive
@@ -49,6 +49,8 @@ Item {
property bool showAlbum: true
property bool showArtwork: false
property string artSource: ""
/// The LocalLibraryView root (shared skeleton pulse + artwork gate).
property var view: null
property bool selectMode: false
property bool checked: false
signal playRequested()
@@ -59,6 +61,14 @@ Item {
height: 50
// The shared TrackRow takes its artwork from `item.artPath`, and a LOCAL
// track row publishes an empty one (local_rows.rs:284) — the cover comes
// from the id-keyed artMap instead. Patch it in rather than fork the
// shared row; the copy only happens when a cover is actually present.
readonly property var rowItem: root.artSource === ""
? root.item
: Object.assign({}, root.item, { "artPath": root.artSource })
// Local rows have a local album GROUP KEY and a bare artist NAME; both
// routes are name/key routes, so each entry is gated on its own datum.
readonly property bool canGoAlbum: (item.albumId || "") !== ""
@@ -70,7 +80,7 @@ Item {
// row would have spent on its own ⋯ — so every other column lands
// exactly where it did before the menu moved out.
width: root.width - 26 - 46
item: root.item
item: root.rowItem
number: root.number
showArtwork: root.showArtwork
showAlbum: root.showAlbum
@@ -56,10 +56,20 @@ Item {
function onTracksGroupChanged() { root.rebuild() }
}
QbzSpinner {
anchors.centerIn: parent
size: 36
// First page = the shape of the 50px track rows (the Slint mounts a bare
// 36px LoadingSpinner). ONE instance for the whole viewport.
QbzSkeleton {
variant: "rowList"
anchors.fill: parent
anchors.leftMargin: 32
anchors.rightMargin: 32
anchors.topMargin: 12
visible: QbzLocal.localTracksLoading
rowH: root.rowH
rowGap: 0
rowArt: root.view.trackArtwork
rowArtSize: 36
phase: root.view.skelPhase
}
LocalNote {
visible: !QbzLocal.localTracksLoading && root.view.tracks.length === 0
@@ -148,7 +158,15 @@ Item {
id: rowComp
LocalTrackRow {
width: list.width
view: root.view
item: modelData.row
// The row artwork column was rendering a
// permanently blank 36px cell: local track rows
// ship an EMPTY artPath (local_rows.rs:284) and
// nothing ever fed LocalTrackRow.artSource. It is
// the same id-keyed artMap every other local
// surface reads.
artSource: root.view.artMap[modelData.row.artKey] || ""
number: modelData.n
// Album column hides when the list is already
// grouped by album (Slint :1486).
@@ -165,13 +183,21 @@ Item {
}
}
// Infinite scroll: the NEXT page in the shape it will arrive
// in (3 rows), appended below the last real row. Each
// placeholder is replaced by a real row as the page lands.
footer: Item {
width: list.width
height: QbzLocal.localTracksLoadingMore ? 48 : 0
QbzSpinner {
anchors.centerIn: parent
size: 22
height: QbzLocal.localTracksLoadingMore ? 3 * root.rowH : 0
QbzSkeleton {
variant: "rowList"
anchors.fill: parent
visible: QbzLocal.localTracksLoadingMore
rowH: root.rowH
rowGap: 0
rowArt: root.view.trackArtwork
rowArtSize: 36
phase: root.view.skelPhase
}
}
}
@@ -126,10 +126,16 @@ Item {
height: parent.height - 38
- (root.view.treeSelectMode && QbzLocal.localTreeSelectedCount > 0 ? 44 : 0)
QbzSpinner {
anchors.centerIn: parent
size: 28
// Lazy level fetch: the rail shows the shape of the tree rows
// (26px, small leading glyph) instead of a 28px spinner.
QbzSkeleton {
variant: "rowList"
anchors.fill: parent
visible: QbzLocal.localTreeLoading
rowH: 26
rowGap: 0
rowArtSize: 14
phase: root.view ? root.view.skelPhase : false
}
ListView {
id: treeList