Files
qbz/crates/qbz-qt/qml/shell/ContentRouter.qml
T
vicrodh 63e36c7543 feat(qt): the kiosk shell, its NavRail, the 4 card primitives and the 8 views
Contract sections 4, 5, 6 and the QML half of 7.

ContentRouter.qml is the D3 extraction: ONE view-mount chain, shared by both
shells. The Slint kiosk's content block is a verbatim copy of AppShell's and
KioskShell.slint:10-17 names this extraction as the follow-up it was waiting
for. 23 desktop arms carried over with their comments, 8 kiosk overrides, and
the kiosk-only nowplaying route. AppShell's four viewLoader.item sites now go
through contentRouter.currentItem; a tree-wide grep confirmed no other
reference (LESSONS L4 -- grep, do not trust memory about "both places").

KioskShell.qml carries the back bar with the full search focus lifecycle, the
content-height arithmetic including the now-playing exception, the offline
gate, the forced small transport, the player-zone focus ring, the root key
handler and the 30ms initial focus grab. Two findings worth keeping:

- the NavRail tiles fire header-menu-navigate("favorites-tracks") in the
  reference, which switches the view AND selects a landing tab; navigateTo
  carries only the route, so the tiles reuse the port's existing pending-tab
  handshake rather than landing on the wrong tab.
- the visualizer tile's action is a sanctioned no-op (R2). Wiring
  QbzImmersive would be worse than nothing: `open` would go true over an
  empty mount and the key guard would then reject every key against a
  surface that renders nothing.

Three fractional font sizes had to be rounded. Qt's font.pixelSize is an INT
property, so the reference's 11.5px (NavRail.slint:47) and 12.5px
(KioskNowPlaying.slint:59,67) are not expressible; font.pointSize is the only
real-valued alternative and is DPI-relative, which would make those three
labels scale differently from every other in the tree.

The NavRail one was caught by the offscreen smoke run IN THE KIOSK PROFILE --
it made the whole rail unavailable, i.e. a kiosk with no footer. The build,
both QML audits and qmllint were all green while that was true, and the
desktop smoke was clean because it never mounts KioskShell. The other two
were still latent: KioskNowPlaying is not on the smoke's route.

Gate: build 9 warnings = baseline, 291 tests, both audits OK over 229 QML
files (+15), qmllint filtered = the same 20 pre-existing ArtistView lines and
nothing else, kiosk-profile offscreen smoke clean.

cc
2026-08-03 23:23:24 -06:00

149 lines
8.6 KiB
QML

// Content router — the ONE view-mount chain, shared by BOTH shells
// (2026-08-02 kiosk-port contract §4.2, divergence D3).
//
// The Slint kiosk's own content block is a VERBATIM copy of AppShell's, and
// KioskShell.slint:10-17 declares that copy to be temporary debt whose named
// follow-up is exactly this extraction. So the chain lives here once:
// AppShell.qml mounts it with `kiosk: false`, KioskShell.qml with
// `kiosk: true`, and the route -> file mapping is stated in a single place.
//
// This file MUST stay in qml/shell/: every `source` below is a relative path
// resolved against this document's directory (`../views/…`, `../settings/…`,
// `../kiosk/…`). Moving it silently breaks all of them.
//
// MOUNT PLUMBING ONLY (D3 guard). No view component is modified by the
// existence of this file, and the desktop chain below is the AppShell chain it
// replaced — same route ids, same files, same order, same comments.
//
// KIOSK OVERRIDES — EIGHT routes, and only these eight, resolve to a kiosk
// view instead (KioskShell.slint:361-608 is the authority):
//
// home -> ../kiosk/KioskDiscover.qml (:367)
// album -> ../kiosk/KioskAlbum.qml (:402)
// artist -> ../kiosk/KioskArtist.qml (:416)
// library -> ../kiosk/KioskLibrary.qml (:481, ContentView.favorites)
// local -> ../kiosk/KioskLocalLibrary.qml (:526, ContentView.local-library)
// mixtapes -> ../kiosk/KioskMyQBZ.qml (:512)
// collections-> ../kiosk/KioskMyQBZ.qml (:517)
// search -> ../kiosk/KioskSearch.qml (:589)
// nowplaying -> ../kiosk/KioskNowPlaying.qml (:598) — KIOSK-ONLY route
//
// Every OTHER route mounts the SAME desktop view in both shells — that is the
// whole point of sharing the router, and KioskShell.slint:381-586 confirms the
// kiosk re-hosts the desktop components for them.
import QtQuick
import com.blitzfc.qbz
Item {
id: root
// false = desktop shell (AppShell), true = kiosk shell (KioskShell).
property bool kiosk: false
// The mounted view. The replacement for AppShell's four `viewLoader.item`
// references (multi-select: the two Ctrl+A / Escape routers and the two
// duck-typed reporters) — a QML `id` is scoped to its own document, so
// those cannot reach into this file and need a public surface.
readonly property var currentItem: viewLoader.item
Loader {
id: viewLoader
anchors.fill: parent
source: QbzShell.currentView === "home"
? (root.kiosk ? "../kiosk/KioskDiscover.qml" : "../views/HomeView.qml")
: QbzShell.currentView === "library"
? (root.kiosk ? "../kiosk/KioskLibrary.qml" : "../views/LibraryView.qml")
: QbzShell.currentView === "local"
? (root.kiosk ? "../kiosk/KioskLocalLibrary.qml" : "../views/LocalLibraryView.qml")
: QbzShell.currentView === "localalbum" ? "../views/LocalAlbumView.qml"
: QbzShell.currentView === "album"
? (root.kiosk ? "../kiosk/KioskAlbum.qml" : "../views/AlbumView.qml")
: QbzShell.currentView === "artist"
? (root.kiosk ? "../kiosk/KioskArtist.qml" : "../views/ArtistView.qml")
: QbzShell.currentView === "settings" ? "../settings/SettingsView.qml"
: QbzShell.currentView === "search"
? (root.kiosk ? "../kiosk/KioskSearch.qml" : "../views/SearchView.qml")
: QbzShell.currentView === "playlist" ? "../views/PlaylistView.qml"
: QbzShell.currentView === "discoverbrowse" ? "../views/DiscoverBrowseView.qml"
: QbzShell.currentView === "playlistbrowse" ? "../views/PlaylistBrowseView.qml"
: QbzShell.currentView === "recentalbums" ? "../views/PlayHistoryView.qml"
: QbzShell.currentView === "mostplayedalbums" ? "../views/PlayHistoryView.qml"
: QbzShell.currentView === "label" ? "../views/LabelView.qml"
: QbzShell.currentView === "labelreleases" ? "../views/LabelReleasesView.qml"
// Artist page > a release section > "See discography", and the
// album page's "From the same artist" View all. Same two-file
// contract as the rest of this chain (nav_qt.rs:9-16):
// artist_releases_qt::open records the id, this arm mounts it.
// Forgetting the arm is NOT a crash — the ternary falls through
// to "" and the pane goes blank with nothing logged, i.e. the
// dead "See discography" in a new costume.
: QbzShell.currentView === "artistreleases" ? "../views/ArtistReleasesView.qml"
// For You > Qobuz Mixes. The whole chain existed —
// HomeView's tile -> QbzHome.openMix -> foryou_qt -> nav_qt
// records the "mix" view — and MixView.qml is written and
// registered in build.rs, but this arm was missing, so every
// mix tile navigated to a view nobody mounted and the content
// pane simply went blank (back recovered, which is why it read
// as "nothing happens" rather than a crash).
: QbzShell.currentView === "mix" ? "../views/MixView.qml"
// MyQBZ. Mixtapes and Collections are ONE file — the two
// pages differ only in their filter row and their empty
// state, so MyQbzGridView carries a `kind` discriminator
// (see the Binding below, which is how it gets set). The kiosk
// does the same with ONE component on both routes
// (KioskShell.slint:512,517).
: QbzShell.currentView === "mixtapes"
|| QbzShell.currentView === "collections"
? (root.kiosk ? "../kiosk/KioskMyQBZ.qml" : "../views/myqbz/MyQbzGridView.qml")
: QbzShell.currentView === "mixtapedetail" ? "../views/myqbz/MyQbzDetailView.qml"
: QbzShell.currentView === "discobuilder" ? "../views/myqbz/DiscoBuilderView.qml"
// Settings > Blacklist > Manage. Not reachable from the
// sidebar — blacklist_qt::open_manager records the route.
: QbzShell.currentView === "blacklist" ? "../views/BlacklistManagerView.qml"
// Sidebar > Playlists ⋯ > Manage playlists. Not a sidebar
// section — playlist_manager_qt::navigate() records the route.
// The route is a TWO-FILE contract (nav_qt.rs:9-16): the caller
// records the id, this arm mounts it, and the failure mode for a
// missing arm is a BLANK content pane, logged nowhere.
: QbzShell.currentView === "playlistmanager" ? "../views/PlaylistManagerView.qml"
// KIOSK-ONLY route (nav_qt.rs:24-29): the NavRail's fifth tile,
// the full-screen player. The desktop shell has no equivalent —
// its transport is the persistent bar — so outside kiosk this id
// falls through to "" and the pane is blank, which is the desktop
// behaviour before this router existed.
: root.kiosk && QbzShell.currentView === "nowplaying" ? "../kiosk/KioskNowPlaying.qml" : ""
}
// MyQbzGridView serves BOTH the "mixtapes" and "collections" routes
// and needs to know which. A Loader cannot pass properties through a
// declarative `source:` ternary, and the view's own default is
// "mixtape" — so without this the Collections route would render the
// Mixtapes page, permanently, with nothing logged.
//
// A Binding rather than `onLoaded`/`setSource`: it applies the moment
// `item` exists (the Loader creates it synchronously, so still before
// the first render pass), it re-applies if the route flips
// mixtapes <-> collections while the same instance is mounted, and it
// keeps the whole router declarative. Same shape as the seeded-field
// pattern at controls/QbzLineEdit.qml:174-179.
//
// RestoreNone: the target is DESTROYED on unload, and the default
// RestoreBindingOrValue would try to write the old value back into a
// dying object.
//
// It applies in KIOSK too, and must: KioskMyQBZ serves the same two
// routes as one component (KioskShell.slint:512,517), so without the
// discriminator Collections would render the Mixtapes page there for
// exactly the same reason.
Binding {
target: viewLoader.item
property: "kind"
value: QbzShell.currentView === "collections" ? "collection" : "mixtape"
when: viewLoader.item !== null
&& (QbzShell.currentView === "mixtapes"
|| QbzShell.currentView === "collections")
restoreMode: Binding.RestoreNone
}
}