source_showScenes.bs

import "pkg:/source/api/ApiClient.bs"

function LoginFlow()
  'Collect Jellyfin server and user information
  startLogin:

  serverUrl = getSetting("server")
  if isValid(serverUrl)
    print "Previous server connection saved to registry"
    ' Pass originalUrl to preserve user's input for re-discovery on each connection
    startOver = not server.UpdateURL(serverUrl, serverUrl)
    if startOver
      print "Could not connect to previously saved server."
    end if
  else
    startOver = true
    print "No previous server connection saved to registry"
  end if

  invalidServer = true
  if not startOver
    m.scene.isLoading = true
    invalidServer = ServerInfo().Error
    m.scene.isLoading = false
  end if

  m.serverSelection = "Saved"
  ' Always ensure server select is on the stack before user select (for back button)
  if startOver or invalidServer
    ' Need to show server select interactively
    print "Get server details"
    SendPerformanceBeacon("AppDialogInitiate") ' Roku Performance monitoring - Dialog Starting
    m.serverSelection = CreateServerGroup()
    SendPerformanceBeacon("AppDialogComplete") ' Roku Performance monitoring - Dialog Closed
    if m.serverSelection = "backPressed"
      print "backPressed"
      m.global.sceneManager.callFunc("clearScenes")
      return false
    end if
    SaveServerList()
  else
    ' Server is valid - push placeholder to maintain consistent stack depth
    ' This ensures the scene stack has the same depth regardless of whether
    ' server selection UI was shown, preventing stack corruption on cleanup
    ' Using Group because it has a visible field (ContentNode does not)
    print "Server valid, pushing placeholder to stack"
    placeholderNode = CreateObject("roSGNode", "Group")
    placeholderNode.visible = false
    m.global.sceneManager.callFunc("pushScene", placeholderNode)
  end if

  localUser = m.global.user

  activeUser = getSetting("active_user")
  if not isValid(activeUser)
    print "No active user found in registry"
    userSelect:
    SendPerformanceBeacon("AppDialogInitiate") ' Roku Performance monitoring - Dialog Starting

    publicUsers = GetPublicUsers()
    numPubUsers = 0
    if isValid(publicUsers) then numPubUsers = publicUsers.count()

    savedUsers = getSavedUsers()
    numSavedUsers = savedUsers.count()

    if numPubUsers > 0 or numSavedUsers > 0
      publicUsersNodes = []
      publicUserIds = []
      ' load public users
      if numPubUsers > 0
        for each item in publicUsers
          userData = CreateObject("roSGNode", "PublicUserData")
          userData.id = item.Id
          userData.name = item.Name
          if isValidAndNotEmpty(item.PrimaryImageTag)
            userData.ImageURL = UserImageURL(userData.id, { "tag": item.PrimaryImageTag })
          end if
          publicUsersNodes.push(userData)
          publicUserIds.push(userData.id)
        end for
      end if
      ' load saved users for this server id
      if numSavedUsers > 0
        for each savedUser in savedUsers
          if isValid(savedUser.serverId) and savedUser.serverId = m.global.server.id
            ' only show unique userids on screen.
            if not arrayHasValue(publicUserIds, savedUser.Id)
              userData = CreateObject("roSGNode", "PublicUserData")
              userData.id = savedUser.Id

              if isValid(savedUser.username)
                userData.name = savedUser.username
              end if

              if isValidAndNotEmpty(savedUser.primaryImageTag)
                userData.ImageURL = UserImageURL(userData.id, { "tag": savedUser.primaryImageTag })
              end if

              publicUsersNodes.push(userData)
            end if
          end if
        end for
      end if
      ' push all users to the user select view
      userSelected = CreateUserSelectGroup(publicUsersNodes)
      SendPerformanceBeacon("AppDialogComplete") ' Roku Performance monitoring - Dialog Closed
      if userSelected = "quickConnect"
        ' Quick Connect completed the sign-in in-flow. user state (id, authToken,
        ' and optionally registry creds based on the post-auth prompt) is already
        ' populated on m.global.user, so skip the token/password ladder entirely.
        m.global.sceneManager.callFunc("clearScenes")
        return true
      else if userSelected = "backPressed"
        ' User wants to change server - clear all scenes and restart
        server.Delete()
        unsetSetting("server")
        m.global.sceneManager.callFunc("clearScenes")
        goto startLogin
      else if userSelected <> ""
        startLoadingSpinner()
        print "A public user was selected with username=" + userSelected
        localUser.name = userSelected

        ' save userid to session
        for each userData in publicUsersNodes
          if userData.name = userSelected
            localUser.id = userData.id
            exit for
          end if
        end for
        ' try to login with token from registry
        myToken = getUserSetting("authToken")
        if isValid(myToken)
          ' check if token is valid
          print "Auth token found in registry for selected user"
          localUser.authToken = myToken
          print "Attempting to use API with auth token"
          currentUser = AboutMe()
          if not isValid(currentUser)
            print "Auth token is no longer valid - deleting token"
            unsetUserSetting("authToken")
            unsetUserSetting("username")
            unsetUserSetting("primaryImageTag")
          else
            print "Success! Auth token is still valid"
            user.Login(currentUser, true)
            return true
          end if
        else
          print "No auth token found in registry for selected user"
        end if
        'Try to login without password. If the token is valid, we're done
        print "Attempting to login with no password"
        userData = getToken(userSelected, "")
        if isValid(userData)
          print "login success!"
          user.Login(userData, true)
          return true
        else
          print "Auth failed. Password required"
        end if
      end if
    else
      userSelected = ""
    end if
    stopLoadingSpinner()
    passwordEntry = CreateSigninGroup(userSelected)
    SendPerformanceBeacon("AppDialogComplete") ' Roku Performance monitoring - Dialog Closed
    if passwordEntry = "backPressed"
      if numPubUsers > 0
        goto userSelect
      else
        server.Delete()
        unsetSetting("server")
        goto startLogin
      end if
    end if
  else
    print "Active user found in registry"
    localUser.id = activeUser

    myUsername = getUserSetting("username")
    myAuthToken = getUserSetting("authToken")
    myPrimaryImageTag = getUserSetting("primaryImageTag")
    if isValid(myAuthToken) and isValid(myUsername)
      print "Auth token found in registry"
      localUser.authToken = myAuthToken
      localUser.name = myUsername
      if isValidAndNotEmpty(myPrimaryImageTag)
        localUser.primaryImageTag = myPrimaryImageTag
      end if
      print "Attempting to use API with auth token"
      currentUser = AboutMe()
      if not isValid(currentUser)
        print "Auth token is no longer valid"
        'Try to login without password. If the token is valid, we're done
        print "Attempting to login with no password"
        userData = getToken(myUsername, "")
        if isValid(userData)
          print "login success!"
          user.Login(userData, true)
          return true
        else
          print "Auth failed. Password required"
          print "delete token and restart login flow"
          unsetUserSetting("authToken")
          unsetUserSetting("username")
          if isValid(myPrimaryImageTag)
            unsetUserSetting("primaryImageTag")
          end if
          goto startLogin
        end if
      else
        print "Success! Auth token is still valid"
        user.Login(currentUser, true)
      end if
    else
      print "No auth token found in registry"
    end if
  end if

  if not isValid(localUser.id) or not isValid(localUser.authToken)
    print "Login failed, restart flow"
    unsetSetting("active_user")
    user.Logout()
    goto startLogin
  end if

  m.global.sceneManager.callFunc("clearScenes")

  return true
end function

sub SaveServerList()
  ' Save this server to the list of previously-used servers shown in the server picker.
  ' baseUrl: canonical URL (lowercase) — used for deduplication against SSDP-discovered servers.
  ' originalUrl: user-entered URL from registry — shown in the picker and pre-filled on re-selection,
  '              so inferServerUrl() can re-discover the correct protocol on each connection.
  ' id: Jellyfin server ID — primary deduplication key, robust to URL changes (e.g. HTTP→HTTPS).
  globalServer = m.global.server
  serverUrl = globalServer.serverUrl
  serverId = globalServer.id
  serverName = globalServer.name
  originalUrl = getSetting("server") ' set correctly by server.UpdateURL() before this is called

  if isValid(serverUrl)
    serverUrl = LCase(serverUrl) ' canonical URL always lowercase for comparison
  end if
  if not isValidAndNotEmpty(originalUrl)
    originalUrl = serverUrl ' fallback: use canonical if original is somehow missing
  end if

  savedServers = { serverList: [] }
  saved = getSetting("saved_servers")
  if isValid(saved)
    parsed = ParseJson(saved)
    if isValid(parsed) and isValid(parsed.serverList)
      savedServers = parsed
    end if
  end if

  ' Check for an existing entry (ID-based first; URL fallback for old entries without id)
  for i = 0 to savedServers.serverList.Count() - 1
    item = savedServers.serverList[i]
    isMatch = false
    if isValidAndNotEmpty(serverId) and isValidAndNotEmpty(item.id)
      isMatch = (item.id = serverId)
    else if LCase(item.baseUrl) = serverUrl
      isMatch = true
    end if

    if isMatch
      ' Update in-place: refresh mutable server identity fields (name, id, baseUrl, originalUrl).
      ' iconUrl/iconWidth/iconHeight are static app branding defaults and are not changed.
      savedServers.serverList[i].name = serverName
      savedServers.serverList[i].id = serverId
      savedServers.serverList[i].baseUrl = serverUrl ' keep canonical URL current (e.g. HTTP→HTTPS)
      savedServers.serverList[i].originalUrl = originalUrl
      setSetting("saved_servers", FormatJson(savedServers))
      return
    end if
  end for

  ' No existing entry found — append a new one
  savedServers.serverList.Push({
    name: serverName,
    id: serverId,
    baseUrl: serverUrl,
    originalUrl: originalUrl,
    iconUrl: "pkg:/images/branding/logo-icon120.jpg",
    iconWidth: 120,
    iconHeight: 120
  })
  setSetting("saved_servers", FormatJson(savedServers))
end sub

sub DeleteFromServerList(idOrUrl as string)
  ' idOrUrl should be the server's id when available (passed from itemToDelete.id).
  ' Falls back to a canonical baseUrl for legacy entries that predate the id field.
  ' ID match is tried first so deletion is correct even when the saved entry's baseUrl
  ' differs from the picker item's baseUrl (e.g. a saved HTTPS entry matched via SSDP
  ' on HTTP — the picker item carries the SSDP baseUrl, not the saved one).
  saved = getSetting("saved_servers")
  if not isValid(saved) then return

  savedServers = ParseJson(saved)
  newServers = { serverList: [] }
  normalizedInput = LCase(idOrUrl) ' for URL fallback comparison (baseUrls are always lowercase)
  for each item in savedServers.serverList
    keepEntry = true
    if isValidAndNotEmpty(item.id) and item.id = idOrUrl
      keepEntry = false ' ID match — remove this entry
    else if item.baseUrl = normalizedInput
      keepEntry = false ' URL fallback — remove this entry
    end if
    if keepEntry
      newServers.serverList.Push(item)
    end if
  end for
  setSetting("saved_servers", FormatJson(newServers))
end sub

' Roku Performance monitoring
sub SendPerformanceBeacon(signalName as string)
  if m.global.appLoaded = false
    m.scene.signalBeacon(signalName)
  end if
end sub

function CreateServerGroup()
  globalServer = m.global.server
  ' Capture the URL before any updates to detect changes during discovery/redirects
  previousServerUrl = globalServer.serverUrl
  screen = CreateObject("roSGNode", "SetServerScreen")
  screen.isOptionsAvailable = true
  m.global.sceneManager.callFunc("pushScene", screen)
  port = CreateObject("roMessagePort")

  if isValid(globalServer.serverUrl)
    screen.serverUrl = globalServer.serverUrl
  end if

  buttons = screen.findNode("buttons")
  buttons.observeField("buttonSelected", port)
  'create delete saved server option
  newOptions = []
  sidepanel = screen.findNode("options")
  opt = CreateObject("roSGNode", "OptionsButton")
  opt.title = translate(translationKeys.LabelDeleteSaved)
  opt.id = ServerAction.DELETE_SAVED
  opt.observeField("optionSelected", port)
  newOptions.push(opt)
  sidepanel.options = newOptions
  sidepanel.observeField("closeSidePanel", port)

  screen.observeField("backPressed", port)

  while true
    msg = wait(0, port)
    print type(msg), msg
    if type(msg) = "roSGScreenEvent" and msg.isScreenClosed()
      return "false"
    else if isNodeEvent(msg, "backPressed")
      return "backPressed"
    else if isNodeEvent(msg, "closeSidePanel")
      screen.setFocus(true)
      serverPicker = screen.findNode("serverPicker")
      serverPicker.setFocus(true)
    else if type(msg) = "roSGNodeEvent"
      nodeName = msg.getNode()

      ' print "roSGNodeEvent: msg.getNode() =", msg.getNode()
      ' print "roSGNodeEvent: msg.getData() =", msg.getData()
      ' print "roSGNodeEvent: msg.getField() =", msg.getField()
      ' print "roSGNodeEvent: msg.getRoSGNode() =", msg.getRoSGNode()
      ' print "roSGNodeEvent: msg.getInfo() =", msg.getInfo()
      if nodeName = "buttons"
        buttonIndex = msg.getData()
        buttonGroup = msg.getRoSGNode()
        buttonSelected = buttonGroup.getChild(buttonIndex)

        if buttonSelected.id = "submit"
          m.scene.isLoading = true

          originalUrl = screen.serverUrl
          serverUrl = inferServerUrl(originalUrl)

          ' Pass originalUrl so it gets persisted (not the canonical redirect URL)
          ' This allows re-discovery on each connection for multi-network access
          isConnected = server.UpdateURL(serverUrl, originalUrl)
          serverInfoResult = invalid
          if isConnected
            serverInfoResult = ServerInfo()
            'If this is a different server from what we know, reset username/password setting
            ' Compare using canonical URL from global state against pre-connection URL
            canonicalUrl = m.global.server.serverUrl
            if previousServerUrl <> canonicalUrl
              setSetting("username", "")
              setSetting("password", "")
            end if
          end if
          m.scene.isLoading = false

          if isConnected = false or not isValid(serverInfoResult) or (isValid(serverInfoResult.Error) and serverInfoResult.Error)
            ' Maybe don't unset setting, but offer as a prompt
            ' Server not found, is it online? New values / Retry
            print "Server not found, is it online? New values / Retry"
            screen.errorMessage = translate(translationKeys.MessageServerNotFoundIsItOnline)
            SignOut(false)
          else
            screen.visible = false
            if isValid(serverInfoResult.serverName)
              return serverInfoResult.ServerName + " (Saved)"
            else
              return "Saved"
            end if
          end if
        end if
      else if nodeName = ServerAction.DELETE_SAVED
        serverPicker = screen.findNode("serverPicker")
        itemToDelete = serverPicker.content.getChild(serverPicker.itemFocused)
        ' Prefer id for deletion — robust when the picker item's baseUrl differs from
        ' the saved entry's baseUrl (e.g. SSDP HTTP item merged from an HTTPS saved entry)
        idOrUrl = itemToDelete.id
        if not isValidAndNotEmpty(idOrUrl)
          idOrUrl = itemToDelete.baseUrl
        end if
        if isValidAndNotEmpty(idOrUrl)
          DeleteFromServerList(idOrUrl)
          serverPicker.content.removeChild(itemToDelete)
          sidepanel.visible = false
          serverPicker.setFocus(true)
        end if
      end if
    end if
  end while

  ' Just hide it when done, in case we need to come back
  screen.visible = false
  return ""
end function

function CreateUserSelectGroup(users = [])
  if users.count() = 0
    return ""
  end if
  group = CreateObject("roSGNode", "UserSelect")
  m.global.sceneManager.callFunc("pushScene", group)
  port = CreateObject("roMessagePort")

  group.itemContent = users
  group.findNode("userRow").observeField("userSelected", port)
  group.findNode("buttons").observeField("buttonSelected", port)
  group.observeField("backPressed", port)
  while true
    msg = wait(0, port)
    if type(msg) = "roSGScreenEvent" and msg.isScreenClosed()
      group.visible = false
      return -1
    else if isNodeEvent(msg, "backPressed")
      group.visible = false
      return "backPressed"
    else if type(msg) = "roSGNodeEvent" and msg.getField() = "userSelected"
      return msg.GetData()
    else if type(msg) = "roSGNodeEvent" and msg.getField() = "buttonSelected"
      buttonGroup = msg.getRoSGNode()
      buttonSelected = buttonGroup.getChild(msg.getData())
      if buttonSelected.id = "manualLoginButton"
        return ""
      else if buttonSelected.id = "quickConnect"
        json = initQuickConnect()
        if not isValid(json)
          m.global.sceneManager.callFunc("userMessage", translate(translationKeys.ButtonQuickConnect), translate(translationKeys.LabelQuickConnectNotAvailable))
        else
          ' Server supports Quick Connect - show the code, then the dialog transforms
          ' into a "Save credentials?" prompt on auth success.
          m.quickConnectDialog = createObject("roSGNode", "QuickConnectDialog")
          m.quickConnectDialog.quickConnectJson = json
          m.quickConnectDialog.title = translate(translationKeys.ButtonQuickConnect)
          m.quickConnectDialog.message = [translate(translationKeys.MessageHereIsYourQuickConnectCode, [json.Code])]
          m.quickConnectDialog.buttons = [translate(translationKeys.ButtonCancel)]
          m.quickConnectDialog.observeField("isAuthenticated", port)
          m.scene.dialog = m.quickConnectDialog
        end if
      end if
    else if type(msg) = "roSGNodeEvent" and msg.getField() = "isAuthenticated"
      ' QuickConnectDialog only ever fires this with value=true (after the
      ' user has answered the post-auth save-credentials prompt).
      return "quickConnect"
    end if
  end while

  ' Just hide it when done, in case we need to come back
  group.visible = false
  return ""
end function

function CreateSigninGroup(userName = "")
  ' Get and Save Jellyfin user login credentials
  group = CreateObject("roSGNode", "LoginScene")
  m.global.sceneManager.callFunc("pushScene", group)
  port = CreateObject("roMessagePort")

  group.findNode("prompt").text = translate(translationKeys.ButtonSignIn)

  config = group.findNode("configOptions")
  usernameField = CreateObject("roSGNode", "ConfigData")
  usernameField.label = translate(translationKeys.LabelUsername)
  usernameField.field = "username"
  usernameField.type = "string"
  if userName = "" and isValid(getSetting("username"))
    usernameField.value = getSetting("username")
  else
    usernameField.value = userName
  end if
  passwordField = CreateObject("roSGNode", "ConfigData")
  passwordField.label = translate(translationKeys.LabelPassword)
  passwordField.field = "password"
  passwordField.type = "password"
  registryPassword = getSetting("password")
  if isValid(registryPassword)
    passwordField.value = registryPassword
  end if
  ' Add checkbox for saving credentials
  checkbox = group.findNode("onOff")
  items = CreateObject("roSGNode", "ContentNode")
  saveCheckBox = CreateObject("roSGNode", "ContentNode")
  saveCheckBox.title = translate(translationKeys.MessageSaveCredentials)
  items.appendChild(saveCheckBox)
  checkbox.content = items

  items = [usernameField, passwordField]
  config.configItems = items

  buttons = group.findNode("buttons")
  buttons.observeField("buttonSelected", port)

  config = group.findNode("configOptions")

  userName = config.content.getChild(0)
  password = config.content.getChild(1)

  group.observeField("backPressed", port)

  while true
    msg = wait(0, port)
    ' print "roSGNodeEvent: msg.getNode() =", msg.getNode()
    ' print "roSGNodeEvent: msg.getData() =", msg.getData()
    ' print "roSGNodeEvent: msg.getField() =", msg.getField()
    ' print "roSGNodeEvent: msg.getRoSGNode() =", msg.getRoSGNode()
    ' print "roSGNodeEvent: msg.getInfo() =", msg.getInfo()
    if type(msg) = "roSGScreenEvent" and msg.isScreenClosed()
      group.visible = false
      return "false"
    else if isNodeEvent(msg, "backPressed")
      group.unobserveField("backPressed")
      group.backPressed = false
      return "backPressed"
    else if type(msg) = "roSGNodeEvent"
      nodeName = msg.getNode()
      if nodeName = "buttons"
        buttonIndex = msg.getData()
        buttonGroup = msg.getRoSGNode()
        buttonSelected = buttonGroup.getChild(buttonIndex)

        if buttonSelected.id = "submit"
          startLoadingSpinner()
          ' Validate credentials
          activeUser = getToken(userName.value, password.value)
          if isValid(activeUser)
            print "activeUser=", activeUser
            if checkbox.checkedState[0] = true
              ' save credentials
              user.Login(activeUser, true)
              setUserSetting("authToken", activeUser.token)
              setUserSetting("username", userName.value)
              if isValidAndNotEmpty(activeUser.json.PrimaryImageTag)
                setUserSetting("primaryImageTag", activeUser.json.PrimaryImageTag)
              end if
            else
              user.Login(activeUser)
            end if
            return "true"
          end if
          stopLoadingSpinner()
          print "Login attempt failed..."
          group.findNode("alert").text = translate(translationKeys.ErrorLoginAttemptFailed)
        end if
      end if
    end if
  end while

  ' Just hide it when done, in case we need to come back
  group.visible = false
  return ""
end function

function CreateHomeGroup()
  ' Main screen after logging in. Shows the user's libraries
  group = CreateObject("roSGNode", "Home")
  group.overhangTitle = translate(translationKeys.LabelHome)
  group.isOptionsAvailable = true

  group.observeField("selectedItem", m.port)
  group.observeField("quickPlayNode", m.port)
  group.observeField("voiceQuery", m.port)
  group.observeField("userMenuAction", m.port)

  sidepanel = group.findNode("options")
  sidepanel.observeField("closeSidePanel", m.port)
  newOptions = []

  o = CreateObject("roSGNode", "OptionsButton")
  o.title = translate(translationKeys.ButtonSearch)
  o.id = HomeAction.OPEN_SEARCH
  o.observeField("optionSelected", m.port)
  newOptions.push(o)
  o = invalid

  ' Add settings option to menu
  o = CreateObject("roSGNode", "OptionsButton")
  o.title = translate(translationKeys.LabelSettings)
  o.id = HomeAction.OPEN_SETTINGS
  o.observeField("optionSelected", m.port)
  newOptions.push(o)
  o = invalid

  o = CreateObject("roSGNode", "OptionsButton")
  o.title = translate(translationKeys.LabelChangeUser)
  o.id = HomeAction.CHANGE_USER
  o.observeField("optionSelected", m.port)
  newOptions.push(o)
  o = invalid

  o = CreateObject("roSGNode", "OptionsButton")
  o.title = translate(translationKeys.LabelChangeServer)
  o.id = HomeAction.CHANGE_SERVER
  o.observeField("optionSelected", m.port)
  newOptions.push(o)
  o = invalid

  o = CreateObject("roSGNode", "OptionsButton")
  o.title = translate(translationKeys.ButtonSignOut)
  o.id = HomeAction.SIGN_OUT
  o.observeField("optionSelected", m.port)
  newOptions.push(o)

  sidepanel.options = newOptions

  return group
end function

function CreateItemDetailsGroup(item as object) as dynamic
  if not isValid(item) or not isValid(item.id) then return invalid

  startLoadingSpinner()

  group = CreateObject("roSGNode", "ItemDetails")
  group.observeField("quickPlayNode", m.port)
  group.overhangTitle = ""
  group.isOptionsAvailable = false

  ' Push scene asap to prevent extra button presses while fetching data
  m.global.sceneManager.callFunc("pushScene", group)

  ' Tell the component to load itself (triggers onItemIdChanged)
  group.itemType = isValid(item.type) ? item.type : ""
  group.itemId = item.id

  ' Watch for button presses
  buttons = group.findNode("buttons")
  buttons.observeField("buttonSelected", m.port)

  ' Set up extras observer
  extras = group.findNode("extrasGrid")
  extras.observeField("selectedItem", m.port)

  return group
end function

function CreateSearchPage()
  ' Search + Results Page
  group = CreateObject("roSGNode", "SearchResults")
  group.observeField("quickPlayNode", m.port)
  options = group.findNode("searchSelect")
  options.observeField("itemSelected", m.port)

  return group
end function

'Opens dialog asking user if they want to resume video or start playback over only on the home screen
sub playbackOptionDialog(time as longinteger)

  resumeData = [
    translate(translationKeys.MessageResumePlayingAt, [ticksToHuman(time)]),
    translate(translationKeys.LabelStartOverFromTheBeginning)
  ]

  stopLoadingSpinner()
  m.global.sceneManager.callFunc("showConfirmationDialog", translate(translationKeys.LabelPlaybackOptions), [], resumeData)
end sub