components_ItemGrid_GridItem.bs
import "pkg:/source/constants/imageSize.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/utils/itemImageUrl.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/placeholderImage.bs"
import "pkg:/source/utils/textureManager.bs"
sub init()
m.log = new log.Logger("GridItem")
m.itemPoster = m.top.findNode("itemPoster")
m.itemIcon = m.top.findNode("itemIcon")
m.itemText = m.top.findNode("itemText")
m.placeholder = m.top.findNode("placeholder")
m.itemPoster.observeField("loadStatus", "onPosterLoadStatusChanged")
' Enable render tracking for texture management
m.top.enableRenderTracking = true
m.top.observeField("renderTracking", "onRenderTrackingChanged")
' URI cache for texture management.
' When a cell scrolls off-screen, its poster URI is cleared to release texture memory.
' This cache holds the real URI so it can be restored when the cell scrolls back.
m.cachedPosterUri = ""
m.cachedLoadDisplayMode = "scaleToZoom"
m.isTextureUnloaded = false
' Reference to the MarkupGrid's root ContentNode — used to observe loadedRowRange.
' Set once in setupGridTextureObserver() when the first itemContent is assigned.
m.contentRoot = invalid
' Cached flat index of this cell's itemContent within the content root.
' Recomputed on each cell recycle (onItemContentChanged).
m.cachedFlatIndex = -1
' Add some padding space when Item Titles are always showing
if m.itemText.visible then m.itemText.maxWidth = 224
' grab data from ItemGrid node
m.itemGrid = m.top.GetParent().GetParent() 'Parent is JRMarkupGrid and it's parent is the ItemGrid
if isValid(m.itemGrid)
if isValid(m.itemGrid.imageDisplayMode)
m.itemPoster.loadDisplayMode = m.itemGrid.imageDisplayMode
end if
if isValid(m.itemGrid.gridTitles)
m.gridTitles = m.itemGrid.gridTitles
end if
end if
posterY = m.itemPoster.translation[1]
m.itemText.translation = [0, posterY + m.itemPoster.height + 18]
m.itemText.visible = m.gridTitles = "showalways"
end sub
sub onItemContentChanged()
itemData = m.top.itemContent
if not isValid(itemData) then return
setupGridTextureObserver()
m.isTextureUnloaded = false
if itemData.isWatched
m.itemPoster.isWatched = true
end if
if itemData.unplayedItemCount > 0
m.itemPoster.unplayedCount = itemData.unplayedItemCount
end if
itemType = LCase(itemData.type)
posterUri = ""
if itemType = "movie" or itemType = "musicvideo"
posterUri = getItemPosterUrl(itemData)
m.itemText.text = itemData.title
else if itemType = "series"
posterUri = getItemPosterUrl(itemData)
m.itemText.text = itemData.title
else if itemType = "boxset"
posterUri = getItemPosterUrl(itemData)
m.itemText.text = itemData.title
else if itemType = "tvchannel"
posterUri = getItemPosterUrl(itemData)
m.itemText.text = itemData.title
else if itemType = "folder" or itemType = "collectionfolder" or itemType = "channelfolderitem" or itemType = "photoalbum"
posterUri = getItemPosterUrl(itemData)
m.itemText.text = itemData.title
m.itemPoster.loadDisplayMode = m.itemGrid.imageDisplayMode
else if itemType = "genre" or itemType = "studio"
posterUri = getItemPosterUrl(itemData)
m.itemText.text = itemData.title
else if itemType = "musicgenre"
' Music genres use square 1:1 sizing
posterUri = getItemPosterUrl(itemData)
m.itemText.text = itemData.title
setSquarePosterLayout()
m.itemPoster.loadDisplayMode = "scaleToFit"
else if itemType = "video" or itemType = "recording"
posterUri = getItemPosterUrl(itemData)
m.itemText.text = itemData.title
else if itemType = "playlist"
posterUri = getItemPosterUrl(itemData)
m.itemText.text = itemData.title
else if itemType = "photo"
posterUri = getItemPosterUrl(itemData)
m.itemText.text = itemData.title
else if itemType = "episode"
posterUri = getItemPosterUrl(itemData)
if isValidAndNotEmpty(itemData.seriesName)
m.itemText.text = itemData.seriesName + " - " + itemData.title
else
m.itemText.text = itemData.title
end if
else if itemType = "musicartist"
posterUri = getItemPosterUrl(itemData)
m.itemText.text = itemData.title
setSquarePosterLayout()
m.itemPoster.loadDisplayMode = "limitSize"
else if itemType = "musicalbum"
posterUri = getItemPosterUrl(itemData)
m.itemText.text = itemData.title
setSquarePosterLayout()
m.itemPoster.loadDisplayMode = "limitSize"
else if itemType = "audio"
posterUri = getItemPosterUrl(itemData)
m.itemText.text = itemData.title
else
m.log.warn("Unhandled Grid Item Type", itemData.type)
end if
' Cache URI for texture management
m.cachedPosterUri = posterUri
m.cachedLoadDisplayMode = m.itemPoster.loadDisplayMode
if posterUri = ""
' Known-missing image — eagerly surface the typed glyph on the backdrop.
' Load-status never fires "failed" for an empty URI, so we must set the
' placeholder state here rather than waiting for onPosterLoadStatusChanged.
m.itemPoster.uri = ""
showPlaceholder(resolveItemPlaceholderType(m.top.itemContent))
else if shouldLoadGridTexture()
' Image expected and the texture manager wants this cell loaded — start
' the placeholder in loading state; the load-status observer takes over.
resetPlaceholderToLoading()
m.itemPoster.uri = posterUri
else
' Image expected but the texture manager is keeping this cell off-screen
' to save memory. Loading state until the cell scrolls into range and
' reloadGridTexture() restores the URI.
m.isTextureUnloaded = true
resetPlaceholderToLoading()
m.itemPoster.uri = ""
end if
end sub
' Apply square 323x323 layout for music items (artists, albums, genres)
sub setSquarePosterLayout()
m.itemPoster.height = 323
m.itemPoster.width = 323
m.itemPoster.loadWidth = 323
m.itemPoster.loadHeight = 323
m.itemText.translation = [0, m.itemPoster.translation[1] + m.itemPoster.height + 18]
m.itemText.maxWidth = 323
m.placeholder.width = 323
m.placeholder.height = 323
end sub
' Enable title scrolling based on item focus
sub onFocusChanged()
if m.top.itemHasFocus = true
m.itemText.repeatCount = -1
else
m.itemText.repeatCount = 0
end if
if m.gridTitles = "showonhover"
m.itemText.visible = m.top.itemHasFocus
end if
end sub
' Toggle the JRPlaceholder fallback based on the real poster's load state.
' Mirrors the canonical state machine from JRRowItem.bs onPosterLoadStatusChanged.
sub onPosterLoadStatusChanged()
' Ignore status changes from intentional texture unloads (uri cleared to free memory)
if m.isTextureUnloaded then return
if m.itemPoster.loadStatus = "ready" and m.itemPoster.uri <> ""
' Real image loaded — placeholder is no longer needed
m.placeholder.visible = false
else if m.itemPoster.loadStatus = "failed" and m.itemPoster.uri <> ""
' Real image failed — surface a type-appropriate glyph on the backdrop
showPlaceholder(resolveItemPlaceholderType(m.top.itemContent))
else
' Loading or no URI — keep the placeholder visible in its current state
m.placeholder.visible = true
end if
end sub
' Flip the placeholder into failure state with a type-appropriate glyph, then
' clear the poster URI so the glyph isn't covered by a stale broken image.
' itemType="" puts JRPlaceholder in loading state (backdrop only, no glyph);
' resolveItemPlaceholderType returns "" only for invalid/typeless items, so
' valid items always end up here with a typed glyph or the Folder fallback.
sub showPlaceholder(itemType as string)
m.placeholder.itemType = itemType
m.placeholder.visible = true
m.itemPoster.uri = ""
end sub
' Reset the JRPlaceholder to loading state (themed backdrop visible, no glyph).
' Used by the initial-load branches in onItemContentChanged and by the texture
' unload paths so off-screen / unloaded cells don't carry a stale failure-state
' glyph back into view when they reload.
sub resetPlaceholderToLoading()
m.placeholder.itemType = ""
m.placeholder.visible = true
end sub
' ============================================================================
' Texture Management
' ============================================================================
' One-time setup: observes loadedRowRange and textureManagerState on the
' MarkupGrid's content root. The content root is the direct parent of
' itemContent in the content tree (flat model, unlike RowList's 2-level hierarchy):
' contentRoot → itemNode (itemContent)
' Called from onItemContentChanged. Skips if already connected.
sub setupGridTextureObserver()
item = m.top.itemContent
if not isValid(item) then return
contentRoot = item.getParent()
if not isValid(contentRoot) then return
' Cache flat index for row position calculations (O(1) lookup via BaseGridView)
m.cachedFlatIndex = m.itemGrid.callFunc("getItemFlatIndex", item.id)
' One-time observer setup — only run once per content root
if isValid(m.contentRoot) and m.contentRoot.isSameNode(contentRoot) then return
' Content root changed (view switch, filter, etc.) — reconnect observers
if isValid(m.contentRoot)
if m.contentRoot.hasField("textureManagerState")
m.contentRoot.unobserveField("textureManagerState")
end if
if m.contentRoot.hasField("loadedRowRange")
m.contentRoot.unobserveField("loadedRowRange")
end if
end if
m.contentRoot = contentRoot
if m.contentRoot.hasField("textureManagerState")
m.contentRoot.observeField("textureManagerState", "onTextureManagerStateChanged")
end if
if m.contentRoot.hasField("loadedRowRange")
m.contentRoot.observeField("loadedRowRange", "onLoadedRowRangeChanged")
end if
end sub
' Returns this cell's row position relative to the managed row ranges.
' Derives the grid row from the cached flat index and numColumns.
' @returns "visible" if in a visible row, "buffer" if in a vertical buffer row,
' or "outside" if not in any managed range
function getGridRowPosition() as string
if not isValid(m.contentRoot) or not m.contentRoot.hasField("loadedRowRange") then return "outside"
range = m.contentRoot.loadedRowRange
if not isValid(range) or range.count() < 4 then return "outside"
if m.cachedFlatIndex < 0 then return "outside"
markupGrid = m.top.GetParent() ' JRMarkupGrid
if not isValid(markupGrid) then return "outside"
numColumns = markupGrid.numColumns
if numColumns <= 0 then return "outside"
rowIndex = int(m.cachedFlatIndex / numColumns)
' Visible rows: visibleStart..visibleEnd
if rowIndex >= range[1] and rowIndex <= range[2] then return "visible"
' Buffer rows: bufferStart..visibleStart-1 or visibleEnd+1..bufferEnd
if rowIndex >= range[0] and rowIndex < range[1] then return "buffer"
if rowIndex > range[2] and rowIndex <= range[3] then return "buffer"
return "outside"
end function
' Returns the current texture manager state from the content root.
' @returns "init", "active", "hidden", "destroyed", or "init" if unavailable
function getGridTextureManagerState() as string
if not isValid(m.contentRoot) or not m.contentRoot.hasField("textureManagerState")
return "init"
end if
return m.contentRoot.textureManagerState
end function
' Determines whether this cell should load its poster during initial render.
' Mirrors evaluateGridTextureState logic: managed rows trust buffer logic,
' outside rows trust renderTracking.
function shouldLoadGridTexture() as boolean
state = getGridTextureManagerState()
' Init/hidden: allow load (freeze = don't change current state)
if state = "init" or state = "hidden"
return true
end if
' Destroyed: never load
if state = "destroyed"
return false
end if
' Active: check row position
rowPosition = getGridRowPosition()
if rowPosition = "visible" or rowPosition = "buffer"
return true
end if
' Outside managed ranges — renderTracking decides
return m.top.renderTracking <> "none"
end function
' Central texture decision — called when renderTracking, textureManagerState,
' or loadedRowRange changes.
'
' State machine (same as JRRowItem but without horizontal buffer):
' "destroyed" → force unload everything (no guards)
' "init" → no-op (freeze state during layout changes)
' "hidden" → no-op (freeze — screen is behind another on the stack)
' "active" → managed rows (visible + buffer): always loaded.
' Outside rows: renderTracking decides.
sub evaluateGridTextureState()
state = getGridTextureManagerState()
' Destroyed: force-unload everything unconditionally
if state = "destroyed"
forceUnloadGridTexture()
return
end if
' Init: freeze — layout recalculations cause spurious renderTracking changes.
if state = "init"
return
end if
' Hidden: freeze — screen is behind another on the stack. Textures stay
' loaded so returning is instant. renderTracking flips to "none" when
' visible=false propagates, but we must not react to it.
if state = "hidden"
return
end if
' Active: for managed rows, trust our own buffer logic over renderTracking.
rowPosition = getGridRowPosition()
if rowPosition = "buffer" or rowPosition = "visible"
' All items in visible + buffer rows stay loaded (no horizontal buffer for grids)
reloadGridTexture()
return
end if
' Outside managed ranges — renderTracking decides
if m.top.renderTracking <> "none"
reloadGridTexture()
else
unloadGridTexture()
end if
end sub
' Clears poster URI to release texture memory when the cell is off-screen.
' Items with no real image (cachedPosterUri = "") are skipped — they already show
' a lightweight local placeholder that costs negligible texture memory.
sub unloadGridTexture()
if m.cachedPosterUri = "" or m.isTextureUnloaded then return
m.isTextureUnloaded = true
m.itemPoster.uri = ""
resetPlaceholderToLoading()
end sub
' Force-unloads textures unconditionally — used only during onDestroy().
' Ignores m.isTextureUnloaded and m.cachedPosterUri guards so every cell releases memory.
sub forceUnloadGridTexture()
m.isTextureUnloaded = true
m.itemPoster.uri = ""
resetPlaceholderToLoading()
end sub
' Restores poster URI from cache when the cell scrolls back on screen.
' The backdrop will show briefly while the image reloads from Roku's HTTP cache.
sub reloadGridTexture()
if m.cachedPosterUri <> "" and m.itemPoster.uri <> m.cachedPosterUri
m.isTextureUnloaded = false
m.itemPoster.loadDisplayMode = m.cachedLoadDisplayMode
m.itemPoster.uri = m.cachedPosterUri
end if
end sub
' Fires when the cell scrolls in/out of the visible area.
sub onRenderTrackingChanged()
evaluateGridTextureState()
end sub
' Fires when textureManagerState changes (init → active, active → hidden, etc.)
sub onTextureManagerStateChanged()
evaluateGridTextureState()
end sub
' Fires when the parent screen updates the loaded row range (focus change).
sub onLoadedRowRangeChanged()
evaluateGridTextureState()
end sub