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/home/latestRows.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/backdrop.bs"
import "pkg:/source/utils/itemImageUrl.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/rowListWrap.bs"
import "pkg:/source/utils/skeleton.bs"
import "pkg:/source/utils/tasks.bs"
import "pkg:/source/utils/textureManager.bs"
import "pkg:/source/utils/translate.bs"

sub init()
  ' Every m.log.* call in this file faulted with &hf3 without this — the component
  ' assumed a logger it never created. The one pre-existing caller sits on the
  ' stall-reclaim path, so it had simply never run.
  m.log = new log.Logger("HomeRows")
  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"

  m.rowSizesDirty = false
  m.rowSizesBatching = false
  m.pendingRowRemovals = []

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

  ' Single orchestrator services all latest-media rows — one Task thread at any library count
  m.latestRowsTask = createObject("roSGNode", "LoadLatestRowsTask")
  m.latestRowsProcessedIndex = 0
  ' Entries handed to the current run — the run is finished once the cursor reaches it
  m.latestRowsExpectedCount = 0
  ' Wall clock for the current run, read only to detect a run that outlived its own budget
  m.latestRowsRunClock = CreateObject("roTimespan")
  ' The render-thread half of a latest-rows run. The orchestrator's `notify` column
  ' is time IT spends parked inside a write this thread's observer is servicing, so
  ' these three are the same work seen from the owning side and must roughly sum to
  ' it. Kept permanent for the same reason the orchestrator's split is: a probe added
  ' later measures something subtly different and cannot be compared to the recorded
  ' baselines.
  '
  '   attach - the `appendChildren` call, on BOTH of populateRowFromData's branches. In
  '            practice the in-place branch dominates it: an append into the re-insert
  '            branch's still-detached row measures ~0. Where that work lands instead is
  '            not established, so read `attach` next to `total`, never on its own.
  '   detach - dropping the superseded children. In-place branch only, so detach ~= 0 is
  '            the signal that a run went through the re-insert branch instead.
  '   other  - row lookup, row creation + insertion, backdrop, section bookkeeping, and
  '            the no-data exit that removes a row.
  '
  ' These do NOT sum to `notify` exactly: `notify` is the whole observer callback, and
  ' drainReady plus the per-row `child.items = []` sit inside it but outside this function.
  ' Measured residual is 3-9%; a much larger gap means something else went missing.
  #if perfTiming
    m.popClock = CreateObject("roTimespan")
    m.popAttachMs = 0
    m.popDetachMs = 0
    m.popOtherMs = 0
    ' How many times the row-size walk ran, and how many observer wakes it was spread
    ' over. The pair is what makes a coalescing claim checkable: batching the walk at the
    ' drain boundary can only pay if a wake routinely carries more than one row. Measured
    ' on a Stick 4K it does not — 11 rows arrive over 11 wakes.
    m.popSizeCalls = 0
    m.popDrains = 0
    m.popSizeMs = 0
    m.sizeClock = CreateObject("roTimespan")
    ' WHICH of the two mid-run recomputes ran, because `calls` alone cannot say and the
    ' two have different fixes. `calls` is 1 on most launches — the end-of-run flush that
    ' applyPendingRowRemovals triggers — and 2 on a minority, and only these attribute
    ' that second call:
    '
    '   remove - a NON-latest section returned nothing and its row was dropped mid-run
    '            (latestRows.removalIsDeferrable refuses to defer anything else), so this
    '            counts a RACE: the section's task landing inside the run's window rather
    '            than outside it. The same removal on a different launch costs nothing here.
    '   insert - a section that had no row gained one (populateRowFromData's else branch).
    '
    ' The pair is a CHECKABLE decomposition, not decoration: `calls - remove - insert` is
    ' the end-of-run flush and must be 0 or 1 on every sample. It can break in BOTH
    ' directions and they have opposite causes, which is worth knowing because NOTHING
    ' COMPUTES IT TODAY - it is a subtraction over the ledger that an analyst has to run
    ' (`measurement-invariants-ungated` in tech-debt.md is the gap, and the harness is where
    ' it gets closed):
    '
    '   > 1  a recompute reached setRowItemSize() by a path neither counter tags.
    '   < 0  a counted call site did NOT reach it - setRowItemSize() returns early when
    '        m.top.content is invalid, and that return is BEFORE it increments `calls`.
    '        Unreachable today, since both sites dereference m.top.content first and an
    '        invalid one would already have faulted - but it is the direction a predicate
    '        written only against the case above would miss.
    '
    ' The window is the RUN, so `remove` sees only a removal landing between the run's start
    ' and its completion; one landing before OR after it is invisible here. That is a
    ' property of the instrument rather than of the app, and it is the blind spot the
    ' successor hypothesis in `home-first-paint-performance.md` turns on.
    m.popSizeRemove = 0
    m.popSizeInsert = 0
    ' The sectionIds behind those two counts, in the order they fired ("remove:livetv"),
    ' or "-" when only the end-of-run flush ran. `remove` and `insert` say a race happened;
    ' this says WHICH section lost it, which is the half a fix needs. Captured by
    ' `measurements.js` into the sample rather than left for a reader to find on the
    ' console — `measure.js` keeps console lines only when nothing matched, so on a healthy
    ' run they are discarded.
    m.popSizeAt = ""
    ' How many immediate row removals this HomeRows has made. Per INSTANCE, deliberately,
    ' and not per run: the counters above are the ones scoped to a run, and this exists to
    ' number the removals they cannot see. See logRowRemoved for what the number is for.
    m.popRemoveSeq = 0
  #end if

  ' 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
  m.isLoadingLatestRows = 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")
  launchTask(m.LoadLibrariesTask)

  ' 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

  rowStructureChanged()
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.
  row.appendChild(skeleton.createPlaceholder())

  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")
      launchTask(m.LoadContinueWatchingTask)
    else if section.type = "nextup"
      if m.isLoadingNextUp then continue for
      m.isLoadingNextUp = true
      m.LoadNextUpTask.unobserveField("content")
      m.LoadNextUpTask.observeField("content", "updateNextUpItems")
      launchTask(m.LoadNextUpTask)
    else if section.type = "livetv"
      if m.isLoadingOnNow then continue for
      m.isLoadingOnNow = true
      m.LoadOnNowTask.unobserveField("content")
      m.LoadOnNowTask.observeField("content", "updateOnNowItems")
      launchTask(m.LoadOnNowTask)
    else if section.type = "activerecordings"
      if m.isLoadingActiveRecordings then continue for
      m.isLoadingActiveRecordings = true
      m.LoadActiveRecordingsTask.unobserveField("content")
      m.LoadActiveRecordingsTask.observeField("content", "updateActiveRecordingsItems")
      launchTask(m.LoadActiveRecordingsTask)
    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()
    rowStructureChanged()
    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 the latest-media orchestrator
  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
      row.appendChild(skeleton.createPlaceholder())

      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: Hands the eligible libraries (in row order) to the orchestrator task.
'
' Skips outright while a run is in flight, matching the isLoading* rule updateHomeRows()
' already applies to the other persistent tasks: the running orchestrator is about to deliver
' fresh data, so restarting buys nothing and costs a cancel. It also keeps this off the one
' path nobody has measured — Home is SUSPENDED rather than destroyed on navigation
' (onScreenHidden stops no tasks), so a Home revisit lands here mid-run routinely, and
' restarting would mean setting control = "RUN" on a thread that may still be unwinding.
sub startLatestMediaLoads()
  if not isValidAndNotEmpty(m.filteredLatest) then return

  if m.isLoadingLatestRows
    if not latestRows.runIsStalled(m.latestRowsRunClock.totalMilliseconds()) then return
    ' Past its own budget the orchestrator thread is gone or wedged, so the flag would
    ' otherwise freeze these rows for the life of this Home. Reclaiming it is safe for the
    ' same reason: STOP here can only reach a thread that has already stopped running.
    m.log.warn("latest-rows run outlived its budget; reclaiming", m.latestRowsRunClock.totalMilliseconds())
  end if

  ' Ensure any previous run is stopped and its delivered results cleared
  resetLatestRowsRun()

  libs = []
  for each lib in m.filteredLatest
    if lib.collectionType <> "boxsets" and lib.collectionType <> "livetv" and lib.collectionType <> "Program"
      libs.push({ id: lib.id })
    end if
  end for
  if libs.count() = 0 then return

  m.latestRowsTask.libraries = libs
  m.latestRowsTask.observeField("rowReady", "onLatestRowsReady")
  m.latestRowsExpectedCount = libs.count()
  #if perfTiming
    m.popAttachMs = 0
    m.popDetachMs = 0
    m.popOtherMs = 0
    m.popSizeCalls = 0
    m.popDrains = 0
    m.popSizeMs = 0
    m.popSizeRemove = 0
    m.popSizeInsert = 0
    m.popSizeAt = ""
  #end if
  ' Hold the row-size recompute for the whole run, so a load pays one instead of one per
  ' library that returned nothing.
  '
  ' Two things about the boundary that the code cannot tell you:
  '
  '   - The RUN is the boundary, NOT the drain loop, which looks like one and is not: the
  '     orchestrator delivers one row per observer wake, so a per-drain flush coalesces
  '     nothing. `drains` in the perfTiming line is what keeps that checkable.
  '   - The batch owns only the rows this run delivers. latestRows.removalIsDeferrable is
  '     the seam; every other Home section stays eager, and the reasoning is there.
  '
  ' Why this shape, which arms were rejected on measurement, and what it is worth per RAM
  ' tier: decisions.md `home-row-removals-deferred` and
  ' tech-debt.md#home-row-size-recompute-per-row.
  m.rowSizesBatching = true
  m.isLoadingLatestRows = true
  m.latestRowsRunClock.mark()
  launchTask(m.latestRowsTask)
end sub

' onLatestRowsReady: Drains all unprocessed result children from the orchestrator.
' Each wake may cover several results — wake events can coalesce, the children can't.
sub onLatestRowsReady()
  if not isValid(m.latestRowsTask) then return

  ' The failure-vs-empty rule and the cursor arithmetic live in source/home/latestRows.bs
  ' so they can be unit-tested without a callFunc seam on this component.
  drained = latestRows.drainReady(m.latestRowsTask, m.latestRowsProcessedIndex)
  m.latestRowsProcessedIndex = drained.nextIndex
  #if perfTiming
    m.popDrains++
  #end if

  for each child in drained.ready
    populateRowFromData("latest_" + child.libId, child.items)
    ' The row owns the item nodes now — drop the orchestrator's reference so a long
    ' Home session doesn't retain every batch it has ever delivered.
    child.items = []
  end for

  ' A failed row is deliberately left alone — it keeps the skeleton placeholder
  ' insertLatestMediaSkeletons gave it and fills in place on the next refresh, so nothing
  ' shifts. See latestRows.drainReady for why clearing it would be worse.
  '
  ' apiPipeline yields every entry exactly once and emitRow appends one child per yield, so
  ' the cursor reaching the entry count means the run is over. Frees the guard without a
  ' second interface field or a read of the firmware-managed `state` transitions.
  if m.latestRowsProcessedIndex >= m.latestRowsExpectedCount
    m.isLoadingLatestRows = false
    #if perfTiming
      m.popClock.mark()
    #end if
    m.rowSizesBatching = false
    applyPendingRowRemovals()
    flushRowSizes()
    #if perfTiming
      m.popOtherMs += m.popClock.totalMilliseconds()
    #end if
    ' "No slower first paint than today" is a success criterion of the task-thread work,
    ' and this run IS Home's first paint for latest media. Log the wall clock so a
    ' regression is a number someone can compare, not a feeling. Stripped in prod builds.
    m.log.info("latest-rows run complete", m.latestRowsExpectedCount, "rows", m.latestRowsRunClock.totalMilliseconds(), "ms")
    #if perfTiming
      ' These three are the render-thread work the orchestrator's `notify` column is
      ' blocked inside, so they must sum to roughly that figure.
      m.log.info("latest-rows populate split", "attach", m.popAttachMs, "detach", m.popDetachMs, "other", m.popOtherMs)
      ' Separate line, not more arguments on the one above: m.log.* faults at RUNTIME past
      ' nine call-site arguments and drops the app into the BrightScript debugger.
      m.log.info("latest-rows size recompute", "calls", m.popSizeCalls, "drains", m.popDrains, "ms", m.popSizeMs)
      ' Same nine-argument ceiling, so the attribution is its own line rather than four
      ' more arguments above.
      sizeAt = m.popSizeAt
      if sizeAt = "" then sizeAt = "-"
      m.log.info("latest-rows size recompute by", "remove", m.popSizeRemove, "insert", m.popSizeInsert, "at", sizeAt)
    #end if
  end if
end sub

' resetLatestRowsRun: Return the orchestrator to a clean pre-run state — unobserved, stopped,
' its delivered result children dropped and the drain cursor rewound.
'
' Called before starting a run and from onDestroy. Named for the run rather than the task
' (the old cleanupLatestMediaTasks) because there is exactly one task now; what gets reset is
' the run's state around it.
sub resetLatestRowsRun()
  if not isValid(m.latestRowsTask) then return

  m.latestRowsTask.unobserveField("rowReady")
  m.latestRowsTask.control = "STOP"
  m.latestRowsTask.removeChildrenIndex(m.latestRowsTask.getChildCount(), 0)
  m.latestRowsProcessedIndex = 0
  m.latestRowsExpectedCount = 0
  m.isLoadingLatestRows = false

  ' Close the row-size batch too. A run that is stopped or reclaimed never reaches its
  ' completion branch, so without this the batch flag would stay set and the latest rows it
  ' still had queued would never be dropped.
  '
  ' Note WHEN this fires: the next startLatestMediaLoads (which is where runIsStalled is
  ' consulted) or onDestroy. There is no timer. So an orchestrator that wedges while the user
  ' sits on Home holds the queue until they navigate away or Home reloads, and the queued rows
  ' keep their skeletons for that whole time. Those rows are the ones that DID answer, empty —
  ' before the batch they would already have been removed, so this is a real difference and not
  ' a no-op. Accepted rather than overlooked: it needs a run to wedge, and the result is the
  ' same skeleton a library whose request FAILED already leaves on screen.
  m.rowSizesBatching = false
  applyPendingRowRemovals()
  flushRowSizes()
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
'
' Recomputes IMMEDIATELY, batch or no batch. This path is only reached for a row the current
' run does not own (latestRows.removalIsDeferrable said no), so nothing later is going to
' flush on its behalf — and a removal without its recompute leaves the three geometry arrays
' describing the old row list, which draws every row below it at its neighbour's size.
'
' @param {integer} index - Content child index to remove
sub removeRowAtIndex(index as integer)
  m.top.content.removeChildIndex(index)
  rowStructureChanged(true)
end sub

' logRowRemoved: Publish an immediate row removal AT THE INSTANT IT HAPPENS.
'
' Its own line and its own moment, because `sizeRemove` cannot answer the question it was
' built for: those counters are zeroed at run start and read at run end, so a removal
' landing outside that window increments nothing anybody reports — and on this server the
' removal is unconditional, which makes `sizeRemove` a race outcome rather than an
' occurrence count. Why it is unconditional is a four-step chain through session.bs,
' createSkeletonRow, startParallelLoads and removalIsDeferrable; it is written out once, in
' `docs/dev/home-first-paint-performance.md` ("`row removed`"), rather than twice.
'
' What a change to this function has to preserve:
'
'   - The ORDINAL on `at`, and that it is per HomeRows INSTANCE. `assembleSamples` closes a
'     sample when a line it already holds repeats, so a second immediate removal in one
'     launch splits that launch across two samples. The ordinal is what makes the split
'     visible in the record instead of a silent short read.
'   - BOTH halves of `cells`, and that both are signed. `loadsStarted` is the discriminator
'     and `binds` is the postcondition on it — `loadsStarted 0` is what a genuinely early
'     removal prints AND what a probe reading a counter-less root would print, so dropping
'     `binds` makes those one reading. The `-1` guard cannot fire in HomeRows today (init
'     creates `m.top.content` and attaches the counters to that same node in the same init),
'     but it is nearly free and the alternative to printing it is dropping the line, which
'     reads as "no removal happened" — the one wrong answer.
'   - The argument COUNT. roku-log faults at RUNTIME (&hf1, straight into the debugger) past
'     nine call-site arguments once the BSC plugin has spent one on the injected pkg path.
'     Three label/value pairs with two composed values sits a pair clear of that; nine is
'     legal and this line would fit, but a probe whose overflow costs a whole device run is
'     not worth writing at the ceiling.
'
' @param {string} sectionId - the sectionId of the row being removed
sub logRowRemoved(sectionId as string)
  #if perfTiming
    m.popRemoveSeq++

    loads = -1
    binds = -1
    if isValid(m.top.content) and m.top.content.hasField("cellLoadBinds")
      loads = m.top.content.cellLoadLoadsStarted
      binds = m.top.content.cellLoadBinds
    end if

    m.log.info("latest-rows row removed", "at", sectionId + "#" + m.popRemoveSeq.toStr(), "cells", loads.toStr() + "/" + binds.toStr(), "run", m.latestRowsProcessedIndex.toStr() + "/" + m.latestRowsExpectedCount.toStr())
  #end if
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")
      launchTask(m.LoadOnNowTask)
    else if section.type = "activerecordings" and not m.isLoadingActiveRecordings
      m.isLoadingActiveRecordings = true
      m.LoadActiveRecordingsTask.unobserveField("content")
      m.LoadActiveRecordingsTask.observeField("content", "updateActiveRecordingsItems")
      launchTask(m.LoadActiveRecordingsTask)
    end if
  end for
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)
  ' Only the latest-media rows are timed; every other Home section shares this
  ' function and would pollute the totals.
  timed = false
  #if perfTiming
    timed = sectionId.startsWith("latest_")
    if timed then m.popClock.mark()
  #end if

  result = findRowBySectionId(sectionId)

  if not isValidAndNotEmpty(itemData)
    ' No data — remove the row if it exists.
    '
    ' A row the current run delivers is QUEUED instead, and dropped once the run ends.
    ' Deferring only the SIZE recompute is not enough: the row list would shrink while the
    ' three geometry arrays still described the old one, so every row below the removal
    ' rendered at its neighbour's size — measured through ODC, not reasoned. Holding the row
    ' keeps tree and arrays in step for the whole run, and it is what a FAILED row already
    ' does: it keeps the skeleton insertLatestMediaSkeletons gave it.
    '
    ' WHICH rows may be held is latestRows.removalIsDeferrable's call, not this branch's —
    ' every other Home section removes and recomputes immediately, mid-run or not.
    if isValid(result)
      if latestRows.removalIsDeferrable(sectionId, m.rowSizesBatching)
        m.pendingRowRemovals.push(sectionId)
      else
        #if perfTiming
          m.popSizeRemove++
          if m.popSizeAt <> "" then m.popSizeAt += ","
          m.popSizeAt += "remove:" + sectionId
          logRowRemoved(sectionId)
        #end if
        removeRowAtIndex(result.index)
      end if
    end if
    ' Accumulate before returning, not just on the fall-through path. A library that
    ' holds nothing takes this exit on every run, and the three columns are only
    ' trustworthy while they still sum to the orchestrator's `notify`.
    #if perfTiming
      if timed then m.popOtherMs += m.popClock.totalMilliseconds()
    #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()

    #if perfTiming
      if timed
        m.popOtherMs += m.popClock.totalMilliseconds()
        m.popClock.mark()
      end if
    #end if

    ' ONE call, not one per item. The loop this replaced paid ~4.8 ms per item on a Stick
    ' 4K: 849 -> 328 ms of attach, Home first paint 2646 -> 2129 ms over 11 rows.
    '
    ' WHY it costs that is NOT established, so don't restate it as a thread rendezvous:
    ' this function runs on the render thread, and Roku documents render-thread operations
    ' as not rendezvousing (DEVELOPER/core-concepts/threads.md). What is measured is only
    ' that the cost scales with the number of append CALLS, and that it appears only when
    ' the target row is already in the live tree — see the else branch below.
    row.appendChildren(itemData)

    #if perfTiming
      if timed
        m.popAttachMs += m.popClock.totalMilliseconds()
        m.popClock.mark()
      end if
    #end if

    ' Remove old items (now at indices 0..oldCount-1)
    for i = oldCount - 1 to 0 step -1
      row.removeChildIndex(i)
    end for

    #if perfTiming
      if timed
        m.popDetachMs += m.popClock.totalMilliseconds()
        m.popClock.mark()
      end if
    #end if
  else
    ' Row was previously removed (no data last time) — create and insert at correct position
    rowConfig = getRowConfigForSection(sectionId)
    if not isValid(rowConfig)
      #if perfTiming
        if timed then m.popOtherMs += m.popClock.totalMilliseconds()
      #end if
      return
    end if

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

    #if perfTiming
      if timed
        m.popOtherMs += m.popClock.totalMilliseconds()
        m.popClock.mark()
      end if
    #end if

    ' Same single call as the in-place branch above. Note this row is NOT in m.top.content
    ' yet: appending into a still-detached row measured ~0 ms, against ~315 ms for the
    ' identical items into a live row. So unlike the in-place branch this was never the hot
    ' spot. Where that work goes instead is NOT established — forcing every row down this
    ' branch made the whole load slower, so building detached is not a shortcut to copy.
    ' Written the same way so the function has ONE attach mechanism to reason about.
    ' Same build-then-attach shape as ConfigList.setData.
    row.appendChildren(itemData)

    #if perfTiming
      if timed
        m.popAttachMs += m.popClock.totalMilliseconds()
        m.popClock.mark()
      end if
    #end if

    insertIndex = findInsertIndexForSection(sectionId)
    m.top.content.insertChild(row, insertIndex)
    #if perfTiming
      m.popSizeInsert++
      if m.popSizeAt <> "" then m.popSizeAt += ","
      m.popSizeAt += "insert:" + sectionId
    #end if
    ' Recompute NOW even mid-run. An insertion makes the row list longer than the geometry
    ' arrays, which is the same wrong-size-per-row defect deferring removals avoids, only
    ' in the other direction. Removals are what there are many of (one per empty library,
    ' every load); this branch fires only when a library that was empty last load has data
    ' again, so flushing it eagerly costs a recompute that is rare rather than N of them.
    rowStructureChanged(true)
  end if

  updateBackdropForFocusedItem()
  onSectionPopulated()

  #if perfTiming
    if timed then m.popOtherMs += m.popClock.totalMilliseconds()
  #end if
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
' ============================================

' applyPendingRowRemovals: Drop the rows whose libraries returned nothing, queued during a
' run so the row list and the geometry arrays never disagree mid-load. Looked up by
' sectionId rather than a stored index — each removal shifts the ones after it.
sub applyPendingRowRemovals()
  if not isValidAndNotEmpty(m.pendingRowRemovals) then return

  removed = 0
  for each sectionId in m.pendingRowRemovals
    result = findRowBySectionId(sectionId)
    if isValid(result)
      m.top.content.removeChildIndex(result.index)
      removed++
    end if
  end for
  m.pendingRowRemovals = []

  ' Only announce a structural change if one happened. Every queued id has a live row today
  ' — the orchestrator yields each library once, so nothing can drop one out from under the
  ' queue — but rowStructureChanged() costs a full recompute, and it should never be spent
  ' claiming an edit that did not occur. The guard is what keeps that true if the queue is
  ' ever widened past the run's own rows.
  if removed > 0 then rowStructureChanged()
end sub

' discardRowBatch: Drop pending row work without paying for it.
'
' Teardown only. setRowItemSize() is the most expensive call in this file (~90-200 ms, nearly
' all of it the RowList re-measuring every cell against the new rowItemSize) and it runs on the
' render thread — so flushing a batch for a node that is about to be released spends that on
' geometry nobody will see. Clearing the state first makes resetLatestRowsRun's
' applyPendingRowRemovals + flushRowSizes no-op.
'
' WHEN this is reached, because the obvious answer is wrong: NOT on navigating away from Home.
' Home is SUSPENDED rather than destroyed by the router (see startLatestMediaLoads, which relies
' on that). HomeRows.onDestroy comes from Home.destroyActiveContent() — a tab switch to
' Favorites — and from Home.onDestroy when sgRouter permanently closes the view. So the way to
' force a run in flight into this path is to switch tabs during a Home load.
sub discardRowBatch()
  m.pendingRowRemovals = []
  m.rowSizesDirty = false
  m.rowSizesBatching = false
end sub

' rowStructureChanged: Announce that a row was added to or removed from m.top.content.
'
' Outside a batch this recomputes immediately, so the single-row callers are unchanged.
'
' Don't go looking for the deferred path — no caller reaches it. Every mid-run caller passes
' `immediate` (it MUST, or the geometry arrays stop describing the row list) and the rest only
' run while m.rowSizesBatching is already false, so m.rowSizesDirty is false at both batch
' boundaries and flushRowSizes() there is a no-op. What actually coalesces a run into one
' recompute is m.pendingRowRemovals.
'
' m.rowSizesDirty is a FAIL-SAFE, not scaffolding for a future feature: a new mid-run caller
' that forgets `immediate` recomputes at the batch boundary instead of never. That turns the
' failure from wrong geometry — rows drawn at their neighbour's size, which produces no error,
' no log line and no timing change — into geometry that is merely late. Deleting it makes that
' mistake silent, which is the one failure mode in this file nothing else catches.
sub rowStructureChanged(immediate = false as boolean)
  m.rowSizesDirty = true
  if immediate or not m.rowSizesBatching then flushRowSizes()
end sub

' flushRowSizes: Recompute if a structural change is still pending. No-op otherwise, so it
' is safe to call unconditionally at a batch boundary.
sub flushRowSizes()
  if not m.rowSizesDirty then return
  m.rowSizesDirty = false
  setRowItemSize()
end sub

' 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.
'
' Reach for rowStructureChanged() rather than this — it is the coalescing seam, and this is
' expensive enough (~85 ms early in a load, ~200 ms once every row is populated) that how
' many times it runs is the whole cost model.
sub setRowItemSize()
  if not isValid(m.top.content) then return

  #if perfTiming
    m.popSizeCalls++
    m.sizeClock.mark()
  #end if

  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

  #if perfTiming
    m.popSizeMs += m.sizeClock.totalMilliseconds()
  #end if
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")
    launchTask(m.LoadLibrariesTask)
  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 skeleton.isPlaceholder(item) 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()
  ' Don't clobber the foreground view's backdrop when Home is no longer the active routed view. On a
  ' cold deep-link launch (Home -> ItemDetails), Home's rows finish loading LATE and this observer can
  ' fire after ItemDetails is showing; without this guard the shared global backdrop (last-writer-wins)
  ' gets blanked out under the deep-linked item. See backgroundWriteIsStale (source/utils/backdrop.bs).
  active = m.global.activeRoutedView
  activeSubtype = invalid
  if isValid(active) then activeSubtype = active.subtype()
  if backgroundWriteIsStale(activeSubtype, "Home") then return

  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 not skeleton.isPlaceholder(itemToPlay)
        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

  ' Drop any held row work BEFORE the reset — see discardRowBatch for why a teardown must not
  ' pay for a recompute. This makes the reset's own flush a no-op rather than a render stall
  ' on the way out of Home.
  discardRowBatch()

  ' Stop and release the latest-media orchestrator
  resetLatestRowsRun()
  m.latestRowsTask = invalid

  ' 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