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/rowItemText.bs"
import "pkg:/source/utils/textureManager.bs"
' Vertical offset of the poster/placeholder from the cell top. The title label sits
' below the poster, inside the grid's inter-row spacing (see updateLayout).
const POSTER_TOP_OFFSET = 22
' Gap between the bottom of the poster and the title label. Matches JRRowItem's
' FOCUS_PADDING so grid and row cells share one vertical rhythm.
const TITLE_GAP = 18
' Inset of the genre action-tile label from the backdrop edges.
const GENRE_LABEL_INSET = 5
sub init()
m.log = new log.Logger("GridItem")
m.itemPoster = m.top.findNode("itemPoster")
m.itemText = m.top.findNode("itemText")
m.placeholder = m.top.findNode("placeholder")
m.genreBackdrop = m.top.findNode("genreBackdrop")
m.genreLabel = m.top.findNode("genreLabel")
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
' Display/title config read from the owning BaseGridView. Resolved lazily via
' resolveGridViewConfig() (defaults until then).
m.imageDisplayMode = "scaleToZoom"
m.gridTitles = ""
' The owning BaseGridView node, resolved lazily by walking up the node tree.
' Also the callFunc target for getItemFlatIndex in texture management.
m.gridView = invalid
end sub
' Resolves the owning BaseGridView and reads its display/title config. Walks up
' the node tree to the first ancestor exposing gridTitles rather than assuming a
' fixed depth: the MarkupGrid puts cells 2 levels below BaseGridView, but the genre
' RowList nests them deeper. The old fixed GetParent().GetParent() walk missed
' BaseGridView for RowList cells, so genre tiles silently fell back to scaleToZoom
' (cropping posters the presenter intended as scaleToFit) and an empty gridTitles
' (no titles ever). Idempotent — caches m.gridView on first success.
sub resolveGridViewConfig()
if isValid(m.gridView) then return
node = m.top.getParent()
while isValid(node) and not node.hasField("gridTitles")
node = node.getParent()
end while
if not isValid(node) then return
m.gridView = node
if isValid(node.imageDisplayMode) then m.imageDisplayMode = node.imageDisplayMode
if isValid(node.gridTitles) then m.gridTitles = node.gridTitles
end sub
' Re-render when the parent assigns a new item to this (possibly recycled) cell.
' Texture observers only need itemContent, so wire them up even before the cell has
' been sized; the actual render waits until width/height are known (onSizeChanged
' fires the first paint when the parent applies itemSize/rowItemSize).
sub onItemContentChanged()
itemData = m.top.itemContent
if not isValid(itemData) then return
resolveGridViewConfig()
setupGridTextureObserver()
if m.top.width <= 0 or m.top.height <= 0 then return
renderItem()
end sub
' Re-render when the parent applies/changes the cell size (MarkupGrid itemSize or
' RowList rowItemSize). Mirrors JRRowItem so one component renders every cell size.
sub onSizeChanged()
if not isValid(m.top.itemContent) then return
if m.top.width <= 0 or m.top.height <= 0 then return
renderItem()
end sub
sub renderItem()
itemData = m.top.itemContent
if not isValid(itemData) then return
updateLayout(m.top.width, m.top.height)
' Clear any badge from a prior bind before this recycled cell renders the new item.
m.itemPoster.callFunc("resetBadge")
m.isTextureUnloaded = false
' Title text + default (always-on) visibility — recomputed every recycle.
m.itemText.text = getRowItemTitle(itemData)
m.itemText.visible = m.gridTitles = "showalways"
itemType = LCase(itemData.type)
itemFolderType = ""
if isValid(itemData.folderType) then itemFolderType = LCase(itemData.folderType)
if itemType = "genre" or itemFolderType = "genre"
' "View All [Genre]" action tiles are navigation buttons, not media — they get a
' themed backdrop + in-cell label rather than going through JRPlaceholder, so they
' don't read as failed-to-load placeholders. Check both type and folderType: if
' Jellyfin returns IsFolder=false/absent the transformer leaves type="Genre" and
' folderType="" so we must match on type directly. MusicGenre/Studio do NOT match
' here and keep the regular poster path.
renderGenreTile(itemData)
else
renderMediaItem(itemData, itemType)
end if
end sub
' Sizes and positions every child to the current cell dimensions. The focus slot is
' rowItemSize/itemSize; the poster fills it at POSTER_TOP_OFFSET so the poster overflows
' the slot bottom and the focus border (drawn behind it) reads with a top margin and no
' overlap. The title sits below at POSTER_TOP_OFFSET + h + TITLE_GAP — kept OUTSIDE the
' focus border because the container sets rowHeights/itemSize taller than the slot (the
' focus border pins to the slot, not the cell's full bounding box).
sub updateLayout(w as float, h as float)
setPosterRect(0, POSTER_TOP_OFFSET, w, h)
m.genreLabel.translation = [GENRE_LABEL_INSET, POSTER_TOP_OFFSET + GENRE_LABEL_INSET]
m.genreLabel.width = w - 2 * GENRE_LABEL_INSET
m.genreLabel.height = h - 2 * GENRE_LABEL_INSET
m.itemText.translation = [0, POSTER_TOP_OFFSET + h + TITLE_GAP]
m.itemText.maxWidth = w
end sub
' Positions/sizes the placeholder, poster, and genre backdrop to the same rect.
sub setPosterRect(x as integer, y as integer, rw as float, rh as float)
m.placeholder.translation = [x, y]
m.placeholder.width = rw
m.placeholder.height = rh
m.itemPoster.translation = [x, y]
m.itemPoster.width = rw
m.itemPoster.height = rh
m.itemPoster.loadWidth = int(rw)
m.itemPoster.loadHeight = int(rh)
m.genreBackdrop.translation = [x, y]
m.genreBackdrop.width = rw
m.genreBackdrop.height = rh
end sub
' Renders a "View All [Genre]" action tile: themed backdrop + centered genre label,
' no poster image. The below-tile item title is hidden — the tile's own centered label
' already names the genre, and the row's genre header sits directly above it, so a third
' copy is redundant. cachedPosterUri is cleared so texture management skips this cell.
sub renderGenreTile(itemData as object)
m.itemPoster.uri = ""
m.placeholder.visible = false
m.itemText.visible = false
m.genreBackdrop.visible = true
m.genreLabel.text = itemData.title
m.genreLabel.visible = true
m.cachedPosterUri = ""
end sub
' Renders a standard media item: poster image with the JRPlaceholder fallback state
' machine and texture management.
sub renderMediaItem(itemData as object, itemType as string)
m.genreBackdrop.visible = false
m.genreLabel.visible = false
' Watched / unplayed-count badges, honoring the user setting (matches JRRowItem and
' the former GridItemSmall; the former GridItem ignored this setting).
if not m.global.user.settings.uiTvShowsDisableUnwatchedCount
if itemData.isWatched
m.itemPoster.isWatched = true
else if itemData.unplayedItemCount > 0
m.itemPoster.unplayedCount = itemData.unplayedItemCount
end if
end if
m.itemPoster.loadDisplayMode = resolveLoadDisplayMode(itemType)
posterUri = getItemPosterUrl(itemData)
' Cache URI + display mode 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
' Picks the poster scaling mode. Music posters are square art that must not be cropped;
' everything else uses the grid's configured display mode (default scaleToZoom).
function resolveLoadDisplayMode(itemType as string) as string
if itemType = "musicartist" or itemType = "musicalbum"
return "limitSize"
else if itemType = "musicgenre"
return "scaleToFit"
end if
return m.imageDisplayMode
end function
' 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 renderMediaItem 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
'
' Active when this component backs the MarkupGrid (itemGrid), whose content root is
' texture-managed via initTextureManager. In the genre RowList the content root has no
' textureManagerState/loadedRowRange fields, so getGridTextureManagerState() resolves to
' "init" — which means "always load, never unload" — making this section naturally inert
' there (matching the former GridItemSmall, which had no texture management at all).
' ============================================================================
' 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).
' Returns -1 for items not in the texture-managed grid (e.g. genre RowList rows),
' or when BaseGridView couldn't be resolved.
m.cachedFlatIndex = -1
if isValid(m.gridView)
flatIndex = m.gridView.callFunc("getItemFlatIndex", item.id)
if isValid(flatIndex) then m.cachedFlatIndex = flatIndex
end if
' 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