Files
qbz/crates/qbz-qt/qml/shell/ContentRouter.qml
T
vicrodh b1dba198c9 feat(qt): kiosk profile release hardening
Integrate the Kiosk profile hardening round: 12 dedicated Kiosk views plus
22 shared opt-in hosts, shared navigation history with Back/Forward, Albums
as the default library tab, native paged Local Library (artists/tracks),
lightweight MyQBZ grid and detail, Ephemeral lifecycle, bounded lists and
artwork sized to the physical frame, static skeletons, 64px transport bar,
Settings touch reflow and a persistent Immersive exit.

Desktop keeps its prior behavior: every shared component defaults to the
desktop path and only opts into Kiosk behavior through explicit properties
(origin-relative ScrollMemory, viewport-bounded artwork requests).

Regression coverage: kiosk navigation and artwork Node tests added to the
runtime gate, scroll-origin fixtures for fixed and grouped rows, i18n
strings for all eight locales.

Verified by 750 release tests, six QML audits, Kiosk smokes at 800x480 and
1280x800 and a Desktop A/B with seven pixel-identical captures.

cc
2026-09-07 08:23:11 -06:00

627 lines
33 KiB
QML

// Shared route-to-view mapping for Desktop and Kiosk.
// Kiosk-specific views and explicit kioskHost initial properties implement
// the 2026-09-07 hardening contract; Desktop retains its existing route files.
// Keep this file in qml/shell: paths are relative to this directory.
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
import "../controls"
import "../kiosk"
Item {
id: root
// false = desktop shell (AppShell), true = kiosk shell (KioskShell).
property bool kiosk: false
// Each shared view declares kioskHost:false; only this shell opts in.
readonly property var kioskHostRoutes: [
"settings", "metadataeditor", "purchases", "purchase-album",
"queue-view", "offlinemanager", "libraryfolders", "blacklist",
"recentalbums", "mostplayedalbums", "label", "labelreleases",
"artistreleases", "awardalbums", "discoverbrowse", "playlistbrowse",
"playlistmanager", "musician", "scene", "discobuilder",
"award", "mix"
]
// Capture only the explicit live tab when switching into Kiosk. The
// ordinary desktop mount and navigation path do not consume this seam.
Connections {
target: QbzShell
function onKioskProfileChanged() {
if (!QbzShell.kioskProfile || root.kiosk || !viewLoader.item) return
var item = viewLoader.item
if (QbzShell.currentView === "search" && typeof item.tab === "number") {
QbzShell.reportNavState("search", JSON.stringify({
activeTab: String(item.tab), query: item.query || ""
}))
} else if (typeof item.activeTab === "string") {
var state = typeof item.navigationStateJson === "string"
? item.navigationStateJson : JSON.stringify({activeTab: item.activeTab})
QbzShell.reportNavState(QbzShell.currentView, state)
}
}
}
// 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
// SYNCHRONOUS — and the reasoning for the failed experiment is kept here so
// nobody re-runs it.
//
// The dead-click complaint ("nothing is happening after the click… ah, it
// WAS loading, I thought it had died") is real: a route change instantiates
// the whole view on the UI thread before anything can paint. `asynchronous:
// true` plus a router-level placeholder looked like the answer, and it made
// the app MUCH worse — the owner's words were "como si mi conexión fuera de
// 56kbps" and "nos regresó 10 años atrás".
//
// WHY it was worse, because this is the part that is not obvious: async
// incubation is TIME-SLICED. It yields to the UI thread between slices, so
// it trades a short freeze for a much longer wall-clock build, and the
// Loader stays in `Loading` until the ENTIRE tree exists — for Discover >
// Home that means all 413 cards. A 1s freeze became a five-second skeleton.
// On a top-level tab the user expects to be instant, that trade is simply
// the wrong one.
//
// WHAT THE REAL FIX TURNED OUT TO BE — and this paragraph used to say the
// opposite, so read it before acting on a memory of it. It claimed the
// answer was to "keep the top-level tabs alive across navigation". That is
// wrong, and it was wrong on the evidence available at the time: BOTH
// references destroy and re-create the view exactly like this Loader does.
// Slint gates every view on `if NavState.view == ...` (AppShell.slint:426),
// which destroys the element tree; Tauri used `{#if activeView === 'home'}`
// (+page.svelte:6286), which removes it from the DOM — and Tauri even
// REFETCHES from the network on mount and still transitions instantly.
// Neither gets its speed from caching the view.
//
// What they both do is keep the MOUNT proportional to one viewport, and
// that is where this port had diverged. Measured 2026-08-17 by driving the
// real app (`QBZ_QT_NAV_BENCH`, the probe below, and a frame-by-frame read
// of a screen recording): a click on Discover froze the UI thread for
// 1.52 s — not slow, FROZEN, zero pixels changing anywhere in the window —
// where the Slint build painted the same page, artwork included, in a
// single frame. Inside that time HomeView was building all FOUR of its
// Discover tabs, because `visible: false` hides an item and does not stop
// QML from instantiating it, so 19 of 28 section rails belonged to tabs
// nobody was looking at. Slint runs ONE repeater whose model is chosen by
// a ternary (HomeView.slint:321) and mounts the rest behind `if`.
//
// So the rule is about the SIZE of a mount, not its lifetime: a view may
// be rebuilt freely as long as it only builds what is on screen. That rule
// is now enforced statically — qml_eager_tab_audit.py runs in qt-run.sh
// and fails the build on a heavy per-tab body gated only by `visible:`.
// Synchronous stays, and is correct — what changed on 2026-08-21 is
// WHEN it runs, not how. See THE TWO-PHASE ROUTE COMMIT below.
// Route-change stopwatch. OFF by default (a LoggingCategory at Warning
// emits nothing at Info), so the only cost on a normal run is one
// Date.now() per route change. Turn it on with:
//
// QT_LOGGING_RULES="qbz.nav.timing.info=true" ./target/release/qbz
//
// It answers the question no static gate can: of the wall-clock between
// the click and a usable page, how much is QML instantiation (`built`)
// versus everything the event loop still owes afterwards (`to-idle`).
LoggingCategory {
id: navTiming
name: "qbz.nav.timing"
defaultLogLevel: LoggingCategory.Warning
}
property double _routeT0: 0
// ── THE TWO-PHASE ROUTE COMMIT (the dead click) ────────────────────────
//
// MEASURED 2026-08-21, by driving the real app under Xvfb and diffing the
// captured frames: a click on Discover > Home left the window showing the
// OLD page — flyout still open, not one pixel changing anywhere — for
// 590 ms. The route change is synchronous (read the Loader's header above, which
// is still correct), so the click handler, the flyout close, the sidebar
// highlight, the page blank AND the whole mount all happened inside ONE
// turn of the event loop, and the first frame that could be presented was
// the one that already had the new page in it. Nothing the user did was
// acknowledged until everything was finished.
//
// The breakdown of that 590 ms is worth keeping, because only ONE of the
// three parts is what it looks like:
// navigate_to (Rust, on the GUI thread) 7.6 ms
// bridge queue hop 1 ms
// the QML mount (`built`) 263 ms
// post-mount work before the first frame ~290 ms <-- `to-idle`
// `built` is under half of it. The other half is everything the mount
// DEFERS — layout and the first delegate refills — which the GUI thread
// pays before it can reach the event loop again and let a frame out. So
// `to-idle`, not `built`, is the number that matches the freeze.
//
// THE SPLIT: arm on the route change, commit on the next PRESENTED frame.
// Frame N carries the cheap half — flyout gone, nav highlight moved, page
// blanked — and only then does frame N+1 pay for the mount. The click is
// acknowledged in one frame instead of in `to-idle`.
//
// `frameSwapped` and not a zero-interval Timer: the timer fires on the
// next event-loop iteration, which is NOT the same thing as "after a frame
// reached the screen" — the render pass is scheduled through the event
// loop too, so a Timer can easily win the race and put the mount back in
// the same frame, which is the bug. `frameSwapped` is emitted after the
// buffer swap (verified against this Qt build with a standalone scene),
// and its queued delivery lands the handler on the GUI thread.
//
// Cost: ONE frame (~16 ms) of added latency to the content, in exchange
// for the whole `to-idle` window becoming visibly alive.
/// The file the CURRENT route wants. Pure — no side effects — so that the
/// "two ids, one file" cases (recentalbums / mostplayedalbums) simply do
/// not change it, and the arm below never fires for them. That is the same
/// guard the old `_fade.path` holder carried, expressed as the identity of
/// the thing being watched rather than as a manual comparison.
readonly property string _wantedPath: root._pathFor(QbzShell.currentView)
/// What the Loader has actually been given. Assigned, never bound: it
/// trails `_wantedPath` by exactly one presented frame.
property string _loaderPath: ""
/// A route is waiting for its frame.
property bool _armed: false
function _pathFor(v) {
return v === "home"
? (root.kiosk ? "../kiosk/KioskDiscover.qml" : "../views/HomeView.qml")
: v === "library"
? (root.kiosk ? "../kiosk/KioskLibrary.qml" : "../views/LibraryView.qml")
: v === "local"
? (root.kiosk ? "../kiosk/KioskLocalLibrary.qml" : "../views/LocalLibraryView.qml")
: v === "localalbum" ? (root.kiosk ? "../kiosk/KioskLocalAlbum.qml" : "../views/LocalAlbumView.qml")
: v === "metadataeditor" ? "../controls/TagEditorModal.qml"
: v === "album"
? (root.kiosk ? "../kiosk/KioskAlbum.qml" : "../views/AlbumView.qml")
: v === "artist"
? (root.kiosk ? "../kiosk/KioskArtist.qml" : "../views/ArtistView.qml")
: v === "settings" ? "../settings/SettingsView.qml"
: v === "search"
? (root.kiosk ? "../kiosk/KioskSearch.qml" : "../views/SearchView.qml")
: v === "queue-view" ? "../views/QueueView.qml"
: v === "playlist" ? (root.kiosk ? "../kiosk/KioskPlaylist.qml" : "../views/PlaylistView.qml")
: v === "discoverbrowse" ? "../views/DiscoverBrowseView.qml"
: v === "playlistbrowse" ? "../views/PlaylistBrowseView.qml"
: v === "recentalbums" ? "../views/PlayHistoryView.qml"
: v === "mostplayedalbums" ? "../views/PlayHistoryView.qml"
: v === "label" ? "../views/LabelView.qml"
: v === "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-19):
// 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.
: v === "artistreleases" ? "../views/ArtistReleasesView.qml"
// Artist page > Network > Origin > the location link, and the
// header ⋯ > "Artist Scene". Both doors call QbzScene.open(...),
// which records this id (artist_scene_qt.rs:430).
: v === "scene" ? "../views/ArtistSceneView.qml"
// A credited musician who did NOT resolve to a Qobuz artist:
// musician_qt.rs:362 records this on the `contextual` branch only.
// `weak`/`none` are a global modal, not a route — see AppShell.
: v === "musician" ? "../views/MusicianPageView.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.
: v === "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).
: (v === "mixtapes" || v === "collections")
? (root.kiosk ? "../kiosk/KioskMyQBZ.qml" : "../views/myqbz/MyQbzGridView.qml")
: v === "mixtapedetail" ? (root.kiosk ? "../kiosk/KioskMyQBZDetailLite.qml" : "../views/myqbz/MyQbzDetailView.qml")
: v === "discobuilder" ? "../views/myqbz/DiscoBuilderView.qml"
// Settings > Blacklist > Manage. Not reachable from the sidebar —
// blacklist_qt::open_manager records the route.
: v === "blacklist" ? "../views/BlacklistManagerView.qml"
// Settings > Local Library > Library folders > Manage. Same
// shape as the blacklist arm above: not a sidebar section, and
// the "library-open-folders" settings action records the route.
: v === "libraryfolders" ? "../views/LibraryFoldersView.qml"
// Settings > Offline > Manage offline cache > Open manager. Same
// shape again; offline_manager_qt::open() records the route.
: v === "offlinemanager" ? "../views/OfflineManagerView.qml"
// Awards — reached from the album sidebar's laurel, an "Other
// awards" card or the landing page's all-awards dropdown. Both
// routes are recorded by award_qt (open_award / open_albums); the
// id lives in the controller, not in the route, exactly like
// "label" / "labelreleases" next to them.
: v === "award" ? "../views/AwardView.qml"
: v === "awardalbums" ? "../views/AwardAlbumsView.qml"
// Sidebar > Playlists ⋯ > Manage playlists. Not a sidebar
// section — playlist_manager_qt::navigate() records the route.
: v === "playlistmanager" ? "../views/PlaylistManagerView.qml"
// Purchases — the opt-in Qobuz store surface (Settings >
// Appearance > Navigation > Show Purchases, default OFF). The
// sidebar's direct row records "purchases"; a purchased album's
// card records "purchase-album" through
// QbzPurchases.openAlbum(id), which holds the id — the route
// carries none, exactly like "localalbum".
//
// BOTH arms land in the same edit on purpose. This chain's failure
// mode is a blank pane logged nowhere, and Purchases is the one
// feature nobody on this team can smoke-test (the owner's region
// does not sell it, so the account returns an empty list forever):
// a missing arm here would first be seen by a stranger.
: v === "purchases" ? "../views/PurchasesView.qml"
: v === "purchase-album" ? "../views/PurchaseAlbumView.qml"
// KIOSK-ONLY route (nav_qt.rs:46-51): 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 && v === "nowplaying") ? "../kiosk/KioskNowPlaying.qml" : ""
}
/// ARM. Runs the instant the route resolves to a DIFFERENT file: blank the
/// page and start the stopwatch, but do not touch the Loader yet.
// `on_Wanted...`, NOT `onWanted...`: QML derives a change handler's name by
// upper-casing the property's FIRST letter, and for `_wantedPath` that
// first letter is the underscore — so the handler is `on_WantedPathChanged`
// (the same shape `theme/RoundedImage.qml` already uses for
// `on_EffectiveSourceChanged` and `on_DprChanged`).
//
// Worth the comment because of HOW it fails: `onWantedPathChanged` is not a
// syntax error, qmlcachegen compiles it without a word, and the app starts
// fine — the first route is armed by Component.onCompleted below. Every
// navigation AFTER that silently does nothing. Proven against this Qt build
// with a standalone scene before it could ship.
on_WantedPathChanged: {
root._routeT0 = Date.now()
root._armed = true
// An unmapped route unloads the Loader; there is nothing to reveal, so
// there is nothing to blank either.
if (root._wantedPath !== "" && !QbzShell.reduceMotion) {
// Stop first: a second navigation inside the 300 ms would
// otherwise have a running render-thread Animator writing the
// property back from underneath this assignment.
fadeIn.stop()
viewLoader.opacity = 0
fadeGuard.restart()
}
commitGuard.restart()
}
function _commit() {
if (!root._armed)
return
commitGuard.stop()
// ORDER MATTERS. `_armed` is what keeps the two Bindings below OFF the
// OUTGOING view (see their `when`), and the Loader builds the new item
// SYNCHRONOUSLY inside this assignment — so clearing the flag first
// would re-evaluate those `when` clauses one statement too early, while
// `viewLoader.item` is still the view on its way out. Assign, then
// disarm.
root._loaderPath = root._wantedPath
if (root.kiosk) {
var properties = root.kioskHostRoutes.indexOf(QbzShell.currentView) >= 0 ? {kioskHost: true} : {}
viewLoader.setSource(root._loaderPath, properties)
}
root._armed = false
}
/// The frame that carries the acknowledgement has reached the screen —
/// build the view now.
Connections {
target: root.Window.window
ignoreUnknownSignals: true
function onFrameSwapped() { root._commit() }
}
/// Backstop, and NOTHING ELSE. A window that is minimized or hidden
/// presents no frames at all, and a route that never commits is a
/// permanently blank content pane — the worst failure this file can
/// produce. So there has to be a timer; the only question is how long.
///
/// IT WAS 32 ms AND THAT WAS A RACE, measured 2026-08-21 by capturing two
/// navigations frame by frame: Discover > Home got its acknowledgement
/// frame at +90 ms, and Library > All got NONE — one step at +290 ms with
/// nothing before it, i.e. the old dead click, on the same build, minutes
/// apart. The path is render-at-the-next-vsync (16.67 ms on this driver,
/// confirmed with QSG_INFO=1) plus a QUEUED delivery of `frameSwapped`
/// back to the GUI thread, so the honest range is ~16-33 ms and a 32 ms
/// guard wins the coin flip often enough to make navigation feel
/// inconsistent — which is worse than being slow, because the user cannot
/// learn it.
///
/// 250 ms puts the guard far outside that range: `frameSwapped` wins on
/// every visible window, and the only thing the timer still covers is the
/// case it was written for, where nobody can see the delay anyway.
Timer {
id: commitGuard
interval: 250
repeat: false
onTriggered: root._commit()
}
/// THE FIRST ROUTE. `on_WantedPathChanged` covers every navigation, but the
/// startup view arrives as the INITIAL evaluation of the binding, and
/// whether that counts as a change is not something to bet the whole
/// content pane on: if it does not fire, `_loaderPath` stays "" and the
/// app opens to a blank page with nothing logged. Arm it explicitly.
Component.onCompleted: {
if (root._loaderPath !== root._wantedPath) {
root._armed = true
commitGuard.restart()
}
}
// --- Page fade ---------------------------------------------------------
// COSMETIC, FIXED DURATION, AND IT NEVER WAITS FOR CONTENT. Only the
// REVEAL is animated: the page snaps to transparent when the route is
// ARMED (the frame that acknowledges the click), and fades back in from
// the moment the view has been built. There is no fade-OUT because there
// is nothing to fade out of — the acknowledgement frame is where the old
// page leaves, and animating that would put a 300 ms wait in front of a
// navigation the whole two-phase commit exists to make feel instant.
//
// OpacityAnimator, not NumberAnimation, and this is the whole trick: an
// Animator runs on the RENDER thread, so it keeps advancing while the GUI
// thread is still paying off everything the mount deferred (layout, the
// first delegate refills). Measured on this shell with the thread blocked:
// OpacityAnimator advanced 0.53 where NumberAnimation advanced 0.11. The
// shared QbzShell.pulseMs cannot serve this: the pulse arrives through
// `ui(...)` onto the Qt event loop (shell_bridge.rs:966), i.e. the very
// thread the mount is blocking, so a pulse-driven fade would freeze during
// exactly the milliseconds it exists to cover.
//
// GPU doctrine (qt-frontend/2026-08-11-scenegraph-batches §9) forbids
// CONTINUOUS animation off the shared pulse, because each dirty frame
// presents the whole window at ~1.2% GPU. This one is bounded and
// user-initiated: one navigation buys ~18 presents at 60 Hz, and at rest
// it writes NOTHING (the Animator is stopped and opacity is a flat 1).
//
// 300 ms. The contract's first proposal was 120-150 ms; at 140 the owner
// could not see it at all. Part of that was the duration and part was a
// real defect (see the note on WHAT fades, below) — 300 fixes the half
// that is duration, and is still one bounded animation per navigation.
property int fadeMs: 300
// WHAT FADES, and why this is the page and not a veil over it.
//
// The first cut of this faded a Rectangle laid over the content, coloured
// from the pane it sits in. That is wrong here, and invisibly so: AppShell
// paints the pane `surface-main` normally but `surface-main @ 0.22` while
// the ambient background is on (AppShell.qml:324, QbzTheme.qml:38), and the
// ambient background is on whenever a track is loaded (QbzTheme.qml:132).
// So in the configuration the app actually runs in, a veil at FULL opacity
// covered 22% of the page and the fade read as a flicker. Fading the page
// itself has no such failure mode, needs no colour at all, and keeps the
// ambient art visible underneath — which a veil opaque enough to work would
// have blacked out for the length of the fade.
//
// No `layer.enabled`: group opacity would cost an FBO the size of the
// content pane, allocated at the exact moment the mount is already the most
// expensive thing on screen. Per-node alpha lets overlapping children show
// through each other, which is the honest trade — and at 300 ms on a page
// that is arriving rather than sitting still, it is not visible.
//
// reduceMotion (shell_bridge.rs:217) skips the whole thing: the page simply
// appears, which is the behaviour before this block existed.
// Watchdog. `onLoaded` is the only thing that brings the page back, and an
// invisible content pane is the worst failure this file can produce, so a
// missed signal (a view that fails to instantiate, a source that resolves
// to nothing) must not be able to leave it at zero.
Timer {
id: fadeGuard
interval: 1500
repeat: false
onTriggered: viewLoader.opacity = 1
}
OpacityAnimator {
id: fadeIn
target: viewLoader
from: 0.0
to: 1.0
duration: root.fadeMs
easing.type: Easing.OutCubic
}
// TABS FADE TOO. A tab switch inside a view (Discover's four, Library's,
// Local Library's) never touches the Loader's `source`, so none of the
// machinery above fires — the page changed under the user with a hard cut
// while a route change faded. Same transition, same duration, for what is
// the same thing from where the user sits.
//
// `activeTab` is the ONE name every tabbed view uses for this (the Binding
// further down already routes the nav flyout through it), so watching it
// here covers all of them from one place instead of a copy per view.
// `ignoreUnknownSignals` is what lets the untabbed views mount unharmed:
// without it a Connections whose target has no such signal is an error.
//
// No watchdog and no opacity assignment: a tab body is built synchronously
// inside the view, in this same turn, so by the time a frame renders there
// is something to reveal — `restart()` alone sets the page to `from` (0)
// and animates it back. The `running` guard keeps a tab that is stamped
// right after a route change from restarting the fade that route already
// started.
Connections {
target: viewLoader.item
ignoreUnknownSignals: true
function onActiveTabChanged() {
if (!QbzShell.reduceMotion && !fadeIn.running)
fadeIn.restart()
}
}
Loader {
id: viewLoader
anchors.fill: parent
source: root.kiosk ? "" : root._loaderPath
visible: !root.kiosk || !root._armed
onLoaded: {
// The reveal starts in the same turn the view finished building,
// i.e. before a single frame of it has been presented. The render
// thread picks the Animator up at that first frame, so the fade is
// exactly as long as it says it is no matter how busy the GUI
// thread still is.
fadeGuard.stop()
if (QbzShell.reduceMotion) {
viewLoader.opacity = 1
} else {
fadeIn.restart()
}
var built = Date.now() - root._routeT0
console.info(navTiming, "[navtiming] " + QbzShell.currentView
+ " built=" + built + "ms at=" + Date.now())
// One more turn of the event loop: everything the mount deferred
// (layout, the first delegate refills, the pending property
// updates) has run by the time this fires, so the delta is the
// honest "how long until the page stops owing work" number.
var t0 = root._routeT0
var view = QbzShell.currentView
Qt.callLater(function () {
console.info(navTiming, "[navtiming] " + view
+ " to-idle=" + (Date.now() - t0) + "ms")
})
}
}
// Kiosk acknowledges the route with cheap static geometry during the
// existing one-frame commit split. No remote model or image is mounted.
Loader {
anchors.fill: parent
active: root.kiosk && root._armed
sourceComponent: Component {
KioskSkeleton {
loading: true
kind: ["album", "localalbum", "nowplaying"].indexOf(QbzShell.currentView) >= 0
? "list" : "grid"
}
}
}
// 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.
//
// `!root._armed` IS LOAD-BEARING ON BOTH BINDINGS BELOW, and it is newer
// than the rest of these comments. The two-phase commit means
// `QbzShell.currentView` is the NEW route for one frame while
// `viewLoader.item` is still the OLD view — so without it, every
// navigation applies the incoming route's discriminators to the OUTGOING
// view.
//
// That is not theoretical: it shipped for one build and a frame capture
// caught it. Library -> Discover > Home wrote `activeTab = "home"` onto
// the LIBRARY view (which has an `activeTab`, so the `typeof` guard passes
// happily), whose tabs are all/tracks/albums/… — the body emptied, the tab
// bar stayed with nothing selected, and the `onActiveTabChanged` that
// followed restarted the page fade and brought the OUTGOING page back up
// over the blank. Visible as a flash of the previous section, mid-flight.
//
// The `typeof` guards stay as well: they are what keeps the `kind` Binding
// from warning against views that have no such property at all.
Binding {
target: viewLoader.item
property: "kind"
value: QbzShell.currentView === "collections" ? "collection" : "mixtape"
when: !root._armed
&& viewLoader.item !== null
&& (QbzShell.currentView === "mixtapes"
|| QbzShell.currentView === "collections")
&& typeof viewLoader.item.kind === "string"
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.
/// Does the MOUNTED view have an `activeTab` at all?
///
/// Captured in a HANDLER, never inside the Binding's `when`. Reading
/// `typeof viewLoader.item.activeTab` from `when` makes the condition
/// depend on the very property the Binding WRITES, and QML reports the
/// cycle on every flyout navigation:
///
/// QML Binding: Binding loop detected for property "when"
///
/// (Pre-existing — reproduced on the pre-2026-08-21 binary by navigating
/// through a nav flyout, where it logs from the same Binding at its own
/// line number.) A handler runs once per item change and registers no
/// dependency, so the guard survives and the cycle does not.
property bool _itemTabbed: false
property bool _itemTabRequest: false
Connections {
target: viewLoader
function onItemChanged() {
root._itemTabbed = viewLoader.item !== null
&& typeof viewLoader.item.activeTab === "string"
root._itemTabRequest = viewLoader.item !== null
&& typeof viewLoader.item.tabNavigationRequest !== "undefined"
}
}
// Opening ad-hoc media is a GLOBAL navigation action. The old listener
// lived only inside LocalLibraryView, so opening a folder/disc from
// Settings or another section completed successfully but left the user on
// the old page. The sequence changes only for an explicit user open (not
// boot rehydrate); navigateToTab avoids duplicate history when Local
// Library is already mounted and applies the tab after a fresh mount.
Connections {
target: QbzLocal
function onLocalEphemeralOpenSeqChanged() {
QbzShell.navigateToTab("local", "ephemeral")
}
}
Binding {
target: viewLoader.item
property: root._itemTabRequest ? "tabNavigationRequest" : "activeTab"
when: !root._armed
&& root._itemTabbed
&& QbzShell.navTab !== ""
&& QbzShell.navTabView === QbzShell.currentView
value: root._itemTabRequest
? ({ "tab": QbzShell.navTab, "sequence": QbzShell.navTabSeq })
: (QbzShell.navTabSeq, QbzShell.navTab)
restoreMode: Binding.RestoreNone
}
}