components_extras_ExtrasRowList.bs

import "pkg:/source/constants/itemAspectRatio.bs"
import "pkg:/source/utils/misc.bs"

sub init()
  m.top.visible = true
  updateSize()
  m.top.observeField("rowItemSelected", "onRowItemSelected")
  m.top.observeField("rowItemFocused", "onRowItemFocused")

  ' Set up all tasks
  m.LoadPeopleTask = CreateObject("roSGNode", "LoadItemsTask")
  m.LoadPeopleTask.itemsToLoad = "people"
  m.LikeThisTask = CreateObject("roSGNode", "LoadItemsTask")
  m.LikeThisTask.itemsToLoad = "likethis"
  m.SpecialFeaturesTask = CreateObject("roSGNode", "LoadItemsTask")
  m.SpecialFeaturesTask.itemsToLoad = "specialfeatures"
  m.LoadAdditionalPartsTask = CreateObject("roSGNode", "LoadItemsTask")
  m.LoadAdditionalPartsTask.itemsToLoad = "additionalparts"
  m.LoadSeasonsTask = CreateObject("roSGNode", "LoadItemsTask")
  m.LoadSeasonsTask.itemsToLoad = "seasons"
  m.LoadBoxSetItemsTask = CreateObject("roSGNode", "LoadItemsTask")
  m.LoadBoxSetItemsTask.itemsToLoad = "boxsetitems"
  m.LoadMoviesTask = CreateObject("roSGNode", "LoadItemsTask")
  m.LoadMoviesTask.itemsToLoad = "personMovies"
  m.LoadShowsTask = CreateObject("roSGNode", "LoadItemsTask")
  m.LoadShowsTask.itemsToLoad = "personTVShows"
  m.LoadSeriesTask = CreateObject("roSGNode", "LoadItemsTask")
  m.LoadSeriesTask.itemsToLoad = "personSeries"

  ' Season episodes task (Episode type — loads all episodes from the current season)
  m.LoadSeasonEpisodesTask = CreateObject("roSGNode", "LoadItemsTask")
  m.LoadSeasonEpisodesTask.itemsToLoad = "seasonEpisodes"

  ' Season episodes task (Season type — loads all episodes for this season in order)
  m.LoadSeasonAllEpisodesTask = CreateObject("roSGNode", "LoadItemsTask")
  m.LoadSeasonAllEpisodesTask.itemsToLoad = "seasonEpisodes"

  ' Permanent root content node — never replaced.
  m.top.content = CreateObject("roSGNode", "ContentNode")

  ' Named row refs — reused across refreshes so the RowList never loses focus position.
  ' Each ref is invalid until that row's data loads for the first time.
  m.rowAdditionalParts = invalid
  m.rowSeasons = invalid
  m.rowEpisodes = invalid
  m.rowSeasonEpisodes = invalid
  m.rowCast = invalid
  m.rowLikeThis = invalid
  m.rowSpecialFeatures = invalid
  m.rowBoxSetItems = invalid
  m.rowMovies = invalid
  m.rowTvShows = invalid
  m.rowSeries = invalid

  ' Track row heights for rowHeights array (per-row height customization)
  m.rowHeights = []
end sub

sub updateSize()
  ' Use maximum row height to accommodate PORTRAIT slots (351px slot + 90px text = 441px)
  ' WIDE rows (264px slot + 90px text = 354px) will have extra padding, which is acceptable
  m.top.itemSize = [1920, rowSlotSize.ROW_HEIGHT_PORTRAIT]

  ' override defaults from JRRowList
  m.top.vertFocusAnimationStyle = "fixedFocus"
end sub

' cancelInFlightChain: Stop all tasks and unobserve their content fields before starting a new chain.
' Prevents observer stacking and halts unnecessary in-flight work (network, processing) when
' loadParts() or loadPersonVideos() is called while a previous chain is still running.
sub cancelInFlightChain()
  m.LoadSeasonsTask.control = "STOP"
  m.LoadSeasonsTask.unobserveField("content")
  m.LoadSeasonAllEpisodesTask.control = "STOP"
  m.LoadSeasonAllEpisodesTask.unobserveField("content")
  m.LoadSeasonEpisodesTask.control = "STOP"
  m.LoadSeasonEpisodesTask.unobserveField("content")
  m.LoadAdditionalPartsTask.control = "STOP"
  m.LoadAdditionalPartsTask.unobserveField("content")
  m.LoadPeopleTask.control = "STOP"
  m.LoadPeopleTask.unobserveField("content")
  m.LikeThisTask.control = "STOP"
  m.LikeThisTask.unobserveField("content")
  m.SpecialFeaturesTask.control = "STOP"
  m.SpecialFeaturesTask.unobserveField("content")
  m.LoadMoviesTask.control = "STOP"
  m.LoadMoviesTask.unobserveField("content")
  m.LoadShowsTask.control = "STOP"
  m.LoadShowsTask.unobserveField("content")
  m.LoadSeriesTask.control = "STOP"
  m.LoadSeriesTask.unobserveField("content")
  m.LoadBoxSetItemsTask.control = "STOP"
  m.LoadBoxSetItemsTask.unobserveField("content")
end sub

' loadParts: Start the extras loading chain appropriate for the item type.
'
' Chain by type:
'   Movie / Video / Recording → AdditionalParts → People → LikeThis → SpecialFeatures
'   MusicVideo / Episode      → People → LikeThis
'   Series                    → Seasons → People → LikeThis
'   Season                    → Episodes ("Season X") → People → LikeThis
'   BoxSet                    → BoxSetItems ("Movies") → People → LikeThis
' m.top.type must be set before calling this function (done by ShowScenes.bs).
sub loadParts(data as object)
  cancelInFlightChain()
  m.rowHeights = []
  m.top.parentId = data.id
  m.people = data.people

  itemType = m.top.type

  if itemType = "Series"
    ' Clear and repopulate the permanent root; seasons will be appended in onSeasonsLoaded
    m.top.rowItemSize = [rowSlotSize.PORTRAIT]
    m.LoadSeasonsTask.observeField("content", "onSeasonsLoaded")
    m.LoadSeasonsTask.itemId = m.top.parentId
    m.LoadSeasonsTask.control = "RUN"
  else if itemType = "Season"
    m.top.rowItemSize = [rowSlotSize.WIDE]
    m.LoadSeasonAllEpisodesTask.observeField("content", "onSeasonAllEpisodesLoaded")
    m.LoadSeasonAllEpisodesTask.itemId = data.seriesId
    m.LoadSeasonAllEpisodesTask.metadata = { seasonId: data.id }
    m.LoadSeasonAllEpisodesTask.control = "RUN"
  else if itemType = "Episode"
    m.currentEpisodeId = data.id
    m.currentEpisodeSeasonNumber = data.parentIndexNumber
    m.episodeSeriesId = data.seriesId
    m.top.rowItemSize = [rowSlotSize.PORTRAIT]
    m.LoadSeasonEpisodesTask.observeField("content", "onSeasonEpisodesLoaded")
    m.LoadSeasonEpisodesTask.itemId = data.seriesId
    m.LoadSeasonEpisodesTask.metadata = { seasonId: data.seasonId }
    m.LoadSeasonEpisodesTask.control = "RUN"
  else if itemType = "MusicVideo"
    m.top.rowItemSize = [rowSlotSize.PORTRAIT]
    m.LoadPeopleTask.observeField("content", "onPeopleLoaded")
    m.LoadPeopleTask.peopleList = m.people
    m.LoadPeopleTask.control = "RUN"
  else if itemType = "BoxSet"
    m.top.rowItemSize = [rowSlotSize.PORTRAIT]
    m.LoadBoxSetItemsTask.itemId = m.top.parentId
    m.LoadBoxSetItemsTask.observeField("content", "onBoxSetItemsLoaded")
    m.LoadBoxSetItemsTask.control = "RUN"
  else
    ' Movie, Video, Recording: full chain starting with AdditionalParts
    m.LoadAdditionalPartsTask.observeField("content", "onAdditionalPartsLoaded")
    m.LoadAdditionalPartsTask.itemId = m.top.parentId
    m.LoadAdditionalPartsTask.control = "RUN"
  end if
end sub

sub loadPersonVideos(personId)
  cancelInFlightChain()
  m.rowHeights = []
  m.personId = personId
  m.top.personHasMedia = false ' Reset before new chain — prevents stale true from a prior run
  m.LoadMoviesTask.itemId = m.personId
  m.LoadMoviesTask.observeField("content", "onMoviesLoaded")
  m.LoadMoviesTask.control = "RUN"
end sub

' onSeasonsLoaded: Build "Seasons" row then chain to People (Series only)
sub onSeasonsLoaded()
  seasons = m.LoadSeasonsTask.content
  m.LoadSeasonsTask.unobserveField("content")

  if isValid(seasons) and seasons.count() > 0
    m.rowSeasons = populateRow(m.rowSeasons, tr("Seasons"), seasons, rowSlotSize.ROW_HEIGHT_PORTRAIT)
  else if isValid(m.rowSeasons)
    m.top.content.removeChild(m.rowSeasons)
    m.rowSeasons = invalid
  end if

  m.LoadPeopleTask.observeField("content", "onPeopleLoaded")
  m.LoadPeopleTask.peopleList = m.people
  m.LoadPeopleTask.control = "RUN"
end sub

' onSeasonEpisodesLoaded: Build "More from Season X" row with current episode first,
' then episodes that follow it in order, then episodes that precede it (wrapping around).
' E.g. viewing ep 3 of 9 → [3, 4, 5, 6, 7, 8, 9, 1, 2]
sub onSeasonEpisodesLoaded()
  episodes = m.LoadSeasonEpisodesTask.content
  m.LoadSeasonEpisodesTask.unobserveField("content")

  if isValid(episodes) and episodes.count() > 0
    ' Split into: current episode, episodes after it, episodes before it
    current = invalid
    before = []
    after = []
    foundCurrent = false
    for each ep in episodes
      if ep.id = m.currentEpisodeId
        current = ep
        foundCurrent = true
      else if foundCurrent
        after.push(ep)
      else
        before.push(ep)
      end if
    end for

    orderedEpisodes = []
    if isValid(current) then orderedEpisodes.push(current)
    for each ep in after
      orderedEpisodes.push(ep)
    end for
    for each ep in before
      orderedEpisodes.push(ep)
    end for

    if orderedEpisodes.count() > 0
      if isValid(m.currentEpisodeSeasonNumber) and m.currentEpisodeSeasonNumber > 0
        rowTitle = tr("More from Season %1").Replace("%1", stri(m.currentEpisodeSeasonNumber).trim())
      else
        rowTitle = tr("More Episodes")
      end if
      m.top.rowItemSize = [rowSlotSize.WIDE, rowSlotSize.PORTRAIT]
      m.rowSeasonEpisodes = populateRow(m.rowSeasonEpisodes, rowTitle, orderedEpisodes, rowSlotSize.ROW_HEIGHT_WIDE)
    else if isValid(m.rowSeasonEpisodes)
      m.top.content.removeChild(m.rowSeasonEpisodes)
      m.rowSeasonEpisodes = invalid
    end if
  else if isValid(m.rowSeasonEpisodes)
    m.top.content.removeChild(m.rowSeasonEpisodes)
    m.rowSeasonEpisodes = invalid
  end if

  m.LoadPeopleTask.observeField("content", "onPeopleLoaded")
  m.LoadPeopleTask.peopleList = m.people
  m.LoadPeopleTask.control = "RUN"
end sub

' onSeasonAllEpisodesLoaded: Build "Episodes" row then chain to People (Season only)
sub onSeasonAllEpisodesLoaded()
  episodes = m.LoadSeasonAllEpisodesTask.content
  m.LoadSeasonAllEpisodesTask.unobserveField("content")

  if isValid(episodes) and episodes.count() > 0
    m.top.rowItemSize = [rowSlotSize.WIDE, rowSlotSize.PORTRAIT]
    m.rowEpisodes = populateRow(m.rowEpisodes, tr("Episodes"), episodes, rowSlotSize.ROW_HEIGHT_WIDE)
  else if isValid(m.rowEpisodes)
    m.top.content.removeChild(m.rowEpisodes)
    m.rowEpisodes = invalid
  end if

  m.LoadPeopleTask.observeField("content", "onPeopleLoaded")
  m.LoadPeopleTask.peopleList = m.people
  m.LoadPeopleTask.control = "RUN"
end sub

' onBoxSetItemsLoaded: Build "Movies" row then chain to People → LikeThis (BoxSet only)
sub onBoxSetItemsLoaded()
  content = m.LoadBoxSetItemsTask.content
  m.LoadBoxSetItemsTask.unobserveField("content")
  m.LoadBoxSetItemsTask.content = []

  if isValid(content) and content.count() > 0
    m.rowBoxSetItems = populateRow(m.rowBoxSetItems, tr("Movies"), content, rowSlotSize.ROW_HEIGHT_PORTRAIT)
  else if isValid(m.rowBoxSetItems)
    m.top.content.removeChild(m.rowBoxSetItems)
    m.rowBoxSetItems = invalid
  end if

  ' Continue chain: People → LikeThis
  m.LoadPeopleTask.observeField("content", "onPeopleLoaded")
  m.LoadPeopleTask.peopleList = m.people
  m.LoadPeopleTask.control = "RUN"
end sub

sub onAdditionalPartsLoaded()
  parts = m.LoadAdditionalPartsTask.content
  m.LoadAdditionalPartsTask.unobserveField("content")

  if isValid(parts) and parts.count() > 0
    m.rowAdditionalParts = populateRow(m.rowAdditionalParts, tr("Additional Parts"), parts, rowSlotSize.ROW_HEIGHT_PORTRAIT)
    m.top.rowItemSize = [rowSlotSize.PORTRAIT]
  else
    if isValid(m.rowAdditionalParts)
      m.top.content.removeChild(m.rowAdditionalParts)
      m.rowAdditionalParts = invalid
    end if
    m.top.rowItemSize = [rowSlotSize.PORTRAIT]
  end if

  ' Load Cast and Crew and everything else...
  m.LoadPeopleTask.observeField("content", "onPeopleLoaded")
  m.LoadPeopleTask.peopleList = m.people
  m.LoadPeopleTask.control = "RUN"
end sub

sub onPeopleLoaded()
  people = m.LoadPeopleTask.content
  m.loadPeopleTask.unobserveField("content")
  if isValid(people) and people.count() > 0
    m.rowCast = populateRow(m.rowCast, tr("Cast & Crew"), people, rowSlotSize.ROW_HEIGHT_PORTRAIT)
    addRowSize(rowSlotSize.PORTRAIT)
  else if isValid(m.rowCast)
    m.top.content.removeChild(m.rowCast)
    m.rowCast = invalid
  end if
  m.LikeThisTask.observeField("content", "onLikeThisLoaded")
  ' For Episode, query similar items against the parent series since Jellyfin returns
  ' no results for individual episode IDs.
  if m.top.type = "Episode" and isValid(m.episodeSeriesId) and m.episodeSeriesId <> ""
    m.LikeThisTask.itemId = m.episodeSeriesId
  else
    m.LikeThisTask.itemId = m.top.parentId
  end if
  m.LikeThisTask.control = "RUN"
end sub

sub onLikeThisLoaded()
  data = m.LikeThisTask.content
  m.LikeThisTask.unobserveField("content")
  itemType = m.top.type
  if isValid(data) and data.count() > 0
    ' MusicVideo and Video items are always landscape (≥4:3) and have no portrait posters —
    ' use a wide slot so the image URL logic fetches Thumb/Backdrop instead of Primary.
    if itemType = "MusicVideo" or itemType = "Video"
      m.rowLikeThis = populateRow(m.rowLikeThis, tr("More Like This"), data, rowSlotSize.ROW_HEIGHT_WIDE)
      ' Cannot use addRowSize() here. loadParts() seeds rowItemSize with a phantom PORTRAIT
      ' element before any rows exist, which offsets all addRowSize() indices by one and would
      ' leave this row still mapped to PORTRAIT. Instead, rebuild the array from the actual
      ' content child count: PORTRAIT for every preceding row, WIDE for this (final) row.
      newSizes = []
      childCount = m.top.content.getChildCount()
      for i = 0 to childCount - 2
        newSizes.push(rowSlotSize.PORTRAIT)
      end for
      newSizes.push(rowSlotSize.WIDE)
      m.top.rowItemSize = newSizes
    else
      m.rowLikeThis = populateRow(m.rowLikeThis, tr("More Like This"), data, rowSlotSize.ROW_HEIGHT_PORTRAIT)
      addRowSize(rowSlotSize.PORTRAIT)
    end if
  else if isValid(m.rowLikeThis)
    m.top.content.removeChild(m.rowLikeThis)
    m.rowLikeThis = invalid
  end if

  ' SpecialFeatures only for Movie / Video / Recording — not MusicVideo, Episode, Series, Season, or BoxSet
  if itemType <> "MusicVideo" and itemType <> "Episode" and itemType <> "Series" and itemType <> "Season" and itemType <> "BoxSet"
    m.SpecialFeaturesTask.observeField("content", "onSpecialFeaturesLoaded")
    m.SpecialFeaturesTask.itemId = m.top.parentId
    m.SpecialFeaturesTask.control = "RUN"
  end if
end sub

function onSpecialFeaturesLoaded()
  data = m.SpecialFeaturesTask.content
  m.SpecialFeaturesTask.unobserveField("content")
  if isValid(data) and data.count() > 0
    m.rowSpecialFeatures = populateRow(m.rowSpecialFeatures, tr("Special Features"), data, rowSlotSize.ROW_HEIGHT_WIDE)
    m.top.visible = true
    addRowSize(rowSlotSize.WIDE)
  else if isValid(m.rowSpecialFeatures)
    m.top.content.removeChild(m.rowSpecialFeatures)
    m.rowSpecialFeatures = invalid
  end if

  return m.top.content
end function

sub onMoviesLoaded()
  data = m.LoadMoviesTask.content
  m.LoadMoviesTask.unobserveField("content")
  if isValid(data) and data.count() > 0
    m.rowMovies = populateRow(m.rowMovies, tr("Movies"), data, rowSlotSize.ROW_HEIGHT_PORTRAIT)
    m.top.rowItemSize = [rowSlotSize.PORTRAIT]
    ' Signal early — we already know there is media, no need to wait for the full chain
    m.top.personHasMedia = true
  else if isValid(m.rowMovies)
    m.top.content.removeChild(m.rowMovies)
    m.rowMovies = invalid
  end if
  m.LoadShowsTask.itemId = m.personId
  m.LoadShowsTask.observeField("content", "onShowsLoaded")
  m.LoadShowsTask.control = "RUN"
end sub

sub onShowsLoaded()
  data = m.LoadShowsTask.content
  m.LoadShowsTask.unobserveField("content")
  if isValid(data) and data.count() > 0
    ' personTVShows loads Episode items — use WIDE slot for episode screenshots
    m.rowTvShows = populateRow(m.rowTvShows, tr("Episodes"), data, rowSlotSize.ROW_HEIGHT_WIDE)
    addRowSize(rowSlotSize.WIDE)
    ' Signal early — we already know there is media, no need to wait for the full chain
    m.top.personHasMedia = true
  else if isValid(m.rowTvShows)
    m.top.content.removeChild(m.rowTvShows)
    m.rowTvShows = invalid
  end if
  m.LoadSeriesTask.itemId = m.personId
  m.LoadSeriesTask.observeField("content", "onSeriesLoaded")
  m.LoadSeriesTask.control = "RUN"
end sub

sub onSeriesLoaded()
  data = m.LoadSeriesTask.content
  m.LoadSeriesTask.unobserveField("content")
  if isValid(data) and data.count() > 0
    m.rowSeries = populateRow(m.rowSeries, tr("Series"), data, rowSlotSize.ROW_HEIGHT_PORTRAIT)
    addRowSize(rowSlotSize.PORTRAIT)
  else if isValid(m.rowSeries)
    m.top.content.removeChild(m.rowSeries)
    m.rowSeries = invalid
  end if
  m.top.visible = true
  ' Final authoritative signal — covers the false case (no media at all) and the Series-only case.
  ' onPersonHasMediaChanged guards against adding a duplicate button if already signalled true above.
  m.top.personHasMedia = isValid(m.rowMovies) or isValid(m.rowTvShows) or isValid(m.rowSeries)
end sub

' populateRow: Reuse an existing row ContentNode (clearing its children) or create a new one.
' Creating appends the row to m.top.content at the current end — correct since the async chain
' fires in display order. m.rowHeights is reset at chain start and rebuilt here on every call,
' keeping it in sync with m.top.content across refreshes where rows may appear or disappear.
' @param rowHeight - height for this specific row (from rowSlotSize.ROW_HEIGHT_* constants)
' Returns the row for the caller to store as a named ref.
function populateRow(rowRef as object, title as string, items as object, rowHeight as integer) as object
  if isValid(rowRef)
    rowRef.removeChildrenIndex(rowRef.getChildCount(), 0)
  else
    rowRef = m.top.content.createChild("ContentNode")
  end if
  ' Always push — m.rowHeights is reset at chain start so this rebuilds in display order each run.
  ' Covers both reuse (row exists in content) and create (just appended) paths.
  m.rowHeights.push(rowHeight)
  m.top.rowHeights = m.rowHeights
  rowRef.Title = title
  for each item in items
    rowRef.appendChild(item)
  end for
  return rowRef
end function

sub addRowSize(newRow)
  sizeArray = m.top.rowItemSize
  newSizeArray = []
  for each size in sizeArray
    newSizeArray.push(size)
  end for
  newSizeArray.push(newRow)
  m.top.rowItemSize = newSizeArray
end sub

sub onRowItemSelected()
  m.top.selectedItem = m.top.content.getChild(m.top.rowItemSelected[0]).getChild(m.top.rowItemSelected[1])
end sub

sub onRowItemFocused()
  m.top.focusedItem = m.top.content.getChild(m.top.rowItemFocused[0]).getChild(m.top.rowItemFocused[1])
end sub