components_ItemGrid_GridItem.bs

import "pkg:/source/constants/imageSize.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/utils/itemImageUrl.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/placeholderImage.bs"
import "pkg:/source/utils/textureManager.bs"

sub init()
  m.log = new log.Logger("GridItem")
  m.itemPoster = m.top.findNode("itemPoster")
  m.itemIcon = m.top.findNode("itemIcon")
  m.itemText = m.top.findNode("itemText")
  m.placeholder = m.top.findNode("placeholder")

  m.itemPoster.observeField("loadStatus", "onPosterLoadStatusChanged")

  ' Enable render tracking for texture management
  m.top.enableRenderTracking = true
  m.top.observeField("renderTracking", "onRenderTrackingChanged")

  ' URI cache for texture management.
  ' When a cell scrolls off-screen, its poster URI is cleared to release texture memory.
  ' This cache holds the real URI so it can be restored when the cell scrolls back.
  m.cachedPosterUri = ""
  m.cachedLoadDisplayMode = "scaleToZoom"
  m.isTextureUnloaded = false

  ' Reference to the MarkupGrid's root ContentNode — used to observe loadedRowRange.
  ' Set once in setupGridTextureObserver() when the first itemContent is assigned.
  m.contentRoot = invalid
  ' Cached flat index of this cell's itemContent within the content root.
  ' Recomputed on each cell recycle (onItemContentChanged).
  m.cachedFlatIndex = -1

  ' Add some padding space when Item Titles are always showing
  if m.itemText.visible then m.itemText.maxWidth = 224

  ' grab data from ItemGrid node
  m.itemGrid = m.top.GetParent().GetParent() 'Parent is JRMarkupGrid and it's parent is the ItemGrid

  if isValid(m.itemGrid)
    if isValid(m.itemGrid.imageDisplayMode)
      m.itemPoster.loadDisplayMode = m.itemGrid.imageDisplayMode
    end if
    if isValid(m.itemGrid.gridTitles)
      m.gridTitles = m.itemGrid.gridTitles
    end if
  end if

  posterY = m.itemPoster.translation[1]
  m.itemText.translation = [0, posterY + m.itemPoster.height + 18]
  m.itemText.visible = m.gridTitles = "showalways"

end sub

sub onItemContentChanged()
  itemData = m.top.itemContent
  if not isValid(itemData) then return

  setupGridTextureObserver()
  m.isTextureUnloaded = false

  if itemData.isWatched
    m.itemPoster.isWatched = true
  end if

  if itemData.unplayedItemCount > 0
    m.itemPoster.unplayedCount = itemData.unplayedItemCount
  end if

  itemType = LCase(itemData.type)
  posterUri = ""

  if itemType = "movie" or itemType = "musicvideo"
    posterUri = getItemPosterUrl(itemData)
    m.itemText.text = itemData.title
  else if itemType = "series"
    posterUri = getItemPosterUrl(itemData)
    m.itemText.text = itemData.title
  else if itemType = "boxset"
    posterUri = getItemPosterUrl(itemData)
    m.itemText.text = itemData.title
  else if itemType = "tvchannel"
    posterUri = getItemPosterUrl(itemData)
    m.itemText.text = itemData.title
  else if itemType = "folder" or itemType = "collectionfolder" or itemType = "channelfolderitem" or itemType = "photoalbum"
    posterUri = getItemPosterUrl(itemData)
    m.itemText.text = itemData.title
    m.itemPoster.loadDisplayMode = m.itemGrid.imageDisplayMode
  else if itemType = "genre" or itemType = "studio"
    posterUri = getItemPosterUrl(itemData)
    m.itemText.text = itemData.title
  else if itemType = "musicgenre"
    ' Music genres use square 1:1 sizing
    posterUri = getItemPosterUrl(itemData)
    m.itemText.text = itemData.title
    setSquarePosterLayout()
    m.itemPoster.loadDisplayMode = "scaleToFit"
  else if itemType = "video" or itemType = "recording"
    posterUri = getItemPosterUrl(itemData)
    m.itemText.text = itemData.title
  else if itemType = "playlist"
    posterUri = getItemPosterUrl(itemData)
    m.itemText.text = itemData.title
  else if itemType = "photo"
    posterUri = getItemPosterUrl(itemData)
    m.itemText.text = itemData.title
  else if itemType = "episode"
    posterUri = getItemPosterUrl(itemData)
    if isValidAndNotEmpty(itemData.seriesName)
      m.itemText.text = itemData.seriesName + " - " + itemData.title
    else
      m.itemText.text = itemData.title
    end if
  else if itemType = "musicartist"
    posterUri = getItemPosterUrl(itemData)
    m.itemText.text = itemData.title
    setSquarePosterLayout()
    m.itemPoster.loadDisplayMode = "limitSize"
  else if itemType = "musicalbum"
    posterUri = getItemPosterUrl(itemData)
    m.itemText.text = itemData.title
    setSquarePosterLayout()
    m.itemPoster.loadDisplayMode = "limitSize"
  else if itemType = "audio"
    posterUri = getItemPosterUrl(itemData)
    m.itemText.text = itemData.title
  else
    m.log.warn("Unhandled Grid Item Type", itemData.type)
  end if

  ' Cache URI for texture management
  m.cachedPosterUri = posterUri
  m.cachedLoadDisplayMode = m.itemPoster.loadDisplayMode

  if posterUri = ""
    ' Known-missing image — eagerly surface the typed glyph on the backdrop.
    ' Load-status never fires "failed" for an empty URI, so we must set the
    ' placeholder state here rather than waiting for onPosterLoadStatusChanged.
    m.itemPoster.uri = ""
    showPlaceholder(resolveItemPlaceholderType(m.top.itemContent))
  else if shouldLoadGridTexture()
    ' Image expected and the texture manager wants this cell loaded — start
    ' the placeholder in loading state; the load-status observer takes over.
    resetPlaceholderToLoading()
    m.itemPoster.uri = posterUri
  else
    ' Image expected but the texture manager is keeping this cell off-screen
    ' to save memory. Loading state until the cell scrolls into range and
    ' reloadGridTexture() restores the URI.
    m.isTextureUnloaded = true
    resetPlaceholderToLoading()
    m.itemPoster.uri = ""
  end if
end sub

' Apply square 323x323 layout for music items (artists, albums, genres)
sub setSquarePosterLayout()
  m.itemPoster.height = 323
  m.itemPoster.width = 323
  m.itemPoster.loadWidth = 323
  m.itemPoster.loadHeight = 323

  m.itemText.translation = [0, m.itemPoster.translation[1] + m.itemPoster.height + 18]
  m.itemText.maxWidth = 323

  m.placeholder.width = 323
  m.placeholder.height = 323
end sub

' Enable title scrolling based on item focus
sub onFocusChanged()
  if m.top.itemHasFocus = true
    m.itemText.repeatCount = -1
  else
    m.itemText.repeatCount = 0
  end if
  if m.gridTitles = "showonhover"
    m.itemText.visible = m.top.itemHasFocus
  end if
end sub

' Toggle the JRPlaceholder fallback based on the real poster's load state.
' Mirrors the canonical state machine from JRRowItem.bs onPosterLoadStatusChanged.
sub onPosterLoadStatusChanged()
  ' Ignore status changes from intentional texture unloads (uri cleared to free memory)
  if m.isTextureUnloaded then return

  if m.itemPoster.loadStatus = "ready" and m.itemPoster.uri <> ""
    ' Real image loaded — placeholder is no longer needed
    m.placeholder.visible = false
  else if m.itemPoster.loadStatus = "failed" and m.itemPoster.uri <> ""
    ' Real image failed — surface a type-appropriate glyph on the backdrop
    showPlaceholder(resolveItemPlaceholderType(m.top.itemContent))
  else
    ' Loading or no URI — keep the placeholder visible in its current state
    m.placeholder.visible = true
  end if
end sub

' Flip the placeholder into failure state with a type-appropriate glyph, then
' clear the poster URI so the glyph isn't covered by a stale broken image.
' itemType="" puts JRPlaceholder in loading state (backdrop only, no glyph);
' resolveItemPlaceholderType returns "" only for invalid/typeless items, so
' valid items always end up here with a typed glyph or the Folder fallback.
sub showPlaceholder(itemType as string)
  m.placeholder.itemType = itemType
  m.placeholder.visible = true
  m.itemPoster.uri = ""
end sub

' Reset the JRPlaceholder to loading state (themed backdrop visible, no glyph).
' Used by the initial-load branches in onItemContentChanged and by the texture
' unload paths so off-screen / unloaded cells don't carry a stale failure-state
' glyph back into view when they reload.
sub resetPlaceholderToLoading()
  m.placeholder.itemType = ""
  m.placeholder.visible = true
end sub

' ============================================================================
' Texture Management
' ============================================================================

' One-time setup: observes loadedRowRange and textureManagerState on the
' MarkupGrid's content root. The content root is the direct parent of
' itemContent in the content tree (flat model, unlike RowList's 2-level hierarchy):
'   contentRoot → itemNode (itemContent)
' Called from onItemContentChanged. Skips if already connected.
sub setupGridTextureObserver()
  item = m.top.itemContent
  if not isValid(item) then return

  contentRoot = item.getParent()
  if not isValid(contentRoot) then return

  ' Cache flat index for row position calculations (O(1) lookup via BaseGridView)
  m.cachedFlatIndex = m.itemGrid.callFunc("getItemFlatIndex", item.id)

  ' One-time observer setup — only run once per content root
  if isValid(m.contentRoot) and m.contentRoot.isSameNode(contentRoot) then return

  ' Content root changed (view switch, filter, etc.) — reconnect observers
  if isValid(m.contentRoot)
    if m.contentRoot.hasField("textureManagerState")
      m.contentRoot.unobserveField("textureManagerState")
    end if
    if m.contentRoot.hasField("loadedRowRange")
      m.contentRoot.unobserveField("loadedRowRange")
    end if
  end if

  m.contentRoot = contentRoot
  if m.contentRoot.hasField("textureManagerState")
    m.contentRoot.observeField("textureManagerState", "onTextureManagerStateChanged")
  end if
  if m.contentRoot.hasField("loadedRowRange")
    m.contentRoot.observeField("loadedRowRange", "onLoadedRowRangeChanged")
  end if
end sub

' Returns this cell's row position relative to the managed row ranges.
' Derives the grid row from the cached flat index and numColumns.
' @returns "visible" if in a visible row, "buffer" if in a vertical buffer row,
'          or "outside" if not in any managed range
function getGridRowPosition() as string
  if not isValid(m.contentRoot) or not m.contentRoot.hasField("loadedRowRange") then return "outside"

  range = m.contentRoot.loadedRowRange
  if not isValid(range) or range.count() < 4 then return "outside"

  if m.cachedFlatIndex < 0 then return "outside"

  markupGrid = m.top.GetParent() ' JRMarkupGrid
  if not isValid(markupGrid) then return "outside"
  numColumns = markupGrid.numColumns
  if numColumns <= 0 then return "outside"

  rowIndex = int(m.cachedFlatIndex / numColumns)

  ' Visible rows: visibleStart..visibleEnd
  if rowIndex >= range[1] and rowIndex <= range[2] then return "visible"

  ' Buffer rows: bufferStart..visibleStart-1 or visibleEnd+1..bufferEnd
  if rowIndex >= range[0] and rowIndex < range[1] then return "buffer"
  if rowIndex > range[2] and rowIndex <= range[3] then return "buffer"

  return "outside"
end function

' Returns the current texture manager state from the content root.
' @returns "init", "active", "hidden", "destroyed", or "init" if unavailable
function getGridTextureManagerState() as string
  if not isValid(m.contentRoot) or not m.contentRoot.hasField("textureManagerState")
    return "init"
  end if
  return m.contentRoot.textureManagerState
end function

' Determines whether this cell should load its poster during initial render.
' Mirrors evaluateGridTextureState logic: managed rows trust buffer logic,
' outside rows trust renderTracking.
function shouldLoadGridTexture() as boolean
  state = getGridTextureManagerState()

  ' Init/hidden: allow load (freeze = don't change current state)
  if state = "init" or state = "hidden"
    return true
  end if

  ' Destroyed: never load
  if state = "destroyed"
    return false
  end if

  ' Active: check row position
  rowPosition = getGridRowPosition()
  if rowPosition = "visible" or rowPosition = "buffer"
    return true
  end if

  ' Outside managed ranges — renderTracking decides
  return m.top.renderTracking <> "none"
end function

' Central texture decision — called when renderTracking, textureManagerState,
' or loadedRowRange changes.
'
' State machine (same as JRRowItem but without horizontal buffer):
'   "destroyed" → force unload everything (no guards)
'   "init"      → no-op (freeze state during layout changes)
'   "hidden"    → no-op (freeze — screen is behind another on the stack)
'   "active"    → managed rows (visible + buffer): always loaded.
'                  Outside rows: renderTracking decides.
sub evaluateGridTextureState()
  state = getGridTextureManagerState()

  ' Destroyed: force-unload everything unconditionally
  if state = "destroyed"
    forceUnloadGridTexture()
    return
  end if

  ' Init: freeze — layout recalculations cause spurious renderTracking changes.
  if state = "init"
    return
  end if

  ' Hidden: freeze — screen is behind another on the stack. Textures stay
  ' loaded so returning is instant. renderTracking flips to "none" when
  ' visible=false propagates, but we must not react to it.
  if state = "hidden"
    return
  end if

  ' Active: for managed rows, trust our own buffer logic over renderTracking.
  rowPosition = getGridRowPosition()

  if rowPosition = "buffer" or rowPosition = "visible"
    ' All items in visible + buffer rows stay loaded (no horizontal buffer for grids)
    reloadGridTexture()
    return
  end if

  ' Outside managed ranges — renderTracking decides
  if m.top.renderTracking <> "none"
    reloadGridTexture()
  else
    unloadGridTexture()
  end if
end sub

' Clears poster URI to release texture memory when the cell is off-screen.
' Items with no real image (cachedPosterUri = "") are skipped — they already show
' a lightweight local placeholder that costs negligible texture memory.
sub unloadGridTexture()
  if m.cachedPosterUri = "" or m.isTextureUnloaded then return

  m.isTextureUnloaded = true
  m.itemPoster.uri = ""
  resetPlaceholderToLoading()
end sub

' Force-unloads textures unconditionally — used only during onDestroy().
' Ignores m.isTextureUnloaded and m.cachedPosterUri guards so every cell releases memory.
sub forceUnloadGridTexture()
  m.isTextureUnloaded = true
  m.itemPoster.uri = ""
  resetPlaceholderToLoading()
end sub

' Restores poster URI from cache when the cell scrolls back on screen.
' The backdrop will show briefly while the image reloads from Roku's HTTP cache.
sub reloadGridTexture()
  if m.cachedPosterUri <> "" and m.itemPoster.uri <> m.cachedPosterUri
    m.isTextureUnloaded = false
    m.itemPoster.loadDisplayMode = m.cachedLoadDisplayMode
    m.itemPoster.uri = m.cachedPosterUri
  end if
end sub

' Fires when the cell scrolls in/out of the visible area.
sub onRenderTrackingChanged()
  evaluateGridTextureState()
end sub

' Fires when textureManagerState changes (init → active, active → hidden, etc.)
sub onTextureManagerStateChanged()
  evaluateGridTextureState()
end sub

' Fires when the parent screen updates the loaded row range (focus change).
sub onLoadedRowRangeChanged()
  evaluateGridTextureState()
end sub