components_home_FavoritesRows.bs

import "pkg:/source/constants/itemAspectRatio.bs"
import "pkg:/source/constants/itemTypeOrder.bs"
import "pkg:/source/home/homeRowFailure.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/skeleton.bs"
import "pkg:/source/utils/tasks.bs"
import "pkg:/source/utils/textureManager.bs"
import "pkg:/source/utils/translate.bs"

' The one row filled by its own request (/Persons); every other type comes from /Items.
const PEOPLE_TYPE = "Person"
' The row that stands in for the /Items rows when their first load fails
' (homeRowFailure.needsFavoritesStandIn).
const FAVORITES_STAND_IN_ID = "favoritesStandIn"

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

  ' One Task per request, so each reports its own status: /Items fills every row but People,
  ' /Persons fills People (LoadItemsTask `favorites` / `favoritePeople`).
  m.loadFavoritesTask = invalid
  m.loadFavoritePeopleTask = invalid
  m.isLoadingFavorites = false
  m.isLoadingFavoritePeople = false

  ' Known type ordering and labels, used for skeleton creation and data grouping.
  ' Ordering follows itemTypeOrder.FAVORITES (web-client section sort order).
  m.typeArray = itemTypeOrder.FAVORITES
  m.typeLabels = {
    "Movie": translate(translationKeys.LabelMovies),
    "Series": translate(translationKeys.LabelSeries),
    "Episode": translate(translationKeys.LabelEpisodes),
    "Person": translate(translationKeys.LabelPeople),
    "Playlist": translate(translationKeys.LabelPlaylists),
    "MusicArtist": translate(translationKeys.LabelArtists),
    "MusicAlbum": translate(translationKeys.LabelAlbums),
    "Audio": translate(translationKeys.LabelSongs),
    "Video": translate(translationKeys.LabelVideos),
    "MusicVideo": translate(translationKeys.LabelMusicVideos),
    "Program": translate(translationKeys.LabelPrograms),
    "TvChannel": translate(translationKeys.LabelChannels),
    "Recording": translate(translationKeys.LabelRecordings),
    "PhotoAlbum": translate(translationKeys.LabelPhotoAlbums),
    "Photo": translate(translationKeys.LabelPhotos),
    "BoxSet": translate(translationKeys.LabelCollections)
  }
  ' The types the /Items load fills: every type but People.
  m.itemTypes = []
  for each itemType in m.typeArray
    if itemType <> PEOPLE_TYPE then m.itemTypes.push(itemType)
  end for
  m.skeletonRowsCreated = false
end sub

sub updateSize()
  uiRowLayout = m.global.user.settings.uiRowLayout

  if isValid(uiRowLayout)
    if uiRowLayout = "fullwidth"
      m.top.translation = [0, 126]
      m.top.itemSize = [1920, rowSlotSize.ROW_HEIGHT_PORTRAIT]
      m.top.focusXOffset = [96]
      m.top.rowLabelOffset = [96, 18]
    else
      m.top.translation = [111, 126]
      m.top.itemSize = [1703, rowSlotSize.ROW_HEIGHT_PORTRAIT]
      m.top.focusXOffset = []
      m.top.rowLabelOffset = [0, 18]
    end if
  end if

  m.top.visible = true
end sub

' Entry point called by Home.bs when the Favorites tab is selected and on every return to Home,
' and by OK on a failed tile (itemSelected). Creates skeleton rows once, then starts each load
' that is not still running.
sub loadFavorites()
  if not m.skeletonRowsCreated
    createSkeletonRows()
    updateTextureBufferRange(m.top.content, 0, 0, m.top.numRows)
    m.top.showRowCounter = [true]
    m.skeletonRowsCreated = true
  end if

  startFavoritesLoad()
  startFavoritePeopleLoad()
end sub

' A load still running is left to finish, not asked again, as HomeRows' isLoading* guards do:
' stopping its Task does not stop its request (a default-limit read keeps its pool slot), so
' a second one would stack on a server that may still be computing the first (decision
' `home-row-failure-tile`). A failed tile goes back to the spinner while its load runs again.
sub startFavoritesLoad()
  if m.isLoadingFavorites then return
  m.loadFavoritesTask = replaceTask(m.loadFavoritesTask, "LoadItemsTask", "content", { itemsToLoad: "favorites" })
  m.loadFavoritesTask.observeField("content", "onFavoritesLoaded")
  m.isLoadingFavorites = launchTask(m.loadFavoritesTask)
  if m.isLoadingFavorites then showRowRetrying(FAVORITES_STAND_IN_ID)
end sub

sub startFavoritePeopleLoad()
  if m.isLoadingFavoritePeople then return
  m.loadFavoritePeopleTask = replaceTask(m.loadFavoritePeopleTask, "LoadItemsTask", "content", { itemsToLoad: "favoritePeople" })
  m.loadFavoritePeopleTask.observeField("content", "onFavoritePeopleLoaded")
  m.isLoadingFavoritePeople = launchTask(m.loadFavoritePeopleTask)
  if m.isLoadingFavoritePeople then showRowRetrying(PEOPLE_TYPE)
end sub

' Creates skeleton rows for all known favorite types with loading placeholders
sub createSkeletonRows()
  for each itemType in m.typeArray
    row = CreateObject("roSGNode", "HomeRow")
    row.title = m.typeLabels[itemType] ?? itemType
    row.sectionId = itemType
    row.cursorSize = getSlotSizeForType(itemType)

    row.appendChild(skeleton.createPlaceholder())

    m.top.content.appendChild(row)
  end for

  applyRowSizes()
end sub

' The /Items load finished. An answered one fills or removes every row but People; a failed
' one never removes a row (decision `home-row-failure-tile`): rows keep their items, and on a
' first load the spinning rows give way to one failed "Favorites" row (showItemsFailed).
sub onFavoritesLoaded(event as object)
  if not isCurrentTaskEvent(event, m.loadFavoritesTask) then return
  m.isLoadingFavorites = false
  status = m.loadFavoritesTask.status
  itemData = m.loadFavoritesTask.content
  m.loadFavoritesTask.unobserveField("content")
  m.loadFavoritesTask.content = []

  if homeRowFailure.isAnswered(status)
    standInRemoved = removeStandIn()
    applyItems(m.itemTypes, itemData, standInRemoved)
  else
    showItemsFailed()
  end if
  homeRowFailure.noteResult(m.top, "favorites", status)
  finishLoad()
end sub

' The /Persons load finished: an answered one fills or removes the People row, a failed one
' leaves it standing, showing the failed tile if it was still spinning.
sub onFavoritePeopleLoaded(event as object)
  if not isCurrentTaskEvent(event, m.loadFavoritePeopleTask) then return
  m.isLoadingFavoritePeople = false
  status = m.loadFavoritePeopleTask.status
  itemData = m.loadFavoritePeopleTask.content
  m.loadFavoritePeopleTask.unobserveField("content")
  m.loadFavoritePeopleTask.content = []

  if homeRowFailure.isAnswered(status)
    applyItems([PEOPLE_TYPE], itemData, false)
  else
    showRowFailed(PEOPLE_TYPE)
  end if
  homeRowFailure.noteResult(m.top, "favoritePeople", status)
  finishLoad()
end sub

' After either load: the backdrop follows the focused tile, and texture management is active
' so off-screen cells unload. Also after a failure, which may be the only answer this list gets.
' Recalculate the buffer range first: rows may have been added or removed since the first range was set.
sub finishLoad()
  updateBackdropForFocusedItem()
  updateTextureBufferRange(m.top.content, m.top.rowItemFocused[0], m.top.rowItemFocused[1], m.top.numRows)
  activateTextureManager(m.top.content)
end sub

' Groups an answered load's items by type and populates/removes the rows of `types`.
' On refresh, updates existing rows in place to preserve focus position.
'
' @param types - the row types this load fills
' @param itemData - the load's items
' @param sizesChanged - whether rows were already added or removed, so sizes need applying
sub applyItems(types as object, itemData as dynamic, sizesChanged as boolean)
  ' Bucket items by type
  buckets = {}
  for each itemType in types
    buckets[itemType] = []
  end for

  if isValidAndNotEmpty(itemData)
    for each item in itemData
      if buckets.doesExist(item.type)
        buckets[item.type].push(item)
      end if
    end for
  end if

  ' Cap each bucket at 50 items (API returns sorted by SortName)
  maxPerRow = 50
  for each itemType in types
    bucket = buckets[itemType]
    if bucket.count() > maxPerRow
      buckets[itemType] = []
      for i = 0 to maxPerRow - 1
        buckets[itemType].push(bucket[i])
      end for
    end if
  end for

  ' Populate or remove each row
  for each itemType in types
    bucket = buckets[itemType]
    result = findRowBySectionId(itemType)

    if bucket.count() = 0
      ' No items: remove row if it exists (skeleton or populated)
      if isValid(result)
        m.top.content.removeChildIndex(result.index)
        sizesChanged = true
      end if
    else if isValid(result)
      ' Row exists: swap children in place (append new before removing old)
      row = result.row
      oldCount = row.getChildCount()

      for each item in bucket
        row.appendChild(item)
      end for

      for i = oldCount - 1 to 0 step -1
        row.removeChildIndex(i)
      end for
    else
      ' Row was previously removed but now has data: re-insert at correct position
      row = CreateObject("roSGNode", "HomeRow")
      row.title = m.typeLabels[itemType] ?? itemType
      row.sectionId = itemType
      row.cursorSize = getSlotSizeForType(itemType)

      for each item in bucket
        row.appendChild(item)
      end for

      insertIndex = findInsertIndexForType(itemType, m.typeArray)
      m.top.content.insertChild(row, insertIndex)
      sizesChanged = true
    end if
  end for

  if sizesChanged
    applyRowSizes()
  end if
end sub

' The /Items load failed. Rows with items keep them. Rows still spinning (a first load) are
' replaced by one "Favorites" row showing the failed tile, where the first of them was, so the
' failure reads once rather than on up to fifteen rows; a stand-in already there shows the
' failed tile again (homeRowFailure.needsFavoritesStandIn).
sub showItemsFailed()
  states = []
  for each itemType in m.itemTypes
    result = findRowBySectionId(itemType)
    if isValid(result) then states.push(homeRowFailure.placeholderState(result.row))
  end for
  hasStandIn = isValid(findRowBySectionId(FAVORITES_STAND_IN_ID))
  if not homeRowFailure.needsFavoritesStandIn(states, hasStandIn) then return

  if hasStandIn
    showRowFailed(FAVORITES_STAND_IN_ID)
    return
  end if

  ' Remove the spinning rows, bottom up so the indices still to visit do not shift.
  insertIndex = -1
  for i = m.top.content.getChildCount() - 1 to 0 step -1
    row = m.top.content.getChild(i)
    if row.sectionId <> PEOPLE_TYPE and homeRowFailure.placeholderState(row) = homeRowFailure.PLACEHOLDER_LOADING
      m.top.content.removeChildIndex(i)
      insertIndex = i
    end if
  end for

  standIn = CreateObject("roSGNode", "HomeRow")
  standIn.title = translate(translationKeys.LabelFavorites)
  standIn.sectionId = FAVORITES_STAND_IN_ID
  standIn.cursorSize = rowSlotSize.PORTRAIT
  standIn.appendChild(skeleton.createFailedPlaceholder())
  m.top.content.insertChild(standIn, insertIndex)
  applyRowSizes()
end sub

' Drops the stand-in, if there is one: an answered /Items load puts the real rows back.
'
' @return true when a row was removed, so row sizes need applying
function removeStandIn() as boolean
  result = findRowBySectionId(FAVORITES_STAND_IN_ID)
  if not isValid(result) then return false
  m.top.content.removeChildIndex(result.index)
  return true
end function

' A load failed: the row keeps its items, and a row still spinning shows the failed tile
' instead (homeRowFailure.afterFailure). A row that is not on screen stays off it.
sub showRowFailed(sectionId as string)
  result = findRowBySectionId(sectionId)
  if not isValid(result) then return
  if homeRowFailure.afterFailure(homeRowFailure.placeholderState(result.row)) = "" then return
  replacePlaceholder(result.row, skeleton.createFailedPlaceholder())
end sub

' A load is starting again: a row showing the failed tile goes back to the spinner, so the
' retry shows it is under way (homeRowFailure.afterRetry).
sub showRowRetrying(sectionId as string)
  result = findRowBySectionId(sectionId)
  if not isValid(result) then return
  if homeRowFailure.afterRetry(homeRowFailure.placeholderState(result.row)) = "" then return
  replacePlaceholder(result.row, skeleton.createPlaceholder())
end sub

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

' Find a row by its sectionId
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

' Find the correct insert index for a type based on the type ordering
function findInsertIndexForType(itemType as string, typeArray as object) as integer
  ' Find what comes before this type in typeArray and locate it in content
  targetIdx = -1
  for i = 0 to typeArray.count() - 1
    if typeArray[i] = itemType
      targetIdx = i
      exit for
    end if
  end for

  ' Walk backward to find the last preceding type that has a row
  for i = targetIdx - 1 to 0 step -1
    result = findRowBySectionId(typeArray[i])
    if isValid(result)
      return result.index + 1
    end if
  end for

  return 0
end function

' Returns the appropriate slot size for a Jellyfin item type.
' Follows the same logic as SearchRow.bs getSlotSizeForType.
' WIDE  (16:9): Episode, Video, MusicVideo, PhotoAlbum, Photo
' SQUARE (1:1): Music types, Playlist, Program, TvChannel, Recording
' PORTRAIT (2:3): all others (Movie, Series, Person, BoxSet, etc.)
function getSlotSizeForType(itemType as string) as object
  if itemType = "Episode" or itemType = "Video" or itemType = "MusicVideo" or itemType = "PhotoAlbum" or itemType = "Photo"
    return rowSlotSize.WIDE
  else if itemType = "MusicArtist" or itemType = "MusicAlbum" or itemType = "Audio" or itemType = "Playlist" or itemType = "Program" or itemType = "TvChannel" or itemType = "Recording"
    return rowSlotSize.SQUARE
  end if
  return rowSlotSize.PORTRAIT
end function

' Applies per-row slot sizes, heights, and spacings based on each row's cursorSize
sub applyRowSizes()
  if not isValid(m.top.content) then return
  rows = m.top.content.getChildren(-1, 0)
  if rows.count() = 0 then return

  newSizeArray = []
  newRowHeights = []
  newRowSpacings = []

  for each row in rows
    slotSize = isValid(row.cursorSize) ? row.cursorSize : rowSlotSize.PORTRAIT
    newSizeArray.push(slotSize)

    slotHeight = slotSize[1]
    if slotHeight = rowSlotSize.PORTRAIT[1]
      newRowHeights.push(rowSlotSize.ROW_HEIGHT_PORTRAIT)
    else if slotHeight = rowSlotSize.SQUARE[1]
      newRowHeights.push(rowSlotSize.ROW_HEIGHT_SQUARE)
    else
      newRowHeights.push(rowSlotSize.ROW_HEIGHT_WIDE)
    end if

    newRowSpacings.push(60)
  end for

  m.top.rowItemSize = newSizeArray
  m.top.rowHeights = newRowHeights
  m.top.rowSpacings = newRowSpacings
end sub

' ============================================
' ITEM SELECTION, FOCUS, AND KEY EVENTS
' ============================================

function getItemAtIndices(indices as object) as dynamic
  if not isValidAndNotEmpty(indices) or not isValid(m.top.content)
    return invalid
  end if

  if indices[0] < 0 or indices[0] >= m.top.content.getChildCount()
    return invalid
  end if

  row = m.top.content.getChild(indices[0])
  if not isValid(row)
    return invalid
  end if

  if indices[1] < 0 or indices[1] >= row.getChildCount()
    return invalid
  end if

  return row.getChild(indices[1])
end function

sub itemSelected()
  item = getItemAtIndices(m.top.rowItemSelected)
  if not isValid(item) then return

  ' OK on a "couldn't load" tile loads Favorites again, the same reload a return to Home runs:
  ' each load not still running starts again (loadFavorites). Nothing retries on its own: the
  ' viewer asks (decision `home-row-failure-tile`).
  if skeleton.isFailedPlaceholder(item)
    loadFavorites()
    return
  end if

  if skeleton.isPlaceholder(item) then return

  m.top.selectedItem = item
  m.top.selectedItem = invalid
end sub

sub onItemFocused()
  updateTextureBufferRange(m.top.content, m.top.rowItemFocused[0], m.top.rowItemFocused[1], m.top.numRows)
  updateBackdropForFocusedItem()
end sub

sub updateBackdropForFocusedItem()
  focusedItem = getItemAtIndices(m.top.rowItemFocused)

  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"
      itemToPlay = getItemAtIndices(m.top.rowItemFocused)
      if isValid(itemToPlay) and not skeleton.isPlaceholder(itemToPlay)
        m.top.quickPlayNode = itemToPlay
        ' Clear immediately, the same pattern as selectedItem. Prevents stale
        ' value from re-firing when the screen is restored after playback.
        m.top.quickPlayNode = invalid
      end if
      return true
    else if key = "replay"
      indices = m.top.rowItemFocused
      if isValidAndNotEmpty(indices) and indices[0] >= 0
        m.top.jumpToRowItem = [indices[0], 0]
      end if
      return true
    end if
  end if
  return false
end function

' ============================================
' TEARDOWN
' ============================================

sub onDestroy()
  destroyTextureManager(m.top.content)

  m.top.unobserveField("rowItemSelected")
  m.top.unobserveField("rowItemFocused")

  m.loadFavoritesTask = releaseTask(m.loadFavoritesTask, "content")
  m.loadFavoritePeopleTask = releaseTask(m.loadFavoritePeopleTask, "content")
  m.isLoadingFavorites = false
  m.isLoadingFavoritePeople = false
end sub