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
This commit is contained in:
vicrodh committed 2026-08-02 16:32:03 -06:00
1 parent 9ce7ef4ca0
commit 5c2577c7ce
4 files changed
+345 -46

No files matched your search

+113 -18
View File
@@ -3,17 +3,18 @@
// 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:
// The Slint has TWO routes for this band, and this component carries BOTH:
// route A header-atmosphere.width > 0 -> ImmersiveAtmosphere (a blurred,
// warped copy of the cover). GPU only.
// 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).
// 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`).
// 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%)
@@ -50,6 +51,26 @@
// 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"
@@ -58,6 +79,9 @@ Item {
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:
@@ -69,6 +93,12 @@ Item {
/// 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
@@ -88,17 +118,82 @@ Item {
property string atmosphere: ""
readonly property bool hasAtmosphere: root.atmosphere !== ""
Image {
// --- 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
source: root.atmosphere
visible: root.hasAtmosphere
// 128px stretched across a whole header IS the design — the source is
// eight pixels of colour blurred out, so smooth scaling is the point.
smooth: true
fillMode: Image.Stretch
// ImmersiveAtmosphere.slint's base-opacity for a static header field.
opacity: 0.92
asynchronous: true
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
@@ -366,6 +366,23 @@ Rectangle {
width: (root.width - 6) * root.sideFrac
clip: true
// VERTICAL CENTRING IS PER-CHILD HERE. This Row is as tall as
// the whole ~39px controls row (`height: parent.height`), and
// a QML Row TOP-ALIGNS every child that does not anchor itself
// — so a 32px button with no anchor rendered at y=0, flush
// against the 3px seekbar, with all 7px of slack below it. The
// active-state fill then read as "too big" when it was simply
// sitting too high (the button IS 32x32, matching
// controls/QbzIconButton.qml's btnSize and the reference's
// 32x32 ConnectButton Rectangle).
// The reference has no such bug because PlayerBarSmall.slint
// builds this cluster as a HorizontalLayout, which centres by
// default; every wrapper there is a `VerticalLayout {
// alignment: center; }` (e.g. Connect :366-368, cast :601-603).
// A child of a Row MAY anchor vertically — only horizontal
// anchoring is forbidden — which is what AudioStamp and
// QbzSlider already did, and what every sibling below now does
// so the whole cluster sits on one line with the volume bar.
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
@@ -405,6 +422,7 @@ Rectangle {
width: 32
height: 32
radius: theme.radiusSm
anchors.verticalCenter: parent.verticalCenter
color: qcActive ? Qt.rgba(gold.r, gold.g, gold.b, 0.16)
: (smallQcArea.containsMouse ? theme.surfaceHover : "transparent")
border.width: qcActive ? 1 : 0
@@ -432,6 +450,7 @@ Rectangle {
QbzIconButton {
name: "cast"
active: QbzPlayer.npCastActive
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzCast.openPicker()
}
// Audio settings — opens the Normalization + Gapless
@@ -450,17 +469,20 @@ Rectangle {
id: smallAudioBtn
name: "settings-2"
active: false
anchors.verticalCenter: parent.verticalCenter
onClicked: smallAudioMenu.openBelowRight(smallAudioBtn)
}
QbzIconButton {
id: smallViewBtn
name: "layout-grid"
active: smallViewMenu.opened
anchors.verticalCenter: parent.verticalCenter
onClicked: smallViewMenu.openBelowRight(smallViewBtn)
}
QbzIconButton {
name: "mic-vocal"
active: QbzShell.lyricsOpen
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzShell.toggleLyrics()
}
@@ -472,6 +494,7 @@ Rectangle {
name: QbzPlayer.npMuted ? "volume-x" : "volume-2"
btnEnabled: !root.volLocked
active: QbzPlayer.npMuted
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzPlayer.toggleMute()
}
// WIDE: the shared QbzSlider (PlayerBarSmall mounts the
@@ -495,6 +518,7 @@ Rectangle {
QbzIconButton {
name: "list-ordered"
active: QbzShell.queueOpen
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzShell.toggleQueue()
}
}
+21 -12
View File
@@ -31,9 +31,14 @@
// Slint TransportControls/PlayerBar that descend from it):
// transport: info · shuffle · prev · play · next · repeat · "+" (Add to…)
// (+ the inline favorite heart in Classic, like Tauri)
// right: Cast · Connect · Lyrics · Now-Playing view · Audio settings
// right: Connect · Cast · Lyrics · Now-Playing view · Audio settings
// (normalization) · [6px] · Mute · slider · −/+ steppers ·
// [6px] · Queue
// Connect BEFORE Cast is the reference's order, not a preference: PlayerBar.
// slint mounts the ConnectButton at :395-401 and the cast IconButton at
// :646-653. This file had the pair inverted (Cast first) until the order was
// homologated; NowPlayingBarSmall.qml always matched (PlayerBarSmall.slint
// ConnectButton :368, cast :603).
// Tauri's Miniplayer + Full screen buttons live inside the Now-Playing view
// flyout (ViewModeMenu) since 2.0 — that ONE button holds both.
//
@@ -451,17 +456,6 @@ Rectangle {
}
Item { visible: root.largeActive; width: 12; height: 1 }
// Cast (Chromecast / DLNA) — Tauri's first right-cluster
// button. Opens the picker modal and, with it, discovery
// (PlayerBar.slint:646-664: picker-open = true +
// CastActions.open()); lit while a renderer is connected.
QbzIconButton {
name: "cast"
active: QbzPlayer.npCastActive
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzCast.openPicker()
}
// Qobuz Connect — the Slint ConnectButton (PlayerBar.
// slint:32-77): monitor-speaker glyph, GOLDEN over a soft
// golden tint whenever a session is on — whether qbz is
@@ -471,6 +465,10 @@ Rectangle {
// gold tint + 1px border + gold glyph, which the shared
// button's accent-glyph `active` cannot express. The
// "amber" tint IS the reference's gold (#e0b341).
//
// FIRST of the icon cluster, ahead of Cast — the order the
// reference lays out (PlayerBar.slint:395-401 Connect,
// :646-653 Cast) and the one the Small bar already used.
Rectangle {
id: qconnectBtn
readonly property bool qcActive: QbzQConnect.qconnectConnected
@@ -518,6 +516,17 @@ Rectangle {
}
}
// Cast (Chromecast / DLNA) — SECOND, straight after
// Connect. Opens the picker modal and, with it, discovery
// (PlayerBar.slint:646-664: picker-open = true +
// CastActions.open()); lit while a renderer is connected.
QbzIconButton {
name: "cast"
active: QbzPlayer.npCastActive
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzCast.openPicker()
}
// Lyrics.
QbzIconButton {
name: "mic-vocal"
+187 -16
View File
@@ -68,6 +68,7 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import com.blitzfc.qbz
import "../controls"
import "../theme"
@@ -157,6 +158,26 @@ Rectangle {
}
}
/// Shader availability for the playlist micro-collage's rounding mask.
/// Verbatim from cards/PlaylistCollage.qml:120-122 (and
/// theme/RoundedImage.qml:234-236): the ONLY case the retired "effects
/// render nothing" doctrine was ever true for is the software/Null
/// renderer, and `GraphicsInfo.api` answers it per window at runtime
/// (`api` carries `notify: "apiChanged"`, so this re-evaluates when the
/// window arrives). Test Software/Null NEGATIVELY — under the RHI an
/// OpenGL backend reports `GraphicsInfo.OpenGL`. `QbzShell.forceCanvasArt`
/// (env `QBZ_QT_ROUND_MODE=canvas`, default OFF) is honoured here too, so
/// a machine where `GraphicsInfo` reports a GPU and the mask still
/// misbehaves degrades to square corners instead of losing the covers.
/// `typeof` guard mirrors both files: eager binding, and the sidebar must
/// stay loadable in an isolated qml6 scene.
///
/// Declared ONCE on this root rather than per delegate: the answer is a
/// per-window constant and the playlist tree can hold hundreds of rows.
readonly property bool _noShaders: GraphicsInfo.api === GraphicsInfo.Software
|| GraphicsInfo.api === GraphicsInfo.Null
|| (typeof QbzShell !== "undefined" && QbzShell.forceCanvasArt)
QbzTheme { id: theme }
// The section-nav dropdown (catalog + panel + open/close behaviour) —
@@ -887,28 +908,178 @@ Rectangle {
y: Math.round((parent.height - height) / 2)
tintName: "accent"
}
// 2x2 micro-collage (or list-music glyph).
Rectangle {
// Playlist micro-collage (or list-music
// glyph) — the port of
// primitives/SidebarPlaylistCollage.slint:
// a 20px box, 3px radius, surface-elevated
// backing, 1px seam.
//
// TWO arms, not one. The reference splits
// on the cover count (.slint:27-68):
// 1-3 covers -> ONE full-bleed cover, 4+ ->
// the 2x2 grid. This used to be an
// unconditional 2x2 of hard-coded 10x10
// tiles with no seam, so a playlist with a
// single cover drew one 10x10 tile in the
// top-left quadrant and left three quarters
// of the box empty.
//
// Covers stay on the host's already-resolved
// `root.coverMap` (filled by the ONE
// `sidebarArtworkWindow` dispatch in
// parseEntries) — this is not a second
// artwork seam.
Item {
id: collage
visible: useCollage
width: 20
height: 20
x: Math.round((parent.width - width) / 2)
y: Math.round((parent.height - height) / 2)
radius: 3
color: theme.surfaceElevated
clip: true
Grid {
// `n`, straight off SidebarEntry.cover-count
// (.slint:24). sidebar_qt.rs already
// de-duplicates and caps `covers` at 4
// (SidebarEntry::covers), so this is 0..4
// and no dedup pass is owed here.
readonly property int n: useCollage ? plRow.modelData.covers.length : 0
// .slint:25 `gap: 1px`. NOT the mosaic
// card's 2px seam — the micro-collage is
// its own component with its own number.
readonly property real gap: 1
// Backing surface. A Rectangle's OWN
// rounded fill paints correctly; only its
// CHILDREN need the mask below, which is
// exactly the distinction the old code
// missed.
Rectangle {
anchors.fill: parent
columns: 2
Repeater {
model: modelData.covers.slice(0, 4)
delegate: Image {
required property string modelData
width: 10
height: 10
source: root.coverMap[modelData] || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
radius: 3
color: theme.surfaceElevated
}
// The rounded outer shape as a mask
// texture. `clip: true` in Qt Quick is a
// RECTANGULAR scissor and IGNORES
// `radius`, which is why the old
// `Rectangle { radius: 3; clip: true }`
// shipped square collages: the fill was
// round, the four covers painted on top
// of it were not (owner report). The
// documented fix is the `layer.enabled` +
// `MultiEffect { maskEnabled }` idiom —
// same mechanism, same constants, as
// cards/PlaylistCollage.qml:264-305 and
// theme/RoundedImage.qml.
Item {
id: collageMask
anchors.fill: parent
visible: false
// A layer renders even when its item
// is invisible — that IS the mask
// idiom — so the gate has to be
// `useCollage`, not `visible`.
// PlaylistCollage.qml gates its own
// two layers on `tiles.length > 0`
// for the same reason, and it matters
// MORE here: this delegate is inside
// a Repeater over the whole playlist
// tree, so an ungated pair of layers
// would cost two FBOs on every folder
// row and every glyph row too.
layer.enabled: useCollage && !root._noShaders
layer.smooth: true
Rectangle {
anchors.fill: parent
radius: 3
color: "#ffffff"
}
}
// ONE layer + ONE mask for the whole
// collage, not one per tile. `clip` stays
// on the container because
// PreserveAspectCrop paints OUTSIDE its
// item's bounds unless clipped — it just
// cannot round on its own.
Item {
anchors.fill: parent
clip: true
layer.enabled: useCollage && !root._noShaders
layer.smooth: true
layer.effect: MultiEffect {
maskEnabled: true
maskSource: collageMask
// 0.5 / 1.0 — MEASURED; the full
// table is in
// theme/RoundedImage.qml. Qt's
// mask is a smoothstep CENTRED on
// maskThresholdMin of width
// maskSpreadAtMin, so this is the
// only pair that maps mask alpha
// 0..1 onto 0..1. (0.0, 0.0)
// collapses it to step() and the
// corners come back HARD; (0.0,
// > 0) clamps everything opaque
// and disables the mask outright.
// Do not "restore the defaults".
maskThresholdMin: 0.5
maskSpreadAtMin: 1.0
}
// 1-3 covers -> a single full-bleed
// cover (.slint:28-33). `image-fit:
// cover` = PreserveAspectCrop.
Image {
anchors.fill: parent
visible: collage.n >= 1 && collage.n < 4
// Empty out of arm so the hidden
// branch holds no texture. Reading
// `visible` back gives the
// EFFECTIVE visibility, so this
// also drops the texture for a row
// the mini rail collapses — and
// re-binds when it comes back.
source: visible ? (root.coverMap[plRow.modelData.covers[0]] || "") : ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
// 4+ covers -> a 2x2 grid of
// (size - gap) / 2 tiles with the 1px
// seam (.slint:36-68). Grid `spacing`
// IS that seam: 9.5 + 1 + 9.5 = 20.
Grid {
anchors.fill: parent
columns: 2
spacing: collage.gap
Repeater {
// Empty below four — the arm
// is not merely hidden, it
// holds no tiles and no
// textures.
model: collage.n >= 4 ? plRow.modelData.covers.slice(0, 4) : []
delegate: Image {
required property string modelData
width: (collage.width - collage.gap) / 2
height: (collage.height - collage.gap) / 2
source: root.coverMap[modelData] || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
// Per-tile scissor: a crop
// fit overflows its own
// bounds, and the
// container's clip is 20px
// wide — without this a
// wide cover bleeds into
// the neighbouring cell
// instead of being cropped
// to it (Slint's
// `image-fit: cover`
// clips per element).
clip: true
}
}
}
}