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

@@ -34,6 +34,7 @@
// album/LocalAlbumView.slint.
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
import "../controls"
import "../theme"
@@ -367,6 +368,54 @@ Rectangle {
if (keys.length > 0) QbzLocal.artworkWindow(JSON.stringify(keys))
}
// ------------------------- skeleton pulse ----------------------------
// ONE 900ms Timer drives EVERY placeholder in this view AND in every
// tab body under views/local/ (they read `view.skelPhase`) — N
// placeholders, 1 timer, which is 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
readonly property bool anyTabLoading: QbzLocal.localAlbumsLoading
|| QbzLocal.localArtistsLoading || QbzLocal.localFoldersLoading
|| QbzLocal.localTreeLoading || QbzLocal.localTracksLoading
|| QbzLocal.localTracksLoadingMore || QbzLocal.localDetailLoading
|| QbzLocal.localEphemeralLoading
// THE CLEANUP RULE FOR LOCAL ARTWORK. src/local_artwork.rs
// (`resolve_window_blocking`) DROPS keys with no cover — "the QML map
// only ever grows with real hits" — so a local album with no embedded
// art NEVER produces an artMap entry. A per-item placeholder gated only
// on "artMap has no path yet" would therefore shimmer forever on every
// artless album. `artSettleMs` is the bound: each placeholder fades out
// by itself that long after it appears, revealing the card's own empty
// tile. Per-item clearing still wins whenever the cover DOES arrive.
readonly property int artSettleMs: 2500
// The pulse only has to run while an artwork window can still resolve:
// armed by every window report, disarmed artSettleMs after the last.
property bool artPulse: false
Timer {
id: artPulseOff
interval: root.artSettleMs
onTriggered: root.artPulse = false
}
Timer {
interval: 900
repeat: true
running: (root.anyTabLoading || root.artPulse)
&& root.visible && root.windowShowing
onTriggered: root.skelPhase = !root.skelPhase
}
/// Is THIS row's cover still outstanding? (the per-item gate every local
/// surface uses — one place, so the rule cannot drift between tabs.)
function artPending(key) {
return (key || "") !== "" && (root.artMap[key] || "") === ""
}
// Debounced reporting (180ms — the LibraryView throttle).
Timer {
id: windowDebounce
@@ -381,6 +430,10 @@ Rectangle {
windowDebounce.pendingFirst = first
windowDebounce.pendingLast = last
windowDebounce.restart()
// Covers can now land: keep the shared pulse alive until artSettleMs
// after the LAST report (scrolling keeps re-arming it).
root.artPulse = true
artPulseOff.restart()
}
// ============================ actions ================================