Files
qbz/crates/qbz-qt/qml/immersive/AlbumReactivePanel.qml
T

179 lines
8.0 KiB
QML

// 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"
}
}
}
}