diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index 8ed9db2fe..62ebb47a2 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -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", diff --git a/crates/qbz-qt/qml/immersive/AlbumReactivePanel.qml b/crates/qbz-qt/qml/immersive/AlbumReactivePanel.qml new file mode 100644 index 000000000..ec5e7004d --- /dev/null +++ b/crates/qbz-qt/qml/immersive/AlbumReactivePanel.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" + } + } + } +} diff --git a/crates/qbz-qt/qml/immersive/CoverflowPanel.qml b/crates/qbz-qt/qml/immersive/CoverflowPanel.qml new file mode 100644 index 000000000..351d3aa91 --- /dev/null +++ b/crates/qbz-qt/qml/immersive/CoverflowPanel.qml @@ -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" + } + } + } +} diff --git a/crates/qbz-qt/qml/immersive/EqualizerBars.qml b/crates/qbz-qt/qml/immersive/EqualizerBars.qml new file mode 100644 index 000000000..e4e8fb3da --- /dev/null +++ b/crates/qbz-qt/qml/immersive/EqualizerBars.qml @@ -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 + } +} diff --git a/crates/qbz-qt/qml/immersive/ImmersiveAtmosphere.qml b/crates/qbz-qt/qml/immersive/ImmersiveAtmosphere.qml new file mode 100644 index 000000000..b5999ecee --- /dev/null +++ b/crates/qbz-qt/qml/immersive/ImmersiveAtmosphere.qml @@ -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" } + } + } +} diff --git a/crates/qbz-qt/qml/immersive/ImmersiveSongCard.qml b/crates/qbz-qt/qml/immersive/ImmersiveSongCard.qml new file mode 100644 index 000000000..0e6e0871f --- /dev/null +++ b/crates/qbz-qt/qml/immersive/ImmersiveSongCard.qml @@ -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 + } +} diff --git a/crates/qbz-qt/qml/immersive/ImmersiveTrackMeta.qml b/crates/qbz-qt/qml/immersive/ImmersiveTrackMeta.qml new file mode 100644 index 000000000..e571efa12 --- /dev/null +++ b/crates/qbz-qt/qml/immersive/ImmersiveTrackMeta.qml @@ -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 + } + } +} diff --git a/crates/qbz-qt/qml/immersive/ImmersiveView.qml b/crates/qbz-qt/qml/immersive/ImmersiveView.qml index 3cf2da287..2aeae723d 100644 --- a/crates/qbz-qt/qml/immersive/ImmersiveView.qml +++ b/crates/qbz-qt/qml/immersive/ImmersiveView.qml @@ -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 { diff --git a/crates/qbz-qt/qml/immersive/SpectrumPanel.qml b/crates/qbz-qt/qml/immersive/SpectrumPanel.qml new file mode 100644 index 000000000..64d3c3975 --- /dev/null +++ b/crates/qbz-qt/qml/immersive/SpectrumPanel.qml @@ -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 +} diff --git a/crates/qbz-qt/qml/immersive/StaticPanel.qml b/crates/qbz-qt/qml/immersive/StaticPanel.qml new file mode 100644 index 000000000..017cd9f17 --- /dev/null +++ b/crates/qbz-qt/qml/immersive/StaticPanel.qml @@ -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" + } + } + } +} diff --git a/crates/qbz-qt/qml/immersive/WaveBedPanel.qml b/crates/qbz-qt/qml/immersive/WaveBedPanel.qml new file mode 100644 index 000000000..efb2c8f3a --- /dev/null +++ b/crates/qbz-qt/qml/immersive/WaveBedPanel.qml @@ -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 + } + } +}