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:
52 files changed
+4962
-719
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user