components_ui_rowitem_JRRowItem.bs

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/skeleton.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.
' The title stays OUTSIDE the focus border because the RowList sets rowHeights taller
' than rowItemSize — see docs/architecture/list-grid-item-layout.md.
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.lastBindKey = ""
  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()
  cellLoad.bind(m.contentRoot, cellLoad.FROM_CONTENT, isRepeatBind())
  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
  cellLoad.bind(m.contentRoot, cellLoad.FROM_SIZE, isRepeatBind())
  renderItem()
end sub

' True when this cell is being bound to the SAME item at the SAME size it already holds —
' a bind that cannot change a single pixel and exists only to be counted as waste.
' Kept on the cell because its own previous bind is free to remember here, where storing
' it on the shared content root would cost a node round trip per bind.
' Reads `id` rather than a title: a skeleton placeholder carries ONLY `type`, and anything
' that CASTS an absent field (`getRowItemTitle` returns `item.name as String`) is a &h18
' crash on that path. A field read compared with isValid is safe on every item shape.
function isRepeatBind() as boolean
  item = m.top.itemContent
  id = ""
  if isValid(item) and isValid(item.id) then id = item.id
  key = id + "@" + int(m.top.width).toStr() + "x" + int(m.top.height).toStr()
  isRepeat = (m.lastBindKey = key)
  m.lastBindKey = key
  return isRepeat
end function

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 "".
  if m.placeholder.itemType <> "" then cellLoad.glyphWipe(m.contentRoot, cellLoad.WIPE_BIND)
  m.placeholder.itemType = ""
  m.placeholder.visible = true

  ' Skeleton placeholder — show backdrop + centered spinner, no poster or text
  if skeleton.isPlaceholder(item)
    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

  noteAppearance()
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()
    cellLoad.loadStarted(m.contentRoot)
    m.poster.uri = posterUri
  else
    deferTextureLoad()
  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()
      cellLoad.loadStarted(m.contentRoot)
      m.poster.uri = posterUri
    else
      deferTextureLoad()
    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.
    cellLoad.loadSucceeded(m.contentRoot)
    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 the cell can retry the original URL — but only
    ' once it has actually LEFT the loaded range, because `unloadTexture` is what sets
    ' `isTextureUnloaded` and that flag is what `shouldReloadTexture` gates on. Moving
    ' back and forth within the range no longer re-requests it, which is what stopped
    ' the glyph blinking. If the retry 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.
    cellLoad.loadErrored(m.contentRoot)
    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(cellLoad.UNLOAD_WINDOW)
      end if
    else
      reloadTexture()
    end if
    return
  end if

  ' Outside managed ranges — renderTracking decides
  if m.top.renderTracking <> "none"
    reloadTexture()
  else
    unloadTexture(cellLoad.UNLOAD_RANGE)
  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)
  noteAppearance()
  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

' Records that this cell is on screen, for the pop-in half of the cell-load ledger.
'
' Called from BOTH renderItem (the cell was bound while already on screen — Home's first
' paint) and onRenderTrackingChanged (it scrolled in), because for a recycled cell there is
' no ordering guarantee between the two. `cellLoad.appeared()` counts at most once per bind,
' so calling it from both is idempotent rather than double-counting.
'
' `renderTracking` is the appearance signal rather than getRowPosition() +
' isInHorizontalBuffer(), deliberately. Those are the app's own model of what is on screen,
' and the horizontal half assumes the focused column is the LEFTMOST visible one — an
' assumption this file's buffer maths makes and that has never been verified. Scoring the
' buffer against its own coordinate system would bake that unverified premise into the
' number; renderTracking is the compositor's answer and carries no such premise.
sub noteAppearance()
  ' Off screen: end the episode so a scroll-back counts as a new appearance. Without this
  ' the once-per-bind gate swallows every re-entry, which is the race the buffer exists
  ' to win — see cellLoad.departed().
  '
  ' 🚨 GATED ON "active" — this is the same refusal `evaluateTextureState` makes two
  ' functions below, and for the same reason. A "none" that arrives while the screen is
  ' `hidden` is `visible=false` propagating down, and one that arrives during `init` is a
  ' layout recalculation. Neither is the cell leaving the screen, and closing the episode
  ' on either re-arms the gate so the return scores a FRESH appearance the user never saw.
  ' The rule is that on a resume with no scrolling every appearance must be paid for by a
  ' bind. Measured against the demo server, backing out of one item detail: 18 appearances
  ' against 10 binds — every one a guaranteed non-pop-in, because `hidden` freezes textures
  ' loaded. They land in the denominator of `popIns / appearances` as free wins and flatter
  ' the buffer. Those counts are that fixture's; the bound is not, and it is gated in
  ' tests/rta/specs/cell-load.spec.js.
  '
  ' Only the DEPARTURE is gated, not `cellLoad.appeared()` below. Closing an episode is
  ' the sole destructive step — an appearance is idempotent per episode, so an ungated
  ' `appeared()` cannot double-count, while first paint legitimately happens during `init`
  ' (`activateTextureManager` runs only once initial content has loaded) and gating it
  ' would delete `popInsFirst` entirely.
  if m.top.renderTracking = "none"
    if getTextureManagerState() = "active" then cellLoad.departed(m.contentRoot)
    return
  end if
  ' No real image expected — a skeleton, a library tile with no artwork, or an item whose
  ' poster URI is empty. There is nothing for the buffer to have raced.
  if m.cachedPosterUri = "" then return
  cellLoad.appeared(m.contentRoot, m.poster.loadStatus = "ready", m.isTextureUnloaded)
end sub

' Skips the image request for a cell the texture manager is keeping off screen. The
' caller has already cached the URI, so reloadTexture() issues the load when the cell
' enters the buffer zone.
'
' Clearing the poster URI is what makes `isTextureUnloaded` true by construction. Leave a
' recycled cell's PREVIOUS poster live and the flag claims a texture was released while it
' is still resident: unloadTexture() then early-returns on that flag and never frees it,
' and the stale image can surface under the new item if the cell draws before its range
' observer fires. GridItem's equivalent branch clears it for the same reason.
sub deferTextureLoad()
  m.isTextureUnloaded = true
  m.poster.uri = ""
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.
'
' @param reason - which mechanism evicted this cell, cellLoad.UNLOAD_RANGE or
'                 cellLoad.UNLOAD_WINDOW. Required rather than defaulted: the two call
'                 sites below are the ONLY eviction paths, and a default would let a third
'                 one be added that silently scores as the wrong mechanism.
sub unloadTexture(reason as string)
  if m.cachedPosterUri = "" or m.isTextureUnloaded then return

  logTextureAction("unloadTexture " + reason)
  cellLoad.unload(m.contentRoot, reason)
  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.
'
' It is NOT a general retry path for a failed load. `shouldReloadTexture` deliberately
' skips a cell that is showing its failure glyph but was never unloaded — see
' `failed-poster-no-scroll-retry` in docs/decisions.md. Such a cell does get one fresh
' attempt, but only after it leaves the loaded range and `unloadTexture` sets the flag.
sub reloadTexture()
  if shouldReloadTexture(m.isTextureUnloaded, m.cachedPosterUri)
    logTextureAction("reloadTexture")
    cellLoad.reload(m.contentRoot)
    ' `true` = this request came from a RE-ENTRY, which is the case the texture buffer
    ' exists to win. A pop-in here means the buffer tried and the network was slower; one
    ' on the initial-render path means the data had only just arrived and no buffer could
    ' have helped. The ledger keeps them apart.
    cellLoad.loadStarted(m.contentRoot, true)
    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.
    if m.placeholder.itemType <> "" then cellLoad.glyphWipe(m.contentRoot, cellLoad.WIPE_RELOAD)
    m.placeholder.itemType = ""
    m.placeholder.visible = true
  end if
  ' The icon keeps the URI compare the poster gave up, and is NOT the same bug: nothing
  ' clears itemIcon.uri on failure — onIconLoadStatusChanged only hides the icon — so a
  ' failed icon still compares EQUAL to its cached URI and is never re-requested. The
  ' compare only fires when an unload actually cleared it. Checked rather than assumed,
  ' because this line reads exactly like the predicate that was replaced.
  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

' Shows the skeleton spinner over the backdrop for Loading placeholders. The sizing and
' placement live in source/utils/skeleton.bs so GridItem's skeleton cells draw the
' identical spinner rather than a second copy of it.
sub showLoadingSpinner(slotWidth as float, posterHeight as float)
  m.loadingSpinner = skeleton.showSpinner(m.top, m.loadingSpinner, slotWidth, posterHeight)
end sub

' Hides and stops the loading spinner if it exists.
sub hideLoadingSpinner()
  skeleton.hideSpinner(m.loadingSpinner)
end sub