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

  ' setup global nodes now that the screen has been shown
  setGlobalNodes()

  appStart:
  m.global.sceneManager.callFunc("clearScenes")
  ' First thing to do is validate the ability to use the API
  if not LoginFlow() then return

  ' remove login scenes from the stack
  m.global.sceneManager.callFunc("clearScenes")

  ' Initialize fallback font processing
  initializeFallbackFont()

  ' load home page - this may be delayed if UI fallback fonts are enabled
  loadHomeScreen()

  ' Reset transient state used by button handlers across scene transitions
  m.photoAlbumShuffleMode = false

  ' Handle input messages
  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)

  ' Check if we were sent content to play with the startup command (Deep Link)
  if isValidAndNotEmpty(args.mediaType) and isValidAndNotEmpty(args.contentId)

    m.global.queueManager.callFunc("push", nodeHelpers.createQueueItem({ id: args.contentId, type: "video" }))
    m.global.queueManager.callFunc("playQueue")
  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, "closeSidePanel")
      group = m.global.sceneManager.callFunc("getActiveScene")
      if isValid(group.lastFocus)
        group.lastFocus.setFocus(true)
      else
        group.setFocus(true)
      end if
    else if isNodeEvent(msg, "isFontDownloadCompleted")
      ' Handle font download completion
      fontDownloadTask = msg.getRoSGNode()
      if isValid(fontDownloadTask)
        handleFontDownloadCompletion(fontDownloadTask)
      end if
    else if isNodeEvent(msg, "quickPlayNode")
      ' Read from msg.getData() — the value at event-queue time — not from
      ' the current field value which may already be cleared to invalid by
      ' the set-then-clear pattern that prevents stale re-fires.
      itemNode = msg.getData()
      if isValid(itemNode) and isValid(itemNode.id) and itemNode.id <> ""
        itemType = invalid
        if isValid(itemNode.type) and itemNode.type <> ""
          itemType = Lcase(itemNode.type)
        end if

        if isValid(itemType)
          startLoadingSpinner()
          m.global.queueManager.callFunc("clear")
          m.global.queueManager.callFunc("resetShuffle")

          ' Synchronous types (no API calls) — handle inline
          if itemType = "chapter"
            ' Chapter: start playback of parent video at chapter position
            queueItem = nodeHelpers.createQueueItem(itemNode)
            if isValid(queueItem)
              queueItem.type = itemNode.parentType
              queueItem.startingPoint = itemNode.playbackPositionTicks
              m.global.queueManager.callFunc("push", queueItem)
              m.global.queueManager.callFunc("playQueue")
            end if
          else if itemType = "episode" or itemType = "recording" or itemType = "movie" or itemType = "video"
            quickplay.video(itemNode)
            m.global.queueManager.callFunc("playQueue")
          else if itemType = "audio"
            quickplay.audio(itemNode)
            m.global.queueManager.callFunc("playQueue")
          else if itemType = "musicvideo"
            quickplay.musicVideo(itemNode)
            m.global.queueManager.callFunc("playQueue")
          else if itemType = "photo"
            quickplay.photo(itemNode)
          else if itemType = "tvchannel"
            quickplay.tvChannel(itemNode)
            m.global.queueManager.callFunc("playQueue")
          else if itemType = "program"
            quickplay.program(itemNode)
            m.global.queueManager.callFunc("playQueue")
          else
            ' All other types need API calls — delegate to QuickPlayTask
            m.activeQuickPlayTask = CreateObject("roSGNode", "QuickPlayTask")
            m.activeQuickPlayTask.input = {
              action: itemType,
              id: itemNode.id,
              seriesId: itemNode.seriesId,
              collectionType: itemNode.collectionType,
              folderType: itemNode.folderType,
              movieCount: itemNode.movieCount,
              seriesCount: itemNode.seriesCount,
              channelId: itemNode.channelId
            }
            m.activeQuickPlayTask.observeField("output", m.port)
            m.activeQuickPlayTask.control = "RUN"
          end if
        end if
      end if
    else if isNodeEvent(msg, "voiceQuery")
      ' Voice search from Home screen — open SearchResults with spoken query pre-populated
      voiceQuery = msg.getData()
      if isValidAndNotEmpty(voiceQuery)
        ' Guard: skip if a SearchResults is already the active scene (prevents duplicate pushes
        ' when voice system fires multiple text changes for a single input)
        activeScene = m.global.sceneManager.callFunc("getActiveScene")
        if isValid(activeScene) and activeScene.subtype() = "SearchResults"
          searchKey = activeScene.findNode("SearchBox").findNode("searchKey")
          searchKey.text = voiceQuery
          searchKey.textEditBox.setFocus(true)
        else
          group = CreateSearchPage()
          group.shouldSkipInitialFocus = true
          m.global.sceneManager.callFunc("pushScene", group)
          searchKey = group.findNode("SearchBox").findNode("searchKey")
          searchKey.text = voiceQuery
          searchKey.textEditBox.setFocus(true)
        end if
      end if
    else if isNodeEvent(msg, "output")
      task = msg.getRoSGNode()
      if isValid(task) and task.subtype() = "QuickPlayTask"
        handleQuickPlayOutput(task)
      end if
    else if isNodeEvent(msg, "result")
      task = msg.getRoSGNode()
      if isValid(task) and task.subtype() = "RecordProgramTask"
        handleRecordResult(task)
      end if
    else if isNodeEvent(msg, "isDone")
      resultNode = msg.getRoSGNode()
      if isValid(resultNode) and resultNode.isSameNode(m.favoriteResultNode)
        handleFavoriteToggleDone()
      else if isValid(resultNode) and resultNode.isSameNode(m.watchedResultNode)
        handleWatchedToggleDone()
      end if
    else if isNodeEvent(msg, "selectedItem")
      ' If you select a library from ANYWHERE, follow this flow
      selectedItem = msg.getData()
      if isValid(selectedItem)
        startLoadingSpinner()
        selectedItemType = selectedItem.type

        ' Normalize alias types to their canonical forms
        if selectedItemType = "LiveTvChannel" then selectedItemType = "TvChannel"
        if selectedItemType = "LiveTvProgram" or selectedItemType = "TvProgram" then selectedItemType = "Program"

        if selectedItemType = "CollectionFolder"
          group = CreateLibraryView(selectedItem)
          group.observeField("selectedItem", m.port)
          group.observeField("quickPlayNode", m.port)
          m.global.sceneManager.callFunc("pushScene", group)
        else if selectedItemType = "Genre" or selectedItemType = "MusicGenre" or (selectedItemType = "Folder" and (selectedItem.folderType = "Genre" or selectedItem.folderType = "MusicGenre"))
          ' User clicked on a genre/music-genre — open scoped library view
          group = CreateLibraryView(selectedItem)
          group.observeField("selectedItem", m.port)
          group.observeField("quickPlayNode", m.port)
          m.global.sceneManager.callFunc("pushScene", group)
        else if selectedItemType = "Studio"
          ' Studio items from /Studios endpoint have IsFolder=false so type="Studio" directly.
          ' Route to a library view scoped to that studio (presenter inferred via collectionType).
          group = CreateLibraryView(selectedItem)
          group.observeField("selectedItem", m.port)
          group.observeField("quickPlayNode", m.port)
          m.global.sceneManager.callFunc("pushScene", group)
        else if selectedItemType = "UserView" or selectedItemType = "Folder" or selectedItemType = "Channel"
          group = CreateLibraryView(selectedItem)
          group.observeField("selectedItem", m.port)
          group.observeField("quickPlayNode", m.port)
          m.global.sceneManager.callFunc("pushScene", group)
        else if selectedItemType = "BoxSet"
          group = CreateItemDetailsGroup(selectedItem)
        else if selectedItemType = "Episode" or LCase(selectedItemType) = "recording"
          group = CreateItemDetailsGroup(selectedItem)
        else if selectedItemType = "Series"
          group = CreateItemDetailsGroup(selectedItem)
        else if selectedItemType = "Season"
          group = CreateItemDetailsGroup(selectedItem)
        else if selectedItemType = "Movie"
          group = CreateItemDetailsGroup(selectedItem)
        else if selectedItemType = "Person"
          group = CreateItemDetailsGroup(selectedItem)
        else if selectedItemType = "Video"
          group = CreateItemDetailsGroup(selectedItem)
        else if selectedItemType = "TvChannel" or selectedItemType = "Program"
          group = CreateItemDetailsGroup(selectedItem)
        else if selectedItemType = "Photo"
          group = CreateItemDetailsGroup(selectedItem)
        else if selectedItemType = "PhotoAlbum"
          group = CreateItemDetailsGroup(selectedItem)
        else if selectedItemType = "MusicArtist"
          group = CreateItemDetailsGroup(selectedItem)
        else if selectedItemType = "MusicAlbum"
          group = CreateItemDetailsGroup(selectedItem)
        else if selectedItemType = "Audio"
          group = CreateItemDetailsGroup(selectedItem)
        else if selectedItemType = "MusicVideo"
          group = CreateItemDetailsGroup(selectedItem)
        else if selectedItemType = "Playlist"
          group = CreateItemDetailsGroup(selectedItem)
        else if selectedItemType = "Chapter"
          ' Chapter: start playback of parent video at chapter position
          m.global.queueManager.callFunc("clear")
          m.global.queueManager.callFunc("resetShuffle")
          queueItem = nodeHelpers.createQueueItem(selectedItem)
          if isValid(queueItem)
            queueItem.type = selectedItem.parentType
            queueItem.startingPoint = selectedItem.playbackPositionTicks
            m.global.queueManager.callFunc("push", queueItem)
            m.global.queueManager.callFunc("playQueue")
          end if
        else
          ' TODO - switch on more node types
          stopLoadingSpinner()
          messageDialog(translate(translationKeys.ErrorTypeNotYetSupported, [selectedItemType]))
        end if
      end if
    else if isNodeEvent(msg, "movieSelected")
      ' If you select a movie from ANYWHERE, follow this flow
      startLoadingSpinner()
      node = getMsgPicker(msg, "picker")
      group = CreateItemDetailsGroup(node)
    else if isNodeEvent(msg, "seriesSelected")
      ' If you select a TV Series from ANYWHERE, follow this flow
      startLoadingSpinner()
      node = getMsgPicker(msg, "picker")
      group = CreateItemDetailsGroup(node)
    else if isNodeEvent(msg, "playItem")
      ' User has selected audio they want us to play
      startLoadingSpinner()
      selectedIndex = msg.getData()
      screenContent = msg.getRoSGNode()

      m.global.queueManager.callFunc("resetShuffle")
      m.global.queueManager.callFunc("set", screenContent.albumData.items)
      m.global.queueManager.callFunc("setPosition", selectedIndex)
      m.global.queueManager.callFunc("playQueue")
    else if isNodeEvent(msg, "searchValue")
      query = msg.getRoSGNode().searchValue
      group.findNode("SearchBox").visible = false
      options = group.findNode("SearchSelect")
      options.visible = true
      options.setFocus(true)

      m.searchDialog = createObject("roSGNode", "ProgressDialog")
      m.searchDialog.title = translate(translationKeys.LabelLoadingSearchData)
      m.scene.dialog = m.searchDialog

      m.searchGroup = group
      m.searchQuery = query

      m.searchTask = CreateObject("roSGNode", "SearchTask")
      m.searchTask.query = query
      m.searchTask.observeField("results", m.port)
      m.searchTask.control = "RUN"
    else if isNodeEvent(msg, "results")
      ' Search results arrived from SearchTask
      task = msg.getRoSGNode()
      task.unobserveField("results")
      results = task.results
      task.control = "STOP"
      m.searchTask = invalid

      if isValid(m.searchDialog)
        m.searchDialog.close = true
        m.searchDialog = invalid
      end if

      if isValid(m.searchGroup) and isValid(results)
        searchOptions = m.searchGroup.findNode("SearchSelect")
        searchOptions.itemData = results
        searchOptions.query = m.searchQuery
      end if
      m.searchGroup = invalid
    else if isNodeEvent(msg, "itemSelected")
      ' Search item selected
      startLoadingSpinner()
      node = getMsgPicker(msg)

      ' Normalize alias types to their canonical forms
      nodeType = node.type
      if nodeType = "LiveTvChannel" then nodeType = "TvChannel"
      if nodeType = "LiveTvProgram" or nodeType = "TvProgram" then nodeType = "Program"

      if nodeType = "Series"
        group = CreateItemDetailsGroup(node)
      else if nodeType = "Movie"
        group = CreateItemDetailsGroup(node)
      else if nodeType = "MusicArtist"
        group = CreateItemDetailsGroup(node)
      else if nodeType = "MusicAlbum"
        group = CreateItemDetailsGroup(node)
      else if nodeType = "Audio"
        group = CreateItemDetailsGroup(node)
      else if nodeType = "MusicVideo"
        group = CreateItemDetailsGroup(node)
      else if nodeType = "Person"
        group = CreateItemDetailsGroup(node)
      else if nodeType = "BoxSet"
        group = CreateItemDetailsGroup(node)
      else if nodeType = "TvChannel" or nodeType = "Program"
        group = CreateItemDetailsGroup(node)
      else if nodeType = "Episode"
        group = CreateItemDetailsGroup(node)
      else if LCase(nodeType) = "recording"
        group = CreateItemDetailsGroup(node)
      else if nodeType = "Playlist"
        group = CreateItemDetailsGroup(node)
      else if nodeType = "Photo" or nodeType = "PhotoAlbum"
        group = CreateItemDetailsGroup(node)
      else if nodeType = "Video"
        group = CreateItemDetailsGroup(node)
      else
        ' TODO - switch on more node types
        stopLoadingSpinner()
        messageDialog(translate(translationKeys.ErrorTypeNotYetSupported, [nodeType]))
      end if
    else if isNodeEvent(msg, "buttonSelected")
      ' If a button is selected, we have some determining to do
      btn = getButton(msg)
      if not isValid(btn) then print "No button found in buttonSelected event"

      ' Guard: skip if spinner is active — isRemoteDisabled prevents FUTURE key events
      ' but cannot clear events already queued in m.port from rapid presses.
      if isValid(btn) and not m.scene.isLoading
        group = m.global.sceneManager.callFunc("getActiveScene")

        if btn.id = "playButton"
          if not isValid(group) then return

          ' User chose Play button from movie detail view
          startLoadingSpinner()
          ' Check if a specific Audio Stream was selected
          audioStreamIdx = 0
          if isValid(group.selectedAudioStreamIndex)
            audioStreamIdx = group.selectedAudioStreamIndex
          end if
          ' Subtitle dropdown override (-1 = SubtitleSelection.NONE). Only forwarded for
          ' ItemDetails screens since that's the only place selectedSubtitleStreamIndex
          ' is surfaced pre-playback.
          subtitleStreamIdx = invalid
          if isValid(group.selectedSubtitleStreamIndex)
            subtitleStreamIdx = group.selectedSubtitleStreamIndex
          end if

          if isValid(group.itemContent)
            ' Types requiring API calls — delegate to QuickPlayTask
            itemContentType = group.itemContent.type
            if group.subtype() = "ItemDetails" and (itemContentType = "Series" or itemContentType = "Season" or itemContentType = "BoxSet" or itemContentType = "MusicArtist" or itemContentType = "MusicAlbum" or itemContentType = "Playlist")
              m.global.queueManager.callFunc("clear")
              m.global.queueManager.callFunc("resetShuffle")
              m.activeQuickPlayTask = CreateObject("roSGNode", "QuickPlayTask")
              m.activeQuickPlayTask.input = {
                action: "playAll" + itemContentType,
                id: group.itemContent.id,
                seriesId: group.itemContent.seriesId
              }
              m.activeQuickPlayTask.observeField("output", m.port)
              m.activeQuickPlayTask.control = "RUN"
            else
              queueItem = nodeHelpers.createQueueItem(group.itemContent)
              if not isValid(queueItem) then return
              queueItem.selectedAudioStreamIndex = audioStreamIdx
              if isValid(subtitleStreamIdx)
                queueItem.selectedSubtitleStreamIndex = subtitleStreamIdx
              end if
              ' selectedVideoStreamId is the user-chosen MediaSource (alternate version).
              ' This is a separate identifier from the Jellyfin item id — it must land in
              ' queueItem.mediaSourceId, which VideoPlayerView.init passes to
              ' LoadMetaDataTask.mediaSourceId. Overwriting queueItem.id here would
              ' replace the Jellyfin item id and break metadata lookup entirely.
              if isValidAndNotEmpty(group.selectedVideoStreamId)
                queueItem.mediaSourceId = group.selectedVideoStreamId
              end if

              ' ItemDetails: Play button always starts from beginning (Resume button handles resume)
              if group.subtype() = "ItemDetails"
                queueItem.startingPoint = 0
                m.global.queueManager.callFunc("clear")
                m.global.queueManager.callFunc("push", queueItem)
                m.global.queueManager.callFunc("playQueue")
              end if
            end if
          end if

          if isValid(group.lastFocus) and isValid(group.lastFocus.id) and group.lastFocus.id = "mainGroup"
            buttons = group.findNode("buttons")
            if isValid(buttons)
              group.lastFocus = group.findNode("buttons")
            end if
          end if

          if isValid(group.lastFocus)
            group.lastFocus.setFocus(true)
          end if

        else if btn.id = "resumeButton"
          if not isValid(group) then return

          ' User chose Resume button from detail view
          startLoadingSpinner()
          ' Check if a specific Audio Stream was selected
          audioStreamIdx = 0
          if isValid(group.selectedAudioStreamIndex)
            audioStreamIdx = group.selectedAudioStreamIndex
          end if
          ' Subtitle dropdown override (see playButton path above)
          subtitleStreamIdx = invalid
          if isValid(group.selectedSubtitleStreamIndex)
            subtitleStreamIdx = group.selectedSubtitleStreamIndex
          end if

          if isValid(group.itemContent)
            ' Series on ItemDetails: resume the next-up episode stored on the component
            if group.subtype() = "ItemDetails" and group.itemContent.type = "Series"
              nextEp = group.nextUpEpisode
              if isValid(nextEp) and isValidAndNotEmpty(nextEp.id)
                queueItem = nodeHelpers.createQueueItem(nextEp)
                if not isValid(queueItem)
                  stopLoadingSpinner()
                  return
                end if
                queueItem.startingPoint = nextEp.playbackPositionTicks
                m.global.queueManager.callFunc("clear")
                m.global.queueManager.callFunc("push", queueItem)
                m.global.queueManager.callFunc("playQueue")
              else
                stopLoadingSpinner()
              end if
            else
              queueItem = nodeHelpers.createQueueItem(group.itemContent)
              if not isValid(queueItem) then return
              queueItem.selectedAudioStreamIndex = audioStreamIdx
              if isValid(subtitleStreamIdx)
                queueItem.selectedSubtitleStreamIndex = subtitleStreamIdx
              end if
              ' selectedVideoStreamId is the user-chosen MediaSource (alternate version).
              ' Land it in mediaSourceId (not id) — see playButton handler comment above
              ' for the rationale.
              if isValidAndNotEmpty(group.selectedVideoStreamId)
                queueItem.mediaSourceId = group.selectedVideoStreamId
              end if

              ' Set starting point to saved position
              if group.itemContent.playbackPositionTicks > 0
                queueItem.startingPoint = group.itemContent.playbackPositionTicks
              else
                queueItem.startingPoint = 0
              end if

              m.global.queueManager.callFunc("clear")
              m.global.queueManager.callFunc("push", queueItem)
              m.global.queueManager.callFunc("playQueue")
            end if
          end if

          if isValid(group.lastFocus) and isValid(group.lastFocus.id) and group.lastFocus.id = "mainGroup"
            buttons = group.findNode("buttons")
            if isValid(buttons)
              group.lastFocus = group.findNode("buttons")
            end if
          end if

          if isValid(group.lastFocus)
            group.lastFocus.setFocus(true)
          end if

        else if btn.id = "trailerButton"
          ' User chose to play a trailer — delegate to QuickPlayTask
          startLoadingSpinner()
          m.global.queueManager.callFunc("clear")
          m.global.queueManager.callFunc("resetShuffle")
          m.activeQuickPlayTask = CreateObject("roSGNode", "QuickPlayTask")
          m.activeQuickPlayTask.input = { action: "loadTrailers", id: group.id }
          m.activeQuickPlayTask.observeField("output", m.port)
          m.activeQuickPlayTask.control = "RUN"
        else if btn.id = "watchedButton"
          item = group.itemContent
          if isValid(item) and isValidAndNotEmpty(item.id)
            ' Series: confirm before marking all episodes watched/unwatched (can affect hundreds of episodes)
            if item.type = "Series"
              watchedButton = group.findNode("watchedButton")
              m.pendingWatchedItemId = item.id
              ' Read current state from button — writing to itemContent triggers a full rebuild
              m.pendingWatchedIsCurrentlyWatched = isValid(watchedButton) and watchedButton.isButtonSelected
              if m.pendingWatchedIsCurrentlyWatched
                confirmMsg = translate(translationKeys.MessageMarkAllEpisodesInThisSeries)
                confirmBtn = translate(translationKeys.ButtonMarkUnwatched)
              else
                confirmMsg = translate(translationKeys.MessageMarkAllEpisodesInThisSeries2)
                confirmBtn = translate(translationKeys.ButtonMarkWatched)
              end if
              m.global.sceneManager.callFunc("showConfirmationDialog", translate(translationKeys.LabelWatched), [confirmMsg], [translate(translationKeys.ButtonCancel), confirmBtn])
            else
              watchedButton = group.findNode("watchedButton")
              if isValid(watchedButton) and watchedButton.isLoading then continue while ' Already in-flight
              ' Read current state from button — writing to itemContent triggers a full rebuild
              isCurrentlyWatched = isValid(watchedButton) and watchedButton.isButtonSelected
              if isCurrentlyWatched
                req = GetApi().BuildUnmarkPlayedRequest(item.id)
              else
                req = GetApi().BuildMarkPlayedRequest(item.id)
              end if
              newWatchedState = not isCurrentlyWatched
              if isValid(watchedButton) then watchedButton.isLoading = true
              ' Set resume button to loading while we wait for async response
              if newWatchedState
                resumeButton = group.findNode("resumeButton")
                if isValid(resumeButton) then resumeButton.isLoading = true
              end if
              resultNode = submitApiRequest(req, "watchedToggle")
              if isValid(resultNode)
                m.watchedResultNode = resultNode
                m.pendingWatchedButton = watchedButton
                m.pendingWatchedNewState = newWatchedState
                m.pendingWatchedItemType = item.type
                m.watchedResultNode.observeField("isDone", m.port)
              else
                ' Pool not ready — fall back to fire-and-forget + direct update
                SubmitSideEffect(req)
                if isValid(watchedButton)
                  watchedButton.isButtonSelected = newWatchedState
                  watchedButton.isLoading = false
                end if
                ' Remove stale resume button when marking as watched
                if newWatchedState
                  removeResumeButtonFromGroup(group)
                end if
              end if
            end if
          end if
        else if btn.id = "favoriteButton"
          item = group.itemContent
          favoriteButton = group.findNode("favoriteButton")
          if isValid(item) and isValidAndNotEmpty(item.id) and isValid(favoriteButton)
            if favoriteButton.isLoading then continue while ' Already in-flight
            ' Read current state from button — writing to itemContent triggers a full rebuild
            newState = not favoriteButton.isButtonSelected
            if newState
              req = GetApi().BuildMarkFavoriteRequest(item.id)
            else
              req = GetApi().BuildUnmarkFavoriteRequest(item.id)
            end if
            favoriteButton.isLoading = true
            resultNode = submitApiRequest(req, "favoriteToggle")
            if isValid(resultNode)
              m.favoriteResultNode = resultNode
              m.pendingFavoriteButton = favoriteButton
              m.pendingFavoriteNewState = newState
              m.favoriteResultNode.observeField("isDone", m.port)
            else
              ' Pool not ready — fall back to fire-and-forget
              SubmitSideEffect(req)
              favoriteButton.isButtonSelected = newState
              favoriteButton.isLoading = false
            end if
          end if
        else if btn.id = "refreshButton"
          ' Trigger data refresh for the current screen
          if isValid(group)
            startLoadingSpinner()
            group.refreshExtrasData = not group.refreshExtrasData
            group.refreshItemDetailsData = not group.refreshItemDetailsData
          end if
        else if btn.id = "shuffleButton"
          ' Shuffle: routes by item type so new types (Season, Artist, Person, etc.) can be added.
          if isValid(group) and isValid(group.itemContent) and isValidAndNotEmpty(group.itemContent.id)
            startLoadingSpinner()
            item = group.itemContent
            if item.type = "PhotoAlbum"
              ' PhotoAlbum shuffle: launch random slideshow
              m.photoAlbumShuffleMode = true
              m.activeQuickPlayTask = CreateObject("roSGNode", "QuickPlayTask")
              m.activeQuickPlayTask.input = { action: "loadPhotoAlbum", id: item.id }
              m.activeQuickPlayTask.observeField("output", m.port)
              m.activeQuickPlayTask.control = "RUN"
            else
              ' API calls run off the render thread via GetShuffleItemsTask; result handled by "shuffleItems" event.
              m.shuffleTask = CreateObject("roSGNode", "GetShuffleItemsTask")
              m.shuffleTask.shuffleInput = { type: item.type, id: item.id, seriesId: item.seriesId }
              m.shuffleTask.observeField("shuffleItems", m.port)
              m.shuffleTask.control = "RUN"
            end if
          end if
        else if btn.id = "instantMixButton"
          ' Instant Mix — delegate to QuickPlayTask
          if isValid(group) and isValid(group.itemContent) and isValidAndNotEmpty(group.itemContent.id)
            startLoadingSpinner()
            m.global.queueManager.callFunc("clear")
            m.global.queueManager.callFunc("resetShuffle")
            m.activeQuickPlayTask = CreateObject("roSGNode", "QuickPlayTask")
            m.activeQuickPlayTask.input = { action: "instantMix", id: group.itemContent.id }
            m.activeQuickPlayTask.observeField("output", m.port)
            m.activeQuickPlayTask.control = "RUN"
          end if
        else if btn.id = "goToSeriesButton"
          ' Navigate to the parent series detail screen
          if isValid(group) and isValid(group.itemContent) and isValidAndNotEmpty(group.itemContent.seriesId)
            CreateItemDetailsGroup({ id: group.itemContent.seriesId, type: "Series" })
          end if
        else if btn.id = "goToAlbumButton"
          ' Navigate to the album detail screen for the current Audio item
          if isValid(group) and isValid(group.itemContent) and isValidAndNotEmpty(group.itemContent.albumId)
            CreateItemDetailsGroup({ id: group.itemContent.albumId, type: "MusicAlbum" })
          end if
        else if btn.id = "goToArtistButton"
          ' Navigate to the artist detail screen for the current MusicAlbum.
          ' Prefers albumArtists (primary album artist); falls back to artistItems when only one artist.
          if isValid(group) and isValid(group.itemContent)
            item = group.itemContent
            artistId = ""
            if isValid(item.albumArtists) and item.albumArtists.count() > 0
              firstAlbumArtist = item.albumArtists[0]
              if isValid(firstAlbumArtist) and isValidAndNotEmpty(firstAlbumArtist.Id)
                artistId = firstAlbumArtist.Id
              end if
            end if
            if artistId = "" and isValid(item.artistItems) and item.artistItems.count() = 1
              firstArtistItem = item.artistItems[0]
              if isValid(firstArtistItem) and isValidAndNotEmpty(firstArtistItem.Id)
                artistId = firstArtistItem.Id
              end if
            end if
            if isValidAndNotEmpty(artistId)
              CreateItemDetailsGroup({ id: artistId, type: "MusicArtist" })
            end if
          end if
        else if btn.id = "viewPhotoButton"
          ' Open Photo in the PhotoDetails viewer
          if isValid(group) and isValid(group.itemContent)
            quickplay.photo(group.itemContent)
          end if
        else if btn.id = "slideshowButton"
          ' Launch sequential slideshow for PhotoAlbum
          if isValid(group) and isValid(group.itemContent) and isValidAndNotEmpty(group.itemContent.id)
            startLoadingSpinner()
            m.activeQuickPlayTask = CreateObject("roSGNode", "QuickPlayTask")
            m.activeQuickPlayTask.input = { action: "loadPhotoAlbum", id: group.itemContent.id }
            m.activeQuickPlayTask.observeField("output", m.port)
            m.activeQuickPlayTask.control = "RUN"
          end if
        else if btn.id = "watchChannelButton"
          ' Play a Live TV channel (TvChannel or Program's channel)
          if isValid(group) and isValid(group.itemContent)
            startLoadingSpinner()
            item = group.itemContent
            queueItem = nodeHelpers.createQueueItem(item)
            ' Program: play the channel the program is on
            if item.type = "Program" and isValidAndNotEmpty(item.channelId)
              queueItem.id = item.channelId
              queueItem.type = "TvChannel"
            end if
            m.global.queueManager.callFunc("clear")
            m.global.queueManager.callFunc("push", queueItem)
            m.global.queueManager.callFunc("playQueue")
          end if
        else if btn.id = "goToChannelButton"
          ' Navigate to the TvChannel detail screen for this Program
          if isValid(group) and isValid(group.itemContent) and isValidAndNotEmpty(group.itemContent.channelId)
            CreateItemDetailsGroup({ id: group.itemContent.channelId, type: "TvChannel" })
          end if
        else if btn.id = "recordButton"
          item = group.itemContent
          recordButton = group.findNode("recordButton")
          if isValid(recordButton) and recordButton.isLoading then continue while ' Already in-flight
          programToRecord = invalid
          if item.type = "TvChannel" and isValid(item.currentProgram)
            programToRecord = item.currentProgram
          else if item.type = "Program"
            programToRecord = item
          end if
          if isValid(programToRecord)
            if isValid(recordButton) then recordButton.isLoading = true
            m.pendingRecordButton = recordButton
            m.pendingRecordIsCancel = isValidAndNotEmpty(programToRecord.timerId)
            recordTask = CreateObject("roSGNode", "RecordProgramTask")
            recordTask.programDetails = programToRecord
            recordTask.observeField("result", m.port)
            recordTask.control = "RUN"
          end if
        else if btn.id = "deleteButton"
          print "deleteButton pressed"
          if isValid(group) and isValid(group.itemContent) and isValidAndNotEmpty(group.itemContent.id)
            m.pendingDeleteItemId = group.itemContent.id
            m.global.sceneManager.callFunc("showConfirmationDialog", translate(translationKeys.ButtonDelete), [translate(translationKeys.ErrorAreYouSureYouWantTo)], [translate(translationKeys.ButtonCancel), translate(translationKeys.ButtonDelete)])
          end if
        else
          ' If there are no other button matches, check if this is a simple "OK" Dialog & Close if so
          dialog = msg.getRoSGNode()
          if dialog.id = "OKDialog"
            dialog.unobserveField("buttonSelected")
            dialog.close = true
            return
          end if

          print "ERROR: Unhandled button id: " + btn.id
        end if
      end if ' isValid(btn) and not m.scene.isLoading
    else if isNodeEvent(msg, "optionSelected")
      button = msg.getRoSGNode()
      group = m.global.sceneManager.callFunc("getActiveScene")
      actionId = button.id

      ' Close the side panel before handling the action
      ' Search from scene — panel may be reparented to optionsPanelOverlay while open
      if isValid(group)
        panel = m.scene.findNode("options")
        if isValid(panel)
          panel.visible = false
        end if
        if isValid(group.lastFocus)
          group.lastFocus.setFocus(true)
        else
          group.setFocus(true)
        end if
      end if

      if handleMenuAction(actionId) then goto appStart
    else if isNodeEvent(msg, "userMenuAction")
      actionId = msg.getData()
      if handleMenuAction(actionId) then goto appStart
    else if type(msg) = "roDeviceInfoEvent"
      event = msg.GetInfo()

      if event.exitedScreensaver = true
        m.global.sceneManager.callFunc("resetTime")
        group = m.global.sceneManager.callFunc("getActiveScene")
        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("mediatype") and info.DoesExist("contentid")
          ' Deep-link launch: another app handed us content to play.
          m.global.queueManager.callFunc("clear")
          m.global.queueManager.callFunc("push", nodeHelpers.createQueueItem({ id: info.contentId, type: "video" }))
          m.global.queueManager.callFunc("playQueue")
        else if isValid(info.type) and info.type = "transport"
          ' Voice transport command (play, pause, seek, next, startover, skip, ...).
          ' Dispatch to the active player; otherwise ack as no-media so Roku OS shows
          ' "Nothing is playing" rather than the default "Command not available".
          ' roAppManager calls happen here (MAIN thread) — it can't be created on the
          ' render thread where the player's callFunc executes.
          activeScene = m.global.sceneManager.callFunc("getActiveScene")
          transportStatus = "error.no-media"
          transportNowPlaying = invalid
          if isValid(activeScene)
            sceneType = activeScene.subtype()
            if sceneType = "VideoPlayerView" or sceneType = "AudioPlayerView"
              transportRet = activeScene.callFunc("handleTransport", info)
              if isValid(transportRet) and isValid(transportRet.status)
                transportStatus = transportRet.status
              else
                transportStatus = "unhandled"
              end if
              if isValid(transportRet) and isValid(transportRet.nowPlaying)
                transportNowPlaying = transportRet.nowPlaying
              end if
            end if
          end if
          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()
      stopLoadingSpinner()
      if isValid(popupNode) and isValid(popupNode.returnData)
        print "popupNode.returnData = ", popupNode.returnData
        ' Handle exit confirmation dialog
        if m.global.sceneManager.isPendingExitConfirmation = true
          m.global.sceneManager.isPendingExitConfirmation = false
          if popupNode.returnData.indexSelected = 1 and popupNode.returnData.buttonSelected = translate(translationKeys.ButtonExit)
            ' User confirmed exit
            m.scene.exit = true
          end if
          ' Handle delete confirmation dialog
        else if isValid(m.pendingDeleteItemId)
          if popupNode.returnData.indexSelected = 1 and popupNode.returnData.buttonSelected = translate(translationKeys.ButtonDelete)
            ' User confirmed deletion
            SubmitSideEffect(GetApi().BuildDeleteItemRequest(m.pendingDeleteItemId))
            m.pendingDeleteItemId = invalid
            m.global.sceneManager.callFunc("popScene")
          else
            ' User cancelled or dialog closed
            m.pendingDeleteItemId = invalid
          end if
          ' Handle watched confirmation dialog (Series only)
        else if isValid(m.pendingWatchedItemId)
          if popupNode.returnData.indexSelected = 1
            ' User confirmed — mark/unmark all episodes
            if m.pendingWatchedIsCurrentlyWatched
              req = GetApi().BuildUnmarkPlayedRequest(m.pendingWatchedItemId)
            else
              req = GetApi().BuildMarkPlayedRequest(m.pendingWatchedItemId)
            end if
            newWatchedState = not m.pendingWatchedIsCurrentlyWatched
            m.pendingWatchedItemId = invalid
            m.pendingWatchedIsCurrentlyWatched = invalid
            activeGroup = m.global.sceneManager.callFunc("getActiveScene")
            watchedButton = invalid
            if isValid(activeGroup)
              watchedButton = activeGroup.findNode("watchedButton")
            end if
            if isValid(watchedButton) then watchedButton.isLoading = true
            ' Set resume button to loading while we wait for async data
            resumeButton = invalid
            if isValid(activeGroup) then resumeButton = activeGroup.findNode("resumeButton")
            if isValid(resumeButton) then resumeButton.isLoading = true
            resultNode = submitApiRequest(req, "watchedToggle")
            if isValid(resultNode)
              m.watchedResultNode = resultNode
              m.pendingWatchedButton = watchedButton
              m.pendingWatchedNewState = newWatchedState
              m.pendingWatchedItemType = "Series"
              m.watchedResultNode.observeField("isDone", m.port)
            else
              ' Pool not ready — fall back to fire-and-forget + direct update
              SubmitSideEffect(req)
              if isValid(watchedButton)
                watchedButton.isButtonSelected = newWatchedState
                watchedButton.isLoading = false
              end if
              ' Series: refresh resume data to reflect new watched state
              if isValid(activeGroup)
                activeGroup.refreshResumeData = not activeGroup.refreshResumeData
              end if
            end if
          else
            ' User cancelled
            m.pendingWatchedItemId = invalid
            m.pendingWatchedIsCurrentlyWatched = invalid
          end if
        else
          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, "shuffleItems")
      ' GetShuffleItemsTask completed — queue and play the results
      shuffleTask = msg.getRoSGNode()
      shuffleItems = shuffleTask.shuffleItems
      shuffleTask.unobserveField("shuffleItems")
      shuffleTask.control = "STOP"
      m.shuffleTask = invalid
      if isValid(shuffleItems) and shuffleItems.count() > 0
        m.global.queueManager.callFunc("clear")
        m.global.queueManager.callFunc("resetShuffle")
        m.global.queueManager.callFunc("set", shuffleItems)
        if shuffleItems.count() > 1
          m.global.queueManager.callFunc("toggleShuffle") ' Fisher-Yates client-side shuffle
        end if
        m.global.queueManager.callFunc("playQueue")
      else
        stopLoadingSpinner()
      end if
    else if isNodeEvent(msg, "reloadHomeRequested")
      ' Theme colors changed - reload home screen with fresh state
      m.global.sceneManager.callFunc("clearScenes")
      createAndShowHomeGroup()
    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()
  group = CreateHomeGroup()
  group.callFunc("loadLibraries")
  m.global.sceneManager.callFunc("pushScene", group)
  stopLoadingSpinner()

  m.scene.observeField("exit", m.port)

  ' 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

' Handles the output from QuickPlayTask — queues items, opens photo player, or plays trailers.
sub handleQuickPlayOutput(task as object)
  output = task.output
  task.unobserveField("output")
  task.control = "STOP"
  m.activeQuickPlayTask = invalid

  if not isValid(output) or not isValidAndNotEmpty(output.items)
    stopLoadingSpinner()
    return
  end if

  if output.action = "queue"
    quickplay.pushToQueue(output.items)
    if output.firstItemStartingPoint > 0
      m.global.queueManager.callFunc("setCurrentStartingPoint", output.firstItemStartingPoint)
    end if
    if output.shuffle and m.global.queueManager.callFunc("getCount") > 1
      m.global.queueManager.callFunc("toggleShuffle")
    end if
    m.global.queueManager.callFunc("playQueue")
  else if output.action = "photo"
    photoPlayer = CreateObject("roSgNode", "PhotoDetails")
    photoPlayer.isSlideshow = true
    ' PhotoAlbum shuffle sets isRandom=true for random slideshow
    photoPlayer.isRandom = m.photoAlbumShuffleMode = true
    m.photoAlbumShuffleMode = false
    photoPlayer.itemsArray = output.items
    photoPlayer.itemIndex = 0
    m.global.sceneManager.callfunc("pushScene", photoPlayer)
    stopLoadingSpinner()
  else if output.action = "trailer"
    if isValid(output.items[0]) and isValidAndNotEmpty(output.items[0].id)
      m.global.queueManager.callFunc("clear")
      m.global.queueManager.callFunc("set", output.items)
      m.global.queueManager.callFunc("playQueue")
    else
      stopLoadingSpinner()
    end if
  end if
end sub

sub handleFavoriteToggleDone()
  res = m.favoriteResultNode.result
  m.favoriteResultNode.unobserveField("isDone")
  m.favoriteResultNode = invalid

  ' Debug error injection — compiled out in production (bs_const=debug=false)
  #if debug
    if isValid(m.global.debug) and m.global.debug.shouldForceFavoriteFail
      res = { ok: false }
    end if
  #end if

  if isValid(res) and res.ok
    if isValid(m.pendingFavoriteButton)
      m.pendingFavoriteButton.isButtonSelected = m.pendingFavoriteNewState
    end if
  else
    ' Revert button to original state on failure
    if isValid(m.pendingFavoriteButton)
      m.pendingFavoriteButton.isButtonSelected = not m.pendingFavoriteNewState
    end if
    displayToast(translate(translationKeys.ErrorFailedToUpdateFavorite), "error")
  end if

  if isValid(m.pendingFavoriteButton)
    m.pendingFavoriteButton.isLoading = false
  end if
  m.pendingFavoriteButton = invalid
  m.pendingFavoriteNewState = invalid
end sub

sub handleWatchedToggleDone()
  res = m.watchedResultNode.result
  m.watchedResultNode.unobserveField("isDone")
  m.watchedResultNode = invalid

  ' Debug error injection — compiled out in production (bs_const=debug=false)
  #if debug
    if isValid(m.global.debug) and m.global.debug.shouldForceWatchedFail
      res = { ok: false }
    end if
  #end if

  if isValid(res) and res.ok
    if isValid(m.pendingWatchedButton)
      m.pendingWatchedButton.isButtonSelected = m.pendingWatchedNewState
    end if
    ' Update resume button to reflect new watched state
    activeGroup = m.global.sceneManager.callFunc("getActiveScene")
    if isValid(activeGroup)
      if m.pendingWatchedItemType = "Series"
        ' Series: re-fetch next-up episode (may change or disappear)
        activeGroup.refreshResumeData = not activeGroup.refreshResumeData
      else if m.pendingWatchedNewState
        ' Non-Series marking watched: server clears playback position, remove stale resume button
        removeResumeButtonFromGroup(activeGroup)
      end if
    end if
  else
    ' Revert button to original state on failure
    if isValid(m.pendingWatchedButton)
      m.pendingWatchedButton.isButtonSelected = not m.pendingWatchedNewState
    end if
    ' Revert resume button loading state on failure
    activeGroup = m.global.sceneManager.callFunc("getActiveScene")
    if isValid(activeGroup)
      resumeButton = activeGroup.findNode("resumeButton")
      if isValid(resumeButton) then resumeButton.isLoading = false
    end if
    displayToast(translate(translationKeys.ErrorFailedToUpdateWatchedStatus), "error")
  end if

  if isValid(m.pendingWatchedButton)
    m.pendingWatchedButton.isLoading = false
  end if
  m.pendingWatchedButton = invalid
  m.pendingWatchedNewState = invalid
  m.pendingWatchedItemType = invalid
end sub

sub handleRecordResult(task as object)
  res = task.result
  task.unobserveField("result")
  task.control = "STOP"

  if isValid(res) and res.ok
    ' Toggle button label and icon color to reflect new state
    if isValid(m.pendingRecordButton)
      if m.pendingRecordIsCancel
        m.pendingRecordButton.text = translate(translationKeys.ButtonRecord)
        ' Revert to default icon color (white)
        m.pendingRecordButton.iconBackground = m.global.constants.colorTextPrimary
        m.pendingRecordButton.iconFocusBackground = m.global.constants.colorTextPrimary
      else
        m.pendingRecordButton.text = translate(translationKeys.LabelCancelRecording)
        ' Active recording — red icon
        m.pendingRecordButton.iconBackground = m.global.constants.colorError
        m.pendingRecordButton.iconFocusBackground = m.global.constants.colorError
      end if
    end if
    if res.action = "record"
      displayToast(translate(translationKeys.LabelRecordingScheduled), "success")
    else
      displayToast(translate(translationKeys.LabelRecordingCancelled), "success")
    end if
  else
    ' Failed — button label stays as-is (no state change)
    if isValid(res) and res.action = "record"
      displayToast(translate(translationKeys.ErrorFailedToScheduleRecording), "error")
    else
      displayToast(translate(translationKeys.ErrorFailedToCancelRecording), "error")
    end if
  end if

  if isValid(m.pendingRecordButton)
    m.pendingRecordButton.isLoading = false
  end if
  m.pendingRecordButton = invalid
  m.pendingRecordIsCancel = invalid
end sub

' removeResumeButtonFromGroup: Remove the resume button from an ItemDetails button group.
' Used after marking an item as watched — the server clears playback position so the
' resume button is stale. Focus is preserved on the nearest remaining button.
sub removeResumeButtonFromGroup(group as object)
  resumeButton = group.findNode("resumeButton")
  if not isValid(resumeButton) then return

  buttonGrp = group.findNode("buttons")
  if not isValid(buttonGrp) then return

  currentFocusIndex = buttonGrp.buttonFocused
  resumeIndex = -1
  for i = 0 to buttonGrp.getChildCount() - 1
    child = buttonGrp.getChild(i)
    if isValid(child) and child.isSameNode(resumeButton)
      resumeIndex = i
      exit for
    end if
  end for

  wasFocused = resumeButton.isInFocusChain()
  buttonGrp.removeChild(resumeButton)

  if resumeIndex >= 0 and currentFocusIndex >= resumeIndex
    focusIdx = currentFocusIndex - 1
    if focusIdx < 0 then focusIdx = 0
    buttonGrp.buttonFocused = focusIdx
  end if
  if wasFocused
    nextButton = buttonGrp.getChild(buttonGrp.buttonFocused)
    if isValid(nextButton)
      nextButton.setFocus(true)
    end if
  end if
end sub

' Shared handler for menu actions from both the OptionsSlider and the user dropdown.
' Returns true if the caller should goto appStart (session-ending actions).
function handleMenuAction(actionId as string) as boolean
  if actionId = HomeAction.OPEN_SEARCH
    group = CreateSearchPage()
    m.global.sceneManager.callFunc("pushScene", group)
    group.findNode("SearchBox").findNode("searchKey").setFocus(true)
    group.findNode("SearchBox").findNode("searchKey").active = true
  else if actionId = HomeAction.OPEN_SETTINGS
    m.global.sceneManager.callFunc("settings")
  else if actionId = HomeAction.CHANGE_SERVER
    startLoadingSpinner()
    unsetSetting("server")
    server.Delete()
    SignOut(false)
    m.global.sceneManager.callFunc("clearScenes")
    return true
  else if actionId = HomeAction.CHANGE_USER
    startLoadingSpinner()
    SignOut(false)
    m.global.sceneManager.callFunc("clearScenes")
    return true
  else if actionId = HomeAction.SIGN_OUT
    startLoadingSpinner()
    SignOut()
    m.global.sceneManager.callFunc("clearScenes")
    return true
  end if

  return false
end function