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/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