components_home_HomeRows.bs

' bsc-disable-file print-locations — legacy print() sites; migration to m.log.* tracked by tech-debt.md#legacy-print-statements
import "pkg:/source/constants/itemAspectRatio.bs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/itemImageUrl.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/rowListWrap.bs"
import "pkg:/source/utils/textureManager.bs"
import "pkg:/source/utils/translate.bs"

sub init()
  m.top.itemComponentName = "JRRowItem"
  m.top.numRows = 3
  m.top.vertFocusAnimationStyle = "fixedFocus"

  m.top.content = CreateObject("roSGNode", "ContentNode")

  updateSize()
  initTextureManager(m.top.content, m.top.itemSize, m.top.focusXOffset, m.top.rowItemSpacing)

  m.top.setfocus(true)

  m.top.observeField("rowItemSelected", "itemSelected")
  m.top.observeField("rowItemFocused", "onItemFocused")

  ' Persistent task nodes for data loading
  m.LoadLibrariesTask = createObject("roSGNode", "LoadItemsTask")

  m.LoadContinueWatchingTask = createObject("roSGNode", "LoadItemsTask")
  m.LoadContinueWatchingTask.itemsToLoad = "continue"

  m.LoadNextUpTask = createObject("roSGNode", "LoadItemsTask")
  m.LoadNextUpTask.itemsToLoad = "nextUp"

  m.LoadOnNowTask = createObject("roSGNode", "LoadItemsTask")
  m.LoadOnNowTask.itemsToLoad = "onNow"

  m.LoadActiveRecordingsTask = createObject("roSGNode", "LoadItemsTask")
  m.LoadActiveRecordingsTask.itemsToLoad = "activeRecordings"

  ' Track populated section count for AppLaunchComplete beacon
  m.populatedSectionCount = 0
  m.initialLoadComplete = false

  ' Track per-library latest media tasks so they can be cleaned up
  m.latestMediaTasks = []

  ' Track loading state for persistent tasks to prevent duplicate observers/requests
  m.isLoadingLibraries = false
  m.isLoadingResume = false
  m.isLoadingNextUp = false
  m.isLoadingOnNow = false
  m.isLoadingActiveRecordings = false

  ' Refetch the On Now and Active Recordings rows when the base class's progress
  ' tick detects that at least one Program or Recording has ended. Without this,
  ' sitting on Home long enough would leave a row of finished broadcasts/recordings
  ' until the user refreshes manually.
  m.top.observeField("programsExpired", "onProgramsExpired")
end sub

' loadLibraries: Entry point called by Home.bs via callFunc.
' Builds ordered section plan, creates skeleton rows, then fires data tasks.
sub loadLibraries()
  m.sectionPlan = buildSectionPlan()

  ' Create skeleton rows for all non-latestmedia sections
  createSkeletonRows()

  ' Set initial buffer range now that skeleton rows exist. This lets shouldLoadTexture()
  ' use buffer logic immediately — cells outside the buffer skip image fetch during init,
  ' preventing HTTP cache pollution that would mask texture management on first scroll.
  updateTextureBufferRange(m.top.content, 0, 0, m.top.numRows)

  ' Show rows immediately — no timer hack needed since skeletons are in place
  m.top.showRowCounter = [true]

  ' Observe library task (observer is removed after each load in onLibrariesLoaded)
  m.isLoadingLibraries = true
  m.LoadLibrariesTask.unobserveField("content")
  m.LoadLibrariesTask.observeField("content", "onLibrariesLoaded")
  m.LoadLibrariesTask.control = "RUN"

  ' Start loading data for sections that don't depend on library data
  startParallelLoads()
end sub

sub updateSize()
  uiRowLayout = m.global.user.settings.uiRowLayout

  if isValid(uiRowLayout)
    if uiRowLayout = "fullwidth"
      m.top.translation = [0, 126]
      ' itemSize height = tallest possible row (PORTRAIT slot + text area).
      ' Per-row rowHeights overrides this for each actual row.
      m.top.itemSize = [1920, rowSlotSize.ROW_HEIGHT_PORTRAIT]
      ' align with edge of "action" safe zone
      m.top.focusXOffset = [96]
      m.top.rowLabelOffset = [96, 18]
    else
      ' original layout
      m.top.translation = [111, 126]
      m.top.itemSize = [1703, rowSlotSize.ROW_HEIGHT_PORTRAIT]
      ' reset to defaults
      m.top.focusXOffset = []
      m.top.rowLabelOffset = [0, 18]
    end if
  end if

  m.top.visible = true
end sub

' ============================================
' SECTION PLAN & SKELETON ROW CREATION
' ============================================

' buildSectionPlan: Reads homeSection0-6 settings, returns ordered array of sections to display
'
' @return {roArray} Array of { type: string, settingIndex: integer }
function buildSectionPlan() as object
  plan = []
  userSettings = m.global.user.settings

  for i = 0 to 6
    sectionName = LCase(userSettings["homeSection" + i.toStr()] ?? "none")
    if sectionName <> "none"
      plan.push({ type: sectionName, settingIndex: i })
    end if
  end for

  return plan
end function

' createSkeletonRows: Creates empty HomeRow nodes for all planned sections except latestmedia.
' Each skeleton row gets a single placeholder child so the RowList renders the row label
' and a loading indicator at the correct slot size.
sub createSkeletonRows()
  for each section in m.sectionPlan
    if section.type = "latestmedia"
      ' latestmedia rows are created after library data loads (we don't know how many yet)
      continue for
    end if

    row = createSkeletonRow(section.type)
    if isValid(row)
      m.top.content.appendChild(row)
    end if
  end for

  setRowItemSize()
end sub

' createSkeletonRow: Creates a single empty HomeRow with correct title, sectionId, and cursorSize.
' Adds a single placeholder child node so the RowList renders the row.
'
' @param {string} sectionType - The section type from user settings
' @return {object} HomeRow node, or invalid if section type is unsupported
function createSkeletonRow(sectionType as string) as object
  row = CreateObject("roSGNode", "HomeRow")

  if sectionType = "resume"
    row.title = translate(translationKeys.LabelContinueWatching)
    row.sectionId = "resume"
    row.cursorSize = rowSlotSize.WIDE
  else if sectionType = "nextup"
    row.title = translate(translationKeys.LabelNextUp)
    row.sectionId = "nextup"
    row.cursorSize = rowSlotSize.WIDE
  else if sectionType = "livetv"
    row.title = translate(translationKeys.LabelOnNow)
    row.sectionId = "livetv"
    row.cursorSize = rowSlotSize.SQUARE
  else if sectionType = "librarybuttons" or sectionType = "smalllibrarytiles"
    row.title = translate(translationKeys.LabelMyMedia)
    row.sectionId = "library"
    row.cursorSize = rowSlotSize.LIBRARY
  else if sectionType = "activerecordings"
    row.title = translate(translationKeys.LabelActiveRecordings)
    row.sectionId = "activeRecordings"
    row.cursorSize = rowSlotSize.SQUARE
  else
    return invalid
  end if

  ' Add single placeholder child so the RowList renders this row with correct slot size.
  ' JRRowItem will show its default RectangleBackgroundSecondary backdrop for this node.
  placeholder = CreateObject("roSGNode", "ContentNode")
  placeholder.addFields({ type: "Loading" })
  row.appendChild(placeholder)

  return row
end function

' ============================================
' DATA LOADING
' ============================================

' startParallelLoads: Fires off data tasks for sections that don't need library data.
' Library-dependent sections (library row, latestmedia) are handled in onLibrariesLoaded.
sub startParallelLoads()
  for each section in m.sectionPlan
    if section.type = "resume"
      if m.isLoadingResume then continue for
      m.isLoadingResume = true
      m.LoadContinueWatchingTask.unobserveField("content")
      m.LoadContinueWatchingTask.observeField("content", "updateContinueWatchingItems")
      m.LoadContinueWatchingTask.control = "RUN"
    else if section.type = "nextup"
      if m.isLoadingNextUp then continue for
      m.isLoadingNextUp = true
      m.LoadNextUpTask.unobserveField("content")
      m.LoadNextUpTask.observeField("content", "updateNextUpItems")
      m.LoadNextUpTask.control = "RUN"
    else if section.type = "livetv"
      if m.isLoadingOnNow then continue for
      m.isLoadingOnNow = true
      m.LoadOnNowTask.unobserveField("content")
      m.LoadOnNowTask.observeField("content", "updateOnNowItems")
      m.LoadOnNowTask.control = "RUN"
    else if section.type = "activerecordings"
      if m.isLoadingActiveRecordings then continue for
      m.isLoadingActiveRecordings = true
      m.LoadActiveRecordingsTask.unobserveField("content")
      m.LoadActiveRecordingsTask.observeField("content", "updateActiveRecordingsItems")
      m.LoadActiveRecordingsTask.control = "RUN"
    end if
  end for
end sub

' onLibrariesLoaded: Handler when LoadLibrariesTask returns data.
' Populates the library row, creates latestmedia skeleton rows, then fires latest tasks.
sub onLibrariesLoaded()
  m.libraryData = m.LoadLibrariesTask.content
  m.LoadLibrariesTask.unobserveField("content")
  m.LoadLibrariesTask.content = []
  m.isLoadingLibraries = false

  ' Always recompute filteredLatest — library data or latestItemsExcludes may have changed since last load.
  ' startLatestMediaLoads() and getRowConfigForSection() both depend on this being current.
  m.filteredLatest = filterNodeArray(m.libraryData, "id", m.global.user.config.latestItemsExcludes)

  ' Populate the library row immediately (data is available now)
  populateLibraryRow()

  ' Only create latestmedia skeletons on initial load. On refresh, latestmedia rows
  ' either already exist (updated in place by populateRowFromData) or were removed
  ' because they had no data — populateRowFromData will re-insert them if data arrives.
  if not m.initialLoadComplete
    insertLatestMediaSkeletons()
    setRowItemSize()
    m.initialLoadComplete = true

    ' Layout is stable — activate texture management so cells can start
    ' unloading off-screen textures. Before this point, all cells keep
    ' their textures loaded to prevent visual glitches during layout changes.
    ' Recalculate buffer range first — the initial updateTextureBufferRange ran
    ' when the content root had 0 children, leaving loadedRowRange at [-1,-1,-1,-1].
    updateTextureBufferRange(m.top.content, m.top.rowItemFocused[0], m.top.rowItemFocused[1], m.top.numRows)
    activateTextureManager(m.top.content)
  end if

  ' Fire off latestmedia data tasks
  startLatestMediaLoads()
end sub

' populateLibraryRow: Fills the library row with filtered library items.
' Uses populateRowFromData for consistent in-place update behavior.
sub populateLibraryRow()
  if not isValidAndNotEmpty(m.libraryData) then return

  filteredMedia = filterNodeArray(m.libraryData, "id", m.global.user.config.myMediaExcludes)
  populateRowFromData("library", filteredMedia)
end sub

' insertLatestMediaSkeletons: Creates skeleton rows for each non-excluded library
' and inserts them at the correct position in the content node.
sub insertLatestMediaSkeletons()
  if not isValidAndNotEmpty(m.filteredLatest) then return

  ' Find where latestmedia should be inserted by finding the index after the last
  ' non-latestmedia section that comes before it in the plan
  insertIndex = findLatestMediaInsertIndex()

  for each lib in m.filteredLatest
    if lib.collectionType <> "boxsets" and lib.collectionType <> "livetv" and lib.collectionType <> "Program"
      sectionId = "latest_" + lib.id

      ' Skip if this row already exists (e.g., on refresh)
      if isValid(findRowBySectionId(sectionId)) then continue for

      slotSize = rowSlotSize.WIDE
      if isValidAndNotEmpty(lib.collectionType)
        if LCase(lib.collectionType) = "movies"
          slotSize = rowSlotSize.PORTRAIT
        else if LCase(lib.collectionType) = "music"
          slotSize = rowSlotSize.SQUARE
        end if
      end if

      row = CreateObject("roSGNode", "HomeRow")
      row.title = `${translate(translationKeys.LabelRecentlyAddedIn)} ${lib.name}`
      row.sectionId = sectionId
      row.cursorSize = slotSize

      ' Add placeholder child
      placeholder = CreateObject("roSGNode", "ContentNode")
      placeholder.addFields({ type: "Loading" })
      row.appendChild(placeholder)

      m.top.content.insertChild(row, insertIndex)
      insertIndex++
    end if
  end for
end sub

' findLatestMediaInsertIndex: Determines the correct content index for latestmedia rows
' based on the section plan ordering.
'
' @return {integer} Index where latestmedia rows should be inserted
function findLatestMediaInsertIndex() as integer
  ' Walk the section plan to find what comes before latestmedia
  ' The insert index is after the last section that precedes latestmedia in the plan
  latestPlanIndex = -1
  for i = 0 to m.sectionPlan.count() - 1
    if m.sectionPlan[i].type = "latestmedia"
      latestPlanIndex = i
      exit for
    end if
  end for

  if latestPlanIndex = -1
    ' latestmedia not in plan, append at end
    return m.top.content.getChildCount()
  end if

  ' Find the last section before latestmedia that has a row in content
  for i = latestPlanIndex - 1 to 0 step -1
    sectionType = m.sectionPlan[i].type
    sectionId = getSectionIdForType(sectionType)
    if isValid(sectionId)
      result = findRowBySectionId(sectionId)
      if isValid(result)
        return result.index + 1
      end if
    end if
  end for

  ' No preceding sections found, insert at beginning
  return 0
end function

' startLatestMediaLoads: Fires LoadItemsTask for each latestmedia library
sub startLatestMediaLoads()
  if not isValidAndNotEmpty(m.filteredLatest) then return

  ' Ensure any previous latest-media tasks are stopped and unobserved
  cleanupLatestMediaTasks()

  for each lib in m.filteredLatest
    if lib.collectionType <> "boxsets" and lib.collectionType <> "livetv" and lib.collectionType <> "Program"
      loadLatest = createObject("roSGNode", "LoadItemsTask")
      loadLatest.itemsToLoad = "latest"
      loadLatest.itemId = lib.id

      metadata = { "title": lib.name, "contentType": lib.collectionType }
      loadLatest.metadata = metadata

      loadLatest.observeField("content", "updateLatestItems")

      ' Track the task so it can be cleaned up later
      m.latestMediaTasks.push(loadLatest)

      loadLatest.control = "RUN"
    end if
  end for
end sub

' cleanupLatestMediaTasks: Stop and unobserve any in-flight latest media tasks
sub cleanupLatestMediaTasks()
  if not isValid(m.latestMediaTasks) then return

  for each task in m.latestMediaTasks
    if isValid(task)
      ' Remove all observers on the content field to prevent callbacks after teardown
      task.unobserveField("content")

      ' Stop the task if it is still running
      if lcase(task.control) = "run"
        task.control = "stop"
      end if
    end if
  end for

  m.latestMediaTasks = []
end sub

' ============================================
' ROW LOOKUP & MANAGEMENT
' ============================================

' findRowBySectionId: Find a row in content by its sectionId field
'
' @param {string} sectionId - The sectionId to search for
' @return {object} { row: node, index: integer } or invalid if not found
function findRowBySectionId(sectionId as string) as object
  if not isValid(m.top.content) then return invalid

  for i = 0 to m.top.content.getChildCount() - 1
    row = m.top.content.getChild(i)
    if row.sectionId = sectionId
      return { row: row, index: i }
    end if
  end for

  return invalid
end function

' getSectionIdForType: Maps a section type string to its sectionId
'
' @param {string} sectionType - Section type from user settings
' @return {string} sectionId, or invalid if type is unsupported
function getSectionIdForType(sectionType as string) as dynamic
  if sectionType = "resume" then return "resume"
  if sectionType = "nextup" then return "nextup"
  if sectionType = "livetv" then return "livetv"
  if sectionType = "librarybuttons" or sectionType = "smalllibrarytiles" then return "library"
  if sectionType = "activerecordings" then return "activeRecordings"
  return invalid
end function

' removeRowAtIndex: Removes a row at the given index and recalculates sizes
'
' @param {integer} index - Content child index to remove
sub removeRowAtIndex(index as integer)
  m.top.content.removeChildIndex(index)
  setRowItemSize()
end sub

' getRowConfigForSection: Returns title and slotSize for a sectionId.
' Used when inserting a row that was previously removed (no skeleton exists).
'
' @param {string} sectionId - The sectionId
' @return {object} { title: string, slotSize: array } or invalid if unknown
function getRowConfigForSection(sectionId as string) as object
  if sectionId = "resume" then return { title: translate(translationKeys.LabelContinueWatching), slotSize: rowSlotSize.WIDE }
  if sectionId = "nextup" then return { title: translate(translationKeys.LabelNextUp), slotSize: rowSlotSize.WIDE }
  if sectionId = "livetv" then return { title: translate(translationKeys.LabelOnNow), slotSize: rowSlotSize.SQUARE }
  if sectionId = "library" then return { title: translate(translationKeys.LabelMyMedia), slotSize: rowSlotSize.LIBRARY }
  if sectionId = "activeRecordings" then return { title: translate(translationKeys.LabelActiveRecordings), slotSize: rowSlotSize.SQUARE }

  ' latestmedia rows: derive title and slotSize from the library data
  if sectionId.startsWith("latest_") and isValidAndNotEmpty(m.filteredLatest)
    libId = sectionId.mid(7) ' strip "latest_" prefix
    for each lib in m.filteredLatest
      if lib.id = libId
        slotSize = rowSlotSize.WIDE
        if isValidAndNotEmpty(lib.collectionType)
          if LCase(lib.collectionType) = "movies"
            slotSize = rowSlotSize.PORTRAIT
          else if LCase(lib.collectionType) = "music"
            slotSize = rowSlotSize.SQUARE
          end if
        end if
        return { title: `${translate(translationKeys.LabelRecentlyAddedIn)} ${lib.name}`, slotSize: slotSize }
      end if
    end for
  end if

  return invalid
end function

' findInsertIndexForSection: Determines the correct content index for a section
' that needs to be inserted. Uses the section plan ordering to maintain row order.
'
' @param {string} sectionId - The sectionId to insert
' @return {integer} Index where the row should be inserted
function findInsertIndexForSection(sectionId as string) as integer
  ' Build ordered list of sectionIds from the plan (expanding latestmedia)
  orderedIds = []
  for each section in m.sectionPlan
    if section.type = "latestmedia"
      if isValidAndNotEmpty(m.filteredLatest)
        for each lib in m.filteredLatest
          if lib.collectionType <> "boxsets" and lib.collectionType <> "livetv" and lib.collectionType <> "Program"
            orderedIds.push("latest_" + lib.id)
          end if
        end for
      end if
    else
      id = getSectionIdForType(section.type)
      if isValid(id) then orderedIds.push(id)
    end if
  end for

  ' Find where our sectionId falls in the ordered plan
  targetPlanIndex = -1
  for i = 0 to orderedIds.count() - 1
    if orderedIds[i] = sectionId
      targetPlanIndex = i
      exit for
    end if
  end for

  if targetPlanIndex = -1
    return m.top.content.getChildCount()
  end if

  ' Walk backward from our position to find the last preceding section
  ' that has a row in content — insert after it
  for i = targetPlanIndex - 1 to 0 step -1
    result = findRowBySectionId(orderedIds[i])
    if isValid(result)
      return result.index + 1
    end if
  end for

  return 0
end function

' ============================================
' DATA UPDATE CALLBACKS
' Each follows the same pattern:
' 1. Get data from task, unobserve + clear task
' 2. Find pre-created row by sectionId
' 3. If empty data → remove row, recalculate sizes
' 4. If data → replace skeleton with populated row
' 5. Update backdrop for focused item
' ============================================

sub updateContinueWatchingItems()
  itemData = m.LoadContinueWatchingTask.content
  m.LoadContinueWatchingTask.unobserveField("content")
  m.LoadContinueWatchingTask.content = []
  m.isLoadingResume = false

  populateRowFromData("resume", itemData)
end sub

sub updateNextUpItems()
  itemData = m.LoadNextUpTask.content
  m.LoadNextUpTask.unobserveField("content")
  m.LoadNextUpTask.content = []
  m.LoadNextUpTask.control = "STOP"
  m.isLoadingNextUp = false

  populateRowFromData("nextup", itemData)
end sub

sub updateOnNowItems()
  itemData = m.LoadOnNowTask.content
  m.LoadOnNowTask.unobserveField("content")
  m.LoadOnNowTask.content = []
  m.isLoadingOnNow = false

  populateRowFromData("livetv", itemData)
end sub

sub updateActiveRecordingsItems()
  itemData = m.LoadActiveRecordingsTask.content
  m.LoadActiveRecordingsTask.unobserveField("content")
  m.LoadActiveRecordingsTask.content = []
  m.isLoadingActiveRecordings = false

  populateRowFromData("activeRecordings", itemData)
end sub

' Fires when JRRowList's progress tick detects at least one expired Program
' or Recording. Re-runs LoadOnNowTask and/or LoadActiveRecordingsTask to pull
' fresh data. Loading guards debounce repeated expiry signals while a load is
' already in flight, and section plan checks avoid wasted requests when the
' user has disabled the relevant sections.
sub onProgramsExpired()
  if not isValidAndNotEmpty(m.sectionPlan) then return

  for each section in m.sectionPlan
    if section.type = "livetv" and not m.isLoadingOnNow
      m.isLoadingOnNow = true
      m.LoadOnNowTask.unobserveField("content")
      m.LoadOnNowTask.observeField("content", "updateOnNowItems")
      m.LoadOnNowTask.control = "RUN"
    else if section.type = "activerecordings" and not m.isLoadingActiveRecordings
      m.isLoadingActiveRecordings = true
      m.LoadActiveRecordingsTask.unobserveField("content")
      m.LoadActiveRecordingsTask.observeField("content", "updateActiveRecordingsItems")
      m.LoadActiveRecordingsTask.control = "RUN"
    end if
  end for
end sub

' updateLatestItems: Processes LoadItemsTask content for a latest-in-library row.
' Uses msg parameter because each library has its own dynamically created task.
'
' @param {dynamic} msg - roSGNodeEvent from the LoadItemsTask
sub updateLatestItems(msg)
  itemData = msg.GetData()
  node = msg.getRoSGNode()
  node.unobserveField("content")
  node.content = []

  populateRowFromData("latest_" + node.itemId, itemData)
end sub

' populateRowFromData: Unified row population logic used by all update callbacks.
' Updates the children of the existing row node in place to avoid RowList re-layout
' and focus disruption. Removes the row if data is empty. If the row doesn't exist
' but data is available (e.g., a previously empty section now has content on refresh),
' creates and inserts the row at the correct position.
'
' @param {string} sectionId - The sectionId of the target row
' @param {dynamic} itemData - Array of content nodes from the task, or invalid/empty
sub populateRowFromData(sectionId as string, itemData as dynamic)
  result = findRowBySectionId(sectionId)

  if not isValidAndNotEmpty(itemData)
    ' No data — remove the row if it exists
    if isValid(result)
      removeRowAtIndex(result.index)
    end if
    return
  end if

  if isValid(result)
    ' Row exists — update children in place. Append new items BEFORE removing old ones
    ' to avoid a momentary empty-row state that would cause the RowList to shift focus.
    row = result.row
    oldCount = row.getChildCount()

    for each item in itemData
      row.appendChild(item)
    end for

    ' Remove old items (now at indices 0..oldCount-1)
    for i = oldCount - 1 to 0 step -1
      row.removeChildIndex(i)
    end for
  else
    ' Row was previously removed (no data last time) — create and insert at correct position
    rowConfig = getRowConfigForSection(sectionId)
    if not isValid(rowConfig) then return

    row = CreateObject("roSGNode", "HomeRow")
    row.title = rowConfig.title
    row.sectionId = sectionId
    row.cursorSize = rowConfig.slotSize

    for each item in itemData
      row.appendChild(item)
    end for

    insertIndex = findInsertIndexForSection(sectionId)
    m.top.content.insertChild(row, insertIndex)
    setRowItemSize()
  end if

  updateBackdropForFocusedItem()
  onSectionPopulated()
end sub

' onSectionPopulated: Called after each section is populated with data.
' Signals AppLaunchComplete after 2 sections have loaded.
sub onSectionPopulated()
  m.populatedSectionCount++

  if not m.global.appLoaded and m.populatedSectionCount >= 2
    m.top.signalBeacon("AppLaunchComplete")
    m.global.appLoaded = true
  end if
end sub

' ============================================
' ROW SIZE CALCULATION
' ============================================

' setRowItemSize: Loops through all home sections and sets the correct item sizes, heights, and spacings per row.
' rowItemSize[i] = slot size [width, posterHeight] — determines focus ring dimensions (poster only, no text).
' rowHeights[i]  = total row height: slot + 90px text area for standard rows; slot-only for library tiles.
' rowSpacings[i] = gap after each row before the next row label. Must be set for ALL rows because Roku
'                  ignores itemSpacing entirely once rowSpacings is assigned (even partially). Standard rows
'                  use 60px; My Media uses 78px to partially compensate for its absent text area.
sub setRowItemSize()
  if not isValid(m.top.content) then return

  homeSections = m.top.content.getChildren(-1, 0)
  newSizeArray = CreateObject("roArray", homeSections.count(), false)
  newRowHeights = CreateObject("roArray", homeSections.count(), false)
  newRowSpacings = CreateObject("roArray", homeSections.count(), false)

  interRowSpacing = 60

  for i = 0 to homeSections.count() - 1
    section = homeSections[i]
    slotSize = isValid(section.cursorSize) ? section.cursorSize : rowSlotSize.WIDE
    newSizeArray[i] = slotSize

    if section.sectionId = "library"
      ' Library tiles render no text below the slot. rowHeight = slot only.
      ' rowSpacings is intentionally larger than standard rows — the next row's label
      ' lives inside the gap, giving a consistent visual distance to the label text.
      newRowHeights[i] = rowSlotSize.ROW_HEIGHT_LIBRARY
      newRowSpacings[i] = 78
    else
      ' Standard rows: text flows below the slot — infer total height from slot height
      slotHeight = slotSize[1]
      if slotHeight = rowSlotSize.PORTRAIT[1]
        newRowHeights[i] = rowSlotSize.ROW_HEIGHT_PORTRAIT
      else if slotHeight = rowSlotSize.SQUARE[1]
        newRowHeights[i] = rowSlotSize.ROW_HEIGHT_SQUARE
      else
        ' WIDE (264px) — default
        newRowHeights[i] = rowSlotSize.ROW_HEIGHT_WIDE
      end if
      newRowSpacings[i] = interRowSpacing
    end if
  end for

  m.top.rowItemSize = newSizeArray
  m.top.rowHeights = newRowHeights
  m.top.rowSpacings = newRowSpacings
end sub

' ============================================
' UPDATE / REFRESH
' ============================================

' updateHomeRows: Refresh data for all rows without tearing down the UI.
' Keeps existing row nodes in place to avoid focus disruption. As fresh data
' arrives, populateRowFromData updates row children in place.
sub updateHomeRows()
  ' Guard: if a library load is already in flight, skip to avoid stacking observers.
  ' No deferred re-run is needed — the in-flight load will deliver fresh data shortly.
  if not m.isLoadingLibraries
    m.isLoadingLibraries = true
    m.LoadLibrariesTask.unobserveField("content")
    m.LoadLibrariesTask.observeField("content", "onLibrariesLoaded")
    m.LoadLibrariesTask.control = "RUN"
  end if

  ' Re-fire non-library-dependent tasks
  startParallelLoads()
end sub

' ============================================
' ITEM SELECTION, FOCUS, AND KEY EVENTS
' ============================================

' Gets an item from content at specified row and item indices
' Performs all necessary bounds checking and validation
' @param {roArray} indices - [rowIndex, itemIndex]
' @return {dynamic} ContentNode if valid, invalid otherwise
function getItemAtIndices(indices as object) as dynamic
  if not isValidAndNotEmpty(indices) or not isValid(m.top.content)
    return invalid
  end if

  if indices[0] < 0 or indices[0] >= m.top.content.getChildCount()
    return invalid
  end if

  row = m.top.content.getChild(indices[0])
  if not isValid(row)
    return invalid
  end if

  if indices[1] < 0 or indices[1] >= row.getChildCount()
    return invalid
  end if

  return row.getChild(indices[1])
end function

sub itemSelected()
  item = getItemAtIndices(m.top.rowItemSelected)

  ' Ignore skeleton placeholder items
  if not isValid(item) or item.type = "Loading" then return

  m.top.selectedItem = item

  'Prevent the selected item event from double firing
  m.top.selectedItem = invalid
end sub

' Observer for rowItemFocused field - delegates to updateBackdropForFocusedItem
sub onItemFocused()
  updateTextureBufferRange(m.top.content, m.top.rowItemFocused[0], m.top.rowItemFocused[1], m.top.numRows)
  updateBackdropForFocusedItem()
end sub

' Update backdrop to match currently focused item
' Handles all validation and edge cases
' Used by: onItemFocused observer and row update functions after replaceChild
sub updateBackdropForFocusedItem()
  focusedItem = getItemAtIndices(m.top.rowItemFocused)

  ' Always call setBackgroundImage so the backdrop clears when the focused item has none.
  ' Passing "" explicitly removes the previous backdrop (photos, channels, etc.)
  backdropUrl = ""
  if isValid(focusedItem)
    deviceRes = m.global.device.uiResolution
    backdropUrl = getItemBackdropUrl(focusedItem, { width: deviceRes[0], height: deviceRes[1] })
  end if
  m.global.sceneManager.callFunc("setBackgroundImage", backdropUrl)
end sub

function onKeyEvent(key as string, press as boolean) as boolean
  if wrapRowFocus(key, press) then return true

  if press
    if key = "play"
      print "play was pressed from homerow"
      itemToPlay = getItemAtIndices(m.top.rowItemFocused)
      if isValid(itemToPlay) and itemToPlay.type <> "Loading"
        m.top.quickPlayNode = itemToPlay
        ' Clear immediately — same pattern as selectedItem. Prevents stale
        ' value from re-firing when the screen is restored after playback.
        m.top.quickPlayNode = invalid
      end if
      return true
    else if key = "replay"
      m.top.jumpToRowItem = [m.top.rowItemFocused[0], 0]
      return true
    end if
  end if
  return false
end function

' ============================================
' TEARDOWN
' ============================================

' onDestroy: Full teardown releasing all resources before component removal
' Called by Home.bs onDestroy() before nulling the homeRows reference
sub onDestroy()
  destroyTextureManager(m.top.content)

  ' Unobserve m.top fields
  m.top.unobserveField("rowItemSelected")
  m.top.unobserveField("rowItemFocused")
  m.top.unobserveField("programsExpired")

  ' Stop and release all persistent task nodes
  m.LoadLibrariesTask.unobserveField("content")
  m.LoadLibrariesTask.control = "STOP"
  m.LoadLibrariesTask = invalid

  m.LoadContinueWatchingTask.unobserveField("content")
  m.LoadContinueWatchingTask.control = "STOP"
  m.LoadContinueWatchingTask = invalid

  m.LoadNextUpTask.unobserveField("content")
  m.LoadNextUpTask.control = "STOP"
  m.LoadNextUpTask = invalid

  m.LoadOnNowTask.unobserveField("content")
  m.LoadOnNowTask.control = "STOP"
  m.LoadOnNowTask = invalid

  m.LoadActiveRecordingsTask.unobserveField("content")
  m.LoadActiveRecordingsTask.control = "STOP"
  m.LoadActiveRecordingsTask = invalid

  ' Stop and release any per-library latest media tasks
  cleanupLatestMediaTasks()

  ' Clear data caches
  m.libraryData = invalid
  m.filteredLatest = invalid
  m.sectionPlan = invalid

  ' Reset loading flags
  m.isLoadingLibraries = false
  m.isLoadingResume = false
  m.isLoadingNextUp = false
  m.isLoadingOnNow = false
  m.isLoadingActiveRecordings = false
end sub