// KioskShell — the small-panel touch shell (QML port of // crates/qbz-ui/ui/shell/KioskShell.slint, 748 lines). // // A SIBLING of AppShell, mounted by Main.qml when QbzSession.screen is // "kiosk". It reads the SAME bridges as the desktop shell — nothing is // mirrored — so the two swap live with nothing torn down. The only // differences are chrome: a thin back bar + a bottom NavRail replace the // HeaderBar + Sidebar, the transport is forced to NowPlayingBarSmall, and // there is no queue/lyrics side panel. // // Structure, top to bottom (KioskShell.slint:229-747). The last three are // overlays drawn on top of the layout, and the ordering is load-bearing: // 1. Column: back bar (42) / content frame / transport (42, conditional) / // NavRail (80) KioskShell.slint:229-659 // 2. the player-zone focus ring overlay :666-677 // 3. the root key handler + the deferred initial focus grab :684-735 // 4. the kiosk's OWN Immersive mount :741-747 // // The view mounts live in the SHARED shell/ContentRouter.qml (contract §4.2, // divergence D3) — the Slint's verbatim copy of AppShell's block declares // that extraction as its own follow-up (KioskShell.slint:10-17). import QtQuick import com.blitzfc.qbz import "../controls" import "../immersive" import "../kiosk" import "../theme" Rectangle { id: root // KioskShell.slint:58 sets NO background — every visible surface is // painted by a child, and what shows through is the window's own fill // (Main.qml:93, the same surface-card the back bar uses). color: "transparent" // The host ApplicationWindow. Main.qml:463 assigns this UNCONDITIONALLY // on load, so the property must exist or the shell fails to mount // (the desktop declaration is shell/AppShell.qml:59). property var hostWindow: null // The shell-root marker six components walk the parent chain for // (immersive/ImmersiveView.qml:93-99 hands focus BACK to whatever carries // it). Without it every hotkey after an immersive close is swallowed. // `focus` + the explicit grab are AppShell.qml:69,78's hard-won pair: // declarative focus alone did not survive the splash -> shell Loader swap // in a WM-less session. readonly property bool isQbzShellRoot: true focus: true Component.onCompleted: root.forceActiveFocus() QbzTheme { id: theme } // KioskShell.slint:204 — the NavRail is a fat touch target. readonly property int navRailHeight: 80 // The chrome hover fill (Theme.alpha-8, KioskShell.slint:255,273). The // ramp is empty on the pre-publish frame, where alphaTier() silently // returns "transparent" — the tree's required guard idiom, whose reason // is written out at rows/TrackRow.qml:264-269. readonly property color chromeHoverBg: theme.alphaTiers.length > 0 ? theme.alphaTier(8) : (theme.isDark ? "#14ffffff" : "#14000000") // --------------------------------------------------------------------- // The offline gate (KioskShell.slint:209-227) // --------------------------------------------------------------------- // Qobuz-only routes mount only online; the local-DB routes (Local // Library, local album, My QBZ, Settings, Now Playing, the manager // views) keep mounting offline and are deliberately absent below. // // Two arms of the reference's predicate have no counterpart here: // - award / award-albums / location are ABSENT-BY-RULING (contract R1) — // there is no Qt view and no Qt route id for them, so they can never be // `currentView`. // (This list USED to include musician and the two purchases routes. It // was wrong about both by the time anyone read it: ContentRouter mounts // `musician`, `purchases` and `purchase-album`, and nav_qt.rs lists all // three ids. Corrected 2026-08-16 — a stale absence claim is the kind of // comment that gets cited as authority later.) // Purchases needs no arm here regardless: it is gated OFF while offline // at both of its entry points (Sidebar's `purchasesVisible` and // HeaderBar's `purchasesInHeader` both require `!QbzSession.offline`), // so a kiosk session cannot reach it in the first place. // - PlaylistState.offline-subset (the D11.a "mixed Qobuz playlist // rendered from its local sidecar rows" flag, state.slint:2183) has // NO Qt member: playlist_qt.rs's PlaylistDoc carries isLocalPlaylist // (:175) and offlineOnly (:181) and nothing else. The arm below is // therefore the reference's minus that one disjunct. readonly property var playlistDoc: root.parsePlaylistDoc() function parsePlaylistDoc() { try { return JSON.parse(QbzBridge.playlistJson) } catch (e) { return ({}) } } readonly property bool qobuzViewBlocked: QbzSession.offline && ( QbzShell.currentView === "home" || QbzShell.currentView === "discoverbrowse" || QbzShell.currentView === "search" || QbzShell.currentView === "library" || QbzShell.currentView === "album" || QbzShell.currentView === "artist" || QbzShell.currentView === "artistreleases" || QbzShell.currentView === "label" || QbzShell.currentView === "labelreleases" || QbzShell.currentView === "mix" || (QbzShell.currentView === "playlist" && root.playlistDoc.isLocalPlaylist !== true)) // --------------------------------------------------------------------- // NavRail routing (KioskShell.slint:637-657) // --------------------------------------------------------------------- // The Slint tiles fire `header-menu-navigate("discover-home")` etc., and // the Rust handler behind that route both switches the view AND selects a // landing tab (crates/qbz/src/main.rs:18017-18040). Qt has no such // combined seam — QbzShell.navigateTo carries only the route — so the // landing tab uses the port's existing pending-tab handshake // (shell/NavFlyout.qml:263-291): remember the tab, wait for the route to // land, stamp it on the mounted view. The view is reached through // ContentRouter's public `currentItem` rather than NavFlyout's depth- // capped tree walk, which the kiosk's deeper mount chain would outrun. property string pendingTab: "" property string pendingView: "" function navigateWithTab(view, tab) { if (QbzShell.currentView === view) { // Already mounted — no currentViewChanged will fire. root.applyTab(tab) return } root.pendingTab = tab root.pendingView = view QbzShell.navigateTo(view) } // Duck-typed exactly like NavFlyout.qml:320-321: only the tabbed views // expose `activeTab`, and a view without one is simply left alone. function applyTab(tab) { if (!tab || tab === "") return var mounted = contentLoader.item !== null ? contentLoader.item.currentItem : null if (mounted !== null && typeof mounted.activeTab === "string") mounted.activeTab = tab } Connections { target: QbzShell function onCurrentViewChanged() { // KioskShell.slint:194-200 — every route change parks the focus // ring on the new view's first item. nav_active is NOT reset: the // latch is a session-level thing (kiosk_nav_qt.rs:233-242). QbzKioskNav.resetForView() if (root.pendingTab === "") return var wanted = root.pendingView var tab = root.pendingTab root.pendingTab = "" root.pendingView = "" // A navigation somewhere else beat us to it — drop the request // instead of stamping a stale tab later. if (QbzShell.currentView !== wanted) return // The router instantiates the view from its own binding on the // same property; binding order between it and this handler is // undefined, so apply after the current pass. Qt.callLater(function () { root.applyTab(tab) }) } } // ===================================================================== // 1. The layout column (KioskShell.slint:229-659) // ===================================================================== Column { id: shellColumn width: root.width spacing: 0 // --- Back bar (KioskShell.slint:237-319) ------------------------- // Persistent global back/forward (ADR-004) + the kiosk search entry. // The desktop search box lives in the HeaderBar, which the kiosk // drops, so this field is what makes Search reachable at all. Rectangle { id: backBar width: shellColumn.width height: theme.headerHeight color: theme.surfaceCard // The kiosk carries no HeaderBar, so it owns the window chrome // too (KioskShell.slint:240-245): macOS overlay leaves the native // traffic-light inset, Linux frameless draws the controls. // // DIVERGENCE, reported: the reference reads // AppearanceState.is-macos and .show-window-controls. Neither has // a Qt bridge member (QbzShell exposes only systemTitleBar, // shell_bridge.rs:111), and this port must not add Rust here. The // platform half uses Qt's own Qt.platform.os; the pref half is // dropped, which puts the kiosk on exactly the predicate the // desktop chrome already uses (HeaderBar.qml:924). readonly property real macInset: (Qt.platform.os === "osx" && !QbzShell.systemTitleBar) ? 78 : 0 readonly property bool linChrome: Qt.platform.os !== "osx" && !QbzShell.systemTitleBar // padding-left 6 + macInset, padding-right 118 (controls) or 8, // padding-top/bottom 4, spacing 6 -> a 34px content row. // The 118 is not a round number: WindowControls is 34*3 + 2*2 = // 106 wide, pinned 8px from the right edge, leaving 4px of // clearance to the search field. Row { id: backBarRow x: 6 + backBar.macInset y: 4 width: backBar.width - (6 + backBar.macInset) - (backBar.linChrome ? 118 : 8) height: backBar.height - 8 spacing: 6 // KioskShell.slint:252-269. The disabled state is expressed // THREE times — dimmed, un-hoverable and un-clickable; an // opacity-only port leaves a clickable ghost. Rectangle { id: backBtn width: 44 height: backBarRow.height radius: theme.radiusSm color: (backArea.containsMouse && QbzShell.canBack) ? root.chromeHoverBg : "transparent" QbzIcon { // Opacity on the LEAF, not on the button: the // Rectangle resolves to "transparent" in every // disabled frame (hover requires canBack), so the // group node only ever dimmed this glyph — and an // always-on opacity group is its own batch. opacity: QbzShell.canBack ? 1.0 : 0.32 name: "chevron-left" width: 22 height: 22 anchors.centerIn: backBtn tintName: "secondary" } MouseArea { id: backArea anchors.fill: backBtn enabled: QbzShell.canBack hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: QbzShell.navigateBack() } } // KioskShell.slint:270-287. Rectangle { id: fwdBtn width: 44 height: backBarRow.height radius: theme.radiusSm color: (fwdArea.containsMouse && QbzShell.canForward) ? root.chromeHoverBg : "transparent" QbzIcon { // Opacity on the LEAF, not on the button: the // Rectangle resolves to "transparent" in every // disabled frame (hover requires canForward), so the // group node only ever dimmed this glyph — and an // always-on opacity group is its own batch. opacity: QbzShell.canForward ? 1.0 : 0.32 name: "chevron-right" width: 22 height: 22 anchors.centerIn: fwdBtn tintName: "secondary" } MouseArea { id: fwdArea anchors.fill: fwdBtn enabled: QbzShell.canForward hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: QbzShell.navigateForward() } } // KioskShell.slint:288-312. Submit-only: there is NO live / // debounced search in the back bar and no cortinilla — do not // add one. QbzLineEdit { id: searchField width: backBarRow.width - backBtn.width - fwdBtn.width - 2 * backBarRow.spacing height: backBarRow.height placeholder: QbzSession.tr("Search", QbzSession.trRev) // The full accept sequence, KioskShell.slint:301-311. // Rust's search_submit navigates to the results view // itself (search_qt.rs:1971), which is the reference's // explicit `NavState.view = ContentView.search` (:303). // Slint then needs TWO more steps — clear-focus() so the // OS on-screen keyboard closes (:307), then keys.focus() // to hand the arrows back (:310). In Qt keyboard focus is // exclusive, so grabbing it at the root does both at once: // the inner TextInput loses activeFocus (text-input // DISABLE -> squeekboard hides) and the nav scope has it. onAccepted: function (value) { QbzSearch.searchSubmit(value) root.forceActiveFocus() } } } // Linux custom window controls at the right edge // (KioskShell.slint:315-318). Inlined rather than extracted from // HeaderBar.qml:923-999: a shared component would need a new file // in build.rs, and touching HeaderBar risks the desktop // non-regression guard (contract D3). Same three 34x26 buttons, // same 2px spacing, same fixed #e81123 close hover. Row { id: windowControls visible: backBar.linChrome x: backBar.width - width - 8 y: (backBar.height - height) / 2 height: 26 spacing: 2 Rectangle { id: wcMin width: 34 height: 26 radius: theme.radiusSm color: wcMinArea.containsMouse ? theme.surfaceHover : "transparent" QbzIcon { name: "minus" width: 14 height: 14 anchors.centerIn: wcMin tintName: "secondary" } MouseArea { id: wcMinArea anchors.fill: wcMin hoverEnabled: true onClicked: if (root.hostWindow) root.hostWindow.showMinimized() } } Rectangle { id: wcMax width: 34 height: 26 radius: theme.radiusSm color: wcMaxArea.containsMouse ? theme.surfaceHover : "transparent" QbzIcon { // The glyph swaps while maximized // (WindowControls.slint:52-54). name: root.hostWindow && root.hostWindow.visibility === Window.Maximized ? "minimize-2" : "maximize-2" width: 14 height: 14 anchors.centerIn: wcMax tintName: "secondary" } MouseArea { id: wcMaxArea anchors.fill: wcMax hoverEnabled: true onClicked: { if (root.hostWindow) { root.hostWindow.visibility = root.hostWindow.visibility === Window.Maximized ? Window.Windowed : Window.Maximized } } } } Rectangle { id: wcClose width: 34 height: 26 radius: theme.radiusSm color: wcCloseArea.containsMouse ? "#e81123" : "transparent" QbzIcon { name: "x" width: 14 height: 14 anchors.centerIn: wcClose // The hover fill is a FIXED #e81123 under every // theme, so the hovered glyph is literal white. tintName: wcCloseArea.containsMouse ? "white" : "secondary" } MouseArea { id: wcCloseArea anchors.fill: wcClose hoverEnabled: true // The FIFTH exit. §5.7 of the 2026-08-03 miniplayer/ // tray contract enumerates four; this shell grew its // own drawn X after that contract was written, and an // exit still calling Qt.quit() directly is precisely // the half-port the enumeration exists to prevent. Same // choreography as the desktop chrome: Main.qml's // closeOrHide hides to the tray while QbzTray.trayLive // && QbzTray.closeToTray and quits otherwise. onClicked: if (root.hostWindow) root.hostWindow.closeOrHide(null) } } } } // --- Content frame (KioskShell.slint:325-611) -------------------- // The outer surface-card frame takes the leftover height; the inner // surface-main panel is inset 8px left/right/bottom (0 top — it butts // the back bar) and takes an EXPLICIT height, because a // conditionally-mounted view does not stretch to fill a layout cell. // // THE NOW-PLAYING EXCEPTION (:348-355): the transport row is hidden // on that view, so its 42px must NOT be subtracted — otherwise the // content stays short and the NavRail rides up, leaving dead space // below the footer. Rectangle { id: contentFrame width: shellColumn.width height: root.height - theme.headerHeight - (QbzShell.currentView === "nowplaying" ? 0 : theme.headerHeight) - root.navRailHeight color: theme.surfaceCard // Tap anywhere in the content area to dismiss the OS on-screen // keyboard (KioskShell.slint:328-339): releasing the field's // focus makes Qt send text-input DISABLE and squeekboard closes. // Declared FIRST so it sits BEHIND the views — later siblings get // the event first, so this only catches empty-area taps. MouseArea { anchors.fill: contentFrame onClicked: root.forceActiveFocus() } Rectangle { id: contentPanel x: 8 y: 0 width: contentFrame.width - 16 height: contentFrame.height - 8 color: theme.surfaceMain radius: theme.radiusMd clip: true // The offline placeholder (KioskShell.slint:362-365). The // reference's `favorites` variant nests a FavoritesOfflineRail // inside it; that rail is ABSENT-BY-RULING (contract R1), so // the plain placeholder is the correct kiosk rendering for // every blocked route, favourites included. QbzOfflinePlaceholder { visible: root.qobuzViewBlocked anchors.centerIn: contentPanel showSettingsAction: true onSettingsClicked: QbzShell.navigateTo("settings") } // The shared router, in its kiosk arm. `active` rather than // `visible`: the reference UNMOUNTS the view while the gate is // up (every Qobuz route carries its own `&& !offline` guard, // :367-608), and an invisible view would keep running. Loader { id: contentLoader anchors.fill: contentPanel active: !root.qobuzViewBlocked sourceComponent: kioskRouter } } } // --- The transport (KioskShell.slint:617-631) -------------------- // Forced to the small bar, exactly one header row tall. Hidden on Now // Playing — that view carries the full transport, so the bar would be // redundant there. Loader { id: transport width: shellColumn.width height: transport.active ? theme.headerHeight : 0 active: QbzShell.currentView !== "nowplaying" visible: transport.active sourceComponent: transportBar } // --- Bottom NavRail (KioskShell.slint:635-658) ------------------- NavRail { id: navRail width: shellColumn.width height: root.navRailHeight onDiscover: root.navigateWithTab("home", "home") onLibrary: root.navigateWithTab("library", "tracks") onLocalLibrary: root.navigateWithTab("local", "albums") onMyqbz: QbzShell.navigateTo("mixtapes") // The reference assigns NavState.view directly here (:650), so it // records NO history entry. Qt's only view-switch path is // navigateTo, which always records (shell_bridge.rs:645 -> // main.rs:1682); the extra history entry is the accepted // divergence rather than a second Rust entry point. onPlaying: QbzShell.navigateTo("nowplaying") // The kiosk owns its Immersive mount below, so the visualizer tile // follows the same open funnel as the desktop view-mode row. onVisualizer: QbzImmersive.openFromMenu() onMenu: QbzShell.navigateTo("settings") } } // ===================================================================== // 2. The player-zone focus ring (KioskShell.slint:666-677) // ===================================================================== // An overlay in ROOT coordinates, outside the column, so it never // reflows anything. The transport is ONE focusable unit; on Now Playing // the bar is hidden and that view draws its own in-page ring instead. Rectangle { id: playerRing visible: QbzShell.currentView !== "nowplaying" && QbzKioskNav.navActive && QbzKioskNav.zone === "player" x: 0 y: root.height - root.navRailHeight - theme.headerHeight width: root.width height: theme.headerHeight border.width: 3 border.color: theme.accent radius: theme.radiusSm color: Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.08) } // ===================================================================== // 3. Root key handling (KioskShell.slint:684-723) // ===================================================================== // One scope drives the whole kiosk: arrows move the ring through the // three zones (rail -> content -> player -> rail on Down, reversed on // Up), Enter activates, Esc goes back. MouseAreas never take keyboard // focus, so this survives every card/tile tap and only yields to the // search field. // // Evaluation order is literal and must be preserved. The reference's two // guards RETURN REJECT — the key keeps its normal path — and in Qt that // path is the hotkeys dispatcher at the end of this handler, whose own // central text-input gate (hotkeys_bridge.rs:295-299) is the counterpart // of the first guard. The textInputFocused predicate is computed exactly // as the desktop shell computes it (AppShell.qml:91). Keys.onPressed: function (event) { var w = root.Window.window var afi = w !== null ? w.activeFocusItem : null var textInputFocused = (afi instanceof TextInput) || (afi instanceof TextEdit) if (!textInputFocused && !QbzImmersive.open) { if (event.key === Qt.Key_Left) { QbzKioskNav.navLeft() event.accepted = true return } if (event.key === Qt.Key_Right) { QbzKioskNav.navRight() event.accepted = true return } if (event.key === Qt.Key_Up) { QbzKioskNav.navUp() event.accepted = true return } if (event.key === Qt.Key_Down) { QbzKioskNav.navDown() event.accepted = true return } if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { // The three-way dispatch, KioskShell.slint:175-190. The rail // is always mounted, so it is called directly; a content view // is conditionally mounted and cannot be addressed from here, // so its request goes out as the activate-seq pulse. Both the // rail and the player arm still have to latch the ring (:176) // — bumpActivate() does it for the content arm itself // (kiosk_nav_qt.rs:222-225). if (QbzKioskNav.zone === "player") { QbzKioskNav.markActive() if (QbzPlayer.npHasTrack || QbzQueue.hasPlayTarget) QbzPlayer.togglePlay() } else if (QbzKioskNav.zone === "rail") { QbzKioskNav.markActive() navRail.activateIndex(QbzKioskNav.index) } else { QbzKioskNav.bumpActivate() } event.accepted = true return } // Escape only CONSUMES when there is history; otherwise it keeps // its normal path (:714-720), which here is the dispatcher's own // Escape stack. if (event.key === Qt.Key_Escape && QbzShell.canBack) { QbzShell.navigateBack() event.accepted = true return } } event.accepted = QbzHotkeys.keyPressed(event.key, event.modifiers, event.text, textInputFocused) } // Deferred mount focus (KioskShell.slint:728-735): synchronous focus // during the initial layout races it, so a one-shot timer grabs it a tick // later. The port already uses this exact 30ms idiom at // controls/QbzLineEdit.qml:123-128. Timer { interval: 30 running: true repeat: false onTriggered: root.forceActiveFocus() } // ===================================================================== // 4. The kiosk's OWN Immersive mount (KioskShell.slint:737-747) // ===================================================================== // The desktop hangs its copy inside AppShell, so the kiosk shell must // mount its own; declared LAST = on top of everything. // // The same surface as desktop, hosted locally because AppShell and // KioskShell are sibling roots. `preKiosk` prevents an Immersive close // from changing a fullscreen appliance window back to Windowed. ImmersiveView { anchors.fill: root preKiosk: true } // The two conditionally-mounted children, hoisted out of the column so // the positioner never sees a non-visual child. Component { id: kioskRouter ContentRouter { kiosk: true } } Component { id: transportBar NowPlayingBarSmall { } } }