import "pkg:/source/constants/itemAspectRatio.bs"
import "pkg:/source/utils/itemImageUrl.bs"
import "pkg:/source/utils/misc.bs"
const LOADING_WAIT_TIME = 2
sub init()
m.top.itemComponentName = "JRRowItem"
' how many rows are visible on the screen
m.top.numRows = 3
m.top.vertFocusAnimationStyle = "fixedFocus"
' Hide the row counter to prevent flicker. We'll show it once loading timer fires
m.top.showRowCounter = [false]
m.top.content = CreateObject("roSGNode", "ContentNode")
m.loadingTimer = createObject("roSGNode", "Timer")
m.loadingTimer.duration = LOADING_WAIT_TIME
m.loadingTimer.observeField("fire", "loadingTimerComplete")
updateSize()
m.top.setfocus(true)
m.top.observeField("rowItemSelected", "itemSelected")
m.top.observeField("rowItemFocused", "onItemFocused")
' Load the Libraries from API via task
m.LoadLibrariesTask = createObject("roSGNode", "LoadItemsTask")
m.LoadLibrariesTask.observeField("content", "onLibrariesLoaded")
' set up task nodes for other rows
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.LoadFavoritesTask = createObject("roSGNode", "LoadItemsTask")
m.LoadFavoritesTask.itemsToLoad = "favorites"
end sub
sub loadLibraries()
m.LoadLibrariesTask.control = "RUN"
end sub
sub updateSize()
uiRowLayout = m.global.user.settings.uiRowLayout
if isValid(uiRowLayout)
if uiRowLayout = "fullwidth"
m.top.translation = [0, 120]
' 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, 120]
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
' processUserSections: Loop through user's chosen home section settings and generate the content for each row
'
sub processUserSections()
m.expectedRowCount = 1 ' the favorites row is hardcoded to always show atm
m.processedRowCount = 0
sessionUser = m.global.user
userSettings = sessionUser.settings
' calculate expected row count by processing homesections
for i = 0 to 6
userSection = userSettings["homeSection" + i.toStr()]
sectionName = userSection ?? "none"
sectionName = LCase(sectionName)
if sectionName = "latestmedia"
' expect 1 row per filtered media library
m.filteredLatest = filterNodeArray(m.libraryData, "id", sessionUser.config.latestItemsExcludes)
for each latestLibrary in m.filteredLatest
if latestLibrary.collectionType <> "boxsets" and latestLibrary.collectionType <> "livetv" and latestLibrary.collectionType <> "Program"
m.expectedRowCount++
end if
end for
else if sectionName <> "none"
m.expectedRowCount++
end if
end for
' Add home sections in order based on user settings
loadedSections = 0
for i = 0 to 6
userSection = userSettings["homeSection" + i.toStr()]
sectionName = userSection ?? "none"
sectionName = LCase(sectionName)
sectionLoaded = false
if sectionName <> "none"
sectionLoaded = addHomeSection(sectionName)
end if
' Count how many sections with data are loaded
if sectionLoaded then loadedSections++
' If 2 sections with data are loaded or we're at the end of the web client section data, consider the home view loaded
if not m.global.appLoaded
if loadedSections = 2 or i = 6
m.top.signalBeacon("AppLaunchComplete") ' Roku Performance monitoring
m.global.appLoaded = true
end if
end if
end for
' Favorites isn't an option in Web settings, so we manually add it to the end for now
addHomeSection("favorites")
' Start the timer for creating the content rows before we set the cursor size
m.loadingTimer.control = "start"
end sub
' onLibrariesLoaded: Handler when LoadLibrariesTask returns data
'
sub onLibrariesLoaded()
' save data for other functions
m.libraryData = m.LoadLibrariesTask.content
m.LoadLibrariesTask.unobserveField("content")
m.LoadLibrariesTask.content = []
processUserSections()
end sub
' getOriginalSectionIndex: Gets the index of a section from user settings and adds count of currently known latest media sections
'
' @param {string} sectionName - Name of section we're looking up
'
' @return {integer} indicating index of section taking latest media sections into account
function getOriginalSectionIndex(sectionName as string) as integer
searchSectionName = LCase(sectionName).Replace(" ", "")
sectionIndex = 0
indexLatestMediaSection = 0
userSettings = m.global.user.settings
for i = 0 to 6
userSection = userSettings["homeSection" + i.toStr()]
settingSectionName = userSection ?? "none"
settingSectionName = LCase(settingSectionName)
if settingSectionName = "latestmedia"
indexLatestMediaSection = i
end if
if settingSectionName = searchSectionName
sectionIndex = i
end if
end for
' If the latest media section is before the section we're searching for, then we need to account for how many latest media rows there are
addLatestMediaSectionCount = (indexLatestMediaSection < sectionIndex)
if addLatestMediaSectionCount
for i = sectionIndex to m.top.content.getChildCount() - 1
sectionToTest = m.top.content.getChild(i)
if LCase(Left(sectionToTest.title, 6)) = "latest"
sectionIndex++
end if
end for
end if
return sectionIndex
end function
' removeHomeSection: Removes a home section from the home rows
'
' @param {string} sectionToRemove - Title property of section we're removing
sub removeHomeSection(sectionTitleToRemove as string)
if not isValid(sectionTitleToRemove) then return
sectionTitle = LCase(sectionTitleToRemove).Replace(" ", "")
if not sectionExists(sectionTitle) then return
sectionIndexToRemove = getSectionIndex(sectionTitle)
m.top.content.removeChildIndex(sectionIndexToRemove)
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.
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.title = tr("My Media")
' 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 we have processed the expected number of content rows, stop the loading timer and run the complete function
if m.expectedRowCount = m.processedRowCount
m.loadingTimer.control = "stop"
loadingTimerComplete()
end if
end sub
' loadingTimerComplete: Event handler for when loading wait time has expired
'
sub loadingTimerComplete()
if not m.top.showRowCounter[0]
' Show the row counter to prevent flicker
m.top.showRowCounter = [true]
end if
end sub
' addHomeSection: Adds a new home section to the home rows.
'
' @param {string} sectionType - Type of section to add
' @return {boolean} indicating if the section was handled
function addHomeSection(sectionType as string) as boolean
' Poster size library items
if sectionType = "livetv"
createLiveTVRow()
return true
end if
' Poster size library items
if sectionType = "librarybuttons" or sectionType = "smalllibrarytiles"
createLibraryRow(sectionType)
return true
end if
' Continue Watching items
if sectionType = "resume"
createContinueWatchingRow()
return true
end if
' Next Up items
if sectionType = "nextup"
createNextUpRow()
return true
end if
' Latest items in each library
if sectionType = "latestmedia"
createLatestInRows()
return true
end if
' Favorite Items
if sectionType = "favorites"
createFavoritesRow()
return true
end if
' This section type isn't supported.
' Count it as processed since we aren't going to do anything else with it
m.processedRowCount++
return false
end function
' createLibraryRow: Creates a row displaying the user's libraries
'
sub createLibraryRow(sectionType as string)
m.processedRowCount++
' Ensure we have data
if not isValidAndNotEmpty(m.libraryData) then return
sectionName = tr("My Media")
' We don't refresh library data, so if section already exists, exit
if sectionExists(sectionName)
return
end if
row = CreateObject("roSGNode", "HomeRow")
row.title = sectionName
row.cursorSize = rowSlotSize.LIBRARY
filteredMedia = filterNodeArray(m.libraryData, "id", m.global.user.config.myMediaExcludes)
for each item in filteredMedia
row.appendChild(item)
end for
' Row does not exist, insert it into the home view
m.top.content.insertChild(row, getOriginalSectionIndex(sectionType))
setRowItemSize()
end sub
' createLatestInRows: Creates a row displaying latest items in each of the user's libraries
'
sub createLatestInRows()
' Ensure we have data
if not isValidAndNotEmpty(m.libraryData) then return
' create a "Latest In" row for each library
for each lib in m.filteredLatest
if lib.collectionType <> "boxsets" and lib.collectionType <> "livetv" and lib.collectionType <> "Program"
sectionName = `${tr("Recently Added in")} ${lib.name}`
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
if not sectionExists(sectionName)
nextUpRow = m.top.content.CreateChild("HomeRow")
nextUpRow.title = sectionName
nextUpRow.cursorSize = slotSize
end if
loadLatest = createObject("roSGNode", "LoadItemsTask")
loadLatest.itemsToLoad = "latest"
loadLatest.itemId = lib.id
metadata = { "title": lib.name }
metadata.Append({ "contentType": lib.collectionType })
loadLatest.metadata = metadata
loadLatest.observeField("content", "updateLatestItems")
loadLatest.control = "RUN"
end if
end for
end sub
' sectionExists: Checks if passed section exists in home row content
'
' @param {string} sectionTitle - Title of section we're checking for
'
' @return {boolean} indicating if the section currently exists in the home row content
function sectionExists(sectionTitle as string) as boolean
if not isValid(sectionTitle) then return false
if not isValid(m.top.content) then return false
searchSectionTitle = LCase(sectionTitle).Replace(" ", "")
homeSections = m.top.content.getChildren(-1, 0)
for each section in homeSections
if LCase(section.title).Replace(" ", "") = searchSectionTitle
return true
end if
end for
return false
end function
' getSectionIndex: Returns index of requested section in home row content
'
' @param {string} sectionTitle - Title of section we're checking for
'
' @return {integer} indicating index of request section
function getSectionIndex(sectionTitle as string) as integer
if not isValid(sectionTitle) then return false
if not isValid(m.top.content) then return false
searchSectionTitle = LCase(sectionTitle).Replace(" ", "")
homeSections = m.top.content.getChildren(-1, 0)
sectionIndex = homeSections.count()
i = 0
for each section in homeSections
if LCase(section.title).Replace(" ", "") = searchSectionTitle
sectionIndex = i
exit for
end if
i++
end for
return sectionIndex
end function
' createLiveTVRow: Creates a row displaying the live tv now on section
'
sub createLiveTVRow()
sectionName = tr("On Now")
if not sectionExists(sectionName)
nextUpRow = m.top.content.CreateChild("HomeRow")
nextUpRow.title = sectionName
nextUpRow.cursorSize = rowSlotSize.PORTRAIT
end if
m.LoadOnNowTask.observeField("content", "updateOnNowItems")
m.LoadOnNowTask.control = "RUN"
end sub
' createContinueWatchingRow: Creates a row displaying items the user can continue watching
'
sub createContinueWatchingRow()
' Load the Continue Watching Data
m.LoadContinueWatchingTask.observeField("content", "updateContinueWatchingItems")
m.LoadContinueWatchingTask.control = "RUN"
end sub
' createNextUpRow: Creates a row displaying next episodes up to watch
'
sub createNextUpRow()
sectionName = tr("Next Up")
if not sectionExists(sectionName)
nextUpRow = m.top.content.CreateChild("HomeRow")
nextUpRow.title = sectionName
nextUpRow.cursorSize = rowSlotSize.WIDE
end if
' Load the Next Up Data
m.LoadNextUpTask.observeField("content", "updateNextUpItems")
m.LoadNextUpTask.control = "RUN"
end sub
' createFavoritesRow: Creates a row displaying items from the user's favorites list
'
sub createFavoritesRow()
' Load the Favorites Data
m.LoadFavoritesTask.observeField("content", "updateFavoritesItems")
m.LoadFavoritesTask.control = "RUN"
end sub
' updateHomeRows: Update function exposed to outside components
'
sub updateHomeRows()
' Hide the row counter to prevent flicker. We'll show it once loading timer fires
m.top.showRowCounter = [false]
m.top.visible = false
updateSize()
processUserSections()
end sub
' updateFavoritesItems: Processes LoadFavoritesTask content. Removes, Creates, or Updates favorites row as needed
'
sub updateFavoritesItems()
m.processedRowCount++
itemData = m.LoadFavoritesTask.content
m.LoadFavoritesTask.unobserveField("content")
m.LoadFavoritesTask.content = []
sectionName = tr("Favorites")
if not isValidAndNotEmpty(itemData)
removeHomeSection(sectionName)
return
end if
' remake row using the new data
row = CreateObject("roSGNode", "HomeRow")
row.title = sectionName
row.cursorSize = rowSlotSize.WIDE
for each item in itemData
row.appendChild(item)
end for
if sectionExists(sectionName)
m.top.content.replaceChild(row, getSectionIndex(sectionName))
setRowItemSize()
updateBackdropForFocusedItem()
return
end if
m.top.content.insertChild(row, getSectionIndex(sectionName))
setRowItemSize()
end sub
' updateContinueWatchingItems: Processes LoadContinueWatchingTask content. Removes, Creates, or Updates continue watching row as needed
'
sub updateContinueWatchingItems()
m.processedRowCount++
itemData = m.LoadContinueWatchingTask.content
m.LoadContinueWatchingTask.unobserveField("content")
m.LoadContinueWatchingTask.content = []
sectionName = tr("Continue Watching")
if not isValidAndNotEmpty(itemData)
removeHomeSection(sectionName)
return
end if
' remake row using the new data
row = CreateObject("roSGNode", "HomeRow")
row.title = sectionName
row.cursorSize = rowSlotSize.WIDE
for each item in itemData
row.appendChild(item)
end for
' Row already exists, replace it with new content
if sectionExists(sectionName)
m.top.content.replaceChild(row, getSectionIndex(sectionName))
setRowItemSize()
updateBackdropForFocusedItem()
return
end if
' Row does not exist, insert it into the home view
m.top.content.insertChild(row, getOriginalSectionIndex("resume"))
setRowItemSize()
end sub
' updateNextUpItems: Processes LoadNextUpTask content. Removes, Creates, or Updates next up row as needed
'
sub updateNextUpItems()
m.processedRowCount++
itemData = m.LoadNextUpTask.content
m.LoadNextUpTask.unobserveField("content")
m.LoadNextUpTask.content = []
m.LoadNextUpTask.control = "STOP"
sectionName = tr("Next Up")
if not isValidAndNotEmpty(itemData)
removeHomeSection(sectionName)
return
end if
' remake row using the new data
row = CreateObject("roSGNode", "HomeRow")
row.title = tr("Next Up")
row.cursorSize = rowSlotSize.WIDE
for each item in itemData
row.appendChild(item)
end for
' Row already exists, replace it with new content
if sectionExists(sectionName)
m.top.content.replaceChild(row, getSectionIndex(sectionName))
setRowItemSize()
updateBackdropForFocusedItem()
return
end if
' Row does not exist, insert it into the home view
m.top.content.insertChild(row, getSectionIndex(sectionName))
setRowItemSize()
end sub
' updateLatestItems: Processes LoadItemsTask content. Removes, Creates, or Updates latest in {library} row as needed
'
' @param {dynamic} msg - LoadItemsTask
sub updateLatestItems(msg)
m.processedRowCount++
itemData = msg.GetData()
node = msg.getRoSGNode()
node.unobserveField("content")
node.content = []
sectionName = tr("Recently Added in") + " " + node.metadata.title
if not isValidAndNotEmpty(itemData)
removeHomeSection(sectionName)
return
end if
slotSize = rowSlotSize.WIDE
if isValid(node.metadata.contentType)
if LCase(node.metadata.contentType) = "movies"
slotSize = rowSlotSize.PORTRAIT
else if LCase(node.metadata.contentType) = "music"
slotSize = rowSlotSize.SQUARE
end if
end if
' remake row using new data
row = CreateObject("roSGNode", "HomeRow")
row.title = sectionName
row.cursorSize = slotSize
for each item in itemData
row.appendChild(item)
end for
if sectionExists(sectionName)
' Row already exists, replace it with new content
m.top.content.replaceChild(row, getSectionIndex(sectionName))
setRowItemSize()
updateBackdropForFocusedItem()
return
end if
m.top.content.insertChild(row, getOriginalSectionIndex("latestmedia"))
setRowItemSize()
end sub
' updateOnNowItems: Processes LoadOnNowTask content. Removes, Creates, or Updates on now row as needed
'
sub updateOnNowItems()
m.processedRowCount++
itemData = m.LoadOnNowTask.content
m.LoadOnNowTask.unobserveField("content")
m.LoadOnNowTask.content = []
sectionName = tr("On Now")
if not isValidAndNotEmpty(itemData)
removeHomeSection(sectionName)
return
end if
' On Now programs always render at square size.
' Program items show their primary image; JRRowItem/getRowItemImageUrl handles the channel art fallback.
row = CreateObject("roSGNode", "HomeRow")
row.title = tr("On Now")
row.cursorSize = rowSlotSize.SQUARE
for each item in itemData
row.appendChild(item)
end for
' Row already exists, replace it with new content
if sectionExists(sectionName)
m.top.content.replaceChild(row, getSectionIndex(sectionName))
setRowItemSize()
updateBackdropForFocusedItem()
return
end if
' Row does not exist, insert it into the home view
m.top.content.insertChild(row, getOriginalSectionIndex("livetv"))
setRowItemSize()
end sub
' Gets an item from content at specified row and item indices
' Performs all necessary bounds checking and validation
' @param {array} indices - [rowIndex, itemIndex]
' @return {dynamic} - ContentNode if valid, invalid otherwise
function getItemAtIndices(indices as object) as dynamic
' Validate indices and content exist
if not isValidAndNotEmpty(indices) or not isValid(m.top.content)
return invalid
end if
' Validate row index is within bounds
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
' Validate item index is within bounds
if indices[1] < 0 or indices[1] >= row.getChildCount()
return invalid
end if
return row.getChild(indices[1])
end function
sub itemSelected()
m.selectedRowItem = m.top.rowItemSelected
m.top.selectedItem = getItemAtIndices(m.top.rowItemSelected)
'Prevent the selected item event from double firing
m.top.selectedItem = invalid
end sub
' Observer for rowItemFocused field - delegates to updateBackdropForFocusedItem
sub onItemFocused()
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()
' Get the focused item with bounds checking
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 press
if key = "play"
print "play was pressed from homerow"
itemToPlay = getItemAtIndices(m.top.rowItemFocused)
if isValid(itemToPlay)
m.top.quickPlayNode = itemToPlay
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
function filterNodeArray(nodeArray as object, nodeKey as string, excludeArray as object) as object
if excludeArray.IsEmpty() then return nodeArray
newNodeArray = []
for each node in nodeArray
excludeThisNode = false
for each exclude in excludeArray
if node[nodeKey] = exclude
excludeThisNode = true
end if
end for
if excludeThisNode = false
newNodeArray.Push(node)
end if
end for
return newNodeArray
end function