import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/placeholderImage.bs"
import "pkg:/source/utils/rowItemImage.bs"
import "pkg:/source/utils/rowItemText.bs"
import "pkg:/source/utils/textureManager.bs"
' Vertical gap between the bottom of the poster (= bottom of the RowList focus ring)
' and the first text label. Prevents the focus border from overlapping the title.
const FOCUS_PADDING = 18
sub init()
m.log = new log.Logger("JRRowItem")
m.placeholder = m.top.findNode("placeholder")
m.poster = m.top.findNode("poster")
m.itemIcon = m.top.findNode("itemIcon")
m.title = m.top.findNode("title")
m.staticTitle = m.top.findNode("staticTitle")
m.subtitle = m.top.findNode("subtitle")
m.top.enableRenderTracking = true
m.isLibraryTile = false
' URI caches for renderTracking texture management.
' When a cell scrolls off-screen, its poster URI is cleared to release texture memory.
' These caches hold the real URIs so they can be restored when the cell scrolls back.
m.cachedPosterUri = ""
m.cachedIconUri = ""
m.cachedLoadDisplayMode = "scaleToZoom"
m.isTextureUnloaded = false
' Reference to the RowList's root ContentNode — used to observe loadedRowRange.
' Set once in setupTextureObserver() when the first itemContent is assigned.
m.contentRoot = invalid
' Reactive bridge for live Program progress bars. When this cell is bound to
' a Program content node, an observer is installed on that node's
' playedPercentage field so JRRowList's 60s tick can refresh the visible
' progress bar without reassigning itemContent (which would re-render the cell).
' See applyPlayedPercentage().
m.observedProgramNode = invalid
m.poster.observeField("loadStatus", "onPosterLoadStatusChanged")
m.itemIcon.observeField("loadStatus", "onIconLoadStatusChanged")
m.top.observeField("renderTracking", "onRenderTrackingChanged")
end sub
sub onItemContentChanged()
' Tear down any Program progress observer from a prior bind even when the
' new itemContent is invalid. renderItem()'s top-of-function teardown only
' covers transitions through a valid bind; transitions to invalid never
' reach renderItem() and would otherwise leak the observer onto a stale
' content node — bounded by the next valid rebind, or unbounded if the cell
' is destroyed (Roku exposes no destroy lifecycle hook to clean up later).
if not isValid(m.top.itemContent)
unobserveProgramProgress()
return
end if
if m.top.width <= 0 or m.top.height <= 0 then return
setupTextureObserver()
renderItem()
end sub
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()
item = m.top.itemContent
if not isValid(item) then return
' Tear down any Program progress observer from the previous render before
' branching into tile/standard/loading modes. Must run unconditionally here
' (not inside applyPlayedPercentage) because the library-tile and loading
' branches never reach applyPlayedPercentage — leaving a stale observer
' attached would land tick writes on the wrong recycled poster.
unobserveProgramProgress()
m.poster.callFunc("resetBadge")
m.isTextureUnloaded = false
slotWidth = m.top.width
posterHeight = int(m.top.height)
updateLayout(slotWidth, posterHeight)
' Reset placeholder to loading state (themed backdrop only, no glyph) on
' every recycle. JRPlaceholder resets glyph visibility internally when
' itemType becomes "".
m.placeholder.itemType = ""
m.placeholder.visible = true
' Skeleton placeholder — show backdrop + centered spinner, no poster or text
if item.type = "Loading"
m.cachedPosterUri = ""
m.cachedIconUri = ""
m.poster.uri = ""
m.poster.visible = false
m.staticTitle.visible = false
m.title.visible = false
m.subtitle.visible = false
showLoadingSpinner(slotWidth, posterHeight)
return
end if
' Hide loading spinner if this recycled item previously showed one
hideLoadingSpinner()
m.poster.visible = true
m.isLibraryTile = (item.type = "CollectionFolder" or item.type = "UserView" or item.type = "Channel")
if m.isLibraryTile
renderLibraryTile(item, slotWidth, posterHeight)
else
renderStandardItem(item, slotWidth, posterHeight)
end if
end sub
' Renders a My Media library tile: themed backdrop + type icon + library name.
' The poster loads the library's Primary image; when it loads successfully the
' placeholder (and overlaid text) hide to reveal the image. Library tiles never
' set placeholder.itemType — the type-specific itemIcon (live_tv_white etc.)
' overlays the backdrop, so the JRPlaceholder glyph stays empty.
sub renderLibraryTile(item as object, slotWidth as float, posterHeight as float)
m.placeholder.visible = true
m.title.visible = false
m.staticTitle.visible = false
m.subtitle.visible = false
' Lazy-create the backdrop label the first time a library tile is rendered
initBackdropText(slotWidth, posterHeight)
backdropText = m.top.findNode("backdropText")
if isValid(backdropText)
backdropText.text = item.name
backdropText.visible = true
end if
' Icon based on collection type (music note, TV icon, etc.)
' Explicitly manage visibility so a stale icon from a previously-rendered tile
' in the same row doesn't persist when this cell is recycled.
iconPath = getLibraryIconPath(item.collectionType)
if iconPath <> ""
m.itemIcon.uri = iconPath
m.cachedIconUri = iconPath
' onIconLoadStatusChanged shows it once the image loads
else
m.itemIcon.visible = false
m.itemIcon.uri = ""
m.cachedIconUri = ""
end if
posterUri = getRowItemImageUrl(item, int(slotWidth), int(posterHeight), invalid)
m.cachedPosterUri = posterUri
m.cachedLoadDisplayMode = "scaleToZoom"
if shouldLoadTexture()
m.poster.uri = posterUri
else
m.isTextureUnloaded = true
end if
' When the same URL is re-assigned (e.g. data refresh returns identical libraries),
' Roku's Poster won't re-trigger loadStatus, so onPosterLoadStatusChanged() never
' fires to hide the placeholder. Detect this and hide immediately.
if not m.isTextureUnloaded and m.poster.loadStatus = "ready" and posterUri <> ""
m.placeholder.visible = false
if isValid(backdropText)
backdropText.visible = false
end if
end if
end sub
' Renders a standard item: poster image with title/subtitle text below.
sub renderStandardItem(item as object, slotWidth as float, posterHeight as float)
globalUser = m.global.user
userSettings = globalUser.settings
' Text — static title shown by default, scrolling title shown on focus
m.staticTitle.text = getRowItemTitle(item)
m.title.text = m.staticTitle.text
m.subtitle.text = getRowItemSubtitle(item)
m.staticTitle.visible = true
m.title.visible = false
m.subtitle.visible = true
' Hide any backdropText left over from a previous library tile render on this recycled instance.
' onPosterLoadStatusChanged is also gated, but this closes the window between render and first callback.
backdropText = m.top.findNode("backdropText")
if isValid(backdropText)
backdropText.visible = false
end if
' Placeholder shows while poster is loading (themed backdrop, no glyph yet),
' hidden once the real image is ready.
m.placeholder.visible = true
' Pre-compute webclient episode image flag to keep the utility function pure
useEpisodeImages = false
if isValid(globalUser.config)
useEpisodeImages = (globalUser.config.useEpisodeImagesInNextUpAndResume = true)
end if
posterUri = getRowItemImageUrl(item, int(slotWidth), int(posterHeight), userSettings, useEpisodeImages, m.top.shouldApplyEpisodeImageSetting)
' Blur unwatched episodes when the user setting is enabled.
' Guard posterUri <> "" to avoid appending to an empty string, which would produce
' the invalid URI "&blur=15" and trigger a bogus network request.
if isValid(userSettings) and userSettings.uiTvShowsBlurUnwatched and item.type = "Episode" and not item.isWatched and posterUri <> ""
posterUri = posterUri + "&blur=15"
end if
if posterUri = ""
' No image available — show placeholder icon centered on backdrop
showPlaceholder(item.type)
m.cachedPosterUri = ""
else
' TvChannel logos may be any aspect ratio — use scaleToFit (letterbox) to show the full logo.
' Audio album art is always square; scaleToFit prevents cropping when placed in a portrait slot
' (e.g. a mixed-content Playlist row). In a square slot the result is identical to scaleToZoom.
' All other types use scaleToZoom (fill) since their images match the slot shape.
m.poster.loadDisplayMode = (item.type = "TvChannel" or item.type = "Audio" or item.type = "Chapter") ? "scaleToFit" : "scaleToZoom"
m.cachedPosterUri = posterUri
m.cachedLoadDisplayMode = m.poster.loadDisplayMode
' Only fetch the image if this cell is on screen or in the buffer zone.
' Off-screen cells cache the URI for later reload but skip the network request.
if shouldLoadTexture()
m.poster.uri = posterUri
else
m.isTextureUnloaded = true
end if
end if
m.cachedIconUri = ""
' Watch badges
if item.isWatched
m.poster.isWatched = true
else if item.type = "Series" and item.unplayedItemCount > 0
m.poster.unplayedCount = item.unplayedItemCount
end if
applyPlayedPercentage(item)
end sub
' Sets the poster's progress bar percentage based on item type.
' Programs use live broadcast elapsed time (wall-clock derived); everything
' else uses UserData.PlayedPercentage as flattened by the data transformer.
'
' For Programs, also installs a reactive bridge observer on the content node's
' playedPercentage field. JRRowList's 60s tick rewrites that field, and this
' observer forwards the change to m.poster.playedPercentage — updating any
' currently-mounted VideoProgressBar without a cell re-render.
'
' Observer teardown is split across two sites to cover every itemContent
' transition this cell can see:
' • renderItem() top — handles transitions into a new valid bind (recycle
' into a different content node) and re-renders triggered by size changes
' on the same item. Required so applyPlayedPercentage doesn't double-
' install observers on size-change re-renders.
' • onItemContentChanged() invalid path — handles transitions to invalid,
' which never reach renderItem() and would otherwise leak the observer.
' This function therefore only needs to install — not clear.
sub applyPlayedPercentage(item as object)
if item.type = "Program" or item.type = "Recording"
nowSeconds = CreateObject("roDateTime").AsSeconds()
m.poster.playedPercentage = computeProgramBroadcastProgress(item.PlayStart, item.PlayDuration, nowSeconds)
item.observeField("playedPercentage", "onProgramProgressChanged")
m.observedProgramNode = item
else
m.poster.playedPercentage = item.playedPercentage
end if
end sub
' Tears down the bridge observer on the previously-bound Program content node.
' Called from two sites to cover every itemContent transition this cell sees:
' • Top of renderItem() — runs before tile/standard/loading branching so
' recycled cells never leak observers onto stale content nodes regardless
' of which render branch the new bind takes. Also covers size-change re-
' renders on the same item (without it, applyPlayedPercentage would stack
' duplicate observers on every size change).
' • Invalid path of onItemContentChanged() — covers transitions to invalid,
' which never reach renderItem(). Without this site, an observer installed
' on a Program node would leak when the cell unbinds.
sub unobserveProgramProgress()
if isValid(m.observedProgramNode)
m.observedProgramNode.unobserveField("playedPercentage")
m.observedProgramNode = invalid
end if
end sub
' Forwarded write from the bound Program content node to the live poster.
' Fires when JRRowList's tick rewrites the node's playedPercentage.
sub onProgramProgressChanged(msg as object)
m.poster.playedPercentage = msg.getData()
end sub
' Updates positions and sizes of all child nodes to match the current slot dimensions.
' Called at the start of every renderItem() before branching into tile/standard mode.
sub updateLayout(slotWidth as float, posterHeight as float)
m.placeholder.width = slotWidth
m.placeholder.height = posterHeight
m.poster.width = slotWidth
m.poster.height = posterHeight
m.poster.loadWidth = int(slotWidth)
m.poster.loadHeight = int(posterHeight)
' Text sits below the poster, outside the slot bounds (which = the focus ring).
' FOCUS_PADDING clears the focus border so it doesn't overlap the title.
titleY = posterHeight + FOCUS_PADDING
' title and staticTitle overlap at the same position; only one is visible at a time.
' Height is intentionally tighter than a standard line height to reduce dead space
' between the title and subtitle.
m.title.translation = [0, titleY]
m.title.maxWidth = slotWidth
m.title.height = 0
m.staticTitle.translation = [0, titleY]
m.staticTitle.width = slotWidth
m.staticTitle.height = 0
' Subtitle sits directly below title
m.subtitle.translation = [0, titleY + 30]
m.subtitle.maxWidth = slotWidth
m.subtitle.height = 0
end sub
' Lazy-creates the backdrop text label for library tiles on first use.
' Deferred allocation avoids creating the node for non-library rows.
sub initBackdropText(slotWidth as float, posterHeight as float)
if isValid(m.top.findNode("backdropText")) then return
backdropText = CreateObject("roSGNode", "LabelPrimaryLarger")
backdropText.id = "backdropText"
backdropText.isBold = true
backdropText.width = slotWidth - 18
backdropText.height = posterHeight - 18
backdropText.translation = [9, 9]
backdropText.horizAlign = "center"
backdropText.vertAlign = "center"
backdropText.ellipsizeOnBoundary = true
backdropText.wrap = true
m.top.appendChild(backdropText)
end sub
' Maps a Jellyfin library collection type to a local icon image path.
' Used to display a type-appropriate icon on library tile backdrops.
' @param collectionType - item.collectionType string (e.g., "music", "livetv")
' @returns pkg:/ path to the icon image
function getLibraryIconPath(collectionType as string) as string
collectionTypeLower = LCase(collectionType)
if collectionTypeLower = "livetv"
return "pkg:/images/media_type_icons/live_tv_white.png"
end if
' No icon for this type — return "" so the icon Poster stays hidden
' and backdropText remains vertically centered (standard library tile appearance).
return ""
end function
' Hides the placeholder (and any backdropText) once the real poster image loads.
' Shows the placeholder when the image is unavailable, still loading, or failed.
' On failure, sets placeholder.itemType so JRPlaceholder surfaces a
' type-appropriate glyph on the themed backdrop.
'
' backdropText visibility is only toggled for library tiles — standard items
' must not inherit a stale label when this component instance is recycled from
' a library row.
sub onPosterLoadStatusChanged()
' Ignore status changes triggered by intentional texture unloads (uri set to "").
' Without this guard, the "failed" status from clearing the URI would trigger
' a stale placeholder render and destroy the cached URI, preventing reload.
if m.isTextureUnloaded then return
if m.poster.loadStatus = "ready" and m.poster.uri <> ""
' Real image loaded — hide the placeholder so the poster shows through.
m.placeholder.visible = false
if m.isLibraryTile
backdropText = m.top.findNode("backdropText")
if isValid(backdropText)
backdropText.visible = false
end if
end if
else if m.poster.loadStatus = "failed" and m.poster.uri <> ""
' Real image failed — surface a type-appropriate placeholder glyph on the
' themed backdrop. Library tiles already have their own fallback (backdrop
' + itemIcon + text), so skip the generic glyph to avoid covering them.
' Keep cachedPosterUri intact so reloadTexture() retries the original URL
' when the cell scrolls off-screen and back. If the failure was transient
' (network hiccup, server throttle), the retry succeeds and the real image
' replaces the placeholder. If it fails again, this callback fires again
' and the placeholder stays.
'
' The uri <> "" guard prevents recursive re-trigger after showPlaceholder
' clears the URI: clearing produces a "failed" loadStatus from Roku, but
' since we set uri="" intentionally there's nothing to fall back to.
if not m.isLibraryTile
itemType = ""
if isValid(m.top.itemContent) then itemType = m.top.itemContent.type
showPlaceholder(itemType)
end if
else
' Loading or no URI — keep the placeholder visible behind the (transparent)
' poster.
m.placeholder.visible = true
if m.isLibraryTile
backdropText = m.top.findNode("backdropText")
if isValid(backdropText)
backdropText.visible = true
end if
end if
end if
end sub
' Shows the icon and repositions it with the backdrop text once the icon image loads.
' Hides the icon on failure so a stale image from a previous render does not persist.
sub onIconLoadStatusChanged()
if m.itemIcon.loadStatus = "ready" and m.itemIcon.uri <> ""
m.itemIcon.visible = true
arrangeIconAndBackdropText()
else
m.itemIcon.visible = false
end if
end sub
' Centers the icon in the upper portion of the backdrop and positions the library
' name label below it.
sub arrangeIconAndBackdropText()
backdropText = m.top.findNode("backdropText")
if not isValid(backdropText) then return
posterHeight = int(m.top.height)
slotWidth = m.top.width
m.itemIcon.translation = [
(slotWidth - m.itemIcon.width) / 2,
((posterHeight - m.itemIcon.height) / 2) / 2
]
backdropText.height = 0
backdropText.translation = [
backdropText.translation[0],
((posterHeight - m.itemIcon.height) / 2) + m.itemIcon.height
]
end sub
' ============================================
' PLACEHOLDER & TEXTURE MANAGEMENT
' ============================================
' Shows a type-appropriate placeholder glyph on the themed backdrop. Delegated
' to JRPlaceholder, which owns the backdrop + glyph + themed-blendColor
' composition and the itemType → URI mapping (via getPlaceholderImagePath).
'
' The poster URI is cleared to "" so a recycled cell's stale image (e.g.,
' the previous cell's photo) doesn't render on top of the placeholder.
' JRPoster sits ON TOP of JRPlaceholder per the XML order, so a stale URI
' would cover the placeholder glyph entirely. m.cachedPosterUri retains
' the real URL so reloadTexture() can retry it when the cell scrolls back
' on screen.
'
' @param itemType - Jellyfin item type string (e.g., "Movie", "Person")
sub showPlaceholder(itemType as string)
m.log.verbose("showPlaceholder", itemType)
m.placeholder.itemType = itemType
m.placeholder.visible = true
m.poster.uri = ""
end sub
' Returns a human-readable identifier for the current cell: "[RowTitle] ItemName"
' Used in texture management logs so you can tell which item changed without decoding URLs.
' Uses m.log to avoid string concat crashes from invalid/non-string field values.
sub logTextureAction(action as string)
item = m.top.itemContent
rowTitle = invalid
itemName = invalid
if isValid(item)
itemName = item.name
rowNode = item.getParent()
if isValid(rowNode) then rowTitle = rowNode.title
end if
m.log.verbose(action, "row:", rowTitle, "item:", itemName)
end sub
' One-time setup: observes loadedRowRange and focusedColumns on the RowList's content root.
' The content root is the grandparent of itemContent in the content tree:
' contentRoot → rowNode → itemNode (itemContent)
' Called from onItemContentChanged. Skips if already connected.
sub setupTextureObserver()
if isValid(m.contentRoot) then return
item = m.top.itemContent
if not isValid(item) then return
rowNode = item.getParent()
if not isValid(rowNode) then return
contentRoot = rowNode.getParent()
if not isValid(contentRoot) then return
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
if m.contentRoot.hasField("focusedColumns")
m.contentRoot.observeField("focusedColumns", "onFocusedColumnsChanged")
end if
end sub
' Returns this cell's row index by finding its row node in the content root's children.
' Computed on demand rather than cached because row indices shift when rows are
' dynamically inserted or removed (e.g. HomeRows section loading).
' @returns row index, or -1 if the cell's position cannot be determined
function getMyRowIndex() as integer
if not isValid(m.top.itemContent) then return -1
rowNode = m.top.itemContent.getParent()
if not isValid(rowNode) or not isValid(m.contentRoot) then return -1
for i = 0 to m.contentRoot.getChildCount() - 1
if m.contentRoot.getChild(i).isSameNode(rowNode) then return i
end for
return -1
end function
' Returns this cell's column index (position within its row).
' @returns column index, or -1 if the cell's position cannot be determined
function getMyColumnIndex() as integer
if not isValid(m.top.itemContent) then return -1
rowNode = m.top.itemContent.getParent()
if not isValid(rowNode) then return -1
for i = 0 to rowNode.getChildCount() - 1
if rowNode.getChild(i).isSameNode(m.top.itemContent) then return i
end for
return -1
end function
' Returns the number of items in this cell's row.
function getRowItemCount() as integer
if not isValid(m.top.itemContent) then return 0
rowNode = m.top.itemContent.getParent()
if not isValid(rowNode) then return 0
return rowNode.getChildCount()
end function
' Checks whether this cell is in the horizontal buffer zone for its row.
' The buffer keeps up to TEXTURE_BUFFER_THRESHOLD items loaded, split evenly
' around the visible items: (threshold - numVisible) / 2 on each side.
' Wrap-aware for fixedFocusWrap rows where items wrap around the end.
' Only applies to rows with > TEXTURE_BUFFER_THRESHOLD items.
function isInHorizontalBuffer() as boolean
if not isValid(m.contentRoot) then return false
if not m.contentRoot.hasField("focusedColumns") or not m.contentRoot.hasField("textureVisibleWidth")
return false
end if
range = m.contentRoot.loadedRowRange
if not isValid(range) or range.count() < 4 then return false
rowIndex = getMyRowIndex()
if rowIndex < 0 then return false
cols = m.contentRoot.focusedColumns
focusedCol = 0
if isValid(cols) and rowIndex < cols.count()
focusedCol = cols[rowIndex]
end if
' Calculate how many items fit in the visible area.
' Ceiling via integer math — partial items at the edge still render.
slotWidth = int(m.top.width)
if slotWidth <= 0 then return false
visibleWidth = int(m.contentRoot.textureVisibleWidth)
itemSpacing = int(m.contentRoot.textureItemSpacing)
itemStride = slotWidth + itemSpacing
numVisible = int(visibleWidth / itemStride)
if itemStride > 0 and visibleWidth mod itemStride > 0 then numVisible++
totalItems = getRowItemCount()
' If all items fit within the threshold, everything stays loaded
if totalItems <= TEXTURE_BUFFER_THRESHOLD then return true
' Split the remaining budget evenly: half on the left, half on the right.
' e.g. threshold=20, numVisible=5 → 7 items buffered on each side, 19 total loaded.
' Extra item (from odd remainder) goes to the right to favour forward scroll.
bufferBudget = TEXTURE_BUFFER_THRESHOLD - numVisible
if bufferBudget < 2 then bufferBudget = 2
leftBuffer = int(bufferBudget / 2)
rightBuffer = bufferBudget - leftBuffer
' Wrap-aware range using modular arithmetic for fixedFocusWrap rows.
bufferStart = (focusedCol - leftBuffer + totalItems) mod totalItems
bufferEnd = focusedCol + numVisible - 1 + rightBuffer
colIndex = getMyColumnIndex()
if colIndex < 0 then return false
if bufferEnd >= totalItems
' Buffer wraps around the end — item is in range if at the tail OR head
return colIndex >= bufferStart or colIndex <= (bufferEnd mod totalItems)
else if bufferStart > focusedCol
' Buffer wraps around the start (focusedCol near 0, bufferStart near end)
return colIndex >= bufferStart or colIndex <= bufferEnd
end if
return colIndex >= bufferStart and colIndex <= bufferEnd
end function
' Determines whether this cell should load its texture during initial render.
' Used in renderStandardItem/renderLibraryTile to skip network requests for
' cells outside the buffer zone.
'
' Determines whether this cell should load its poster during initial render.
' Mirrors evaluateTextureState logic: managed rows trust buffer logic,
' outside rows trust renderTracking.
function shouldLoadTexture() as boolean
state = getTextureManagerState()
if state = "destroyed" then return false
if state = "hidden" then return true
rowPosition = getRowPosition()
' Buffer rows: always pre-load for seamless scrolling
if rowPosition = "buffer"
return true
end if
if rowPosition = "visible"
' Long rows: horizontal buffer decides which items load
if getRowItemCount() > TEXTURE_BUFFER_THRESHOLD
return isInHorizontalBuffer()
end if
' Short rows: all items fit in budget — always load
return true
end if
' Outside managed ranges — renderTracking decides
return m.top.renderTracking <> "none"
end function
' Central texture decision — called when renderTracking, textureManagerState,
' loadedRowRange, or focusedColumns changes.
'
' State machine:
' "destroyed" → force unload everything (no guards)
' "init" → no-op (freeze state during layout changes)
' "hidden" → renderTracking decides (keep Roku-allocated cells, unload rest)
' "active" → managed rows (visible + buffer): our buffer logic decides,
' renderTracking ignored (it's unreliable during layout recalcs).
' Outside rows: renderTracking decides.
sub evaluateTextureState()
state = getTextureManagerState()
' Destroyed: force-unload everything unconditionally
if state = "destroyed"
forceUnloadTexture()
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.
' Roku can briefly flip renderTracking to "none" during layout recalculations
' (e.g. when a new screen's init() creates JRRowItem cells). Reacting to
' those spurious changes causes a visible flash of empty backdrops.
rowPosition = getRowPosition()
if rowPosition = "buffer"
' Buffer rows (±1 from visible): ALL items stay loaded for seamless scroll.
reloadTexture()
return
end if
if rowPosition = "visible"
' Long rows (> threshold): horizontal buffer decides which items stay loaded.
' Short rows (≤ threshold): all items fit in budget — always loaded.
if getRowItemCount() > TEXTURE_BUFFER_THRESHOLD
if isInHorizontalBuffer()
reloadTexture()
else
unloadTexture()
end if
else
reloadTexture()
end if
return
end if
' Outside managed ranges — renderTracking decides
if m.top.renderTracking <> "none"
reloadTexture()
else
unloadTexture()
end if
end sub
' Returns the current texture manager state from the content root.
' @returns "init", "active", "hidden", "destroyed", or "init" if unavailable
function getTextureManagerState() as string
if not isValid(m.contentRoot) or not m.contentRoot.hasField("textureManagerState")
return "init"
end if
return m.contentRoot.textureManagerState
end function
' Returns this cell's position relative to the managed row ranges.
' @returns "visible" if in a visible row, "buffer" if in a vertical buffer row,
' or "outside" if not in any managed range
function getRowPosition() 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"
rowIndex = getMyRowIndex()
if rowIndex < 0 then return "outside"
' 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
' Fires when the cell scrolls in/out of the visible area.
sub onRenderTrackingChanged()
logTextureAction("renderTracking=" + m.top.renderTracking)
evaluateTextureState()
end sub
' Fires when textureManagerState changes (init → active, active → hidden, etc.)
sub onTextureManagerStateChanged()
evaluateTextureState()
end sub
' Fires when the parent screen updates the loaded row range (focus change).
sub onLoadedRowRangeChanged()
evaluateTextureState()
end sub
' Fires when the parent screen updates the per-row focused column (horizontal scroll).
sub onFocusedColumnsChanged()
evaluateTextureState()
end sub
' Clears poster/icon URIs 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 unloadTexture()
if m.cachedPosterUri = "" or m.isTextureUnloaded then return
logTextureAction("unloadTexture")
m.isTextureUnloaded = true
m.poster.uri = ""
m.placeholder.visible = true
if m.cachedIconUri <> ""
m.itemIcon.uri = ""
m.itemIcon.visible = false
end if
end sub
' Force-unloads textures unconditionally — used only during onDestroy().
' Ignores m.isTextureUnloaded and m.cachedPosterUri guards so every cell releases memory.
sub forceUnloadTexture()
m.isTextureUnloaded = true
m.poster.uri = ""
m.placeholder.visible = true
m.itemIcon.uri = ""
m.itemIcon.visible = false
end sub
' Restores poster/icon URIs from cache when the cell scrolls back on screen.
' The placeholder will show briefly while the real image reloads from Roku's
' HTTP cache. Also serves as a retry path for previously failed loads — if the
' cell currently shows the fallback placeholder glyph, this re-attempts the
' real load.
sub reloadTexture()
if m.cachedPosterUri <> "" and m.poster.uri <> m.cachedPosterUri
logTextureAction("reloadTexture")
m.isTextureUnloaded = false
m.poster.loadDisplayMode = m.cachedLoadDisplayMode
m.poster.uri = m.cachedPosterUri
' Reset the placeholder to loading state (backdrop only, no glyph) — the
' real image is starting to load again.
m.placeholder.itemType = ""
m.placeholder.visible = true
end if
if m.cachedIconUri <> "" and m.itemIcon.uri <> m.cachedIconUri
m.itemIcon.uri = m.cachedIconUri
end if
end sub
' ============================================
' FOCUS
' ============================================
' Toggles scrolling vs static title on focus, and speaks the title via audio guide.
sub onFocusChanged()
' Library tiles have no scrolling text labels to toggle
if m.isLibraryTile then return
if m.top.itemHasFocus
m.title.repeatCount = -1
m.subtitle.repeatCount = -1
m.staticTitle.visible = false
m.title.visible = true
else
m.title.repeatCount = 0
m.subtitle.repeatCount = 0
m.staticTitle.visible = true
m.title.visible = false
end if
if m.global.device.isAudioGuideEnabled
txt2Speech = CreateObject("roTextToSpeech")
txt2Speech.Flush()
txt2Speech.Say(m.staticTitle.text)
end if
end sub
' Lazy-creates and shows a small centered spinner over the backdrop for Loading placeholders.
sub showLoadingSpinner(slotWidth as float, posterHeight as float)
if not isValid(m.loadingSpinner)
m.loadingSpinner = CreateObject("roSGNode", "Spinner")
end if
spinnerSize = 48
m.loadingSpinner.poster.width = spinnerSize
m.loadingSpinner.poster.height = spinnerSize
m.loadingSpinner.translation = [
(slotWidth - spinnerSize) / 2,
(posterHeight - spinnerSize) / 2
]
m.loadingSpinner.visible = true
m.loadingSpinner.control = "start"
m.top.appendChild(m.loadingSpinner)
end sub
' Hides and stops the loading spinner if it exists.
sub hideLoadingSpinner()
if isValid(m.loadingSpinner)
m.loadingSpinner.visible = false
m.loadingSpinner.control = "stop"
end if
end sub