' 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