Files
qbz/crates/qbz-qt/qml/shell/KioskShell.qml
T
vicrodh f6e1c4e023 fix(qconnect): harden queue ownership handoffs
Preserve local playback when it is authoritative, make renderer conflicts explicit and persist the selected policy. Filter unsupported queue rows, restore portable Qobuz identities after disconnect, and fence takeover commands to avoid stale queue and playback loops.

cx
2026-09-01 17:23:14 -06:00

645 lines
30 KiB
QML

// 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()
}
// QConnect bootstrap conflicts are global and can also originate from the
// compact player bar used by kiosk mode.
QconnectPlaybackConflictModal { }
// =====================================================================
// 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 { }
}
}