components_ItemDetails.bs

import "pkg:/source/api/ApiClient.bs"
import "pkg:/source/api/apiPool.bs"
import "pkg:/source/api/apiPromise.bs"
import "pkg:/source/api/image.bs"
import "pkg:/source/constants/extrasLayout.bs"
import "pkg:/source/constants/focusableOverview.bs"
import "pkg:/source/constants/itemTypeOrder.bs"
import "pkg:/source/deepLink.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/buttonOverflow.bs"
import "pkg:/source/utils/config.bs"
import "pkg:/source/utils/dialogs.bs"
import "pkg:/source/utils/infoRowFit.bs"
import "pkg:/source/utils/itemImageUrl.bs"
import "pkg:/source/utils/liveTv.bs"
import "pkg:/source/utils/logoLayout.bs"
import "pkg:/source/utils/mediaDisplayTitle.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/nodeHelpers.bs"
import "pkg:/source/utils/people.bs"
import "pkg:/source/utils/placeholderImage.bs"
import "pkg:/source/utils/remoteSubtitles.bs"
import "pkg:/source/utils/screenReadiness.bs"
import "pkg:/source/utils/serverCapabilities.bs"
import "pkg:/source/utils/streamSelection.bs"
import "pkg:/source/utils/tasks.bs"
import "pkg:/source/utils/textureManager.bs"
import "pkg:/source/utils/trackClusterFocus.bs"
import "pkg:/source/utils/trackDropdownItems.bs"
import "pkg:/source/utils/translate.bs"
import "pkg:/source/utils/versionLabels.bs"
import "pkg:/source/utils/versionPick.bs"
import "pkg:/source/utils/versionResume.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
' Right edge the logo is anchored to (1920 * 0.95). Named because the button row's
' overflow cap is derived from it (see itemDetailsButtonCap()). If the logo moves and
' this stays behind, the cap silently stops describing the space it is meant to protect.
const LOGO_RIGHT_ANCHOR_X = 1824
' 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
' Narrowest a cut info-row item may be before it is dropped instead. Measured
' 2026-09-22 on a Roku Ultra: the row font averages 15.6px per character, so 160px
' is about ten characters, the shortest cut that still reads as a word or name
' rather than a stub.
const INFO_ROW_MIN_CUT_WIDTH = 160
' The gap kept between the logo and any text beside or above it. Matches the
' block's own spacing between its title rows and the description.
const LOGO_TEXT_CLEARANCE = 12
' 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
  ' from clear to 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")
  ' Holds the buttons that did not fit, while "More" stands in for them.
  ' See applyOverflow() for the contract the row mutators bracket with.
  m.buttonOverflow = m.top.findNode("buttonOverflow")
  ' 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

  ' Subtitle management panel (#750). Self-contained: it owns its own API calls
  ' and focus; we only feed it inputs and react to its two outbound fields.
  m.subtitlePanel = m.top.findNode("subtitlePanel")
  m.subtitlePanel.observeField("requestClose", "onSubtitlePanelRequestClose")
  m.subtitlePanel.observeField("subtitlesChanged", "onSubtitlesChanged")
  m.subtitlePanelOpen = false

  ' Item logo - positioned at fixed Y position
  m.itemLogo = m.top.findNode("itemLogo")
  m.itemLogo.observeField("loadStatus", "onLogoLoadStatusChanged")
  m.textHoldTimer = m.top.findNode("textHoldTimer")
  m.textHoldTimer.observeField("fire", "onTextHoldTimeout")
  ' Where the logo is drawn, once known: { x, y, width, height }, or invalid for no
  ' logo. Until m.logoBoxKnown, the text is fitted to the widest logo possible.
  m.logoBox = invalid
  m.logoBoxKnown = false
  m.logoSizeSeq = 0
  ' What the text was last fitted to, and the width each line got.
  m.textLayoutBox = invalid
  m.textLayoutKnown = false
  m.textLineWidths = {}
  m.textHeld = false

  ' 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, so no target math change is 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 + detailsGroup + creditsGroup (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

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

  ' Monotonic counter giving each trailer-availability fetch a unique requestId, so a
  ' rapid content refresh can never collide two in-flight requests on the same key.
  m.trailerCheckSeq = 0
  ' Same, for the subtitle-provider check that gates Manage Subtitles.
  m.subtitleProviderCheckSeq = 0
  ' Per-version resume state for the loaded content (see resetVersionResume), and the
  ' counter that keys its fetches and tells a stale reply from a current one.
  m.versionResume = invalid
  m.versionResumeSeq = 0
  ' Whether the viewer picked the version from the Video menu (see onVideoDropdownSelection).
  ' Set here as well as in resetVersionResume, which content without video never reaches.
  m.versionUserOverridden = false
  ' Whether the readiness ledger holds an open Resume fill (see trackVersionResumeFill).
  m.isVersionResumeFillOpen = false

  m.isFirstRun = true
  m.isDeepLinkLaunch = false
  m.lastDeepLinkRouteId = ""
  m.deepLinkAction = "open"

  m.detailsGroup = m.top.findNode("detailsGroup")
  m.creditsGroup = m.top.findNode("creditsGroup")
  m.videoTitle = m.top.findNode("videoTitle")
  ' The block's resting y. The extras slide moves the block but not the logo, so text
  ' is always fitted against where it rests.
  m.itemDetailsRestY = int(m.itemDetails.translation[1])

  ' ItemDetails owns its own in-screen navigation AND its own action buttons. Extras-grid
  ' selections (cast / related items) navigate to their route; goTo* buttons navigate to
  ' the linked item. Every action button is handled by onDetailNavButton through
  ' dispatchDetailButtonAction: play/resume (single + playAll), trailer / shuffle /
  ' instant-mix / slideshow / view-photo, watched / favorite / delete / record /
  ' watchChannel / refresh. Task-based launches dispatch through the QueueManager
  ' launch path.
  m.buttonGrp.observeField("buttonSelected", "onDetailNavButton")
  m.extrasGrid.observeField("selectedItem", "onExtrasItemSelected")

  ' A non-navigable extras selection (for example a Chapter, a next-up episode) sets
  ' m.top.quickPlayNode; forward it to the QueueManager launcher. onDestroy already
  ' unobserves quickPlayNode.
  m.top.observeField("quickPlayNode", "onQuickPlayLaunch")

  ' Observe personHasMedia to show the Shuffle button as soon as the Person media chain finds media
  m.extrasGrid.observeField("personHasMedia", "onPersonHasMediaChanged")

  ' Observe contentReady to close the extras fill in the readiness ledger. The extras
  ' chain is the largest and most universal of this screen's async fills, so `settled`
  ' is almost always waiting on this one. Also where a Person's "no media" answer lands.
  m.extrasGrid.observeField("contentReady", "onExtrasContentReady")

  ' 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

  ' Interface observers are registered HERE rather than declared as XML onChange
  ' attributes: this component releases node references in onDestroy and these
  ' handlers dereference them, so the registrations have to be detachable. An XML
  ' onChange does not appear to be removable by unobserveField (see the wiring
  ' rule in components/CLAUDE.md).
  m.top.observeField("itemId", "onItemIdChanged")
  m.top.observeField("itemContent", "onItemContentChanged")
  m.top.observeField("trailerAvailable", "onTrailerAvailableChanged")
  m.top.observeField("refreshItemDetailsData", "onRefreshItemDetailsData")
  m.top.observeField("refreshExtrasData", "onRefreshExtrasData")
  m.top.observeField("nextUpEpisode", "onNextUpEpisodeChanged")
  m.top.observeField("refreshResumeData", "onRefreshResumeData")
end sub

' onScreenShown: Callback when view is presented on screen
sub onScreenShown()
  ' On first mount, self-load from the route params (/details/:type/:id). The router creates
  ' this view, so there is no factory to set itemId: we read it from m.top.route here. The
  ' details route sets no allowReuse, so every detail-to-detail navigation creates a fresh
  ' ItemDetails. The one in-place path is a SAME-path navigation, which the router reuses
  ' regardless of any flag and delivers through onRouteUpdate, not here.
  if not isValidAndNotEmpty(m.top.itemId)
    loadFromRoute(m.top.route)
  end if

  ' Fire the validate + auto-launch exactly once per deep-link NAVIGATION, whether this view
  ' is freshly mounted or resumed after being uncovered (back from /play: onScreenShown runs
  ' but the fresh-mount loadFromRoute above does not). Keyed off the router's per-navigation
  ' route.id, so an ordinary back-from-player resume, which keeps its route.id, does not re-fire.
  checkDeepLinkLaunch()

  ' 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

' The router reuses this view (no remount) when navigating to the SAME details path it's
' already showing, for example a cast of the item you're already on, after a player above it was
' popped. m.top.route is the new route by now, so re-run the deep-link check (its route.id
' dedup makes it a one-shot per navigation). Non-deep-link same-path updates are no-ops.
function onRouteUpdate(_event = {} as object) as dynamic
  checkDeepLinkLaunch()
  return promises.resolve(invalid)
end function

' Set itemType + itemId from a route's params. Setting itemId fires onItemIdChanged (the
' async loader). itemType is set first since the loader reads it.
sub loadFromRoute(route as object)
  if not isValid(route) or not isValid(route.routeParams) then return
  params = route.routeParams
  if not isValidAndNotEmpty(params.id) then return

  ' Open the readiness ledger HERE rather than in onItemIdChanged, because the two
  ' load paths diverge below and only this point precedes both: a deep-link route
  ' adopts an already-fetched node into itemContent (which paints immediately) and
  ' onItemIdChanged then skips the fetch entirely, so a ledger opened there would miss
  ' the deep-link load's paint completely.
  '
  ' Only reached on a FRESH mount (onScreenShown calls this only when itemId is empty),
  ' which is the definition of one load of this screen. A later in-place refresh does
  ' not re-open the ledger; see `pending`'s post-settle rule in screenReadiness.bs.
  screenLoad.begin("itemDetails")

  if isValid(params.type)
    m.top.itemType = params.type
  end if

  ' The deep-link resolver already fetched the FULL metaDataDetails node to validate the id and
  ' hands it forward as a complete context. Adopt it as itemContent (which renders the springboard
  ' via onItemContentChanged) BEFORE setting itemId, so onItemIdChanged sees the content already
  ' loaded and skips the redundant fetch. Only an explicitly-complete context is trusted: in-app
  ' nav may pass a lighter list node, which must still go through the fetch.
  ctx = route.context
  if isValid(ctx) and isValid(ctx.itemIsComplete) and ctx.itemIsComplete and isValid(ctx.item) and isValidAndNotEmpty(ctx.item.id) and ctx.item.id = params.id
    m.top.itemContent = ctx.item
  end if

  m.top.itemId = params.id
end sub

' See the call site in onScreenShown. A deep-link route carries ?deeplink=<action>
' (open | play | shuffle | trailer | instantmix). We handle it once per distinct navigation
' (route.id), so a repeat cast of the item already showing still fires (the router reuses the
' active view on a same-path navigation and calls onRouteUpdate, which routes back through here
' with the new route.id), while a back-from-player resume (same route.id) does not re-fire.
' The action (the sender's explicit intent, never inferred from the resolved item) is
' dispatched in dispatchDeepLinkAction. Fresh/loading -> flag for onDetailsLoaded (which also
' handles an invalid id -> error + Home); already-loaded resume -> dispatch once the router idles.
sub checkDeepLinkLaunch()
  route = m.top.route
  if not isValid(route) then return
  if not (isValid(route.queryParams) and isValidAndNotEmpty(route.queryParams.deeplink)) then return
  if route.id = m.lastDeepLinkRouteId then return
  m.lastDeepLinkRouteId = route.id
  m.deepLinkAction = route.queryParams.deeplink

  if isValid(m.top.itemContent) and isValidAndNotEmpty(m.top.itemContent.id) and m.top.itemContent.id = m.top.itemId
    deferDeepLinkLaunchUntilIdle()
  else
    m.isDeepLinkLaunch = true
  end if
end sub

' Already-loaded view (an uncovered resume, or a same-path re-cast through onRouteUpdate): the
' content is there, but the hook runs MID-navigation, and the router rejects a navigateTo issued
' while a navigation is in progress (Router.brs _navigateTo).
' So wait for the navigation to settle (routerState -> NavigationEnd) before launching, otherwise
' startPlayFromDetail's navigateTo("/play") is swallowed (toast fires, but no player). Fresh mounts
' don't need this: they launch from onDetailsLoaded, which is already async / router-idle.
sub deferDeepLinkLaunchUntilIdle()
  router = invalid
  if isValid(m.top.route) then router = m.top.route.router
  if not isValid(router)
    dispatchDeepLinkAction()
    return
  end if
  m.deepLinkRouterNode = router
  router.unobserveField("routerState")
  router.observeField("routerState", "onDeepLinkRouterIdle")
end sub

sub onDeepLinkRouterIdle()
  if not isValid(m.deepLinkRouterNode) then return
  state = m.deepLinkRouterNode.routerState
  if not isValid(state) or state.type <> "NavigationEnd" then return
  m.deepLinkRouterNode.unobserveField("routerState")
  m.deepLinkRouterNode = invalid
  dispatchDeepLinkAction()
end sub

' Extras grid (cast / related / next-up) selection: navigate to its route, passing
' the rich node as context. Non-navigable items fall through to playback (quickPlayNode).
sub onExtrasItemSelected(msg)
  item = msg.getData()
  if not isValid(item) then return

  route = routeForItem(item)
  if isValid(route)
    sgrouter.navigateTo(route, { context: { item: item } })
  else
    m.top.quickPlayNode = item
  end if
end sub

' Forward a non-navigable extras selection to the playback launcher.
sub onQuickPlayLaunch(msg)
  node = msg.getData()
  if isValid(node) then m.global.queueManager.callFunc("launchItem", node)
end sub

' Single handler for every button on this detail's button group.
sub onDetailNavButton(msg)
  buttons = msg.getRoSGNode()
  btn = buttons.getChild(msg.getData())
  if not isValid(btn) then return

  ' "More" is a row affordance rather than an item action: it opens the overflow
  ' menu, whose rows come back through dispatchDetailButtonAction below. Handled
  ' here so the dispatch function stays purely item actions.
  if btn.id = "moreButton"
    openMoreMenu()
    return
  end if

  dispatchDetailButtonAction(btn.id)
end sub

' Run the action for a button ID. Reached two ways (a press on a visible button,
' and a row picked out of the "More" menu), so the overflow menu adds no second
' copy of this mapping.
'
' Detail-to-detail nav (goToSeries / goToAlbum / goToArtist / goToChannel) navigates the router;
' playback buttons launch through the QueueManager / the play route; the rest run their
' render-thread action directly.
sub dispatchDetailButtonAction(buttonId as string)
  if not isValid(m.top.itemContent) then return
  item = m.top.itemContent

  if buttonId = "goToSeriesButton"
    if isValidAndNotEmpty(item.seriesId)
      sgrouter.navigateTo({ name: "details", params: { type: "Series", id: item.seriesId } })
    end if
  else if buttonId = "goToAlbumButton"
    if isValidAndNotEmpty(item.albumId)
      sgrouter.navigateTo({ name: "details", params: { type: "MusicAlbum", id: item.albumId } })
    end if
  else if buttonId = "goToArtistButton"
    artistId = resolveGoToArtistId(item)
    if isValidAndNotEmpty(artistId)
      sgrouter.navigateTo({ name: "details", params: { type: "MusicArtist", id: artistId } })
    end if
  else if buttonId = "goToChannelButton"
    if isValidAndNotEmpty(item.channelId)
      sgrouter.navigateTo({ name: "details", params: { type: "TvChannel", id: item.channelId } })
    end if
  else if buttonId = "playButton"
    ' Route playback from the view itself. Play always starts
    ' from the beginning (Resume handles resume). Multi-item playAll types
    ' (Series/Season/BoxSet/MusicArtist/MusicAlbum/Playlist) expand into a queue via a
    ' QuickPlayTask owned by the QueueManager (which then routes to the play view); single
    ' items build their queue inline.
    if isPlayAllType(item.type)
      m.global.queueManager.callFunc("launchQuickPlayAction", { action: "playAll" + item.type, id: item.id, seriesId: item.seriesId })
    else
      startPlayFromDetail(0)
    end if
  else if buttonId = "resumeButton"
    ' Series resume = the next-up episode (a single item). Other resumable types resume
    ' the item itself from its saved position. (Non-Series playAll types have no resume
    ' button, so they're intentionally unhandled here.)
    if item.type = "Series"
      resumeSeriesNextUp()
    else if not isPlayAllType(item.type)
      ' The selected version's position, which on a per-version server may not be the
      ' item's own. A press while that is still loading does nothing.
      resumeState = resolveResumeState()
      if resumeState.isLoading then return
      startPlayFromDetail(resumeState.ticks)
    end if
  else if buttonId = "trailerButton"
    ' Play this item's trailer(s) via the QueueManager task path. Use itemContent.id (the
    ' item), not the screen node id.
    m.global.queueManager.callFunc("launchQuickPlayAction", { action: "loadTrailers", id: item.id })
  else if buttonId = "instantMixButton"
    ' Build an instant mix queue from this item.
    if isValidAndNotEmpty(item.id)
      m.global.queueManager.callFunc("launchQuickPlayAction", { action: "instantMix", id: item.id })
    end if
  else if buttonId = "shuffleButton"
    onShuffleButtonPressed(item)
  else if buttonId = "viewPhotoButton"
    ' Open a single Photo in the viewer. launchItem routes the "photo" type to
    ' quickplay.photo; PhotoDetails stops the spinner on mount.
    m.global.queueManager.callFunc("launchItem", item)
  else if buttonId = "slideshowButton"
    ' Sequential PhotoAlbum slideshow.
    if isValidAndNotEmpty(item.id)
      startLoadingSpinner()
      m.global.queueManager.callFunc("launchPhotoAlbum", false, item.id)
    end if
  else if buttonId = "favoriteButton"
    ' The favorite toggle is a render-thread method already on this screen: call it directly.
    toggleFavorite()
  else if buttonId = "watchedButton"
    onWatchedButtonPressed(item)
  else if buttonId = "deleteButton"
    onDeleteButtonPressed(item)
  else if buttonId = "manageSubtitlesButton"
    openSubtitlePanel(item)
  else if buttonId = "recordButton"
    onRecordButtonPressed(item)
  else if buttonId = "watchChannelButton"
    onWatchChannelButtonPressed(item)
  else if buttonId = "refreshButton"
    ' Refresh this screen's data.
    startLoadingSpinner()
    m.top.refreshExtrasData = not m.top.refreshExtrasData
    m.top.refreshItemDetailsData = not m.top.refreshItemDetailsData
  end if
end sub

' Watched button. Movie/Episode toggle immediately; a Series confirms first (marking all
' episodes can affect hundreds). Uses the standard showConfirmDialog helper: the result
' arrives on the dialog node's own `result` field (no shared SceneManager state). On
' confirm we call toggleWatched(), which re-reads the button state to pick mark vs unmark.
sub onWatchedButtonPressed(item as object)
  if not (isValid(item) and isValidAndNotEmpty(item.id)) then return

  if item.type = "Series"
    watchedButton = m.top.findNode("watchedButton")
    ' Read current state from the button: writing itemContent triggers a full rebuild.
    isCurrentlyWatched = isValid(watchedButton) and watchedButton.isButtonSelected
    if isCurrentlyWatched
      confirmMsg = translate(translationKeys.MessageMarkAllEpisodesInThisSeries)
      confirmBtn = translate(translationKeys.ButtonMarkUnwatched)
    else
      confirmMsg = translate(translationKeys.MessageMarkAllEpisodesInThisSeries2)
      confirmBtn = translate(translationKeys.ButtonMarkWatched)
    end if
    m.watchedConfirmDialog = showConfirmDialog(translate(translationKeys.LabelWatched), confirmMsg, "onWatchedConfirmResponse", confirmBtn, translate(translationKeys.ButtonCancel))
  else
    toggleWatched()
  end if
end sub

' Series mark-all-watched confirmation result.
sub onWatchedConfirmResponse()
  dialog = m.watchedConfirmDialog
  m.watchedConfirmDialog = invalid
  if isValid(dialog) and isValid(dialog.result) and dialog.result.confirmed
    toggleWatched()
  end if
end sub

' ── Subtitle management panel (#750) ─────────────────────────────────────────
'
' The panel shares the extras pane's slide gesture: it rises to
' extrasLayout.PANEL_OPEN_Y while itemDetails lifts its metadata block above it,
' so the title / year / director rows stay visible as grounding. The two panels
' are mutually exclusive (extras opens on DOWN-from-buttons, this on a button
' press), and neither may be open while the other is.

' openSubtitlePanel: Hand the panel everything it needs and raise it.
'
' The panel targets the SELECTED VERSION, not the item. Jellyfin builds each
' MediaSource from its own item (source Id = that version's item id), and the
' subtitle endpoints act on the item in the URL: delete resolves the stream index
' on it and removes that stream's file, download saves beside its video. So the
' id, the path and the stream list must all come from the same source: an index
' from one version sent to another version's id can delete that version's media
' file. A single-version item is unaffected: its only source's Id is the item id.
'
' Everything comes from data we already hold, so opening costs no round-trip.
' canDelete is resolved HERE rather than inside the panel so the panel stays free
' of policy knowledge (delete is admin-only on every server version).
sub openSubtitlePanel(item as object)
  if not (isValid(item) and isValidAndNotEmpty(item.id)) then return
  if m.subtitlePanelOpen then return

  ' All three from ONE place. Falling back per-field would recreate the mismatch.
  source = selectedMediaSource()
  if isValid(source) and isValidAndNotEmpty(source.Id)
    m.subtitlePanel.itemId = source.Id
    m.subtitlePanel.filePath = source.Path ?? ""
    m.subtitlePanel.existingStreams = source.MediaStreams ?? []
  else
    m.subtitlePanel.itemId = item.id
    m.subtitlePanel.filePath = item.path
    m.subtitlePanel.existingStreams = []
  end if
  m.subtitlePanel.canDelete = remoteSubtitles.canDeleteSubtitles(m.global.user.policy)

  m.subtitlePanelOpen = true

  ' Same measure-then-slide order the extras pane uses: the target depends on
  ' the settled text layout, so it must be computed immediately before the run.
  updateItemDetailsAnimationTarget()
  m.itemDescription.opacity = 0
  m.trackCluster.opacity = 0
  m.itemDetailsSliderInterp.reverse = false
  m.itemDetailsSlider.control = "start"

  m.subtitlePanel.visible = true
  m.subtitlePanel.isOpen = true
end sub

' closeSubtitlePanel: Lower the panel and give the buttons back the remote.
sub closeSubtitlePanel()
  if not m.subtitlePanelOpen then return
  m.subtitlePanelOpen = false

  m.subtitlePanel.isOpen = false

  m.itemDescription.opacity = 1
  m.trackCluster.opacity = 1
  m.itemDetailsSliderInterp.reverse = true
  m.itemDetailsSlider.control = "start"

  m.top.lastFocus = m.buttonGrp
  m.buttonGrp.setFocus(true)
end sub

sub onSubtitlePanelRequestClose()
  closeSubtitlePanel()
end sub

' onSubtitlesChanged: The server's subtitle set for this item changed.
'
' This is not cosmetic. An added subtitle is inserted at stream index 0 and
' renumbers every other stream, so selectedAudioStreamIndex /
' selectedSubtitleStreamIndex (which are server stream indices, and which get
' handed to playback) are now pointing at the wrong tracks. Re-fetching the
' item is what makes them right again, and it also repopulates the subtitle
' dropdown so the newly added track is selectable the moment the panel closes.
sub onSubtitlesChanged()
  m.subtitleUserOverridden = false
  m.top.refreshItemDetailsData = not m.top.refreshItemDetailsData
end sub

' selectedMediaSource: The currently selected MediaSource AA, or invalid. Uses the
' same resolution as the track dropdowns (findSelectedSource), so the panel and
' the dropdowns can never disagree about which version is in play.
function selectedMediaSource() as dynamic
  item = m.top.itemContent
  if not isValid(item) then return invalid
  if not isValid(item.mediaSourcesData) then return invalid
  if not isValidAndNotEmpty(item.mediaSourcesData.mediaSources) then return invalid

  return findSelectedSource(item.mediaSourcesData.mediaSources)
end function

' Delete button. Confirm, then delete and leave the now-gone routed detail via the router.
' Standard showConfirmDialog helper (see onWatchedButtonPressed).
sub onDeleteButtonPressed(item as object)
  if not (isValid(item) and isValidAndNotEmpty(item.id)) then return
  m.pendingDeleteItemId = item.id
  m.deleteConfirmDialog = showConfirmDialog(translate(translationKeys.ButtonDelete), translate(translationKeys.ErrorAreYouSureYouWantTo), "onDeleteConfirmResponse", translate(translationKeys.ButtonDelete), translate(translationKeys.ButtonCancel))
end sub

sub onDeleteConfirmResponse()
  dialog = m.deleteConfirmDialog
  m.deleteConfirmDialog = invalid
  deleteId = m.pendingDeleteItemId
  m.pendingDeleteItemId = invalid
  if not isValid(dialog) or not isValid(dialog.result) or not isValidAndNotEmpty(deleteId) then return
  if dialog.result.confirmed
    SubmitSideEffect(GetApi().BuildDeleteItemRequest(deleteId))
    ' The item is gone server-side, but the library that contained it is suspended beneath
    ' us with stale content. Bump the scene's content-freshness
    ' token so that library (and any other kept-alive grid) re-fetches on its next resume;
    ' otherwise the deleted item lingers as a selectable ghost until logout.
    scene = m.top.getScene()
    if isValid(scene) then scene.contentVersion = scene.contentVersion + 1
    ' Leave the deleted detail; the router resumes the view beneath (the prior screen).
    sgrouter.goBack()
  end if
end sub

' Record / cancel-record a Live TV program. Owns the RecordProgramTask on this render thread.
sub onRecordButtonPressed(item as object)
  if not isValid(item) then return
  recordButton = m.top.findNode("recordButton")
  if isValid(recordButton) and recordButton.isLoading then return ' already in-flight

  programToRecord = invalid
  if item.type = "TvChannel" and isValid(item.currentProgram)
    programToRecord = item.currentProgram
  else if item.type = "Program"
    programToRecord = item
  end if
  if not isValid(programToRecord) then return

  if isValid(recordButton) then recordButton.isLoading = true
  m.pendingRecordButton = recordButton
  m.pendingRecordIsCancel = isValidAndNotEmpty(programToRecord.timerId)
  m.recordTask = CreateObject("roSGNode", "RecordProgramTask")
  m.recordTask.programDetails = programToRecord
  m.recordTask.observeField("result", "onRecordResult")
  launchTask(m.recordTask)
end sub

sub onRecordResult()
  res = m.recordTask.result
  m.recordTask.unobserveField("result")
  m.recordTask.control = "STOP"
  m.recordTask = invalid

  ' Toggle button label and icon color to reflect the new state; a failure leaves them as they were
  if isValid(res) and res.ok
    if isValid(m.pendingRecordButton)
      if m.pendingRecordIsCancel
        m.pendingRecordButton.text = translate(translationKeys.ButtonRecord)
        m.pendingRecordButton.iconBackground = m.global.constants.colorTextPrimary
        m.pendingRecordButton.iconFocusBackground = m.global.constants.colorTextPrimary
      else
        m.pendingRecordButton.text = translate(translationKeys.LabelCancelRecording)
        m.pendingRecordButton.iconBackground = m.global.constants.colorError
        m.pendingRecordButton.iconFocusBackground = m.global.constants.colorError
      end if
    end if
  end if
  toast = recordResultToast(res)
  displayToast(toast.text, toast.type)

  if isValid(m.pendingRecordButton) then m.pendingRecordButton.isLoading = false
  m.pendingRecordButton = invalid
  m.pendingRecordIsCancel = invalid
end sub

' Watch a Live TV channel (TvChannel detail, or the channel a Program airs on): single-item
' launch via the play route.
sub onWatchChannelButtonPressed(item as object)
  if not isValid(item) then return
  queueItem = nodeHelpers.createQueueItem(item)
  if not isValid(queueItem) then return

  routeType = m.top.itemType
  routeId = item.id
  ' Program: play the channel the program is on.
  if item.type = "Program" and isValidAndNotEmpty(item.channelId)
    queueItem.id = item.channelId
    queueItem.type = "TvChannel"
    routeType = "TvChannel"
    routeId = item.channelId
  end if
  launchQueueItemToPlay(queueItem, routeType, routeId)
end sub

' Shuffle button. Routes by item type so new types can be added. PhotoAlbum launches a random
' slideshow; everything else resolves a randomized item set via GetShuffleItemsTask on the
' QueueManager.
sub onShuffleButtonPressed(item as object)
  if not (isValid(item) and isValidAndNotEmpty(item.id)) then return
  if item.type = "PhotoAlbum"
    ' A slideshow is not a play session: the plain spinner (QueueManager.launchPhotoAlbum).
    startLoadingSpinner()
    m.global.queueManager.callFunc("launchPhotoAlbum", true, item.id)
  else
    m.global.queueManager.callFunc("launchShuffle", { type: item.type, id: item.id, seriesId: item.seriesId })
  end if
end sub

' Series Resume plays the next-up episode (a single item) from its saved position, via the
' play route.
sub resumeSeriesNextUp()
  nextEp = m.top.nextUpEpisode
  if not (isValid(nextEp) and isValidAndNotEmpty(nextEp.id)) then return
  queueItem = nodeHelpers.createQueueItem(nextEp)
  if not isValid(queueItem) then return
  nodeHelpers.setResumeStart(queueItem, nextEp.playbackPositionTicks)
  launchQueueItemToPlay(queueItem, "Episode", nextEp.id)
end sub

' True for item types whose Play builds a multi-item queue via an API call (handled by
' QuickPlayTask, not the single-item path below).
function isPlayAllType(itemType as dynamic) as boolean
  if not isValidAndNotEmpty(itemType) then return false
  return itemType = "Series" or itemType = "Season" or itemType = "BoxSet" or itemType = "MusicArtist" or itemType = "MusicAlbum" or itemType = "Playlist"
end function

' Build the single-item play queue from this detail's own state and navigate to the play
' route. The router is idle (the user pressed a button on this settled view), so navigateTo
' won't hit the in-progress guard, and the PlayerHostView reads this queue on mount.
sub startPlayFromDetail(startingPoint as longinteger)
  item = m.top.itemContent
  if not isValid(item) or not isValidAndNotEmpty(item.id) then return

  queueItem = nodeHelpers.createQueueItem(item)
  if not isValid(queueItem) then return

  audioStreamIdx = 0
  if isValid(m.top.selectedAudioStreamIndex) then audioStreamIdx = m.top.selectedAudioStreamIndex
  queueItem.selectedAudioStreamIndex = audioStreamIdx
  if isValid(m.top.selectedSubtitleStreamIndex)
    queueItem.selectedSubtitleStreamIndex = m.top.selectedSubtitleStreamIndex
  end if
  ' selectedVideoStreamId is the user-chosen MediaSource: land it in mediaSourceId
  ' (not id), which LoadVideoContentTask reads.
  if isValidAndNotEmpty(m.top.selectedVideoStreamId)
    queueItem.mediaSourceId = m.top.selectedVideoStreamId
  end if
  ' Exact: this screen already resolved where the selected version starts (its own position, a
  ' carry, or 0 for Play), and the loader must not second-guess it.
  nodeHelpers.setExactStart(queueItem, startingPoint)

  ' A version the viewer picked from the Video menu carries to the items the queue arrives at
  ' next; one the screen chose on its own does not, so they still get the best for the device.
  preference = invalid
  if m.versionUserOverridden and isValid(item.mediaSourcesData)
    preference = versionPick.preferenceFor(mediaSourceForId(item.mediaSourcesData.mediaSources, m.top.selectedVideoStreamId))
  end if

  launchQueueItemToPlay(queueItem, m.top.itemType, item.id, preference)
end sub

' Seed the queue with a single item and navigate to the play route. The router is idle (the
' user pressed a button on this settled view), so navigateTo won't hit the in-progress guard;
' PlayerHostView reads this queue on mount. :type/:id give the route a deep-link identity; the
' queue is the source of truth for what actually plays.
'
' @param {dynamic} versionPreference - an explicit version pick for the queue (versionPick), or invalid
sub launchQueueItemToPlay(queueItem as object, routeType as string, routeId as string, versionPreference = invalid as dynamic)
  m.global.queueManager.callFunc("beginPlaybackStart", "")
  m.global.queueManager.callFunc("clear")
  if isValid(versionPreference) then m.global.queueManager.callFunc("setVersionPreference", versionPreference)
  m.global.queueManager.callFunc("push", queueItem)
  sgrouter.navigateTo("/details/" + routeType + "/" + routeId + "/play")
end sub

' Resolve the artist id for goToArtist: prefer the primary album artist, fall back
' to the single artist item.
function resolveGoToArtistId(item as object) as string
  if isValid(item.albumArtists) and item.albumArtists.count() > 0
    first = item.albumArtists[0]
    if isValid(first) and isValidAndNotEmpty(first.Id) then return first.Id
  end if
  if isValid(item.artistItems) and item.artistItems.count() = 1
    first = item.artistItems[0]
    if isValid(first) and isValidAndNotEmpty(first.Id) then return first.Id
  end if
  return ""
end function

' Triggered when itemId is set (route load): kicks off async data load
sub onItemIdChanged()
  itemId = m.top.itemId
  if not isValidAndNotEmpty(itemId) then return

  ' A complete route context already populated itemContent for this exact id (loadFromRoute
  ' adopted the resolver's metaDataDetails node), so the springboard has rendered via
  ' onItemContentChanged, so skip the redundant metadata fetch. The deep-link action (if any)
  ' dispatches from checkDeepLinkLaunch's already-loaded branch, not onDetailsLoaded.
  if isValid(m.top.itemContent) and isValidAndNotEmpty(m.top.itemContent.id) and m.top.itemContent.id = itemId
    return
  end if

  m.loadDetailsTask = replaceTask(m.loadDetailsTask, "LoadItemsTask", "content", { itemsToLoad: "metaDataDetails" })

  ' Show the loading spinner during the metadata fetch (onDetailsLoaded stops it). Restores the
  ' pre-#677 behavior: the removed showScenes.CreateItemDetailsGroup factory started the spinner
  ' before pushScene; under the router the bare sgrouter.navigateTo at the call sites (Home / grid /
  ' search) doesn't, so the spinner has to start here, the one place that owns the fetch. itemId's
  ' observer (the plain m.top.observeField in init()) fires inside loadFromRoute during
  ' onScreenShown, so the spinner is up before the empty springboard renders a frame.
  startLoadingSpinner()

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

' Callback when initial item details metadata arrives from the task
sub onDetailsLoaded(event as object)
  if not isCurrentTaskEvent(event, m.loadDetailsTask) then return
  m.loadDetailsTask.unobserveField("content")
  content = m.loadDetailsTask.content
  m.loadDetailsTask.content = []

  if isValidAndNotEmpty(content) and isValid(content[0])
    m.top.itemContent = content[0]
  else
    ' Content failed to load. Deep links are pre-validated by JRScene.resolveDeepLink (an invalid
    ' id never navigates here), so this is the ordinary non-deep-link path: only stop the spinner.
    ' Clear the deep-link flag defensively in case one ever slips through; never navigate away
    ' (an invalid id must not disrupt the session).
    stopLoadingSpinner()
    m.isDeepLinkLaunch = false
    return
  end if

  stopLoadingSpinner()

  ' Id validated; now do what the route's action says (open/play/shuffle/trailer/instantmix).
  if m.isDeepLinkLaunch
    m.isDeepLinkLaunch = false
    dispatchDeepLinkAction()
  end if
end sub

' Do exactly what the route's action asked, on the validated item. The action is the SENDER'S
' explicit intent, never inferred from the resolved item:
'   open (and any unknown action)  -> stay on the springboard (this view). Grid-container
'                                     targets (library/folder/genre/studio/channel) never reach
'                                     here: JRScene.onDeepLinkResolved diverts them to their grid.
'   play                           -> QueueManager.launchItem (the DRY per-type play engine:
'                                     video resumes its bookmark, Series smart-resumes, containers
'                                     play, live watches, audio -> audio player, photo -> viewer).
'   shuffle                        -> the same path the Shuffle button uses (onShuffleButtonPressed).
'   trailer                        -> play this item's trailer(s).
'   instantmix                     -> build an instant mix from the item (mirrors the instantMixButton).
sub dispatchDeepLinkAction()
  item = m.top.itemContent
  if not isValid(item) or not isValidAndNotEmpty(item.type) then return

  if not isDeepLinkPlaybackAction(m.deepLinkAction)
    ' open / unknown -> stay on this springboard (already shown). Grid-container targets
    ' (library / folder / genre / studio / channel) never reach here: JRScene.onDeepLinkResolved
    ' diverts them straight to their /library grid instead of mounting ItemDetails.
    return
  end if

  ' Playback actions. Commit this item's backdrop BEFORE launching (so backing out of playback
  ' shows the detail's backdrop, not a Home/stale flash) and hand the player the resolved title to
  ' toast on mount. Photo / PhotoAlbum open a viewer, not a media player, so skip both.
  if item.type <> "Photo" and item.type <> "PhotoAlbum"
    device = m.global.device
    m.global.sceneManager.callFunc("setBackgroundImage", getItemBackdropUrl(item, { width: device.uiResolution[0], height: device.uiResolution[1] }), false)
    if isValidAndNotEmpty(item.name) then m.global.deepLinkOpeningTitle = item.name
  end if

  if m.deepLinkAction = "shuffle"
    onShuffleButtonPressed(item)
  else if m.deepLinkAction = "trailer"
    m.global.queueManager.callFunc("launchQuickPlayAction", { action: "loadTrailers", id: item.id })
  else if m.deepLinkAction = "instantmix"
    ' Mirrors the instantMixButton handler. The engine LCases the action and degrades
    ' gracefully on an empty result (for example a non-music target), so no type gating is needed.
    m.global.queueManager.callFunc("launchQuickPlayAction", { action: "instantMix", id: item.id })
  else
    m.global.queueManager.callFunc("launchItem", item) ' play
  end if
end sub

' Run the deep-link action on this already-loaded item in response to a runtime cast that
' landed while the user is already on its details (replayRoute.replayDeepLinkRuntime):
' no navigation, so no Home flash and no re-mount. The router is idle here, so any navigateTo the
' action triggers succeeds directly (no NavigationEnd defer needed). `action` is the new cast's
' intent (the cached view's m.deepLinkAction is from its original mount and may be stale).
sub playFromDeepLink(action = "play" as string)
  if isValidAndNotEmpty(action) then m.deepLinkAction = action
  dispatchDeepLinkAction()
end sub


' Fetches whether the item has local trailers, to add/remove the Trailer button
' (onTrailerAvailableChanged reacts to the result). Render-thread promise; replaces
' the former trailerResultNode + onTrailerCheckDone observer (Phase-3b: a render-thread
' submitApiRequest + observeField flow became fetchAsync().then(); canonical in docs/dev/promises.md).
sub checkTrailerAvailability(itemId as string)
  m.trailerCheckSeq += 1
  req = GetApi().BuildGetLocalTrailersRequest(itemId)

  ' Pass itemId through the promise context (the no-closure state channel) so a result
  ' that lands after the user navigated to a different item is dropped: content changes
  ' re-run this check, and only the current item's answer should touch trailerAvailable.
  ' The readiness fill is resolved here, in both settlement paths, and not in
  ' onTrailerAvailableChanged: `trailerAvailable` is an ordinary bool field defaulting
  ' to false, so an item with no trailer assigns false over false and the observer never
  ' fires at all. Resolving there would leave every trailer-less Movie permanently
  ' unsettled: a whole item type reported unmeasurable by an instrumentation bug rather
  ' than by anything about the app.
  promises.chain(fetchAsync(req, "trailerCheck-" + m.trailerCheckSeq.toStr()), { itemId: itemId }).then(sub(res as object, ctx as object)
    if ctx.itemId <> m.top.itemId then return ' stale: content changed under us
    screenLoad.resolve("trailer")
    if res.ok and isValid(res.json)
      m.top.trailerAvailable = res.json.Count() > 0
    else
      ' Any non-ok HTTP response (the error contract resolves these) means no trailer button.
      m.top.trailerAvailable = false
    end if
  end sub).catch(sub(err as object, ctx as object)
    ' Transport failure / timeout: treat as "no trailer", same as the old callback's else.
    if ctx.itemId <> m.top.itemId then return
    screenLoad.resolve("trailer")
    m.log.warn("trailer-availability fetch failed", err.reason)
    m.top.trailerAvailable = false
  end sub)
end sub

' canOfferManageSubtitles: Everything but the plugin: a Movie or Episode, and a
' user the server would let search. canSearchSubtitles() carries the permission
' rule and the measurements behind it.
function canOfferManageSubtitles(item as object) as boolean
  if not isValid(item) then return false
  if not remoteSubtitles.isSearchableItemType(item.type) then return false
  return remoteSubtitles.canSearchSubtitles(m.global.user.policy, getApiVersionFromGlobal())
end function

function newManageSubtitlesButton() as object
  button = CreateObject("roSGNode", "IconButton")
  button.id = "manageSubtitlesButton"
  button.icon = "pkg:/images/icons/closedCaptions_$$RES$$.png"
  button.text = translate(translationKeys.LabelManageSubtitles)
  return button
end function

' checkSubtitleProviders: Ask the server whether a subtitle provider is installed,
' and insert Manage Subtitles if so.
'
' Reached only when Home's session check has not answered yet. A failed check
' fails OPEN (the button is offered and the answer stays unknown, so the next screen
' asks again). A transport error is not evidence the plugin is missing, and an
' empty search already explains itself.
sub checkSubtitleProviders(itemId as string)
  req = GetApi().BuildGetLibraryOptionsInfoRequest()
  if not isValid(req) then return

  m.subtitleProviderCheckSeq += 1
  promises.chain(fetchAsync(req, "subtitleProviderCheck-" + m.subtitleProviderCheckSeq.toStr()), { itemId: itemId }).then(sub(res as object, ctx as object)
    if ctx.itemId <> m.top.itemId then return ' stale: content changed under us
    status = serverCapabilities.recordSubtitleProviderStatus(res)
    if serverCapabilities.shouldOfferSubtitleSearch(status) then insertManageSubtitlesButton()
  end sub).catch(sub(err as object, ctx as object)
    if ctx.itemId <> m.top.itemId then return
    m.log.warn("subtitle-provider check failed; offering Manage Subtitles", err.reason)
    insertManageSubtitlesButton()
  end sub)
end sub

' insertManageSubtitlesButton: Add the button to a row that is already built.
'
' In setupButtons() order: after Trailer, before Delete / Refresh. Inside the
' unsplit/applyOverflow bracket, which keeps focus on the button the user is on
' when this lands to its left (see captureRowFocus() in buttonOverflow.bs).
sub insertManageSubtitlesButton()
  ' findNode also sees the overflow stash, so a button moved into More counts.
  if isValid(m.top.findNode("manageSubtitlesButton")) then return
  if not canOfferManageSubtitles(m.top.itemContent) then return

  unsplitButtons()

  button = newManageSubtitlesButton()
  insertAt = getButtonIndex("deleteButton")
  if insertAt < 0 then insertAt = getButtonIndex("refreshButton")
  if insertAt >= 0
    m.buttonGrp.insertChild(button, insertAt)
  else
    m.buttonGrp.appendChild(button)
  end if

  applyOverflow()
end sub

' Toggles the favorite state of the current item. Called from dispatchDetailButtonAction on
' this component's render thread, where the fetchAsync().then() promise delivery and
' auto-abandon work.
sub toggleFavorite()
  favoriteButton = m.top.findNode("favoriteButton")
  item = m.top.itemContent
  if not (isValid(item) and isValidAndNotEmpty(item.id) and isValid(favoriteButton)) then return
  if favoriteButton.isLoading then return ' already in-flight

  ' Read the desired state from the button (writing itemContent would trigger a full rebuild).
  newState = not favoriteButton.isButtonSelected
  if newState
    req = GetApi().BuildMarkFavoriteRequest(item.id)
  else
    req = GetApi().BuildUnmarkFavoriteRequest(item.id)
  end if
  favoriteButton.isLoading = true

  ' Carry the button + intended state through the promise context (no closures in BS).
  promises.chain(fetchAsync(req, "favoriteToggle-" + item.id), { button: favoriteButton, newState: newState }).then(sub(res as object, ctx as object)
    ok = res.ok
    ' Debug error injection, compiled out in production (bs_const=debug=false).
    #if debug
      if isValid(m.global.debug) and m.global.debug.shouldForceFavoriteFail then ok = false
    #end if
    if ok
      ctx.button.isButtonSelected = ctx.newState
    else
      ' 4xx/5xx resolve under the error contract: the server rejected the change.
      onFavoriteToggleFailed(ctx.button, ctx.newState)
    end if
    ctx.button.isLoading = false
  end sub).catch(sub(err as object, ctx as object)
    ' Transport failure / timeout / pool-unavailable: the request never completed.
    m.log.warn("favorite toggle failed", err.reason)
    onFavoriteToggleFailed(ctx.button, ctx.newState)
    ctx.button.isLoading = false
  end sub)
end sub

' Reverts the favorite button to its pre-press state and surfaces an error toast.
sub onFavoriteToggleFailed(favoriteButton as object, attemptedState as boolean)
  if isValid(favoriteButton) then favoriteButton.isButtonSelected = not attemptedState
  displayToast(translate(translationKeys.ErrorFailedToUpdateFavorite), "error")
end sub

' Toggles the watched/played state of the current item. Called from onWatchedButtonPressed
' (Movie/Episode) or onWatchedConfirmResponse (a Series, after the "mark all episodes"
' confirmation), on this component's render thread like toggleFavorite().
sub toggleWatched()
  watchedButton = m.top.findNode("watchedButton")
  item = m.top.itemContent
  if not (isValid(item) and isValidAndNotEmpty(item.id) and isValid(watchedButton)) then return
  if watchedButton.isLoading then return ' already in-flight

  isSeries = (item.type = "Series")
  ' Read the desired state from the button (writing itemContent would trigger a full rebuild).
  newState = not watchedButton.isButtonSelected
  if newState
    req = GetApi().BuildMarkPlayedRequest(item.id)
  else
    req = GetApi().BuildUnmarkPlayedRequest(item.id)
  end if
  watchedButton.isLoading = true

  ' Show the resume button as loading while the toggle is in flight: always for a Series (its
  ' next-up episode is re-fetched on success), and on mark-watched for a Movie/Episode (its now
  ' stale resume button is removed on success).
  if isSeries or newState
    resumeButton = m.top.findNode("resumeButton")
    if isValid(resumeButton) then resumeButton.isLoading = true
  end if

  ' Carry the button + intended state + itemType through the promise context (no closures in BS).
  promises.chain(fetchAsync(req, "watchedToggle-" + item.id), { button: watchedButton, newState: newState, isSeries: isSeries }).then(sub(res as object, ctx as object)
    ok = res.ok
    ' Debug error injection, compiled out in production (bs_const=debug=false).
    #if debug
      if isValid(m.global.debug) and m.global.debug.shouldForceWatchedFail then ok = false
    #end if
    if ok
      ctx.button.isButtonSelected = ctx.newState
      if ctx.isSeries
        ' Series: re-fetch next-up episode (may change or disappear).
        m.top.refreshResumeData = not m.top.refreshResumeData
      else if ctx.newState
        ' Non-Series marking watched: server clears playback position, so drop the stale resume button.
        clearVersionResumePositions()
        refreshVideoDropdownLabels()
        removeResumeButtonWithFocus(m.top.findNode("resumeButton"))
      end if
    else
      ' 4xx/5xx resolve under the error contract: the server rejected the change.
      onWatchedToggleFailed(ctx.button, ctx.newState)
    end if
    ctx.button.isLoading = false
  end sub).catch(sub(err as object, ctx as object)
    ' Transport failure / timeout / pool-unavailable: the request never completed.
    m.log.warn("watched toggle failed", err.reason)
    onWatchedToggleFailed(ctx.button, ctx.newState)
    ctx.button.isLoading = false
  end sub)
end sub

' Reverts the watched button to its pre-press state, clears the resume button's loading
' state, and surfaces an error toast.
sub onWatchedToggleFailed(watchedButton as object, attemptedState as boolean)
  if isValid(watchedButton) then watchedButton.isButtonSelected = not attemptedState
  resumeButton = m.top.findNode("resumeButton")
  if isValid(resumeButton) then resumeButton.isLoading = false
  displayToast(translate(translationKeys.ErrorFailedToUpdateWatchedStatus), "error")
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 = replaceTask(m.loadSeriesResumeTask, "LoadItemsTask", "content", { itemsToLoad: "seriesResume" })
  m.loadSeriesResumeTask.itemId = item.id
  m.loadSeriesResumeTask.observeField("content", "onSeriesResumeLoaded")
  launchTask(m.loadSeriesResumeTask)
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"))
      ' Resume leads the row, so its placeholder takes focus from Play, the rule every
      ' other type follows. Any other focused button keeps focus.
      insertButtonAtFront(createResumeLoadingButton(seriesResumeLoadingText()))
    end if
  end if

  m.loadDetailsTask = replaceTask(m.loadDetailsTask, "LoadItemsTask", "content", { itemsToLoad: "metaDataDetails" })

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

' Callback when refreshed item details metadata arrives
sub onRefreshDetailsLoaded(event as object)
  if not isCurrentTaskEvent(event, m.loadDetailsTask) then return
  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 view (watched/refresh button flow).
  ' If a video player or other scene is on top, the spinner belongs to that flow, so don't touch it.
  ' getActiveView() resolves the routed detail (SceneManager.getActiveScene would be invalid
  ' for it), so the isSameNode guard still holds.
  activeScene = getActiveView()
  if isValid(activeScene) and activeScene.isSameNode(m.top)
    stopLoadingSpinner()
  end if
end sub

sub onTrailerAvailableChanged()
  ' Whole row first: this inserts RELATIVE to Delete / Refresh, which are exactly
  ' the buttons the overflow takes. See applyOverflow() for the contract.
  unsplitButtons()

  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 in setupButtons() order: before Manage Subtitles, else Delete, else
      ' Refresh. Manage Subtitles can itself arrive late, so it may already be here.
      insertAt = getButtonIndex("manageSubtitlesButton")
      if insertAt < 0 then insertAt = getButtonIndex("deleteButton")
      if insertAt < 0 then insertAt = getButtonIndex("refreshButton")
      if insertAt >= 0
        m.buttonGrp.insertChild(trailerButton, insertAt)
      else
        m.buttonGrp.appendChild(trailerButton)
      end if
    end if
  else
    trailerButton = m.top.findNode("trailerButton")
    if isValid(trailerButton)
      m.buttonGrp.removeChild(trailerButton)
    end if
  end if

  applyOverflow()
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()
  ' Whole row first: this swaps a placeholder at a looked-up index. See applyOverflow().
  unsplitButtons()

  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 = createResumeButton(resumeText, item.playbackPositionTicks, item.runTimeTicks)

      if isValid(loadingButton)
        swapResumeLoadingButton(loadingButton, resumeButton)
        if m.buttonGrp.isInFocusChain()
          focusButtonGroupChild()
        end if
      else
        ' No placeholder (refresh path): insert at position 0
        insertButtonAtFront(resumeButton)
      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
    removeResumeLoadingButton()
    removeResumeButtonWithFocus(resumeButton)
  end if

  applyOverflow()
end sub

' onExtrasContentReady: the extras chain has run to its end. Close the ledger's extras
' fill.
'
' Reads the VALUE rather than treating the notification as the signal: the field is
' reset to false at the start of every chain, so half the callbacks here are the reset
' rather than the completion. (This is why it is not declared alwaysNotify: an
' alwaysNotify field would wake this on every re-set of false as well.)
sub onExtrasContentReady()
  if not m.extrasGrid.contentReady then return
  screenLoad.resolve("extras")
  ' The run is over, so its personHasMedia is final: the only point a false can be
  ' trusted to mean "no media". A no-op for every type but Person.
  updatePersonShuffle(true)
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, update or remove the Resume button from the selected version's
' resume position (non-Series types). While that position is still loading, the Resume
' slot shows a loading button, the same placeholder Series uses for its next-up fetch.
sub manageResumeButton()
  ' Whole row first: this inserts at index 0 and shifts every later index.
  unsplitButtons()

  resumeButton = m.top.findNode("resumeButton")
  loadingButton = m.top.findNode("resumeLoadingButton")

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

    resumeState = resolveResumeState()
    trackVersionResumeFill(resumeState.isLoading)

    if resumeState.isLoading
      if isValid(resumeButton)
        resumeButton.isLoading = true
      else if not isValid(loadingButton)
        ' Resume leads the row, so its placeholder takes focus from Play as the real one would.
        insertButtonAtFront(createResumeLoadingButton(translate(translationKeys.ButtonResume)))
      end if
    else if resumeState.ticks > 0
      if not isValid(resumeButton)
        resumeButton = createResumeButton(translate(translationKeys.ButtonResume), resumeState.ticks, resumeState.runtimeTicks)

        if isValid(loadingButton)
          swapResumeLoadingButton(loadingButton, resumeButton)
        else
          insertButtonAtFront(resumeButton)
        end if
      else
        resumeButton.playbackPositionTicks = resumeState.ticks
        resumeButton.runtimeTicks = resumeState.runtimeTicks
        resumeButton.isLoading = false
      end if
    else
      removeResumeLoadingButton()
      removeResumeButtonWithFocus(resumeButton)
    end if
  else
    trackVersionResumeFill(false)
    removeResumeLoadingButton()
    removeResumeButtonWithFocus(resumeButton)
  end if

  applyOverflow()
end sub

' trackVersionResumeFill: Declare the Resume slot's loading state to the readiness ledger.
'
' A Movie's Resume button is otherwise built synchronously from the item, but on a
' per-version server it can wait on a version's position or the server's thresholds, and
' `settled` must not fire while it does. Declared on the first loading answer and resolved
' on the first final one; the flag lives on `m`, not on m.versionResume, so a content
' reload that drops that state cannot orphan the declaration. After the screen has
' settled (a version switch, a refresh) the ledger ignores both sides.
'
' @param {boolean} isLoading - whether the Resume slot is still waiting
sub trackVersionResumeFill(isLoading as boolean)
  if isLoading = m.isVersionResumeFillOpen then return
  m.isVersionResumeFillOpen = isLoading
  if isLoading
    screenLoad.pending("versionResume")
  else
    screenLoad.resolve("versionResume")
  end if
end sub

' ============================================
' RESUME SLOT: shared by the Series (next-up) and per-item Resume flows
' ============================================
'
' The two that place a button are bracketed as LEAVES (unsplitButtons() / applyOverflow()),
' like removeResumeButtonWithFocus: every index they use assumes the whole row is present,
' and both halves are idempotent, so a caller that already brackets loses nothing.

' createResumeButton: A Resume button, not yet placed in the row.
'
' @param {string} text - the label ("Resume", or "Resume S2E3" for a Series)
' @param {dynamic} positionTicks - where it resumes, for its progress bar
' @param {dynamic} runtimeTicks - the runtime that progress bar is measured against
' @return {object} the ResumeButton node
function createResumeButton(text as string, positionTicks as dynamic, runtimeTicks as dynamic) as object
  resumeButton = CreateObject("roSGNode", "ResumeButton")
  resumeButton.id = "resumeButton"
  resumeButton.icon = "pkg:/images/icons/resume_$$RES$$.png"
  resumeButton.text = text
  resumeButton.playbackPositionTicks = positionTicks
  resumeButton.runtimeTicks = runtimeTicks
  return resumeButton
end function

' createResumeLoadingButton: The loading placeholder that holds the Resume slot while its
' data loads, recorded in m.resumeLoadingButton. Not yet placed in the row.
'
' @param {string} text - the label the real button is expected to carry
' @return {object} the IconButton node
function createResumeLoadingButton(text as string) as object
  m.resumeLoadingButton = CreateObject("roSGNode", "IconButton")
  m.resumeLoadingButton.id = "resumeLoadingButton"
  m.resumeLoadingButton.icon = "pkg:/images/icons/resume_$$RES$$.png"
  m.resumeLoadingButton.text = text
  m.resumeLoadingButton.isLoading = true
  return m.resumeLoadingButton
end function

' seriesResumeLoadingText: The Series placeholder label: the episode-specific text (for example
' "Resume S2E3") from prior data when there is some, so the label holds steady through a
' reload, otherwise plain "Resume".
'
' @return {string}
function seriesResumeLoadingText() as string
  if isValid(m.top.nextUpEpisode) and isValidAndNotEmpty(m.top.nextUpEpisode.id)
    return getResumeButtonText(m.top.nextUpEpisode)
  end if
  return translate(translationKeys.ButtonResume)
end function

' swapResumeLoadingButton: Replace the loading placeholder with the real Resume button at
' the same index. The user may have moved focus while the data loaded; removing and then
' inserting at one index is a net-zero shift, so the focus index read first still names the
' same button. Real focus moves only if the placeholder itself had it.
'
' @param {object} loadingButton - the placeholder currently in the row
' @param {object} resumeButton - createResumeButton()
sub swapResumeLoadingButton(loadingButton as object, resumeButton as object)
  unsplitButtons()

  loadingIndex = getButtonIndex("resumeLoadingButton")
  wasFocused = loadingButton.isInFocusChain()
  currentFocusIndex = m.buttonGrp.buttonFocused
  m.buttonGrp.removeChild(loadingButton)
  m.resumeLoadingButton = invalid
  m.buttonGrp.insertChild(resumeButton, loadingIndex)
  m.buttonGrp.buttonFocused = currentFocusIndex
  if wasFocused
    focusButtonGroupChild()
  end if

  applyOverflow()
end sub

' insertButtonAtFront: Put a Resume-slot button at the front of the row. It takes focus
' from Play, as a Resume button leads the row; any other focused button keeps focus, with
' its index shifted past the insert.
'
' Real focus moves only when the row already owns it. An async insert must not steal
' focus from the extras row, for example after setupButtons() cleared and rebuilt the row.
'
' @param {object} button - createResumeButton() or createResumeLoadingButton()
sub insertButtonAtFront(button as object)
  unsplitButtons()

  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(button, 0)

  if isValid(currentFocusedButton) and currentFocusedButton.id = "playButton"
    m.buttonGrp.buttonFocused = 0
    ' A deliberate move: without the claim, applyOverflow() puts focus back on Play.
    claimRowFocus(m.buttonOverflow, button.id)
  else if isValid(currentFocusIndex) and currentFocusIndex >= 0
    m.buttonGrp.buttonFocused = currentFocusIndex + 1
  else
    m.buttonGrp.buttonFocused = 0
  end if
  if m.buttonGrp.isInFocusChain()
    focusButtonGroupChild()
  end if

  applyOverflow()
end sub

' ============================================
' PER-VERSION RESUME (Jellyfin 12.0+)
' ============================================
'
' From Jellyfin 12.0 each alternate version keeps its own position, so the Resume button
' describes the SELECTED version: its own position or, when the user picked a version
' that has none, the in-progress version's position, carried only while the server would
' still store it there rather than count it finished.
'
' Every CHOICE in that lives in source/utils/versionResume.bs, as pure functions over plain
' values; what is left here is the shell that feeds them. resumeStateFor() answers with the
' state AND with what it still needs (needsSourceId / needsPolicy); this file fetches that,
' caches it for the content load, and asks again. Keeping the decision out of the component
' is what makes it testable: a component spec has no way to stub the two requests, so any
' rule expressed here could only ever be checked by hand on a device.
'
' Positions of versions other than the item itself, and the server's resume thresholds, are
' fetched on demand and at most once per content load; Resume shows the loading placeholder
' until they arrive.

' resetVersionResume: Start per-version resume state for newly loaded content. Leaves
' m.versionResume invalid ("use the item's own position", as before 12.0) unless the
' server keeps positions per version and the item has alternate versions.
'
' @param {object} item - the loaded JellyfinBaseItem
' @param {dynamic} mediaSources - its MediaSources, in server order
sub resetVersionResume(item as object, mediaSources as dynamic)
  m.versionResume = invalid
  ' Newly loaded content, so no one has picked a version on it yet: the automatic choice is
  ' free to move again once the positions arrive. Set before the early returns below, or a
  ' single-version item would leave the previous item's override standing.
  m.versionUserOverridden = false
  if not isValid(item) or type(mediaSources) <> "roArray" or mediaSources.count() < 2 then return
  if not versionResume.isPerVersionServer(m.global.server.version) then return

  inProgressSourceId = ""
  inProgressIndex = versionResume.inProgressSourceIndex(item.id, item.playbackPositionTicks, mediaSources, true)
  if inProgressIndex >= 0 then inProgressSourceId = mediaSources[inProgressIndex].Id

  if inProgressSourceId = ""
    ' The item holds a position but names no source of its own: nothing here can say WHICH
    ' version that position belongs to, so leave per-version resume off entirely and let
    ' the item's own position answer, exactly as it does before 12.0. Zeroing below would
    ' instead report "no progress" and silently drop a Resume button that works today.
    ' Not reachable on any multi-version item checked on a 12.0 server (GetAllItemsForMedia
    ' Sources always leads with the queried item and GetStaticMediaSources exempts it from
    ' the permission filter), so this is a floor under an assumption, not a fix.
    if isValid(item.playbackPositionTicks) and item.playbackPositionTicks > 0 then return

    ' No version is in progress: the server leads a primary's sources with the most recently
    ' played in-progress version, so an unchanged order means there is nothing to resume:
    ' no request, no loading button. Opened AS an alternate version (search, a link), that
    ' order carries no signal about the other versions, and a position one of them holds is
    ' not offered here; Continue Watching, where in-progress versions surface, opens the
    ' version that holds it.
    positions = zeroVersionPositions(item.id, mediaSources)
  else
    ' The item's own position arrived with it; every other version's is fetched on demand.
    positions = {}
    positions[item.id] = item.playbackPositionTicks
  end if

  m.versionResumeSeq += 1
  m.versionResume = {
    generation: m.versionResumeSeq,
    itemId: item.id,
    inProgressSourceId: inProgressSourceId,
    positions: positions, ' sourceId -> ticks (0 = none, POSITION_UNREADABLE = could not read)
    pending: {}, ' sourceId -> true while its fetch is in flight
    mostRecentId: invalid, ' the version the server would resume; "" = it could not say
    isMostRecentPending: false,
    isConfigPending: false,
    hasConfigFailed: false
  }
end sub

' clearVersionResumePositions: Record that no version has a position any more. Marking any
' version watched marks every version played and clears every position on the server, so
' switching version afterwards must not bring Resume back from positions read earlier.
sub clearVersionResumePositions()
  state = m.versionResume
  item = m.top.itemContent
  if not isValid(state) or not isValid(item) then return
  mediaSources = invalid
  if isValid(item.mediaSourcesData) then mediaSources = item.mediaSourcesData.mediaSources

  ' A new generation drops replies still in flight: they describe the old state.
  m.versionResumeSeq += 1
  state.generation = m.versionResumeSeq
  state.pending = {}
  state.isConfigPending = false
  state.mostRecentId = invalid
  state.isMostRecentPending = false
  state.inProgressSourceId = ""
  state.positions = zeroVersionPositions(state.itemId, mediaSources)
end sub

' applyVersionSelection: Point the screen at a version: the field, its streams and the track
' dropdowns, the Video row included (populateTrackDropdowns rebuilds it with its labels).
'
' Shared by the viewer's pick and the automatic one (syncVersionSelectionToChoice) so the two
' cannot drift: a version carries its own audio and subtitle streams, and leaving those behind
' is what hands playback a stream index belonging to a different file.
'
' @param {string} sourceId - the version to select
' @param {object} item - m.top.itemContent
sub applyVersionSelection(sourceId as string, item as object)
  m.top.selectedVideoStreamId = sourceId

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

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

' syncVersionSelectionToChoice: Move the selected version to the one that should resume, once
' enough positions are known to say which that is.
'
' The details screen's first guess is made before any position has been read: it can only go
' on the server's source order. That order names the most recently played version, which is
' the rule's usual answer, but not when two versions sit at about the same place: there the
' better version for the device wins, and deciding that needs their actual positions. So the
' choice is re-run as each position lands, and the SAME pure function answers it here as in
' LoadVideoContentTask, which is what keeps the screen from naming one version while quick
' play, a cast or Continue Watching plays another.
'
' Requests ride the existing per-version fetch, so a position already read or in flight is not
' asked for twice, and each reply re-enters here through onVersionPositionLoaded.
sub syncVersionSelectionToChoice()
  state = m.versionResume
  if not isValid(state) then return
  if m.versionUserOverridden then return

  item = m.top.itemContent
  if not isValid(item) or not isValid(item.mediaSourcesData) then return
  mediaSources = item.mediaSourcesData.mediaSources
  if type(mediaSources) <> "roArray" or mediaSources.count() < 2 then return

  ctx = {
    itemId: item.id,
    itemPositionTicks: item.playbackPositionTicks,
    mediaSources: mediaSources,
    positions: state.positions,
    isPerVersion: true,
    mostRecentId: state.mostRecentId,
    deviceBestId: ""
  }

  answer = versionResume.chooseResumeSource(ctx)

  if answer.needsSourceIds.count() > 0
    for each sourceId in answer.needsSourceIds
      requestVersionPosition(sourceId)
    end for
    return ' each reply comes back through onVersionPositionLoaded
  end if

  if answer.needsMostRecent
    requestMostRecentVersion(item, mediaSources)
    return ' the reply comes back through onMostRecentVersionLoaded
  end if

  if answer.tiedIds.count() > 1
    ctx.deviceBestId = findBestVideoSourceAmongIds(mediaSources, answer.tiedIds)
    if not isValidAndNotEmpty(ctx.deviceBestId) then return
    answer = versionResume.chooseResumeSource(ctx)
  end if

  if not isValidAndNotEmpty(answer.sourceId) then return
  if versionResume.idsMatch(answer.sourceId, m.top.selectedVideoStreamId) then return

  m.log.info("Version selection moved to the resume choice", "sourceId", answer.sourceId)
  applyVersionSelection(answer.sourceId, item)
end sub

' refreshVideoDropdownLabels: Rebuild the Video dropdown's labels after the in-progress
' version changed, so its marker does not outlive the position it stood for.
sub refreshVideoDropdownLabels()
  item = m.top.itemContent
  if not isValid(m.videoDropdown) or not m.videoDropdown.visible then return
  if not isValid(item) or not isValid(item.mediaSourcesData) then return

  items = buildVideoDropdownItems(item.mediaSourcesData.mediaSources, inProgressVersionId())
  m.videoDropdown.items = items
  m.videoDropdown.triggerText = triggerTextForSelection(items, m.top.selectedVideoStreamId)
end sub

' zeroVersionPositions: A positions map recording that no version has a position.
'
' @param {string} itemId
' @param {dynamic} mediaSources - the item's MediaSources
' @return {object} sourceId -> 0 for the item and every source
function zeroVersionPositions(itemId as string, mediaSources as dynamic) as object
  positions = {}
  positions[itemId] = 0
  if type(mediaSources) = "roArray"
    for each source in mediaSources
      if isValid(source) and isValidAndNotEmpty(source.Id) then positions[source.Id] = 0
    end for
  end if
  return positions
end function

' resolveResumeState: Where Resume starts for the selected version, starting any fetch that
' answer still needs.
'
' @return {object} { ticks, runtimeTicks, isLoading }; ticks 0 means no Resume
function resolveResumeState() as object
  item = m.top.itemContent
  resumeState = { ticks: 0&, runtimeTicks: 0&, isLoading: false }
  if not isValid(item) then return resumeState
  resumeState.runtimeTicks = item.runTimeTicks

  state = m.versionResume
  if not isValid(state) or not versionResume.idsMatch(state.itemId, item.id)
    if item.playbackPositionTicks > 0 then resumeState.ticks = item.playbackPositionTicks
    return resumeState
  end if

  mediaSources = invalid
  if isValid(item.mediaSourcesData) then mediaSources = item.mediaSourcesData.mediaSources

  ctx = {
    itemId: item.id,
    itemPositionTicks: item.playbackPositionTicks,
    itemRuntimeTicks: item.runTimeTicks,
    mediaSources: mediaSources,
    selectedId: m.top.selectedVideoStreamId,
    positions: state.positions,
    inProgressSourceId: state.inProgressSourceId,
    policy: serverResumePolicy(),
    isPolicyUnreadable: state.hasConfigFailed
  }

  ' Ask, then start whatever the answer still needs. A request that cannot even be built
  ' records its failure on the spot, which changes the answer, so ask again rather than
  ' return a "loading" that no reply will ever finish. It can need at most three things
  ' (this version's position, the in-progress version's, the thresholds), so at most three
  ' can fail on the spot.
  answer = versionResume.resumeStateFor(ctx)
  for attempt = 1 to 3
    if answer.needsSourceId <> ""
      if requestVersionPosition(answer.needsSourceId) then exit for
    else if answer.needsPolicy
      if requestServerResumePolicy() then exit for
      ctx.isPolicyUnreadable = state.hasConfigFailed
    else
      exit for
    end if
    answer = versionResume.resumeStateFor(ctx)
  end for

  resumeState.ticks = answer.ticks
  resumeState.runtimeTicks = answer.runtimeTicks
  resumeState.isLoading = answer.isLoading
  return resumeState
end function

' requestVersionPosition: Fetch one version's own position, once per content load.
'
' @param {string} sourceId - the version (MediaSource) id
' @return {boolean} true while a reply is on its way; false when the request could not be
'                   built, in which case the position is already recorded as unreadable
function requestVersionPosition(sourceId as string) as boolean
  state = m.versionResume
  if not isValid(state) or state.pending.doesExist(sourceId) then return true

  req = GetApi().BuildGetItemUserDataRequest(sourceId)
  if not isValid(req)
    state.positions[sourceId] = versionResume.POSITION_UNREADABLE
    return false
  end if

  state.pending[sourceId] = true
  m.versionResumeSeq += 1
  promises.chain(fetchAsync(req, "versionUserData-" + m.versionResumeSeq.toStr()), { generation: state.generation, sourceId: sourceId }).then(sub(res as object, ctx as object)
    onVersionPositionLoaded(ctx, versionResume.playbackPositionFrom(res))
  end sub).catch(sub(err as object, ctx as object)
    m.log.warn("version user data fetch failed", "sourceId", ctx.sourceId, "reason", err.reason)
    onVersionPositionLoaded(ctx, versionResume.POSITION_UNREADABLE)
  end sub)
  return true
end function

' requestMostRecentVersion: Ask the server which version it would resume, once per content load.
'
' The source order answers that only from a primary; this screen can be showing an alternate
' (see versionResume.chooseResumeSource). Scoped to the item's parent and lean, as in
' LoadVideoContentTask.fetchMostRecentVersionId, so both shells ask the same question.
'
' @param {object} item - m.top.itemContent
' @param {object} mediaSources - its MediaSources
sub requestMostRecentVersion(item as object, mediaSources as object)
  state = m.versionResume
  if not isValid(state) or state.isMostRecentPending then return

  req = invalid
  if isValidAndNotEmpty(item.parentId)
    req = GetApi().BuildGetResumeItemsRequest({ parentId: item.parentId, EnableImages: false, EnableUserData: false })
  end if
  if not isValid(req)
    ' Nothing to ask: record that the server cannot say, so the choice falls back to the order.
    state.mostRecentId = ""
    syncVersionSelectionToChoice()
    return
  end if

  state.isMostRecentPending = true
  m.versionResumeSeq += 1
  promises.chain(fetchAsync(req, "versionMostRecent-" + m.versionResumeSeq.toStr()), { generation: state.generation, mediaSources: mediaSources }).then(sub(res as object, ctx as object)
    onMostRecentVersionLoaded(ctx, versionResume.mostRecentIdFrom(res, ctx.mediaSources))
  end sub).catch(sub(err as object, ctx as object)
    m.log.warn("resume query for the most recent version failed", "reason", err.reason)
    onMostRecentVersionLoaded(ctx, "")
  end sub)
end sub

' onMostRecentVersionLoaded: Record the server's answer and re-run the choice, unless the
' content has been reloaded since the request started.
sub onMostRecentVersionLoaded(ctx as object, mostRecentId as string)
  state = m.versionResume
  if not isValid(state) or state.generation <> ctx.generation then return ' stale
  state.isMostRecentPending = false
  state.mostRecentId = mostRecentId
  syncVersionSelectionToChoice()
  manageResumeButton()
end sub

' onVersionPositionLoaded: Record a fetched version position and redraw Resume, unless the
' content has been reloaded since the fetch started.
sub onVersionPositionLoaded(ctx as object, ticks as dynamic)
  state = m.versionResume
  if not isValid(state) or state.generation <> ctx.generation then return ' stale
  state.pending.delete(ctx.sourceId)
  state.positions[ctx.sourceId] = ticks
  ' Before the button, not after: a position can change WHICH version resumes, and the button
  ' then has to describe the version that actually would.
  syncVersionSelectionToChoice()
  manageResumeButton()
end sub

' serverResumePolicy: The server's resume thresholds, if they have been read.
'
' @return {dynamic} { maxResumePct, minResumeDurationSeconds }, or invalid when unknown
function serverResumePolicy() as dynamic
  policy = m.global.server.resumePolicy
  if versionResume.isResumePolicy(policy) then return policy
  return invalid
end function

' requestServerResumePolicy: Read GET /System/Configuration the first time a carry needs it.
'
' Fails CLOSED: while the thresholds are unknown wouldMarkPlayed() answers "played", so
' nothing is carried: a carried position past a threshold would mark the item played and
' erase the place being resumed from. A failed read is remembered only for this content
' load, not on the server node, so the next one asks again.
'
' @return {boolean} true while a reply is on its way; false when the read has already
'                   failed for this content load, or its request could not be built
function requestServerResumePolicy() as boolean
  state = m.versionResume
  if not isValid(state) or state.isConfigPending then return true
  if state.hasConfigFailed then return false

  req = GetApi().BuildGetServerConfigurationRequest()
  if not isValid(req)
    state.hasConfigFailed = true
    return false
  end if

  state.isConfigPending = true
  m.versionResumeSeq += 1
  promises.chain(fetchAsync(req, "serverConfiguration-" + m.versionResumeSeq.toStr()), { generation: state.generation }).then(sub(res as object, ctx as object)
    onServerResumePolicyLoaded(ctx, versionResume.resumePolicyFrom(res))
  end sub).catch(sub(err as object, ctx as object)
    m.log.warn("server configuration fetch failed", "reason", err.reason)
    onServerResumePolicyLoaded(ctx, invalid)
  end sub)
  return true
end function

' onServerResumePolicyLoaded: Store read thresholds for the session and redraw Resume.
sub onServerResumePolicyLoaded(ctx as object, policy as dynamic)
  ' Server-wide values, still true if the content changed meanwhile.
  if versionResume.isResumePolicy(policy) then m.global.server.resumePolicy = policy

  state = m.versionResume
  if not isValid(state) or state.generation <> ctx.generation then return ' stale
  state.isConfigPending = false
  if not versionResume.isResumePolicy(policy)
    m.log.warn("server resume thresholds unreadable; not carrying resume positions between versions")
    state.hasConfigFailed = true
  end if
  manageResumeButton()
end sub

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

  ' Bracketed as a LEAF: it is reached from manageResumeButton (already bracketed,
  ' harmlessly re-entering these idempotent halves) and from two watched-toggle
  ' paths that call it as their last act. See applyOverflow().
  unsplitButtons()

  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, to avoid stealing
  ' focus from the extras row or description when the button is removed asynchronously.
  if m.buttonGrp.isInFocusChain()
    focusButtonGroupChild()
  end if

  applyOverflow()
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)
  appendInfoRowItem(m.infoGroup, node, "infoDivider")
end sub

' displayDetailsNode: Add a node to the second info row, prepending a bullet divider if needed
sub displayDetailsNode(node as object)
  appendInfoRowItem(m.detailsGroup, node, "detailsDivider")
end sub

' displayCreditsNode: Add a people credit ("Directed by ...") to the credits row
sub displayCreditsNode(node as object)
  appendInfoRowItem(m.creditsGroup, node, "creditsDivider")
end sub

' The rows fitInfoRow() cuts are built only here, which is what lets it read a row as
' item, divider, item, ... by position: an item at every even index, a divider at
' every odd one. Append to those rows any other way and the fit misreads them.
sub appendInfoRowItem(group as object, node as object, dividerIdPrefix as string)
  if not isValid(node) then return
  childCount = group.getChildCount()
  if childCount > 0
    group.appendChild(createDividerNode(dividerIdPrefix + childCount.toStr()))
  end if
  group.appendChild(node)
end sub

' The x where the logo's LEFTMOST possible edge sits: the right anchor less the widest
' the logo may be drawn. Everything left of the logo stops here, for any logo, so no
' measurement of the loaded image (which arrives after the text paints) is needed.
function logoLeftmostX() as integer
  return LOGO_RIGHT_ANCHOR_X - LOGO_MAX_DISPLAY_WIDTH
end function

' The width a line of text may use when its bottom edge sits at bottomY. Until the
' logo's place is known, it is taken to be the widest logo possible reaching the top of
' the screen, so every line stops short of the logo's leftmost POSSIBLE edge, which is
' safe for any logo. The left edge is read from the live translation, like the button
' row's geometry, so moving the block moves the budget with it.
function detailsLineWidth(bottomY as integer) as integer
  logo = { x: logoLeftmostX(), y: 0 }
  if m.logoBoxKnown then logo = m.logoBox
  return textLineWidth(bottomY, int(m.itemDetails.translation[0]), logo, LOGO_TEXT_CLEARANCE, LOGO_RIGHT_ANCHOR_X)
end function

' Where each line of the text block will have its bottom edge once the block lays out,
' in screen space, at the block's resting position. Computed, not read: before the
' block's first layout its rows' boundingRect() reports nothing usable (measured
' 2026-09-22), and this has to run before the text first paints. The block is
' bottom-aligned; under the rows sit the description and the track spacer when each
' is in the layout, with the block's itemSpacings between them, and the rows stack
' with no spacing. A debug build checks this against the real layout in
' onItemDetailsRendered().
function detailsLineBottoms() as object
  spacing = int(m.itemDetails.itemSpacings[0])
  bottom = m.itemDetailsRestY
  if m.itemTracksInLayout then bottom = bottom - rowHeight(m.itemTracks) - spacing
  lines = { itemDescription: bottom }
  if m.itemDescriptionInLayout then bottom = bottom - int(m.itemDescription.boundingRect().height) - spacing
  for each row in [m.creditsGroup, m.detailsGroup, m.infoGroup, m.videoTitle]
    lines[row.id] = bottom
    bottom = bottom - rowHeight(row)
  end for
  return lines
end function

' A row's height before layout: its tallest child for a horizontal group, else its own.
function rowHeight(node as object) as integer
  if not node.isSubtype("LayoutGroup") then return int(node.boundingRect().height)
  height = 0
  for i = 0 to node.getChildCount() - 1
    childHeight = int(node.getChild(i).boundingRect().height)
    if childHeight > height then height = childHeight
  end for
  return height
end function

' Fit the title, the description and all three info rows to the space the logo leaves.
' The rows must be freshly built (fitting only ever cuts); rebuildDetailsText() does both.
sub layoutDetailsText()
  lines = detailsLineBottoms()
  ' The description first: its width sets its height, which moves every row above it.
  ' Its focus border is drawn outside the text, so the text stops that much sooner.
  descriptionWidth = detailsLineWidth(lines.itemDescription) - focusableOverview.FOCUS_OUTSET
  if m.itemDescription.width <> descriptionWidth
    m.itemDescription.width = descriptionWidth
    lines = detailsLineBottoms()
  end if

  m.textLineWidths = {}
  for each id in ["videoTitle", "infoGroup", "detailsGroup", "creditsGroup"]
    m.textLineWidths[id] = detailsLineWidth(lines[id])
  end for
  m.videoTitle.width = m.textLineWidths.videoTitle
  fitInfoRows()

  m.textLayoutKnown = m.logoBoxKnown
  m.textLayoutBox = m.logoBox
end sub

' Build the info rows again and fit them, for when what they are fitted to changes.
sub rebuildDetailsText()
  populateInfoGroup()
  ' The rebuild put the Playlist count label back to its default wording.
  if m.top.itemContent.type = "Playlist" and m.extrasLoaded then updatePlaylistItemCountLabel()
  layoutDetailsText()
end sub

' Keep the text hidden until the logo's place is known, so it paints once, already
' fitted, instead of moving when the logo arrives. Only the text is held: the buttons,
' backdrop and logo paint as usual. textHoldTimer caps the wait.
sub holdDetailsText()
  m.textHeld = true
  m.itemInfoRows.opacity = 0
  m.itemDescription.opacity = 0
  m.textHoldTimer.control = "stop"
  m.textHoldTimer.control = "start"
end sub

sub revealDetailsText()
  m.textHeld = false
  m.textHoldTimer.control = "stop"
  m.itemInfoRows.opacity = 1
  m.itemDescription.opacity = 1
end sub

' The logo's size has not arrived in time. The text is already fitted to the widest logo
' possible, so show it as it is; it is not refitted when the logo does arrive.
sub onTextHoldTimeout()
  if m.textHeld then revealDetailsText()
end sub

' The logo's place on screen is known: newBox, or invalid for no logo at all.
sub setLogoBox(newBox as object)
  m.logoBox = newBox
  m.logoBoxKnown = true
  if m.textHeld
    rebuildDetailsText()
    revealDetailsText()
  else if m.textLayoutKnown and logoBoxIntrudes(newBox, m.textLayoutBox)
    ' A different logo after the text showed (a Season swapping in its series logo)
    ' reaching further than the one the text was fitted to. Refitting is a visible
    ' change, but text over the logo is worse.
    rebuildDetailsText()
  end if
end sub

' Whether a logo at newBox reaches into space text fitted to oldBox may be using.
function logoBoxIntrudes(newBox as object, oldBox as object) as boolean
  if not isValid(newBox) then return false
  if not isValid(oldBox) then return true
  return newBox.x < oldBox.x or newBox.y < oldBox.y
end function

sub resetLogoBox()
  m.logoBox = invalid
  m.logoBoxKnown = false
end sub

' Where the logo is drawn for a bitmap of this size: right-aligned to LOGO_RIGHT_ANCHOR_X,
' its bottom 18px above the date label.
function logoBoxFor(bitmapWidth as integer, bitmapHeight as integer) as object
  limits = { minHeight: LOGO_MIN_DISPLAY_HEIGHT, maxWidth: LOGO_MAX_DISPLAY_WIDTH, maxHeight: LOGO_MAX_DISPLAY_HEIGHT }
  ' Person and MusicArtist photos are exempt from the height cap: their info rows are
  ' short and don't reach the right edge where the photo sits.
  itemType = ""
  if isValid(m.top.itemContent) then itemType = m.top.itemContent.type
  capHeight = itemType <> "Person" and itemType <> "MusicArtist"
  size = logoDisplaySize(bitmapWidth, bitmapHeight, limits, capHeight)
  if not isValid(size) then return invalid
  return {
    x: LOGO_RIGHT_ANCHOR_X - size.width,
    y: anchorDateLabel() - size.height - 18,
    width: size.width,
    height: size.height
  }
end function

' Point the logo at a server image, and ask the server for the image's size so the text
' can be fitted before the image itself arrives. A first request for a resized image can
' take seconds while the server makes it (measured 2026-09-22: 0.26 s median, 3 s worst,
' on a LAN); the size answers in tens of milliseconds.
sub setLogoImage(itemId as string, imageType as string, params as object)
  uri = ImageURL(itemId, imageType, params)
  if uri = m.itemLogo.uri then return
  m.itemLogo.uri = uri
  resetLogoBox()
  if uri = ""
    ' Nothing will load, so there is no logo to keep clear of.
    m.itemLogo.visible = false
    setLogoBox(invalid)
    return
  end if
  requestLogoSize(itemId, imageType, params.maxWidth, params.maxHeight, uri)
end sub

' Point the logo at a packaged placeholder. It loads in moments, and its size is
' learned when it does (onLogoLoadStatusChanged).
sub setLogoPlaceholder(itemType as string)
  uri = getPlaceholderImagePath(itemType)
  if uri = m.itemLogo.uri then return
  m.itemLogo.uri = uri
  resetLogoBox()
end sub

' The size the server will send the logo at, from the original size it reports, drawn
' through the same logoBoxFor() the loaded bitmap uses. No answer, or a size of 0 (the
' server has none recorded), leaves the text waiting on the image itself.
sub requestLogoSize(itemId as string, imageType as string, maxWidth as integer, maxHeight as integer, uri as string)
  req = GetApi().BuildGetItemImageInfosRequest(itemId)
  if not isValid(req) then return
  m.logoSizeSeq += 1
  ctx = { uri: uri, imageType: imageType, maxWidth: maxWidth, maxHeight: maxHeight }
  promises.chain(fetchAsync(req, "logoSize-" + m.logoSizeSeq.toStr()), ctx).then(sub(res as object, ctx as object)
    ' Stale (the logo changed), or the image beat the answer.
    if not isValid(m.itemLogo) or m.itemLogo.uri <> ctx.uri or m.logoBoxKnown then return
    if not res.ok or not isValid(res.json) then return
    for each info in res.json
      if isValid(info) and info.ImageType = ctx.imageType
        served = serverFitSize(info.Width ?? 0, info.Height ?? 0, ctx.maxWidth, ctx.maxHeight)
        if isValid(served) then setLogoBox(logoBoxFor(served.width, served.height))
        return
      end if
    end for
  end sub).catch(sub(err as object, ctx as object)
    m.log.warn("logo-size fetch failed", ctx.imageType, err.reason)
  end sub)
end sub

' Empty all three info rows before a rebuild.
sub resetInfoRows()
  m.infoGroup.removeChildrenIndex(m.infoGroup.getChildCount(), 0)
  m.detailsGroup.removeChildrenIndex(m.detailsGroup.getChildCount(), 0)
  m.creditsGroup.removeChildrenIndex(m.creditsGroup.getChildCount(), 0)
  m.endsAtNode = invalid
  m.endsAtDurationSeconds = 0
end sub

' Cut the info rows to the space left of the logo. Runs after every rebuild, before the
' rows first paint: a Label's width is correct the moment its text is set. Row 1 has
' text that changes after the rebuild ("Ends at" each minute, a Playlist's item count),
' so those writes fit it again; a second fit of an already fitted row changes nothing.
sub fitInfoRows()
  fitInfoRow(m.infoGroup)
  fitInfoRow(m.detailsGroup)
  fitInfoRow(m.creditsGroup)
end sub

' A LayoutGroup never clips, so an over-long row would run on into the logo. The item
' that crosses the budget is narrowed so the Label ellipsizes it, and what follows it
' is removed; see fitTextRow() for the rule. The row's own boundingRect() is not used:
' until the group has laid out it does not include its children's positions, so the
' widths are summed from the children instead.
'
' Only a Label can be narrowed. A rating chip (an icon plus a label) that crosses the
' budget is dropped with the rest instead.
'
' The cut is by character, not ellipsizeOnBoundary: on a Label that does not wrap, a
' single word too wide for the space renders as a bare "..." (#798).
sub fitInfoRow(group as object)
  childCount = group.getChildCount()
  if childCount = 0 then return

  spacing = 0
  if isValid(group.itemSpacings) and group.itemSpacings.count() > 0 then spacing = int(group.itemSpacings[0])
  separatorWidth = 0
  if childCount > 1 then separatorWidth = spacing + int(group.getChild(1).boundingRect().width) + spacing

  widths = []
  for i = 0 to childCount - 1 step 2
    widths.push(int(group.getChild(i).boundingRect().width))
  end for

  budget = m.textLineWidths.lookup(group.id)
  if not isValid(budget) then budget = detailsLineWidth(0)
  fit = fitTextRow(widths, separatorWidth, budget, INFO_ROW_MIN_CUT_WIDTH)
  if fit.keep = widths.count() and fit.cutWidth = 0 then return

  keep = fit.keep
  if fit.cutWidth > 0
    cutItem = group.getChild((keep - 1) * 2)
    if cutItem.isSubtype("Label")
      cutItem.width = fit.cutWidth
    else if keep > 1
      keep--
    end if
  end if

  lastKeptIndex = (keep - 1) * 2
  group.removeChildrenIndex(childCount - lastKeptIndex - 1, lastKeptIndex + 1)
end sub

' displayRatingNodes: Community + critic rating chips shared across every rateable item type's
' Row 1. Both are gated behind uiMoviesShowRatings and only render when the value is present
' (> 0), so it's safe to call for any type: types without a rating from the server
' show nothing. Values are passed explicitly (rather than reading item.* directly) so callers
' that need a parent-series fallback (Season) can resolve the value first.
sub displayRatingNodes(userSettings as object, communityRating as float, criticRating as float)
  if userSettings.uiMoviesShowRatings and communityRating > 0
    communityRatingNode = CreateObject("roSGNode", "CommunityRating")
    communityRatingNode.id = "communityRating"
    communityRatingNode.rating = communityRating
    displayInfoNode(communityRatingNode)
  end if

  if userSettings.uiMoviesShowRatings and criticRating > 0
    criticRatingNode = CreateObject("roSGNode", "CriticRating")
    criticRatingNode.id = "criticRating"
    criticRatingNode.rating = criticRating
    displayInfoNode(criticRatingNode)
  end if
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, so 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, so 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 = replaceTask(m.loadLyricsTask, "LoadItemsTask", "content", { itemsToLoad: "lyrics" })
      m.loadLyricsTask.itemId = item.id
      m.loadLyricsTask.observeField("content", "onLyricsLoaded")
      screenLoad.pending("lyrics")
      launchTask(m.loadLyricsTask)
    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()
  resetInfoRows()

  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

  ' Row 3 for every type: whatever credits the item's own People carry.
  displayPeopleCredits(item)
end sub

' populateInfoGroupMovie: Info rows for Movie, Video, Recording
' Row 1: Year · Official Rating · Community Rating · Critic Rating · Runtime · Ends At
' Row 2: Genres
' Row 3: credits (displayPeopleCredits)
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

  displayRatingNodes(userSettings, item.communityRating, item.criticRating)

  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(" / ")
    displayDetailsNode(genreNode)
  end if
end sub

' The credits row, for ANY item type: whichever credits the item's own People carry, in
' the order creditRowKinds() lists them.
'
' Driven by the item's own People rather than by its type, so a type the server starts
' crediting later needs no change here, and a type it doesn't credit shows nothing: 10.11
' sends no Creator credits at all (12.0+ adds them to Series), and a Season carries
' Director credits on every version. Nothing is looked up from a parent: a Season or
' Episode shows only its own credits.
'
' WHICH credits appear, in WHAT order, and how they read is `people.bs`'s to own, beside
' the other PersonKind tables; this owns only the nodes. Same split as castCardSpecs() and
' transformPersonCard(). `item.people` is read exactly ONCE and handed over: it is a node
' array field, so every read copies the whole array. The row is fitted to the logo by
' fitInfoRow(), which drops a later credit rather than let it reach the logo.
sub displayPeopleCredits(item as object)
  for each spec in creditRowSpecs(item.people)
    creditNode = createInfoLabel(spec.id)
    creditNode.text = spec.text
    displayCreditsNode(creditNode)
  end for
end sub

' populateInfoGroupMusicVideo: Info rows for MusicVideo
' Row 1: Year · Official Rating · Community Rating · Critic Rating · Runtime · Ends At
' Row 2: Genres · Artist(s)
' Row 3: credits (displayPeopleCredits)
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

  displayRatingNodes(userSettings, item.communityRating, item.criticRating)

  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(" / ")
    displayDetailsNode(genreNode)
  end if

  if isValid(item.artists) and item.artists.count() > 0
    artistNode = createInfoLabel("artists")
    artistNode.text = item.artists.join(", ")
    displayDetailsNode(artistNode)
  end if
end sub

' populateInfoGroupEpisode: Info rows for Episode
' Row 1: Air Date · Official Rating · Community Rating · Critic Rating · Runtime · Ends At
' Row 2: "S{n}E{n}" · Series name
' Row 3: credits (displayPeopleCredits)
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

  displayRatingNodes(userSettings, item.communityRating, item.criticRating)

  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
  if item.parentIndexNumber > 0 and item.indexNumber > 0
    episodeCodeNode = createInfoLabel("episodeCode")
    episodeCodeNode.text = "S" + item.parentIndexNumber.toStr() + "E" + item.indexNumber.toStr()
    displayDetailsNode(episodeCodeNode)
  end if

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

' populateInfoGroupSeries: Info rows for Series
' Row 1: Year Range · Official Rating · Community Rating · Critic 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

  displayRatingNodes(userSettings, item.communityRating, item.criticRating)

  ' 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(" / ")
    displayDetailsNode(genreNode)
  end if

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

' populateInfoGroupBoxSet: Info rows for BoxSet (movie collection)
' Row 1: Year · Official Rating · Community Rating · Critic 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

  displayRatingNodes(userSettings, item.communityRating, item.criticRating)

  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(" / ")
    displayDetailsNode(genreNode)
  end if

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

' populateInfoGroupSeason: Info rows for Season
' Row 1: Year · Official Rating (series) · Community Rating (series) · Critic 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, so 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

  ' Community/critic ratings also live on the Series, not the Season: same fallback as above
  communityRating = item.communityRating
  if communityRating <= 0 and isValid(seriesData)
    communityRating = seriesData.communityRating
  end if
  criticRating = item.criticRating
  if criticRating <= 0 and isValid(seriesData)
    criticRating = seriesData.criticRating
  end if
  displayRatingNodes(userSettings, communityRating, criticRating)

  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. Don't swap m.top.itemContent,
    ' as that fires onItemContentChanged() synchronously causing reentrancy and row duplication.
    ' Setting the duration first lets createEndsAtNode() do the rest, so Season shares the one
    ' formatter rather than keeping its own copy of it.
    m.endsAtDurationSeconds = int(seriesData.runTimeTicks / 10000000.0)
    createEndsAtNode()
    displayInfoNode(m.endsAtNode)
  end if

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

  if item.childCount > 0
    episodeCountNode = createInfoLabel("episodeCount")
    episodeCountNode.text = translatePlural(translationKeys.LabelEpisodeCount, item.childCount, [stri(item.childCount).trim()])
    displayDetailsNode(episodeCountNode)
  end if

  if isValid(seriesData) and seriesData.studios.count() > 0
    studioNode = createInfoLabel("studio")
    studioNode.text = seriesData.studios[0]
    displayDetailsNode(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()])
    displayDetailsNode(movieCountNode)
  end if
  if item.episodeCount > 0
    episodeCountNode = createInfoLabel("personEpisodeCount")
    episodeCountNode.text = translatePlural(translationKeys.LabelEpisodeCount, item.episodeCount, [stri(item.episodeCount).trim()])
    displayDetailsNode(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(" / ")
    displayDetailsNode(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
    displayDetailsNode(artistNode)
  end if

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

  if item.childCount > 0
    trackCountNode = createInfoLabel("trackCount")
    trackCountNode.text = translatePlural(translationKeys.LabelTrackCount, item.childCount, [stri(item.childCount).trim()])
    displayDetailsNode(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(" / ")
    displayDetailsNode(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
    displayDetailsNode(albumNameNode)
  end if

  if isValidAndNotEmpty(item.albumArtist)
    albumArtistNode = createInfoLabel("albumArtistName")
    albumArtistNode.text = item.albumArtist
    displayDetailsNode(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 (for example "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
    displayDetailsNode(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 · CriticRating · S#E# · Program Name · Runtime · BroadcastTime
' 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

    displayRatingNodes(userSettings, prog.communityRating, prog.criticRating)

    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 (for example, "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(" / ")
      displayDetailsNode(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(" / ")
        displayDetailsNode(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 · CriticRating · S#E# · Premiere/Repeat · Runtime · BroadcastTime
' 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

  displayRatingNodes(userSettings, item.communityRating, item.criticRating)

  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

  ' No "Ends at" chip here: the broadcastTime range immediately above already ends with this
  ' program's end time, so it rendered the same fact twice ("11:30 - 1:15 am • Ends at 1:15 am").

  ' Row 2: genres (or category flags)
  if item.genres.count() > 0
    genreNode = createInfoLabel("genre")
    genreNode.text = item.genres.join(" / ")
    displayDetailsNode(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(" / ")
      displayDetailsNode(catNode)
    end if
  end if
end sub

' populateInfoGroupRecording: Info rows for Recording (Live TV recorded content)
' Row 1: PremiereYear · OfficialRating · CommunityRating · CriticRating · 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

  displayRatingNodes(userSettings, item.communityRating, item.criticRating)

  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(" / ")
    displayDetailsNode(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)
    ' Don't overwrite m.top.id with the item id. The router assigns this
    ' view's node id and tracks it in its history stack; reassigning it here desyncs that
    ' history so sgRouter.goBack() can't find this (suspended) view on the way
    ' back from the player -> back wrongly bubbles to the scene's Exit dialog. Consumers that
    ' need the item id read m.top.itemId (the route param) or m.top.itemContent.id instead.

    ' The logo is a TEXTURE fill, declared at the call site rather than inside
    ' setItemLogo: that function returns from each item-type branch, and a declaration
    ' on each is one more chance to add a branch without one.
    '
    ' Gated on the uri actually CHANGING: a Poster re-loads only when its uri changes,
    ' so a fill left declared for an unchanged uri would wait on a load that never
    ' starts and leave the screen permanently unsettled. Whether it changed is knowable
    ' only AFTER the call, which is why this is declare-then-cancel rather than the
    ' obvious check-then-declare: if a warm texture cache can deliver `loadStatus`
    ' synchronously during the assignment, declaring afterwards would let the resolve
    ' outrun its own pending, stranding the screen unsettled and emitting a spurious
    ' "unbalanced" warning. Roku documents the `loadStatus` progression but not whether
    ' a same-thread observer is delivered synchronously, so this ordering is correct
    ' under both answers instead of betting on one.
    '
    ' The isValid guard mirrors setItemLogo's own first line. Without it this call site
    ' would fault on a node that function is written to tolerate: an instrumentation
    ' change that made a screen CRASH where it used to degrade.
    previousLogoUri = ""
    if isValid(m.itemLogo) then previousLogoUri = m.itemLogo.uri
    screenLoad.pending("logo", screenLoad.TEXTURE)
    setItemLogo(item)
    if not isValid(m.itemLogo) or m.itemLogo.uri = previousLogoUri
      screenLoad.cancel("logo")
    end if

    setDateAdded(item)

    setupButtons(item)

    ' Person Shuffle: the rebuild above put the placeholder back. When the media chain has
    ' already finished, nothing else will answer it, so resolve it now from the known
    ' answer. While a run is still going (Refresh starts one), that run's answer does.
    if item.type = "Person" and m.extrasGrid.contentReady
      updatePersonShuffle(true)
    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

    ' Rebuilt below for video content with alternate versions; nothing else has any.
    m.versionResume = 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

      resetVersionResume(item, mediaSources)

      ' Select the video source: the version in progress on a per-version server (resuming
      ' is exact only on the file that holds the position), otherwise the best one for the
      ' device instead of always the first.
      if m.top.selectedVideoStreamId = "" and isValid(mediaSources) and mediaSources.Count() > 0
        bestSourceIndex = -1
        if isValid(m.versionResume)
          bestSourceIndex = versionResume.sourceIndexById(mediaSources, m.versionResume.inProgressSourceId)
        end if
        if bestSourceIndex < 0 then 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)

      ' The selection above is a first guess from the server's source order alone. Start the
      ' position reads that can correct it; each reply re-enters through
      ' onVersionPositionLoaded. No-op unless a version is actually in progress.
      syncVersionSelectionToChoice()
    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()

    ' Playlist Watched button and "Tracks" label: same reason as Person Shuffle above. A reload
    ' rebuilds the buttons and the info labels, and the extras do not re-run to restore them.
    ' Here, AFTER populateInfoGroup, because the label it corrects is created there.
    if item.type = "Playlist" and m.extrasLoaded
      onPlaylistContentKindChanged()
    end if

    setFieldText("videoTitle", item.name)
    layoutDetailsText()
    if not m.logoBoxKnown then holdDetailsText()

    updateFavoriteButton()
    updateWatchedButton()

    ' Non-Series/Season/music types: Resume based on playbackPositionTicks
    ' Series: Resume based on nextUpEpisode (managed by nextUpEpisodeChanged observer)
    ' Season: No resume, as the NextUp API does not support SeasonId filtering
    ' Music types: no resume, as 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() has 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"
      ' A new node per run: a refresh can land while the previous fetch is still running,
      ' and relaunching a running node is ignored.
      m.loadSeriesResumeTask = replaceTask(m.loadSeriesResumeTask, "LoadItemsTask", "content", { itemsToLoad: "seriesResume" })
      m.loadSeriesResumeTask.itemId = item.id
      m.loadSeriesResumeTask.observeField("content", "onSeriesResumeLoaded")
      screenLoad.pending("seriesResume")
      launchTask(m.loadSeriesResumeTask)
    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"
      screenLoad.pending("trailer")
      checkTrailerAvailability(m.top.itemId)
    end if

    ' Season: kick off background fetch of parent series metadata for ratings/runtime/genres/logo
    if item.type = "Season" and isValidAndNotEmpty(item.seriesId)
      ' A new node per run: a refresh can land while the previous fetch is still running,
      ' and relaunching a running node is ignored.
      m.loadSeasonSeriesTask = replaceTask(m.loadSeasonSeriesTask, "LoadItemsTask", "content", { itemsToLoad: "metaDataDetails" })
      m.loadSeasonSeriesTask.itemId = item.seriesId
      m.loadSeasonSeriesTask.observeField("content", "onSeasonSeriesDataLoaded")
      screenLoad.pending("seasonSeries")
      launchTask(m.loadSeasonSeriesTask)
    end if

    ' Load extras rows on first content load only; explicit refresh via onRefreshExtrasData.
    if not m.extrasLoaded
      m.extrasLoaded = true
      ' The extras rows are the one fill every item type runs, and the largest: several
      ' rows from one orchestrator run. Its completion is ExtrasRowList's
      ' `contentReady`, set when the run's last row has resolved (see finishRunIfResolved).
      screenLoad.pending("extras")
      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

  ' PAINT. Everything above is the synchronous render, and every async fill this load
  ' waits on has been declared by now, which is the ledger's first rule, and the reason
  ' `settled` can never fire in a gap between two declarations.
  '
  ' The variant is the ITEM TYPE, and it is what makes a sample attributable: reaching a
  ' Season means walking THROUGH its Series, so one launch mounts this screen more than
  ' once and "the first sample in the window" is not the screen that was asked for.
  if isValid(item) and isValidAndNotEmpty(item.type)
    screenLoad.paint(item.type)
  else
    screenLoad.paint()
  end if

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

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

' The track dropdown cluster (trackCluster) is a top-level Group in ItemDetails.xml at a
' fixed translation, not part of the itemDetails LayoutGroup. Slot width and stride are
' configured declaratively there: slot width via the TrackDropdown default, stride via the
' fixed x translations of the three slots in the trackDropdowns Group.

' 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, inProgressVersionId())
  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, itemOriginalLanguage())
    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], so no runtime layout math is 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.
' Prefers triggerTitle where an item carries one: the video and subtitle slots use it
' to keep the collapsed trigger free of what only reads correctly beside the other
' entries. Audio items have no triggerTitle and fall through to title unchanged.
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 trackDropdownItems.triggerText(item)
    end if
  end for
  return trackDropdownItems.triggerText(items[0])
end function

' buildVideoDropdownItems: Convert MediaSources into dropdown items. "Video" here is the
' MediaSource, not a stream: it's the user-facing alternate-version selector. Every
' version is offered, whatever its VideoType: the version selected here (findBestVideoSource,
' the in-progress version) can be any of them, and so can the one the player offers and
' names, so a narrower list would leave the trigger naming a version that is not selected
' and label versions differently from the player. Labels come from versionLabels; the
' version in progress is marked in the open menu only (triggerTitle stays plain).
'
' @param {object} mediaSources - the item's MediaSources
' @param {dynamic} inProgressSourceId - the version the viewer is partway through, or ""
function buildVideoDropdownItems(mediaSources as object, inProgressSourceId as dynamic) as object
  return versionLabels.labelsFor(mediaSources, inProgressSourceId)
end function

' inProgressVersionId: The version to mark as in progress in the Video dropdown, or "".
' Only a per-version server (m.versionResume set) keeps a position per version.
function inProgressVersionId() as string
  if not isValid(m.versionResume) then return ""
  return m.versionResume.inProgressSourceId
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: Subtitle rows for the track cluster.
'
' 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 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.
'
' Titles come from remoteSubtitles.subtitleDropdownEntries() rather than straight
' from formatSubtitleDisplayTitle(), because language + codec + flags does not
' separate two English SRTs. Measured on a 6-stream movie (2026-09-14), this dropdown offered
' "English SUBRIP" three times, so a freshly downloaded subtitle was impossible
' to pick out from the built-in ones. The helper marks the external ones (a
' download is one) and leaves the rest plain, which is the one question worth
' answering here.
'
' Each item carries triggerTitle as well: the collapsed trigger is a narrow slot
' that already ellipsizes, and the marker is only meaningful beside the entries
' it distinguishes FROM, so the trigger shows the plain form.
function buildSubtitleDropdownItems(streams as object) as object
  entries = remoteSubtitles.subtitleDropdownEntries(streams)

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

  offLabel = translate(translationKeys.LabelOff)
  items = [{ id: "-1", title: offLabel, triggerTitle: offLabel }]
  for each entry in entries
    items.push({ id: str(entry.index).trim(), title: entry.title, triggerTitle: entry.triggerTitle })
  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 (for example, first load before selection).
function getStreamsForSelectedSource(mediaSources as object) as object
  source = findSelectedSource(mediaSources)
  if not isValid(source) then return []
  return source.MediaStreams
end function

' findSelectedSource: The MediaSource AA getStreamsForSelectedSource reads from, or
' invalid. Only a source carrying MediaStreams qualifies, including the fallback. The id is
' matched ignoring case, as playback start matches it (mediaSourceIndexForId).
function findSelectedSource(mediaSources as object) as dynamic
  if not isValid(mediaSources) then return invalid

  index = mediaSourceIndexForId(mediaSources, m.top.selectedVideoStreamId)
  if index >= 0 and isValid(mediaSources[index].MediaStreams) then return mediaSources[index]

  if isValid(mediaSources[0]) and isValid(mediaSources[0].MediaStreams)
    return mediaSources[0]
  end if
  return invalid
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
  selectedIndex = selectAudioStreamIndex(mediaStreams, localUser.settings, localUser.config, itemOriginalLanguage())

  m.top.selectedAudioStreamIndex = selectedIndex
end sub

' The shown item's OriginalLanguage (12.0+), for the "Original language" audio preference.
function itemOriginalLanguage() as string
  if not isValid(m.top.itemContent) then return ""
  return m.top.itemContent.originalLanguage ?? ""
end function

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

' endsAtDate: Local time at which playback would finish if started right now.
function endsAtDate() as object
  date = CreateObject("roDateTime")
  date.fromSeconds(date.asSeconds() + m.endsAtDurationSeconds)
  date.toLocalTime()
  return date
end function

' formatEndsAtText: The one place the "Ends at" label text is built. Both the initial build and
' the per-minute refresh below go through here. They used to format independently, which meant
' any change to the rule had to be made twice or the label would render one way on open and
' another at the first clock tick.
'
' formatDisambiguatedTime() owns the contextual am/pm rule and is shared with the playback OSD's
' own "Ends at", so one end time reads the same way on both screens.
function formatEndsAtText(date as object) as string
  return translate(translationKeys.MessageEndsAt1, [formatDisambiguatedTime(date)])
end function

' createEndsAtNode: Build an "Ends at" label and store it on m.endsAtNode for dynamic clock
' updates. Used by types with runTimeTicks-based durations.
sub createEndsAtNode()
  m.endsAtNode = createInfoLabel("ends-at")
  m.endsAtNode.text = formatEndsAtText(endsAtDate())
end sub

' onClockMinuteChanged: Dynamically update "Ends At" time when overhang clock minute changes.
sub onClockMinuteChanged()
  if not isValid(m.endsAtNode) then return
  m.endsAtNode.text = formatEndsAtText(endsAtDate())
  ' "9:59" to "10:00" widens the row it sits on.
  fitInfoRow(m.infoGroup)
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, then primaryImageTag (poster, compact size), then placeholder
' - Episode/Season/Recording: primaryImageTag, then parentLogoImageTag (series logo), then seriesPrimaryImageTag, then placeholder
' - Video/MusicVideo: primaryImageTag, then placeholder
' - MusicAlbum / Playlist / Photo / PhotoAlbum / TvChannel: primaryImageTag, then placeholder
' - Program: primaryImageTag, then channel logo, then placeholder
' - Audio: primaryImageTag, then album art, then placeholder
' - Person / MusicArtist: primaryImageTag (portrait), then 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 }
      setLogoImage(item.id, "Primary", imgParams)
    else
      setLogoPlaceholder(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.
    if isValidAndNotEmpty(item.primaryImageTag)
      imgParams = { maxHeight: 534, maxWidth: LOGO_MAX_DISPLAY_WIDTH, quality: 90, tag: item.primaryImageTag }
      setLogoImage(item.id, "Primary", imgParams)
    else
      setLogoPlaceholder(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 }
      setLogoImage(item.id, "Primary", imgParams)
    else
      setLogoPlaceholder(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 }
      setLogoImage(item.id, "Primary", imgParams)
    else
      setLogoPlaceholder(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 }
      setLogoImage(item.id, "Primary", imgParams)
    else
      setLogoPlaceholder(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 }
      setLogoImage(item.id, "Primary", imgParams)
    else
      setLogoPlaceholder(item.type)
    end if
    return
  else if item.type = "Program"
    ' Program image, then channel logo, then typed placeholder
    if isValidAndNotEmpty(item.primaryImageTag)
      imgParams = { maxHeight: 534, maxWidth: LOGO_MAX_DISPLAY_WIDTH, quality: 90, tag: item.primaryImageTag }
      setLogoImage(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 }
      setLogoImage(item.channelId, "Primary", imgParams)
    else
      setLogoPlaceholder(item.type)
    end if
    return
  else if item.type = "Audio"
    ' Own primary image, then album art fallback, then typed placeholder
    if isValidAndNotEmpty(item.primaryImageTag)
      imgParams = { maxHeight: 534, maxWidth: LOGO_MAX_DISPLAY_WIDTH, quality: 90, tag: item.primaryImageTag }
      setLogoImage(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 }
      setLogoImage(item.albumId, "Primary", imgParams)
    else
      setLogoPlaceholder(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 }
      setLogoImage(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 the Primary (poster)
    ' image instead; playCircle placeholder if none.
    if isValidAndNotEmpty(item.primaryImageTag)
      imgParams = { maxHeight: 212, maxWidth: 500, quality: 90, tag: item.primaryImageTag }
      setLogoImage(item.id, "Primary", imgParams)
    else
      setLogoPlaceholder(item.type)
    end if
    return
  end if

  if isValidAndNotEmpty(logoImageTag)
    imgParams = { maxHeight: 212, maxWidth: 500, quality: 90, tag: logoImageTag }
    setLogoImage(logoItemId, "Logo", imgParams)
  else if item.type = "Movie" or item.type = "Series" or item.type = "BoxSet"
    ' No logo: fall back to primary (poster) image, fetched 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 }
      setLogoImage(item.id, "Primary", imgParams)
    else
      setLogoPlaceholder(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 }
      setLogoImage(item.seriesId, "Primary", imgParams)
    else
      setLogoPlaceholder(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 unknown types).
    setLogoPlaceholder(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, with
  ' 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 (for example 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]
    ' Keep the text's idea of the logo in step, and refit if it moved up into the text.
    if m.logoBoxKnown and isValid(m.logoBox) and m.logoBox.y <> logoY
      setLogoBox({ x: m.logoBox.x, y: logoY, width: m.logoBox.width, height: m.logoBox.height })
    end if
  end if
end sub

' onLogoLoadStatusChanged: Position logo to the right, with its bottom a little 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

  ' Both TERMINAL statuses resolve the texture fill. "failed" is as final an answer as
  ' "ready" (the screen has stopped waiting either way), and a Poster whose image 404s
  ' would otherwise hold the whole screen unsettled forever. "loading" is skipped
  ' because it is the transition INTO the wait, not out of it.
  if m.itemLogo.loadStatus = "ready" or m.itemLogo.loadStatus = "failed"
    screenLoad.resolve("logo")
  end if

  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

    ' A box predicted from the server's size is where the text was fitted, so the logo is
    ' drawn in it (scaleToFit keeps the aspect). Otherwise it is placed from the bitmap.
    placement = m.logoBox
    if not m.logoBoxKnown or not isValid(placement) then placement = logoBoxFor(m.itemLogo.bitmapWidth, m.itemLogo.bitmapHeight)

    if isValid(placement)
      m.itemLogo.width = placement.width
      m.itemLogo.height = placement.height
      m.itemLogo.loadDisplayMode = "scaleToFit"
      m.itemLogo.translation = [placement.x, placement.y]
      m.itemLogo.visible = true
      if not m.logoBoxKnown then setLogoBox(placement)
    end if
  else if m.itemLogo.loadStatus = "failed"
    m.itemLogo.uri = ""
    m.itemLogo.visible = false
    setLogoBox(invalid)
  end if
end sub

sub onItemDetailsRendered()
  if m.itemDetails.renderTracking = "none" then return
  updateTextGradient()
  #if debug
    checkDetailsLineBottoms()
  #end if
end sub

' Debug builds: warn when detailsLineBottoms() disagrees with where the block really laid
' out its lines, since the text is fitted against the computed positions.
sub checkDetailsLineBottoms()
  if int(m.itemDetails.translation[1]) <> m.itemDetailsRestY then return
  lines = detailsLineBottoms()
  nodes = [m.videoTitle, m.infoGroup, m.detailsGroup, m.creditsGroup]
  if m.itemDescriptionInLayout then nodes.push(m.itemDescription)
  for each node in nodes
    rect = node.sceneBoundingRect()
    if rect.height > 0
      actual = int(rect.y + rect.height)
      if abs(actual - lines[node.id]) > 1
        m.log.warn("details-text line bottom mismatch", node.id, "computed", lines[node.id], "laid out", actual)
      end if
    end if
  end for
end sub

' updateItemDetailsAnimationTarget: Slide itemDetails so the bottom of itemInfoRows sits right
' 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 at the last moment 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

' ===========================================================================
' Button-row overflow
'
' The row is capped at whatever fits before the logo, and the last slot becomes
' a "More" button holding the remainder. The cap is DERIVED (see
' itemDetailsButtonCap) rather than written down, so it follows the logo when
' the logo moves.
'
' THE CONTRACT, and the reason it is two functions rather than one: the places
' in this file mutate the row after setupButtons has built it (inserting Resume
' at 0, swapping a loading placeholder for a real button, adding Trailer relative
' to Delete or Refresh), and every one computes an index or looks a button up by
' id. None of them can be allowed to see a row with its tail missing, because
' getButtonIndex("refreshButton") would answer -1 on a split row and the insert
' would land at the wrong end.
'
' So each of those functions brackets its work:
'
'     unsplitButtons()      ' the row is whole again; index math is safe
'     ... existing insert / remove / focus logic, unchanged ...
'     applyOverflow()       ' re-split against the new count
'
' Both halves are idempotent and both are no-ops while the row fits, which is
' every item type as of 2026-09-07 (the busiest reached exactly 8 of 8). That is precisely why
' the split is exercised by a spec driving synthetic rows rather than by running
' the app: a guard rail that first runs when the row already overflows is not a
' guard rail.
' ===========================================================================

' The row's geometry, read from the live nodes rather than restated (so a change
' to the group's translation or spacing is picked up instead of drifting), and
' read in one place, so the cap and the overflow menu's anchor can never be
' derived from different numbers.
'
' `translation` is taken as a SCREEN coordinate, which it is: buttonGrp is a
' direct child of this screen's root and the screen is mounted at the origin, the
' same thing updateItemDetailsAnimationTarget() already relies on when it compares
' a derived y against extrasLayout's absolute constants.
function itemDetailsRowGeometry() as object
  spacing = 0
  if isValid(m.buttonGrp.itemSpacings) and m.buttonGrp.itemSpacings.count() > 0
    spacing = int(m.buttonGrp.itemSpacings[0])
  end if

  return {
    originX: int(m.buttonGrp.translation[0]),
    rowY: int(m.buttonGrp.translation[1]),
    spacing: spacing,
    itemWidth: iconButtonLayoutWidth(ICON_BUTTON_ICON_SIZE),
    rightBound: logoLeftmostX()
  }
end function

' The cap for THIS row: it may not reach the logo's leftmost possible edge.
function itemDetailsButtonCap() as integer
  geometry = itemDetailsRowGeometry()
  return maxVisibleButtons(geometry.originX, geometry.spacing, geometry.itemWidth, geometry.rightBound)
end function

' The two halves of the contract. Both delegate to source/utils/buttonOverflow.bs
' so the OSD's row runs the same implementation rather than a second copy of it;
' what stays here is only which nodes this screen's row is made of.
'
' The closing half puts focus back on the button that had it when the opening half
' ran. A mutator that moves focus to a DIFFERENT button on purpose (a new Resume taking
' over from Play) must say so with claimRowFocus(m.buttonOverflow, id) between the two,
' or its choice is silently reverted (see ItemDetailsRowFocus.spec.bs).
sub unsplitButtons()
  closeMoreMenuBeforeRowChanges()
  restoreOverflowedButtons(m.buttonGrp, m.buttonOverflow)
end sub

' An open More menu describes a row that is about to change, so it closes with
' it. Cancelled HERE, at the top of the bracket, because at this instant nothing
' has detached yet: the More button is still in the row, so the dialog's
' returnFocusTo is still a live node and focus lands where the user left it.
' Cancelling after the re-derive would be a race against the very orphan this
' avoids.
'
' No bookkeeping needed: cancelDialog resolves as externallyCancelled, and
' onMoreMenuResult already clears m.moreMenuDialog / m.overflowMenuIds and
' declines that result before it can act.
sub closeMoreMenuBeforeRowChanges()
  if isValid(m.moreMenuDialog) then m.moreMenuDialog.callFunc("cancelDialog")
end sub

sub applyOverflow()
  applyButtonOverflow(m.buttonGrp, m.buttonOverflow, itemDetailsButtonCap(), translate(translationKeys.ButtonMore), MORE_BUTTON_ICON)
end sub

' Open the overflow menu. The rows carry the same label and glyph the buttons
' were wearing, and resolve back to a button ID so the selection runs through
' onDetailNavButton's dispatch rather than a second copy of it.
sub openMoreMenu()
  entries = overflowMenuEntries(m.buttonOverflow)
  if entries.ids.count() = 0 then return

  m.overflowMenuIds = entries.ids
  ' selectedIndex -1: these are actions, so no row is "current", which is also
  ' what frees the row gutter for the icons.
  geometry = itemDetailsRowGeometry()
  anchor = moreButtonAnchor(geometry.originX, geometry.spacing, geometry.itemWidth, geometry.rowY, itemDetailsButtonCap())

  m.moreMenuDialog = showOverflowMenu(translate(translationKeys.ButtonMore), entries.labels, "onMoreMenuResult", anchor, entries.icons)
  m.moreMenuDialog.returnFocusTo = m.buttonGrp.findNode("moreButton")
end sub

sub onMoreMenuResult()
  dialog = m.moreMenuDialog
  ids = m.overflowMenuIds
  m.moreMenuDialog = invalid
  m.overflowMenuIds = invalid
  if not isValid(dialog) then return

  ' Declining a code-made close is overflowMenuSelection's job. This handler acts
  ' (navigates, deletes, starts playback), and that rule is shared with the OSD's
  ' identical handler rather than copied into both.
  buttonId = overflowMenuSelection(dialog.result, ids)
  if buttonId <> "" then dispatchDetailButtonAction(buttonId)
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 (for example 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

  ' Bring any overflowed buttons back into the row FIRST, so the clear below empties
  ' the stash too. Skipping this would carry the previous item's spilled buttons into
  ' the next one's menu.
  unsplitButtons()

  ' 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 (for example "Resume S2E3") from prior data if available
  ' so the user sees a consistent label through the loading transition.
  if itemType = "Series"
    m.buttonGrp.appendChild(createResumeLoadingButton(seriesResumeLoadingText()))
  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, since 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. updatePersonShuffle() 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)
  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, which 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

  ' Manage Subtitles button (#750): Movie and Episode only, when the server
  ' would actually let this user search.
  '
  ' The permission clause is more than the enableSubtitleManagement flag: that
  ' defaults to false for every account including administrators, yet admins are
  ' allowed through by the server anyway, and the flag does not exist at all on
  ' 10.7-10.8 where the endpoints are open to any user. canSearchSubtitles()
  ' carries the whole rule (and the measurements behind it).
  '
  ' It also needs a subtitle provider PLUGIN on the server, or every search comes
  ' back empty. When that is not known yet the button is left out here and inserted
  ' when checkSubtitleProviders() answers (see serverCapabilities.bs).
  if canOfferManageSubtitles(item)
    providerStatus = m.global.server.subtitleProviderStatus
    if providerStatus = serverCapabilities.STATUS_AVAILABLE
      m.buttonGrp.appendChild(newManageSubtitlesButton())
    else if providerStatus = serverCapabilities.STATUS_UNKNOWN
      checkSubtitleProviders(item.id)
    end if
  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

  ' Debug-only: pad the row so the overflow can be reached on a device. Inert at
  ' its default and compiled out of production. See docs/dev/debug-flags.md.
  #if debug
    appendDebugSpareButtons(m.buttonGrp)
  #end if

  ' Last: split the finished row if it no longer fits. Runs AFTER the focus restore
  ' above so it inherits a settled buttonFocused and can re-point it at More when the
  ' button the user was on has moved into the menu.
  applyOverflow()
end sub

' onPersonHasMediaChanged: Show Shuffle as soon as the person media chain finds media.
'
' Only a TRUE is acted on here. Every run starts by resetting the field to false, so a
' false on its own is not an answer: acting on it removed Shuffle on every Refresh. The
' run's "no media" answer is read when the run finishes (onExtrasContentReady).
' alwaysNotify, so the run's final true is delivered even when an early true already was.
sub onPersonHasMediaChanged()
  updatePersonShuffle(false)
end sub

' updatePersonShuffle: Bring the Person row's Shuffle slot in line with the media chain.
' Shows Shuffle when the chain has found media, replacing the setupButtons() placeholder,
' or inserting before Favorite when there is none. Removes the placeholder, or a Shuffle
' whose media has gone, only on the chain's FINAL answer.
'
' @param {boolean} isFinalAnswer - true when the chain has finished, so a false
'   personHasMedia means "no media"; false when that false may be a run's reset
sub updatePersonShuffle(isFinalAnswer as boolean)
  if not isValid(m.top.itemContent) or m.top.itemContent.type <> "Person" then return

  ' Whole row first: this swaps a placeholder and inserts relative to Favorite,
  ' both at looked-up indices. See applyOverflow().
  unsplitButtons()

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

  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)

        ' Focus stays on the button the user is on. Unlike Resume taking over from Play,
        ' Shuffle replaces no action here, so moving onto it would turn a pending OK into
        ' shuffled playback.
        if currentFocusIndex >= insertIndex
          m.buttonGrp.buttonFocused = currentFocusIndex + 1
        end if
      end if

      if m.buttonGrp.isInFocusChain()
        focusButtonGroupChild()
      end if
    end if
  else if isFinalAnswer
    ' The finished chain found no media: drop the placeholder, or a Shuffle from an
    ' earlier answer.
    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

  applyOverflow()
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 (for example 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

  kind = m.extrasGrid.playlistContentKind

  ' --- Watched button ---
  ' Whole row first: inserted relative to Favorite at a looked-up index. See applyOverflow().
  unsplitButtons()

  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

  applyOverflow()

  updatePlaylistItemCountLabel()
end sub

' The Playlist info row's count: "Tracks" only when the playlist is positively confirmed
' all-audio. "unknown" (load failure) and "mixed" both fall through to "Items", the safe
' neutral default.
sub updatePlaylistItemCountLabel()
  childCount = m.top.itemContent.childCount
  kind = m.extrasGrid.playlistContentKind
  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
    fitInfoRow(m.infoGroup)
  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 versionResume.idsMatch(newStreamId, m.top.selectedVideoStreamId) then return

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

  ' A deliberate pick outranks the automatic choice: once the viewer has named a version, a
  ' position arriving later must not move the selection out from under them. Set only once
  ' the pick is applied, so a pick that could not be applied does not freeze the choice.
  m.versionUserOverridden = true

  applyVersionSelection(newStreamId, item)

  ' On a per-version server each version resumes from its own position.
  if isValid(m.versionResume) then manageResumeButton()
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, itemOriginalLanguage())
  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

' 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, then audio, then 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(event as object)
  ' An event from a replaced run is not an answer at all, so it returns before the resolve.
  if not isCurrentTaskEvent(event, m.loadSeasonSeriesTask) then return
  ' Otherwise at the TOP, ahead of the "no data" early returns below. A fill is resolved when the
  ' answer ARRIVES, not when the answer turns out to be useful. A Season whose parent
  ' series returned nothing has still finished waiting, and resolving after the returns
  ' would leave it pending forever and the screen permanently unsettled.
  screenLoad.resolve("seasonSeries")
  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

  ' The same rebuild as a fresh load, so the rows are reset and fitted exactly as they
  ' are there (for a Season it dispatches to populateInfoGroupSeason).
  rebuildDetailsText()

  ' Update logo using series data if the season didn't carry parentLogoItemId
  if isValid(m.itemLogo) and not m.itemLogo.visible
    ' A SECOND logo load for the same screen, so it gets its own declared fill by the
    ' same declare-then-cancel rule as the first. Without one, its `loadStatus` resolves
    ' a "logo" fill that is already closed and trips the unbalanced warning on every
    ' Season whose own poster never rendered. Where the ledger has already settled both
    ' calls are no-ops, which is the ordinary case on a refresh.
    previousLogoUri = m.itemLogo.uri
    screenLoad.pending("logo", screenLoad.TEXTURE)
    setItemLogo(m.seasonSeriesData)
    if m.itemLogo.uri = previousLogoUri then screenLoad.cancel("logo")
  end if
end sub

' onSeriesResumeLoaded: Set nextUpEpisode from seriesResume task result to show/hide the Resume button
sub onSeriesResumeLoaded(event as object)
  ' Before the resolve: an event from a replaced run is not the answer the fill is
  ' waiting for, and resolving on it would close the live run's fill early.
  if not isCurrentTaskEvent(event, m.loadSeriesResumeTask) then return
  screenLoad.resolve("seriesResume")
  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

  ' Whole row before the index lookup below. See applyOverflow().
  unsplitButtons()

  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

  applyOverflow()
end sub

' onLyricsLoaded: Show lyrics in the description area once the async fetch completes (Audio only)
sub onLyricsLoaded(event as object)
  ' An event from a replaced run is not an answer at all, so it returns before the resolve.
  if not isCurrentTaskEvent(event, m.loadLyricsTask) then return
  ' Otherwise at the TOP, for the same reason as onSeasonSeriesDataLoaded: the returns below are
  ' "no usable lyrics", which is still an answer that ended the wait.
  screenLoad.resolve("lyrics")
  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

function onKeyEvent(key as string, press as boolean) as boolean
  if not press then return false

  ' 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. Blocked while the subtitle panel occupies the
  ' same screen region: the two panels are mutually exclusive.
  if key = "down" and m.buttonGrp.isInFocusChain() and m.subtitlePanelOpen then return true

  ' DOWN: buttonGrp -> extrasGrid
  if key = "down" and m.buttonGrp.isInFocusChain()
    ' Guard: don't open the extras pane when there are no rows, or 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 via JRScreen.beforeViewClose when sgRouter permanently closes this view.
sub onDestroy()
  ' Detach the interface observers registered in init(). Must precede every
  ' reference release below, because these handlers dereference them.
  m.top.unobserveField("itemId")
  m.top.unobserveField("itemContent")
  m.top.unobserveField("trailerAvailable")
  m.top.unobserveField("refreshItemDetailsData")
  m.top.unobserveField("refreshExtrasData")
  m.top.unobserveField("nextUpEpisode")
  m.top.unobserveField("refreshResumeData")

  m.log.verbose("onDestroy")

  ' Tear down ExtrasRowList FIRST: it 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")

  ' Subtitle panel owns its own in-flight pool requests; SceneGraph does not run
  ' onDestroy on a plain child, so drive it explicitly.
  if isValid(m.subtitlePanel)
    m.subtitlePanel.unobserveField("requestClose")
    m.subtitlePanel.unobserveField("subtitlesChanged")
    m.subtitlePanel.callFunc("teardown")
  end if

  ' Unobserve the handler observers init() registered, before anything below is
  ' released, so a last delivery can't reach a handler whose references are gone.
  m.top.unobserveField("quickPlayNode")
  m.extrasGrid.unobserveField("selectedItem")
  m.buttonGrp.unobserveField("buttonSelected")
  m.buttonGrp.unobserveField("renderTracking")

  ' Drop the router routerState observer if a deferred resume-launch is pending.
  if isValid(m.deepLinkRouterNode)
    m.deepLinkRouterNode.unobserveField("routerState")
    m.deepLinkRouterNode = invalid
  end if

  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 child node observers
  m.itemLogo.unobserveField("loadStatus")
  m.textHoldTimer.unobserveField("fire")
  m.textHoldTimer.control = "stop"
  m.itemDetails.unobserveField("renderTracking")
  m.extrasGrid.unobserveField("personHasMedia")
  m.extrasGrid.unobserveField("contentReady")
  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.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

  ' Tear down the record task. The delete / series-watched confirmations no longer
  ' route through SceneManager.isDataReturned: they use the standard dialog helpers,
  ' which deliver results on the dialog node's own `result` field.
  if isValid(m.recordTask)
    m.recordTask.unobserveField("result")
    m.recordTask.control = "STOP"
    m.recordTask = invalid
  end if

  ' Drop any confirmation still on screen. It is a scene child, so it would
  ' otherwise outlive this view (see abandonDialog in source/utils/dialogs.bs)
  abandonDialog(m.watchedConfirmDialog)
  abandonDialog(m.deleteConfirmDialog)
  ' The More menu's result handler ACTS (navigates, deletes, starts playback), so it
  ' must be abandoned rather than cancelled: a cancel is indistinguishable from the
  ' user pressing Back and would fire an item action into a dying scope (dialogs.md).
  abandonDialog(m.moreMenuDialog)
  m.watchedConfirmDialog = invalid
  m.deleteConfirmDialog = invalid
  m.moreMenuDialog = invalid
  m.overflowMenuIds = invalid

  ' 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.buttonOverflow = invalid
  m.itemLogo = invalid
  m.textHoldTimer = invalid
  m.videoTitle = invalid
  m.dateCreatedLabel = invalid
  m.itemTextGradient = invalid
  m.itemDetails = invalid
  m.itemDetailsSlider = invalid
  m.itemDetailsSliderInterp = invalid
  m.itemInfoRows = invalid
  m.detailsGroup = invalid
  m.creditsGroup = 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