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

+351 -32
View File
@@ -1,8 +1,20 @@
// Top header bar — QML port of crates/qbz-ui/ui/shell/HeaderBar.slint.
//
// Left: the three "sacred" nav buttons (sidebar cycle, back, forward) and
// — while the sidebar is fully closed — the compact icon-only section nav
// (nav-in-sidebar is ON in this POC, so the full text tabs never mount).
// Left: the three "sacred" nav buttons (sidebar cycle, back, forward) and,
// after them, the section nav in whichever form the placement prefs ask for
// (HeaderBar.slint:858 / :974 — the two blocks are mutually exclusive):
//
// navInSidebar ON -> the sections live in Sidebar.qml. The header shows
// the COMPACT icon nav only while the sidebar is fully
// closed (state 2), plus the separator + playlists
// flyout button, so nothing is unreachable.
// navInSidebar OFF -> the sections live HERE. Full text tabs while the
// sidebar is not fully closed and navHeaderCompact is
// OFF; the compact icon form when navHeaderCompact is
// ON, or whenever the sidebar is fully closed.
//
// The search field gives up 60px whenever the nav is in the header, and
// re-centers with a 220ms animation (HeaderBar.slint:569).
// Center: the search field, absolutely centered on the window (VISUAL
// replica — the cortinilla/live-search is out of scope; POC-NOTE).
// Right: the tri-state offline status badge with its flyout (recovery
@@ -65,7 +77,118 @@ Rectangle {
: QbzSession.offlineMode === 2 ? 2
: QbzSession.offlineMode === 1 ? 1 : 0
// --- Section nav (shared by both header forms) ------------------------
// Same four sections Sidebar.qml lists, in the same order. Slint keeps two
// copies of this list too (Sidebar.slint:724 and HeaderBar.slint:858/974)
// — the alternative here would be a new singleton .qml file, which needs a
// build.rs entry; the list is six lines, so it stays local.
//
// `view` is the route QbzShell.navigateTo takes. My QBZ has NO view in
// this port (AppShell's loader maps home/library/local/localalbum/album/
// artist/settings/search/playlist), so it renders disabled rather than as
// a live control that clicks into nothing — same call Sidebar.qml makes.
readonly property var navSections: [
{ "id": "discover", "icon": "compass", "label": QbzSession.tr("Discover", QbzSession.trRev), "view": "home", "qobuz": true, "enabled": true },
{ "id": "library", "icon": "music-library-2", "label": QbzSession.tr("Library", QbzSession.trRev), "view": "library", "qobuz": true, "enabled": true },
{ "id": "local", "icon": "hard-drive", "label": QbzSession.tr("Local Library", QbzSession.trRev), "view": "local", "qobuz": false, "enabled": true },
{ "id": "myqbz", "icon": "qbz-symbolic", "label": QbzSession.tr("My QBZ", QbzSession.trRev), "view": "", "qobuz": false, "enabled": false }
]
// Highlighted section — derived from the live view, exactly like
// Sidebar.qml (see the note there on why this is not click-set state).
readonly property string activeNav:
QbzShell.currentView === "home" ? "discover"
: QbzShell.currentView === "library" ? "library"
: (QbzShell.currentView === "local" || QbzShell.currentView === "localalbum") ? "local"
: ""
// Which of the two header forms is mounted (mutually exclusive, and both
// off while the nav lives in the sidebar and the sidebar is not closed).
readonly property bool headerTabsOn: !QbzShell.navInSidebar
&& QbzShell.sidebarState !== 2 && !QbzShell.navHeaderCompact
readonly property bool headerCompactOn: QbzShell.sidebarState === 2
|| (!QbzShell.navInSidebar && QbzShell.navHeaderCompact)
// Full text tab (HeaderBar.slint NavTab): 30px tall, radius sm, icon 14
// (dropped under 1140px), label 11px — semibold + elevated fill when the
// section is the current view.
component NavTab: Rectangle {
id: navTab
property var section: null
property bool showIcon: true
readonly property bool isActive: section && root.activeNav === section.id
readonly property bool isEnabled: section && section.enabled
height: 30
width: tabRow.implicitWidth
radius: theme.radiusSm
opacity: isEnabled ? 1.0 : 0.5
color: isActive ? theme.surfaceElevated
: (tabArea.containsMouse && isEnabled) ? theme.surfaceHover : "transparent"
Row {
id: tabRow
height: parent.height
leftPadding: navTab.showIcon ? 9 : 11
rightPadding: 11
spacing: 6
QbzIcon {
visible: navTab.showIcon
name: navTab.section ? navTab.section.icon : ""
width: navTab.showIcon ? 14 : 0
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: navTab.isActive ? "primary" : "secondary"
}
Text {
height: parent.height
text: navTab.section ? navTab.section.label : ""
color: theme.textPrimary
font.pixelSize: 11
font.weight: navTab.isActive ? theme.weightSemibold : theme.weightRegular
verticalAlignment: Text.AlignVCenter
}
}
MouseArea {
id: tabArea
anchors.fill: parent
enabled: navTab.isEnabled
hoverEnabled: navTab.isEnabled
cursorShape: Qt.PointingHandCursor
onClicked: QbzShell.navigateTo(navTab.section.view)
}
}
// Compact icon-only button (HeaderBar.slint CompactNavBtn): 30x30, icon
// 16, elevated fill + primary tint while its section is current.
component CompactNavBtn: Rectangle {
id: cnb
property var section: null
readonly property bool isActive: section && root.activeNav === section.id
readonly property bool isEnabled: section && section.enabled
width: 30
height: 30
radius: theme.radiusSm
opacity: isEnabled ? 1.0 : 0.5
color: isActive ? theme.surfaceElevated
: (cnbArea.containsMouse && isEnabled) ? theme.surfaceHover : "transparent"
QbzIcon {
name: cnb.section ? cnb.section.icon : ""
width: 16
height: 16
anchors.centerIn: parent
tintName: cnb.isActive ? "primary" : "secondary"
}
MouseArea {
id: cnbArea
anchors.fill: parent
enabled: cnb.isEnabled
hoverEnabled: cnb.isEnabled
cursorShape: Qt.PointingHandCursor
onClicked: QbzShell.navigateTo(cnb.section.view)
}
}
// --- Left controls ---------------------------------------------------
Row {
@@ -93,48 +216,238 @@ Rectangle {
onClicked: QbzShell.navigateForward()
}
// Compact section nav — only while the sidebar is fully closed
// (Cerrado), so the sections stay reachable. POC-NOTE: these open
// dropdown menus in Slint; here they are inert visual replicas.
// Full section nav (text tabs) — nav in the header, sidebar not fully
// closed, compact form OFF. Sits AFTER the three sacred buttons so it
// can never overlap them (HeaderBar.slint:853).
Row {
visible: QbzShell.sidebarState === 2
visible: root.headerTabsOn
height: parent.height
spacing: 2
Item { width: 6; height: 1 }
QbzIconButton { activeBackground: true
name: "compass"
btnSize: 30; iconSize: 16
anchors.verticalCenter: parent.verticalCenter
visible: !QbzSession.offline
Repeater {
model: root.navSections
delegate: NavTab {
required property var modelData
section: modelData
// Qobuz-only sections are HIDDEN while offline (ADR-010
// mount-site gating, not rendered-disabled).
visible: !(modelData.qobuz && QbzSession.offline)
// Tabs drop their icons under the first breakpoint.
showIcon: root.width >= 1140
anchors.verticalCenter: parent.verticalCenter
}
}
QbzIconButton { activeBackground: true
name: "music-library-2"
btnSize: 30; iconSize: 16
anchors.verticalCenter: parent.verticalCenter
visible: !QbzSession.offline
}
// Compact section nav — while the sidebar is fully closed (so the
// sections stay reachable), or always when the nav is in the header
// and "Compact header navigation" is ON (HeaderBar.slint:974).
Row {
visible: root.headerCompactOn
height: parent.height
spacing: 2
Item { width: 6; height: 1 }
Repeater {
model: root.navSections
delegate: CompactNavBtn {
required property var modelData
section: modelData
visible: !(modelData.qobuz && QbzSession.offline)
anchors.verticalCenter: parent.verticalCenter
}
}
QbzIconButton { activeBackground: true
name: "hard-drive"
btnSize: 30; iconSize: 16
anchors.verticalCenter: parent.verticalCenter
}
QbzIconButton { activeBackground: true
name: "qbz-symbolic"
btnSize: 30; iconSize: 16
anchors.verticalCenter: parent.verticalCenter
}
// Thin separator + the playlists flyout button.
// Thin separator + the playlists flyout button — ONLY while the
// sidebar is really closed. In the opt-in always-compact mode the
// sidebar list is still on screen, so the button stays away
// (HeaderBar.slint:1078).
Rectangle {
visible: QbzShell.sidebarState === 2
width: 1
height: 18
anchors.verticalCenter: parent.verticalCenter
color: theme.borderSubtle
}
QbzIconButton { activeBackground: true
name: "list-music"
btnSize: 30; iconSize: 16
Rectangle {
id: plBtn
visible: QbzShell.sidebarState === 2
width: 30
height: 30
radius: theme.radiusSm
anchors.verticalCenter: parent.verticalCenter
color: plPopup.opened ? theme.surfaceElevated
: plBtnArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: "list-music"
width: 16
height: 16
anchors.centerIn: parent
tintName: plPopup.opened ? "primary" : "secondary"
}
MouseArea {
id: plBtnArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: plPopup.open()
}
// Closed-sidebar playlists flyout (SidebarPlaylistsPopup):
// 248px wide, a search header over the SAME Rust-side filter
// the sidebar uses, then the flat entries (folders toggle in
// place, playlists open and close the flyout). Six rows
// visible, the rest scroll.
// GAP: Slint's footer (Import / Manage playlists) is omitted —
// this port has neither seam, and the sidebar "..." menu marks
// the same two as unavailable rather than faking them.
Popup {
id: plPopup
y: plBtn.height + 6
width: 248
padding: 4
closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape
readonly property var entries: JSON.parse(QbzShell.sidebarJson)
onClosed: {
// Clear the shared filter so it doesn't silently scope
// the expanded sidebar's list later.
plSearch.text = ""
QbzShell.sidebarSearch("")
}
background: Rectangle {
color: theme.surfaceMain
radius: theme.radiusSm
border.width: 1
border.color: theme.borderMuted
}
contentItem: Column {
width: parent.width
spacing: 0
// Search header (34px band, 26px field).
Item {
width: parent.width
height: 34
Rectangle {
anchors.centerIn: parent
width: parent.width - 16
height: 26
radius: 4
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
TextInput {
id: plSearch
anchors.fill: parent
anchors.leftMargin: 6
anchors.rightMargin: 6
color: theme.textPrimary
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
clip: true
onTextEdited: QbzShell.sidebarSearch(text)
Text {
visible: plSearch.text === ""
anchors.fill: parent
text: QbzSession.tr("Search playlists", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
}
}
}
}
// Entries — 32px rows, six visible then scroll.
Flickable {
width: parent.width
height: Math.max(32, Math.min(plPopup.entries.length, 6) * 32)
clip: true
contentWidth: width
contentHeight: plList.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Column {
id: plList
width: parent.width
Repeater {
model: plPopup.entries
delegate: Rectangle {
required property var modelData
readonly property bool isFolder: modelData.kind === "folder"
width: plList.width
height: 32
radius: 5
color: ppArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: modelData.indent ? 26 : 12
anchors.rightMargin: 12
spacing: 8
QbzIcon {
name: isFolder
? (modelData.expanded ? "folder-open" : "folder")
: "list-music"
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: isFolder ? "accent" : "muted"
}
Text {
width: parent.width - 22 - (ppCount.visible ? ppCount.implicitWidth + 8 : 0)
height: parent.height
text: modelData.name
color: isFolder ? theme.textPrimary : theme.textSecondary
font.pixelSize: 13
font.weight: isFolder ? theme.weightSemibold : theme.weightRegular
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
Text {
id: ppCount
visible: isFolder && modelData.count > 0
height: parent.height
text: modelData.count
color: theme.textMuted
font.pixelSize: 11
verticalAlignment: Text.AlignVCenter
}
}
MouseArea {
id: ppArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (isFolder) {
QbzShell.sidebarToggleFolder(modelData.id)
} else {
QbzBridge.openPlaylist(modelData.id)
plPopup.close()
}
}
}
}
}
// Empty state — the flyout would otherwise be a
// blank 32px band with no explanation.
Text {
visible: plPopup.entries.length === 0
width: plList.width
height: 32
leftPadding: 12
text: QbzSession.tr("No playlists yet.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
}
}
}
}
}
}
}
}
@@ -152,8 +465,14 @@ Rectangle {
id: searchBox
x: (root.width - width) / 2
y: (root.height - height) / 2
width: root.width < 960 ? 179 : 256
// 80% of the prior search width; gives up 60px to the section nav
// whenever that nav lives in the header (HeaderBar.slint:569). The
// width animates and `x` re-centers with it.
width: (root.width < 960 ? 179 : 256) - (QbzShell.navInSidebar ? 0 : 60)
height: 32
Behavior on width {
NumberAnimation { duration: 220; easing.type: Easing.InOutQuad }
}
radius: 6
border.width: 1
border.color: searchInput.activeFocus ? theme.accent : theme.borderSubtle
+47 -22
View File
@@ -7,7 +7,10 @@
// Top-level section nav rows (Discover / Library / Local Library / My QBZ)
// replicate SidebarNavRow: 34px rows, radius 6, 16px icons, 13px/w500
// labels, surface-hover on hover, Discover + Library HIDDEN while offline
// (ADR-010 mount-site gating).
// (ADR-010 mount-site gating). The whole block — rows AND the hairline under
// them — is mounted only while `QbzShell.navInSidebar` is ON; with it OFF the
// sections move to the header (HeaderBar.qml) and this sidebar starts at the
// PLAYLISTS toolbar, exactly like Sidebar.slint:724/817.
//
// POC-NOTE: in the Slint app these rows open dropdown flyout menus; the
// flyouts are out of scope — rows here navigate straight to their view and
@@ -29,8 +32,17 @@ Rectangle {
id: root
property bool mini: QbzShell.sidebarState === 1
// The current section — Discover (home) is the only real view.
property string activeNav: "discover"
// Which section owns the highlight. DERIVED from the live view, not set on
// click: the same rows can live here or in the header, history navigation
// (back/forward) must keep the highlight honest, and a click-set local
// went stale on both counts. Slint highlights its rows off
// HeaderMenuState.open-index — a flyout this port does not have — so the
// current view is the faithful stand-in (it is what SidebarDirectRow uses).
readonly property string activeNav:
QbzShell.currentView === "home" ? "discover"
: QbzShell.currentView === "library" ? "library"
: (QbzShell.currentView === "local" || QbzShell.currentView === "localalbum") ? "local"
: ""
// Playlist tree state (phase 7).
property bool searchOpen: false
property bool playlistsCollapsed: false
@@ -154,12 +166,19 @@ Rectangle {
anchors.leftMargin: root.mini ? 8 : theme.spacingMd
anchors.rightMargin: root.mini ? 8 : theme.spacingMd
anchors.topMargin: root.mini ? 8 : theme.spacingMd
anchors.bottomMargin: root.mini ? 8 : theme.spacingMd
// Per-side padding so Large can zero ONLY the bottom (Sidebar.slint:719):
// the reserved dock band then reaches the true window bottom-left and the
// cover sits flush (the L corner). Left/right/top are unchanged.
anchors.bottomMargin: root.largeDockActive ? 0 : (root.mini ? 8 : theme.spacingMd)
spacing: theme.spacingMd
// ---- Section nav -------------------------------------------
Column {
id: navColumn
// Section-nav placement: OFF moves the whole block to the header.
// A Column skips invisible children entirely (no phantom spacing),
// so this is the `if` mount-site gate Slint uses.
visible: QbzShell.navInSidebar
width: parent.width
spacing: 2
@@ -169,29 +188,20 @@ Rectangle {
name: "compass"
label: QbzSession.tr("Discover", QbzSession.trRev)
visible: !QbzSession.offline
onClicked: {
root.activeNav = "discover"
QbzShell.navigateTo("home")
}
onClicked: QbzShell.navigateTo("home")
}
NavRow {
navId: "library"
name: "music-library-2"
label: QbzSession.tr("Library", QbzSession.trRev)
visible: !QbzSession.offline
onClicked: {
root.activeNav = "library"
QbzShell.navigateTo("library")
}
onClicked: QbzShell.navigateTo("library")
}
NavRow {
navId: "local"
name: "hard-drive"
label: QbzSession.tr("Local Library", QbzSession.trRev)
onClicked: {
root.activeNav = "local"
QbzShell.navigateTo("local")
}
onClicked: QbzShell.navigateTo("local")
}
// GAP: there is no MyQBZ view in this port (AppShell's loader
// maps home/library/local/localalbum/album/artist/settings/
@@ -206,7 +216,10 @@ Rectangle {
}
}
// Hairline under the section nav — mounted with the nav block, not
// on its own (Sidebar.slint:817 gates it on the same flag).
Rectangle {
visible: QbzShell.navInSidebar
width: parent.width
height: 1
color: theme.borderSubtle
@@ -480,10 +493,20 @@ Rectangle {
Item {
visible: !root.playlistsCollapsed
width: parent.width
// This is the element that FILLS (Sidebar.slint gives the playlist
// Rectangle `vertical-stretch: 1`): everything above it — the nav
// block, the hairline, the 22px toolbar — takes its natural height
// and the list eats the rest, down to the Column's bottom margin.
// `root.dockReserve` keeps the rows clear of the Large-NPB cover
// (0 in every other mode). Clamped so a short window shrinks the
// list to nothing instead of giving it a negative height.
height: Math.max(0, parent.height - y - 16 - root.dockReserve)
//
// There used to be a trailing `Item { height: 0 }` "filler" after
// this one and a matching `- 16` here to pay for the Column spacing
// it introduced. Net effect: the list stopped 16px short and the
// sidebar read as too short against Slint. Both are gone — the
// list is the last child, so its bottom IS the Column's bottom.
height: Math.max(0, parent.height - y - root.dockReserve)
Flickable {
id: plFlick
@@ -503,6 +526,11 @@ Rectangle {
delegate: Rectangle {
required property var modelData
property bool isFolder: modelData.kind === "folder"
// Slint SidebarRow.use-collage: opt-OUT setting AND
// at least one cover; otherwise the list-music glyph.
property bool useCollage: !isFolder
&& QbzShell.sidebarPlaylistCollage
&& modelData.covers.length > 0
property bool isActive: !isFolder && modelData.id === root.activePlaylistId
// Track-drag drop target (Sidebar.slint SidebarRow):
// the row self-detects the drop from the shared
@@ -559,7 +587,7 @@ Rectangle {
}
// 2x2 micro-collage (or list-music glyph).
Rectangle {
visible: !isFolder && modelData.covers.length > 0
visible: useCollage
width: 20
height: 20
anchors.centerIn: parent
@@ -584,7 +612,7 @@ Rectangle {
}
}
QbzIcon {
visible: !isFolder && modelData.covers.length === 0
visible: !isFolder && !useCollage
name: "list-music"
width: 15
height: 15
@@ -684,8 +712,5 @@ Rectangle {
}
}
}
// Fill the rest.
Item { width: 1; height: 0; }
}
}