feat(qt): immersive atmosphere and focus panels (B3)

km
This commit is contained in:
vicrodh committed 2026-08-02 22:25:19 -06:00
1 parent 8bc255269d
commit 46c1c42408
11 files changed
+1434 -22

No files matched your search

+13 -2
View File
@@ -140,10 +140,21 @@ fn main() {
"qml/shell/QconnectDevModal.qml",
// Immersive mode (2026-08-02 immersive-port contract §2) —
// its own module directory like views/local/ and
// views/playlistmanager/. B2 ships the root overlay + the
// header band; B3-B5 add the remaining 17 files here.
// views/playlistmanager/. B2 shipped the root overlay + the
// header band; B3 adds the atmosphere underlay, the five
// FOCUS panels and the song card / track meta / equalizer;
// B4-B5 add the remaining 8 files here.
"qml/immersive/AlbumReactivePanel.qml",
"qml/immersive/CoverflowPanel.qml",
"qml/immersive/EqualizerBars.qml",
"qml/immersive/ImmersiveAtmosphere.qml",
"qml/immersive/ImmersiveHeader.qml",
"qml/immersive/ImmersiveSongCard.qml",
"qml/immersive/ImmersiveTrackMeta.qml",
"qml/immersive/ImmersiveView.qml",
"qml/immersive/SpectrumPanel.qml",
"qml/immersive/StaticPanel.qml",
"qml/immersive/WaveBedPanel.qml",
"qml/theme/QbzIcon.qml",
"qml/theme/QbzScrollBar.qml",
"qml/theme/QbzSpinner.qml",
@@ -0,0 +1,178 @@
// AlbumReactivePanel — FOCUS mode 0, the energy-reactive now-playing card
// (§6.1 of the 2026-08-02 immersive-port contract), port of
// crates/qbz-ui/ui/immersive/AlbumReactivePanel.slint:43-178.
//
// REACTIVE SCALARS (contract formulas, Svelte lineage in the Slint header):
// globalEnergy = avg(energy[0..5]) (:50-55)
// bassEnergy = avg(energy[0], energy[1]) (:56-59)
// artScale = 1 + global * 0.25 (:62)
// glowSpread = 15 + bass * 200 (px -> blur)(:64)
// glowOpacity = min(0.1 + global * 1.5, 1) (:66)
// baseArt = max(180, min((vh-384)/1.25, vw*0.62, 660, native)) (:88-89)
// The reserve constant 384 = pad-top(52) + spacing(40) + info(~160) +
// player clearance(132) — keep it in sync with the paddings below (:106-112).
//
// FFT CONSUMPTION (trap 6): energy arrives through ONE VizSettle on
// QbzViz.energy — NEVER a Repeater.model on a QbzViz list. The visual
// settle is then reproduced by the 100ms eases on width/height/opacity/blur
// (:138-139,:160), exactly as the Slint panel reproduces the Svelte EMA with
// its 100ms `animate`s.
//
// SPEAKER-CONE PULSE: the art grows from its CENTER via animated
// width/height; x/y are PLAIN bindings ((parent - size)/2), NEVER animated —
// animating x/y puts the centering offset on its own timeline and the box
// drifts sideways mid-pulse (the Slint header documents the bug, :126-131).
//
// GLOW FIDELITY: Slint drop-shadow has blur only (CSS's blur+spread already
// collapsed there). The Qt twin renders the halo as the glow-colored rounded
// rect under ONE MultiEffect blur. MultiEffect's blur ceiling (blurMax 64)
// caps the halo below Slint's 15+bass*200 px — the halo reads tighter under
// heavy bass; blur+opacity dominate the look (the same accepted gap the
// Slint port flags, :32-34). No shaders (software/VNC) -> the layer drops
// and the plain rects render unblurred.
import QtQuick
import QtQuick.Effects
import QtQuick.Window
import com.blitzfc.qbz
import "../shell"
import "../theme"
Item {
id: root
// --- Energy (ONE VizSettle per stream, trap 6) -------------------------
VizSettle {
id: energySettle
target: QbzViz.energy
// Park the driver while the panel is not on screen or the overlay is
// closed (the B1 close edge also stops the publishes; this is the
// QML half of the gate).
live: root.visible && QbzImmersive.open
}
readonly property real globalEnergy: {
if (QbzViz.energy.length < 5)
return 0.0
return (energySettle.at(0) + energySettle.at(1) + energySettle.at(2)
+ energySettle.at(3) + energySettle.at(4)) / 5.0
}
readonly property real bassEnergy: {
if (QbzViz.energy.length < 2)
return 0.0
return (energySettle.at(0) + energySettle.at(1)) / 2.0
}
// --- Reactive formulas (:62-66) ----------------------------------------
readonly property real artScale: 1.0 + root.globalEnergy * 0.25
// NOT readonly: the 100ms Behavior below must attach (a Behavior on a
// readonly property is a load-time "Invalid property assignment").
property real glowSpread: 15.0 + root.bassEnergy * 200.0
Behavior on glowSpread { NumberAnimation { duration: 100; easing.type: Easing.OutQuad } }
property real glowOpacity: Math.min(0.1 + root.globalEnergy * 1.5, 1.0)
Behavior on glowOpacity { NumberAnimation { duration: 100; easing.type: Easing.OutQuad } }
// --- Base art box (:84-92) ---------------------------------------------
// Native source resolution — cap the RESTING art so the cover is never
// UPSCALED beyond its own size (owner: oversized blurry art looked
// nefasto); the audio breathing still grows it momentarily. Slint reads
// the decoded image's width; Qt's twin is a hidden probe Image on the
// same file:// cache path (the shared image cache makes the second
// decode free). Unloaded/empty art -> 0 -> baseArt 180, as Slint.
Image {
id: artProbe
visible: false
source: QbzPlayer.npArtworkPath
}
readonly property real srcNative: artProbe.status === Image.Ready
? artProbe.implicitWidth : 0
readonly property real baseArt: Math.max(180,
Math.min(Math.min(Math.min((root.height - 384) / 1.25,
root.width * 0.62), 660), root.srcNative))
readonly property real artSize: root.baseArt * root.artScale
// No-shader renderer detection, verbatim from SpectrumBand.qml.
readonly property bool _noShaders: GraphicsInfo.api === GraphicsInfo.Software
|| GraphicsInfo.api === GraphicsInfo.Null
// --- Layout: artwork block + track info, centered as a group -----------
// Slint VerticalLayout padding-top 52 / padding-bottom 132 /
// alignment center / spacing 40 (:101-112).
Item {
anchors.fill: parent
anchors.topMargin: 52
anchors.bottomMargin: 132
Column {
anchors.centerIn: parent
width: root.width
spacing: 40
// Artwork block, sized to the largest reactive footprint so the
// growth doesn't clip (:115-118).
Item {
width: root.width
height: root.baseArt * 1.25
// Static black art shadow 32/#00000080/8 (:157-159).
Rectangle {
width: root.artSize
height: root.artSize
x: (parent.width - width) / 2
y: (parent.height - height) / 2 + 8
radius: 8
color: "#80000000"
layer.enabled: !root._noShaders
layer.effect: MultiEffect {
blurEnabled: true
blurMax: 32
blur: 1.0
}
Behavior on width { NumberAnimation { duration: 100; easing.type: Easing.OutQuad } }
Behavior on height { NumberAnimation { duration: 100; easing.type: Easing.OutQuad } }
}
// Glow halo (:123-140): same size/position as the art, glow
// color + reactive opacity, blurred. x/y NOT animated (see
// the header's speaker-cone note).
Rectangle {
width: root.artSize
height: root.artSize
x: (parent.width - width) / 2
y: (parent.height - height) / 2
radius: 8
color: QbzImmersive.glowColor
opacity: root.glowOpacity
layer.enabled: !root._noShaders
layer.effect: MultiEffect {
blurEnabled: true
blurMax: 64
blur: Math.min(1, root.glowSpread / 64)
}
Behavior on width { NumberAnimation { duration: 100; easing.type: Easing.OutQuad } }
Behavior on height { NumberAnimation { duration: 100; easing.type: Easing.OutQuad } }
}
// Artwork (:147-170). Grows from center via animated
// width/height; x/y plain bindings (lockstep, zero drift).
Item {
width: root.artSize
height: root.artSize
x: (parent.width - width) / 2
y: (parent.height - height) / 2
Behavior on width { NumberAnimation { duration: 100; easing.type: Easing.OutQuad } }
Behavior on height { NumberAnimation { duration: 100; easing.type: Easing.OutQuad } }
RoundedImage {
anchors.fill: parent
radius: 8
source: QbzPlayer.npArtworkPath
}
}
}
// Track info (shared, homologated across the panels, :173-176).
ImmersiveTrackMeta {
width: root.width
equalizerTint: "#7c3aed"
}
}
}
}
@@ -0,0 +1,305 @@
// CoverflowPanel — FOCUS mode 2, the cheap classic-iPhone coverflow (§6.2 of
// the 2026-08-02 immersive-port contract), port of
// crates/qbz-ui/ui/immersive/CoverflowPanel.slint:125-285.
//
// ARCHITECTURE (Slint header :4-28, binding):
// 1. PERSISTENT covers. QbzQueue.coverflowJson is the WHOLE flat queue
// [history oldest..newest, NOW, upcoming...], rebuilt by Rust ONLY on
// id-sequence change (§4.4) — a track advance moves only `index`, so it
// NEVER rebuilds the Repeaters and NEVER re-decodes a visible cover.
// 2. ANIMATED SCROLL. One float `scroll` bound to the doc's int `index`
// with a 320ms ease-in-out Behavior (:152-153). When Rust moves the
// index, `scroll` EASES to it and the fan repaints only during the
// 320ms, then idles. NO continuous tick, NO Timer.
// 3. TRANSFORM-ONLY layout. Each card derives offset = i - scroll;
// x = center + offset*pitch; size = baseArt * scale(|offset|); a
// per-depth black overlay dims on an INNER Rectangle (never root
// opacity). |offset| > 3.2 drops the card (:75). FLAT scaled cards —
// no rotateY/skew/perspective (owner accepted flat, :24).
// 4. NAVIGATION. Left = history -> QbzQueue.queuePlayHistory(center-1-i)
// (:219-221); right = upcoming -> QbzQueue.queuePlayUpcomingFlat(
// orig-center-1) (:249-251, §4.4 — the queue-wide flat invokable, NEVER
// the page-local queuePlayUpcoming, trap 23). The center card is
// non-interactive, full-res cover (:259-273).
//
// GEOMETRY (:144-147): baseArt = max(160, min(vh-372, vw*0.52, 560))
// (reserve 372 = pad-top 52 + info ~160 + spacing 28 + player clearance
// 132); pitch = baseArt*0.42. Card scale max(0.5, 1-0.18*|off|) (:66), dim
// min(0.55, |off|*0.18) (:69), y = (stageH-slot)/2 - 12 (:82), radius 8,
// near-center shadow 28/#00000066/8 (:88-90).
//
// Z-ORDER: LEFT Repeater iterates the FORWARD model (near-left declared
// last -> paints on top); RIGHT iterates the REVERSED model (near-upcoming
// declared last, :225-233); the CENTER card is a separate topmost instance
// (a Repeater can't reorder by data, :255-258). The paint-side windows
// ((-3.5,-0.5) / (0.5,3.5)) hide each Repeater's center-index GHOST copy —
// the dedicated center card is the ONLY center cover (:209-213).
//
// COVERS: the doc carries artUrl only (§4.4); resolution rides the shared
// artwork pipeline — QbzShell.sidebarArtworkWindow(urls) dispatch +
// QbzLibrary.libraryArtworkReady -> coverMap, the QueuePanel.qml:154-196
// idiom. The center card prefers QbzPlayer.npArtworkPath (the unified
// now-playing cover, sharper), falling back to the model row's cover while
// it resolves (:158-161,:266-271).
import QtQuick
import QtQuick.Effects
import QtQuick.Window
import com.blitzfc.qbz
import "../theme"
Item {
id: root
// --- Data: the FLAT coverflow document (§4.4) ---------------------------
// Guarded try/catch (trap 15); the bridge seeds a full-shape default
// ({"index":0,"tracks":[]}, queue_bridge.rs:143) so the fallback is
// paranoia, not the steady state.
property var cfDoc: {
try {
var d = JSON.parse(QbzQueue.coverflowJson)
if (d && d.tracks)
return d
} catch (e) {
}
return { "index": 0, "tracks": [] }
}
readonly property int centerIdx: root.cfDoc.index
readonly property int cfLen: root.cfDoc.tracks.length
// Reversed copy for the RIGHT Repeater (:234).
readonly property var revTracks: root.cfDoc.tracks.slice().reverse()
// --- Geometry (:144-147) ------------------------------------------------
readonly property real baseArt: Math.max(160,
Math.min(Math.min(root.height - 372, root.width * 0.52), 560))
readonly property real pitch: root.baseArt * 0.42
// --- Animated scroll — the ONLY motion (:152-153) -----------------------
property real scroll: root.cfDoc.index
Behavior on scroll { NumberAnimation { duration: 320; easing.type: Easing.InOutQuad } }
// --- Cover resolution (shared artwork pipeline) -------------------------
property var coverMap: ({})
Connections {
target: QbzLibrary
function onLibraryArtworkReady(key, path) {
var m = root.coverMap
m[key] = path
root.coverMap = Object.assign({}, m)
}
}
Component.onCompleted: root.dispatchCovers()
onCfDocChanged: root.dispatchCovers()
function dispatchCovers() {
var tracks = root.cfDoc.tracks
var urls = []
for (var i = 0; i < tracks.length; i++)
if (tracks[i].artUrl)
urls.push(tracks[i].artUrl)
if (urls.length > 0)
QbzShell.sidebarArtworkWindow(JSON.stringify(urls))
}
function coverFor(row) {
return (row && row.artUrl) ? (root.coverMap[row.artUrl] || "") : ""
}
// No-shader renderer detection, verbatim from SpectrumBand.qml (the
// near-center drop shadow is a MultiEffect blur; no shaders -> plain
// offset rect).
readonly property bool _noShaders: GraphicsInfo.api === GraphicsInfo.Software
|| GraphicsInfo.api === GraphicsInfo.Null
// --- One flat coverflow card (:46-123) ----------------------------------
// ALL geometry derives from `offset` + the stage props — when `scroll`
// eases, every card eases to its new transform with NO model change and
// NO source reassignment. `dim` is on an INNER Rectangle, never root
// opacity (:109-116).
component CoverCard: Item {
id: card
property string artSource: ""
property bool hasArt: false
property real offset: 0 // signed distance from center (i - scroll)
property real baseArt: 0 // depth-0 (center) edge length
property real pitch: 0 // x spacing between adjacent depths
property real stageCx: 0 // window-x of the center
property real stageHeight: 0
property bool interactive: false
// Whether THIS card paints its slot (:55-60). The fan is split into
// a LEFT Repeater, a RIGHT Repeater and the dedicated center card;
// each Repeater card sets this true only inside its own side's
// offset window so a slot is never double-drawn (GHOST FIX).
property bool paintSide: true
signal navigate()
readonly property real ao: Math.abs(card.offset)
// Step-down scale: center 1.0 -> ~0.5 at |offset| >= ~2.8 (:66).
readonly property real scale: Math.max(0.5, 1.0 - 0.18 * card.ao)
readonly property real slot: card.baseArt * card.scale
// Per-depth black overlay: 0 at center, up to 0.55 outward (:69).
readonly property real dim: Math.min(0.55, card.ao * 0.18)
// Only ~7 cards near the center paint (:75).
visible: card.ao <= 3.2 && card.paintSide
width: card.slot
height: card.slot
// Centered on (stageCx + offset*pitch); small upward bias to leave
// room for the song card below (:81-82).
x: card.stageCx + card.offset * card.pitch - card.slot / 2
y: (card.stageHeight - card.slot) / 2 - 12
// Static drop shadow on (near-)center cards only — 28/#00000066/8,
// NEVER animated (:88-90).
Rectangle {
visible: card.ao < 0.5
anchors.fill: parent
anchors.topMargin: 8
radius: 8
color: "#66000000"
layer.enabled: !root._noShaders
layer.effect: MultiEffect {
blurEnabled: true
blurMax: 32
blur: 0.875 // 28/32
}
}
// Neutral placeholder while the cover is unresolved (:93-99).
Rectangle {
visible: !card.hasArt
anchors.fill: parent
radius: 8
color: "#14ffffff"
}
RoundedImage {
visible: card.hasArt
anchors.fill: parent
radius: 8
source: card.artSource
}
// Per-depth attenuation on an INNER Rectangle (:110-116).
Rectangle {
anchors.fill: parent
radius: 8
color: "#000000"
opacity: card.dim
}
// Side cards navigate; the center is non-interactive (:119-122).
MouseArea {
anchors.fill: parent
enabled: card.interactive && card.ao > 0.5
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: card.navigate()
}
}
// --- Balanced composition: fan stage + track-info in ONE centered
// column (:163-178): pad-top 52, pad-bottom 132 (player clearance),
// spacing 28, center-aligned at any window size.
Item {
anchors.fill: parent
anchors.topMargin: 52
anchors.bottomMargin: 132
Column {
anchors.centerIn: parent
width: root.width
spacing: 28
// --- Cover fan stage (:181-192) --------------------------------
Item {
id: fanStage
width: root.width
// Fixed height holds the center cover footprint + headroom.
height: root.baseArt + 56
readonly property real cx: root.width / 2
// LEFT (history) Repeater — forward model, ascending index,
// so the near-left cover is declared last and paints ON TOP
// (:194-199). Paints only offset in (-3.5, -0.5).
Repeater {
model: root.cfDoc.tracks
delegate: CoverCard {
required property int index
required property var modelData
readonly property real off: index - root.scroll
offset: off
baseArt: root.baseArt
pitch: root.pitch
stageCx: fanStage.cx
stageHeight: fanStage.height
artSource: root.coverFor(modelData)
hasArt: artSource !== ""
// Left side only; hides the center-index card (GHOST
// FIX) and the whole right half (:209-213).
paintSide: off < -0.5 && off > -3.5
interactive: true
// i < centerIdx -> a history cover. The flat model is
// oldest-first while queuePlayHistory is
// most-recent-first: hist = center-1-i (:216-221).
onNavigate: {
if (index < root.centerIdx)
QbzQueue.queuePlayHistory(root.centerIdx - 1 - index)
}
}
}
// RIGHT (upcoming) Repeater — REVERSED model: rev index j is
// original flat index orig = cfLen-1-j, so the loop walks
// far-upcoming -> near-upcoming and the near-upcoming cover
// is declared last (paints ON TOP, :225-233). Paints only
// offset in (0.5, 3.5).
Repeater {
model: root.revTracks
delegate: CoverCard {
required property int index
required property var modelData
readonly property int orig: root.cfLen - 1 - index
readonly property real off: orig - root.scroll
offset: off
baseArt: root.baseArt
pitch: root.pitch
stageCx: fanStage.cx
stageHeight: fanStage.height
artSource: root.coverFor(modelData)
hasArt: artSource !== ""
paintSide: off > 0.5 && off < 3.5
interactive: true
// orig > centerIdx -> an upcoming cover:
// up_index = orig - center - 1 (:246-251), the
// QUEUE-WIDE flat invokable (trap 23).
onNavigate: {
if (orig > root.centerIdx)
QbzQueue.queuePlayUpcomingFlat(orig - root.centerIdx - 1)
}
}
}
// CENTER card — drawn LAST so it paints over its neighbors
// (:255-273). Bound to the current index's offset (eases to
// 0 with `scroll`). Prefers the unified large now-playing
// cover; falls back to the model row's cover while it
// resolves. Non-interactive.
CoverCard {
readonly property string rowCover: root.centerIdx >= 0
&& root.centerIdx < root.cfLen
? root.coverFor(root.cfDoc.tracks[root.centerIdx]) : ""
offset: root.centerIdx - root.scroll
baseArt: root.baseArt
pitch: root.pitch
stageCx: fanStage.cx
stageHeight: fanStage.height
artSource: QbzPlayer.npArtworkPath !== ""
? QbzPlayer.npArtworkPath : rowCover
hasArt: artSource !== ""
interactive: false
}
}
// --- Track info (shared, homologated across the panels) --------
// Sits directly below the fan in the SAME centered column so the
// fan + metadata read as one balanced group (:276-283).
ImmersiveTrackMeta {
width: root.width
equalizerTint: "#7c3aed"
}
}
}
}
@@ -0,0 +1,86 @@
// EqualizerBars — the 4-bar "now playing" equalizer (§6.7 of the
// 2026-08-02 immersive-port contract), port of the EqualizerBars component
// in crates/qbz-ui/ui/immersive/ImmersiveTrackInfo.slint:23-74.
//
// 18x14 box, four 3px bars at 2px spacing (manual x: 0/5/10/15 — a Row
// positioner would own the children's y and the bars must bottom-align by
// their OWN animated height). Base heights 60/100/40/80% of the 14px box,
// each sweeping scaleY 0.3..1.0 on the shared 800ms loop with phase offsets
// 0 / 0.25 / 0.125 / 0.375 (the Svelte animation-delays 0.0/0.2/0.1/0.3 of
// the 0.8s period, as fractions).
//
// The Slint loop is pull-based off animation-tick(); the Qt twin is ONE
// 800ms linear NumberAnimation on `t` (0->1), so the whole equalizer is a
// single animation node. The wave is the Slint formula verbatim:
// 0.3 + 0.7 * (0.5 - 0.5*cos((t+phase mod 1) * 2π))
// (Svelte keyframes 0/100% -> 0.3, 50% -> 1.0). The loop runs only while
// the item is visible — every mount site gates visibility on npPlaying
// (ImmersiveTrackInfo.slint:85,:100), so a paused player animates nothing.
// Slint's reduce-motion coarse clock has NO Qt twin (D11) — not ported.
import QtQuick
Item {
id: root
/// Bar color — every immersive mount passes #7c3aed.
property color tint: "#7c3aed"
width: 18
height: 14
implicitWidth: 18
implicitHeight: 14
// Loop phase 0..1, 800ms (ImmersiveTrackInfo.slint:32).
property real t: 0
NumberAnimation on t {
from: 0
to: 1
duration: 800
loops: Animation.Infinite
running: root.visible
}
// Slint wave() verbatim (:34-36).
function wave(phase) {
var u = (root.t + phase) % 1.0
return 0.3 + 0.7 * (0.5 - 0.5 * Math.cos(u * 6.283185))
}
// bar 1: delay 0.0, base 60% (:42-48)
Rectangle {
x: 0
width: 3
y: root.height - height
height: root.height * 0.6 * root.wave(0.0)
radius: 1
color: root.tint
}
// bar 2: delay 0.2, base 100% (:50-56)
Rectangle {
x: 5
width: 3
y: root.height - height
height: root.height * 1.0 * root.wave(0.25)
radius: 1
color: root.tint
}
// bar 3: delay 0.1, base 40% (:58-64)
Rectangle {
x: 10
width: 3
y: root.height - height
height: root.height * 0.4 * root.wave(0.125)
radius: 1
color: root.tint
}
// bar 4: delay 0.3, base 80% (:66-72)
Rectangle {
x: 15
width: 3
y: root.height - height
height: root.height * 0.8 * root.wave(0.375)
radius: 1
color: root.tint
}
}
@@ -0,0 +1,145 @@
// ImmersiveAtmosphere — the Kawarp-like animated atmosphere underlay (§6.1
// of the 2026-08-02 immersive-port contract), port of
// crates/qbz-ui/ui/immersive/ImmersiveAtmosphere.slint:8-83.
//
// FOUR animated layers of the SAME 128x128 atmosphere bitmap (host-generated:
// tiny artwork sample -> blur -> color adjust -> vignette), each oversized
// past the viewport and drifting on its own sine pair, plus a STATIC fallback
// (the plain cover at 0.35 opacity) when no atmosphere bitmap exists. A
// `dim` overlay and a 180-degree gradient scrim sit on top (:77-82). The
// component only animates/composites — it can be reused outside ImmersiveView.
//
// Mounted by ImmersiveView as layer 2 (§5.1): ALWAYS the underlay (the Slint
// shader-mode==0 gate is constant in v1, ruling 1), source
// QbzImmersive.atmosphereUrl, fallback QbzPlayer.npArtworkPath,
// `animated: QbzPlayer.npPlaying`, dim 0.15 (:1313-1321). Spectrum/WaveBed
// paint opaque #000 over it — intended faithful behavior
// (SpectrumPanel.slint:45-46,118, ImmersiveWaveBedPanel.slint:49).
//
// The drift clock: Slint reads animation-tick() pull-based and FREEZES it at
// 0 while !animated (:16). The Qt twin is ONE linear NumberAnimation on
// `tick` (ms), reset to 0 whenever `animated` drops, so the static pose is
// the Slint tick=0 pose exactly. All x/y offsets are plain bindings of tick
// — nothing is animated per-layer.
import QtQuick
Item {
id: root
/// 128x128 atmosphere PNG (file:// URL; "" = not ready). NEVER cleared
/// on track change by the publisher (flicker fix, §3).
property string source: ""
/// Plain cover (file:// URL) shown when `source` is empty.
property string fallbackSource: ""
/// Dark overlay opacity (:11, :78).
property real dim: 0.15
/// Layer opacities (:12-13).
property real baseOpacity: 0.95
property real warpOpacity: 0.48
/// Gates the layer drift (:14); the mount binds QbzPlayer.npPlaying.
property bool animated: true
// Drift clock in ms (:16). 24h period, linear; reset to 0 on the
// animated->static edge so the static pose is the Slint tick=0 pose.
property real tick: 0
NumberAnimation on tick {
from: 0
to: 86400000
duration: 86400000
loops: Animation.Infinite
running: root.animated
}
onAnimatedChanged: if (!animated) tick = 0
clip: true
// Root surface (:18).
Rectangle {
anchors.fill: parent
color: "#0a0a0b"
}
// --- Layer 1 (:21-32) -------------------------------------------------
Image {
readonly property real sx: Math.sin(root.tick / 7600.0 * 6.283185)
+ 0.35 * Math.sin((root.tick / 3300.0 + 1.9) * 6.283185)
readonly property real sy: Math.sin((root.tick / 11200.0 + 1.1) * 6.283185)
visible: root.source !== ""
source: root.source
fillMode: Image.PreserveAspectCrop
width: root.width + 520
height: root.height + 420
x: -260 + sx * 132
y: -210 + sy * 58
opacity: root.baseOpacity
}
// --- Layer 2 (:33-44) -------------------------------------------------
Image {
readonly property real sx: Math.sin((root.tick / 9200.0 + 0.4) * 6.283185)
+ 0.42 * Math.sin((root.tick / 4100.0 + 2.6) * 6.283185)
readonly property real sy: Math.sin((root.tick / 14800.0 + 1.7) * 6.283185)
visible: root.source !== ""
source: root.source
fillMode: Image.PreserveAspectCrop
width: root.width + 620
height: root.height + 500
x: -310 - sx * 156
y: -250 - sy * 74
opacity: root.warpOpacity
}
// --- Layer 3 (:45-56) -------------------------------------------------
Image {
readonly property real sx: Math.sin((root.tick / 5700.0 + 1.6) * 6.283185)
readonly property real sy: Math.sin((root.tick / 12300.0 + 0.2) * 6.283185)
+ 0.28 * Math.sin((root.tick / 3600.0 + 2.8) * 6.283185)
visible: root.source !== ""
source: root.source
fillMode: Image.PreserveAspectCrop
width: root.width + 700
height: root.height + 540
x: -350 + sx * 118
y: -270 + sy * 92
opacity: 0.24
}
// --- Layer 4 (:57-67) -------------------------------------------------
Image {
readonly property real sx: Math.sin((root.tick / 15100.0 + 2.4) * 6.283185)
readonly property real sy: Math.sin((root.tick / 6900.0 + 1.4) * 6.283185)
visible: root.source !== ""
source: root.source
fillMode: Image.PreserveAspectCrop
width: root.width + 560
height: root.height + 620
x: -280 - sx * 102
y: -310 + sy * 128
opacity: 0.18
}
// --- Static fallback (:68-76) ------------------------------------------
Image {
visible: root.source === "" && root.fallbackSource !== ""
source: root.fallbackSource
fillMode: Image.PreserveAspectCrop
width: root.width + 220
height: root.height + 220
x: -110
y: -110
opacity: 0.35
}
// dim overlay (:77-79).
Rectangle {
anchors.fill: parent
color: Qt.rgba(0, 0, 0, root.dim)
}
// 180-degree scrim (:80-82): #00000066 top -> transparent 35% ->
// #00000080 bottom (RRGGBBAA converted).
Rectangle {
anchors.fill: parent
gradient: Gradient {
GradientStop { position: 0.0; color: "#66000000" }
GradientStop { position: 0.35; color: "transparent" }
GradientStop { position: 1.0; color: "#80000000" }
}
}
}
@@ -0,0 +1,164 @@
// ImmersiveSongCard — the minimal bottom-right metadata card (§6.7 of the
// 2026-08-02 immersive-port contract), port of
// crates/qbz-ui/ui/immersive/ImmersiveSongCard.slint:15-114 (divergence D6:
// built fresh — shell/SongCard.qml is the bar's INTERACTIVE meta-link card
// with AudioStamp; this twin is NON-interactive with the E chip).
//
// Mounted by ImmersiveView as layer 4: visible ONLY
// `viewMode==0 && mode==6 && npHasTrack` (trap 19), bottom-right 24px
// insets, and it does NOT fade with the auto-hide chrome
// (ImmersiveView.slint:1602-1605). Content-sized: the card hugs its content
// and the mount pins it bottom-right, so a wider card grows LEFTWARD.
//
// meta column (right-aligned): title 15px semibold capped 420px + elide
// (:33-45) + "E" explicit chip 16px (:47-60); subline album italic ·
// artist 12px, NEVER truncated (:63-84); QualityBadgeFull showIcon:true
// (:89-93).
// cover thumb (RIGHT): 72x72 radius 6, shadow 16/#00000080/4 (:97-105).
//
// EXPLICIT SOURCE: Slint reads NowPlayingState.explicit; QbzPlayer publishes
// NO np_explicit (player_bridge.rs:31-114). The queue document's `current`
// row carries the SAME datum (QueueTrack::parental_warning ->
// queue_qt.rs:47,194), so the chip reads QbzQueue.queueJson with the
// guarded try/catch + track-id guard of PlayerBar.qml's npFavorite/npSource
// (the id guard keeps a stale queue document from lending the PREVIOUS
// track's chip to this one).
import QtQuick
import QtQuick.Effects
import QtQuick.Window
import com.blitzfc.qbz
import "../controls"
import "../theme"
Item {
id: root
QbzTheme { id: theme }
// No-shader renderer detection, verbatim from SpectrumBand.qml (effects
// draw NOTHING on the software/VNC path — the layer is dropped there and
// the plain shadow rect remains).
readonly property bool _noShaders: GraphicsInfo.api === GraphicsInfo.Software
|| GraphicsInfo.api === GraphicsInfo.Null
readonly property bool npExplicit: {
try {
var d = JSON.parse(QbzQueue.queueJson)
return !!(d && d.current && d.current.id === QbzPlayer.npTrackId
&& d.current.explicit)
} catch (e) {
return false
}
}
// Content-sized (:18-19): meta column width + 12px spacing + 72px cover.
readonly property real metaW: Math.max(titleRow.implicitWidth,
subRow.implicitWidth,
badge.implicitWidth)
readonly property real metaH: titleRow.height + 3 + subRow.height
+ 3 + badge.implicitHeight
implicitWidth: metaW + 12 + 72
implicitHeight: Math.max(metaH, 72)
width: implicitWidth
height: implicitHeight
// --- Meta column (LEFT), every row right-aligned ----------------------
// Title + inline E chip (:30-61). Slint HorizontalLayout alignment: end.
Row {
id: titleRow
x: root.metaW - width
spacing: 6
height: Math.max(titleText.implicitHeight, 16)
Text {
id: titleText
// Capped + elided (:34); implicitWidth is the natural content
// width, so this is NOT a binding loop.
width: Math.min(implicitWidth, 420)
text: QbzPlayer.npTitle
color: theme.textPrimary
font.pixelSize: 15
font.weight: Font.DemiBold
elide: Text.ElideRight
horizontalAlignment: Text.AlignRight
}
// Explicit "E" chip — 1:1 with TrackRow's badge (:47-60).
Rectangle {
visible: root.npExplicit
width: visible ? 16 : 0
height: 16
radius: 3
color: theme.surfaceElevated
Text {
anchors.centerIn: parent
text: "E"
color: theme.textMuted
font.pixelSize: 9
font.weight: Font.DemiBold
}
}
}
// Subline: album (italic) · artist — FULL, never truncated (:63-84).
Row {
id: subRow
x: root.metaW - width
y: titleRow.height + 3
spacing: 4
height: 14
Text {
visible: QbzPlayer.npAlbum !== ""
text: QbzPlayer.npAlbum
color: "#80ffffff"
font.pixelSize: 12
font.italic: true
}
Text {
visible: QbzPlayer.npAlbum !== "" && QbzPlayer.npArtist !== ""
text: "·"
color: "#66ffffff"
font.pixelSize: 12
}
Text {
visible: QbzPlayer.npArtist !== ""
text: QbzPlayer.npArtist
color: "#99ffffff"
font.pixelSize: 12
}
}
// Quality badge (full mode, icon shown), right-aligned (:86-94).
QualityBadgeFull {
id: badge
x: root.metaW - width
y: titleRow.height + 3 + subRow.height + 3
tier: QbzPlayer.npQualityTier
detail: QbzPlayer.npQualityDetail
showIcon: true
}
// --- Cover thumb (RIGHT) — 72x72 radius 6, shadow 16/#00000080/4 ------
// The drop shadow is a dark rounded rect blurred by a MultiEffect (the
// Qt twin of Slint's drop-shadow-*; no shaders -> plain offset rect).
Rectangle {
x: root.width - 72
y: 4
width: 72
height: 72
radius: 6
color: "#80000000"
layer.enabled: !root._noShaders
layer.effect: MultiEffect {
blurEnabled: true
blurMax: 16
blur: 1.0
}
}
RoundedImage {
x: root.width - 72
width: 72
height: 72
radius: 6
source: QbzPlayer.npArtworkPath
}
}
@@ -0,0 +1,107 @@
// ImmersiveTrackMeta — the homologated immersive track-info block (§6.7 of
// the 2026-08-02 immersive-port contract), port of ImmersiveTrackInfo in
// crates/qbz-ui/ui/immersive/ImmersiveTrackInfo.slint:78-143.
//
// HOMOLOGATED look/spacing across AlbumReactive / Static / Coverflow (and the
// SPLIT left column, §5.6): spacing 6px, the "Now Playing" indicator padded
// 8px below, the quality badge padded 12px above, fonts 28/18/14.
//
// Every row reads QbzPlayer.np* (the Slint block reads NowPlayingState):
// "Now Playing" + EqualizerBars — VISIBLE ONLY WHILE npPlaying (:85,:100)
// title 28px/700 elide center (:106-113)
// artist 18px #ffffffb3 elide center (:116-122)
// album 14px italic #ffffff80, only when non-empty (:125-132)
// QualityBadgeFull (tier + detail), self-hides when tier == "" (:135-141)
//
// The Slint root is a VerticalLayout with alignment: center — the Qt twin is
// a Column whose width the MOUNT sets (every B3 mount passes the panel width)
// and whose rows center themselves horizontally.
import QtQuick
import com.blitzfc.qbz
import "../controls"
Column {
id: root
/// Equalizer tint + "Now Playing" label color (Slint equalizer-tint).
property color equalizerTint: "#7c3aed"
spacing: 6
// "Now Playing" indicator: equalizer bars + label, shown only while
// playing (:85-103). The Slint HorizontalLayout carries padding-bottom 8
// — the wrapper Item's +8 height is that padding.
Item {
visible: QbzPlayer.npPlaying
width: parent.width
height: visible ? npRow.implicitHeight + 8 : 0
Row {
id: npRow
anchors.horizontalCenter: parent.horizontalCenter
spacing: 8
height: 14
EqualizerBars {
tint: root.equalizerTint
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: QbzSession.tr("Now Playing", QbzSession.trRev)
color: root.equalizerTint
font.pixelSize: 12
font.weight: Font.DemiBold
font.letterSpacing: 0.5
anchors.verticalCenter: parent.verticalCenter
}
}
}
// Track title (:106-113).
Text {
width: parent.width
text: QbzPlayer.npTitle
color: "#ffffff"
font.pixelSize: 28
font.weight: Font.Bold
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
// Artist (:116-122). Slint #ffffffb3 (RRGGBBAA) -> #b3ffffff.
Text {
width: parent.width
text: QbzPlayer.npArtist
color: "#b3ffffff"
font.pixelSize: 18
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
// Album (italic, dimmer), only when present (:125-132).
Text {
visible: QbzPlayer.npAlbum !== ""
width: parent.width
text: QbzPlayer.npAlbum
color: "#80ffffff"
font.pixelSize: 14
font.italic: true
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
// Quality badge, centered, padding-top 12 (:135-141). Self-hides when
// tier == "" (QualityBadgeFull's own gate) — the wrapper collapses with
// it so the 12px pad vanishes too.
Item {
visible: QbzPlayer.npQualityTier !== ""
width: parent.width
height: visible ? badge.implicitHeight + 12 : 0
QualityBadgeFull {
id: badge
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
tier: QbzPlayer.npQualityTier
detail: QbzPlayer.npQualityDetail
}
}
}
+80 -20
View File
@@ -3,20 +3,27 @@
// AppShell AFTER QbzToast and BEFORE ArtPreviewOverlay/QbzTooltip (§5.1
// declaration-order z convention).
//
// BLOCK B2 SCOPE: the overlay shell only — root + click-blocker + chrome +
// auto-hide + exit paths 2-4 + the fullscreen guard + seek arrows. Panels are
// STUB Rectangles (labeled with the mode name); B3 replaces the FOCUS stubs,
// B4 the SPLIT stubs, B5 adds the player bar + search cortinilla (layer 7 is
// therefore absent, per contract §12 B2).
// BLOCK B2+B3 SCOPE: the overlay shell (root + click-blocker + chrome +
// auto-hide + exit paths 2-4 + the fullscreen guard + seek arrows) PLUS the
// B3 panels: the ImmersiveAtmosphere underlay (layer 2), the five FOCUS
// panels (Album Reactive / Static / Coverflow / Spectrum / Wave Bed) and the
// layer-4 ImmersiveSongCard. Lyrics (mode 4) and Queue (mode 5) stay STUBS
// until B4; the SPLIT stubs stay until B4 (ImmersiveTrackMeta is already
// wired under splitArtStub, §6.7); B5 adds the player bar + search
// cortinilla (layer 7 is therefore absent, per contract §12 B2).
//
// Layer order bottom->top (§5.1):
// 1. root color #0a0a0b, clip (:1199-1200)
// 2. FULL-COVERAGE click-blocker MouseArea (load-bearing — without it
// 2. ImmersiveAtmosphere — ALWAYS the underlay (the Slint shader-mode==0
// gate is constant in v1, ruling 1; :1313-1321)
// 2b. FULL-COVERAGE click-blocker MouseArea (load-bearing — without it
// clicks pass through to the desktop header search field, the dock's
// spectrum band and the viz eye toggle; port of the Slint root
// TouchArea :1283-1286). Stacked BELOW panels + chrome.
// 3. Panel stubs (FOCUS per viewMode==0 && mode==N; SPLIT per viewMode==1)
// 6. ImmersiveHeader band (layers 4/5 — SongCard / PlayerBar — are B3/B5)
// 3. FOCUS panels (viewMode==0 && mode==N) / SPLIT stubs (viewMode==1)
// 4. ImmersiveSongCard (viewMode==0 && mode==6 && npHasTrack — trap 19;
// does NOT fade with the chrome, :1602-1605)
// 6. ImmersiveHeader band (layer 5 — the PlayerBar — is B5)
import QtQuick
import com.blitzfc.qbz
@@ -91,7 +98,22 @@ Item {
color: "#0a0a0b"
}
// --- Layer 2: the click-blocker (§5.1, load-bearing) -------------------
// --- Layer 2: the atmosphere underlay (§5.1, :1313-1321) ----------------
// ALWAYS the underlay in v1 (the Slint shader-mode==0 gate is constant,
// ruling 1). Source = the host-generated 128x128 atmosphere PNG, fallback
// the plain cover; dim 0.15. `animated` binds npPlaying (:1321); the
// && open arm stops the drift clock while the overlay is closed (the
// Slint view is UNMOUNTED then — same zero cost). Spectrum/WaveBed paint
// opaque #000 over it — intended (§5.1).
ImmersiveAtmosphere {
anchors.fill: parent
source: QbzImmersive.atmosphereUrl
fallbackSource: QbzPlayer.npArtworkPath
animated: QbzPlayer.npPlaying && QbzImmersive.open
dim: 0.15
}
// --- Layer 2b: the click-blocker (§5.1, load-bearing) ------------------
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.AllButtons
@@ -154,21 +176,37 @@ Item {
}
}
// --- Layer 3: panel stubs (B2; B3/B4 replace them) ----------------------
// FOCUS: one stub per mode, gated viewMode==0 && mode==N. The §5.5 entry
// loads wire at panel mount for the singletons that exist in B2: Queue ->
// QbzQueue.queuePanelOpened() (both the FOCUS Queue stub and the SPLIT
// Queue stub); Lyrics -> nothing (Qt fetches lyrics automatically per
// track, §5.5).
// --- Layer 3: the FOCUS panels (B3) + the remaining stubs (B4) ---------
// FOCUS: one panel per mode, gated viewMode==0 && mode==N, full-viewport
// — the B2 stubs' 24/64/132 insets are replaced by each panel's own
// Slint-internal reserves (pad-top 52/70, the 132px player clearance;
// §6.1/§6.2). Lyrics (mode 4) and Queue (mode 5) stay stub Rectangles
// until B4; the Queue stub keeps its §5.5 entry load
// (QbzQueue.queuePanelOpened) so the B2 header-menu wiring still works.
AlbumReactivePanel {
anchors.fill: parent
visible: QbzImmersive.viewMode === 0 && QbzImmersive.mode === 0
}
StaticPanel {
anchors.fill: parent
visible: QbzImmersive.viewMode === 0 && QbzImmersive.mode === 1
}
CoverflowPanel {
anchors.fill: parent
visible: QbzImmersive.viewMode === 0 && QbzImmersive.mode === 2
}
SpectrumPanel {
anchors.fill: parent
visible: QbzImmersive.viewMode === 0 && QbzImmersive.mode === 3
}
WaveBedPanel {
anchors.fill: parent
visible: QbzImmersive.viewMode === 0 && QbzImmersive.mode === 6
}
Repeater {
model: [
{ "m": 0, "label": QbzSession.tr("Album Reactive", QbzSession.trRev) },
{ "m": 1, "label": QbzSession.tr("Static", QbzSession.trRev) },
{ "m": 2, "label": QbzSession.tr("Coverflow", QbzSession.trRev) },
{ "m": 3, "label": QbzSession.tr("Spectrum", QbzSession.trRev) },
{ "m": 4, "label": QbzSession.tr("Lyrics", QbzSession.trRev) },
{ "m": 5, "label": QbzSession.tr("Queue", QbzSession.trRev) },
{ "m": 6, "label": QbzSession.tr("Wave Bed", QbzSession.trRev) },
]
delegate: Rectangle {
required property var modelData
@@ -214,6 +252,15 @@ Item {
border.width: 1
border.color: "#2effffff"
}
// B3 seam (§6.7): the real 50/50 SPLIT left column lands in B4
// (§5.6/D1); ImmersiveTrackMeta is already wired under the artwork
// placeholder so the SPLIT gate renders something real.
ImmersiveTrackMeta {
anchors.top: splitArtStub.bottom
anchors.topMargin: 20
anchors.left: splitArtStub.left
width: splitArtStub.width
}
Repeater {
model: [
{ "sp": 0, "label": QbzSession.tr("Lyrics", QbzSession.trRev) },
@@ -255,6 +302,19 @@ Item {
}
}
// --- Layer 4: ImmersiveSongCard (§5.1, :1602-1605) ----------------------
// Visible ONLY viewMode==0 && mode==6 && npHasTrack (trap 19);
// bottom-right 24px insets; NON-interactive; does NOT fade with the
// auto-hide chrome (no opacity binding here — that is deliberate).
ImmersiveSongCard {
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.rightMargin: 24
anchors.bottomMargin: 24
visible: QbzImmersive.viewMode === 0 && QbzImmersive.mode === 6
&& QbzPlayer.npHasTrack
}
// §5.5: TrackInfo RELOADS on track change while its panel is mounted
// (:1557-1560). The Suggestions twin of this reload is B4.
Connections {
@@ -0,0 +1,181 @@
// SpectrumPanel — FOCUS mode 3, the 28-column mirrored spectrum (§6.1 of
// the 2026-08-02 immersive-port contract), port of
// crates/qbz-ui/ui/immersive/SpectrumPanel.slint:110-251.
//
// OPAQUE #000 background (:118) — intended faithful behavior: the panel
// HIDES the atmosphere underlay behind it (§5.1, SpectrumPanel.slint:45-46).
//
// GEOMETRY (:120-131): 28 visual columns = 14 ACTIVE bins [0,2,3,...,14]
// (the 16-bin FFT leaves {1, 15} empty) x 2 mirror about the vertical
// center. slot = vw/28, bar = slot-4, x = col*slot + 2, baseY = vh*0.85
// (bars grow UP), maxH = vh*0.7. Columns 0..13 read activeBins[col], columns
// 14..27 replay them mirrored (activeBins[27-col]); both read the SAME
// source bin. The discrete-cubelet texture of the Svelte original is dropped
// — ONE solid gradient bar per column, exactly as the Slint port resolved
// the retained-node churn (:62-70).
//
// FFT CONSUMPTION (trap 6): heights bind through ONE VizSettle on
// QbzViz.bars (bars arrive pre-smoothed + pre-clamped from the Rust EMA —
// no second smoothing here, :34-42). The inter-frame settle is VizSettle's
// job (the Slint `animate bar-height 90ms` equivalent, :84).
//
// COLORS: vertical gradient QbzShell.ambientPrimary (base) ->
// ambientSecondary (tip) — the Slint reads ImmersiveState
// spectrum-primary/secondary; Qt's ambient triad is the same album-derived
// pipeline (SpectrumBand.qml:109-113 precedent). Slint's gradient is 0deg
// (base -> tip), so the Qt stops are INVERTED: position 0 (top) = secondary,
// position 1 (bottom) = primary. ONE shared Gradient instance for every bar.
//
// NO PEAK CAPS (peak-hold belongs to SpectralRibbon, :31). NO opacity on any
// container holding the positioned bars.
import QtQuick
import QtQuick.Effects
import QtQuick.Window
import com.blitzfc.qbz
import "../controls"
import "../shell"
import "../theme"
Rectangle {
id: root
color: "#000000"
// 14 active SOURCE bins after excluding {1, 15} (:131).
readonly property var activeBins: [0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
readonly property real slot: root.width / 28
readonly property real maxBarHeight: root.height * 0.7
// ONE VizSettle for the whole stream (trap 6, :146-148).
VizSettle {
id: barsSettle
target: QbzViz.bars
live: root.visible && QbzImmersive.open
}
// ONE gradient shared by every bar (perf note 3 of SpectrumBand.qml).
Gradient {
id: barGradient
GradientStop { position: 0.0; color: QbzShell.ambientSecondary }
GradientStop { position: 1.0; color: QbzShell.ambientPrimary }
}
// --- Bars (drawn FIRST, behind the song card, :133-149) ----------------
// STATIC Repeater model (28 columns); only each bar's HEIGHT binds to the
// stream. A missing/short frame reads 0 — flat baseline, no
// index-out-of-range (:134-137).
Repeater {
model: 28
delegate: Rectangle {
required property int index
readonly property int bin: index < 14
? root.activeBins[index] : root.activeBins[27 - index]
x: index * root.slot + 2
width: root.slot - 4
height: barsSettle.at(bin) * root.maxBarHeight
anchors.bottom: parent.bottom
// baseY = vh*0.85 -> 15% bottom margin.
anchors.bottomMargin: root.height * 0.15
gradient: barGradient
}
}
// --- Song card (drawn SECOND, in front of the bars, :151-249) ----------
// Artwork smaller than the Static/Album card (secondary to the bars):
// max(160, min(vh*0.45, vw*0.5, 360)) (:161-162). Player clearance 132
// KEPT IDENTICAL to the siblings (:157). NO "Now Playing" indicator here
// (the Spectrum info block is its own, :205-249).
readonly property real artSize: Math.max(160,
Math.min(Math.min(root.height * 0.45, root.width * 0.5), 360))
Item {
anchors.fill: parent
anchors.topMargin: 70
anchors.bottomMargin: 132
Column {
anchors.centerIn: parent
width: root.width
spacing: 20
// Artwork + halo shadow 36/#00000099/8 (:175-202).
Item {
width: root.width
height: root.artSize
Rectangle {
width: root.artSize
height: root.artSize
x: (parent.width - width) / 2
y: (parent.height - height) / 2 + 8
radius: 8
color: "#99000000"
layer.enabled: !root._noShaders
layer.effect: MultiEffect {
blurEnabled: true
blurMax: 36
blur: 1.0
}
}
RoundedImage {
width: root.artSize
height: root.artSize
x: (parent.width - width) / 2
y: (parent.height - height) / 2
radius: 8
source: QbzPlayer.npArtworkPath
}
}
// Track info (:205-249). Explicit badge OMITTED — Slint has no
// `explicit` in NowPlayingState here either (:209-210).
Column {
width: root.width
spacing: 6
Text {
width: parent.width
text: QbzPlayer.npTitle
color: "#ffffff"
font.pixelSize: 28
font.weight: Font.Bold
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
Text {
width: parent.width
text: QbzPlayer.npArtist
color: "#b3ffffff"
font.pixelSize: 18
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
Text {
visible: QbzPlayer.npAlbum !== ""
width: parent.width
text: QbzPlayer.npAlbum
color: "#80ffffff"
font.pixelSize: 14
font.italic: true
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
// Quality badge, self-hides when tier == "", padding-top 12.
Item {
visible: QbzPlayer.npQualityTier !== ""
width: parent.width
height: visible ? badge.implicitHeight + 12 : 0
QualityBadgeFull {
id: badge
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
tier: QbzPlayer.npQualityTier
detail: QbzPlayer.npQualityDetail
}
}
}
}
}
// No-shader renderer detection, verbatim from SpectrumBand.qml.
readonly property bool _noShaders: GraphicsInfo.api === GraphicsInfo.Software
|| GraphicsInfo.api === GraphicsInfo.Null
}
+102
View File
@@ -0,0 +1,102 @@
// StaticPanel — FOCUS mode 1, the calm centered now-playing card (§6.1 of
// the 2026-08-02 immersive-port contract), port of
// crates/qbz-ui/ui/immersive/StaticPanel.slint:25-111.
//
// NON-reactive: it does NOT read QbzViz.energy at all (no art-scale, no glow
// halo, no VizSettle). The ONLY motion is the 4-bar equalizer inside the
// "Now Playing" indicator — a fixed-period loop, allowed in the static panel
// (Slint header :4-9).
//
// DIFF vs AlbumReactivePanel (dropped): the energy scalars, the glow
// Rectangle, the reactive 100ms grow eases, the *1.25 breathing footprint.
// KEPT: the homologated ImmersiveTrackMeta, the centered layout (pad-top 52 /
// pad-bottom 132), spacing 20 (AlbumReactive's is 40), the static art drop
// shadow 32/#00000080/8, radius 8 (:84-103).
//
// baseArt = max(180, min(vh-364, vw*0.62, 640, native)) (:50-51) — static
// has NO breathing growth so the height budget divides by 1.0; reserve 364 =
// pad-top(52) + spacing(20) + info(160) + player clearance(132). The 132
// clearance is KEPT identical to AlbumReactivePanel (same floating player
// bar); if it changes, both move together (:33-36).
import QtQuick
import QtQuick.Effects
import QtQuick.Window
import com.blitzfc.qbz
import "../theme"
Item {
id: root
// Native source resolution — cap so the cover is never UPSCALED beyond
// its own size (:46-49). Hidden probe on the same file:// cache path
// (shared image cache; see AlbumReactivePanel.qml). Empty art -> 0 ->
// baseArt 180, as Slint.
Image {
id: artProbe
visible: false
source: QbzPlayer.npArtworkPath
}
readonly property real srcNative: artProbe.status === Image.Ready
? artProbe.implicitWidth : 0
readonly property real baseArt: Math.max(180,
Math.min(Math.min(Math.min(root.height - 364,
root.width * 0.62), 640), root.srcNative))
// Fixed size — no art-scale (:53).
// No-shader renderer detection, verbatim from SpectrumBand.qml.
readonly property bool _noShaders: GraphicsInfo.api === GraphicsInfo.Software
|| GraphicsInfo.api === GraphicsInfo.Null
// Slint VerticalLayout padding-top 52 / padding-bottom 132 /
// alignment center / spacing 20 (:62-73).
Item {
anchors.fill: parent
anchors.topMargin: 52
anchors.bottomMargin: 132
Column {
anchors.centerIn: parent
width: root.width
spacing: 20
// Artwork block — FIXED footprint, no *1.25 (:76-79).
Item {
width: root.width
height: root.baseArt
// Static drop shadow 32/#00000080/8 (:91-93) — the two-layer
// CSS shadow collapses to one blur, same accepted gap as
// AlbumReactivePanel.
Rectangle {
width: root.baseArt
height: root.baseArt
x: (parent.width - width) / 2
y: (parent.height - height) / 2 + 8
radius: 8
color: "#80000000"
layer.enabled: !root._noShaders
layer.effect: MultiEffect {
blurEnabled: true
blurMax: 32
blur: 1.0
}
}
RoundedImage {
width: root.baseArt
height: root.baseArt
x: (parent.width - width) / 2
y: (parent.height - height) / 2
radius: 8
source: QbzPlayer.npArtworkPath
}
}
// Track info (shared, homologated across the panels, :106-109).
ImmersiveTrackMeta {
width: root.width
equalizerTint: "#7c3aed"
}
}
}
}
@@ -0,0 +1,73 @@
// WaveBedPanel — FOCUS mode 6, the full-screen VERTICAL-mirror waveform
// (§6.1 of the 2026-08-02 immersive-port contract), port of
// crates/qbz-ui/ui/immersive/ImmersiveWaveBedPanel.slint:24-74.
//
// OPAQUE #000 (:49) — hides the atmosphere, intended (§5.1). This is NOT the
// Spectrum panel: every column is centered on the viewport Y midline and
// grows up AND down symmetrically (the full-screen twin of the dock's
// WaveCol, :5-13).
//
// GEOMETRY (:52-72): 48 downsampled L-channel columns (waveform indices
// 0..235 step 5 — the L half is samples 0..255), slot = vw/48, bar =
// slot-4, x = i*slot + 2, colH = max(2, amp*0.7*vh) with amp = min(1, |w|),
// y = (H - h)/2 (the vertical mirror), radius 1, color
// QbzShell.ambientPrimary (the Slint ImmersiveState.spectrum-primary — same
// album-derived pipeline, SpectrumBand.qml:109-113 precedent).
//
// FFT CONSUMPTION (trap 6): ONE VizSettle on QbzViz.waveform; the
// |sample|-fold + downsample happens ONCE per publish in the settle's
// `target` binding (48 ops — the SpectrumBand.qml:239-248 idiom), never per
// column per frame. The inter-frame settle is VizSettle's (the Slint
// `animate col-h 70ms` equivalent, :31).
//
// The bottom-right ImmersiveSongCard is mounted by ImmersiveView (layer 4),
// NOT inside this panel (:15-16).
import QtQuick
import com.blitzfc.qbz
import "../shell"
Rectangle {
id: root
color: "#000000"
readonly property real slot: root.width / 48
// Max half-amplitude reach ~0.35*H up and down (full bar = 0.7*H tall).
readonly property real maxColH: root.height * 0.7
// ONE VizSettle for the whole stream (trap 6). The |sample| envelope is
// folded HERE, once per publish, instead of an abs+min per column per
// rendered frame (VizSettle branches on Array.isArray and skips its own
// copy for this arm).
VizSettle {
id: waveSettle
live: root.visible && QbzImmersive.open
target: {
var s = QbzViz.waveform
var out = new Array(48)
for (var i = 0; i < 48; ++i) {
var v = s[i * 5] || 0
if (v < 0) v = -v
out[i] = v > 1 ? 1 : v
}
return out
}
}
// STATIC Repeater model (48 columns); only each column's HEIGHT binds to
// the stream.
Repeater {
model: 48
delegate: Rectangle {
required property int index
x: index * root.slot + 2
width: root.slot - 4
height: Math.max(2, waveSettle.at(index) * root.maxColH)
// Centered on the viewport Y-mid -> vertical mirror (:37-38).
anchors.verticalCenter: parent.verticalCenter
radius: 1
color: QbzShell.ambientPrimary
}
}
}