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

+179 -33
View File
@@ -20,9 +20,20 @@
// or plain), enqueueRequested(mode) ("next"|"later"|"queue"),
// removeRequested(), bodyDragStarted(index) (fired BEFORE the shared
// dragStart — the #589 reorder pre-hook).
// Favorite toggling and Go-to-artist/album are identical on every site —
// handled internally. The row BODY is the drag source (6px threshold,
// ghost + sidebar drops in main.rs).
// Favorite toggling, Go-to-artist/album, Share and Track info are
// identical on every site — handled internally. The row BODY is the drag
// source (6px threshold, ghost + sidebar drops in main.rs) and its RIGHT
// press opens the very same menu the ⋯ button does.
//
// --- Menu inventory vs primitives/TrackContextMenu.slint ----------------
// The .slint menu is FLAT (no separators) in this order: Play now · Play
// next · Play later · Add to queue · Create QBZ radio · Create Qobuz radio ·
// Add to library · Add to mixtape · Add to playlist · Remove from
// playlist(danger) · Share Qobuz link · Share Song.link · Make available
// offline | Refresh + Remove offline copy(danger) · Go to album · Go to
// artist · Track info. Everything the Qt bridge has a seam for is here, in
// that order; the rest is gated OFF by the `has*Seam` constants below
// rather than rendered as a row that silently does nothing.
//
// Deliberately NOT consolidated here (Slint-distinct): QueuePanel.QueueRow
// (QueueItem data + queue-op menu + press-and-hold reorder) and
@@ -31,6 +42,7 @@
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../shell"
import "../theme"
Rectangle {
@@ -58,6 +70,36 @@ Rectangle {
// `playlist add-tracks` as a QOBUZ catalog id — a local row dropped on a
// playlist would silently add an unrelated catalog track.
property bool draggable: true
// Per-row artwork placeholder (see the 36px cell below). The host view
// owns the phase clock so one timer drives every row.
property bool artPending: false
property bool skelPhase: false
property int artSettleMs: 0
// Catalog-backed row? `draggable` is ALREADY the "item.id is a Qobuz
// catalog track id" predicate (the Local Library / ephemeral rows set it
// false because their id is a local DB row id — see the note above), so
// the two entries that hit the Qobuz catalog by id ride it instead of
// asking every host to pass a new arm.
readonly property bool catalogRow: root.draggable
property bool menuShowShare: root.catalogRow
property bool menuShowTrackInfo: root.catalogRow
// --- Seams the Qt bridge does NOT have yet (menu-parity round) -------
// Each maps to a TrackContextMenu.slint entry. OFF = the row is not
// built at all, never rendered-and-inert. Flip the constant AND fill the
// matching `menuAction` branch when the invokable lands; the entry then
// appears in its .slint-correct slot. Why each is missing:
// radio no radio invokable on any Qt bridge object
// mixtape QbzLocal.albumAddToMixtape is LOCAL-album only
// playlistAdd no picker modal, no by-id add (only the sidebar drag)
// songlink needs the ISRC -> Deezer -> Odesli round-trip (backend)
// offlineCache no per-track cache bridge (the download cell is a stub)
readonly property bool hasRadioSeam: false
readonly property bool hasMixtapeSeam: false
readonly property bool hasPlaylistAddSeam: false
readonly property bool hasSonglinkSeam: false
readonly property bool hasOfflineCacheSeam: false
signal playRequested()
signal enqueueRequested(string mode)
@@ -139,6 +181,18 @@ Rectangle {
source: root.item.artPath || ""
radius: 4
}
// Per-item cover placeholder — clears when THIS row's cover lands,
// which is what makes a long list read as progressive instead of
// filling in one lump. Host views drive the three properties;
// default-off, so no existing call site changes.
QbzSkeleton {
variant: "art"
anchors.fill: parent
blockRadius: 4
visible: root.artPending
phase: root.skelPhase
settleMs: root.artSettleMs
}
}
// Title (+ explicit) / artist.
Column {
@@ -304,34 +358,110 @@ Rectangle {
}
CardMenu {
id: rowMenu
menuWidth: 220
entries: {
var m = [
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
]
if (root.menuShowLater) m.push({ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" })
m.push({ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" })
if (root.menuShowGoTo && root.item.artistId) m.push({ "label": QbzSession.tr("Go to artist", QbzSession.trRev), "icon": "user", "action": "go-artist" })
if (root.menuShowGoTo && root.item.albumId) m.push({ "label": QbzSession.tr("Go to album", QbzSession.trRev), "icon": "disc", "action": "go-album" })
if (root.menuShowFavorite) m.push({
"label": root.item.isFavorite ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev),
"icon": root.item.isFavorite ? "heart-filled" : "heart", "action": "favorite" })
if (root.menuShowRemove) m.push({ "label": QbzSession.tr("Remove from playlist", QbzSession.trRev), "icon": "trash-2", "action": "remove" })
return m
}
onPicked: function (a) {
if (a === "play") root.playRequested()
else if (a === "next") root.enqueueRequested("next")
else if (a === "later") root.enqueueRequested("later")
else if (a === "queue") root.enqueueRequested("queue")
else if (a === "go-artist") QbzArtist.openArtist(root.item.artistId)
else if (a === "go-album") QbzAlbum.openAlbum(root.item.albumId)
else if (a === "favorite") {
root.item.isFavorite = !root.item.isFavorite
QbzLibrary.libraryToggleFavorite("track", root.item.id)
} else if (a === "remove") root.removeRequested()
menuWidth: 224
entries: root.menuModel()
onPicked: function (a) { root.menuAction(a) }
}
// TrackContextMenu.slint, in its order. Every row here reaches a live
// seam; the seamless ones are gated off above.
function menuModel() {
var t = QbzSession.tr
var r = QbzSession.trRev
var m = [
{ "label": t("Play now", r), "icon": "play-fill", "action": "play" },
{ "label": t("Play next", r), "icon": "list-start", "action": "next" },
]
// #442 "Play later" — the end of the MANUAL block (after everything
// already queued by hand, before the source resumes).
if (root.menuShowLater)
m.push({ "label": t("Play later", r), "icon": "list-plus", "action": "later" })
m.push({ "label": t("Add to queue", r), "icon": "list-end", "action": "queue" })
if (root.hasRadioSeam) {
m.push({ "label": t("Create QBZ radio", r), "icon": "radio", "action": "radio-qbz" })
m.push({ "label": t("Create Qobuz radio", r), "icon": "radio", "action": "radio-qobuz" })
}
if (root.menuShowFavorite)
m.push({ "label": root.item.isFavorite ? t("Remove from Library", r) : t("Add to Library", r),
"icon": root.item.isFavorite ? "heart-filled" : "heart", "action": "favorite" })
if (root.hasMixtapeSeam)
m.push({ "label": t("Add to mixtape", r), "icon": "cassette-tape", "action": "mixtape" })
if (root.hasPlaylistAddSeam)
m.push({ "label": t("Add to playlist", r), "icon": "list-music", "action": "add-to-playlist" })
if (root.menuShowRemove)
m.push({ "label": t("Remove from playlist", r), "icon": "trash-2",
"action": "remove", "danger": true })
if (root.menuShowShare)
m.push({ "label": t("Share Qobuz link", r), "icon": "link", "action": "share-qobuz" })
if (root.hasSonglinkSeam)
m.push({ "label": t("Share Song.link", r), "icon": "link", "action": "share-songlink" })
if (root.hasOfflineCacheSeam)
m.push({ "label": t("Make available offline", r), "icon": "cloud-download", "action": "cache" })
if (root.menuShowGoTo && root.item.albumId)
m.push({ "label": t("Go to album", r), "icon": "disc-3", "action": "go-album" })
if (root.menuShowGoTo && root.item.artistId)
m.push({ "label": t("Go to artist", r), "icon": "user", "action": "go-artist" })
if (root.menuShowTrackInfo)
m.push({ "label": t("Track info", r), "icon": "info", "action": "track-info" })
return m
}
function menuAction(a) {
if (a === "play") root.playRequested()
else if (a === "next") root.enqueueRequested("next")
else if (a === "later") root.enqueueRequested("later")
else if (a === "queue") root.enqueueRequested("queue")
else if (a === "go-artist") QbzArtist.openArtist(root.item.artistId)
else if (a === "go-album") QbzAlbum.openAlbum(root.item.albumId)
else if (a === "favorite") {
root.item.isFavorite = !root.item.isFavorite
QbzLibrary.libraryToggleFavorite("track", root.item.id)
} else if (a === "remove") root.removeRequested()
else if (a === "share-qobuz") root.copyToClipboard(
"https://open.qobuz.com/track/" + (root.item.id || ""))
else if (a === "track-info") root.openTrackInfo()
}
// --- Share (share.rs::qobuz_track_url + copy_to_clipboard) -----------
// The .slint arm copies the link and raises a toast; the Qt port has no
// toast seam yet (GLUE NEEDED), so the copy is silent — but it IS a real
// clipboard write. QtQuick exposes no Clipboard type; TextEdit.copy() is
// the supported route, kept in an INACTIVE Loader so a 16K-row list does
// not carry one TextEdit per row.
Loader {
id: clipLoader
active: false
sourceComponent: TextEdit { visible: false }
}
function copyToClipboard(text) {
if (!text || text === "")
return
clipLoader.active = true
clipLoader.item.text = text
clipLoader.item.selectAll()
clipLoader.item.copy()
clipLoader.active = false
}
// --- Track info (QbzAlbum.openTrackInfo + shell/TrackInfoModal) ------
// Same lazy-Loader reason: the modal is a full Popup tree and a list row
// must not instantiate one per delegate. Activated on demand, torn down
// on close (Qt.callLater so the Popup is not destroyed mid-signal).
Loader {
id: trackInfoLoader
active: false
sourceComponent: TrackInfoModal { }
}
Connections {
target: trackInfoLoader.item
ignoreUnknownSignals: true
function onClosed() { Qt.callLater(function () { trackInfoLoader.active = false }) }
}
function openTrackInfo() {
if (!root.item.id)
return
trackInfoLoader.active = true
trackInfoLoader.item.openFor(root.item.id)
}
// Shared drag (the row BODY is the source — TrackRow.slint): press-drag
@@ -344,10 +474,21 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
propagateComposedEvents: true
// Right press opens the SAME menu as ⋯ (rowMenu), at the pointer.
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: root.clickPlays ? Qt.PointingHandCursor : Qt.ArrowCursor
onPressed: function (mouse) { root.downPos = Qt.point(mouse.x, mouse.y) }
onPressed: function (mouse) {
if (mouse.button === Qt.RightButton) {
if (root.showMenu)
rowMenu.openAtCursor(trArea, mouse.x, mouse.y)
mouse.accepted = true
return
}
root.downPos = Qt.point(mouse.x, mouse.y)
}
onPositionChanged: function (mouse) {
if (!pressed || !root.draggable) return
// Only a LEFT press drags — a right press is the context gesture.
if (!pressed || !(pressedButtons & Qt.LeftButton) || !root.draggable) return
const g = mapToItem(null, mouse.x, mouse.y)
if (!root.dragging
&& (Math.abs(mouse.x - root.downPos.x) > 6
@@ -360,6 +501,8 @@ Rectangle {
if (root.dragging) QbzShell.dragMove(g.x, g.y)
}
onReleased: function (mouse) {
if (mouse.button === Qt.RightButton)
return
if (root.dragging) {
QbzShell.dragEnd()
root.dragging = false
@@ -370,6 +513,9 @@ Rectangle {
mouse.accepted = false
}
}
onDoubleClicked: root.playRequested()
onDoubleClicked: function (mouse) {
if (mouse.button === Qt.LeftButton)
root.playRequested()
}
}
}