components_ItemDetails.bs

import "pkg:/source/api/ApiClient.bs"
import "pkg:/source/api/apiPool.bs"
import "pkg:/source/api/image.bs"
import "pkg:/source/constants/extrasLayout.bs"
import "pkg:/source/constants/itemTypeOrder.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/translationKeys.bs"
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/placeholderImage.bs"
import "pkg:/source/utils/streamSelection.bs"
import "pkg:/source/utils/textureManager.bs"
import "pkg:/source/utils/trackClusterFocus.bs"
import "pkg:/source/utils/translate.bs"

' Gap between bottom of itemInfoRows and top of extras pane when extras are open
const ITEM_DETAILS_EXTRAS_PADDING = 24
' 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
' First-frame estimate for the button row's total rendered height. Used by anchorDateLabel()
' before boundingRect() has settled (it reports height=0 during initial render). Observed to
' land around 130 px for the typical 2-line-label icon button. A later renderTracking callback
' replaces this with the real measured height once layout completes.
const ESTIMATED_BUTTON_ROW_HEIGHT_PX = 130

sub init()
  m.log = new log.Logger("ItemDetails")
  m.extrasGrp = m.top.findNode("extrasGrp")
  m.extrasGrid = m.top.findNode("extrasGrid")
  ' Hide the overhang's Options hint — track selection happens inline via the TrackDropdown
  ' cluster above the button group, not through the overhang options popup pattern.
  m.top.isOptionsAvailable = false

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

  ' Inline track dropdowns (Video / Audio / Subtitles). trackCluster is a top-level
  ' sibling of itemDetails statically positioned at [96, 713] — see ItemDetails.xml for
  ' the geometry rationale. itemTracks inside itemDetails is a 62px invisible spacer
  ' that pushes description bottom up to y=701 so the cluster sits in a 99px band
  ' between description and buttons, with 12px pad above and 18px pad below.
  m.trackCluster = m.top.findNode("trackCluster")
  m.trackTitles = m.top.findNode("trackTitles")
  m.videoTrackTitle = m.top.findNode("videoTrackTitle")
  m.audioTrackTitle = m.top.findNode("audioTrackTitle")
  m.subtitleTrackTitle = m.top.findNode("subtitleTrackTitle")

  m.trackDropdowns = m.top.findNode("trackDropdowns")
  m.videoDropdown = m.top.findNode("videoDropdown")
  m.audioDropdown = m.top.findNode("audioDropdown")
  m.subtitleDropdown = m.top.findNode("subtitleDropdown")
  m.subtitleUserOverridden = false

  ' Remembers the last TrackDropdown that held focus so returning to the track row via
  ' UP-from-buttons restores the previous slot. Reset per-content in itemContentChanged
  ' and when the remembered slot becomes non-interactive during repopulation.
  m.lastFocusedDropdown = invalid
  m.videoDropdown.observeField("focusedChild", "onTrackDropdownFocusChanged")
  m.audioDropdown.observeField("focusedChild", "onTrackDropdownFocusChanged")
  m.subtitleDropdown.observeField("focusedChild", "onTrackDropdownFocusChanged")

  m.videoDropdown.observeField("selectedAction", "onVideoDropdownSelection")
  m.audioDropdown.observeField("selectedAction", "onAudioDropdownSelection")
  m.subtitleDropdown.observeField("selectedAction", "onSubtitleDropdownSelection")

  m.videoDropdown.observeField("requestFocusReturn", "onDropdownRequestUp")
  m.audioDropdown.observeField("requestFocusReturn", "onDropdownRequestUp")
  m.subtitleDropdown.observeField("requestFocusReturn", "onDropdownRequestUp")

  m.videoDropdown.observeField("requestFocusDown", "onDropdownRequestDown")
  m.audioDropdown.observeField("requestFocusDown", "onDropdownRequestDown")
  m.subtitleDropdown.observeField("requestFocusDown", "onDropdownRequestDown")

  m.videoDropdown.observeField("requestFocusExit", "onVideoDropdownFocusExit")
  m.audioDropdown.observeField("requestFocusExit", "onAudioDropdownFocusExit")
  m.subtitleDropdown.observeField("requestFocusExit", "onSubtitleDropdownFocusExit")

  ' Vertical gradient dimmer shown while any track dropdown has its menu open. Fades
  ' clear → fully opaque black from y=707 to y=1080 so the menu pops against
  ' IconButtons / chapters peek without dimming the metadata above the trackCluster.
  ' See the dropdownDimmer comment in ItemDetails.xml for the full geometry rationale.
  ' Colors are set here (not in XML) so theme changes can swap colorBlack if it
  ' ever varies (today it's a fixed 0x000000 across themes).
  m.dropdownDimmer = m.top.findNode("dropdownDimmer")
  globalConstants = m.global.constants
  m.dropdownDimmer.startColor = globalConstants.colorBlack + globalConstants.alpha0
  m.dropdownDimmer.endColor = globalConstants.colorBlack + globalConstants.alpha100

  ' The handler ORs the three menuOpen fields; only one menu can be open at a time
  ' today, but the OR keeps the logic correct regardless of how openMenu/closeMenu
  ' evolve.
  m.videoDropdown.observeField("menuOpen", "onTrackDropdownMenuOpenChanged")
  m.audioDropdown.observeField("menuOpen", "onTrackDropdownMenuOpenChanged")
  m.subtitleDropdown.observeField("menuOpen", "onTrackDropdownMenuOpenChanged")

  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")
  ' Button group starts hidden — no buttons until setupButtons() builds the real set.
  ' Focus stays on the component (scene manager sets it) until buttons are ready.
  m.buttonGrp.visible = false

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

  ' Re-anchor date label (and logo, if loaded) after the button group layout settles.
  ' setDateAdded() runs before setupButtons(), so its boundingRect() call would see an
  ' empty group. This observer fires after the render thread computes the real height.
  m.buttonGrp.observeField("renderTracking", "onButtonGrpRendered")

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

  ' 62px invisible spacer inside itemDetails. Pushes description bottom up to y=701
  ' so the trackCluster sibling has 12px breathing pad above and 18px below between
  ' description and buttons. Removed from the layout for NO_MEDIA_TRACKS item types
  ' (Series/Season/Person/etc.) so those items' description bottom drops back to
  ' y=775 (matching main's behavior). The extras slide animation in
  ' updateItemDetailsAnimationTarget() reads itemInfoRows.boundingRect() at runtime
  ' and adapts to the taller spacer automatically — no target math change needed.
  m.itemTracks = m.top.findNode("itemTracks")
  m.itemTracksInLayout = true

  ' itemDetails slide animation - synced with extras slider
  m.itemDetailsSlider = m.top.findNode("itemDetailsSlider")
  m.itemDetailsSliderInterp = m.top.findNode("itemDetailsSliderInterp")
  m.extrasActive = 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.shuffleButton = invalid
  m.shuffleLoadingButton = invalid
  m.resumeLoadingButton = invalid
  m.personMediaFireCount = 0

  ' 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 = {}

  ' Audio: fetch lyrics in the background for the description area
  m.loadLyricsTask = CreateObject("roSGNode", "LoadItemsTask")
  m.loadLyricsTask.itemsToLoad = "lyrics"

  ' Self-loading: fetch item details metadata via task (triggered by onItemIdChanged)
  m.loadDetailsTask = CreateObject("roSGNode", "LoadItemsTask")
  m.loadDetailsTask.itemsToLoad = "metaDataDetails"

  ' Trailer check result node (Pattern 1: submitApiRequest, trivial callback)
  m.trailerResultNode = invalid

  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 playlistContentKind to show/hide Watched button and set "Tracks"/"Items" label
  ' after the Playlist items chain completes.
  m.extrasGrid.observeField("playlistContentKind", "onPlaylistContentKindChanged")

  ' Grid translation animation — smoothly repositions the RowList when row focus changes.
  ' Set early via onKeyEvent in ExtrasRowList so the animation runs in sync with the RowList's
  ' own floatingFocus animation. gridAnime and gridTranslationInterp are defined in ExtrasSlider.xml.
  m.gridAnime = m.top.findNode("gridAnime")
  m.gridTranslationInterp = m.top.findNode("gridTranslationInterp")
  m.extrasGrid.observeField("targetTranslationY", "onExtrasTargetTranslationYChanged")

  ' 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()
  ' Restore texture management — reactivate and restore buffer range so cells reload.
  if isValid(m.extrasGrid) and isValid(m.extrasGrid.content)
    updateTextureBufferRange(m.extrasGrid.content, m.extrasGrid.rowItemFocused[0], m.extrasGrid.rowItemFocused[1], m.extrasGrid.numRows)
    activateTextureManager(m.extrasGrid.content)
  end if

  ' 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
    if isValid(m.top.lastFocus)
      m.top.lastFocus.setFocus(true)
    end if
  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 onScreenHidden()
  m.log.info("onScreenHidden")
  hideTextureManager(m.extrasGrid.content)
end sub

' Triggered when CreateItemDetailsGroup sets itemId — kicks off async data load
sub onItemIdChanged()
  itemId = m.top.itemId
  if not isValidAndNotEmpty(itemId) then return

  m.loadDetailsTask.unobserveField("content")
  m.loadDetailsTask.control = "STOP"

  m.loadDetailsTask.itemId = itemId
  m.loadDetailsTask.shouldForceRefresh = false
  m.loadDetailsTask.observeField("content", "onDetailsLoaded")
  m.loadDetailsTask.control = "RUN"
end sub

' Callback when initial item details metadata arrives from the task
sub onDetailsLoaded()
  m.loadDetailsTask.unobserveField("content")
  content = m.loadDetailsTask.content
  m.loadDetailsTask.content = []

  if isValidAndNotEmpty(content) and isValid(content[0])
    m.top.itemContent = content[0]
  else
    stopLoadingSpinner()
    return
  end if

  stopLoadingSpinner()
end sub

' Callback when trailer availability check completes
sub onTrailerCheckDone()
  res = m.trailerResultNode.result
  m.trailerResultNode.unobserveField("isDone")
  m.trailerResultNode = invalid
  if isValid(res) and res.ok and isValid(res.json)
    m.top.trailerAvailable = res.json.Count() > 0
  else
    m.top.trailerAvailable = false
  end if
end sub

' Triggered by refreshResumeData field toggle (watched toggle on Series).
' Re-fetches ONLY the next-up/resume episode — no full rebuild, no setupButtons().
' The existing onNextUpEpisodeChanged() handles adding/removing/updating the resume button.
sub onRefreshResumeData()
  item = m.top.itemContent
  if not isValid(item) or item.type <> "Series" then return

  m.loadSeriesResumeTask.unobserveField("content")
  m.loadSeriesResumeTask.control = "STOP"
  m.loadSeriesResumeTask.itemId = item.id
  m.loadSeriesResumeTask.observeField("content", "onSeriesResumeLoaded")
  m.loadSeriesResumeTask.control = "RUN"
end sub

' Triggered by refreshItemDetailsData field toggle (return from playback, refresh button).
' Refreshes silently in the background — no spinner, no input blocking.
' The UI already has data from the initial load so the user can navigate freely.
sub onRefreshItemDetailsData()
  itemId = m.top.itemId
  if not isValidAndNotEmpty(itemId) then return

  ' Series: show resume loading state immediately while we wait for fresh data.
  ' If a resume button exists, set it to loading. If not, create a loading placeholder
  ' so the user sees a skeleton instead of a button popping in unexpectedly.
  if isValid(m.top.itemContent) and m.top.itemContent.type = "Series"
    resumeButton = m.top.findNode("resumeButton")
    if isValid(resumeButton)
      resumeButton.isLoading = true
    else if not isValid(m.top.findNode("resumeLoadingButton"))
      m.resumeLoadingButton = CreateObject("roSGNode", "IconButton")
      m.resumeLoadingButton.id = "resumeLoadingButton"
      m.resumeLoadingButton.icon = "pkg:/images/icons/resume_$$RES$$.png"
      if isValid(m.top.nextUpEpisode) and isValidAndNotEmpty(m.top.nextUpEpisode.id)
        m.resumeLoadingButton.text = getResumeButtonText(m.top.nextUpEpisode)
      else
        m.resumeLoadingButton.text = translate(translationKeys.ButtonResume)
      end if
      m.resumeLoadingButton.isLoading = true
      m.buttonGrp.insertChild(m.resumeLoadingButton, 0)
      ' Shift focus index to account for the new button at position 0
      m.buttonGrp.buttonFocused = m.buttonGrp.buttonFocused + 1
    end if
  end if

  m.loadDetailsTask.unobserveField("content")
  m.loadDetailsTask.control = "STOP"

  m.loadDetailsTask.itemId = itemId
  m.loadDetailsTask.shouldForceRefresh = true
  m.loadDetailsTask.observeField("content", "onRefreshDetailsLoaded")
  m.loadDetailsTask.control = "RUN"
end sub

' Callback when refreshed item details metadata arrives
sub onRefreshDetailsLoaded()
  m.loadDetailsTask.unobserveField("content")
  content = m.loadDetailsTask.content
  m.loadDetailsTask.content = []

  if isValidAndNotEmpty(content) and isValid(content[0])
    m.top.itemContent = content[0]
    if content[0].playbackPositionTicks > 0
      m.global.queueManager.callFunc("setCurrentStartingPoint", content[0].playbackPositionTicks)
    end if
  end if

  ' Stop spinner only when this screen is the active scene (watched/refresh button flow).
  ' If a video player or other scene is on top, the spinner belongs to that flow — don't touch it.
  activeScene = m.global.sceneManager.callFunc("getActiveScene")
  if isValid(activeScene) and activeScene.isSameNode(m.top)
    stopLoadingSpinner()
  end if
end sub

sub onTrailerAvailableChanged()
  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_$$RES$$.png"
      trailerButton.text = translate(translationKeys.LabelPlayTrailer)
      ' 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.
' On first load, replaces the LoadingButton placeholder from setupButtons().
' On refresh, inserts/updates/removes the Resume button directly.
sub onNextUpEpisodeChanged()
  item = m.top.nextUpEpisode
  resumeButton = m.top.findNode("resumeButton")
  loadingButton = m.top.findNode("resumeLoadingButton")

  if isValid(item) and isValidAndNotEmpty(item.id)
    resumeText = getResumeButtonText(item)
    if not isValid(resumeButton)
      resumeButton = CreateObject("roSGNode", "ResumeButton")
      resumeButton.id = "resumeButton"
      resumeButton.icon = "pkg:/images/icons/resume_$$RES$$.png"
      resumeButton.text = resumeText
      resumeButton.playbackPositionTicks = item.playbackPositionTicks
      resumeButton.runtimeTicks = item.runTimeTicks

      if isValid(loadingButton)
        ' Swap the LoadingButton placeholder for the real resume button.
        ' The user may have moved focus to another button before async data arrived,
        ' so capture which button has focus before the remove/insert.
        loadingIndex = getButtonIndex("resumeLoadingButton")
        wasFocused = loadingButton.isInFocusChain()
        currentFocusIndex = m.buttonGrp.buttonFocused
        m.buttonGrp.removeChild(loadingButton)
        m.resumeLoadingButton = invalid
        m.buttonGrp.insertChild(resumeButton, loadingIndex)
        ' Remove then insert at same index is a net-zero index shift,
        ' so the original focusIndex is still correct.
        m.buttonGrp.buttonFocused = currentFocusIndex
        if wasFocused
          focusButtonGroupChild()
        end if
      else
        ' No placeholder (refresh path) — insert at position 0
        currentFocusIndex = m.buttonGrp.buttonFocused
        currentFocusedButton = invalid
        if isValid(currentFocusIndex) and currentFocusIndex >= 0 and currentFocusIndex < m.buttonGrp.getChildCount()
          currentFocusedButton = m.buttonGrp.getChild(currentFocusIndex)
        end if

        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
      end if
      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
      resumeButton.isLoading = false
    end if
  else
    ' No resume data — remove the LoadingButton placeholder if still present
    if isValid(loadingButton)
      currentFocusIndex = m.buttonGrp.buttonFocused
      loadingIndex = getButtonIndex("resumeLoadingButton")
      m.buttonGrp.removeChild(loadingButton)
      m.resumeLoadingButton = invalid
      if isValid(loadingIndex) and loadingIndex >= 0 and currentFocusIndex >= loadingIndex
        focusIdx = currentFocusIndex - 1
        if focusIdx < 0 then focusIdx = 0
        m.buttonGrp.buttonFocused = focusIdx
      end if
      if m.buttonGrp.isInFocusChain()
        focusButtonGroupChild()
      end if
    end if
    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 translate(translationKeys.ButtonResume) + " S" + item.parentIndexNumber.toStr() + "E" + item.indexNumber.toStr()
  end if
  return translate(translationKeys.ButtonResume)
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_$$RES$$.png"
        resumeButton.text = translate(translationKeys.ButtonResume)
        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.isBold = 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.isBold = true
  return labelNode
end function

' createTimeLabel: Creates a time label with an optional lowercase am/pm suffix.
' In 24h mode returns a single label; in 12h mode appends the period to the time text.
function createTimeLabel(labelId as string, timeText as string, periodText as string) as object
  node = createInfoLabel(labelId)
  if periodText <> ""
    node.text = timeText + " " + periodText
  else
    node.text = timeText
  end if
  return node
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 = translate(translationKeys.MessageBiographicalInformationForThisPersonIs)
    end if
    m.itemDescription.tagline = ""
    m.itemDescription.maxLines = 8
    m.itemDescription.dialogTitle = item.name
    m.itemDescription.visible = true
    setDescriptionInLayout(true)
    return
  end if

  ' MusicArtist: biography with higher maxLines; placeholder when none
  if item.type = "MusicArtist"
    if isValidAndNotEmpty(item.overview)
      m.itemDescription.text = item.overview
    else
      m.itemDescription.text = translate(translationKeys.LabelNoBiographyAvailableForThisArtist)
    end if
    m.itemDescription.tagline = ""
    m.itemDescription.maxLines = 8
    m.itemDescription.dialogTitle = item.name
    m.itemDescription.visible = true
    setDescriptionInLayout(true)
    return
  end if

  ' Playlist: overview if present; hidden otherwise (no placeholder)
  if item.type = "Playlist"
    m.itemDescription.tagline = ""
    if isValidAndNotEmpty(item.overview)
      m.itemDescription.text = item.overview
      m.itemDescription.maxLines = 4
      m.itemDescription.dialogTitle = item.name
      m.itemDescription.visible = true
      setDescriptionInLayout(true)
    else
      m.itemDescription.visible = false
      setDescriptionInLayout(false)
    end if
    return
  end if

  ' MusicAlbum: overview if present; hidden otherwise (no placeholder)
  if item.type = "MusicAlbum"
    m.itemDescription.tagline = ""
    if isValidAndNotEmpty(item.overview)
      m.itemDescription.text = item.overview
      m.itemDescription.maxLines = 4
      m.itemDescription.dialogTitle = item.name
      m.itemDescription.visible = true
      setDescriptionInLayout(true)
    else
      m.itemDescription.visible = false
      setDescriptionInLayout(false)
    end if
    return
  end if

  ' Photo / PhotoAlbum: no text descriptions — hide
  if item.type = "Photo" or item.type = "PhotoAlbum"
    m.itemDescription.visible = false
    setDescriptionInLayout(false)
    return
  end if

  ' TvChannel: show current program overview if available
  if item.type = "TvChannel"
    if isValid(item.currentProgram) and isValidAndNotEmpty(item.currentProgram.overview)
      m.itemDescription.text = item.currentProgram.overview
      m.itemDescription.tagline = ""
      m.itemDescription.maxLines = 4
      m.itemDescription.dialogTitle = item.name
      m.itemDescription.visible = true
      setDescriptionInLayout(true)
    else
      m.itemDescription.visible = false
      setDescriptionInLayout(false)
    end if
    return
  end if

  ' Program: show EPG overview if available
  if item.type = "Program"
    m.itemDescription.tagline = ""
    if isValidAndNotEmpty(item.overview)
      m.itemDescription.text = item.overview
      m.itemDescription.maxLines = 4
      m.itemDescription.dialogTitle = item.name
      m.itemDescription.visible = true
      setDescriptionInLayout(true)
    else
      m.itemDescription.visible = false
      setDescriptionInLayout(false)
    end if
    return
  end if

  ' Audio: lyrics fetched async — start hidden; onLyricsLoaded() will populate when ready
  if item.type = "Audio"
    m.itemDescription.tagline = ""
    m.itemDescription.maxLines = 8
    m.itemDescription.dialogTitle = item.name
    m.itemDescription.visible = false
    setDescriptionInLayout(false)
    if item.hasLyrics
      m.loadLyricsTask.unobserveField("content")
      m.loadLyricsTask.control = "STOP"
      m.loadLyricsTask.itemId = item.id
      m.loadLyricsTask.observeField("content", "onLyricsLoaded")
      m.loadLyricsTask.control = "RUN"
    end if
    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 if item.type = "MusicArtist"
    populateInfoGroupMusicArtist(item)
  else if item.type = "MusicAlbum"
    populateInfoGroupMusicAlbum(item, userSettings)
  else if item.type = "Playlist"
    populateInfoGroupPlaylist(item, userSettings)
  else if item.type = "Audio"
    populateInfoGroupAudio(item, userSettings)
  else if item.type = "Photo"
    populateInfoGroupPhoto(item)
  else if item.type = "PhotoAlbum"
    populateInfoGroupPhotoAlbum(item)
  else if item.type = "TvChannel"
    populateInfoGroupTvChannel(item, userSettings)
  else if item.type = "Program"
    populateInfoGroupProgram(item, userSettings)
  else if item.type = "Recording"
    populateInfoGroupRecording(item, userSettings)
  else
    ' Movie, Video
    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() + " " + translate(translationKeys.LabelMins)
    displayInfoNode(runtimeNode)
  end if

  if item.runTimeTicks > 0 and not userSettings.uiDesignHideClock
    m.endsAtDurationSeconds = int(m.top.itemContent.runTimeTicks / 10000000.0)
    createEndsAtNode()
    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 = translate(translationKeys.MessageDirectedBy1, [directors.join(", ")])
    displayDirectorGenreNode(directorNode)
  end if
end sub

' populateInfoGroupMusicVideo: Info rows for MusicVideo
' Row 1: Year · Official Rating · Runtime · Ends At
' Row 2: Genres · 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() + " " + translate(translationKeys.LabelMins)
    displayInfoNode(runtimeNode)
  end if

  if item.runTimeTicks > 0 and not userSettings.uiDesignHideClock
    m.endsAtDurationSeconds = int(m.top.itemContent.runTimeTicks / 10000000.0)
    createEndsAtNode()
    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 = 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 = translate(translationKeys.MessageDirectedBy1, [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() + " " + translate(translationKeys.LabelMins)
    displayInfoNode(runtimeNode)
  end if

  if item.runTimeTicks > 0 and not userSettings.uiDesignHideClock
    m.endsAtDurationSeconds = int(m.top.itemContent.runTimeTicks / 10000000.0)
    createEndsAtNode()
    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 = translate(translationKeys.MessageDirectedBy1, [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 + " - " + translate(translationKeys.LabelPresent)
    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() + " " + translate(translationKeys.LabelMins)
    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.endsAtDurationSeconds = int(m.top.itemContent.runTimeTicks / 10000000.0)
    createEndsAtNode()
    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")
    movieCountNode.text = translatePlural(translationKeys.LabelMovieCount, item.childCount, [stri(item.childCount).trim()])
    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() + " " + translate(translationKeys.LabelMins)
    displayInfoNode(runtimeNode)
  end if

  if isValid(seriesData) and seriesData.runTimeTicks > 0 and not userSettings.uiDesignHideClock
    ' Compute end time directly from series runTimeTicks — do NOT swap m.top.itemContent,
    ' as that fires onItemContentChanged() 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 = createTimeLabel("ends-at", translate(translationKeys.MessageEndsAt1, [formatTime(endDate)]), getTimePeriod(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 = translatePlural(translationKeys.LabelEpisodeCount, item.childCount, [stri(item.childCount).trim()])
    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 + " · " + translatePlural(translationKeys.LabelYearsOldCount, age, [stri(age).trim()])

    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")
    movieCountNode.text = translatePlural(translationKeys.LabelMovieCount, item.movieCount, [stri(item.movieCount).trim()])
    displayDirectorGenreNode(movieCountNode)
  end if
  if item.episodeCount > 0
    episodeCountNode = createInfoLabel("personEpisodeCount")
    episodeCountNode.text = translatePlural(translationKeys.LabelEpisodeCount, item.episodeCount, [stri(item.episodeCount).trim()])
    displayDirectorGenreNode(episodeCountNode)
  end if
end sub

' populateInfoGroupMusicArtist: Info rows for MusicArtist
' Row 1: N Albums · N Songs · Year
' Row 2: Genres
sub populateInfoGroupMusicArtist(item as object)
  if item.albumCount > 0
    albumCountNode = createInfoLabel("albumCount")
    albumCountNode.text = translatePlural(translationKeys.LabelAlbumCount, item.albumCount, [stri(item.albumCount).trim()])
    displayInfoNode(albumCountNode)
  end if

  if item.songCount > 0
    songCountNode = createInfoLabel("songCount")
    songCountNode.text = translatePlural(translationKeys.LabelSongCount, item.songCount, [stri(item.songCount).trim()])
    displayInfoNode(songCountNode)
  end if

  if item.productionYear > 0
    yearNode = createInfoLabel("releaseYear")
    yearNode.text = stri(item.productionYear).trim()
    displayInfoNode(yearNode)
  end if

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

' populateInfoGroupMusicAlbum: Info rows for MusicAlbum
' Row 1: Year · Runtime · Ends At
' Row 2: Album Artist · Genres · N Tracks
sub populateInfoGroupMusicAlbum(item as object, userSettings as object)
  if item.productionYear > 0
    yearNode = createInfoLabel("releaseYear")
    yearNode.text = stri(item.productionYear).trim()
    displayInfoNode(yearNode)
  end if

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

  if item.runTimeTicks > 0 and not userSettings.uiDesignHideClock
    m.endsAtDurationSeconds = int(m.top.itemContent.runTimeTicks / 10000000.0)
    createEndsAtNode()
    displayInfoNode(m.endsAtNode)
  end if

  if isValidAndNotEmpty(item.albumArtist)
    artistNode = createInfoLabel("albumArtist")
    artistNode.text = item.albumArtist
    displayDirectorGenreNode(artistNode)
  end if

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

  if item.childCount > 0
    trackCountNode = createInfoLabel("trackCount")
    trackCountNode.text = translatePlural(translationKeys.LabelTrackCount, item.childCount, [stri(item.childCount).trim()])
    displayDirectorGenreNode(trackCountNode)
  end if
end sub

' populateInfoGroupPlaylist: Info rows for Playlist
' Row 1: N Items · Runtime · Ends At
' Row 2: Genres
' The item count label starts as "Items" and is swapped to "Tracks" by onPlaylistContentKindChanged()
' once the extras chain confirms the playlist is all-audio.
sub populateInfoGroupPlaylist(item as object, userSettings as object)
  if item.childCount > 0
    itemCountNode = createInfoLabel("itemCount")
    itemCountNode.text = translatePlural(translationKeys.LabelItemCount, item.childCount, [stri(item.childCount).trim()])
    displayInfoNode(itemCountNode)
  end if

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

  if item.runTimeTicks > 0 and not userSettings.uiDesignHideClock
    m.endsAtDurationSeconds = int(m.top.itemContent.runTimeTicks / 10000000.0)
    createEndsAtNode()
    displayInfoNode(m.endsAtNode)
  end if

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

' populateInfoGroupAudio: Info rows for Audio (song)
' Row 1: Track N · Disc N (only when disc > 1) · Runtime · Ends At
' Row 2: Album name · Album artist
sub populateInfoGroupAudio(item as object, userSettings as object)
  if item.indexNumber > 0
    trackNode = createInfoLabel("trackNumber")
    trackNode.text = translate(translationKeys.MessageTrack1, [stri(item.indexNumber).trim()])
    displayInfoNode(trackNode)
  end if

  ' Only show disc number when there is more than one disc in the album
  if item.parentIndexNumber > 1
    discNode = createInfoLabel("discNumber")
    discNode.text = translate(translationKeys.MessageDisc1, [stri(item.parentIndexNumber).trim()])
    displayInfoNode(discNode)
  end if

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

  if item.runTimeTicks > 0 and not userSettings.uiDesignHideClock
    m.endsAtDurationSeconds = int(m.top.itemContent.runTimeTicks / 10000000.0)
    createEndsAtNode()
    displayInfoNode(m.endsAtNode)
  end if

  if isValidAndNotEmpty(item.albumName)
    albumNameNode = createInfoLabel("albumName")
    albumNameNode.text = item.albumName
    displayDirectorGenreNode(albumNameNode)
  end if

  if isValidAndNotEmpty(item.albumArtist)
    albumArtistNode = createInfoLabel("albumArtistName")
    albumArtistNode.text = item.albumArtist
    displayDirectorGenreNode(albumArtistNode)
  end if
end sub

' populateInfoGroupPhoto: Info rows for Photo
' Row 1: Resolution ("WxH") · Camera ("Make Model") · Production Year
' Row 2: Album name (if present)
sub populateInfoGroupPhoto(item as object)
  ' Resolution: "WIDTHxHEIGHT"
  if item.imageWidth > 0 and item.imageHeight > 0
    resNode = createInfoLabel("resolution")
    resNode.text = stri(item.imageWidth).trim() + "x" + stri(item.imageHeight).trim()
    displayInfoNode(resNode)
  end if

  ' Camera: prefer "Make Model", fall back to whichever is available.
  ' Avoids duplication when model already contains the make (e.g. "Canon Canon EOS R5").
  cameraText = ""
  if isValidAndNotEmpty(item.cameraMake) and isValidAndNotEmpty(item.cameraModel)
    if item.cameraModel.inStr(item.cameraMake) = 0
      cameraText = item.cameraModel
    else
      cameraText = item.cameraMake + " " + item.cameraModel
    end if
  else if isValidAndNotEmpty(item.cameraModel)
    cameraText = item.cameraModel
  else if isValidAndNotEmpty(item.cameraMake)
    cameraText = item.cameraMake
  end if
  if cameraText <> ""
    cameraNode = createInfoLabel("camera")
    cameraNode.text = cameraText
    displayInfoNode(cameraNode)
  end if

  if item.productionYear > 0
    yearNode = createInfoLabel("releaseYear")
    yearNode.text = stri(item.productionYear).trim()
    displayInfoNode(yearNode)
  end if

  ' Row 2: album name
  if isValidAndNotEmpty(item.albumName)
    albumNode = createInfoLabel("albumName")
    albumNode.text = item.albumName
    displayDirectorGenreNode(albumNode)
  end if
end sub

' populateInfoGroupPhotoAlbum: Info rows for PhotoAlbum
' Row 1: Photo count
sub populateInfoGroupPhotoAlbum(item as object)
  if item.childCount > 0
    countNode = createInfoLabel("photoCount")
    countNode.text = translatePlural(translationKeys.LabelPhotoCount, item.childCount, [stri(item.childCount).trim()])
    displayInfoNode(countNode)
  end if
end sub

' populateInfoGroupTvChannel: Info rows for TvChannel (Live TV)
' Displays metadata from the current program (the channel is a vessel for what's airing).
' Row 1: CH N · OfficialRating · CommunityRating · S#E# · Program Name · Runtime · BroadcastTime · Ends At
' Row 2: Genres (or category flags)
sub populateInfoGroupTvChannel(item as object, userSettings as object)
  if isValidAndNotEmpty(item.channelNumber)
    chanNode = createInfoLabel("channelNumber")
    chanNode.text = translate(translationKeys.LabelCh) + " " + item.channelNumber
    displayInfoNode(chanNode)
  end if

  if isValid(item.currentProgram)
    prog = item.currentProgram

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

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

    if prog.parentIndexNumber > 0 and prog.indexNumber > 0
      episodeNode = createInfoLabel("episodeInfo")
      episodeNode.text = "S" + stri(prog.parentIndexNumber).trim() + "E" + stri(prog.indexNumber).trim()
      displayInfoNode(episodeNode)
    end if

    if isValidAndNotEmpty(prog.name)
      programNode = createInfoLabel("currentProgramName")
      programNode.text = prog.name
      displayInfoNode(programNode)
    end if

    ' Runtime from broadcast window
    if prog.PlayDuration > 0
      runtimeMins = int(prog.PlayDuration / 60)
      if runtimeMins > 0
        runtimeNode = createInfoLabel("runtime")
        runtimeNode.text = stri(runtimeMins).trim() + " " + translate(translationKeys.LabelMins)
        displayInfoNode(runtimeNode)
      end if
    end if

    ' Broadcast time range on Row 1
    if isValidAndNotEmpty(prog.startDate) and isValidAndNotEmpty(prog.endDate)
      startDt = createObject("roDateTime")
      startDt.FromISO8601String(prog.startDate)
      startDt.toLocalTime()
      broadcastEndDt = createObject("roDateTime")
      broadcastEndDt.FromISO8601String(prog.endDate)
      broadcastEndDt.toLocalTime()
      startPeriod = getTimePeriod(startDt)
      endPeriod = getTimePeriod(broadcastEndDt)
      ' Omit start period when same as end (e.g., "9:00 - 10:00 PM")
      if startPeriod = endPeriod
        timeText = formatTime(startDt) + " - " + formatTime(broadcastEndDt)
        periodText = endPeriod
      else
        timeText = formatTime(startDt) + " " + startPeriod + " - " + formatTime(broadcastEndDt)
        periodText = endPeriod
      end if
      displayInfoNode(createTimeLabel("broadcastTime", timeText, periodText))
    end if

    ' Row 2: genres (or category flags)
    if prog.genres.count() > 0
      genreNode = createInfoLabel("genre")
      genreNode.text = prog.genres.join(" / ")
      displayDirectorGenreNode(genreNode)
    else
      categories = []
      if prog.isNews then categories.push(translate(translationKeys.LabelNews))
      if prog.isSports then categories.push(translate(translationKeys.LabelSports))
      if prog.isKids then categories.push(translate(translationKeys.LabelKids))
      if prog.isMovie then categories.push(translate(translationKeys.LabelMovie))
      if categories.count() > 0
        catNode = createInfoLabel("programCategories")
        catNode.text = categories.join(" / ")
        displayDirectorGenreNode(catNode)
      end if
    end if
  end if
end sub

' populateInfoGroupProgram: Info rows for Program (Live TV EPG entry)
' Row 1: Year · CH X - ChanName · OfficialRating · CommunityRating · S#E# · Premiere/Repeat · Runtime · BroadcastTime · Ends At
' Row 2: Genres (or category flags)
sub populateInfoGroupProgram(item as object, userSettings as object)
  if item.productionYear > 0
    yearNode = createInfoLabel("releaseYear")
    yearNode.text = stri(item.productionYear).trim()
    displayInfoNode(yearNode)
  end if

  ' Channel info — early position mirrors TvChannel's "channel identity first" layout
  channelParts = []
  if isValidAndNotEmpty(item.channelNumber) then channelParts.push(translate(translationKeys.LabelCh) + " " + item.channelNumber)
  if isValidAndNotEmpty(item.channelName) then channelParts.push(item.channelName)
  if channelParts.count() > 0
    channelNode = createInfoLabel("channelInfo")
    channelNode.text = channelParts.join(" - ")
    displayInfoNode(channelNode)
  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.parentIndexNumber > 0 and item.indexNumber > 0
    episodeNode = createInfoLabel("episodeInfo")
    episodeNode.text = "S" + stri(item.parentIndexNumber).trim() + "E" + stri(item.indexNumber).trim()
    displayInfoNode(episodeNode)
  end if

  ' Scheduling badges — Premiere/New indicates first broadcast, Repeat indicates rerun
  if item.isPremiere
    premiereNode = createInfoLabel("premiereIndicator")
    premiereNode.text = translate(translationKeys.LabelPremiere)
    displayInfoNode(premiereNode)
  else if item.isRepeat
    repeatNode = createInfoLabel("repeatIndicator")
    repeatNode.text = translate(translationKeys.ButtonRepeat)
    displayInfoNode(repeatNode)
  end if

  ' Runtime: prefer runTimeTicks (media duration), fall back to PlayDuration (broadcast window)
  programRuntimeMins = 0
  if item.runTimeTicks > 0
    programRuntimeMins = int(item.runTimeTicks / 600000000&)
  else if item.PlayDuration > 0
    programRuntimeMins = int(item.PlayDuration / 60)
  end if
  if programRuntimeMins > 0
    runtimeNode = createInfoLabel("runtime")
    runtimeNode.text = stri(programRuntimeMins).trim() + " " + translate(translationKeys.LabelMins)
    displayInfoNode(runtimeNode)
  end if

  ' Broadcast time range on Row 1
  if isValidAndNotEmpty(item.startDate) and isValidAndNotEmpty(item.endDate)
    startDt = createObject("roDateTime")
    startDt.FromISO8601String(item.startDate)
    startDt.toLocalTime()
    broadcastEndDt = createObject("roDateTime")
    broadcastEndDt.FromISO8601String(item.endDate)
    broadcastEndDt.toLocalTime()
    startPeriod = getTimePeriod(startDt)
    endPeriod = getTimePeriod(broadcastEndDt)
    if startPeriod = endPeriod
      timeText = formatTime(startDt) + " - " + formatTime(broadcastEndDt)
      periodText = endPeriod
    else
      timeText = formatTime(startDt) + " " + startPeriod + " - " + formatTime(broadcastEndDt)
      periodText = endPeriod
    end if
    displayInfoNode(createTimeLabel("broadcastTime", timeText, periodText))
  end if

  ' "Ends at" — only for live programs with valid broadcast times
  if item.isLive and not userSettings.uiDesignHideClock and isValidAndNotEmpty(item.endDate)
    endDt = createObject("roDateTime")
    endDt.FromISO8601String(item.endDate)
    endDt.toLocalTime()
    displayInfoNode(createTimeLabel("ends-at", translate(translationKeys.MessageEndsAt1, [formatTime(endDt)]), getTimePeriod(endDt)))
  end if

  ' Row 2: genres (or category flags)
  if item.genres.count() > 0
    genreNode = createInfoLabel("genre")
    genreNode.text = item.genres.join(" / ")
    displayDirectorGenreNode(genreNode)
  else
    categories = []
    if item.isNews then categories.push(translate(translationKeys.LabelNews))
    if item.isSports then categories.push(translate(translationKeys.LabelSports))
    if item.isKids then categories.push(translate(translationKeys.LabelKids))
    if item.isMovie then categories.push(translate(translationKeys.LabelMovie))
    if categories.count() > 0
      catNode = createInfoLabel("programCategories")
      catNode.text = categories.join(" / ")
      displayDirectorGenreNode(catNode)
    end if
  end if
end sub

' populateInfoGroupRecording: Info rows for Recording (Live TV recorded content)
' Row 1: PremiereYear · OfficialRating · CommunityRating · S#E# · Runtime · Ends At · CH X - ChanName · Recorded: Date
' Row 2: Genres
sub populateInfoGroupRecording(item as object, userSettings as object)
  ' Premiere year: prefer PremiereDate year, fall back to ProductionYear (which is recording year)
  premiereYear = 0
  if isValidAndNotEmpty(item.premiereDate)
    premDt = createObject("roDateTime")
    premDt.FromISO8601String(item.premiereDate)
    premiereYear = premDt.getYear()
  end if
  if premiereYear = 0 and item.productionYear > 0
    premiereYear = item.productionYear
  end if
  if premiereYear > 0
    yearNode = createInfoLabel("releaseYear")
    yearNode.text = stri(premiereYear).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.parentIndexNumber > 0 and item.indexNumber > 0
    episodeNode = createInfoLabel("episodeInfo")
    episodeNode.text = "S" + stri(item.parentIndexNumber).trim() + "E" + stri(item.indexNumber).trim()
    displayInfoNode(episodeNode)
  end if

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

  if item.runTimeTicks > 0 and not userSettings.uiDesignHideClock
    m.endsAtDurationSeconds = int(m.top.itemContent.runTimeTicks / 10000000.0)
    createEndsAtNode()
    displayInfoNode(m.endsAtNode)
  end if

  ' Channel info
  channelParts = []
  if isValidAndNotEmpty(item.channelNumber) then channelParts.push(translate(translationKeys.LabelCh) + " " + item.channelNumber)
  if isValidAndNotEmpty(item.channelName) then channelParts.push(item.channelName)
  if channelParts.count() > 0
    channelNode = createInfoLabel("channelInfo")
    channelNode.text = channelParts.join(" - ")
    displayInfoNode(channelNode)
  end if

  ' Recorded date from startDate (when recording began)
  if isValidAndNotEmpty(item.startDate)
    recordedNode = createInfoLabel("recordedDate")
    recordedNode.text = translate(translationKeys.LabelRecorded) + ": " + formatIsoDateVideo(item.startDate)
    displayInfoNode(recordedNode)
  end if

  ' Row 2: genres
  if item.genres.count() > 0
    genreNode = createInfoLabel("genre")
    genreNode.text = item.genres.join(" / ")
    displayDirectorGenreNode(genreNode)
  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 + " " + translate(translationKeys.LabelAt) + " " + 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 + " " + translate(translationKeys.LabelOn) + " " + studio
    else
      words = studio
    end if
  end if

  return words
end function

sub onItemContentChanged()
  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] })
    ' TvChannel: fall back to current program's backdrop when channel has none
    if backdropUrl = "" and item.type = "TvChannel" and isValid(item.currentProgram)
      backdropUrl = getItemBackdropUrl(item.currentProgram, { width: device.uiResolution[0], height: device.uiResolution[1] })
    end if
    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)

    ' Person Shuffle: on refresh (extras already loaded), the extras chain won't re-fire
    ' because only refreshItemDetailsData was toggled. Resolve the loading placeholder
    ' immediately from the already-known personHasMedia value.
    if item.type = "Person" and m.extrasLoaded
      onPersonHasMediaChanged()
    end if

    ' Media tracks and stream options are only relevant for playable types.
    ' MusicArtist/MusicAlbum have no track info. Audio shows audio codec only.
    hasMediaTracks = not inArray(itemTypeOrder.NO_MEDIA_TRACKS, item.type)

    ' Keep/remove the itemTracks spacer + trackCluster visibility in one step so
    ' NO_MEDIA_TRACKS item types render identically to main.
    setTracksInLayout(hasMediaTracks)
    m.trackCluster.visible = hasMediaTracks

    ' Fresh content = fresh auto-selected subtitle (reset the user-override flag so the
    ' subtitle dropdown re-seeds from findDefaultSubtitleStreamIndex).
    m.subtitleUserOverridden = false

    ' Fresh content = forget the previously-focused dropdown so the next UP-from-buttons
    ' press picks the slot based on the current button column, not the prior item.
    m.lastFocusedDropdown = invalid

    if item.type = "Audio"
      ' Audio: no video source selection, no subtitle display. Audio dropdown still applies.
      allStreams = []
      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)
      populateTrackDropdowns([], allStreams, item.type)
    else if hasMediaTracks
      mediaSources = invalid
      if isValid(item.mediaSourcesData) and isValidAndNotEmpty(item.mediaSourcesData.mediaSources)
        mediaSources = item.mediaSourcesData.mediaSources
      end if

      ' Select best video source based on device capabilities instead of always using first
      if m.top.selectedVideoStreamId = "" and isValid(mediaSources) and mediaSources.Count() > 0
        bestSourceIndex = findBestVideoSource(mediaSources)
        m.top.selectedVideoStreamId = mediaSources[bestSourceIndex].Id ?? item.mediaSourceId
      else if m.top.selectedVideoStreamId = "" and isValidAndNotEmpty(item.mediaSourceId)
        m.top.selectedVideoStreamId = item.mediaSourceId
      end if

      allStreams = getStreamsForSelectedSource(mediaSources)

      SetDefaultAudioTrack(allStreams)
      populateTrackDropdowns(mediaSources ?? [], allStreams, item.type)
    end if
    ' Series/Season/Person/BoxSet/MusicArtist/MusicAlbum: the NO_MEDIA_TRACKS branch
    ' above already removed itemTracks from layout and hid m.trackCluster, so nothing
    ' more to do here.

    populateInfoGroup()
    populateDescriptionGroup()

    setFieldText("videoTitle", item.name)

    updateFavoriteButton()
    updateWatchedButton()

    ' Non-Series/Season/music types: Resume based on playbackPositionTicks
    ' Series: Resume based on nextUpEpisode (managed by nextUpEpisodeChanged observer)
    ' Season: No resume — NextUp API does not support SeasonId filtering
    ' Music types: no resume — play state for music is not tracked via playbackPositionTicks
    if not inArray(itemTypeOrder.NO_RESUME, item.type)
      manageResumeButton()
    end if

    ' Series: kick off background fetch of resume/next-up episode for the Resume button.
    ' Don't reset nextUpEpisode here — setupButtons() just created a LoadingButton placeholder
    ' and resetting to invalid would trigger onNextUpEpisodeChanged() which removes it immediately.
    ' onSeriesResumeLoaded() handles both the data and no-data cases.
    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.loadSeriesResumeTask.itemId = item.id
      m.loadSeriesResumeTask.observeField("content", "onSeriesResumeLoaded")
      m.loadSeriesResumeTask.control = "RUN"
    end if

    ' Trailer availability check — re-run on every content change so refreshes get fresh data.
    ' onTrailerAvailableChanged() adds or removes the button when the result arrives.
    if item.type = "Movie" or item.type = "Video" or item.type = "MusicVideo"
      if isValid(m.trailerResultNode)
        m.trailerResultNode.unobserveField("isDone")
        m.trailerResultNode = invalid
      end if
      m.trailerResultNode = submitApiRequest(GetApi().BuildGetLocalTrailersRequest(m.top.itemId), "trailerCheck")
      if isValid(m.trailerResultNode)
        m.trailerResultNode.observeField("isDone", "onTrailerCheckDone")
      end if
    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

  ' 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

' ============================================
' TRACK DROPDOWNS
' ============================================

' The track dropdown cluster is now a child of itemDetails LayoutGroup, so vertical
' stacking and horizontal anchoring are handled by RSG's layout engine. Slot width and
' stride are configured declaratively in ItemDetails.xml's trackDropdowns LayoutGroup
' (slot width via TrackDropdown default, stride via itemSpacings).

' populateTrackDropdowns: Build the Video/Audio/Subtitle dropdown items from the current
' media source list and stream list for the given item type, then toggle the cluster +
' title row visibility. Seeds m.top.selectedSubtitleStreamIndex from
' findDefaultSubtitleStreamIndex() on first load so the initial trigger label reflects
' what playback will actually use.
'
' @param {object} mediaSources - Array of MediaSource objects (may be empty)
' @param {object} allStreams - MediaStreams from the selected source (video/audio/subs mixed)
' @param {string} itemType - The item type string (drives which slots apply)
sub populateTrackDropdowns(mediaSources as object, allStreams as object, itemType as string)
  videoItems = buildVideoDropdownItems(mediaSources)
  audioItems = buildAudioDropdownItems(allStreams)
  subtitleItems = buildSubtitleDropdownItems(allStreams)

  ' Seed default subtitle selection on first load / fresh content so the dropdown trigger
  ' text reflects the subtitle stream playback would auto-pick. A subsequent user pick on
  ' the dropdown flips m.subtitleUserOverridden so we don't re-seed on audio changes.
  if not m.subtitleUserOverridden
    defaultSubIdx = findDefaultSubtitleStreamIndex(allStreams, m.top.selectedAudioStreamIndex)
    m.top.selectedSubtitleStreamIndex = defaultSubIdx
  end if

  ' Audio-only items get no video and no subtitle slot. Video items always show
  ' the subtitle slot for layout consistency — buildSubtitleDropdownItems returns
  ' a single "None" entry when no tracks exist, which applyDropdown renders as
  ' static non-interactive text.
  videoApplicable = itemType <> "Audio" and videoItems.count() > 0
  audioApplicable = audioItems.count() > 0
  subtitleApplicable = itemType <> "Audio" and subtitleItems.count() > 0

  ' Title row: static labels above each dropdown. Always shown for applicable slots so
  ' users see the category name even when the slot is single-option static text.
  m.videoTrackTitle.text = translate(translationKeys.LabelVideo)
  m.audioTrackTitle.text = translate(translationKeys.LabelAudio)
  m.subtitleTrackTitle.text = translate(translationKeys.LabelSubtitles)
  m.videoTrackTitle.visible = videoApplicable
  m.audioTrackTitle.visible = audioApplicable
  m.subtitleTrackTitle.visible = subtitleApplicable

  applyDropdown(m.videoDropdown, videoItems, m.top.selectedVideoStreamId, videoApplicable)
  applyDropdown(m.audioDropdown, audioItems, str(m.top.selectedAudioStreamIndex).trim(), audioApplicable)
  applyDropdown(m.subtitleDropdown, subtitleItems, str(m.top.selectedSubtitleStreamIndex).trim(), subtitleApplicable)

  ' Cluster visibility: shown if any slot applies. trackCluster is a top-level sibling
  ' statically positioned in XML at [96, 713] — no runtime layout math needed here.
  m.trackCluster.visible = videoApplicable or audioApplicable or subtitleApplicable
end sub

' applyDropdown: Push items + selection into a single TrackDropdown. Hides the slot when
' the track type isn't applicable (keeps layout order stable for the other two slots).
sub applyDropdown(dropdown as object, items as object, selectedId as string, applicable as boolean)
  if not applicable
    dropdown.visible = false
    return
  end if

  dropdown.visible = true
  dropdown.items = items
  dropdown.selectedItemId = selectedId
  dropdown.triggerText = triggerTextForSelection(items, selectedId)
  ' Single-option slots render as static (non-focusable) text with the same geometry
  dropdown.isInteractive = items.count() > 1
end sub

' triggerTextForSelection: Look up the currently-selected item's display title.
function triggerTextForSelection(items as object, selectedId as string) as string
  if not isValid(items) or items.count() = 0 then return ""
  for each item in items
    if isValid(item.id) and item.id = selectedId
      return item.title
    end if
  end for
  return items[0].title
end function

' buildVideoDropdownItems: Convert MediaSources into dropdown items. "Video" here is the
' MediaSource, not a stream — it's the user-facing alternate-version selector (e.g. "1080p
' H264" vs "4K HEVC HDR10").
function buildVideoDropdownItems(mediaSources as object) as object
  items = []
  if not isValid(mediaSources) then return items

  hasMultipleSources = mediaSources.count() > 1
  for each source in mediaSources
    if isValid(source) and source.VideoType = "VideoFile" and isValidAndNotEmpty(source.id)
      items.push({ id: source.id, title: formatVideoSourceTitle(source, hasMultipleSources) })
    end if
  end for
  return items
end function

' buildAudioDropdownItems: Convert the MediaStreams array into dropdown items for audio.
' Item id is the stringified server-side stream index, matching the ItemDetails contract
' that selectedAudioStreamIndex is an integer spanning all stream types.
function buildAudioDropdownItems(streams as object) as object
  items = []
  if not isValid(streams) then return items

  for each stream in streams
    if isValid(stream) and isValid(stream.Type) and LCase(stream.Type) = "audio" and isValid(stream.index)
      items.push({ id: str(stream.index).trim(), title: formatAudioDisplayTitle(stream) })
    end if
  end for
  return items
end function

' buildSubtitleDropdownItems: Convert the MediaStreams array into dropdown items for
' subtitles.
'
' Two shapes are returned depending on whether the item actually has subtitle streams:
'   * No subtitle streams at all → [{ id:"-1", title:"None" }]. Rendered as a static
'     non-interactive slot so the "Subtitles" title row still shows and the layout
'     stays consistent across items. "None" (not "Off") because the user has no
'     choice to make — subtitles simply don't exist on this item.
'   * One or more subtitle streams → [{ id:"-1", title:"Off" }, ...tracks]. The
'     "Off" entry lets the user disable subtitles when tracks ARE present. id "-1"
'     is the SubtitleSelection.NONE sentinel used by m.top.selectedSubtitleStreamIndex.
function buildSubtitleDropdownItems(streams as object) as object
  tracks = []
  if isValid(streams)
    for each stream in streams
      if isValid(stream) and isValid(stream.Type) and LCase(stream.Type) = "subtitle" and isValid(stream.index)
        tracks.push({ id: str(stream.index).trim(), title: formatSubtitleDisplayTitle(stream) })
      end if
    end for
  end if

  if tracks.count() = 0
    return [{ id: "-1", title: translate(translationKeys.LabelNone) }]
  end if

  items = [{ id: "-1", title: translate(translationKeys.LabelOff) }]
  for each track in tracks
    items.push(track)
  end for
  return items
end function

' getStreamsForSelectedSource: Pull the MediaStreams array from whichever media source
' matches m.top.selectedVideoStreamId. Falls back to mediaSources[0] when the selected
' id isn't found (e.g. first load before selection).
function getStreamsForSelectedSource(mediaSources as object) as object
  if not isValid(mediaSources) then return []

  for each source in mediaSources
    if source.Id = m.top.selectedVideoStreamId and isValid(source.MediaStreams)
      return source.MediaStreams
    end if
  end for

  if isValid(mediaSources[0]) and isValid(mediaSources[0].MediaStreams)
    return mediaSources[0].MediaStreams
  end if
  return []
end function

' SetDefaultAudioTrack: Auto-select the best audio stream based on user language prefs,
' storing the result on m.top.selectedAudioStreamIndex. The dropdown trigger label is
' refreshed separately by applyDropdown() using the same index.
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, resolveAudioLanguagePreference(localUser.settings, localUser.config))

  m.top.selectedAudioStreamIndex = selectedIndex
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

' createEndsAtNode: Build an "Ends at" time label with AM/PM support and store it
' on m.endsAtNode for dynamic clock updates. Used by types with runTimeTicks-based durations.
sub createEndsAtNode()
  date = CreateObject("roDateTime")
  date.fromSeconds(date.asSeconds() + m.endsAtDurationSeconds)
  date.toLocalTime()
  m.endsAtNode = createTimeLabel("ends-at", translate(translationKeys.MessageEndsAt1, [formatTime(date)]), getTimePeriod(date))
end sub

' 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()
  timeStr = formatTime(date)
  periodText = getTimePeriod(date)
  if periodText <> "" then timeStr += " " + periodText
  m.endsAtNode.text = translate(translationKeys.MessageEndsAt1, [timeStr])
end sub

sub updateFavoriteButton()
  fave = m.top.itemContent.isFavorite
  favoriteButton = m.top.findNode("favoriteButton")
  if isValid(favoriteButton)
    if isValid(fave) and fave
      favoriteButton.isButtonSelected = true
    else
      favoriteButton.isButtonSelected = 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.isButtonSelected = true
    else
      watchedButton.isButtonSelected = false
    end if
  end if
end sub

' setItemLogo: Set the item logo image URL if available.
' Fallback chains by type — every chain ends in a typed placeholder via
' getPlaceholderImagePath() so the logo slot never goes blank, matching the
' pattern Person and MusicArtist always used:
' - Movie/Series/BoxSet: Logo → primaryImageTag (poster, compact size) → placeholder
' - Episode/Season/Recording: primaryImageTag → parentLogoImageTag (series logo) → seriesPrimaryImageTag → placeholder
' - Video/MusicVideo: primaryImageTag → placeholder
' - MusicAlbum / Playlist / Photo / PhotoAlbum / TvChannel: primaryImageTag → placeholder
' - Program: primaryImageTag → channel logo → placeholder
' - Audio: primaryImageTag → album art → placeholder
' - Person / MusicArtist: primaryImageTag (portrait) → placeholder
' @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 glyph
    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 = getPlaceholderImagePath(item.type)
    end if
    return
  else if item.type = "MusicArtist"
    ' Same as Person — primary photo where the logo sits, falling back to the
    ' canonical artist silhouette (account_box) when no primary image. Matches
    ' what JRRowItem shows for MusicArtist items in the row list and what
    ' MusicArtistGridItem shows on grid fallback.
    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 = getPlaceholderImagePath(item.type)
    end if
    return
  else if item.type = "MusicAlbum"
    ' Square album art as the primary image; typed placeholder if none
    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 = getPlaceholderImagePath(item.type)
    end if
    return
  else if item.type = "Playlist"
    ' Square playlist cover as primary image; typed placeholder if none
    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 = getPlaceholderImagePath(item.type)
    end if
    return
  else if item.type = "Photo" or item.type = "PhotoAlbum"
    ' Photo thumbnail or album cover as primary image; typed placeholder if none
    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 = getPlaceholderImagePath(item.type)
    end if
    return
  else if item.type = "TvChannel"
    ' Channel logo (typically square); folder-fallback placeholder if none
    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 = getPlaceholderImagePath(item.type)
    end if
    return
  else if item.type = "Program"
    ' Program image → channel logo → typed placeholder
    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 if isValidAndNotEmpty(item.channelPrimaryImageTag) and isValidAndNotEmpty(item.channelId)
      imgParams = { maxHeight: 534, maxWidth: LOGO_MAX_DISPLAY_WIDTH, quality: 90, tag: item.channelPrimaryImageTag }
      m.itemLogo.uri = ImageURL(item.channelId, "Primary", imgParams)
    else
      m.itemLogo.uri = getPlaceholderImagePath(item.type)
    end if
    return
  else if item.type = "Audio"
    ' Own primary image → album art fallback → typed placeholder
    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 if isValidAndNotEmpty(item.albumId) and isValidAndNotEmpty(item.albumPrimaryImageTag)
      imgParams = { maxHeight: 534, maxWidth: LOGO_MAX_DISPLAY_WIDTH, quality: 90, tag: item.albumPrimaryImageTag }
      m.itemLogo.uri = ImageURL(item.albumId, "Primary", imgParams)
    else
      m.itemLogo.uri = getPlaceholderImagePath(item.type)
    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; playCircle placeholder if none.
    if isValidAndNotEmpty(item.primaryImageTag)
      imgParams = { maxHeight: 212, maxWidth: 500, quality: 90, tag: item.primaryImageTag }
      m.itemLogo.uri = ImageURL(item.id, "Primary", imgParams)
    else
      m.itemLogo.uri = getPlaceholderImagePath(item.type)
    end if
    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.
    ' Movie placeholder if neither logo nor primary is available.
    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 = getPlaceholderImagePath(item.type)
    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,
    ' then a playCircle placeholder if the series has no primary image either.
    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.uri = getPlaceholderImagePath(item.type)
    end if
  else
    ' Catch-all: any other type without a known fallback chain gets the type-driven
    ' placeholder (falls through to the folder glyph for genuinely unknown types).
    m.itemLogo.uri = getPlaceholderImagePath(item.type)
  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 = translate(translationKeys.LabelAdded) + " " + dateAdded.AsDateString("short-month-no-weekday") + " " + formatTime(dateAdded)

  ' Set X here; Y is computed by anchorDateLabel() which handles both the initial
  ' placement (using the button group's XML translation + a safe height estimate) and
  ' later refinements when the button group's actual rendered height is known. Calling
  ' it here guarantees the label is correctly positioned on the very first render —
  ' no flash at the top of the screen.
  m.dateCreatedLabel.translation = [1920 - 96 - 450, m.dateCreatedLabel.translation[1]]
  m.dateCreatedLabel.visible = true
  anchorDateLabel()
end sub

' anchorDateLabel: Derive the date label's Y position from the button group's position.
' Returns dateLabelY so callers (e.g. logo positioning) can use it without duplicating the math.
' Safe to call multiple times — idempotent, and safe to call BEFORE the button group has
' finished laying out: falls back to the group's fixed translation + ESTIMATED_BUTTON_ROW_HEIGHT_PX
' so the label never lands off-screen. A later renderTracking callback refines the position
' once the real rendered height is known.
' @return {integer} dateLabelY — the Y coordinate used for the date label
function anchorDateLabel() as integer
  buttonY = m.buttonGrp.translation[1]
  buttonHeight = ESTIMATED_BUTTON_ROW_HEIGHT_PX

  ' Refine with the measured height once layout has settled. Ignore the degenerate 0-size
  ' bounding rect reported during the initial render — that's what was pushing the label
  ' to y=-30 on the Series detail screen.
  buttonRect = m.buttonGrp.boundingRect()
  if buttonRect.height > 0
    buttonHeight = buttonRect.height
  end if

  dateLabelY = buttonY + buttonHeight - 30

  if isValid(m.dateCreatedLabel) and m.dateCreatedLabel.visible
    m.dateCreatedLabel.translation = [m.dateCreatedLabel.translation[0], dateLabelY]
  end if

  return dateLabelY
end function

' onButtonGrpRendered: Fires after the render thread settles the button group layout.
' Re-anchors the date label and repositions the logo. The track dropdown cluster lives
' inside itemDetails LayoutGroup and is positioned automatically by the layout engine,
' so it doesn't need to be re-placed here.
sub onButtonGrpRendered()
  if m.buttonGrp.renderTracking = "none" then return

  dateLabelY = anchorDateLabel()

  ' If the logo is already loaded and visible, reposition it relative to the new dateLabelY.
  if isValid(m.itemLogo) and m.itemLogo.visible
    logoY = dateLabelY - m.itemLogo.height - 18
    m.itemLogo.translation = [m.itemLogo.translation[0], logoY]
  end if
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"
    ' Tint placeholder PNGs (white glyph on transparent BG) with a near-page-BG
    ' color so they recess like a watermark instead of competing with the title.
    ' Detection by URI prefix: every placeholder asset lives under
    ' pkg:/images/placeholders/. Real (server-loaded) images need the default
    ' white blendColor so their native colors render correctly.
    if Left(m.itemLogo.uri, 24) = "pkg:/images/placeholders"
      m.itemLogo.blendColor = m.global.constants.colorBackgroundSecondary
    else
      m.itemLogo.blendColor = "0xFFFFFFFF"
    end if

    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/MusicArtist items so the logo top stays below the metadata rows.
      ' Person and MusicArtist 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" and m.top.itemContent.type <> "MusicArtist"
        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

      dateLabelY = anchorDateLabel()
      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()
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.
' Called just-in-time from the DOWN key handler to guarantee the layout is fully settled
' (text wrapping complete) before measuring. Eager calculation via renderTracking races
' with async text layout, producing stale targets — especially for long descriptions.
sub updateItemDetailsAnimationTarget()
  currentTransY = m.itemDetails.translation[1]
  itemInfoRowsLocalRect = m.itemInfoRows.boundingRect()
  screenBottomOfInfoRows = currentTransY + itemInfoRowsLocalRect.y + itemInfoRowsLocalRect.height

  dY = (extrasLayout.PANEL_OPEN_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 = extrasLayout.PANEL_CLOSED_Y
  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

' 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

' onExtrasTargetTranslationYChanged: Animate the RowList translation to the new target.
' Fired early by onKeyEvent in ExtrasRowList (at key press) so the animation runs in parallel
' with the RowList's floatingFocus animation, and confirmed by onRowItemFocused after focus lands.
sub onExtrasTargetTranslationYChanged()
  currentY = m.extrasGrid.translation[1]
  targetY = m.extrasGrid.targetTranslationY
  if abs(currentY - targetY) < 1 then return
  m.gridTranslationInterp.keyValue = [[0, currentY], [0, targetY]]
  m.gridAnime.control = "start"
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 the 62px itemTracks spacer from the itemDetails
' LayoutGroup. When a track cluster is applicable to the current item, the spacer
' stays in the layout and pushes description bottom up to y=701 so the trackCluster
' sibling can sit at y=713 with 12px breathing pad above and 18px below between the
' description and the button group. For item types without any media tracks
' (Series/Person/etc.), the spacer is removed and description bottom drops back to
' y=775 (matching main's behavior — no cluster, no padding, no regression).
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 and adds the correct sync buttons in order.
' Loading placeholders are added for async buttons (Series Resume, Person Shuffle) so
' the button row renders at its final width immediately. The async handlers replace or remove
' the placeholders when data arrives.
' 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()
  isInitialLoad = not m.buttonGrp.visible
  ' 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.
  focusedButtonId = ""
  focusIndex = m.buttonGrp.buttonFocused
  if focusIndex >= 0 and focusIndex < m.buttonGrp.getChildCount()
    focusedBtn = m.buttonGrp.getChild(focusIndex)
    if isValid(focusedBtn) then focusedButtonId = focusedBtn.id
  end if

  ' Clear everything 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.shuffleButton = invalid

  itemType = item.type

  ' Series Resume placeholder — shows a loading IconButton at position 0 while the seriesResume
  ' task fetches the next-up episode. onNextUpEpisodeChanged() replaces or removes it.
  ' Preserves the episode-specific text (e.g. "Resume S2E3") from prior data if available
  ' so the user sees a consistent label through the loading transition.
  if itemType = "Series"
    m.resumeLoadingButton = CreateObject("roSGNode", "IconButton")
    m.resumeLoadingButton.id = "resumeLoadingButton"
    m.resumeLoadingButton.icon = "pkg:/images/icons/resume_$$RES$$.png"
    if isValid(m.top.nextUpEpisode) and isValidAndNotEmpty(m.top.nextUpEpisode.id)
      m.resumeLoadingButton.text = getResumeButtonText(m.top.nextUpEpisode)
    else
      m.resumeLoadingButton.text = translate(translationKeys.ButtonResume)
    end if
    m.resumeLoadingButton.isLoading = true
    m.buttonGrp.appendChild(m.resumeLoadingButton)
  end if

  ' Photo: View button
  if itemType = "Photo"
    viewPhotoButton = CreateObject("roSGNode", "IconButton")
    viewPhotoButton.id = "viewPhotoButton"
    viewPhotoButton.icon = "pkg:/images/icons/play_$$RES$$.png"
    viewPhotoButton.text = translate(translationKeys.LabelView)
    m.buttonGrp.appendChild(viewPhotoButton)
  end if

  ' PhotoAlbum: Slideshow button
  if itemType = "PhotoAlbum"
    slideshowButton = CreateObject("roSGNode", "IconButton")
    slideshowButton.id = "slideshowButton"
    slideshowButton.icon = "pkg:/images/icons/play_$$RES$$.png"
    slideshowButton.text = translate(translationKeys.LabelSlideshow)
    m.buttonGrp.appendChild(slideshowButton)
  end if

  ' Determine if a Program is currently airing by checking startDate/endDate against current time.
  ' More reliable than item.isLive which depends on the API response being fresh.
  programIsLiveNow = false
  programIsFuture = false
  if itemType = "Program" and isValidAndNotEmpty(item.startDate) and isValidAndNotEmpty(item.endDate)
    progStartDt = createObject("roDateTime")
    progStartDt.FromISO8601String(item.startDate)
    progEndDt = createObject("roDateTime")
    progEndDt.FromISO8601String(item.endDate)
    progNow = createObject("roDateTime")
    nowSec = progNow.AsSeconds()
    programIsLiveNow = nowSec >= progStartDt.AsSeconds() and nowSec < progEndDt.AsSeconds()
    programIsFuture = nowSec < progStartDt.AsSeconds()
  else if itemType = "Program" and item.isLive
    ' Fallback for programs without start/end dates
    programIsLiveNow = true
  end if

  ' TvChannel: Watch button with live progress bar (always shown — channels are always live)
  ' Program: Watch button only when program is currently airing
  showWatch = itemType = "TvChannel" or (itemType = "Program" and programIsLiveNow)

  if showWatch
    ' Determine the program whose start/end times drive the progress bar
    liveProg = invalid
    if itemType = "TvChannel" and isValid(item.currentProgram)
      liveProg = item.currentProgram
    else if itemType = "Program"
      liveProg = item
    end if

    ' Calculate ticks for progress bar (1 second = 10,000,000 ticks)
    hasTimes = isValid(liveProg) and isValidAndNotEmpty(liveProg.startDate) and isValidAndNotEmpty(liveProg.endDate)
    if hasTimes
      startDt = createObject("roDateTime")
      startDt.FromISO8601String(liveProg.startDate)
      endDt = createObject("roDateTime")
      endDt.FromISO8601String(liveProg.endDate)
      now = createObject("roDateTime")
      runtimeSeconds& = endDt.AsSeconds() - startDt.AsSeconds()
      elapsedSeconds& = now.AsSeconds() - startDt.AsSeconds()
      if elapsedSeconds& < 0 then elapsedSeconds& = 0
      if elapsedSeconds& > runtimeSeconds& then elapsedSeconds& = runtimeSeconds&

      watchChannelButton = CreateObject("roSGNode", "ResumeButton")
      watchChannelButton.id = "watchChannelButton"
      watchChannelButton.icon = "pkg:/images/icons/play_$$RES$$.png"
      watchChannelButton.runtimeTicks = runtimeSeconds& * 10000000&
      watchChannelButton.playbackPositionTicks = elapsedSeconds& * 10000000&
    else
      watchChannelButton = CreateObject("roSGNode", "IconButton")
      watchChannelButton.id = "watchChannelButton"
      watchChannelButton.icon = "pkg:/images/icons/play_$$RES$$.png"
    end if

    watchChannelButton.text = translate(translationKeys.LabelWatch)
    m.buttonGrp.appendChild(watchChannelButton)
  end if

  ' Record button — TvChannel (always recordable) and Program (live or future)
  if m.global.user.policy.enableLiveTvManagement = true
    showRecord = itemType = "TvChannel" or (itemType = "Program" and (programIsLiveNow or programIsFuture))
    if showRecord
      recordButton = CreateObject("roSGNode", "IconButton")
      recordButton.id = "recordButton"
      recordButton.icon = "pkg:/images/icons/record_$$RES$$.png"
      ' Check existing recording state — colorError icon when active
      isRecording = false
      if itemType = "Program" and isValidAndNotEmpty(item.timerId)
        isRecording = true
      else if itemType = "TvChannel" and isValid(item.currentProgram) and isValidAndNotEmpty(item.currentProgram.timerId)
        isRecording = true
      end if
      if isRecording
        recordButton.text = translate(translationKeys.LabelCancelRecording)
        recordButton.iconBackground = m.global.constants.colorError
        recordButton.iconFocusBackground = m.global.constants.colorError
      else
        recordButton.text = translate(translationKeys.ButtonRecord)
      end if
      m.buttonGrp.appendChild(recordButton)
    end if
  end if

  ' Play button — standard content types (not Person, Photo, PhotoAlbum, TvChannel, Program)
  if itemType <> "Person" and itemType <> "Photo" and itemType <> "PhotoAlbum" and itemType <> "TvChannel" and itemType <> "Program"
    playButton = CreateObject("roSGNode", "IconButton")
    playButton.id = "playButton"
    playButton.icon = "pkg:/images/icons/play_$$RES$$.png"
    if itemType = "Series" or itemType = "Season" or itemType = "BoxSet" or itemType = "MusicArtist" or itemType = "MusicAlbum" or itemType = "Playlist"
      playButton.text = translate(translationKeys.LabelPlayAll)
    else
      playButton.text = translate(translationKeys.ButtonPlay)
    end if
    m.buttonGrp.appendChild(playButton)
  end if

  ' Shuffle button — Series, Season, BoxSet, MusicArtist, MusicAlbum, Playlist, PhotoAlbum (not Audio; Person shuffle is async)
  if itemType = "Series" or itemType = "Season" or itemType = "BoxSet" or itemType = "MusicArtist" or itemType = "MusicAlbum" or itemType = "Playlist" or itemType = "PhotoAlbum"
    m.shuffleButton = CreateObject("roSGNode", "IconButton")
    m.shuffleButton.id = "shuffleButton"
    m.shuffleButton.icon = "pkg:/images/icons/shuffle_$$RES$$.png"
    m.shuffleButton.text = translate(translationKeys.ButtonShuffle)
    m.buttonGrp.appendChild(m.shuffleButton)
  end if

  ' Watched button — not for Person, music types, Playlist, Photo, PhotoAlbum, TvChannel, or Program
  ' (Playlist Watched is added async by onPlaylistContentKindChanged() once the items chain confirms video items)
  if not inArray(itemTypeOrder.NO_WATCHED, itemType)
    watchedButton = CreateObject("roSGNode", "IconButton")
    watchedButton.id = "watchedButton"
    watchedButton.icon = "pkg:/images/icons/check_$$RES$$.png"
    watchedButton.text = translate(translationKeys.LabelWatched)
    m.buttonGrp.appendChild(watchedButton)
  end if

  ' Person Shuffle placeholder — shows a loading IconButton while the extras chain determines
  ' whether this person has playable media. onPersonHasMediaChanged() replaces or removes it.
  if itemType = "Person"
    m.shuffleLoadingButton = CreateObject("roSGNode", "IconButton")
    m.shuffleLoadingButton.id = "shuffleLoadingButton"
    m.shuffleLoadingButton.icon = "pkg:/images/icons/shuffle_$$RES$$.png"
    m.shuffleLoadingButton.text = translate(translationKeys.ButtonShuffle)
    m.shuffleLoadingButton.isLoading = true
    m.buttonGrp.appendChild(m.shuffleLoadingButton)
    ' Guard counter: the ExtrasRowList observer fires multiple times — first a reset
    ' (personHasMedia=false at chain start), then the definitive answer(s). We skip the
    ' first fire (the reset) so the placeholder stays visible until a real answer arrives.
    m.personMediaFireCount = 0
  end if

  ' Instant Mix button — MusicArtist, MusicAlbum, Audio (grouped with play buttons before Favorite)
  if itemType = "MusicArtist" or itemType = "MusicAlbum" or itemType = "Audio"
    instantMixButton = CreateObject("roSGNode", "IconButton")
    instantMixButton.id = "instantMixButton"
    instantMixButton.icon = "pkg:/images/icons/instantMix_$$RES$$.png"
    instantMixButton.text = translate(translationKeys.LabelInstantMix)
    m.buttonGrp.appendChild(instantMixButton)
  end if

  ' Favorite button — all types
  favoriteButton = CreateObject("roSGNode", "IconButton")
  favoriteButton.id = "favoriteButton"
  favoriteButton.icon = "pkg:/images/icons/heart_$$RES$$.png"
  favoriteButton.text = translate(translationKeys.LabelFavorite)
  m.buttonGrp.appendChild(favoriteButton)

  ' Go to Series button — Season, Episode, and Program (when program is a series)
  if itemType = "Season" or itemType = "Episode" or (itemType = "Program" and isValidAndNotEmpty(item.seriesId))
    goToSeriesButton = CreateObject("roSGNode", "IconButton")
    goToSeriesButton.id = "goToSeriesButton"
    goToSeriesButton.icon = "pkg:/images/icons/tv_$$RES$$.png"
    goToSeriesButton.text = translate(translationKeys.LabelGoToSeries)
    m.buttonGrp.appendChild(goToSeriesButton)
  end if

  ' Go to Channel button — Program only (navigate to TvChannel details)
  if itemType = "Program" and isValidAndNotEmpty(item.channelId)
    goToChannelButton = CreateObject("roSGNode", "IconButton")
    goToChannelButton.id = "goToChannelButton"
    goToChannelButton.icon = "pkg:/images/icons/tv_$$RES$$.png"
    goToChannelButton.text = translate(translationKeys.LabelGoToChannel)
    m.buttonGrp.appendChild(goToChannelButton)
  end if

  ' Go to Album button — Audio only (when album ID is available)
  if itemType = "Audio" and isValidAndNotEmpty(item.albumId)
    goToAlbumButton = CreateObject("roSGNode", "IconButton")
    goToAlbumButton.id = "goToAlbumButton"
    goToAlbumButton.icon = "pkg:/images/icons/album_$$RES$$.png"
    goToAlbumButton.text = translate(translationKeys.LabelGoToAlbum)
    m.buttonGrp.appendChild(goToAlbumButton)
  end if

  ' Go to Artist button — MusicAlbum only.
  ' Prefers albumArtists (primary album artist with an ID). Falls back to artistItems when
  ' there is exactly one contributing artist — avoids the ambiguity of multi-artist compilations.
  if itemType = "MusicAlbum"
    hasNavigableArtist = false
    if isValid(item.albumArtists) and item.albumArtists.count() > 0
      firstAlbumArtist = item.albumArtists[0]
      if isValid(firstAlbumArtist) and isValidAndNotEmpty(firstAlbumArtist.Id)
        hasNavigableArtist = true
      end if
    end if
    if not hasNavigableArtist and isValid(item.artistItems) and item.artistItems.count() = 1
      firstArtistItem = item.artistItems[0]
      if isValid(firstArtistItem) and isValidAndNotEmpty(firstArtistItem.Id)
        hasNavigableArtist = true
      end if
    end if
    if hasNavigableArtist
      goToArtistButton = CreateObject("roSGNode", "IconButton")
      goToArtistButton.id = "goToArtistButton"
      goToArtistButton.icon = "pkg:/images/icons/person_$$RES$$.png"
      goToArtistButton.text = translate(translationKeys.LabelGoToArtist)
      m.buttonGrp.appendChild(goToArtistButton)
    end if
  end if

  ' Trailer button — on refresh, restored from prior run while the async re-check runs.
  ' onTrailerAvailableChanged() will add/remove it if the server state changed.
  ' Must come before Delete to match the insertion order used in onTrailerAvailableChanged()
  if m.top.trailerAvailable
    trailerButton = CreateObject("roSGNode", "IconButton")
    trailerButton.id = "trailerButton"
    trailerButton.icon = "pkg:/images/icons/playOutline_$$RES$$.png"
    trailerButton.text = translate(translationKeys.LabelPlayTrailer)
    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_$$RES$$.png"
    deleteButton.text = translate(translationKeys.ButtonDelete)
    m.buttonGrp.appendChild(deleteButton)
  end if

  ' Refresh button — always last
  refreshButton = CreateObject("roSGNode", "IconButton")
  refreshButton.id = "refreshButton"
  refreshButton.icon = "pkg:/images/icons/refresh_$$RES$$.png"
  refreshButton.text = translate(translationKeys.ButtonRefresh)
  m.buttonGrp.appendChild(refreshButton)

  ' Show the button group (hidden until first setupButtons call)
  m.buttonGrp.visible = true
  m.top.lastFocus = m.buttonGrp

  ' 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 isInitialLoad or wasButtonGroupFocused
    focusButtonGroupChild()
  end if
end sub

' onPersonHasMediaChanged: Show or hide the Shuffle button once the person extras chain completes.
' On first load, replaces the LoadingButton placeholder from setupButtons().
' On refresh, inserts/removes the Shuffle button directly.
' 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

  loadingButton = m.top.findNode("shuffleLoadingButton")

  m.personMediaFireCount++

  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_$$RES$$.png"
      m.shuffleButton.text = translate(translationKeys.ButtonShuffle)

      if isValid(loadingButton)
        ' Swap the LoadingButton placeholder for the real shuffle button.
        ' The user may have moved focus to another button before async data arrived,
        ' so capture which button has focus before the remove/insert.
        loadingIndex = getButtonIndex("shuffleLoadingButton")
        wasFocused = loadingButton.isInFocusChain()
        currentFocusIndex = m.buttonGrp.buttonFocused
        m.buttonGrp.removeChild(loadingButton)
        m.shuffleLoadingButton = invalid
        m.buttonGrp.insertChild(m.shuffleButton, loadingIndex)
        ' Remove then insert at same index is a net-zero index shift,
        ' so the original focusIndex is still correct.
        m.buttonGrp.buttonFocused = currentFocusIndex
        if wasFocused
          focusButtonGroupChild()
        end if
      else
        ' No placeholder (refresh path) — insert before Favorite
        currentFocusIndex = m.buttonGrp.buttonFocused
        favoriteButtonIndex = getButtonIndex("favoriteButton")
        insertIndex = 0
        if favoriteButtonIndex >= 0 then insertIndex = favoriteButtonIndex
        m.buttonGrp.insertChild(m.shuffleButton, insertIndex)

        if currentFocusIndex = 0
          m.buttonGrp.buttonFocused = 0
        else
          m.buttonGrp.buttonFocused = currentFocusIndex + 1
        end if
      end if

      if m.buttonGrp.isInFocusChain()
        focusButtonGroupChild()
      end if
    end if
  else if m.personMediaFireCount > 1
    ' Skip the first false (ExtrasRowList reset at chain start). Any subsequent false is
    ' a definitive answer from the completed chain — safe to remove the placeholder.
    if isValid(loadingButton)
      currentFocusIndex = m.buttonGrp.buttonFocused
      loadingIndex = getButtonIndex("shuffleLoadingButton")
      wasFocused = loadingButton.isInFocusChain()
      m.buttonGrp.removeChild(loadingButton)
      m.shuffleLoadingButton = invalid
      if isValid(loadingIndex) and loadingIndex >= 0 and currentFocusIndex >= loadingIndex
        focusIdx = currentFocusIndex - 1
        if focusIdx < 0 then focusIdx = 0
        m.buttonGrp.buttonFocused = focusIdx
      end if
      if wasFocused
        focusButtonGroupChild()
      end if
    end if

    ' Remove the real shuffle button if present (refresh path where media disappeared)
    if isValid(m.shuffleButton)
      currentFocusIndex = m.buttonGrp.buttonFocused
      shuffleIndex = getButtonIndex("shuffleButton")
      m.buttonGrp.removeChild(m.shuffleButton)
      m.shuffleButton = invalid

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

      if m.buttonGrp.isInFocusChain()
        focusButtonGroupChild()
      end if
    end if
  end if
end sub

' onPlaylistContentKindChanged: Drives the Watched button and item-count label based on the
' confirmed content kind of the playlist ("video" / "audio" / "mixed" / "unknown").
' Fires via alwaysNotify so it triggers even when the kind is unchanged (e.g. on refresh).
'
' Watched button: only shown for "video" (has at least one video-type item).
' Label:         "Tracks" only when positively confirmed "audio" (all items are Audio type).
'                All other kinds — including "unknown" (load failure) and "mixed" — stay "Items".
sub onPlaylistContentKindChanged()
  if not isValid(m.top.itemContent) or m.top.itemContent.type <> "Playlist" then return

  item = m.top.itemContent
  childCount = item.childCount
  kind = m.extrasGrid.playlistContentKind

  ' --- Watched button ---
  watchedButton = m.top.findNode("watchedButton")
  if kind = "video"
    ' Video items detected — ensure Watched button exists before Favorite
    if not isValid(watchedButton)
      watchedButton = CreateObject("roSGNode", "IconButton")
      watchedButton.id = "watchedButton"
      watchedButton.icon = "pkg:/images/icons/check_$$RES$$.png"
      watchedButton.text = translate(translationKeys.LabelWatched)

      currentFocusIndex = m.buttonGrp.buttonFocused
      favoriteButtonIndex = getButtonIndex("favoriteButton")
      insertIndex = 0
      if favoriteButtonIndex >= 0 then insertIndex = favoriteButtonIndex
      m.buttonGrp.insertChild(watchedButton, insertIndex)

      if currentFocusIndex >= insertIndex
        ' Shift focus index up to track the same button after insertion
        m.buttonGrp.buttonFocused = currentFocusIndex + 1
      end if
    end if

    updateWatchedButton()
  else
    ' Not a video playlist — remove Watched button if present
    if isValid(watchedButton)
      currentFocusIndex = m.buttonGrp.buttonFocused
      watchedIndex = getButtonIndex("watchedButton")
      m.buttonGrp.removeChild(watchedButton)

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

      if m.buttonGrp.isInFocusChain()
        focusButtonGroupChild()
      end if
    end if
  end if

  ' --- Item count label ---
  ' "Tracks" only when the playlist is positively confirmed all-audio.
  ' "unknown" (load failure) and "mixed" both fall through to "Items" — the safe neutral default.
  itemCountNode = m.top.findNode("itemCount")
  if isValid(itemCountNode) and childCount > 0
    if kind = "audio"
      itemCountNode.text = translatePlural(translationKeys.LabelTrackCount, childCount, [stri(childCount).trim()])
    else
      itemCountNode.text = translatePlural(translationKeys.LabelItemCount, childCount, [stri(childCount).trim()])
    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

' ============================================
' TRACK DROPDOWN EVENT HANDLERS
' ============================================

' Video dropdown selection fires when the user picks a different MediaSource (alternate
' version). We swap in the new source's streams, re-run audio auto-select, and rebuild
' the audio/subtitle dropdowns against the new source. Focus stays on the video dropdown.
sub onVideoDropdownSelection()
  newStreamId = m.videoDropdown.selectedAction
  if not isValidAndNotEmpty(newStreamId) then return
  if newStreamId = m.top.selectedVideoStreamId then return

  m.top.selectedVideoStreamId = newStreamId

  item = m.top.itemContent
  if not isValid(item) or not isValid(item.mediaSourcesData) then return

  mediaSources = item.mediaSourcesData.mediaSources
  allStreams = getStreamsForSelectedSource(mediaSources)

  SetDefaultAudioTrack(allStreams)
  m.subtitleUserOverridden = false
  populateTrackDropdowns(mediaSources ?? [], allStreams, item.type)
end sub

' Audio dropdown selection: updates selectedAudioStreamIndex and re-seeds the subtitle
' auto-selection (Smart mode depends on which audio is playing). If the user has already
' manually overridden the subtitle, we leave it alone.
sub onAudioDropdownSelection()
  action = m.audioDropdown.selectedAction
  if not isValidAndNotEmpty(action) then return

  newIndex = action.toInt()
  if newIndex = m.top.selectedAudioStreamIndex then return
  m.top.selectedAudioStreamIndex = newIndex

  if m.subtitleUserOverridden then return

  item = m.top.itemContent
  if not isValid(item) or not isValid(item.mediaSourcesData) then return
  allStreams = getStreamsForSelectedSource(item.mediaSourcesData.mediaSources)

  defaultSubIdx = findDefaultSubtitleStreamIndex(allStreams, newIndex)
  m.top.selectedSubtitleStreamIndex = defaultSubIdx

  ' Refresh the subtitle dropdown's selection highlight + trigger text
  items = m.subtitleDropdown.items
  if isValid(items)
    newId = str(defaultSubIdx).trim()
    m.subtitleDropdown.selectedItemId = newId
    m.subtitleDropdown.triggerText = triggerTextForSelection(items, newId)
  end if
end sub

' Subtitle dropdown selection: marks user override so subsequent audio changes don't
' clobber the pick, then stores the new index. Action "-1" = Off.
sub onSubtitleDropdownSelection()
  action = m.subtitleDropdown.selectedAction
  if not isValidAndNotEmpty(action) then return

  m.top.selectedSubtitleStreamIndex = action.toInt()
  m.subtitleUserOverridden = true
end sub

' Dropdown navigation: UP from any dropdown goes to description when in layout, else
' is consumed (no-op). parent-level UP handling reused so we don't have three identical
' handlers.
sub onDropdownRequestUp()
  if m.itemDescriptionInLayout
    m.top.lastFocus = m.itemDescription
    m.itemDescription.setFocus(true)
  end if
end sub

' DOWN from any dropdown returns focus to the button group.
sub onDropdownRequestDown()
  m.top.lastFocus = m.buttonGrp
  m.buttonGrp.setFocus(true)
end sub

' LEFT/RIGHT between dropdowns — each dropdown has its own exit handler so we know which
' slot we came from and can target the correct neighbor, skipping non-interactive slots.
sub onVideoDropdownFocusExit()
  dir = m.videoDropdown.requestFocusExit
  if dir = "left"
    focusInteractiveDropdown([m.subtitleDropdown, m.audioDropdown, m.videoDropdown])
  else if dir = "right"
    focusInteractiveDropdown([m.audioDropdown, m.subtitleDropdown, m.videoDropdown])
  end if
end sub

sub onAudioDropdownFocusExit()
  dir = m.audioDropdown.requestFocusExit
  if dir = "left"
    focusInteractiveDropdown([m.videoDropdown, m.subtitleDropdown, m.audioDropdown])
  else if dir = "right"
    focusInteractiveDropdown([m.subtitleDropdown, m.videoDropdown, m.audioDropdown])
  end if
end sub

sub onSubtitleDropdownFocusExit()
  dir = m.subtitleDropdown.requestFocusExit
  if dir = "left"
    focusInteractiveDropdown([m.audioDropdown, m.videoDropdown, m.subtitleDropdown])
  else if dir = "right"
    focusInteractiveDropdown([m.videoDropdown, m.audioDropdown, m.subtitleDropdown])
  end if
end sub

' focusInteractiveDropdown: Walks the given candidate list and focuses the first dropdown
' that is interactive AND visible. The candidate order encodes both direction and wrap
' behavior; the currently-focused dropdown is always last so the nav falls back to it if
' nothing else is interactive (effectively: stay put).
sub focusInteractiveDropdown(candidates as object)
  for each candidate in candidates
    if isValid(candidate) and candidate.visible and candidate.isInteractive
      m.top.lastFocus = candidate
      candidate.setFocus(true)
      return
    end if
  end for
end sub

' firstInteractiveDropdown: Returns the first visible, interactive TrackDropdown in
' left-to-right order, or invalid if none exist.
function firstInteractiveDropdown() as object
  candidates = [m.videoDropdown, m.audioDropdown, m.subtitleDropdown]
  for each candidate in candidates
    if isValid(candidate) and candidate.visible and candidate.isInteractive
      return candidate
    end if
  end for
  return invalid
end function

' onTrackDropdownFocusChanged: Observer wired to each TrackDropdown's focusedChild.
' Remembers the last dropdown that held focus so the UP-from-buttons handler can
' restore the previous slot on re-entry instead of always defaulting to video.
sub onTrackDropdownFocusChanged()
  if isValid(m.videoDropdown) and m.videoDropdown.isInFocusChain()
    m.lastFocusedDropdown = m.videoDropdown
  else if isValid(m.audioDropdown) and m.audioDropdown.isInFocusChain()
    m.lastFocusedDropdown = m.audioDropdown
  else if isValid(m.subtitleDropdown) and m.subtitleDropdown.isInFocusChain()
    m.lastFocusedDropdown = m.subtitleDropdown
  end if
end sub

' onTrackDropdownMenuOpenChanged: Observer wired to each TrackDropdown's menuOpen.
' Toggles the full-screen dimmer iff ANY dropdown's menu is currently open. The
' trackCluster (titles + triggers + the open menu itself) renders ABOVE the dimmer
' in XML sibling order, so only the screen content BEHIND the cluster dims —
' category titles and trigger labels stay full-brightness as the user's "you are
' here" cue while picking a track.
sub onTrackDropdownMenuOpenChanged()
  anyOpen = false
  if isValid(m.videoDropdown) and m.videoDropdown.menuOpen then anyOpen = true
  if isValid(m.audioDropdown) and m.audioDropdown.menuOpen then anyOpen = true
  if isValid(m.subtitleDropdown) and m.subtitleDropdown.menuOpen then anyOpen = true
  if isValid(m.dropdownDimmer)
    m.dropdownDimmer.visible = anyOpen
  end if
end sub

' pickTrackDropdownFromButtons: Chooses which TrackDropdown to focus when UP is pressed
' from the button group. Ignores m.lastFocusedDropdown by design — UP-from-buttons
' should ALWAYS land on the dropdown visually above the currently focused button, not
' on wherever the user was last time. Each slot spans 2 buttons: slot 1 sits above
' buttons [0,1], slot 2 above [2,3], slot 3 above [4,5,...]. Widens outward to the
' nearest interactive slot if the preferred one is static/hidden.
'
' CRITICAL: guards on m.trackCluster.visible — when the item type has no media tracks
' (Series/Person/BoxSet/etc.), the cluster is hidden but the individual dropdown nodes
' may still carry stale visible=true / isInteractive=true from a previously-loaded
' item. Without this guard, focus would move to an invisible stale dropdown when the
' user presses UP on those item types.
function pickTrackDropdownFromButtons() as object
  if not isValid(m.trackCluster) or not m.trackCluster.visible then return invalid

  slots = [m.videoDropdown, m.audioDropdown, m.subtitleDropdown]

  buttonIdx = m.buttonGrp.buttonFocused
  if not isValid(buttonIdx) or buttonIdx < 0 then buttonIdx = 0
  preferredSlot = 0
  if buttonIdx >= 4
    preferredSlot = 2
  else if buttonIdx >= 2
    preferredSlot = 1
  end if

  order = trackClusterFocus.buildSearchOrder(preferredSlot)
  for each idx in order
    candidate = slots[idx]
    if isValid(candidate) and candidate.visible and candidate.isInteractive
      return candidate
    end if
  end for
  return invalid
end function

' pickTrackDropdownFromDescription: Chooses which TrackDropdown to focus when DOWN is
' pressed from the description. Restores m.lastFocusedDropdown if still usable so the
' user returns to wherever they were before moving up. Falls back to the leftmost
' interactive dropdown (video → audio → subtitles) for the first-ever entry.
' Also guards on m.trackCluster.visible to match pickTrackDropdownFromButtons above.
function pickTrackDropdownFromDescription() as object
  if not isValid(m.trackCluster) or not m.trackCluster.visible then return invalid

  if isValid(m.lastFocusedDropdown) and m.lastFocusedDropdown.visible and m.lastFocusedDropdown.isInteractive
    return m.lastFocusedDropdown
  end if

  slots = [m.videoDropdown, m.audioDropdown, m.subtitleDropdown]
  for each candidate in slots
    if isValid(candidate) and candidate.visible and candidate.isInteractive
      return candidate
    end if
  end for
  return invalid
end function

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

  ' 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
    ' Setting invalid to invalid won't fire onChange, so remove the loading button directly
    removeResumeLoadingButton()
    m.top.nextUpEpisode = invalid
  end if
end sub

' removeResumeLoadingButton: Remove the resume LoadingButton placeholder if still present.
' Called when async data confirms no resume episode exists (nextUpEpisode stays invalid).
sub removeResumeLoadingButton()
  loadingButton = m.top.findNode("resumeLoadingButton")
  if not isValid(loadingButton) then return

  currentFocusIndex = m.buttonGrp.buttonFocused
  loadingIndex = getButtonIndex("resumeLoadingButton")
  wasFocused = loadingButton.isInFocusChain()
  m.buttonGrp.removeChild(loadingButton)
  m.resumeLoadingButton = invalid
  if isValid(loadingIndex) and loadingIndex >= 0 and currentFocusIndex >= loadingIndex
    focusIdx = currentFocusIndex - 1
    if focusIdx < 0 then focusIdx = 0
    m.buttonGrp.buttonFocused = focusIdx
  end if
  if wasFocused
    focusButtonGroupChild()
  end if
end sub

' onLyricsLoaded: Show lyrics in the description area once the async fetch completes (Audio only)
sub onLyricsLoaded()
  m.loadLyricsTask.unobserveField("content")
  content = m.loadLyricsTask.content
  m.loadLyricsTask.content = []

  if not isValid(m.top.itemContent) or m.top.itemContent.type <> "Audio" then return
  if not isValid(content) or content.count() = 0 or not isValidAndNotEmpty(content[0]) then return

  m.itemDescription.text = content[0]
  m.itemDescription.visible = true
  setDescriptionInLayout(true)
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]
    m.top.quickPlayNode = invalid
  else
    m.log.warn("Series Play: could not load first episode for series", m.top.itemContent.id)
    m.global.sceneManager.callFunc("standardDialog", translate(translationKeys.ErrorPlaybackError), { data: ["<p>" + translate(translationKeys.ErrorCouldNotLoadTheFirstEpisode) + "</p>"] })
  end if
end sub

function onKeyEvent(key as string, press as boolean) as boolean
  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
        m.top.quickPlayNode = invalid
        return true
      end if
    end if
  end if

  ' DOWN: itemDescription -> track row dropdown (if any interactive), else buttonGrp.
  ' Uses the "remember last focused" picker so returning to the row after coming back
  ' from buttons/extras lands the user on the same slot. Falls through to the button
  ' group only when no interactive dropdown exists. The track row is NEVER skipped.
  if key = "down" and m.itemDescription.isInFocusChain()
    target = pickTrackDropdownFromDescription()
    if isValid(target)
      m.top.lastFocus = target
      target.setFocus(true)
    else
      m.top.lastFocus = m.buttonGrp
      m.buttonGrp.setFocus(true)
    end if
    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)

    ' Calculate animation target with the fully-settled layout before activating extras
    updateItemDetailsAnimationTarget()
    activateExtras()

    ' Hide description and the entire track cluster before sliding up so they don't
    ' show during the transition. trackCluster wraps both rows so a single opacity
    ' toggle is sufficient.
    m.itemDescription.opacity = 0
    m.trackCluster.opacity = 0

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

    ' Extras slider animation — panel always opens to extrasLayout.PANEL_OPEN_Y (306).
    ' Row positioning within the panel is handled by translating the RowList (ExtrasRowList.bs).
    vertSlider = m.top.findNode("VertSlider")
    vertSlider.keyValue = [[0, extrasLayout.PANEL_CLOSED_Y], [0, extrasLayout.PANEL_OPEN_Y]]
    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 track cluster before sliding back down
      m.itemDescription.opacity = 1
      m.trackCluster.opacity = 1

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

      ' Extras slider animation — panel always closes from extrasLayout.PANEL_OPEN_Y (306).
      vertSlider = m.top.findNode("VertSlider")
      vertSlider.keyValue = [[0, extrasLayout.PANEL_CLOSED_Y], [0, extrasLayout.PANEL_OPEN_Y]]
      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 -> track dropdown picked from the button column (ignoring any
  ' remembered slot — by design, UP-from-buttons ALWAYS targets the slot visually
  ' above the currently focused button). If no dropdowns are interactive, fall through
  ' to itemDescription. Navigation ladder: buttons <-> track row <-> description.
  if key = "up" and m.buttonGrp.isInFocusChain()
    target = pickTrackDropdownFromButtons()
    if isValid(target)
      m.top.lastFocus = target
      target.setFocus(true)
      return true
    else if m.itemDescriptionInLayout
      m.top.lastFocus = m.itemDescription
      m.itemDescription.setFocus(true)
      return true
    end if
  end if

  if key = "play" and m.extrasGrid.hasFocus()
    if isValid(m.extrasGrid.focusedItem)
      m.top.quickPlayNode = m.extrasGrid.focusedItem
      m.top.quickPlayNode = invalid
      return true
    end if
  end if

  return false
end function

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

  ' Tear down ExtrasRowList FIRST — stops in-flight tasks (including any
  ' LoadChannelProgramsTask started by onProgramsExpired) and unobserves all
  ' fields before we destroy the texture manager or null out references.
  m.extrasGrid.callFunc("onDestroy")

  ' Unobserve m.port observers set by showScenes.bs CreateItemDetailsGroup().
  ' These persist until the node is garbage collected, so explicit cleanup
  ' prevents stale events from reaching main.bs's event loop after popScene.
  m.top.unobserveField("quickPlayNode")
  m.extrasGrid.unobserveField("selectedItem")
  m.buttonGrp.unobserveField("buttonSelected")
  m.buttonGrp.unobserveField("renderTracking")

  destroyTextureManager(m.extrasGrid.content)

  ' Safety net: stop the loading spinner in case the component is destroyed
  ' while the initial load or a refresh is still in flight.
  stopLoadingSpinner()

  ' 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")
  m.extrasGrid.unobserveField("playlistContentKind")
  m.extrasGrid.unobserveField("targetTranslationY")

  ' Unobserve TrackDropdown output fields + focusedChild memory observers
  if isValid(m.videoDropdown)
    m.videoDropdown.unobserveField("selectedAction")
    m.videoDropdown.unobserveField("requestFocusReturn")
    m.videoDropdown.unobserveField("requestFocusDown")
    m.videoDropdown.unobserveField("requestFocusExit")
    m.videoDropdown.unobserveField("focusedChild")
    m.videoDropdown.unobserveField("menuOpen")
    m.videoDropdown.callFunc("onDestroy")
  end if
  if isValid(m.audioDropdown)
    m.audioDropdown.unobserveField("selectedAction")
    m.audioDropdown.unobserveField("requestFocusReturn")
    m.audioDropdown.unobserveField("requestFocusDown")
    m.audioDropdown.unobserveField("requestFocusExit")
    m.audioDropdown.unobserveField("focusedChild")
    m.audioDropdown.unobserveField("menuOpen")
    m.audioDropdown.callFunc("onDestroy")
  end if
  if isValid(m.subtitleDropdown)
    m.subtitleDropdown.unobserveField("selectedAction")
    m.subtitleDropdown.unobserveField("requestFocusReturn")
    m.subtitleDropdown.unobserveField("requestFocusDown")
    m.subtitleDropdown.unobserveField("requestFocusExit")
    m.subtitleDropdown.unobserveField("focusedChild")
    m.subtitleDropdown.unobserveField("menuOpen")
    m.subtitleDropdown.callFunc("onDestroy")
  end if
  m.lastFocusedDropdown = invalid
  m.dropdownDimmer = invalid

  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

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

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

  if isValid(m.trailerResultNode)
    m.trailerResultNode.unobserveField("isDone")
    m.trailerResultNode = invalid
  end if

  ' Clear node references
  m.extrasGrp = invalid
  m.extrasGrid = invalid
  m.gridAnime = invalid
  m.gridTranslationInterp = invalid
  m.infoGroup = invalid
  m.itemDescription = invalid
  m.buttonGrp = invalid
  m.itemLogo = invalid
  m.dateCreatedLabel = invalid
  m.itemTextGradient = invalid
  m.itemDetails = invalid
  m.itemDetailsSlider = invalid
  m.itemDetailsSliderInterp = invalid
  m.itemInfoRows = invalid
  m.directorGenreGroup = invalid
  m.itemTracks = invalid
  m.trackCluster = invalid
  m.trackTitles = invalid
  m.videoTrackTitle = invalid
  m.audioTrackTitle = invalid
  m.subtitleTrackTitle = invalid
  m.trackDropdowns = invalid
  m.videoDropdown = invalid
  m.audioDropdown = invalid
  m.subtitleDropdown = invalid
  m.shuffleButton = invalid
  m.shuffleLoadingButton = invalid
  m.resumeLoadingButton = invalid
  m.endsAtNode = invalid

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