fix(qbz-qt): local albums route to the local view, and the detail-page parity round

**The empty album view.** Opening a local album from Library "All" landed on a
blank page. The mixed feed hands Qobuz and local albums to the SAME card, and
the card always called the catalog open — so a local id, which is a group key
or a filesystem path, went to /album/get and came back 404. It was in the
owner's log before this round as `get_album(/mnt/nas/…) returned 404`; what
changed is that clearing the stale document turned "shows the previous album"
into "shows nothing", which is how it surfaced. Routing now happens in
open_album itself, using the predicate the feed already uses to route
favourites, so every call site is covered at once instead of card by card.

**Header atmosphere.** Both detail pages get Slint's two-band gradient, tinted
from THIS page's artwork through the ported header_tint — not the now-playing
ambient triad, which is a different subject with a different algorithm.

**Artist page.** The catalog/library toggle existed but was broken: a
hand-rolled copy of the shared tab bar whose delegate walked the wrong parent
chain, so it threw on every evaluation and never took its active colours. It
now mounts the shared control. Added the JUMP TO label, the tab-strip search
with the client-side filter it drives, and the year under release-section
albums. The Network panel ran the full window height and never auto-opened; it
is bounded below the tab strip now and opens unless the content is constrained,
as Slint does. The action circles were the wrong size with the wrong glyph
colour and no overlay arm — which would have left them dark-on-dark once the
gradient landed.

**Quality in track rows.** The column rendered a bare tier; it carries the
badge now, in the one shared row component every surface mounts. playlist_qt
was the only producer not emitting the bit-depth line.

**Skeletons hand over instead of disappearing.** They cleared when the PATH
arrived, but the image still had to load and rasterize after that, so the
placeholder vanished and left an empty cell. They now wait for the image to be
ready.

**Local covers.** Most local covers ARE already the scanner's thumbnail, but
re-hashing their path produced a different filename, so each one was decoded,
resized to its own size and re-encoded into a duplicate before being shown.
That was most of the wait.

cc
This commit is contained in:
vicrodh committed 2026-07-28 19:01:25 -06:00
1 parent f3a33245b2
commit 9ab3d7db96
29 files changed
+1776 -446

No files matched your search

@@ -46,20 +46,23 @@ Rectangle {
tintName: "muted"
}
RoundedImage {
id: subcardArt
anchors.fill: parent
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).
// Per-item cover placeholder — hands over when the art is on the
// canvas, not when the path 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
pending: root.view ? root.view.artWanted(root.item.artKey) : false
coverReady: subcardArt.ready
phase: root.view ? root.view.skelPhase : false
settleMs: root.view ? root.view.artSettleMs : 0
settleHold: root.view ? root.view.artPulse : false
}
}
Text {
@@ -207,12 +207,15 @@ 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.
// Per-item cover placeholder, handed over to the
// art itself: AlbumCard seals its RoundedImage
// away, so this uses QbzSkeleton's probe arm —
// `coverSource` loads the SAME pixmap-cache entry
// the card is loading and retires the placeholder
// when that decode completes, not when the path
// appears. A bare Rectangle, so it does not take
// pointer events and 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
@@ -221,10 +224,13 @@ Item {
variant: "art"
width: 200
height: 200
visible: root.view
? root.view.artPending(cardCell.modelData.artKey) : false
pending: root.view
? root.view.artWanted(cardCell.modelData.artKey) : false
coverSource: root.view
? root.view.artPathOf(cardCell.modelData.artKey) : ""
phase: root.view ? root.view.skelPhase : false
settleMs: root.view ? root.view.artSettleMs : 0
settleHold: root.view ? root.view.artPulse : false
}
// RIGHT-only, declared after the card so every
// left click still reaches the card's own areas.
@@ -21,7 +21,15 @@ Row {
property var versions: []
property string coverSource: ""
/// Per-item cover state, owned by the page (LocalAlbumView).
/// `coverPending` means "this album EXPECTS a cover" — whether one has
/// arrived is decided here, by the image's own readiness, never by the
/// path being non-empty (see theme/RoundedImage.qml's contract).
property bool coverPending: false
/// Optional: true while the page's artwork pass is still in flight. It
/// suspends the settle countdown so a slow cold decode cannot expire the
/// placeholder a beat before the cover lands. Defaults to false, so the
/// page need not supply it.
property bool artPassActive: false
property bool skelPhase: false
property int artSettleMs: 0
signal openArtist(string name)
@@ -40,19 +48,22 @@ Row {
color: theme.surfaceElevated
clip: true
RoundedImage {
id: headerArt
anchors.fill: parent
source: root.coverSource
radius: 12
}
// Per-item: clears when THIS album's cover lands; settles out when
// Per-item: hands over on the paint, not the path; 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
pending: root.coverPending
coverReady: headerArt.ready
phase: root.skelPhase
settleMs: root.artSettleMs
settleHold: root.artPassActive
}
}
@@ -99,19 +99,23 @@ Rectangle {
color: theme.surfaceElevated
clip: true
RoundedImage {
id: rowArt
anchors.fill: parent
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).
// Per-item: hands over the instant the art is actually ON the
// canvas (`rowArt.ready`, not "the path landed"); 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
pending: root.view ? root.view.artWanted(root.item.artKey) : false
coverReady: rowArt.ready
phase: root.view ? root.view.skelPhase : false
settleMs: root.view ? root.view.artSettleMs : 0
settleHold: root.view ? root.view.artPulse : false
}
}
Column {
@@ -19,8 +19,10 @@ Rectangle {
property var view: null
signal picked(string name)
readonly property bool avatarPending: root.view
? root.view.artPending(root.item.artKey) : false
/// Does this artist expect an avatar at all? (Whether one has ARRIVED is
/// the placeholder's business — it hands over on the paint, not the path.)
readonly property bool avatarWanted: root.view
? root.view.artWanted(root.item.artKey) : false
QbzTheme { id: theme }
@@ -42,11 +44,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)
// The designed empty state — shown exactly when there is no
// art AND the placeholder has retired, so the user is never
// left with a blank disc and never sees the glyph flash
// underneath a cover that is about to paint.
visible: root.artSource === "" && avatarSkel.retired
name: "user"
width: 22
height: 22
@@ -54,6 +56,7 @@ Rectangle {
tintName: "muted"
}
RoundedImage {
id: avatarArt
anchors.fill: parent
source: root.artSource
radius: 24
@@ -63,9 +66,11 @@ Rectangle {
id: avatarSkel
variant: "circle"
anchors.fill: parent
visible: root.avatarPending
pending: root.avatarWanted
coverReady: avatarArt.ready
phase: root.view ? root.view.skelPhase : false
settleMs: root.view ? root.view.artSettleMs : 0
settleHold: root.view ? root.view.artPulse : false
}
}
Column {
@@ -131,20 +131,25 @@ Item {
tintName: "muted"
}
RoundedImage {
id: blockArt
anchors.fill: parent
source: root.view.artMap[block.modelData.artKey] || ""
source: root.view
? root.view.artPathOf(block.modelData.artKey) : ""
radius: 6
}
// Per-item: clears when THIS album's cover
// lands, settles out when it has none.
// Per-item: hands over when THIS album's
// cover is actually painted, settles out when
// it has none.
QbzSkeleton {
variant: "art"
anchors.fill: parent
blockRadius: 6
visible: root.view
? root.view.artPending(block.modelData.artKey) : false
pending: root.view
? root.view.artWanted(block.modelData.artKey) : false
coverReady: blockArt.ready
phase: root.view ? root.view.skelPhase : false
settleMs: root.view ? root.view.artSettleMs : 0
settleHold: root.view ? root.view.artPulse : false
}
}
Column {