components_ItemDetails.bs

import "pkg:/source/api/Image.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/utils/config.bs"
import "pkg:/source/utils/itemImageUrl.bs"
import "pkg:/source/utils/mediaDisplayTitle.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/streamSelection.bs"

' Y translation extrasGrp reaches after sliding open (ExtrasSlider.xml VertSlider end keyValue)
const EXTRAS_GRP_TARGET_Y = 306
' Gap between bottom of itemInfoRows and top of extras pane when extras are open
const ITEM_DETAILS_EXTRAS_PADDING = 48
' Minimum display height for the logo image — images too small are scaled up (aspect ratio preserved).
const LOGO_MIN_DISPLAY_HEIGHT = 212 ' Note: LOGO_MAX_DISPLAY_WIDTH takes precedence for very wide/flat logos; this minimum may not be reached.
' Maximum display width for the logo image — prevents very wide/flat logos from overlapping buttons
const LOGO_MAX_DISPLAY_WIDTH = 500
' Maximum display height for non-Person primary images — prevents portrait posters from
' extending above the metadata rows. Person photos are exempt because their info rows
' are short and don't reach the right edge where the photo sits.
const LOGO_MAX_DISPLAY_HEIGHT = 336

sub init()
  m.log = log.Logger("ItemDetails")
  m.extrasGrp = m.top.findNode("extrasGrp")
  m.extrasGrid = m.top.findNode("extrasGrid")
  m.top.optionsAvailable = false

  m.options = m.top.findNode("itemOptions")
  m.infoGroup = m.top.findNode("infoGroup")

  m.itemDescription = m.top.findNode("itemDescription")
  m.itemDescriptionInLayout = true ' itemDescription is a static XML child, so it starts in layout

  m.buttonGrp = m.top.findNode("buttons")
  ' LoadingButton placeholder gives focus a home before itemContent arrives.
  ' setupButtons() clears it and builds the real button set once item type is known.
  loadingButton = CreateObject("roSGNode", "LoadingButton")
  loadingButton.id = "loadingButton"
  m.buttonGrp.appendChild(loadingButton)
  m.buttonGrp.setFocus(true)
  m.top.lastFocus = m.buttonGrp

  ' Item logo - positioned at fixed Y position
  m.itemLogo = m.top.findNode("itemLogo")
  m.itemLogo.observeField("loadStatus", "onLogoLoadStatusChanged")

  ' Date added label - positioned at bottom right
  m.dateCreatedLabel = m.top.findNode("dateCreatedLabel")

  ' Gradient overlay - dynamically sized to span from itemDetails to extrasSlider
  m.itemTextGradient = m.top.findNode("itemTextGradient")
  m.itemDetails = m.top.findNode("itemDetails")

  ' Observe renderTracking to update gradient after layout is calculated
  m.itemDetails.observeField("renderTracking", "onItemDetailsRendered")

  ' itemDetails slide animation - synced with extras slider
  m.itemTracks = m.top.findNode("itemTracks")
  m.itemTracksInLayout = true ' itemTracks is a static XML child, so it starts in layout
  m.itemDetailsSlider = m.top.findNode("itemDetailsSlider")
  m.itemDetailsSliderInterp = m.top.findNode("itemDetailsSliderInterp")
  m.extrasActive = false
  m.animationTargetCalculated = false
  m.extrasLoaded = false

  ' Inner group: title + infoGroup + directorGenreGroup (never changes during activate/deactivate)
  ' Used to compute the correct animation target height when extras are shown
  m.itemInfoRows = m.top.findNode("itemInfoRows")

  ' Dynamic button refs — reset by setupButtons() on each content load
  m.optionsButton = invalid
  m.shuffleButton = invalid

  ' First episode task (Series Play button)
  m.loadFirstEpisodeTask = CreateObject("roSGNode", "LoadItemsTask")
  m.loadFirstEpisodeTask.itemsToLoad = "seriesFirstEpisode"

  ' Series: fetch the resume/next-up episode in the background for the Resume button
  m.loadSeriesResumeTask = CreateObject("roSGNode", "LoadItemsTask")
  m.loadSeriesResumeTask.itemsToLoad = "seriesResume"

  ' Season: fetch parent series metadata in the background for ratings/runtime/genres/logo
  m.loadSeasonSeriesTask = CreateObject("roSGNode", "LoadItemsTask")
  m.loadSeasonSeriesTask.itemsToLoad = "metaDataDetails"
  m.seasonSeriesData = invalid
  ' Cache persists for the lifetime of this component — keyed by seriesId so returning to a
  ' previously-visited season renders immediately rather than showing a metadata pop-in.
  m.seasonSeriesCache = {}

  m.isFirstRun = true

  m.directorGenreGroup = m.top.findNode("directorGenreGroup")
  m.infoDividerCount = 0
  m.directorGenreDividerCount = 0

  ' Observe personHasMedia to show/hide Shuffle button after Person media chain completes
  m.extrasGrid.observeField("personHasMedia", "onPersonHasMediaChanged")

  ' Observe overhang clock for dynamic "Ends At" updates (only when clock is visible)
  m.endsAtNode = invalid
  m.endsAtDurationSeconds = 0
  if not m.global.user.settings.uiDesignHideClock
    overhang = m.top.getScene().findNode("overhang")
    if isValid(overhang)
      m.clock = overhang.findNode("clock")
      if isValid(m.clock)
        m.clock.observeField("minutes", "onClockMinuteChanged")
      end if
    end if
  end if
end sub

' OnScreenShown: Callback when view is presented on screen
sub OnScreenShown()
  ' Set backdrop from item data (only if itemContent is already loaded)
  if isValid(m.top.itemContent) and isValidAndNotEmpty(m.top.itemContent.id)
    device = m.global.device
    backdropUrl = getItemBackdropUrl(m.top.itemContent, { width: device.uiResolution[0], height: device.uiResolution[1] })
    m.global.sceneManager.callFunc("setBackgroundImage", backdropUrl)
  end if

  ' Restore focus to last focused element
  if m.extrasGrp.opacity = 1
    m.top.lastFocus.setFocus(true)
  else
    if isValid(m.top.lastFocus) and m.top.lastFocus.visible
      m.top.lastFocus.setFocus(true)
    else
      m.buttonGrp.setFocus(true)
    end if
  end if

  if m.isFirstRun
    m.isFirstRun = false
  else
    ' Don't refresh when closing OverviewDialog
    if not isValid(m.top.lastFocus) or m.top.lastFocus.id <> "itemDescription"
      m.top.refreshItemDetailsData = not m.top.refreshItemDetailsData
    end if
  end if
end sub

sub trailerAvailableChanged()
  if m.top.trailerAvailable
    ' Guard: setupButtons() may have already restored the trailer button on refresh
    if not isValid(m.top.findNode("trailerButton"))
      trailerButton = CreateObject("roSGNode", "IconButton")
      trailerButton.id = "trailerButton"
      trailerButton.icon = "pkg:/images/icons/playOutline.png"
      trailerButton.text = tr("Play Trailer")
      ' Insert before Delete (if present) to match setupButtons() ordering, else before Refresh
      deleteButtonIndex = getButtonIndex("deleteButton")
      if deleteButtonIndex >= 0
        m.buttonGrp.insertChild(trailerButton, deleteButtonIndex)
      else
        refreshButtonIndex = getButtonIndex("refreshButton")
        if refreshButtonIndex >= 0
          m.buttonGrp.insertChild(trailerButton, refreshButtonIndex)
        else
          m.buttonGrp.appendChild(trailerButton)
        end if
      end if
    end if
  else
    trailerButton = m.top.findNode("trailerButton")
    if isValid(trailerButton)
      m.buttonGrp.removeChild(trailerButton)
    end if
  end if
end sub

' nextUpEpisodeChanged: For Series — create/remove Resume button based on nextUpEpisode
sub nextUpEpisodeChanged()
  item = m.top.nextUpEpisode
  resumeButton = m.top.findNode("resumeButton")

  if isValid(item) and isValidAndNotEmpty(item.id)
    resumeText = getResumeButtonText(item)
    if not isValid(resumeButton)
      currentFocusIndex = m.buttonGrp.buttonFocused
      currentFocusedButton = invalid
      if isValid(currentFocusIndex) and currentFocusIndex >= 0 and currentFocusIndex < m.buttonGrp.getChildCount()
        currentFocusedButton = m.buttonGrp.getChild(currentFocusIndex)
      end if

      resumeButton = CreateObject("roSGNode", "ResumeButton")
      resumeButton.id = "resumeButton"
      resumeButton.icon = "pkg:/images/icons/resume.png"
      resumeButton.text = resumeText
      resumeButton.playbackPositionTicks = item.playbackPositionTicks
      resumeButton.runtimeTicks = item.runTimeTicks
      m.buttonGrp.insertChild(resumeButton, 0)

      if isValid(currentFocusedButton) and currentFocusedButton.id = "playButton"
        m.buttonGrp.buttonFocused = 0
      else if isValid(currentFocusIndex) and currentFocusIndex >= 0
        m.buttonGrp.buttonFocused = currentFocusIndex + 1
      else
        m.buttonGrp.buttonFocused = 0
      end if
      ' Only move actual focus when the button group already owns it — avoid stealing
      ' focus from the extras row or description when the button arrives asynchronously.
      if m.buttonGrp.isInFocusChain()
        focusButtonGroupChild()
      end if
    else
      ' Resume button already present — update text and tick values
      resumeButton.text = resumeText
      resumeButton.playbackPositionTicks = item.playbackPositionTicks
      resumeButton.runtimeTicks = item.runTimeTicks
    end if
  else
    removeResumeButtonWithFocus(resumeButton)
  end if
end sub

' getResumeButtonText: Return "Resume S{n}E{n}" when season and episode numbers are known,
' otherwise fall back to plain "Resume".
' @param {object} item - nextUpEpisode JellyfinBaseItem node
' @return {string} Localised button label
function getResumeButtonText(item as object) as string
  if item.parentIndexNumber > 0 and item.indexNumber > 0
    return tr("Resume") + " S" + item.parentIndexNumber.toStr() + "E" + item.indexNumber.toStr()
  end if
  return tr("Resume")
end function

' manageResumeButton: Add or remove Resume button based on playback position (non-Series types)
sub manageResumeButton()
  resumeButton = m.top.findNode("resumeButton")

  if isValid(m.top.itemContent) and isValidAndNotEmpty(m.top.itemContent.id)
    item = m.top.itemContent
    if item.runTimeTicks <= 0
      removeResumeButtonWithFocus(resumeButton)
      return
    end if

    if item.playbackPositionTicks > 0
      if not isValid(resumeButton)
        currentFocusIndex = m.buttonGrp.buttonFocused
        currentFocusedButton = invalid
        if isValid(currentFocusIndex) and currentFocusIndex >= 0 and currentFocusIndex < m.buttonGrp.getChildCount()
          currentFocusedButton = m.buttonGrp.getChild(currentFocusIndex)
        end if

        resumeButton = CreateObject("roSGNode", "ResumeButton")
        resumeButton.id = "resumeButton"
        resumeButton.icon = "pkg:/images/icons/resume.png"
        resumeButton.text = tr("Resume")
        resumeButton.playbackPositionTicks = item.playbackPositionTicks
        resumeButton.runtimeTicks = item.runTimeTicks
        m.buttonGrp.insertChild(resumeButton, 0)

        if isValid(currentFocusedButton) and currentFocusedButton.id = "playButton"
          m.buttonGrp.buttonFocused = 0
        else if isValid(currentFocusIndex) and currentFocusIndex >= 0
          m.buttonGrp.buttonFocused = currentFocusIndex + 1
        else
          m.buttonGrp.buttonFocused = 0
        end if
        ' Only move actual focus when the button group already owns it — avoid stealing
        ' focus from the extras row when setupButtons() cleared and rebuilt buttons on refresh.
        if m.buttonGrp.isInFocusChain()
          focusButtonGroupChild()
        end if
      else
        resumeButton.playbackPositionTicks = item.playbackPositionTicks
        resumeButton.runtimeTicks = item.runTimeTicks
      end if
    else
      removeResumeButtonWithFocus(resumeButton)
    end if
  else
    removeResumeButtonWithFocus(resumeButton)
  end if
end sub

' removeResumeButtonWithFocus: Remove resume button while preserving focus position
sub removeResumeButtonWithFocus(resumeButton as object)
  if not isValid(resumeButton) then return

  currentFocusIndex = m.buttonGrp.buttonFocused
  resumeButton.playbackPositionTicks = 0
  resumeButton.runtimeTicks = 0
  m.buttonGrp.removeChild(resumeButton)

  if isValid(currentFocusIndex) and currentFocusIndex > 0
    m.buttonGrp.buttonFocused = currentFocusIndex - 1
  else
    m.buttonGrp.buttonFocused = 0
  end if
  ' Only move actual focus when the button group already owns it — avoid stealing
  ' focus from the extras row or description when the button is removed asynchronously.
  if m.buttonGrp.isInFocusChain()
    focusButtonGroupChild()
  end if
end sub


' createInfoLabel: Create a bold label node for the info rows
' @param {string} labelId - Unique ID for the label
' @return {object} Configured LabelPrimaryMedium node
function createInfoLabel(labelId as string) as object
  labelNode = CreateObject("roSGNode", "LabelPrimaryMedium")
  labelNode.id = labelId
  labelNode.vertAlign = "center"
  labelNode.bold = true
  return labelNode
end function

' createDividerNode: Create a bullet divider node for separating info items
' @param {string} dividerId - Unique ID for the divider
' @return {object} Configured divider node
function createDividerNode(dividerId as string) as object
  labelNode = CreateObject("roSGNode", "LabelPrimarySmall")
  labelNode.id = dividerId
  labelNode.horizAlign = "left"
  labelNode.vertAlign = "center"
  labelNode.height = 40
  labelNode.width = 0
  labelNode.text = "•"
  labelNode.bold = true
  return labelNode
end function

' displayInfoNode: Add a node to the info group, prepending a bullet divider if needed
sub displayInfoNode(node as object)
  if not isValid(node) then return
  if m.infoGroup.getChildCount() > 0
    m.infoDividerCount++
    divider = createDividerNode("infoDivider" + m.infoDividerCount.toStr())
    m.infoGroup.appendChild(divider)
  end if
  m.infoGroup.appendChild(node)
end sub

' displayDirectorGenreNode: Add a node to the second info row, prepending a bullet divider if needed
sub displayDirectorGenreNode(node as object)
  if not isValid(node) then return
  if m.directorGenreGroup.getChildCount() > 0
    m.directorGenreDividerCount++
    divider = createDividerNode("directorGenreDivider" + m.directorGenreDividerCount.toStr())
    m.directorGenreGroup.appendChild(divider)
  end if
  m.directorGenreGroup.appendChild(node)
end sub

' populateDescriptionGroup: Set FocusableOverview text with tagline and overview
sub populateDescriptionGroup()
  item = m.top.itemContent
  userSettings = m.global.user.settings

  ' Person: always show biography with higher maxLines; no tagline support
  if item.type = "Person"
    if isValidAndNotEmpty(item.overview)
      m.itemDescription.text = item.overview
    else
      m.itemDescription.text = tr("Biographical information for this person is not currently available.")
    end if
    m.itemDescription.tagline = ""
    m.itemDescription.maxLines = 8
    m.itemDescription.dialogTitle = item.name
    m.itemDescription.visible = true
    setDescriptionInLayout(true)
    return
  end if

  hasTagline = false
  hasOverview = false

  if userSettings.uiDetailsHideTagline = false
    if item.taglines.count() > 0 and isValidAndNotEmpty(item.taglines[0])
      m.itemDescription.tagline = item.taglines[0]
      m.itemDescription.taglineMaxLines = 2
      hasTagline = true
    end if
  end if

  if not hasTagline
    m.itemDescription.tagline = ""
  end if

  if isValidAndNotEmpty(item.overview)
    m.itemDescription.text = item.overview
    hasOverview = true
  else
    m.itemDescription.text = ""
  end if

  if hasTagline
    m.itemDescription.maxLines = 2
  else
    m.itemDescription.maxLines = 4
  end if

  if hasTagline or hasOverview
    m.itemDescription.dialogTitle = item.name
    m.itemDescription.visible = true
    setDescriptionInLayout(true)
  else
    m.itemDescription.visible = false
    setDescriptionInLayout(false)
  end if
end sub

' populateInfoGroup: Dispatch to type-specific info row builder
sub populateInfoGroup()
  m.infoGroup.removeChildrenIndex(m.infoGroup.getChildCount(), 0)
  m.directorGenreGroup.removeChildrenIndex(m.directorGenreGroup.getChildCount(), 0)
  m.infoDividerCount = 0
  m.directorGenreDividerCount = 0
  m.endsAtNode = invalid
  m.endsAtDurationSeconds = 0

  item = m.top.itemContent
  userSettings = m.global.user.settings

  if item.type = "Series"
    populateInfoGroupSeries(item, userSettings)
  else if item.type = "Season"
    populateInfoGroupSeason(item, userSettings)
  else if item.type = "Episode"
    populateInfoGroupEpisode(item, userSettings)
  else if item.type = "MusicVideo"
    populateInfoGroupMusicVideo(item, userSettings)
  else if item.type = "Person"
    populateInfoGroupPerson(item)
  else if item.type = "BoxSet"
    populateInfoGroupBoxSet(item, userSettings)
  else
    ' Movie, Video, Recording
    populateInfoGroupMovie(item, userSettings)
  end if
end sub

' populateInfoGroupMovie: Info rows for Movie, Video, Recording
' Row 1: Year · Official Rating · Community Rating · Critic Rating · Runtime · Ends At
' Row 2: Genres · Director(s)
sub populateInfoGroupMovie(item as object, userSettings as object)
  if item.productionYear > 0
    yearNode = createInfoLabel("releaseYear")
    yearNode.text = stri(item.productionYear).trim()
    displayInfoNode(yearNode)
  end if

  if isValidAndNotEmpty(item.officialRating)
    ratingNode = createInfoLabel("officialRating")
    ratingNode.text = item.officialRating
    displayInfoNode(ratingNode)
  end if

  if userSettings.uiMoviesShowRatings and item.communityRating > 0
    communityRatingNode = CreateObject("roSGNode", "CommunityRating")
    communityRatingNode.id = "communityRating"
    communityRatingNode.rating = item.communityRating
    displayInfoNode(communityRatingNode)
  end if

  if userSettings.uiMoviesShowRatings and item.criticRating > 0
    criticRatingNode = CreateObject("roSGNode", "CriticRating")
    criticRatingNode.id = "criticRating"
    criticRatingNode.rating = item.criticRating
    displayInfoNode(criticRatingNode)
  end if

  if item.runTimeTicks > 0
    runtimeNode = createInfoLabel("runtime")
    runtimeNode.text = stri(getRuntime()).trim() + " " + tr("mins")
    displayInfoNode(runtimeNode)
  end if

  if item.runTimeTicks > 0 and not userSettings.uiDesignHideClock
    m.endsAtNode = createInfoLabel("ends-at")
    m.endsAtDurationSeconds = int(m.top.itemContent.runTimeTicks / 10000000.0)
    m.endsAtNode.text = tr("Ends at %1").Replace("%1", getEndTime())
    displayInfoNode(m.endsAtNode)
  end if

  if item.genres.count() > 0
    genreNode = createInfoLabel("genre")
    genreNode.text = item.genres.join(" / ")
    displayDirectorGenreNode(genreNode)
  end if

  directors = []
  if isValid(item.people)
    for each person in item.people
      if person.type = "Director"
        directors.push(person.name)
      end if
    end for
  end if
  if directors.count() > 0
    directorNode = createInfoLabel("director")
    directorNode.text = tr("Directed by %1").Replace("%1", directors.join(", "))
    displayDirectorGenreNode(directorNode)
  end if
end sub

' populateInfoGroupMusicVideo: Info rows for MusicVideo
' Row 1: Year · Official Rating · Runtime · Ends At
' Row 2: Genres · By: Artist(s)
sub populateInfoGroupMusicVideo(item as object, userSettings as object)
  if item.productionYear > 0
    yearNode = createInfoLabel("releaseYear")
    yearNode.text = stri(item.productionYear).trim()
    displayInfoNode(yearNode)
  end if

  if isValidAndNotEmpty(item.officialRating)
    ratingNode = createInfoLabel("officialRating")
    ratingNode.text = item.officialRating
    displayInfoNode(ratingNode)
  end if

  if item.runTimeTicks > 0
    runtimeNode = createInfoLabel("runtime")
    runtimeNode.text = stri(getRuntime()).trim() + " " + tr("mins")
    displayInfoNode(runtimeNode)
  end if

  if item.runTimeTicks > 0 and not userSettings.uiDesignHideClock
    m.endsAtNode = createInfoLabel("ends-at")
    m.endsAtDurationSeconds = int(m.top.itemContent.runTimeTicks / 10000000.0)
    m.endsAtNode.text = tr("Ends at %1").Replace("%1", getEndTime())
    displayInfoNode(m.endsAtNode)
  end if

  if item.genres.count() > 0
    genreNode = createInfoLabel("genre")
    genreNode.text = item.genres.join(" / ")
    displayDirectorGenreNode(genreNode)
  end if

  if isValid(item.artists) and item.artists.count() > 0
    artistNode = createInfoLabel("artists")
    artistNode.text = tr("By: %1").Replace("%1", item.artists.join(", "))
    displayDirectorGenreNode(artistNode)
  end if

  directors = []
  if isValid(item.people)
    for each person in item.people
      if person.type = "Director" then directors.push(person.name)
    end for
  end if
  if directors.count() > 0
    directorNode = createInfoLabel("director")
    directorNode.text = tr("Directed by %1").Replace("%1", directors.join(", "))
    displayDirectorGenreNode(directorNode)
  end if
end sub

' populateInfoGroupEpisode: Info rows for Episode
' Row 1: Air Date · Official Rating · Runtime · Ends At
' Row 2: Series name + "S{n}E{n}"
sub populateInfoGroupEpisode(item as object, userSettings as object)
  if isValidAndNotEmpty(item.premiereDate)
    airDate = CreateObject("roDateTime")
    airDate.FromISO8601String(item.premiereDate)
    airedNode = createInfoLabel("aired")
    airedNode.text = airDate.AsDateString("short-month-no-weekday")
    displayInfoNode(airedNode)
  end if

  if isValidAndNotEmpty(item.officialRating)
    ratingNode = createInfoLabel("officialRating")
    ratingNode.text = item.officialRating
    displayInfoNode(ratingNode)
  end if

  if item.runTimeTicks > 0
    runtimeNode = createInfoLabel("runtime")
    runtimeNode.text = stri(getRuntime()).trim() + " " + tr("mins")
    displayInfoNode(runtimeNode)
  end if

  if item.runTimeTicks > 0 and not userSettings.uiDesignHideClock
    m.endsAtNode = createInfoLabel("ends-at")
    m.endsAtDurationSeconds = int(m.top.itemContent.runTimeTicks / 10000000.0)
    m.endsAtNode.text = tr("Ends at %1").Replace("%1", getEndTime())
    displayInfoNode(m.endsAtNode)
  end if

  ' Row 2: S{n}E{n} • SeriesName • Directed by X, Y, Z
  if item.parentIndexNumber > 0 and item.indexNumber > 0
    episodeCodeNode = createInfoLabel("episodeCode")
    episodeCodeNode.text = "S" + item.parentIndexNumber.toStr() + "E" + item.indexNumber.toStr()
    displayDirectorGenreNode(episodeCodeNode)
  end if

  if isValidAndNotEmpty(item.seriesName)
    seriesNameNode = createInfoLabel("seriesName")
    seriesNameNode.text = item.seriesName
    displayDirectorGenreNode(seriesNameNode)
  end if

  directors = []
  if isValid(item.people)
    for each person in item.people
      if person.type = "Director" then directors.push(person.name)
    end for
  end if
  if directors.count() > 0
    directorNode = createInfoLabel("director")
    directorNode.text = tr("Directed by %1").Replace("%1", directors.join(", "))
    displayDirectorGenreNode(directorNode)
  end if
end sub

' populateInfoGroupSeries: Info rows for Series
' Row 1: Year Range · Official Rating · Community Rating · Runtime · Ends At (only when avg episode runtime is available)
' Row 2: Genres · Air schedule/Status
sub populateInfoGroupSeries(item as object, userSettings as object)
  ' Year range: "{productionYear} - {endYear}" or "{productionYear} - Present"
  if item.productionYear > 0
    yearText = stri(item.productionYear).trim()
    if isValidAndNotEmpty(item.endDate)
      endYear = item.endDate.left(4)
      yearText = yearText + " - " + endYear
    else if item.status = "Ended"
      ' Ended but no endDate available — show production year only
    else
      yearText = yearText + " - " + tr("Present")
    end if
    yearNode = createInfoLabel("releaseYear")
    yearNode.text = yearText
    displayInfoNode(yearNode)
  end if

  if isValidAndNotEmpty(item.officialRating)
    ratingNode = createInfoLabel("officialRating")
    ratingNode.text = item.officialRating
    displayInfoNode(ratingNode)
  end if

  if userSettings.uiMoviesShowRatings and item.communityRating > 0
    communityRatingNode = CreateObject("roSGNode", "CommunityRating")
    communityRatingNode.id = "communityRating"
    communityRatingNode.rating = item.communityRating
    displayInfoNode(communityRatingNode)
  end if

  ' Average episode runtime (Jellyfin stores avg episode length in runTimeTicks for Series)
  if item.runTimeTicks > 0
    runtimeNode = createInfoLabel("runtime")
    runtimeNode.text = stri(getRuntime()).trim() + " " + tr("mins")
    displayInfoNode(runtimeNode)
  end if

  ' "Ends At" is only meaningful when we have a valid avg episode runtime to calculate with
  if item.runTimeTicks > 0 and not userSettings.uiDesignHideClock
    m.endsAtNode = createInfoLabel("ends-at")
    m.endsAtDurationSeconds = int(m.top.itemContent.runTimeTicks / 10000000.0)
    m.endsAtNode.text = tr("Ends at %1").Replace("%1", getEndTime())
    displayInfoNode(m.endsAtNode)
  end if

  if item.genres.count() > 0
    genreNode = createInfoLabel("genre")
    genreNode.text = item.genres.join(" / ")
    displayDirectorGenreNode(genreNode)
  end if

  historyText = getHistory()
  if isValidAndNotEmpty(historyText)
    historyNode = createInfoLabel("history")
    historyNode.text = historyText
    displayDirectorGenreNode(historyNode)
  end if
end sub

' populateInfoGroupBoxSet: Info rows for BoxSet (movie collection)
' Row 1: Year · Official Rating · Community Rating · N Movies
' Row 2: Genres · Studio
sub populateInfoGroupBoxSet(item as object, userSettings as object)
  if item.productionYear > 0
    yearNode = createInfoLabel("releaseYear")
    yearNode.text = stri(item.productionYear).trim()
    displayInfoNode(yearNode)
  end if

  if isValidAndNotEmpty(item.officialRating)
    ratingNode = createInfoLabel("officialRating")
    ratingNode.text = item.officialRating
    displayInfoNode(ratingNode)
  end if

  if userSettings.uiMoviesShowRatings and item.communityRating > 0
    communityRatingNode = CreateObject("roSGNode", "CommunityRating")
    communityRatingNode.id = "communityRating"
    communityRatingNode.rating = item.communityRating
    displayInfoNode(communityRatingNode)
  end if

  if item.childCount > 0
    movieCountNode = createInfoLabel("movieCount")
    movieLabel = tr("Movie")
    if item.childCount <> 1 then movieLabel = tr("Movies")
    movieCountNode.text = stri(item.childCount).trim() + " " + movieLabel
    displayInfoNode(movieCountNode)
  end if

  if item.genres.count() > 0
    genreNode = createInfoLabel("genre")
    genreNode.text = item.genres.join(" / ")
    displayDirectorGenreNode(genreNode)
  end if

  if item.studios.count() > 0
    studioNode = createInfoLabel("studio")
    studioNode.text = item.studios[0]
    displayDirectorGenreNode(studioNode)
  end if
end sub

' populateInfoGroupSeason: Info rows for Season
' Row 1: Year · Official Rating (series) · Avg episode runtime (series) · Ends At
' Row 2: Series name · Episode count · Studio
sub populateInfoGroupSeason(item as object, userSettings as object)
  if item.productionYear > 0
    yearNode = createInfoLabel("releaseYear")
    yearNode.text = stri(item.productionYear).trim()
    displayInfoNode(yearNode)
  end if

  ' Runtime and ends-at fall back to parent series data (Jellyfin stores avg episode runtime there)
  seriesData = m.seasonSeriesData

  ' Official rating lives on the Series in Jellyfin, not on Season items — fall back to series data
  officialRating = item.officialRating
  if not isValidAndNotEmpty(officialRating) and isValid(seriesData)
    officialRating = seriesData.officialRating
  end if
  if isValidAndNotEmpty(officialRating)
    ratingNode = createInfoLabel("officialRating")
    ratingNode.text = officialRating
    displayInfoNode(ratingNode)
  end if

  if isValid(seriesData) and seriesData.runTimeTicks > 0
    runtimeNode = createInfoLabel("runtime")
    runtimeNode.text = stri(round(seriesData.runTimeTicks / 600000000.0)).trim() + " " + tr("mins")
    displayInfoNode(runtimeNode)
  end if

  if isValid(seriesData) and seriesData.runTimeTicks > 0 and not userSettings.uiDesignHideClock
    m.endsAtNode = createInfoLabel("ends-at")
    ' Compute end time directly from series runTimeTicks — do NOT swap m.top.itemContent,
    ' as that fires itemContentChanged() synchronously causing reentrancy and row duplication.
    m.endsAtDurationSeconds = int(seriesData.runTimeTicks / 10000000.0)
    endDate = CreateObject("roDateTime")
    endDate.fromSeconds(endDate.asSeconds() + m.endsAtDurationSeconds)
    endDate.toLocalTime()
    m.endsAtNode.text = tr("Ends at %1").Replace("%1", formatTime(endDate))
    displayInfoNode(m.endsAtNode)
  end if

  ' Row 2: series name · episode count · studio
  if isValidAndNotEmpty(item.seriesName)
    seriesNameNode = createInfoLabel("seriesName")
    seriesNameNode.text = item.seriesName
    displayDirectorGenreNode(seriesNameNode)
  end if

  if item.childCount > 0
    episodeCountNode = createInfoLabel("episodeCount")
    episodeCountNode.text = stri(item.childCount).trim() + " " + tr("episodes")
    displayDirectorGenreNode(episodeCountNode)
  end if

  if isValid(seriesData) and seriesData.studios.count() > 0
    studioNode = createInfoLabel("studio")
    studioNode.text = seriesData.studios[0]
    displayDirectorGenreNode(studioNode)
  end if
end sub

' populateInfoGroupPerson: Info rows for Person
' Row 1: {birthDate} [- {deathDate}] · {n} years old
'   Living:  Jan 1, 1980 · 45 years old
'   Deceased: Jan 1, 1920 - Dec 31, 1980 · 60 years old
' Row 2: {n} Movie(s) [· {n} Episode(s)]  — omitted entirely if both counts are 0
sub populateInfoGroupPerson(item as object)
  ' Row 1: birth / death / age
  if isValidAndNotEmpty(item.premiereDate)
    birthDate = CreateObject("roDateTime")
    birthDate.FromISO8601String(item.premiereDate)
    lifeString = birthDate.AsDateString("short-month-no-weekday")

    if isValidAndNotEmpty(item.endDate)
      ' Deceased: show birth - death dates as one hyphenated unit
      deathDate = CreateObject("roDateTime")
      deathDate.FromISO8601String(item.endDate)
      lifeString = lifeString + " - " + deathDate.AsDateString("short-month-no-weekday")
      ' Age at time of death
      age = deathDate.getYear() - birthDate.getYear()
      if deathDate.getMonth() < birthDate.getMonth()
        age--
      else if deathDate.getMonth() = birthDate.getMonth()
        if deathDate.getDayOfMonth() < birthDate.getDayOfMonth()
          age--
        end if
      end if
    else
      ' Living: calculate age from birth to today
      today = CreateObject("roDateTime")
      age = today.getYear() - birthDate.getYear()
      if today.getMonth() < birthDate.getMonth()
        age--
      else if today.getMonth() = birthDate.getMonth()
        if today.getDayOfMonth() < birthDate.getDayOfMonth()
          age--
        end if
      end if
    end if
    lifeString = lifeString + " · " + stri(age).trim() + " " + tr("years old")

    lifeNode = createInfoLabel("personLife")
    lifeNode.text = lifeString
    displayInfoNode(lifeNode)
  end if

  ' Row 2: movie count / episode count (only shown when the API returns non-zero values)
  if item.movieCount > 0
    movieCountNode = createInfoLabel("personMovieCount")
    movieLabel = tr("Movie")
    if item.movieCount <> 1 then movieLabel = tr("Movies")
    movieCountNode.text = stri(item.movieCount).trim() + " " + movieLabel
    displayDirectorGenreNode(movieCountNode)
  end if
  if item.episodeCount > 0
    episodeCountNode = createInfoLabel("personEpisodeCount")
    episodeLabel = tr("Episode")
    if item.episodeCount <> 1 then episodeLabel = tr("Episodes")
    episodeCountNode.text = stri(item.episodeCount).trim() + " " + episodeLabel
    displayDirectorGenreNode(episodeCountNode)
  end if
end sub

' getHistory: Format the air schedule/status string for a Series item
' Example output: "ABC" or "Fridays at 9:30 PM on NBC"
function getHistory() as string
  item = m.top.itemContent

  airwords = invalid
  studio = invalid
  words = ""
  if isValid(item.airDays) and item.airDays.count() = 1
    airwords = item.airDays[0]
  end if
  if isValidAndNotEmpty(item.airTime)
    if isValid(airwords)
      airwords = airwords + " " + tr("at") + " " + item.airTime
    else
      airwords = item.airTime
    end if
  end if

  if item.studios.count() > 0
    studio = item.studios[0]
  end if

  if not isValid(studio) and not isValid(airwords) then return words

  if isValid(airwords)
    words = airwords
  end if

  if isValid(studio)
    if isValid(airwords)
      words = words + " " + tr("on") + " " + studio
    else
      words = studio
    end if
  end if

  return words
end function

sub itemContentChanged()
  m.animationTargetCalculated = false
  m.seasonSeriesData = invalid
  item = m.top.itemContent

  ' Pre-populate series data from cache so Season info rows render on first paint
  if isValid(item) and item.type = "Season" and isValidAndNotEmpty(item.seriesId)
    cached = m.seasonSeriesCache[item.seriesId]
    if isValid(cached)
      m.seasonSeriesData = cached
    end if
  end if

  if isValid(item) and isValidAndNotEmpty(item.id)
    device = m.global.device
    backdropUrl = getItemBackdropUrl(item, { width: device.uiResolution[0], height: device.uiResolution[1] })
    m.global.sceneManager.callFunc("setBackgroundImage", backdropUrl)
  else
    m.global.sceneManager.callFunc("setBackgroundImage", "")
  end if

  if isValid(item) and isValidAndNotEmpty(item.id)
    m.top.id = item.id

    setItemLogo(item)
    setDateAdded(item)

    setupButtons(item)

    ' Media tracks and stream options are only relevant for playable types
    ' Use reparenting instead of visible=false — invisible nodes still take up LayoutGroup space
    setTracksInLayout(item.type <> "Series" and item.type <> "Season" and item.type <> "Person" and item.type <> "BoxSet")

    if item.type <> "Series" and item.type <> "Season" and item.type <> "Person" and item.type <> "BoxSet"
      if m.top.selectedVideoStreamId = "" and isValidAndNotEmpty(item.mediaSourceId)
        m.top.selectedVideoStreamId = item.mediaSourceId
      end if

      allStreams = []
      mediaSources = invalid
      if isValid(item.mediaSourcesData) and isValidAndNotEmpty(item.mediaSourcesData.mediaSources)
        mediaSources = item.mediaSourcesData.mediaSources
        if isValid(mediaSources[0].MediaStreams)
          allStreams = mediaSources[0].MediaStreams
        end if
      end if

      SetDefaultAudioTrack(allStreams)
      SetUpVideoOptions(mediaSources ?? [])
      SetUpAudioOptions(allStreams)
      SetUpSubtitleDisplay(allStreams, m.top.selectedAudioStreamIndex)
      updateOptionsButtonVisibility()
    else
      ' Series/Season/Person: ensure options button is removed if it was left from a previous load
      if isValid(m.optionsButton)
        m.buttonGrp.removeChild(m.optionsButton)
        m.optionsButton = invalid
      end if
    end if

    populateInfoGroup()
    populateDescriptionGroup()

    setFieldText("videoTitle", item.name)

    updateFavoriteButton()
    updateWatchedButton()

    ' Non-Series/Season types: Resume based on playbackPositionTicks
    ' Series: Resume based on nextUpEpisode (managed by nextUpEpisodeChanged observer)
    ' Season: No resume — NextUp API does not support SeasonId filtering
    if item.type <> "Series" and item.type <> "Season"
      manageResumeButton()
    end if

    ' Series: kick off background fetch of resume/next-up episode for the Resume button
    if item.type = "Series"
      ' Unobserve before re-observing to prevent callbacks stacking on repeated content changes.
      m.loadSeriesResumeTask.unobserveField("content")
      m.loadSeriesResumeTask.control = "STOP"
      m.top.nextUpEpisode = invalid
      m.loadSeriesResumeTask.itemId = item.id
      m.loadSeriesResumeTask.observeField("content", "onSeriesResumeLoaded")
      m.loadSeriesResumeTask.control = "RUN"
    end if

    ' Season: kick off background fetch of parent series metadata for ratings/runtime/genres/logo
    if item.type = "Season" and isValidAndNotEmpty(item.seriesId)
      ' Unobserve before re-observing to prevent callbacks stacking on repeated content changes.
      m.loadSeasonSeriesTask.unobserveField("content")
      m.loadSeasonSeriesTask.itemId = item.seriesId
      m.loadSeasonSeriesTask.observeField("content", "onSeasonSeriesDataLoaded")
      m.loadSeasonSeriesTask.control = "RUN"
    end if

    ' Load extras rows on first content load only; explicit refresh via onRefreshExtrasData.
    if not m.extrasLoaded
      m.extrasLoaded = true
      if item.type = "Person"
        m.extrasGrid.callFunc("loadPersonVideos", item.id)
      else
        m.extrasGrid.type = item.type
        m.extrasGrid.callFunc("loadParts", item)
      end if
    end if
  end if

  m.buttonGrp.visible = true
  stopLoadingSpinner()

  ' Re-hide description and tracks if extras slider is still active (e.g. returning from sub-screen)
  if m.extrasActive
    activateExtras()
  end if
end sub

sub SetUpVideoOptions(streams)
  videos = []
  codecDetailsSet = false
  hasMultipleSources = streams.Count() > 1

  for i = 0 to streams.Count() - 1
    if streams[i].VideoType = "VideoFile"
      codec = formatVideoSourceTitle(streams[i], hasMultipleSources)

      if not codecDetailsSet
        setFieldText("video_codec", tr("Video") + ": " + codec)
        codecDetailsSet = true
      end if

      videos.push({
        "Title": codec,
        "Description": tr("Video"),
        "Selected": m.top.selectedVideoStreamId = streams[i].id,
        "StreamID": streams[i].id,
        "video_codec": codec
      })
    end if
  end for

  if streams.count() > 1
    m.top.findnode("video_codec_count").text = "+" + stri(streams.Count() - 1).trim()
  end if

  options = {}
  options.videos = videos
  m.options.options = options
end sub

sub SetUpAudioOptions(streams)
  tracks = []

  for i = 0 to streams.Count() - 1
    if streams[i].Type = "Audio"
      ' Use server-side stream.index for both the pre-selection check and the StreamIndex value
      ' carried to ItemOptions. Jellyfin indices span all stream types and must match what
      ' ItemPostPlaybackInfo expects.
      if isValid(streams[i].index)
        tracks.push({ "Title": formatAudioDisplayTitle(streams[i]), "Description": streams[i].Title, "Selected": m.top.selectedAudioStreamIndex = streams[i].index, "StreamIndex": streams[i].index })
      end if
    end if
  end for

  if tracks.count() > 1
    m.top.findnode("audio_codec_count").text = "+" + stri(tracks.Count() - 1).trim()
  end if

  options = {}
  if isValid(m.options.options.videos)
    options.videos = m.options.options.videos
  end if
  options.audios = tracks
  m.options.options = options
end sub

' SetUpSubtitleDisplay: Display the subtitle stream that will be auto-selected during playback.
' Uses findDefaultSubtitleStreamIndex() to match the same selection logic as playback
' (subtitle mode, language preference, text-only preference, smart mode audio matching).
' Shows "+N" count if multiple subtitle tracks exist.
'
' @param {object} streams - Array of MediaStream objects (all types: video, audio, subtitle)
' @param {integer} selectedAudioStreamIndex - Server-side index of the selected audio stream
sub SetUpSubtitleDisplay(streams, selectedAudioStreamIndex as integer)
  subtitleCountNode = m.top.findNode("subtitle_codec_count")
  if isValid(subtitleCountNode) then subtitleCountNode.text = ""

  if not isValid(streams) then return

  subtitleCount = 0
  for i = 0 to streams.count() - 1
    if isValid(streams[i].Type) and LCase(streams[i].Type) = "subtitle"
      subtitleCount++
    end if
  end for

  if subtitleCount = 0
    setFieldText("subtitle_codec", tr("Subtitles") + ": " + tr("None"))
    return
  end if

  selectedIndex = findDefaultSubtitleStreamIndex(streams, selectedAudioStreamIndex)

  if selectedIndex >= 0
    for i = 0 to streams.count() - 1
      if isValid(streams[i].index) and streams[i].index = selectedIndex
        setFieldText("subtitle_codec", tr("Subtitles") + ": " + formatSubtitleDisplayTitle(streams[i]))
        exit for
      end if
    end for
  else
    setFieldText("subtitle_codec", tr("Subtitles") + ": " + tr("Off"))
  end if

  if subtitleCount > 1
    m.top.findNode("subtitle_codec_count").text = "+" + stri(subtitleCount - 1).trim()
  end if
end sub

sub SetDefaultAudioTrack(mediaStreams as object)
  if not isValid(mediaStreams) then return

  localUser = m.global.user
  playDefault = resolvePlayDefaultAudioTrack(localUser.settings, localUser.config)
  selectedIndex = findBestAudioStreamIndex(mediaStreams, playDefault, localUser.config.audioLanguagePreference)

  defaultAudioStream = invalid
  for each stream in mediaStreams
    if LCase(stream.Type) = "audio" and isValid(stream.index) and stream.index = selectedIndex
      defaultAudioStream = stream
      exit for
    end if
  end for

  m.top.selectedAudioStreamIndex = selectedIndex

  if isValid(defaultAudioStream)
    setFieldText("audio_codec", tr("Audio") + ": " + formatAudioDisplayTitle(defaultAudioStream))
  end if
end sub

sub setFieldText(field, value)
  node = m.top.findNode(field)
  if not isValid(node) or not isValid(value) then return

  if type(value) = "roInt" or type(value) = "Integer"
    value = str(value).trim()
  else if type(value) = "roFloat" or type(value) = "Float"
    value = str(value).trim()
  else if type(value) <> "roString" and type(value) <> "String"
    value = ""
  else
    value = value.trim()
  end if

  node.text = value
end sub

function getRuntime() as integer
  ' A tick is .1ms, so 1/10,000,000 for ticks to seconds,
  ' then 1/60 for seconds to minutes... 1/600,000,000
  return round(m.top.itemContent.runTimeTicks / 600000000.0)
end function

function getEndTime() as string
  date = CreateObject("roDateTime")
  duration_s = int(m.top.itemContent.runTimeTicks / 10000000.0)
  date.fromSeconds(date.asSeconds() + duration_s)
  date.toLocalTime()
  return formatTime(date)
end function

' onClockMinuteChanged: Dynamically update "Ends At" time when overhang clock minute changes
sub onClockMinuteChanged()
  if not isValid(m.endsAtNode) then return
  date = CreateObject("roDateTime")
  date.fromSeconds(date.asSeconds() + m.endsAtDurationSeconds)
  date.toLocalTime()
  m.endsAtNode.text = tr("Ends at %1").Replace("%1", formatTime(date))
end sub

sub updateFavoriteButton()
  fave = m.top.itemContent.isFavorite
  favoriteButton = m.top.findNode("favoriteButton")
  if isValid(favoriteButton)
    if isValid(fave) and fave
      favoriteButton.selected = true
    else
      favoriteButton.selected = false
    end if
  end if
end sub

sub updateWatchedButton()
  watched = m.top.itemContent.isWatched
  watchedButton = m.top.findNode("watchedButton")
  if isValid(watchedButton)
    if watched
      watchedButton.selected = true
    else
      watchedButton.selected = false
    end if
  end if
end sub

' setItemLogo: Set the item logo image URL if available.
' Fallback chains by type:
' - Movie/Series:   Logo → primaryImageTag (poster, compact size)
' - Episode/Season/Recording: primaryImageTag (item thumb/poster) → parentLogoImageTag (series logo) → seriesPrimaryImageTag → hide
' - Video/MusicVideo: primaryImageTag (poster)
' - Person: primaryImageTag (portrait photo, tall) → silhouette icon
' @param {object} item - JellyfinBaseItem node
sub setItemLogo(item as object)
  if not isValid(m.itemLogo) then return

  logoItemId = item.id
  logoImageTag = item.logoImageTag

  if item.type = "Person"
    ' Person: display primary (portrait) photo where the logo sits; fall back to silhouette icon
    if isValidAndNotEmpty(item.primaryImageTag)
      imgParams = { maxHeight: 534, maxWidth: LOGO_MAX_DISPLAY_WIDTH, quality: 90, tag: item.primaryImageTag }
      m.itemLogo.uri = ImageURL(item.id, "Primary", imgParams)
    else
      m.itemLogo.uri = "pkg:/images/icons/baseline_person_white_48dp.png"
    end if
    return
  else if item.type = "Episode" or item.type = "Season" or item.type = "Recording"
    ' Show the item's own primary image first (episode still, season poster).
    ' If none, fall through to the series logo chain via logoItemId/logoImageTag.
    if isValidAndNotEmpty(item.primaryImageTag)
      imgParams = { maxHeight: 534, maxWidth: LOGO_MAX_DISPLAY_WIDTH, quality: 90, tag: item.primaryImageTag }
      m.itemLogo.uri = ImageURL(item.id, "Primary", imgParams)
      return
    end if
    if isValidAndNotEmpty(item.parentLogoItemId)
      logoItemId = item.parentLogoItemId
      logoImageTag = item.parentLogoImageTag
    end if
  else if item.type = "Video" or item.type = "MusicVideo"
    ' Videos and MusicVideos don't typically have Logo images — use Primary (poster) image instead
    if isValidAndNotEmpty(item.primaryImageTag)
      imgParams = { maxHeight: 212, maxWidth: 500, quality: 90, tag: item.primaryImageTag }
      m.itemLogo.uri = ImageURL(item.id, "Primary", imgParams)
      return
    end if
    m.itemLogo.visible = false
    m.itemLogo.uri = ""
    return
  end if

  if isValidAndNotEmpty(logoImageTag)
    imgParams = { maxHeight: 212, maxWidth: 500, quality: 90, tag: logoImageTag }
    m.itemLogo.uri = ImageURL(logoItemId, "Logo", imgParams)
  else if item.type = "Movie" or item.type = "Series" or item.type = "BoxSet"
    ' No logo: fall back to primary (poster) image — fetch at full quality; display height is
    ' capped by LOGO_MAX_DISPLAY_HEIGHT in onLogoLoadStatusChanged to avoid overlapping info rows.
    if isValidAndNotEmpty(item.primaryImageTag)
      imgParams = { maxHeight: 534, maxWidth: LOGO_MAX_DISPLAY_WIDTH, quality: 90, tag: item.primaryImageTag }
      m.itemLogo.uri = ImageURL(item.id, "Primary", imgParams)
    else
      m.itemLogo.visible = false
      m.itemLogo.uri = ""
    end if
  else if item.type = "Episode" or item.type = "Season" or item.type = "Recording"
    ' No item primary (already tried above), no series logo: fall back to series primary poster
    if isValidAndNotEmpty(item.seriesPrimaryImageTag) and isValidAndNotEmpty(item.seriesId)
      imgParams = { maxHeight: 534, maxWidth: LOGO_MAX_DISPLAY_WIDTH, quality: 90, tag: item.seriesPrimaryImageTag }
      m.itemLogo.uri = ImageURL(item.seriesId, "Primary", imgParams)
    else
      m.itemLogo.visible = false
      m.itemLogo.uri = ""
    end if
  else
    m.itemLogo.visible = false
    m.itemLogo.uri = ""
  end if
end sub

' setDateAdded: Set date added label text and position at bottom right corner
' @param {object} item - JellyfinBaseItem node
sub setDateAdded(item as object)
  if not isValid(m.dateCreatedLabel) then return

  if not isValidAndNotEmpty(item.dateCreated)
    m.dateCreatedLabel.visible = false
    return
  end if

  dateAdded = CreateObject("roDateTime")
  dateAdded.FromISO8601String(item.dateCreated)

  ' Hide dates at or before Unix epoch — server returned no real date
  if dateAdded.getYear() <= 1970
    m.dateCreatedLabel.visible = false
    return
  end if

  dateAdded.toLocalTime()
  m.dateCreatedLabel.text = tr("Added") + " " + dateAdded.AsDateString("short-month-no-weekday") + " " + formatTime(dateAdded)

  ' Position at bottom right: screen is 1920x1080, with 96px padding
  buttonRect = m.buttonGrp.boundingRect()
  buttonBottom = buttonRect.y + buttonRect.height
  m.dateCreatedLabel.translation = [1920 - 96 - 450, buttonBottom - 30]
  m.dateCreatedLabel.visible = true
end sub

' onLogoLoadStatusChanged: Position logo to the right, with its bottom just above the date label.
' When the date label is hidden, anchors to where the date label would be.
' Images smaller than LOGO_MIN_DISPLAY_HEIGHT are scaled up while preserving aspect ratio,
' unless the logo is very wide/flat, in which case LOGO_MAX_DISPLAY_WIDTH takes precedence.
sub onLogoLoadStatusChanged()
  if not isValid(m.itemLogo) then return

  if m.itemLogo.loadStatus = "ready"
    logoWidth = m.itemLogo.bitmapWidth
    logoHeight = m.itemLogo.bitmapHeight

    if logoWidth > 0 and logoHeight > 0
      ' Compute display size satisfying both constraints where possible.
      ' LOGO_MAX_DISPLAY_WIDTH is a hard layout constraint (prevents button overlap).
      ' LOGO_MIN_DISPLAY_HEIGHT is a best-effort aesthetic target — it cannot be
      ' guaranteed when the logo is too wide/flat to satisfy both simultaneously.
      scaleToMinHeight = LOGO_MIN_DISPLAY_HEIGHT / logoHeight
      if logoHeight >= LOGO_MIN_DISPLAY_HEIGHT and logoWidth <= LOGO_MAX_DISPLAY_WIDTH
        ' Native size already satisfies both constraints.
        displayWidth = logoWidth
        displayHeight = logoHeight
      else if int(logoWidth * scaleToMinHeight) <= LOGO_MAX_DISPLAY_WIDTH
        ' Scaling up to min-height keeps width within max-width — both satisfied.
        displayWidth = int(logoWidth * scaleToMinHeight)
        displayHeight = LOGO_MIN_DISPLAY_HEIGHT
      else
        ' Very wide/flat logo: max-width is the binding constraint.
        ' Height will be below LOGO_MIN_DISPLAY_HEIGHT — max-width takes precedence.
        displayWidth = LOGO_MAX_DISPLAY_WIDTH
        displayHeight = int(logoHeight * LOGO_MAX_DISPLAY_WIDTH / logoWidth)
      end if

      ' Cap portrait images for non-Person items so the logo top stays below the metadata rows.
      ' Person photos are exempt — their info rows are short and don't reach the right edge.
      if isValid(m.top.itemContent) and m.top.itemContent.type <> "Person"
        if displayHeight > LOGO_MAX_DISPLAY_HEIGHT
          displayWidth = int(displayWidth * LOGO_MAX_DISPLAY_HEIGHT / displayHeight)
          displayHeight = LOGO_MAX_DISPLAY_HEIGHT
        end if
      end if

      m.itemLogo.width = displayWidth
      m.itemLogo.height = displayHeight
      m.itemLogo.loadDisplayMode = "scaleToFit"

      rightEdge = 1920 * 0.95
      logoX = rightEdge - displayWidth

      if isValid(m.dateCreatedLabel) and m.dateCreatedLabel.visible
        ' Anchor logo bottom 18px above the date label
        dateLabelY = m.dateCreatedLabel.translation[1]
      else
        ' Date label hidden — use the same position it would occupy
        buttonRect = m.buttonGrp.boundingRect()
        buttonBottom = buttonRect.y + buttonRect.height
        dateLabelY = buttonBottom - 30
      end if
      logoY = dateLabelY - displayHeight - 18

      m.itemLogo.translation = [logoX, logoY]
      m.itemLogo.visible = true
    end if
  else if m.itemLogo.loadStatus = "failed"
    m.itemLogo.uri = ""
    m.itemLogo.visible = false
  end if
end sub

sub onItemDetailsRendered()
  if m.itemDetails.renderTracking = "none" then return
  updateTextGradient()
  ' Only calculate animation target once per content load — itemInfoRows height is stable after metadata is set.
  if not m.animationTargetCalculated
    m.animationTargetCalculated = true
    updateItemDetailsAnimationTarget()
  end if
end sub

' updateItemDetailsAnimationTarget: Slide itemDetails so the bottom of itemInfoRows sits just
' above the extras pane. boundingRect() on a child returns LOCAL coords relative to the parent's
' translation point, so we use translation[1] (not boundingRect().y) as the screen origin.
sub updateItemDetailsAnimationTarget()
  if m.extrasActive then return

  currentTransY = m.itemDetails.translation[1]
  itemInfoRowsLocalRect = m.itemInfoRows.boundingRect()
  screenBottomOfInfoRows = currentTransY + itemInfoRowsLocalRect.y + itemInfoRowsLocalRect.height

  dY = (EXTRAS_GRP_TARGET_Y - ITEM_DETAILS_EXTRAS_PADDING) - screenBottomOfInfoRows
  targetTransY = currentTransY + dY

  startTransX = m.itemDetails.translation[0]
  m.itemDetailsSliderInterp.keyValue = [[startTransX, currentTransY], [startTransX, targetTransY]]
end sub

sub updateTextGradient()
  if not isValid(m.itemTextGradient) or not isValid(m.itemDetails) then return

  globalConstants = m.global.constants
  itemDetailsRect = m.itemDetails.boundingRect()
  itemDetailsTop = itemDetailsRect.y
  extrasGrpY = 972
  gradientHeight = extrasGrpY - itemDetailsTop

  if gradientHeight > 0
    m.itemTextGradient.translation = [0, itemDetailsTop]
    m.itemTextGradient.height = gradientHeight
    m.itemTextGradient.width = 1920
    m.itemTextGradient.startColor = globalConstants.colorBlack + globalConstants.alpha60
    m.itemTextGradient.endColor = globalConstants.colorBlack + globalConstants.alpha0
  end if
end sub

' updateOptionsButtonVisibility: Create/remove options button based on available tracks.
' Creates button if there are multiple video or audio tracks to choose from.
' Removes button if there is 1 or fewer video tracks AND 1 or fewer audio tracks.
sub updateOptionsButtonVisibility()
  videoCount = 0
  audioCount = 0

  if isValid(m.options.options) and isValid(m.options.options.videos)
    videoCount = m.options.options.videos.count()
  end if

  if isValid(m.options.options) and isValid(m.options.options.audios)
    audioCount = m.options.options.audios.count()
  end if

  shouldShowOptions = videoCount > 1 or audioCount > 1
  buttonExists = isValid(m.optionsButton)

  if shouldShowOptions and not buttonExists
    currentFocusIndex = m.buttonGrp.buttonFocused

    m.optionsButton = CreateObject("roSGNode", "IconButton")
    m.optionsButton.id = "optionsButton"
    m.optionsButton.icon = "pkg:/images/icons/settings.png"
    m.optionsButton.text = tr("Options")

    playButtonIndex = getButtonIndex("playButton")
    if playButtonIndex >= 0
      insertIndex = playButtonIndex + 1
      m.buttonGrp.insertChild(m.optionsButton, insertIndex)

      if isValid(currentFocusIndex) and currentFocusIndex >= insertIndex
        m.buttonGrp.buttonFocused = currentFocusIndex + 1
      end if
    else
      m.log.warn("playButton not found in button group; appending optionsButton to end")
      m.buttonGrp.appendChild(m.optionsButton)
    end if
  else if not shouldShowOptions and buttonExists
    currentFocusIndex = m.buttonGrp.buttonFocused
    optionsButtonIndex = getButtonIndex("optionsButton")

    m.buttonGrp.removeChild(m.optionsButton)
    m.optionsButton = invalid

    if isValid(currentFocusIndex) and isValid(optionsButtonIndex) and currentFocusIndex > optionsButtonIndex
      focusIndex = currentFocusIndex - 1
      if focusIndex < 0 then focusIndex = 0
      m.buttonGrp.buttonFocused = focusIndex
    end if
    focusButtonGroupChild()
  end if
end sub

' getButtonIndex: Find the index of a button by ID in the button group
' @param {string} buttonId - The id of the button to find
' @return {integer} The index of the button, or -1 if not found
function getButtonIndex(buttonId as string) as integer
  for i = 0 to m.buttonGrp.getChildCount() - 1
    child = m.buttonGrp.getChild(i)
    if isValid(child) and child.id = buttonId
      return i
    end if
  end for
  return -1
end function

' onRefreshExtrasData: Reload all extras rows when the user explicitly presses Refresh.
sub onRefreshExtrasData()
  item = m.top.itemContent
  if not isValid(item) or not isValidAndNotEmpty(item.id) then return
  if item.type = "Person"
    m.extrasGrid.callFunc("loadPersonVideos", item.id)
  else
    m.extrasGrid.type = item.type
    m.extrasGrid.callFunc("loadParts", item)
  end if
end sub

sub activateExtras()
  m.extrasActive = true
end sub

sub deactivateExtras()
  m.extrasActive = false
end sub

' setDescriptionInLayout: Add or remove itemDescription from the itemDetails LayoutGroup.
' Invisible nodes still take up space in RSG LayoutGroups, so reparenting is required
' to truly eliminate spacing when there is no description text.
sub setDescriptionInLayout(shouldShow as boolean)
  if shouldShow = m.itemDescriptionInLayout then return
  if shouldShow
    m.itemDetails.insertChild(m.itemDescription, 1)
  else
    m.itemDetails.removeChild(m.itemDescription)
  end if
  m.itemDescriptionInLayout = shouldShow
end sub

' setTracksInLayout: Add or remove itemTracks from the itemDetails LayoutGroup.
' Invisible nodes still take up space in RSG LayoutGroups, so reparenting is required
' to truly eliminate spacing when tracks are not applicable (e.g. Series type).
sub setTracksInLayout(shouldShow as boolean)
  if shouldShow = m.itemTracksInLayout then return
  if shouldShow
    m.itemDetails.appendChild(m.itemTracks)
  else
    m.itemDetails.removeChild(m.itemTracks)
  end if
  m.itemTracksInLayout = shouldShow
end sub

' focusButtonGroupChild: Focus the button at the current buttonFocused index directly
sub focusButtonGroupChild()
  focusIndex = m.buttonGrp.buttonFocused
  if focusIndex < 0 or focusIndex >= m.buttonGrp.getChildCount()
    return
  end if
  m.buttonGrp.getChild(focusIndex).setFocus(true)
end sub

' setupButtons: Build the complete button set for the given item type.
' Clears all existing buttons (including the LoadingButton placeholder from init()) and adds
' the correct sync buttons in order. Async buttons (Series Resume, Trailer, Person Shuffle)
' are handled separately by their existing observers.
' Called on every itemContentChanged — the button list is rebuilt from scratch each time.
sub setupButtons(item as object)
  ' Capture focus state BEFORE clearing — removing buttons nulls out focus, so
  ' isInFocusChain() would return false even if buttons were focused a moment ago.
  wasButtonGroupFocused = m.buttonGrp.isInFocusChain()
  ' Capture the focused button's ID so we can restore to the same button after the rebuild.
  ' Falling back to index 0 only when the previously-focused button no longer exists (e.g. Delete
  ' was removed because canDelete changed) or when this is the initial load (LoadingButton).
  focusedButtonId = ""
  focusIndex = m.buttonGrp.buttonFocused
  if focusIndex >= 0 and focusIndex < m.buttonGrp.getChildCount()
    focusedBtn = m.buttonGrp.getChild(focusIndex)
    if isValid(focusedBtn) and focusedBtn.id <> "loadingButton" then focusedButtonId = focusedBtn.id
  end if

  ' Clear everything (LoadingButton placeholder + any buttons from a previous content load)
  while m.buttonGrp.getChildCount() > 0
    m.buttonGrp.removeChild(m.buttonGrp.getChild(0))
  end while

  ' Reset dynamic refs that were pointing into the now-cleared button group
  m.optionsButton = invalid
  m.shuffleButton = invalid

  itemType = item.type

  ' Play button — all types except Person
  if itemType <> "Person"
    playButton = CreateObject("roSGNode", "IconButton")
    playButton.id = "playButton"
    playButton.icon = "pkg:/images/icons/play.png"
    if itemType = "Series" or itemType = "Season" or itemType = "BoxSet"
      playButton.text = tr("Play All")
    else
      playButton.text = tr("Play")
    end if
    m.buttonGrp.appendChild(playButton)
  end if

  ' Watched button — all types except Person
  if itemType <> "Person"
    watchedButton = CreateObject("roSGNode", "IconButton")
    watchedButton.id = "watchedButton"
    watchedButton.icon = "pkg:/images/icons/check.png"
    watchedButton.text = tr("Watched")
    m.buttonGrp.appendChild(watchedButton)
  end if

  ' Shuffle button — Series, Season, and BoxSet (Person shuffle is async via onPersonHasMediaChanged)
  if itemType = "Series" or itemType = "Season" or itemType = "BoxSet"
    m.shuffleButton = CreateObject("roSGNode", "IconButton")
    m.shuffleButton.id = "shuffleButton"
    m.shuffleButton.icon = "pkg:/images/icons/shuffle.png"
    m.shuffleButton.text = tr("Shuffle")
    m.buttonGrp.appendChild(m.shuffleButton)
  end if

  ' Favorite button — all types
  favoriteButton = CreateObject("roSGNode", "IconButton")
  favoriteButton.id = "favoriteButton"
  favoriteButton.icon = "pkg:/images/icons/heart.png"
  favoriteButton.text = tr("Favorite")
  m.buttonGrp.appendChild(favoriteButton)

  ' Go to Series button — Season and Episode only
  if itemType = "Season" or itemType = "Episode"
    goToSeriesButton = CreateObject("roSGNode", "IconButton")
    goToSeriesButton.id = "goToSeriesButton"
    goToSeriesButton.icon = "pkg:/images/icons/tv.png"
    goToSeriesButton.text = tr("Go to Series")
    m.buttonGrp.appendChild(goToSeriesButton)
  end if

  ' Trailer button — restore on refresh (on first load, trailerAvailableChanged adds it async)
  ' Must come before Delete to match the insertion order used in trailerAvailableChanged()
  if m.top.trailerAvailable
    trailerButton = CreateObject("roSGNode", "IconButton")
    trailerButton.id = "trailerButton"
    trailerButton.icon = "pkg:/images/icons/playOutline.png"
    trailerButton.text = tr("Play Trailer")
    m.buttonGrp.appendChild(trailerButton)
  end if

  ' Delete button — when server grants permission
  if item.canDelete
    deleteButton = CreateObject("roSGNode", "IconButton")
    deleteButton.id = "deleteButton"
    deleteButton.icon = "pkg:/images/icons/delete.png"
    deleteButton.text = tr("Delete")
    m.buttonGrp.appendChild(deleteButton)
  end if

  ' Refresh button — always last
  refreshButton = CreateObject("roSGNode", "IconButton")
  refreshButton.id = "refreshButton"
  refreshButton.icon = "pkg:/images/icons/refresh.png"
  refreshButton.text = tr("Refresh")
  m.buttonGrp.appendChild(refreshButton)

  ' Person Shuffle: if personHasMedia was already set before this refresh, restore the button now.
  ' On first load this is a no-op; onPersonHasMediaChanged fires later via the extras chain.
  if itemType = "Person"
    onPersonHasMediaChanged()
  end if

  ' Restore focus to the same button the user was on before the rebuild, identified by ID.
  ' Falls back to index 0 when the button no longer exists or this is the initial load.
  restoredIndex = 0
  if focusedButtonId <> ""
    restoredIndex = getButtonIndex(focusedButtonId)
    if restoredIndex < 0 then restoredIndex = 0
  end if
  m.buttonGrp.buttonFocused = restoredIndex
  if wasButtonGroupFocused
    focusButtonGroupChild()
  end if
end sub

' onPersonHasMediaChanged: Show or hide the Shuffle button once the person extras chain completes.
' Fires via alwaysNotify so it triggers even when personHasMedia stays the same value (e.g. on refresh).
sub onPersonHasMediaChanged()
  if not isValid(m.top.itemContent) or m.top.itemContent.type <> "Person" then return
  if m.extrasGrid.personHasMedia
    if not isValid(m.shuffleButton)
      m.shuffleButton = CreateObject("roSGNode", "IconButton")
      m.shuffleButton.id = "shuffleButton"
      m.shuffleButton.icon = "pkg:/images/icons/shuffle.png"
      m.shuffleButton.text = tr("Shuffle")
      ' Insert before Favorite so button order is: Shuffle · Favorite · Refresh
      currentFocusIndex = m.buttonGrp.buttonFocused
      favoriteButtonIndex = getButtonIndex("favoriteButton")
      insertIndex = 0
      if favoriteButtonIndex >= 0 then insertIndex = favoriteButtonIndex
      m.buttonGrp.insertChild(m.shuffleButton, insertIndex)

      if currentFocusIndex = 0
        ' User is on the default button (index 0) — Shuffle takes that position, follow it.
        m.buttonGrp.buttonFocused = 0
        if m.buttonGrp.isInFocusChain()
          focusButtonGroupChild()
        end if
      else
        ' User moved focus away from the default — shift index to track their button, don't steal.
        m.buttonGrp.buttonFocused = currentFocusIndex + 1
      end if
    end if
  else
    if isValid(m.shuffleButton)
      currentFocusIndex = m.buttonGrp.buttonFocused
      shuffleIndex = getButtonIndex("shuffleButton")
      m.buttonGrp.removeChild(m.shuffleButton)
      m.shuffleButton = invalid

      ' Shuffle was at or before the focused button — shift the index down by one so
      ' buttonFocused still tracks the same button after the removal.
      if isValid(shuffleIndex) and shuffleIndex >= 0 and currentFocusIndex >= shuffleIndex
        focusIndex = currentFocusIndex - 1
        if focusIndex < 0 then focusIndex = 0
        m.buttonGrp.buttonFocused = focusIndex
      end if

      ' Re-anchor Roku RSG focus if the button group is active — the removed node may
      ' have been the physically-focused child, leaving focus orphaned.
      if m.buttonGrp.isInFocusChain()
        focusButtonGroupChild()
      end if
    end if
  end if
end sub


function round(f as float) as integer
  ' BrightScript only has a "floor" round.
  ' Compare floor to floor+1 to find which is closer.
  m = int(f)
  n = m + 1
  x = abs(f - m)
  y = abs(f - n)
  if y > x
    return m
  else
    return n
  end if
end function

' audioOptionsClosed: Sync audio stream selection back from ItemOptions popup
sub audioOptionsClosed()
  if m.options.audioStreamIndex <> m.top.selectedAudioStreamIndex
    m.top.selectedAudioStreamIndex = m.options.audioStreamIndex
    for each stream in m.top.itemContent.audioStreams
      if stream.index = m.top.selectedAudioStreamIndex
        setFieldText("audio_codec", tr("Audio") + ": " + formatAudioDisplayTitle(stream))
        exit for
      end if
    end for
  end if
  m.buttonGrp.setFocus(true)
end sub

' videoOptionsClosed: Sync video stream selection back from ItemOptions popup;
' reload audio/subtitle options for the newly selected source
sub videoOptionsClosed()
  if m.options.videoStreamId <> m.top.selectedVideoStreamId
    m.top.selectedVideoStreamId = m.options.videoStreamId
    setFieldText("video_codec", tr("Video") + ": " + m.options.video_codec)
    m.top.unobserveField("itemContent")
    mediaSources = m.top.itemContent.mediaSourcesData.mediaSources
    for each mediaSource in mediaSources
      if mediaSource.id = m.top.selectedVideoStreamId
        streams = mediaSource.MediaStreams ?? []
        SetDefaultAudioTrack(streams)
        SetUpAudioOptions(streams)
        SetUpSubtitleDisplay(streams, m.top.selectedAudioStreamIndex)
        exit for
      end if
    end for
    m.top.observeField("itemContent", "itemContentChanged")
  end if
  m.buttonGrp.setFocus(true)
end sub

' onSeasonSeriesDataLoaded: Update Season info rows and logo with parent series metadata
sub onSeasonSeriesDataLoaded()
  m.loadSeasonSeriesTask.unobserveField("content")
  content = m.loadSeasonSeriesTask.content

  if not isValid(content) or content.count() = 0 then return
  seriesItem = content[0]
  if not isValid(seriesItem) then return
  m.seasonSeriesData = seriesItem

  ' Update cache so future visits to any season of this series render immediately
  seriesId = m.top.itemContent.seriesId
  if isValidAndNotEmpty(seriesId)
    m.seasonSeriesCache[seriesId] = seriesItem
  end if

  ' Clear both rows — removeChildrenIndex(num_children, startIndex)
  m.infoGroup.removeChildrenIndex(m.infoGroup.getChildCount(), 0)
  m.directorGenreGroup.removeChildrenIndex(m.directorGenreGroup.getChildCount(), 0)
  m.infoDividerCount = 0
  m.directorGenreDividerCount = 0
  m.endsAtNode = invalid
  m.endsAtDurationSeconds = 0

  populateInfoGroupSeason(m.top.itemContent, m.global.user.settings)

  ' Row heights changed — reset so onItemDetailsRendered() recalculates the extras animation target
  m.animationTargetCalculated = false

  ' Update logo using series data if the season didn't carry parentLogoItemId
  if isValid(m.itemLogo) and not m.itemLogo.visible
    setItemLogo(m.seasonSeriesData)
  end if
end sub

' onSeriesResumeLoaded: Set nextUpEpisode from seriesResume task result to show/hide the Resume button
sub onSeriesResumeLoaded()
  m.loadSeriesResumeTask.unobserveField("content")
  content = m.loadSeriesResumeTask.content
  m.loadSeriesResumeTask.content = []

  if isValid(content) and content.count() > 0
    m.top.nextUpEpisode = content[0]
  else
    m.top.nextUpEpisode = invalid
  end if
end sub

' onFirstEpisodeLoaded: Start playback of first episode for Series Play button
sub onFirstEpisodeLoaded()
  m.loadFirstEpisodeTask.unobserveField("content")
  content = m.loadFirstEpisodeTask.content

  if isValid(content) and content.count() > 0
    m.top.quickPlayNode = content[0]
  else
    m.log.warn("Series Play: could not load first episode for series", m.top.itemContent.id)
    m.global.sceneManager.callFunc("standardDialog", tr("Playback Error"), { data: ["<p>" + tr("Could not load the first episode.") + "</p>"] })
  end if
end sub

function onKeyEvent(key as string, press as boolean) as boolean
  ' Options popup: intercept OK release on optionsButton
  if key = "OK" and isValid(m.optionsButton) and m.optionsButton.isInFocusChain()
    m.options.visible = true
    m.options.setFocus(true)
    return true
  end if

  if not press then return false

  item = m.top.itemContent
  itemType = ""
  if isValid(item)
    itemType = item.type
  end if

  ' Series-specific button handling — intercept before Main.bs receives buttonSelected
  if itemType = "Series" and key = "OK" and m.buttonGrp.isInFocusChain()
    focusedButton = m.buttonGrp.getChild(m.buttonGrp.buttonFocused)
    if isValid(focusedButton)
      if focusedButton.id = "playButton" and isValidAndNotEmpty(item.id)
        ' Load first episode and set quickPlayNode
        m.loadFirstEpisodeTask.itemId = item.id
        m.loadFirstEpisodeTask.observeField("content", "onFirstEpisodeLoaded")
        m.loadFirstEpisodeTask.control = "RUN"
        return true
      else if focusedButton.id = "resumeButton" and isValid(m.top.nextUpEpisode) and isValidAndNotEmpty(m.top.nextUpEpisode.id)
        m.top.quickPlayNode = m.top.nextUpEpisode
        return true
      end if
    end if
  end if

  ' DOWN: itemDescription -> buttonGrp
  if key = "down" and m.itemDescription.isInFocusChain()
    m.top.lastFocus = m.buttonGrp
    m.buttonGrp.setFocus(true)
    return true
  end if

  ' DOWN: buttonGrp -> extrasGrid
  if key = "down" and m.buttonGrp.isInFocusChain()
    ' Guard: don't open the extras pane when there are no rows — focus would be lost in an empty grid.
    ' content.getChildCount() is 0 when the async chain is still in-flight or returned no data.
    if m.extrasGrid.content.getChildCount() = 0 then return true

    m.top.lastFocus = m.extrasGrid
    m.extrasGrid.setFocus(true)

    activateExtras()

    ' Hide description and tracks before sliding up so they don't show during the transition
    m.itemDescription.opacity = 0
    m.itemTracks.opacity = 0

    ' Animate itemDetails to top (synced with extras slider)
    m.itemDetailsSliderInterp.reverse = false
    m.itemDetailsSlider.control = "start"

    ' Extras slider animation
    m.top.findNode("VertSlider").reverse = false
    m.top.findNode("colorSlider").reverse = false
    m.top.findNode("pplAnime").control = "start"
    return true
  end if

  ' UP: extrasGrid -> buttonGrp
  if key = "up" and m.top.findNode("extrasGrid").isInFocusChain()
    if m.extrasGrid.itemFocused = 0
      m.top.lastFocus = m.buttonGrp

      deactivateExtras()

      ' Restore description and tracks before sliding back down
      m.itemDescription.opacity = 1
      m.itemTracks.opacity = 1

      ' Animate itemDetails back to rest position (synced with extras slider)
      m.itemDetailsSliderInterp.reverse = true
      m.itemDetailsSlider.control = "start"

      ' Extras slider animation
      m.top.findNode("VertSlider").reverse = true
      m.top.findNode("colorSlider").reverse = true
      m.top.findNode("pplAnime").control = "start"
      m.buttonGrp.setFocus(true)
      return true
    end if
  end if

  ' UP: buttonGrp -> itemDescription (if in layout, i.e. has content)
  if key = "up" and m.buttonGrp.isInFocusChain()
    if m.itemDescriptionInLayout
      m.top.lastFocus = m.itemDescription
      m.itemDescription.setFocus(true)
      return true
    end if
  end if

  if key = "back"
    if m.options.visible = true
      m.options.visible = false
      videoOptionsClosed()
      audioOptionsClosed()
      return true
    end if
  else if key = "play" and m.extrasGrid.hasFocus()
    if isValid(m.extrasGrid.focusedItem)
      m.top.quickPlayNode = m.extrasGrid.focusedItem
      return true
    end if
  end if

  return false
end function

' destroy: Full teardown releasing all resources before component removal
' Called automatically by SceneManager.popScene() / clearScenes()
sub destroy()
  m.log.verbose("destroy")

  ' Unobserve m.top programmatic observer (may or may not be active depending on flow)
  m.top.unobserveField("itemContent")

  ' Unobserve child node observers
  m.itemLogo.unobserveField("loadStatus")
  m.itemDetails.unobserveField("renderTracking")
  m.extrasGrid.unobserveField("personHasMedia")
  if isValid(m.clock)
    m.clock.unobserveField("minutes")
    m.clock = invalid
  end if

  ' Stop and release task nodes
  m.loadFirstEpisodeTask.unobserveField("content")
  m.loadFirstEpisodeTask.control = "STOP"
  m.loadFirstEpisodeTask = invalid

  m.loadSeriesResumeTask.unobserveField("content")
  m.loadSeriesResumeTask.control = "STOP"
  m.loadSeriesResumeTask = invalid

  m.loadSeasonSeriesTask.unobserveField("content")
  m.loadSeasonSeriesTask.control = "STOP"
  m.loadSeasonSeriesTask = invalid

  ' Clear node references
  m.extrasGrp = invalid
  m.extrasGrid = invalid
  m.options = invalid
  m.infoGroup = invalid
  m.itemDescription = invalid
  m.buttonGrp = invalid
  m.itemLogo = invalid
  m.dateCreatedLabel = invalid
  m.itemTextGradient = invalid
  m.itemDetails = invalid
  m.itemTracks = invalid
  m.itemDetailsSlider = invalid
  m.itemDetailsSliderInterp = invalid
  m.itemInfoRows = invalid
  m.directorGenreGroup = invalid
  m.optionsButton = invalid
  m.shuffleButton = invalid
  m.endsAtNode = invalid

  ' Clear data caches
  m.seasonSeriesCache = invalid
  m.seasonSeriesData = invalid
end sub