' bsc-disable-file print-locations — legacy print() sites; migration to m.log.* tracked by tech-debt.md#legacy-print-statements
import "pkg:/source/constants/itemAspectRatio.bs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/itemImageUrl.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/rowListWrap.bs"
import "pkg:/source/utils/textureManager.bs"
import "pkg:/source/utils/translate.bs"
sub init()
m.top.itemComponentName = "JRRowItem"
m.top.numRows = 3
m.top.vertFocusAnimationStyle = "fixedFocus"
m.top.content = CreateObject("roSGNode", "ContentNode")
updateSize()
initTextureManager(m.top.content, m.top.itemSize, m.top.focusXOffset, m.top.rowItemSpacing)
m.top.setfocus(true)
m.top.observeField("rowItemSelected", "itemSelected")
m.top.observeField("rowItemFocused", "onItemFocused")
' Persistent task nodes for data loading
m.LoadLibrariesTask = createObject("roSGNode", "LoadItemsTask")
m.LoadContinueWatchingTask = createObject("roSGNode", "LoadItemsTask")
m.LoadContinueWatchingTask.itemsToLoad = "continue"
m.LoadNextUpTask = createObject("roSGNode", "LoadItemsTask")
m.LoadNextUpTask.itemsToLoad = "nextUp"
m.LoadOnNowTask = createObject("roSGNode", "LoadItemsTask")
m.LoadOnNowTask.itemsToLoad = "onNow"
m.LoadActiveRecordingsTask = createObject("roSGNode", "LoadItemsTask")
m.LoadActiveRecordingsTask.itemsToLoad = "activeRecordings"
' Track populated section count for AppLaunchComplete beacon
m.populatedSectionCount = 0
m.initialLoadComplete = false
' Track per-library latest media tasks so they can be cleaned up
m.latestMediaTasks = []
' Track loading state for persistent tasks to prevent duplicate observers/requests
m.isLoadingLibraries = false
m.isLoadingResume = false
m.isLoadingNextUp = false
m.isLoadingOnNow = false
m.isLoadingActiveRecordings = false
' Refetch the On Now and Active Recordings rows when the base class's progress
' tick detects that at least one Program or Recording has ended. Without this,
' sitting on Home long enough would leave a row of finished broadcasts/recordings
' until the user refreshes manually.
m.top.observeField("programsExpired", "onProgramsExpired")
end sub
' loadLibraries: Entry point called by Home.bs via callFunc.
' Builds ordered section plan, creates skeleton rows, then fires data tasks.
sub loadLibraries()
m.sectionPlan = buildSectionPlan()
' Create skeleton rows for all non-latestmedia sections
createSkeletonRows()
' Set initial buffer range now that skeleton rows exist. This lets shouldLoadTexture()
' use buffer logic immediately — cells outside the buffer skip image fetch during init,
' preventing HTTP cache pollution that would mask texture management on first scroll.
updateTextureBufferRange(m.top.content, 0, 0, m.top.numRows)
' Show rows immediately — no timer hack needed since skeletons are in place
m.top.showRowCounter = [true]
' Observe library task (observer is removed after each load in onLibrariesLoaded)
m.isLoadingLibraries = true
m.LoadLibrariesTask.unobserveField("content")
m.LoadLibrariesTask.observeField("content", "onLibrariesLoaded")
m.LoadLibrariesTask.control = "RUN"
' Start loading data for sections that don't depend on library data
startParallelLoads()
end sub
sub updateSize()
uiRowLayout = m.global.user.settings.uiRowLayout
if isValid(uiRowLayout)
if uiRowLayout = "fullwidth"
m.top.translation = [0, 126]
' itemSize height = tallest possible row (PORTRAIT slot + text area).
' Per-row rowHeights overrides this for each actual row.
m.top.itemSize = [1920, rowSlotSize.ROW_HEIGHT_PORTRAIT]
' align with edge of "action" safe zone
m.top.focusXOffset = [96]
m.top.rowLabelOffset = [96, 18]
else
' original layout
m.top.translation = [111, 126]
m.top.itemSize = [1703, rowSlotSize.ROW_HEIGHT_PORTRAIT]
' reset to defaults
m.top.focusXOffset = []
m.top.rowLabelOffset = [0, 18]
end if
end if
m.top.visible = true
end sub
' ============================================
' SECTION PLAN & SKELETON ROW CREATION
' ============================================
' buildSectionPlan: Reads homeSection0-6 settings, returns ordered array of sections to display
'
' @return {roArray} Array of { type: string, settingIndex: integer }
function buildSectionPlan() as object
plan = []
userSettings = m.global.user.settings
for i = 0 to 6
sectionName = LCase(userSettings["homeSection" + i.toStr()] ?? "none")
if sectionName <> "none"
plan.push({ type: sectionName, settingIndex: i })
end if
end for
return plan
end function
' createSkeletonRows: Creates empty HomeRow nodes for all planned sections except latestmedia.
' Each skeleton row gets a single placeholder child so the RowList renders the row label
' and a loading indicator at the correct slot size.
sub createSkeletonRows()
for each section in m.sectionPlan
if section.type = "latestmedia"
' latestmedia rows are created after library data loads (we don't know how many yet)
continue for
end if
row = createSkeletonRow(section.type)
if isValid(row)
m.top.content.appendChild(row)
end if
end for
setRowItemSize()
end sub
' createSkeletonRow: Creates a single empty HomeRow with correct title, sectionId, and cursorSize.
' Adds a single placeholder child node so the RowList renders the row.
'
' @param {string} sectionType - The section type from user settings
' @return {object} HomeRow node, or invalid if section type is unsupported
function createSkeletonRow(sectionType as string) as object
row = CreateObject("roSGNode", "HomeRow")
if sectionType = "resume"
row.title = translate(translationKeys.LabelContinueWatching)
row.sectionId = "resume"
row.cursorSize = rowSlotSize.WIDE
else if sectionType = "nextup"
row.title = translate(translationKeys.LabelNextUp)
row.sectionId = "nextup"
row.cursorSize = rowSlotSize.WIDE
else if sectionType = "livetv"
row.title = translate(translationKeys.LabelOnNow)
row.sectionId = "livetv"
row.cursorSize = rowSlotSize.SQUARE
else if sectionType = "librarybuttons" or sectionType = "smalllibrarytiles"
row.title = translate(translationKeys.LabelMyMedia)
row.sectionId = "library"
row.cursorSize = rowSlotSize.LIBRARY
else if sectionType = "activerecordings"
row.title = translate(translationKeys.LabelActiveRecordings)
row.sectionId = "activeRecordings"
row.cursorSize = rowSlotSize.SQUARE
else
return invalid
end if
' Add single placeholder child so the RowList renders this row with correct slot size.
' JRRowItem will show its default RectangleBackgroundSecondary backdrop for this node.
placeholder = CreateObject("roSGNode", "ContentNode")
placeholder.addFields({ type: "Loading" })
row.appendChild(placeholder)
return row
end function
' ============================================
' DATA LOADING
' ============================================
' startParallelLoads: Fires off data tasks for sections that don't need library data.
' Library-dependent sections (library row, latestmedia) are handled in onLibrariesLoaded.
sub startParallelLoads()
for each section in m.sectionPlan
if section.type = "resume"
if m.isLoadingResume then continue for
m.isLoadingResume = true
m.LoadContinueWatchingTask.unobserveField("content")
m.LoadContinueWatchingTask.observeField("content", "updateContinueWatchingItems")
m.LoadContinueWatchingTask.control = "RUN"
else if section.type = "nextup"
if m.isLoadingNextUp then continue for
m.isLoadingNextUp = true
m.LoadNextUpTask.unobserveField("content")
m.LoadNextUpTask.observeField("content", "updateNextUpItems")
m.LoadNextUpTask.control = "RUN"
else if section.type = "livetv"
if m.isLoadingOnNow then continue for
m.isLoadingOnNow = true
m.LoadOnNowTask.unobserveField("content")
m.LoadOnNowTask.observeField("content", "updateOnNowItems")
m.LoadOnNowTask.control = "RUN"
else if section.type = "activerecordings"
if m.isLoadingActiveRecordings then continue for
m.isLoadingActiveRecordings = true
m.LoadActiveRecordingsTask.unobserveField("content")
m.LoadActiveRecordingsTask.observeField("content", "updateActiveRecordingsItems")
m.LoadActiveRecordingsTask.control = "RUN"
end if
end for
end sub
' onLibrariesLoaded: Handler when LoadLibrariesTask returns data.
' Populates the library row, creates latestmedia skeleton rows, then fires latest tasks.
sub onLibrariesLoaded()
m.libraryData = m.LoadLibrariesTask.content
m.LoadLibrariesTask.unobserveField("content")
m.LoadLibrariesTask.content = []
m.isLoadingLibraries = false
' Always recompute filteredLatest — library data or latestItemsExcludes may have changed since last load.
' startLatestMediaLoads() and getRowConfigForSection() both depend on this being current.
m.filteredLatest = filterNodeArray(m.libraryData, "id", m.global.user.config.latestItemsExcludes)
' Populate the library row immediately (data is available now)
populateLibraryRow()
' Only create latestmedia skeletons on initial load. On refresh, latestmedia rows
' either already exist (updated in place by populateRowFromData) or were removed
' because they had no data — populateRowFromData will re-insert them if data arrives.
if not m.initialLoadComplete
insertLatestMediaSkeletons()
setRowItemSize()
m.initialLoadComplete = true
' Layout is stable — activate texture management so cells can start
' unloading off-screen textures. Before this point, all cells keep
' their textures loaded to prevent visual glitches during layout changes.
' Recalculate buffer range first — the initial updateTextureBufferRange ran
' when the content root had 0 children, leaving loadedRowRange at [-1,-1,-1,-1].
updateTextureBufferRange(m.top.content, m.top.rowItemFocused[0], m.top.rowItemFocused[1], m.top.numRows)
activateTextureManager(m.top.content)
end if
' Fire off latestmedia data tasks
startLatestMediaLoads()
end sub
' populateLibraryRow: Fills the library row with filtered library items.
' Uses populateRowFromData for consistent in-place update behavior.
sub populateLibraryRow()
if not isValidAndNotEmpty(m.libraryData) then return
filteredMedia = filterNodeArray(m.libraryData, "id", m.global.user.config.myMediaExcludes)
populateRowFromData("library", filteredMedia)
end sub
' insertLatestMediaSkeletons: Creates skeleton rows for each non-excluded library
' and inserts them at the correct position in the content node.
sub insertLatestMediaSkeletons()
if not isValidAndNotEmpty(m.filteredLatest) then return
' Find where latestmedia should be inserted by finding the index after the last
' non-latestmedia section that comes before it in the plan
insertIndex = findLatestMediaInsertIndex()
for each lib in m.filteredLatest
if lib.collectionType <> "boxsets" and lib.collectionType <> "livetv" and lib.collectionType <> "Program"
sectionId = "latest_" + lib.id
' Skip if this row already exists (e.g., on refresh)
if isValid(findRowBySectionId(sectionId)) then continue for
slotSize = rowSlotSize.WIDE
if isValidAndNotEmpty(lib.collectionType)
if LCase(lib.collectionType) = "movies"
slotSize = rowSlotSize.PORTRAIT
else if LCase(lib.collectionType) = "music"
slotSize = rowSlotSize.SQUARE
end if
end if
row = CreateObject("roSGNode", "HomeRow")
row.title = `${translate(translationKeys.LabelRecentlyAddedIn)} ${lib.name}`
row.sectionId = sectionId
row.cursorSize = slotSize
' Add placeholder child
placeholder = CreateObject("roSGNode", "ContentNode")
placeholder.addFields({ type: "Loading" })
row.appendChild(placeholder)
m.top.content.insertChild(row, insertIndex)
insertIndex++
end if
end for
end sub
' findLatestMediaInsertIndex: Determines the correct content index for latestmedia rows
' based on the section plan ordering.
'
' @return {integer} Index where latestmedia rows should be inserted
function findLatestMediaInsertIndex() as integer
' Walk the section plan to find what comes before latestmedia
' The insert index is after the last section that precedes latestmedia in the plan
latestPlanIndex = -1
for i = 0 to m.sectionPlan.count() - 1
if m.sectionPlan[i].type = "latestmedia"
latestPlanIndex = i
exit for
end if
end for
if latestPlanIndex = -1
' latestmedia not in plan, append at end
return m.top.content.getChildCount()
end if
' Find the last section before latestmedia that has a row in content
for i = latestPlanIndex - 1 to 0 step -1
sectionType = m.sectionPlan[i].type
sectionId = getSectionIdForType(sectionType)
if isValid(sectionId)
result = findRowBySectionId(sectionId)
if isValid(result)
return result.index + 1
end if
end if
end for
' No preceding sections found, insert at beginning
return 0
end function
' startLatestMediaLoads: Fires LoadItemsTask for each latestmedia library
sub startLatestMediaLoads()
if not isValidAndNotEmpty(m.filteredLatest) then return
' Ensure any previous latest-media tasks are stopped and unobserved
cleanupLatestMediaTasks()
for each lib in m.filteredLatest
if lib.collectionType <> "boxsets" and lib.collectionType <> "livetv" and lib.collectionType <> "Program"
loadLatest = createObject("roSGNode", "LoadItemsTask")
loadLatest.itemsToLoad = "latest"
loadLatest.itemId = lib.id
metadata = { "title": lib.name, "contentType": lib.collectionType }
loadLatest.metadata = metadata
loadLatest.observeField("content", "updateLatestItems")
' Track the task so it can be cleaned up later
m.latestMediaTasks.push(loadLatest)
loadLatest.control = "RUN"
end if
end for
end sub
' cleanupLatestMediaTasks: Stop and unobserve any in-flight latest media tasks
sub cleanupLatestMediaTasks()
if not isValid(m.latestMediaTasks) then return
for each task in m.latestMediaTasks
if isValid(task)
' Remove all observers on the content field to prevent callbacks after teardown
task.unobserveField("content")
' Stop the task if it is still running
if lcase(task.control) = "run"
task.control = "stop"
end if
end if
end for
m.latestMediaTasks = []
end sub
' ============================================
' ROW LOOKUP & MANAGEMENT
' ============================================
' findRowBySectionId: Find a row in content by its sectionId field
'
' @param {string} sectionId - The sectionId to search for
' @return {object} { row: node, index: integer } or invalid if not found
function findRowBySectionId(sectionId as string) as object
if not isValid(m.top.content) then return invalid
for i = 0 to m.top.content.getChildCount() - 1
row = m.top.content.getChild(i)
if row.sectionId = sectionId
return { row: row, index: i }
end if
end for
return invalid
end function
' getSectionIdForType: Maps a section type string to its sectionId
'
' @param {string} sectionType - Section type from user settings
' @return {string} sectionId, or invalid if type is unsupported
function getSectionIdForType(sectionType as string) as dynamic
if sectionType = "resume" then return "resume"
if sectionType = "nextup" then return "nextup"
if sectionType = "livetv" then return "livetv"
if sectionType = "librarybuttons" or sectionType = "smalllibrarytiles" then return "library"
if sectionType = "activerecordings" then return "activeRecordings"
return invalid
end function
' removeRowAtIndex: Removes a row at the given index and recalculates sizes
'
' @param {integer} index - Content child index to remove
sub removeRowAtIndex(index as integer)
m.top.content.removeChildIndex(index)
setRowItemSize()
end sub
' getRowConfigForSection: Returns title and slotSize for a sectionId.
' Used when inserting a row that was previously removed (no skeleton exists).
'
' @param {string} sectionId - The sectionId
' @return {object} { title: string, slotSize: array } or invalid if unknown
function getRowConfigForSection(sectionId as string) as object
if sectionId = "resume" then return { title: translate(translationKeys.LabelContinueWatching), slotSize: rowSlotSize.WIDE }
if sectionId = "nextup" then return { title: translate(translationKeys.LabelNextUp), slotSize: rowSlotSize.WIDE }
if sectionId = "livetv" then return { title: translate(translationKeys.LabelOnNow), slotSize: rowSlotSize.SQUARE }
if sectionId = "library" then return { title: translate(translationKeys.LabelMyMedia), slotSize: rowSlotSize.LIBRARY }
if sectionId = "activeRecordings" then return { title: translate(translationKeys.LabelActiveRecordings), slotSize: rowSlotSize.SQUARE }
' latestmedia rows: derive title and slotSize from the library data
if sectionId.startsWith("latest_") and isValidAndNotEmpty(m.filteredLatest)
libId = sectionId.mid(7) ' strip "latest_" prefix
for each lib in m.filteredLatest
if lib.id = libId
slotSize = rowSlotSize.WIDE
if isValidAndNotEmpty(lib.collectionType)
if LCase(lib.collectionType) = "movies"
slotSize = rowSlotSize.PORTRAIT
else if LCase(lib.collectionType) = "music"
slotSize = rowSlotSize.SQUARE
end if
end if
return { title: `${translate(translationKeys.LabelRecentlyAddedIn)} ${lib.name}`, slotSize: slotSize }
end if
end for
end if
return invalid
end function
' findInsertIndexForSection: Determines the correct content index for a section
' that needs to be inserted. Uses the section plan ordering to maintain row order.
'
' @param {string} sectionId - The sectionId to insert
' @return {integer} Index where the row should be inserted
function findInsertIndexForSection(sectionId as string) as integer
' Build ordered list of sectionIds from the plan (expanding latestmedia)
orderedIds = []
for each section in m.sectionPlan
if section.type = "latestmedia"
if isValidAndNotEmpty(m.filteredLatest)
for each lib in m.filteredLatest
if lib.collectionType <> "boxsets" and lib.collectionType <> "livetv" and lib.collectionType <> "Program"
orderedIds.push("latest_" + lib.id)
end if
end for
end if
else
id = getSectionIdForType(section.type)
if isValid(id) then orderedIds.push(id)
end if
end for
' Find where our sectionId falls in the ordered plan
targetPlanIndex = -1
for i = 0 to orderedIds.count() - 1
if orderedIds[i] = sectionId
targetPlanIndex = i
exit for
end if
end for
if targetPlanIndex = -1
return m.top.content.getChildCount()
end if
' Walk backward from our position to find the last preceding section
' that has a row in content — insert after it
for i = targetPlanIndex - 1 to 0 step -1
result = findRowBySectionId(orderedIds[i])
if isValid(result)
return result.index + 1
end if
end for
return 0
end function
' ============================================
' DATA UPDATE CALLBACKS
' Each follows the same pattern:
' 1. Get data from task, unobserve + clear task
' 2. Find pre-created row by sectionId
' 3. If empty data → remove row, recalculate sizes
' 4. If data → replace skeleton with populated row
' 5. Update backdrop for focused item
' ============================================
sub updateContinueWatchingItems()
itemData = m.LoadContinueWatchingTask.content
m.LoadContinueWatchingTask.unobserveField("content")
m.LoadContinueWatchingTask.content = []
m.isLoadingResume = false
populateRowFromData("resume", itemData)
end sub
sub updateNextUpItems()
itemData = m.LoadNextUpTask.content
m.LoadNextUpTask.unobserveField("content")
m.LoadNextUpTask.content = []
m.LoadNextUpTask.control = "STOP"
m.isLoadingNextUp = false
populateRowFromData("nextup", itemData)
end sub
sub updateOnNowItems()
itemData = m.LoadOnNowTask.content
m.LoadOnNowTask.unobserveField("content")
m.LoadOnNowTask.content = []
m.isLoadingOnNow = false
populateRowFromData("livetv", itemData)
end sub
sub updateActiveRecordingsItems()
itemData = m.LoadActiveRecordingsTask.content
m.LoadActiveRecordingsTask.unobserveField("content")
m.LoadActiveRecordingsTask.content = []
m.isLoadingActiveRecordings = false
populateRowFromData("activeRecordings", itemData)
end sub
' Fires when JRRowList's progress tick detects at least one expired Program
' or Recording. Re-runs LoadOnNowTask and/or LoadActiveRecordingsTask to pull
' fresh data. Loading guards debounce repeated expiry signals while a load is
' already in flight, and section plan checks avoid wasted requests when the
' user has disabled the relevant sections.
sub onProgramsExpired()
if not isValidAndNotEmpty(m.sectionPlan) then return
for each section in m.sectionPlan
if section.type = "livetv" and not m.isLoadingOnNow
m.isLoadingOnNow = true
m.LoadOnNowTask.unobserveField("content")
m.LoadOnNowTask.observeField("content", "updateOnNowItems")
m.LoadOnNowTask.control = "RUN"
else if section.type = "activerecordings" and not m.isLoadingActiveRecordings
m.isLoadingActiveRecordings = true
m.LoadActiveRecordingsTask.unobserveField("content")
m.LoadActiveRecordingsTask.observeField("content", "updateActiveRecordingsItems")
m.LoadActiveRecordingsTask.control = "RUN"
end if
end for
end sub
' updateLatestItems: Processes LoadItemsTask content for a latest-in-library row.
' Uses msg parameter because each library has its own dynamically created task.
'
' @param {dynamic} msg - roSGNodeEvent from the LoadItemsTask
sub updateLatestItems(msg)
itemData = msg.GetData()
node = msg.getRoSGNode()
node.unobserveField("content")
node.content = []
populateRowFromData("latest_" + node.itemId, itemData)
end sub
' populateRowFromData: Unified row population logic used by all update callbacks.
' Updates the children of the existing row node in place to avoid RowList re-layout
' and focus disruption. Removes the row if data is empty. If the row doesn't exist
' but data is available (e.g., a previously empty section now has content on refresh),
' creates and inserts the row at the correct position.
'
' @param {string} sectionId - The sectionId of the target row
' @param {dynamic} itemData - Array of content nodes from the task, or invalid/empty
sub populateRowFromData(sectionId as string, itemData as dynamic)
result = findRowBySectionId(sectionId)
if not isValidAndNotEmpty(itemData)
' No data — remove the row if it exists
if isValid(result)
removeRowAtIndex(result.index)
end if
return
end if
if isValid(result)
' Row exists — update children in place. Append new items BEFORE removing old ones
' to avoid a momentary empty-row state that would cause the RowList to shift focus.
row = result.row
oldCount = row.getChildCount()
for each item in itemData
row.appendChild(item)
end for
' Remove old items (now at indices 0..oldCount-1)
for i = oldCount - 1 to 0 step -1
row.removeChildIndex(i)
end for
else
' Row was previously removed (no data last time) — create and insert at correct position
rowConfig = getRowConfigForSection(sectionId)
if not isValid(rowConfig) then return
row = CreateObject("roSGNode", "HomeRow")
row.title = rowConfig.title
row.sectionId = sectionId
row.cursorSize = rowConfig.slotSize
for each item in itemData
row.appendChild(item)
end for
insertIndex = findInsertIndexForSection(sectionId)
m.top.content.insertChild(row, insertIndex)
setRowItemSize()
end if
updateBackdropForFocusedItem()
onSectionPopulated()
end sub
' onSectionPopulated: Called after each section is populated with data.
' Signals AppLaunchComplete after 2 sections have loaded.
sub onSectionPopulated()
m.populatedSectionCount++
if not m.global.appLoaded and m.populatedSectionCount >= 2
m.top.signalBeacon("AppLaunchComplete")
m.global.appLoaded = true
end if
end sub
' ============================================
' ROW SIZE CALCULATION
' ============================================
' setRowItemSize: Loops through all home sections and sets the correct item sizes, heights, and spacings per row.
' rowItemSize[i] = slot size [width, posterHeight] — determines focus ring dimensions (poster only, no text).
' rowHeights[i] = total row height: slot + 90px text area for standard rows; slot-only for library tiles.
' rowSpacings[i] = gap after each row before the next row label. Must be set for ALL rows because Roku
' ignores itemSpacing entirely once rowSpacings is assigned (even partially). Standard rows
' use 60px; My Media uses 78px to partially compensate for its absent text area.
sub setRowItemSize()
if not isValid(m.top.content) then return
homeSections = m.top.content.getChildren(-1, 0)
newSizeArray = CreateObject("roArray", homeSections.count(), false)
newRowHeights = CreateObject("roArray", homeSections.count(), false)
newRowSpacings = CreateObject("roArray", homeSections.count(), false)
interRowSpacing = 60
for i = 0 to homeSections.count() - 1
section = homeSections[i]
slotSize = isValid(section.cursorSize) ? section.cursorSize : rowSlotSize.WIDE
newSizeArray[i] = slotSize
if section.sectionId = "library"
' Library tiles render no text below the slot. rowHeight = slot only.
' rowSpacings is intentionally larger than standard rows — the next row's label
' lives inside the gap, giving a consistent visual distance to the label text.
newRowHeights[i] = rowSlotSize.ROW_HEIGHT_LIBRARY
newRowSpacings[i] = 78
else
' Standard rows: text flows below the slot — infer total height from slot height
slotHeight = slotSize[1]
if slotHeight = rowSlotSize.PORTRAIT[1]
newRowHeights[i] = rowSlotSize.ROW_HEIGHT_PORTRAIT
else if slotHeight = rowSlotSize.SQUARE[1]
newRowHeights[i] = rowSlotSize.ROW_HEIGHT_SQUARE
else
' WIDE (264px) — default
newRowHeights[i] = rowSlotSize.ROW_HEIGHT_WIDE
end if
newRowSpacings[i] = interRowSpacing
end if
end for
m.top.rowItemSize = newSizeArray
m.top.rowHeights = newRowHeights
m.top.rowSpacings = newRowSpacings
end sub
' ============================================
' UPDATE / REFRESH
' ============================================
' updateHomeRows: Refresh data for all rows without tearing down the UI.
' Keeps existing row nodes in place to avoid focus disruption. As fresh data
' arrives, populateRowFromData updates row children in place.
sub updateHomeRows()
' Guard: if a library load is already in flight, skip to avoid stacking observers.
' No deferred re-run is needed — the in-flight load will deliver fresh data shortly.
if not m.isLoadingLibraries
m.isLoadingLibraries = true
m.LoadLibrariesTask.unobserveField("content")
m.LoadLibrariesTask.observeField("content", "onLibrariesLoaded")
m.LoadLibrariesTask.control = "RUN"
end if
' Re-fire non-library-dependent tasks
startParallelLoads()
end sub
' ============================================
' ITEM SELECTION, FOCUS, AND KEY EVENTS
' ============================================
' Gets an item from content at specified row and item indices
' Performs all necessary bounds checking and validation
' @param {roArray} indices - [rowIndex, itemIndex]
' @return {dynamic} ContentNode if valid, invalid otherwise
function getItemAtIndices(indices as object) as dynamic
if not isValidAndNotEmpty(indices) or not isValid(m.top.content)
return invalid
end if
if indices[0] < 0 or indices[0] >= m.top.content.getChildCount()
return invalid
end if
row = m.top.content.getChild(indices[0])
if not isValid(row)
return invalid
end if
if indices[1] < 0 or indices[1] >= row.getChildCount()
return invalid
end if
return row.getChild(indices[1])
end function
sub itemSelected()
item = getItemAtIndices(m.top.rowItemSelected)
' Ignore skeleton placeholder items
if not isValid(item) or item.type = "Loading" then return
m.top.selectedItem = item
'Prevent the selected item event from double firing
m.top.selectedItem = invalid
end sub
' Observer for rowItemFocused field - delegates to updateBackdropForFocusedItem
sub onItemFocused()
updateTextureBufferRange(m.top.content, m.top.rowItemFocused[0], m.top.rowItemFocused[1], m.top.numRows)
updateBackdropForFocusedItem()
end sub
' Update backdrop to match currently focused item
' Handles all validation and edge cases
' Used by: onItemFocused observer and row update functions after replaceChild
sub updateBackdropForFocusedItem()
focusedItem = getItemAtIndices(m.top.rowItemFocused)
' Always call setBackgroundImage so the backdrop clears when the focused item has none.
' Passing "" explicitly removes the previous backdrop (photos, channels, etc.)
backdropUrl = ""
if isValid(focusedItem)
deviceRes = m.global.device.uiResolution
backdropUrl = getItemBackdropUrl(focusedItem, { width: deviceRes[0], height: deviceRes[1] })
end if
m.global.sceneManager.callFunc("setBackgroundImage", backdropUrl)
end sub
function onKeyEvent(key as string, press as boolean) as boolean
if wrapRowFocus(key, press) then return true
if press
if key = "play"
print "play was pressed from homerow"
itemToPlay = getItemAtIndices(m.top.rowItemFocused)
if isValid(itemToPlay) and itemToPlay.type <> "Loading"
m.top.quickPlayNode = itemToPlay
' Clear immediately — same pattern as selectedItem. Prevents stale
' value from re-firing when the screen is restored after playback.
m.top.quickPlayNode = invalid
end if
return true
else if key = "replay"
m.top.jumpToRowItem = [m.top.rowItemFocused[0], 0]
return true
end if
end if
return false
end function
' ============================================
' TEARDOWN
' ============================================
' onDestroy: Full teardown releasing all resources before component removal
' Called by Home.bs onDestroy() before nulling the homeRows reference
sub onDestroy()
destroyTextureManager(m.top.content)
' Unobserve m.top fields
m.top.unobserveField("rowItemSelected")
m.top.unobserveField("rowItemFocused")
m.top.unobserveField("programsExpired")
' Stop and release all persistent task nodes
m.LoadLibrariesTask.unobserveField("content")
m.LoadLibrariesTask.control = "STOP"
m.LoadLibrariesTask = invalid
m.LoadContinueWatchingTask.unobserveField("content")
m.LoadContinueWatchingTask.control = "STOP"
m.LoadContinueWatchingTask = invalid
m.LoadNextUpTask.unobserveField("content")
m.LoadNextUpTask.control = "STOP"
m.LoadNextUpTask = invalid
m.LoadOnNowTask.unobserveField("content")
m.LoadOnNowTask.control = "STOP"
m.LoadOnNowTask = invalid
m.LoadActiveRecordingsTask.unobserveField("content")
m.LoadActiveRecordingsTask.control = "STOP"
m.LoadActiveRecordingsTask = invalid
' Stop and release any per-library latest media tasks
cleanupLatestMediaTasks()
' Clear data caches
m.libraryData = invalid
m.filteredLatest = invalid
m.sectionPlan = invalid
' Reset loading flags
m.isLoadingLibraries = false
m.isLoadingResume = false
m.isLoadingNextUp = false
m.isLoadingOnNow = false
m.isLoadingActiveRecordings = false
end sub