Two defects with one cause: a tint name that means a frozen hex. "primary" is the legacy alias of literal #ffffff, not Theme.text-primary, so every control passing it drew a white glyph. On the eleven light themes that is white-on-light -- the header back/forward chevrons, the sidebar toggle and the carousel arrows the owner marked. They now pass the theme-following names, which icon_tint_qt bakes at runtime. The stale `hslLightness > 0.5 ? "primary" : "black"` workarounds that predate the runtime tinter collapse into those names, and the qrc floor underneath them becomes polarity-aware: dir_for() returns "" whenever the cache dir is unavailable, a bake fails or the theme document has not been published, and that path used to resolve textPrimary straight back to the white bake -- reproducing the bug it was fixing, app-wide, in the degraded state. On accent fills the fix is a deviation from Slint, approved by the owner after measurement. A fixed white glyph on Theme.accent falls under 2.6:1 on 16 of the 35 shipped palettes, including high-contrast at 1.70:1 and wcag-dark at 1.82:1 -- the two themes whose reason to exist is contrast. Slint hardcodes #ffffff there (CircleAction.slint:72 and three others), so the bug is upstream. accentText alone is not the answer either: it is picked for the theme's text ramp and gives 2.56:1 on Rose Pine Dawn, worse than the black it replaces. So QbzTheme picks the best-contrast tint by real sRGB luminance against the live accent, and the labels beside those glyphs use its colour twin -- BrowseGenreButton and LibraryView were drawing a white glyph next to an accentText label inside the same pill. Rounds out the two parity items the same files owe: QbzNavButton gains the ambient arm from Carousel.slint (wired at the six carousel sites, not left dead), and QbzCircleAction rounds its glyph to whole pixels the way CircleAction.slint does. cc
435 lines
18 KiB
QML
435 lines
18 KiB
QML
// Local album detail — QML port of album/LocalAlbumView.slint, a ROUTED
|
|
// PAGE (not a pane inside the Local Library view, which is how this port
|
|
// used to render it).
|
|
//
|
|
// Homologated 1:1 with the Qobuz AlbumView: whole-page Flickable, the same
|
|
// header proportions and action row, the toolbar (quality badge + track
|
|
// search), the column header, and source-aware track rows. The intentional
|
|
// differences vs the Qobuz page are the Slint's own: no label/awards
|
|
// sidebar, no Qobuz context menus, and the local-only version picker.
|
|
//
|
|
// Local actions ONLY: play all / shuffle / edit tags / add to playlist /
|
|
// add to Mixtape. A multi-artist album gets the "+N more artists" expander;
|
|
// a multi-disc album gets the disc dividers with their per-disc ⋯ menu.
|
|
|
|
import QtQuick
|
|
import QtQuick.Window
|
|
import com.blitzfc.qbz
|
|
import "../controls"
|
|
import "../theme"
|
|
import "local"
|
|
|
|
Rectangle {
|
|
id: root
|
|
|
|
color: ambientOn ? "transparent" : theme.surfaceMain
|
|
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
|
|
radius: 12
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
// ---------------------------- document -------------------------------
|
|
function parseDoc(json, fallback) {
|
|
if (json === "") return fallback
|
|
try { return JSON.parse(json) } catch (e) { return fallback }
|
|
}
|
|
readonly property var doc: parseDoc(QbzLocal.localAlbumJson, null)
|
|
readonly property var album: doc ? doc.album : null
|
|
readonly property var tracks: doc ? doc.tracks : []
|
|
readonly property var versions: album && album.versions ? album.versions : []
|
|
readonly property var allArtists: {
|
|
if (!album) return []
|
|
var raw = (album.allArtists || "").split(",")
|
|
var out = []
|
|
for (var i = 0; i < raw.length; i++) {
|
|
var n = raw[i].trim()
|
|
if (n !== "" && out.indexOf(n) < 0) out.push(n)
|
|
}
|
|
return out
|
|
}
|
|
// The Slint's `info-line` — built in Rust there, derived here from the
|
|
// fields the album row already carries.
|
|
readonly property string infoLine: {
|
|
if (!album) return ""
|
|
var parts = []
|
|
if ((album.year || "") !== "") parts.push(album.year)
|
|
parts.push((album.trackCount || 0) + " "
|
|
+ QbzSession.tr("tracks", QbzSession.trRev))
|
|
if ((album.duration || "") !== "") parts.push(album.duration)
|
|
if ((album.format || "") !== "") parts.push(album.format.toUpperCase())
|
|
return parts.join(" • ")
|
|
}
|
|
|
|
property string trackQuery: ""
|
|
// Client-side track search: an album's track list is bounded, so the
|
|
// Slint's LocalAlbumActions.search is a pure view filter here.
|
|
readonly property var visibleTracks: {
|
|
if (trackQuery === "") return tracks
|
|
var q = trackQuery.toLowerCase()
|
|
var out = []
|
|
for (var i = 0; i < tracks.length; i++) {
|
|
if ((tracks[i].title || "").toLowerCase().indexOf(q) >= 0) out.push(tracks[i])
|
|
}
|
|
return out
|
|
}
|
|
// Disc divider before the first row of each disc on a multi-disc album
|
|
// (0 = flat list, as the Slint's disc-header-number).
|
|
function discHeader(i) {
|
|
var t = visibleTracks[i]
|
|
if (!t) return 0
|
|
var multi = false
|
|
for (var j = 0; j < visibleTracks.length; j++) {
|
|
if ((visibleTracks[j].disc || 1) > 1) { multi = true; break }
|
|
}
|
|
if (!multi) return 0
|
|
if (i === 0) return t.disc || 1
|
|
return (visibleTracks[i - 1].disc || 1) !== (t.disc || 1) ? (t.disc || 1) : 0
|
|
}
|
|
|
|
// Cover — the same id-keyed artwork channel every local surface uses.
|
|
property var artMap: ({})
|
|
Connections {
|
|
target: QbzLocal
|
|
function onLocalArtworkReady(key, path) {
|
|
var m = root.artMap
|
|
m[key] = path
|
|
root.artMap = Object.assign({}, m)
|
|
}
|
|
}
|
|
onAlbumChanged: {
|
|
if (album && album.artKey) {
|
|
QbzLocal.artworkWindow(JSON.stringify([album.artKey]))
|
|
}
|
|
}
|
|
|
|
// ------------------------- skeleton pulse ----------------------------
|
|
// ONE 900ms Timer for the whole page. GATING RULE: freeze on NOT VISIBLE
|
|
// (view hidden / window minimized), NEVER on lost focus — a tiling
|
|
// desktop keeps windows visible and unfocused.
|
|
property bool skelPhase: false
|
|
readonly property bool windowShowing: root.Window.window
|
|
? (root.Window.window.visibility !== Window.Minimized
|
|
&& root.Window.window.visibility !== Window.Hidden)
|
|
: true
|
|
readonly property bool pageLoading: QbzLocal.localAlbumLoading
|
|
&& root.tracks.length === 0
|
|
// Local artwork resolution DROPS keys with no cover (local_artwork.rs),
|
|
// so the cover placeholder MUST be bounded or an album with no embedded
|
|
// art shimmers forever. Same constant as LocalLibraryView.artSettleMs.
|
|
readonly property int artSettleMs: 2500
|
|
readonly property bool coverPending: root.album
|
|
? ((root.album.artKey || "") !== ""
|
|
&& (root.artMap[root.album.artKey] || "") === "")
|
|
: false
|
|
Timer {
|
|
interval: 900
|
|
repeat: true
|
|
running: (root.pageLoading || root.coverPending)
|
|
&& root.visible && root.windowShowing
|
|
onTriggered: root.skelPhase = !root.skelPhase
|
|
}
|
|
|
|
// ============================ page ===================================
|
|
// Neutral header band (local albums have no artwork-derived tint yet).
|
|
Rectangle {
|
|
x: 0
|
|
y: 0
|
|
width: parent.width
|
|
height: 340
|
|
gradient: Gradient {
|
|
GradientStop { position: 0.0; color: "#181820" }
|
|
GradientStop { position: 0.16; color: "#181820" }
|
|
GradientStop { position: 1.0; color: "#00181820" }
|
|
}
|
|
}
|
|
|
|
Flickable {
|
|
id: flick
|
|
anchors.fill: parent
|
|
clip: true
|
|
contentWidth: width
|
|
contentHeight: page.height + 100
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
|
|
Column {
|
|
id: page
|
|
x: 32
|
|
y: 11
|
|
width: parent.width - 64
|
|
spacing: 0
|
|
|
|
// ---- History navigation ----
|
|
Row {
|
|
spacing: 6
|
|
QbzNavButton {
|
|
name: "chevron-left"
|
|
btnEnabled: QbzShell.canBack
|
|
onClicked: QbzShell.navigateBack()
|
|
}
|
|
QbzNavButton {
|
|
name: "chevron-right"
|
|
btnEnabled: QbzShell.canForward
|
|
onClicked: QbzShell.navigateForward()
|
|
}
|
|
}
|
|
Item { width: 1; height: 22 }
|
|
|
|
// ---- Album header ----
|
|
// Placeholder in the header's own proportions (224px cover, 32px
|
|
// gap — LocalAlbumHeader.qml:32/46) until the document lands.
|
|
QbzSkeleton {
|
|
visible: root.pageLoading && !root.album
|
|
variant: "header"
|
|
width: parent.width
|
|
height: 224
|
|
coverSize: 224
|
|
coverGap: 32
|
|
actionCount: 5
|
|
phase: root.skelPhase
|
|
}
|
|
LocalAlbumHeader {
|
|
visible: !(root.pageLoading && !root.album)
|
|
width: parent.width
|
|
album: root.album
|
|
allArtists: root.allArtists
|
|
infoLine: root.infoLine
|
|
versions: root.versions
|
|
coverSource: root.album ? (root.artMap[root.album.artKey] || "") : ""
|
|
coverPending: root.coverPending
|
|
skelPhase: root.skelPhase
|
|
artSettleMs: root.artSettleMs
|
|
onOpenArtist: function (name) { root.openArtist(name) }
|
|
}
|
|
|
|
Item { width: 1; height: 20 }
|
|
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
|
|
Item { width: 1; height: 8 }
|
|
|
|
// ---- Loading ----
|
|
// The track list in the shape it will arrive in (50px rows, no
|
|
// artwork column on this page), replacing the 36px spinner the
|
|
// Slint mounts here. ONE instance, ONE animator.
|
|
QbzSkeleton {
|
|
visible: root.pageLoading
|
|
variant: "rowList"
|
|
width: parent.width
|
|
height: visible ? 280 : 0
|
|
rowH: 50
|
|
rowGap: 0
|
|
rowArt: false
|
|
phase: root.skelPhase
|
|
}
|
|
|
|
// ---- Toolbar: quality badge + track search ----
|
|
Item {
|
|
width: parent.width
|
|
height: 52
|
|
QualityBadgeFull {
|
|
anchors.left: parent.left
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
tier: root.album ? (root.album.qualityTier || "") : ""
|
|
detail: root.album ? (root.album.qualityDetail || "") : ""
|
|
}
|
|
Rectangle {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: 280
|
|
height: 34
|
|
radius: 6
|
|
border.width: 1
|
|
border.color: theme.borderSubtle
|
|
color: theme.surfaceElevated
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 10
|
|
anchors.rightMargin: 10
|
|
spacing: 7
|
|
QbzIcon {
|
|
name: "search"
|
|
width: 14
|
|
height: 14
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
tintName: "muted"
|
|
}
|
|
Item {
|
|
width: parent.width - 21
|
|
height: parent.height
|
|
clip: true
|
|
TextInput {
|
|
id: searchInput
|
|
anchors.fill: parent
|
|
color: theme.textPrimary
|
|
font.pixelSize: 13
|
|
verticalAlignment: Text.AlignVCenter
|
|
selectByMouse: true
|
|
onTextEdited: root.trackQuery = text
|
|
}
|
|
Text {
|
|
visible: searchInput.text === ""
|
|
anchors.fill: parent
|
|
text: QbzSession.tr("Search tracks...", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: 13
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Column header ----
|
|
Row {
|
|
width: parent.width
|
|
height: 40
|
|
leftPadding: 12
|
|
rightPadding: 12
|
|
spacing: 16
|
|
Text {
|
|
width: 32
|
|
height: parent.height
|
|
text: "#"
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLegal
|
|
font.letterSpacing: 0.5
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
Text {
|
|
width: parent.width - 24 - 32 - 80 - 80 - 28 - 32 - 5 * 16
|
|
height: parent.height
|
|
text: QbzSession.tr("Title", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLegal
|
|
font.letterSpacing: 0.5
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
Text {
|
|
width: 80
|
|
height: parent.height
|
|
text: QbzSession.tr("Duration", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLegal
|
|
font.letterSpacing: 0.5
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
Text {
|
|
width: 80
|
|
height: parent.height
|
|
text: QbzSession.tr("Quality", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLegal
|
|
font.letterSpacing: 0.5
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
// Source column + the per-row more-button slot.
|
|
Item { width: 28; height: parent.height }
|
|
Item { width: 32; height: parent.height }
|
|
}
|
|
|
|
// ---- Track list ----
|
|
// An album's track count is bounded (the windowed views are for
|
|
// the library-scale surfaces), so this mounts like the Slint.
|
|
Column {
|
|
width: parent.width
|
|
spacing: 0
|
|
Repeater {
|
|
model: root.visibleTracks
|
|
delegate: Column {
|
|
id: trackBlock
|
|
required property var modelData
|
|
required property int index
|
|
width: page.width
|
|
spacing: 0
|
|
|
|
// Disc divider + its per-disc ⋯ menu.
|
|
Item {
|
|
visible: root.discHeader(trackBlock.index) > 0
|
|
width: parent.width
|
|
height: visible ? 40 : 0
|
|
Text {
|
|
x: 12
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: QbzSession.tr("Disc", QbzSession.trRev) + " "
|
|
+ root.discHeader(trackBlock.index)
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLegal
|
|
font.weight: theme.weightSemibold
|
|
font.letterSpacing: 0.5
|
|
}
|
|
Rectangle {
|
|
x: parent.width - 44
|
|
width: 32
|
|
height: 32
|
|
radius: theme.radiusSm
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: discArea.containsMouse ? theme.surfaceElevated : "transparent"
|
|
QbzIcon {
|
|
name: "ellipsis"
|
|
width: 16
|
|
height: 16
|
|
anchors.centerIn: parent
|
|
tintName: discArea.containsMouse ? "textPrimary" : "muted"
|
|
}
|
|
MouseArea {
|
|
id: discArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: function (mouse) {
|
|
discMenu.openAtCursor(discArea, mouse.x, mouse.y)
|
|
}
|
|
}
|
|
CardMenu {
|
|
id: discMenu
|
|
menuWidth: 200
|
|
entries: [
|
|
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
|
|
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
|
|
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
|
|
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
|
|
]
|
|
onPicked: function (a) {
|
|
QbzLocal.albumDiscAction(root.discHeader(trackBlock.index), a)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
LocalTrackRow {
|
|
width: page.width
|
|
item: trackBlock.modelData
|
|
number: trackBlock.modelData.number > 0
|
|
? trackBlock.modelData.number : trackBlock.index + 1
|
|
showAlbum: false
|
|
showArtwork: false
|
|
onPlayRequested: QbzLocal.playAlbumTrack(
|
|
root.album.id, trackBlock.modelData.id)
|
|
onEnqueueRequested: function (m) {
|
|
QbzLocal.enqueue("track", trackBlock.modelData.id, m)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
QbzScrollBar {
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 4
|
|
anchors.top: parent.top
|
|
anchors.bottom: parent.bottom
|
|
target: flick
|
|
visible: flick.contentHeight > flick.height
|
|
}
|
|
|
|
// Local/Plex artists have no catalog id, so "go to artist" is a NAME
|
|
// route into the Local Library Artists tab (the Slint's source-aware
|
|
// open-artist).
|
|
function openArtist(name) {
|
|
QbzLocal.openArtistByName(name)
|
|
QbzShell.navigateTo("local")
|
|
}
|
|
}
|