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

+57 -1
View File
@@ -16,6 +16,7 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Window
import com.blitzfc.qbz
import "../controls"
import "../theme"
@@ -57,7 +58,43 @@ Rectangle {
var i
for (i = 0; i < upcoming.length; i++) if (upcoming[i].artUrl) urls.push(upcoming[i].artUrl)
for (i = 0; i < historyRows.length; i++) if (historyRows[i].artUrl) urls.push(historyRows[i].artUrl)
if (urls.length > 0) QbzShell.sidebarArtworkWindow(JSON.stringify(urls))
if (urls.length > 0) {
QbzShell.sidebarArtworkWindow(JSON.stringify(urls))
root.artPulse = true
artPulseOff.restart()
}
}
// --- skeleton pulse (PER-ITEM COVERS ONLY) ---------------------------
// The queue is LOCAL state — it never "loads", so this panel has no
// list-level skeleton ON PURPOSE: there is no asynchronous fetch to
// stand in for. What IS asynchronous is each row's cover, which is why
// the placeholders here are per-item and nothing else.
// GATING RULE: freeze on NOT VISIBLE — the panel collapsed, 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 cover that never resolves (no art, or a failed fetch) leaves the map
// untouched, so every placeholder is bounded — see QbzSkeleton settleMs.
readonly property int artSettleMs: 2500
property bool artPulse: false
Timer {
id: artPulseOff
interval: root.artSettleMs
onTriggered: root.artPulse = false
}
Timer {
interval: 900
repeat: true
running: root.artPulse && root.visible && root.windowShowing
onTriggered: root.skelPhase = !root.skelPhase
}
function coverPending(url) {
return (url || "") !== "" && (root.coverMap[url] || "") === ""
}
component QueueTab: Text {
@@ -132,6 +169,16 @@ Rectangle {
source: root.coverMap[row.artUrl] || ""
radius: 4
}
// Per-item: this thumbnail clears when ITS cover lands, so
// History fills in progressively rather than all at once.
QbzSkeleton {
variant: "art"
anchors.fill: parent
blockRadius: 4
visible: root.coverPending(row.artUrl)
phase: root.skelPhase
settleMs: root.artSettleMs
}
}
// Title + artist.
@@ -389,6 +436,15 @@ Rectangle {
source: root.currentRow ? (root.coverMap[root.currentRow.artUrl] || "") : ""
radius: 4
}
QbzSkeleton {
variant: "art"
anchors.fill: parent
blockRadius: 4
visible: root.currentRow
? root.coverPending(root.currentRow.artUrl) : false
phase: root.skelPhase
settleMs: root.artSettleMs
}
}
Column {
width: parent.width - 34 - 28 - 2 * 9