Files
qbz/crates/qbz-qt/qml/shell/ContentRouter.qml
T
vicrodh 98dfcd8173 fix(qt): nav flyout entries land on their own tab
Every flyout entry (Discover > For You, Library > Albums, Local >
Folders...) navigated to the section but landed on its default tab: the
port handed the tab to the mounted view through a depth-capped scene
search (NavFlyout.findTabHost, cap 7), and the ContentRouter extraction
(kiosk port D3) added a mount level the walk never reached — flagged as
a latent desktop regression in the kiosk audit (2026-08-02
AUDIT-REPORTS.md, finding 11) and confirmed by smoke test.

The tab now travels the way this codebase crosses documents: the
bridge. NavFlyout.activate calls the new QbzShell.navigateToTab(view,
tab), which publishes navTab/navTabView/navTabSeq, and a Binding in
ContentRouter applies it to the mounted view's activeTab — the same
pattern as the MyQBZ kind discriminator. The seq counter re-applies
on a repeated click of the same entry, the navTabView guard keeps a
cross-view request off the outgoing view, and a plain navigateTo clears
the request so section navigation never drags a stale tab along. A
click for the already-mounted view skips navigation (no duplicate
history entry) and re-applies the tab live.

Verified by smoke: cross-section, same-section, repeated-click
and plain-navigation cases all land where Slint lands.

km
2026-08-10 18:57:02 -06:00

177 lines
10 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
}
// NavFlyout landing tab (Discover > For You, Library > Albums, Local >
// Folders...). The request rides the bridge (QbzShell.navigateToTab ->
// navTab/navTabView/navTabSeq) because a QML id cannot cross documents;
// this Binding is the ONLY writer. It replaces NavFlyout's depth-capped
// scene search (findTabHost), which the ContentRouter extraction (kiosk
// port D3) silently outran — every flyout entry landed on the section's
// default tab instead of its own.
//
// `navTabSeq` in the value expression forces re-application when the
// same entry is clicked twice in a row (a bare navTab would not
// renotify). `navTabView === currentView` keeps a cross-view request
// from being stamped on the outgoing view before the route lands. The
// typeof guard leaves non-tabbed views alone rather than warning about
// a non-existent property. RestoreNone, like the `kind` Binding above:
// the target is destroyed on unload. An in-view tab-bar click writes
// `activeTab` directly and simply wins until the next flyout click —
// this Binding does not refire without a dependency change.
Binding {
target: viewLoader.item
property: "activeTab"
when: viewLoader.item !== null
&& QbzShell.navTab !== ""
&& QbzShell.navTabView === QbzShell.currentView
&& typeof viewLoader.item.activeTab === "string"
value: (QbzShell.navTabSeq, QbzShell.navTab)
restoreMode: Binding.RestoreNone
}
}