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")

  ' 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