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