components_extras_ExtrasRowList.bs

import "pkg:/source/constants/extrasLayout.bs"
import "pkg:/source/constants/itemAspectRatio.bs"
import "pkg:/source/constants/itemTypeOrder.bs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/chapterItems.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"

' Default RowList translation.y — places the focused row (TOP) 18px below the panel top edge.
const EXTRAS_ROWLIST_DEFAULT_Y = 18
' Extra clearance so the focused row's bottom edge sits comfortably above the 5% action-safe zone.
' Adjusting this shifts BOTTOM and MIDDLE positions equally relative to the action zone.
const EXTRAS_BOTTOM_SAFE_PADDING = 18

sub init()
  m.top.visible = true
  m.top.numRows = 1
  m.top.translation = [0, EXTRAS_ROWLIST_DEFAULT_Y]
  m.top.targetTranslationY = EXTRAS_ROWLIST_DEFAULT_Y
  updateSize()
  ' Cache row spacing from JRRowList so updatePanelPosition stays in sync with the rendered spacing.
  m.rowSpacing = m.top.itemSpacing[1]
  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.LoadArtistAlbumsTask = CreateObject("roSGNode", "LoadItemsTask")
  m.LoadArtistAlbumsTask.itemsToLoad = "artistAlbums"
  m.LoadArtistAppearsOnTask = CreateObject("roSGNode", "LoadItemsTask")
  m.LoadArtistAppearsOnTask.itemsToLoad = "artistAppearsOn"
  m.LoadArtistSongsTask = CreateObject("roSGNode", "LoadItemsTask")
  m.LoadArtistSongsTask.itemsToLoad = "artistSongs"
  m.LoadArtistSimilarTask = CreateObject("roSGNode", "LoadItemsTask")
  m.LoadArtistSimilarTask.itemsToLoad = "artistSimilar"
  m.LoadAlbumSongsTask = CreateObject("roSGNode", "LoadItemsTask")
  m.LoadAlbumSongsTask.itemsToLoad = "albumSongs"
  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"

  ' Playlist items task — loads all items in a Playlist sorted by date added descending
  m.LoadPlaylistItemsTask = CreateObject("roSGNode", "LoadItemsTask")
  m.LoadPlaylistItemsTask.itemsToLoad = "playlistItems"

  ' PhotoAlbum items task — loads all photos in a PhotoAlbum
  m.LoadPhotoAlbumItemsTask = CreateObject("roSGNode", "LoadItemsTask")
  m.LoadPhotoAlbumItemsTask.itemsToLoad = "photoAlbumItems"

  ' Channel programs task — loads upcoming programs on a Live TV channel
  m.LoadChannelProgramsTask = CreateObject("roSGNode", "LoadItemsTask")
  m.LoadChannelProgramsTask.itemsToLoad = "channelPrograms"

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

  ' 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.rowChapters = invalid
  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
  m.rowArtistAlbums = invalid
  m.rowArtistAppearsOn = invalid
  m.rowArtistSongs = invalid
  m.rowAlbumSongs = invalid
  m.rowAlbumArtistAlbums = invalid
  m.rowPlaylistItems = invalid
  m.rowPhotoAlbumItems = invalid
  m.rowChannelPrograms = invalid

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

  ' Track the last focused row index so horizontal item scrolling within a row
  ' does not trigger an unnecessary panel position recalculation.
  m.lastFocusedRowIndex = -1

  ' Refetch the Channel Programs row (TvChannel "Up Next" / Program "More on
  ' this Channel") when the base class's progress tick detects an expired
  ' broadcast. m.isRefetchingChannelPrograms debounces repeated expiry signals
  ' while a refetch is already in flight.
  m.isRefetchingChannelPrograms = false
  m.top.observeField("programsExpired", "onProgramsExpired")
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]

  ' floatingFocus: when navigating to a new row, the previous row remains visible above the
  ' focused row within the RowList's visible area. fixedFocus always scrolls the previous row
  ' off-screen (focus snaps to RowList Y=0), so the peek effect is impossible with fixedFocus.
  m.top.vertFocusAnimationStyle = "floatingFocus"
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")
  m.LoadArtistAlbumsTask.control = "STOP"
  m.LoadArtistAlbumsTask.unobserveField("content")
  m.LoadArtistAppearsOnTask.control = "STOP"
  m.LoadArtistAppearsOnTask.unobserveField("content")
  m.LoadArtistSongsTask.control = "STOP"
  m.LoadArtistSongsTask.unobserveField("content")
  m.LoadArtistSimilarTask.control = "STOP"
  m.LoadArtistSimilarTask.unobserveField("content")
  m.LoadAlbumSongsTask.control = "STOP"
  m.LoadAlbumSongsTask.unobserveField("content")
  m.LoadPlaylistItemsTask.control = "STOP"
  m.LoadPlaylistItemsTask.unobserveField("content")
  m.LoadPhotoAlbumItemsTask.control = "STOP"
  m.LoadPhotoAlbumItemsTask.unobserveField("content")
  m.LoadChannelProgramsTask.control = "STOP"
  m.LoadChannelProgramsTask.unobserveField("content")
end sub

' loadParts: Start the extras loading chain appropriate for the item type.
'
' Chain by type:
'   Movie / Video / Recording → Chapters → AdditionalParts → People → LikeThis → SpecialFeatures
'   Episode                   → Chapters → SeasonEpisodes → People → LikeThis
'   MusicVideo                → People → LikeThis
'   Series                    → Seasons → People → LikeThis
'   Season                    → Episodes ("Season X") → People → LikeThis
'   BoxSet                    → BoxSetItems ("Movies") → People → LikeThis
'   MusicArtist               → Albums → Appears On → Songs → ArtistSimilar
'   Photo                     → LikeThis
'   PhotoAlbum                → Album Photos → LikeThis
'   TvChannel                 → Upcoming Programs → LikeThis
'   Program                   → More on Channel → LikeThis
' m.top.type must be set before calling this function (done by ShowScenes.bs).
sub loadParts(data as object)
  cancelInFlightChain()
  m.rowHeights = []
  m.lastFocusedRowIndex = -1
  m.top.translation = [0, EXTRAS_ROWLIST_DEFAULT_Y]
  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

    ' Chapters row (synchronous — chapter data already on the item node)
    chapterItems = buildChapterItems(data)
    if chapterItems.count() > 0
      m.top.rowItemSize = [rowSlotSize.WIDE, rowSlotSize.PORTRAIT]
      m.rowChapters = populateRow(m.rowChapters, translate(translationKeys.LabelChapters), chapterItems, rowSlotSize.ROW_HEIGHT_WIDE)
    else
      if isValid(m.rowChapters)
        removeRow(m.rowChapters)
        m.rowChapters = invalid
      end if
      m.top.rowItemSize = [rowSlotSize.PORTRAIT]
    end if

    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 if itemType = "MusicArtist"
    ' Chain: Albums → Appears On → Songs → More Like This
    m.top.rowItemSize = [rowSlotSize.SQUARE]
    m.LoadArtistAlbumsTask.itemId = m.top.parentId
    m.LoadArtistAlbumsTask.observeField("content", "onArtistAlbumsLoaded")
    m.LoadArtistAlbumsTask.control = "RUN"
  else if itemType = "MusicAlbum"
    ' Chain: Songs → More Albums by Artist → More Like This
    ' Store current album ID so we can exclude it from the "More Albums by Artist" row
    m.currentAlbumId = data.id
    m.currentAlbumArtistId = ""
    if isValid(data.albumArtists) and data.albumArtists.count() > 0
      firstArtist = data.albumArtists[0]
      if isValid(firstArtist) and isValid(firstArtist.Id)
        m.currentAlbumArtistId = firstArtist.Id
      end if
    end if
    m.top.rowItemSize = [rowSlotSize.SQUARE]
    m.LoadAlbumSongsTask.itemId = m.top.parentId
    m.LoadAlbumSongsTask.observeField("content", "onAlbumSongsLoaded")
    m.LoadAlbumSongsTask.control = "RUN"
  else if itemType = "Playlist"
    m.top.playlistContentKind = "unknown" ' Reset before chain — prevents stale kind from a prior run
    ' Chain: Playlist Items → More Like This
    ' Slot type is determined by item content (Audio → SQUARE, mixed → PORTRAIT), so we cannot
    ' set rowItemSize to a final value here. Reset to [] now so no stale values from a previous
    ' chain can leak into addRowSize() calls later in onPlaylistItemsLoaded/onLikeThisLoaded.
    m.top.rowItemSize = []
    m.LoadPlaylistItemsTask.itemId = m.top.parentId
    m.LoadPlaylistItemsTask.observeField("content", "onPlaylistItemsLoaded")
    m.LoadPlaylistItemsTask.control = "RUN"
  else if itemType = "Audio"
    ' Chain: Album Tracklist (current track first) → More Like This
    m.currentAudioId = data.id
    m.currentAlbumName = data.albumName
    m.top.rowItemSize = [rowSlotSize.SQUARE]
    if isValidAndNotEmpty(data.albumId)
      m.LoadAlbumSongsTask.itemId = data.albumId
      m.LoadAlbumSongsTask.observeField("content", "onAudioAlbumTracksLoaded")
      m.LoadAlbumSongsTask.control = "RUN"
    else
      ' No album — skip tracklist row and go straight to More Like This
      m.LikeThisTask.itemId = m.top.parentId
      m.LikeThisTask.observeField("content", "onLikeThisLoaded")
      m.LikeThisTask.control = "RUN"
    end if
  else if itemType = "Photo"
    ' Chain: More Like This
    m.top.rowItemSize = [rowSlotSize.WIDE]
    m.LikeThisTask.itemId = m.top.parentId
    m.LikeThisTask.observeField("content", "onLikeThisLoaded")
    m.LikeThisTask.control = "RUN"
  else if itemType = "PhotoAlbum"
    ' Chain: Album Photos → More Like This
    m.top.rowItemSize = [rowSlotSize.WIDE]
    m.LoadPhotoAlbumItemsTask.itemId = m.top.parentId
    m.LoadPhotoAlbumItemsTask.observeField("content", "onPhotoAlbumItemsLoaded")
    m.LoadPhotoAlbumItemsTask.control = "RUN"
  else if itemType = "TvChannel"
    ' Chain: Upcoming Programs → More Like This
    m.top.rowItemSize = [rowSlotSize.SQUARE]
    m.LoadChannelProgramsTask.itemId = m.top.parentId
    m.LoadChannelProgramsTask.observeField("content", "onChannelProgramsLoaded")
    m.LoadChannelProgramsTask.control = "RUN"
  else if itemType = "Program"
    ' Chain: More on this Channel → More Like This
    m.programChannelId = data.channelId
    m.top.rowItemSize = [rowSlotSize.SQUARE]
    if isValidAndNotEmpty(data.channelId)
      m.LoadChannelProgramsTask.itemId = data.channelId
      m.LoadChannelProgramsTask.observeField("content", "onProgramChannelProgramsLoaded")
      m.LoadChannelProgramsTask.control = "RUN"
    else
      ' No channel — skip to More Like This
      m.LikeThisTask.itemId = m.top.parentId
      m.LikeThisTask.observeField("content", "onLikeThisLoaded")
      m.LikeThisTask.control = "RUN"
    end if
  else
    ' Movie, Video, Recording: Chapters (sync) → AdditionalParts → People → LikeThis → SpecialFeatures
    chapterItems = buildChapterItems(data)
    if chapterItems.count() > 0
      m.top.rowItemSize = [rowSlotSize.WIDE]
      m.rowChapters = populateRow(m.rowChapters, translate(translationKeys.LabelChapters), chapterItems, rowSlotSize.ROW_HEIGHT_WIDE)
    else if isValid(m.rowChapters)
      removeRow(m.rowChapters)
      m.rowChapters = invalid
    end if

    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.lastFocusedRowIndex = -1
  m.top.translation = [0, EXTRAS_ROWLIST_DEFAULT_Y]
  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, translate(translationKeys.LabelSeasons), seasons, rowSlotSize.ROW_HEIGHT_PORTRAIT)
  else if isValid(m.rowSeasons)
    removeRow(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 = []
    isFoundCurrent = false
    for each ep in episodes
      if ep.id = m.currentEpisodeId
        current = ep
        isFoundCurrent = true
      else if isFoundCurrent
        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 = translate(translationKeys.MessageMoreFromSeason1, [stri(m.currentEpisodeSeasonNumber).trim()])
      else
        rowTitle = translate(translationKeys.LabelMoreEpisodes)
      end if
      if isValid(m.rowChapters)
        m.top.rowItemSize = [rowSlotSize.WIDE, rowSlotSize.WIDE, rowSlotSize.PORTRAIT]
      else
        m.top.rowItemSize = [rowSlotSize.WIDE, rowSlotSize.PORTRAIT]
      end if
      m.rowSeasonEpisodes = populateRow(m.rowSeasonEpisodes, rowTitle, orderedEpisodes, rowSlotSize.ROW_HEIGHT_WIDE)
    else if isValid(m.rowSeasonEpisodes)
      removeRow(m.rowSeasonEpisodes)
      m.rowSeasonEpisodes = invalid
    end if
  else if isValid(m.rowSeasonEpisodes)
    removeRow(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, translate(translationKeys.LabelEpisodes), episodes, rowSlotSize.ROW_HEIGHT_WIDE)
  else if isValid(m.rowEpisodes)
    removeRow(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, translate(translationKeys.LabelMovies), content, rowSlotSize.ROW_HEIGHT_PORTRAIT)
  else if isValid(m.rowBoxSetItems)
    removeRow(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, translate(translationKeys.LabelAdditionalParts), parts, rowSlotSize.ROW_HEIGHT_PORTRAIT)
  else if isValid(m.rowAdditionalParts)
    removeRow(m.rowAdditionalParts)
    m.rowAdditionalParts = invalid
  end if

  ' Rebuild rowItemSize: WIDE for chapters row (if present), PORTRAIT for everything after
  if isValid(m.rowChapters)
    m.top.rowItemSize = [rowSlotSize.WIDE, rowSlotSize.PORTRAIT]
  else
    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

' onPlaylistItemsLoaded: Build "Playlist Items" row then chain to More Like This (Playlist only).
' Detects whether items are all audio, all video, or mixed, and selects SQUARE vs PORTRAIT slots accordingly.
' Sets playlistContentKind ("audio"/"video"/"mixed"/"unknown") so ItemDetails can add the Watched button asynchronously.
sub onPlaylistItemsLoaded()
  items = m.LoadPlaylistItemsTask.content
  m.LoadPlaylistItemsTask.unobserveField("content")
  m.LoadPlaylistItemsTask.content = []

  ' Reset before branching — ensures addRowSize() in onLikeThisLoaded() always starts from a
  ' clean slate whether items are present or empty. The items branches below set the real value.
  m.top.rowItemSize = []

  hasVideos = false
  isAllAudio = false
  if isValid(items) and items.count() > 0
    isAllAudio = true ' assume all-audio until a non-Audio item is found
    for each item in items
      if item.type <> "Audio"
        isAllAudio = false
        if item.type = "Movie" or item.type = "Episode" or item.type = "Video" or item.type = "MusicVideo" or item.type = "Recording"
          hasVideos = true
        end if
      end if
    end for
  end if

  if isValid(items) and items.count() > 0
    if isAllAudio
      m.top.rowItemSize = [rowSlotSize.SQUARE]
      m.rowPlaylistItems = populateRow(m.rowPlaylistItems, translate(translationKeys.LabelPlaylistItems), items, rowSlotSize.ROW_HEIGHT_SQUARE)
    else
      m.top.rowItemSize = [rowSlotSize.PORTRAIT]
      m.rowPlaylistItems = populateRow(m.rowPlaylistItems, translate(translationKeys.LabelPlaylistItems), items, rowSlotSize.ROW_HEIGHT_PORTRAIT)
    end if
  else if isValid(m.rowPlaylistItems)
    removeRow(m.rowPlaylistItems)
    m.rowPlaylistItems = invalid
  end if

  ' Derive content kind: "video" / "audio" / "mixed" / "unknown" (empty or load failure).
  ' "unknown" keeps ItemDetails in the safe default state (no Watched button, "Items" label).
  if not isValid(items) or items.count() = 0
    kind = "unknown"
  else if hasVideos
    kind = "video"
  else if isAllAudio
    kind = "audio"
  else
    kind = "mixed"
  end if
  m.top.playlistContentKind = kind

  m.LikeThisTask.itemId = m.top.parentId
  m.LikeThisTask.observeField("content", "onLikeThisLoaded")
  m.LikeThisTask.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, translate(translationKeys.LabelCastCrew), people, rowSlotSize.ROW_HEIGHT_PORTRAIT)
    addRowSize(rowSlotSize.PORTRAIT)
  else if isValid(m.rowCast)
    removeRow(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, translate(translationKeys.LabelMoreLikeThis), 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
        ' Chapters row (index 0 when present) uses WIDE; all other preceding rows use PORTRAIT
        if i = 0 and isValid(m.rowChapters)
          newSizes.push(rowSlotSize.WIDE)
        else
          newSizes.push(rowSlotSize.PORTRAIT)
        end if
      end for
      newSizes.push(rowSlotSize.WIDE)
      m.top.rowItemSize = newSizes
    else if itemType = "Photo" or itemType = "PhotoAlbum"
      ' Photo/PhotoAlbum use WIDE slots (landscape images). Rebuild from actual content child
      ' count with WIDE for all rows — the preceding Photos row (if any) is also WIDE.
      m.rowLikeThis = populateRow(m.rowLikeThis, translate(translationKeys.LabelMoreLikeThis), data, rowSlotSize.ROW_HEIGHT_WIDE)
      newSizes = []
      childCount = m.top.content.getChildCount()
      for i = 0 to childCount - 1
        newSizes.push(rowSlotSize.WIDE)
      end for
      m.top.rowItemSize = newSizes
    else if itemType = "MusicArtist" or itemType = "MusicAlbum" or itemType = "Audio" or itemType = "Playlist" or itemType = "Program" or itemType = "TvChannel"
      ' Music, Playlist, Program, and TvChannel rows use square slots — primary images are 1:1.
      m.rowLikeThis = populateRow(m.rowLikeThis, translate(translationKeys.LabelMoreLikeThis), data, rowSlotSize.ROW_HEIGHT_SQUARE)
      addRowSize(rowSlotSize.SQUARE)
    else
      m.rowLikeThis = populateRow(m.rowLikeThis, translate(translationKeys.LabelMoreLikeThis), data, rowSlotSize.ROW_HEIGHT_PORTRAIT)
      addRowSize(rowSlotSize.PORTRAIT)
    end if
  else if isValid(m.rowLikeThis)
    removeRow(m.rowLikeThis)
    m.rowLikeThis = invalid
  end if

  ' SpecialFeatures only for Movie / Video / Recording
  if not inArray(itemTypeOrder.NO_SPECIAL_FEATURES, itemType)
    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, translate(translationKeys.LabelSpecialFeatures), data, rowSlotSize.ROW_HEIGHT_WIDE)
    m.top.visible = true
    addRowSize(rowSlotSize.WIDE)
  else if isValid(m.rowSpecialFeatures)
    removeRow(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, translate(translationKeys.LabelMovies), 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)
    removeRow(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, translate(translationKeys.LabelEpisodes), 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)
    removeRow(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, translate(translationKeys.LabelSeries), data, rowSlotSize.ROW_HEIGHT_PORTRAIT)
    addRowSize(rowSlotSize.PORTRAIT)
  else if isValid(m.rowSeries)
    removeRow(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

' onArtistAlbumsLoaded: Build "Albums" row then chain to Appears On (MusicArtist only)
sub onArtistAlbumsLoaded()
  content = m.LoadArtistAlbumsTask.content
  m.LoadArtistAlbumsTask.unobserveField("content")
  m.LoadArtistAlbumsTask.content = []

  if isValid(content) and content.count() > 0
    m.rowArtistAlbums = populateRow(m.rowArtistAlbums, translate(translationKeys.LabelAlbums), content, rowSlotSize.ROW_HEIGHT_SQUARE)
    addRowSize(rowSlotSize.SQUARE)
  else if isValid(m.rowArtistAlbums)
    removeRow(m.rowArtistAlbums)
    m.rowArtistAlbums = invalid
  end if

  m.LoadArtistAppearsOnTask.itemId = m.top.parentId
  m.LoadArtistAppearsOnTask.observeField("content", "onArtistAppearsOnLoaded")
  m.LoadArtistAppearsOnTask.control = "RUN"
end sub

' onArtistAppearsOnLoaded: Build "Appears On" row then chain to Songs (MusicArtist only)
sub onArtistAppearsOnLoaded()
  content = m.LoadArtistAppearsOnTask.content
  m.LoadArtistAppearsOnTask.unobserveField("content")
  m.LoadArtistAppearsOnTask.content = []

  if isValid(content) and content.count() > 0
    m.rowArtistAppearsOn = populateRow(m.rowArtistAppearsOn, translate(translationKeys.LabelAppearsOn), content, rowSlotSize.ROW_HEIGHT_SQUARE)
    addRowSize(rowSlotSize.SQUARE)
  else if isValid(m.rowArtistAppearsOn)
    removeRow(m.rowArtistAppearsOn)
    m.rowArtistAppearsOn = invalid
  end if

  m.LoadArtistSongsTask.itemId = m.top.parentId
  m.LoadArtistSongsTask.observeField("content", "onArtistSongsLoaded")
  m.LoadArtistSongsTask.control = "RUN"
end sub

' onArtistSongsLoaded: Build "Songs" row then chain to ArtistSimilar (MusicArtist only).
' Uses ArtistIds (broad) so songs on compilation albums surface even when album-level
' metadata doesn't associate the artist via AlbumArtistIds.
sub onArtistSongsLoaded()
  content = m.LoadArtistSongsTask.content
  m.LoadArtistSongsTask.unobserveField("content")
  m.LoadArtistSongsTask.content = []

  if isValid(content) and content.count() > 0
    m.rowArtistSongs = populateRow(m.rowArtistSongs, translate(translationKeys.LabelSongs), content, rowSlotSize.ROW_HEIGHT_SQUARE)
    addRowSize(rowSlotSize.SQUARE)
  else if isValid(m.rowArtistSongs)
    removeRow(m.rowArtistSongs)
    m.rowArtistSongs = invalid
  end if

  m.LoadArtistSimilarTask.itemId = m.top.parentId
  m.LoadArtistSimilarTask.observeField("content", "onArtistSimilarLoaded")
  m.LoadArtistSimilarTask.control = "RUN"
end sub

' onArtistSimilarLoaded: Build "More Like This" row (MusicArtist only, end of chain).
' Uses Artists/{id}/Similar which is more reliable for MusicArtist than Items/{id}/Similar.
sub onArtistSimilarLoaded()
  data = m.LoadArtistSimilarTask.content
  m.LoadArtistSimilarTask.unobserveField("content")
  if isValid(data) and data.count() > 0
    m.rowLikeThis = populateRow(m.rowLikeThis, translate(translationKeys.LabelMoreLikeThis), data, rowSlotSize.ROW_HEIGHT_SQUARE)
    addRowSize(rowSlotSize.SQUARE)
  else if isValid(m.rowLikeThis)
    removeRow(m.rowLikeThis)
    m.rowLikeThis = invalid
  end if
end sub

' onAlbumSongsLoaded: Build "Songs" row then chain to More Albums by Artist → More Like This (MusicAlbum only)
sub onAlbumSongsLoaded()
  content = m.LoadAlbumSongsTask.content
  m.LoadAlbumSongsTask.unobserveField("content")
  m.LoadAlbumSongsTask.content = []

  if isValid(content) and content.count() > 0
    m.rowAlbumSongs = populateRow(m.rowAlbumSongs, translate(translationKeys.LabelSongs), content, rowSlotSize.ROW_HEIGHT_SQUARE)
    addRowSize(rowSlotSize.SQUARE)
  else if isValid(m.rowAlbumSongs)
    removeRow(m.rowAlbumSongs)
    m.rowAlbumSongs = invalid
  end if

  ' Chain to more albums by the same artist if we have an artist ID, otherwise skip to LikeThis
  if isValidAndNotEmpty(m.currentAlbumArtistId)
    m.LoadArtistAlbumsTask.itemId = m.currentAlbumArtistId
    m.LoadArtistAlbumsTask.observeField("content", "onAlbumArtistAlbumsLoaded")
    m.LoadArtistAlbumsTask.control = "RUN"
  else
    m.LikeThisTask.itemId = m.top.parentId
    m.LikeThisTask.observeField("content", "onLikeThisLoaded")
    m.LikeThisTask.control = "RUN"
  end if
end sub

' onAlbumArtistAlbumsLoaded: Build "More Albums" row (excluding current album) then chain to More Like This (MusicAlbum only)
sub onAlbumArtistAlbumsLoaded()
  content = m.LoadArtistAlbumsTask.content
  m.LoadArtistAlbumsTask.unobserveField("content")
  m.LoadArtistAlbumsTask.content = []

  ' Filter out the current album so it doesn't appear in its own "More Albums" row
  filtered = []
  if isValid(content)
    for each album in content
      if album.id <> m.currentAlbumId
        filtered.push(album)
      end if
    end for
  end if

  if filtered.count() > 0
    m.rowAlbumArtistAlbums = populateRow(m.rowAlbumArtistAlbums, translate(translationKeys.LabelMoreAlbums), filtered, rowSlotSize.ROW_HEIGHT_SQUARE)
    addRowSize(rowSlotSize.SQUARE)
  else if isValid(m.rowAlbumArtistAlbums)
    removeRow(m.rowAlbumArtistAlbums)
    m.rowAlbumArtistAlbums = invalid
  end if

  m.LikeThisTask.itemId = m.top.parentId
  m.LikeThisTask.observeField("content", "onLikeThisLoaded")
  m.LikeThisTask.control = "RUN"
end sub

' onAudioAlbumTracksLoaded: Build "More from {Album}" row with current track first,
' then tracks that follow it in order, then tracks that preceded it (wrapping around).
' E.g. viewing track 3 of 10 → [3, 4, 5, 6, 7, 8, 9, 10, 1, 2]
sub onAudioAlbumTracksLoaded()
  tracks = m.LoadAlbumSongsTask.content
  m.LoadAlbumSongsTask.unobserveField("content")
  m.LoadAlbumSongsTask.content = []

  if isValid(tracks) and tracks.count() > 0
    current = invalid
    before = []
    after = []
    isFoundCurrent = false
    for each track in tracks
      if track.id = m.currentAudioId
        current = track
        isFoundCurrent = true
      else if isFoundCurrent
        after.push(track)
      else
        before.push(track)
      end if
    end for

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

    if orderedTracks.count() > 0
      if isValidAndNotEmpty(m.currentAlbumName)
        rowTitle = translate(translationKeys.MessageMoreFrom1, [m.currentAlbumName])
      else
        rowTitle = translate(translationKeys.LabelAlbumTracks)
      end if
      m.rowAlbumSongs = populateRow(m.rowAlbumSongs, rowTitle, orderedTracks, rowSlotSize.ROW_HEIGHT_SQUARE)
      addRowSize(rowSlotSize.SQUARE)
    else if isValid(m.rowAlbumSongs)
      removeRow(m.rowAlbumSongs)
      m.rowAlbumSongs = invalid
    end if
  else if isValid(m.rowAlbumSongs)
    removeRow(m.rowAlbumSongs)
    m.rowAlbumSongs = invalid
  end if

  m.LikeThisTask.itemId = m.top.parentId
  m.LikeThisTask.observeField("content", "onLikeThisLoaded")
  m.LikeThisTask.control = "RUN"
end sub

' onPhotoAlbumItemsLoaded: Build "Photos" row then chain to More Like This (PhotoAlbum only)
sub onPhotoAlbumItemsLoaded()
  items = m.LoadPhotoAlbumItemsTask.content
  m.LoadPhotoAlbumItemsTask.unobserveField("content")
  m.LoadPhotoAlbumItemsTask.content = []

  if isValid(items) and items.count() > 0
    m.rowPhotoAlbumItems = populateRow(m.rowPhotoAlbumItems, translate(translationKeys.LabelPhotos), items, rowSlotSize.ROW_HEIGHT_WIDE)
  else if isValid(m.rowPhotoAlbumItems)
    removeRow(m.rowPhotoAlbumItems)
    m.rowPhotoAlbumItems = invalid
  end if

  ' Chain to More Like This
  m.LikeThisTask.itemId = m.top.parentId
  m.LikeThisTask.observeField("content", "onLikeThisLoaded")
  m.LikeThisTask.control = "RUN"
end sub

' onChannelProgramsLoaded: Build "Up Next" row then chain to More Like This (TvChannel only)
sub onChannelProgramsLoaded()
  items = m.LoadChannelProgramsTask.content
  m.LoadChannelProgramsTask.unobserveField("content")
  m.LoadChannelProgramsTask.content = []

  if isValid(items) and items.count() > 0
    m.rowChannelPrograms = populateRow(m.rowChannelPrograms, translate(translationKeys.LabelUpNext), items, rowSlotSize.ROW_HEIGHT_SQUARE)
  else if isValid(m.rowChannelPrograms)
    removeRow(m.rowChannelPrograms)
    m.rowChannelPrograms = invalid
  end if

  ' Chain to More Like This
  m.LikeThisTask.itemId = m.top.parentId
  m.LikeThisTask.observeField("content", "onLikeThisLoaded")
  m.LikeThisTask.control = "RUN"
end sub

' onProgramChannelProgramsLoaded: Build "More on this Channel" row then chain to More Like This (Program only)
sub onProgramChannelProgramsLoaded()
  items = m.LoadChannelProgramsTask.content
  m.LoadChannelProgramsTask.unobserveField("content")
  m.LoadChannelProgramsTask.content = []

  if isValid(items) and items.count() > 0
    m.rowChannelPrograms = populateRow(m.rowChannelPrograms, translate(translationKeys.LabelMoreOnThisChannel), items, rowSlotSize.ROW_HEIGHT_SQUARE)
    addRowSize(rowSlotSize.SQUARE)
  else if isValid(m.rowChannelPrograms)
    removeRow(m.rowChannelPrograms)
    m.rowChannelPrograms = invalid
  end if

  ' Chain to More Like This
  m.LikeThisTask.itemId = m.top.parentId
  m.LikeThisTask.observeField("content", "onLikeThisLoaded")
  m.LikeThisTask.control = "RUN"
end sub

' Fires when JRRowList's progress tick detects at least one expired Program
' among this list's rows. Triggers a targeted refetch of the Channel Programs
' row (TvChannel "Up Next" or Program "More on this Channel") so finished
' broadcasts roll off and new ones take their place.
'
' Deliberately does NOT re-chain to LikeThisTask — related content is stable
' over a viewing session and should not be refetched just because a program
' expired. m.isRefetchingChannelPrograms debounces repeated expiry signals.
sub onProgramsExpired()
  if m.isRefetchingChannelPrograms then return
  if not isValid(m.rowChannelPrograms) then return

  itemType = m.top.type
  handlerName = ""
  channelId = invalid

  if itemType = "TvChannel"
    handlerName = "onChannelProgramsRefetched"
    channelId = m.top.parentId
  else if itemType = "Program"
    handlerName = "onProgramChannelProgramsRefetched"
    channelId = m.programChannelId
  end if

  if handlerName = "" or not isValidAndNotEmpty(channelId) then return

  m.isRefetchingChannelPrograms = true
  m.LoadChannelProgramsTask.control = "STOP"
  m.LoadChannelProgramsTask.unobserveField("content")
  m.LoadChannelProgramsTask.itemId = channelId
  m.LoadChannelProgramsTask.observeField("content", handlerName)
  m.LoadChannelProgramsTask.control = "RUN"
end sub

' Refetch handler for TvChannel "Up Next". Replaces the row content in place
' without re-chaining to LikeThisTask (unlike the initial-load handler).
sub onChannelProgramsRefetched()
  items = m.LoadChannelProgramsTask.content
  m.LoadChannelProgramsTask.unobserveField("content")
  m.LoadChannelProgramsTask.content = []
  m.isRefetchingChannelPrograms = false

  if isValid(items) and items.count() > 0
    m.rowChannelPrograms = populateRow(m.rowChannelPrograms, translate(translationKeys.LabelUpNext), items, rowSlotSize.ROW_HEIGHT_SQUARE)
  else if isValid(m.rowChannelPrograms)
    removeRow(m.rowChannelPrograms)
    m.rowChannelPrograms = invalid
  end if
end sub

' Refetch handler for Program "More on this Channel". Same in-place-update
' pattern as onChannelProgramsRefetched — no chain to LikeThisTask.
sub onProgramChannelProgramsRefetched()
  items = m.LoadChannelProgramsTask.content
  m.LoadChannelProgramsTask.unobserveField("content")
  m.LoadChannelProgramsTask.content = []
  m.isRefetchingChannelPrograms = false

  if isValid(items) and items.count() > 0
    m.rowChannelPrograms = populateRow(m.rowChannelPrograms, translate(translationKeys.LabelMoreOnThisChannel), items, rowSlotSize.ROW_HEIGHT_SQUARE)
  else if isValid(m.rowChannelPrograms)
    removeRow(m.rowChannelPrograms)
    m.rowChannelPrograms = invalid
  end if
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
  ' numRows must always equal the actual row count so floatingFocus never needs to scroll.
  ' If numRows < rowCount, navigation causes internal scrolling which changes the focused row's
  ' RowList Y position unpredictably, breaking our translation formula.
  ' Use content.getChildCount() (not m.rowHeights.count()) so that on a Refresh — where all
  ' row nodes are reused in place and content.count() stays constant — numRows never dips
  ' below content.count() during the incremental rebuild.
  m.top.numRows = m.top.content.getChildCount()
  rowRef.Title = title
  for each item in items
    rowRef.appendChild(item)
  end for

  ' Activate texture management after the first row populates (layout is stable enough).
  ' activateTextureManager from "init" → "active" only runs once; subsequent calls
  ' when already "active" are harmless (state doesn't change, no observer fire).
  if isValid(m.top.content) and m.top.content.hasField("textureManagerState") and m.top.content.textureManagerState = "init"
    updateTextureBufferRange(m.top.content, m.top.rowItemFocused[0], m.top.rowItemFocused[1], m.top.numRows)
    activateTextureManager(m.top.content)
  end if

  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

' removeRow: Remove a row ContentNode from content and keep numRows in sync with content.getChildCount().
' Always call this instead of m.top.content.removeChild() directly; then null the caller's ref.
sub removeRow(rowNode as object)
  m.top.content.removeChild(rowNode)
  m.top.numRows = m.top.content.getChildCount()
end sub

sub onRowItemSelected()
  m.top.selectedItem = m.top.content.getChild(m.top.rowItemSelected[0]).getChild(m.top.rowItemSelected[1])
  ' Clear immediately — prevents stale value from re-firing the m.port
  ' observer when the parent ItemDetails screen is restored after popScene.
  m.top.selectedItem = invalid
end sub

sub onRowItemFocused()
  rowIndex = m.top.rowItemFocused[0]
  m.top.focusedItem = m.top.content.getChild(rowIndex).getChild(m.top.rowItemFocused[1])
  updateTextureBufferRange(m.top.content, rowIndex, m.top.rowItemFocused[1], m.top.numRows)

  ' Skip panel position recalculation when horizontal item scrolling fires rowItemFocused
  ' without an actual row change (avoids redundant translation/targetTranslationY updates).
  if rowIndex = m.lastFocusedRowIndex then return
  m.lastFocusedRowIndex = rowIndex

  updatePanelPosition(rowIndex)
end sub

' updatePanelPosition: Set targetTranslationY so ItemDetails.bs can animate the RowList's
' translation within extrasGrp. extrasGrp stays fixed at extrasLayout.PANEL_OPEN_Y (306).
'
' numRows always equals the total row count (set in populateRow), so floatingFocus never scrolls
' internally. All rows stay at fixed RowList-relative Y positions:
'   row 0 → RowList Y = 0
'   row N → RowList Y = sum(heights[0..N-1]) + N * m.rowSpacing  (= focusedRowY)
'
' Visible screen height between panel top and action zone:
'   extrasLayout.ACTION_SAFE_BOTTOM_Y - extrasLayout.PANEL_OPEN_Y = 720px.
'
' Three position classes:
'   TOP    — row 0 (or only row): focused row at RowList Y=0, default translation (18px).
'   BOTTOM — last row: focused row bottom edge at action zone - EXTRAS_BOTTOM_SAFE_PADDING.
'              extrasLayout.PANEL_OPEN_Y + translationY + focusedRowY + rowHeight = extrasLayout.ACTION_SAFE_BOTTOM_Y - EXTRAS_BOTTOM_SAFE_PADDING
'              translationY = (extrasLayout.ACTION_SAFE_BOTTOM_Y - extrasLayout.PANEL_OPEN_Y - EXTRAS_BOTTOM_SAFE_PADDING) - rowHeight - focusedRowY
'   MIDDLE — rows 1..N-2: focused row centered in the visible area.
'              translationY = int((extrasLayout.ACTION_SAFE_BOTTOM_Y - extrasLayout.PANEL_OPEN_Y - rowHeight) / 2) - focusedRowY
'
' Negative translationY moves the RowList above extrasGrp Y=0. The clippingRect Group in
' ExtrasSlider.xml clips that overflow so the previous row only peeks within the panel bounds.
' targetTranslationY is observed by ItemDetails.bs which runs the gridAnime Animation.
' Called early from onKeyEvent (before RowList focuses the row) and confirmed by onRowItemFocused.
sub updatePanelPosition(rowIndex as integer)
  rowCount = m.top.content.getChildCount()

  ' Safe row height — fall back to portrait if rowHeights not yet populated for this index.
  rowHeight = rowSlotSize.ROW_HEIGHT_PORTRAIT
  if isValid(m.rowHeights) and m.rowHeights.count() > rowIndex
    rowHeight = m.rowHeights[rowIndex]
  end if

  if rowIndex = 0 or rowCount <= 1
    ' TOP: focused row at RowList Y=0, default translation
    m.top.targetTranslationY = EXTRAS_ROWLIST_DEFAULT_Y
  else
    ' focusedRowY: Y of the focused row within the RowList's rendered area.
    ' Sum all preceding row heights plus one spacing per preceding row.
    focusedRowY = 0
    for i = 0 to rowIndex - 1
      rowH = rowSlotSize.ROW_HEIGHT_PORTRAIT
      if isValid(m.rowHeights) and m.rowHeights.count() > i
        rowH = m.rowHeights[i]
      end if
      focusedRowY += rowH + m.rowSpacing
    end for

    if rowIndex = rowCount - 1
      ' BOTTOM: focused row bottom edge at action zone minus safe padding
      translationY = (extrasLayout.ACTION_SAFE_BOTTOM_Y - extrasLayout.PANEL_OPEN_Y - EXTRAS_BOTTOM_SAFE_PADDING) - rowHeight - focusedRowY
    else
      ' MIDDLE: center focused row within the visible area (extrasLayout.PANEL_OPEN_Y..extrasLayout.ACTION_SAFE_BOTTOM_Y)
      translationY = int((extrasLayout.ACTION_SAFE_BOTTOM_Y - extrasLayout.PANEL_OPEN_Y - rowHeight) / 2) - focusedRowY
    end if
    m.top.targetTranslationY = translationY
  end if
end sub

' onDestroy: Full teardown releasing all resources before component removal.
' Called by ItemDetails.onDestroy() before nulling the extrasGrid reference.
sub onDestroy()
  ' Stop all in-flight tasks and unobserve their content fields
  cancelInFlightChain()

  ' Stop and release the program progress timer (inherited from JRRowList)
  stopProgramProgressTicking(true)

  ' Unobserve m.top fields set in init()
  m.top.unobserveField("rowItemSelected")
  m.top.unobserveField("rowItemFocused")
  m.top.unobserveField("programsExpired")

  ' Release task references
  m.LoadSeasonsTask = invalid
  m.LoadSeasonAllEpisodesTask = invalid
  m.LoadSeasonEpisodesTask = invalid
  m.LoadAdditionalPartsTask = invalid
  m.LoadPeopleTask = invalid
  m.LikeThisTask = invalid
  m.SpecialFeaturesTask = invalid
  m.LoadMoviesTask = invalid
  m.LoadShowsTask = invalid
  m.LoadSeriesTask = invalid
  m.LoadBoxSetItemsTask = invalid
  m.LoadArtistAlbumsTask = invalid
  m.LoadArtistAppearsOnTask = invalid
  m.LoadArtistSongsTask = invalid
  m.LoadArtistSimilarTask = invalid
  m.LoadAlbumSongsTask = invalid
  m.LoadPlaylistItemsTask = invalid
  m.LoadPhotoAlbumItemsTask = invalid
  m.LoadChannelProgramsTask = invalid

  ' Clear row references
  m.rowChapters = invalid
  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
  m.rowArtistAlbums = invalid
  m.rowArtistAppearsOn = invalid
  m.rowArtistSongs = invalid
  m.rowAlbumSongs = invalid
  m.rowAlbumArtistAlbums = invalid
  m.rowPlaylistItems = invalid
  m.rowPhotoAlbumItems = invalid
  m.rowChannelPrograms = invalid

  ' Reset flags
  m.isRefetchingChannelPrograms = false
end sub

' onKeyEvent: Anticipate the next row on UP/DOWN so the panel translation animation starts
' in sync with the RowList floatingFocus animation rather than after it completes.
' Returns false so the RowList handles the actual navigation.
function onKeyEvent(key as string, press as boolean) as boolean
  if wrapRowFocus(key, press) then return true

  if press
    rowCount = m.top.content.getChildCount()
    if key = "down" and m.lastFocusedRowIndex < rowCount - 1
      updatePanelPosition(m.lastFocusedRowIndex + 1)
    else if key = "up" and m.lastFocusedRowIndex > 0
      updatePanelPosition(m.lastFocusedRowIndex - 1)
    end if
  end if
  return false
end function