Files
qbz/crates/qbz-qt/qml/immersive/ImmersiveView.qml
T
vicrodh 31da9c57bb fix(qt): kiosk feedback round — chrome, search, album, artists, lyrics
Address the owner's kiosk feedback on top of the release hardening:

- Chrome: the back bar is a drag surface and shrank to 44px with one shared
  button form for Back/Forward/Connect/Cast; Qobuz Connect and Cast are a
  permanent cluster in the bar; the dead Share-logs button left the kiosk
  Settings header.
- Transport bar: the hidden overflow menu is gone; Volume is always visible
  with a vertical sheet, and Desktop mode has its own button.
- Now Playing: one centred transport row (New-bar order); lyrics are centred
  (shared LyricsLinesView gains an opt-in centered, desktop default false).
- Local Library: the media funnel and an Open (ephemeral) entry point reach
  the kiosk; Artists is its own multi-column round-card grid (kiosk-only, the
  desktop LocalArtistsTab is untouched); the Artists drill-down query no
  longer hangs off a stale derived guard.
- Search: the All-tab dashboard shelves render again — they were mounted in a
  Loader Component that read the delegate id under reuse and bound an empty
  model; they are direct children now.
- Album detail: icon actions the height of the quality badge plus play next /
  later / queue, an inline floating quality badge, and zebra rows; both album
  headers show a quality badge.
- Immersive: a quiet auto-hiding kiosk exit moved to the bottom, clear of the
  window controls.

Regression coverage in scripts/test_qt_kiosk_feedback.mjs (wired into the
runtime gate). Verified on a private authenticated Xvfb; QML audits 6/6,
Node gate 5/5, release build 0 warnings.

cc
2026-09-07 12:33:28 -06:00

837 lines
38 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ImmersiveView — the immersive root overlay (ImmersiveView.slint:1186-1705),
// the Qt port of contract 2026-08-02-immersive-port §5.1-§5.5. Mounted by
// AppShell AFTER QbzToast and BEFORE ArtPreviewOverlay/QbzTooltip (§5.1
// declaration-order z convention).
//
// BLOCK B2+B3+B4+B5 SCOPE: the overlay shell (root + click-blocker + chrome +
// auto-hide + exit paths 2-4 + the fullscreen guard; the seek arrows later
// moved to the hotkeys dispatcher — 2026-08-03 hotkeys-port §1.3) PLUS the
// B3 panels (the ImmersiveAtmosphere underlay, the five FOCUS panels Album
// Reactive / Static / Coverflow / Spectrum / Wave Bed, the layer-4
// ImmersiveSongCard) PLUS the B4 panels: FOCUS Lyrics (mode 4,
// LyricsFocusPanel) and Queue (mode 5, QueueTabsPanel), and the full §5.6
// SPLIT layout (50/50: artwork + ImmersiveTrackMeta LEFT, the Lyrics /
// TrackInfo / Suggestions / Queue panels RIGHT) with the §5.5 entry loads —
// PLUS the B5 layer-5 ImmersivePlayerBar (TinyBar/VolumeBar, §5.7/§5.8) and
// the B5 layer-7 ImmersiveSearchCortinilla (§3.4).
//
// Layer order bottom->top (§5.1):
// 1. root color #0a0a0b, clip (:1199-1200)
// 1b. ShaderSceneLayer (block A1, :1293-1300) — the GPU shader scene,
// bottom-most VISUAL layer when QbzShaderScene.scene > 0, replacing
// the atmosphere AND the FOCUS/SPLIT panels (their gates below gain
// `scene === 0`, parity :1313-1405); chrome stays on top
// 2. ImmersiveAtmosphere — the underlay while NO scene is active
// (gated `scene === 0`; in v1 the shader-mode==0 gate was constant,
// 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. FOCUS panels (viewMode==0 && mode==N) / the SPLIT layout (viewMode==1)
// 4. ImmersiveSongCard (Wave Bed, full Lyrics, scopes and shader scenes;
// does NOT fade with the chrome)
// 5. ImmersivePlayerBar (B5, :1607-1616) — centered, y = height-90-24,
// fades with the chrome like the header
// 6. ImmersiveHeader band
// 7. ImmersiveSearchCortinilla (B5, :1695-1705) — topmost
import QtQuick
import QtQuick.Effects
import QtQuick.Window
import com.blitzfc.qbz
import "../controls"
import "../shell"
import "../theme"
Item {
id: root
QbzTheme { id: theme }
// Self-gated on the bridge open flag (§5.1: closed = invisible,
// non-interactive, zero-cost). Every open/close transition funnels
// through QbzImmersive's open setter (§3/D3) — this item only reacts.
visible: QbzImmersive.open
clip: true
// focus: true while open so the root's Keys handler (Escape) is live;
// the 30ms grab timer below re-asserts it after mount (:1259-1267).
focus: QbzImmersive.open
// §5.4 text-input gate (kept per 2026-08-03 hotkeys-port §1.3 — the
// seek arrows and the Shift+I Shortcut it used to feed are GONE,
// absorbed into the dispatcher's rebindable focus.seek*/ui.focusMode
// actions; the central gate in QbzHotkeys.keyPressed now does their
// job. Retained as the immersive surface's own "a text input is
// focused" flag).
readonly property bool textInputActive: header.searchActive
// --- §5.4 fullscreen / shell guard ------------------------------------
// Captured on the OPEN edge; on the close edge FullScreen is dropped
// only if the window was NOT fullscreen before (port of
// main.rs:8660-8716, as QML state — no 150ms timer, D3).
property bool wasFullscreen: false
// The kiosk host sets this true so closing Immersive never changes an
// appliance window's fullscreen state. Desktop leaves the default false.
property bool preKiosk: false
Connections {
target: QbzImmersive
function onOpenChanged() {
var w = root.Window.window
if (QbzImmersive.open) {
root.wasFullscreen = w !== null && w.visibility === Window.FullScreen
root.wake()
focusGrab.restart()
} else {
if (!root.wasFullscreen && !root.preKiosk
&& w !== null && w.visibility === Window.FullScreen)
w.visibility = Window.Windowed
// Focus hygiene (measured 2026-08-02 over RFB): when the
// overlay hides while its search field holds activeFocus, Qt
// leaves activeFocus STRANDED on the now-invisible TextInput
// — the AppShell dispatcher's text-input gate would read
// that dead field as focused and swallow every hotkey.
// Hand focus to the SHELL ROOT (K8, 2026-08-03 hotkeys-port
// §1.4.2 — NOT contentItem): the shell root is the
// dispatcher's fallback focus item, so the pipeline keeps
// receiving keys with no click in between.
var p = root
while (p.parent) {
if (p.parent.isQbzShellRoot === true) {
p.parent.forceActiveFocus()
break
}
p = p.parent
}
}
}
}
// 30ms focus-grab after mount (:1259-1267) — focusing synchronously
// inside the open edge races the visibility transition.
Timer {
id: focusGrab
interval: 30
repeat: false
onTriggered: root.forceActiveFocus()
}
// --- Layer 1: root surface ---------------------------------------------
Rectangle {
anchors.fill: parent
color: "#0a0a0b"
}
// --- Layer 1b: the shader scene (block A1, :1293-1300) ------------------
// Bottom-most VISUAL layer, ABOVE the root surface, below everything
// else. Self-gated: it renders and ticks only while a scene is active
// (scene > 0 && open && available — see the file's header). The
// atmosphere below and every FOCUS/SPLIT panel gate themselves off on
// `scene === 0`, so the scene owns the background (parity :1313-1405).
ShaderSceneLayer {
anchors.fill: parent
visible: QbzShaderScene.scene > 0
}
// --- Layer 2: the atmosphere underlay (§5.1, :1313-1321) ----------------
// The underlay WHILE NO SCENE IS ACTIVE (the Slint shader-mode==0 gate,
// :1313). 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
visible: QbzShaderScene.scene === 0
&& !(QbzImmersive.viewMode === 0
&& (QbzImmersive.mode === 7 || QbzImmersive.mode === 8
|| QbzImmersive.mode === 9))
source: QbzImmersive.atmosphereUrl
fallbackSource: QbzPlayer.npArtworkPath
animated: visible && QbzPlayer.npPlaying && QbzImmersive.open
dim: 0.15
}
// --- Layer 2b: the click-blocker (§5.1, load-bearing) ------------------
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.AllButtons
hoverEnabled: true
// C1 fullscreen integration (contract 03 §5, divergence D2 — not a
// Slint parity item): blank the cursor when the chrome has
// auto-hidden in FullScreen; wake() restores it. Windowed mode keeps
// the cursor always. Note: panels with their own cursor-less
// MouseAreas above this layer still show the default arrow — the
// blank covers the bare-atmosphere regions, which is where a
// stationary cursor rests while watching fullscreen.
cursorShape: (root.Window.window !== null
&& root.Window.window.visibility === Window.FullScreen
&& !root.chromeVisible) ? Qt.BlankCursor : Qt.ArrowCursor
// A press on the backdrop WAKES the chrome: touch panels have no
// hover, so this is the only way a kiosk finger brings the exit back.
onPressed: function (mouse) { mouse.accepted = true; root.wake() }
onReleased: function (mouse) { mouse.accepted = true }
onClicked: function (mouse) { mouse.accepted = true }
onDoubleClicked: function (mouse) { mouse.accepted = true }
onWheel: function (wheel) { wheel.accepted = true }
}
// --- §5.3 auto-hide / wake ----------------------------------------------
// hideTimer 6000ms (:1271); hides chrome only when !immSearchOpen &&
// !pointerInChrome; wake on any REAL mouse move (delta guard below);
// 300ms fades on the chrome itself.
//
// 2026-08-15 C1 (contract 03 §4): pointerInChrome is the union of the
// REAL chrome rects (header ∪ player bar ∪ song card), not the Slint
// y-only bands (:1196-1197). The bands kept the chrome awake over dead
// strips (the 24px side margins, the 18px above the bar) which read as
// "mouse-sensitive in some zones", and spanned the full width.
property bool chromeVisible: true
readonly property bool pointerInWindow: rootHover.hovered
readonly property bool pointerInChrome: rootHover.hovered
&& (pointInChromeItem(header) || pointInChromeItem(playerBar)
|| pointInChromeItem(songCard))
function pointInChromeItem(item) {
if (!item.visible)
return false
var p = item.mapFromItem(root, rootHover.point.position.x,
rootHover.point.position.y)
return p.x >= 0 && p.y >= 0 && p.x <= item.width && p.y <= item.height
}
function wake() {
chromeVisible = true
hideTimer.restart()
}
// C1 macOS traffic lights (contract 03 §6): NOTHING TO CLEAR ANY MORE.
//
// The band used to be inset to their right (`leftMargin: 24 + inset`,
// the shell HeaderBar's rule) because the view trigger sat at the band's
// left edge — x ~ 7-85, exactly where the lights float. The trigger moved
// to the RIGHT of the band, beside the window-controls capsule, so the
// left half is empty and the lights have that corner to themselves.
//
// Dropping the whole row below the lights was tried and reverted: it moved
// the search field and the capsule down with it to solve a problem only
// the trigger had.
// Last hover position that WOKE the chrome. Load-bearing delta guard:
// Qt re-delivers HoverMove at the unchanged position whenever the scene
// under a stationary cursor updates (constant on any animated surface —
// and under the VNC platform it storms every frame, measured 2026-08-02).
// Without the guard each re-delivery restarts hideTimer and the chrome
// never auto-hides. "Wake on any mouse move" (:1283-1286) means an actual
// position CHANGE.
property point lastHoverPos: Qt.point(-1, -1)
HoverHandler {
id: rootHover
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
onPointChanged: {
var p = rootHover.point.position
if (Math.abs(p.x - root.lastHoverPos.x) > 0.5
|| Math.abs(p.y - root.lastHoverPos.y) > 0.5) {
root.lastHoverPos = p
root.wake()
}
}
}
Timer {
id: hideTimer
interval: 6000
repeat: false
running: root.visible
onTriggered: {
if (!QbzImmersive.immSearchOpen && !root.pointerInChrome)
root.chromeVisible = false
else
restart() // suppressed — re-check after another interval
}
}
// --- Layer 3: the FOCUS panels (B3 + B4) --------------------------------
// FOCUS: one panel per mode, gated viewMode==0 && mode==N, full-viewport
// — each panel carries its own Slint-internal reserves (pad-top 52/70,
// the 132px player clearance; §6.1/§6.2/§6.6). Block A1 adds the
// `scene === 0` gate to every panel (parity :1313-1405 — a shader scene
// REPLACES the panels; chrome stays on top).
AlbumReactivePanel {
anchors.fill: parent
visible: QbzShaderScene.scene === 0
&& QbzImmersive.viewMode === 0 && QbzImmersive.mode === 0
}
StaticPanel {
anchors.fill: parent
visible: QbzShaderScene.scene === 0
&& QbzImmersive.viewMode === 0 && QbzImmersive.mode === 1
}
// LOADER-GATED, and this one is not a nicety — it is the fix for a
// 28-second frozen UI at startup.
//
// This overlay is mounted UNCONDITIONALLY by AppShell (see its comment
// there: while closed it is "an invisible, non-interactive Item"), and
// `visible: false` stops an item from PAINTING, never from being BUILT.
// CoverflowPanel's model is the flat whole-queue document
// (`QbzQueue.coverflowJson` — unlike the queue PANEL's, it is not
// paginated), and it runs TWO Repeaters over it: the forward list and a
// reversed copy. So every launch built two CoverCards per queue entry,
// for a panel nobody had opened.
//
// Measured 2026-08-17 on a real session with 1935 persisted queue rows:
// ~4000 CoverCards on the UI thread at boot. The app came up frozen for
// ~28 s at 120% CPU — deterministic, identical across every build, and
// "fixed" by moving ~/.local/share/qbz aside, which is what made it look
// like corrupted state rather than a queue the UI could not carry. The
// stacks showed the QML engine forcing GC while allocating strings.
//
// `active` repeats the visibility expression on purpose: the panel must
// not exist while it is not the immersive mode being shown.
Loader {
anchors.fill: parent
active: QbzShaderScene.scene === 0
&& QbzImmersive.viewMode === 0 && QbzImmersive.mode === 2
visible: active
sourceComponent: CoverflowPanel { }
}
SpectrumPanel {
anchors.fill: parent
visible: QbzShaderScene.scene === 0
&& QbzImmersive.viewMode === 0 && QbzImmersive.mode === 3
}
WaveBedPanel {
anchors.fill: parent
visible: QbzShaderScene.scene === 0
&& QbzImmersive.viewMode === 0 && QbzImmersive.mode === 6
}
ReactiveRingsPanel {
anchors.fill: parent
visible: QbzShaderScene.scene === 0
&& QbzImmersive.viewMode === 0 && QbzImmersive.mode === 9
}
Loader {
anchors.fill: parent
active: QbzShaderScene.scene === 0
&& QbzShell.gpuTier
&& QbzImmersive.viewMode === 0 && QbzImmersive.mode === 7
visible: active
sourceComponent: Item {
// A single host-generated blurred cover, without the animated
// four-layer atmosphere. The dark veil makes one album-derived
// high-luminance trace colour hold over the whole viewport.
Rectangle { anchors.fill: parent; color: "#090909" }
Image {
anchors.fill: parent
source: QbzImmersive.atmosphereUrl
fillMode: Image.PreserveAspectCrop
asynchronous: true
smooth: true
mipmap: true
opacity: status === Image.Ready ? 0.78 : 0.0
}
Rectangle { anchors.fill: parent; color: "#66000000" }
ScopePanel {
width: Math.max(1, Math.min(parent.width - 96, parent.height - 196))
height: width
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: 132
scopeMode: 0
albumContrast: true
}
}
}
Loader {
anchors.fill: parent
active: QbzShaderScene.scene === 0
&& QbzShell.gpuTier
&& QbzImmersive.viewMode === 0 && QbzImmersive.mode === 8
visible: active
sourceComponent: Item {
Rectangle { anchors.fill: parent; color: "#000000" }
ScopePanel {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.leftMargin: 48
anchors.rightMargin: 48
anchors.topMargin: 96
anchors.bottomMargin: 132
scopeMode: 1
albumContrast: true
}
}
}
// FOCUS Lyrics (mode 4, B4 §6.6): the one-line variant. It shares the
// immersive LyricsSyncEngine with the split lyrics mount (trap 22 — the
// gate covers BOTH lyric surfaces and nothing else). No entry load: Qt
// fetches lyrics automatically per track (§5.5).
LyricsFocusPanel {
anchors.fill: parent
visible: QbzShaderScene.scene === 0
&& QbzImmersive.viewMode === 0 && QbzImmersive.mode === 4
lines: root.immLyricsLines
synced: root.immLyricsSynced
status: root.immLyricsStatus
sync: immLyricsEngine
}
// FOCUS Queue (mode 5, B4 §6.5): the same QueueTabsPanel as the split
// placement, full-screen (centered: false -> min(vw-96, 720) column).
// Loader-gated for the reason spelled out on CoverflowPanel above: it
// reads the same unpaginated whole-queue document.
Loader {
anchors.fill: parent
active: QbzShaderScene.scene === 0
&& QbzImmersive.viewMode === 0 && QbzImmersive.mode === 5
visible: active
sourceComponent: QueueTabsPanel { centered: false }
}
// --- The shared immersive lyrics engine + document (§6.6, trap 22) ------
// ONE LyricsSyncEngine for all immersive lyric surfaces (FOCUS mode 4,
// legacy SPLIT panel 0 and cinematic SPLIT panel 5); the gate extends
// the §6.6 formula
// (lyrics_sync.rs:214-217 parity). The QbzLyrics doc fields are parsed
// once here and handed to both mounts.
readonly property var immLyricsDoc: {
try {
return JSON.parse(QbzLyrics.docJson)
} catch (e) {
return ({})
}
}
readonly property var immLyricsLines: root.immLyricsDoc.lines !== undefined
&& root.immLyricsDoc.lines !== null ? root.immLyricsDoc.lines : []
readonly property bool immLyricsSynced: root.immLyricsDoc.synced === true
readonly property int immLyricsStatus: root.immLyricsDoc.status !== undefined
? root.immLyricsDoc.status : 0
LyricsSyncEngine {
id: immLyricsEngine
gateOpen: QbzImmersive.open
&& QbzShaderScene.scene === 0 // A1: the scenes replace the panels
&& ((QbzImmersive.viewMode === 0 && QbzImmersive.mode === 4)
|| (QbzImmersive.viewMode === 1
&& (QbzImmersive.splitPanel === 0
|| QbzImmersive.splitPanel === 5)))
}
// Every doc commit resets the ladder and re-lands on the right line (the
// LyricsPanel.qml:60-66 idiom — the Slint kick() on commit/panel open).
Connections {
target: QbzLyrics
function onDocJsonChanged() {
immLyricsEngine.reset()
immLyricsEngine.kick()
}
}
// --- Layer 3 (viewMode==1): the SPLIT layout (§5.6/D1) -------------------
// HorizontalLayout padding-top 56 bottom 132 sides 56 spacing 48
// (:1423-1427); LEFT column EXPLICIT width (root.width - 160)/2 — the
// shipped Slint is a true 50/50 (:1435,:1516; the stale "55/45"/"11:9"
// comments at :1406-1410,:1503-1510 are wrong, D1). Artwork + the B3
// ImmersiveTrackMeta LEFT; the active data panel RIGHT, on a pad-20
// clipped plate with NO glass backing (owner removed it,
// :1484-1487,:1517-1518).
Item {
id: splitRoot
anchors.fill: parent
// A1: a shader scene replaces the SPLIT layout too (:1416 parity).
visible: QbzShaderScene.scene === 0 && QbzImmersive.viewMode === 1
&& QbzImmersive.splitPanel >= 0 && QbzImmersive.splitPanel <= 3
// D2: re-request the large art when the SPLIT layout becomes visible
// (leftCol's own request is gated on this visibility).
onVisibleChanged: if (visible) leftCol.requestArtSize()
// col-h: the column height below the header/player clearance
// (:1415). colW: the explicit half (:1435).
readonly property real colW: (root.width - 160) / 2
readonly property real colH: root.height - 56 - 132
Row {
anchors.fill: parent
anchors.topMargin: 56
anchors.bottomMargin: 132
anchors.leftMargin: 56
anchors.rightMargin: 56
spacing: 48
// LEFT (50%): artwork + the shared metadata block, centered in
// its half (:1433-1501), spacing 28.
Item {
id: leftCol
width: splitRoot.colW
height: parent.height
// D2 (contract 04 §4): size-aware large art — prefer the
// size-resolved large feed, fall back to the small one while
// it resolves (the StaticPanel/AlbumReactivePanel pattern).
readonly property string artSource: QbzPlayer.npArtworkPathLarge !== ""
? QbzPlayer.npArtworkPathLarge : QbzPlayer.npArtworkPath
// The artSize slot expression WITHOUT the native cap — the
// request must reflect the SLOT; the probe cap below stays
// the final upscale safety. Bucketed in Rust (one re-resolve
// per variant tier crossed, none per pixel).
readonly property real artSlot: Math.max(240,
Math.min(splitRoot.colW, splitRoot.colH - 200))
function requestArtSize() {
if (splitRoot.visible)
QbzPlayer.requestNpArtworkSize(Math.round(leftCol.artSlot))
}
onArtSlotChanged: requestArtSize()
Component.onCompleted: requestArtSize()
// Native source resolution (physical px) of the active
// cover, so the art is never UPSCALED beyond its own size
// (:1449-1452) — the AlbumReactivePanel.qml artProbe idiom
// (a hidden probe Image on the same file:// cache path; the
// shared image cache makes the second decode free).
Image {
id: splitArtProbe
visible: false
source: leftCol.artSource
}
readonly property real srcNative: splitArtProbe.status === Image.Ready
? splitArtProbe.implicitWidth : 0
// max(240, min(colW, colH-200, native)) (:1453-1455).
readonly property real artSize: Math.max(240,
Math.min(splitRoot.colW, splitRoot.colH - 200, leftCol.srcNative))
Column {
anchors.centerIn: parent
width: parent.width
spacing: 28
// Artwork square, radius 12, shadow 40/#00000099/12
// (:1458-1476).
Item {
width: parent.width
height: leftCol.artSize
// Static drop shadow, offset-y 12 — the
// CoverflowPanel MultiEffect idiom (blur 40 of
// blurMax 64); no shaders -> a plain offset rect.
Rectangle {
x: (parent.width - leftCol.artSize) / 2
y: 12
width: leftCol.artSize
height: leftCol.artSize
radius: 12
color: "#99000000"
layer.enabled: !root._noShaders
layer.effect: MultiEffect {
blurEnabled: true
blurMax: 64
blur: 0.625 // 40/64
}
}
// Neutral placeholder while the cover is unresolved.
Rectangle {
visible: QbzPlayer.npArtworkPath === ""
x: (parent.width - leftCol.artSize) / 2
width: leftCol.artSize
height: leftCol.artSize
radius: 12
color: "#14ffffff"
}
RoundedImage {
x: (parent.width - leftCol.artSize) / 2
width: leftCol.artSize
height: leftCol.artSize
visible: QbzPlayer.npArtworkPath !== ""
radius: 12
source: leftCol.artSource
}
}
// The shared metadata block (:1484-1500). The Slint
// meta-plate is padding-only (the glass fill was removed
// by the owner) — 22px side gutters so the elided rows
// have a real width to truncate against.
ImmersiveTrackMeta {
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width - 44
}
}
}
// RIGHT (50%): the active split panel on a clipped plate, NO
// glass backing (:1511-1596). The panel's inner box is inset by
// the 20px pad on all sides (:1522-1527).
Item {
width: splitRoot.colW
height: parent.height
clip: true
Item {
anchors.fill: parent
anchors.margins: 20
clip: true
// splitPanel 0: the shared lyrics lines view with the
// §6.6 immersive knobs EXACT (:1528-1549). The sizes are
// computed from the WINDOW width, not the column
// (:1533-1537). Slint's show-attribution: false has NO
// Qt twin (LyricsLinesView.qml has no such property and
// no attribution row — D13, nothing to set).
LyricsLinesView {
anchors.fill: parent
visible: QbzImmersive.splitPanel === 0
lines: root.immLyricsLines
synced: root.immLyricsSynced
showTranslation: QbzLyrics.showTranslation
sync: immLyricsEngine
sidePadding: 8
lineGap: Math.round(Math.max(16,
Math.min(root.width * 0.014, 26)))
sizeInactive: Math.round(Math.max(24,
Math.min(root.width * 0.018, 34)))
sizeActive: Math.round(Math.max(28,
Math.min(root.width * 0.022, 42)))
dimmingMode: QbzLyrics.dimmingMode
autoFollow: true
uppercase: QbzLyrics.uppercase
fontIndex: QbzLyrics.fontIndex
// Solid white active line in immersive
// (data-panels.md §2, :1548). The unsung part of the
// active line drops to 35% white so the karaoke fill
// is actually visible (2026-08-31 visual cleanup).
activeColor: "#ffffff"
inactiveColor: "#59ffffff"
onSeekRequested: function (timeMs) {
if (QbzPlayer.npDurationSecs > 0)
QbzPlayer.seek(timeMs / 1000 / QbzPlayer.npDurationSecs)
}
}
// splitPanel 1: the inline Track Info credits panel
// (§6.3). Entry load at mount + reload on track change
// below (:1557-1560, §5.5).
TrackInfoPanel {
anchors.fill: parent
visible: QbzImmersive.splitPanel === 1
onVisibleChanged: if (visible)
QbzAlbum.openTrackInfo(QbzPlayer.npTrackId)
}
// splitPanel 2: the live Suggestions panel (§6.4). Entry
// load at mount + reload on track change below
// (:1574-1577, §5.5).
// Loader-gated like the queue panels: a closed panel has
// no business being built. Mounting IS becoming visible
// now, so the load hook is a plain Component.onCompleted
// — the `onVisibleChanged` pair existed only because the
// panel was built eagerly and its mount meant nothing.
Loader {
anchors.fill: parent
active: QbzImmersive.splitPanel === 2
visible: active
sourceComponent: SuggestionsPanel {
Component.onCompleted: QbzSuggestions.load(QbzPlayer.npTrackId)
}
}
// splitPanel 3: the Queue + History panel, centered in
// the right half (§6.5; same component as the focus
// mount, centered: true fills the slot). The panel
// self-fires queuePanelOpened() when it becomes visible
// (:253-255).
// Loader-gated like the focus mount above — same
// component, same whole-queue document.
Loader {
anchors.fill: parent
active: QbzImmersive.splitPanel === 3
visible: active
sourceComponent: QueueTabsPanel { centered: true }
}
}
}
}
}
// The two cinematic SPLIT variants replace the legacy 50/50 composition
// with the supplied full-viewport artwork stage. One shared component
// guarantees that the background/card/waveform stay pixel-identical; only
// the card's right-hand content changes between metadata+seek and lyrics.
Loader {
anchors.fill: parent
active: QbzShaderScene.scene === 0
&& QbzImmersive.viewMode === 1
&& (QbzImmersive.splitPanel === 4
|| QbzImmersive.splitPanel === 5)
visible: active
sourceComponent: ReactiveSplitPanel {
lyricsMode: QbzImmersive.splitPanel === 5
lines: root.immLyricsLines
synced: root.immLyricsSynced
status: root.immLyricsStatus
sync: immLyricsEngine
}
}
// --- Layer 4: ImmersiveSongCard (§5.1, :1602-1605) ----------------------
// Keep the compact bottom-right metadata over shader backgrounds,
// Wave Bed, full-screen Lyrics and the two scope views. The lyrics surface
// deliberately keeps one line at a time, so the card supplies the
// otherwise absent track context without competing with the active line.
// shader-mode was constant 0 so the first arm was dead (trap 19); A1
// makes it real. NON-interactive; does NOT fade with the auto-hide
// chrome (no opacity binding here — that is deliberate).
ImmersiveSongCard {
id: songCard
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.rightMargin: 24
anchors.bottomMargin: 24
visible: (QbzShaderScene.scene > 0
|| (QbzImmersive.viewMode === 0
&& (QbzImmersive.mode === 4 || QbzImmersive.mode === 6
|| QbzImmersive.mode === 7 || QbzImmersive.mode === 8
|| QbzImmersive.mode === 9)))
&& QbzPlayer.npHasTrack
}
// §5.5: TrackInfo + Suggestions RELOAD on track change while their panel
// is mounted (:1557-1560, :1574-1577 — the Slint `changed live-track-id`
// watchers; the dedup guard lives in the Rust loaders).
Connections {
target: QbzPlayer
function onNpTrackIdChanged() {
if (!root.visible || QbzImmersive.viewMode !== 1
|| QbzShaderScene.scene !== 0) // A1: split panels unmounted under a scene
return
if (QbzImmersive.splitPanel === 1)
QbzAlbum.openTrackInfo(QbzPlayer.npTrackId)
else if (QbzImmersive.splitPanel === 2)
QbzSuggestions.load(QbzPlayer.npTrackId)
}
}
// No-shader renderer detection, verbatim from SpectrumBand.qml (the
// SPLIT artwork drop shadow is a MultiEffect blur; no shaders -> plain
// offset rect).
readonly property bool _noShaders: GraphicsInfo.api === GraphicsInfo.Software
|| GraphicsInfo.api === GraphicsInfo.Null
// --- Exit paths 3 (§5.4, §7) -------------------------------------------
// Escape on the root: dismisses the search cortinilla FIRST when open
// (§3.4), exits immersive otherwise. The search field's own Escape
// declines and propagates here (ImmersiveHeader.qml comment).
Keys.onEscapePressed: function (event) {
if (QbzImmersive.immSearchOpen)
QbzImmersive.dismissSearch()
else
QbzImmersive.open = false
event.accepted = true
}
// Block A1: `g` cycles the SHIPPED shader scenes (0→1→2→3→4→5→7→0 with
// Plasma A2, Ribbon A3 and Line Bed A4 shipped — the ring lives in
// QbzShaderScene.cycleScene; Tunnel Flow 8 is menu-only, NOT in the
// ring). No-op on tiers where the scenes can't run
// (QbzShell.shaderScenesAvailable — the same flag the FOCUS menu rows
// check), and the textInputActive gate keeps a 'g' typed in the search
// field from cycling (§5.4). wake() on cycle, like Slint. The key is
// ACCEPTED even when unavailable — Slint's `return accept` — so it never
// falls through to another handler.
Keys.onPressed: function (event) {
if (event.key === Qt.Key_G && !root.textInputActive) {
if (QbzShell.shaderScenesAvailable) {
QbzShaderScene.cycleScene()
root.wake()
}
event.accepted = true
}
}
// The seek arrows are GONE (2026-08-03 hotkeys-port §1.3): ±5s/±10s now
// fire as the REBINDABLE focus.seekForward/Back/Long actions through the
// AppShell dispatcher, with the Context::Immersive gate and the central
// text-input gate (QbzHotkeys.keyPressed, same §1.3 math). Kept here per
// the same section: the Escape handler above, `textInputActive`, and the
// cortinilla arrows (ImmersiveHeader).
// --- Layer 5: the player bar (§5.7, :1607-1616) -------------------------
// Centered, y = height-90-24; fades with the auto-hide chrome on the
// SAME binding as the header (300ms), inert while hidden.
// C1: hit-enabled follows the STATE (`chromeVisible`), not the animated
// opacity — gating on `opacity > 0` left the bar clickable mid-fade and
// re-triggered the Behavior on wake/hide races (contract 03 §2 item 4).
ImmersivePlayerBar {
id: playerBar
anchors.horizontalCenter: parent.horizontalCenter
y: root.height - 90 - 24
viewportWidth: root.width
opacity: root.chromeVisible ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 300 } }
enabled: root.chromeVisible
onWakeRequested: root.wake()
}
// --- Layer 6: the header band (§5.2) --------------------------------------
ImmersiveHeader {
id: header
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: 24
anchors.rightMargin: 24
anchors.topMargin: 16
height: 36
opacity: root.chromeVisible ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 300 } }
enabled: root.chromeVisible
}
// --- Layer 7: the search cortinilla (§3.4, :1695-1705) -------------------
// TOPMOST: above the header band and the player bar. Self-gated on
// QbzImmersive.immSearchOpen && query >= 2 chars; NOT chrome-faded (its
// open flag is exactly what suppresses the auto-hide, trap 10). The
// accent follows the atmosphere so the keyboard-active row matches.
ImmersiveSearchCortinilla {
anchors.fill: parent
accent: QbzShell.ambientAccent
}
// Appliance exit. It FOLLOWS the auto-hiding chrome now (owner feedback
// 2026-09-07: an always-on 64px white-bordered box was too loud on the
// art) — same `chromeVisible` state as the header and the player bar,
// hit-enabled only while shown, and it comes back on any pointer move,
// key or tap (the backdrop press below wakes the chrome for touch). The
// close path is unchanged: it retains fullscreen and restores shell
// focus via preKiosk. Quiet chrome: translucent fill, hairline border.
Rectangle {
visible: root.preKiosk
opacity: root.chromeVisible ? 1 : 0
enabled: root.chromeVisible
// BOTTOM-right (owner feedback 2026-09-07): at the top it collided
// with the immersive's own window controls. The player bar is
// horizontally centred at y=height-114, so the right corner is clear;
// this sits in it, clear of both the controls and the bar.
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.bottomMargin: 24
anchors.rightMargin: 16
width: 52
height: 52
radius: 8
color: "#99171717"
border.color: "#59ffffff"
border.width: 1
z: 3001
activeFocusOnTab: visible && root.chromeVisible
Accessible.role: Accessible.Button
Accessible.name: QbzSession.tr("Close", QbzSession.trRev)
Accessible.onPressAction: QbzImmersive.open = false
QbzIcon { anchors.centerIn: parent; width: 24; height: 24; name: "x"; tintName: "primary" }
Keys.onPressed: function (event) {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_Space) {
QbzImmersive.open = false
event.accepted = true
}
}
MouseArea { anchors.fill: parent; onClicked: QbzImmersive.open = false }
}
}