feat(qt): immersive atmosphere and focus panels (B3)
km
This commit is contained in:
11 files changed
+1434
-22
No files matched your search
+13
-2
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user