components_video_TrickplayCarousel.bs

import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/trickplay.bs"

' Trickplay carousel component - displays 5 thumbnails above the trickPlayBar
' Uses index-based thumbnail shifting for efficient updates
sub init()
  m.log = new log.Logger("TrickplayCarousel")

  ' Cache low memory device flag for texture size optimization
  ' Low memory devices (512MB RAM) have limited texture memory (~50MB)
  ' and cannot load full-size trickplay tiles into GPU memory
  m.isLowMemoryDevice = m.global.device.isLowMemoryDevice

  ' Cache poster references in array for easy shifting [minus2, minus1, center, plus1, plus2]
  m.posterArray = [
    m.top.findNode("posterMinus2"),
    m.top.findNode("posterMinus1"),
    m.top.findNode("posterCenter"),
    m.top.findNode("posterPlus1"),
    m.top.findNode("posterPlus2")
  ]

  ' Track which thumbnail index each poster is currently displaying
  m.posterThumbnailIndexes = [-1, -1, -1, -1, -1]

  ' Cache poster group and clip groups for dynamic sizing
  m.posterGroup = m.top.findNode("posterGroup")
  m.clipGroups = [
    m.top.findNode("clipMinus2"),
    m.top.findNode("clipMinus1"),
    m.top.findNode("clipCenter"),
    m.top.findNode("clipPlus1"),
    m.top.findNode("clipPlus2")
  ]

  ' Dynamic display dimensions - calculated from trickplay metadata aspect ratio
  ' These replace the hardcoded 16:9 dimensions (320x180 side, 480x270 center)
  m.sideDisplayWidth = 320
  m.sideDisplayHeight = 180
  m.centerDisplayWidth = 480
  m.centerDisplayHeight = 270

  ' Create tile loader task
  m.tileLoader = createObject("roSGNode", "TrickplayTileLoader")
  m.tileLoader.observeField("state", "onTaskStateChanged")

  ' Cache for loaded tiles {tileIndex: cachePath}
  m.loadedTileCache = {}

  ' Tile request queue state - prevents request cancellation during rapid scrubbing
  m.pendingTiles = {} ' Tiles currently loading OR queued (prevents duplicates)
  m.tileRequestQueue = [] ' Tile indexes waiting for current task to finish
  m.taskRunning = false

  ' Track current state
  m.currentThumbnailIndex = -1
  m.scrubDirection = 0 ' -1 = backward, 0 = none, 1 = forward
  m.lastCachedTileIndex = -1 ' Tracks which tile we last cached around (prevents redundant requests)
  m.config = invalid

  ' Velocity tracking for dynamic preload depth
  m.lastIndexUpdateTime = 0 ' Timestamp of last thumbnail index update (ms)
  m.lastIndexForVelocity = -1 ' Previous thumbnail index for velocity calc
  m.currentVelocity = 0 ' Thumbnails per second (smoothed)
  m.velocitySmoothingFactor = 0.3 ' EMA smoothing (0.3 = 30% new, 70% old)

  ' Download time tracking for preload calculations
  m.estimatedDownloadTime = 3.0 ' Initial estimate in seconds
  m.minPreloadTiles = 2 ' Minimum tiles to preload ahead
  m.maxPreloadTiles = 6 ' Maximum tiles to preload ahead
  m.minVelocityThreshold = 0.01 ' Min velocity to prevent division issues (thumbnails/second)

  ' Texture preloader - hidden poster that warms GPU texture cache
  ' By loading tile image here first, visible posters get instant display
  m.preloaderPoster = m.top.findNode("preloaderPoster")
  m.preloadedTileIndex = -1 ' Track which tile is currently in preloader
end sub

' Called when trickplayConfig is set
sub onConfigChanged()
  m.config = m.top.trickplayConfig

  if not isValidAndNotEmpty(m.config)
    m.log.warn("Invalid trickplay config provided")
    m.top.visible = false
    return
  end if

  ' Validate config using authoritative validation function
  if not trickplay.validateConfig(m.config)
    m.log.warn("Invalid config - should have been validated at creation")
    m.top.visible = false
    return
  end if

  ' Calculate dynamic display dimensions based on actual thumbnail aspect ratio
  ' Keep widths fixed (320 side, 480 center) and calculate heights to preserve aspect ratio
  calculateDisplayDimensions()

  ' Update clipping rectangles and positions based on calculated dimensions
  updateClipGroupsForAspectRatio()

  m.log.info("Trickplay config loaded", "videoID", m.config.videoID, "thumbSize", `${m.config.width}x${m.config.height}`, "interval", m.config.interval, "count", m.config.thumbnailCount)

  ' Calculate thumbs per tile for velocity calculations
  m.thumbsPerTile = m.config.tileWidth * m.config.tileHeight

  ' Configure preloader poster for low memory devices
  ' Must be done after config is loaded so we know tile dimensions
  if m.isLowMemoryDevice
    fullTileWidth = m.config.tileWidth * m.config.width
    fullTileHeight = m.config.tileHeight * m.config.height
    m.preloaderPoster.loadWidth = fullTileWidth / 2
    m.preloaderPoster.loadHeight = fullTileHeight / 2
    m.preloaderPoster.loadDisplayMode = "limitSize"
    m.log.info("Low memory device detected - using reduced texture size", "loadWidth", fullTileWidth / 2, "loadHeight", fullTileHeight / 2)
  end if

  ' Start early tile caching immediately when config is available
  ' This ensures tiles are downloading before user starts scrubbing
  performEarlyCaching()
end sub

' Calculates display dimensions for thumbnails based on actual aspect ratio from trickplay metadata
' Keeps widths fixed (320 for sides, 480 for center) and calculates heights to preserve aspect ratio
' This ensures thumbnails from any video aspect ratio (16:9, 4:3, 21:9, etc.) display correctly
sub calculateDisplayDimensions()
  if not isValid(m.config) or m.config.width <= 0 or m.config.height <= 0
    ' Fallback to default 16:9 dimensions
    m.sideDisplayWidth = 320
    m.sideDisplayHeight = 180
    m.centerDisplayWidth = 480
    m.centerDisplayHeight = 270
    return
  end if

  ' Calculate aspect ratio from actual thumbnail dimensions
  aspectRatio = m.config.height / m.config.width

  ' Side posters: fixed 320 width, calculate height from aspect ratio
  m.sideDisplayWidth = 320
  m.sideDisplayHeight = int(m.sideDisplayWidth * aspectRatio)

  ' Center poster: 1.5x scale of side poster
  m.centerDisplayWidth = 480
  m.centerDisplayHeight = int(m.centerDisplayWidth * aspectRatio)

  m.log.debug("Display dimensions", "ratio", aspectRatio, "side", `${m.sideDisplayWidth}x${m.sideDisplayHeight}`, "center", `${m.centerDisplayWidth}x${m.centerDisplayHeight}`)
end sub

' Updates clip group rectangles and positions based on calculated display dimensions
' Adjusts vertical positions to keep thumbnails vertically centered regardless of aspect ratio
sub updateClipGroupsForAspectRatio()
  if not isValid(m.clipGroups) or m.clipGroups.count() < 5 then return

  ' Calculate vertical offset to center thumbnails
  ' Center poster is tallest, side posters should be vertically centered relative to it
  verticalOffset = int((m.centerDisplayHeight - m.sideDisplayHeight) / 2)

  ' Calculate horizontal positions dynamically from display widths
  ' [clipMinus2, clipMinus1, clipCenter, clipPlus1, clipPlus2]
  ' Positions are derived from side and center display widths with a fixed gap
  ' - Side posters use (m.sideDisplayWidth + gap) step
  ' - Center poster spacing uses (m.centerDisplayWidth + gap) from side to center origins
  gap = 10
  sideStep = m.sideDisplayWidth + gap
  centerStep = m.centerDisplayWidth + gap
  horizontalPositions = [
    0, ' clipMinus2
    sideStep, ' clipMinus1
    sideStep * 2, ' clipCenter
    sideStep * 2 + centerStep, ' clipPlus1
    sideStep * 3 + centerStep ' clipPlus2
  ]

  ' Update side posters (indexes 0, 1, 3, 4)
  sideIndexes = [0, 1, 3, 4]
  for each idx in sideIndexes
    m.clipGroups[idx].clippingRect = [0, 0, m.sideDisplayWidth, m.sideDisplayHeight]
    m.clipGroups[idx].translation = [horizontalPositions[idx], verticalOffset]
  end for

  ' Update center poster (index 2)
  m.clipGroups[2].clippingRect = [0, 0, m.centerDisplayWidth, m.centerDisplayHeight]
  m.clipGroups[2].translation = [horizontalPositions[2], 0]

  ' Calculate posterGroup vertical position
  ' Base position: 620 was for 16:9 (270 height center)
  ' Adjust so carousel remains visually centered above trickPlayBar
  ' trickPlayBar is at ~890, we want some space above it
  baseVerticalPos = 890 - m.centerDisplayHeight - 20 ' 20px gap above trickPlayBar
  m.posterGroup.translation = [60, baseVerticalPos]

  m.log.debug("Updated clip groups", "verticalOffset", verticalOffset, "posterGroupY", baseVerticalPos)
end sub

' Performs early tile caching when config is loaded (before carousel is visible)
' Uses initialPosition from config to cache tiles around the starting playback position
' This ensures tiles are ready before the user starts scrubbing
sub performEarlyCaching()
  ' Skip if we've already done position-based caching (carousel was visible)
  if m.currentThumbnailIndex >= 0 then return

  m.log.info("Starting early tile caching before carousel visible")

  thumbsPerTile = m.config.tileWidth * m.config.tileHeight
  maxTileIndex = int((m.config.thumbnailCount - 1) / thumbsPerTile)

  ' Calculate starting thumbnail index from initial position
  initialPosition = 0
  if isValid(m.config.initialPosition)
    initialPosition = m.config.initialPosition
  end if

  ' Calculate which tile contains the initial position
  initialThumbnailIndex = trickplay.calculateThumbnailIndex(initialPosition, m.config.interval)
  initialTileIndex = trickplay.calculateTileIndex(initialThumbnailIndex, m.config.tileWidth, m.config.tileHeight)

  m.log.debug("Early cache", "position", initialPosition, "thumbIndex", initialThumbnailIndex, "tileIndex", initialTileIndex)

  ' Build cache list - current tile + adjacent tiles
  tilesToCache = []

  ' Priority 1: Tile containing initial position
  if not m.loadedTileCache.doesExist(initialTileIndex.ToStr())
    tilesToCache.push(initialTileIndex)
  end if

  ' Priority 2: Next tile (forward scrubbing is more common)
  if initialTileIndex + 1 <= maxTileIndex and not m.loadedTileCache.doesExist((initialTileIndex + 1).ToStr())
    tilesToCache.push(initialTileIndex + 1)
  end if

  ' Priority 3: Previous tile (if not at start)
  if initialTileIndex > 0 and not m.loadedTileCache.doesExist((initialTileIndex - 1).ToStr())
    tilesToCache.push(initialTileIndex - 1)
  end if

  if tilesToCache.count() > 0
    m.log.debug("Early cache plan", "tiles", tilesToCache)
    requestTiles(tilesToCache)
  end if
end sub

' Updates velocity tracking when thumbnail index changes
' Uses exponential moving average for smooth velocity estimation
' @param {Integer} newIndex - New thumbnail index
' @param {Integer} oldIndex - Previous thumbnail index
sub updateVelocity(newIndex as integer, oldIndex as integer)
  dateTime = createObject("roDateTime")
  currentTime = dateTime.asSeconds() * 1000 + dateTime.getMilliseconds()

  ' Skip if this is the first update (no previous time reference)
  if m.lastIndexUpdateTime = 0
    m.lastIndexUpdateTime = currentTime
    m.lastIndexForVelocity = oldIndex
    return
  end if

  ' Calculate time delta in seconds
  timeDeltaMs = currentTime - m.lastIndexUpdateTime
  if timeDeltaMs <= 0 then timeDeltaMs = 1 ' Prevent division by zero

  timeDeltaSec = timeDeltaMs / 1000.0

  ' Calculate instantaneous velocity (thumbnails per second)
  indexDelta = abs(newIndex - oldIndex)
  instantVelocity = indexDelta / timeDeltaSec

  ' Apply exponential moving average smoothing
  ' This prevents spiky velocity readings from single large jumps
  if m.currentVelocity = 0
    m.currentVelocity = instantVelocity
  else
    m.currentVelocity = (m.velocitySmoothingFactor * instantVelocity) + ((1 - m.velocitySmoothingFactor) * m.currentVelocity)
  end if

  ' Update tracking state
  m.lastIndexUpdateTime = currentTime
  m.lastIndexForVelocity = newIndex

  m.log.debug("Velocity update", "instant", instantVelocity, "smoothed", m.currentVelocity, "timeDelta", timeDeltaSec)
end sub

' Calculates how many thumbnail STEPS until we need to load a different tile
' Used for proactive tile downloading based on velocity and scrub direction
'
' Examples (100 thumbs per tile):
'   Forward at index 99 (last in tile 0): returns 1 (next step enters tile 1)
'   Backward at index 100 (first in tile 1): returns 1 (next step enters tile 0)
'   Forward at index 50 (mid tile 0): returns 50 (50 steps until tile 1)
'
' @param {Integer} thumbnailIndex - Current thumbnail index being displayed
' @return {Integer} - Number of thumbnail steps until crossing tile boundary
function calculateThumbsUntilBoundary(thumbnailIndex as integer) as integer
  if not isValid(m.thumbsPerTile) or m.thumbsPerTile <= 0 then return 0

  currentTile = int(thumbnailIndex / m.thumbsPerTile)

  if m.scrubDirection > 0
    ' Forward: calculate steps until we enter the next tile
    ' Example: tile 0 ends at index 99, so at 99 we need 1 step to reach 100 (tile 1)
    nextTileBoundary = (currentTile + 1) * m.thumbsPerTile
    return nextTileBoundary - thumbnailIndex
  else if m.scrubDirection < 0
    ' Backward: calculate steps until we enter the previous tile
    ' Example: tile 1 starts at 100, so at 100 we need 1 step backward to reach 99 (tile 0)
    ' The +1 is correct: we're currently IN the current tile, need to step OUT of it
    currentTileBoundary = currentTile * m.thumbsPerTile
    return thumbnailIndex - currentTileBoundary + 1
  end if

  ' Should never reach here - caller checks scrubDirection before calling
  m.log.error("calculateThumbsUntilBoundary called with scrubDirection=0", "thumbnailIndex", thumbnailIndex)
  return 0 ' Safest fallback - no distance to travel
end function

' Calculates the safety threshold - how many thumbnails ahead we need to trigger download
' Formula: thumbsPerTick * ticksToDownload * safetyBuffer
' @return {Integer} - Thumbnail count threshold for triggering next tile download
function calculateSafetyThreshold() as integer
  if m.currentVelocity <= 0 then return 50 ' Default when stationary

  ' Tick interval from Roku's trickPlayBar updates (~200ms)
  tickInterval = 0.2

  ' How many thumbnails we move per tick at current velocity
  thumbsPerTick = m.currentVelocity * tickInterval

  ' How many ticks until download completes
  ticksToDownload = m.estimatedDownloadTime / tickInterval

  ' Threshold = thumbs we'll travel while download is in progress
  ' 1.5x buffer for network variance
  threshold = int(thumbsPerTick * ticksToDownload * 1.5)

  ' Clamp to reasonable range (at least 10, at most full tile)
  if threshold < 10 then threshold = 10
  if threshold > m.thumbsPerTile then threshold = m.thumbsPerTile

  return threshold
end function

' Proactively checks if we need to request the next tile based on velocity
' Called on every thumbnail index change - the KEY function for smooth scrubbing
' Uses formula: if thumbsUntilBoundary < safetyThreshold → request next tile NOW
' @param {Integer} thumbnailIndex - Current thumbnail index
sub checkProactiveTileRequest(thumbnailIndex as integer)
  if m.scrubDirection = 0 then return ' No direction yet
  if m.currentVelocity <= 0 then return ' Not moving

  thumbsUntilBoundary = calculateThumbsUntilBoundary(thumbnailIndex)
  threshold = calculateSafetyThreshold()

  currentTile = int(thumbnailIndex / m.thumbsPerTile)
  nextTile = currentTile + m.scrubDirection
  maxTileIndex = int((m.config.thumbnailCount - 1) / m.thumbsPerTile)

  ' Check if next tile is valid
  if nextTile < 0 or nextTile > maxTileIndex then return

  ' If we're within threshold AND tile isn't cached/pending, request it NOW
  if thumbsUntilBoundary <= threshold
    nextTileStr = nextTile.ToStr()
    if not m.loadedTileCache.doesExist(nextTileStr) and not m.pendingTiles.doesExist(nextTileStr)
      m.log.debug("Proactive tile request", "thumbsLeft", thumbsUntilBoundary, "threshold", threshold, "tile", nextTile)
      requestTiles([nextTile])
    end if
  end if
end sub

' Calculates how many tiles ahead to preload based on current velocity
' Uses the formula: tilesNeeded = ceil(downloadTime / timeToNextTile) + 1
' @return {Integer} - Number of tiles to preload in scrub direction
function calculateTilesNeeded() as integer
  if not isValid(m.thumbsPerTile) or m.thumbsPerTile <= 0 then return m.minPreloadTiles

  ' If no velocity or extremely slow (prevents division issues), use minimum
  if m.currentVelocity <= m.minVelocityThreshold then return m.minPreloadTiles

  ' Calculate time to cross one tile at current velocity
  ' tilesPerSecond = velocity / thumbsPerTile
  ' timeToNextTile = 1 / tilesPerSecond = thumbsPerTile / velocity
  timeToNextTile = m.thumbsPerTile / m.currentVelocity

  ' If time to next tile is very long, we only need minimum preload
  if timeToNextTile > 30 then return m.minPreloadTiles

  ' Calculate tiles needed: ceil(downloadTime / timeToNextTile) + 1 buffer
  tilesNeeded = int(m.estimatedDownloadTime / timeToNextTile) + 2 ' +2 for ceiling + buffer

  ' Clamp to min/max range
  if tilesNeeded < m.minPreloadTiles then tilesNeeded = m.minPreloadTiles
  if tilesNeeded > m.maxPreloadTiles then tilesNeeded = m.maxPreloadTiles

  m.log.debug("Tiles needed calc", "velocity", m.currentVelocity, "timeToTile", timeToNextTile, "needed", tilesNeeded)

  return tilesNeeded
end function

' Called when playback position changes during normal video playback
' Proactively caches tiles around current position so they're ready when user scrubs
' Only triggers when crossing into a new tile to avoid excessive requests
sub onPlaybackPositionChanged()
  if not isValidAndNotEmpty(m.config) then return

  ' Don't update cache while user is actively scrubbing (carousel visible)
  ' The scrubbing logic handles caching during active scrubbing
  if m.top.visible then return

  ' Use playbackPosition, or initialPosition as fallback when video hasn't started
  position = m.top.playbackPosition
  if position <= 0 and isValid(m.config.initialPosition) and m.config.initialPosition > 0
    position = m.config.initialPosition
  end if

  thumbnailIndex = trickplay.calculateThumbnailIndex(position, m.config.interval)
  currentTileIndex = trickplay.calculateTileIndex(thumbnailIndex, m.config.tileWidth, m.config.tileHeight)

  ' Only cache if we've moved to a different tile
  if currentTileIndex = m.lastCachedTileIndex then return

  m.lastCachedTileIndex = currentTileIndex
  m.log.debug("Playback position cache update", "position", position, "tile", currentTileIndex)

  ' Cache current tile ± 2 tiles (aggressive caching during playback)
  ' This ensures tiles are ready when user starts scrubbing in any direction
  maxTileIndex = int((m.config.thumbnailCount - 1) / m.thumbsPerTile)

  tilesToCache = []

  ' Cache current tile and ±2 tiles
  for offset = -2 to 2
    tileIdx = currentTileIndex + offset
    if tileIdx >= 0 and tileIdx <= maxTileIndex
      if not m.loadedTileCache.doesExist(tileIdx.ToStr()) and not m.pendingTiles.doesExist(tileIdx.ToStr())
        tilesToCache.push(tileIdx)
      end if
    end if
  end for

  if tilesToCache.count() > 0
    m.log.debug("Playback cache plan", "tiles", tilesToCache)
    requestTiles(tilesToCache)
  end if
end sub

' Called when visible field changes
' Performs initial tile caching and displays posters when carousel first appears
sub onVisibilityChanged()
  if not m.top.visible
    ' Reset velocity tracking when carousel hides (scrubbing session ended)
    m.currentVelocity = 0
    m.lastIndexUpdateTime = 0
    m.lastIndexForVelocity = -1
    m.preloadedTileIndex = -1 ' Reset so next session preloads fresh
    return
  end if

  if not isValidAndNotEmpty(m.config) then return

  ' Reset velocity tracking for new scrubbing session
  m.currentVelocity = 0
  m.lastIndexUpdateTime = 0
  m.lastIndexForVelocity = -1

  ' ALWAYS cache tiles around current position when carousel becomes visible
  ' This ensures tiles are ready BEFORE user starts moving, regardless of playback history
  if m.top.thumbnailIndex >= 0
    ensureTilesCachedAroundPosition(m.top.thumbnailIndex)
  end if

  ' If this is first time visible and we have a valid thumbnail index, perform initial setup
  if m.currentThumbnailIndex = -1 and m.top.thumbnailIndex >= 0
    m.log.info("Carousel first visible, initializing to thumbnail", m.top.thumbnailIndex)
    m.currentThumbnailIndex = m.top.thumbnailIndex
    fullRecalculatePosters(m.top.thumbnailIndex)
  end if

  ' Preload next tile's texture for when user starts scrubbing
  nextTileToPreload = getNextTileToPreload()
  if nextTileToPreload >= 0
    preloadTextureForTile(nextTileToPreload)
  end if
end sub

' Immediately ensures tiles are cached around the given thumbnail position
' Called when carousel becomes visible to prepare for ANY scrub direction
' Requests current tile ± 2 tiles to cover both forward and backward scrubbing
' @param {Integer} thumbnailIndex - Current thumbnail position
sub ensureTilesCachedAroundPosition(thumbnailIndex as integer)
  currentTileIndex = trickplay.calculateTileIndex(thumbnailIndex, m.config.tileWidth, m.config.tileHeight)
  maxTileIndex = int((m.config.thumbnailCount - 1) / m.thumbsPerTile)

  tilesToCache = []

  ' Cache current tile and ±2 tiles in both directions
  ' This covers: current position, 2 tiles forward, 2 tiles backward
  for offset = -2 to 2
    tileIdx = currentTileIndex + offset
    if tileIdx >= 0 and tileIdx <= maxTileIndex
      if not m.loadedTileCache.doesExist(tileIdx.ToStr()) and not m.pendingTiles.doesExist(tileIdx.ToStr())
        tilesToCache.push(tileIdx)
      end if
    end if
  end for

  if tilesToCache.count() > 0
    m.log.debug("Visibility cache", "currentTile", currentTileIndex, "requesting", tilesToCache)
    requestTiles(tilesToCache)
  end if
end sub

' Main entry point - called when parent component updates thumbnail index
sub onThumbnailIndexChanged()
  if not isValidAndNotEmpty(m.config) then return
  if not m.top.visible then return

  newIndex = m.top.thumbnailIndex

  ' Bounds check
  if newIndex < 0 or newIndex >= m.config.thumbnailCount
    m.log.debug("Thumbnail index out of bounds", newIndex)
    return
  end if

  ' First time initialization - do full recalc
  if m.currentThumbnailIndex = -1
    m.log.debug("Initial thumbnail load", newIndex)
    m.currentThumbnailIndex = newIndex
    fullRecalculatePosters(newIndex)
    ' Preload next tile for when user starts scrubbing
    nextTileToPreload = getNextTileToPreload()
    if nextTileToPreload >= 0
      preloadTextureForTile(nextTileToPreload)
    end if
    return
  end if

  ' No change - skip update
  if newIndex = m.currentThumbnailIndex then return

  ' Update current index and track scrub direction
  oldIndex = m.currentThumbnailIndex
  m.currentThumbnailIndex = newIndex

  ' Track scrub direction for predictive tile loading
  if newIndex > oldIndex
    m.scrubDirection = 1 ' Forward
  else
    m.scrubDirection = -1 ' Backward
  end if

  ' Update velocity tracking for dynamic preload depth
  updateVelocity(newIndex, oldIndex)

  ' CRITICAL: Check if we need to proactively request the next tile
  ' This uses velocity to predict when we'll need the next tile and requests it early
  checkProactiveTileRequest(newIndex)

  ' Preload next tile's texture based on current scrub direction
  ' If tile is already cached, this warms GPU texture cache for instant display
  nextTileToPreload = getNextTileToPreload()
  if nextTileToPreload >= 0
    preloadTextureForTile(nextTileToPreload)
  end if

  m.log.debug("Index changed", "from", oldIndex, "to", newIndex, "direction", m.scrubDirection, "velocity", m.currentVelocity)

  fullRecalculatePosters(newIndex)
end sub

' Recalculates and loads all 5 posters for a new center thumbnail index
' Called on every thumbnail index change - we always recalculate all 5 because
' center poster (480x270) has different display properties than sides (320x180)
' @param {Integer} centerThumbnailIndex - New center thumbnail to display
sub fullRecalculatePosters(centerThumbnailIndex as integer)
  m.log.debug("Full recalculate posters", "centerIndex", centerThumbnailIndex, "currentThumbnailIndex", m.currentThumbnailIndex, "visible", m.top.visible)

  ' Calculate thumbnail indexes for all 5 poster positions
  ' [center-2, center-1, center, center+1, center+2]
  posterOffsets = [-2, -1, 0, 1, 2]

  for i = 0 to 4
    thumbnailIndex = centerThumbnailIndex + posterOffsets[i]
    updateSinglePoster(i, thumbnailIndex)
  end for

  ' Request tile preloading for adjacent tiles
  preloadAdjacentTiles(centerThumbnailIndex)
end sub

' Updates a single poster with a new thumbnail
' Handles tile loading, clipping calculation, and visibility
' @param {Integer} posterIndex - Poster array index (0-4)
' @param {Integer} thumbnailIndex - Thumbnail index to display
sub updateSinglePoster(posterIndex as integer, thumbnailIndex as integer)
  poster = m.posterArray[posterIndex]

  ' Early exit if poster is already displaying this exact thumbnail and is visible
  ' This prevents width/height/translation changes from triggering texture reloads
  ' Safe because: tile cache paths are deterministic (never change for same tileIndex)
  ' and tiles are never re-downloaded once successfully cached during the video session
  if m.posterThumbnailIndexes[posterIndex] = thumbnailIndex and poster.visible
    m.log.debug("Early exit - poster already showing thumbnail", "poster", posterIndex, "thumbIdx", thumbnailIndex)
    return
  end if

  ' Check bounds - hide poster if thumbnail out of range
  if thumbnailIndex < 0 or thumbnailIndex >= m.config.thumbnailCount
    m.log.debug("Hiding poster - thumbnail out of bounds", "poster", posterIndex, "oldUri", poster.uri, "thumbIndex", thumbnailIndex)
    ' Only hide poster - DON'T clear URI as that triggers render processing even when invisible
    poster.visible = false
    m.posterThumbnailIndexes[posterIndex] = -1

    return
  end if

  ' Track which thumbnail this poster displays
  m.posterThumbnailIndexes[posterIndex] = thumbnailIndex

  ' Calculate which tile contains this thumbnail
  tileIndex = trickplay.calculateTileIndex(thumbnailIndex, m.config.tileWidth, m.config.tileHeight)
  tileIndexStr = tileIndex.ToStr()
  m.log.debug("Tile calculation", "poster", posterIndex, "thumb", thumbnailIndex, "tile", tileIndex)

  ' Check if tile is cached
  if not m.loadedTileCache.doesExist(tileIndexStr)
    ' Tile not loaded - hide poster and request load
    ' Don't clear URI to avoid extra render work; refreshPostersWithNewTiles() will update when ready
    poster.visible = false
    m.log.debug("Poster", posterIndex, "tile", tileIndex, "not cached, requesting")
    requestSingleTile(tileIndex)
    return
  end if

  ' Tile is cached - calculate display parameters
  cachePath = m.loadedTileCache[tileIndexStr]

  ' Calculate thumbnail position within tile
  thumbRect = trickplay.calculateClippingRect(thumbnailIndex, m.config.tileWidth, m.config.tileHeight, m.config.width, m.config.height)

  ' Determine poster display size based on position using dynamic dimensions
  ' Side posters (0,1,3,4) use sideDisplay*, Center (2) uses centerDisplay*
  if posterIndex = 2
    ' Center poster - 1.5x scale
    displayWidth = m.centerDisplayWidth
    displayHeight = m.centerDisplayHeight
  else
    ' Side posters - 1x scale
    displayWidth = m.sideDisplayWidth
    displayHeight = m.sideDisplayHeight
  end if

  ' Calculate scale factor from source thumbnail to display size
  scaleX = displayWidth / m.config.width
  scaleY = displayHeight / m.config.height

  ' Calculate full tile dimensions (source image size before display scaling)
  fullTileWidth = m.config.tileWidth * m.config.width
  fullTileHeight = m.config.tileHeight * m.config.height

  ' Low memory devices: load texture at half size to fit in limited texture memory (~50MB)
  ' Roku scales the smaller texture to fill poster.width/height, reducing quality but enabling display
  if m.isLowMemoryDevice
    poster.loadWidth = fullTileWidth / 2
    poster.loadHeight = fullTileHeight / 2
    poster.loadDisplayMode = "limitSize"
  end if

  ' Set poster to scaled full tile size for display
  poster.width = fullTileWidth * scaleX
  poster.height = fullTileHeight * scaleY

  ' Translate poster negatively so desired thumbnail appears in clip region
  poster.translation = [-thumbRect.x * scaleX, -thumbRect.y * scaleY]

  ' Only update URI if it changed (avoid redundant texture reload when scrubbing within same tile)
  if poster.uri <> cachePath
    m.log.debug("Setting poster URI", "poster", posterIndex, "oldUri", poster.uri, "newUri", cachePath)
    poster.uri = cachePath
  end if

  poster.visible = true

  m.log.debug("Updated poster", posterIndex, "thumb", thumbnailIndex, "tile", tileIndex, "uri", cachePath)
end sub

' Preloads tiles adjacent to current position for smooth scrubbing
' Uses velocity-based calculation to determine how many tiles ahead to preload
' Prioritizes tiles in the scrub direction
' @param {Integer} centerThumbnailIndex - Current center thumbnail
sub preloadAdjacentTiles(centerThumbnailIndex as integer)
  ' Calculate current tile and bounds
  currentTileIndex = trickplay.calculateTileIndex(centerThumbnailIndex, m.config.tileWidth, m.config.tileHeight)
  thumbsPerTile = m.config.tileWidth * m.config.tileHeight
  maxTileIndex = int((m.config.thumbnailCount - 1) / thumbsPerTile)

  ' Calculate dynamic preload depth based on velocity
  tilesNeeded = calculateTilesNeeded()

  ' Build preload list - prioritize based on scrub direction
  tilesToPreload = []

  ' Priority 1: Current tile (always needed)
  if not m.loadedTileCache.doesExist(currentTileIndex.ToStr())
    tilesToPreload.push(currentTileIndex)
  end if

  if m.scrubDirection > 0
    ' Scrubbing forward: prioritize tiles ahead, then 1 behind
    for i = 1 to tilesNeeded
      tileIdx = currentTileIndex + i
      if tileIdx <= maxTileIndex and not m.loadedTileCache.doesExist(tileIdx.ToStr())
        tilesToPreload.push(tileIdx)
      end if
    end for
    ' Also cache 1 tile behind for direction changes
    if currentTileIndex - 1 >= 0 and not m.loadedTileCache.doesExist((currentTileIndex - 1).ToStr())
      tilesToPreload.push(currentTileIndex - 1)
    end if
  else if m.scrubDirection < 0
    ' Scrubbing backward: prioritize tiles behind, then 1 ahead
    for i = 1 to tilesNeeded
      tileIdx = currentTileIndex - i
      if tileIdx >= 0 and not m.loadedTileCache.doesExist(tileIdx.ToStr())
        tilesToPreload.push(tileIdx)
      end if
    end for
    ' Also cache 1 tile ahead for direction changes
    if currentTileIndex + 1 <= maxTileIndex and not m.loadedTileCache.doesExist((currentTileIndex + 1).ToStr())
      tilesToPreload.push(currentTileIndex + 1)
    end if
  else
    ' No direction yet (initial load): load tiles in both directions
    for i = 1 to m.minPreloadTiles
      if currentTileIndex + i <= maxTileIndex and not m.loadedTileCache.doesExist((currentTileIndex + i).ToStr())
        tilesToPreload.push(currentTileIndex + i)
      end if
      if currentTileIndex - i >= 0 and not m.loadedTileCache.doesExist((currentTileIndex - i).ToStr())
        tilesToPreload.push(currentTileIndex - i)
      end if
    end for
  end if

  ' Request tiles if any need loading
  if tilesToPreload.count() > 0
    m.log.debug("Preload plan", "tiles", tilesToPreload, "tilesNeeded", tilesNeeded, "velocity", m.currentVelocity)
    requestTiles(tilesToPreload)
  end if
end sub

' Sorts tile indexes by priority: closest to current position first, then by scrub direction
' @param {Array} tileIndexes - Array of tile indexes to sort
' @return {Array} - Sorted array of tile indexes
function prioritizeTileQueue(tileIndexes as object) as object
  if not isValid(tileIndexes) or tileIndexes.count() <= 1 then return tileIndexes

  ' Get current tile for distance calculation
  currentTileIndex = 0
  if m.currentThumbnailIndex >= 0 and isValid(m.thumbsPerTile) and m.thumbsPerTile > 0
    currentTileIndex = int(m.currentThumbnailIndex / m.thumbsPerTile)
  end if

  ' Simple bubble sort by distance (tile lists are small, <10 items typically)
  sorted = []
  for each tile in tileIndexes
    sorted.push(tile)
  end for

  for i = 0 to sorted.count() - 2
    for j = i + 1 to sorted.count() - 1
      ' Calculate distance from current tile
      distI = abs(sorted[i] - currentTileIndex)
      distJ = abs(sorted[j] - currentTileIndex)

      ' If same distance, prioritize by scrub direction
      shouldSwap = false
      if distJ < distI
        shouldSwap = true
      else if distJ = distI and m.scrubDirection <> 0
        ' Same distance: prefer tile in scrub direction
        if m.scrubDirection > 0 and sorted[j] > sorted[i]
          shouldSwap = true
        else if m.scrubDirection < 0 and sorted[j] < sorted[i]
          shouldSwap = true
        end if
      end if

      if shouldSwap
        temp = sorted[i]
        sorted[i] = sorted[j]
        sorted[j] = temp
      end if
    end for
  end for

  return sorted
end function

' Requests a single tile to be loaded (for immediate needs)
' @param {Integer} tileIndex - Tile index to load
sub requestSingleTile(tileIndex as integer)
  requestTiles([tileIndex])
end sub

' Preloads a tile's texture into GPU memory via the hidden preloader poster
' This warms Roku's texture cache so visible posters display instantly
' @param {Integer} tileIndex - Tile index to preload into texture memory
sub preloadTextureForTile(tileIndex as integer)
  m.log.debug("Texture preload", "tile", tileIndex, "uri", m.loadedTileCache)

  ' Skip if already preloaded (by index)
  if tileIndex = m.preloadedTileIndex then return

  tileIndexStr = tileIndex.ToStr()

  ' Only preload if tile is cached to disk
  if not m.loadedTileCache.doesExist(tileIndexStr) then return

  cachePath = m.loadedTileCache[tileIndexStr]

  ' Skip if URI is already set - once texture is in GPU memory
  if m.preloaderPoster.uri = cachePath then return

  m.preloaderPoster.uri = cachePath
  m.preloadedTileIndex = tileIndex
end sub

' Determines which tile to preload - always ONE TILE AHEAD of the edge poster
' The preloader must finish loading BEFORE the edge poster needs the tile
' Edge poster = rightmost (FF) or leftmost (RW) visible poster
' @return {Integer} - Tile index to preload, or -1 if none needed
function getNextTileToPreload() as integer
  if not isValid(m.config) or m.currentThumbnailIndex < 0 then return -1

  maxTileIndex = int((m.config.thumbnailCount - 1) / m.thumbsPerTile)

  ' Calculate edge poster's thumbnail index
  ' Posters are: [center-2, center-1, center, center+1, center+2]
  if m.scrubDirection > 0
    ' FF: edge is rightmost poster (center + 2)
    edgeThumbIndex = m.currentThumbnailIndex + 2
  else if m.scrubDirection < 0
    ' RW: edge is leftmost poster (center - 2)
    edgeThumbIndex = m.currentThumbnailIndex - 2
  else
    ' No direction: default to forward
    edgeThumbIndex = m.currentThumbnailIndex + 2
  end if

  ' Clamp to valid range
  if edgeThumbIndex < 0 then edgeThumbIndex = 0
  if edgeThumbIndex >= m.config.thumbnailCount then edgeThumbIndex = m.config.thumbnailCount - 1

  ' Get tile the edge poster is currently on
  edgeTileIndex = trickplay.calculateTileIndex(edgeThumbIndex, m.config.tileWidth, m.config.tileHeight)

  ' Preload ONE TILE AHEAD of the edge poster (in scrub direction)
  ' This ensures the texture is ready BEFORE the edge poster needs it
  if m.scrubDirection > 0
    tileToPreload = edgeTileIndex + 1
    if tileToPreload <= maxTileIndex then return tileToPreload
  else if m.scrubDirection < 0
    tileToPreload = edgeTileIndex - 1
    if tileToPreload >= 0 then return tileToPreload
  else
    tileToPreload = edgeTileIndex + 1
    if tileToPreload <= maxTileIndex then return tileToPreload
  end if

  return -1
end function

' Requests multiple tiles to be loaded via queue system
' Prevents request cancellation by queuing instead of stopping in-flight requests
' @param {Array} tileIndexes - Array of tile indexes to load
sub requestTiles(tileIndexes as dynamic)
  if not isValid(tileIndexes) or tileIndexes.count() = 0 then return

  ' Filter out tiles already cached or pending
  tilesToRequest = []
  for each tileIndex in tileIndexes
    tileIndexStr = tileIndex.ToStr()
    if not m.loadedTileCache.doesExist(tileIndexStr) and not m.pendingTiles.doesExist(tileIndexStr)
      tilesToRequest.push(tileIndex)
      m.pendingTiles[tileIndexStr] = true ' Mark pending immediately to prevent duplicates
    end if
  end for

  if tilesToRequest.count() = 0 then return

  if m.taskRunning
    ' Queue for later - don't cancel current task
    m.tileRequestQueue.append(tilesToRequest)
    m.log.debug("Queued tiles", tilesToRequest, "queueSize", m.tileRequestQueue.count())
  else
    startTileLoader(tilesToRequest)
  end if
end sub

' Starts the tile loader task with specified tiles
' @param {Array} tileIndexes - Array of tile indexes to load
sub startTileLoader(tileIndexes as dynamic)
  m.log.debug("Starting tile loader", tileIndexes)
  m.taskRunning = true
  m.tileLoader.videoID = m.config.videoID
  m.tileLoader.width = m.config.width
  m.tileLoader.tileIndexes = tileIndexes
  m.tileLoader.control = "RUN"
end sub

' Called when tile loader task state changes
' Handles task completion: updates cache, clears pending, processes queue
sub onTaskStateChanged()
  if m.tileLoader.state <> "stop" then return

  m.taskRunning = false
  loadedTiles = m.tileLoader.loadedTiles

  ' Process loaded tiles - move from pending to cache
  if isValid(loadedTiles)
    for each tileIndexStr in loadedTiles
      m.pendingTiles.delete(tileIndexStr)
      cachePath = loadedTiles[tileIndexStr]
      m.loadedTileCache[tileIndexStr] = cachePath
      m.log.info("Cached tile", tileIndexStr, "path", cachePath)
    end for
  end if

  ' Clear failed tiles from pending so they can be retried
  failedTiles = m.tileLoader.failedTiles
  if isValid(failedTiles)
    for each tileIndex in failedTiles
      m.pendingTiles.delete(tileIndex.ToStr())
    end for
  end if

  ' Update estimated download time from actual measurements
  if isValid(m.tileLoader.avgDownloadTime) and m.tileLoader.avgDownloadTime > 0
    ' Use weighted average: 70% new measurement, 30% previous estimate
    ' This prevents sudden spikes from affecting preload calculations too drastically
    m.estimatedDownloadTime = (0.7 * m.tileLoader.avgDownloadTime) + (0.3 * m.estimatedDownloadTime)
    m.log.debug("Updated download time estimate", m.estimatedDownloadTime)
  end if

  loadedCount = 0
  if isValid(loadedTiles) then loadedCount = loadedTiles.count()
  m.log.debug("Task complete", "loaded", loadedCount, "cacheSize", m.loadedTileCache.count(), "estDownloadTime", m.estimatedDownloadTime)

  ' Process queue if anything waiting
  if m.tileRequestQueue.count() > 0
    queuedTiles = m.tileRequestQueue
    m.tileRequestQueue = []

    ' Re-prioritize queue based on current position (user may have moved)
    prioritizedTiles = prioritizeTileQueue(queuedTiles)

    m.log.debug("Processing queued tiles", "count", prioritizedTiles.count(), "order", prioritizedTiles)
    startTileLoader(prioritizedTiles)
  end if

  ' Refresh posters that were waiting for these tiles
  if isValid(loadedTiles) and m.currentThumbnailIndex >= 0
    refreshPostersWithNewTiles(loadedTiles)
  end if

  ' Preload tile into texture memory for instant display when needed
  if m.top.visible
    ' During scrubbing - preload next tile in scrub direction
    nextTileToPreload = getNextTileToPreload()
    if nextTileToPreload >= 0
      preloadTextureForTile(nextTileToPreload)
    end if
  else if m.currentThumbnailIndex < 0 and isValidAndNotEmpty(m.config)
    ' Early caching (carousel never opened) - warm GPU texture by setting up posters
    ' Posters are invisible but loading URIs warms the texture cache
    initialPosition = 0
    if isValid(m.config.initialPosition)
      initialPosition = m.config.initialPosition
    end if
    initialThumbnailIndex = trickplay.calculateThumbnailIndex(initialPosition, m.config.interval)
    fullRecalculatePosters(initialThumbnailIndex)
    ' Also preload next tile for when user starts scrubbing
    initialTileIndex = trickplay.calculateTileIndex(initialThumbnailIndex, m.config.tileWidth, m.config.tileHeight)
    maxTileIndex = int((m.config.thumbnailCount - 1) / m.thumbsPerTile)
    if initialTileIndex + 1 <= maxTileIndex
      preloadTextureForTile(initialTileIndex + 1)
    end if
  end if
  ' Normal playback after scrubbing - keep existing texture cache warm, don't change preloader
end sub

' Refreshes posters that were waiting for newly loaded tiles
' Only updates posters that have thumbnails in the newly loaded tiles
' @param {AssocArray} loadedTiles - Map of tileIndex -> cachePath
sub refreshPostersWithNewTiles(loadedTiles as object)
  ' Check each poster to see if its thumbnail is in a newly loaded tile
  for i = 0 to 4
    posterThumbIndex = m.posterThumbnailIndexes[i]

    ' Skip if poster has no assigned thumbnail
    if posterThumbIndex < 0 then continue for

    ' Calculate which tile this poster's thumbnail belongs to
    tileIndex = trickplay.calculateTileIndex(posterThumbIndex, m.config.tileWidth, m.config.tileHeight)
    tileIndexStr = tileIndex.ToStr()

    ' If this tile was just loaded AND poster is currently hidden, update it
    if loadedTiles.doesExist(tileIndexStr) and not m.posterArray[i].visible
      m.log.debug("Refreshing poster", i, "with newly loaded tile", tileIndex)
      updateSinglePoster(i, posterThumbIndex)
    end if
  end for
end sub

' destroy: Full teardown releasing all resources before component removal
' Called by parent when popping scene or navigating away
' Clears task nodes, observers, all node refs, and data structures
sub destroy()
  ' First do soft reset (clears caches, resets state, stops task)
  reset()

  ' Unobserve task field to prevent callbacks on invalid object, then release
  if isValid(m.tileLoader)
    m.tileLoader.unobserveField("state")
    m.tileLoader = invalid
  end if

  ' Clear all node references
  m.preloaderPoster = invalid
  m.posterGroup = invalid

  for i = 0 to m.posterArray.count() - 1
    m.posterArray[i] = invalid
  end for
  m.posterArray = invalid

  for i = 0 to m.clipGroups.count() - 1
    m.clipGroups[i] = invalid
  end for
  m.clipGroups = invalid

  m.config = invalid
end sub

' reset: Clears all caches when video session ends
' Called by parent VideoPlayerView when video changes or stops
' Prevents unbounded cache growth across multiple videos in binge-watching sessions
sub reset()
  m.log.info("Cleaning up carousel caches for new video session")

  ' Clear in-memory caches (cachefs:/ files persist for performance)
  m.loadedTileCache.clear()
  m.pendingTiles.clear()
  m.tileRequestQueue.clear()

  ' Reset state tracking
  m.currentThumbnailIndex = -1
  m.lastCachedTileIndex = -1
  m.preloadedTileIndex = -1

  ' Reset velocity tracking
  m.currentVelocity = 0
  m.lastIndexUpdateTime = 0
  m.lastIndexForVelocity = -1

  ' Reset poster state to prevent showing wrong video's thumbnails
  ' This prevents the early exit optimization in updateSinglePoster from incorrectly
  ' skipping updates when a new video with the same trickplay structure is loaded
  m.posterThumbnailIndexes = [-1, -1, -1, -1, -1]

  ' Clear poster URIs and hide them to ensure fresh tiles load for new video
  for each poster in m.posterArray
    poster.uri = ""
    poster.visible = false
  end for

  ' Cancel any in-flight tile loading task
  if m.taskRunning
    m.tileLoader.control = "STOP"
    m.taskRunning = false
  end if
end sub