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/ApiClient.bs"
import "pkg:/source/api/apiPromise.bs"
import "pkg:/source/api/baseRequest.bs"
import "pkg:/source/api/image.bs"
import "pkg:/source/constants/imageSize.bs"
import "pkg:/source/GridView/collectionsView.bs"
import "pkg:/source/GridView/GenericPresenter.bs"
import "pkg:/source/GridView/gridPaging.bs"
import "pkg:/source/GridView/gridQuery.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/libraryViewFactory.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/dialogNarration.bs"
import "pkg:/source/utils/displaySettings.bs"
import "pkg:/source/utils/itemImageUrl.bs"
import "pkg:/source/utils/languageFilters.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/paging.bs"
import "pkg:/source/utils/skeleton.bs"
import "pkg:/source/utils/tasks.bs"
import "pkg:/source/utils/textureManager.bs"
import "pkg:/source/utils/translate.bs"

' When a slow first page's loading text changes, in seconds since the load started. From a
' server that keeps up, the first page arrives in about 3.4 s even on a 512 MB Stick (measured
' 2026-09-24, 8,643-movie library), so a load that is merely normal never shows any text.
const SLOW_LOAD_STILL_S = 8
const SLOW_LOAD_SERVER_S = 30

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

  userSettings = m.global.user.settings

  m.showItemCount = userSettings.itemGridShowItemCount

  m.loadedItems = 0

  m.data = CreateObject("roSGNode", "ContentNode")
  m.itemGrid.content = m.data
  m.flatIndexMap = {}
  initTextureManager(m.data, invalid, invalid, invalid)

  m.genreData = CreateObject("roSGNode", "ContentNode")
  m.genreList.focusXOffset = 0
  ' rowLabelOffset is set in the XML, not here: it only makes sense alongside
  ' rowHeights/itemSpacing, so all three live together. See the comment there.
  ' Override JRRowList's inherited "fixedFocusWrap": this is a browse view with an
  ' alpha menu to its left, so Left at the first item must exit to that menu (handled
  ' in onKeyEvent) rather than wrap to the end of the row. floatingFocus stops the row
  ' from consuming/wrapping the boundary key so it bubbles up. Genre rows no longer
  ' cycle horizontally, matching the main MarkupGrid's browse behavior.
  m.genreList.rowFocusAnimationStyle = "floatingFocus"
  m.genreList.observeField("rowItemFocused", "onGenreItemFocused")
  m.genreList.observeField("itemSelected", "onGenreItemSelected")
  m.genreList.content = m.genreData

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

  ' The library view is router-created, so it owns its own navigation. The
  ' grid/genre/watch-channel handlers set m.top.selectedItem; we observe it here and route.
  m.top.observeField("selectedItem", "onLibrarySelection")

  ' A non-navigable selection (plays directly: an episode, a Live TV channel) sets
  ' m.top.quickPlayNode; this self-observer forwards it to the QueueManager launcher.
  m.top.observeField("quickPlayNode", "onQuickPlayLaunch")

  ' Voice filter setup
  m.voiceBox.opacity = 0.0001
  m.voiceBox.voiceEnabled = true
  m.voiceBox.active = true
  m.voiceBox.observeField("text", "onVoiceFilter")
  m.voiceBox.hintText = translate(translationKeys.LabelUseVoiceRemoteToSearch)

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

  ' Grid run state. Each run gets its own LoadItemsTask2 node (startGridRun), so the query
  ' and the paging state live here: a replaced node takes anything stored on it with it.
  m.loadItemsTask = invalid
  m.gridQuery = {}
  m.nameStartsWith = ""
  m.totalRecordCount = 0
  m.loading = false
  ' Whether a later page's failure has been told (see gridPaging).
  m.pageFailureNotice = gridPaging.newFailureNotice()
  ' What decides when the next page is asked for (gridPaging, "keep a runway"): how fast the
  ' user is moving down the rows, and how long a page takes to arrive here. One clock for
  ' both. The page timer outlives a query: it measures the server and this device, not the
  ' query.
  m.gridClock = CreateObject("roTimespan")
  m.scrollTracker = gridPaging.newScrollTracker()
  m.pageTimer = gridPaging.newPageTimer()
  m.pageRequestedMs = -1
  #if perfTiming
    newGridSession()
  #end if
  ' Whether this view is the one on screen. Not hasFocus(): while the router holds this view
  ' suspended out of the tree, its own nodes still report focus (see navigation.md, Focus
  ' management), so only the lifecycle hooks can say.
  m.isShown = false

  ' Load-failure state. The button centers itself once it has measured its label.
  m.loadFailedText.text = translate(translationKeys.MessageCouldNotLoadItems)
  m.retryButton.text = translate(translationKeys.ButtonTryAgain)
  m.retryButton.observeField("isReady", "onRetryButtonReady")
  m.failureNarrationTimer.duration = narration.OPENING_DELAY
  m.failureNarrationTimer.observeField("fire", "onFailureNarrationDue")
  m.loadingMore.text = translate(translationKeys.LabelLoadingMore)
  m.isLoadingMoreDue = false
  m.loadingMoreDelay.observeField("fire", "onLoadingMoreDue")
  m.ownsSpinner = false
  m.slowLoadTimer.observeField("fire", "onSlowLoadDue")

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

  ' Presenter (built by onPresenterTypeChanged when presenterType is set)
  m.presenter = invalid

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

  ' Cache device backdrop size once for all getItemBackdropUrl() calls
  device = m.global.device
  m.backdropSize = { width: device.uiResolution[0], height: device.uiResolution[1] }

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

  ' Interface observers are registered HERE rather than declared as XML onChange
  ' attributes: this component releases node references in onDestroy and these
  ' handlers dereference them, so the registrations have to be detachable. An XML
  ' onChange does not appear to be removable by unobserveField. See the wiring
  ' rule in components/CLAUDE.md.
  m.top.observeField("presenterType", "onPresenterTypeChanged")
  m.top.observeField("parentItem", "loadInitialItems")
  m.top.observeField("alphaSelected", "onAlphaSelectedChanged")
  m.top.observeField("isAlphaActive", "onAlphaActiveChanged")
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.loadFailed = m.top.findNode("loadFailed")
  m.loadFailedText = m.top.findNode("loadFailedText")
  m.retryButton = m.top.findNode("retryButton")
  m.failureNarrationTimer = m.top.findNode("failureNarrationTimer")
  m.loadingMore = m.top.findNode("loadingMore")
  m.loadingMoreDelay = m.top.findNode("loadingMoreDelay")
  m.slowLoadTimer = m.top.findNode("slowLoadTimer")
  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()
  ' First: the first load below may deliver focus-taking results, which ask it.
  m.isShown = true

  ' On first mount, self-load from the route (/library/:id). Both in-app navigation AND a
  ' deep link / cast pass the rich library node as route.context.item (in-app: the selected
  ' node; deep link: the node JRScene.resolveDeepLink already validated): instant, no
  ' re-fetch. presenterType has to be set before parentItem (parentItem triggers
  ' loadInitialItems, which reads presenterType). A resumed view skips this
  ' (parentItem already set).
  if not isValid(m.top.parentItem)
    loadLibraryFromRoute(m.top.route)
  else
    ' Resume: this view was suspended beneath a pushed screen, not destroyed, so its content
    ' survived. If a mutation elsewhere (for example an item delete in ItemDetails) bumped
    ' the scene's content-freshness token since we last
    ' loaded, our cached grid is stale: re-fetch so deleted items don't linger as
    ' selectable ghosts. Unchanged token = no reload, so the resume stays cheap.
    scene = m.top.getScene()
    if isValid(scene) and scene.contentVersion <> m.lastContentVersion
      reloadGridItems()
    else
      ' The runway may have run short while another screen was up: a later page failed, or
      ' the user left from near the end. Asked here and not only when focus lands back in
      ' the grid (the grid reports its focused item again, and onItemFocused asks), because
      ' focus may return somewhere else; m.loading keeps the two to one request.
      loadMoreIfShort()
    end if
  end if

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

  ' Re-activate voice capture. The voiceBox loses its active connection to the
  ' Roku firmware while this screen is suspended ("detach" removes the node from the
  ' tree entirely) and another view is on top.
  if isValid(m.voiceBox)
    m.voiceBox.active = true
  end if

  ' Restore texture management after returning from a pushed screen
  if isValid(m.data) and m.data.hasField("textureManagerState")
    updateGridTextureBufferRange(m.data, m.itemGrid.itemFocused, m.itemGrid.numColumns, m.itemGrid.numRows)
    activateTextureManager(m.data)
  end if

  ' The guide is a child of this view, not a routed screen, so nothing else tells it it is back
  ' (it resumes retrying what failed). Before the focus restore below, which stays this view's.
  if isValid(m.tvGuide) then m.tvGuide.callFunc("onScreenShown")

  restoreGridFocus()
  updateLoadingMore()
end sub

' Focus hook (overrides JRScreen.restoreScreenFocus). The router's handleFocus runs it right after
' onScreenShown, and the base restores lastFocus again, undoing a restore that followed what
' changed while this view was suspended. Same trap and same fix as Home.restoreHomeFocus.
sub restoreScreenFocus()
  restoreGridFocus()
end sub

' Configure the library view from its route. Both in-app navigation and a deep link / cast
' supply the rich library node as route.context.item (the resolver passes the node
' it already validated), so there is no fetch here. A missing context node is therefore an
' unexpected route shape, not a deferred feature: warn rather than silently render nothing.
sub loadLibraryFromRoute(route as object)
  if not isValid(route) then return

  item = invalid
  if isValid(route.context) then item = route.context.item

  if isValid(item)
    m.top.presenterType = DeterminePresenterType(item)
    m.top.parentItem = item ' triggers loadInitialItems
  else
    m.log.warn("library route has no context item", route.routeParams)
  end if
end sub

' A grid / genre / channel item was selected: navigate to its route (rich node as context).
' Non-navigable selections fall through to playback.
sub onLibrarySelection(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

' Forward a non-navigable selection to the playback launcher.
sub onQuickPlayLaunch(msg)
  node = msg.getData()
  if isValid(node) then m.global.queueManager.callFunc("launchItem", node)
end sub

sub onScreenHidden()
  m.isShown = false
  updateLoadingMore()
  hideTextureManager(m.data)
  if isValid(m.tvGuide) then m.tvGuide.callFunc("onScreenHidden")
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.callFunc("startLoading")
  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")
  ' The guide is a child of this view, not a routed screen, so nothing else will tear it down:
  ' without this its channel and program fetches keep running after it is gone.
  m.tvGuide.callFunc("onDestroy")
  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
' ============================================================================

' Hides the loading spinner, and stops its text from moving on (startSlowLoadText).
sub stopGridSpinner()
  m.ownsSpinner = false
  m.slowLoadTimer.control = "stop"
  stopLoadingSpinner()
end sub

' A first page can take the server most of a minute (timeouts.GRID_PAGE_MS). The spinner says
' nothing for SLOW_LOAD_STILL_S, then that it is still loading, then from SLOW_LOAD_SERVER_S
' that the wait is the server's: all we know then, since nothing has arrived. The cause, if
' the page fails, goes to the log (decision `grid-load-failure-retry-policy`).
sub startSlowLoadText()
  m.slowLoadStage = 0
  m.slowLoadTimer.control = "stop"
  m.slowLoadTimer.duration = SLOW_LOAD_STILL_S
  m.slowLoadTimer.control = "start"
end sub

sub onSlowLoadDue()
  m.slowLoadStage++
  if m.slowLoadStage = 1
    text = translate(translationKeys.LabelStillLoading)
    m.slowLoadTimer.duration = SLOW_LOAD_SERVER_S - SLOW_LOAD_STILL_S
    m.slowLoadTimer.control = "start"
  else
    text = translate(translationKeys.MessageServerTakingAWhileToAnswer)
  end if
  ' The spinner is the scene's: never write its text over another screen, nor say it.
  if not m.isShown then return
  appScene().loadingText = text
  ' Queued, not flushed, as "Loading more" is (onLoadingMoreDue): nothing moved.
  narrateStatus(text)
end sub

' Prepare for data loading by releasing any run in flight, showing spinner, and hiding empty text
' @param {boolean} [disableRemote=false] - Whether to disable remote input during loading
sub prepareDataLoad(disableRemote = false as boolean)
  ' Released here, not only when the next run replaces it: the TV Guide path of
  ' loadInitialItems starts no run at all.
  releaseGridRun()
  startLoadingSpinner(disableRemote)
  ' The spinner is the scene's, so closing this view mid-load must take it down (onDestroy).
  m.ownsSpinner = true
  startSlowLoadText()
  m.emptyText.visible = false
  m.loadFailed.visible = false
end sub

' Full reset + reload of the grid. Used by optionsClosed (sort/filter/view change) and by
' the resume-time content-freshness check in
' onScreenShown (a delete elsewhere invalidated our cached content).
sub reloadGridItems()
  m.loadedItems = 0
  m.data = CreateObject("roSGNode", "ContentNode")
  m.itemGrid.content = m.data
  m.flatIndexMap = {}
  initTextureManager(m.data, invalid, invalid, invalid)
  loadInitialItems()
end sub

sub loadInitialItems()
  m.log.debug("start loadInitialItems()")
  ' Record the content-freshness token we're loading at, so a resume after a mutation
  ' elsewhere (for example a delete) can detect staleness and re-fetch.
  scene = m.top.getScene()
  if isValid(scene) then m.lastContentVersion = scene.contentVersion
  prepareDataLoad(false)
  m.top.loadState = "loading"

  if m.top.parentItem.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, or if the saved one is no longer on offer.
  '
  ' A saved view could always be honored until 12.0, because every view a presenter
  ' listed existed on every server. The Collections view is the first that does not: the
  ' same library opened against an older server has no such view, and loading it anyway
  ' takes the presenter's default query branch while the layout stays the named view's.
  '
  ' The fallback is not written back to the registry. The saved choice is still what the
  ' user picked, and it is valid again the moment the library is opened against a server
  ' that offers it, so this reads the setting down, it does not overwrite it.
  m.view = gridQuery.resolveView(m.view, offeredViews(), getDefaultView())

  ' 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()

  ' The presenter decides whether this view is the TV Guide rather than the grid
  if m.presenter.showsTVGuide(m.view)
    ' Update title before early return to ensure overhang shows correct title on first load
    updateTitle()
    showTVGuide()
    stopGridSpinner()
    ' The guide is this view's finished surface; the grid never loads on this path.
    m.top.loadState = "loaded"
    return
  else
    ' Ensure TV Guide is hidden if switching away from it
    hideTVGuide()
  end if

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

  setGridQuery(buildGridQuery(m.nameStartsWith, m.voiceBox.text))

  ' Update title after the query is final
  updateTitle()

  ' Only a Genres run writes genreSkeletons, and only before its samples are fetched: it
  ' is a head start on drawing, not a second delivery channel. `content` still carries the
  ' real rows for every view including this one.
  startGridRun(0, true)

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

' The query for a fresh load of the current view (see gridQuery.build).
' @param {string} nameStartsWith - The alpha filter (" " and "" are both sent as-is)
' @param {string} searchTerm - The search term; a presenter may override it
' @return {object} LoadItemsTask2 input fields
function buildGridQuery(nameStartsWith as string, searchTerm as string) as object
  return gridQuery.build({
    nameStartsWith: nameStartsWith,
    searchTerm: searchTerm,
    sortField: m.sortField,
    sortAscending: m.sortAscending,
    filter: m.filter,
    filterOptions: m.filterOptions
  }, m.top.parentItem, m.view, m.presenter)
end function

' Makes `query` the grid's current query. A new query is a new result set, so the previous
' query's total no longer applies: until this query's first page lands, nothing may page
' against it. (Swapping the grid's content reports focus on item 0, and onItemFocused
' would otherwise page against the old total.)
sub setGridQuery(query as object)
  #if perfTiming
    logGridSession()
    newGridSession()
  #end if
  m.gridQuery = query
  m.totalRecordCount = 0
  m.pageFailureNotice = gridPaging.newFailureNotice()
  ' The new result set starts at the top: the speed of scrolling the last one says nothing.
  m.scrollTracker = gridPaging.newScrollTracker()
end sub

' Runs m.gridQuery from `startIndex` on a NEW LoadItemsTask2 node, releasing the run in
' flight. Never a relaunch of the previous node: see replaceTask.
' @param {integer} startIndex - The first item to fetch
' @param {boolean} observeSkeletons - Whether this run may deliver genreSkeletons
sub startGridRun(startIndex as integer, observeSkeletons as boolean)
  if isValid(m.loadItemsTask) then m.loadItemsTask.unobserveField("genreSkeletons")
  m.loadItemsTask = replaceTask(m.loadItemsTask, "LoadItemsTask2", "content", paging.forPage(m.gridQuery, startIndex))
  m.pageRequestedMs = m.gridClock.totalMilliseconds()
  m.loadItemsTask.observeField("content", "onItemDataLoaded")
  if observeSkeletons then m.loadItemsTask.observeField("genreSkeletons", "onGenreSkeletonsReady")
  launchTask(m.loadItemsTask)
end sub

' Stops the grid's run in flight, if any, and detaches both fields a run is observed on.
sub releaseGridRun()
  if isValid(m.loadItemsTask) then m.loadItemsTask.unobserveField("genreSkeletons")
  m.loadItemsTask = releaseTask(m.loadItemsTask, "content")
end sub

' The views the presenter is currently listing for this library, [] when there is no
' presenter yet.
'
' Derived from getOptions() rather than from a second list the presenters would also have
' to maintain: there is one list, so nothing can disagree with it. "Currently" matters:
' a presenter's views depend on the server version and on the parent item
' (a genre folder offers fewer than its library), so this is asked per load, not cached.
' The cost is one extra options build per library load, the same work SetUpOptions already
' does once: a handful of translate() calls on the render thread.
function offeredViews() as object
  if not isValid(m.presenter) then return []
  options = m.presenter.getOptions(m.top.parentItem)
  if not isValid(options) or type(options.views) <> "roArray" then return []
  return options.views
end function

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
    m.presentationBackdrop.uri = getItemBackdropUrl(m.top.parentItem, m.backdropSize)

    ' 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

    m.global.sceneManager.callFunc("setBackgroundImage", getItemBackdropUrl(m.top.parentItem, m.backdropSize))
  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

' Swaps the genre RowList to a new set of rows and reveals it, preserving the user's
' position. One owner for both delivery stages (the skeleton rows and the real rows),
' so the opacity/focus/spinner sequence cannot drift between them.
'
' `rowsRoot` is a DETACHED ContentNode root: building rows off-tree and assigning
' `content` once is a single change notification instead of one per row (the same shape
' onAlphaSelectedChanged already uses).
'
' Focus/scroll: the view is interactive from the moment skeletons draw, so the user may
' have scrolled before the real rows land. Skeleton and real rows are 1:1 in server
' order, so the previous [row, item] position is index-stable: restore it rather than
' letting the content swap snap the list back to the top. The item index is clamped:
' a skeleton row holds one cell, the filled row up to 7.
sub revealGenreList(rowsRoot as object)
  prevFocus = invalid
  if m.genreList.isInFocusChain() then prevFocus = m.genreList.rowItemFocused

  m.genreData = rowsRoot
  m.genreList.content = rowsRoot

  if type(prevFocus) = "roArray" and prevFocus.count() = 2
    row = prevFocus[0]
    if row >= 0 and row < rowsRoot.getChildCount()
      itemIndex = prevFocus[1]
      maxItem = rowsRoot.getChild(row).getChildCount() - 1
      if itemIndex > maxItem then itemIndex = maxItem
      if itemIndex < 0 then itemIndex = 0
      m.genreList.jumpToRowItem = [row, itemIndex]
    end if
  end if

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

  m.loading = false
  stopGridSpinner()

  focusAfterLoad()
end sub

' Draw the Genres view's rows before their artwork exists.
'
' The task knows every genre's name as soon as the genre LIST query returns, which is one
' request rather than one-per-genre, so the list can be on screen long before the samples
' land. Each row gets its title and a single skeleton placeholder cell: a RowList does not
' lay out a childless row at all, and that one cell is also what gives the row its slot
' height.
'
' `genreSkeletons` carries ids and titles, not nodes. Building the rows here rather than
' shipping them from the task keeps the payload of that cross-thread write small: the
' rendezvous is priced by what it carries. Same split of work as
' HomeRows.createSkeletonRows().
'
' Nothing here is authoritative: `content` still arrives later with the real rows and
' replaces these wholesale. If it never arrives, the skeletons are what the failure looks
' like, which beats an empty screen.
sub onGenreSkeletonsReady(msg)
  ' A delivery queued by a run that has since been replaced or released
  if not isCurrentTaskEvent(msg, m.loadItemsTask) then return
  ' Mirror the view dispatch onItemDataLoaded performs: the task publishes skeletons
  ' whenever every item is a genre, not only for the Genres view.
  if m.loadItemsTask.view <> "Genres" then return

  genres = msg.getData()
  if not isValidAndNotEmpty(genres) then return

  rowsRoot = CreateObject("roSGNode", "ContentNode")
  for each genre in genres
    row = CreateObject("roSGNode", "ContentNode")
    row.title = genre.title
    row.appendChild(skeleton.createPlaceholder())
    rowsRoot.appendChild(row)
  end for

  revealGenreList(rowsRoot)
  ' Roku's own RowList counter reports "focused item of items IN THIS ROW"
  ' (JRRowList sets showRowCounter = [true]). A skeleton row holds exactly one
  ' placeholder, so it would truthfully but uselessly read "1 of 1" and then jump to
  ' "1 of 7" on fill. Hidden until the row can answer the question, and restored beside
  ' the "loaded" transition below, so the two can't drift apart.
  m.genreList.showRowCounter = [false]
  m.top.loadState = "skeleton"
end sub

sub onItemDataLoaded(msg)
  m.log.debug("start onItemDataLoaded()")
  ' A delivery queued by a run that has since been replaced or released: reading
  ' m.loadItemsTask in its place would read the new run's still-empty output.
  if not isCurrentTaskEvent(msg, m.loadItemsTask) then return
  itemData = msg.GetData()
  m.loadItemsTask.unobserveField("content")
  m.loadItemsTask.unobserveField("genreSkeletons")
  m.loadItemsTask.content = []
  isFirstPage = m.loadItemsTask.startIndex = 0

  ' Before the total is read: a failed run's total is its default -1, and keeping it would
  ' stop every later page, including the retry that scrolling near the end makes.
  if m.loadItemsTask.status = "failed"
    onGridLoadFailed(isFirstPage)
    return
  end if
  gridPaging.pageLoaded(m.pageFailureNotice)
  ' The total is counted once, on the first page: later pages are asked for without it (the
  ' count is most of a page's cost on a slow server), and their reply's total is then only
  ' the size of that page.
  if isFirstPage
    m.totalRecordCount = m.loadItemsTask.totalRecordCount
    ' The "#" filter's later pages find their place from its first range's count, so it goes
    ' into the query every later page is asked with (see LoadItemsTask2.executeHashPage).
    hashFirstRangeTotal = m.loadItemsTask.hashFirstRangeTotal
    if hashFirstRangeTotal >= 0 then m.gridQuery.hashFirstRangeTotal = hashFirstRangeTotal
  end if

  if not isValid(itemData)
    m.loading = false
    stopGridSpinner()
    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).
    ' Movie/TV genre items are plain ContentNode row containers (no typed fields),
    ' so guard LCase() with isValid() to avoid a type mismatch crash.
    isMusicGenre = false
    if itemData.Count() > 0 and isValid(itemData[0])
      itemType = ""
      itemFolderType = ""
      if isValid(itemData[0].type) then itemType = LCase(itemData[0].type)
      if isValid(itemData[0].folderType) then itemFolderType = LCase(itemData[0].folderType)
      if itemType = "musicgenre" or itemFolderType = "musicgenre"
        isMusicGenre = true
      end if
    end if

    ' Only use rowlist for movie/TV genres, not music
    if not isMusicGenre
      rowsRoot = CreateObject("roSGNode", "ContentNode")
      for each item in itemData
        rowsRoot.appendChild(item)
      end for

      revealGenreList(rowsRoot)
      ' Rows can answer "N of M" now: undo the suppression onGenreSkeletonsReady applied.
      ' Unconditional: this path also runs when no skeletons preceded it, where the field
      ' is already [true] and setting it again is a no-op.
      m.genreList.showRowCounter = [true]
      ' Count ITEMS, not rows: populateGenreRow leaves a row childless when its sample
      ' fetch failed, so rows can all be present with nothing selectable under them. Same
      ' question the grid path below asks (m.loadedItems = 0). One local write, so an
      ' observer sees one transition rather than a spurious "empty" first.
      hasItems = false
      for i = 0 to rowsRoot.getChildCount() - 1
        if rowsRoot.getChild(i).getChildCount() > 0
          hasItems = true
          exit for
        end if
      end for
      if hasItems
        m.top.loadState = "loaded"
      else
        m.top.loadState = "empty"
      end if
      return
    end if
    ' Music genres fall through to use regular grid below
  end if

  if not m.top.isAlphaActive
    m.itemGrid.opacity = "1"
    m.genreList.opacity = "0"
  end if
  ' Only a load's first page places focus: the later pages arrive every few hundred ms while
  ' the user may be anywhere in the grid.
  if isFirstPage then focusAfterLoad()

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

  appendStartMs = 0
  #if perfTiming
    appendStartMs = m.gridClock.totalMilliseconds()
  #end if
  startIndex = m.data.getChildCount()
  for i = 0 to itemData.count() - 1
    item = itemData[i]
    m.data.appendChild(item)
    if isValidAndNotEmpty(item.id)
      m.flatIndexMap[item.id] = startIndex + i
    end if
  end for
  #if perfTiming
    appendMs = m.gridClock.totalMilliseconds() - appendStartMs
    m.session.appendMs += appendMs
    if appendMs > m.session.appendMaxMs then m.session.appendMaxMs = appendMs
  #end if

  ' Update the stored counts
  m.loadedItems = m.itemGrid.content.getChildCount()
  m.loading = false
  gridPaging.recordPageTime(m.pageTimer, m.gridClock.totalMilliseconds() - m.pageRequestedMs)
  #if perfTiming
    m.session.pages++
    m.session.items = m.loadedItems
  #end if

  ' A later page with nothing in it, although the first page's total says more exist: the
  ' library shrank since it was counted. The total the grid shows and pages against is what it
  ' holds, or it would ask for the same empty page again on every move near the end.
  if not isFirstPage and itemData.count() = 0 then m.totalRecordCount = m.loadedItems

  ' Update texture buffer range and activate after data is loaded
  if m.loadedItems > 0
    updateGridTextureBufferRange(m.data, m.itemGrid.itemFocused, m.itemGrid.numColumns, m.itemGrid.numRows)
    activateTextureManager(m.data)
  end if

  ' If there are no items to display, show message
  if m.loadedItems = 0
    m.presentationInfo.visible = false
    m.emptyText.text = translate(translationKeys.LabelNoItems, [m.top.parentItem.type])
    m.emptyText.visible = true
    m.top.loadState = "empty"
  else
    m.top.loadState = "loaded"
  end if

  stopGridSpinner()

  ' The page may not have been enough: a fast scroller can still be short of runway.
  loadMoreIfShort()
  updateLoadingMore()

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

' A run that got no usable answer from the server (its task logged why). That says nothing
' about what the library holds, so it is never shown as empty.
'
' A later page: what is on screen stays, and so does the total, so scrolling near the end
' asks again (onItemFocused), and so does coming back to the grid (onScreenShown). When the
' user is told is gridPaging's rule. The first page: the
' failure state replaces the grid, with a Try again the user chooses to press. Retrying on
' our own would re-send a query the server may still be working on.
sub onGridLoadFailed(isFirstPage as boolean)
  m.loading = false
  stopGridSpinner()
  updateLoadingMore()

  if not isFirstPage
    if gridPaging.shouldTellPageFailure(m.pageFailureNotice, m.isShown)
      displayToast(translate(translationKeys.MessageCouldNotLoadMoreItems))
    end if
    return
  end if

  ' A Genres load that fails must not leave an earlier load's rows standing.
  m.genreData = CreateObject("roSGNode", "ContentNode")
  m.genreList.content = m.genreData
  m.presentationInfo.visible = false
  m.emptyText.visible = false
  m.loadFailed.visible = true
  m.top.loadState = "failed"

  focusAfterLoad()
end sub

sub focusRetryButton()
  m.itemGrid.setFocus(false)
  m.genreList.setFocus(false)
  m.retryButton.setFocus(true)
  ' The platform speaks only the focused button; the message is ours to announce.
  m.failureNarrationTimer.control = "stop"
  m.failureNarrationTimer.control = "start"
end sub

' Runs the failed query again, from its first page.
sub retryGridLoad()
  prepareDataLoad(false)
  m.top.loadState = "loading"
  startGridRun(0, true)
end sub

sub onRetryButtonReady()
  width = m.retryButton.localBoundingRect().width
  x = int((1920 - width) / 2)
  ' On the 720p output grid (every 3 px of 1080p); see dialogLayout.bs.
  m.retryButton.translation = [x - (x mod 3), m.retryButton.translation[1]]
end sub

sub onFailureNarrationDue()
  ' isShown as well as hasFocus(): a suspended view's button still reports focus, and this
  ' would speak over the screen the user is on.
  if not m.isShown or not m.loadFailed.visible or not m.retryButton.hasFocus() then return
  narrateDialogOpening("", m.loadFailedText.text, m.retryButton.text)
end sub

' The row the user is at: where the grid is scrolling to, or where focus is when it is still.
function gridRow() as integer
  if m.scrollTracker.row >= 0 then return m.scrollTracker.row
  focusedItem = m.itemGrid.itemFocused
  if focusedItem < 0 then return 0
  return focusedItem \ m.itemGrid.numColumns
end function

' The grid's scroll position, reported on every rendered frame while it scrolls. itemFocused is
' not enough to keep a runway: during a run of key presses the grid keeps scrolling and reports
' focus only where it stops (measured 2026-09-25 on a Stick 4K: 15 reports over 66 rows), by
' which time the user can be at the last loaded row. Acts only when the row entering the focus
' position changes, so a frame that moves no row costs one comparison.
sub onGridScrolled()
  position = m.itemGrid.currFocusRow
  row = Int(position)
  if position > row then row++
  if row = m.scrollTracker.row then return

  gridPaging.trackFocus(m.scrollTracker, row, m.gridClock.totalMilliseconds())
  #if perfTiming
    if row > m.session.furthestRow then m.session.furthestRow = row
  #end if
  loadMoreIfShort()
  updateLoadingMore()
end sub

' Asks for the next page when the loaded rows left below the focus would run out before a page
' could arrive (gridPaging, "keep a runway"). Asked whenever that can change: focus moved, a
' page arrived, the view came back.
sub loadMoreIfShort()
  columns = m.itemGrid.numColumns
  row = gridRow()
  nowMs = m.gridClock.totalMilliseconds()

  wantedRows = gridPaging.runwayRows(m.itemGrid.numRows, gridPaging.scrollSpeed(m.scrollTracker, nowMs), m.pageTimer.averageMs)
  if gridPaging.shouldLoadMore(row, gridPaging.lastRow(m.loadedItems, columns), gridPaging.hasMore(m.loadedItems, m.totalRecordCount), m.loading, wantedRows)
    loadMoreData()
  end if
end sub

' Shows "Loading more" while the user waits at the last loaded row for the next page, after a
' short delay so a fast page never flashes it. Asked wherever the answer can change.
sub updateLoadingMore()
  columns = m.itemGrid.numColumns
  isWaiting = m.isShown and gridPaging.isWaitingAtEnd(gridRow(), gridPaging.lastRow(m.loadedItems, columns), gridPaging.hasMore(m.loadedItems, m.totalRecordCount), m.loading)

  #if perfTiming
    trackStall(isWaiting)
  #end if

  if not isWaiting
    m.isLoadingMoreDue = false
    m.loadingMoreDelay.control = "stop"
    m.loadingMore.isShown = false
    return
  end if
  if m.loadingMore.isShown or m.isLoadingMoreDue then return
  m.isLoadingMoreDue = true
  m.loadingMoreDelay.control = "start"
end sub

#if perfTiming
  ' One line per grid query, for tuning gridPaging's starting values: how often the user waited
  ' at the end of the loaded rows and for how long (stalls), against how much was loaded and how
  ' far down they actually went. A stall counts from reaching the last loaded row with a page in
  ' flight, before the indicator's delay. appendMs / appendMaxMs are the render thread's side of a
  ' page (adding its items to the grid), summed over the session and at its worst page, because
  ' a long add is a scroll hitch that no task-side number shows.
  sub newGridSession()
    m.session = { pages: 0, items: 0, stalls: 0, stallMs: 0, stallStartMs: -1, furthestRow: 0, appendMs: 0, appendMaxMs: 0 }
  end sub

  sub trackStall(isWaiting as boolean)
    nowMs = m.gridClock.totalMilliseconds()
    if isWaiting and m.session.stallStartMs < 0
      m.session.stalls++
      m.session.stallStartMs = nowMs
    else if not isWaiting and m.session.stallStartMs >= 0
      m.session.stallMs += nowMs - m.session.stallStartMs
      m.session.stallStartMs = -1
    end if
  end sub

  sub logGridSession()
    if not isValid(m.session) or m.session.pages = 0 then return
    trackStall(false)
    ' At most nine args (see LoadItemsTask2's item-grid line).
    m.log.info("item-grid paging - stalls " + m.session.stalls.toStr() + " stallMs " + m.session.stallMs.toStr() + " furthestRow " + m.session.furthestRow.toStr() + " appendMs " + m.session.appendMs.toStr() + " appendMaxMs " + m.session.appendMaxMs.toStr(), "pages", m.session.pages, "items", m.session.items, "pageMs", Int(m.pageTimer.averageMs))
  end sub
#end if

sub onLoadingMoreDue()
  if not m.isLoadingMoreDue then return
  m.isLoadingMoreDue = false
  m.loadingMore.isShown = true
  ' Queued, not flushed: the reader may still be naming the focused tile.
  narrateStatus(m.loadingMore.text)
end sub

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

  m.emptyText.visible = false
  m.loading = true
  ' The next page of the same query: m.gridQuery, not the previous node, is what it asks for.
  startGridRun(m.loadedItems, false)
  updateLoadingMore()
  m.log.debug("end loadMoreData()")
end sub

' ============================================================================
' Focus Handling
' ============================================================================
'
' Where focus goes is decided from what the view shows NOW, in the three subs below, never
' by an event on its own: a load that could not take focus (the options dialog was open,
' the view was suspended) leaves nothing to put it back, so whatever later hands focus back
' to the content (closing the dialog, leaving the alpha menu, resuming) asks here.

' Focus the content area as it currently is: Try again when the load failed, otherwise
' whichever of the genre list and the grid is showing.
sub focusContent()
  if m.loadFailed.visible
    focusRetryButton()
    return
  end if
  m.itemGrid.setFocus(m.itemGrid.opacity = 1)
  m.genreList.setFocus(m.genreList.opacity = 1)
end sub

' A load's first result arrived. It moves focus only when the user is not somewhere it must
' not be taken from: another screen (the view is suspended), or the options dialog, where
' moving it loses their place and their next OK is dropped or commits the wrong entry. On
' the alpha menu it stays there.
sub focusAfterLoad()
  if not m.isShown or m.options.visible then return
  if m.top.isAlphaActive
    m.alphaMenu.setFocus(true)
  else
    focusContent()
  end if
end sub

' Focus on return to this view (onScreenShown and restoreScreenFocus). The saved lastFocus is kept
' when it is outside the content area (the alpha menu, the TV guide). Inside it, what it
' names may no longer be what the view shows (a load can fail or finish while suspended),
' so the content area is focused as it is now.
sub restoreGridFocus()
  last = m.top.lastFocus
  if not isValid(last)
    m.top.setFocus(true)
  else if last.isSameNode(m.itemGrid) or last.isSameNode(m.genreList) or last.isSameNode(m.retryButton)
    focusContent()
  else
    last.setFocus(true)
  end if
end sub

sub onItemFocused()
  itemInt = m.itemGrid.itemFocused

  if itemInt = -1 then return

  ' Update texture buffer range on every focus change
  updateGridTextureBufferRange(m.data, m.itemGrid.itemFocused, m.itemGrid.numColumns, m.itemGrid.numRows)

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

  gridPaging.trackFocus(m.scrollTracker, itemInt \ m.itemGrid.numColumns, m.gridClock.totalMilliseconds())
  #if perfTiming
    if itemInt \ m.itemGrid.numColumns > m.session.furthestRow then m.session.furthestRow = itemInt \ m.itemGrid.numColumns
  #end if
  loadMoreIfShort()
  updateLoadingMore()

  ' 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

' Render-thread filter load, collapsed from GetFiltersTask (Phase-4 pure-fetch Task
' collapse; see docs/dev/promises.md). Invoked by the presenter via callFunc so the
' body runs in component-m context, where fetchAsync + auto-abandon work. The presenter
' is a class, and the auto-abandon plugin only injects into components with onDestroy
' (which BaseGridView has). The presenter still owns m.apiFilters + the toast; this only
' fetches and hands the result back via deliverFilters().
sub loadFilters(params as object)
  ' Debug error injection, compiled out in production (mirrors ItemDetails.toggleFavorite).
  #if debug
    if isValid(m.global.debug) and m.global.debug.shouldForceFiltersFail
      deliverFilters({}, "[DEBUG] Forced filter failure")
      return
    end if
  #end if

  ' Carried on m rather than in a closure: a Roku promise callback runs at component
  ' scope and captures no locals (the same reason deliverFilters() exists).
  m.pendingFilterParams = params

  req = GetApi().BuildGetFiltersRequest(params)
  promises.chain(fetchAsync(req, "filters-" + params.parentid)).then(sub(res as object)
    ' Any HTTP response lands here (incl. 4xx/5xx). Mirrors the old Task's contract:
    ' res.ok delivers the filters; otherwise deliver {} + the error message.
    if res.ok
      loadLanguageFilters(res.json, "")
    else
      loadLanguageFilters({}, "Unable to load filter options")
    end if
  end sub).catch(sub(err as object)
    ' Transport failure / timeout (see the error contract): never completed.
    m.log.warn("filter load failed", err.reason)
    loadLanguageFilters({}, "Unable to load filter options")
  end sub)
end sub

' Second leg of the filter load, on 12.0+ servers only: the audio/subtitle language
' lists, which live on a DIFFERENT endpoint from the rest.
'
' The two are disjoint and neither is a superset: `/Items/Filters` carries
' OfficialRatings and Years, `/Items/Filters2` carries the language lists. So a
' client that wants both has to ask twice. Sequential rather than parallel because a
' promise callback here cannot capture locals, so the first leg's result has to be
' parked on m anyway; the extra round trip happens once per library load, off the
' critical path (the grid's own items load independently).
'
' The language leg is strictly OPTIONAL: any failure delivers the first leg's result
' unchanged and shows no toast. Losing two filter categories is not worth interrupting
' someone who was not necessarily going to open the panel, and the categories are
' offered only when their lists arrive non-empty, so the panel is correct either way.
sub loadLanguageFilters(filters as object, errorMsg as string)
  serverVersion = ""
  if isValid(m.global.server) then serverVersion = toString(m.global.server.version)

  if not languageFilters.supported(serverVersion) or not isValid(m.pendingFilterParams)
    deliverFilters(filters, errorMsg)
    return
  end if

  m.pendingFilters = filters
  m.pendingFiltersError = errorMsg

  req = GetApi().BuildGetFilters2Request(m.pendingFilterParams)
  promises.chain(fetchAsync(req, "filters2-" + m.pendingFilterParams.parentid)).then(sub(res as object)
    merged = m.pendingFilters
    if res.ok and isValid(res.json)
      ' Absent keys, not empty ones, are how a pre-12.0 server answers, so a missing
      ' key here means "no such filter", which is exactly what an empty option list
      ' makes the panel do.
      merged[languageFilters.AUDIO_KEY] = languageFilters.optionsFrom(res.json.AudioLanguages)
      merged[languageFilters.SUBTITLE_KEY] = languageFilters.optionsFrom(res.json.SubtitleLanguages)
    end if
    deliverFilters(merged, m.pendingFiltersError)
  end sub).catch(sub(err as object)
    m.log.warn("language filter load failed", err.reason)
    deliverFilters(m.pendingFilters, m.pendingFiltersError)
  end sub)
end sub

' Hand the filter result back to the presenter, which owns m.apiFilters + the toast.
' This is needed because Roku promise callbacks run at component scope, not on the
' presenter class instance.
sub deliverFilters(filters as object, errorMsg as string)
  if isValid(m.presenter)
    m.presenter.onFiltersLoaded(filters, errorMsg)
  end if
end sub

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

  backdropUrl = getItemBackdropUrl(m.selectedFavoriteItem, m.backdropSize)

  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()
  ' The list is interactive while deliveries are pending, so rowItemSelected can be STALE
  ' against freshly swapped content: an OK queued behind a swap can index a row the new
  ' content no longer has. Walk the tree stepwise; a chained getChild().getChild() faults
  ' on the invalid intermediate.
  rowNode = m.genreList.content.getChild(m.genreList.rowItemSelected[0])
  if not isValid(rowNode) then return
  selected = rowNode.getChild(m.genreList.rowItemSelected[1])

  ' Ignore a skeleton placeholder. The Genres view is interactive from the moment its rows
  ' are drawn, which is BEFORE the samples land, so OK on a not-yet-filled row is reachable
  ' for a few hundred ms, longer on a slow server. Routing on a bare placeholder would
  ' navigate to nothing. Same guard, same reason, as HomeRows.itemSelected.
  if not isValid(selected) or skeleton.isPlaceholder(selected) then return

  m.top.selectedItem = selected
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

  ' A skeleton placeholder carries no image tags: asking it for a backdrop reads fields a
  ' bare ContentNode does not have. Leave the current backdrop up until the row is filled.
  if skeleton.isPlaceholder(focusedItem) then return

  ' Update backdrop with focused item's backdrop
  m.global.sceneManager.callFunc("setBackgroundImage", getItemBackdropUrl(focusedItem, m.backdropSize))
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()
    ' Same stale-index and skeleton exposure as onGenreItemSelected: rowItemFocused is -1
    ' (an integer) when nothing is focused, the row index can outlive a content swap, and
    ' a skeleton placeholder must not quick-play (HomeRows guards its play path the same
    ' way; QueueManager would reject the bare node anyway, but not by design).
    rowItemFocused = m.genreList.rowItemFocused
    if type(rowItemFocused) <> "roArray" or rowItemFocused.count() < 2 then return invalid
    rowNode = m.genreList.content.getChild(rowItemFocused[0])
    if not isValid(rowNode) then return invalid
    focusedItem = rowNode.getChild(rowItemFocused[1])
    if skeleton.isPlaceholder(focusedItem) then return invalid
    return focusedItem
  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])
          ' Re-ticks the boxes from the saved query. The inverse of the join in
          ' ItemGridOptions.onKeyEvent, and they are tested as a round trip: a
          ' disagreement between them is a filter that applies while showing no ticks.
          o.checkedState = gridQuery.checkedStateFor(o.options, m.filterOptions[m.filterOptions.keys()[0]], o.delimiter)
        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)

    ' Propagate filter change to TV Guide if it's active
    if isValid(m.tvGuide)
      m.tvGuide.filter = m.filter
    end if
  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.nameStartsWith = " "
    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 (for example, PhotoPresenter saves slideshow/random settings)
  if isValid(m.presenter)
    m.presenter.onOptionsClosed(m.options)
  end if

  if reload
    reloadGridItems()
  end if

  ' Restore focus to the correct view after options close
  if isValid(m.tvGuide)
    if isValid(m.tvGuide.lastFocus)
      m.tvGuide.lastFocus.setFocus(true)
    end if
  else
    focusContent()
  end if
end sub

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

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

    m.data = CreateObject("roSGNode", "ContentNode")
    m.itemGrid.content = m.data
    m.flatIndexMap = {}
    initTextureManager(m.data, invalid, invalid, invalid)

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

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

sub onAlphaActiveChanged()
  m.log.debug("start onAlphaActiveChanged()", m.top.isAlphaActive)

  if m.top.isAlphaActive
    ' 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 onAlphaActiveChanged()")
end sub

sub onVoiceFilter()
  if m.voiceBox.text <> ""
    ' Route voice search to TV Guide when it's active
    if isValid(m.tvGuide)
      m.tvGuide.searchTerm = m.voiceBox.text
      updateTitle()
      m.tvGuide.lastFocus.setFocus(true)
      return
    end if

    m.loadedItems = 0
    m.data = CreateObject("roSGNode", "ContentNode")
    m.itemGrid.content = m.data
    m.flatIndexMap = {}
    initTextureManager(m.data, invalid, invalid, invalid)
    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.nameStartsWith = " "
    query = buildGridQuery(m.nameStartsWith, voiceSearchTerm)
    ' Force recursive and searchTerm for voice search even if the presenter changed them
    query.isRecursive = true
    query.searchTerm = voiceSearchTerm
    setGridQuery(query)
    startGridRun(0, false)

    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 isValidAndNotEmpty(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()
    focusContent()
    m.voiceBox.setFocus(false)
  end if

  ' Photos go to ItemDetails like any other item (OK, then itemSelected, then onItemSelected).
  ' The fullscreen viewer opens from ItemDetails' "View" button (viewPhotoButton, then
  ' quickplay.photo), not directly from the grid. Launching PhotoDetails here bypassed
  ' ItemDetails and double-pushed (viewer + ItemDetails stacked).

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

      ' Opening the panel is the only moment the filter lists are actually read, so it is
      ' also where they are revalidated: SetUpOptions() above has already rendered the
      ' cached lists, and this refresh lands silently for the NEXT open rather than
      ' reshuffling categories under a cursor already in the list. It is what lets every
      ' other path (sort, filter, view, alpha, voice search) stop refetching entirely,
      ' and it retries a load that previously failed.
      if isValid(m.presenter) then m.presenter.refreshFilters()

      ' Use TV Guide's focused channel when active, otherwise use the grid's focused item
      if isValid(m.tvGuide) and isValid(m.channelFocused)
        itemSelected = m.channelFocused
      else
        itemSelected = m.selectedFavoriteItem
      end if
      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
      ' The library view is router-owned: pop it via the router (back to Home / the
      ' previous routed view).
      sgrouter.goBack()
      return true
    end if
  else if key = "play"
    ' TV Guide: quickplay the focused channel
    if isValid(m.tvGuide)
      m.log.debug("play pressed on TV Guide", "channelFocused", isValid(m.channelFocused))
      if isValid(m.channelFocused)
        m.log.debug("quickplay channel", "id", m.channelFocused.id, "type", m.channelFocused.type)
        m.top.quickPlayNode = m.channelFocused
        m.top.quickPlayNode = invalid
        return true
      end if
    end if

    itemToPlay = getItemFocused()

    if isValid(itemToPlay)
      m.top.quickPlayNode = itemToPlay
      m.top.quickPlayNode = invalid
      return true
    end if
  else if key = "OK" and m.retryButton.hasFocus()
    if m.loadFailed.visible then retryGridLoad()
    return true
  else if key = "left"
    if m.retryButton.hasFocus()
      m.top.isAlphaActive = true
      m.retryButton.setFocus(false)
      m.alphaMenu.setFocus(true)
      return true
    else if m.itemGrid.isinFocusChain()
      m.top.isAlphaActive = true
      m.itemGrid.setFocus(false)
      m.alphaMenu.setFocus(true)
      return true
    else if m.genreList.isinFocusChain()
      m.top.isAlphaActive = true
      m.genreList.setFocus(false)
      m.alphaMenu.setFocus(true)
      return true
    end if
  else if key = "right" and m.alpha.isinFocusChain()
    m.top.isAlphaActive = false
    m.alphaMenu.setFocus(false)
    focusContent()
    return true
  else if key = "replay" and m.options.visible
    ' The dialog does not handle Replay, so it arrives here. Reloading and resetting the
    ' filters under an open dialog would rewrite the choices the user is making in it.
    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.nameStartsWith = ""
    m.top.alphaSelected = ""
    m.filter = "All"
    m.filterOptions = {}
    m.data = CreateObject("roSGNode", "ContentNode")
    m.itemGrid.content = m.data
    m.flatIndexMap = {}
    initTextureManager(m.data, invalid, invalid, invalid)
    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 + " (" + translate(translationKeys.LabelFavorites) + ")"
  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 = translate(translationKeys.MessageFilteredBy, [m.top.parentItem.title, m.voiceBox.text])
    return
  end if

  if m.nameStartsWith.trim() <> ""
    m.top.overhangTitle = translate(translationKeys.MessageFilteredBy, [m.top.parentItem.title, m.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, translate(translationKeys.LabelNetworks))
  else if lowerView = "studios"
    m.top.overhangTitle = "%s (%s)".Format(m.top.parentItem.title, translate(translationKeys.LabelStudios))
  else if lowerView = "genres"
    m.top.overhangTitle = "%s (%s)".Format(m.top.parentItem.title, translate(translationKeys.LabelGenres))
  else if lowerView = LCase(collectionsView.VIEW_NAME)
    m.top.overhangTitle = "%s (%s)".Format(m.top.parentItem.title, translate(translationKeys.LabelCollections))
  else if lowerView = "artistsgrid"
    m.top.overhangTitle = "%s (%s)".Format(m.top.parentItem.title, translate(translationKeys.LabelArtists))
  else if lowerView = "albumartistsgrid"
    m.top.overhangTitle = "%s (%s)".Format(m.top.parentItem.title, translate(translationKeys.LabelAlbumArtists))
  else if lowerView = "albums"
    m.top.overhangTitle = "%s (%s)".Format(m.top.parentItem.title, translate(translationKeys.LabelAlbums))
  end if

  ' Add item count if enabled
  actInt = m.itemGrid.itemFocused + 1
  if m.showItemCount and m.totalRecordCount > 0 and lowerView <> "genres"
    m.top.overhangTitle += " (" + translate(translationKeys.Message1Of2, [actInt.toStr(), m.totalRecordCount.toStr()]) + ")"
  end if
end sub

' Returns the flat index of an item within the grid's content root.
' Called by GridItem via callFunc for O(1) index lookup (avoids O(N) child scan).
' @param itemId - the Jellyfin item ID to look up
' @returns flat index, or -1 if not found
function getItemFlatIndex(itemId as string) as integer
  if not isValidAndNotEmpty(itemId) then return -1
  if isValid(m.flatIndexMap) and m.flatIndexMap.doesExist(itemId)
    return m.flatIndexMap[itemId]
  end if
  return -1
end function

' onDestroy: Full teardown releasing all resources before component removal
' Called automatically via JRScreen.beforeViewClose when sgRouter permanently closes this view.
sub onDestroy()
  #if perfTiming
    logGridSession()
  #end if
  ' Detach the interface observers registered in init(). Must precede every
  ' reference release below: these handlers dereference them.
  m.top.unobserveField("presenterType")
  m.top.unobserveField("parentItem")
  m.top.unobserveField("alphaSelected")
  m.top.unobserveField("isAlphaActive")

  m.log.verbose("onDestroy")

  ' Destroy texture manager: force-unloads all grid item textures
  destroyTextureManager(m.data)

  ' Unobserve child node observers
  m.top.unobserveField("selectedItem") ' self-navigation observer
  m.top.unobserveField("quickPlayNode") ' playback launcher
  m.genreList.unobserveField("rowItemFocused")
  m.genreList.unobserveField("itemSelected")
  m.itemGrid.unobserveField("itemFocused")
  m.itemGrid.unobserveField("currFocusRow")
  m.itemGrid.unobserveField("itemSelected")
  m.voiceBox.unobserveField("text")
  m.retryButton.unobserveField("isReady")
  m.failureNarrationTimer.unobserveField("fire")
  m.failureNarrationTimer.control = "stop"
  m.loadingMoreDelay.unobserveField("fire")
  m.loadingMoreDelay.control = "stop"
  m.slowLoadTimer.unobserveField("fire")
  ' Backing out while the first page is on its way: nothing else would hide the spinner.
  if m.ownsSpinner then stopGridSpinner()

  ' Break the GridItem <-> content-root retain CYCLE before dropping our references.
  ' Every GridItem caches m.gridView = this view node (resolveGridViewConfig walks up for
  ' the gridTitles field) AND observes fields on the shared content root with observeField
  ' (setupGridTextureObserver), which makes the content root hold each cell's component
  ' scope. That closes the loop:
  '   view -> m.data -> cell scope -> m.gridView -> view
  ' BrightScript refcounts, so a cycle is never collected: the whole view survived every
  ' back-out as an unparented root, one per library visit. GridItem has no onDestroy and
  ' only unobserves when the content root CHANGES, so the release has to come from here:
  ' dropping BOTH references to the content root (ours and the ArrayGrid's) frees it, which
  ' frees the cell scopes it holds, which drops their back-reference to us.
  '
  ' This has to stay BELOW the unobserve block: emptying an ArrayGrid can move itemFocused /
  ' rowItemFocused, and onItemFocused / onGenreItemFocused both dereference `.content`
  ' unguarded. Dropping the observers first means a reset fires at nothing.
  m.itemGrid.content = invalid
  m.genreList.content = invalid

  ' tvGuide may already be cleaned up by hideTvGuide() on back-press
  if isValid(m.tvGuide)
    m.tvGuide.unobserveField("watchChannel")
    m.tvGuide.unobserveField("focusedChannel")
    m.tvGuide.callFunc("onDestroy")
    m.tvGuide = invalid
  end if

  ' Stop and release the grid's run
  releaseGridRun()

  ' Destroy presenter class object (may already be invalid from back-press handler)
  if isValid(m.presenter)
    m.presenter.onDestroy()
    m.presenter = invalid
  end if

  ' Clear node references
  m.options = invalid
  m.itemGrid = invalid
  m.voiceBox = invalid
  m.emptyText = invalid
  m.loadFailed = invalid
  m.loadFailedText = invalid
  m.retryButton = invalid
  m.failureNarrationTimer = invalid
  m.loadingMore = invalid
  m.loadingMoreDelay = invalid
  m.slowLoadTimer = invalid
  m.alpha = invalid
  m.alphaMenu = invalid
  m.genreList = invalid
  m.presentationBackdrop = invalid
  m.presentationInfo = invalid
  m.channelFocused = invalid

  ' Release content nodes
  m.data = invalid
  m.genreData = invalid
end sub