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
@@ -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