components_ItemGrid_BaseGridView.bs

' BaseGridView: Unified library grid view component
'
' Uses presenter pattern for media-type-specific behavior.
' Combines common functionality from ItemGrid, MovieLibraryView, and MusicLibraryView.

import "pkg:/source/api/baserequest.bs"
import "pkg:/source/api/Image.bs"
import "pkg:/source/GridView/GenericPresenter.bs"
import "pkg:/source/GridView/LiveTVPresenter.bs"
import "pkg:/source/GridView/MoviePresenter.bs"
import "pkg:/source/GridView/MusicPresenter.bs"
import "pkg:/source/GridView/PhotoPresenter.bs"
import "pkg:/source/GridView/TVShowPresenter.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/utils/config.bs"
import "pkg:/source/utils/deviceCapabilities.bs"
import "pkg:/source/utils/displaySettings.bs"
import "pkg:/source/utils/misc.bs"

sub init()
  m.log = log.Logger("BaseGridView")
  m.log.debug("start init()")
  setupNodes()

  userSettings = m.global.user.settings

  m.showItemCount = userSettings.itemGridShowItemCount

  m.loadedRows = 0
  m.loadedItems = 0

  m.data = CreateObject("roSGNode", "ContentNode")
  m.itemGrid.content = m.data

  m.genreData = CreateObject("roSGNode", "ContentNode")
  m.genreList.focusXOffset = 0
  m.genreList.rowLabelOffset = [0, 21]
  m.genreList.observeField("rowItemFocused", "onGenreItemFocused")
  m.genreList.observeField("itemSelected", "onGenreItemSelected")
  m.genreList.content = m.genreData

  m.itemGrid.observeField("itemFocused", "onItemFocused")
  m.itemGrid.observeField("itemSelected", "onItemSelected")

  ' Voice filter setup
  m.voiceBox.opacity = 0.0001
  m.voiceBox.voiceEnabled = true
  m.voiceBox.active = true
  m.voiceBox.observeField("text", "onVoiceFilter")
  m.voiceBox.hintText = tr("Use voice remote to search")

  ' Sort/filter defaults
  m.sortField = "SortName"
  m.sortAscending = true
  m.filter = "All"
  m.filterOptions = {}
  m.favorite = "Favorite"

  ' Create load task
  m.loadItemsTask = createObject("roSGNode", "LoadItemsTask2")
  m.loadItemsTask.totalRecordCount = 0

  ' Get reset folder setting
  m.resetGrid = userSettings.itemGridReset
  m.top.gridTitles = userSettings.itemGridTitles

  ' Presenter (set via setPresenter function)
  m.presenter = invalid

  ' TV Guide state (LiveTV only)
  m.tvGuide = invalid
  m.channelFocused = invalid

  m.log.debug("end init()")
end sub

sub setupNodes()
  m.options = m.top.findNode("options")
  m.itemGrid = m.top.findNode("itemGrid")
  m.voiceBox = m.top.findNode("VoiceBox")
  m.emptyText = m.top.findNode("emptyText")
  m.alpha = m.top.findNode("alpha")
  m.alphaMenu = m.alpha.findNode("alphaMenu")
  m.genreList = m.top.findNode("genreList")
  m.presentationBackdrop = m.top.findNode("presentationBackdrop")
  m.presentationInfo = m.top.findNode("presentationInfo")
end sub

' ============================================================================
' Presenter Management
' ============================================================================

' Called when presenterType field changes - creates the appropriate presenter
sub onPresenterTypeChanged()
  presenterType = m.top.presenterType
  if presenterType = "" then return

  m.log.debug("Creating presenter of type:", presenterType)

  if presenterType = "movie"
    m.presenter = new MoviePresenter()
  else if presenterType = "music"
    m.presenter = new MusicPresenter()
  else if presenterType = "tvshow"
    m.presenter = new TVShowPresenter()
  else if presenterType = "livetv"
    m.presenter = new LiveTVPresenter()
  else if presenterType = "photo"
    m.presenter = new PhotoPresenter()
  else
    m.presenter = new GenericPresenter()
  end if

  if isValid(m.presenter)
    m.presenter.onInit(m)
  end if
end sub

' ============================================================================
' Lifecycle
' ============================================================================

sub OnScreenShown()
  ' Clear global backdrop - we handle our own backdrops
  m.global.sceneManager.callFunc("setBackgroundImage", "")

  if isValid(m.top.lastFocus)
    m.top.lastFocus.setFocus(true)
  else
    m.top.setFocus(true)
  end if
end sub

' ============================================================================
' TV Guide Integration (LiveTV)
' ============================================================================

' Show TV Guide (EPG) for LiveTV
sub showTVGuide()
  if isValid(m.tvGuide) then return

  m.log.debug("Creating TV Guide")
  m.top.signalBeacon("EPGLaunchInitiate")

  ' Create Schedule component
  m.tvGuide = CreateObject("roSGNode", "Schedule")
  m.tvGuide.observeField("watchChannel", "onTVGuideWatchChannel")
  m.tvGuide.observeField("focusedChannel", "onTVGuideFocusedChannel")

  ' Pass current filter and search state to TV Guide
  m.tvGuide.filter = m.filter
  m.tvGuide.searchTerm = m.voiceBox.text

  ' Hide the grid and show the guide
  m.itemGrid.visible = false
  m.top.appendChild(m.tvGuide)
  m.tvGuide.lastFocus.setFocus(true)
  m.top.lastFocus = m.tvGuide.lastFocus
end sub

' Hide TV Guide and return to grid
sub hideTVGuide()
  if not isValid(m.tvGuide) then return

  m.log.debug("Hiding TV Guide")
  m.tvGuide.unobserveField("watchChannel")
  m.tvGuide.unobserveField("focusedChannel")
  m.top.removeChild(m.tvGuide)
  m.tvGuide = invalid
  m.channelFocused = invalid

  m.itemGrid.visible = true
  m.itemGrid.setFocus(true)
  m.top.lastFocus = m.itemGrid
end sub

' Handle channel selection from TV Guide
sub onTVGuideWatchChannel()
  if not isValid(m.tvGuide) or not isValid(m.tvGuide.watchChannel) then return

  m.log.debug("TV Guide channel selected")
  m.top.lastFocus = m.tvGuide.lastFocus

  ' Clone the node to ensure playback triggers properly
  m.top.selectedItem = m.tvGuide.watchChannel.clone(false)

  ' Reset watchChannel to allow same channel to be selected again
  m.tvGuide.watchChannel = invalid
end sub

' Handle channel focus from TV Guide
sub onTVGuideFocusedChannel()
  if isValid(m.tvGuide)
    m.channelFocused = m.tvGuide.focusedChannel
  end if
end sub

' ============================================================================
' Data Loading
' ============================================================================

' Prepare for data loading by stopping any active task, showing spinner, and hiding empty text
' @param {boolean} [disableRemote=false] - Whether to disable remote input during loading
sub prepareDataLoad(disableRemote = false as boolean)
  m.loadItemsTask.control = "stop"
  startLoadingSpinner(disableRemote)
  m.emptyText.visible = false
end sub

sub loadInitialItems()
  m.log.debug("start loadInitialItems()")
  prepareDataLoad(false)

  if isValid(m.top.parentItem.json) and m.top.parentItem.json.Type = "CollectionFolder"
    m.top.HomeLibraryItem = m.top.parentItem.Id
  end if

  ' Load saved display settings
  libraryId = m.top.parentItem.Id
  m.sortField = getLibraryDisplaySetting(libraryId, "sortField", "SortName")
  m.filter = getLibraryDisplaySetting(libraryId, "filter", "All")
  m.filterOptions = parseJson(getLibraryDisplaySetting(libraryId, "filterOptions", "{}"))
  m.view = getLibraryDisplaySetting(libraryId, "landing", invalid)
  m.sortAscending = getLibraryDisplaySetting(libraryId, "sortAscending", true)

  ' Set default view if not saved
  if not isValid(m.view)
    m.view = getDefaultView()
  end if

  ' Keep currentView in sync with the finalized view
  m.top.currentView = m.view

  ' Configure backdrop based on presenter
  configureBackdrop()

  ' Configure grid based on presenter and view
  configureGrid()

  ' Check if LiveTV presenter wants to show TV Guide
  if m.top.presenterType = "livetv"
    if LCase(m.view) = "tvguide"
      ' Update title before early return to ensure overhang shows correct title on first load
      updateTitle()
      showTVGuide()
      stopLoadingSpinner()
      return
    else
      ' Ensure TV Guide is hidden if switching away from it
      hideTVGuide()
    end if
  end if

  ' Alpha search
  if m.loadItemsTask.nameStartsWith = m.top.alphaSelected
    m.loadItemsTask.nameStartsWith = ""
  else
    m.loadItemsTask.nameStartsWith = m.alpha.letterSelected
  end if

  m.loadItemsTask.searchTerm = m.voiceBox.text
  m.loadItemsTask.sortField = m.sortField
  m.loadItemsTask.sortAscending = m.sortAscending
  m.loadItemsTask.filter = m.filter
  m.loadItemsTask.filterOptions = m.filterOptions
  m.loadItemsTask.startIndex = 0

  ' Set default itemId - presenter can override if needed
  m.loadItemsTask.itemId = m.top.parentItem.Id

  ' Reset stateful fields to prevent leakage from previous operations
  ' recursive defaults to true (matches LoadItemsTask2.xml) - presenters can override
  m.loadItemsTask.recursive = true
  m.loadItemsTask.itemType = ""
  m.loadItemsTask.view = ""
  m.loadItemsTask.studioIds = ""
  m.loadItemsTask.genreIds = ""

  ' Let presenter configure the load task (can override any defaults)
  if isValid(m.presenter)
    m.presenter.configureLoadTask(m.loadItemsTask, m.top.parentItem, m.view)
  end if

  ' Update title after all task fields are configured
  updateTitle()

  m.loadItemsTask.observeField("content", "ItemDataLoaded")
  m.loadItemsTask.control = "RUN"

  SetUpOptions()
  m.log.debug("end loadInitialItems()")
end sub

function getDefaultView() as string
  ' Let presenter determine default view, or use fallback
  ' Movies and TV default to grid mode for consistency
  collectionType = getCollectionType()
  if collectionType = "movies"
    return "MoviesGrid"
  else if collectionType = "music"
    return "AlbumArtistsGrid"
  else if collectionType = "tvshows"
    return "Shows"
  end if

  ' Fallback to presenterType for genres and other items without collectionType
  presenterType = m.top.presenterType
  if presenterType = "movie"
    return "MoviesGrid"
  else if presenterType = "music"
    return "AlbumArtistsGrid"
  else if presenterType = "tvshow"
    return "Shows"
  end if

  return ""
end function

sub configureBackdrop()
  if not isValid(m.presenter) then return

  backdropMode = m.presenter.getBackdropMode()

  if backdropMode = "presentation"
    ' Clear fullscreen backdrop first to avoid overlap during transition
    m.global.sceneManager.callFunc("setBackgroundImage", "")

    ' Use presentation backdrop (right half of screen)
    m.presentationBackdrop.visible = true

    ' Set initial backdrop from parent item
    if isValid(m.top.parentItem.backdropUrl)
      m.presentationBackdrop.uri = m.top.parentItem.backdropUrl
    else
      m.presentationBackdrop.uri = ""
    end if

    ' Show presentation info if presenter supports it
    showInfo = m.presenter.shouldShowPresentationInfo(m.view)
    m.presentationInfo.visible = showInfo
  else
    ' Use fullscreen backdrop via scene manager
    m.presentationBackdrop.visible = false
    m.presentationInfo.visible = false

    if isValid(m.top.parentItem.backdropUrl)
      m.global.sceneManager.callFunc("setBackgroundImage", m.top.parentItem.backdropUrl)
    end if
  end if
end sub

sub configureGrid()
  if not isValid(m.presenter) then return

  config = m.presenter.getGridConfig(m.view)

  if isValid(config.translation)
    if type(config.translation) = "roArray"
      m.itemGrid.translation = config.translation
    else
      m.itemGrid.translation = config.translation
    end if
  end if

  if isValid(config.itemSize)
    m.itemGrid.itemSize = config.itemSize
  end if

  if isValid(config.rowHeights)
    m.itemGrid.rowHeights = config.rowHeights
  end if

  if isValid(config.numRows)
    m.itemGrid.numRows = config.numRows
  end if

  if isValid(config.numColumns)
    m.itemGrid.numColumns = config.numColumns
  end if

  if isValid(config.imageDisplayMode)
    m.top.imageDisplayMode = config.imageDisplayMode
  end if

  ' Configure item titles visibility
  userSettings = m.global.user.settings
  showInfo = m.presenter.shouldShowPresentationInfo(m.view)
  if showInfo
    m.top.showItemTitles = "hidealways"
  else
    m.top.showItemTitles = userSettings.itemGridTitles
  end if
end sub

sub ItemDataLoaded(msg)
  m.log.debug("start ItemDataLoaded()")
  itemData = msg.GetData()
  m.loadItemsTask.unobserveField("content")
  m.loadItemsTask.content = []

  if not isValid(itemData)
    m.loading = false
    stopLoadingSpinner()
    return
  end if

  ' Route to genre rowlist for movies/TV, but use regular grid for music
  if m.loadItemsTask.view = "Genres"
    ' Check if this is music genres (MusicGenre) or movie/TV genres (Genre)
    ' Must check json.Type since the node Type gets overridden to "Folder"
    isMusicGenre = false
    if itemData.Count() > 0 and isValid(itemData[0])
      if isValid(itemData[0].json) and isValid(itemData[0].json.Type)
        if itemData[0].json.Type = "MusicGenre"
          isMusicGenre = true
        end if
      end if
    end if

    ' Only use rowlist for movie/TV genres, not music
    if not isMusicGenre
      ' Reset genre list data
      m.genreData.removeChildren(m.genreData.getChildren(-1, 0))

      for each item in itemData
        m.genreData.appendChild(item)
      end for

      m.itemGrid.opacity = "0"
      m.genreList.opacity = "1"

      m.itemGrid.setFocus(false)
      m.genreList.setFocus(true)

      m.loading = false
      stopLoadingSpinner()

      ' Return focus to options menu if it was opened while library was loading
      if m.options.visible
        m.options.setFocus(true)
      end if
      return
    end if
    ' Music genres fall through to use regular grid below
  end if

  ' keep focus on alpha menu if it's active
  if m.top.alphaActive
    m.alphaMenu.setFocus(true)
  else
    m.itemGrid.opacity = "1"
    m.genreList.opacity = "0"

    m.alphaMenu.setFocus(false)
    m.itemGrid.setFocus(true)
    m.genreList.setFocus(false)
  end if

  if m.data.getChildCount() = 0
    m.itemGrid.jumpToItem = 0
  end if

  for each item in itemData
    m.data.appendChild(item)
  end for

  ' Update the stored counts
  m.loadedItems = m.itemGrid.content.getChildCount()
  m.loadedRows = m.loadedItems / m.itemGrid.numColumns
  m.loading = false

  ' If there are no items to display, show message
  if m.loadedItems = 0
    m.presentationInfo.visible = false
    m.emptyText.text = tr("NO_ITEMS").Replace("%1", m.top.parentItem.Type)
    m.emptyText.visible = true
  end if

  stopLoadingSpinner()

  ' Return focus to options menu if it was opened while library was loading
  if m.options.visible
    m.options.setFocus(true)
  end if
  m.log.debug("end ItemDataLoaded()")
end sub

sub loadMoreData()
  m.log.debug("start loadMoreData()")
  if m.loading = true then return

  prepareDataLoad(false)
  m.loading = true
  m.loadItemsTask.startIndex = m.loadedItems
  m.loadItemsTask.observeField("content", "ItemDataLoaded")
  m.loadItemsTask.control = "RUN"
  m.log.debug("end loadMoreData()")
end sub

' ============================================================================
' Focus Handling
' ============================================================================

sub onItemFocused()
  focusedRow = m.itemGrid.currFocusRow
  itemInt = m.itemGrid.itemFocused

  if itemInt = -1 then return

  updateTitle()
  m.selectedFavoriteItem = m.itemGrid.content.getChild(m.itemGrid.itemFocused)

  ' Load more data if focus is within last 5 rows
  if focusedRow >= m.loadedRows - 5 and m.loadedItems < m.loadItemsTask.totalRecordCount
    loadMoreData()
  end if

  ' Update backdrop
  updateBackdropForFocusedItem()

  ' Let presenter handle item-specific metadata display
  if isValid(m.presenter) and isValid(m.selectedFavoriteItem)
    m.presenter.onItemFocused(m.selectedFavoriteItem, m.view)
  end if
end sub

' Bridge callback for presenter logo loading - forwards to presenter.onLogoLoaded()
' This is needed because Roku observers look for functions at component scope,
' not on class instances. Presenters should observe with "onPresenterLogoLoaded".
sub onPresenterLogoLoaded(event as object)
  if isValid(m.presenter)
    m.presenter.onLogoLoaded(event)
  end if
end sub

' Bridge callback for presenter filters loading - forwards to presenter.onFiltersLoaded()
' This is needed because Roku observers look for functions at component scope,
' not on class instances. Presenters should observe with "onPresenterFiltersLoaded".
sub onPresenterFiltersLoaded(event as object)
  if isValid(m.presenter)
    m.presenter.onFiltersLoaded(event)
  end if
end sub

sub updateBackdropForFocusedItem()
  if not isValid(m.selectedFavoriteItem) then return

  backdropUrl = m.selectedFavoriteItem.backdropUrl

  if isValid(m.presenter)
    backdropMode = m.presenter.getBackdropMode()
    if backdropMode = "presentation"
      ' Clear fullscreen backdrop to prevent overlap with presentation backdrop
      m.global.sceneManager.callFunc("setBackgroundImage", "")
      m.presentationBackdrop.uri = backdropUrl
    else
      m.global.sceneManager.callFunc("setBackgroundImage", backdropUrl)
    end if
  else
    m.global.sceneManager.callFunc("setBackgroundImage", backdropUrl)
  end if
end sub

' ============================================================================
' Selection Handling
' ============================================================================

sub onItemSelected()
  m.top.selectedItem = m.itemGrid.content.getChild(m.itemGrid.itemSelected)
end sub

sub onGenreItemSelected()
  m.top.selectedItem = m.genreList.content.getChild(m.genreList.rowItemSelected[0]).getChild(m.genreList.rowItemSelected[1])
end sub

sub onGenreItemFocused()
  ' rowItemFocused can be -1 (integer) when no item is focused, or [row, col] array when focused
  rowItemFocused = m.genreList.rowItemFocused
  if type(rowItemFocused) <> "roArray" then return
  if rowItemFocused.count() < 2 then return

  rowNode = m.genreList.content.getChild(rowItemFocused[0])
  if not isValid(rowNode) then return

  focusedItem = rowNode.getChild(rowItemFocused[1])
  if not isValid(focusedItem) then return

  ' Update backdrop with focused item's backdrop
  backdropUrl = focusedItem.backdropUrl
  if isValid(backdropUrl)
    m.global.sceneManager.callFunc("setBackgroundImage", backdropUrl)
  end if
end sub

function getItemFocused()
  if m.itemGrid.isinFocusChain() and isValid(m.itemGrid.itemFocused)
    return m.itemGrid.content.getChild(m.itemGrid.itemFocused)
  else if m.genreList.isinFocusChain() and isValid(m.genreList.rowItemFocused)
    return m.genreList.content.getChild(m.genreList.rowItemFocused[0]).getChild(m.genreList.rowItemFocused[1])
  end if
  return invalid
end function

' ============================================================================
' Options
' ============================================================================

sub SetUpOptions()
  options = {
    views: [],
    sort: [],
    filter: [],
    favorite: []
  }

  ' Let presenter provide options
  if isValid(m.presenter)
    presenterOptions = m.presenter.getOptions(m.top.parentItem)
    if isValid(presenterOptions.views) then options.views = presenterOptions.views
    if isValid(presenterOptions.sort) then options.sort = presenterOptions.sort
    if isValid(presenterOptions.filter) then options.filter = presenterOptions.filter
  end if

  ' Set selected view option
  for each o in options.views
    if LCase(o.Name) = LCase(m.view)
      o.Selected = true
      o.Ascending = m.sortAscending
      m.options.view = o.Name
    end if
  end for

  ' Set selected sort option
  for each o in options.sort
    if LCase(o.Name) = LCase(m.sortField)
      o.Selected = true
      o.Ascending = m.sortAscending
      m.options.sortField = o.Name
    end if
  end for

  ' Set selected filter option
  for each o in options.filter
    if LCase(o.Name) = LCase(m.filter)
      o.Selected = true
      m.options.filter = o.Name
    end if

    ' Select selected filter options
    if isValid(o.options) and isValid(m.filterOptions)
      if o.options.Count() > 0 and m.filterOptions.Count() > 0
        if LCase(o.Name) = LCase(m.filterOptions.keys()[0])
          selectedFilterOptions = m.filterOptions[m.filterOptions.keys()[0]].split(o.delimiter)
          checkedState = []

          for each availableFilterOption in o.options
            matchFound = false
            for each selectedFilterOption in selectedFilterOptions
              if LCase(toString(availableFilterOption).trim()) = LCase(selectedFilterOption.trim())
                matchFound = true
              end if
            end for
            checkedState.push(matchFound)
          end for

          o.checkedState = checkedState
        end if
      end if
    end if
  end for

  m.options.options = options
end sub

sub optionsClosed()
  reload = false

  if m.options.sortField <> m.sortField or m.options.sortAscending <> m.sortAscending
    m.sortField = m.options.sortField
    m.sortAscending = m.options.sortAscending
    reload = true

    sortAscendingStr = "true"
    if not m.sortAscending
      sortAscendingStr = "false"
    end if

    libraryId = m.top.parentItem.Id
    setLibraryDisplaySetting(libraryId, "sortField", m.sortField)
    setLibraryDisplaySetting(libraryId, "sortAscending", sortAscendingStr)
  end if

  if m.options.filter <> m.filter
    m.filter = m.options.filter
    updateTitle()
    reload = true
    libraryId = m.top.parentItem.Id
    setLibraryDisplaySetting(libraryId, "filter", m.options.filter)
  end if

  if not isValid(m.options.filterOptions)
    m.options.filterOptions = {}
  end if

  if not AssocArrayEqual(m.options.filterOptions, m.filterOptions)
    m.filterOptions = m.options.filterOptions
    reload = true
    libraryId = m.top.parentItem.Id
    setLibraryDisplaySetting(libraryId, "filterOptions", FormatJson(m.options.filterOptions))
  end if

  ' Check if view changed
  libraryId = m.top.parentItem.Id

  if m.options.view <> m.view
    m.view = m.options.view
    m.top.currentView = m.view
    setLibraryDisplaySetting(libraryId, "landing", m.view)

    ' Reset any filtering or search terms
    m.voiceBox.text = ""
    m.top.alphaSelected = ""
    m.loadItemsTask.NameStartsWith = " "
    m.loadItemsTask.searchTerm = ""
    m.filter = "All"
    m.filterOptions = {}
    m.sortField = "SortName"
    m.sortAscending = true

    ' Reset view to defaults
    setLibraryDisplaySetting(libraryId, "sortField", m.sortField)
    setLibraryDisplaySetting(libraryId, "sortAscending", "true")
    setLibraryDisplaySetting(libraryId, "filter", m.filter)
    setLibraryDisplaySetting(libraryId, "filterOptions", FormatJson(m.filterOptions))

    reload = true
  end if

  ' Let presenter handle any custom option processing (e.g., PhotoPresenter saves slideshow/random settings)
  if isValid(m.presenter)
    m.presenter.onOptionsClosed(m.options)
  end if

  if reload
    m.loadedRows = 0
    m.loadedItems = 0
    m.data = CreateObject("roSGNode", "ContentNode")
    m.itemGrid.content = m.data
    loadInitialItems()
  end if

  m.itemGrid.setFocus(m.itemGrid.opacity = 1)
  m.genreList.setFocus(m.genreList.opacity = 1)
end sub

' ============================================================================
' Alpha / Voice Search
' ============================================================================

sub alphaSelectedChanged()
  if m.top.alphaSelected <> ""
    m.loadedRows = 0
    m.loadedItems = 0

    m.data = CreateObject("roSGNode", "ContentNode")
    m.itemGrid.content = m.data

    m.genreData = CreateObject("roSGNode", "ContentNode")
    m.genreList.content = m.genreData

    m.loadItemsTask.searchTerm = ""
    m.voiceBox.text = ""
    loadInitialItems()
  end if
end sub

sub alphaActiveChanged()
  m.log.debug("start alphaActiveChanged()", m.top.alphaActive)

  if m.top.alphaActive
    ' Clear backdrop when alpha menu is active
    if isValid(m.presenter)
      backdropMode = m.presenter.getBackdropMode()
      if backdropMode = "presentation"
        m.presentationBackdrop.uri = ""
      else
        m.global.sceneManager.callFunc("setBackgroundImage", "")
      end if
    else
      m.global.sceneManager.callFunc("setBackgroundImage", "")
    end if
  end if

  m.log.debug("end alphaActiveChanged()")
end sub

sub onVoiceFilter()
  if m.voiceBox.text <> ""
    m.loadedRows = 0
    m.loadedItems = 0
    m.data = CreateObject("roSGNode", "ContentNode")
    m.itemGrid.content = m.data
    m.top.alphaSelected = ""

    ' Store voice text before any other operations
    voiceSearchTerm = m.voiceBox.text

    ' Prepare for data loading (stop task, show spinner, hide empty text)
    prepareDataLoad(false)
    m.loadItemsTask.NameStartsWith = " "
    m.loadItemsTask.sortField = m.sortField
    m.loadItemsTask.sortAscending = m.sortAscending
    m.loadItemsTask.filter = m.filter
    m.loadItemsTask.filterOptions = m.filterOptions
    m.loadItemsTask.startIndex = 0
    m.loadItemsTask.itemId = m.top.parentItem.Id

    ' Reset stateful fields, then set voice search specific config
    m.loadItemsTask.itemType = ""
    m.loadItemsTask.view = ""
    m.loadItemsTask.studioIds = ""
    m.loadItemsTask.genreIds = ""
    m.loadItemsTask.recursive = true

    ' Let presenter override if needed
    if isValid(m.presenter)
      m.presenter.configureLoadTask(m.loadItemsTask, m.top.parentItem, m.view)
      ' Force recursive and searchTerm for voice search even if presenter changes them
      m.loadItemsTask.recursive = true
    end if

    ' Set searchTerm after presenter to ensure it's not cleared
    m.loadItemsTask.searchTerm = voiceSearchTerm

    m.loadItemsTask.observeField("content", "ItemDataLoaded")
    m.loadItemsTask.control = "RUN"

    if voiceSearchTerm.len() = 1
      ' move focus to the letter spoken
      intConversion = voiceSearchTerm.ToInt()

      if voiceSearchTerm = "0" or (isValid(intConversion) and intConversion <> 0)
        m.alphaMenu.jumpToItem = 0
      else
        ' loop through each option until we find a match
        for i = 1 to m.alphaMenu.numRows - 1
          alphaMenuOption = m.alphaMenu.content.getChild(i)
          if Lcase(alphaMenuOption.TITLE) = Lcase(voiceSearchTerm)
            m.alphaMenu.jumpToItem = i
            exit for
          end if
        end for
      end if
    end if

    updateTitle()
  end if
end sub

' ============================================================================
' Utilities
' ============================================================================

function getCollectionType() as string
  if not isValid(m.top.parentItem.collectionType)
    return LCase(m.top.parentItem.Type)
  else
    return LCase(m.top.parentItem.CollectionType)
  end if
end function

' ============================================================================
' Key Events
' ============================================================================

function onKeyEvent(key as string, press as boolean) as boolean
  if not press then return false

  if key = "left" and m.voiceBox.isinFocusChain()
    m.itemGrid.setFocus(m.itemGrid.opacity = 1)
    m.genreList.setFocus(m.genreList.opacity = 1)
    m.voiceBox.setFocus(false)
  end if

  ' Handle OK key for photo items - launch PhotoDetails viewer
  if key = "OK" and m.itemGrid.isinFocusChain()
    focusedItem = m.itemGrid.content.getChild(m.itemGrid.itemFocused)
    if isValid(focusedItem) and LCase(focusedItem.type) = "photo"
      photoPlayer = CreateObject("roSgNode", "PhotoDetails")
      photoPlayer.itemsNode = m.itemGrid.content
      photoPlayer.itemIndex = m.itemGrid.itemFocused

      ' Set slideshow/random flags based on current view if using PhotoPresenter
      if m.top.presenterType = "photo" and isValid(m.presenter)
        photoPlayer.isSlideshow = m.presenter.isSlideshow()
        photoPlayer.isRandom = m.presenter.isRandom()
      end if

      m.global.sceneManager.callfunc("pushScene", photoPlayer)
      return true
    end if
  end if

  if key = "options"
    if m.options.visible = true
      m.options.visible = false
      m.top.removeChild(m.options)
      optionsClosed()
    else
      ' Rebuild options before showing dialog (to include any dynamically loaded filters)
      SetUpOptions()

      itemSelected = m.selectedFavoriteItem
      if isValid(itemSelected)
        m.options.selectedFavoriteItem = itemSelected
      end if

      m.options.visible = true
      m.top.appendChild(m.options)
      m.options.setFocus(true)
    end if
    return true
  else if key = "back"
    if m.options.visible = true
      m.options.visible = false
      optionsClosed()
      return true
    else
      ' Cleanup presenter
      if isValid(m.presenter)
        m.presenter.destroy()
        m.presenter = invalid
      end if

      m.global.sceneManager.callfunc("popScene")
      m.loadItemsTask.control = "stop"
      return true
    end if
  else if key = "play"
    itemToPlay = getItemFocused()

    if isValid(itemToPlay)
      m.top.quickPlayNode = itemToPlay
      return true
    end if
  else if key = "left"
    if m.itemGrid.isinFocusChain()
      m.top.alphaActive = true
      m.itemGrid.setFocus(false)
      m.alphaMenu.setFocus(true)
      return true
    else if m.genreList.isinFocusChain()
      m.top.alphaActive = true
      m.genreList.setFocus(false)
      m.alphaMenu.setFocus(true)
      return true
    end if
  else if key = "right" and m.alpha.isinFocusChain()
    m.top.alphaActive = false
    m.alphaMenu.setFocus(false)

    m.itemGrid.setFocus(m.itemGrid.opacity = 1)
    m.genreList.setFocus(m.genreList.opacity = 1)

    return true
  else if key = "replay" and m.itemGrid.isinFocusChain()
    if m.resetGrid = true
      m.itemGrid.animateToItem = 0
    else
      m.itemGrid.jumpToItem = 0
    end if
    return true
  else if key = "replay" and m.genreList.isinFocusChain()
    if m.resetGrid = true
      m.genreList.animateToItem = 0
    else
      m.genreList.jumpToItem = 0
    end if
    return true
  end if

  if key = "replay"
    ' Clear all search/filter state
    m.voiceBox.text = ""
    m.loadItemsTask.searchTerm = ""
    m.loadItemsTask.nameStartsWith = ""
    m.top.alphaSelected = ""
    m.loadItemsTask.filter = "All"
    m.filter = "All"
    m.filterOptions = {}
    ' Reset stateful fields to ensure clean state
    m.loadItemsTask.recursive = false
    m.loadItemsTask.itemType = ""
    m.loadItemsTask.view = ""
    m.loadItemsTask.studioIds = ""
    m.loadItemsTask.genreIds = ""
    m.data = CreateObject("roSGNode", "ContentNode")
    m.itemGrid.content = m.data
    loadInitialItems()
    return true
  end if

  return false
end function

' ============================================================================
' Overhang Title
' ============================================================================

sub updateTitle()
  m.top.overhangTitle = m.top.parentItem.title

  if m.filter = "Favorites"
    m.top.overhangTitle = m.top.parentItem.title + " " + tr("(Favorites)")
  end if

  ' Voice search and alpha filters take precedence over view-specific titles
  ' Check trim() to avoid showing "(Filtered by )" with empty/whitespace values
  if m.voiceBox.text.trim() <> ""
    m.top.overhangTitle = m.top.parentItem.title + tr(" (Filtered by ") + m.voiceBox.text + ")"
    return
  end if

  if m.loadItemsTask.nameStartsWith.trim() <> ""
    m.top.overhangTitle = m.top.parentItem.title + tr(" (Filtered by ") + m.loadItemsTask.nameStartsWith + ")"
    return
  end if

  ' View-specific titles (only when no active filters)
  lowerView = LCase(m.view)
  if lowerView = "networks"
    m.top.overhangTitle = "%s (%s)".Format(m.top.parentItem.title, tr("Networks"))
  else if lowerView = "studios"
    m.top.overhangTitle = "%s (%s)".Format(m.top.parentItem.title, tr("Studios"))
  else if lowerView = "genres"
    m.top.overhangTitle = "%s (%s)".Format(m.top.parentItem.title, tr("Genres"))
  else if lowerView = "artistsgrid"
    m.top.overhangTitle = "%s (%s)".Format(m.top.parentItem.title, tr("Artists"))
  else if lowerView = "albumartistsgrid"
    m.top.overhangTitle = "%s (%s)".Format(m.top.parentItem.title, tr("Album Artists"))
  else if lowerView = "albums"
    m.top.overhangTitle = "%s (%s)".Format(m.top.parentItem.title, tr("Albums"))
  end if

  ' Add item count if enabled
  actInt = m.itemGrid.itemFocused + 1
  if m.showItemCount and m.loadItemsTask.totalRecordCount > 0 and lowerView <> "genres"
    m.top.overhangTitle += " (" + tr("%1 of %2").Replace("%1", actInt.toStr()).Replace("%2", m.loadItemsTask.totalRecordCount.toStr()) + ")"
  end if
end sub