import "pkg:/source/api/ApiClient.bs"
import "pkg:/source/api/apiPool.bs"
import "pkg:/source/enums/HomeAction.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/misc.bs"
import "pkg:/source/utils/textureManager.bs"
import "pkg:/source/utils/translate.bs"
sub init()
m.log = new log.Logger("Home")
m.log.verbose("init")
m.isFirstRun = true
m.top.isOptionsAvailable = true
' HomeRows starts in XML as the default tab content
m.homeRows = m.top.findNode("homeRows")
m.activeContent = m.homeRows
m.activeTabId = "home"
' Proxy item selection from the active content. Cleanup happens via
' destroyActiveContent() against m.activeContent (which aliases m.homeRows
' or m.favoritesRows depending on the active tab).
m.homeRows.observeField("selectedItem", "onRowItemSelected")
m.homeRows.observeField("quickPlayNode", "onQuickPlayNode")
' Item taps that fall through to playback launch via QueueManager. onRowItemSelected /
' onQuickPlayNode set m.top.quickPlayNode; this self-observer forwards the node to the
' launcher.
m.top.observeField("quickPlayNode", "onQuickPlayLaunch")
' Voice search setup — matches BaseGridView's pattern:
' Set voiceEnabled once in init(), never set it to false.
' Only toggle active on screen show/hide.
initVoiceBox()
' Tab configuration
m.top.overhangTabs = [
{ title: translate(translationKeys.LabelHome), id: "home" },
{ title: translate(translationKeys.LabelFavorites), id: "favorites" }
]
m.top.selectedTabId = "home"
m.top.observeField("selectedTabId", "onTabChanged")
' Home is mounted by the router, so it self-wires its overhang title and the options
' side-panel menu.
m.top.overhangTitle = translate(translationKeys.LabelHome)
' Overhang config — declarative fields projected onto the overhang by JRScene (registerOverhangData).
m.top.isLogoVisible = true
m.top.shouldShowIcons = true
m.top.shouldShowUserDropdown = true
buildOptionsMenu()
end sub
' Build the options side-panel (search / settings / change user / change server / sign out).
' The buttons are observed by Home itself; onSidePanelOption routes them — navigation actions
' go through the router, session actions up to main.bs.
sub buildOptionsMenu()
sidepanel = m.top.findNode("options")
if not isValid(sidepanel) then return
m.optionsPanel = sidepanel
sidepanel.observeField("closeSidePanel", "onCloseSidePanel")
menu = [
{ title: translate(translationKeys.ButtonSearch), id: HomeAction.OPEN_SEARCH },
{ title: translate(translationKeys.LabelSettings), id: HomeAction.OPEN_SETTINGS },
{ title: translate(translationKeys.LabelChangeUser), id: HomeAction.CHANGE_USER },
{ title: translate(translationKeys.LabelChangeServer), id: HomeAction.CHANGE_SERVER },
{ title: translate(translationKeys.ButtonSignOut), id: HomeAction.SIGN_OUT }
]
options = []
for each entry in menu
o = CreateObject("roSGNode", "OptionsButton")
o.title = entry.title
o.id = entry.id
o.observeField("optionSelected", "onSidePanelOption")
options.push(o)
end for
m.optionButtons = options
sidepanel.options = options
end sub
' Route a Home menu action. Navigation actions resolve through the router on the
' render thread; session-ending actions (change user/server, sign out) are emitted
' as userMenuAction for main.bs (which owns the LoginFlow re-entry via goto appStart).
sub handleHomeMenuAction(actionId as string)
if actionId = HomeAction.OPEN_SEARCH
sgrouter.navigateTo("/search")
else if actionId = HomeAction.OPEN_SETTINGS
sgrouter.navigateTo("/settings")
else
' Session-reset (change user/server, sign out) needs main.bs to re-enter the login flow.
' Set it on the SCENE, which main.bs observes via its message port — observing a routed
' view's field via port never delivered (the relay defect), but a scene field does (same
' channel as `exit`).
m.top.getScene().userMenuAction = actionId
end if
end sub
' Options side-panel button pressed — close the panel, then route the action.
sub onSidePanelOption(msg)
button = msg.getRoSGNode()
panel = m.top.findNode("options")
if isValid(panel) then panel.visible = false
handleHomeMenuAction(button.id)
end sub
' Options side-panel closed without a selection — restore focus to content.
sub onCloseSidePanel()
if isValid(m.top.lastFocus)
m.top.lastFocus.setFocus(true)
else if isValid(m.activeContent)
m.activeContent.setFocus(true)
else
m.top.setFocus(true)
end if
end sub
sub refresh()
if not isValid(m.activeContent) then return
if m.activeTabId = "home"
m.activeContent.callFunc("updateHomeRows")
else if m.activeTabId = "favorites"
m.activeContent.callFunc("loadFavorites")
end if
end sub
' Kicks off the home-row library load. Called from onScreenShown on first run (the
' router mounts Home, so this is no longer triggered externally by main.bs).
sub loadLibraries()
if not isValid(m.homeRows)
m.log.error("Cannot load libraries - homeRows is invalid")
return
end if
m.homeRows.callFunc("loadLibraries")
end sub
' JRScreen hook called when the screen is displayed by the screen manager
sub onScreenShown()
m.log.info("onScreenShown")
' Configure overhang UI
scene = m.top.getScene()
globalUser = m.global.user
overhang = scene.findNode("overhang")
if isValid(overhang)
m.overhang = overhang
' Cache overhang element references and observe their events ONCE.
' onScreenShown runs on every return (e.g. back from Settings) —
' calling observeField again would stack duplicate observers, causing
' actions to fire N times (1 extra per show).
if not isValid(m.tabBar)
tabBar = overhang.callFunc("getTabBar")
if isValid(tabBar)
m.tabBar = tabBar
m.tabBar.observeField("requestFocusReturn", "onTabBarFocusReturn")
end if
end if
if not isValid(m.userDropdown)
userDropdown = overhang.callFunc("getUserDropdown")
if isValid(userDropdown)
m.userDropdown = userDropdown
m.userDropdown.observeField("requestFocusReturn", "onUserDropdownFocusReturn")
m.userDropdown.observeField("selectedAction", "onUserMenuAction")
end if
end if
' Cache the search/settings icon refs + observe their events once (re-observing stacks).
if not isValid(m.searchIcon)
overhang.observeField("iconAction", "onOverhangIconAction")
searchIcon = overhang.callFunc("getSearchIcon")
if isValid(searchIcon)
m.searchIcon = searchIcon
m.searchIcon.observeField("requestFocusReturn", "onIconFocusReturn")
end if
settingsIcon = overhang.callFunc("getSettingsIcon")
if isValid(settingsIcon)
m.settingsIcon = settingsIcon
m.settingsIcon.observeField("requestFocusReturn", "onIconFocusReturn")
end if
end if
' Hide clock if user preference is set
if globalUser.settings.uiDesignHideClock
overhang.callFunc("hideClock")
end if
end if
' Re-activate voice capture — matching BaseGridView's onScreenShown pattern.
' Only toggle active; voiceEnabled was set once in init() and never changed.
if isValid(m.voiceBox)
m.voiceBox.active = true
end if
' Reset overhang element tracking so the next UP defaults to the active tab
m.lastOverhangElement = invalid
' Restore focus (overhang-icon-aware — see restoreHomeFocus). NOTE: do NOT clear
' m.lastOverhangFocus here. The router calls handleFocus() immediately AFTER onScreenShown,
' and its default restoreFocus(m.top) would override the overhang-icon focus with a Home row;
' Home's handleFocus re-runs restoreHomeFocus and clears the one-shot target once consumed.
restoreHomeFocus()
' First run: load libraries (the router mounts Home now, so this is no longer
' triggered externally by main.bs) and post device capabilities to the server via
' fire-and-forget side effect.
if m.isFirstRun
m.isFirstRun = false
loadLibraries()
SubmitSideEffect(GetApi().BuildPostSessionCapabilitiesRequest(getDeviceCapabilities()))
' Start the ws:// remote-control receiver now that we're authed (it needs the session token).
' isFirstRun is per Home instance, so this restarts the receiver after a server switch too.
m.global.remoteControlTask.control = "RUN"
else
' Subsequent visits: refresh the active tab's data in place
refresh()
end if
' Restore texture management — reactivate and restore buffer range so cells reload.
if isValid(m.activeContent) and isValid(m.activeContent.content)
updateTextureBufferRange(m.activeContent.content, m.activeContent.rowItemFocused[0], m.activeContent.rowItemFocused[1], m.activeContent.numRows)
activateTextureManager(m.activeContent.content)
end if
' Re-evaluate backdrop setting visibility (updates when user changes setting from Settings screen)
scene.callFunc("refreshBackdropSetting")
end sub
' Restore Home's focus, OVERHANG-ICON-AWARE. Priority:
' 1. The overhang element that triggered the last navigation (gear / search / dropdown). These
' live in the persistent overhang — a child of JRScene, NOT of Home — so they can't be reached
' via m.top.lastFocus.
' 2. Home's own lastFocus (normal in-group restoration), skipping the hidden voice box.
' 3. Active content, then the Home group.
' Called by BOTH onScreenShown and handleFocus: the router invokes handleFocus right after
' onScreenShown, and its base restoreFocus(m.top) would otherwise override the overhang-icon focus
' with a Home row (the "gear focus removed on return to Home" regression). Does NOT clear the
' one-shot m.lastOverhangFocus — handleFocus clears it once the resume's focus restore is complete.
sub restoreHomeFocus()
if isValid(m.lastOverhangFocus)
m.lastOverhangFocus.setFocus(true)
else if isValid(m.top.lastFocus) and m.top.lastFocus.id <> "homeVoiceBox"
m.top.lastFocus.setFocus(true)
else if isValid(m.activeContent)
m.activeContent.setFocus(true)
else
m.top.setFocus(true)
end if
end sub
' Router focus hook (overrides JRScreen.handleFocus). The router asks the resuming view to take
' focus right after onViewResume -> onScreenShown. The base implementation uses restoreFocus(m.top),
' which focuses a Home row and clobbers the overhang-icon (gear/search) focus onScreenShown just set.
' Re-run Home's overhang-aware restore so the gear stays focused on return (matching prod), then
' consume the one-shot overhang target.
function handleFocus(_data = {} as object) as boolean
restoreHomeFocus()
m.lastOverhangFocus = invalid
return true
end function
' JRScreen hook called when the screen is hidden by the screen manager
sub onScreenHidden()
m.log.info("onScreenHidden")
' Release texture memory — cells with renderTracking "none" unload their URIs.
if isValid(m.activeContent) and isValid(m.activeContent.content)
hideTextureManager(m.activeContent.content)
end if
' Deactivate voice so other screens can claim the voice route.
' Only toggle active — never set voiceEnabled=false (matches BaseGridView pattern).
if isValid(m.voiceBox)
m.voiceBox.active = false
end if
' Overhang state is reset by the controller on view-change — intentionally not cleared here.
end sub
' ============================================
' VOICE SEARCH
' ============================================
' initVoiceBox: One-time voice setup in init() — matches BaseGridView's pattern.
' Sets voiceEnabled=true ONCE and never sets it back to false. The firmware
' silently manages voiceEnabled when other screens claim voice routing.
' Only `active` is toggled on screen show/hide.
sub initVoiceBox()
m.voiceBox = m.top.findNode("homeVoiceBox")
if not isValid(m.voiceBox)
m.log.error("initVoiceBox: homeVoiceBox not found!")
return
end if
m.voiceBox.opacity = 0.0001
m.voiceBox.hintText = translate(translationKeys.LabelUseVoiceRemoteToSearch)
m.voiceBox.voiceEnabled = true
m.voiceBox.active = true
m.voiceBox.observeField("text", "onVoiceSearch")
end sub
' onVoiceSearch: Fires when voice input is captured by the hidden VoiceTextEditBox.
' Signals Main.bs to open SearchResults with the spoken query pre-populated.
sub onVoiceSearch()
voiceText = m.voiceBox.text
if voiceText = "" then return
' Reset for next voice search
m.voiceBox.text = ""
' Record the captured query on Home's interface. Home navigates directly (below), but the
' field is kept as the last-captured-query record (and is what the unit suite asserts).
m.top.voiceQuery = voiceText
' Open the search route with the spoken query as context (the SearchResults view reads
' route.context.query in onViewOpen / applyRouteQuery).
sgrouter.navigateTo("/search", { context: { query: voiceText } })
end sub
' ============================================
' TAB HANDLING
' ============================================
' Called when selectedTabId changes (proxied back from the overhang tab bar by JRScene's
' overhang controller).
' Destroys the old tab's content and creates the new tab's content.
sub onTabChanged()
tabId = m.top.selectedTabId
if tabId = m.activeTabId then return
' Destroy the current content
destroyActiveContent()
m.activeTabId = tabId
if tabId = "home"
' Create HomeRows, add to scene, load data
m.homeRows = CreateObject("roSGNode", "HomeRows")
m.activeContent = m.homeRows
m.top.insertChild(m.homeRows, 0)
m.homeRows.observeField("selectedItem", "onRowItemSelected")
m.homeRows.observeField("quickPlayNode", "onQuickPlayNode")
m.homeRows.callFunc("loadLibraries")
m.homeRows.setFocus(true)
else if tabId = "favorites"
' Create FavoritesRows, add to scene, load data
m.favoritesRows = CreateObject("roSGNode", "FavoritesRows")
m.activeContent = m.favoritesRows
m.top.insertChild(m.favoritesRows, 0)
m.favoritesRows.observeField("selectedItem", "onRowItemSelected")
m.favoritesRows.observeField("quickPlayNode", "onQuickPlayNode")
m.favoritesRows.callFunc("loadFavorites")
m.favoritesRows.setFocus(true)
end if
end sub
' Destroys the currently active tab content and removes it from the scene
sub destroyActiveContent()
if not isValid(m.activeContent) then return
m.activeContent.unobserveField("selectedItem")
m.activeContent.unobserveField("quickPlayNode")
if m.activeTabId = "home" and isValid(m.homeRows)
m.homeRows.callFunc("onDestroy")
m.top.removeChild(m.homeRows)
m.homeRows = invalid
else if m.activeTabId = "favorites" and isValid(m.favoritesRows)
m.favoritesRows.callFunc("onDestroy")
m.top.removeChild(m.favoritesRows)
m.favoritesRows = invalid
end if
m.activeContent = invalid
end sub
' Returns the currently active row list
function getActiveRows() as object
return m.activeContent
end function
' A row item was selected — navigate to its route, passing the rich content node as context
' (so details/library self-load without a re-fetch). Items that aren't view navigation (e.g.
' Chapter) fall through to the playback path via quickPlayNode.
sub onRowItemSelected(msg)
item = msg.getData()
if not isValid(item) then return
route = routeForItem(item)
if isValid(route)
sgrouter.navigateTo(route, { context: { item: item } })
else
m.top.quickPlayNode = item
end if
end sub
' Proxy quickPlayNode from whichever row list fires it.
' Must pass through invalid clears so Home.quickPlayNode doesn't retain
' a stale value that re-fires the m.port observer on screen restoration.
sub onQuickPlayNode(msg)
m.top.quickPlayNode = msg.getData()
end sub
' Forward a fallen-through item tap to the playback launcher.
sub onQuickPlayLaunch(msg)
node = msg.getData()
if isValid(node) then m.global.queueManager.callFunc("launchItem", node)
end sub
' ============================================
' FOCUS MANAGEMENT
' ============================================
function onKeyEvent(key as string, press as boolean) as boolean
if not press then return false
' UP at top of content → focus the overhang.
' First entry after screen show defaults to the tab bar (active tab).
' Subsequent entries restore the last overhang element the user was on.
if key = "up"
activeRows = getActiveRows()
rowItemFocused = activeRows?.rowItemFocused
if isValid(activeRows) and type(rowItemFocused) = "roArray" and rowItemFocused.count() >= 2 and rowItemFocused[0] = 0
' Restore last overhang element if the user has visited before
if isValid(m.lastOverhangElement)
m.lastOverhangElement.setFocus(true)
return true
end if
' First visit — default to the tab bar (active tab)
' Lazy-init: the tab bar is created by the shared overhang (from JRScene's
' registerOverhangData), not by Home, so m.tabBar may still be uncached here —
' grab it from the overhang on first use.
if not isValid(m.tabBar) and isValid(m.overhang)
tabBar = m.overhang.callFunc("getTabBar")
if isValid(tabBar)
m.tabBar = tabBar
m.tabBar.observeField("requestFocusReturn", "onTabBarFocusReturn")
end if
end if
if isValid(m.tabBar)
m.tabBar.setFocus(true)
return true
end if
end if
end if
return false
end function
' User dropdown signals DOWN was pressed — return focus to active content
sub onUserDropdownFocusReturn()
m.lastOverhangElement = m.userDropdown
if isValid(m.activeContent)
m.activeContent.setFocus(true)
end if
end sub
' User dropdown menu item was selected — proxy to Home's interface for Main.bs.
' Saves the dropdown so onScreenShown can restore focus when the user returns,
' then moves focus into Home's tree so the router's suspend saves a focus that
' back-from-route can restore.
sub onUserMenuAction()
if isValid(m.userDropdown)
m.lastOverhangFocus = m.userDropdown
' Move focus into Home's tree before navigating so back-from-route restores it.
m.top.setFocus(true)
' Route the action (search/settings navigate; session actions emit userMenuAction up to
' main.bs).
handleHomeMenuAction(m.userDropdown.selectedAction)
end if
end sub
' Overhang icon (search/settings) was pressed — proxy to Home's interface for Main.bs.
' Saves the focused icon for focus restoration on return, then moves focus to
' Home group BEFORE firing the action. This ensures the router's suspend saves a
' focus inside Home's tree. We focus m.top (not activeContent) to avoid
' a visible focus jump to the row list before the screen transition.
sub onOverhangIconAction()
if isValid(m.overhang)
' Determine which icon fired the action and save it for focus restoration.
' Use the action ID rather than hasFocus() — the field observer callback
' may fire after focus has already moved, making hasFocus() unreliable.
action = m.overhang.iconAction
if action = HomeAction.OPEN_SEARCH and isValid(m.searchIcon)
m.lastOverhangFocus = m.searchIcon
else if action = HomeAction.OPEN_SETTINGS and isValid(m.settingsIcon)
m.lastOverhangFocus = m.settingsIcon
end if
' Move focus to Home group — not activeContent (avoids visible row highlight)
m.top.setFocus(true)
' Search/settings icons navigate through the router.
handleHomeMenuAction(m.overhang.iconAction)
end if
end sub
' Overhang icon signals DOWN was pressed — return focus to active content
sub onIconFocusReturn(msg as object)
m.lastOverhangElement = msg.getRoSGNode()
if isValid(m.activeContent)
m.activeContent.setFocus(true)
end if
end sub
' Tab bar signals DOWN was pressed — return focus to active content
sub onTabBarFocusReturn()
m.lastOverhangElement = m.tabBar
if isValid(m.activeContent)
m.activeContent.setFocus(true)
end if
end sub
' ============================================
' TEARDOWN
' ============================================
' 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")
m.top.unobserveField("selectedTabId")
m.top.unobserveField("quickPlayNode")
' Release the options side-panel observers wired in buildOptionsMenu
if isValid(m.optionsPanel)
m.optionsPanel.unobserveField("closeSidePanel")
m.optionsPanel = invalid
end if
if isValid(m.optionButtons)
for each o in m.optionButtons
o.unobserveField("optionSelected")
end for
m.optionButtons = invalid
end if
' Release voice box — match BaseGridView: never set voiceEnabled=false,
' just unobserve and release the reference.
if isValid(m.voiceBox)
m.voiceBox.unobserveField("text")
m.voiceBox = invalid
end if
' Release tab bar observer
if isValid(m.tabBar)
m.tabBar.unobserveField("requestFocusReturn")
m.tabBar = invalid
end if
' Release user dropdown observers
if isValid(m.userDropdown)
m.userDropdown.unobserveField("requestFocusReturn")
m.userDropdown.unobserveField("selectedAction")
m.userDropdown = invalid
end if
' Release overhang icon observers
m.lastOverhangFocus = invalid
m.lastOverhangElement = invalid
if isValid(m.overhang)
m.overhang.unobserveField("iconAction")
end if
if isValid(m.searchIcon)
m.searchIcon.unobserveField("requestFocusReturn")
m.searchIcon = invalid
end if
if isValid(m.settingsIcon)
m.settingsIcon.unobserveField("requestFocusReturn")
m.settingsIcon = invalid
end if
' Destroy active tab content
destroyActiveContent()
end sub