components_login_UserSelect.bs

import "pkg:/source/api/ApiClient.bs"
import "pkg:/source/api/apiPromise.bs"
import "pkg:/source/api/baseRequest.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/translate.bs"

sub init()
  m.log = new log.Logger("UserSelect")
  m.log.verbose("Initializing UserSelect component")

  m.top.isOptionsAvailable = false
  ' Overhang config — declarative field projected onto the overhang by JRScene (registerOverhangData).
  m.top.isLogoVisible = true
  m.top.findNode("selectUserLabel").text = translate(translationKeys.LabelSelectAUser)
  m.top.findNode("manualLoginButton").text = translate(translationKeys.ButtonManualLogin)
  m.buttons = m.top.findNode("buttons")
  m.userRow = m.top.findNode("userRow")

  ' A picked user / the manual-login + quick-connect buttons emit intents the loginRouter
  ' coordinator handles.
  m.userRow.observeField("userSelected", "onUserSelected")
  m.buttons.observeField("buttonSelected", "onButtonSelected")

  ' Quick Connect is supported on every Jellyfin version JellyRock targets
  ' (>= 10.7.0). Endpoint and request-shape differences are handled inside
  ' sdk.quickConnect.* via versionChecker / apiVersion dispatch. The button is
  ' removed only when the server explicitly reports the feature disabled.
  m.quickConnectButton = m.top.findNode("quickConnect")
  m.quickConnectButton.text = translate(translationKeys.ButtonQuickConnect)
  ' Apply prior probe result immediately if already known disabled this session.
  ' Default of true (fail-open) means the button stays unless we have evidence.
  ' Otherwise the probe runs in onScreenShown (render-thread fetchAsync). On 10.7
  ' the /QuickConnect/Enabled endpoint is missing, so the probe fails open (button
  ' stays visible) - the dialog fallback handles the rare admin-disabled case there.
  if m.global.server.isQuickConnectEnabled = false
    removeQuickConnectButton("server reports Quick Connect disabled (cached)")
  end if

  m.buttons.callFunc("center")
end sub

' Probe the server's /QuickConnect/Enabled endpoint and remove the QC button if
' the server reports it disabled. Render-thread promise (#551 Batch 1, 3b pattern)
' — replaces the former QuickConnectEnabledTask (createObject + observeFieldScoped
' on responseCode). Fail-open: only a definitive `false` removes the button; any
' non-ok HTTP response, transport failure, or timeout leaves it visible.
sub probeQuickConnectAvailability()
  ' Already removed this session (cached-disabled in init) → nothing to probe.
  if not isValid(m.quickConnectButton) then return

  req = GetApi().BuildGetQuickConnectEnabledRequest()
  if not isValid(req)
    m.log.warn("Quick Connect probe skipped - server URL not configured")
    return
  end if
  m.log.info("Probing /QuickConnect/Enabled to gate the Quick Connect button", "url:", req.url)

  promises.chain(fetchAsync(req, "quickConnectEnabled")).then(sub(res as object)
    if not res.ok
      ' Server reachable but returned a non-2xx status (e.g. 10.7 has no endpoint).
      ' Fail-open: leave the button and the cached global field as-is.
      m.log.warn("Quick Connect probe got non-OK response - failing open", "statusCode:", res.statusCode)
      return
    end if

    ' Endpoint returns a plain boolean body. Coerce defensively in case a future
    ' server returns an object envelope or wraps the value as a string.
    enabled = res.json
    enabledType = type(enabled)
    if enabledType <> "Boolean" and enabledType <> "roBoolean"
      m.log.warn("Quick Connect probe got unexpected body type", "type:", enabledType, "raw:", res.text)
      enabled = (LCase(res.text.Trim()) = "true")
    end if

    m.global.server.isQuickConnectEnabled = enabled
    m.log.info("Quick Connect availability probed", "enabled:", enabled, "statusCode:", res.statusCode)
    if enabled = false
      removeQuickConnectButton("server reports Quick Connect disabled")
    end if
  end sub).catch(sub(err as object)
    ' Transport failure / timeout — fail-open, leave the button visible.
    m.log.warn("Quick Connect probe failed - failing open", err.reason)
  end sub)
end sub

' Remove the Quick Connect button and re-center the remaining buttons.
' Safe to call repeatedly - removeChild is a no-op once the node is detached.
sub removeQuickConnectButton(reason as string)
  if not isValid(m.quickConnectButton) then return
  if isValid(m.quickConnectButton.getParent())
    m.buttons.removeChild(m.quickConnectButton)
    m.buttons.callFunc("center")
  end if
  m.quickConnectButton = invalid
  m.log.info("Quick Connect button removed", reason)
end sub

sub onItemContentChanged()
  stopLoadingSpinner()
  m.top.findNode("UserRow").ItemContent = m.top.itemContent
  redraw()
end sub

' A user tile was picked. Resolve its id from the rendered list (UserRow fires the
' username), publish both, and emit the userSelected intent (was CreateUserSelectGroup
' returning the username to LoginFlow's public-user branch).
sub onUserSelected(event as object)
  name = event.getData()
  if not isValidAndNotEmpty(name) then return
  userId = ""
  for each u in m.top.itemContent
    if u.name = name
      userId = u.id
      exit for
    end if
  end for
  m.top.selectedUserName = name
  m.top.selectedUserId = userId
  m.top.getScene().preLoginIntent = "userSelected"
end sub

' Manual-login / Quick Connect buttons → intents the loginRouter coordinator handles.
sub onButtonSelected(event as object)
  buttonGroup = event.getRoSGNode()
  btn = buttonGroup.getChild(event.getData())
  if not isValid(btn) then return
  if btn.id = "manualLoginButton"
    m.top.getScene().preLoginIntent = "manualLogin"
  else if btn.id = "quickConnect"
    m.top.getScene().preLoginIntent = "quickConnectRequested"
  end if
end sub

sub redraw()
  userCount = m.top.itemContent.Count()
  topBorder = 360
  leftBorder = 130
  itemWidth = 300
  itemSpacing = 40

  if userCount < 5
    leftBorder = (1920 - ((userCount * itemWidth) + ((userCount - 1) * itemSpacing))) / 2
  end if
  '   break()
  m.top.findNode("UserRow").translation = [leftBorder, topBorder]
end sub

' JRScreen hook called when the screen is displayed by the screen manager
sub onScreenShown()
  m.log.info("UserSelect screen shown")

  ' The loginRouter coordinator builds the public+saved user list (sync GetPublicUsers on the
  ' main thread) and passes it as route context. Render it once on mount.
  if not isValidAndNotEmpty(m.top.itemContent)
    route = m.top.route
    if isValid(route) and isValid(route.context) and isValid(route.context.users)
      m.top.itemContent = route.context.users
    end if
  end if

  ' Load splashscreen (handles race condition)
  loadSplashscreen()

  ' Probe Quick Connect availability so the button can hide on disabled servers.
  probeQuickConnectAvailability()
end sub

' JRScreen hook called when the screen is hidden by the screen manager
sub onScreenHidden()
  m.log.info("UserSelect screen hidden - clearing backdrop")
  ' Clear backdrop using forceBackdrop to ensure it clears even before login
  m.global.sceneManager.callFunc("setBackgroundImage", "", true, true)
end sub

' Load splashscreen if enabled on server
' Always fetches fresh branding config from server
sub loadSplashscreen()
  m.log.info("Loading splashscreen - fetching branding config from server")

  ' Clear backdrop while waiting (forceBackdrop ensures it works before login)
  m.global.sceneManager.callFunc("setBackgroundImage", "", false, true)

  ' Check if value is already cached (from previous session)
  serverNode = m.global.server
  if isValid(serverNode.isSplashscreenEnabled)
    ' Already cached, apply immediately for instant display
    m.log.debug("Splashscreen setting already cached, applying immediately")
    applySplashscreen(serverNode.isSplashscreenEnabled)
  else
    m.log.debug("Splashscreen setting not cached")
  end if

  ' Always refresh in case the cached value is stale. Render-thread promise (#551
  ' Batch 1, 3b pattern) — replaces the former BrandingConfigTask (createObject +
  ' observeFieldScoped on responseCode) plus its companion observer on the global
  ' isSplashscreenEnabled field. The task also wrote that global session-cache
  ' field; we keep that write here and apply the result directly in .then().
  req = GetApi().BuildGetBrandingConfigurationRequest()
  promises.chain(fetchAsync(req, "brandingConfig")).then(sub(res as object)
    serverNode = m.global.server
    if res.ok and isValid(res.json) and isValid(res.json.SplashscreenEnabled)
      serverNode.isSplashscreenEnabled = res.json.SplashscreenEnabled
      m.log.info("Branding config cached successfully", "splashscreenEnabled:", res.json.SplashscreenEnabled)
    else
      ' Any non-ok HTTP response (the error contract resolves these) or a missing
      ' SplashscreenEnabled field → default to disabled, matching the old task's
      ' failure path.
      serverNode.isSplashscreenEnabled = false
      m.log.warn("Branding config fetch returned no usable data - defaulting splashscreen to disabled")
    end if
    applySplashscreen(serverNode.isSplashscreenEnabled)
  end sub).catch(sub(err as object)
    ' Transport failure / timeout — default to disabled, same as the old failure path.
    m.log.warn("Branding config fetch failed", err.reason)
    m.global.server.isSplashscreenEnabled = false
    applySplashscreen(false)
  end sub)
end sub

' Apply splash background based on enabled flag
' Uses forceBackdrop parameter to show splashscreen before user login
sub applySplashscreen(serverSplashEnabled as boolean)
  ' Get global splash screen setting (reads from registry or settings.json default)
  ' This works before user login since global settings are device-wide
  globalSetting = getGlobalSplashScreenSetting()

  ' Resolve splash screen setting (JellyRock global override or server setting)
  isSplashEnabled = resolveSplashScreen(globalSetting, serverSplashEnabled)

  m.log.debug("Applying splashscreen", "globalSetting:", globalSetting, "serverSetting:", serverSplashEnabled, "resolvedValue:", isSplashEnabled)

  if isSplashEnabled = true
    ' Build splash URL with correct parameters
    splashUrl = buildURL("/Branding/Splashscreen", { "format": "jpg", "tag": "splash" })

    ' buildURL returns invalid when server URL is not configured
    if not isValid(splashUrl)
      m.log.warn("Cannot load splashscreen - server URL not configured")
      m.global.sceneManager.callFunc("setBackgroundImage", "", true, true)
      return
    end if

    m.log.info("Splashscreen enabled - loading", splashUrl)

    ' Set backdrop with animation, forceBackdrop=true bypasses user setting check
    m.global.sceneManager.callFunc("setBackgroundImage", splashUrl, true, true)
  else
    m.log.info("Splashscreen disabled - using default background")
    m.global.sceneManager.callFunc("setBackgroundImage", "", true, true)
  end if
end sub

function onKeyEvent(key as string, press as boolean) as boolean
  if not press then return false

  if key = "back"
    ' Back → change server (coordinator deletes the server and navigates to /server).
    m.top.getScene().preLoginIntent = "userBack"
    return true
  else if key = "up"
    if m.top.focusedChild.isSubType("JRButtonGroup")
      m.top.findNode("UserRow").setFocus(true)
      return true
    end if
  else if key = "down"
    if m.top.focusedChild.isSubType("UserRow")
      m.buttons.setFocus(true)
      return true
    end if
  end if
  return false
end function

' onDestroy: Full teardown releasing all resources before component removal
' Called automatically via JRScreen.beforeViewClose when sgRouter permanently closes this view.
' Note: onScreenHidden already cleared the backdrop; this handles task/ref cleanup.
sub onDestroy()
  m.log.verbose("onDestroy")

  ' The branding-config + Quick Connect probes are now render-thread promises
  ' (fetchAsync); any in-flight request is cancelled by the abandonApiPromises()
  ' call the auto-abandon BSC plugin injects as the first statement of onDestroy.

  ' Release the selection + button observers.
  if isValid(m.userRow) then m.userRow.unobserveField("userSelected")
  if isValid(m.buttons) then m.buttons.unobserveField("buttonSelected")

  ' Clear node references
  m.userRow = invalid
  m.quickConnectButton = invalid
  m.buttons = invalid
end sub