feat(qt): immersive root overlay, header band, entry/exit chrome (B2)

Root overlay self-gated on QbzImmersive.open with the load-bearing
click-blocker, 6s auto-hide with the VNC hover-storm delta guard,
fullscreen guard, exit paths 2-4, seek arrows, the header band (view
menu in Slint order, search field on the inert B1 surface, window
controls with the first FullScreen write in the Qt frontend), the
ViewModeMenu Immersive row live, Shift+I in Main.qml with the
text-input gate, QbzLineEdit fieldActive seam (D16). Panels are stubs;
B3/B4/B5 replace them. RFB evidence in scratchpad/immersive-b2/.

km
This commit is contained in:
vicrodh committed 2026-08-02 20:48:40 -06:00
1 parent 3c721a448e
commit 8bc255269d
7 files changed
+727 -10

No files matched your search

+6
View File
@@ -138,6 +138,12 @@ fn main() {
// AppShell mounts last. // AppShell mounts last.
"qml/shell/QconnectFlyout.qml", "qml/shell/QconnectFlyout.qml",
"qml/shell/QconnectDevModal.qml", "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.
"qml/immersive/ImmersiveHeader.qml",
"qml/immersive/ImmersiveView.qml",
"qml/theme/QbzIcon.qml", "qml/theme/QbzIcon.qml",
"qml/theme/QbzScrollBar.qml", "qml/theme/QbzScrollBar.qml",
"qml/theme/QbzSpinner.qml", "qml/theme/QbzSpinner.qml",
+12
View File
@@ -421,6 +421,18 @@ ApplicationWindow {
// Immersive toggle (2026-08-02 immersive-port contract §5.4 exit path 4 /
// §7): lives on the ApplicationWindow so it works open AND closed. The
// text-input gate (port of main.rs:1371-1375) makes it INERT while any
// text input has focus — with the immersive search field focused,
// Shift+I types "I". The FIRST `Shortcut {}` in the tree (BlacklistManagerView.qml:29
// confirms none existed), so no conflicts.
Shortcut {
sequence: "Shift+I"
enabled: !(window.activeFocusItem instanceof TextInput)
onActivated: QbzImmersive.toggle()
}
Loader { Loader {
id: screenLoader id: screenLoader
anchors.fill: parent anchors.fill: parent
@@ -96,6 +96,13 @@ Rectangle {
input.focus = false input.focus = false
} }
// Immersive-port contract D16 (2026-08-02, additive one-liner): the ONE
// read-only seam into the inner TextInput's focus. The immersive header
// gates Shift+I and the seek arrows on this ("inert while any text input
// has focus", §5.4) — the input itself stays deliberately unexposed
// (:186). No behavior change for existing consumers.
readonly property bool fieldActive: input.activeFocus
/// Focus the field from OUTSIDE the control — what a modal needs on open. /// Focus the field from OUTSIDE the control — what a modal needs on open.
/// ///
/// The deferred Timer below already existed, but it was driven only by /// The deferred Timer below already existed, but it was driven only by
@@ -0,0 +1,345 @@
// ImmersiveHeader — the immersive chrome band (ImmersiveView.slint:470-1184,
// 1618-1685), mounted by ImmersiveView at x=24 / y=16 / height=36 /
// width=parent-48 (§5.2 of the 2026-08-02 immersive-port contract).
//
// Three zones:
// LEFT — the view-menu trigger (44x36 glass) + a 256px QbzContextMenu
// popup with the FOCUS and SPLIT groups in SLINT ORDER. Every row
// fires QbzImmersive.setView(...); the data-panel rows also fire
// their §5.5 entry load (Queue / Track Info in B2 — Suggestions is
// B4 with its singleton, Lyrics needs nothing: Qt fetches lyrics
// automatically per track, §5.5).
// CENTER — the QbzLineEdit-based search field, absolutely centered,
// min(band.width, 420) wide. The cortinilla itself ships in B5;
// the Rust search invokables are the inert B1 surface until then
// (that is CORRECT — do not implement them here).
// RIGHT — the window-controls capsule: a 44px circle (sliders glyph) that
// expands LEFTWARD to 112px on hover (150ms ease-in-out), revealing
// the fullscreen toggle (the FIRST FullScreen write in the Qt
// frontend, §5.2) and the X exit.
//
// The whole band fades with the auto-hide chrome (ImmersiveView drives the
// opacity); nothing here knows about the hide timer.
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../controls"
import "../theme"
Item {
id: header
// §5.4 text-input gate: the view root reads this to inert the seek
// arrows while the search field has focus (D16 — the QbzLineEdit
// `fieldActive` seam).
readonly property bool searchActive: searchField.fieldActive
// --- LEFT: the view menu ---------------------------------------------
Rectangle {
id: viewTrigger
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: 44
height: 36
radius: 18
// Slint glass #00000080 / border #ffffff2e (RRGGBBAA), converted.
color: "#80000000"
border.width: 1
border.color: "#2effffff"
QbzIcon {
name: "layout-grid"
width: 16
height: 16
anchors.centerIn: parent
tintName: "white"
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: viewMenu.openBelowLeft(viewTrigger)
}
}
QbzContextMenu {
id: viewMenu
menuWidth: 256
contentSpacing: 1
// §5.2 popup chrome: #0b0b0ef7, radius 16 (Slint RRGGBBAA converted).
background: Rectangle {
color: "#f70b0b0e"
radius: 16
border.width: 1
border.color: "#2effffff"
}
// One row model entry: vm 0 = FOCUS (mode m), vm 1 = SPLIT (panel sp).
// `entry` names the §5.5 entry load fired alongside setView:
// "queue" -> QbzQueue.queuePanelOpened()
// "trackinfo" -> QbzAlbum.openTrackInfo(QbzPlayer.npTrackId)
// "" -> none (Lyrics rows: Qt fetches lyrics automatically
// per track, §5.5; Suggestions: setView ONLY in B2 —
// B4 wires QbzSuggestions.load with its singleton).
function fireRow(row) {
QbzImmersive.setView(
row.vm === 0 ? 0 : 1,
row.vm === 0 ? row.m : QbzImmersive.mode,
row.vm === 0 ? QbzImmersive.splitPanel : row.sp)
if (row.entry === "queue")
QbzQueue.queuePanelOpened()
else if (row.entry === "trackinfo")
QbzAlbum.openTrackInfo(QbzPlayer.npTrackId)
viewMenu.close()
}
Component {
id: menuRow
Rectangle {
required property var modelData
readonly property bool active: modelData.vm === 0
? (QbzImmersive.viewMode === 0 && QbzImmersive.mode === modelData.m)
: (QbzImmersive.viewMode === 1 && QbzImmersive.splitPanel === modelData.sp)
width: parent ? parent.width : 0
height: 33
radius: 8
// Active row #ffffff26 (Slint RRGGBBAA converted).
color: active ? "#26ffffff"
: (rowArea.containsMouse ? "#14ffffff" : "transparent")
Text {
anchors.left: parent.left
anchors.leftMargin: 10
anchors.right: dot.left
anchors.rightMargin: 8
height: parent.height
text: modelData.label
color: "#f2ffffff"
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
// Trailing 7px active dot (:529-537).
Rectangle {
id: dot
visible: parent.active
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
width: 7
height: 7
radius: 3.5
color: "#ffffff"
}
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: viewMenu.fireRow(modelData)
}
}
}
// Group headers: 11px / 700 / #ffffff80 / letter-spacing 1.5 /
// ALL-CAPS (:481-487). The msgids are already caps.
Text {
height: 26
leftPadding: 10
verticalAlignment: Text.AlignVCenter
text: QbzSession.tr("FOCUS", QbzSession.trRev)
color: "#80ffffff"
font.pixelSize: 11
font.weight: Font.Bold
font.letterSpacing: 1.5
}
Repeater {
// FOCUS rows IN SLINT ORDER (:586-650): Album Reactive(0),
// Static(1), Coverflow(2), Spectrum(3), Wave Bed(6), Lyrics(4),
// Queue(5). NO shader rows (owner ruling 1).
model: [
{ "vm": 0, "m": 0, "sp": -1, "entry": "",
"label": QbzSession.tr("Album Reactive", QbzSession.trRev) },
{ "vm": 0, "m": 1, "sp": -1, "entry": "",
"label": QbzSession.tr("Static", QbzSession.trRev) },
{ "vm": 0, "m": 2, "sp": -1, "entry": "",
"label": QbzSession.tr("Coverflow", QbzSession.trRev) },
{ "vm": 0, "m": 3, "sp": -1, "entry": "",
"label": QbzSession.tr("Spectrum", QbzSession.trRev) },
{ "vm": 0, "m": 6, "sp": -1, "entry": "",
"label": QbzSession.tr("Wave Bed", QbzSession.trRev) },
{ "vm": 0, "m": 4, "sp": -1, "entry": "",
"label": QbzSession.tr("Lyrics", QbzSession.trRev) },
{ "vm": 0, "m": 5, "sp": -1, "entry": "queue",
"label": QbzSession.tr("Queue", QbzSession.trRev) },
]
delegate: menuRow
}
Text {
height: 26
leftPadding: 10
verticalAlignment: Text.AlignVCenter
text: QbzSession.tr("SPLIT", QbzSession.trRev)
color: "#80ffffff"
font.pixelSize: 11
font.weight: Font.Bold
font.letterSpacing: 1.5
}
Repeater {
// SPLIT rows (:741-780): Lyrics(0), Track Info(1),
// Suggestions(2), Queue(3).
model: [
{ "vm": 1, "m": -1, "sp": 0, "entry": "",
"label": QbzSession.tr("Lyrics", QbzSession.trRev) },
{ "vm": 1, "m": -1, "sp": 1, "entry": "trackinfo",
"label": QbzSession.tr("Track Info", QbzSession.trRev) },
{ "vm": 1, "m": -1, "sp": 2, "entry": "",
"label": QbzSession.tr("Suggestions", QbzSession.trRev) },
{ "vm": 1, "m": -1, "sp": 3, "entry": "queue",
"label": QbzSession.tr("Queue", QbzSession.trRev) },
]
delegate: menuRow
}
}
// --- CENTER: the search field -----------------------------------------
// QbzLineEdit-based (§5.2). 36px tall, min(band.width, 420) wide,
// absolutely centered in the band. The 220ms debounce + >=2-char gate
// live in the Rust immersive controller (§3.4, B5) — the field reports
// every keystroke.
QbzLineEdit {
id: searchField
searchMode: true
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
width: Math.min(header.width, 420)
height: 36
placeholder: QbzSession.tr("Search", QbzSession.trRev)
// Two-way with the bridge (the B5 controller clears it on
// activation); the B1 surface is inert until then — correct.
text: QbzImmersive.searchInputText
onEdited: function (value) { QbzImmersive.searchLive(value) }
onAccepted: function (value) {
QbzImmersive.searchRowActivated(QbzImmersive.immSearchSelectedIndex)
}
// Up/Down on the field ROOT (Qt handles them here; Slint rejects
// them in the field because winit owns them — D15, same observable
// behavior). Escape is NOT handled here: the inner input declines it
// (QbzLineEdit non-expandable arm) and it propagates to the view
// root, whose handler dismisses search first / exits otherwise
// (§3.4).
Keys.onPressed: function (event) {
if (event.key === Qt.Key_Down) {
QbzImmersive.searchMoveSelection(1)
event.accepted = true
} else if (event.key === Qt.Key_Up) {
QbzImmersive.searchMoveSelection(-1)
event.accepted = true
}
}
}
// "↵ Enter" hint, visible only while the cortinilla is open (:985-993) —
// inert in B2 because immSearchOpen cannot flip true before B5.
Text {
visible: QbzImmersive.immSearchOpen
anchors.left: searchField.right
anchors.leftMargin: 10
anchors.verticalCenter: searchField.verticalCenter
text: "↵ " + QbzSession.tr("Enter", QbzSession.trRev)
color: "#bfffffff"
font.pixelSize: 12
}
// --- RIGHT: the window-controls capsule --------------------------------
// Collapsed: 44px circle, sliders glyph. Hover expands it LEFTWARD to
// 112px (150ms ease-in-out, anchored right) revealing the fullscreen
// toggle and the X exit. Minimize/maximize/drag parked (Slint :1173-1183).
Rectangle {
id: capsule
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
height: 36
width: capHover.containsMouse ? 112 : 44
radius: 18
color: "#80000000"
border.width: 1
border.color: "#2effffff"
clip: true
Behavior on width {
NumberAnimation { duration: 150; easing.type: Easing.InOutQuad }
}
MouseArea {
id: capHover
anchors.fill: parent
hoverEnabled: true
}
// Collapsed-state glyph (fades out as the capsule opens).
QbzIcon {
name: "sliders-horizontal"
width: 16
height: 16
anchors.centerIn: parent
tintName: "white"
opacity: capsule.width < 100 ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 100 } }
}
// Fullscreen toggle (glyph swaps on state) + X exit — revealed by the
// expansion, pinned to the capsule's right half.
QbzIcon {
id: fsGlyph
name: (header.Window.window
&& header.Window.window.visibility === Window.FullScreen)
? "minimize-2" : "maximize-2"
width: 15
height: 15
x: capsule.width - 72
anchors.verticalCenter: parent.verticalCenter
tintName: fsArea.containsMouse ? "white" : "muted"
opacity: capsule.width > 100 ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 100 } }
MouseArea {
id: fsArea
anchors.fill: parent
anchors.margins: -9
// Gated on the expansion: the two button MouseAreas are
// INVISIBLE while collapsed but would still cover the 44px
// circle (the -9 margins make the X's hit area span almost
// all of it), so a click on the collapsed capsule exited
// immersive instead of doing nothing.
enabled: capsule.width > 100
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
var w = header.Window.window
if (!w)
return
w.visibility = w.visibility === Window.FullScreen
? Window.Windowed : Window.FullScreen
}
}
}
QbzIcon {
name: "x"
width: 14
height: 14
x: capsule.width - 36
anchors.verticalCenter: parent.verticalCenter
tintName: xArea.containsMouse ? "white" : "muted"
opacity: capsule.width > 100 ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 100 } }
MouseArea {
id: xArea
anchors.fill: parent
anchors.margins: -9
enabled: capsule.width > 100
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// Exit path 2 (§5.4) — every exit funnels through the bridge's
// open setter.
onClicked: QbzImmersive.open = false
}
}
}
}
@@ -0,0 +1,318 @@
// 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 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).
//
// Layer order bottom->top (§5.1):
// 1. root color #0a0a0b, clip (:1199-1200)
// 2. 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)
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../theme"
Item {
id: root
// 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 handlers (Escape, seek
// arrows) are live; the 30ms grab timer below re-asserts it after mount
// (:1259-1267).
focus: QbzImmersive.open
// §5.4 text-input gate: Shift+I + the seek arrows are inert while ANY
// text input has focus. The immersive surface's own input is the header
// search field (D16 seam).
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
// Kiosk seam: Qt has no kiosk screen yet, so this is always false and
// the kiosk shell-restore arm is a documented no-op (D4) — the kiosk
// Loader contract is 2026-08-02-kiosk-port §4.6 (Claude).
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 —
// Main.qml's Shift+I gate (activeFocusItem instanceof
// TextInput) and the seek-arrow textInputActive gate then stay
// inert forever. Hand focus to the window content root so the
// gates see a non-text item again.
if (w !== null)
w.contentItem.forceActiveFocus()
}
}
}
// 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 2: the click-blocker (§5.1, load-bearing) -------------------
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.AllButtons
hoverEnabled: true
onPressed: function (mouse) { mouse.accepted = true }
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 (:1275); pointerInChrome = pointerInWindow &&
// (y <= 64 || y >= height-132) (:1196-1197, HoverHandler on the root);
// wake on any mouse move (:1283-1286); 300ms fades on the chrome itself.
property bool chromeVisible: true
readonly property bool pointerInWindow: rootHover.hovered
readonly property bool pointerInChrome: rootHover.hovered
&& (rootHover.point.position.y <= 64
|| rootHover.point.position.y >= root.height - 132)
function wake() {
chromeVisible = true
hideTimer.restart()
}
// 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: 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).
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
anchors.fill: parent
anchors.leftMargin: 24
anchors.rightMargin: 24
anchors.topMargin: 64
anchors.bottomMargin: 132
visible: QbzImmersive.viewMode === 0 && QbzImmersive.mode === modelData.m
radius: 12
color: "#0dffffff"
border.width: 1
border.color: "#2effffff"
Text {
anchors.centerIn: parent
text: modelData.label
color: "#80ffffff"
font.pixelSize: 15
}
onVisibleChanged: if (visible && modelData.m === 5)
QbzQueue.queuePanelOpened()
}
}
// SPLIT (viewMode==1): left artwork placeholder + right plate with one
// stub per splitPanel. The real 50/50 layout lands in B4 (§5.6/D1).
Item {
anchors.fill: parent
anchors.leftMargin: 24
anchors.rightMargin: 24
anchors.topMargin: 64
anchors.bottomMargin: 132
visible: QbzImmersive.viewMode === 1
Rectangle {
id: splitArtStub
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: Math.max(240, Math.min(parent.width / 2 - 24, parent.height - 200))
height: width
radius: 12
color: "#0dffffff"
border.width: 1
border.color: "#2effffff"
}
Repeater {
model: [
{ "sp": 0, "label": QbzSession.tr("Lyrics", QbzSession.trRev) },
{ "sp": 1, "label": QbzSession.tr("Track Info", QbzSession.trRev) },
{ "sp": 2, "label": QbzSession.tr("Suggestions", QbzSession.trRev) },
{ "sp": 3, "label": QbzSession.tr("Queue", QbzSession.trRev) },
]
delegate: Rectangle {
required property var modelData
anchors.left: splitArtStub.right
anchors.leftMargin: 48
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
visible: QbzImmersive.viewMode === 1
&& QbzImmersive.splitPanel === modelData.sp
radius: 12
color: "#0dffffff"
border.width: 1
border.color: "#2effffff"
Text {
anchors.centerIn: parent
text: modelData.label
color: "#80ffffff"
font.pixelSize: 15
}
onVisibleChanged: {
if (!visible)
return
if (modelData.sp === 3)
QbzQueue.queuePanelOpened()
else if (modelData.sp === 1)
QbzAlbum.openTrackInfo(QbzPlayer.npTrackId)
// sp==2 Suggestions: entry load lands in B4 with the
// QbzSuggestions singleton (contract §12 cross-block rule:
// no QML may reference QbzSuggestions before B4).
}
}
}
}
// §5.5: TrackInfo RELOADS on track change while its panel is mounted
// (:1557-1560). The Suggestions twin of this reload is B4.
Connections {
target: QbzPlayer
function onNpTrackIdChanged() {
if (root.visible && QbzImmersive.viewMode === 1
&& QbzImmersive.splitPanel === 1)
QbzAlbum.openTrackInfo(QbzPlayer.npTrackId)
}
}
// --- Exit paths 3 + seek arrows (§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
}
// Seek arrows (§7, keybindings.rs:107-110,572-581): ArrowRight/Left ±5s,
// Shift+ArrowRight/Left ±10s. Target clamps to DURATION (not
// npSeekableMax — that clamp belongs to the B5 TinyBar only, trap 9).
// Position base is the 1Hz npElapsedSecs (D14, resolved). Inert while a
// text input has focus (§5.4) — the gate is load-bearing because key
// events PROPAGATE from the focused field up to this root.
Keys.onPressed: function (event) {
if (root.textInputActive)
return
var d = 0
if (event.key === Qt.Key_Right)
d = (event.modifiers & Qt.ShiftModifier) ? 10 : 5
else if (event.key === Qt.Key_Left)
d = -((event.modifiers & Qt.ShiftModifier) ? 10 : 5)
else
return
if (QbzPlayer.npDurationSecs > 0) {
var target = Math.max(0, Math.min(QbzPlayer.npDurationSecs,
QbzPlayer.npElapsedSecs + d))
QbzPlayer.seek(target / QbzPlayer.npDurationSecs)
}
event.accepted = true
}
// --- 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: opacity > 0
}
}
+13
View File
@@ -15,6 +15,7 @@
import QtQuick import QtQuick
import com.blitzfc.qbz import com.blitzfc.qbz
import "../controls" import "../controls"
import "../immersive"
import "../theme" import "../theme"
Rectangle { Rectangle {
@@ -615,6 +616,18 @@ Rectangle {
anchors.fill: parent anchors.fill: parent
} }
// Immersive mode root overlay (2026-08-02 immersive-port contract §5.1):
// plain Item, LAST-child class like its Slint twin (a Popup would grab
// the pointer and kill typing in the immersive search field — the
// cortinilla doctrine, Cortinilla.qml:1-5). Self-gates on
// QbzImmersive.open, so while closed it is an invisible, non-interactive
// Item. Sits AFTER QbzToast and BEFORE ArtPreviewOverlay/QbzTooltip so
// those two keep painting above it; covering the content pane's rounded
// corners is correct for a window-level overlay (:555-563 above).
ImmersiveView {
anchors.fill: parent
}
// LAST child = above every surface, exactly like ArtPreviewOverlay.slint's // LAST child = above every surface, exactly like ArtPreviewOverlay.slint's
// mount in AppShell.slint. Non-interactive, so it never steals the hover // mount in AppShell.slint. Non-interactive, so it never steals the hover
// that is keeping it open (see the file header). // that is keeping it open (see the file header).
+26 -10
View File
@@ -1,8 +1,8 @@
// Now-Playing-view mode menu (PlayerBar.slint layout-menu, phase 18) — // Now-Playing-view mode menu (PlayerBar.slint layout-menu, phase 18) —
// one QbzContextMenu shared by the full PlayerBar and the Small bar: // one QbzContextMenu shared by the full PlayerBar and the Small bar:
// New / Classic / Small / Large (with the current mode checked), then the // New / Classic / Small / Large (with the current mode checked), then the
// inert window-mode rows (Miniplayer / Immersive / Kiosk — the Slint has // window-mode rows. Immersive is LIVE (2026-08-02 immersive-port §7, B2);
// them; the POC has no such surfaces, POC-NOTE). // Miniplayer / Kiosk stay inert (kiosk goes live in the kiosk contract §8).
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
@@ -81,8 +81,8 @@ QbzContextMenu {
} }
} }
// Divider + the window-mode rows (Miniplayer / Immersive / Kiosk — // Divider + the window-mode rows (Miniplayer / Immersive / Kiosk).
// present in the Slint menu; inert in the POC (no such surfaces). // Immersive is live (below); the other two stay inert.
Item { Item {
width: parent ? parent.width : 0 width: parent ? parent.width : 0
height: 7 height: 7
@@ -95,18 +95,24 @@ QbzContextMenu {
} }
} }
Repeater { Repeater {
// Window-mode rows. `live` flips per-row inside the shared delegate
// (2026-08-02 immersive-port §7): only the Immersive row is wired in
// B2 — it loses the 0.45 dim and gains the openFromMenu click.
// Miniplayer + Kiosk stay inert (kiosk goes live in the
// 2026-08-02-kiosk-port contract §8 — do not touch it).
model: [ model: [
{ "label": QbzSession.tr("Miniplayer", QbzSession.trRev), "icon": "picture-in-picture-2" }, { "label": QbzSession.tr("Miniplayer", QbzSession.trRev), "icon": "picture-in-picture-2", "live": false },
{ "label": QbzSession.tr("Immersive", QbzSession.trRev), "icon": "maximize-2" }, { "label": QbzSession.tr("Immersive", QbzSession.trRev), "icon": "maximize-2", "live": true },
{ "label": QbzSession.tr("Kiosk mode", QbzSession.trRev), "icon": "hard-drive" }, { "label": QbzSession.tr("Kiosk mode", QbzSession.trRev), "icon": "hard-drive", "live": false },
] ]
delegate: Rectangle { delegate: Rectangle {
required property var modelData required property var modelData
width: parent ? parent.width : 0 width: parent ? parent.width : 0
height: 33 height: 33
radius: 5 radius: 5
opacity: 0.45 opacity: modelData.live ? 1.0 : 0.45
color: "transparent" color: (modelData.live && modeArea.containsMouse)
? theme.surfaceHover : "transparent"
Row { Row {
anchors.fill: parent anchors.fill: parent
anchors.leftMargin: 8 anchors.leftMargin: 8
@@ -120,7 +126,17 @@ QbzContextMenu {
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
} }
} }
// Inert (POC-NOTE): no miniplayer/immersive/kiosk surfaces. MouseArea {
id: modeArea
anchors.fill: parent
enabled: modelData.live
hoverEnabled: true
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
root.close()
QbzImmersive.openFromMenu()
}
}
} }
} }
} }