components_home_Home.bs

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