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