components_data_SceneManager.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("SceneManager")
m.groups = []
m.scene = m.top.getScene()
m.content = m.scene.findNode("content")
m.overhang = m.scene.findNode("overhang")
end sub
' Push a new group onto the stack, replacing the existing group on the screen
sub pushScene(newGroup)
currentGroup = m.groups.peek()
if isValid(newGroup)
if isValid(currentGroup)
'Search through group and store off last focused item
if isValid(currentGroup.focusedChild)
focused = currentGroup.focusedChild
while focused.hasFocus() = false
focused = focused.focusedChild
end while
currentGroup.lastFocus = focused
currentGroup.setFocus(false)
else
currentGroup.setFocus(false)
end if
if currentGroup.isSubType("JRGroup")
unregisterOverhangData(currentGroup)
end if
currentGroup.visible = false
if currentGroup.isSubType("JRScreen")
currentGroup.callFunc("onScreenHidden")
end if
end if
m.groups.push(newGroup)
if isValid(currentGroup)
m.content.replaceChild(newGroup, 0)
else
m.content.appendChild(newGroup)
end if
if newGroup.isSubType("JRScreen")
newGroup.callFunc("onScreenShown")
end if
'observe info about new group, set overhang title, etc.
if newGroup.isSubType("JRGroup")
registerOverhangData(newGroup)
' Some groups set focus to a specific component within init(), so we don't want to
' change if that is the case.
if newGroup.isInFocusChain() = false
newGroup.setFocus(true)
end if
end if
else
currentGroup.focusedChild.setFocus(true)
end if
end sub
' Remove the current group and load the last group from the stack
sub popScene()
' If this is the last scene on the stack, show exit confirmation instead of immediately exiting
if m.groups.count() <= 1
showConfirmationDialog(translate(translationKeys.LabelExitJellyrock), [translate(translationKeys.MessageAreYouSureYouWantTo)], [translate(translationKeys.ButtonCancel), translate(translationKeys.ButtonExit)])
m.top.isPendingExitConfirmation = true
return
end if
group = m.groups.pop()
if isValid(group)
if group.isSubType("JRGroup")
unregisterOverhangData(group)
else if group.isSubType("Video")
' Stop video to make sure app communicates stop playstate to server
group.control = "stop"
end if
group.visible = false
if group.isSubType("JRScreen")
group.callFunc("onScreenHidden")
group.callFunc("onDestroy")
else if group.isSubType("Video")
group.callFunc("onDestroy")
end if
else
' Exit app if for some reason we don't have anything on the stack
m.scene.exit = true
end if
group = m.groups.peek()
if isValid(group)
registerOverhangData(group)
group.visible = true
m.content.replaceChild(group, 0)
if group.isSubType("JRScreen")
group.callFunc("onScreenShown")
else
' Restore focus
if isValid(group.lastFocus)
group.lastFocus.setFocus(true)
else
if isValid(group.focusedChild)
group.focusedChild.setFocus(true)
else
group.setFocus(true)
end if
end if
end if
else
' Exit app if the stack is empty after removing group
m.scene.exit = true
end if
end sub
' Return group at top of stack without removing
function getActiveScene() as object
return m.groups.peek()
end function
' Clear all content from group stack
sub clearScenes()
' Hide overhang immediately so all element removals (logo, tabs, title)
' happen off-screen — prevents staggered visual cleanup across frames
if isValid(m.overhang) then m.overhang.visible = false
' Unregister overhang observers from the active group before teardown
activeGroup = m.groups.peek()
if isValid(activeGroup) and activeGroup.isSubType("JRGroup")
unregisterOverhangData(activeGroup)
end if
if isValid(m.content) then m.content.removeChildrenIndex(m.content.getChildCount(), 0)
for each group in m.groups
if type(group) = "roSGNode"
if group.isSubtype("JRScreen")
group.callFunc("onScreenHidden")
group.callFunc("onDestroy")
else if group.isSubtype("Video")
group.callFunc("onDestroy")
end if
end if
end for
m.groups = []
end sub
' Signal that home screen should be reloaded (used after theme color changes)
' Main.bs observes reloadHomeRequested field and handles the actual reload
sub reloadHome()
m.top.reloadHomeRequested = true
end sub
' Clear previous scene from group stack
sub clearPreviousScene()
m.groups.pop()
end sub
' Delete scene from group stack at passed index
sub deleteSceneAtIndex(index = 1)
m.groups.Delete(index)
end sub
' Display user/device settings screen
sub settings()
settingsScreen = createObject("roSGNode", "Settings")
pushScene(settingsScreen)
end sub
' Register observers for overhang data
sub registerOverhangData(group)
if group.isSubType("JRGroup")
if group.isOverhangVisible
m.overhang.visible = true
else
m.overhang.visible = false
end if
group.observeField("isOverhangVisible", "updateOverhangVisible")
' Set tabs BEFORE the title so onTabsChanged can hide the title before it
' renders with text — prevents a visible title→tab transition flash
m.overhang.selectedTabId = group.selectedTabId
m.overhang.tabs = group.overhangTabs
group.observeField("overhangTabs", "updateOverhangTabs")
m.overhang.observeField("selectedTabId", "onOverhangTabSelected")
if isValid(group.overhangTitle) then m.overhang.title = group.overhangTitle
group.observeField("overhangTitle", "updateOverhangTitle")
end if
end sub
' Remove observers for overhang data
sub unregisterOverhangData(group)
group.unobserveField("overhangTitle")
group.unobserveField("overhangTabs")
group.unobserveField("isOverhangVisible")
m.overhang.unobserveField("selectedTabId")
end sub
' Update overhang title
sub updateOverhangTitle(msg)
m.overhang.title = msg.getData()
end sub
' Update whether the overhang is visible or not
sub updateOverhangVisible(msg)
m.overhang.visible = msg.getData()
end sub
' Update overhang tabs when group's overhangTabs changes
sub updateOverhangTabs(msg)
m.overhang.tabs = msg.getData()
end sub
' Proxy overhang tab selection back to the active group's selectedTabId
sub onOverhangTabSelected()
group = m.groups.peek()
if isValid(group) and group.isSubType("JRGroup")
group.selectedTabId = m.overhang.selectedTabId
end if
end sub
' Update username in overhang
sub updateUser()
' Passthrough to overhang
if isValid(m.overhang) then m.overhang.currentUser = m.top.currentUser
end sub
' Reset time
sub resetTime()
' Passthrough to overhang
m.overhang.callFunc("resetTime")
end sub
' Set the background image
' @param {string} uri - The image URI to display
' @param {boolean} isAnimated - Whether to animate the transition
' @param {boolean} forceBackdrop - Force show backdrop regardless of user setting (used for login splashscreen)
sub setBackgroundImage(uri as string, isAnimated = true as boolean, forceBackdrop = false as boolean)
m.log.info("SceneManager.setBackgroundImage called", uri, isAnimated, forceBackdrop)
' Passthrough to scene via callFunc
if isValid(m.scene)
m.scene.callFunc("setBackgroundImage", uri, isAnimated, forceBackdrop)
else
m.log.error("SceneManager.setBackgroundImage: m.scene is invalid!")
end if
end sub
' Display dialog to user with an OK button
sub userMessage(title as string, message as string)
dialog = createObject("roSGNode", "StandardMessageDialog")
dialog.title = title
' StandardMessageDialog.message is an array-of-strings field; assigning a
' bare string fails silently and renders an empty body.
dialog.message = [message]
dialog.buttons = [translate(translationKeys.ButtonOk)]
dialog.observeField("buttonSelected", "dismissDialog")
m.scene.dialog = dialog
end sub
' Display dialog to user with an OK button
sub standardDialog(title, message)
constants = m.global.constants
dialog = createObject("roSGNode", "StandardDialog")
dlgPalette = createObject("roSGNode", "RSGPalette")
dlgPalette.colors = {
DialogBackgroundColor: constants.colorBackgroundPrimary,
DialogFocusColor: constants.colorPrimary,
DialogFocusItemColor: constants.colorTextPrimary,
DialogSecondaryTextColor: constants.colorTextSecondary,
DialogSecondaryItemColor: constants.colorSecondary,
DialogTextColor: constants.colorTextPrimary
}
dialog.palette = dlgPalette
dialog.observeField("buttonSelected", "dismissDialog")
dialog.title = title
dialog.setField("contentData", message) ' setField: BSC cannot resolve custom fields on built-in dialog subtypes
dialog.setField("buttons", [translate(translationKeys.ButtonOk)])
m.scene.dialog = dialog
end sub
' Display dialog to user with an OK button
sub radioDialog(title, message)
constants = m.global.constants
dialog = createObject("roSGNode", "RadioDialog")
dlgPalette = createObject("roSGNode", "RSGPalette")
dlgPalette.colors = {
DialogBackgroundColor: constants.colorBackgroundPrimary,
DialogFocusColor: constants.colorPrimary,
DialogFocusItemColor: constants.colorTextPrimary,
DialogSecondaryTextColor: constants.colorTextSecondary,
DialogSecondaryItemColor: constants.colorSecondary,
DialogTextColor: constants.colorTextPrimary
}
dialog.palette = dlgPalette
dialog.observeField("buttonSelected", "dismissDialog")
dialog.title = title
dialog.setField("contentData", message) ' setField: BSC cannot resolve custom fields on built-in dialog subtypes
dialog.setField("buttons", [translate(translationKeys.ButtonOk)])
m.scene.dialog = dialog
end sub
' Display dialog to user with an OK button
sub showConfirmationDialog(title, message, buttons)
constants = m.global.constants
m.top.isDataReturned = false
m.top.returnData = invalid
m.userselection = false
dialog = createObject("roSGNode", "StandardMessageDialog")
dlgPalette = createObject("roSGNode", "RSGPalette")
dlgPalette.colors = {
DialogBackgroundColor: constants.colorBackgroundPrimary,
DialogFocusColor: constants.colorPrimary,
DialogFocusItemColor: constants.colorTextPrimary,
DialogSecondaryTextColor: constants.colorTextSecondary,
DialogSecondaryItemColor: constants.colorSecondary,
DialogTextColor: constants.colorTextPrimary
}
dialog.palette = dlgPalette
dialog.observeField("buttonSelected", "optionSelected")
dialog.observeField("wasClosed", "optionClosed")
dialog.title = title
if message = invalid
dialog.message = invalid
else if type(message) = "roArray"
dialog.message = message
else
dialog.message = [message]
end if
dialog.buttons = buttons
m.scene.dialog = dialog
end sub
' Return button the user selected
sub optionClosed()
if m.userselection then return
m.top.returnData = {
indexSelected: -1,
buttonSelected: ""
}
m.top.isDataReturned = true
end sub
' Return button the user selected
sub optionSelected()
m.userselection = true
m.top.returnData = {
indexSelected: m.scene.dialog.buttonSelected,
buttonSelected: m.scene.dialog.buttons[m.scene.dialog.buttonSelected]
}
m.top.isDataReturned = true
dismissDialog()
end sub
' Close currently displayed dialog
sub dismissDialog()
m.scene.dialog.close = true
end sub
' Returns bool indicating if dialog is currently displayed
function isDialogOpen() as boolean
return isValid(m.scene.dialog)
end function
' Refresh theme colors on JRScene and overhang.
' Called after theme color settings change (settings exit, login, logout).
' Uses refreshThemeOnTree() to walk the overhang's node tree and update all
' themed elements (labels, buttons, icons) in place — no component recreation
' or per-component refresh functions needed.
sub refreshThemeColors()
m.scene.backgroundColor = m.global.constants.colorBackgroundPrimary
if isValid(m.overhang)
refreshThemeOnTree(m.overhang)
end if
end sub