Files
qbz/crates/qbz-qt/qml/controls/HeaderGradient.qml
T
vicrodh 5c2577c7ce fix(qt): header atmosphere, NPB button order, small-bar alignment, sidebar collage
The album/artist header band painted the 128x128 atmosphere as ONE Image at
anchors.fill + Image.Stretch, mapping the field 1:1 onto the band. That field
is vignetted in Rust, so a 1:1 map puts its bright core dead centre and the
vignette ring on the edges - the "plasta en medio" the owner reported.
ImmersiveAtmosphere.slint:21-67 lays FOUR copies, each larger than the band
(+520/+620/+700/+560 wide) and pulled back by a negative offset with
image-fit: cover, so what shows is a crop of a magnified field. Ported with
the opaque #0a0a0b base and the header's own base/warp opacities. The phase
constants are the .slint expressions with tick = 0 (animated: false), signs
included: copy 2 subtracts where copy 1 adds, and equalising them stacks the
four into one brighter copy.

PlayerBar mounted Cast before Connect; PlayerBar.slint has ConnectButton at
:401 and cast at :646. NowPlayingBarSmall already matched and is untouched.

Seven children of the small bar's right-cluster Row carried no vertical
anchor, and a QML Row top-aligns children that do not anchor themselves, so
the 32px buttons rendered at y=0 against the 3px seek line with all the slack
below. The queue button was among them.

The sidebar playlist micro-collage used radius + clip, and clip is a
rectangular scissor that ignores radius, so the cover tiles kept hard
corners. Rounded with the MultiEffect mask idiom PlaylistCollage.qml
documents, on the same _noShaders predicate so a software renderer degrades
to square corners rather than a blank tile. Its tile layout also diverged
from SidebarPlaylistCollage.slint, which draws ONE full-bleed cover below
four covers and only splits into a 2x2 at four, with a 1px seam.

cc
2026-08-02 16:32:03 -06:00

240 lines
12 KiB
QML

// 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, and this component carries BOTH:
// route A header-atmosphere.width > 0 -> ImmersiveAtmosphere (a blurred,
// magnified, four-times-overlaid copy of the cover). This is what
// the app actually shows.
// route B header-atmosphere.width <= 0 -> a flat linear gradient built
// from `header-color` (crates/qbz/src/artwork.rs header_tint), the
// arm the Slint paints only while the atmosphere is still missing.
// Route A needs NO shader here: the atmosphere is a 128x128 PNG produced in
// Rust (atmosphere_qt.rs, the immersive.rs pipeline ported stage for stage),
// so it is an ordinary picture. `tint` is the header_tint hex the Rust side
// publishes in the page document (album_qt.rs / artist_qt.rs `headerColor`)
// and feeds route B.
//
// Bands, read off the .slint:
// band 1 linear-gradient(180deg, tint 0%, tint 16%, transparent 100%)
// band 4 linear-gradient(180deg, transparent 0%, transparent 82%,
// surface-main 100%)
// Band 4 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.
//
// --- LIGHT-THEME CONTRAST FLOOR (bands 2 + 3) ---------------------------
// The header text is WHITE whenever this band is on — AlbumPageView.slint
// :169-172 pins `hdr-strong: #ffffff` / `hdr-body: #ffffff@88%` the moment
// `header-light` is true, on EVERY theme. That is only safe because the
// backdrop the .slint normally paints is route A, and route A is OPAQUE
// DARK: ImmersiveAtmosphere.slint:18 fills `#0a0a0b` under the artwork
// copies, then :76-79 lay a flat `#000000 @ dim` sheet (the album/artist
// headers pass `dim: 0.24`) and a `linear-gradient(180deg, #00000066 0%,
// transparent 35%, #00000080 100%)` vignette over the whole band. White text
// and the overlay-palette circles sit on that, not on the page colour.
//
// Route B alone does NOT reproduce that: its tint alpha decays from 1.0 at
// 16% to 0.0 at 100%, so below roughly half the band the backdrop IS the
// page. On a dark theme that is invisible (white on dark either way); on a
// LIGHT theme the description, the meta line and the whole CircleAction row
// — which live in the bottom third of the header — were white on white.
// Route A's two constant sheets are therefore replicated verbatim here as
// bands 2 and 3, and band 1 holds the tint solid until the 82% mark where
// band 4 takes over, matching route A's opaque coverage instead of route
// B's early decay. Same numbers, same order as ImmersiveAtmosphere.
//
// 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).
// --- ROUTE A IS FOUR OVERSIZED COPIES, NOT ONE STRETCHED ONE -------------
// The first version of this file painted the atmosphere as ONE Image at
// `anchors.fill` + `Image.Stretch`, i.e. the 128x128 field mapped 1:1 onto the
// band. That is not what ImmersiveAtmosphere does and it is why the header read
// as "a blob in the middle": the field is VIGNETTED in Rust (atmosphere_qt.rs
// `vignette`, 0.20 from 20% to 70% of the shorter side) and its colour is
// concentrated at the centre, so mapping it 1:1 onto a 1400x300 band puts the
// bright core dead centre and the vignette's dark ring on the edges.
//
// ImmersiveAtmosphere.slint:21-67 lays FOUR copies, each SIGNIFICANTLY LARGER
// than the band (+520/+620/+700/+560 wide, +420/+500/+540/+620 tall) and pulled
// back by a negative offset, `image-fit: cover`. So what the band shows is a
// CROP of a heavily magnified field: the vignette ring falls outside the
// viewport, the four crops disagree with each other, and the colour spreads
// across the whole band instead of pooling. THAT is the "zoom" — it is not
// decoration, it is what makes the gradient read as a field.
//
// `animated: false` (both headers pass it) freezes `tick` at 0, so the four sin
// pairs collapse to their PHASE terms — written out below with the same
// expressions the .slint uses, so they stay checkable line by line.
import QtQuick
import "../theme"
Item {
id: root
QbzTheme { id: theme }
// The band must not bleed: every route-A copy is larger than it.
clip: true
/// 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
/// ImmersiveAtmosphere's flat black sheet — the album and artist headers
/// both mount it with `dim: 0.24` (AlbumPageView.slint:230,
/// ArtistPageView.slint:222).
property real dim: 0.24
/// The two opacity knobs both headers pass (AlbumPageView.slint:231-232,
/// ArtistPageView.slint:223-224). ImmersiveAtmosphere's OWN defaults are
/// 0.95 / 0.48 — the headers override them, so these are the header values,
/// not the component's.
property real baseOpacity: 0.92
property real warpOpacity: 0.38
// `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 !== "" || root.hasAtmosphere)
// Never eat clicks meant for the header controls painted on top.
enabled: false
/// ROUTE A — the blurred artwork field Slint actually renders
/// (`ImmersiveAtmosphere`, AlbumPageView.slint:222). It needs no shader:
/// the atmosphere is a 128x128 image produced in Rust
/// (atmosphere_qt.rs, the immersive.rs pipeline ported stage for stage),
/// so it works on the software path like any other picture. Empty means
/// the cover has not resolved yet and route B stands in.
property string atmosphere: ""
readonly property bool hasAtmosphere: root.atmosphere !== ""
// --- the four phase constants, one per copy --------------------------
// ImmersiveAtmosphere drives each copy from `tick = animation-tick()/1ms`,
// and `animated: false` pins tick at 0. What survives is the phase offset
// inside each sine, so these are the .slint expressions with `tick = 0`
// substituted and nothing else changed. `Math.sin` takes radians and
// Slint's `* 1rad` does the same, so the arguments are identical.
// They are constants: QML evaluates each of these exactly once.
readonly property real _tau: 6.283185
// :22-24 sx = sin(0) + 0.35*sin(1.9τ) sy = sin(1.1τ)
readonly property real _sx1: 0.35 * Math.sin(1.9 * root._tau)
readonly property real _sy1: Math.sin(1.1 * root._tau)
// :34-36 sx = sin(0.4τ) + 0.42*sin(2.6τ) sy = sin(1.7τ)
readonly property real _sx2: Math.sin(0.4 * root._tau) + 0.42 * Math.sin(2.6 * root._tau)
readonly property real _sy2: Math.sin(1.7 * root._tau)
// :46-48 sx = sin(1.6τ) sy = sin(0.2τ) + 0.28*sin(2.8τ)
readonly property real _sx3: Math.sin(1.6 * root._tau)
readonly property real _sy3: Math.sin(0.2 * root._tau) + 0.28 * Math.sin(2.8 * root._tau)
// :58-59 sx = sin(2.4τ) sy = sin(1.4τ)
readonly property real _sx4: Math.sin(2.4 * root._tau)
readonly property real _sy4: Math.sin(1.4 * root._tau)
// ImmersiveAtmosphere.slint:18 — the component's own `background: #0a0a0b`.
// Route A is OPAQUE DARK by construction; the white header text and the
// overlay-palette circles are legible because of THIS, not because of the
// tint. Route B never had it (Slint paints the flat gradient without the
// component), so it stays gated on the atmosphere.
Rectangle {
anchors.fill: parent
visible: root.hasAtmosphere
color: "#0a0a0b"
}
// The four artwork copies, :21-67, one row per copy. `growW`/`growH` are
// the +Npx the .slint adds to the band's size; `baseX`/`baseY` are its
// constant negative offsets; `offX`/`offY` are the already-resolved
// `sx * Npx` / `sy * Npx` terms, SIGNS INCLUDED — copy 2 subtracts where
// copy 1 adds (`x: -310px - sx*156px`), and copy 4 subtracts on x only.
// That disagreement between the crops is the point: four identical
// offsets would just stack into one brighter copy.
//
// A Repeater, not four hand-written Images and not an inline `component`:
// an inline component does NOT see this document's ids, so an `AtmoLayer`
// reading `root.atmosphere` would silently resolve to nothing (the trap
// controls/QbzSkeleton.qml documents). A Repeater's delegate DOES live in
// document scope.
readonly property var _atmoLayers: [
{ growW: 520, growH: 420, baseX: -260, baseY: -210,
offX: root._sx1 * 132, offY: root._sy1 * 58, o: root.baseOpacity },
{ growW: 620, growH: 500, baseX: -310, baseY: -250,
offX: -root._sx2 * 156, offY: -root._sy2 * 74, o: root.warpOpacity },
{ growW: 700, growH: 540, baseX: -350, baseY: -270,
offX: root._sx3 * 118, offY: root._sy3 * 92, o: 0.24 },
{ growW: 560, growH: 620, baseX: -280, baseY: -310,
offX: -root._sx4 * 102, offY: root._sy4 * 128, o: 0.18 }
]
Repeater {
model: root._atmoLayers
delegate: Image {
required property var modelData
source: root.atmosphere
visible: root.hasAtmosphere
asynchronous: true
smooth: true
// Slint `image-fit: cover`. The source is square and this rect is
// far wider than tall, so cover scales to WIDTH and crops the
// height — that magnification is what spreads the colour across
// the band instead of pooling it in the middle.
fillMode: Image.PreserveAspectCrop
// All four share ONE decode: same url, same pixmap-cache entry.
width: root.width + modelData.growW
height: root.height + modelData.growH
x: modelData.baseX + modelData.offX
y: modelData.baseY + modelData.offY
opacity: modelData.o
}
}
// Band 1 — ROUTE B, the flat tint. This is Slint's FALLBACK, painted only
// while no atmosphere exists; shipping it as the design is what made the
// header read as a plain ugly gradient. Solid to the 82% mark, the last
// 18% is band 4's job.
Rectangle {
anchors.fill: parent
visible: !root.hasAtmosphere
gradient: Gradient {
GradientStop { position: 0.0; color: root.tintColor }
GradientStop { position: 0.16; color: root.tintColor }
GradientStop { position: 0.82; color: root.tintColor }
GradientStop { position: 1.0; color: "transparent" }
}
}
// Band 2 — ImmersiveAtmosphere.slint:77-79, `#000000.with-alpha(dim)`.
Rectangle {
anchors.fill: parent
color: Qt.rgba(0, 0, 0, root.dim)
}
// Band 3 — ImmersiveAtmosphere.slint:80-82 vignette, verbatim stops.
Rectangle {
anchors.fill: parent
gradient: Gradient {
GradientStop { position: 0.0; color: "#66000000" }
GradientStop { position: 0.35; color: "transparent" }
GradientStop { position: 1.0; color: "#80000000" }
}
}
// Band 4 — the blend that must COMPLETE on the header/content divider.
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 }
}
}
}