components_config_SetServerScreen.bs
import "pkg:/source/enums/ServerAction.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/config.bs"
import "pkg:/source/utils/dialogs.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/screenReadiness.bs"
import "pkg:/source/utils/tasks.bs"
import "pkg:/source/utils/translate.bs"
sub init()
' Opened before anything else so the run covers the chrome build too — the
' findNode/translate/center work below is part of what the user waits for, and a
' ledger opened after it would report a screen that is faster than it is.
screenLoad.begin("setServer")
m.log = new log.Logger("SetServerScreen")
' The router hands this view focus via the JRScreen lifecycle bridge (handleFocus).
' onScanForServersComplete sets the precise focus target (picker vs. textbox) once the SSDP
' scan returns.
m.top.isOptionsAvailable = true
' Overhang config — declarative field projected onto the overhang by JRScene (registerOverhangData).
m.top.isLogoVisible = true
m.top.findNode("prompt").text = translate(translationKeys.LabelConnectToServer)
m.top.findNode("serverPickerHint").text = translate(translationKeys.LabelPickAJellyfinServerFromThe)
m.top.findNode("manualEntryHint").text = translate(translationKeys.LabelOrEnterServerUrlManually)
m.top.findNode("submit").text = translate(translationKeys.ButtonSubmit)
m.serverPicker = m.top.findNode("serverPicker")
m.serverUrlTextbox = m.top.findNode("serverUrlTextbox")
m.serverUrlContainer = m.top.findNode("serverUrlContainer")
m.serverUrlOutline = m.top.findNode("serverUrlOutline")
m.serverUrlOutline.blendColor = m.global.constants.colorPrimary
m.buttons = m.top.findNode("buttons")
m.buttons.callFunc("center")
' Prefill the previously-entered server URL.
if isValid(m.global.server.serverUrl)
m.top.serverUrl = m.global.server.serverUrl
end if
m.top.observeField("serverUrl", "clearErrorMessage")
' Submit drives the connect via the loginRouter coordinator (serverSubmitted intent); the
' options sidepanel offers "Delete Saved" for saved picker entries.
m.buttons.observeField("buttonSelected", "onButtonSelected")
setupDeleteSavedOption()
ScanForServers()
end sub
' Build the "Delete Saved" options sidepanel entry (was CreateServerGroup 346-355).
sub setupDeleteSavedOption()
sidepanel = m.top.findNode("options")
opt = CreateObject("roSGNode", "OptionsButton")
opt.title = translate(translationKeys.LabelDeleteSaved)
opt.id = ServerAction.DELETE_SAVED
opt.observeField("optionSelected", "onDeleteSaved")
sidepanel.options = [opt]
sidepanel.observeField("closeSidePanel", "onCloseSidePanel")
end sub
' Submit pressed → publish the entered URL and emit the serverSubmitted intent.
' The coordinator (loginRouter.onServerSubmitted) runs the blocking connect.
sub onButtonSelected(event as object)
buttonGroup = event.getRoSGNode()
buttonSelected = buttonGroup.getChild(event.getData())
if buttonSelected.id = "submit"
m.top.enteredUrl = m.top.serverUrl
m.top.getScene().preLoginIntent = "serverSubmitted"
end if
end sub
' Delete the focused saved server from the picker + registry (was CreateServerGroup 420-435).
sub onDeleteSaved()
itemToDelete = m.serverPicker.content.getChild(m.serverPicker.itemFocused)
if not isValid(itemToDelete) then return
' 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)
m.serverPicker.content.removeChild(itemToDelete)
m.top.findNode("options").visible = false
m.serverPicker.setFocus(true)
end if
end sub
' Sidepanel closed without a selection (was CreateServerGroup 366-369).
sub onCloseSidePanel()
' setFocus on the child focuses up the chain, so focusing m.top first is redundant.
m.serverPicker.setFocus(true)
end sub
sub ScanForServers()
startLoadingSpinner(false)
' Declared BEFORE the launch, per the ledger's rule 1. A `back` re-scan does not
' re-open the ledger, so this is ignored on that path — the run is over and a
' user-driven refresh is not a second load. Same shape as ItemDetails' refresh.
screenLoad.pending("scan")
m.ssdpScanner = CreateObject("roSGNode", "ServerDiscoveryTask")
'run the task
m.ssdpScanner.observeField("content", "onScanForServersComplete")
launchTask(m.ssdpScanner)
end sub
sub onScanForServersComplete(event)
m.servers = event.getData()
' Resolved at the TOP so the fill measures the TASK and nothing else. Everything
' below it — the `saved_servers` registry read and the two merge passes — is
' synchronous render-thread work with no async boundary to bracket, so it cannot be
' a fill of its own; it falls out as `paint` minus `scan` instead. That subtraction
' is only valid because the two spans nest exactly here, which is the whole reason
' the resolve is not left at the bottom beside the paint.
screenLoad.resolve("scan")
' What the picker is about to be built FROM, which is the workload this screen's
' timing actually depends on: a LAN where discovery answers and one where the picker
' fills from `saved_servers` alone are different workloads, not a noisy one, and a
' sample that cannot say which it was must not be compared against the other.
'
' ⚠️ Unlike every other `variant` in the app, this one is decided by the ENVIRONMENT
' rather than by what the user opened — so it can differ BETWEEN launches of one
' series. `scripts/measure-selection.js` refuses a median over a mixed series for
' that reason; do not "simplify" this to a constant.
discovered = 0
if isValid(m.servers) then discovered = m.servers.Count()
items = CreateObject("roSGNode", "ContentNode")
stopLoadingSpinner()
' Load saved servers upfront — used in both passes below
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
' Pass 1: Add all SSDP-discovered servers.
' If a matching saved entry exists, inject its originalUrl so the user sees their own
' URL (e.g. "192.168.1.100:8096" or an HTTPS address) instead of the raw SSDP URL.
' Dedup uses id first (robust to URL changes), then falls back to canonical baseUrl
' for backward compatibility with saved entries that predate this change.
for each serverItem in m.servers
serverItem.subtype = "ContentNode"
for each savedServer in savedServers.serverList
isMatch = false
if isValidAndNotEmpty(serverItem.id) and isValidAndNotEmpty(savedServer.id)
isMatch = (serverItem.id = savedServer.id)
else if LCase(serverItem.baseUrl) = savedServer.baseUrl
isMatch = true
end if
if isMatch
' Merge: keep SSDP name and connection URL, restore saved originalUrl
savedOriginalUrl = savedServer.originalUrl
if not isValidAndNotEmpty(savedOriginalUrl)
savedOriginalUrl = savedServer.baseUrl ' backward compat: old entries have no originalUrl
end if
serverItem.originalUrl = savedOriginalUrl
serverItem.isSaved = true ' mark as deletable — this server exists in saved_servers
exit for
end if
end for
items.update([serverItem], true)
end for
' Pass 2: Add saved servers not found by SSDP (e.g. remote servers)
for each savedServer in savedServers.serverList
isAlreadyListed = false
for each listed in m.servers
if isValidAndNotEmpty(listed.id) and isValidAndNotEmpty(savedServer.id)
if listed.id = savedServer.id
isAlreadyListed = true
exit for
end if
else if LCase(listed.baseUrl) = savedServer.baseUrl
isAlreadyListed = true
exit for
end if
end for
if not isAlreadyListed
savedServer.isSaved = true ' mark as deletable — this server exists in saved_servers
items.update([savedServer], true)
m.servers.push(savedServer)
end if
end for
m.serverPicker.content = items
'if we have at least one server, focus on the server picker
if m.servers.Count() > 0
m.serverPicker.setFocus(true)
'no servers found...focus on the input textbox
else
m.serverUrlContainer.setFocus(true)
'show/hide input box outline
m.serverUrlOutline.visible = true
end if
' Paint LAST, and deliberately not at the end of init(). At the end of init() the
' user has prompt text, hints and a spinner — something to look at, and nothing to
' act on: the picker is empty and no focus target has been chosen yet, so a keypress
' lands nowhere. The screen becomes usable on the line above, which is why the clock
' stops here. `settled` therefore lands with `paint` (the one fill resolved at the
' top), and that is the honest report rather than a missing milestone: this screen
' has exactly one readiness moment, and the number worth acting on is `scan` — the
' share of it that was the task rather than the screen.
'
' `variant` separates the two workloads, per ADR 0028. `savedOnly` is the run whose
' whole wait was the collection window over registry content.
if discovered > 0
screenLoad.paint("discovered")
else
screenLoad.paint("savedOnly")
end if
end sub
' Standard keyboard dialog (see source/utils/dialogs.bs). The helper owns the
' themed chrome and the OK/Cancel buttons; the result arrives on the dialog
' node's own `result` field.
sub ShowKeyboard()
m.dialog = showKeyboardDialog(translate(translationKeys.LabelEnterTheServerNameOrIp), "onServerUrlEntered", m.serverUrlTextbox.text)
end sub
sub onServerUrlEntered()
dialog = m.dialog
m.dialog = invalid
if not isValid(dialog) then return
if dialog.result.confirmed then m.serverUrlTextbox.text = dialog.result.value
end sub
sub clearErrorMessage()
m.top.errorMessage = ""
end sub
' JRScreen hook called when the screen is displayed by the screen manager
sub onScreenShown()
' Clear backdrop on config screens
m.global.sceneManager.callFunc("setBackgroundImage", "")
end sub
function onKeyEvent(key as string, press as boolean) as boolean
m.log.debug("SetServerScreen onKeyEvent", key, press)
if not press then return true
isHandled = true
if key = "OK" and m.serverPicker.hasFocus()
item = m.serverPicker.content.getChild(m.serverPicker.itemFocused)
' Prefer originalUrl (user-entered or scheme-stripped SSDP URL) so inferServerUrl()
' can re-discover the correct protocol rather than locking in the canonical form
selectedUrl = item.originalUrl
if not isValidAndNotEmpty(selectedUrl)
selectedUrl = item.baseUrl
end if
m.top.serverUrl = selectedUrl
m.buttons.setFocus(true)
'if the user pressed the down key and we are already at the last child of server picker, then change focus to the url textbox
else if key = "down" and m.serverPicker.hasFocus() and m.serverPicker.content.getChildCount() > 0 and m.serverPicker.itemFocused = m.serverPicker.content.getChildCount() - 1
m.serverUrlContainer.setFocus(true)
'user navigating up to the server picker from the input box (it's only focusable if it has items)
else if key = "up" and m.serverUrlContainer.hasFocus() and m.servers.Count() > 0
m.serverPicker.setFocus(true)
else if key = "up" and m.serverUrlContainer.hasFocus() and m.servers.Count() = 0
ScanForServers()
else if key = "back" and m.serverUrlContainer.hasFocus() and m.servers.Count() > 0
m.serverPicker.setFocus(true)
else if key = "OK" and m.serverUrlContainer.hasFocus()
ShowKeyboard()
else if key = "back" and m.buttons.isInFocusChain() and m.servers.Count() > 0
m.serverPicker.setFocus(true)
else if key = "back" and m.buttons.isInFocusChain() and m.servers.Count() = 0
m.serverUrlContainer.setFocus(true)
else if key = "back" and m.serverUrlContainer.hasFocus() and m.servers.Count() = 0
ScanForServers()
else if key = "back" and m.serverPicker.hasFocus() and m.servers.Count() > 0
ScanForServers()
' On "back" with or without available local servers, will rescan for servers
else if key = "up" and m.buttons.isInFocusChain()
m.serverUrlContainer.setFocus(true)
'focus the submit button from serverUrl
else if key = "down" and m.serverUrlContainer.hasFocus()
m.buttons.setFocus(true)
else if key = "options"
if m.serverPicker.itemFocused >= 0 and m.serverPicker.itemFocused < m.serverPicker.content.getChildCount()
item = m.serverPicker.content.getChild(m.serverPicker.itemFocused)
if m.servers.Count() > 0 and item.isSaved = true
'Can only delete previously saved servers, not locally discovered ones
'So if we are on a saved item, let the options dialog be shown (isHandled elsewhere)
isHandled = false
end if
end if
else
isHandled = false
end if
'show/hide input box outline
m.serverUrlOutline.visible = m.serverUrlContainer.isInFocusChain()
return isHandled
end function
' onDestroy: Full teardown releasing all resources before component removal
' Called automatically via JRScreen.beforeViewClose when sgRouter permanently closes this view.
sub onDestroy()
m.log.verbose("onDestroy")
' Unobserve m.top observer
m.top.unobserveField("serverUrl")
' Release the submit + options sidepanel observers
if isValid(m.buttons) then m.buttons.unobserveField("buttonSelected")
sidepanel = m.top.findNode("options")
if isValid(sidepanel)
sidepanel.unobserveField("closeSidePanel")
for each opt in sidepanel.options
opt.unobserveField("optionSelected")
end for
end if
' Keyboard may or may not be open — unobserve and dismiss if present
abandonDialog(m.dialog)
m.dialog = invalid
' Stop and release task node (created by ScanForServers on init)
if isValid(m.ssdpScanner)
m.ssdpScanner.unobserveField("content")
m.ssdpScanner.control = "STOP"
m.ssdpScanner = invalid
end if
' Clear node references
m.serverPicker = invalid
m.serverUrlTextbox = invalid
m.serverUrlContainer = invalid
m.serverUrlOutline = invalid
m.buttons = invalid
' Release server list data
m.servers = invalid
end sub