components_ItemGrid_GridItem.bs

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

' Vertical offset of the poster/placeholder from the cell top. The title label sits
' below the poster, inside the grid's inter-row spacing (see updateLayout).
const POSTER_TOP_OFFSET = 22
' Gap between the bottom of the poster and the title label. Matches JRRowItem's
' FOCUS_PADDING so grid and row cells share one vertical rhythm.
const TITLE_GAP = 18
' Inset of the genre action-tile label from the backdrop edges.
const GENRE_LABEL_INSET = 5

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

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

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

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

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

  ' Display/title config read from the owning BaseGridView. Resolved lazily via
  ' resolveGridViewConfig() (defaults until then).
  m.imageDisplayMode = "scaleToZoom"
  m.gridTitles = ""
  ' The owning BaseGridView node, resolved lazily by walking up the node tree.
  ' Also the callFunc target for getItemFlatIndex in texture management.
  m.gridView = invalid
end sub

' Resolves the owning BaseGridView and reads its display/title config. Walks up
' the node tree to the first ancestor exposing gridTitles rather than assuming a
' fixed depth: the MarkupGrid puts cells 2 levels below BaseGridView, but the genre
' RowList nests them deeper. The old fixed GetParent().GetParent() walk missed
' BaseGridView for RowList cells, so genre tiles silently fell back to scaleToZoom
' (cropping posters the presenter intended as scaleToFit) and an empty gridTitles
' (no titles ever). Idempotent — caches m.gridView on first success.
sub resolveGridViewConfig()
  if isValid(m.gridView) then return

  node = m.top.getParent()
  while isValid(node) and not node.hasField("gridTitles")
    node = node.getParent()
  end while
  if not isValid(node) then return

  m.gridView = node
  if isValid(node.imageDisplayMode) then m.imageDisplayMode = node.imageDisplayMode
  if isValid(node.gridTitles) then m.gridTitles = node.gridTitles
end sub

' Re-render when the parent assigns a new item to this (possibly recycled) cell.
' Texture observers only need itemContent, so wire them up even before the cell has
' been sized; the actual render waits until width/height are known (onSizeChanged
' fires the first paint when the parent applies itemSize/rowItemSize).
sub onItemContentChanged()
  itemData = m.top.itemContent
  if not isValid(itemData) then return

  resolveGridViewConfig()
  setupGridTextureObserver()

  if m.top.width <= 0 or m.top.height <= 0 then return
  renderItem()
end sub

' Re-render when the parent applies/changes the cell size (MarkupGrid itemSize or
' RowList rowItemSize). Mirrors JRRowItem so one component renders every cell size.
sub onSizeChanged()
  if not isValid(m.top.itemContent) then return
  if m.top.width <= 0 or m.top.height <= 0 then return
  renderItem()
end sub

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

  updateLayout(m.top.width, m.top.height)

  ' Clear any badge from a prior bind before this recycled cell renders the new item.
  m.itemPoster.callFunc("resetBadge")
  m.isTextureUnloaded = false

  ' Title text + default (always-on) visibility — recomputed every recycle.
  m.itemText.text = getRowItemTitle(itemData)
  m.itemText.visible = m.gridTitles = "showalways"

  itemType = LCase(itemData.type)
  itemFolderType = ""
  if isValid(itemData.folderType) then itemFolderType = LCase(itemData.folderType)

  if itemType = "genre" or itemFolderType = "genre"
    ' "View All [Genre]" action tiles are navigation buttons, not media — they get a
    ' themed backdrop + in-cell label rather than going through JRPlaceholder, so they
    ' don't read as failed-to-load placeholders. Check both type and folderType: if
    ' Jellyfin returns IsFolder=false/absent the transformer leaves type="Genre" and
    ' folderType="" so we must match on type directly. MusicGenre/Studio do NOT match
    ' here and keep the regular poster path.
    renderGenreTile(itemData)
  else
    renderMediaItem(itemData, itemType)
  end if
end sub

' Sizes and positions every child to the current cell dimensions. The focus slot is
' rowItemSize/itemSize; the poster fills it at POSTER_TOP_OFFSET so the poster overflows
' the slot bottom and the focus border (drawn behind it) reads with a top margin and no
' overlap. The title sits below at POSTER_TOP_OFFSET + h + TITLE_GAP — kept OUTSIDE the
' focus border because the container sets rowHeights/itemSize taller than the slot (the
' focus border pins to the slot, not the cell's full bounding box).
sub updateLayout(w as float, h as float)
  setPosterRect(0, POSTER_TOP_OFFSET, w, h)
  m.genreLabel.translation = [GENRE_LABEL_INSET, POSTER_TOP_OFFSET + GENRE_LABEL_INSET]
  m.genreLabel.width = w - 2 * GENRE_LABEL_INSET
  m.genreLabel.height = h - 2 * GENRE_LABEL_INSET
  m.itemText.translation = [0, POSTER_TOP_OFFSET + h + TITLE_GAP]
  m.itemText.maxWidth = w
end sub

' Positions/sizes the placeholder, poster, and genre backdrop to the same rect.
sub setPosterRect(x as integer, y as integer, rw as float, rh as float)
  m.placeholder.translation = [x, y]
  m.placeholder.width = rw
  m.placeholder.height = rh

  m.itemPoster.translation = [x, y]
  m.itemPoster.width = rw
  m.itemPoster.height = rh
  m.itemPoster.loadWidth = int(rw)
  m.itemPoster.loadHeight = int(rh)

  m.genreBackdrop.translation = [x, y]
  m.genreBackdrop.width = rw
  m.genreBackdrop.height = rh
end sub

' Renders a "View All [Genre]" action tile: themed backdrop + centered genre label,
' no poster image. The below-tile item title is hidden — the tile's own centered label
' already names the genre, and the row's genre header sits directly above it, so a third
' copy is redundant. cachedPosterUri is cleared so texture management skips this cell.
sub renderGenreTile(itemData as object)
  m.itemPoster.uri = ""
  m.placeholder.visible = false
  m.itemText.visible = false
  m.genreBackdrop.visible = true
  m.genreLabel.text = itemData.title
  m.genreLabel.visible = true
  m.cachedPosterUri = ""
end sub

' Renders a standard media item: poster image with the JRPlaceholder fallback state
' machine and texture management.
sub renderMediaItem(itemData as object, itemType as string)
  m.genreBackdrop.visible = false
  m.genreLabel.visible = false

  ' Watched / unplayed-count badges, honoring the user setting (matches JRRowItem and
  ' the former GridItemSmall; the former GridItem ignored this setting).
  if not m.global.user.settings.uiTvShowsDisableUnwatchedCount
    if itemData.isWatched
      m.itemPoster.isWatched = true
    else if itemData.unplayedItemCount > 0
      m.itemPoster.unplayedCount = itemData.unplayedItemCount
    end if
  end if

  m.itemPoster.loadDisplayMode = resolveLoadDisplayMode(itemType)

  posterUri = getItemPosterUrl(itemData)

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

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

' Picks the poster scaling mode. Music posters are square art that must not be cropped;
' everything else uses the grid's configured display mode (default scaleToZoom).
function resolveLoadDisplayMode(itemType as string) as string
  if itemType = "musicartist" or itemType = "musicalbum"
    return "limitSize"
  else if itemType = "musicgenre"
    return "scaleToFit"
  end if
  return m.imageDisplayMode
end function

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

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

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

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

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

' ============================================================================
' Texture Management
'
' Active when this component backs the MarkupGrid (itemGrid), whose content root is
' texture-managed via initTextureManager. In the genre RowList the content root has no
' textureManagerState/loadedRowRange fields, so getGridTextureManagerState() resolves to
' "init" — which means "always load, never unload" — making this section naturally inert
' there (matching the former GridItemSmall, which had no texture management at all).
' ============================================================================

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

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

  ' Cache flat index for row position calculations (O(1) lookup via BaseGridView).
  ' Returns -1 for items not in the texture-managed grid (e.g. genre RowList rows),
  ' or when BaseGridView couldn't be resolved.
  m.cachedFlatIndex = -1
  if isValid(m.gridView)
    flatIndex = m.gridView.callFunc("getItemFlatIndex", item.id)
    if isValid(flatIndex) then m.cachedFlatIndex = flatIndex
  end if

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

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

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

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

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

  if m.cachedFlatIndex < 0 then return "outside"

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

  rowIndex = int(m.cachedFlatIndex / numColumns)

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

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

  return "outside"
end function

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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