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()

  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
    set_setting("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()

  app_start:
  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()

  ' Handle input messages
  input = CreateObject("roInput")
  input.SetMessagePort(m.port)

  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
  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, "fontDownloadCompleted")
      ' Handle font download completion
      fontDownloadTask = msg.getRoSGNode()
      if isValid(fontDownloadTask)
        handleFontDownloadCompletion(fontDownloadTask)
      end if
    else if isNodeEvent(msg, "quickPlayNode")
      ' measure processing time
      timeSpan = CreateObject("roTimespan")

      group = m.global.sceneManager.callFunc("getActiveScene")
      reportingNode = msg.getRoSGNode()
      itemNode = invalid
      print "quickplay reportingNode=", reportingNode
      if isValid(reportingNode) and isValid(reportingNode.quickPlayNode)
        itemNode = reportingNode.quickPlayNode
        reportingNodeType = reportingNode.subtype()
        print "Quick Play reporting node type=", reportingNodeType
      end if
      print "Quick Play started. itemNode=", itemNode
      if isValid(itemNode) and isValid(itemNode.id) and itemNode.id <> ""
        ' make sure there is a type and convert type to lowercase
        itemType = invalid
        if isValid(itemNode.type) and itemNode.type <> ""
          itemType = Lcase(itemNode.type)
        end if

        ' can't play the item without knowing what type it is
        if isValid(itemType)
          startLoadingSpinner()
          m.global.queueManager.callFunc("clear") ' empty queue/playlist
          m.global.queueManager.callFunc("resetShuffle") ' turn shuffle off

          if itemType = "episode" or itemType = "recording" or itemType = "movie" or itemType = "video"
            quickplay.video(itemNode)
          else if itemType = "audio"
            quickplay.audio(itemNode)
          else if itemType = "musicalbum"
            quickplay.album(itemNode)
          else if itemType = "musicartist"
            quickplay.artist(itemNode)
          else if itemType = "series"
            quickplay.series(itemNode)
          else if itemType = "season"
            quickplay.season(itemNode)
          else if itemType = "boxset"
            quickplay.boxset(itemNode)
          else if itemType = "collectionfolder"
            quickplay.collectionFolder(itemNode)
          else if itemType = "playlist"
            quickplay.playlist(itemNode)
          else if itemType = "userview"
            quickplay.userView(itemNode)
          else if itemType = "folder"
            quickplay.folder(itemNode)
          else if itemType = "musicvideo"
            quickplay.musicVideo(itemNode)
          else if itemType = "person"
            quickplay.person(itemNode)
          else if itemType = "tvchannel"
            quickplay.tvChannel(itemNode)
          else if itemType = "program"
            quickplay.program(itemNode)
          else if itemType = "photo"
            quickplay.photo(itemNode)
          else if itemType = "photoalbum"
            quickplay.photoAlbum(itemNode)
          end if
          m.global.queueManager.callFunc("playQueue")
        end if
      end if
      elapsed = timeSpan.TotalMilliseconds() / 1000
      print "Quick Play finished loading in " + elapsed.toStr() + " seconds."
    else if isNodeEvent(msg, "refreshItemDetailsData")
      startLoadingSpinner()

      currentScene = m.global.sceneManager.callFunc("getActiveScene")

      if isValid(currentScene) and isValid(currentScene.itemContent) and isValidAndNotEmpty(currentScene.itemContent.id)
        ' Use ItemDetailsMetaData (same as initial load) so People/Genres/Studios/UserData are all present.
        updatedItem = ItemDetailsMetaData(currentScene.itemContent.id)
        if isValid(updatedItem)
          currentScene.itemContent = updatedItem
          if updatedItem.playbackPositionTicks > 0
            m.global.queueManager.callFunc("setTopStartingPoint", updatedItem.playbackPositionTicks)
          end if
        end if
      end if

      stopLoadingSpinner()
    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

        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"
          ' User selected a Live TV channel / program
          ' Show Channel Loading spinner
          dialog = createObject("roSGNode", "ProgressDialog")
          dialog.title = tr("Loading Channel Data")
          m.scene.dialog = dialog

          queueItem = nodeHelpers.createQueueItem(selectedItem)

          ' User selected a program — play the channel the program is on instead
          if LCase(selectedItemType) = "program"
            queueItem.id = selectedItem.channelId
            queueItem.type = "TvChannel"
          end if

          ' Display playback options dialog if there is a saved resume position
          ' Guard: ChannelData (Phase 6) doesn't have playbackPositionTicks field
          resumeTicks = 0
          if isValid(selectedItem.playbackPositionTicks)
            resumeTicks = selectedItem.playbackPositionTicks
          end if
          if resumeTicks > 0
            dialog.close = true
            m.global.queueManager.callFunc("hold", queueItem)
            playbackOptionDialog(resumeTicks)
          else
            m.global.queueManager.callFunc("clear")
            m.global.queueManager.callFunc("push", queueItem)
            m.global.queueManager.callFunc("playQueue")
            dialog.close = true
          end if

        else if selectedItemType = "Photo"
          quickplay.photo(selectedItem)
        else if selectedItemType = "PhotoAlbum"
          print "a photo album was selected"
          print "selectedItem=", selectedItem

          ' grab all photos inside photo album
          photoAlbumData = GetApi().GetItemsByQuery({
            "parentId": selectedItem.id,
            "includeItemTypes": "Photo",
            "Recursive": true
          })
          print "photoAlbumData=", photoAlbumData

          if isValid(photoAlbumData) and isValidAndNotEmpty(photoAlbumData.items)
            photoPlayer = CreateObject("roSgNode", "PhotoDetails")
            photoPlayer.itemsArray = photoAlbumData.items
            photoPlayer.itemIndex = 0
            m.global.sceneManager.callfunc("pushScene", photoPlayer)
          end if
        else if selectedItemType = "MusicArtist"
          group = CreateArtistView(selectedItem)
          if not isValid(group)
            stopLoadingSpinner()
            message_dialog(tr("Unable to find any albums or songs belonging to this artist"))
          end if
        else if selectedItemType = "MusicAlbum"
          group = CreateAlbumView(selectedItem)
        else if selectedItemType = "MusicVideo"
          group = CreateItemDetailsGroup(selectedItem)
        else if selectedItemType = "Playlist"
          group = CreatePlaylistView(selectedItem)
        else if selectedItemType = "Audio"
          m.global.queueManager.callFunc("clear")
          m.global.queueManager.callFunc("resetShuffle")
          m.global.queueManager.callFunc("push", nodeHelpers.createQueueItem(selectedItem))
          m.global.queueManager.callFunc("playQueue")
        else
          ' TODO - switch on more node types
          stopLoadingSpinner()
          message_dialog("This type is not yet supported: " + 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, "musicAlbumSelected")
      ' If you select a Music Album from ANYWHERE, follow this flow
      startLoadingSpinner()
      ptr = msg.getData()
      albums = msg.getRoSGNode()
      node = albums.musicArtistAlbumData.items[ptr]
      group = CreateAlbumView(node)
      if not isValid(group)
        stopLoadingSpinner()
      end if
    else if isNodeEvent(msg, "appearsOnSelected")
      ' If you select a Music Album from ANYWHERE, follow this flow
      startLoadingSpinner()
      ptr = msg.getData()
      albums = msg.getRoSGNode()
      node = albums.musicArtistAppearsOnData.items[ptr]
      group = CreateAlbumView(node)
      if not isValid(group)
        stopLoadingSpinner()
      end if
    else if isNodeEvent(msg, "playSong")
      ' 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, "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, "playAllSelected")
      ' User has selected playlist of of audio they want us to play
      screenContent = msg.getRoSGNode()
      startLoadingSpinner()

      m.global.queueManager.callFunc("clear")
      m.global.queueManager.callFunc("resetShuffle")
      m.global.queueManager.callFunc("set", screenContent.albumData.items)
      m.global.queueManager.callFunc("playQueue")
    else if isNodeEvent(msg, "playArtistSelected")
      ' User has selected playlist of of audio they want us to play
      startLoadingSpinner()
      screenContent = msg.getRoSGNode()

      m.global.queueManager.callFunc("clear")
      m.global.queueManager.callFunc("resetShuffle")
      m.global.queueManager.callFunc("set", CreateArtistMix(screenContent.pageContent.id).Items)
      m.global.queueManager.callFunc("playQueue")

    else if isNodeEvent(msg, "instantMixSelected")
      ' User has selected instant mix
      ' User has selected playlist of of audio they want us to play
      screenContent = msg.getRoSGNode()
      startLoadingSpinner()

      viewHandled = false

      ' Create instant mix based on selected album
      if isValid(screenContent.albumData)
        if isValid(screenContent.albumData.items)
          if screenContent.albumData.items.count() > 0
            m.global.queueManager.callFunc("clear")
            m.global.queueManager.callFunc("resetShuffle")
            instantMixData = CreateInstantMix(screenContent.albumData.items[0].id)
            if isValid(instantMixData) and isValid(instantMixData.Items)
              m.global.queueManager.callFunc("set", instantMixData.Items)
            else
              print "Failed to create instant mix for album item: ", screenContent.albumData.items[0].id
            end if
            m.global.queueManager.callFunc("playQueue")

            viewHandled = true
          end if
        end if
      end if

      if not viewHandled
        ' Create instant mix based on selected artist
        m.global.queueManager.callFunc("clear")
        m.global.queueManager.callFunc("resetShuffle")
        instantMixData = CreateInstantMix(screenContent.pageContent.id)
        if isValid(instantMixData) and isValid(instantMixData.Items)
          m.global.queueManager.callFunc("set", instantMixData.Items)
        else
          print "Failed to create instant mix for artist: ", screenContent.pageContent.id
        end if
        m.global.queueManager.callFunc("playQueue")
      end if

    else if isNodeEvent(msg, "search_value")
      query = msg.getRoSGNode().search_value
      group.findNode("SearchBox").visible = false
      options = group.findNode("SearchSelect")
      options.visible = true
      options.setFocus(true)

      dialog = createObject("roSGNode", "ProgressDialog")
      dialog.title = tr("Loading Search Data")
      m.scene.dialog = dialog
      results = SearchMedia(query)
      dialog.close = true
      options.itemData = results
      options.query = query
    else if isNodeEvent(msg, "itemSelected")
      ' Search item selected
      startLoadingSpinner()
      node = getMsgPicker(msg)
      ' TODO - swap this based on target.mediatype
      ' types: [ Series (Show), Episode, Movie, Audio, Person, Studio, MusicArtist, Recording ]
      if node.type = "Series"
        group = CreateItemDetailsGroup(node)
      else if node.type = "Movie"
        group = CreateItemDetailsGroup(node)
      else if node.type = "MusicArtist"
        group = CreateArtistView(node)
      else if node.type = "MusicAlbum"
        group = CreateAlbumView(node)
      else if node.type = "MusicVideo"
        group = CreateItemDetailsGroup(node)
      else if node.type = "Audio"
        m.global.queueManager.callFunc("clear")
        m.global.queueManager.callFunc("resetShuffle")
        m.global.queueManager.callFunc("push", nodeHelpers.createQueueItem(node))
        m.global.queueManager.callFunc("playQueue")
      else if node.type = "Person"
        group = CreateItemDetailsGroup(node)
      else if node.type = "BoxSet"
        group = CreateItemDetailsGroup(node)
      else if node.type = "TvChannel"
        m.global.queueManager.callFunc("clear")
        m.global.queueManager.callFunc("push", nodeHelpers.createQueueItem(node))
        m.global.queueManager.callFunc("playQueue")
      else if node.type = "Episode"
        group = CreateItemDetailsGroup(node)
      else if LCase(node.type) = "recording"
        group = CreateItemDetailsGroup(node)
      else if node.type = "Audio"
        selectedIndex = msg.getData()
        screenContent = msg.getRoSGNode()
        m.global.queueManager.callFunc("clear")
        m.global.queueManager.callFunc("resetShuffle")
        m.global.queueManager.callFunc("push", nodeHelpers.createQueueItem(screenContent.albumData.items[node.id]))
        m.global.queueManager.callFunc("playQueue")
      else
        ' TODO - switch on more node types
        stopLoadingSpinner()
        message_dialog("This type is not yet supported: " + node.type + ".")
      end if
    else if isNodeEvent(msg, "buttonSelected")
      print "buttonSelected event received"
      print "msg=", msg
      ' If a button is selected, we have some determining to do
      btn = getButton(msg)
      print "btn=", btn
      if not isValid(btn)
        print "No button found in message"
        return
      end if
      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
        audio_stream_idx = 0
        if isValid(group.selectedAudioStreamIndex)
          audio_stream_idx = group.selectedAudioStreamIndex
        end if

        if isValid(group.itemContent)
          ' Series on ItemDetails: "Play All" queues all episodes from the beginning
          if group.subtype() = "ItemDetails" and group.itemContent.type = "Series"
            allEpData = GetApi().GetEpisodes(group.itemContent.id, {
              SortBy: "IndexNumber,SortName",
              SortOrder: "Ascending"
            })
            if isValid(allEpData) and isValid(allEpData.Items) and allEpData.Items.count() > 0
              ' Exclude Season 0 (Specials) from Play All
              regularEps = []
              for each ep in allEpData.Items
                if isValid(ep.ParentIndexNumber) and ep.ParentIndexNumber > 0
                  regularEps.push(ep)
                end if
              end for
              if regularEps.count() > 0
                m.global.queueManager.callFunc("clear")
                m.global.queueManager.callFunc("set", regularEps)
                m.global.queueManager.callFunc("playQueue")
              else
                stopLoadingSpinner()
              end if
            else
              stopLoadingSpinner()
            end if
          else if group.subtype() = "ItemDetails" and group.itemContent.type = "Season"
            ' Season "Play All": queue all episodes for this season in order
            item = group.itemContent
            allEpData = GetApi().GetEpisodes(item.seriesId, {
              SeasonId: item.id,
              SortBy: "IndexNumber,SortName",
              SortOrder: "Ascending"
            })
            if isValid(allEpData) and isValid(allEpData.Items) and allEpData.Items.count() > 0
              m.global.queueManager.callFunc("clear")
              m.global.queueManager.callFunc("set", allEpData.Items)
              m.global.queueManager.callFunc("playQueue")
            else
              stopLoadingSpinner()
            end if
          else if group.subtype() = "ItemDetails" and group.itemContent.type = "BoxSet"
            ' BoxSet "Play All": queue all movies in collection in alphabetical order
            item = group.itemContent
            boxsetData = GetApi().GetItemsByQuery({
              "ParentId": item.id,
              "SortBy": "SortName",
              "SortOrder": "Ascending",
              "EnableTotalRecordCount": false
            })
            if isValid(boxsetData) and isValid(boxsetData.Items) and boxsetData.Items.count() > 0
              m.global.queueManager.callFunc("clear")
              m.global.queueManager.callFunc("set", boxsetData.Items)
              m.global.queueManager.callFunc("playQueue")
            else
              stopLoadingSpinner()
            end if
          else
            queueItem = nodeHelpers.createQueueItem(group.itemContent)
            if not isValid(queueItem) then return
            queueItem.selectedAudioStreamIndex = audio_stream_idx
            ' selectedVideoStreamId is the user-chosen media source (e.g. alternate version)
            if isValidAndNotEmpty(group.selectedVideoStreamId)
              queueItem.id = 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 = "main_group"
          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
        audio_stream_idx = 0
        if isValid(group.selectedAudioStreamIndex)
          audio_stream_idx = group.selectedAudioStreamIndex
        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 = audio_stream_idx
            ' selectedVideoStreamId is the user-chosen media source (e.g. alternate version)
            if isValidAndNotEmpty(group.selectedVideoStreamId)
              queueItem.id = 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 = "main_group"
          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"
        print "trailerButton pressed"
        ' User chose to play a trailer from the movie detail view
        startLoadingSpinner()
        dialog = createObject("roSGNode", "ProgressDialog")
        dialog.title = tr("Loading trailer")
        m.scene.dialog = dialog

        trailerData = GetApi().GetLocalTrailers(group.id)

        if isValid(trailerData) and isValid(trailerData[0]) and isValid(trailerData[0].id)
          m.global.queueManager.callFunc("clear")
          m.global.queueManager.callFunc("set", trailerData)
          m.global.queueManager.callFunc("playQueue")
          dialog.close = true
        else
          stopLoadingSpinner()
        end if

        if isValid(group) and isValid(group.lastFocus)
          group.lastFocus.setFocus(true)
        end if
      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"
            m.pendingWatchedItemId = item.id
            m.pendingWatchedIsCurrentlyWatched = item.isWatched
            if item.isWatched
              confirmMsg = tr("Mark all episodes in this series as unwatched?")
              confirmBtn = tr("Mark Unwatched")
            else
              confirmMsg = tr("Mark all episodes in this series as watched?")
              confirmBtn = tr("Mark Watched")
            end if
            m.global.sceneManager.callFunc("optionDialog", tr("Watched"), [confirmMsg], [tr("Cancel"), confirmBtn])
          else
            startLoadingSpinner()
            if item.isWatched
              GetApi().UnmarkPlayed(item.id)
            else
              GetApi().MarkPlayed(item.id)
            end if
            ' Refresh from server to get authoritative state
            group.refreshItemDetailsData = not group.refreshItemDetailsData
          end if
        end if
      else if btn.id = "favoriteButton"
        print "favoriteButton pressed"
        movie = group.itemContent
        favoriteButton = group.findNode("favoriteButton")
        if isValid(movie) and isValidAndNotEmpty(movie.id) and isValid(favoriteButton)
          startLoadingSpinner()
          if movie.isFavorite
            GetApi().UnmarkFavorite(movie.id)
            movie.isFavorite = false
            favoriteButton.selected = false
          else
            GetApi().MarkFavorite(movie.id)
            movie.isFavorite = true
            favoriteButton.selected = true
          end if
          stopLoadingSpinner()
        end if
      else if btn.id = "refreshButton"
        ' Trigger data refresh for the current screen
        if isValid(group)
          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.
        ' API calls run off the render thread via GetShuffleItemsTask; result handled by "shuffleItems" event.
        if isValid(group) and isValid(group.itemContent) and isValidAndNotEmpty(group.itemContent.id)
          startLoadingSpinner()
          item = group.itemContent
          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
      else if btn.id = "goToSeriesButton"
        ' Navigate to the parent series detail screen
        if isValid(group) and isValid(group.itemContent) and isValidAndNotEmpty(group.itemContent.seriesId)
          seriesRef = CreateObject("roSGNode", "ContentNode")
          seriesRef.id = group.itemContent.seriesId
          seriesRef.type = "Series"
          CreateItemDetailsGroup(seriesRef)
        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("optionDialog", tr("Delete"), [tr("Are you sure you want to delete this item? This action cannot be undone.")], [tr("Cancel"), tr("Delete")])
        end if
      else if btn.id = "playAll"
        print "playAll button pressed"
        ' User has selected playlist of of audio they want us to play
        startLoadingSpinner()
        node = msg.getRoSGNode()

        if isValid(group) and isValid(group.albumData) and isValid(group.albumData.items)
          m.global.queueManager.callFunc("clear")
          m.global.queueManager.callFunc("resetShuffle")
          m.global.queueManager.callFunc("set", group.albumData.items)
          m.global.queueManager.callFunc("playQueue")
        else
          stopLoadingSpinner()
        end if
      else if btn.id = "instantMix"
        print "instantMix button pressed"
        ' User has selected instant mix
        ' User has selected playlist of of audio they want us to play
        startLoadingSpinner()
        node = msg.getRoSGNode()
        viewHandled = false

        ' Create instant mix based on selected album
        if isValid(group) and isValid(group.albumData)
          if isValid(group.albumData.items)
            if group.albumData.items.count() > 0
              m.global.queueManager.callFunc("clear")
              m.global.queueManager.callFunc("resetShuffle")
              instantMixData = CreateInstantMix(group.albumData.items[0].id)
              if isValid(instantMixData) and isValid(instantMixData.Items)
                m.global.queueManager.callFunc("set", instantMixData.Items)
              else
                print "Failed to create instant mix for album item: ", group.albumData.items[0].id
              end if
              m.global.queueManager.callFunc("playQueue")

              viewHandled = true
            end if
          end if
        end if

        if not viewHandled
          ' Create instant mix based on selected artist - fallback to pageContent
          parentWithPageContent = findParentWithField(node, "pageContent")
          if isValid(parentWithPageContent) and isValid(parentWithPageContent.pageContent) and isValid(parentWithPageContent.pageContent.id)
            m.global.queueManager.callFunc("clear")
            m.global.queueManager.callFunc("resetShuffle")
            instantMixData = CreateInstantMix(parentWithPageContent.pageContent.id)
            if isValid(instantMixData) and isValid(instantMixData.Items)
              m.global.queueManager.callFunc("set", instantMixData.Items)
            else
              print "Failed to create instant mix for artist: ", parentWithPageContent.pageContent.id
            end if
            m.global.queueManager.callFunc("playQueue")
          else
            stopLoadingSpinner()
          end if
        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
    else if isNodeEvent(msg, "optionSelected")
      button = msg.getRoSGNode()
      group = m.global.sceneManager.callFunc("getActiveScene")
      if button.id = "goto_search" and isValid(group)
        ' Exit out of the side panel
        panel = group.findNode("options")
        panel.visible = false
        if isValid(group.lastFocus)
          group.lastFocus.setFocus(true)
        else
          group.setFocus(true)
        end if
        group = CreateSearchPage()
        m.global.sceneManager.callFunc("pushScene", group)
        group.findNode("SearchBox").findNode("search_Key").setFocus(true)
        group.findNode("SearchBox").findNode("search_Key").active = true
      else if button.id = "change_server"
        startLoadingSpinner()
        unset_setting("server")
        server.Delete()
        SignOut(false)
        m.global.sceneManager.callFunc("clearScenes")
        goto app_start
      else if button.id = "change_user"
        startLoadingSpinner()
        SignOut(false)
        m.global.sceneManager.callFunc("clearScenes")
        goto app_start
      else if button.id = "sign_out"
        startLoadingSpinner()
        SignOut()
        m.global.sceneManager.callFunc("clearScenes")
        goto app_start
      else if button.id = "settings"
        ' Exit out of the side panel
        panel = group.findNode("options")
        panel.visible = false
        if isValid(group) and isValid(group.lastFocus)
          group.lastFocus.setFocus(true)
        else
          group.setFocus(true)
        end if
        m.global.sceneManager.callFunc("settings")
      end if
    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

        postTask = createObject("roSGNode", "PostTask")
        postTask.arrayData = getDeviceCapabilities()
        postTask.apiUrl = "/Sessions/Capabilities/Full"
        postTask.control = "RUN"
      else if isValid(event.videoCodecCapabilityChanged)
        ' The video codec capability has changed if true.
        print "event.videoCodecCapabilityChanged = ", event.videoCodecCapabilityChanged

        postTask = createObject("roSGNode", "PostTask")
        postTask.arrayData = getDeviceCapabilities()
        postTask.apiUrl = "/Sessions/Capabilities/Full"
        postTask.control = "RUN"
      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")
          m.global.queueManager.callFunc("clear")
          m.global.queueManager.callFunc("push", nodeHelpers.createQueueItem({ id: info.contentId, type: "video" }))
          m.global.queueManager.callFunc("playQueue")
        end if
      end if
    else if isNodeEvent(msg, "dataReturned")
      popupNode = msg.getRoSGNode()
      stopLoadingSpinner()
      if isValid(popupNode) and isValid(popupNode.returnData)
        print "popupNode.returnData = ", popupNode.returnData
        ' Handle exit confirmation dialog
        if m.global.sceneManager.pendingExitConfirmation = true
          m.global.sceneManager.pendingExitConfirmation = false
          if popupNode.returnData.indexSelected = 1 and popupNode.returnData.buttonSelected = tr("Exit")
            ' 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 = tr("Delete")
            ' User confirmed deletion
            GetApi().DeleteItem(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
            startLoadingSpinner()
            if m.pendingWatchedIsCurrentlyWatched
              GetApi().UnmarkPlayed(m.pendingWatchedItemId)
            else
              GetApi().MarkPlayed(m.pendingWatchedItemId)
            end if
            m.pendingWatchedItemId = invalid
            m.pendingWatchedIsCurrentlyWatched = invalid
            activeGroup = m.global.sceneManager.callFunc("getActiveScene")
            if isValid(activeGroup)
              activeGroup.refreshItemDetailsData = not activeGroup.refreshItemDetailsData
            else
              stopLoadingSpinner()
            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("fontDownloadCompleted", 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, tr("Downloading Fallback Font"))
    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 fontDownloadCompleted
  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)
      set_user_setting("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.fontDownloadSuccess
    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("fontDownloadCompleted")
  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