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")
' 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")
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
' Entry point called by Main.bs createAndShowHomeGroup() BEFORE pushScene().
' Starting data loads before the screen transition completes improves perceived performance.
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)
overhang.isLogoVisible = true
overhang.currentUser = globalUser.name
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
' Enable overhang icons (search + settings) and observe their events once
overhang.shouldShowIcons = true
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
' Restore focus. Priority:
' 1. Overhang element that triggered the last navigation (icon/dropdown action)
' — these are children of the overhang, not Home, so SceneManager's
' lastFocus walk can't find them.
' 2. SceneManager's lastFocus (normal in-group focus restoration).
' 3. Active content (fallback).
' Reset overhang element tracking so the next UP defaults to the active tab
m.lastOverhangElement = invalid
if isValid(m.lastOverhangFocus)
m.lastOverhangFocus.setFocus(true)
m.lastOverhangFocus = invalid
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
' First run: post device capabilities to the server via fire-and-forget side effect.
' No response handling needed — Main.bs already called loadLibraries() before
' pushing this screen, so data is already in flight.
if m.isFirstRun
m.isFirstRun = false
SubmitSideEffect(GetApi().BuildPostSessionCapabilitiesRequest(getDeviceCapabilities()))
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
' 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
scene = m.top.getScene()
overhang = scene.findNode("overhang")
if isValid(overhang)
overhang.isLogoVisible = false
overhang.shouldShowIcons = false
overhang.currentUser = ""
end if
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 = ""
m.top.voiceQuery = voiceText
end sub
' ============================================
' TAB HANDLING
' ============================================
' Called when selectedTabId changes (via SceneManager proxy from overhang tab bar).
' 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
' Proxy selectedItem from whichever row list fires it
sub onRowItemSelected(msg)
m.top.selectedItem = msg.getData()
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
' ============================================
' 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: tab bar may not exist during first onScreenShown (pushScene
' calls onScreenShown before registerOverhangData creates the tab bar)
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 pushScene can properly manage focus transfer.
sub onUserMenuAction()
if isValid(m.userDropdown)
m.lastOverhangFocus = m.userDropdown
' Move focus to Home group (not activeContent — that would visibly highlight
' a row item before the screen transition). m.top is in Home's tree, which
' satisfies SceneManager.pushScene() without any visible focus jump.
m.top.setFocus(true)
m.top.userMenuAction = 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 SceneManager.pushScene()
' can find focus in 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)
m.top.userMenuAction = 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 by SceneManager.popScene() / clearScenes()
sub onDestroy()
m.log.verbose("onDestroy")
m.top.unobserveField("selectedTabId")
' 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