import "pkg:/source/api/ApiClient.bs"
import "pkg:/source/api/apiPool.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/config.bs"
import "pkg:/source/utils/deviceCapabilities.bs"
import "pkg:/source/utils/dialogs.bs"
import "pkg:/source/utils/globals.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/screenReadiness.bs"
import "pkg:/source/utils/translate.bs"
import "pkg:/source/utils/translateLocale.bs"
sub init()
' Open the readiness ledger for one load of this screen. `init()` is the whole load:
' everything this screen shows is built here, synchronously, from two `pkg:` JSON files
' — so unlike `ItemDetails` (which opens its ledger in the route handler because its
' load is a fetch that outlives init) there is no later handler to open it in.
'
' Deliberately NOT `onScreenShown()`: that runs after the router has already mounted
' this node, so a ledger opened there would begin AFTER the work it is supposed to
' measure. `ItemDetails` measures the same span this does — from "the screen starts
' loading" to "the screen has rendered" — and excludes the router's mount either way.
screenLoad.begin("settings")
m.log = new log.Logger("Settings")
m.top.overhangTitle = translate(translationKeys.LabelSettings)
m.top.isOptionsAvailable = false
' Overhang config — declarative fields projected onto the overhang by JRScene (registerOverhangData).
m.top.isLogoVisible = true
m.top.shouldShowUserDropdown = true
m.userLocation = []
m.themeColorsChanged = false
m.languageChanged = false
m.customColorBackup = invalid
' Load available languages for the language picker
languagesJson = ReadAsciiFile("pkg:/locale/languages.json")
m.availableLanguages = ParseJson(languagesJson)
if not isValid(m.availableLanguages) then m.availableLanguages = []
' Normalize language entries so downstream code can safely access all fields
for each lang in m.availableLanguages
if not isValid(lang.name) then lang.name = ""
if not isValid(lang.nativeName) then lang.nativeName = ""
if not isValid(lang.code) then lang.code = ""
end for
versionLabel = m.top.findNode("versionLabel")
versionLabel.text = "v" + m.global.app.version
m.settingsMenu = m.top.findNode("settingsMenu")
m.settingDetail = m.top.findNode("settingDetail")
m.settingDesc = m.top.findNode("settingDesc")
m.path = m.top.findNode("path")
m.top.findNode("rightPanelBackground").blendColor = m.global.constants.colorBackgroundSecondary
m.boolSetting = m.top.findNode("boolSetting")
m.integerSetting = m.top.findNode("integerSetting")
m.radioSetting = m.top.findNode("radioSetting")
m.hexSetting = m.top.findNode("hexSetting")
m.alphaSetting = m.top.findNode("alphaSetting")
m.languagePicker = m.top.findNode("languagePicker")
m.integerSetting.observeField("submit", "onKeyGridSubmit")
m.integerSetting.observeField("escape", "onKeyGridEscape")
m.hexSetting.observeField("submit", "onHexKeyGridSubmit")
m.hexSetting.observeField("escape", "onHexKeyGridEscape")
m.hexSetting.observeField("reset", "onHexResetRequested")
m.alphaSetting.observeField("submit", "onAlphaKeyGridSubmit")
m.alphaSetting.observeField("escape", "onAlphaKeyGridEscape")
m.alphaSetting.observeField("reset", "onAlphaResetRequested")
m.languagePicker.observeField("selectedCode", "onLanguagePickerSelected")
m.languagePicker.languages = m.availableLanguages
m.suppressLanguagePicker = false
m.settingsMenu.setFocus(true)
m.settingsMenu.observeField("itemFocused", "settingFocused")
m.settingsMenu.observeField("itemSelected", "settingSelected")
m.boolSetting.observeField("checkedItem", "boolSettingChanged")
m.radioSetting.observeField("checkedItem", "radioSettingChanged")
' The right-hand panel — description text plus whichever setting control the focused row
' uses — is filled by `settingFocused`, which Scene Graph dispatches from the observer
' above on a LATER turn of the event loop, not inline from `LoadMenu`'s content
' assignment. So it is outstanding at paint, and the ledger's first rule is that it must
' be declared before paint rather than discovered afterwards.
screenLoad.pending("detail")
' Load Configuration Tree
m.configTree = GetConfigTree()
LoadMenu({ children: m.configTree })
' PAINT — the LEFT half of the screen. The menu list is built and focusable by here, and
' the two `pkg:` JSON reads that feed it (`languages.json` above, `settings.json` in
' `GetConfigTree`) are synchronous, so this is genuinely "content ready" for that half.
'
' The RIGHT half is not, and that is why `detail` is declared above rather than this
' screen reporting zero fills. Setting `m.settingsMenu.content` does not call
' `settingFocused` inline — Scene Graph delivers the `itemFocused` change through the
' message port, so the handler that fills the description and reveals the setting control
' runs on a later turn of the event loop, ~250 ms after this line on a Stick 4K.
'
' Verified on device rather than reasoned about, because same-component observer dispatch
' is exactly the kind of platform behaviour that is easy to assume and wrong: a probe log
' in `settingFocused` landed after the `screen-load paint` line on every launch. An
' earlier cut of this file marked paint here and declared nothing, and so published a
' number for a screen whose right half was still empty — the flattering-first-paint
' failure the whole ledger exists to prevent.
screenLoad.paint()
end sub
sub onKeyGridSubmit()
selectedSetting = m.userLocation.peek().filteredChildren[m.settingsMenu.itemFocused]
newValue = m.integerSetting.text
' A settings.json entry MAY declare `min` / `max`. One that does is range-checked here,
' at SAVE, rather than clamped where the value is read.
'
' Clamping at use time is invisible: the number in Settings keeps saying what was typed
' while the app quietly behaves as though it said something else, and the next reader of
' either half has no way to tell. `playbackBitrateLimit` is the standing example — it
' takes any number and the consumer guards, so a user who types 400 sees 400 forever.
' Refusing outright is the other extreme and is worse for a keypad: the user has already
' committed the value and would have to retype it to learn what is allowed. So the range
' is enforced once, at the boundary, and the user is TOLD what will be stored instead and
' gets to decline.
'
' A setting with no declared range takes the pre-existing path untouched, raw text and
' all — this is a capability entries opt into, not a change to every integer setting.
bounds = settingRangeBounds(selectedSetting)
if isValid(bounds)
typed = Int(Val(newValue))
inRange = clampToSettingRange(typed, bounds)
if inRange <> typed
' Carried on `m` rather than re-read in the response handler: the handler runs after
' a round trip through a dialog, and re-deriving the setting from the focused menu
' index would save against whatever is focused THEN.
m.rangeConfirmSettingName = selectedSetting.settingName
m.rangeConfirmValue = inRange.ToStr()
m.rangeConfirmDialog = showConfirmDialog(translate(translationKeys.ErrorValueOutOfRange), translate(translationKeys.MessageValueOutsideSupportedRange, [typed.ToStr(), bounds.min.ToStr(), bounds.max.ToStr(), m.rangeConfirmValue]), "onIntegerRangeDialogResponse", translate(translationKeys.ButtonSave), translate(translationKeys.ButtonCancel))
return
end if
' In range, so store the NORMALIZED number rather than the raw text: the keypad happily
' produces "016", which round-trips through Val() to the right value but shows the user
' their leading zeros forever. Scoped to entries that declare a range so no existing
' setting changes shape.
user.settings.Save(selectedSetting.settingName, typed.ToStr())
m.integerSetting.text = typed.ToStr()
m.settingsMenu.setFocus(true)
return
end if
' Update node field - observer handles registry persistence
user.settings.Save(selectedSetting.settingName, newValue)
m.settingsMenu.setFocus(true)
end sub
' Handle the user's answer to "that value is out of range — save the clamped one instead?"
sub onIntegerRangeDialogResponse()
' The dialog and the two values it was raised over are ONE carried decision, so they are
' read into locals and cleared together, before any early return. Clearing only the dialog
' would leave `m` describing a confirmation that is no longer pending — a stale setting
' name outliving its dialog is exactly the mismatch this handler carries them to avoid.
dialog = m.rangeConfirmDialog
settingName = m.rangeConfirmSettingName
clampedValue = m.rangeConfirmValue
m.rangeConfirmDialog = invalid
m.rangeConfirmSettingName = invalid
m.rangeConfirmValue = invalid
if not isValid(dialog) or not isValid(dialog.result) then return
' Declined, or dismissed. Focus goes back to the KEYPAD, not the menu: the user was told
' the value they typed is not storable, so the useful next action is typing another one.
if not dialog.result.confirmed
m.integerSetting.setFocus(true)
return
end if
user.settings.Save(settingName, clampedValue)
' Show what was actually stored. Leaving the typed value on screen after saving a
' different one is the same invisible divergence the save-time check exists to remove.
m.integerSetting.text = clampedValue
m.settingsMenu.setFocus(true)
end sub
sub onKeyGridEscape()
if m.integerSetting.escape = "left" or m.integerSetting.escape = "back"
m.settingsMenu.setFocus(true)
end if
end sub
sub onHexKeyGridSubmit()
selectedSetting = m.userLocation.peek().filteredChildren[m.settingsMenu.itemFocused]
newValue = UCase(m.hexSetting.text)
' Validate hex color input
if not isValidHexColor(newValue)
showAlertDialog(translate(translationKeys.ErrorInvalidColor), translate(translationKeys.LabelPleaseEnterExactly6HexCharacters09A))
return
end if
' Track if theme color changed (requires UI refresh on exit)
if selectedSetting.settingName.left(12) = "uiThemeColor"
m.themeColorsChanged = true
end if
' Update node field - observer handles registry persistence
user.settings.Save(selectedSetting.settingName, newValue)
m.settingsMenu.setFocus(true)
end sub
sub onHexKeyGridEscape()
if m.hexSetting.escape = "left" or m.hexSetting.escape = "back"
m.settingsMenu.setFocus(true)
end if
end sub
' Called when reset button is selected in HexKeyboard
sub onHexResetRequested()
m.resetConfirmDialog = showConfirmDialog(translate(translationKeys.LabelResetSetting), translate(translationKeys.MessageAreYouSureThisWillReset), "onResetDialogResponse", translate(translationKeys.ButtonReset), translate(translationKeys.ButtonCancel))
end sub
' Handle user response from reset confirmation dialog
sub onResetDialogResponse()
dialog = m.resetConfirmDialog
m.resetConfirmDialog = invalid
if not isValid(dialog) or not isValid(dialog.result) then return
' User selected "Cancel" or closed dialog
if not dialog.result.confirmed
m.hexSetting.findNode("resetButton").setFocus(true)
return
end if
' User confirmed reset - get default value from config tree
selectedSetting = m.userLocation.peek().filteredChildren[m.settingsMenu.itemFocused]
configEntry = findConfigTreeKey(selectedSetting.settingName, m.configTree)
if not isValid(configEntry) or not isValid(configEntry.default)
m.log.warn("Could not find default value for setting", selectedSetting.settingName)
m.hexSetting.findNode("resetButton").setFocus(true)
return
end if
defaultValue = configEntry.default
' Track if theme color changed (requires UI refresh on exit)
if selectedSetting.settingName.left(12) = "uiThemeColor"
m.themeColorsChanged = true
end if
' Update node field - observer handles registry persistence
user.settings.Save(selectedSetting.settingName, defaultValue)
' Update the UI
m.hexSetting.text = defaultValue
m.hexSetting.findNode("resetButton").setFocus(true)
end sub
sub onAlphaKeyGridSubmit()
selectedSetting = m.userLocation.peek().filteredChildren[m.settingsMenu.itemFocused]
newValue = LCase(m.alphaSetting.text)
' Validate alpha input is exactly 3 letters (a-z only)
isValidCode = Len(newValue) = 3
if isValidCode
for i = 0 to 2
charCode = Asc(Mid(newValue, i + 1, 1))
if charCode < 97 or charCode > 122
isValidCode = false
exit for
end if
end for
end if
if not isValidCode
showAlertDialog(translate(translationKeys.ErrorInvalidLanguageCode), translate(translationKeys.MessagePleaseEnterExactly3LettersEG))
return
end if
' Update node field - observer handles registry persistence
user.settings.Save(selectedSetting.settingName, newValue)
m.settingsMenu.setFocus(true)
end sub
sub onAlphaKeyGridEscape()
if m.alphaSetting.escape = "left" or m.alphaSetting.escape = "back"
m.settingsMenu.setFocus(true)
end if
end sub
' Called when reset button is selected in AlphaKeyboard
sub onAlphaResetRequested()
m.resetConfirmDialog = showConfirmDialog(translate(translationKeys.LabelResetSetting), translate(translationKeys.MessageAreYouSureThisWillReset), "onAlphaResetDialogResponse", translate(translationKeys.ButtonReset), translate(translationKeys.ButtonCancel))
end sub
' Handle user response from alpha reset confirmation dialog
sub onAlphaResetDialogResponse()
dialog = m.resetConfirmDialog
m.resetConfirmDialog = invalid
if not isValid(dialog) or not isValid(dialog.result) then return
' User selected "Cancel" or closed dialog
if not dialog.result.confirmed
m.alphaSetting.findNode("resetButton").setFocus(true)
return
end if
' User confirmed reset - get default value from config tree
selectedSetting = m.userLocation.peek().filteredChildren[m.settingsMenu.itemFocused]
configEntry = findConfigTreeKey(selectedSetting.settingName, m.configTree)
if not isValid(configEntry) or not isValid(configEntry.default)
m.log.warn("Could not find default value for setting", selectedSetting.settingName)
m.alphaSetting.findNode("resetButton").setFocus(true)
return
end if
defaultValue = configEntry.default
' Update node field - observer handles registry persistence
user.settings.Save(selectedSetting.settingName, defaultValue)
' Update the UI
m.alphaSetting.text = defaultValue
m.alphaSetting.findNode("resetButton").setFocus(true)
end sub
sub LoadMenu(configSection)
if not isValid(configSection.children)
' Load parent menu
m.userLocation.pop()
configSection = m.userLocation.peek()
else
if m.userLocation.Count() > 0 then m.userLocation.peek().selectedIndex = m.settingsMenu.itemFocused
m.userLocation.push(configSection)
end if
result = CreateObject("roSGNode", "ContentNode")
' Build filtered children list (respecting visibleWhen conditions)
filteredChildren = []
for each item in configSection.children
if isValid(item.visibleWhen) and not isSettingVisible(item.visibleWhen)
continue for
end if
filteredChildren.push(item)
end for
configSection.filteredChildren = filteredChildren
for each item in filteredChildren
listItem = result.CreateChild("ContentNode")
listItem.title = translate(item.titleKey)
listItem.Description = translate(item.descriptionKey)
listItem.id = item.id
end for
' Inject "Reset User Settings" action at root level only
if m.userLocation.Count() = 1
resetItem = {
title: "Reset User Settings",
description: "Reset all settings to their default values. Your login session will not be affected.",
titleKey: translationKeys.LabelResetUserSettings,
descriptionKey: translationKeys.SettingResetAllSettingsToTheirDefault,
type: "action",
actionId: "resetUserSettings"
}
filteredChildren.push(resetItem)
listItem = result.CreateChild("ContentNode")
listItem.title = translate(resetItem.titleKey)
listItem.Description = translate(resetItem.descriptionKey)
listItem.id = "resetUserSettings"
end if
m.settingsMenu.content = result
if isValid(configSection.selectedIndex) and configSection.selectedIndex > -1
m.settingsMenu.jumpToItem = configSection.selectedIndex
end if
' Set Path display
m.path.text = ""
for each level in m.userLocation
if isValid(level.title)
if m.path.text = ""
m.path.text = translate(level.titleKey)
else
m.path.text += " / " + translate(level.titleKey)
end if
end if
end for
end sub
sub settingFocused()
' The right panel has landed. Resolved at the TOP because this handler has an early
' return (an entry with no `type`), and a `pending` that some path can skip leaves the
' screen permanently unsettled — reported as unmeasurable, never as a wrong number. The
' wait being measured is the DISPATCH, which is the ~250 ms; everything below is
' synchronous node writes on the render thread.
'
' Silent on every later call. Moving focus re-runs this on a screen that settled long
' ago, and `resolve` ignores a run that is over — see screenReadiness.bs.
screenLoad.resolve("detail")
selectedSetting = m.userLocation.peek().filteredChildren[m.settingsMenu.itemFocused]
m.settingDesc.text = translate(selectedSetting.descriptionKey)
' Hide Settings
m.boolSetting.visible = false
m.integerSetting.visible = false
m.radioSetting.visible = false
m.hexSetting.visible = false
m.alphaSetting.visible = false
m.languagePicker.visible = false
userSettings = m.global.user.settings
if not isValid(selectedSetting.type)
return
else if selectedSetting.type = "bool"
m.boolSetting.visible = true
if userSettings[selectedSetting.settingName] = true
m.boolSetting.checkedItem = 1
else
m.boolSetting.checkedItem = 0
end if
else if selectedSetting.type = "integer"
integerValue = userSettings[selectedSetting.settingName].ToStr()
if isValid(integerValue)
m.integerSetting.text = integerValue
end if
m.integerSetting.visible = true
else if LCase(selectedSetting.type) = "radio"
selectedValue = userSettings[selectedSetting.settingName]
radioContent = CreateObject("roSGNode", "ContentNode")
m.radioSetting.checkedItem = 0
itemIndex = 0
for each item in m.userLocation.peek().filteredChildren[m.settingsMenu.itemFocused].options
listItem = radioContent.CreateChild("ContentNode")
listItem.title = translate(item.titleKey)
listItem.id = item.id
if selectedValue = item.id
m.radioSetting.checkedItem = itemIndex
end if
itemIndex++
end for
m.radioSetting.content = radioContent
m.radioSetting.jumpToItem = m.radioSetting.checkedItem
m.radioSetting.visible = true
else if selectedSetting.type = "text"
' Text input type (used for hex color codes)
textValue = userSettings[selectedSetting.settingName]
if isValid(textValue)
m.hexSetting.text = textValue
else
m.hexSetting.text = ""
end if
m.hexSetting.visible = true
else if selectedSetting.type = "alpha"
' Alpha text input type (used for language codes)
textValue = userSettings[selectedSetting.settingName]
if isValid(textValue)
m.alphaSetting.text = textValue
else
m.alphaSetting.text = ""
end if
m.alphaSetting.visible = true
else if selectedSetting.type = "languagePicker"
selectedValue = userSettings[selectedSetting.settingName]
if not isValid(selectedValue) then selectedValue = ""
m.suppressLanguagePicker = true
m.languagePicker.selectedCode = selectedValue
m.suppressLanguagePicker = false
m.languagePicker.visible = true
else if selectedSetting.type = "action"
' Action items show description only, no setting controls
else
m.log.warn("Unknown setting type", selectedSetting.type)
end if
end sub
sub settingSelected()
selectedItem = m.userLocation.peek().filteredChildren[m.settingsMenu.itemFocused]
' Handle action items (e.g., Reset User Settings)
if isValid(selectedItem.actionId) and selectedItem.actionId = "resetUserSettings"
confirmResetUserSettings()
return
end if
if isValid(selectedItem.type) ' Show setting
if selectedItem.type = "bool"
m.boolSetting.setFocus(true)
else if selectedItem.type = "integer"
m.integerSetting.setFocus(true)
else if selectedItem.type = "radio"
m.radioSetting.setFocus(true)
else if selectedItem.type = "text"
m.hexSetting.setFocus(true)
else if selectedItem.type = "alpha"
m.alphaSetting.setFocus(true)
else if selectedItem.type = "languagePicker"
m.languagePicker.findNode("keyboard").setFocus(true)
end if
else if isValid(selectedItem.children) and selectedItem.children.Count() > 0 ' Show sub menu
LoadMenu(selectedItem)
m.settingsMenu.setFocus(true)
else
return
end if
m.settingDesc.text = m.settingsMenu.content.GetChild(m.settingsMenu.itemFocused).Description
end sub
sub boolSettingChanged()
if not isValid(m.boolSetting.focusedChild) then return
selectedSetting = m.userLocation.peek().filteredChildren[m.settingsMenu.itemFocused]
if m.boolSetting.checkedItem
' Update node field - observer handles registry persistence
user.settings.Save(selectedSetting.settingName, "true")
' Special handling for globalRememberMe - set active_user in global registry
if selectedSetting.settingName = "globalRememberMe"
setSetting("active_user", m.global.user.id)
end if
else
' Update node field - observer handles registry persistence
user.settings.Save(selectedSetting.settingName, "false")
' Special handling for globalRememberMe - remove active_user from global registry
if selectedSetting.settingName = "globalRememberMe"
unsetSetting("active_user")
end if
end if
end sub
sub radioSettingChanged()
if not isValid(m.radioSetting.focusedChild) then return
selectedSetting = m.userLocation.peek().filteredChildren[m.settingsMenu.itemFocused]
' Language picker is handled by onLanguagePickerSelected(), not the radio control
if selectedSetting.type = "languagePicker" then return
selectedOption = selectedSetting.options[m.radioSetting.checkedItem]
newValue = selectedOption.id
' Backup custom colors before applying a preset (so user can switch back)
if isValid(selectedOption.presetValues)
currentTheme = m.global.user.settings.uiTheme
if currentTheme = "custom" or currentTheme = ""
backupCustomColors()
end if
else if newValue = "custom" and isValid(m.customColorBackup)
' Switching to Custom - restore backed up colors
restoreCustomColors()
end if
' Update node field - observer handles registry persistence
user.settings.Save(selectedSetting.settingName, newValue)
' Apply preset values if the selected option has them
if isValid(selectedOption.presetValues)
for each presetKey in selectedOption.presetValues
user.settings.Save(presetKey, selectedOption.presetValues[presetKey])
end for
m.themeColorsChanged = true
end if
' Refresh menu if sibling items have visibleWhen referencing this setting
if hasDependentVisibility(selectedSetting.settingName)
refreshCurrentMenu()
end if
end sub
' Evaluate a visibleWhen condition against current user settings
' @param {object} condition - { settingName: string, value: string }
' @return {boolean} - true if the setting matches the required value
function isSettingVisible(condition as object) as boolean
if not isValid(condition) or not isValid(condition.settingName) or not isValid(condition.value)
return true
end if
currentValue = m.global.user.settings[condition.settingName]
return currentValue = condition.value
end function
' Check if any sibling items in the current menu depend on a setting for visibility
function hasDependentVisibility(settingName as string) as boolean
currentSection = m.userLocation.peek()
for each item in currentSection.children
if isValid(item.visibleWhen) and item.visibleWhen.settingName = settingName
return true
end if
end for
return false
end function
' Refresh the current menu level in-place (re-filter visibleWhen and re-render)
' Rebuilds filtered children and content without touching the navigation stack
sub refreshCurrentMenu()
currentSection = m.userLocation.peek()
savedIndex = m.settingsMenu.itemFocused
' Rebuild filtered children list
filteredChildren = []
for each item in currentSection.children
if isValid(item.visibleWhen) and not isSettingVisible(item.visibleWhen)
continue for
end if
filteredChildren.push(item)
end for
currentSection.filteredChildren = filteredChildren
' Rebuild content nodes
result = CreateObject("roSGNode", "ContentNode")
for each item in filteredChildren
listItem = result.CreateChild("ContentNode")
listItem.title = translate(item.titleKey)
listItem.Description = translate(item.descriptionKey)
listItem.id = item.id
end for
' Re-inject reset button at root level
if m.userLocation.Count() = 1
resetItem = {
title: "Reset User Settings",
description: "Reset all settings to their default values. Your login session will not be affected.",
titleKey: translationKeys.LabelResetUserSettings,
descriptionKey: translationKeys.SettingResetAllSettingsToTheirDefault,
type: "action",
actionId: "resetUserSettings"
}
filteredChildren.push(resetItem)
listItem = result.CreateChild("ContentNode")
listItem.title = translate(resetItem.titleKey)
listItem.Description = translate(resetItem.descriptionKey)
listItem.id = "resetUserSettings"
end if
m.settingsMenu.content = result
' Restore focus, clamping to new list bounds
if savedIndex >= filteredChildren.Count()
savedIndex = filteredChildren.Count() - 1
end if
if savedIndex >= 0
m.settingsMenu.jumpToItem = savedIndex
end if
end sub
' Backup current custom color values before applying a preset
sub backupCustomColors()
userSettings = m.global.user.settings
m.customColorBackup = {
uiThemeColorPrimary: userSettings.uiThemeColorPrimary,
uiThemeColorSecondary: userSettings.uiThemeColorSecondary,
uiThemeColorBackgroundPrimary: userSettings.uiThemeColorBackgroundPrimary,
uiThemeColorBackgroundSecondary: userSettings.uiThemeColorBackgroundSecondary,
uiThemeColorTextPrimary: userSettings.uiThemeColorTextPrimary,
uiThemeColorTextSecondary: userSettings.uiThemeColorTextSecondary,
uiThemeColorTextDisabled: userSettings.uiThemeColorTextDisabled
}
end sub
' Restore custom color values from backup
sub restoreCustomColors()
if not isValid(m.customColorBackup) then return
for each key in m.customColorBackup
user.settings.Save(key, m.customColorBackup[key])
end for
m.themeColorsChanged = true
end sub
' Check if the current color values differ from the backed-up custom colors
' @return {boolean} - true if any color has changed from the backup
function hasCustomColorsChanged() as boolean
if not isValid(m.customColorBackup) then return false
userSettings = m.global.user.settings
for each key in m.customColorBackup
if userSettings[key] <> m.customColorBackup[key]
return true
end if
end for
return false
end function
' Show confirmation dialog before resetting all user settings
sub confirmResetUserSettings()
m.resetConfirmDialog = showConfirmDialog(translate(translationKeys.LabelResetUserSettings), translate(translationKeys.MessageAreYouSureYouWantTo2), "onResetUserSettingsResponse", translate(translationKeys.ButtonReset), translate(translationKeys.ButtonCancel))
end sub
' Handle user response from reset user settings confirmation dialog
sub onResetUserSettingsResponse()
dialog = m.resetConfirmDialog
m.resetConfirmDialog = invalid
if not isValid(dialog) or not isValid(dialog.result) or not dialog.result.confirmed
' User selected "Cancel" or closed dialog
m.settingsMenu.setFocus(true)
return
end if
executeResetUserSettings()
end sub
' Reset all user settings to defaults from settings.json
' Preserves session keys (authToken, username, serverId, primaryImageTag, LastRunVersion)
sub executeResetUserSettings()
localUser = m.global.user
userId = localUser.id
if not isValid(userId) or userId = ""
m.log.warn("Cannot reset settings - user ID is invalid")
m.settingsMenu.setFocus(true)
return
end if
m.log.info("Resetting all user settings to defaults")
' Disable auto-sync to prevent observers from firing during cleanup
localUser.settings.callFunc("disableAutoSync")
' Delete all non-session keys from user registry
sessionKeys = ["authToken", "username", "serverId", "primaryImageTag", "LastRunVersion"]
allRegistryData = RegistryReadAll(userId)
keysToDelete = getSettingKeysToDelete(allRegistryData, sessionKeys)
for each key in keysToDelete
registryDelete(key, userId)
end for
' Reload all defaults from settings.json onto the settings node
user.settings.SaveDefaults()
' globalRememberMe was reset to false — clear auto-login state
unsetSetting("active_user")
' Clear per-library display settings
localUser.settings.displaySettings = {}
' Re-enable auto-sync for future changes
localUser.settings.callFunc("enableAutoSync")
' Theme colors were reset to defaults - flag for refresh on exit
m.themeColorsChanged = true
m.log.info("User settings reset complete", { deletedKeys: keysToDelete.Count() })
' Show success feedback (toast lives on JRScene, persists across scene transitions)
m.top.getScene().callFunc("showToast", translate(translationKeys.LabelSettingsResetToDefaults), "success")
' Exit settings and reload home with fresh theme colors
performSettingsExit()
end sub
' JRScreen hook that gets ran when the screen is shown.
sub onScreenShown()
' Clear backdrop on settings screens
m.global.sceneManager.callFunc("setBackgroundImage", "")
' Ensure settings menu has focus every time this screen is shown.
' init() calls setFocus before the node is in the scene graph (silently fails),
' and SceneManager's fallback focuses the Group, not settingsMenu — breaking
' all BACK key handling since onKeyEvent checks settingsMenu.focusedChild.
if isValid(m.settingsMenu)
m.settingsMenu.setFocus(true)
end if
end sub
' JRScreen hook that gets ran as needed.
' Assumes settings were changed and they affect the device profile.
' Posts a new device profile to the server via fire-and-forget side effect.
sub onScreenHidden()
SubmitSideEffect(GetApi().BuildPostSessionCapabilitiesRequest(getDeviceCapabilities()))
end sub
' Returns true if any of the data entry forms are in focus
function isFormInFocus() as boolean
if isValid(m.settingDetail.focusedChild) or m.radioSetting.hasFocus() or m.boolSetting.hasFocus() or m.integerSetting.hasFocus() or m.hexSetting.hasFocus()
return true
end if
return false
end function
' Exit settings screen, refreshing theme colors only if they changed
' Shows confirmation dialog if custom colors would be permanently lost
sub exitSettingsAndReloadHome()
' Check if custom colors will be lost (user switched from Custom to a preset)
if isValid(m.customColorBackup) and m.global.user.settings.uiTheme <> "custom" and hasCustomColorsChanged()
' Yes = replace the custom colors. The helper puts the affirmative on the
' RIGHT (cancel-left convention), so this reads [No, Yes] rather than the
' [Yes, No] the legacy call used — consistent with every other confirm now.
m.exitConfirmDialog = showConfirmDialog(translate(translationKeys.LabelReplaceCustomColors), translate(translationKeys.MessageYourCustomColorsWillBeReplaced), "onExitConfirmResponse", translate(translationKeys.LabelYes), translate(translationKeys.LabelNo))
return
end if
performSettingsExit()
end sub
' Handle user response from exit confirmation dialog
sub onExitConfirmResponse()
dialog = m.exitConfirmDialog
m.exitConfirmDialog = invalid
if not isValid(dialog) or not isValid(dialog.result) or not dialog.result.confirmed
' User selected "No" or closed dialog - restore custom colors and stay
restoreCustomColors()
user.settings.Save("uiTheme", "custom")
refreshCurrentMenu()
m.settingsMenu.setFocus(true)
return
end if
' User confirmed - clear backup and exit
m.customColorBackup = invalid
performSettingsExit()
end sub
' Handle language selection from LanguagePicker component
sub onLanguagePickerSelected()
' Guard against being triggered during init or programmatic selectedCode changes
if m.suppressLanguagePicker then return
if not isValid(m.userLocation) or m.userLocation.count() = 0 then return
newLocale = m.languagePicker.selectedCode
selectedSetting = m.userLocation.peek().filteredChildren[m.settingsMenu.itemFocused]
' Save to registry (empty string = automatic). The auto-sync observer routes
' global* settings to the device-wide section and per-user settings to the
' user's section.
user.settings.Save(selectedSetting.settingName, newLocale)
' globalTranslationLocale is the device-wide sign-in-screen language, consumed
' ONLY pre-login (see resolveTranslationLocale). It must NOT live-reload the
' current post-login session — that would re-theme the home screen the user is
' looking at to the device default, overriding their own language. It takes
' effect the next time the sign-in screens render (Sign Out / Change User),
' which re-resolve the pre-login locale at the appStart login-flow entry.
if selectedSetting.settingName = "globalTranslationLocale" then return
' Resolve and load the new locale
if newLocale = ""
resolvedLocale = resolveTranslationLocale(true)
else
resolvedLocale = newLocale
end if
if resolvedLocale <> m.global.translationLocale
loadTranslations(resolvedLocale)
m.languageChanged = true
end if
end sub
' Perform the actual settings exit (apply theme changes and navigate)
sub performSettingsExit()
if m.themeColorsChanged or m.languageChanged
if m.themeColorsChanged
applyThemeColorOverrides(m.global.user.settings)
m.global.sceneManager.callFunc("refreshThemeColors")
end if
' reloadHome signals main.bs to re-navigate the router to "/" for a fresh,
' re-themed/re-translated Home (see JRScene.reloadRoutedHome).
m.global.sceneManager.callFunc("reloadHome")
else
' Settings is a routed view — pop it via the router (back to Home).
sgrouter.goBack()
end if
end sub
function onKeyEvent(key as string, press as boolean) as boolean
if not press then return false
if (key = "back" or key = "left") and isValid(m.settingsMenu.focusedChild) and m.userLocation.Count() > 1
LoadMenu({})
return true
else if (key = "back" or key = "left") and isFormInFocus()
m.settingsMenu.setFocus(true)
return true
else if key = "back" and isValid(m.settingsMenu.focusedChild) and m.userLocation.Count() = 1
' Exiting Settings - apply theme colors and reload home screen
exitSettingsAndReloadHome()
return true
end if
if key = "options"
exitSettingsAndReloadHome()
return true
end if
if key = "right"
settingSelected()
end if
if key = "up" and isValid(m.settingsMenu.focusedChild) and m.settingsMenu.itemFocused = 0
m.settingsMenu.jumpToItem = m.settingsMenu.content.getChildCount() - 1
return true
end if
if key = "down" and isValid(m.settingsMenu.focusedChild)
if m.settingsMenu.itemFocused = m.settingsMenu.content.getChildCount() - 1
m.settingsMenu.jumpToItem = 0
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.
sub onDestroy()
m.log.verbose("onDestroy")
' Unobserve all child node observers
m.integerSetting.unobserveField("submit")
m.integerSetting.unobserveField("escape")
m.hexSetting.unobserveField("submit")
m.hexSetting.unobserveField("escape")
m.hexSetting.unobserveField("reset")
m.alphaSetting.unobserveField("submit")
m.alphaSetting.unobserveField("escape")
m.alphaSetting.unobserveField("reset")
m.languagePicker.unobserveField("selectedCode")
m.settingsMenu.unobserveField("itemFocused")
m.settingsMenu.unobserveField("itemSelected")
m.boolSetting.unobserveField("checkedItem")
m.radioSetting.unobserveField("checkedItem")
' Drop any confirmation still on screen — it is a scene child, so it would
' otherwise outlive this view (see abandonDialog in source/utils/dialogs.bs)
abandonDialog(m.resetConfirmDialog)
abandonDialog(m.exitConfirmDialog)
abandonDialog(m.rangeConfirmDialog)
m.resetConfirmDialog = invalid
m.exitConfirmDialog = invalid
m.rangeConfirmDialog = invalid
' Cleared with their dialog, not just alongside it — see onIntegerRangeDialogResponse.
m.rangeConfirmSettingName = invalid
m.rangeConfirmValue = invalid
' Clear node references
m.settingsMenu = invalid
m.settingDetail = invalid
m.settingDesc = invalid
m.path = invalid
m.boolSetting = invalid
m.integerSetting = invalid
m.radioSetting = invalid
m.hexSetting = invalid
m.alphaSetting = invalid
m.languagePicker = invalid
' Clear data structures
m.userLocation = invalid
m.configTree = invalid
m.customColorBackup = invalid
end sub