sub Main (args as dynamic) as void
printRegistry()
' The main function that runs when the application is launched.
m.screen = CreateObject("roSGScreen")
m.port = CreateObject("roMessagePort")
m.screen.setMessagePort(m.port)
m.global = m.screen.getGlobalNode()
setGlobals()
' Load translations using device locale (pre-login, no user context yet)
loadTranslations(resolveTranslationLocale())
user.settings.SaveDefaults()
' Enable auto-sync AFTER loading defaults
m.global.user.settings.callFunc("enableAutoSync")
' migrate registry if needed
m.wasMigrated = false
runGlobalMigrations()
runRegistryUserMigrations()
' update LastRunVersion now that migrations are finished
if m.global.app.version <> m.global.app.lastRunVersion
setSetting("LastRunVersion", m.global.app.version)
end if
if m.wasMigrated then printRegistry()
m.scene = m.screen.CreateScene("JRScene")
m.screen.show() ' vscode_rale_tracker_entry
'vscode_rdb_on_device_component_entry
' Screenshot-automation hook: start roku-test-automation's on-device component
' so the capture driver (scripts/capture-screenshots.js) can seed the registry
' (server / active_user / authToken / globalTranslationLocale / translationLocale)
' and query the node tree. ENABLE_RTA is false in the committed manifest and is
' flipped to true ONLY by the RTA screenshot deploy, so prod / store builds
' compile this out at device load (it never ships enabled).
#if ENABLE_RTA
m.rtaOnDeviceComponent = createObject("roSGNode", "RTA_OnDeviceComponent")
#end if
' setup global nodes now that the screen has been shown
setGlobalNodes()
' Session-reset bridge. Routed Home sets m.scene.userMenuAction (change user / change server /
' sign out); we observe it here on the main thread and re-enter the login flow in place via
' handleMenuAction → reenterLogin. A scene field + port delivers (unlike an m.global field +
' port, which does not — the delivery defect). Registered once here, before any navigation.
m.scene.observeField("userMenuAction", m.port)
m.scene.observeField("exit", m.port)
' Pre-login views (SetServerScreen / UserSelect / LoginScene) set this to an intent action
' string; we dispatch it to the loginRouter coordinator, which runs the blocking bootstrap
' API calls and drives the next navigation. Registered once here.
m.scene.observeField("preLoginIntent", m.port)
' Observe the ws:// receiver's marshalled commands; dispatched on the MAIN thread via remoteDispatch
' (shared with the voice path). The field lives on the task node, not m.global — a task-node field +
' port delivers, an m.global field + port does not (the delivery defect noted above). See
' remote-control.md.
m.global.remoteControlTask.observeField("dispatchCommand", m.port)
' Cold-start deep link. Stash it (structured intent on
' m.global.AuthManager.stashedDeepLink) BEFORE entering the login flow so it replays
' uniformly — the fast path lands straight on the deep-linked content, while a deferred
' (no-token) launch replays once the user finishes signing in. replayAfterLogin (invoked
' from createAndShowHomeGroup) reads the stash; an absent deep link leaves it empty and
' replay is just Home. The deep link routes through ItemDetails, which validates the id and
' auto-launches with the real type + resume position (or errors to Home if it doesn't exist).
m.launchArgs = args
if isValid(m.launchArgs) and isValidAndNotEmpty(m.launchArgs.contentId)
' mediaType is optional (a cast from another Jellyfin client may send only an id +
' server) — ItemDetails infers play-vs-springboard from the resolved item, not the hint.
mediaTypeHint = ""
if isValidAndNotEmpty(m.launchArgs.mediaType) then mediaTypeHint = m.launchArgs.mediaType
itemNameHint = ""
if isValidAndNotEmpty(m.launchArgs.itemName) then itemNameHint = m.launchArgs.itemName
stashDeepLink(m.launchArgs.contentId, mediaTypeHint, itemNameHint)
' If the link names a saved server, point login at it before the fast path runs.
steerColdStartDeepLinkServer()
end if
' Handle input messages. Set up ONCE — these survive session resets (Change Server /
' User / Sign Out re-enter the login flow via handleMenuAction, NOT by restarting the
' app loop), so roInput/roDeviceInfo are no longer recreated per login.
input = CreateObject("roInput")
input.SetMessagePort(m.port)
' Receive Roku voice transport commands (play/pause/seek/next/startover/skip/etc.)
' as roInputEvent with info.type = "transport". Manifest gates: supports_voice_roinput,
' supports_etc_seek, supports_etc_next. Dispatch lives in the roInputEvent branch below.
input.EnableTransportEvents()
device = CreateObject("roDeviceInfo")
device.setMessagePort(m.port)
device.EnableScreensaverExitedEvent(true)
device.EnableAppFocusEvent(true)
device.EnableLowGeneralMemoryEvent(true)
device.EnableLinkStatusEvent(true)
device.EnableCodecCapChangedEvent(true)
device.EnableAudioGuideChangedEvent(true)
' Enter the login flow. reenterLogin() re-resolves the pre-login locale, then beginLogin()
' runs the saved-server + saved-token fast path (sync, no UI). On success it brings up Home;
' otherwise it routes to the right pre-login screen (/server, /users, /login), whose views
' drive the rest through preLoginIntent events handled in the loop below.
reenterLogin()
' If a cold-start deep link couldn't run yet because login is required,
' tell the user it'll open after they sign in (replay fires from createAndShowHomeGroup).
if not isValidAndNotEmpty(m.global.user.authToken)
notifyDeepLinkDeferred()
end if
' This is the core logic loop. Mostly for transitioning between scenes
' This now only references m. fields so could be placed anywhere, in theory
' "group" is always "whats on the screen"
' m.scene's children is the "previous view" stack
group = invalid
while true
msg = wait(0, m.port)
if type(msg) = "roSGScreenEvent" and msg.isScreenClosed()
print "CLOSING SCREEN"
return
else if isNodeEvent(msg, "exit")
return
else if isNodeEvent(msg, "preLoginIntent")
' A routed pre-login view emitted an intent. The coordinator runs the blocking
' bootstrap API call(s) and drives the next navigation.
handlePreLoginIntent(msg.getData())
else if isNodeEvent(msg, "isAuthenticated")
' The QuickConnectDialog (started by the coordinator) signalled a successful in-flow
' sign-in. Complete the login.
handlePreLoginIntent("quickConnectComplete")
else if isNodeEvent(msg, "close")
' The QuickConnectDialog closed. On SUCCESS this fires right after isAuthenticated (the
' branch above already drove quickConnectComplete -> onQuickConnectDialogClosed, so this is
' a guarded no-op); on CANCEL it is the only signal, and releases the observer + node ref
' that would otherwise leak. Only the QuickConnectDialog is observed for "close" on m.port.
onQuickConnectDialogClosed()
else if isNodeEvent(msg, "isFontDownloadCompleted")
' Handle font download completion
fontDownloadTask = msg.getRoSGNode()
if isValid(fontDownloadTask)
handleFontDownloadCompletion(fontDownloadTask)
end if
else if isNodeEvent(msg, "reachable")
' Off-thread deep-link server-switch pre-flight (ServerReachableTask) returned. Tear down
' the probe and hand the verdict to performServerSwitch's continuation.
task = msg.getRoSGNode()
task.unobserveField("reachable")
task.control = "STOP"
m.serverReachableTask = invalid
onServerProbeDone(task.reachable)
else if isNodeEvent(msg, "userMenuAction")
actionId = msg.getData()
handleMenuAction(actionId)
else if type(msg) = "roDeviceInfoEvent"
event = msg.GetInfo()
if event.exitedScreensaver = true
m.global.sceneManager.callFunc("resetTime")
group = getActiveView()
if isValid(group)
' refresh the current view
if group.isSubType("JRScreen")
group.callFunc("onScreenShown")
end if
end if
else if isValid(event.audioGuideEnabled)
tmpGlobalDevice = m.global.device
tmpGlobalDevice.AddReplace("isaudioguideenabled", event.audioGuideEnabled)
' update global device array
m.global.setFields({ device: tmpGlobalDevice })
else if isValid(event.Mode)
' Indicates the current global setting for the Caption Mode property, which may be one of the following values:
' "On"
' "Off"
' "Instant replay"
' "When mute" (Only returned for a TV; this option is not available on STBs).
print "event.Mode = ", event.Mode
if isValid(event.Mute)
print "event.Mute = ", event.Mute
end if
else if isValid(event.linkStatus)
' True if the device currently seems to have an active network connection.
print "event.linkStatus = ", event.linkStatus
else if isValid(event.generalMemoryLevel)
' This event will be sent first when the OS transitions from "normal" to "low" state and will continue to be sent while in "low" or "critical" states.
' - "normal" means that the general memory is within acceptable levels
' - "low" means that the general memory is below acceptable levels but not critical
' - "critical" means that general memory are at dangerously low level and that the OS may force terminate the application
print "event.generalMemoryLevel = ", event.generalMemoryLevel
m.global.device.memoryLevel = event.generalMemoryLevel
else if isValid(event.audioCodecCapabilityChanged)
' The audio codec capability has changed if true.
print "event.audioCodecCapabilityChanged = ", event.audioCodecCapabilityChanged
SubmitSideEffect(GetApi().BuildPostSessionCapabilitiesRequest(getDeviceCapabilities()))
else if isValid(event.videoCodecCapabilityChanged)
' The video codec capability has changed if true.
print "event.videoCodecCapabilityChanged = ", event.videoCodecCapabilityChanged
SubmitSideEffect(GetApi().BuildPostSessionCapabilitiesRequest(getDeviceCapabilities()))
else if isValid(event.appFocus)
' It is set to False when the System Overlay (such as the confirm partner button HUD or the caption control overlay) takes focus and True when the channel regains focus
print "event.appFocus = ", event.appFocus
else
print "Unhandled roDeviceInfoEvent:"
print msg.GetInfo()
end if
else if type(msg) = "roInputEvent"
if msg.IsInput()
info = msg.GetInfo()
if info.DoesExist("contentid")
' Runtime deep link — another Jellyfin client casting to this device (or any sender)
' handed us content. mediaType is optional (ItemDetails infers the
' launch from the resolved item). Stash the intent, then resolve its server: replay
' now if it's the active server (or none), prompt to switch if it's another saved
' server, or toast if unknown. When signed out, the stash rides until login.
mediaTypeHint = ""
if info.DoesExist("mediatype") then mediaTypeHint = info.mediaType
itemNameHint = ""
if info.DoesExist("itemname") then itemNameHint = info.itemName
stashDeepLink(info.contentId, mediaTypeHint, itemNameHint)
onRuntimeDeepLink()
else if isValid(info.type) and info.type = "transport"
' Voice transport command (play, pause, seek, next, startover, skip, ...).
' remoteDispatch.dispatchTransport is the SHARED adapter (voice + ws:// receiver):
' active-view gate + handleTransport -> { status, nowPlaying }. The voice-specific
' ack + roAppManager stay here on the MAIN thread (roInput/roAppManager can't be
' created on the render thread where the player's callFunc executes); a non-player
' active view yields "error.no-media" so Roku OS shows "Nothing is playing".
transportRet = remoteDispatch.dispatchTransport(info)
transportStatus = transportRet.status
transportNowPlaying = transportRet.nowPlaying
if isValid(transportNowPlaying)
appMgr = CreateObject("roAppManager")
appMgr.SetNowPlayingContentMetaData(transportNowPlaying)
else if LCase(info.command) = "nowplaying"
' Per Roku doc: when we can't report current content, pass invalid to clear
' stale metadata. Covers both "no active player" and "player mounted but
' item not yet loaded" — neither is "currently playing" from the user's POV.
appMgr = CreateObject("roAppManager")
appMgr.SetNowPlayingContentMetaData(invalid)
end if
input.EventResponse({ id: info.id, status: transportStatus })
end if
end if
else if isNodeEvent(msg, "isDataReturned")
popupNode = msg.getRoSGNode()
sm = m.global.sceneManager
' main.bs owns exactly three confirmation dialogs, each claimed by a pending flag set when
' it is shown: the deep-link server-switch prompt, the exit prompt, and the resume/start-over
' prompt. The isDataReturned field is shared — routed views (ItemDetails delete / mark-watched)
' observe the same field scoped. Without the flag gate this handler would also fire for THEIR
' dialogs, cancelling the spinner they just started and replaying a stale queue hold. So we act
' only when one of our flags is set, and leave routed-view-owned confirmations untouched.
if isValid(popupNode) and isValid(popupNode.returnData) and (sm.isPendingServerSwitch or sm.isPendingExitConfirmation or sm.isPendingPlaybackOptions)
print "popupNode.returnData = ", popupNode.returnData
stopLoadingSpinner()
' Deep-link server-switch confirmation. On "Switch", run the pre-flighted switch
' (performServerSwitch); on cancel, discard the stashed deep link.
if sm.isPendingServerSwitch = true
sm.isPendingServerSwitch = false
if popupNode.returnData.buttonSelected = translate(translationKeys.ButtonSwitch)
performServerSwitch(m.pendingSwitchServer)
else
m.pendingSwitchServer = invalid
m.global.AuthManager.stashedDeepLink = invalid
displayToast(translate(translationKeys.MessageCastCanceled), "info")
end if
' Exit confirmation dialog
else if sm.isPendingExitConfirmation = true
sm.isPendingExitConfirmation = false
if popupNode.returnData.indexSelected = 1 and popupNode.returnData.buttonSelected = translate(translationKeys.ButtonExit)
' User confirmed exit
m.scene.exit = true
end if
' Resume / start-over playback prompt (shown by playbackOptionDialog)
else if sm.isPendingPlaybackOptions = true
sm.isPendingPlaybackOptions = false
selectedItem = m.global.queueManager.callFunc("getHold")
m.global.queueManager.callFunc("clearHold")
if isValidAndNotEmpty(selectedItem) and isValid(selectedItem[0])
if popupNode.returnData.indexselected = 0
'Resume video from resume point
startLoadingSpinner()
startingPoint = 0
if isValid(selectedItem[0].playbackPositionTicks) and selectedItem[0].playbackPositionTicks > 0
startingPoint = selectedItem[0].playbackPositionTicks
end if
selectedItem[0].startingPoint = startingPoint
m.global.queueManager.callFunc("clear")
m.global.queueManager.callFunc("push", selectedItem[0])
m.global.queueManager.callFunc("playQueue")
else if popupNode.returnData.indexselected = 1
'Start Over from beginning selected, set position to 0
startLoadingSpinner()
selectedItem[0].startingPoint = 0
m.global.queueManager.callFunc("clear")
m.global.queueManager.callFunc("push", selectedItem[0])
m.global.queueManager.callFunc("playQueue")
end if
end if
end if
end if
else if isNodeEvent(msg, "dispatchCommand")
' A ws:// remote-control command marshalled from RemoteControlTask. Dispatch on the MAIN thread —
' remoteDispatch reuses the deep-link (play/navigate) and shared transport seams.
remoteDispatch.dispatch(msg.getData())
else if isNodeEvent(msg, "reloadHomeRequested")
' Theme/locale changed — re-navigate the router to "/" for a fresh Home (picks up new
' theme constants / reloaded translations).
m.scene.callFunc("reloadRoutedHome")
else
print "Unhandled " type(msg)
print msg
end if
end while
end sub
' Initialize fallback font download process
sub initializeFallbackFont()
' Check if user needs fallback fonts
needsFallbackFonts = m.global.user.settings.playbackSubsCustom = true or m.global.user.settings.uiFontFallback = true
if not needsFallbackFonts
print "User doesn't need fallback fonts, skipping font download"
return
end if
print "User has custom subtitles or fallback UI font enabled. Starting font download task..."
' Create and start font download task
fontDownloadTask = CreateObject("roSGNode", "FontDownloadTask")
fontDownloadTask.observeField("isFontDownloadCompleted", m.port)
fontDownloadTask.control = "RUN"
' Store whether we need to wait for completion (UI fonts enabled)
m.waitForFontDownload = (m.global.user.settings.uiFontFallback = true)
if m.waitForFontDownload
startLoadingSpinner(true, translate(translationKeys.LabelDownloadingFallbackFont))
print "UI fallback fonts enabled - app will wait for font download completion"
else
print "Only subtitle fallback fonts enabled - app will continue loading normally"
end if
end sub
' Load the home screen - may wait for font processing if UI fallback fonts are enabled
sub loadHomeScreen()
' If we don't need to wait for font download, load immediately
if not isValid(m.waitForFontDownload) or not m.waitForFontDownload
createAndShowHomeGroup()
return
end if
' Otherwise, we'll wait for the font download to complete
' The actual loading will happen in the message loop when we receive isFontDownloadCompleted
print "Waiting for font download completion before loading home screen"
end sub
' Create and show the home group
sub createAndShowHomeGroup()
' Home is mounted by the router (in JRScene's outlet). replayAfterLogin
' (source/replayRoute.bs) brings the router up and replays any stashed deep link as an
' ordered route chain — Home alone when there is none, or Home -> Details -> Player for a
' deferred/cold-start play deep link (decision #3). Home self-loads its libraries in its own
' onViewOpen. (exit + activeRoutedView + the pre-login intent bridge are observed once during
' bootstrap, not here.)
' The blocking login spinner (started on the outgoing pre-login view) is intentionally NOT
' stopped here. replayAfterLogin() only KICKS OFF the async router nav to Home (sgRouter resolves
' at NavigationEnd), so the outgoing UserSelect/LoginScene is still the active routed view at this
' point — stopping the spinner now resets isRemoteDisabled and re-shows that view for a frame
' before Home mounts (the #677 login flash). The spinner is cleared when the Home nav settles
' (replayRoutedDeepLink -> navigateChainStep clearLoginSpinnerOnEnd). The deep-link branch owns
' its own spinner via resolveDeepLink/onDeepLinkResolved.
replayAfterLogin()
' update lastRunVersion but only on prod
if not m.global.app.isDev
' has the current user ran this version before?
usersLastRunVersion = m.global.user.lastRunVersion
if not isValid(usersLastRunVersion) or not versionChecker(usersLastRunVersion, m.global.app.version)
setUserSetting("LastRunVersion", m.global.app.version)
end if
end if
end sub
' Handle font download completion and optionally calculate scale factor
sub handleFontDownloadCompletion(fontDownloadTask as object)
if not fontDownloadTask.isFontDownloadSuccess
print "WARNING: Font download failed: " + fontDownloadTask.errorMessage
else
' If UI fallback fonts are enabled, calculate scale factor
if m.global.user.settings.uiFontFallback = true
print "Calculating font scale factor for UI fallback fonts"
calculateFontScaleFactor()
end if
end if
' Clean up the task
fontDownloadTask.unobserveField("isFontDownloadCompleted")
fontDownloadTask = invalid
' If we were waiting for font download, now load the home screen
if isValid(m.waitForFontDownload) and m.waitForFontDownload
print "Font processing complete, loading home screen"
createAndShowHomeGroup()
end if
end sub
' Calculate global font scale factor for fallback font
sub calculateFontScaleFactor()
' Verify the fallback font file exists
fs = CreateObject("roFileSystem")
if not fs.Exists("tmp:/font")
print "ERROR - calculateFontScaleFactor: Fallback font file does not exist"
return
end if
' Create and run global font scaling task
fontTask = CreateObject("roSGNode", "FontScalingTask")
fontTask.control = "RUN"
' Wait for the task to complete (with timeout) - don't use observeField
timeout = CreateObject("roTimespan")
timeout.mark()
while fontTask.scaleFactor = 0 and timeout.TotalMilliseconds() < 10000
sleep(10)
end while
if fontTask.scaleFactor > 0
' Update font scale factor in global user session
m.global.user.fontScaleFactor = fontTask.scaleFactor
print "INFO - calculateFontScaleFactor: Set global font scale factor to " + fontTask.scaleFactor.toStr()
else
print "WARNING - calculateFontScaleFactor: Failed to calculate scale factor, using default"
end if
end sub
' Shared handler for menu actions from both the OptionsSlider and the user dropdown.
' OPEN_SEARCH / OPEN_SETTINGS are not handled here — Home navigates to those routes directly
' (navigateTo runs on the render thread, which main.bs is not). Only the session-ending
' actions remain. Each tears down the routed Home (resetRouter) and re-enters the login flow
' IN PLACE (reenterLogin) — no `goto appStart`, since the pre-login flow is routed and driven
' by the same unified event loop.
sub handleMenuAction(actionId as string)
if actionId = HomeAction.CHANGE_SERVER
startLoadingSpinner()
m.scene.callFunc("resetRouter")
unsetSetting("server")
server.Delete()
SignOut(false)
reenterLogin()
else if actionId = HomeAction.CHANGE_USER
startLoadingSpinner()
m.scene.callFunc("resetRouter")
SignOut(false)
reenterLogin()
else if actionId = HomeAction.SIGN_OUT
startLoadingSpinner()
m.scene.callFunc("resetRouter")
SignOut()
reenterLogin()
end if
end sub