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

+269 -31
View File
@@ -50,6 +50,40 @@ Rectangle {
})
}
// ---- Loading staging (album_qt.rs publishes in passes) ---------------
// The PRIMARY document (header + tracks) lands as soon as /album/get
// answers; each bottom rail arrives later carrying its own flag, so the
// page is usable while Qobuz suggestions and the Last.fm row resolve.
//
// A rail is shown when it HAS cards, its placeholder when its flag is
// still up. Both false = the section is ABSENT: `moreLoading` is seeded
// false when the album has no artist id and `similarLoading` false when
// Last.fm is not connected, so nothing ever spins forever on a row that
// will never arrive.
readonly property bool primaryLoading: QbzAlbum.albumLoading && tracks.length === 0
readonly property bool moreLoading: album.moreLoading === true
&& (album.moreFromArtist || []).length === 0
readonly property bool suggestionsLoading: album.suggestionsLoading === true
&& (album.suggestions || []).length === 0
readonly property bool similarLoading: album.similarLoading === true
&& (album.similarAlbums || []).length === 0
// ONE 900ms phase for every placeholder on the page (QbzSkeleton's COST
// note: N placeholders, 1 timer). Stops dead when nothing is pending.
Timer {
id: skeletonPhase
property bool on: false
interval: 900
repeat: true
running: root.visible && (root.primaryLoading || root.moreLoading
|| root.suggestionsLoading || root.similarLoading)
onTriggered: on = !on
}
// Placeholder cards that fill one rail (SectionRail's 232px pitch).
readonly property int railSkeletonCount:
Math.max(1, Math.min(8, Math.ceil((root.width - 64) / 232)))
// Disc headers + work headers precede their first row (computed once
// per track list change, mirroring AlbumState's disc-header-number /
// work-header model fields).
@@ -74,11 +108,43 @@ Rectangle {
root.coverMap = Object.assign({}, m)
}
}
Component.onCompleted: dispatchCovers()
onAlbumChanged: dispatchCovers()
Component.onCompleted: { syncAlbumState(); dispatchCovers() }
onAlbumChanged: { syncAlbumState(); dispatchCovers() }
// The derived binding settles AFTER onAlbumChanged fires (stale race) —
// redispatch when the header itself updates.
onHeaderChanged: dispatchCovers()
onHeaderChanged: { syncAlbumState(); dispatchCovers() }
// Optimistic heart / pin state. The document is republished once per
// deferred rail now, and every republish re-parses `album` — a toggle
// written straight onto the parsed object would silently pop back a
// second later. Overrides live here and win until the album changes.
// (Same pattern, same reason, as ArtistView.localToggles.)
property var localToggles: ({})
function toggleState(key, fallback) {
return localToggles[key] !== undefined ? localToggles[key] : fallback === true
}
function setToggleState(key, value) {
var m = localToggles
m[key] = value
localToggles = Object.assign({}, m)
}
// Per-album view state is reset ONLY when the id actually changes, or a
// deferred rail landing would yank the user's toggles back mid-read.
property string loadedAlbumId: ""
function syncAlbumState() {
var id = (header && header.id) ? header.id : ""
if (id === loadedAlbumId)
return
loadedAlbumId = id
localToggles = ({})
dispatchedCovers = ({})
}
// Already-requested artwork keys. The document is now published in FOUR
// passes (primary, then each rail), and every pass re-fires this — resending
// the whole list each time is pure waste, so only what is new goes out.
property var dispatchedCovers: ({})
function dispatchCovers() {
var urls = []
if (header && header.artUrl) urls.push(header.artUrl)
@@ -90,7 +156,19 @@ Rectangle {
// cards would render as empty frames.
var sim = album.similarAlbums || []
for (i = 0; i < sim.length; i++) if (sim[i].artUrl) urls.push(sim[i].artUrl)
if (urls.length > 0) QbzShell.sidebarArtworkWindow(JSON.stringify(urls))
var seen = dispatchedCovers
var fresh = []
for (i = 0; i < urls.length; i++) {
if (!seen[urls[i]]) {
seen[urls[i]] = true
fresh.push(urls[i])
}
}
if (fresh.length > 0) {
dispatchedCovers = seen
QbzShell.sidebarArtworkWindow(JSON.stringify(fresh))
}
}
// Ghost CircleAction (secondary, on-surface variant): elevated disc,
@@ -156,6 +234,53 @@ Rectangle {
font.letterSpacing: 1
}
// Placeholder for a bottom rail that has not resolved yet: the SAME 28px
// header band, 232px pitch and 246px card band SectionRail uses, so the
// page does not jump when the real cards replace it. Built out of the
// shared QbzSkeleton — no local skeleton primitive.
//
// Everything it needs is a property, not a file-scope id: an inline
// `component` does not see the enclosing document's ids (the gotcha
// QbzSkeleton.qml documents), so `phase` is passed in by the host.
component RailSkeleton: Column {
id: railSk
property bool phase: false
property int cardCount: 4
width: parent ? parent.width : 0
spacing: 12
Item {
width: parent.width
height: 28
QbzSkeleton {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
variant: "block"
width: 180
height: 20
phase: railSk.phase
}
}
Item {
width: parent.width
height: 246
clip: true
Row {
spacing: 32
Repeater {
model: railSk.cardCount
// "card" carries its own 200 x (200+42) footprint.
delegate: QbzSkeleton {
required property int index
variant: "card"
cellIndex: index
phase: railSk.phase
}
}
}
}
}
// One EXTERNAL LINKS brand icon (AlbumPageView.slint BrandLink): the bare
// brand SVG in its NATIVE colors — no tint pass, no visible label, the
// name lives in the hover tooltip (Feishin-style inline links).
@@ -224,8 +349,50 @@ Rectangle {
// NavButtons is a 0px placeholder in the Slint source.
Item { width: 1; height: 22 }
// --- Album header skeleton ----------------------------------
// Mounted on the primary flag, and the real header is hidden by
// the same flag: opening album B never renders a half-empty
// header frame while B's document is in flight.
Row {
visible: root.primaryLoading
width: parent.width - 64
spacing: 32
QbzSkeleton {
variant: "block"
width: 224
height: 224
blockRadius: 12
phase: skeletonPhase.on
}
Column {
width: parent.width - 224 - 32
spacing: 12
Item { width: 1; height: 6 }
QbzSkeleton { variant: "block"; width: Math.min(420, parent.width); height: 30; cellIndex: 0; phase: skeletonPhase.on }
QbzSkeleton { variant: "block"; width: Math.min(260, parent.width); height: 18; cellIndex: 1; phase: skeletonPhase.on }
QbzSkeleton { variant: "block"; width: Math.min(340, parent.width); height: 14; cellIndex: 2; phase: skeletonPhase.on }
Item { width: 1; height: 14 }
Row {
spacing: 12
Repeater {
model: 4
delegate: QbzSkeleton {
required property int index
variant: "circle"
width: 44
height: 44
cellIndex: index
phase: skeletonPhase.on
}
}
}
}
}
// --- Album header -------------------------------------------
Row {
visible: !root.primaryLoading
width: parent.width - 64
spacing: 32
@@ -396,11 +563,12 @@ Rectangle {
onClicked: QbzPlayer.playAlbumShuffled(header.id)
}
QbzCircleAction {
name: header.isFavorite ? "heart-filled" : "heart"
active: header.isFavorite === true
readonly property bool favorite: root.toggleState("album", header.isFavorite)
name: favorite ? "heart-filled" : "heart"
active: favorite
anchors.verticalCenter: parent.verticalCenter
onClicked: {
header.isFavorite = !header.isFavorite
root.setToggleState("album", !favorite)
QbzLibrary.libraryToggleFavorite("album", header.id)
}
}
@@ -433,20 +601,63 @@ Rectangle {
width: parent.width - (root.hasSidebar ? 232 : 0)
spacing: 0
// Loading.
Item {
visible: QbzAlbum.albumLoading && tracks.length === 0
// Track-list placeholder — same flag the spinner used,
// now in the shape of the list it is standing in for
// (toolbar band + column-header band + 8 rows at the
// TrackRow 50px pitch), so nothing shifts on arrival.
Column {
visible: root.primaryLoading
width: parent.width
height: 280
Column {
anchors.centerIn: parent
spacing: 18
QbzSpinner { size: 36; anchors.horizontalCenter: parent.horizontalCenter }
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzSession.tr("Loading album…", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 13
spacing: 0
Item { width: 1; height: 52 }
Item { width: 1; height: 40 }
Repeater {
model: 8
delegate: Item {
required property int index
width: parent ? parent.width : 0
height: 50
QbzSkeleton {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 12
variant: "block"
width: 20
height: 12
cellIndex: index
phase: skeletonPhase.on
}
QbzSkeleton {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 60
variant: "block"
width: Math.max(90, parent.width * 0.36)
height: 14
cellIndex: index
phase: skeletonPhase.on
}
QbzSkeleton {
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
anchors.rightMargin: 128
variant: "block"
width: 52
height: 12
cellIndex: index
phase: skeletonPhase.on
}
QbzSkeleton {
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
anchors.rightMargin: 48
variant: "block"
width: 52
height: 12
cellIndex: index
phase: skeletonPhase.on
}
}
}
}
@@ -721,26 +932,53 @@ Rectangle {
}
// --- Bottom carousels ---------------------------------------
Item { visible: (album.moreFromArtist || []).length > 0; width: 1; height: 40 }
// Each one is deferred and paints the moment ITS fetch lands
// (album_qt.rs spawn_deferred_rows). While a fetch is out the
// rail's placeholder holds its band; when it resolves to nothing
// both the placeholder and the rail are gone — no empty frame.
Item { visible: moreRail.visible || moreRailSk.visible; width: 1; height: 40 }
RailSkeleton {
id: moreRailSk
visible: root.moreLoading
phase: skeletonPhase.on
cardCount: root.railSkeletonCount
}
SectionRail {
id: moreRail
visible: (album.moreFromArtist || []).length > 0
title: QbzSession.tr("From the same artist", QbzSession.trRev)
items: album.moreFromArtist || []
coverMap: root.coverMap
}
Item { visible: (album.suggestions || []).length > 0; width: 1; height: 40 }
Item { visible: sugRail.visible || sugRailSk.visible; width: 1; height: 40 }
RailSkeleton {
id: sugRailSk
visible: root.suggestionsLoading
phase: skeletonPhase.on
cardCount: root.railSkeletonCount
}
SectionRail {
id: sugRail
visible: (album.suggestions || []).length > 0
title: QbzSession.tr("Listening suggestions", QbzSession.trRev)
items: album.suggestions || []
coverMap: root.coverMap
}
// Last.fm row. Absent — not empty — when Last.fm is not connected:
// external_reco_qt returns nothing and makes no network call, so the
// rail simply never becomes visible. Reuses the same delegate as the
// two Qobuz rows rather than a fourth card variant.
Item { visible: (album.similarAlbums || []).length > 0; width: 1; height: 40 }
// Last.fm row. Absent — not empty, and not even a placeholder —
// when Last.fm is not connected: album_qt.rs seeds similarLoading
// false in that case and makes no network call. Reuses the same
// delegate as the two Qobuz rows rather than a fourth card variant.
Item { visible: simRail.visible || simRailSk.visible; width: 1; height: 40 }
RailSkeleton {
id: simRailSk
visible: root.similarLoading
phase: skeletonPhase.on
cardCount: root.railSkeletonCount
}
SectionRail {
id: simRail
visible: (album.similarAlbums || []).length > 0
title: QbzSession.tr("Similar albums", QbzSession.trRev)
items: album.similarAlbums || []
@@ -767,8 +1005,8 @@ Rectangle {
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-plus", "action": "next" },
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
{ "label": header.isFavorite ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev), "icon": header.isFavorite ? "heart-filled" : "heart", "action": "favorite" },
{ "label": header.isPinned ? QbzSession.tr("Unpin", QbzSession.trRev) : QbzSession.tr("Pin", QbzSession.trRev), "icon": header.isPinned ? "pin-filled" : "pin", "action": "pin" },
{ "label": root.toggleState("album", header.isFavorite) ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev), "icon": root.toggleState("album", header.isFavorite) ? "heart-filled" : "heart", "action": "favorite" },
{ "label": root.toggleState("pin", header.isPinned) ? QbzSession.tr("Unpin", QbzSession.trRev) : QbzSession.tr("Pin", QbzSession.trRev), "icon": root.toggleState("pin", header.isPinned) ? "pin-filled" : "pin", "action": "pin" },
]
delegate: Rectangle {
required property var modelData
@@ -803,10 +1041,10 @@ Rectangle {
else if (a === "next") QbzPlayer.enqueueAlbum(header.id, "next")
else if (a === "queue") QbzPlayer.enqueueAlbum(header.id, "later")
else if (a === "favorite") {
header.isFavorite = !header.isFavorite
root.setToggleState("album", !root.toggleState("album", header.isFavorite))
QbzLibrary.libraryToggleFavorite("album", header.id)
} else if (a === "pin") {
header.isPinned = !header.isPinned
root.setToggleState("pin", !root.toggleState("pin", header.isPinned))
QbzLibrary.togglePin("album", header.id, header.title, header.artist, header.artUrl)
}
}
+164 -30
View File
@@ -105,6 +105,35 @@ Rectangle {
return out
}
// ---- Loading staging (artist_qt.rs publishes in passes) --------------
// The Qobuz page lands first; the Magazine stories and each MusicBrainz
// sidebar section arrive later on their own flags. Every one of these is
// ALSO gated on mbAvailable upstream, so with MusicBrainz off in Settings
// (or no confident match) they are absent — placeholder included.
readonly property bool primaryLoading: QbzArtist.artistLoading && topTracks.length === 0
readonly property bool originPending: network.mbAvailable === true
&& network.originLoading === true
readonly property bool relationshipsPending: network.mbAvailable === true
&& network.relationshipsLoading === true
readonly property bool discoveryPending: network.mbAvailable === true
&& network.discoveryLoading === true
&& root.discoveryRows.length === 0
readonly property bool similarPending: similarArtists.length === 0 && QbzArtist.artistLoading
readonly property bool storiesPending: artist.storiesLoading === true && stories.length === 0
// ONE 900ms phase for every placeholder on the page (QbzSkeleton's COST
// note: N placeholders, 1 timer). Stops dead when nothing is pending.
Timer {
id: skeletonPhase
property bool on: false
interval: 900
repeat: true
running: root.visible && (root.primaryLoading || root.originPending
|| root.relationshipsPending || root.discoveryPending
|| root.similarPending || root.storiesPending)
onTriggered: on = !on
}
// JUMP TO tabs from the present sections (ArtistState.jump-tabs).
readonly property var jumpTabs: {
var tabs = []
@@ -507,13 +536,40 @@ Rectangle {
font.letterSpacing: 0.5
}
// Small 11px muted line — sub-group labels and the "Loading…"/empty
// states inside the sidebar sections.
// Small 11px muted line — sub-group labels and the empty states inside
// the sidebar sections. (The "Loading…" lines are now SidebarSkeleton.)
component SidebarNote: Text {
color: theme.textMuted
font.pixelSize: 12
}
// Placeholder rows for a sidebar section still in flight — the shared
// QbzSkeleton at the 28px pitch of SidebarLink, so the section holds its
// band and nothing jumps when the real links land.
//
// `phase` is a property, not a file-scope id lookup: an inline `component`
// does not see the enclosing document's ids (QbzSkeleton.qml's gotcha), so
// the host passes the one shared timer in.
component SidebarSkeleton: Column {
id: sbSk
property bool phase: false
property int rows: 3
// -28 = the section Column's left+right padding (see OriginRow).
width: parent ? parent.width - 28 : 0
spacing: 9
Repeater {
model: sbSk.rows
delegate: QbzSkeleton {
required property int index
variant: "block"
width: sbSk.width * (index % 2 === 0 ? 0.86 : 0.6)
height: 13
cellIndex: index
phase: sbSk.phase
}
}
}
// One "KEY value" row of the MB Origin block.
component OriginRow: Item {
property string key: ""
@@ -744,8 +800,44 @@ Rectangle {
Item { width: 1; height: 22 }
// --- Artist header skeleton ----------------------------------
// Mounted on the primary flag, and the real header is hidden by
// the same flag: opening artist B never renders a half-empty
// header frame while B's document is in flight.
Row {
visible: root.primaryLoading
width: parent.width - 64
spacing: 32
QbzSkeleton { variant: "circle"; width: 200; height: 200; phase: skeletonPhase.on }
Column {
width: parent.width - 200 - 32
spacing: 12
Item { width: 1; height: 10 }
QbzSkeleton { variant: "block"; width: Math.min(360, parent.width); height: 30; cellIndex: 0; phase: skeletonPhase.on }
QbzSkeleton { variant: "block"; width: Math.min(520, parent.width); height: 14; cellIndex: 1; phase: skeletonPhase.on }
QbzSkeleton { variant: "block"; width: Math.min(440, parent.width); height: 14; cellIndex: 2; phase: skeletonPhase.on }
Item { width: 1; height: 14 }
Row {
spacing: 12
Repeater {
model: 4
delegate: QbzSkeleton {
required property int index
variant: "circle"
width: 44
height: 44
cellIndex: index
phase: skeletonPhase.on
}
}
}
}
}
// --- Artist header ------------------------------------------
Row {
visible: !root.primaryLoading
width: parent.width - 64
spacing: 32
@@ -961,20 +1053,34 @@ Rectangle {
}
}
// --- Loading -------------------------------------------------
Item {
visible: QbzArtist.artistLoading && topTracks.length === 0
// --- Primary placeholder --------------------------------------
// Same flag the spinner used, now in the shape of what is coming:
// the Popular Tracks heading plus 5 rows at the PopularTrackRow
// 50px pitch (the preview count), so nothing shifts on arrival.
Column {
visible: root.primaryLoading
width: parent.width - 64
height: 280
Column {
anchors.centerIn: parent
spacing: 18
QbzSpinner { size: 36; anchors.horizontalCenter: parent.horizontalCenter }
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzSession.tr("Loading artist…", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 13
spacing: 0
QbzSkeleton { variant: "block"; width: 190; height: 22; phase: skeletonPhase.on }
Item { width: 1; height: 18 }
Repeater {
model: root.preview
delegate: Item {
required property int index
width: parent ? parent.width : 0
height: 50
QbzSkeleton {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: 12
anchors.rightMargin: 24
height: 40
variant: "row"
cellIndex: index
phase: skeletonPhase.on
}
}
}
}
@@ -1469,9 +1575,10 @@ Rectangle {
topPadding: 12
bottomPadding: 12
spacing: 6
SidebarNote {
visible: network.originLoading === true
text: QbzSession.tr("Loading origin…", QbzSession.trRev)
SidebarSkeleton {
visible: root.originPending
rows: 2
phase: skeletonPhase.on
}
OriginRow {
visible: network.originLoading !== true && (mbOrigin.beginDate || "") !== ""
@@ -1530,9 +1637,10 @@ Rectangle {
bottomPadding: 6
spacing: 4
SidebarSectionHeading { text: QbzSession.tr("SIMILAR ARTISTS", QbzSession.trRev) }
SidebarNote {
visible: similarArtists.length === 0 && QbzArtist.artistLoading
text: QbzSession.tr("Loading…", QbzSession.trRev)
SidebarSkeleton {
visible: root.similarPending
rows: 4
phase: skeletonPhase.on
}
Repeater {
model: similarArtists
@@ -1558,9 +1666,10 @@ Rectangle {
bottomPadding: 6
spacing: 6
SidebarSectionHeading { text: QbzSession.tr("RELATIONSHIPS", QbzSession.trRev) }
SidebarNote {
visible: network.relationshipsLoading === true
text: QbzSession.tr("Loading…", QbzSession.trRev)
SidebarSkeleton {
visible: root.relationshipsPending
rows: 3
phase: skeletonPhase.on
}
RelationshipGroup {
visible: network.relationshipsLoading !== true
@@ -1607,9 +1716,10 @@ Rectangle {
bottomPadding: 12
spacing: 4
SidebarSectionHeading { text: QbzSession.tr("YOU MAY ALSO LIKE", QbzSession.trRev) }
SidebarNote {
visible: network.discoveryLoading === true && root.discoveryRows.length === 0
text: QbzSession.tr("Loading…", QbzSession.trRev)
SidebarSkeleton {
visible: root.discoveryPending
rows: 4
phase: skeletonPhase.on
}
Repeater {
model: root.discoveryRows
@@ -1686,9 +1796,33 @@ Rectangle {
padding: 12
spacing: 10
SidebarNote {
visible: artist.storiesLoading === true && stories.length === 0
text: QbzSession.tr("Loading…", QbzSession.trRev)
// Story teasers are fetched after the page (Qobuz
// editorial): two card placeholders in the teaser shape
// while they resolve. Resolved-to-nothing keeps the
// explicit empty line below — this is a TAB body, where a
// blank panel would read as broken.
Column {
visible: root.storiesPending
width: magBody.width - 24
spacing: 12
Repeater {
model: 2
delegate: Column {
required property int index
width: parent ? parent.width : 0
spacing: 6
QbzSkeleton {
variant: "block"
width: parent.width
height: parent.width
blockRadius: 6
cellIndex: index
phase: skeletonPhase.on
}
QbzSkeleton { variant: "block"; width: parent.width * 0.82; height: 14; cellIndex: index; phase: skeletonPhase.on }
QbzSkeleton { variant: "block"; width: parent.width * 0.45; height: 11; cellIndex: index; phase: skeletonPhase.on }
}
}
}
SidebarNote {
visible: artist.storiesLoading !== true && stories.length === 0
+54 -6
View File
@@ -13,6 +13,7 @@
// a multi-disc album gets the disc dividers with their per-disc ⋯ menu.
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
import "../controls"
import "../theme"
@@ -101,6 +102,33 @@ Rectangle {
}
}
// ------------------------- skeleton pulse ----------------------------
// ONE 900ms Timer for the whole page. GATING RULE: freeze on NOT VISIBLE
// (view hidden / window minimized), 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 pageLoading: QbzLocal.localAlbumLoading
&& root.tracks.length === 0
// Local artwork resolution DROPS keys with no cover (local_artwork.rs),
// so the cover placeholder MUST be bounded or an album with no embedded
// art shimmers forever. Same constant as LocalLibraryView.artSettleMs.
readonly property int artSettleMs: 2500
readonly property bool coverPending: root.album
? ((root.album.artKey || "") !== ""
&& (root.artMap[root.album.artKey] || "") === "")
: false
Timer {
interval: 900
repeat: true
running: (root.pageLoading || root.coverPending)
&& root.visible && root.windowShowing
onTriggered: root.skelPhase = !root.skelPhase
}
// ============================ page ===================================
// Neutral header band (local albums have no artwork-derived tint yet).
Rectangle {
@@ -147,13 +175,29 @@ Rectangle {
Item { width: 1; height: 22 }
// ---- Album header ----
// Placeholder in the header's own proportions (224px cover, 32px
// gap — LocalAlbumHeader.qml:32/46) until the document lands.
QbzSkeleton {
visible: root.pageLoading && !root.album
variant: "header"
width: parent.width
height: 224
coverSize: 224
coverGap: 32
actionCount: 5
phase: root.skelPhase
}
LocalAlbumHeader {
visible: !(root.pageLoading && !root.album)
width: parent.width
album: root.album
allArtists: root.allArtists
infoLine: root.infoLine
versions: root.versions
coverSource: root.album ? (root.artMap[root.album.artKey] || "") : ""
coverPending: root.coverPending
skelPhase: root.skelPhase
artSettleMs: root.artSettleMs
onOpenArtist: function (name) { root.openArtist(name) }
}
@@ -162,14 +206,18 @@ Rectangle {
Item { width: 1; height: 8 }
// ---- Loading ----
Item {
visible: QbzLocal.localAlbumLoading && root.tracks.length === 0
// The track list in the shape it will arrive in (50px rows, no
// artwork column on this page), replacing the 36px spinner the
// Slint mounts here. ONE instance, ONE animator.
QbzSkeleton {
visible: root.pageLoading
variant: "rowList"
width: parent.width
height: visible ? 280 : 0
QbzSpinner {
anchors.centerIn: parent
size: 36
}
rowH: 50
rowGap: 0
rowArt: false
phase: root.skelPhase
}
// ---- Toolbar: quality badge + track 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 ================================
+83 -6
View File
@@ -24,12 +24,17 @@
// add-to-playlist drop (main.rs drag_end). The 2px accent line marks the
// insertion slot while dragging.
//
// LOADING: header + track-row QbzSkeleton placeholders in the shape of what
// is coming (the Slint mounts a bare LoadingSpinner), plus a per-item cover
// placeholder that clears when the playlist's own cover lands.
//
// POC-NOTEs (playlist_qt.rs has the full list): local playlists,
// custom-cover set/clear, multi-select + bulk bar, Suggested Songs,
// offline download, share — not ported.
import QtQuick
import QtQuick.Controls
import QtQuick.Window
import com.blitzfc.qbz
import "../controls"
import "../rows"
@@ -69,6 +74,32 @@ Rectangle {
})
}
// --- skeleton pulse (QbzSkeleton's preferred drive mode) -------------
// ONE 900ms Timer drives EVERY placeholder in this view. 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). Same rule as HomeView / LibraryView / AmbientField.
property bool skelPhase: false
readonly property bool windowShowing: root.Window.window
? (root.Window.window.visibility !== Window.Minimized
&& root.Window.window.visibility !== Window.Hidden)
: true
// The cover is its OWN progressive item: it clears the moment the
// playlist's own cover lands, independently of the track rows.
readonly property bool coverPending: (doc.coverUrl || "") !== ""
&& (doc.coverPath || "") === ""
// The header has no data at all until the first document lands
// (playlist_qt.rs publishes `{ loading: true }` with empty fields).
readonly property bool headerPending: root.loading && (doc.name || "") === ""
readonly property bool listPending: root.loading && root.allTracks.length === 0
Timer {
interval: 900
repeat: true
running: (root.headerPending || root.listPending || root.coverPending)
&& root.visible && root.windowShowing
onTriggered: root.skelPhase = !root.skelPhase
}
function sortLabel() {
return sortField === "title" ? QbzSession.tr("Title", QbzSession.trRev)
: sortField === "artist" ? QbzSession.tr("Artist", QbzSession.trRev)
@@ -150,8 +181,24 @@ Rectangle {
Item { width: 1; height: 22 }
// Header placeholder — the SHAPE of the header that is coming
// (150px cover + eyebrow/title/description/meta bars + the 7 round
// action buttons), not a spinner. Replaced wholesale by the real
// header the moment the document lands.
QbzSkeleton {
visible: root.headerPending
variant: "header"
width: parent.width
height: 150
coverSize: 150
coverGap: 24
actionCount: 7
phase: root.skelPhase
}
// --- Header ---------------------------------------------------------
Row {
visible: !root.headerPending
width: parent.width
spacing: 24
@@ -169,13 +216,31 @@ Rectangle {
radius: theme.radiusMd
}
QbzIcon {
// Hidden only while the placeholder is actually up; the
// moment it settles (or the cover lands) the designed
// empty-state glyph is what the user sees.
visible: (doc.coverPath || "") === ""
&& (!root.coverPending || plCoverSkel.settled)
name: "list-music"
width: 56
height: 56
anchors.centerIn: parent
tintName: "muted"
}
// Per-item: the cover shimmers until THIS playlist's cover
// lands, then clears on its own. settleMs bounds it — a
// failed download republishes the document with an empty
// coverPath and would otherwise shimmer forever; on settle
// the placeholder fades and the glyph above takes over.
QbzSkeleton {
id: plCoverSkel
visible: root.coverPending
variant: "art"
anchors.fill: parent
blockRadius: theme.radiusMd
phase: root.skelPhase
settleMs: 4000
}
}
// Metadata.
@@ -408,12 +473,9 @@ Rectangle {
Item { width: 1; height: 18 }
// Loading / empty.
QbzSpinner {
visible: root.loading
anchors.horizontalCenter: parent.horizontalCenter
size: 36
}
// Empty. (The loading state is the row placeholders inside the
// track-list area below — the Slint's bare LoadingSpinner said
// "busy" but not "this is the shape of what is coming".)
Text {
visible: !root.loading && root.allTracks.length === 0
text: QbzSession.tr("This playlist is empty.", QbzSession.trRev)
@@ -446,6 +508,21 @@ Rectangle {
width: parent.width
height: parent.height - 28 - 150 - 18 - 50
// Track-list placeholder: the exact TrackRow footprint (50px
// rows, no gap, 36px art cell), one instance for the whole
// viewport — ONE animator, not one per row (see QbzSkeleton's
// COST note). It unmounts the moment the first rows land.
QbzSkeleton {
visible: root.listPending
variant: "rowList"
anchors.fill: parent
anchors.rightMargin: 14
rowH: 50
rowGap: 0
rowArtSize: 36
phase: root.skelPhase
}
ListView {
id: trackList
anchors.fill: parent
+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 {
@@ -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