import "pkg:/source/enums/LoadingKind.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/backdrop.bs"
import "pkg:/source/utils/dialogs.bs"
import "pkg:/source/utils/loadingStages.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/nodeHelpers.bs"
import "pkg:/source/utils/translate.bs"
' JRScene is a Scene (not a sgrouter_View), so it does not inherit the router scripts from
' sgrouter_View.xml. Import them here so JRScene.bs can call the sgrouter.* / promises.*
' namespaces directly (mirrors sgrouter_View.xml's script set).
import "pkg:/source/roku_modules/promises/promises.brs"
import "pkg:/source/roku_modules/sgrouter/bslib.brs"
import "pkg:/source/roku_modules/sgrouter/interfaces.brs"
import "pkg:/source/roku_modules/sgrouter/router.brs"
import "pkg:/source/roku_modules/sgrouter/RouterState.brs"
import "pkg:/source/roku_modules/tkss_rodash_v0/rodash.brs"
import "pkg:/source/utils/tasks.bs"
sub init()
' Initialize the log manager HERE: this is the EARLIEST point in the app that can.
' `log.Logger.new()` resolves `m.global.rLog` ONCE and caches it; when the manager does not
' exist yet, every level method (`info`/`warn`/`error`/…) hits an `if m.rLog = invalid then
' return` and silently no-ops for the life of that instance: no output, no error, forever.
' Until this moved out of JRScreen.init, JRScene itself plus RemoteControlTask / SceneManager
' / QueueManager / SideEffectTask logged NOTHING on any build, at any log level.
'
' It cannot move any earlier. This is a platform constraint, not a preference. Verified on
' device (Streaming Stick 4K, OS 15.2.4): `initializeLogManager` creates a `log_Log` node
' whose own init() creates a `Timer` (roku_modules/log/Log.brs), and creating a Timer on the
' MAIN thread before `m.screen.show()` fails: it returns invalid and the library then faults
' with &he4. So main.bs cannot stand the manager up before `setGlobals()`. JRScene.init runs
' on the render thread, where node creation is unrestricted, which is why it works here.
' Consequence: nodes created in `setGlobals()` (source/utils/globals.bs, called from `Main()`)
' build their Logger before any manager exists and stay silent (see
' docs/architecture/logging.md).
'
' Second param is the max level emitted: 0 error, 1 warn, 2 info, 3 verbose, 4 debug.
#if debug
log.initializeLogManager(["log_PrintTransport"], 4) ' Debug: everything
#else
log.initializeLogManager(["log_PrintTransport"], 2) ' Production: error + warn + info
#end if
m.log = new log.Logger("JRScene")
m.top.backgroundColor = m.global.constants.colorBackgroundPrimary
m.top.backgroundURI = ""
m.loadingText = m.top.findNode("loadingText")
m.spinner = m.top.findNode("spinner")
m.loadingStageText = m.top.findNode("loadingStageText")
m.loadingStageTimer = m.top.findNode("loadingStageTimer")
m.loadingStageTimer.observeField("fire", "showLoadingStage")
' When the scene's own spinner started (loadingStages.nowMs), marked at every start.
m.sceneWaitStartedMs = loadingStages.nowMs()
' The routed view whose loadingWaits the spinner is showing (bindLoadingWaits).
m.waitsView = invalid
' Keeps the waits the whole app is in (appWaits); the spinner redraws as they open and end.
m.appWaitHost = m.top.findNode("appWaitHost")
m.appWaitHost.observeField("openWaits", "showLoadingStage")
m.imageFader = m.top.findNode("imageFader")
m.toast = m.top.findNode("toast")
m.lastBackdropUri = "" ' Track last URI to skip redundant imageFader assignments
' Overhang controller state (wired in initRouter, on the first navigation).
' init() runs at CreateScene, BEFORE setGlobalNodes adds m.global.activeRoutedView,
' so the observer is registered in initRouter(), not here. The overhang node itself
' already exists as a child, so cache it now.
m.overhang = m.top.findNode("overhang")
m.previousRoutedView = invalid
' Hide backdrop until setting is resolved on first backdrop request (lazy initialization)
m.imageFader.visible = false
' set text manually to AVOID translation
defaultFont = m.top.findNode("defaultFont")
fallbackFont = m.top.findNode("fallbackFont")
defaultFont.text = "Ag"
fallbackFont.text = "Ag"
' Test toast trigger: set from BrightScript console for quick visual verification
m.top.observeField("testToast", "onTestToast")
' Debug-only: cheat code state for toast testing, compiled out in production
#if debug
m.debugCodeSequence = ["up", "up", "down", "down"]
m.debugCodeProgress = 0
m.debugLastKeyTime = 0
m.debugToastIndex = 0
#end if
end sub
' Test hook, in every build (no `#if debug`): triggered when testToast field is set from the
' BrightScript console.
' Format: "type|message" where type is "error", "success", "warning", or "info".
' Falls back to "error" type if no pipe delimiter is found.
'
' Usage from BrightScript console (port 8085):
' m.top.getScene().testToast = "error|Something went wrong"
' m.top.getScene().testToast = "success|Item saved"
' m.top.getScene().testToast = "info|Loading filters..."
' m.top.getScene().testToast = "A message with no type"
sub onTestToast()
value = m.top.testToast
if value = "" then return
parts = value.split("|")
if parts.count() >= 2
toastType = parts[0]
message = value.mid(toastType.len() + 1)
else
toastType = "error"
message = value
end if
showToast(message, toastType)
end sub
sub onLoadingTextChanged()
' An app wait's label shows only while the scene's own spinner is down (showLoadingStage).
if m.top.isLoading then m.loadingText.text = m.top.loadingText
end sub
sub onBackgroundImageUriChanged()
if not isValid(m.imageFader) then return
' Resolve backdrop setting on first use (lazy initialization)
if m.top.shouldShowBackdrop = invalid
localUser = m.global.user
if isValid(localUser)
m.top.shouldShowBackdrop = resolveShowBackdrop(localUser.settings, localUser.config)
else
m.top.shouldShowBackdrop = false ' Don't show backdrops if called before login
end if
m.imageFader.visible = m.top.shouldShowBackdrop
end if
' Only update URI if backdrops are enabled (performance optimization)
if m.top.shouldShowBackdrop
m.imageFader.isAnimated = true
m.imageFader.uri = m.top.backgroundImageUri
end if
end sub
' Set the background image with animation control
' @param {string} uri - The image URI to display
' @param {boolean} isAnimated - Whether to animate the transition
' @param {boolean} forceBackdrop - Force show backdrop regardless of user setting (used for login splashscreen)
sub setBackgroundImage(uri as string, isAnimated = true as boolean, forceBackdrop = false as boolean)
if not isValid(m.imageFader) then return
' Force backdrop mode bypasses user settings (used for login splashscreen)
if forceBackdrop
' For empty URI in force mode, clear URI and reset state for next screen
if uri = invalid or uri = ""
m.imageFader.uri = ""
' Reset shouldShowBackdrop to invalid so next screen can properly initialize
m.top.shouldShowBackdrop = invalid
' Explicitly hide imageFader to ensure a clean state if next screen does not reinitialize backdrop visibility
m.imageFader.visible = false
else
m.imageFader.isAnimated = isAnimated
m.imageFader.uri = uri
m.imageFader.visible = true
end if
return
end if
' Resolve backdrop setting on first use (lazy initialization)
if m.top.shouldShowBackdrop = invalid
localUser = m.global.user
if isValid(localUser)
m.top.shouldShowBackdrop = resolveShowBackdrop(localUser.settings, localUser.config)
else
m.top.shouldShowBackdrop = false ' Don't show backdrops if called before login
end if
m.imageFader.visible = m.top.shouldShowBackdrop
end if
' Only update URI if backdrops are enabled (performance optimization)
if m.top.shouldShowBackdrop
' Skip if URI has not changed - BackdropFader would deduplicate anyway, but skipping here
' avoids the field assignment and unnecessary observer call entirely.
if m.lastBackdropUri = uri then return
m.lastBackdropUri = uri
m.imageFader.isAnimated = isAnimated
m.imageFader.uri = uri
end if
end sub
' Re-evaluate and apply the backdrop visibility setting
' Useful when settings change at runtime (for example, from Settings screen)
sub refreshBackdropSetting()
if not isValid(m.imageFader) then return
localUser = m.global.user
if isValid(localUser)
m.top.shouldShowBackdrop = resolveShowBackdrop(localUser.settings, localUser.config)
m.imageFader.visible = m.top.shouldShowBackdrop
' Clear backdrop URI if hiding to free memory
if not m.top.shouldShowBackdrop
m.top.backgroundImageUri = ""
end if
end if
end sub
' Triggered when the isLoading boolean component field is changed
sub onIsLoadingChanged()
' toggle visibility of active view/group
group = m.global.activeRoutedView
if isValid(group)
group.visible = not m.top.isRemoteDisabled
end if
if m.top.isLoading
' Every start is a new wait, so its stages count from here.
m.sceneWaitStartedMs = loadingStages.nowMs()
else
' A direct `isLoading = false` (loginRouter's bootstrap) must not leave this kind behind for
' a later direct `isLoading = true` that sets none.
m.top.loadingKind = LoadingKind.PLAIN
end if
showLoadingStage()
end sub
' Shows the spinner for whatever is waiting (the scene's own spinner, its app waits, or the
' active view's loadingWaits; loadingStages.showing) with the text for how long it has
' waited and the label above it, and arms
' the timer for the next change. Also the handler for the timer and for the active view's
' loadingWaits: an early fire finds the same text and re-arms for the remainder; a late one,
' after the wait ended, finds nothing to show.
sub showLoadingStage()
m.loadingStageTimer.control = "stop"
waits = invalid
if isValid(m.waitsView) then waits = m.waitsView.loadingWaits
sceneWait = { isLoading: m.top.isLoading, kind: m.top.loadingKind, startedMs: m.sceneWaitStartedMs }
shown = loadingStages.showing(sceneWait, m.appWaitHost.openWaits, waits, loadingStages.nowMs())
m.spinner.visible = shown.visible
' The text above the spinner: the scene's own spinner's (loadingText), or else the label of
' the newest app wait (a channel's name).
if m.top.isLoading
m.loadingText.text = m.top.loadingText
m.loadingText.visible = true
else
m.loadingText.text = shown.label
m.loadingText.visible = shown.label <> ""
end if
kind = shown.kind
elapsedMs = shown.elapsedMs
key = loadingStages.textKeyAt(kind, elapsedMs)
if key = ""
m.loadingStageText.text = ""
else
m.loadingStageText.text = translate(key)
end if
m.loadingStageText.visible = key <> ""
nextMs = loadingStages.msUntilNextChange(kind, elapsedMs)
if nextMs > 0
m.loadingStageTimer.duration = nextMs / 1000
m.loadingStageTimer.control = "start"
end if
end sub
' showToast: Display a transient toast notification.
' @param {string} message - The message to display
' @param {string} [toastType="error"] - "error", "success", or "info"
sub showToast(message as string, toastType = "error" as string)
if not isValid(m.toast) then return
m.toast.message = message
m.toast.toastType = toastType
m.toast.shouldShow = true
end sub
function onKeyEvent(key as string, press as boolean) as boolean
' Debug-only: up-up-down-down cheat code on key UP events to cycle test toasts.
' KNOWN LIMITATION (verified on-device 2026-07): key-up events from routed views no
' longer reach JRScene: Roku built-ins (RowList) consume releases for keys they
' handle, and the sgRouter Outlet consumes every release that bubbles out of a routed
' view. The sequence only registers while focus is OUTSIDE the outlet subtree (for
' example the overhang), so treat this cheat as unreliable; prefer the testToast field
' (see docs/dev/debug-flags.md). Compiled out in production (bs_const=debug=false).
#if debug
if not press
now = CreateObject("roDateTime").asSeconds()
if now - m.debugLastKeyTime > 2
m.debugCodeProgress = 0
end if
m.debugLastKeyTime = now
if key = m.debugCodeSequence[m.debugCodeProgress]
m.debugCodeProgress++
if m.debugCodeProgress >= m.debugCodeSequence.count()
m.debugCodeProgress = 0
debugToasts = [
{ type: "error", msg: "[DEBUG] Error toast test" },
{ type: "success", msg: "[DEBUG] Success toast test" },
{ type: "warning", msg: "[DEBUG] Warning toast test" },
{ type: "info", msg: "[DEBUG] Info toast test" }
]
toast = debugToasts[m.debugToastIndex]
m.debugToastIndex = (m.debugToastIndex + 1) mod 4
showToast(toast.msg, toast.type)
return true
end if
else
m.debugCodeProgress = 0
end if
end if
#end if
if not press then return false
if m.top.isRemoteDisabled then return true
if key = "back"
' Back arbiter. The whole app is routed: a routed view's back is intercepted by the outlet
' first (sgRouter.goBack), so a back key only bubbles up to JRScene when goBack returned
' false. goBack returns false BOTH at the history root (our cue to confirm exit) AND while a
' navigation is in flight (sgRouter rejects goBack mid-transition). Without the guard below,
' a back pressed during a transition surfaces a spurious Exit dialog mid-stack. The settling
' navigation owns that back, so swallow it; only confirm exit when the router is idle.
'
' We read routerState.type DIRECTLY here rather than mirroring it via an observer: the
' routerState SG field observer COALESCES rapid writes and reliably drops the terminal
' NavigationEnd (proven on device: a mirrored "navInProgress" flag wedged true and ate
' back-to-exit). A field READ never coalesces, so the field always holds the true latest state:
' a terminal type means idle (confirm exit); any non-terminal type means a nav is in flight.
'
' A deep-link/cast resolve in flight is a DISTINCT case the router doesn't know about yet: the
' metadata fetch runs BEFORE any navigation starts, so the router is still idle on the current
' view (the spinner is up but the remote is intentionally live; see resolveDeepLink). A back
' here means "abort that pending cast", not "exit the app": cancel the resolve and swallow.
if isValid(m.deepLinkResolveTask)
cancelDeepLinkResolve()
return true
end if
if isRouterNavigating() then return true
' A playback start hiding the screen it was pressed on holds the focus (AppWaitHost), so its
' Back lands here: cancel the start and show that screen again, as Back from the player
' would. Mid-navigation it was swallowed above: the settling navigation mounts the player.
if m.appWaitHost.callFunc("cancelOnBack") then return true
showExitConfirmation()
return true
else if key = "options"
group = getActiveView()
if isValid(group) and isValid(group.isOptionsAvailable) and group.isOptionsAvailable
' A view can report isOptionsAvailable without owning a panel (PlayerHostView), and the
' key still arrives from an overlay dialog over it, so there may be nothing to show.
panel = nodeHelpers.findOptionsPanel(group)
if isValid(panel)
group.lastFocus = group.focusedChild
panel.visible = true
panel.findNode("panelList").setFocus(true)
end if
end if
return true
end if
return false
end function
' ===========================================================================
' sgRouter host
'
' JRScene owns the router: it initializes it over the outlet, registers the
' pre-login and post-login routes, drives the overhang from whichever view the
' router has mounted (m.global.activeRoutedView), and confirms app exit when
' back reaches the router root.
' ===========================================================================
' Initialize the router (outlet + routes + observers) WITHOUT navigating. Idempotent:
' a no-op if a router already exists on the scene. Called lazily by routerNavigate
' (main thread) so the very first navigation (pre-login OR Home) brings the router up.
' Re-callable after resetRouter() (sign-out, then re-login): sgRouter.initialize creates a
' fresh router when none exists on the scene.
'
' The router hosts the FULL app: the pre-login flow (/server, /users, /login) as well as
' the post-login content/playback routes. The pre-login routes carry no canActivate guards
' (the redirect target /login is one of them); the post-login routes carry the AuthManager
' guard. Pre-login back transitions are coordinator-driven (loginRouter), not
' router-history-driven, because cold start can enter directly at /users with no /server in
' history.
sub initRouter()
if isValid(sgrouter.getRouter()) then return
' Register the overhang controller now that m.global.activeRoutedView exists
' (added in setGlobalNodes, which runs after init()). Guard against a double
' registration on re-login.
m.global.unobserveField("activeRoutedView")
m.global.observeField("activeRoutedView", "onActiveRoutedViewChanged")
' QueueManager.playQueue can't navigate (it has no router chain), so it sets
' m.global.playbackLaunchRequest and we turn that into a route here. The host
' (PlayerHostView) reads the already-built queue on mount.
m.global.unobserveField("playbackLaunchRequest")
m.global.observeField("playbackLaunchRequest", "onPlaybackLaunchRequested")
' Photo viewer launch signal (same no-router-chain reason as playbackLaunchRequest above).
' PhotoDetails reads the AA as route context on mount.
m.global.unobserveField("photoLaunchRequest")
m.global.observeField("photoLaunchRequest", "onPhotoLaunchRequested")
' The auth guard. The SAME node we put on m.global in setGlobalNodes, registered by NODE
' REFERENCE so the path it stashes on a signed-out redirect is readable by the main-thread
' replay helper after login. Added to every POST-login route below; the pre-login routes
' carry no guard (their redirect target, /login, is one of them).
guard = [m.global.AuthManager]
sgrouter.initialize({ outlet: m.top.findNode("routerOutlet") })
routes = [
{ pattern: "/", name: "home", component: "Home", clearStackOnResolve: true, allowReuse: true, canActivate: guard }
' Pre-login flow. No flags, no guard: back is coordinator-driven and the cleanup of these
' views falls out of Home's clearStackOnResolve when login completes.
{ pattern: "/server", name: "server", component: "SetServerScreen" }
{ pattern: "/users", name: "users", component: "UserSelect" }
{ pattern: "/login", name: "login", component: "LoginScene" }
{ pattern: "/details/:type/:id/play", name: "play", component: "PlayerHostView", canActivate: guard }
' No route sets `keepAlive`. It governs whether a view survives being POPPED: upstream
' documents it as "retained for later resumption", a session-lifetime, path-keyed view
' CACHE, so a forward navigation to the same path resumes the old instance. It is also
' unbounded: the only eviction helper (sgrouter_collectDetachedViewsToDestroy) skips
' keepAlive views by design, so nothing short of sgrouter.destroy() at sign-out releases
' them. With it set, every distinct library and item visited stayed alive for the session,
' and onDestroy (tasks stopped, observers dropped, textures freed, promises abandoned, and
' SearchResults releasing the firmware's voice route) never ran. Without it: covered =>
' suspended, popped => closed.
'
' NO allowReuse: JellyRock has always created a FRESH ItemDetails per navigation (the
' old SceneManager world pushScene'd a new one every time, including detail->detail).
' allowReuse would force in-place onRouteUpdate reuse the component was never built for.
' This does not affect a same-path navigation (a repeat cast of the item already
' showing): the router reuses the ACTIVE view whenever isSamePath, independent of both
' flags, which is the path ItemDetails.onRouteUpdate serves.
'
' detail -> /play still restores the launching detail on back: the detail is COVERED by
' the player, not popped, so it suspends and resumes.
{ pattern: "/details/:type/:id", name: "details", component: "ItemDetails", canActivate: guard }
{ pattern: "/library/:id", name: "library", component: "BaseGridView", canActivate: guard }
{ pattern: "/search", name: "search", component: "SearchResults", canActivate: guard }
{ pattern: "/settings", name: "settings", component: "Settings", canActivate: guard }
' Photo viewer / slideshow. A fresh PhotoDetails per launch (the launch data arrives as
' route context), destroyed on goBack: the router equivalent of the old SceneManager
' pushScene/popScene. It sets isOverhangVisible=false, so the overhang hides while it's the
' active routed view, and the library/detail beneath it suspends (focus saved) and resumes
' (focus restored) on back, like the video player.
{ pattern: "/photo", name: "photo", component: "PhotoDetails", canActivate: guard }
' Music player. A fresh AudioPlayerView per launch (it self-loads from the queue),
' destroyed on goBack. It suspends the detail beneath (focus saved / restored on back)
' instead of rendering over it, the same behavior as the video player.
{ pattern: "/audio", name: "audio", component: "AudioPlayerView", canActivate: guard }
]
' Every route suspends with "show": the router leaves a covered view on screen, and
' onActiveRoutedViewChanged hides it when the incoming view opens. Any other mode hides the
' outgoing view several message-loop turns before the incoming one is shown, which leaves
' the screen empty under the old overhang for that long. Set here so a new route cannot
' miss it.
for each route in routes
route.suspendMode = "show"
end for
sgrouter.addRoutes(routes)
end sub
' Back-arbiter helper: is the router mid-navigation right now? Reads the public routerState
' field DIRECTLY (no observer). The routerState observer coalesces rapid writes and reliably
' drops the terminal NavigationEnd (proven on device), so a mirrored flag wedges true; a field
' READ never coalesces, so the field always holds the true latest state. A navigation runs
' through NavigationStart to NavigationEnd; any non-terminal type means a nav is in flight,
' a terminal type (or no router/state yet) means idle.
function isRouterNavigating() as boolean
router = sgrouter.getRouter()
if not isValid(router) or not isValid(router.routerState) then return false
stateType = router.routerState.type
if stateType = "" or stateType = "NavigationEnd" or stateType = "NavigationError" or stateType = "NavigationCancel"
return false
end if
return true
end function
' Navigate + take focus on settle + surface a rejected nav: the shared tail centralizing the
' navigate/focus/catch trio that was hand-copied across routerNavigate, navigateChainStep, and the
' settle drain (folds in finding A4: a rejected nav must re-assert focus, never strand the remote).
' routePath is dynamic: a string path for most calls, or a named-route AA ({ name, ... }) from
' routeForItem on the deep-link container path. sgRouter.navigateTo accepts both.
' clearSpinner: set by the login paths only. A blocking login spinner (started by the login
' coordinator on the OUTGOING pre-login view) is kept up across this async nav and cleared HERE,
' once the destination view has mounted (navigateTo resolves at NavigationEnd). Clearing it
' synchronously before the nav settles re-shows the outgoing pre-login view for a frame (the
' #677 login flash). Mirrors the ItemDetails -> player pattern (spinner stays up across the nav,
' the destination clears it).
sub navigateThenFocus(routePath as dynamic, context = {} as object, clearSpinner = false as boolean)
options = {}
if isValidAndNotEmpty(context) then options = { context: context }
promises.chain(sgrouter.navigateTo(routePath, options), { routePath: routePath, clearSpinner: clearSpinner }).then(sub(_result as object, ctx as object)
if ctx.clearSpinner then stopLoadingSpinner()
sgrouter.setFocus({ focus: true })
end sub).catch(sub(err as object, ctx as object)
' navigateTo rejects when a nav is already in progress (transient) or the route isn't
' registered (a programming error). Re-assert focus so a rejected nav can't strand the remote,
' and surface the reason instead of dropping it silently. Recover before logging, and log err
' whole: reading `.message` crashes when a rejection is not an AA, which skips the recovery.
' Clear the spinner even on a rejected login nav so a failure can't strand it up.
if ctx.clearSpinner then stopLoadingSpinner()
sgrouter.setFocus({ focus: true })
m.log.warn("navigateThenFocus failed; re-asserting focus", ctx.routePath, err)
end sub)
end sub
' Navigate the router to a route, bringing the router up first if needed, then hand
' the mounted view remote focus. Called from main.bs / loginRouter (main thread) to drive
' BOTH the pre-login flow (/server, /users, /login) and the transition to Home ("/").
' context is forwarded as route context (for example a prefilled username for /login).
sub routerNavigate(routePath as string, context = {} as object, clearSpinner = false as boolean)
initRouter()
navigateThenFocus(routePath, context, clearSpinner)
end sub
' Replay the route the auth guard stashed on a signed-out redirect, after login, by navigating
' a SEQUENCE of routes, each step waiting for the previous to settle. Called from
' replayAfterLogin (source/replayRoute.bs) via callFunc when no deep link is stashed; `routes`
' is buildReplayRoutes' ordered array of path strings: ["/"] (a plain Home nav) when nothing
' was stashed, ["/", stashed] for most paths, and ["/", "/details/:type/:id",
' "/details/:type/:id/play"] for a /play path, so back lands Player -> Details -> Home. The
' queue for a /play step was built before the redirect (QueueManager.playQueue); PlayerHostView
' reads it on mount. A stashed deep link goes through resolveDeepLink / onDeepLinkResolved
' instead.
sub replayRoutedDeepLink(routes as object)
initRouter()
if not isValidAndNotEmpty(routes) then routes = ["/"]
' Post-login replay: the blocking login spinner is still up on the outgoing pre-login view.
' Keep it up across the async nav and clear it when the FINAL route settles (clearLoginSpinnerOnEnd
' -> navigateThenFocus clearSpinner). Clearing it synchronously in createAndShowHomeGroup re-showed
' the outgoing view for a frame before Home mounted (the #677 login flash).
navigateChainStep(routes, 0, true)
end sub
' Navigate routes[index], then chain to the next step once it settles (sgRouter's
' navigateTo promise resolves at NavigationEnd, so each .then() defers until the previous
' route is fully mounted). The final step takes remote focus. Context carries routes/index
' (BrightScript closures can't capture locals).
' clearLoginSpinnerOnEnd: threaded through to the FINAL step's navigateThenFocus so the login
' replay (replayRoutedDeepLink) keeps the blocking spinner up across the whole chain and clears it
' only once the last route mounts. The runtime-cast caller (replayDeepLinkReplacingPlayer) leaves
' it false: no login spinner is up there.
sub navigateChainStep(routes as object, index as integer, clearLoginSpinnerOnEnd = false as boolean)
if index >= routes.count() then return
' Final step: navigate + take focus + catch is exactly the shared settle tail.
if index = routes.count() - 1
navigateThenFocus(routes[index], {}, clearLoginSpinnerOnEnd)
return
end if
' Intermediate step: navigate, then chain to the next once it settles. A rejection stops the
' chain (a later step would build on a route that never mounted) and takes focus on whatever IS
' mounted, so a failed deep-link replay can't strand the remote.
promises.chain(sgrouter.navigateTo(routes[index]), { routes: routes, index: index, clearSpinner: clearLoginSpinnerOnEnd }).then(sub(_result as object, ctx as object)
navigateChainStep(ctx.routes, ctx.index + 1, ctx.clearSpinner)
end sub).catch(sub(err as object, ctx as object)
' If this was the login replay, don't leave the blocking spinner stranded on a failed chain.
' Recover before logging, and log err whole (`.message` crashes on a non-AA rejection).
if ctx.clearSpinner then stopLoadingSpinner()
sgrouter.setFocus({ focus: true })
m.log.warn("deep-link route chain step failed; stopping chain", ctx.routes[ctx.index], err)
end sub)
end sub
' A deep link arrived while a media player is active. Two steps:
' 1) tear the player down synchronously (stop + report to the server) so nothing keeps decoding;
' 2) navigate Home, then to the new content, via the reliable navigateTo PROMISE chain
' (navigateChainStep). Navigating "/" tears the player host down (Home is clearStackOnResolve,
' so the stack, player host included, is cleared and Home re-mounts) and resolves at its
' NavigationEnd; the chain then mounts the target, which auto-launches. Back lands
' Player, then Details, then Home, the intended deep-link shape, with no stale prior item left behind.
'
' We don't pop via goBack + a routerState-observer settle wait here (the old ADR-0020 primitive):
' goBack returns a bare Boolean (no promise to chain), and the routerState observer coalesces and
' reliably drops the terminal NavigationEnd, so the settle drain never fired and the cast stranded
' (proven on device). navigateChainStep rides navigateTo's promise, which resolves reliably via
' the router's internal chain, not the coalescing field observer, exactly as post-login deep-link
' replay already does.
sub replayDeepLinkReplacingPlayer(targetRoute as string)
initRouter()
view = m.global.activeRoutedView
if isValid(view) then view.callFunc("teardownForDeepLink")
navigateChainStep(["/", targetRoute], 0)
end sub
' VALIDATE a deep-link id (a metadata fetch) BEFORE navigating, so an invalid id never disturbs
' the active session: it only toasts. `args` = { itemId, action, isPlayback,
' homeFirst, replacePlayer, detailsRoute } from source/replayRoute.bs.
' homeFirst (post-login/cold): land on Home first so an invalid id toasts on Home (good UX).
' On a valid id, onDeepLinkResolved navigates (replacing an active player for a playback cast).
sub resolveDeepLink(args as object)
if not isValid(args) or not isValidAndNotEmpty(args.itemId) then return
initRouter()
' Every call and every cancel bumps the sequence, so a deferred start (below) that arrives after
' either one sees a stale number and does nothing.
m.deepLinkResolveSeq = (m.deepLinkResolveSeq ?? 0) + 1
' `= true`, not a bare `if`: a missing or invalid flag is a Type Mismatch in a bare `if`, and here
' takes the synchronous path instead.
if args.homeFirst = true
' Wait for Home's nav to settle before starting the resolve. The resolve raises the content
' spinner and re-enables the remote, which re-shows the outgoing sign-in view for a frame if
' Home has not mounted yet (the #677 login flash; see createAndShowHomeGroup). Until then the
' blocking sign-in spinner stays up. A rejected nav still starts the resolve, so the link is
' never dropped.
promises.chain(sgrouter.navigateTo("/"), { args: args, seq: m.deepLinkResolveSeq }).then(sub(_result as object, ctx as object)
startDeepLinkResolve(ctx.args, ctx.seq)
end sub).catch(sub(err as object, ctx as object)
' Start before logging, and log err whole: reading `.message` crashes when a rejection is not
' an AA, which would leave the blocking sign-in spinner up for good.
startDeepLinkResolve(ctx.args, ctx.seq)
m.log.warn("deep-link Home nav failed; resolving anyway", err)
end sub)
return
end if
startDeepLinkResolve(args, m.deepLinkResolveSeq)
end sub
' Raise the spinner and launch the metadata fetch for a deep link. `seq` is the sequence number
' resolveDeepLink took when it was called; a different current value means a cancel or a newer
' call came first, so this start is stale and is dropped.
sub startDeepLinkResolve(args as object, seq as integer)
if seq <> m.deepLinkResolveSeq then return
m.deepLinkResolveArgs = args
' Spinner = feedback during the fetch; don't block the remote (a runtime cast shouldn't freeze
' what the user is doing). Cleared in onDeepLinkResolved.
m.top.isRemoteDisabled = false
m.top.loadingKind = LoadingKind.CONTENT
m.top.isLoading = true
stopDeepLinkResolveTask()
m.deepLinkResolveTask = CreateObject("roSGNode", "LoadItemsTask")
m.deepLinkResolveTask.itemsToLoad = "metaDataDetails"
m.deepLinkResolveTask.itemId = args.itemId
m.deepLinkResolveTask.observeField("content", "onDeepLinkResolved")
launchTask(m.deepLinkResolveTask)
end sub
' Abort an in-flight deep-link/cast resolve (the metadata fetch that runs BEFORE navigation):
' unobserve + stop + drop the task, clear its args, hide the spinner. Safe to call when none is
' pending. Reused by the back arbiter (a back during the resolve aborts the cast) and resetRouter
' (sign-out tears the resolve down with the router).
sub cancelDeepLinkResolve()
m.deepLinkResolveSeq = (m.deepLinkResolveSeq ?? 0) + 1
stopDeepLinkResolveTask()
m.deepLinkResolveArgs = invalid
m.top.isLoading = false
end sub
' Release the in-flight deep-link/cast resolve task: drop its observer, stop it, clear the
' ref. Safe to call when none is pending. Centralizes the unobserve->stop->invalid sequence
' so every teardown path (resolveDeepLink replace, cancelDeepLinkResolve, onDeepLinkResolved)
' stays consistent: a stop without a preceding unobserve can leave a stale observer firing.
sub stopDeepLinkResolveTask()
if isValid(m.deepLinkResolveTask)
m.deepLinkResolveTask.unobserveField("content")
m.deepLinkResolveTask.control = "stop"
m.deepLinkResolveTask = invalid
end if
end sub
sub onDeepLinkResolved()
if not isValid(m.deepLinkResolveTask) then return
content = m.deepLinkResolveTask.content
stopDeepLinkResolveTask()
m.top.isLoading = false
args = m.deepLinkResolveArgs
m.deepLinkResolveArgs = invalid
if not isValid(args) then return
if not (isValidAndNotEmpty(content) and isValid(content[0]))
' Invalid id or fetch failure -> toast only, NO navigation. The session is undisturbed
' (post-login we're already on Home; runtime we stay on whatever was playing/showing).
showToast(translate(translationKeys.MessageContentUnavailable), "error")
return
end if
' A grid-container target (library / folder / genre / studio / channel) has no springboard:
' open its grid directly instead of mounting ItemDetails on a
' bare folder. routeForItem maps the resolved node to the same library route in-app navigation
' uses, and we hand it the node we fetched as route context so BaseGridView renders
' without a second fetch (and Back lands on Home, not a stub springboard). Only the non-playback
' "open" path diverts: a playback action never targets a container, and replacePlayer is
' playback-only, so both fall through to the details route below.
item = content[0]
itemRoute = routeForItem(item)
if not args.isPlayback and isValid(itemRoute) and itemRoute.name = "library"
navigateThenFocus(itemRoute, { item: item })
return
end if
' Valid. Navigate to the details route (ItemDetails loads + dispatches the action on mount).
if args.replacePlayer
' Playback-only path (a cast replacing an active player): the springboard is transient on the
' way to the player, so it tears the active player down then navigates the route chain via
' navigateChainStep, off navigateTo's promise, not the removed settle primitive (see ADR
' 0020). No context: ItemDetails re-fetches by id from the route.
replayDeepLinkReplacingPlayer(args.detailsRoute)
else
' We already fetched the FULL metaDataDetails node to validate the id: hand that exact node
' forward (itemIsComplete) so ItemDetails renders the springboard from
' it instead of re-fetching the same metadata. ItemDetails' loadDetailsTask uses the SAME
' "metaDataDetails" shape, so the node is a complete substitute. Only this explicitly-complete
' context skips the fetch; in-app nav (which may pass a lighter list node) still fetches.
navigateThenFocus(args.detailsRoute, { item: item, itemIsComplete: true })
end if
end sub
' QueueManager.playQueue signalled a video playback launch. The queue is already populated,
' so navigate to the play route. PlayerHostView mounts
' the player for the current queue item. :type/:id give the route a deep-link identity;
' the queue is the source of truth for what actually plays.
sub onPlaybackLaunchRequested()
req = m.global.playbackLaunchRequest
if not isValid(req) or not isValidAndNotEmpty(req.id) then return
' Audio launches the routed AudioPlayerView (the queue is the source of truth, so the
' route takes no params). Every video-family type launches the play host.
if isValid(req.media) and req.media = "audio"
playPath = "/audio"
else
itemType = req.type
if not isValidAndNotEmpty(itemType) then itemType = "video"
playPath = "/details/" + itemType + "/" + req.id + "/play"
end if
' No explicit setFocus: the mounted player self-focuses via its onScreenShown (the JRScreen
' onViewOpen bridge). The .catch is the point: a rejected nav (router busy / unknown route)
' would otherwise leave the populated queue with no player and nothing playing, silently.
promises.chain(sgrouter.navigateTo(playPath), { playPath: playPath }).catch(sub(err as object, ctx as object)
' No player will mount to take the start, so end it here rather than at its time limit.
' Recover before logging, and log err whole (`.message` crashes on a non-AA rejection).
m.global.queueManager.callFunc("failPendingPlaybackStart", "navigation to the player failed")
m.log.warn("playback launch navigation failed", ctx.playPath, err)
end sub)
end sub
' A photo launcher (quickplay.photo / QueueManager slideshow output) signalled a photo-viewer
' launch. Carry the launch AA through as route context: PhotoDetails
' reads itemsNode/itemsArray + slideshow flags + start index from it on mount.
sub onPhotoLaunchRequested()
req = m.global.photoLaunchRequest
if not isValid(req) then return
' PhotoDetails self-focuses on mount (onScreenShown); the .catch surfaces a rejected nav so a
' failed photo launch isn't silent.
promises.chain(sgrouter.navigateTo("/photo", { context: req })).catch(sub(err as object)
' Log err whole: reading `.message` crashes when a rejection is not an AA.
m.log.warn("photo launch navigation failed", err)
end sub)
end sub
' Render-thread sgRouter.goBack() wrapper for main-thread code, which can't call the
' sgrouter namespace directly (it resolves the router via m.top's scene). Its caller is
' the remote-control Back command (remoteDispatch.dispatchGoBack).
sub routerGoBack()
sgrouter.goBack()
end sub
' Theme/locale change: force a fresh Home render through the router. Called from
' main.bs's reloadHomeRequested handler (replaces the old clearScenes +
' createAndShowHomeGroup). Navigating to "/" from Settings (the active routed view)
' is a component change, so clearStackOnResolve rebuilds Home from scratch: it
' picks up the new theme constants / reloaded translations.
sub reloadRoutedHome()
sgrouter.navigateTo("/")
end sub
' Sign-out / change-user / change-server: tear down the whole routed stack before main.bs
' re-enters the routed pre-login flow (loginRouter). Without this the routed views would
' linger and leak. initRouter() re-initializes a fresh router on the next successful login.
sub resetRouter()
if isValid(m.previousRoutedView)
unregisterOverhangData(m.previousRoutedView)
m.previousRoutedView = invalid
end if
bindLoadingWaits(invalid)
m.global.unobserveField("activeRoutedView")
m.global.unobserveField("playbackLaunchRequest")
m.global.unobserveField("photoLaunchRequest")
' Cancel an in-flight deep-link resolve (router is being destroyed under it).
cancelDeepLinkResolve()
' sgrouter.destroy() removes the active + suspended keepAlive view NODES but never runs their
' beforeViewClose lifecycle (verified: Router _destroy only removeNodeChildren). Drive teardown
' ourselves FIRST so each view's onScreenHidden + onDestroy run: onDestroy abandons in-flight
' API promises and releases the view's observers/Tasks. Without this, a library/detail suspended
' beneath a player at sign-out leaks its Task and a late pool response could fire into a
' half-torn-down node.
teardownRoutedViews()
sgrouter.destroy()
m.global.activeRoutedView = invalid
if isValid(m.overhang) then m.overhang.visible = false
end sub
' Run beforeViewClose (which runs onScreenHidden + onDestroy) on every mounted routed view before the
' router is destroyed under them. All of them are children of the outlet's viewTarget: the
' active view and every covered one, since every route suspends with "show". A "detach" view
' would sit in the router's detach store instead, out of this loop's reach. viewTarget lives
' inside the outlet and survives destroy(); only its children (the views) are torn down here.
' beforeViewClose is the same permanent-close entry sgRouter drives on a normal close, so
' re-running onScreenHidden on a covered view (hideCovered already ran it once) matches the
' existing contract: it must be idempotent.
sub teardownRoutedViews()
outlet = m.top.findNode("routerOutlet")
if not isValid(outlet) then return
target = outlet.findNode("viewTarget")
if not isValid(target) then return
for each view in target.getChildren(-1, 0)
if isValid(view) and view.isSubType("JRScreen")
view.callFunc("beforeViewClose", {})
end if
end for
end sub
' The router mounted (or switched to) a different content view. Re-point the
' overhang controller: tear down the binding to the previous view, bind the new
' one. (Lifted from SceneManager's register/unregister pair, same contract.)
sub onActiveRoutedViewChanged()
newView = m.global.activeRoutedView
' This observer fires many times per navigation while the field still holds the same node,
' so act only when the node changes.
if isValid(newView) and isValid(m.previousRoutedView) and m.previousRoutedView.isSameNode(newView) then return
' The screen swap. Routes suspend with "show", so the router leaves the outgoing view on
' screen; hiding it here puts it in the same render pass as the incoming view appearing and
' the overhang below switching.
if isValid(m.previousRoutedView)
if m.previousRoutedView.isSubType("JRScreen")
m.previousRoutedView.callFunc("hideCovered")
else
m.previousRoutedView.visible = false
end if
unregisterOverhangData(m.previousRoutedView)
end if
if isValid(newView) and newView.isSubType("JRGroup")
registerOverhangData(newView)
end if
bindLoadingWaits(newView)
m.appWaitHost.callFunc("failLeftBehind", newView)
m.previousRoutedView = newView
end sub
' Point the spinner at `view`'s loadingWaits (screenWaits): stop following the previous view's,
' follow this one's, and show what it has open. A view's waits therefore show only while it
' is the active view: gone when it closes, hidden under another view, back when it returns.
' `view` = invalid follows none (resetRouter).
sub bindLoadingWaits(view as dynamic)
if isValid(m.waitsView) and not m.waitsView.isSameNode(view)
m.waitsView.unobserveField("loadingWaits")
m.waitsView = invalid
end if
if isValid(view) and not isValid(m.waitsView) and view.isSubType("JRGroup")
view.observeField("loadingWaits", "showLoadingStage")
m.waitsView = view
end if
showLoadingStage()
end sub
' Confirm app exit.
'
' Handled HERE rather than on the main thread, even though exiting is main.bs's
' job. JRScene is a component with its own script scope, so it can use the
' standard scoped-observer path (`onResult`) like every other component dialog;
' the confirm then travels to main.bs the way it already does, through the `exit`
' field it observes on this scene. Routing the dialog itself through main.bs's
' message port would cross the thread boundary for every field write and gain
' nothing.
sub showExitConfirmation()
' A second press while the prompt is up would stack a second overlay. It should
' not be reachable (the dialog consumes every key once it has focus), but the
' guard is a line and the failure mode is two dialogs sharing one node id.
if isValid(m.exitDialog) then return
m.exitDialog = showConfirmDialog(translate(translationKeys.LabelExitJellyrock), translate(translationKeys.MessageAreYouSureYouWantTo), "onExitConfirmResult", translate(translationKeys.ButtonExit), translate(translationKeys.ButtonCancel))
end sub
' Exit prompt answered. `exit` is observed by main.bs on its message port; setting
' it returns from Main() and Roku OS tears the channel down.
sub onExitConfirmResult()
result = m.exitDialog.result
m.exitDialog = invalid
if result.confirmed then m.top.exit = true
end sub
' ---------------------------------------------------------------------------
' Overhang controller. Drives the shared overhang from the router-active view's overhang
' fields, preserving the
' tabs-before-title ordering (prevents a title-to-tab render flash) and the
' bidirectional selectedTabId write-back (an overhang tab tap sets view.selectedTabId).
' ---------------------------------------------------------------------------
sub registerOverhangData(group as object)
if group.isSubType("JRGroup")
if group.isOverhangVisible
m.overhang.visible = true
else
m.overhang.visible = false
end if
group.observeField("isOverhangVisible", "updateOverhangVisible")
' Set tabs BEFORE the title so onTabsChanged can hide the title before it
' renders with text. This prevents a visible title-to-tab transition flash.
m.overhang.selectedTabId = group.selectedTabId
m.overhang.tabs = group.overhangTabs
group.observeField("overhangTabs", "updateOverhangTabs")
m.overhang.observeField("selectedTabId", "onOverhangTabSelected")
if isValid(group.overhangTitle) then m.overhang.title = group.overhangTitle
group.observeField("overhangTitle", "updateOverhangTitle")
' Logo / icons / user-dropdown: projected from the view's declared fields so they update in
' the SAME frame as tabs/title (no transition flicker). Screens declare these; they never poke
' the overhang node directly. currentUser is derived from the global user so screens only say
' whether the dropdown shows.
m.overhang.isLogoVisible = group.isLogoVisible
group.observeField("isLogoVisible", "updateOverhangLogo")
m.overhang.shouldShowIcons = group.shouldShowIcons
group.observeField("shouldShowIcons", "updateOverhangIcons")
applyOverhangUserDropdown(group.shouldShowUserDropdown)
group.observeField("shouldShowUserDropdown", "updateOverhangUserDropdown")
end if
end sub
sub unregisterOverhangData(group as object)
' Mirror registerOverhangData's guard: these fields are only observed for JRGroup views,
' so only unobserve for those (keeps register/unregister symmetric).
if isValid(group) and group.isSubType("JRGroup")
group.unobserveField("overhangTitle")
group.unobserveField("overhangTabs")
group.unobserveField("isOverhangVisible")
group.unobserveField("isLogoVisible")
group.unobserveField("shouldShowIcons")
group.unobserveField("shouldShowUserDropdown")
m.overhang.unobserveField("selectedTabId")
end if
end sub
sub updateOverhangTitle(msg)
m.overhang.title = msg.getData()
end sub
sub updateOverhangLogo(msg)
m.overhang.isLogoVisible = msg.getData()
end sub
sub updateOverhangIcons(msg)
m.overhang.shouldShowIcons = msg.getData()
end sub
sub updateOverhangUserDropdown(msg)
applyOverhangUserDropdown(msg.getData())
end sub
' Show the logged-in user in the overhang dropdown, or clear it to hide the dropdown. The name
' comes from the global user (the same value every post-login screen used to set imperatively),
' so a view only declares WHETHER the dropdown shows via shouldShowUserDropdown.
sub applyOverhangUserDropdown(showDropdown as boolean)
if showDropdown and isValid(m.global.user)
m.overhang.currentUser = m.global.user.name
else
m.overhang.currentUser = ""
end if
end sub
sub updateOverhangVisible(msg)
m.overhang.visible = msg.getData()
end sub
sub updateOverhangTabs(msg)
m.overhang.tabs = msg.getData()
end sub
' Proxy overhang tab selection back to the active routed view's selectedTabId.
sub onOverhangTabSelected()
group = m.global.activeRoutedView
if isValid(group) and group.isSubType("JRGroup")
group.selectedTabId = m.overhang.selectedTabId
end if
end sub