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:
4 files changed
+345
-46
No files matched your search
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user