import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/backdrop.bs"
import "pkg:/source/utils/misc.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"
sub init()
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.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 startRouter, after login).
' init() runs at CreateScene — BEFORE setGlobalNodes adds m.global.activeRoutedView —
' so the observer is registered in startRouter(), 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
' 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 = "Just a message"
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()
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 (e.g., 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
' toggle visibility of loading spinner
m.spinner.visible = m.top.isLoading
' toggle visibility of loading text
m.loadingText.visible = m.top.isLoading
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.
' Key UP (press=false) always bubbles to JRScene because all child components
' return false for press=false. This guarantees the sequence is tracked regardless
' of which screen has focus. Compiled out in production (bs_const=debug=false).
' See docs/dev/debug-flags.md.
#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→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
showExitConfirmation()
return true
else if key = "options"
group = getActiveView()
if isValid(group) and isValid(group.isOptionsAvailable) and group.isOptionsAvailable
group.lastFocus = group.focusedChild
panel = group.findNode("options")
panel.visible = true
panel.findNode("panelList").setFocus(true)
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 → 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") })
sgrouter.addRoutes([
{ 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 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.
' keepAlive so navigating detail -> /play SUSPENDS the detail (keeps its node) rather than
' destroying it -- the router equivalent of the detail staying beneath the pushed player --
' so goBack restores the launching detail instead of bubbling a spurious Exit dialog.
{ pattern: "/details/:type/:id", name: "details", component: "ItemDetails", keepAlive: { enabled: true }, canActivate: guard }
{ pattern: "/library/:id", name: "library", component: "BaseGridView", keepAlive: { enabled: true }, canActivate: guard }
{ pattern: "/search", name: "search", component: "SearchResults", keepAlive: { enabled: true }, canActivate: guard }
{ pattern: "/settings", name: "settings", component: "Settings", canActivate: guard }
' Photo viewer / slideshow. NO keepAlive: 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 keepAlive library/detail beneath it suspends
' (focus saved) and resumes (focus restored) on back, exactly like the video player.
{ pattern: "/photo", name: "photo", component: "PhotoDetails", canActivate: guard }
' Music player. NO keepAlive: a fresh AudioPlayerView per launch (it self-loads from the
' queue), destroyed on goBack. It suspends the keepAlive 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 }
])
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 → … → 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.
m.log.warn("navigateThenFocus failed; re-asserting focus", ctx.routePath, err.message)
' 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 })
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 (e.g. 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 a (deferred) deep link after login by navigating a SEQUENCE of routes, each step
' waiting for the previous to settle. Called from the main-thread replay helper
' (source/replayRoute.bs) via callFunc; `routes` is an ordered array of path strings. Used
' both for the no-deep-link case (["/"], a plain Home nav) and the deep-link case
' (["/", "/details/:type/:id", "/details/:type/:id/play"]) so back lands Player -> Details
' -> Home (decision #3). The queue for a /play step is populated by the stash producer
' (replayRoute / the deep-link handlers) BEFORE this runs; PlayerHostView reads it on mount.
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)
m.log.warn("deep-link route chain step failed; stopping chain", ctx.routes[ctx.index], err.message)
' If this was the login replay, don't leave the blocking spinner stranded on a failed chain.
if ctx.clearSpinner then stopLoadingSpinner()
sgrouter.setFocus({ focus: true })
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 → Details → Home, the intended deep-link shape, with no stale prior item left behind.
'
' We do NOT 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 just 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()
if args.homeFirst then sgrouter.navigateTo("/")
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.isLoading = true
stopDeepLinkResolveTask()
m.deepLinkResolveTask = CreateObject("roSGNode", "LoadItemsTask")
m.deepLinkResolveTask.itemsToLoad = "metaDataDetails"
m.deepLinkResolveTask.itemId = args.itemId
m.deepLinkResolveTask.observeField("content", "onDeepLinkResolved")
m.deepLinkResolveTask.control = "RUN"
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()
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 just 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)
m.log.warn("playback launch navigation failed", ctx.playPath, err.message)
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)
m.log.warn("photo launch navigation failed", err.message)
end sub)
end sub
' Render-thread sgRouter.goBack() wrapper for main.bs, which runs on the main thread
' and can't call the sgrouter namespace directly (it resolves the router via m.top's
' scene). Used after a delete confirmation to leave the now-deleted routed detail.
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
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 (→ onScreenHidden + onDestroy) on every mounted routed view — the active
' one in viewTarget and every suspended keepAlive view in keepAliveViewTarget — before the router
' is destroyed under them. The target Groups live inside the outlet and survive destroy(); only
' their 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 an already-suspended keepAlive
' view matches the existing contract (suspend already fired it once; it must be idempotent).
sub teardownRoutedViews()
outlet = m.top.findNode("routerOutlet")
if not isValid(outlet) then return
for each targetId in ["viewTarget", "keepAliveViewTarget"]
target = outlet.findNode(targetId)
if isValid(target)
for each view in target.getChildren(-1, 0)
if isValid(view) and view.isSubType("JRScreen")
view.callFunc("beforeViewClose", {})
end if
end for
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
if isValid(m.previousRoutedView) and not m.previousRoutedView.isSameNode(newView)
unregisterOverhangData(m.previousRoutedView)
end if
if isValid(newView) and newView.isSubType("JRGroup")
registerOverhangData(newView)
end if
m.previousRoutedView = newView
end sub
' Confirm app exit. Reuses SceneManager's confirmation dialog + the existing
' isPendingExitConfirmation contract that main.bs's isDataReturned handler reads
' to set m.scene.exit on confirm — unchanged from the old popScene stack<=1 branch.
sub showExitConfirmation()
sceneManager = m.global.sceneManager
sceneManager.callFunc("showConfirmationDialog", translate(translationKeys.LabelExitJellyrock), [translate(translationKeys.MessageAreYouSureYouWantTo)], [translate(translationKeys.ButtonCancel), translate(translationKeys.ButtonExit)])
sceneManager.isPendingExitConfirmation = 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→tab render flash) and the
' bidirectional selectedTabId write-back (overhang tab tap → 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 — prevents a visible title→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