' 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