fix(qbz-qt): local albums route to the local view, and the detail-page parity round
**The empty album view.** Opening a local album from Library "All" landed on a blank page. The mixed feed hands Qobuz and local albums to the SAME card, and the card always called the catalog open — so a local id, which is a group key or a filesystem path, went to /album/get and came back 404. It was in the owner's log before this round as `get_album(/mnt/nas/…) returned 404`; what changed is that clearing the stale document turned "shows the previous album" into "shows nothing", which is how it surfaced. Routing now happens in open_album itself, using the predicate the feed already uses to route favourites, so every call site is covered at once instead of card by card. **Header atmosphere.** Both detail pages get Slint's two-band gradient, tinted from THIS page's artwork through the ported header_tint — not the now-playing ambient triad, which is a different subject with a different algorithm. **Artist page.** The catalog/library toggle existed but was broken: a hand-rolled copy of the shared tab bar whose delegate walked the wrong parent chain, so it threw on every evaluation and never took its active colours. It now mounts the shared control. Added the JUMP TO label, the tab-strip search with the client-side filter it drives, and the year under release-section albums. The Network panel ran the full window height and never auto-opened; it is bounded below the tab strip now and opens unless the content is constrained, as Slint does. The action circles were the wrong size with the wrong glyph colour and no overlay arm — which would have left them dark-on-dark once the gradient landed. **Quality in track rows.** The column rendered a bare tier; it carries the badge now, in the one shared row component every surface mounts. playlist_qt was the only producer not emitting the bit-depth line. **Skeletons hand over instead of disappearing.** They cleared when the PATH arrived, but the image still had to load and rasterize after that, so the placeholder vanished and left an empty cell. They now wait for the image to be ready. **Local covers.** Most local covers ARE already the scanner's thumbnail, but re-hashing their path produced a different filename, so each one was decoded, resized to its own size and re-encoded into a duplicate before being shown. That was most of the wait. cc
This commit is contained in:
29 files changed
+1776
-446
No files matched your search
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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('/'),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
Reference in new issue
Block a user