source_main.bs

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