feat(qbz-qt): staged loading, skeletons everywhere, lyrics, gestures, richer menus

**Deferred external rows.** The album page awaited the Last.fm row before
publishing anything, so the tracklist waited on an external service plus
MusicBrainz. It publishes in stages now — header and tracks first, then each
Qobuz rail as it lands, then Last.fm — with every patch merged under a
generation counter so a late reply for album A cannot paint onto album B. The
artist page was already staged this way; its views were the part still showing
bare "Loading…" text.

**Skeletons as the standard.** One control, new variants rather than new
components, mounted across the views. Its second job is the one the owner
named: cleanup. A cover that never resolves settles the placeholder once and
reveals the card's own empty state instead of shimmering forever — local albums
with no embedded art produce no artwork entry at all, so a placeholder gated
only on "not arrived yet" would never stop.

**Local artwork streams.** Fifteen seconds to first cover on a 1274-album grid
was one batch emitted after the whole window resolved. Each cover emits as it
resolves now, visible band before prefetch margin.

**Lyrics migrated.** Smooth sync instead of a 1 Hz tick, word-level karaoke
where the document carries it, the surrounding-line dimming, the controls
flyout and the translation layer the v10 backend already supported. Exactly one
row subscribes to the 30 Hz output; the timer drops to 250ms when paused or
hidden.

**Navigation by mouse and touchpad.** Side buttons and a two-finger horizontal
swipe, matching #653 — including the part that matters, which is that a swipe
over a carousel scrolls the carousel. The layer is declared first in the shell
so everything else gets first refusal.

**Context menus.** Brought to the Slint inventory, Play later included, with
right-click opening the same menu as the button everywhere. Entries whose seam
does not exist are hidden rather than shipped inert.

Two controls that rendered and no-opped are now honestly disabled: the My QBZ
nav row, which routes to a view the shell has no case for, and three sidebar
menu entries with no backing call.

The sidebar caret was never a condition bug. The row is a positioner and the
name cell's width ignored the count label, so a folder WITH content pushed the
chevron past the clip — which is why it vanished exactly when it mattered.

cc
This commit is contained in:
vicrodh committed 2026-07-28 18:12:31 -06:00
1 parent 2b31d3866c
commit f3a33245b2
52 files changed
+4962 -719

No files matched your search

+60 -24
View File
@@ -10,12 +10,15 @@
// (ADR-010 mount-site gating).
//
// POC-NOTE: in the Slint app these rows open dropdown flyout menus; the
// flyouts are out of scope — rows here navigate (only "home" exists, so
// only Discover is live) and carry the SidebarDirectRow active treatment
// (surface-hover bg + primary text/icon) for the current section.
// POC-NOTE: the playlist/folder tree below the nav is out of scope; the
// "PLAYLISTS" header + toolbar render for parity and the Slint empty state
// shows ("No playlists yet.").
// flyouts are out of scope — rows here navigate straight to their view and
// carry the SidebarDirectRow active treatment (surface-hover bg + primary
// text/icon) for the current section. Discover / Library / Local Library
// have views; My QBZ does NOT and is therefore rendered disabled (a row
// that clicks into nothing is a defect, not a stub).
// The playlist/folder tree below the nav IS live (sidebar_qt.rs: load,
// sort, search, expand/collapse, drag-drop target); folder CREATION,
// the Playlist Manager and the importer have no seam and are disabled in
// the "..." menu.
import QtQuick
import QtQuick.Controls
@@ -94,12 +97,16 @@ Rectangle {
property string name: ""
property string label: ""
property bool active: root.activeNav === navId
// No view behind this section in this port — render it as
// unavailable rather than as a live row that clicks into nothing.
property bool disabled: false
signal clicked()
width: parent ? parent.width : 0
height: 34
radius: 6
color: (navArea.containsMouse || active) ? theme.surfaceHover : "transparent"
opacity: disabled ? 0.45 : 1.0
color: (!disabled && (navArea.containsMouse || active)) ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
@@ -135,7 +142,8 @@ Rectangle {
MouseArea {
id: navArea
anchors.fill: parent
hoverEnabled: true
enabled: !navRow.disabled
hoverEnabled: !navRow.disabled
cursorShape: Qt.PointingHandCursor
onClicked: navRow.clicked()
}
@@ -185,12 +193,16 @@ Rectangle {
QbzShell.navigateTo("local")
}
}
// GAP: there is no MyQBZ view in this port (AppShell's loader
// maps home/library/local/localalbum/album/artist/settings/
// search/playlist — no "myqbz"). It used to take the active
// treatment on click and go nowhere; it now renders disabled.
NavRow {
navId: "myqbz"
name: "qbz-symbolic"
// Slint: MyQbzBrandingState.label, default "My QBZ".
label: QbzSession.tr("My QBZ", QbzSession.trRev)
onClicked: root.activeNav = "myqbz"
disabled: true
}
}
@@ -399,9 +411,17 @@ Rectangle {
}
}
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
// New folder / Manage / Import — visual stubs
// (POC-NOTE: folder management + importer are out
// of scope).
// New folder / Manage playlists / Import — DISABLED.
// GAP, not a half-path: this port has no folder
// creation seam (`sidebar_qt.rs` exposes only
// load/sort/search/toggle-folder — no create/delete),
// no Playlist Manager view, and no importer. They used
// to render as live-looking rows (hover highlight +
// pointing-hand cursor) whose click did nothing at
// all; a control that renders and no-ops is a defect,
// so they now read as unavailable: dimmed, no hover
// treatment, no pointer cursor, no MouseArea. Restore
// the interactive form together with the seam.
Repeater {
model: [
{ "icon": "folder-plus", "label": QbzSession.tr("New folder", QbzSession.trRev) },
@@ -413,26 +433,21 @@ Rectangle {
width: parent ? parent.width : 0
height: 30
radius: 5
color: stubArea.containsMouse ? theme.surfaceHover : "transparent"
color: "transparent"
opacity: 0.4
Row {
anchors.fill: parent
anchors.leftMargin: 8
spacing: 8
QbzIcon { name: modelData.icon; width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" }
QbzIcon { name: modelData.icon; width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter; tintName: "muted" }
Text {
height: parent.height
text: modelData.label
color: theme.textSecondary
color: theme.textMuted
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
}
}
MouseArea {
id: stubArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
}
}
}
}
@@ -578,10 +593,27 @@ Rectangle {
tintName: (rowArea.containsMouse || isActive) ? "primary" : "muted"
}
}
// Name (hidden in mini).
// Name (hidden in mini). A Row is a positioner
// with no stretch, so this width must reserve
// EVERY later cell + its spacing or the excess
// is pushed past the row's right edge and the
// `clip: true` on the Flickable eats it.
// (Sidebar.slint gets this free: its
// HorizontalLayout stretches the name cell.)
// Reserve: 8 + 24 icon slot always; for a
// FOLDER also 8 + 14 chevron, plus 8 + the
// count label while it is laid out. The old
// flat -22 ignored the count, pushing the
// chevron ~15px past the clip — which is why
// the caret vanished on exactly the folders
// that HAVE children (owner report).
Text {
visible: !root.mini
width: parent.width - 24 - (isFolder ? 22 : 0)
width: Math.max(0, parent.width - 32
- (isFolder
? 22 + (countLabel.visible
? countLabel.implicitWidth + 8 : 0)
: 0))
height: parent.height
text: modelData.name
color: (rowArea.containsMouse || isActive) ? theme.textPrimary : theme.textSecondary
@@ -590,8 +622,12 @@ Rectangle {
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
// Folder count (hover) + chevron.
// Folder count (hover) + chevron. The count is
// hover-FADED, not hidden — opacity does not
// free layout space, so it must stay in the
// name's reserve above at all times.
Text {
id: countLabel
visible: !root.mini && isFolder && modelData.count > 0
height: parent.height
text: modelData.count