components_home_HomeRows.bs

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