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:
29 files changed
+1776
-446
No files matched your search
@@ -8,9 +8,17 @@
|
||||
// same artist", "Listening suggestions").
|
||||
//
|
||||
// POC-NOTEs: multi-select + bulk bar, offline download column, booklet,
|
||||
// custom-cover menu, album-info modal, the header atmosphere
|
||||
// (album-header-gradient pref) — out of scope (visible stubs are inert).
|
||||
// Text keeps the theme colors (header-light = false here).
|
||||
// custom-cover menu, album-info modal — out of scope (visible stubs are
|
||||
// inert).
|
||||
//
|
||||
// Header atmosphere (AlbumPageView.slint:161-189, 221-257): the
|
||||
// artwork-tinted band IS wired now, through the shared
|
||||
// controls/HeaderGradient.qml (route B — see that file for why the blurred
|
||||
// route is not available on this path). It brings the .slint's header-colour
|
||||
// rules with it: with the band on, the header sits on a DARK backdrop, so
|
||||
// the text goes light regardless of theme (`hdrStrong` / `hdrBody`,
|
||||
// .slint:169-172) and the CircleActions switch to their overlay palette
|
||||
// (`hdrOverlay` = the .slint's inverted `hdr-on-surface`, :179).
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
@@ -42,6 +50,34 @@ Rectangle {
|
||||
// Client-side track search (AlbumActions.search equivalent).
|
||||
property string trackQuery: ""
|
||||
|
||||
// ---- Header atmosphere (AlbumPageView.slint:161-189) -----------------
|
||||
// The pref, LIVE where possible: the settings snapshot is only published
|
||||
// on settings-view open / mutation, so on a cold start it is empty and
|
||||
// the document's own copy (album_qt.rs `headerGradient`) answers instead.
|
||||
readonly property bool headerGradientPref: {
|
||||
var raw = QbzBridge.settingsJson
|
||||
if (raw && raw.length > 2) {
|
||||
try {
|
||||
var d = JSON.parse(raw)
|
||||
if (d.albumHeaderGradient !== undefined)
|
||||
return d.albumHeaderGradient === true
|
||||
} catch (e) { /* fall through to the document copy */ }
|
||||
}
|
||||
return album.headerGradient !== false
|
||||
}
|
||||
// .slint:168 — the album's own atmosphere is SUPPRESSED under the
|
||||
// app-wide dynamic background (they clash); the dynamic background then
|
||||
// provides the dark backdrop instead.
|
||||
readonly property bool headerAtmoOn: headerGradientPref && !ambientOn
|
||||
// .slint:167 — dark backdrop from EITHER source means light header text.
|
||||
readonly property bool headerLight: headerGradientPref || ambientOn
|
||||
readonly property color hdrStrong: headerLight ? "#ffffff" : theme.textPrimary
|
||||
readonly property color hdrBody: headerLight ? "#e0ffffff" : theme.textSecondary
|
||||
// (the .slint declares an `hdr-muted` tier too, :173, but never binds it
|
||||
// to anything — not ported rather than ported dead)
|
||||
// .slint:179 — with no dark backdrop the circles use the on-surface arm.
|
||||
readonly property bool hdrOverlay: headerLight
|
||||
|
||||
readonly property var visibleTracks: {
|
||||
if (trackQuery === "") return tracks
|
||||
var q = trackQuery.toLowerCase()
|
||||
@@ -337,6 +373,23 @@ Rectangle {
|
||||
contentHeight: page.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
// Artwork-tinted header band. FIRST child so it paints under the
|
||||
// page, and inside the Flickable so it scrolls with the content
|
||||
// (AlbumPageView.slint:221 — the atmosphere lives in the Flickable).
|
||||
// Full-bleed on purpose: the page's 32px padding must NOT clip it or
|
||||
// a dark gutter strip appears on the right (.slint:199-202).
|
||||
HeaderGradient {
|
||||
x: 0
|
||||
y: 0
|
||||
width: pageFlick.width
|
||||
// .slint:189 `atmo-height: page.y + header-divider.y` — the band
|
||||
// ends EXACTLY on the header/track-list divider, whatever height
|
||||
// a long editorial description gave the header.
|
||||
height: page.y + headerDivider.y
|
||||
tint: album.headerColor || ""
|
||||
active: root.headerAtmoOn
|
||||
}
|
||||
|
||||
Column {
|
||||
id: page
|
||||
width: parent.width
|
||||
@@ -418,7 +471,7 @@ Rectangle {
|
||||
Text {
|
||||
width: parent.width
|
||||
text: header.title || ""
|
||||
color: theme.textPrimary
|
||||
color: root.hdrStrong
|
||||
font.pixelSize: theme.fontSection
|
||||
font.weight: theme.weightBold
|
||||
elide: Text.ElideRight
|
||||
@@ -437,13 +490,13 @@ Rectangle {
|
||||
Text {
|
||||
visible: index > 0
|
||||
text: " • "
|
||||
color: theme.textSecondary
|
||||
color: root.hdrBody
|
||||
font.pixelSize: theme.fontHeading
|
||||
font.weight: theme.weightBold
|
||||
}
|
||||
Text {
|
||||
text: modelData[0]
|
||||
color: creditArea.containsMouse && modelData[1] !== "" ? theme.textPrimary : theme.textSecondary
|
||||
color: creditArea.containsMouse && modelData[1] !== "" ? root.hdrStrong : root.hdrBody
|
||||
font.pixelSize: theme.fontHeading
|
||||
font.weight: theme.weightBold
|
||||
MouseArea {
|
||||
@@ -458,7 +511,7 @@ Rectangle {
|
||||
Text {
|
||||
visible: modelData[2] !== ""
|
||||
text: " (" + modelData[2] + ")"
|
||||
color: theme.textSecondary
|
||||
color: root.hdrBody
|
||||
font.pixelSize: theme.fontHeading
|
||||
}
|
||||
}
|
||||
@@ -472,12 +525,12 @@ Rectangle {
|
||||
Text {
|
||||
visible: (header.metaPre || "") !== ""
|
||||
text: (header.metaPre || "") + " • "
|
||||
color: theme.textSecondary
|
||||
color: root.hdrBody
|
||||
font.pixelSize: theme.fontBody
|
||||
}
|
||||
Text {
|
||||
text: header.label || ""
|
||||
color: labelArea.containsMouse ? theme.accent : theme.textSecondary
|
||||
color: labelArea.containsMouse ? theme.accent : root.hdrBody
|
||||
font.pixelSize: theme.fontBody
|
||||
MouseArea {
|
||||
id: labelArea
|
||||
@@ -490,7 +543,7 @@ Rectangle {
|
||||
Text {
|
||||
visible: (header.metaPost || "") !== ""
|
||||
text: " • " + (header.metaPost || "")
|
||||
color: theme.textSecondary
|
||||
color: root.hdrBody
|
||||
font.pixelSize: theme.fontBody
|
||||
}
|
||||
}
|
||||
@@ -498,7 +551,7 @@ Rectangle {
|
||||
visible: (header.labelId || "") === "" || (header.label || "") === ""
|
||||
width: parent.width
|
||||
text: header.infoLine || ""
|
||||
color: theme.textSecondary
|
||||
color: root.hdrBody
|
||||
font.pixelSize: theme.fontBody
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
@@ -509,7 +562,7 @@ Rectangle {
|
||||
visible: (header.description || "") !== ""
|
||||
width: parent.width
|
||||
text: header.descriptionShort || ""
|
||||
color: theme.textSecondary
|
||||
color: root.hdrBody
|
||||
font.pixelSize: theme.fontLegal
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
@@ -533,38 +586,31 @@ Rectangle {
|
||||
}
|
||||
|
||||
Item { width: 1; height: 20 }
|
||||
// Action row (on-surface CircleActions).
|
||||
// Action row — AlbumPageView.slint:504-640. One shared
|
||||
// CircleAction for every button including Play (the
|
||||
// hand-rolled 44px disc it used to be drifted from the
|
||||
// control on ring, hover and glyph tint); the palette arm
|
||||
// follows the header backdrop, exactly like the .slint's
|
||||
// `on-surface: root.hdr-on-surface`.
|
||||
Row {
|
||||
spacing: 12
|
||||
// Play — accent disc, white glyph (on-surface primary).
|
||||
Rectangle {
|
||||
width: 44
|
||||
height: 44
|
||||
radius: 22
|
||||
color: playHdrArea.containsMouse ? theme.accentHover : theme.accent
|
||||
QbzIcon {
|
||||
anchors.centerIn: parent
|
||||
name: "play-fill"
|
||||
width: 19
|
||||
height: 19
|
||||
tintName: "primary"
|
||||
}
|
||||
MouseArea {
|
||||
id: playHdrArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzPlayer.playAlbum(header.id)
|
||||
}
|
||||
QbzCircleAction {
|
||||
primary: true
|
||||
overlay: root.hdrOverlay
|
||||
name: "play-fill"
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzPlayer.playAlbum(header.id)
|
||||
}
|
||||
QbzCircleAction {
|
||||
name: "shuffle"
|
||||
overlay: root.hdrOverlay
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzPlayer.playAlbumShuffled(header.id)
|
||||
}
|
||||
QbzCircleAction {
|
||||
readonly property bool favorite: root.toggleState("album", header.isFavorite)
|
||||
name: favorite ? "heart-filled" : "heart"
|
||||
overlay: root.hdrOverlay
|
||||
active: favorite
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: {
|
||||
@@ -572,14 +618,34 @@ Rectangle {
|
||||
QbzLibrary.libraryToggleFavorite("album", header.id)
|
||||
}
|
||||
}
|
||||
// Radio / Mixtape / Info — INERT stubs (POC-NOTE:
|
||||
// radio engines, mixtape store, album-info modal).
|
||||
QbzCircleAction { name: "radio"; anchors.verticalCenter: parent.verticalCenter }
|
||||
QbzCircleAction { name: "cassette-tape"; anchors.verticalCenter: parent.verticalCenter }
|
||||
QbzCircleAction { name: "info"; anchors.verticalCenter: parent.verticalCenter }
|
||||
// Radio / Mixtape / Album info: no seam on this
|
||||
// bridge (radio engines, mixtape store, credits
|
||||
// modal). They stay VISIBLE but DISABLED — the
|
||||
// .slint's own `enabled: false` treatment (dimmed to
|
||||
// 0.4, click gated) — rather than rendering as live
|
||||
// buttons that do nothing on click.
|
||||
QbzCircleAction {
|
||||
name: "radio"
|
||||
overlay: root.hdrOverlay
|
||||
btnEnabled: false
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
QbzCircleAction {
|
||||
name: "cassette-tape"
|
||||
overlay: root.hdrOverlay
|
||||
btnEnabled: false
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
QbzCircleAction {
|
||||
name: "info"
|
||||
overlay: root.hdrOverlay
|
||||
btnEnabled: false
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
QbzCircleAction {
|
||||
id: albumMenuBtn
|
||||
name: "ellipsis"
|
||||
overlay: root.hdrOverlay
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: function (mouse) { albumMenu.openAtCursor(albumMenuBtn, mouse.x, mouse.y) }
|
||||
}
|
||||
@@ -588,8 +654,9 @@ Rectangle {
|
||||
}
|
||||
|
||||
Item { width: 1; height: 20 }
|
||||
// Header divider (the atmosphere ends here in Slint).
|
||||
Rectangle { width: parent.width - 64; height: 1; color: theme.borderSubtle }
|
||||
// Header divider. The gradient band above sizes itself to THIS
|
||||
// item's y (.slint:189 atmo-height), so it keeps its id.
|
||||
Rectangle { id: headerDivider; width: parent.width - 64; height: 1; color: theme.borderSubtle }
|
||||
Item { width: 1; height: 8 }
|
||||
|
||||
// --- Track list + label/awards sidebar ----------------------
|
||||
@@ -668,28 +735,27 @@ Rectangle {
|
||||
width: parent.width
|
||||
height: 52
|
||||
spacing: 16
|
||||
Row {
|
||||
// AlbumPageView.slint:692 mounts QualityBadgeFull —
|
||||
// the contained chip (format mark + tier label over
|
||||
// the exact bit-depth/rate line), NOT a loose mark
|
||||
// plus a plain "16-bit / 44.1 kHz" string. The 1:1
|
||||
// control already exists; this drew its own.
|
||||
QualityBadgeFull {
|
||||
id: qualityRow
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 7
|
||||
Image {
|
||||
visible: header.qualityTier === "hires"
|
||||
source: "../assets/hi-res.svg"
|
||||
width: 42
|
||||
height: 28
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
sourceSize: Qt.size(84, 56)
|
||||
fillMode: Image.PreserveAspectFit
|
||||
}
|
||||
Text {
|
||||
visible: (header.qualityDetail || "") !== ""
|
||||
text: header.qualityDetail || ""
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
tier: header.qualityTier || ""
|
||||
detail: header.qualityDetail || ""
|
||||
}
|
||||
// Clamped, and the badge slot only counts when the
|
||||
// badge is actually there (QualityBadgeFull hides
|
||||
// itself on an empty tier — an unclamped negative
|
||||
// width is a silent layout trap).
|
||||
Item {
|
||||
width: Math.max(0, parent.width
|
||||
- (qualityRow.visible ? qualityRow.width + 16 : 0)
|
||||
- 168 - 30 - 2 * 16)
|
||||
height: 1
|
||||
}
|
||||
Item { width: parent.width - qualityRow.width - 168 - 30 - 3 * 16; height: 1 }
|
||||
Rectangle {
|
||||
width: 168
|
||||
height: 34
|
||||
@@ -729,12 +795,16 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Multi-select toggle — no seam on this bridge, so it
|
||||
// is DIMMED and inert-by-declaration instead of
|
||||
// rendering as a live button that swallows the click.
|
||||
Rectangle {
|
||||
width: 30
|
||||
height: 30
|
||||
radius: 6
|
||||
opacity: 0.4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: selectArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
QbzIcon {
|
||||
@@ -742,14 +812,7 @@ Rectangle {
|
||||
width: 15
|
||||
height: 15
|
||||
anchors.centerIn: parent
|
||||
tintName: selectArea.containsMouse ? "primary" : "secondary"
|
||||
}
|
||||
MouseArea {
|
||||
id: selectArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
// POC-NOTE: multi-select out of scope.
|
||||
tintName: "secondary"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user