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

+2
View File
@@ -105,6 +105,8 @@ fn main() {
"qml/views/PlaylistView.qml",
"qml/views/SearchView.qml",
"qml/views/SectionRail.qml",
"qml/controls/HeaderGradient.qml",
"qml/controls/QbzJumpNavBar.qml",
"qml/shell/LyricsControlsFlyout.qml",
"qml/shell/LyricsLineRow.qml",
"qml/shell/LyricsLinesView.qml",
@@ -0,0 +1,74 @@
// HeaderGradient — the artwork-tinted band behind the album / artist page
// headers. ONE component, mounted by BOTH AlbumView and ArtistView (the
// Slint paints them from identical blocks: AlbumPageView.slint:235-257 and
// ArtistPageView.slint:225-247 are the same four lines twice).
//
// The Slint has TWO routes for this band:
// route A header-atmosphere.width > 0 -> ImmersiveAtmosphere (a blurred,
// warped copy of the cover). GPU only.
// route B header-atmosphere.width <= 0 -> a flat linear gradient built
// from `header-color` (crates/qbz/src/artwork.rs header_tint).
// Route A needs a shader; shader effects render NOTHING on this port's
// software path (the same finding that killed ColorOverlay in QbzIcon), so
// this component is route B — which is not an approximation, it is the exact
// fallback arm the Slint itself paints whenever the atmosphere image is
// missing. `tint` is the header_tint hex the Rust side now publishes in the
// page document (album_qt.rs / artist_qt.rs `headerColor`).
//
// Two stacked bands, read off the .slint:
// band 1 linear-gradient(180deg, tint 0%, tint 16%, transparent 100%)
// band 2 linear-gradient(180deg, transparent 0%, transparent 82%,
// surface-main 100%)
// Band 2 lands the fade EXACTLY on the header/content boundary whatever
// height the header resolved to, which is why the host binds `height` to the
// divider's y rather than to a constant.
//
// Mount it as the FIRST child of the page Flickable (so it scrolls with the
// content, as the Slint's does) at x:0 with the viewport width — the band is
// deliberately full-bleed: the page's own 32px padding must not clip it, or
// a dark gutter strip appears on the right (the .slint comment at
// AlbumPageView.slint:199-202 documents that exact regression).
import QtQuick
import "../theme"
Item {
id: root
QbzTheme { id: theme }
/// Artwork-derived tint, "#rrggbb" (header_tint). Empty = nothing paints.
property string tint: ""
/// The appearance pref AND the "no app-wide dynamic background" gate:
/// `AppearanceState.album-header-gradient && !ShellState.app-background-active`
/// (AlbumPageView.slint:168). The dynamic background and this atmosphere
/// clash, so only one of them is ever on.
property bool active: false
// `visible` gates the paint, but bindings still evaluate underneath it —
// so the stops read this, never the raw string: coercing "" to a colour
// logs a warning on every re-evaluation.
readonly property color tintColor: root.tint === "" ? "transparent" : root.tint
visible: root.active && root.tint !== ""
// Never eat clicks meant for the header controls painted on top.
enabled: false
Rectangle {
anchors.fill: parent
gradient: Gradient {
GradientStop { position: 0.0; color: root.tintColor }
GradientStop { position: 0.16; color: root.tintColor }
GradientStop { position: 1.0; color: "transparent" }
}
}
Rectangle {
anchors.fill: parent
gradient: Gradient {
GradientStop { position: 0.0; color: "transparent" }
GradientStop { position: 0.82; color: "transparent" }
GradientStop { position: 1.0; color: theme.surfaceMain }
}
}
}
+43 -17
View File
@@ -1,39 +1,65 @@
// QbzCircleAction — the on-surface circular header action (Slint
// primitives/CircleAction.slint on-surface arm), consolidated in phase 22
// from THREE copies (AlbumView.CircleBtn ≡ ArtistView.CircleBtn,
// PlaylistView.CircleBtn = superset with the `primary` + `btnEnabled`
// arms). 32px elevated disc + 1.5px muted ring (active = accent glyph);
// `primary` = 40px accent disc (black glyph).
// NOTE: the OVERLAY-palette circle buttons (on artwork) are the separate
// CardOverlayButton.qml — different palette, deliberately not merged.
// QbzCircleAction — the circular header action, BOTH arms of Slint
// primitives/CircleAction.slint. Consolidated in phase 22 from THREE copies
// (AlbumView.CircleBtn ≡ ArtistView.CircleBtn, PlaylistView.CircleBtn =
// superset with the `primary` + `btnEnabled` arms).
//
// Numbers, read off primitives/CircleAction.slint:
// :24 diameter — primary 44, secondary 32 (the hierarchy lives HERE, not
// in the callers; the port had 40 and was uniformly small)
// :66 glyph — primary 19, secondary 15
// :48 ON-SURFACE arm (`overlay: false`, the default): primary = accent
// disc, secondary = surface-elevated disc, hover/active surface-hover
// :60 ring — border-strong (the port used border-muted)
// :70 glyph tint — active accent; else primary WHITE, secondary
// text-primary. The port painted the on-surface primary glyph BLACK,
// which is the OTHER arm's colour.
// :56 OVERLAY arm (`overlay: true`) — for buttons sitting on the dark
// artwork atmosphere (the album / artist header once the header
// gradient is on): primary = solid white disc with a BLACK glyph,
// secondary = #ffffff24 fill (#ffffff3d hover/active), 1.5px
// #ffffffcc ring, white glyph.
// NOTE: the card hover-overlay circles are still the separate
// CardOverlayButton.qml — same palette, different sizing contract.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
property string name: ""
property bool active: false
property bool primary: false
property bool btnEnabled: true
/// Light-on-dark palette for a button painted over artwork/atmosphere
/// (CircleAction.slint's default arm; `on-surface: false` there).
property bool overlay: false
signal clicked(var mouse)
QbzTheme { id: theme }
width: primary ? 40 : 32
height: primary ? 40 : 32
width: primary ? 44 : 32
height: primary ? 44 : 32
radius: width / 2
color: primary ? (cbArea.containsMouse && btnEnabled ? theme.accentHover : theme.accent)
: (cbArea.containsMouse || active) ? theme.surfaceHover : theme.surfaceElevated
color: overlay
? (primary
? (cbArea.containsMouse && btnEnabled ? "#d6ffffff" : "#ffffff")
: ((cbArea.containsMouse || active) ? "#3dffffff" : "#24ffffff"))
: (primary
? (cbArea.containsMouse && btnEnabled ? theme.accentHover : theme.accent)
: ((cbArea.containsMouse || active) ? theme.surfaceHover : theme.surfaceElevated))
border.width: primary ? 0 : 1.5
border.color: theme.borderMuted
border.color: overlay ? "#ccffffff" : theme.borderStrong
opacity: btnEnabled ? 1.0 : 0.4
QbzIcon {
name: parent.name
width: primary ? 20 : 15
height: primary ? 20 : 15
name: root.name
width: root.primary ? 19 : 15
height: root.primary ? 19 : 15
anchors.centerIn: parent
tintName: parent.primary ? "black" : (parent.active ? "accent" : "primary")
// active wins in both arms; then: overlay primary = black glyph on
// the white disc, everything else = the light/primary glyph.
tintName: root.active ? "accent"
: (root.overlay && root.primary ? "black" : "primary")
}
MouseArea {
id: cbArea
@@ -0,0 +1,161 @@
// QbzJumpNavBar — 1:1 port of primitives/JumpNavBar.slint.
//
// The section-jump strip that sits between a page header and its body:
// [JUMP TO] tab tab tab … [magnifier]
// The Slint mounts it on the artist page and (without the search arm) on the
// label page, which is why it lives in controls/ rather than inside a view.
//
// Numbers, read off primitives/JumpNavBar.slint:
// :59 height 44 :60 background = `bar-bg` (host passes the page fill)
// :65 1px border-subtle hairline pinned to the BOTTOM edge
// :74 padding-left/right 32 :76 spacing 18 between label / tabs / search
// :84 "JUMP TO" — legal (13px), semibold, text-muted, letter-spacing 0.5
// :106 tab spacing 14; :111 32px tall cell, width = text + 4, text x = 2
// :116 active -> text-primary + semibold, hover -> text-primary,
// else text-secondary + regular; :128 2px accent underline when active
// :157 open search field 216px wide, 200ms ease-in-out, surface-card
// :260 closed toggle 32px, radius 6, hover surface-elevated, 16px glyph
//
// The search affordance is the SHARED controls/QbzLineEdit expandable arm
// (ExpandableSearch.slint), not a second inline input: same interaction —
// closed magnifier slot, opens right-anchored growing LEFT over the tabs,
// X clears and closes, Esc closes. Its closed slot is 34px against the
// .slint's 32px; that 2px is the price of not owning a fourth search box.
//
// Presentation only: sticky positioning and what a tab scrolls to are the
// host's business (the .slint says the same at :12).
import QtQuick
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
QbzTheme { id: theme }
/// [{ id, label }] — the sections present on the page.
property var tabs: []
property string activeTabId: ""
/// Page fill, so the bar reads as part of the page (and can go
/// translucent under the dynamic background, like the .slint's bar-bg).
property color barBg: theme.surfaceMain
/// The label page mounts the bar WITHOUT the search affordance.
property bool showSearch: true
/// Horizontal inset. 32 = the .slint's own padding for a full-bleed
/// mount; a host that already pads its column passes 0.
property real padH: 32
signal tabClicked(string id)
signal searchEdited(string text)
/// Host hook: clear the filter when the field closes itself (Esc / X).
property alias searchOpen: searchBox.open
height: 44
color: root.barBg
Rectangle {
x: 0
y: parent.height - 1
width: parent.width
height: 1
color: theme.borderSubtle
}
Text {
id: jumpLabel
x: root.padH
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("JUMP TO", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
// Tab strip. Clipped so a long discography's tabs never spill under the
// search slot (the .slint's `center-region { clip: true }`).
Item {
id: centerRegion
x: jumpLabel.x + jumpLabel.width + 18
y: 0
width: Math.max(0, root.width - root.padH
- (searchSlot.visible ? searchSlot.width + 18 : 0)
- x)
height: parent.height
clip: true
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 14
Repeater {
model: root.tabs
delegate: Item {
id: tabCell
required property var modelData
readonly property bool active: modelData.id === root.activeTabId
width: tabText.implicitWidth + 4
height: 32
Text {
id: tabText
x: 2
height: parent.height
verticalAlignment: Text.AlignVCenter
text: tabCell.modelData.label
color: (tabCell.active || tabArea.containsMouse)
? theme.textPrimary : theme.textSecondary
font.pixelSize: theme.fontBody
font.weight: tabCell.active ? theme.weightSemibold
: theme.weightRegular
}
Rectangle {
visible: tabCell.active
x: 0
y: parent.height - 2
width: parent.width
height: 2
color: theme.accent
}
MouseArea {
id: tabArea
anchors.fill: parent
// The .slint disables the tabs while the field is
// open (they are behind it).
enabled: !searchBox.open
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.tabClicked(tabCell.modelData.id)
}
}
}
}
}
// Search slot — keeps the CLOSED footprint; the field itself grows left
// over the tabs, so nothing in the bar reflows when it opens.
Item {
id: searchSlot
visible: root.showSearch
width: searchBox.width
height: searchBox.height
anchors.right: parent.right
anchors.rightMargin: root.padH
anchors.verticalCenter: parent.verticalCenter
QbzLineEdit {
id: searchBox
searchMode: true
expandable: true
// surface-card overlay fill (JumpNavBar.slint:164), not the
// toolbar's elevated.
elevated: false
openWidth: Math.min(216, Math.max(120, centerRegion.width))
placeholder: QbzSession.tr("Search this page…", QbzSession.trRev)
onEdited: function (v) { root.searchEdited(v) }
}
}
}
+78 -5
View File
@@ -41,7 +41,32 @@
// skeleton binds `phase:` to it — N placeholders, 1 timer. `selfDrive: true`
// mounts a private timer for one-off mounts.
//
// ── CLEANUP (settleMs) ─────────────────────────────────────────────────────
// ── COVER HANDOVER (`pending` / `coverReady` / `coverSource`) ──────────────
// THE BUG THIS EXISTS FOR: every surface used to gate its cover placeholder
// on `artPath !== ""`. That predicate goes false the moment the PATH lands —
// but the art is not on screen then. theme/RoundedImage.qml is a Canvas: it
// still has to load the file through its probe and rasterize it. So the
// placeholder vanished and the cell sat EMPTY until the canvas painted.
//
// The honest gate is "has the image handed over", and there are two arms:
// - `coverReady` — bind it to `RoundedImage.ready` when the host can see
// the image item (the local rows, the detail headers). EXACT: the
// placeholder is still fully opaque on the frame the art appears.
// - `coverSource` — the art path, for hosts whose image is sealed inside a
// component they do not own (AlbumCard, PlaylistCard, SlimCard, …). This
// instance then loads the SAME path through its own hidden probe; because
// QQuickPixmapCache is shared and process-wide, that is the SAME cache
// entry the card is loading, so it costs no second decode and no second
// copy in memory — it only tells us WHEN the decode finished. The card's
// canvas paints on the next frame, which the 180ms fade below covers.
// `pending` is the third input: "this cell wants a cover at all". A row with
// no artwork slot passes false and gets no placeholder.
//
// Call sites that still set `visible:` by hand keep their own (path-based)
// behaviour — the new default binding only applies where `visible` is left
// alone. That is deliberate: the gate is opted into file by file.
//
// ── CLEANUP (settleMs / settleHold) ────────────────────────────────────────
// A placeholder gated only on "the payload has not landed" can shimmer
// FOREVER when the payload never lands. That is not hypothetical: local
// artwork resolution (src/local_artwork.rs, `resolve_window_blocking`)
@@ -53,6 +78,11 @@
// stops animating, revealing whatever the real control draws when it has no
// art (AlbumCard's own empty tile, ArtistCard's designed round portrait).
// Per-item clearing still wins whenever the item DOES arrive.
// `settleHold` suspends that countdown while the host knows a resolution pass
// is still in flight, so a cover that takes longer than settleMs to decode
// (a COLD local library takes seconds — see LocalLibraryView.artSettleMs)
// does not settle to an empty tile a beat before it lands. The bound still
// holds: the host drops the hold when its pass goes quiet.
import QtQuick
import QtQuick.Window
@@ -131,28 +161,66 @@ Item {
&& root.Window.window.visibility !== Window.Hidden)
: true
// ---- cover handover (see the block comment) ---------------------------
/// "This cell wants a cover." false retires the placeholder at once.
property bool pending: true
/// Host-driven readiness — bind to `RoundedImage.ready`.
property bool coverReady: false
/// Self-probing readiness — the art path, when the image is sealed inside
/// a component the host does not own. "" disarms the probe entirely.
property string coverSource: ""
Image {
id: coverProbe
source: root.coverSource
visible: false
asynchronous: true
cache: true
// NO sourceSize: it is part of the pixmap-cache key, so requesting a
// different decode size here would fork a SECOND decode of the same
// file instead of riding the one the card already asked for.
}
/// The art is on screen (or, on the probe arm, decoded and one frame from
/// it). This — not the path — is what retires the placeholder.
readonly property bool handedOver: root.coverReady
|| (root.coverSource !== "" && coverProbe.status === Image.Ready)
// ---- cleanup / settle -------------------------------------------------
// 0 = never settle (the Home/Library mounts, whose documents always
// republish). > 0 = fade out and stop after that many ms of shimmering.
property int settleMs: 0
/// While true the countdown is suspended: the host's resolution pass is
/// still in flight, so "nothing has landed yet" is not yet evidence that
/// nothing ever will.
property bool settleHold: false
property bool settled: false
Timer {
interval: Math.max(1, root.settleMs)
repeat: false
running: root.settleMs > 0 && !root.settled
running: root.settleMs > 0 && !root.settled && !root.settleHold
&& !root.handedOver
&& root.visible && root.windowShowing
onTriggered: root.settled = true
}
// A recycled delegate re-shows the placeholder for a DIFFERENT item, so
// the countdown restarts with it.
onVisibleChanged: if (root.visible) root.settled = false
// One 180ms fade, once, at settle — then the instance is inert.
opacity: root.settled ? 0.0 : 1.0
// A new pass arming the hold un-settles an instance that already gave up:
// its cover may be in THIS pass.
onSettleHoldChanged: if (root.settleHold) root.settled = false
// One 180ms fade, once — at settle, at handover, or when the cell stops
// wanting a cover. The fade is what removes the empty frame on handover:
// the canvas paints on the next frame (~16ms in), while this is still at
// ~91% opacity, so the placeholder dissolves INTO the art.
readonly property bool retired: root.settled || root.handedOver || !root.pending
opacity: root.retired ? 0.0 : 1.0
Behavior on opacity { NumberAnimation { duration: 180 } }
// Default only — a call site that assigns `visible` replaces this and
// keeps its own gate (see the block comment).
visible: root.opacity > 0.004
readonly property bool pulsing: root.animated
&& root.cellIndex < root.animatedCap
&& !root.settled
&& !root.retired
&& root.visible && root.windowShowing
property bool _localPhase: false
@@ -183,6 +251,11 @@ Item {
Loader {
anchors.fill: parent
// A retired instance frees its rectangles instead of keeping a dead
// scene subtree per cell — a settled 48-cell grid is 144 Rectangles.
// Keyed off `opacity`, not `visible`, so a call site that overrides
// `visible` cannot strand the shape alive mid-fade.
active: root.opacity > 0.004
sourceComponent: (root.variant === "card" || root.variant === "circleCard") ? cellShape
: root.variant === "row" ? rowShape
: root.variant === "circle" ? circleShape
+41 -20
View File
@@ -4,18 +4,22 @@
// SearchView.SearchTrackRow). 50px, radius 8: number→play cell (pause
// swap + accent pill when it is the now-playing row — Slint-universal),
// optional 36px art cell, title+explicit / artist column, optional 220px
// album link, duration, quality (icon QualityMini | bare text), optional
// album link, duration, quality (BARE QualityBadgeFull — the tier label over
// the exact bit-depth / sample-rate line), optional
// heart, optional download slot (offline column — glyph stub or reserved
// spacer until the offline cache lands, POC-NOTE), ⋯ CardMenu.
//
// item contract: { id, title, artist, artistId, album, albumId, number?,
// duration, qualityTier, explicit, isFavorite, artPath? } (plus
// playlistTrackId for the remove-from-playlist arm).
// duration, qualityTier, qualityDetail, explicit, isFavorite, artPath? }
// (plus playlistTrackId for the remove-from-playlist arm). `qualityDetail`
// is the bare exact-quality string ("16-bit / 44.1 kHz"); when a producer
// does not carry it yet the badge degrades to the tier label alone rather
// than to a blank cell.
//
// Arms: showArtwork / showAlbum / showFavorite / showDownload /
// downloadGlyph / showMenu / zebra / artistLink / clickPlays /
// qualityStyle ("icon"|"text") / menuShowLater / menuShowGoTo /
// menuShowFavorite / menuShowRemove.
// menuShowLater / menuShowGoTo / menuShowFavorite / menuShowRemove.
// (`qualityStyle` is RETIRED — kept declared, inert, see below.)
// Signals: playRequested() (per-site play: album-scoped, playlist-scoped
// or plain), enqueueRequested(mode) ("next"|"later"|"queue"),
// removeRequested(), bodyDragStarted(index) (fired BEFORE the shared
@@ -59,6 +63,12 @@ Rectangle {
property bool zebra: false
property bool artistLink: false
property bool clickPlays: true
/// RETIRED and INERT. primitives/TrackRow.slint has exactly ONE quality
/// form (the bare QualityBadgeFull below) — there was never an icon/text
/// split to arm. Kept declared ONLY so the two existing call sites that
/// still assign it (AlbumView.qml `"text"`, local/LocalTrackRow.qml
/// `"icon"`) keep loading; assigning a non-existent property is a hard
/// QML instantiation error. Both assignments are listed for removal.
property string qualityStyle: "icon"
property bool menuShowLater: true
property bool menuShowGoTo: true
@@ -269,24 +279,35 @@ Rectangle {
font.pixelSize: 12
horizontalAlignment: Text.AlignHCenter
}
// Quality (92px): icon (QualityMini) or bare text.
Rectangle {
// Quality (92px) — the BARE badge, 1:1 with primitives/TrackRow.slint
// 578-592: a 92px cell, `alignment: center` on both axes, holding a
// QualityBadgeFull with `show-icon: false` + `bare: true`. That is the
// tier label ("CD"/"HI-RES"/"MP3"/"LOSSLESS") stacked over the exact
// bit-depth / sample-rate line, with no chip background or border, so
// it blends into the row instead of reading as a contained badge.
// The .slint has ONE form here — no icon variant, no bare-text
// variant — which is why `qualityStyle` above is inert.
//
// The cell keeps its FIXED 92px. That number is a term of
// `cellsRight`, which is what sizes the title column, so a wider badge
// MUST NOT widen the cell or the title would be pushed into the
// trailing controls. The badge is centred and the cell clips; at 8/9px
// the longest real detail ("24-bit / 352.8 kHz") measures ~80px, so
// the clip is a guard, never the normal path.
//
// Cheaper than what it replaces, too: QualityMini resolves to
// QualityBadge, which carries a ToolTip popup and a hover MouseArea
// per row; this one is two Texts.
Item {
width: 92
height: parent.height
color: "transparent"
QualityMini {
visible: root.qualityStyle === "icon"
clip: true
QualityBadgeFull {
anchors.centerIn: parent
tier: root.item.qualityTier || ""
anchors.centerIn: parent
}
Text {
visible: root.qualityStyle === "text"
anchors.centerIn: parent
text: root.item.qualityTier === "hires" ? "HI-RES" : (root.item.qualityTier === "cd" ? "CD" : "")
color: theme.textMuted
font.pixelSize: 10
font.weight: theme.weightBold
horizontalAlignment: Text.AlignHCenter
detail: root.item.qualityDetail || ""
showIcon: false
bare: true
}
}
// Favorite (showFavorite arm).
+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; }
}
}
+41 -1
View File
@@ -13,6 +13,28 @@
// source: artPath; radius: theme.radiusSm }` inside the same rounded
// placeholder Rectangle (the placeholder's OWN fill rounds fine — only
// children needed the workaround).
//
// ── READINESS CONTRACT (`ready`) ───────────────────────────────────────────
// A host that draws a loading placeholder over this item MUST gate it on
// `ready`, never on "the path is non-empty".
//
// The path landing means NOTHING here. This is a Canvas: once `source` is
// assigned the probe still has to load the file through QQuickPixmap and then
// a paint pass has to rasterize it. A placeholder gated on the path therefore
// vanishes the instant the path arrives and leaves an EMPTY tile on screen
// until the canvas paints — the reported bug ("the skeleton disappears before
// the art is rendered").
//
// `ready` is true only when BOTH have happened for the CURRENT source:
// 1. the dimension probe reached Image.Ready (the pixmap is decoded), and
// 2. `onPaint` has run a pass that actually drew THAT source.
// It is derived from `_paintedSource`, so re-assigning `source` drops it back
// to false with no bookkeeping — a recycled list delegate cannot inherit the
// previous row's readiness.
//
// Hosts that cannot reach this item (the image lives inside AlbumCard,
// PlaylistCard, …) use the equivalent self-probing arm of the placeholder
// itself: `QbzSkeleton { coverSource: <path> }` — see QbzSkeleton.qml.
import QtQuick
@@ -24,6 +46,15 @@ Canvas {
// (PreserveAspectFit — label logos, never cropped, LabelCard.slint).
property string fit: "crop"
/// THE handover signal — see the READINESS CONTRACT above. True only once
/// the current `source` is on screen, not merely known.
readonly property bool ready: root._paintedSource !== ""
&& root._paintedSource === root.source
/// The source of the last paint that actually drew pixels. Internal; it
/// exists as a property (not a bool flag) so that `ready` invalidates
/// itself on every source change.
property string _paintedSource: ""
renderTarget: Canvas.Image
// GUI-thread raster. Cooperative/Threaded rasterize on the SCENE GRAPH
// RENDER THREAD, and this item is created and destroyed by list recycling
@@ -45,7 +76,11 @@ Canvas {
onStatusChanged: if (status === Image.Ready || status === Image.Error) root.requestPaint()
}
onSourceChanged: loadImage(source)
// requestPaint() as well as loadImage(): a delegate recycled onto a row
// with NO cover assigns source = "", which emits no imageLoaded, so
// without an explicit repaint the canvas would keep showing the previous
// row's art under a placeholder that thinks it has nothing to cover.
onSourceChanged: { loadImage(source); requestPaint() }
Component.onCompleted: loadImage(source)
onImageLoaded: requestPaint()
onWidthChanged: requestPaint()
@@ -81,6 +116,11 @@ Canvas {
var dw = iw * scale
var dh = ih * scale
ctx.drawImage(source, (w - dw) / 2, (h - dh) / 2, dw, dh)
// ONLY here: pixels for `source` are now on the canvas. Anything
// earlier (the path arriving, the probe going Ready) would hand
// the cell over to an empty tile. Assigning the same string twice
// is a no-op in QML, so repeated paints cost no notifications.
root._paintedSource = source
}
}
}
+131 -68
View File
@@ -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"
}
}
}
+293 -196
View File
@@ -19,9 +19,16 @@
// POC-NOTEs: blacklist banner, artist Scene, Share, Create Collection, radio
// engines (dropdown inert), multi-select, the sticky behavior of the JUMP TO
// bar (it scrolls with the page).
//
// Header atmosphere (ArtistPageView.slint:120-147, 211-247): wired through
// the shared controls/HeaderGradient.qml — the SAME component AlbumView
// mounts, because the .slint paints both headers from identical blocks. It
// carries the .slint's header-colour rules with it (light text + overlay
// CircleActions while the band is on).
import QtQuick
import QtQuick.Controls
import QtQuick.Window
import com.blitzfc.qbz
import "../cards"
import "../controls"
@@ -42,9 +49,54 @@ Rectangle {
QbzTheme { id: theme }
readonly property var artist: JSON.parse(QbzArtist.artistJson)
readonly property var topTracks: artist.topTracks || []
readonly property var appearsOn: artist.appearsOn || []
readonly property var releaseSections: artist.releaseSections || []
// ---- In-page search (JumpNavBar's magnifier) -------------------------
// artist.rs `filter_artist` (main.rs:14991 wires ArtistActions.search to
// it) is a PURE client-side filter over Popular Tracks, Appears On and
// every release-section album — no backend round-trip — so it ports as a
// filter over the parsed document rather than a bridge call.
property string searchQuery: ""
readonly property string needle: searchQuery.trim().toLowerCase()
function matchTrack(t) {
return needle === ""
|| (t.title || "").toLowerCase().indexOf(needle) >= 0
|| (t.artist || "").toLowerCase().indexOf(needle) >= 0
}
readonly property var topTracks: {
var all = artist.topTracks || []
if (root.needle === "") return all
var out = []
for (var i = 0; i < all.length; i++) if (matchTrack(all[i])) out.push(all[i])
return out
}
readonly property var appearsOn: {
var all = artist.appearsOn || []
if (root.needle === "") return all
var out = []
for (var i = 0; i < all.length; i++) if (matchTrack(all[i])) out.push(all[i])
return out
}
// A section whose albums all filter out DISAPPEARS (artist.rs:1033), and
// Load more is suppressed while a filter is active (:1042 — appending
// would bring back unfiltered items).
readonly property var releaseSections: {
var all = artist.releaseSections || []
if (root.needle === "") return all
var out = []
for (var i = 0; i < all.length; i++) {
var kept = []
var cards = all[i].cards || []
for (var j = 0; j < cards.length; j++)
if ((cards[j].title || "").toLowerCase().indexOf(root.needle) >= 0)
kept.push(cards[j])
if (kept.length === 0) continue
out.push({ "releaseType": all[i].releaseType, "title": all[i].title,
"cards": kept, "hasMore": false })
}
return out
}
readonly property var labels: artist.labels || []
readonly property var similarArtists: artist.similarArtists || []
readonly property var playlists: artist.playlists || []
@@ -59,10 +111,48 @@ Rectangle {
property var coverMap: ({})
property string activeJumpTab: "popular-tracks"
property string artistTab: "catalog"
// ---- Header atmosphere (ArtistPageView.slint:120-147) ----------------
// Same three-line rule as AlbumView (the .slint says "same rule as
// AlbumPageView" at :120). The pref is read LIVE off the settings
// snapshot where one exists, else off the document (artist_qt.rs).
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 artist.headerGradient !== false
}
readonly property bool headerAtmoOn: headerGradientPref && !ambientOn
readonly property bool headerLight: headerGradientPref || ambientOn
readonly property color hdrStrong: headerLight ? "#ffffff" : theme.textPrimary
readonly property color hdrBody: headerLight ? "#e0ffffff" : theme.textSecondary
readonly property bool hdrOverlay: headerLight
property bool topTracksExpanded: false
property bool appearsOnExpanded: false
property bool otherExpanded: false
property bool networkOpen: false
// ---- Network sidebar open/closed -------------------------------------
// ShellState.content-constrained (state.slint:4114): window under the NPB
// breakpoint AND a right panel (Queue / Lyrics) open. NOT a raw
// `root.width < N` — the .slint calls that exact trigger out as the
// regression it fixed (ArtistPageView.slint:166-172): at a normal window
// the content area is already narrow WITHOUT any panel, so the sidebar
// would never auto-open at all.
readonly property bool contentConstrained:
Window.width > 0 && Window.width < 1366
&& (QbzShell.queueOpen || QbzShell.lyricsOpen)
// The .slint AUTO-collapses on a constrain edge and AUTO-opens when there
// is room again, and re-applies the same rule on every artist change
// (`changed net-cramped` / `changed net-nav-watch` at :175-180, plus
// artist.rs `reset_network_sidebar`). The port opened FALSE and stayed
// shut until the user found the button.
property bool networkOpen: !contentConstrained
onContentConstrainedChanged: networkOpen = !contentConstrained
property string netTab: "network"
readonly property int preview: 5
// Sidebar lists are unbounded upstream (an orchestra can list 150
@@ -110,7 +200,8 @@ Rectangle {
// sidebar section arrive later on their own flags. Every one of these is
// ALSO gated on mbAvailable upstream, so with MusicBrainz off in Settings
// (or no confident match) they are absent — placeholder included.
readonly property bool primaryLoading: QbzArtist.artistLoading && topTracks.length === 0
readonly property bool primaryLoading: QbzArtist.artistLoading
&& (artist.topTracks || []).length === 0
readonly property bool originPending: network.mbAvailable === true
&& network.originLoading === true
readonly property bool relationshipsPending: network.mbAvailable === true
@@ -135,15 +226,21 @@ Rectangle {
}
// JUMP TO tabs from the present sections (ArtistState.jump-tabs).
// Built from the RAW document, never the filtered lists: artist.rs
// builds jump_tabs ONCE at load (`build_jump_tabs`) and `filter_artist`
// does not touch them, so the strip must not reshuffle per keystroke.
readonly property var jumpTabs: {
var tabs = []
var rawTop = artist.topTracks || []
var rawSections = artist.releaseSections || []
var rawAppears = artist.appearsOn || []
if ((artist.bio || "") !== "") tabs.push({ "id": "about", "label": QbzSession.tr("About", QbzSession.trRev) })
if (topTracks.length > 0) tabs.push({ "id": "popular-tracks", "label": QbzSession.tr("Popular Tracks", QbzSession.trRev) })
for (var i = 0; i < releaseSections.length; i++) {
if (releaseSections[i].releaseType !== "other")
tabs.push({ "id": releaseSections[i].releaseType, "label": releaseSections[i].title })
if (rawTop.length > 0) tabs.push({ "id": "popular-tracks", "label": QbzSession.tr("Popular Tracks", QbzSession.trRev) })
for (var i = 0; i < rawSections.length; i++) {
if (rawSections[i].releaseType !== "other")
tabs.push({ "id": rawSections[i].releaseType, "label": rawSections[i].title })
}
if (appearsOn.length > 0) tabs.push({ "id": "appears-on", "label": QbzSession.tr("Appears On", QbzSession.trRev) })
if (rawAppears.length > 0) tabs.push({ "id": "appears-on", "label": QbzSession.tr("Appears On", QbzSession.trRev) })
return tabs
}
@@ -164,7 +261,10 @@ Rectangle {
target: QbzArtist
function onReleaseSectionReady(releaseType, cardsJson, hasMore) {
var cards = JSON.parse(cardsJson)
var sections = root.releaseSections
// The document, not root.releaseSections: that one is a FILTERED
// projection and may hand back fresh objects, so a push into it
// would be dropped on the next re-evaluation.
var sections = root.artist.releaseSections || []
for (var i = 0; i < sections.length; i++) {
if (sections[i].releaseType === releaseType) {
var seen = {}
@@ -189,7 +289,7 @@ Rectangle {
}
// Cover dispatch keys off the raw document (artist.artUrl etc.), so
// re-fire when the parsed value actually changes (same stale race).
onTopTracksChanged: dispatchCovers()
// (the raw document drives the dispatch; onArtistChanged above covers it)
onArtistTabChanged: if (artistTab === "library") dispatchLibCovers()
// The document is republished several times per page (stories, then each
@@ -204,6 +304,10 @@ Rectangle {
// Slint opens a fresh artist on Network, or on Magazine when
// MusicBrainz is off (an empty Network tab is worse than none).
netTab = (artist.network && artist.network.mbAvailable) ? "network" : "magazine"
// …and re-applies the room rule (ArtistPageView.slint:178, artist.rs
// `reset_network_sidebar`): a new artist re-opens the panel unless
// the content area is constrained.
networkOpen = !contentConstrained
dismissedDiscovery = ({})
localToggles = ({})
membersExpanded = false
@@ -239,10 +343,14 @@ Rectangle {
var urls = []
if (artist.artUrl) urls.push(artist.artUrl)
var i, j
for (i = 0; i < topTracks.length; i++) if (topTracks[i].artUrl) urls.push(topTracks[i].artUrl)
for (i = 0; i < releaseSections.length; i++)
for (j = 0; j < releaseSections[i].cards.length; j++)
if (releaseSections[i].cards[j].artUrl) urls.push(releaseSections[i].cards[j].artUrl)
// RAW lists: a filtered-out card still needs its cover for when the
// query is cleared, and this must not re-run per keystroke.
var rawTop = artist.topTracks || []
var rawSections = artist.releaseSections || []
for (i = 0; i < rawTop.length; i++) if (rawTop[i].artUrl) urls.push(rawTop[i].artUrl)
for (i = 0; i < rawSections.length; i++)
for (j = 0; j < (rawSections[i].cards || []).length; j++)
if (rawSections[i].cards[j].artUrl) urls.push(rawSections[i].cards[j].artUrl)
// Magazine story thumbnails ride the same pipeline (arc-cdn URLs).
for (i = 0; i < stories.length; i++) if (stories[i].artUrl) urls.push(stories[i].artUrl)
dispatchArtwork(urls)
@@ -698,13 +806,19 @@ Rectangle {
// POC-NOTE: dedicated discography page out of scope.
}
}
// Per-section sort (ReleaseGrid.slint:70 QbzSelect). No seam:
// `set-section-sort` persists per release_type and re-sorts
// server-side, and neither exists on this bridge — so the control
// is DIMMED and carries no MouseArea at all instead of offering a
// pointer cursor over a menu that never opens.
Rectangle {
id: sortBtn
width: sortRow.width
height: 28
radius: 5
opacity: 0.4
anchors.verticalCenter: parent.verticalCenter
color: sortArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
color: theme.surfaceElevated
Row {
id: sortRow
height: parent.height
@@ -719,13 +833,6 @@ Rectangle {
}
QbzIcon { name: "chevron-down"; width: 12; height: 12; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" }
}
MouseArea {
id: sortArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// POC-NOTE: per-section sort menu (server re-sort) not wired.
}
}
}
@@ -739,8 +846,14 @@ Rectangle {
delegate: AlbumCard {
albumId: modelData.id
title: modelData.title
artist: modelData.artist
artistId: modelData.artistId
// The subtitle slot carries the YEAR on the artist page,
// not the artist: artist.rs `card_to_item` (:670-688)
// re-routes `year` through the card's `artist` field
// precisely so the shared card primitive stays unchanged
// ("the artist is redundant since we're already on their
// page"), and blanks artist_id so the line is inert.
artist: modelData.year
artistId: ""
genre: modelData.genre
year: modelData.year
qualityTier: modelData.qualityTier
@@ -779,15 +892,33 @@ Rectangle {
}
// ============================ the page ================================
// FULL WIDTH even with the sidebar open. The .slint reserves the 300px
// inside the BODY ROW only (ArtistPageView.slint:1094) — the header and
// the JUMP TO bar span the whole window so the gradient covers edge to
// edge (:184-191). Narrowing the whole Flickable (the port's old
// `anchors.rightMargin`) squeezed the portrait and the bio too.
Flickable {
id: flick
anchors.fill: parent
anchors.rightMargin: root.networkOpen ? 300 : 0
clip: true
contentWidth: width
contentHeight: page.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Behavior on anchors.rightMargin { NumberAnimation { duration: 160; easing.type: Easing.InOutQuad } }
// Artwork-tinted header band — the SAME shared component AlbumView
// mounts. First child = painted under the page; inside the Flickable
// = scrolls with it (ArtistPageView.slint:213); full-bleed.
HeaderGradient {
x: 0
y: 0
width: flick.width
// .slint:147 `atmo-height: page.y + body-row.y` — the band ends
// exactly where the body begins, i.e. at the JUMP TO strip, so a
// long bio pushes the fade down with no manual tuning.
height: page.y + jumpBar.y
tint: artist.headerColor || ""
active: root.headerAtmoOn
}
Column {
id: page
@@ -798,6 +929,12 @@ Rectangle {
bottomPadding: 100
spacing: 0
// Width available to the BODY sections: the page width less the
// 32+32 padding, less the sidebar reservation while it is open
// (.slint's empty 300px slot in the body row). The header and the
// jump strip deliberately do NOT subtract it.
readonly property real bodyWidth: width - 64 - (root.networkOpen ? 300 : 0)
Item { width: 1; height: 22 }
// --- Artist header skeleton ----------------------------------
@@ -865,7 +1002,7 @@ Rectangle {
Text {
width: parent.width
text: artist.name || ""
color: theme.textPrimary
color: root.hdrStrong
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
elide: Text.ElideRight
@@ -876,7 +1013,7 @@ Rectangle {
visible: (artist.bio || "") !== ""
width: parent.width
text: artist.bioShort || ""
color: theme.textSecondary
color: root.hdrBody
font.pixelSize: theme.fontLegal
wrapMode: Text.WordWrap
}
@@ -908,13 +1045,18 @@ Rectangle {
}
Item { width: 1; height: 18 }
// Action row.
// Action row — ArtistPageView.slint:413-591. Four
// circles (NO Play: Popular Tracks carries its own), then
// a stretch, then the catalog/library toggle floated
// right. The palette arm follows the header backdrop
// (`on-surface: root.hdr-on-surface`, :417).
Row {
width: parent.width
spacing: 12
QbzCircleAction {
readonly property bool following: root.toggleState("artist", artist.isFollowing)
name: following ? "heart-filled" : "heart"
overlay: root.hdrOverlay
active: following
anchors.verticalCenter: parent.verticalCenter
onClicked: {
@@ -922,14 +1064,21 @@ Rectangle {
QbzLibrary.libraryToggleFavorite("artist", artist.id)
}
}
// Radio — the .slint opens a QBZ-radio / Qobuz-radio
// dropdown; neither engine has a seam on this bridge,
// and the dropdown it used to open had two rows that
// just closed themselves. DIMMED and inert-by-
// declaration until an engine lands.
QbzCircleAction {
id: radioBtn
name: "radio"
overlay: root.hdrOverlay
btnEnabled: false
anchors.verticalCenter: parent.verticalCenter
onClicked: radioPopup.openBelowRight(radioBtn)
}
QbzCircleAction {
name: "element-connect"
overlay: root.hdrOverlay
active: root.networkOpen
anchors.verticalCenter: parent.verticalCenter
onClicked: root.networkOpen = !root.networkOpen
@@ -937,75 +1086,40 @@ Rectangle {
QbzCircleAction {
id: overflowBtn
name: "ellipsis"
overlay: root.hdrOverlay
anchors.verticalCenter: parent.verticalCenter
onClicked: function (mouse) { overflowMenu.openAtCursor(overflowBtn, mouse.x, mouse.y) }
}
Item { width: parent.width - 4 * 32 - 4 * 12 - segTabs.width; height: 1 }
// From catalog / In library (webplayer parity).
Rectangle {
// Stretch (.slint:579 `Rectangle { horizontal-stretch: 1 }`).
// Clamped: at a narrow window an unclamped negative
// width silently reflows the whole row.
Item {
width: Math.max(0, parent.width - 4 * 32 - 4 * 12
- (segTabs.visible ? segTabs.width + 12 : 0))
height: 1
}
// From catalog / In library — .slint:582 mounts the
// SHARED SegmentedTabBar; the port hand-rolled a copy
// of it here whose delegate walked the wrong parent
// chain (`parent.parent.modelData` on a Row, and the
// count chip read `active` off the wrong node), so the
// count badge never took its active colours. This is
// the shared control (controls/QbzTabBar.qml), which
// is that same SegmentedTabBar 1:1 — counts on, and
// the 2px accent underline the .slint's Segment draws
// for the active tab (:86-93).
QbzTabBar {
id: segTabs
visible: (artist.libraryCount || 0) > 0
width: segRow.width
height: segRow.height
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceElevated
radius: 6
Row {
id: segRow
padding: 3
spacing: 4
Repeater {
model: [
{ "id": "catalog", "label": QbzSession.tr("From catalog", QbzSession.trRev), "count": 0 },
{ "id": "library", "label": QbzSession.tr("In library", QbzSession.trRev), "count": artist.libraryCount || 0 },
]
delegate: Rectangle {
required property var modelData
property bool active: root.artistTab === modelData.id
width: segTabRow.implicitWidth
height: segTabRow.implicitHeight
radius: 4
color: active ? theme.surfaceMain
: segTabArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
id: segTabRow
leftPadding: 12
rightPadding: parent && parent.parent.modelData.count > 0 ? 8 : 12
topPadding: 6
bottomPadding: 6
spacing: 7
Text {
text: modelData.label
color: parent.parent.active ? theme.textPrimary : theme.textMuted
font.pixelSize: theme.fontLegal
font.weight: theme.weightMedium
}
Rectangle {
visible: modelData.count > 0
width: Math.max(18, segCountText.implicitWidth + 10)
height: 16
radius: 8
color: parent.parent.active ? "#26ffffff" : "#14ffffff"
Text {
id: segCountText
anchors.centerIn: parent
text: modelData.count
color: parent.parent.active ? theme.textPrimary : theme.textSecondary
font.pixelSize: 11
font.weight: theme.weightMedium
}
}
}
MouseArea {
id: segTabArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.artistTab = modelData.id
}
}
}
}
counts: true
underline: true
activeId: root.artistTab
tabs: [
{ "id": "catalog", "label": QbzSession.tr("From catalog", QbzSession.trRev), "count": 0 },
{ "id": "library", "label": QbzSession.tr("In library", QbzSession.trRev), "count": artist.libraryCount || 0 },
]
onSelected: function (id) { root.artistTab = id }
}
}
}
@@ -1013,44 +1127,25 @@ Rectangle {
Item { width: 1; height: 20 }
// --- JUMP TO bar (inline; sticky is POC-NOTE) -----------------
Rectangle {
// --- JUMP TO bar ---------------------------------------------
// The SHARED controls/QbzJumpNavBar (primitives/JumpNavBar.slint
// 1:1). What the port drew by hand was the tab strip ONLY: no
// "JUMP TO" caption, no bottom hairline, no search affordance,
// 13px/medium type instead of the .slint's 15px/regular, and the
// wrong three tab colours. padH 0 because this Column already
// pads 32 — the .slint's own 32px padding lands the strip in the
// same place.
// POC-NOTE (unchanged): the bar scrolls with the page; the
// .slint's sticky clamp (:1120-1126) is not ported.
QbzJumpNavBar {
id: jumpBar
width: parent.width - 64
height: 44
color: theme.surfaceMain
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 16
Repeater {
model: root.jumpTabs
delegate: Column {
required property var modelData
spacing: 0
Text {
text: modelData.label
color: root.activeJumpTab === modelData.id ? theme.textPrimary
: jumpTabArea.containsMouse ? theme.textSecondary : theme.textMuted
font.pixelSize: 13
font.weight: root.activeJumpTab === modelData.id ? theme.weightSemibold : theme.weightMedium
MouseArea {
id: jumpTabArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.scrollToSection(modelData.id)
}
}
Rectangle {
visible: root.activeJumpTab === modelData.id
width: parent.width
height: 2
radius: 1
color: theme.accent
}
}
}
}
padH: 0
barBg: root.ambientOn ? "transparent" : theme.surfaceMain
tabs: root.jumpTabs
activeTabId: root.activeJumpTab
onTabClicked: function (id) { root.scrollToSection(id) }
onSearchEdited: function (text) { root.searchQuery = text }
}
// --- Primary placeholder --------------------------------------
@@ -1089,7 +1184,10 @@ Rectangle {
Column {
id: sectionAnchors
visible: root.artistTab === "catalog" && !QbzArtist.artistLoading
width: parent.width - 64
// Body width: yields the 300px the sidebar overlay occupies
// (.slint's empty reservation slot, :1094) so the content is
// never painted under the panel.
width: page.bodyWidth
spacing: 0
// --- Popular Tracks -------------------------------------
@@ -1182,8 +1280,10 @@ Rectangle {
AlbumCard {
albumId: artist.lastRelease ? artist.lastRelease.id : ""
title: artist.lastRelease ? artist.lastRelease.title : ""
artist: artist.lastRelease ? artist.lastRelease.artist : ""
artistId: artist.lastRelease ? artist.lastRelease.artistId : ""
// year in the subtitle slot — card_to_item again
// (artist.rs:784 maps last_release through it too).
artist: artist.lastRelease ? artist.lastRelease.year : ""
artistId: ""
genre: artist.lastRelease ? artist.lastRelease.genre : ""
year: artist.lastRelease ? artist.lastRelease.year : ""
qualityTier: artist.lastRelease ? artist.lastRelease.qualityTier : ""
@@ -1362,8 +1462,9 @@ Rectangle {
delegate: AlbumCard {
albumId: modelData.id
title: modelData.title
artist: modelData.artist
artistId: modelData.artistId
// year in the subtitle slot (card_to_item)
artist: modelData.year
artistId: ""
genre: modelData.genre
year: modelData.year
qualityTier: modelData.qualityTier
@@ -1380,7 +1481,7 @@ Rectangle {
Column {
id: libraryTab
visible: root.artistTab === "library" && !QbzArtist.artistLoading
width: parent.width - 64
width: page.bodyWidth
spacing: 0
readonly property var libItems: {
var out = []
@@ -1450,13 +1551,20 @@ Rectangle {
}
}
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: flick
}
}
// Gutter scrollbar. A SIBLING of the Flickable, not a child: anything
// declared inside a Flickable lands in its contentItem and scrolls away
// with the page. Hidden while the network panel is open — that panel pins
// to the same right edge and carries its own scroll
// (ArtistPageView.slint:1157).
QbzScrollBar {
visible: !root.networkOpen
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: flick
}
// Library feed access (the phase-5 document, parsed in LibraryView).
@@ -1465,10 +1573,30 @@ Rectangle {
}
// --- Network sidebar (300px, surface-card + 1px left border) ---------
// BOUNDED, not full-height. ArtistPageView.slint:1178-1195:
// natural-top = the live viewport y of the body row + the JUMP TO bar
// height (i.e. the panel starts under the strip, never
// beside the portrait)
// y = max(natural-top, sticky-top=44) — it rides the scroll
// down and then parks 44px from the top
// height = root.height - y — always flush with the
// bottom, so no gap appears once it is parked
// The port ran it top-to-bottom of the whole view, which put the panel
// alongside the header and made it read as app chrome instead of a page
// panel.
Rectangle {
id: netPanel
// Viewport-relative y of the strip's bottom edge. `jumpBar.y` is
// content coords, so subtracting the scroll offset gives the live
// viewport position — the same arithmetic the .slint does with
// absolute-position.
readonly property real naturalTop:
page.y + jumpBar.y + jumpBar.height - flick.contentY
readonly property real stickyTop: 44
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
y: Math.max(naturalTop, stickyTop)
height: Math.max(0, root.height - y)
width: root.networkOpen ? 300 : 0
clip: true
color: theme.surfaceCard
@@ -1887,59 +2015,29 @@ Rectangle {
}
}
// --- Radio dropdown (INERT items — POC-NOTE: radio engines) ----------
QbzContextMenu {
id: radioPopup
menuWidth: 180
Repeater {
model: [QbzSession.tr("QBZ Radio", QbzSession.trRev), QbzSession.tr("Qobuz Radio", QbzSession.trRev)]
delegate: Rectangle {
required property string modelData
width: parent ? parent.width : 0
height: 33
radius: 5
color: radioOptArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
spacing: 8
QbzIcon { name: "radio"; width: 15; height: 15; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" }
Text {
height: parent.height
text: modelData
color: theme.textSecondary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
}
}
MouseArea {
id: radioOptArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: radioPopup.close()
}
}
}
}
// --- ⋯ overflow menu ---------------------------------------------------
QbzContextMenu {
id: overflowMenu
menuWidth: 224
Repeater {
// `live: false` = no seam on this bridge. The .slint greys
// its own unavailable rows the same way (Artist Scene is
// `enabled: NetworkSidebarState.mb-available`,
// ArtistPageView.slint:523) — dimmed, no hover, no click,
// and the menu keeps its shape.
model: [
{ "label": QbzSession.tr("Create Artist Collection", QbzSession.trRev), "icon": "library-big", "action": "stub" },
{ "label": QbzSession.tr("Artist Scene", QbzSession.trRev), "icon": "map-pin", "action": "stub" },
{ "label": QbzSession.tr("Share", QbzSession.trRev), "icon": "link", "action": "stub" },
{ "label": root.toggleState("artistPin", artist.isPinned) ? QbzSession.tr("Unpin", QbzSession.trRev) : QbzSession.tr("Pin", QbzSession.trRev), "icon": root.toggleState("artistPin", artist.isPinned) ? "pin-filled" : "pin", "action": "pin" },
{ "label": QbzSession.tr("Create Artist Collection", QbzSession.trRev), "icon": "library-big", "action": "stub", "live": false },
{ "label": QbzSession.tr("Artist Scene", QbzSession.trRev), "icon": "map-pin", "action": "stub", "live": false },
{ "label": QbzSession.tr("Share", QbzSession.trRev), "icon": "link", "action": "stub", "live": false },
{ "label": root.toggleState("artistPin", artist.isPinned) ? QbzSession.tr("Unpin", QbzSession.trRev) : QbzSession.tr("Pin", QbzSession.trRev), "icon": root.toggleState("artistPin", artist.isPinned) ? "pin-filled" : "pin", "action": "pin", "live": true },
]
delegate: Rectangle {
required property var modelData
width: parent ? parent.width : 0
height: 33
radius: 5
color: omiArea.containsMouse ? theme.surfaceHover : "transparent"
opacity: modelData.live ? 1.0 : 0.4
color: (modelData.live && omiArea.containsMouse) ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
@@ -1958,6 +2056,7 @@ Rectangle {
MouseArea {
id: omiArea
anchors.fill: parent
enabled: modelData.live
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
@@ -1971,11 +2070,16 @@ Rectangle {
}
}
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
// Blacklist: the Qt bridge exposes the blacklist COUNTERS but no
// artist-block invokable (same gap AlbumCard.qml documents for
// "Block this album"), so the row is dimmed and inert rather than
// closing the menu and doing nothing.
Rectangle {
width: parent.width
height: 33
radius: 5
color: blArea.containsMouse ? theme.surfaceHover : "transparent"
opacity: 0.4
color: "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
@@ -1989,13 +2093,6 @@ Rectangle {
verticalAlignment: Text.AlignVCenter
}
}
MouseArea {
id: blArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: overflowMenu.close()
}
}
}
+29 -8
View File
@@ -62,8 +62,10 @@ Rectangle {
&& root.Window.window.visibility !== Window.Hidden)
: true
// Runs only while something can actually be shimmering: the first fetch
// (section skeletons) or a card still waiting for its cover.
// (section skeletons), a card still waiting for its cover, or the grace
// window in which landed covers are still decoding (artHold).
readonly property bool skelNeeded: QbzHome.homeLoading || root.artPending
|| root.artHold
// Cheap "some card in the mounted rails has artUrl but no artPath yet"
// probe — recomputed only when a sections document is republished.
readonly property bool artPending: root.anyArtPending(
@@ -79,6 +81,14 @@ Rectangle {
}
return false
}
// The pulse must OUTLIVE `artPending`. That flag drops when the last
// PATH lands, but every one of those cards still has a decode and a
// canvas raster ahead of it and its placeholder is still up (see
// QbzSkeleton's handover) — without the grace the tiles would freeze
// mid-shimmer for the rest of the wait.
property bool artHold: false
Timer { id: artHoldOff; interval: 1500; onTriggered: root.artHold = false }
onArtPendingChanged: { root.artHold = true; artHoldOff.restart() }
Timer {
interval: 900
repeat: true
@@ -142,18 +152,28 @@ Rectangle {
isFavorite: false
}
// Per-item artwork placeholder: the grey tile shimmers
// until THIS card's cover lands, then disappears on its
// own — the rail fills in progressively instead of all
// at once. A bare Rectangle, so it does not eat the
// until THIS card's cover is ON SCREEN, then dissolves
// into it — the rail fills in progressively instead of
// all at once. A bare Rectangle, so it does not eat the
// card's hover/click areas underneath.
// AlbumCard seals its RoundedImage away, so the handover
// uses the probe arm (`coverSource`): it rides the same
// pixmap-cache entry the card is loading and retires on
// the DECODE, never on the path merely appearing — the
// path lands while the card's canvas is still blank.
QbzSkeleton {
variant: "art"
width: 200
height: 200
visible: (modelData.artUrl || "") !== ""
&& (modelData.artPath || "") === ""
pending: (modelData.artUrl || "") !== ""
coverSource: modelData.artPath || ""
phase: root.skelPhase
cellIndex: index
// A cover whose download fails republishes the
// document with an empty artPath and no further
// signal, so the tile must be bounded (same rule and
// constant as SearchView's CardArtSkeleton).
settleMs: 6000
}
}
}
@@ -318,11 +338,12 @@ Rectangle {
variant: "art"
width: 200
height: 200
visible: modelData.itemKind !== "artist"
pending: modelData.itemKind !== "artist"
&& (modelData.artUrl || "") !== ""
&& (modelData.artPath || "") === ""
coverSource: modelData.artPath || ""
phase: root.skelPhase
cellIndex: index
settleMs: 6000
}
}
}
+86 -21
View File
@@ -134,14 +134,38 @@ Rectangle {
// Decoded-cover map {artKey: "file://…"} — fed by the id-keyed signal.
property var artMap: ({})
// Covers arrive ONE AT A TIME (src/local_artwork.rs phase 2 streams each
// thumbnail through the id-keyed signal the moment it resolves). Rebinding
// `artMap` per arrival is quadratic in the window: each arrival copied the
// whole map and re-evaluated the art binding of EVERY mounted cell, so a
// 50-cover window did ~2500 binding evaluations and 50 map copies during
// exactly the seconds the grid is trying to stay smooth. Arrivals are
// coalesced into ONE rebind per frame instead — the covers still appear
// progressively (16ms granularity is invisible), at O(n) total cost.
property var _artInbox: ({})
Timer {
id: artFlush
interval: 16
repeat: false
onTriggered: {
var m = Object.assign({}, root.artMap, root._artInbox)
root._artInbox = ({})
// A rebind needs a NEW object reference (same-ref assignment is
// not a change in QML).
root.artMap = m
}
}
Connections {
target: QbzLocal
function onLocalArtworkReady(key, path) {
var m = root.artMap
m[key] = path
// A rebind needs a NEW object reference (same-ref assignment is
// not a change in QML).
root.artMap = Object.assign({}, m)
root._artInbox[key] = path
if (!artFlush.running) artFlush.start()
// An arrival is live evidence that the pass is still running, so
// it keeps every placeholder's settle countdown suspended. Without
// this the bound is a fixed timeout from the REPORT, and a cold
// local library resolves slower than that (see artPassMs).
root.artPulse = true
artPulseOff.restart()
}
}
@@ -364,6 +388,10 @@ Rectangle {
for (var key in m) {
if (!keep[key]) { delete m[key]; changed = true }
}
// The not-yet-flushed arrivals are evicted too, or a cover that landed
// for a row we have just scrolled away from would be re-added by the
// next flush and defeat the eviction.
for (key in root._artInbox) if (!keep[key]) delete root._artInbox[key]
if (changed) root.artMap = Object.assign({}, m)
if (keys.length > 0) QbzLocal.artworkWindow(JSON.stringify(keys))
}
@@ -392,15 +420,26 @@ Rectangle {
// art NEVER produces an artMap entry. A per-item placeholder gated only
// on "artMap has no path yet" would therefore shimmer forever on every
// artless album. `artSettleMs` is the bound: each placeholder fades out
// by itself that long after it appears, revealing the card's own empty
// tile. Per-item clearing still wins whenever the cover DOES arrive.
readonly property int artSettleMs: 2500
// The pulse only has to run while an artwork window can still resolve:
// armed by every window report, disarmed artSettleMs after the last.
// by itself, revealing the card's own empty tile. Per-item handover
// (QbzSkeleton.handedOver) still wins whenever the cover DOES arrive.
//
// The countdown does NOT start at mount — it starts when the artwork pass
// goes quiet (`artPulse` below). A FIRST visit to a cold library decodes
// every cover from scratch and takes seconds; a countdown from mount used
// to expire a beat BEFORE the covers landed, which is the other half of
// "the skeleton disappears before the art is rendered".
readonly property int artSettleMs: 1200
/// How long after the last sign of life a resolution pass is still
/// presumed in flight. Re-armed by every window report AND by every cover
/// that arrives, so a slow first visit extends it by itself instead of
/// needing a pessimistic constant.
readonly property int artPassMs: 2500
// The pulse runs while an artwork window can still resolve, and is also
// what holds off every placeholder's settle countdown.
property bool artPulse: false
Timer {
id: artPulseOff
interval: root.artSettleMs
interval: root.artPassMs
onTriggered: root.artPulse = false
}
Timer {
@@ -410,28 +449,54 @@ Rectangle {
&& root.visible && root.windowShowing
onTriggered: root.skelPhase = !root.skelPhase
}
/// Is THIS row's cover still outstanding? (the per-item gate every local
/// surface uses — one place, so the rule cannot drift between tabs.)
function artPending(key) {
return (key || "") !== "" && (root.artMap[key] || "") === ""
}
// Debounced reporting (180ms — the LibraryView throttle).
/// Does THIS row want a cover at all? An empty artKey means the row has
/// no artwork slot and there is nothing to wait for.
///
/// NOTE — this REPLACED `artPending(key)`, which asked "is the path still
/// missing". That predicate is not the placeholder gate and never was:
/// it goes false when the PATH lands, while the art needs a further load
/// + canvas raster before it is on screen. The placeholder now hands over
/// on QbzSkeleton's `coverSource`/`coverReady` (see QbzSkeleton.qml), and
/// this function only answers "is a cover expected here".
function artWanted(key) { return (key || "") !== "" }
/// The decoded cover for a row, "" until it lands.
function artPathOf(key) { return root.artMap[key] || "" }
// Reporting is throttled at 180ms, but LEADING-EDGE: the debounce exists
// to stop a scroll from firing a resolution pass per pixel, and a view
// that has just mounted is not scrolling. Trailing-only cost every first
// paint a flat 180ms before Rust was even asked for a cover — pure
// latency on the one report that the user is actually waiting for.
// So: report at once if the last pass is older than the window, and
// debounce only the reports that arrive inside it.
Timer {
id: windowDebounce
interval: 180
property var pendingRows: []
property int pendingFirst: 0
property int pendingLast: 0
onTriggered: root.reportWindow(pendingRows, pendingFirst, pendingLast)
onTriggered: {
root._lastReportMs = Date.now()
root.reportWindow(pendingRows, pendingFirst, pendingLast)
}
}
// `real` is a double in QML — Date.now() (~1.7e12) needs the range.
property real _lastReportMs: 0
function queueWindowReport(rows, first, last) {
windowDebounce.pendingRows = rows
windowDebounce.pendingFirst = first
windowDebounce.pendingLast = last
windowDebounce.restart()
// Covers can now land: keep the shared pulse alive until artSettleMs
// after the LAST report (scrolling keeps re-arming it).
if (!windowDebounce.running
&& Date.now() - root._lastReportMs >= windowDebounce.interval) {
root._lastReportMs = Date.now()
root.reportWindow(rows, first, last)
} else {
windowDebounce.restart()
}
// Covers can now land: keep the shared pulse (and every placeholder's
// settle hold) alive until artPassMs after the LAST sign of life —
// this report, or the next cover that arrives.
root.artPulse = true
artPulseOff.restart()
}
+15 -2
View File
@@ -97,6 +97,13 @@ Rectangle {
}
readonly property bool artPending: anyPending(albums) || anyPending(tracks)
|| anyPending(playlists) || heroPending
// The pulse must OUTLIVE `artPending`: that flag drops when the last PATH
// lands, but each of those cards still has a decode + canvas raster ahead
// of it and its placeholder is still up (QbzSkeleton's handover). Without
// the grace the tiles freeze mid-shimmer for the rest of the wait.
property bool artHold: false
Timer { id: artHoldOff; interval: 1500; onTriggered: root.artHold = false }
onArtPendingChanged: { root.artHold = true; artHoldOff.restart() }
// "Load more" has NO bridge-side busy flag: search_qt.rs only sets
// doc.loading in submit(), never in load_more(). So the appended-page
@@ -122,7 +129,7 @@ Rectangle {
Timer {
interval: 900
repeat: true
running: (root.loading || root.artPending || root.morePending)
running: (root.loading || root.artPending || root.artHold || root.morePending)
&& root.visible && root.windowShowing
onTriggered: root.skelPhase = !root.skelPhase
}
@@ -141,7 +148,13 @@ Rectangle {
variant: "art"
width: 200
height: 200
visible: ((card.artUrl || "") !== "") && ((card.artPath || "") === "")
// HANDOVER, not "the path landed": the cards seal their RoundedImage
// away, so this rides QbzSkeleton's probe arm — the same pixmap-cache
// entry the card is loading, so no second decode — and retires when
// that decode finishes. Gating on `artPath !== ""` (what this used to
// do) drops the placeholder while the card's canvas is still blank.
pending: (card.artUrl || "") !== ""
coverSource: card.artPath || ""
// A cover whose download fails republishes the document with an
// empty artPath — without this the tile would shimmer forever.
settleMs: 6000
@@ -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 {
+115
View File
@@ -159,6 +159,114 @@ pub struct AlbumViewData {
pub suggestions_loading: bool,
#[serde(rename = "similarLoading")]
pub similar_loading: bool,
// ---- Header atmosphere (controls/HeaderGradient.qml) ------------------
/// Artwork-derived header tint, "#rrggbb" ("" until the cover resolves).
/// Patched in by `spawn_header_color` once the cover is on disk.
#[serde(rename = "headerColor")]
pub header_color: String,
/// The `album_header_gradient` appearance pref as of page load. The view
/// prefers the LIVE value off `QbzBridge.settingsJson` and falls back to
/// this, because the settings snapshot is only published on settings-view
/// open / mutation (main.rs `publish_settings`) — on a cold start there is
/// no snapshot to read yet.
#[serde(rename = "headerGradient")]
pub header_gradient: bool,
}
// ==================== Header tint (the gradient band) ======================
//
// 1:1 port of `crates/qbz/src/artwork.rs::header_tint`, the representative
// colour the Slint hands to the album/artist header gradient
// (`AlbumState.header-color`, set in album.rs `apply_artwork`). Kept here
// rather than in artwork_qt.rs because both pages that need it are in this
// pair of modules.
//
// A plain average desaturates badly, so the average is saturation-boosted off
// its own mean and then normalized to a fixed peak brightness: bright enough
// to perceive, dark enough to keep white header text readable.
fn header_tint(pixels: &[u8]) -> (u8, u8, u8) {
let (mut r, mut g, mut b, mut n) = (0f64, 0f64, 0f64, 0u64);
for px in pixels.chunks_exact(4) {
if px[3] < 16 {
continue;
}
r += px[0] as f64;
g += px[1] as f64;
b += px[2] as f64;
n += 1;
}
if n == 0 {
return (34, 34, 42);
}
let nf = n as f64;
let (mut r, mut g, mut b) = (r / nf, g / nf, b / nf);
let mean = (r + g + b) / 3.0;
let boost = 2.1;
let saturate = |c: f64| (mean + (c - mean) * boost).clamp(0.0, 255.0);
r = saturate(r);
g = saturate(g);
b = saturate(b);
let peak = r.max(g).max(b).max(1.0);
let scale = (138.0 / peak).min(1.7);
((r * scale) as u8, (g * scale) as u8, (b * scale) as u8)
}
/// Decode a cached cover and reduce it to its header tint, `#rrggbb`.
/// BLOCKING (decode) — call it from `spawn_blocking`. 64x64 is the sample
/// size: `header_tint` only ever computes an average, and the Slint feeds it
/// an already-downscaled decode too (artwork.rs `decode_size`).
pub(crate) fn header_tint_hex(path: &str) -> Option<String> {
let bytes = std::fs::read(path).ok()?;
let img = image::load_from_memory(&bytes).ok()?;
let tiny = image::imageops::resize(
&img.to_rgba8(),
64,
64,
image::imageops::FilterType::Triangle,
);
let (r, g, b) = header_tint(tiny.as_raw());
Some(format!("#{r:02x}{g:02x}{b:02x}"))
}
/// Resolve `artwork_url` (downloading it if it is not cached yet), reduce it
/// to a header tint and patch it into the page document. Generation-guarded
/// like every other deferred pass, so a slow cover for album A can never tint
/// album B's header.
///
/// ORDERING: unlike the carousel passes this one is NOT gated on a network
/// round-trip (an already-cached cover goes straight to the decode), so on a
/// multi-threaded runtime it can in principle reach the bridge before
/// main.rs writes the primary document and be overwritten. That is benign
/// rather than lossy: `publish_patch` mutates the STASHED document first, so
/// the tint is carried by the next republish — and the deferred carousels
/// always republish (their gates are seeded true). Worst case the band fades
/// in a beat later, never not at all.
fn spawn_header_color(generation: u64, artwork_url: String) {
if artwork_url.is_empty() {
return;
}
crate::spawn(async move {
if crate::artwork_qt::cached_path(&artwork_url).is_empty() {
crate::artwork_qt::download_missing(vec![artwork_url.clone()]).await;
}
let path = crate::artwork_qt::cached_path(&artwork_url);
if path.is_empty() {
return;
}
let path = path.trim_start_matches("file://").to_string();
let hex = tokio::task::spawn_blocking(move || header_tint_hex(&path))
.await
.ok()
.flatten();
if let Some(hex) = hex {
publish_patch(generation, move |doc| {
doc["headerColor"] = json!(hex);
});
}
});
}
fn mmss(secs: u32) -> String {
@@ -629,6 +737,12 @@ pub async fn load_album_view(
data.more_loading = !artist_id.is_empty();
data.suggestions_loading = true;
data.similar_loading = lastfm_on;
// Header atmosphere: the pref rides the document (cold-start fallback —
// see the field comment); the tint itself needs the cover on disk, so it
// lands as a patch a moment later. `headerColor` stays "" until then and
// the band simply does not paint — no colour pop.
data.header_gradient = crate::settings_qt::pref_bool("album_header_gradient", true);
let header_art = data.header.artwork_url.clone();
let json = serde_json::to_string(&data).map_err(|e| e.to_string())?;
log::info!(
@@ -648,6 +762,7 @@ pub async fn load_album_view(
Err(e) => log::warn!("[qbz-qt] album doc stash failed: {e}"),
}
spawn_header_color(generation, header_art);
spawn_deferred_rows(
runtime.clone(),
generation,
+54
View File
@@ -212,6 +212,17 @@ pub struct ArtistViewData {
pub stories_loading: bool,
/// MusicBrainz-driven Network sidebar sections (filled progressively).
pub network: ArtistNetwork,
// ---- Header atmosphere (controls/HeaderGradient.qml) ------------------
/// Artwork-derived header tint, "#rrggbb" ("" until the portrait
/// resolves). Same field, same meaning, same producer as the album page's
/// (`album_qt::header_tint_hex`) — the Slint derives both from the SAME
/// `artwork::header_tint`, so there is one colour pipeline, not two.
#[serde(rename = "headerColor")]
pub header_color: String,
/// `album_header_gradient` as of page load; the view prefers the live
/// settings snapshot and falls back to this (see album_qt.rs).
#[serde(rename = "headerGradient")]
pub header_gradient: bool,
}
fn mmss(secs: u32) -> String {
@@ -534,6 +545,11 @@ fn map_artist(page: PageArtistResponse) -> ArtistViewData {
stories: Vec::new(),
stories_loading: false,
network: ArtistNetwork::default(),
// Header atmosphere: the tint resolves from this page's artwork on a
// background pass (spawn_header_color), so the first publish carries
// the empty value and the gradient fades in with it.
header_color: String::new(),
header_gradient: false,
}
}
@@ -692,6 +708,9 @@ pub async fn load_artist_view(
data.network.relationships_loading = mb_on;
data.network.discovery_loading = mb_on;
data.stories_loading = true;
// Header atmosphere — see the ArtistViewData field comments.
data.header_gradient = crate::settings_qt::pref_bool("album_header_gradient", true);
let header_art = data.artwork_url.clone();
let json = serde_json::to_string(&data).map_err(|e| e.to_string())?;
log::info!(
@@ -713,6 +732,8 @@ pub async fn load_artist_view(
Err(e) => log::warn!("[qbz-qt] artist doc stash failed: {e}"),
}
spawn_header_color(generation, header_art);
let similar_names: Vec<String> =
data.similar_artists.iter().map(|s| s.name.clone()).collect();
spawn_enrichment(
@@ -765,6 +786,39 @@ fn publish_patch(generation: u64, f: impl FnOnce(&mut serde_json::Value)) {
});
}
/// Resolve the artist portrait (downloading it if needed), reduce it to the
/// header tint (`album_qt::header_tint_hex` — ONE colour pipeline for both
/// detail pages, exactly as the Slint shares `artwork::header_tint`) and
/// patch it in. Generation-guarded: a slow portrait for artist A can never
/// tint artist B's header. Same benign publish-ordering note as
/// `album_qt::spawn_header_color` — the stash is written first, so a
/// clobbered publish is recovered by the next enrichment republish (stories
/// always run).
fn spawn_header_color(generation: u64, artwork_url: String) {
if artwork_url.is_empty() {
return;
}
crate::spawn(async move {
if crate::artwork_qt::cached_path(&artwork_url).is_empty() {
crate::artwork_qt::download_missing(vec![artwork_url.clone()]).await;
}
let path = crate::artwork_qt::cached_path(&artwork_url);
if path.is_empty() {
return;
}
let path = path.trim_start_matches("file://").to_string();
let hex = tokio::task::spawn_blocking(move || crate::album_qt::header_tint_hex(&path))
.await
.ok()
.flatten();
if let Some(hex) = hex {
publish_patch(generation, move |doc| {
doc["headerColor"] = serde_json::json!(hex);
});
}
});
}
/// Patch fields inside the `network` object and re-publish.
fn publish_network(generation: u64, fields: Vec<(&'static str, serde_json::Value)>) {
publish_patch(generation, move |doc| {
+1 -1
View File
@@ -788,7 +788,7 @@ fn toggle_local(kind: &str, id: &str) -> Option<bool> {
/// library_all.rs `is_local_feed_id` — local tracks are file paths, local
/// albums group keys, local artists plain names; Qobuz ids are numeric.
fn is_local_feed_id(kind: &str, id: &str) -> bool {
pub(crate) fn is_local_feed_id(kind: &str, id: &str) -> bool {
match kind {
"track" | "artist" => id.parse::<u64>().is_err(),
"album" => id.starts_with("plex:") || id.contains('|') || id.contains('/'),
+12 -1
View File
@@ -69,7 +69,7 @@ const MEMO_CAP: usize = 8192;
/// deliberate: it is the point where a window of ~50 covers stops being the
/// user-visible wait, while the peak resident set stays at four decoded
/// bitmaps (a 3000px jpeg is ~36 MB decoded) rather than one per core.
const GEN_WORKERS: usize = 4;
const GEN_WORKERS: usize = 8;
/// Per-source generation locks, sharded. 64 shards keeps false sharing
/// between unrelated covers negligible at [`GEN_WORKERS`] × a few overlapping
@@ -191,6 +191,17 @@ pub async fn stream_cold(cold: Vec<(String, String)>, emit: fn(String, String))
/// `stat` on the thumbnail path (which is where a previous run's thumbnails
/// live). `None` means "must be generated" — the caller defers it to phase 2.
fn cached_thumbnail(src: &str) -> Option<PathBuf> {
// A local cover is very often ALREADY the thumbnail the scanner generated.
// Re-hashing its path yields a DIFFERENT filename, so the cold phase decodes
// it, Lanczos-resizes it to its own size (a null scale) and re-encodes a
// byte-identical-looking duplicate — that is most of the wait before the
// first cover appears.
if let Ok(dir) = qbz_library::get_thumbnails_dir() {
let p = std::path::Path::new(src);
if p.starts_with(&dir) && p.is_file() {
return Some(p.to_owned());
}
}
if let Ok(memo) = THUMBS.read() {
if let Some(hit) = memo.get(src) {
if hit.is_file() {
+29 -19
View File
@@ -493,25 +493,7 @@ impl qbz_local::QbzLocal {
}
pub fn open_album(self: Pin<&mut Self>, id: QString) {
let id = id.to_string();
ui(|mut b| {
// Clear the previous local album with the loading flag — the same
// stale-render the catalog album view had.
b.as_mut().set_local_album_json(QString::from(""));
b.as_mut().set_local_album_loading(true);
});
crate::spawn(async move {
let detail = tokio::task::spawn_blocking(move || lib::load_album_detail_blocking(&id))
.await
.ok()
.flatten();
let json = detail.map(|d| lib::to_json(&d)).unwrap_or_default();
ui(move |mut b| {
b.as_mut()
.set_local_album_json(QString::from(json.as_str()));
b.as_mut().set_local_album_loading(false);
});
});
open_album_by_id(id.to_string());
}
pub fn close_album(self: Pin<&mut Self>) {
@@ -768,3 +750,31 @@ impl qbz_local::QbzLocal {
}
}
// ---------------------------------------------------------------------------
// Local album routing
// ---------------------------------------------------------------------------
/// Open a LOCAL/Plex album. Lifted out of the invokable so `open_album` in
/// main.rs can route to it when a local id reaches the catalog path.
pub(crate) fn open_album_by_id(id: String) {
ui(|mut b| {
// Clear the previous local album with the loading flag — the same
// stale-render the catalog album view had.
b.as_mut().set_local_album_json(QString::from(""));
b.as_mut().set_local_album_loading(true);
});
crate::spawn(async move {
let detail = tokio::task::spawn_blocking(move || lib::load_album_detail_blocking(&id))
.await
.ok()
.flatten();
let json = detail.map(|d| lib::to_json(&d)).unwrap_or_default();
ui(move |mut b| {
b.as_mut()
.set_local_album_json(QString::from(json.as_str()));
b.as_mut().set_local_album_loading(false);
});
});
}
+10
View File
@@ -419,6 +419,16 @@ pub(crate) fn sidebar_artwork_window(urls_json: String) {
/// Open the album detail view: push the nav entry, then fetch + publish.
pub(crate) fn open_album(album_id: String) {
// A LOCAL or Plex album id is a group key or a path, never a Qobuz catalog
// id. Sending one to /album/get returns 404 and the view lands empty — the
// mixed Library "All" feed hands both kinds to the same card, so the routing
// has to happen HERE rather than in each call site.
if library_qt::is_local_feed_id("album", &album_id) {
nav_qt::record("localalbum");
shell_bridge::ui(|mut b| b.as_mut().set_current_view(QString::from("localalbum")));
local_bridge::open_album_by_id(album_id);
return;
}
if offline_fwd::engine().status().is_offline() {
return;
}
+8
View File
@@ -61,6 +61,10 @@ pub struct PlaylistTrackRow {
pub duration_secs: u64,
#[serde(rename = "qualityTier")]
pub quality_tier: String,
/// Bit-depth / rate line for the row's quality badge — every other
/// producer already emits it; without it the badge shows a bare tier.
#[serde(rename = "qualityDetail")]
pub quality_detail: String,
#[serde(rename = "qualityLabel")]
pub quality_label: String,
pub explicit: bool,
@@ -192,6 +196,10 @@ fn map_track(track: &Track) -> PlaylistTrackRow {
duration: mmss(track.duration),
duration_secs: track.duration as u64,
quality_tier: tier(track.maximum_bit_depth).to_string(),
quality_detail: crate::home_qt::quality_detail_from_parts(
track.maximum_bit_depth,
track.maximum_sampling_rate,
),
quality_label: quality_label(track.maximum_bit_depth, track.maximum_sampling_rate),
explicit: track.parental_warning,
art_url: album
+64 -2
View File
@@ -297,6 +297,58 @@ pub fn set_npb_mode(index: i32) -> i32 {
index
}
// ---------------------------------------------------------------------------
// Shell chrome prefs — sidebar state + section-nav placement
// ---------------------------------------------------------------------------
// `sidebar_state` is the Slint ui_prefs key (ui_prefs.rs:473, default 0):
// 0 = open / 1 = mini / 2 = closed. Slint restores it at startup
// (main.rs:8742) and rewrites it from `ShellState.cycle-sidebar()`
// (persist-sidebar-state, main.rs:14398). The Qt port used to hardcode 0 on
// every launch, so a user who left the sidebar mini or closed got it back
// open — the "initial state is wrong" divergence.
/// The persisted three-state sidebar (clamped 0-2; default 0 = open).
pub fn sidebar_state() -> i32 {
let Some(path) = prefs_path() else {
return 0;
};
std::fs::read_to_string(path)
.ok()
.and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
.and_then(|v| v.get("sidebar_state").and_then(serde_json::Value::as_i64))
.map(|s| (s as i32).clamp(0, 2))
.unwrap_or(0)
}
/// Persist the sidebar state (clamped 0-2); returns the stored value.
pub fn set_sidebar_state(state: i32) -> i32 {
let state = state.clamp(0, 2);
write_pref("sidebar_state", serde_json::Value::Number(state.into()));
state
}
/// Section-nav placement defaults — the Slint `ShellState` literals
/// (state.slint:4124 / :4129). Sidebar ON, compact-header OFF.
pub const NAV_IN_SIDEBAR_DEFAULT: bool = true;
pub const NAV_HEADER_COMPACT_DEFAULT: bool = false;
/// Sections live in the sidebar (ON) or in the header (OFF).
pub fn nav_in_sidebar() -> bool {
pref_bool("nav_in_sidebar", NAV_IN_SIDEBAR_DEFAULT)
}
/// While the nav is in the HEADER: use the icon-only compact form even when
/// the sidebar is not fully closed. No effect while `nav_in_sidebar` is ON.
pub fn nav_header_compact() -> bool {
pref_bool("nav_header_compact", NAV_HEADER_COMPACT_DEFAULT)
}
/// Playlist rows draw a 2x2 micro-collage of track covers (Slint
/// SidebarState.playlist-collage). Opt-OUT — default ON.
pub fn sidebar_playlist_collage() -> bool {
pref_bool("sidebar_playlist_collage", true)
}
// ---------------------------------------------------------------------------
// Large-NPB dock prefs (the cover's eye toggle + the band's mode cycle)
// ---------------------------------------------------------------------------
@@ -1037,8 +1089,8 @@ pub async fn publish_snapshot() {
&pref_str("immersive_default_view", "remember"),
0,
),
nav_in_sidebar: pref_bool("nav_in_sidebar", true),
nav_header_compact: pref_bool("nav_header_compact", true),
nav_in_sidebar: nav_in_sidebar(),
nav_header_compact: nav_header_compact(),
my_qbz_label: myqbz_label(),
sidebar_playlist_collage: pref_bool("sidebar_playlist_collage", true),
local_library_track_artwork: pref_bool("local_library_track_artwork", false),
@@ -1248,16 +1300,26 @@ pub async fn settings_bool(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &str,
crate::ui(move |mut b| b.as_mut().set_intelligent_search(value));
Ok(Apply::None)
}
// Section-nav placement (Slint ShellState.nav-in-sidebar /
// .nav-header-compact). Both apply LIVE — Sidebar.qml mounts/unmounts
// its nav block and HeaderBar.qml swaps tabs <-> compact icons off the
// bridge properties, so the pref push below IS the apply step.
"nav-in-sidebar" => {
save_pref("nav_in_sidebar", serde_json::json!(value));
crate::shell_bridge::ui(move |mut b| b.as_mut().set_nav_in_sidebar(value));
Ok(Apply::None)
}
"nav-header-compact" => {
save_pref("nav_header_compact", serde_json::json!(value));
crate::shell_bridge::ui(move |mut b| b.as_mut().set_nav_header_compact(value));
Ok(Apply::None)
}
"sidebar-playlist-collage" => {
save_pref("sidebar_playlist_collage", serde_json::json!(value));
// LIVE: the sidebar rows swap collage <-> list-music glyph off the
// bridge property. Without this push the row persisted a pref
// nothing read — a Settings toggle that did nothing.
crate::shell_bridge::ui(move |mut b| b.as_mut().set_sidebar_playlist_collage(value));
Ok(Apply::None)
}
"local-library-track-artwork" => {
+27 -1
View File
@@ -26,8 +26,25 @@ pub mod qbz_shell {
#[qml_singleton]
// --- Shell chrome (Slint ShellState) ----------------------------
// Three-state sidebar: 0 = open (240px), 1 = mini (64px), 2 = closed.
// Seeded from ui_prefs `sidebar_state` and rewritten by cycle_sidebar
// (1:1 Slint: persist-sidebar-state).
#[qproperty(i32, sidebar_state)]
#[qproperty(bool, queue_open)]
// --- Section-nav placement (Slint ShellState) ---------------------
// ON = the Discover / Library / Local Library / My QBZ rows live in
// the SIDEBAR (with a hairline under them); the header shows the
// compact icon nav only while the sidebar is fully closed.
// OFF = the sections live in the HEADER, after the three history
// buttons, as full text tabs — or as the icon-only compact form
// when `nav_header_compact` is ON (or the sidebar is closed).
// Both are LIVE (ui_prefs `nav_in_sidebar` / `nav_header_compact`).
#[qproperty(bool, nav_in_sidebar)]
#[qproperty(bool, nav_header_compact)]
// Playlist rows show a 2x2 micro-collage of track covers instead of
// the generic list-music glyph (Slint SidebarState.playlist-collage,
// ui_prefs `sidebar_playlist_collage`). Opt-OUT, live.
#[qproperty(bool, sidebar_playlist_collage)]
// Content view id — only "home" exists (phase 3 adds the rest).
#[qproperty(QString, current_view)]
// Nav history (src/nav_qt.rs):
@@ -226,6 +243,9 @@ use qbz_shell::QbzShell;
/// Rust side of the shell bridge (plain storage, phase-1 pattern).
pub struct QbzShellRust {
sidebar_state: i32,
nav_in_sidebar: bool,
nav_header_compact: bool,
sidebar_playlist_collage: bool,
queue_open: bool,
current_view: QString,
can_back: bool,
@@ -264,7 +284,10 @@ pub struct QbzShellRust {
impl Default for QbzShellRust {
fn default() -> Self {
Self {
sidebar_state: 0,
sidebar_state: crate::settings_qt::sidebar_state(),
nav_in_sidebar: crate::settings_qt::nav_in_sidebar(),
nav_header_compact: crate::settings_qt::nav_header_compact(),
sidebar_playlist_collage: crate::settings_qt::sidebar_playlist_collage(),
queue_open: false,
current_view: QString::from("home"),
can_back: false,
@@ -354,6 +377,9 @@ impl qbz_shell::QbzShell {
pub fn cycle_sidebar(mut self: Pin<&mut Self>) {
let next = (self.sidebar_state() + 1) % 3;
self.as_mut().set_sidebar_state(next);
// 1:1 Slint (`ShellState.cycle-sidebar` -> persist-sidebar-state): the
// state survives the relaunch instead of snapping back to open.
crate::settings_qt::set_sidebar_state(next);
}
pub fn toggle_queue(mut self: Pin<&mut Self>) {