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/homeSlots.bs"
import "pkg:/source/constants/itemAspectRatio.bs"
import "pkg:/source/home/homeRowFailure.bs"
import "pkg:/source/home/homeScreenLoad.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/screenReadiness.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"

' sectionId of the row that stands in for the latest-media rows when the FIRST libraries load
' fails and the layout has no My Media row to show it (homeRowFailure.needsLatestMediaStandIn).
' Not "latest_"-prefixed on purpose: it is no library's row, and latestRows treats that prefix
' as one.
const LATEST_STAND_IN_ID = "latestStandIn"

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 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")

  ' One LoadItemsTask per section load, a new node per run (see "Per-section loads");
  ' invalid until the section first loads, and again once its run has delivered.
  m.LoadLibrariesTask = invalid
  m.LoadContinueWatchingTask = invalid
  m.LoadNextUpTask = invalid
  m.LoadOnNowTask = invalid
  m.LoadActiveRecordingsTask = invalid

  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.
  ' A new node per run, created by startLatestMediaLoads; invalid until the first run.
  m.latestRowsTask = invalid
  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, for the run-complete log
  m.latestRowsRunClock = CreateObject("roTimespan")
  ' Time since the current run started or last delivered a row (latestRows.runIsStalled)
  m.latestRowsSilenceClock = CreateObject("roTimespan")
  ' Library ids of failed rows the viewer asked to retry while a run was going: they show the
  ' spinner now and load when that run ends (retryFailedLatestRows / retryOwedLatestRows)
  m.latestRetryOwed = []
  ' 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 don't 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 was 3-9% on 2026-08-09; 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
    ' 2026-08-19 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 didn't 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
    ' logRowRemoved exists to cover.
    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 on purpose,
    ' 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

  ' One run per section at a time: a refresh that lands mid-run skips that section
  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()
  ' One ledger per HomeRows instance: this runs once per instance (Home's first show, or a
  ' tab switch back to Home, which mounts a fresh HomeRows). Every row declares a fill when
  ' its skeleton is created and resolves when its data lands; paint is when the visible span
  ' has landed (see checkVisibleRowsFilled), settled when every row has.
  screenLoad.begin("homeRows")
  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]

  startLibrariesLoad()

  ' 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 the homeSection settings (slots 0 to homeSlots.SLOT_COUNT - 1), 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 homeSlots.SLOT_COUNT - 1
    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)
      screenLoad.pending(row.sectionId)
    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.
'
' Launches in the user's layout order. The loop may call a helper more than once per refresh
' only if a type repeats; each helper's own isLoading* guard holds its section to one run, and
' states so at its launch: no-task-fanout follows these calls and fails the build otherwise.
sub startParallelLoads()
  for each section in m.sectionPlan
    if section.type = "resume"
      startResumeLoad()
    else if section.type = "nextup"
      startNextUpLoad()
    else if section.type = "livetv"
      startOnNowLoad()
    else if section.type = "activerecordings"
      startActiveRecordingsLoad()
    end if
  end for
end sub

' ── Per-section loads ───────────────────────────────────────────────────────
'
' Each run gets a NEW LoadItemsTask node (replaceTask, ADR 0037), and its handler releases it
' on delivery. Relaunching one node kept for Home's whole life is not reliable: LoadItemsTask
' writes `content` as its last statement, so the handler clears the isLoading* flag while the
' node can still read `run`, and a launch that lands before the function returns is ignored.
' Nothing then delivers, the flag stays set, and every later refresh skips that section for
' the life of this HomeRows.
'
' The isLoading* skip stays: a refresh that lands mid-run lets the running load finish rather
' than discarding a fetch already in flight. So a section never has more than one run.
'
' Releasing on delivery stops the run where it is, so it relies on `content` being
' LoadItemsTask's last write: anything after it would not run (said at the write, too).
'
' The flag takes launchTask's result because a launch refused at the thread watermark starts
' nothing and delivers nothing: a flag set regardless would never clear, and no later refresh
' would retry that section.

sub startLibrariesLoad()
  if m.isLoadingLibraries then return
  m.LoadLibrariesTask = replaceTask(m.LoadLibrariesTask, "LoadItemsTask", "content", { itemsToLoad: "libraries" })
  m.LoadLibrariesTask.observeField("content", "onLibrariesLoaded")
  m.isLoadingLibraries = launchTask(m.LoadLibrariesTask)
  if m.isLoadingLibraries
    showRowRetrying("library")
    showRowRetrying(LATEST_STAND_IN_ID)
  end if
end sub

sub startResumeLoad()
  if m.isLoadingResume then return
  m.LoadContinueWatchingTask = replaceTask(m.LoadContinueWatchingTask, "LoadItemsTask", "content", { itemsToLoad: "continue" })
  m.LoadContinueWatchingTask.observeField("content", "updateContinueWatchingItems")
  ' bsc-disable-next-line no-task-fanout one run at a time: returns above while m.isLoadingResume
  m.isLoadingResume = launchTask(m.LoadContinueWatchingTask)
  if m.isLoadingResume then showRowRetrying("resume")
end sub

sub startNextUpLoad()
  if m.isLoadingNextUp then return
  m.LoadNextUpTask = replaceTask(m.LoadNextUpTask, "LoadItemsTask", "content", { itemsToLoad: "nextUp" })
  m.LoadNextUpTask.observeField("content", "updateNextUpItems")
  ' bsc-disable-next-line no-task-fanout one run at a time: returns above while m.isLoadingNextUp
  m.isLoadingNextUp = launchTask(m.LoadNextUpTask)
  if m.isLoadingNextUp then showRowRetrying("nextup")
end sub

sub startOnNowLoad()
  if m.isLoadingOnNow then return
  m.LoadOnNowTask = replaceTask(m.LoadOnNowTask, "LoadItemsTask", "content", { itemsToLoad: "onNow" })
  m.LoadOnNowTask.observeField("content", "updateOnNowItems")
  ' bsc-disable-next-line no-task-fanout one run at a time: returns above while m.isLoadingOnNow
  m.isLoadingOnNow = launchTask(m.LoadOnNowTask)
  if m.isLoadingOnNow then showRowRetrying("livetv")
end sub

sub startActiveRecordingsLoad()
  if m.isLoadingActiveRecordings then return
  m.LoadActiveRecordingsTask = replaceTask(m.LoadActiveRecordingsTask, "LoadItemsTask", "content", { itemsToLoad: "activeRecordings" })
  m.LoadActiveRecordingsTask.observeField("content", "updateActiveRecordingsItems")
  ' bsc-disable-next-line no-task-fanout one run at a time: returns above while m.isLoadingActiveRecordings
  m.isLoadingActiveRecordings = launchTask(m.LoadActiveRecordingsTask)
  if m.isLoadingActiveRecordings then showRowRetrying("activeRecordings")
end sub

' onLibrariesLoaded: Handler when LoadLibrariesTask returns data.
' Populates the library row, creates latestmedia skeleton rows, then fires latest tasks.
sub onLibrariesLoaded(event as object)
  if not isCurrentTaskEvent(event, m.LoadLibrariesTask) then return
  status = m.LoadLibrariesTask.status
  libraryData = m.LoadLibrariesTask.content
  m.LoadLibrariesTask = releaseTask(m.LoadLibrariesTask, "content")
  m.isLoadingLibraries = false
  noteSectionResult("library", status)

  if not homeRowFailure.isAnswered(status)
    ' The libraries did not answer, so nothing built from them may change: My Media keeps
    ' what it shows (or shows its failed tile), and on a refresh the latest-media rows keep
    ' theirs. On the FIRST load there are no latest-media rows yet, and m.initialLoadComplete
    ' stays false, so the answer that eventually lands (a return to Home, or OK on the failed
    ' tile) builds them exactly as a first load does, rather than each arriving later as a
    ' re-insert, which shifts the rows below it once per library.
    showRowFailed("library")
    if not m.initialLoadComplete
      showLatestMediaStandIn()
      ' The first answer is what normally activates texture management (below), and this one
      ' may never come. Left in "init", cells freeze their texture decisions, so a row
      ' scrolled into view keeps whatever it had. Home's own launch already activates it
      ' before the libraries answer (Home.onScreenShown); a tab switch mounts a fresh HomeRows
      ' and does not, so this covers that case.
      updateTextureBufferRange(m.top.content, m.top.rowItemFocused[0], m.top.rowItemFocused[1], m.top.numRows)
      activateTextureManager(m.top.content)
    end if
    ' The wait for the libraries is over either way; an open fill would leave Home unsettled.
    resolveRowFill("library")
    return
  end if

  m.libraryData = libraryData

  ' 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
    ' A failed first load may have left a stand-in where these rows go. Its removal needs no
    ' recompute of its own: the one right below covers both.
    removeLatestMediaStandIn()
    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 (loadLibraries)
    ' ran before the latest-media rows were inserted, so its range describes only the
    ' skeleton rows, or is [-1,-1,-1,-1] when the layout had none.
    updateTextureBufferRange(m.top.content, m.top.rowItemFocused[0], m.top.rowItemFocused[1], m.top.numRows)
    activateTextureManager(m.top.content)

    ' The first moment the visible span describes the final row layout, so the first moment
    ' paint may be decided. Rows that already landed before the libraries did (Continue
    ' Watching, Next Up, the library row filled above) are counted from here.
    checkVisibleRowsFilled()
  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()
  ' Only reached with an ANSWERED libraries load, so an empty list is the truth (the server
  ' has no libraries to show, or the viewer excluded every one) and removes the row, as any
  ' answered, empty section does. (Before failures were told apart, an empty list kept the
  ' row's spinner up forever, because it could also have meant "no answer".)
  filteredMedia = filterNodeArray(m.libraryData, "id", m.global.user.config.myMediaExcludes)
  populateRowFromData("library", filteredMedia)
  ' The libraries answer has landed; a fill left open here would leave Home unsettled forever.
  resolveRowFill("library")
end sub

' showLatestMediaStandIn: Put one failed "Recently Added" row where the latest-media rows go,
' when the first libraries load failed and the layout has no My Media row to say so
' (homeRowFailure.needsLatestMediaStandIn). Without it, a layout like that shows no sign the
' load failed: the latest-media rows are missing. Pressing OK on it retries like any
' failed tile; the next answered libraries load removes it (onLibrariesLoaded).
sub showLatestMediaStandIn()
  if isValid(findRowBySectionId(LATEST_STAND_IN_ID))
    showRowFailed(LATEST_STAND_IN_ID)
    return
  end if
  if not homeRowFailure.needsLatestMediaStandIn(m.sectionPlan, isValid(findRowBySectionId("library"))) then return

  row = CreateObject("roSGNode", "HomeRow")
  row.title = translate(translationKeys.LabelRecentlyAdded)
  row.sectionId = LATEST_STAND_IN_ID
  row.cursorSize = rowSlotSize.WIDE
  row.appendChild(skeleton.createFailedPlaceholder())
  m.top.content.insertChild(row, findLatestMediaInsertIndex())
  rowStructureChanged()
end sub

' removeLatestMediaStandIn: Drop the stand-in, if there is one. Leaves the row-size recompute to
' the caller, which is about to insert the real latest-media rows and recompute once for both.
sub removeLatestMediaStandIn()
  result = findRowBySectionId(LATEST_STAND_IN_ID)
  if not isValid(result) then return
  m.top.content.removeChildIndex(result.index)
  m.rowSizesDirty = true
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 (for example, 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)
      screenLoad.pending(sectionId)
      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 section loads: the running orchestrator is about to deliver
' fresh data, so restarting buys nothing and costs a cancel. Home is SUSPENDED rather than
' destroyed on navigation (onScreenHidden stops no tasks), so a Home revisit lands here
' mid-run routinely.
'
' Every run gets a NEW node (see replaceTask in source/utils/tasks.bs): a stop-and-relaunch of
' the same node while its function still runs is sometimes silently ignored, and then no row
' would ever load.
'
' @param {dynamic} onlyLibIds - library ids to load, for a retry of failed rows; invalid (the
'   default) loads every latest row
' @return {boolean} true when a run started
function startLatestMediaLoads(onlyLibIds = invalid as dynamic) as boolean
  if not isValidAndNotEmpty(m.filteredLatest) then return false

  if m.isLoadingLatestRows
    if not latestRows.runIsStalled(m.latestRowsSilenceClock.totalMilliseconds()) then return false
    ' Silent past its whole wait budget, the orchestrator is wedged (possibly still parked
    ' inside a wait with its node reading `run`), so the flag would otherwise freeze these rows
    ' for the life of this Home. Reclaiming releases that run (resetLatestRowsRun): a stopped
    ' run makes no further progress, and any event it already queued is ignored by
    ' onLatestRowsReady.
    m.log.warn("latest-rows run went silent past its budget; reclaiming", m.latestRowsSilenceClock.totalMilliseconds())
  end if

  ' Release any previous run; its delivered results go with its node
  resetLatestRowsRun()

  libs = latestRows.runLibraries(m.filteredLatest, onlyLibIds)
  if libs.count() = 0 then return false

  ' resetLatestRowsRun already released the previous node, so this only creates the new one.
  m.latestRowsTask = replaceTask(m.latestRowsTask, "LoadLatestRowsTask", "rowReady", { 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()
  ' A refused launch starts nothing, so a failed tile keeps saying so rather than spinning.
  if not launchTask(m.latestRowsTask) then return false
  for each lib in libs
    showRowRetrying("latest_" + lib.id)
  end for
  ' A run of every row loads the owed ones too.
  if not isValid(onlyLibIds) then m.latestRetryOwed = []
  return true
end function

' retryFailedLatestRows: OK on a failed tile reloads every latest-media row showing one NOW,
' from the libraries Home already has, rather than after Home's libraries request answers
' (which may fail again). Each row shows the spinner from the moment its reload starts until
' that reload answers.
'
' A run in flight cannot take more rows, so those rows are OWED: they show the spinner now and
' load when the run ends (retryOwedLatestRows). A run gone silent past its budget is reclaimed
' with every row, as a refresh reclaims it: a run of only the failed rows would drop the rows
' the reclaimed run was still loading.
sub retryFailedLatestRows()
  if not isValidAndNotEmpty(m.filteredLatest) then return
  failedLibIds = []
  for each lib in m.filteredLatest
    result = findRowBySectionId("latest_" + lib.id)
    if isValid(result) and homeRowFailure.placeholderState(result.row) = homeRowFailure.PLACEHOLDER_FAILED
      failedLibIds.push(lib.id)
    end if
  end for
  if failedLibIds.count() = 0 then return

  if m.isLoadingLatestRows
    if latestRows.runIsStalled(m.latestRowsSilenceClock.totalMilliseconds())
      startLatestMediaLoads()
      return
    end if
    for each libId in failedLibIds
      m.latestRetryOwed.push(libId)
      showRowRetrying("latest_" + libId)
    end for
    return
  end if

  startLatestMediaLoads(failedLibIds)
end sub

' retryOwedLatestRows: The run the owed rows waited for has ended, so load them. If no run can
' start, nothing will answer them, so they go back to the failed tile rather than spin.
sub retryOwedLatestRows()
  if m.latestRetryOwed.count() = 0 then return
  owed = m.latestRetryOwed
  m.latestRetryOwed = []
  if startLatestMediaLoads(owed) then return
  for each libId in owed
    showRowFailed("latest_" + libId)
  end for
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(event as object)
  if not isCurrentTaskEvent(event, m.latestRowsTask)
    ' An invalid m.latestRowsTask is teardown. Otherwise this wake was queued by a run that has
    ' since been released, and m.latestRowsTask is the NEW run's node, not this event's (see
    ' isCurrentTaskEvent). WARN because it is the only field evidence that such an event arrives.
    if isValid(m.latestRowsTask) then m.log.warn("superseded latest-rows wake dropped", event.getData())
    return
  end if

  ' 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.
  drainStart = m.latestRowsProcessedIndex
  drained = latestRows.drainReady(m.latestRowsTask, m.latestRowsProcessedIndex, m.latestRowsSilenceClock)
  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, so drop the orchestrator's reference so a long
    ' Home session doesn't retain every batch it has ever delivered.
    child.items = []
    noteSectionResult("latest_" + child.libId, "ok")
  end for
  for each libId in drained.failed
    showRowFailed("latest_" + libId)
    noteSectionResult("latest_" + libId, "failed")
  end for

  ' Every child the drain walked past has landed, FAILED ones included: drainReady skips
  ' those so their skeleton stays, but the wait for them is over and an open fill would
  ' leave Home unsettled forever. Walked here rather than returned by drainReady so a
  ' release build does no extra work for the instrument.
  #if perfTiming
    for i = drainStart to drained.nextIndex - 1
      delivered = m.latestRowsTask.getChild(i)
      if isValid(delivered) then resolveRowFill("latest_" + delivered.libId)
    end for
  #end if

  ' A failed row is never removed: it keeps its items, or its placeholder turns into the
  ' failed tile in place, and fills in place on the next load, 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
    ' The deferred removals have shifted rows up, possibly into the visible span.
    checkVisibleRowsFilled()
    ' "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
    ' Failed rows the viewer retried while this run was going load now.
    retryOwedLatestRows()
  end if
end sub

' resetLatestRowsRun: Return to a clean pre-run state: the orchestrator's node released
' (unobserved and stopped; its delivered result children go with it) 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()
  m.latestRowsTask = releaseTask(m.latestRowsTask, "rowReady")
  m.latestRowsProcessedIndex = 0
  m.latestRowsExpectedCount = 0
  m.isLoadingLatestRows = false
  ' Every launch comes through here first, so the next run's silence starts at its launch.
  m.latestRowsSilenceClock.mark()

  ' 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. A removal without its recompute leaves the three geometry arrays
' describing the old row list, which draws every row below it at its neighbor'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 a server with no
' recordings in progress the removal is unconditional, which makes `sizeRemove` a race outcome
' rather than an occurrence count. That result is in `docs/dev/home-first-paint-performance.md`
' ("`row removed`"); why it is unconditional is in the commit of #862 (`git show 74d23223`).
'
' 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 truly 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, and 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. Drop a replaced run's event, then get status + data from task and release it
' 2. A failed load leaves the row standing (applySectionResult calls showRowFailed)
' 3. Find pre-created row by sectionId
' 4. If empty data: remove row, recalculate sizes
' 5. If data: replace skeleton with populated row
' 6. Update backdrop for focused item
' ============================================

sub updateContinueWatchingItems(event as object)
  if not isCurrentTaskEvent(event, m.LoadContinueWatchingTask) then return
  status = m.LoadContinueWatchingTask.status
  itemData = m.LoadContinueWatchingTask.content
  m.LoadContinueWatchingTask = releaseTask(m.LoadContinueWatchingTask, "content")
  m.isLoadingResume = false

  applySectionResult("resume", status, itemData)
  resolveRowFill("resume")
end sub

sub updateNextUpItems(event as object)
  if not isCurrentTaskEvent(event, m.LoadNextUpTask) then return
  status = m.LoadNextUpTask.status
  itemData = m.LoadNextUpTask.content
  m.LoadNextUpTask = releaseTask(m.LoadNextUpTask, "content")
  m.isLoadingNextUp = false

  applySectionResult("nextup", status, itemData)
  resolveRowFill("nextup")
end sub

sub updateOnNowItems(event as object)
  if not isCurrentTaskEvent(event, m.LoadOnNowTask) then return
  status = m.LoadOnNowTask.status
  itemData = m.LoadOnNowTask.content
  m.LoadOnNowTask = releaseTask(m.LoadOnNowTask, "content")
  m.isLoadingOnNow = false

  applySectionResult("livetv", status, itemData)
  resolveRowFill("livetv")
end sub

sub updateActiveRecordingsItems(event as object)
  if not isCurrentTaskEvent(event, m.LoadActiveRecordingsTask) then return
  status = m.LoadActiveRecordingsTask.status
  itemData = m.LoadActiveRecordingsTask.content
  m.LoadActiveRecordingsTask = releaseTask(m.LoadActiveRecordingsTask, "content")
  m.isLoadingActiveRecordings = false

  applySectionResult("activeRecordings", status, itemData)
  resolveRowFill("activeRecordings")
end sub

' applySectionResult: A section's load has finished. An answered one fills its row (or removes
' it, when the section is empty); a failed one says nothing about what the section holds, so
' the row stands (showRowFailed).
'
' @param {string} sectionId - The sectionId of the target row
' @param {dynamic} status - The task's status field ("ok" / "failed")
' @param {dynamic} itemData - Array of content nodes from the task
sub applySectionResult(sectionId as string, status as dynamic, itemData as dynamic)
  if homeRowFailure.isAnswered(status)
    populateRowFromData(sectionId, itemData)
  else
    showRowFailed(sectionId)
  end if
  noteSectionResult(sectionId, status)
end sub

' showRowFailed: A section's load failed. Its row keeps its items; a row still showing its
' loading placeholder shows the failed tile instead, so the failure does not read as loading
' forever (homeRowFailure.afterFailure). A section with no row stays off screen.
'
' @param {string} sectionId - The sectionId of the row
sub showRowFailed(sectionId as string)
  result = findRowBySectionId(sectionId)
  if not isValid(result) then return
  if homeRowFailure.afterFailure(homeRowFailure.placeholderState(result.row)) = "" then return
  replacePlaceholder(result.row, skeleton.createFailedPlaceholder())
end sub

' showRowRetrying: A section's load has started again. A row showing the failed tile goes back
' to the spinner, so a retry shows it is under way (homeRowFailure.afterRetry).
'
' @param {string} sectionId - The sectionId of the row
sub showRowRetrying(sectionId as string)
  result = findRowBySectionId(sectionId)
  if not isValid(result) then return
  if homeRowFailure.afterRetry(homeRowFailure.placeholderState(result.row)) = "" then return
  replacePlaceholder(result.row, skeleton.createPlaceholder())
end sub

' replacePlaceholder: Swap a row's lone placeholder for another, in place. Appended before the
' old one is removed, as populateRowFromData does, so the row is never empty and the RowList
' keeps its layout and focus.
sub replacePlaceholder(row as object, placeholder as object)
  row.appendChild(placeholder)
  row.removeChildIndex(0)
end sub

' noteSectionResult: record a section's latest load result for the RTA suite
' (homeRowFailure.noteResult); nothing in the app reads it.
'
' @param {string} sectionId - The sectionId the load was for
' @param {dynamic} status - The load's status
sub noteSectionResult(sectionId as string, status as dynamic)
  homeRowFailure.noteResult(m.top, sectionId, status)
end sub

' resolveRowFill: A row's data has landed: close its screen-load fill, then see whether that
' completed the visible span.
'
' Resolves only a fill that is still OPEN. The same handlers serve every later load, and one
' can deliver a second time before Home settles (a return to Home mid-load refreshes, and
' onProgramsExpired re-fires On Now), so an unguarded resolve would raise the ledger's
' "unbalanced" warning on ordinary use, the warning that is meant to signal real drift.
'
' @param {string} sectionId - The sectionId the fill was declared under
sub resolveRowFill(sectionId as string)
  #if perfTiming
    ledger = screenLoad.state()
    if not isValid(ledger) or not isValid(ledger.open[sectionId]) then return
    screenLoad.resolve(sectionId)
    checkVisibleRowsFilled()
  #end if
end sub

' checkVisibleRowsFilled: Mark Home painted once every row in the visible span has landed.
'
' Waits for the final row layout (m.initialLoadComplete): before the libraries answer, the
' latest-media rows are not in the list yet, so the span would describe rows that are about to
' be pushed down. Called after every resolve and after every structural change that can shift
' rows into the span. The decision itself is homeScreenLoad.visibleRowsResolved.
sub checkVisibleRowsFilled()
  #if perfTiming
    if not m.initialLoadComplete then return
    ledger = screenLoad.state()
    if not isValid(ledger) or ledger.painted then return

    sectionIds = []
    for each row in m.top.content.getChildren(-1, 0)
      sectionIds.push(row.sectionId)
    end for

    if homeScreenLoad.visibleRowsResolved(m.top.content.loadedRowRange, sectionIds, ledger.open)
      screenLoad.paint()
    end if
  #end if
end sub

' Fires when JRRowList's progress tick detects at least one expired Program
' or Recording. Re-runs the On Now and/or Active Recordings loads 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"
      startOnNowLoad()
    else if section.type = "activerecordings"
      startActiveRecordingsLoad()
    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 (for example, 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 neighbor'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 only 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 (measured 2026-08-09): 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. This row is not in m.top.content
    ' yet: appending into a still-detached row measured ~0 ms on 2026-08-09, 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 measured 2026-08-19, 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 has to, 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 neighbor'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 (measured 2026-08-19: ~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, so 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), the 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()
  ' Skipped while a library load is in flight; no deferred re-run is needed, the in-flight
  ' load will deliver fresh data shortly.
  startLibrariesLoad()

  ' 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)
  if not isValid(item) then return

  ' OK on a "couldn't load" tile loads Home again: the same refresh a return to Home runs, so
  ' every failed row retries at once, and a section whose load is still running is left to
  ' finish rather than asked twice (the isLoading* guards). The latest-media rows would only
  ' reload once the libraries answer, so their failed rows are retried directly
  ' (retryFailedLatestRows). Nothing retries on its own: the viewer asks (decision
  ' `home-row-failure-tile`).
  if skeleton.isFailedPlaceholder(item)
    updateHomeRows()
    retryFailedLatestRows()
    return
  end if

  ' Ignore skeleton placeholder items
  if 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, the 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 any section run still in flight. Each is invalid when its section is not
  ' in the layout or its run has delivered, which releaseTask accepts.
  m.LoadLibrariesTask = releaseTask(m.LoadLibrariesTask, "content")
  m.LoadContinueWatchingTask = releaseTask(m.LoadContinueWatchingTask, "content")
  m.LoadNextUpTask = releaseTask(m.LoadNextUpTask, "content")
  m.LoadOnNowTask = releaseTask(m.LoadOnNowTask, "content")
  m.LoadActiveRecordingsTask = releaseTask(m.LoadActiveRecordingsTask, "content")

  ' 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.latestRetryOwed = []

  ' 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