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/itemTypeOrder.bs"
import "pkg:/source/deepLink.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/config.bs"
import "pkg:/source/utils/itemImageUrl.bs"
import "pkg:/source/utils/mediaDisplayTitle.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/nodeHelpers.bs"
import "pkg:/source/utils/placeholderImage.bs"
import "pkg:/source/utils/streamSelection.bs"
import "pkg:/source/utils/textureManager.bs"
import "pkg:/source/utils/trackClusterFocus.bs"
import "pkg:/source/utils/translate.bs"
' Gap between bottom of itemInfoRows and top of extras pane when extras are open
const ITEM_DETAILS_EXTRAS_PADDING = 24
' Minimum display height for the logo image — images too small are scaled up (aspect ratio preserved).
const LOGO_MIN_DISPLAY_HEIGHT = 212 ' Note: LOGO_MAX_DISPLAY_WIDTH takes precedence for very wide/flat logos; this minimum may not be reached.
' Maximum display width for the logo image — prevents very wide/flat logos from overlapping buttons
const LOGO_MAX_DISPLAY_WIDTH = 500
' Maximum display height for non-Person primary images — prevents portrait posters from
' extending above the metadata rows. Person photos are exempt because their info rows
' are short and don't reach the right edge where the photo sits.
const LOGO_MAX_DISPLAY_HEIGHT = 336
' First-frame estimate for the button row's total rendered height. Used by anchorDateLabel()
' before boundingRect() has settled (it reports height=0 during initial render). Observed to
' land around 130 px for the typical 2-line-label icon button. A later renderTracking callback
' replaces this with the real measured height once layout completes.
const ESTIMATED_BUTTON_ROW_HEIGHT_PX = 130
sub init()
m.log = new log.Logger("ItemDetails")
m.extrasGrp = m.top.findNode("extrasGrp")
m.extrasGrid = m.top.findNode("extrasGrid")
' Hide the overhang's Options hint — track selection happens inline via the TrackDropdown
' cluster above the button group, not through the overhang options popup pattern.
m.top.isOptionsAvailable = false
m.infoGroup = m.top.findNode("infoGroup")
' Inline track dropdowns (Video / Audio / Subtitles). trackCluster is a top-level
' sibling of itemDetails statically positioned at [96, 713] — see ItemDetails.xml for
' the geometry rationale. itemTracks inside itemDetails is a 62px invisible spacer
' that pushes description bottom up to y=701 so the cluster sits in a 99px band
' between description and buttons, with 12px pad above and 18px pad below.
m.trackCluster = m.top.findNode("trackCluster")
m.trackTitles = m.top.findNode("trackTitles")
m.videoTrackTitle = m.top.findNode("videoTrackTitle")
m.audioTrackTitle = m.top.findNode("audioTrackTitle")
m.subtitleTrackTitle = m.top.findNode("subtitleTrackTitle")
m.trackDropdowns = m.top.findNode("trackDropdowns")
m.videoDropdown = m.top.findNode("videoDropdown")
m.audioDropdown = m.top.findNode("audioDropdown")
m.subtitleDropdown = m.top.findNode("subtitleDropdown")
m.subtitleUserOverridden = false
' Remembers the last TrackDropdown that held focus so returning to the track row via
' UP-from-buttons restores the previous slot. Reset per-content in itemContentChanged
' and when the remembered slot becomes non-interactive during repopulation.
m.lastFocusedDropdown = invalid
m.videoDropdown.observeField("focusedChild", "onTrackDropdownFocusChanged")
m.audioDropdown.observeField("focusedChild", "onTrackDropdownFocusChanged")
m.subtitleDropdown.observeField("focusedChild", "onTrackDropdownFocusChanged")
m.videoDropdown.observeField("selectedAction", "onVideoDropdownSelection")
m.audioDropdown.observeField("selectedAction", "onAudioDropdownSelection")
m.subtitleDropdown.observeField("selectedAction", "onSubtitleDropdownSelection")
m.videoDropdown.observeField("requestFocusReturn", "onDropdownRequestUp")
m.audioDropdown.observeField("requestFocusReturn", "onDropdownRequestUp")
m.subtitleDropdown.observeField("requestFocusReturn", "onDropdownRequestUp")
m.videoDropdown.observeField("requestFocusDown", "onDropdownRequestDown")
m.audioDropdown.observeField("requestFocusDown", "onDropdownRequestDown")
m.subtitleDropdown.observeField("requestFocusDown", "onDropdownRequestDown")
m.videoDropdown.observeField("requestFocusExit", "onVideoDropdownFocusExit")
m.audioDropdown.observeField("requestFocusExit", "onAudioDropdownFocusExit")
m.subtitleDropdown.observeField("requestFocusExit", "onSubtitleDropdownFocusExit")
' Vertical gradient dimmer shown while any track dropdown has its menu open. Fades
' clear → fully opaque black from y=707 to y=1080 so the menu pops against
' IconButtons / chapters peek without dimming the metadata above the trackCluster.
' See the dropdownDimmer comment in ItemDetails.xml for the full geometry rationale.
' Colors are set here (not in XML) so theme changes can swap colorBlack if it
' ever varies (today it's a fixed 0x000000 across themes).
m.dropdownDimmer = m.top.findNode("dropdownDimmer")
globalConstants = m.global.constants
m.dropdownDimmer.startColor = globalConstants.colorBlack + globalConstants.alpha0
m.dropdownDimmer.endColor = globalConstants.colorBlack + globalConstants.alpha100
' The handler ORs the three menuOpen fields; only one menu can be open at a time
' today, but the OR keeps the logic correct regardless of how openMenu/closeMenu
' evolve.
m.videoDropdown.observeField("menuOpen", "onTrackDropdownMenuOpenChanged")
m.audioDropdown.observeField("menuOpen", "onTrackDropdownMenuOpenChanged")
m.subtitleDropdown.observeField("menuOpen", "onTrackDropdownMenuOpenChanged")
m.itemDescription = m.top.findNode("itemDescription")
m.itemDescriptionInLayout = true ' itemDescription is a static XML child, so it starts in layout
m.buttonGrp = m.top.findNode("buttons")
' Button group starts hidden — no buttons until setupButtons() builds the real set.
' Focus stays on the component (scene manager sets it) until buttons are ready.
m.buttonGrp.visible = false
' Item logo - positioned at fixed Y position
m.itemLogo = m.top.findNode("itemLogo")
m.itemLogo.observeField("loadStatus", "onLogoLoadStatusChanged")
' Date added label - positioned at bottom right
m.dateCreatedLabel = m.top.findNode("dateCreatedLabel")
' Re-anchor date label (and logo, if loaded) after the button group layout settles.
' setDateAdded() runs before setupButtons(), so its boundingRect() call would see an
' empty group. This observer fires after the render thread computes the real height.
m.buttonGrp.observeField("renderTracking", "onButtonGrpRendered")
' Gradient overlay - dynamically sized to span from itemDetails to extrasSlider
m.itemTextGradient = m.top.findNode("itemTextGradient")
m.itemDetails = m.top.findNode("itemDetails")
' Observe renderTracking to update gradient after layout is calculated
m.itemDetails.observeField("renderTracking", "onItemDetailsRendered")
' 62px invisible spacer inside itemDetails. Pushes description bottom up to y=701
' so the trackCluster sibling has 12px breathing pad above and 18px below between
' description and buttons. Removed from the layout for NO_MEDIA_TRACKS item types
' (Series/Season/Person/etc.) so those items' description bottom drops back to
' y=775 (matching main's behavior). The extras slide animation in
' updateItemDetailsAnimationTarget() reads itemInfoRows.boundingRect() at runtime
' and adapts to the taller spacer automatically — no target math change needed.
m.itemTracks = m.top.findNode("itemTracks")
m.itemTracksInLayout = true
' itemDetails slide animation - synced with extras slider
m.itemDetailsSlider = m.top.findNode("itemDetailsSlider")
m.itemDetailsSliderInterp = m.top.findNode("itemDetailsSliderInterp")
m.extrasActive = false
m.extrasLoaded = false
' Inner group: title + infoGroup + directorGenreGroup (never changes during activate/deactivate)
' Used to compute the correct animation target height when extras are shown
m.itemInfoRows = m.top.findNode("itemInfoRows")
' Dynamic button refs — reset by setupButtons() on each content load
m.shuffleButton = invalid
m.shuffleLoadingButton = invalid
m.resumeLoadingButton = invalid
m.personMediaFireCount = 0
' First episode task (Series Play button)
m.loadFirstEpisodeTask = CreateObject("roSGNode", "LoadItemsTask")
m.loadFirstEpisodeTask.itemsToLoad = "seriesFirstEpisode"
' Series: fetch the resume/next-up episode in the background for the Resume button
m.loadSeriesResumeTask = CreateObject("roSGNode", "LoadItemsTask")
m.loadSeriesResumeTask.itemsToLoad = "seriesResume"
' Season: fetch parent series metadata in the background for ratings/runtime/genres/logo
m.loadSeasonSeriesTask = CreateObject("roSGNode", "LoadItemsTask")
m.loadSeasonSeriesTask.itemsToLoad = "metaDataDetails"
m.seasonSeriesData = invalid
' Cache persists for the lifetime of this component — keyed by seriesId so returning to a
' previously-visited season renders immediately rather than showing a metadata pop-in.
m.seasonSeriesCache = {}
' Audio: fetch lyrics in the background for the description area
m.loadLyricsTask = CreateObject("roSGNode", "LoadItemsTask")
m.loadLyricsTask.itemsToLoad = "lyrics"
' Self-loading: fetch item details metadata via task (triggered by onItemIdChanged)
m.loadDetailsTask = CreateObject("roSGNode", "LoadItemsTask")
m.loadDetailsTask.itemsToLoad = "metaDataDetails"
' 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
m.isFirstRun = true
m.isDeepLinkLaunch = false
m.lastDeepLinkRouteId = ""
m.deepLinkAction = "open"
m.directorGenreGroup = m.top.findNode("directorGenreGroup")
m.infoDividerCount = 0
m.directorGenreDividerCount = 0
' 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 — play/resume (single + playAll), trailer /
' shuffle / instant-mix / slideshow / view-photo, watched / favorite / delete / record /
' watchChannel / refresh — is handled in onDetailNavButton (task-based launches dispatch
' through the QueueManager launch path).
m.buttonGrp.observeField("buttonSelected", "onDetailNavButton")
m.extrasGrid.observeField("selectedItem", "onExtrasItemSelected")
' A non-navigable extras selection (e.g. 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/hide Shuffle button after Person media chain completes
m.extrasGrid.observeField("personHasMedia", "onPersonHasMediaChanged")
' Observe playlistContentKind to show/hide Watched button and set "Tracks"/"Items" label
' after the Playlist items chain completes.
m.extrasGrid.observeField("playlistContentKind", "onPlaylistContentKindChanged")
' Grid translation animation — smoothly repositions the RowList when row focus changes.
' Set early via onKeyEvent in ExtrasRowList so the animation runs in sync with the RowList's
' own floatingFocus animation. gridAnime and gridTranslationInterp are defined in ExtrasSlider.xml.
m.gridAnime = m.top.findNode("gridAnime")
m.gridTranslationInterp = m.top.findNode("gridTranslationInterp")
m.extrasGrid.observeField("targetTranslationY", "onExtrasTargetTranslationYChanged")
' Observe overhang clock for dynamic "Ends At" updates (only when clock is visible)
m.endsAtNode = invalid
m.endsAtDurationSeconds = 0
if not m.global.user.settings.uiDesignHideClock
overhang = m.top.getScene().findNode("overhang")
if isValid(overhang)
m.clock = overhang.findNode("clock")
if isValid(m.clock)
m.clock.observeField("minutes", "onClockMinuteChanged")
end if
end if
end if
end sub
' onScreenShown: Callback when view is presented on screen
sub onScreenShown()
' 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 is keepAlive with NO allowReuse, so every detail→detail navigation creates
' a fresh ItemDetails (there is no in-place reuse path).
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 from the keepAlive cache (a repeat cast of the same item
' resumes the cached ItemDetails — onScreenShown runs but the fresh-mount loadFromRoute
' above does not). Keyed off the router's per-navigation route.id.
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 — e.g. 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
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 an item whose ItemDetails is cached (keepAlive) still fires,
' while an ordinary back-from-player resume (which keeps the 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
' keepAlive resume: the content is already loaded, but onScreenShown 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.
' Detail→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 onDetailNavButton(msg)
buttons = msg.getRoSGNode()
btn = buttons.getChild(msg.getData())
if not isValid(btn) or not isValid(m.top.itemContent) then return
item = m.top.itemContent
if btn.id = "goToSeriesButton"
if isValidAndNotEmpty(item.seriesId)
sgrouter.navigateTo({ name: "details", params: { type: "Series", id: item.seriesId } })
end if
else if btn.id = "goToAlbumButton"
if isValidAndNotEmpty(item.albumId)
sgrouter.navigateTo({ name: "details", params: { type: "MusicAlbum", id: item.albumId } })
end if
else if btn.id = "goToArtistButton"
artistId = resolveGoToArtistId(item)
if isValidAndNotEmpty(artistId)
sgrouter.navigateTo({ name: "details", params: { type: "MusicArtist", id: artistId } })
end if
else if btn.id = "goToChannelButton"
if isValidAndNotEmpty(item.channelId)
sgrouter.navigateTo({ name: "details", params: { type: "TvChannel", id: item.channelId } })
end if
else if btn.id = "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)
startLoadingSpinner()
m.global.queueManager.callFunc("launchQuickPlayAction", { action: "playAll" + item.type, id: item.id, seriesId: item.seriesId })
else
startPlayFromDetail(0)
end if
else if btn.id = "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)
resumeTicks = 0&
if isValid(item.playbackPositionTicks) and item.playbackPositionTicks > 0
resumeTicks = item.playbackPositionTicks
end if
startPlayFromDetail(resumeTicks)
end if
else if btn.id = "trailerButton"
' Play this item's trailer(s) via the QueueManager task path. Use itemContent.id (the
' item), not the screen node id.
startLoadingSpinner()
m.global.queueManager.callFunc("launchQuickPlayAction", { action: "loadTrailers", id: item.id })
else if btn.id = "instantMixButton"
' Build an instant mix queue from this item.
if isValidAndNotEmpty(item.id)
startLoadingSpinner()
m.global.queueManager.callFunc("launchQuickPlayAction", { action: "instantMix", id: item.id })
end if
else if btn.id = "shuffleButton"
onShuffleButtonPressed(item)
else if btn.id = "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 btn.id = "slideshowButton"
' Sequential PhotoAlbum slideshow.
if isValidAndNotEmpty(item.id)
startLoadingSpinner()
m.global.queueManager.callFunc("launchPhotoAlbum", false, item.id)
end if
else if btn.id = "favoriteButton"
' The favorite toggle is a render-thread method already on this screen — call it directly.
toggleFavorite()
else if btn.id = "watchedButton"
onWatchedButtonPressed(item)
else if btn.id = "deleteButton"
onDeleteButtonPressed(item)
else if btn.id = "recordButton"
onRecordButtonPressed(item)
else if btn.id = "watchChannelButton"
onWatchChannelButtonPressed(item)
else if btn.id = "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). The confirmation result is read off the SceneManager via a
' SCOPED isDataReturned observer (the proven settings.bs pattern). 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.global.sceneManager.callFunc("showConfirmationDialog", translate(translationKeys.LabelWatched), [confirmMsg], [translate(translationKeys.ButtonCancel), confirmBtn])
m.global.sceneManager.observeFieldScoped("isDataReturned", "onWatchedConfirmResponse")
else
toggleWatched()
end if
end sub
' Series mark-all-watched confirmation result.
sub onWatchedConfirmResponse()
m.global.sceneManager.unobserveFieldScoped("isDataReturned")
returnData = m.global.sceneManager.returnData
if isValid(returnData) and returnData.indexSelected = 1
toggleWatched()
end if
end sub
' Delete button. Confirm, then delete and leave the now-gone routed detail via the router.
' Scoped isDataReturned observer (see onWatchedButtonPressed).
sub onDeleteButtonPressed(item as object)
if not (isValid(item) and isValidAndNotEmpty(item.id)) then return
m.pendingDeleteItemId = item.id
m.global.sceneManager.callFunc("showConfirmationDialog", translate(translationKeys.ButtonDelete), [translate(translationKeys.ErrorAreYouSureYouWantTo)], [translate(translationKeys.ButtonCancel), translate(translationKeys.ButtonDelete)])
m.global.sceneManager.observeFieldScoped("isDataReturned", "onDeleteConfirmResponse")
end sub
sub onDeleteConfirmResponse()
m.global.sceneManager.unobserveFieldScoped("isDataReturned")
returnData = m.global.sceneManager.returnData
deleteId = m.pendingDeleteItemId
m.pendingDeleteItemId = invalid
if not isValid(returnData) or not isValidAndNotEmpty(deleteId) then return
if returnData.indexSelected = 1 and returnData.buttonSelected = translate(translationKeys.ButtonDelete)
SubmitSideEffect(GetApi().BuildDeleteItemRequest(deleteId))
' The item is gone server-side, but a keepAlive library that contained it is suspended
' 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")
m.recordTask.control = "RUN"
end sub
sub onRecordResult()
res = m.recordTask.result
m.recordTask.unobserveField("result")
m.recordTask.control = "STOP"
m.recordTask = invalid
if isValid(res) and res.ok
' Toggle button label and icon color to reflect new state
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
if res.action = "record"
displayToast(translate(translationKeys.LabelRecordingScheduled), "success")
else
displayToast(translate(translationKeys.LabelRecordingCancelled), "success")
end if
else
' Failed — button label stays as-is (no state change)
if isValid(res) and res.action = "record"
displayToast(translate(translationKeys.ErrorFailedToScheduleRecording), "error")
else
displayToast(translate(translationKeys.ErrorFailedToCancelRecording), "error")
end if
end if
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
startLoadingSpinner()
if item.type = "PhotoAlbum"
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
queueItem.startingPoint = 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
queueItem.startingPoint = startingPoint
launchQueueItemToPlay(queueItem, m.top.itemType, item.id)
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.
sub launchQueueItemToPlay(queueItem as object, routeType as string, routeId as string)
startLoadingSpinner()
m.global.queueManager.callFunc("clear")
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 — 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.unobserveField("content")
m.loadDetailsTask.control = "STOP"
' 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
' onChange is a synchronous scoped observer (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")
m.loadDetailsTask.control = "RUN"
end sub
' Callback when initial item details metadata arrives from the task
sub onDetailsLoaded()
m.loadDetailsTask.unobserveField("content")
content = m.loadDetailsTask.content
m.loadDetailsTask.content = []
if isValidAndNotEmpty(content) and isValid(content[0])
m.top.itemContent = content[0]
else
' 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 — just 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 (e.g. 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 → 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.
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
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) → 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
m.log.warn("trailer-availability fetch failed", err.reason)
m.top.trailerAvailable = false
end sub)
end sub
' callFunc entry — toggles the favorite state of the current item. Invoked from main.bs's
' button router; because callFunc rendezvouses to this render-thread-owned node, the body
' (and the fetchAsync().then() promise delivery + auto-abandon) runs on this component's
' render thread, where the named-observer adapter works. This is the Phase-3c canonical
' "main thread → render thread delegation" pattern — see docs/dev/promises.md. It moves the
' favorite toggle off main.bs's god-loop (was submitApiRequest + isDone branch + handler).
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
' callFunc entry — toggles the watched/played state of the current item. Invoked from main.bs's
' button router; Movie/Episode call it directly, while Series route through the "mark all
' episodes" confirmation dialog first (main.bs keeps that dialog routing and re-invokes this on
' confirm — #550 sgRouter territory). Because callFunc rendezvouses to this render-thread-owned
' node, the body (and the fetchAsync().then() promise delivery + auto-abandon) runs on this
' component's render thread, where the named-observer adapter works. This is the deferred twin of
' toggleFavorite() — the Phase-3c "main thread → render thread delegation" pattern (see
' docs/dev/promises.md). It moves the LAST raw submitApiRequest + isDone flow off main.bs's god-loop.
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 — drop the stale resume button.
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.unobserveField("content")
m.loadSeriesResumeTask.control = "STOP"
m.loadSeriesResumeTask.itemId = item.id
m.loadSeriesResumeTask.observeField("content", "onSeriesResumeLoaded")
m.loadSeriesResumeTask.control = "RUN"
end sub
' Triggered by refreshItemDetailsData field toggle (return from playback, refresh button).
' Refreshes silently in the background — no spinner, no input blocking.
' The UI already has data from the initial load so the user can navigate freely.
sub onRefreshItemDetailsData()
itemId = m.top.itemId
if not isValidAndNotEmpty(itemId) then return
' Series: show resume loading state immediately while we wait for fresh data.
' If a resume button exists, set it to loading. If not, create a loading placeholder
' so the user sees a skeleton instead of a button popping in unexpectedly.
if isValid(m.top.itemContent) and m.top.itemContent.type = "Series"
resumeButton = m.top.findNode("resumeButton")
if isValid(resumeButton)
resumeButton.isLoading = true
else if not isValid(m.top.findNode("resumeLoadingButton"))
m.resumeLoadingButton = CreateObject("roSGNode", "IconButton")
m.resumeLoadingButton.id = "resumeLoadingButton"
m.resumeLoadingButton.icon = "pkg:/images/icons/resume_$$RES$$.png"
if isValid(m.top.nextUpEpisode) and isValidAndNotEmpty(m.top.nextUpEpisode.id)
m.resumeLoadingButton.text = getResumeButtonText(m.top.nextUpEpisode)
else
m.resumeLoadingButton.text = translate(translationKeys.ButtonResume)
end if
m.resumeLoadingButton.isLoading = true
m.buttonGrp.insertChild(m.resumeLoadingButton, 0)
' Shift focus index to account for the new button at position 0
m.buttonGrp.buttonFocused = m.buttonGrp.buttonFocused + 1
end if
end if
m.loadDetailsTask.unobserveField("content")
m.loadDetailsTask.control = "STOP"
m.loadDetailsTask.itemId = itemId
m.loadDetailsTask.shouldForceRefresh = true
m.loadDetailsTask.observeField("content", "onRefreshDetailsLoaded")
m.loadDetailsTask.control = "RUN"
end sub
' Callback when refreshed item details metadata arrives
sub onRefreshDetailsLoaded()
m.loadDetailsTask.unobserveField("content")
content = m.loadDetailsTask.content
m.loadDetailsTask.content = []
if isValidAndNotEmpty(content) and isValid(content[0])
m.top.itemContent = content[0]
if content[0].playbackPositionTicks > 0
m.global.queueManager.callFunc("setCurrentStartingPoint", content[0].playbackPositionTicks)
end if
end if
' Stop spinner only when this screen is the active view (watched/refresh button flow).
' If a video player or other scene is on top, the spinner belongs to that flow — 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()
if m.top.trailerAvailable
' Guard: setupButtons() may have already restored the trailer button on refresh
if not isValid(m.top.findNode("trailerButton"))
trailerButton = CreateObject("roSGNode", "IconButton")
trailerButton.id = "trailerButton"
trailerButton.icon = "pkg:/images/icons/playOutline_$$RES$$.png"
trailerButton.text = translate(translationKeys.LabelPlayTrailer)
' Insert before Delete (if present) to match setupButtons() ordering, else before Refresh
deleteButtonIndex = getButtonIndex("deleteButton")
if deleteButtonIndex >= 0
m.buttonGrp.insertChild(trailerButton, deleteButtonIndex)
else
refreshButtonIndex = getButtonIndex("refreshButton")
if refreshButtonIndex >= 0
m.buttonGrp.insertChild(trailerButton, refreshButtonIndex)
else
m.buttonGrp.appendChild(trailerButton)
end if
end if
end if
else
trailerButton = m.top.findNode("trailerButton")
if isValid(trailerButton)
m.buttonGrp.removeChild(trailerButton)
end if
end if
end sub
' nextUpEpisodeChanged: For Series — create/remove Resume button based on nextUpEpisode.
' On first load, replaces the LoadingButton placeholder from setupButtons().
' On refresh, inserts/updates/removes the Resume button directly.
sub onNextUpEpisodeChanged()
item = m.top.nextUpEpisode
resumeButton = m.top.findNode("resumeButton")
loadingButton = m.top.findNode("resumeLoadingButton")
if isValid(item) and isValidAndNotEmpty(item.id)
resumeText = getResumeButtonText(item)
if not isValid(resumeButton)
resumeButton = CreateObject("roSGNode", "ResumeButton")
resumeButton.id = "resumeButton"
resumeButton.icon = "pkg:/images/icons/resume_$$RES$$.png"
resumeButton.text = resumeText
resumeButton.playbackPositionTicks = item.playbackPositionTicks
resumeButton.runtimeTicks = item.runTimeTicks
if isValid(loadingButton)
' Swap the LoadingButton placeholder for the real resume button.
' The user may have moved focus to another button before async data arrived,
' so capture which button has focus before the remove/insert.
loadingIndex = getButtonIndex("resumeLoadingButton")
wasFocused = loadingButton.isInFocusChain()
currentFocusIndex = m.buttonGrp.buttonFocused
m.buttonGrp.removeChild(loadingButton)
m.resumeLoadingButton = invalid
m.buttonGrp.insertChild(resumeButton, loadingIndex)
' Remove then insert at same index is a net-zero index shift,
' so the original focusIndex is still correct.
m.buttonGrp.buttonFocused = currentFocusIndex
if wasFocused
focusButtonGroupChild()
end if
else
' No placeholder (refresh path) — insert at position 0
currentFocusIndex = m.buttonGrp.buttonFocused
currentFocusedButton = invalid
if isValid(currentFocusIndex) and currentFocusIndex >= 0 and currentFocusIndex < m.buttonGrp.getChildCount()
currentFocusedButton = m.buttonGrp.getChild(currentFocusIndex)
end if
m.buttonGrp.insertChild(resumeButton, 0)
if isValid(currentFocusedButton) and currentFocusedButton.id = "playButton"
m.buttonGrp.buttonFocused = 0
else if isValid(currentFocusIndex) and currentFocusIndex >= 0
m.buttonGrp.buttonFocused = currentFocusIndex + 1
else
m.buttonGrp.buttonFocused = 0
end if
end if
if m.buttonGrp.isInFocusChain()
focusButtonGroupChild()
end if
else
' Resume button already present — update text and tick values
resumeButton.text = resumeText
resumeButton.playbackPositionTicks = item.playbackPositionTicks
resumeButton.runtimeTicks = item.runTimeTicks
resumeButton.isLoading = false
end if
else
' No resume data — remove the LoadingButton placeholder if still present
if isValid(loadingButton)
currentFocusIndex = m.buttonGrp.buttonFocused
loadingIndex = getButtonIndex("resumeLoadingButton")
m.buttonGrp.removeChild(loadingButton)
m.resumeLoadingButton = invalid
if isValid(loadingIndex) and loadingIndex >= 0 and currentFocusIndex >= loadingIndex
focusIdx = currentFocusIndex - 1
if focusIdx < 0 then focusIdx = 0
m.buttonGrp.buttonFocused = focusIdx
end if
if m.buttonGrp.isInFocusChain()
focusButtonGroupChild()
end if
end if
removeResumeButtonWithFocus(resumeButton)
end if
end sub
' getResumeButtonText: Return "Resume S{n}E{n}" when season and episode numbers are known,
' otherwise fall back to plain "Resume".
' @param {object} item - nextUpEpisode JellyfinBaseItem node
' @return {string} Localised button label
function getResumeButtonText(item as object) as string
if item.parentIndexNumber > 0 and item.indexNumber > 0
return translate(translationKeys.ButtonResume) + " S" + item.parentIndexNumber.toStr() + "E" + item.indexNumber.toStr()
end if
return translate(translationKeys.ButtonResume)
end function
' manageResumeButton: Add or remove Resume button based on playback position (non-Series types)
sub manageResumeButton()
resumeButton = m.top.findNode("resumeButton")
if isValid(m.top.itemContent) and isValidAndNotEmpty(m.top.itemContent.id)
item = m.top.itemContent
if item.runTimeTicks <= 0
removeResumeButtonWithFocus(resumeButton)
return
end if
if item.playbackPositionTicks > 0
if not isValid(resumeButton)
currentFocusIndex = m.buttonGrp.buttonFocused
currentFocusedButton = invalid
if isValid(currentFocusIndex) and currentFocusIndex >= 0 and currentFocusIndex < m.buttonGrp.getChildCount()
currentFocusedButton = m.buttonGrp.getChild(currentFocusIndex)
end if
resumeButton = CreateObject("roSGNode", "ResumeButton")
resumeButton.id = "resumeButton"
resumeButton.icon = "pkg:/images/icons/resume_$$RES$$.png"
resumeButton.text = translate(translationKeys.ButtonResume)
resumeButton.playbackPositionTicks = item.playbackPositionTicks
resumeButton.runtimeTicks = item.runTimeTicks
m.buttonGrp.insertChild(resumeButton, 0)
if isValid(currentFocusedButton) and currentFocusedButton.id = "playButton"
m.buttonGrp.buttonFocused = 0
else if isValid(currentFocusIndex) and currentFocusIndex >= 0
m.buttonGrp.buttonFocused = currentFocusIndex + 1
else
m.buttonGrp.buttonFocused = 0
end if
' Only move actual focus when the button group already owns it — avoid stealing
' focus from the extras row when setupButtons() cleared and rebuilt buttons on refresh.
if m.buttonGrp.isInFocusChain()
focusButtonGroupChild()
end if
else
resumeButton.playbackPositionTicks = item.playbackPositionTicks
resumeButton.runtimeTicks = item.runTimeTicks
end if
else
removeResumeButtonWithFocus(resumeButton)
end if
else
removeResumeButtonWithFocus(resumeButton)
end if
end sub
' removeResumeButtonWithFocus: Remove resume button while preserving focus position
sub removeResumeButtonWithFocus(resumeButton as object)
if not isValid(resumeButton) then return
currentFocusIndex = m.buttonGrp.buttonFocused
resumeButton.playbackPositionTicks = 0
resumeButton.runtimeTicks = 0
m.buttonGrp.removeChild(resumeButton)
if isValid(currentFocusIndex) and currentFocusIndex > 0
m.buttonGrp.buttonFocused = currentFocusIndex - 1
else
m.buttonGrp.buttonFocused = 0
end if
' Only move actual focus when the button group already owns it — avoid stealing
' focus from the extras row or description when the button is removed asynchronously.
if m.buttonGrp.isInFocusChain()
focusButtonGroupChild()
end if
end sub
' createInfoLabel: Create a bold label node for the info rows
' @param {string} labelId - Unique ID for the label
' @return {object} Configured LabelPrimaryMedium node
function createInfoLabel(labelId as string) as object
labelNode = CreateObject("roSGNode", "LabelPrimaryMedium")
labelNode.id = labelId
labelNode.vertAlign = "center"
labelNode.isBold = true
return labelNode
end function
' createDividerNode: Create a bullet divider node for separating info items
' @param {string} dividerId - Unique ID for the divider
' @return {object} Configured divider node
function createDividerNode(dividerId as string) as object
labelNode = CreateObject("roSGNode", "LabelPrimarySmall")
labelNode.id = dividerId
labelNode.horizAlign = "left"
labelNode.vertAlign = "center"
labelNode.height = 40
labelNode.width = 0
labelNode.text = "•"
labelNode.isBold = true
return labelNode
end function
' createTimeLabel: Creates a time label with an optional lowercase am/pm suffix.
' In 24h mode returns a single label; in 12h mode appends the period to the time text.
function createTimeLabel(labelId as string, timeText as string, periodText as string) as object
node = createInfoLabel(labelId)
if periodText <> ""
node.text = timeText + " " + periodText
else
node.text = timeText
end if
return node
end function
' displayInfoNode: Add a node to the info group, prepending a bullet divider if needed
sub displayInfoNode(node as object)
if not isValid(node) then return
if m.infoGroup.getChildCount() > 0
m.infoDividerCount++
divider = createDividerNode("infoDivider" + m.infoDividerCount.toStr())
m.infoGroup.appendChild(divider)
end if
m.infoGroup.appendChild(node)
end sub
' displayDirectorGenreNode: Add a node to the second info row, prepending a bullet divider if needed
sub displayDirectorGenreNode(node as object)
if not isValid(node) then return
if m.directorGenreGroup.getChildCount() > 0
m.directorGenreDividerCount++
divider = createDividerNode("directorGenreDivider" + m.directorGenreDividerCount.toStr())
m.directorGenreGroup.appendChild(divider)
end if
m.directorGenreGroup.appendChild(node)
end sub
' 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 simply
' 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 — hide
if item.type = "Photo" or item.type = "PhotoAlbum"
m.itemDescription.visible = false
setDescriptionInLayout(false)
return
end if
' TvChannel: show current program overview if available
if item.type = "TvChannel"
if isValid(item.currentProgram) and isValidAndNotEmpty(item.currentProgram.overview)
m.itemDescription.text = item.currentProgram.overview
m.itemDescription.tagline = ""
m.itemDescription.maxLines = 4
m.itemDescription.dialogTitle = item.name
m.itemDescription.visible = true
setDescriptionInLayout(true)
else
m.itemDescription.visible = false
setDescriptionInLayout(false)
end if
return
end if
' Program: show EPG overview if available
if item.type = "Program"
m.itemDescription.tagline = ""
if isValidAndNotEmpty(item.overview)
m.itemDescription.text = item.overview
m.itemDescription.maxLines = 4
m.itemDescription.dialogTitle = item.name
m.itemDescription.visible = true
setDescriptionInLayout(true)
else
m.itemDescription.visible = false
setDescriptionInLayout(false)
end if
return
end if
' Audio: lyrics fetched async — start hidden; onLyricsLoaded() will populate when ready
if item.type = "Audio"
m.itemDescription.tagline = ""
m.itemDescription.maxLines = 8
m.itemDescription.dialogTitle = item.name
m.itemDescription.visible = false
setDescriptionInLayout(false)
if item.hasLyrics
m.loadLyricsTask.unobserveField("content")
m.loadLyricsTask.control = "STOP"
m.loadLyricsTask.itemId = item.id
m.loadLyricsTask.observeField("content", "onLyricsLoaded")
m.loadLyricsTask.control = "RUN"
end if
return
end if
hasTagline = false
hasOverview = false
if userSettings.uiDetailsHideTagline = false
if item.taglines.count() > 0 and isValidAndNotEmpty(item.taglines[0])
m.itemDescription.tagline = item.taglines[0]
m.itemDescription.taglineMaxLines = 2
hasTagline = true
end if
end if
if not hasTagline
m.itemDescription.tagline = ""
end if
if isValidAndNotEmpty(item.overview)
m.itemDescription.text = item.overview
hasOverview = true
else
m.itemDescription.text = ""
end if
if hasTagline
m.itemDescription.maxLines = 2
else
m.itemDescription.maxLines = 4
end if
if hasTagline or hasOverview
m.itemDescription.dialogTitle = item.name
m.itemDescription.visible = true
setDescriptionInLayout(true)
else
m.itemDescription.visible = false
setDescriptionInLayout(false)
end if
end sub
' populateInfoGroup: Dispatch to type-specific info row builder
sub populateInfoGroup()
m.infoGroup.removeChildrenIndex(m.infoGroup.getChildCount(), 0)
m.directorGenreGroup.removeChildrenIndex(m.directorGenreGroup.getChildCount(), 0)
m.infoDividerCount = 0
m.directorGenreDividerCount = 0
m.endsAtNode = invalid
m.endsAtDurationSeconds = 0
item = m.top.itemContent
userSettings = m.global.user.settings
if item.type = "Series"
populateInfoGroupSeries(item, userSettings)
else if item.type = "Season"
populateInfoGroupSeason(item, userSettings)
else if item.type = "Episode"
populateInfoGroupEpisode(item, userSettings)
else if item.type = "MusicVideo"
populateInfoGroupMusicVideo(item, userSettings)
else if item.type = "Person"
populateInfoGroupPerson(item)
else if item.type = "BoxSet"
populateInfoGroupBoxSet(item, userSettings)
else if item.type = "MusicArtist"
populateInfoGroupMusicArtist(item)
else if item.type = "MusicAlbum"
populateInfoGroupMusicAlbum(item, userSettings)
else if item.type = "Playlist"
populateInfoGroupPlaylist(item, userSettings)
else if item.type = "Audio"
populateInfoGroupAudio(item, userSettings)
else if item.type = "Photo"
populateInfoGroupPhoto(item)
else if item.type = "PhotoAlbum"
populateInfoGroupPhotoAlbum(item)
else if item.type = "TvChannel"
populateInfoGroupTvChannel(item, userSettings)
else if item.type = "Program"
populateInfoGroupProgram(item, userSettings)
else if item.type = "Recording"
populateInfoGroupRecording(item, userSettings)
else
' Movie, Video
populateInfoGroupMovie(item, userSettings)
end if
end sub
' populateInfoGroupMovie: Info rows for Movie, Video, Recording
' Row 1: Year · Official Rating · Community Rating · Critic Rating · Runtime · Ends At
' Row 2: Genres · Director(s)
sub populateInfoGroupMovie(item as object, userSettings as object)
if item.productionYear > 0
yearNode = createInfoLabel("releaseYear")
yearNode.text = stri(item.productionYear).trim()
displayInfoNode(yearNode)
end if
if isValidAndNotEmpty(item.officialRating)
ratingNode = createInfoLabel("officialRating")
ratingNode.text = item.officialRating
displayInfoNode(ratingNode)
end if
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(" / ")
displayDirectorGenreNode(genreNode)
end if
directors = []
if isValid(item.people)
for each person in item.people
if person.type = "Director"
directors.push(person.name)
end if
end for
end if
if directors.count() > 0
directorNode = createInfoLabel("director")
directorNode.text = translate(translationKeys.MessageDirectedBy1, [directors.join(", ")])
displayDirectorGenreNode(directorNode)
end if
end sub
' populateInfoGroupMusicVideo: Info rows for MusicVideo
' Row 1: Year · Official Rating · Community Rating · Critic Rating · Runtime · Ends At
' Row 2: Genres · Artist(s)
sub populateInfoGroupMusicVideo(item as object, userSettings as object)
if item.productionYear > 0
yearNode = createInfoLabel("releaseYear")
yearNode.text = stri(item.productionYear).trim()
displayInfoNode(yearNode)
end if
if isValidAndNotEmpty(item.officialRating)
ratingNode = createInfoLabel("officialRating")
ratingNode.text = item.officialRating
displayInfoNode(ratingNode)
end if
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(" / ")
displayDirectorGenreNode(genreNode)
end if
if isValid(item.artists) and item.artists.count() > 0
artistNode = createInfoLabel("artists")
artistNode.text = item.artists.join(", ")
displayDirectorGenreNode(artistNode)
end if
directors = []
if isValid(item.people)
for each person in item.people
if person.type = "Director" then directors.push(person.name)
end for
end if
if directors.count() > 0
directorNode = createInfoLabel("director")
directorNode.text = translate(translationKeys.MessageDirectedBy1, [directors.join(", ")])
displayDirectorGenreNode(directorNode)
end if
end sub
' populateInfoGroupEpisode: Info rows for Episode
' Row 1: Air Date · Official Rating · Community Rating · Critic Rating · Runtime · Ends At
' Row 2: Series name + "S{n}E{n}"
sub populateInfoGroupEpisode(item as object, userSettings as object)
if isValidAndNotEmpty(item.premiereDate)
airDate = CreateObject("roDateTime")
airDate.FromISO8601String(item.premiereDate)
airedNode = createInfoLabel("aired")
airedNode.text = airDate.AsDateString("short-month-no-weekday")
displayInfoNode(airedNode)
end if
if isValidAndNotEmpty(item.officialRating)
ratingNode = createInfoLabel("officialRating")
ratingNode.text = item.officialRating
displayInfoNode(ratingNode)
end if
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 • Directed by X, Y, Z
if item.parentIndexNumber > 0 and item.indexNumber > 0
episodeCodeNode = createInfoLabel("episodeCode")
episodeCodeNode.text = "S" + item.parentIndexNumber.toStr() + "E" + item.indexNumber.toStr()
displayDirectorGenreNode(episodeCodeNode)
end if
if isValidAndNotEmpty(item.seriesName)
seriesNameNode = createInfoLabel("seriesName")
seriesNameNode.text = item.seriesName
displayDirectorGenreNode(seriesNameNode)
end if
directors = []
if isValid(item.people)
for each person in item.people
if person.type = "Director" then directors.push(person.name)
end for
end if
if directors.count() > 0
directorNode = createInfoLabel("director")
directorNode.text = translate(translationKeys.MessageDirectedBy1, [directors.join(", ")])
displayDirectorGenreNode(directorNode)
end if
end sub
' populateInfoGroupSeries: Info rows for Series
' Row 1: Year Range · Official Rating · Community Rating · 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(" / ")
displayDirectorGenreNode(genreNode)
end if
historyText = getHistory()
if isValidAndNotEmpty(historyText)
historyNode = createInfoLabel("history")
historyNode.text = historyText
displayDirectorGenreNode(historyNode)
end if
end sub
' populateInfoGroupBoxSet: Info rows for BoxSet (movie collection)
' Row 1: Year · Official Rating · Community Rating · 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(" / ")
displayDirectorGenreNode(genreNode)
end if
if item.studios.count() > 0
studioNode = createInfoLabel("studio")
studioNode.text = item.studios[0]
displayDirectorGenreNode(studioNode)
end if
end sub
' populateInfoGroupSeason: Info rows for Season
' Row 1: Year · Official Rating (series) · 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 — 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 — do NOT swap m.top.itemContent,
' as that fires onItemContentChanged() synchronously causing reentrancy and row duplication.
m.endsAtDurationSeconds = int(seriesData.runTimeTicks / 10000000.0)
endDate = CreateObject("roDateTime")
endDate.fromSeconds(endDate.asSeconds() + m.endsAtDurationSeconds)
endDate.toLocalTime()
m.endsAtNode = createTimeLabel("ends-at", translate(translationKeys.MessageEndsAt1, [formatTime(endDate)]), getTimePeriod(endDate))
displayInfoNode(m.endsAtNode)
end if
' Row 2: series name · episode count · studio
if isValidAndNotEmpty(item.seriesName)
seriesNameNode = createInfoLabel("seriesName")
seriesNameNode.text = item.seriesName
displayDirectorGenreNode(seriesNameNode)
end if
if item.childCount > 0
episodeCountNode = createInfoLabel("episodeCount")
episodeCountNode.text = translatePlural(translationKeys.LabelEpisodeCount, item.childCount, [stri(item.childCount).trim()])
displayDirectorGenreNode(episodeCountNode)
end if
if isValid(seriesData) and seriesData.studios.count() > 0
studioNode = createInfoLabel("studio")
studioNode.text = seriesData.studios[0]
displayDirectorGenreNode(studioNode)
end if
end sub
' populateInfoGroupPerson: Info rows for Person
' Row 1: {birthDate} [- {deathDate}] · {n} years old
' Living: Jan 1, 1980 · 45 years old
' Deceased: Jan 1, 1920 - Dec 31, 1980 · 60 years old
' Row 2: {n} Movie(s) [· {n} Episode(s)] — omitted entirely if both counts are 0
sub populateInfoGroupPerson(item as object)
' Row 1: birth / death / age
if isValidAndNotEmpty(item.premiereDate)
birthDate = CreateObject("roDateTime")
birthDate.FromISO8601String(item.premiereDate)
lifeString = birthDate.AsDateString("short-month-no-weekday")
if isValidAndNotEmpty(item.endDate)
' Deceased: show birth - death dates as one hyphenated unit
deathDate = CreateObject("roDateTime")
deathDate.FromISO8601String(item.endDate)
lifeString = lifeString + " - " + deathDate.AsDateString("short-month-no-weekday")
' Age at time of death
age = deathDate.getYear() - birthDate.getYear()
if deathDate.getMonth() < birthDate.getMonth()
age--
else if deathDate.getMonth() = birthDate.getMonth()
if deathDate.getDayOfMonth() < birthDate.getDayOfMonth()
age--
end if
end if
else
' Living: calculate age from birth to today
today = CreateObject("roDateTime")
age = today.getYear() - birthDate.getYear()
if today.getMonth() < birthDate.getMonth()
age--
else if today.getMonth() = birthDate.getMonth()
if today.getDayOfMonth() < birthDate.getDayOfMonth()
age--
end if
end if
end if
lifeString = lifeString + " · " + translatePlural(translationKeys.LabelYearsOldCount, age, [stri(age).trim()])
lifeNode = createInfoLabel("personLife")
lifeNode.text = lifeString
displayInfoNode(lifeNode)
end if
' Row 2: movie count / episode count (only shown when the API returns non-zero values)
if item.movieCount > 0
movieCountNode = createInfoLabel("personMovieCount")
movieCountNode.text = translatePlural(translationKeys.LabelMovieCount, item.movieCount, [stri(item.movieCount).trim()])
displayDirectorGenreNode(movieCountNode)
end if
if item.episodeCount > 0
episodeCountNode = createInfoLabel("personEpisodeCount")
episodeCountNode.text = translatePlural(translationKeys.LabelEpisodeCount, item.episodeCount, [stri(item.episodeCount).trim()])
displayDirectorGenreNode(episodeCountNode)
end if
end sub
' populateInfoGroupMusicArtist: Info rows for MusicArtist
' Row 1: N Albums · N Songs · Year
' Row 2: Genres
sub populateInfoGroupMusicArtist(item as object)
if item.albumCount > 0
albumCountNode = createInfoLabel("albumCount")
albumCountNode.text = translatePlural(translationKeys.LabelAlbumCount, item.albumCount, [stri(item.albumCount).trim()])
displayInfoNode(albumCountNode)
end if
if item.songCount > 0
songCountNode = createInfoLabel("songCount")
songCountNode.text = translatePlural(translationKeys.LabelSongCount, item.songCount, [stri(item.songCount).trim()])
displayInfoNode(songCountNode)
end if
if item.productionYear > 0
yearNode = createInfoLabel("releaseYear")
yearNode.text = stri(item.productionYear).trim()
displayInfoNode(yearNode)
end if
if item.genres.count() > 0
genreNode = createInfoLabel("genre")
genreNode.text = item.genres.join(" / ")
displayDirectorGenreNode(genreNode)
end if
end sub
' populateInfoGroupMusicAlbum: Info rows for MusicAlbum
' Row 1: Year · Runtime · Ends At
' Row 2: Album Artist · Genres · N Tracks
sub populateInfoGroupMusicAlbum(item as object, userSettings as object)
if item.productionYear > 0
yearNode = createInfoLabel("releaseYear")
yearNode.text = stri(item.productionYear).trim()
displayInfoNode(yearNode)
end if
if item.runTimeTicks > 0
runtimeNode = createInfoLabel("runtime")
runtimeNode.text = stri(getRuntime()).trim() + " " + translate(translationKeys.LabelMins)
displayInfoNode(runtimeNode)
end if
if item.runTimeTicks > 0 and not userSettings.uiDesignHideClock
m.endsAtDurationSeconds = int(m.top.itemContent.runTimeTicks / 10000000.0)
createEndsAtNode()
displayInfoNode(m.endsAtNode)
end if
if isValidAndNotEmpty(item.albumArtist)
artistNode = createInfoLabel("albumArtist")
artistNode.text = item.albumArtist
displayDirectorGenreNode(artistNode)
end if
if item.genres.count() > 0
genreNode = createInfoLabel("genre")
genreNode.text = item.genres.join(" / ")
displayDirectorGenreNode(genreNode)
end if
if item.childCount > 0
trackCountNode = createInfoLabel("trackCount")
trackCountNode.text = translatePlural(translationKeys.LabelTrackCount, item.childCount, [stri(item.childCount).trim()])
displayDirectorGenreNode(trackCountNode)
end if
end sub
' populateInfoGroupPlaylist: Info rows for Playlist
' Row 1: N Items · Runtime · Ends At
' Row 2: Genres
' The item count label starts as "Items" and is swapped to "Tracks" by onPlaylistContentKindChanged()
' once the extras chain confirms the playlist is all-audio.
sub populateInfoGroupPlaylist(item as object, userSettings as object)
if item.childCount > 0
itemCountNode = createInfoLabel("itemCount")
itemCountNode.text = translatePlural(translationKeys.LabelItemCount, item.childCount, [stri(item.childCount).trim()])
displayInfoNode(itemCountNode)
end if
if item.runTimeTicks > 0
runtimeNode = createInfoLabel("runtime")
runtimeNode.text = stri(getRuntime()).trim() + " " + translate(translationKeys.LabelMins)
displayInfoNode(runtimeNode)
end if
if item.runTimeTicks > 0 and not userSettings.uiDesignHideClock
m.endsAtDurationSeconds = int(m.top.itemContent.runTimeTicks / 10000000.0)
createEndsAtNode()
displayInfoNode(m.endsAtNode)
end if
if item.genres.count() > 0
genreNode = createInfoLabel("genre")
genreNode.text = item.genres.join(" / ")
displayDirectorGenreNode(genreNode)
end if
end sub
' populateInfoGroupAudio: Info rows for Audio (song)
' Row 1: Track N · Disc N (only when disc > 1) · Runtime · Ends At
' Row 2: Album name · Album artist
sub populateInfoGroupAudio(item as object, userSettings as object)
if item.indexNumber > 0
trackNode = createInfoLabel("trackNumber")
trackNode.text = translate(translationKeys.MessageTrack1, [stri(item.indexNumber).trim()])
displayInfoNode(trackNode)
end if
' Only show disc number when there is more than one disc in the album
if item.parentIndexNumber > 1
discNode = createInfoLabel("discNumber")
discNode.text = translate(translationKeys.MessageDisc1, [stri(item.parentIndexNumber).trim()])
displayInfoNode(discNode)
end if
if item.runTimeTicks > 0
runtimeNode = createInfoLabel("runtime")
runtimeNode.text = stri(getRuntime()).trim() + " " + translate(translationKeys.LabelMins)
displayInfoNode(runtimeNode)
end if
if item.runTimeTicks > 0 and not userSettings.uiDesignHideClock
m.endsAtDurationSeconds = int(m.top.itemContent.runTimeTicks / 10000000.0)
createEndsAtNode()
displayInfoNode(m.endsAtNode)
end if
if isValidAndNotEmpty(item.albumName)
albumNameNode = createInfoLabel("albumName")
albumNameNode.text = item.albumName
displayDirectorGenreNode(albumNameNode)
end if
if isValidAndNotEmpty(item.albumArtist)
albumArtistNode = createInfoLabel("albumArtistName")
albumArtistNode.text = item.albumArtist
displayDirectorGenreNode(albumArtistNode)
end if
end sub
' populateInfoGroupPhoto: Info rows for Photo
' Row 1: Resolution ("WxH") · Camera ("Make Model") · Production Year
' Row 2: Album name (if present)
sub populateInfoGroupPhoto(item as object)
' Resolution: "WIDTHxHEIGHT"
if item.imageWidth > 0 and item.imageHeight > 0
resNode = createInfoLabel("resolution")
resNode.text = stri(item.imageWidth).trim() + "x" + stri(item.imageHeight).trim()
displayInfoNode(resNode)
end if
' Camera: prefer "Make Model", fall back to whichever is available.
' Avoids duplication when model already contains the make (e.g. "Canon Canon EOS R5").
cameraText = ""
if isValidAndNotEmpty(item.cameraMake) and isValidAndNotEmpty(item.cameraModel)
if item.cameraModel.inStr(item.cameraMake) = 0
cameraText = item.cameraModel
else
cameraText = item.cameraMake + " " + item.cameraModel
end if
else if isValidAndNotEmpty(item.cameraModel)
cameraText = item.cameraModel
else if isValidAndNotEmpty(item.cameraMake)
cameraText = item.cameraMake
end if
if cameraText <> ""
cameraNode = createInfoLabel("camera")
cameraNode.text = cameraText
displayInfoNode(cameraNode)
end if
if item.productionYear > 0
yearNode = createInfoLabel("releaseYear")
yearNode.text = stri(item.productionYear).trim()
displayInfoNode(yearNode)
end if
' Row 2: album name
if isValidAndNotEmpty(item.albumName)
albumNode = createInfoLabel("albumName")
albumNode.text = item.albumName
displayDirectorGenreNode(albumNode)
end if
end sub
' populateInfoGroupPhotoAlbum: Info rows for PhotoAlbum
' Row 1: Photo count
sub populateInfoGroupPhotoAlbum(item as object)
if item.childCount > 0
countNode = createInfoLabel("photoCount")
countNode.text = translatePlural(translationKeys.LabelPhotoCount, item.childCount, [stri(item.childCount).trim()])
displayInfoNode(countNode)
end if
end sub
' populateInfoGroupTvChannel: Info rows for TvChannel (Live TV)
' Displays metadata from the current program (the channel is a vessel for what's airing).
' Row 1: CH N · OfficialRating · CommunityRating · CriticRating · S#E# · Program Name · Runtime · BroadcastTime · Ends At
' Row 2: Genres (or category flags)
sub populateInfoGroupTvChannel(item as object, userSettings as object)
if isValidAndNotEmpty(item.channelNumber)
chanNode = createInfoLabel("channelNumber")
chanNode.text = translate(translationKeys.LabelCh) + " " + item.channelNumber
displayInfoNode(chanNode)
end if
if isValid(item.currentProgram)
prog = item.currentProgram
if isValidAndNotEmpty(prog.officialRating)
ratingNode = createInfoLabel("officialRating")
ratingNode.text = prog.officialRating
displayInfoNode(ratingNode)
end if
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 (e.g., "9:00 - 10:00 PM")
if startPeriod = endPeriod
timeText = formatTime(startDt) + " - " + formatTime(broadcastEndDt)
periodText = endPeriod
else
timeText = formatTime(startDt) + " " + startPeriod + " - " + formatTime(broadcastEndDt)
periodText = endPeriod
end if
displayInfoNode(createTimeLabel("broadcastTime", timeText, periodText))
end if
' Row 2: genres (or category flags)
if prog.genres.count() > 0
genreNode = createInfoLabel("genre")
genreNode.text = prog.genres.join(" / ")
displayDirectorGenreNode(genreNode)
else
categories = []
if prog.isNews then categories.push(translate(translationKeys.LabelNews))
if prog.isSports then categories.push(translate(translationKeys.LabelSports))
if prog.isKids then categories.push(translate(translationKeys.LabelKids))
if prog.isMovie then categories.push(translate(translationKeys.LabelMovie))
if categories.count() > 0
catNode = createInfoLabel("programCategories")
catNode.text = categories.join(" / ")
displayDirectorGenreNode(catNode)
end if
end if
end if
end sub
' populateInfoGroupProgram: Info rows for Program (Live TV EPG entry)
' Row 1: Year · CH X - ChanName · OfficialRating · CommunityRating · CriticRating · S#E# · Premiere/Repeat · Runtime · BroadcastTime · Ends At
' Row 2: Genres (or category flags)
sub populateInfoGroupProgram(item as object, userSettings as object)
if item.productionYear > 0
yearNode = createInfoLabel("releaseYear")
yearNode.text = stri(item.productionYear).trim()
displayInfoNode(yearNode)
end if
' Channel info — early position mirrors TvChannel's "channel identity first" layout
channelParts = []
if isValidAndNotEmpty(item.channelNumber) then channelParts.push(translate(translationKeys.LabelCh) + " " + item.channelNumber)
if isValidAndNotEmpty(item.channelName) then channelParts.push(item.channelName)
if channelParts.count() > 0
channelNode = createInfoLabel("channelInfo")
channelNode.text = channelParts.join(" - ")
displayInfoNode(channelNode)
end if
if isValidAndNotEmpty(item.officialRating)
ratingNode = createInfoLabel("officialRating")
ratingNode.text = item.officialRating
displayInfoNode(ratingNode)
end if
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
' "Ends at" — only for live programs with valid broadcast times
if item.isLive and not userSettings.uiDesignHideClock and isValidAndNotEmpty(item.endDate)
endDt = createObject("roDateTime")
endDt.FromISO8601String(item.endDate)
endDt.toLocalTime()
displayInfoNode(createTimeLabel("ends-at", translate(translationKeys.MessageEndsAt1, [formatTime(endDt)]), getTimePeriod(endDt)))
end if
' Row 2: genres (or category flags)
if item.genres.count() > 0
genreNode = createInfoLabel("genre")
genreNode.text = item.genres.join(" / ")
displayDirectorGenreNode(genreNode)
else
categories = []
if item.isNews then categories.push(translate(translationKeys.LabelNews))
if item.isSports then categories.push(translate(translationKeys.LabelSports))
if item.isKids then categories.push(translate(translationKeys.LabelKids))
if item.isMovie then categories.push(translate(translationKeys.LabelMovie))
if categories.count() > 0
catNode = createInfoLabel("programCategories")
catNode.text = categories.join(" / ")
displayDirectorGenreNode(catNode)
end if
end if
end sub
' populateInfoGroupRecording: Info rows for Recording (Live TV recorded content)
' Row 1: PremiereYear · OfficialRating · CommunityRating · 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(" / ")
displayDirectorGenreNode(genreNode)
end if
end sub
' getHistory: Format the air schedule/status string for a Series item
' Example output: "ABC" or "Fridays at 9:30 PM on NBC"
function getHistory() as string
item = m.top.itemContent
airwords = invalid
studio = invalid
words = ""
if isValid(item.airDays) and item.airDays.count() = 1
airwords = item.airDays[0]
end if
if isValidAndNotEmpty(item.airTime)
if isValid(airwords)
airwords = airwords + " " + translate(translationKeys.LabelAt) + " " + item.airTime
else
airwords = item.airTime
end if
end if
if item.studios.count() > 0
studio = item.studios[0]
end if
if not isValid(studio) and not isValid(airwords) then return words
if isValid(airwords)
words = airwords
end if
if isValid(studio)
if isValid(airwords)
words = words + " " + translate(translationKeys.LabelOn) + " " + studio
else
words = studio
end if
end if
return words
end function
sub onItemContentChanged()
m.seasonSeriesData = invalid
item = m.top.itemContent
' Pre-populate series data from cache so Season info rows render on first paint
if isValid(item) and item.type = "Season" and isValidAndNotEmpty(item.seriesId)
cached = m.seasonSeriesCache[item.seriesId]
if isValid(cached)
m.seasonSeriesData = cached
end if
end if
if isValid(item) and isValidAndNotEmpty(item.id)
device = m.global.device
backdropUrl = getItemBackdropUrl(item, { width: device.uiResolution[0], height: device.uiResolution[1] })
' TvChannel: fall back to current program's backdrop when channel has none
if backdropUrl = "" and item.type = "TvChannel" and isValid(item.currentProgram)
backdropUrl = getItemBackdropUrl(item.currentProgram, { width: device.uiResolution[0], height: device.uiResolution[1] })
end if
m.global.sceneManager.callFunc("setBackgroundImage", backdropUrl)
else
m.global.sceneManager.callFunc("setBackgroundImage", "")
end if
if isValid(item) and isValidAndNotEmpty(item.id)
' Do NOT 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 (keepAlive-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.
setItemLogo(item)
setDateAdded(item)
setupButtons(item)
' Person Shuffle: on refresh (extras already loaded), the extras chain won't re-fire
' because only refreshItemDetailsData was toggled. Resolve the loading placeholder
' immediately from the already-known personHasMedia value.
if item.type = "Person" and m.extrasLoaded
onPersonHasMediaChanged()
end if
' Media tracks and stream options are only relevant for playable types.
' MusicArtist/MusicAlbum have no track info. Audio shows audio codec only.
hasMediaTracks = not inArray(itemTypeOrder.NO_MEDIA_TRACKS, item.type)
' Keep/remove the itemTracks spacer + trackCluster visibility in one step so
' NO_MEDIA_TRACKS item types render identically to main.
setTracksInLayout(hasMediaTracks)
m.trackCluster.visible = hasMediaTracks
' Fresh content = fresh auto-selected subtitle (reset the user-override flag so the
' subtitle dropdown re-seeds from findDefaultSubtitleStreamIndex).
m.subtitleUserOverridden = false
' Fresh content = forget the previously-focused dropdown so the next UP-from-buttons
' press picks the slot based on the current button column, not the prior item.
m.lastFocusedDropdown = invalid
if item.type = "Audio"
' Audio: no video source selection, no subtitle display. Audio dropdown still applies.
allStreams = []
if isValid(item.mediaSourcesData) and isValidAndNotEmpty(item.mediaSourcesData.mediaSources)
mediaSources = item.mediaSourcesData.mediaSources
if isValid(mediaSources[0].MediaStreams)
allStreams = mediaSources[0].MediaStreams
end if
end if
SetDefaultAudioTrack(allStreams)
populateTrackDropdowns([], allStreams, item.type)
else if hasMediaTracks
mediaSources = invalid
if isValid(item.mediaSourcesData) and isValidAndNotEmpty(item.mediaSourcesData.mediaSources)
mediaSources = item.mediaSourcesData.mediaSources
end if
' Select best video source based on device capabilities instead of always using first
if m.top.selectedVideoStreamId = "" and isValid(mediaSources) and mediaSources.Count() > 0
bestSourceIndex = findBestVideoSource(mediaSources)
m.top.selectedVideoStreamId = mediaSources[bestSourceIndex].Id ?? item.mediaSourceId
else if m.top.selectedVideoStreamId = "" and isValidAndNotEmpty(item.mediaSourceId)
m.top.selectedVideoStreamId = item.mediaSourceId
end if
allStreams = getStreamsForSelectedSource(mediaSources)
SetDefaultAudioTrack(allStreams)
populateTrackDropdowns(mediaSources ?? [], allStreams, item.type)
end if
' Series/Season/Person/BoxSet/MusicArtist/MusicAlbum: the NO_MEDIA_TRACKS branch
' above already removed itemTracks from layout and hid m.trackCluster, so nothing
' more to do here.
populateInfoGroup()
populateDescriptionGroup()
setFieldText("videoTitle", item.name)
updateFavoriteButton()
updateWatchedButton()
' Non-Series/Season/music types: Resume based on playbackPositionTicks
' Series: Resume based on nextUpEpisode (managed by nextUpEpisodeChanged observer)
' Season: No resume — NextUp API does not support SeasonId filtering
' Music types: no resume — play state for music is not tracked via playbackPositionTicks
if not inArray(itemTypeOrder.NO_RESUME, item.type)
manageResumeButton()
end if
' Series: kick off background fetch of resume/next-up episode for the Resume button.
' Don't reset nextUpEpisode here — setupButtons() just created a LoadingButton placeholder
' and resetting to invalid would trigger onNextUpEpisodeChanged() which removes it immediately.
' onSeriesResumeLoaded() handles both the data and no-data cases.
if item.type = "Series"
' Unobserve before re-observing to prevent callbacks stacking on repeated content changes.
m.loadSeriesResumeTask.unobserveField("content")
m.loadSeriesResumeTask.control = "STOP"
m.loadSeriesResumeTask.itemId = item.id
m.loadSeriesResumeTask.observeField("content", "onSeriesResumeLoaded")
m.loadSeriesResumeTask.control = "RUN"
end if
' Trailer availability check — re-run on every content change so refreshes get fresh data.
' onTrailerAvailableChanged() adds or removes the button when the result arrives.
if item.type = "Movie" or item.type = "Video" or item.type = "MusicVideo"
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)
' Unobserve before re-observing to prevent callbacks stacking on repeated content changes.
m.loadSeasonSeriesTask.unobserveField("content")
m.loadSeasonSeriesTask.itemId = item.seriesId
m.loadSeasonSeriesTask.observeField("content", "onSeasonSeriesDataLoaded")
m.loadSeasonSeriesTask.control = "RUN"
end if
' Load extras rows on first content load only; explicit refresh via onRefreshExtrasData.
if not m.extrasLoaded
m.extrasLoaded = true
if item.type = "Person"
m.extrasGrid.callFunc("loadPersonVideos", item.id)
else
m.extrasGrid.type = item.type
m.extrasGrid.callFunc("loadParts", item)
end if
end if
end if
m.buttonGrp.visible = true
' Re-hide description and tracks if extras slider is still active (e.g. returning from sub-screen)
if m.extrasActive
activateExtras()
end if
end sub
' ============================================
' TRACK DROPDOWNS
' ============================================
' The track dropdown cluster is now a child of itemDetails LayoutGroup, so vertical
' stacking and horizontal anchoring are handled by RSG's layout engine. Slot width and
' stride are configured declaratively in ItemDetails.xml's trackDropdowns LayoutGroup
' (slot width via TrackDropdown default, stride via itemSpacings).
' populateTrackDropdowns: Build the Video/Audio/Subtitle dropdown items from the current
' media source list and stream list for the given item type, then toggle the cluster +
' title row visibility. Seeds m.top.selectedSubtitleStreamIndex from
' findDefaultSubtitleStreamIndex() on first load so the initial trigger label reflects
' what playback will actually use.
'
' @param {object} mediaSources - Array of MediaSource objects (may be empty)
' @param {object} allStreams - MediaStreams from the selected source (video/audio/subs mixed)
' @param {string} itemType - The item type string (drives which slots apply)
sub populateTrackDropdowns(mediaSources as object, allStreams as object, itemType as string)
videoItems = buildVideoDropdownItems(mediaSources)
audioItems = buildAudioDropdownItems(allStreams)
subtitleItems = buildSubtitleDropdownItems(allStreams)
' Seed default subtitle selection on first load / fresh content so the dropdown trigger
' text reflects the subtitle stream playback would auto-pick. A subsequent user pick on
' the dropdown flips m.subtitleUserOverridden so we don't re-seed on audio changes.
if not m.subtitleUserOverridden
defaultSubIdx = findDefaultSubtitleStreamIndex(allStreams, m.top.selectedAudioStreamIndex)
m.top.selectedSubtitleStreamIndex = defaultSubIdx
end if
' Audio-only items get no video and no subtitle slot. Video items always show
' the subtitle slot for layout consistency — buildSubtitleDropdownItems returns
' a single "None" entry when no tracks exist, which applyDropdown renders as
' static non-interactive text.
videoApplicable = itemType <> "Audio" and videoItems.count() > 0
audioApplicable = audioItems.count() > 0
subtitleApplicable = itemType <> "Audio" and subtitleItems.count() > 0
' Title row: static labels above each dropdown. Always shown for applicable slots so
' users see the category name even when the slot is single-option static text.
m.videoTrackTitle.text = translate(translationKeys.LabelVideo)
m.audioTrackTitle.text = translate(translationKeys.LabelAudio)
m.subtitleTrackTitle.text = translate(translationKeys.LabelSubtitles)
m.videoTrackTitle.visible = videoApplicable
m.audioTrackTitle.visible = audioApplicable
m.subtitleTrackTitle.visible = subtitleApplicable
applyDropdown(m.videoDropdown, videoItems, m.top.selectedVideoStreamId, videoApplicable)
applyDropdown(m.audioDropdown, audioItems, str(m.top.selectedAudioStreamIndex).trim(), audioApplicable)
applyDropdown(m.subtitleDropdown, subtitleItems, str(m.top.selectedSubtitleStreamIndex).trim(), subtitleApplicable)
' Cluster visibility: shown if any slot applies. trackCluster is a top-level sibling
' statically positioned in XML at [96, 713] — no runtime layout math needed here.
m.trackCluster.visible = videoApplicable or audioApplicable or subtitleApplicable
end sub
' applyDropdown: Push items + selection into a single TrackDropdown. Hides the slot when
' the track type isn't applicable (keeps layout order stable for the other two slots).
sub applyDropdown(dropdown as object, items as object, selectedId as string, applicable as boolean)
if not applicable
dropdown.visible = false
return
end if
dropdown.visible = true
dropdown.items = items
dropdown.selectedItemId = selectedId
dropdown.triggerText = triggerTextForSelection(items, selectedId)
' Single-option slots render as static (non-focusable) text with the same geometry
dropdown.isInteractive = items.count() > 1
end sub
' triggerTextForSelection: Look up the currently-selected item's display title.
function triggerTextForSelection(items as object, selectedId as string) as string
if not isValid(items) or items.count() = 0 then return ""
for each item in items
if isValid(item.id) and item.id = selectedId
return item.title
end if
end for
return items[0].title
end function
' buildVideoDropdownItems: Convert MediaSources into dropdown items. "Video" here is the
' MediaSource, not a stream — it's the user-facing alternate-version selector (e.g. "1080p
' H264" vs "4K HEVC HDR10").
function buildVideoDropdownItems(mediaSources as object) as object
items = []
if not isValid(mediaSources) then return items
hasMultipleSources = mediaSources.count() > 1
for each source in mediaSources
if isValid(source) and source.VideoType = "VideoFile" and isValidAndNotEmpty(source.id)
items.push({ id: source.id, title: formatVideoSourceTitle(source, hasMultipleSources) })
end if
end for
return items
end function
' buildAudioDropdownItems: Convert the MediaStreams array into dropdown items for audio.
' Item id is the stringified server-side stream index, matching the ItemDetails contract
' that selectedAudioStreamIndex is an integer spanning all stream types.
function buildAudioDropdownItems(streams as object) as object
items = []
if not isValid(streams) then return items
for each stream in streams
if isValid(stream) and isValid(stream.Type) and LCase(stream.Type) = "audio" and isValid(stream.index)
items.push({ id: str(stream.index).trim(), title: formatAudioDisplayTitle(stream) })
end if
end for
return items
end function
' buildSubtitleDropdownItems: Convert the MediaStreams array into dropdown items for
' subtitles.
'
' Two shapes are returned depending on whether the item actually has subtitle streams:
' * No subtitle streams at all → [{ id:"-1", title:"None" }]. Rendered as a static
' non-interactive slot so the "Subtitles" title row still shows and the layout
' stays consistent across items. "None" (not "Off") because the user has no
' choice to make — subtitles simply don't exist on this item.
' * One or more subtitle streams → [{ id:"-1", title:"Off" }, ...tracks]. The
' "Off" entry lets the user disable subtitles when tracks ARE present. id "-1"
' is the SubtitleSelection.NONE sentinel used by m.top.selectedSubtitleStreamIndex.
function buildSubtitleDropdownItems(streams as object) as object
tracks = []
if isValid(streams)
for each stream in streams
if isValid(stream) and isValid(stream.Type) and LCase(stream.Type) = "subtitle" and isValid(stream.index)
tracks.push({ id: str(stream.index).trim(), title: formatSubtitleDisplayTitle(stream) })
end if
end for
end if
if tracks.count() = 0
return [{ id: "-1", title: translate(translationKeys.LabelNone) }]
end if
items = [{ id: "-1", title: translate(translationKeys.LabelOff) }]
for each track in tracks
items.push(track)
end for
return items
end function
' getStreamsForSelectedSource: Pull the MediaStreams array from whichever media source
' matches m.top.selectedVideoStreamId. Falls back to mediaSources[0] when the selected
' id isn't found (e.g. first load before selection).
function getStreamsForSelectedSource(mediaSources as object) as object
if not isValid(mediaSources) then return []
for each source in mediaSources
if source.Id = m.top.selectedVideoStreamId and isValid(source.MediaStreams)
return source.MediaStreams
end if
end for
if isValid(mediaSources[0]) and isValid(mediaSources[0].MediaStreams)
return mediaSources[0].MediaStreams
end if
return []
end function
' SetDefaultAudioTrack: Auto-select the best audio stream based on user language prefs,
' storing the result on m.top.selectedAudioStreamIndex. The dropdown trigger label is
' refreshed separately by applyDropdown() using the same index.
sub SetDefaultAudioTrack(mediaStreams as object)
if not isValid(mediaStreams) then return
localUser = m.global.user
playDefault = resolvePlayDefaultAudioTrack(localUser.settings, localUser.config)
selectedIndex = findBestAudioStreamIndex(mediaStreams, playDefault, resolveAudioLanguagePreference(localUser.settings, localUser.config))
m.top.selectedAudioStreamIndex = selectedIndex
end sub
sub setFieldText(field, value)
node = m.top.findNode(field)
if not isValid(node) or not isValid(value) then return
if type(value) = "roInt" or type(value) = "Integer"
value = str(value).trim()
else if type(value) = "roFloat" or type(value) = "Float"
value = str(value).trim()
else if type(value) <> "roString" and type(value) <> "String"
value = ""
else
value = value.trim()
end if
node.text = value
end sub
function getRuntime() as integer
' A tick is .1ms, so 1/10,000,000 for ticks to seconds,
' then 1/60 for seconds to minutes... 1/600,000,000
return round(m.top.itemContent.runTimeTicks / 600000000.0)
end function
' createEndsAtNode: Build an "Ends at" time label with AM/PM support and store it
' on m.endsAtNode for dynamic clock updates. Used by types with runTimeTicks-based durations.
sub createEndsAtNode()
date = CreateObject("roDateTime")
date.fromSeconds(date.asSeconds() + m.endsAtDurationSeconds)
date.toLocalTime()
m.endsAtNode = createTimeLabel("ends-at", translate(translationKeys.MessageEndsAt1, [formatTime(date)]), getTimePeriod(date))
end sub
' onClockMinuteChanged: Dynamically update "Ends At" time when overhang clock minute changes.
sub onClockMinuteChanged()
if not isValid(m.endsAtNode) then return
date = CreateObject("roDateTime")
date.fromSeconds(date.asSeconds() + m.endsAtDurationSeconds)
date.toLocalTime()
timeStr = formatTime(date)
periodText = getTimePeriod(date)
if periodText <> "" then timeStr += " " + periodText
m.endsAtNode.text = translate(translationKeys.MessageEndsAt1, [timeStr])
end sub
sub updateFavoriteButton()
fave = m.top.itemContent.isFavorite
favoriteButton = m.top.findNode("favoriteButton")
if isValid(favoriteButton)
if isValid(fave) and fave
favoriteButton.isButtonSelected = true
else
favoriteButton.isButtonSelected = false
end if
end if
end sub
sub updateWatchedButton()
watched = m.top.itemContent.isWatched
watchedButton = m.top.findNode("watchedButton")
if isValid(watchedButton)
if watched
watchedButton.isButtonSelected = true
else
watchedButton.isButtonSelected = false
end if
end if
end sub
' setItemLogo: Set the item logo image URL if available.
' Fallback chains by type — every chain ends in a typed placeholder via
' getPlaceholderImagePath() so the logo slot never goes blank, matching the
' pattern Person and MusicArtist always used:
' - Movie/Series/BoxSet: Logo → primaryImageTag (poster, compact size) → placeholder
' - Episode/Season/Recording: primaryImageTag → parentLogoImageTag (series logo) → seriesPrimaryImageTag → placeholder
' - Video/MusicVideo: primaryImageTag → placeholder
' - MusicAlbum / Playlist / Photo / PhotoAlbum / TvChannel: primaryImageTag → placeholder
' - Program: primaryImageTag → channel logo → placeholder
' - Audio: primaryImageTag → album art → placeholder
' - Person / MusicArtist: primaryImageTag (portrait) → placeholder
' @param {object} item - JellyfinBaseItem node
sub setItemLogo(item as object)
if not isValid(m.itemLogo) then return
logoItemId = item.id
logoImageTag = item.logoImageTag
if item.type = "Person"
' Person: display primary (portrait) photo where the logo sits; fall back to silhouette glyph
if isValidAndNotEmpty(item.primaryImageTag)
imgParams = { maxHeight: 534, maxWidth: LOGO_MAX_DISPLAY_WIDTH, quality: 90, tag: item.primaryImageTag }
m.itemLogo.uri = ImageURL(item.id, "Primary", imgParams)
else
m.itemLogo.uri = getPlaceholderImagePath(item.type)
end if
return
else if item.type = "MusicArtist"
' Same as Person — primary photo where the logo sits, falling back to the
' canonical artist silhouette (account_box) when no primary image. Matches
' what JRRowItem shows for MusicArtist items in the row list and what
' MusicArtistGridItem shows on grid fallback.
if isValidAndNotEmpty(item.primaryImageTag)
imgParams = { maxHeight: 534, maxWidth: LOGO_MAX_DISPLAY_WIDTH, quality: 90, tag: item.primaryImageTag }
m.itemLogo.uri = ImageURL(item.id, "Primary", imgParams)
else
m.itemLogo.uri = getPlaceholderImagePath(item.type)
end if
return
else if item.type = "MusicAlbum"
' Square album art as the primary image; typed placeholder if none
if isValidAndNotEmpty(item.primaryImageTag)
imgParams = { maxHeight: 534, maxWidth: LOGO_MAX_DISPLAY_WIDTH, quality: 90, tag: item.primaryImageTag }
m.itemLogo.uri = ImageURL(item.id, "Primary", imgParams)
else
m.itemLogo.uri = getPlaceholderImagePath(item.type)
end if
return
else if item.type = "Playlist"
' Square playlist cover as primary image; typed placeholder if none
if isValidAndNotEmpty(item.primaryImageTag)
imgParams = { maxHeight: 534, maxWidth: LOGO_MAX_DISPLAY_WIDTH, quality: 90, tag: item.primaryImageTag }
m.itemLogo.uri = ImageURL(item.id, "Primary", imgParams)
else
m.itemLogo.uri = getPlaceholderImagePath(item.type)
end if
return
else if item.type = "Photo" or item.type = "PhotoAlbum"
' Photo thumbnail or album cover as primary image; typed placeholder if none
if isValidAndNotEmpty(item.primaryImageTag)
imgParams = { maxHeight: 534, maxWidth: LOGO_MAX_DISPLAY_WIDTH, quality: 90, tag: item.primaryImageTag }
m.itemLogo.uri = ImageURL(item.id, "Primary", imgParams)
else
m.itemLogo.uri = getPlaceholderImagePath(item.type)
end if
return
else if item.type = "TvChannel"
' Channel logo (typically square); folder-fallback placeholder if none
if isValidAndNotEmpty(item.primaryImageTag)
imgParams = { maxHeight: 534, maxWidth: LOGO_MAX_DISPLAY_WIDTH, quality: 90, tag: item.primaryImageTag }
m.itemLogo.uri = ImageURL(item.id, "Primary", imgParams)
else
m.itemLogo.uri = getPlaceholderImagePath(item.type)
end if
return
else if item.type = "Program"
' Program image → channel logo → typed placeholder
if isValidAndNotEmpty(item.primaryImageTag)
imgParams = { maxHeight: 534, maxWidth: LOGO_MAX_DISPLAY_WIDTH, quality: 90, tag: item.primaryImageTag }
m.itemLogo.uri = ImageURL(item.id, "Primary", imgParams)
else if isValidAndNotEmpty(item.channelPrimaryImageTag) and isValidAndNotEmpty(item.channelId)
imgParams = { maxHeight: 534, maxWidth: LOGO_MAX_DISPLAY_WIDTH, quality: 90, tag: item.channelPrimaryImageTag }
m.itemLogo.uri = ImageURL(item.channelId, "Primary", imgParams)
else
m.itemLogo.uri = getPlaceholderImagePath(item.type)
end if
return
else if item.type = "Audio"
' Own primary image → album art fallback → typed placeholder
if isValidAndNotEmpty(item.primaryImageTag)
imgParams = { maxHeight: 534, maxWidth: LOGO_MAX_DISPLAY_WIDTH, quality: 90, tag: item.primaryImageTag }
m.itemLogo.uri = ImageURL(item.id, "Primary", imgParams)
else if isValidAndNotEmpty(item.albumId) and isValidAndNotEmpty(item.albumPrimaryImageTag)
imgParams = { maxHeight: 534, maxWidth: LOGO_MAX_DISPLAY_WIDTH, quality: 90, tag: item.albumPrimaryImageTag }
m.itemLogo.uri = ImageURL(item.albumId, "Primary", imgParams)
else
m.itemLogo.uri = getPlaceholderImagePath(item.type)
end if
return
else if item.type = "Episode" or item.type = "Season" or item.type = "Recording"
' Show the item's own primary image first (episode still, season poster).
' If none, fall through to the series logo chain via logoItemId/logoImageTag.
if isValidAndNotEmpty(item.primaryImageTag)
imgParams = { maxHeight: 534, maxWidth: LOGO_MAX_DISPLAY_WIDTH, quality: 90, tag: item.primaryImageTag }
m.itemLogo.uri = ImageURL(item.id, "Primary", imgParams)
return
end if
if isValidAndNotEmpty(item.parentLogoItemId)
logoItemId = item.parentLogoItemId
logoImageTag = item.parentLogoImageTag
end if
else if item.type = "Video" or item.type = "MusicVideo"
' Videos and MusicVideos don't typically have Logo images — use Primary (poster)
' image instead; playCircle placeholder if none.
if isValidAndNotEmpty(item.primaryImageTag)
imgParams = { maxHeight: 212, maxWidth: 500, quality: 90, tag: item.primaryImageTag }
m.itemLogo.uri = ImageURL(item.id, "Primary", imgParams)
else
m.itemLogo.uri = getPlaceholderImagePath(item.type)
end if
return
end if
if isValidAndNotEmpty(logoImageTag)
imgParams = { maxHeight: 212, maxWidth: 500, quality: 90, tag: logoImageTag }
m.itemLogo.uri = ImageURL(logoItemId, "Logo", imgParams)
else if item.type = "Movie" or item.type = "Series" or item.type = "BoxSet"
' No logo: fall back to primary (poster) image — fetch at full quality; display height is
' capped by LOGO_MAX_DISPLAY_HEIGHT in onLogoLoadStatusChanged to avoid overlapping info rows.
' Movie placeholder if neither logo nor primary is available.
if isValidAndNotEmpty(item.primaryImageTag)
imgParams = { maxHeight: 534, maxWidth: LOGO_MAX_DISPLAY_WIDTH, quality: 90, tag: item.primaryImageTag }
m.itemLogo.uri = ImageURL(item.id, "Primary", imgParams)
else
m.itemLogo.uri = getPlaceholderImagePath(item.type)
end if
else if item.type = "Episode" or item.type = "Season" or item.type = "Recording"
' No item primary (already tried above), no series logo: fall back to series primary poster,
' then a playCircle placeholder if the series has no primary image either.
if isValidAndNotEmpty(item.seriesPrimaryImageTag) and isValidAndNotEmpty(item.seriesId)
imgParams = { maxHeight: 534, maxWidth: LOGO_MAX_DISPLAY_WIDTH, quality: 90, tag: item.seriesPrimaryImageTag }
m.itemLogo.uri = ImageURL(item.seriesId, "Primary", imgParams)
else
m.itemLogo.uri = getPlaceholderImagePath(item.type)
end if
else
' Catch-all: any other type without a known fallback chain gets the type-driven
' placeholder (falls through to the folder glyph for genuinely unknown types).
m.itemLogo.uri = getPlaceholderImagePath(item.type)
end if
end sub
' setDateAdded: Set date added label text and position at bottom right corner
' @param {object} item - JellyfinBaseItem node
sub setDateAdded(item as object)
if not isValid(m.dateCreatedLabel) then return
if not isValidAndNotEmpty(item.dateCreated)
m.dateCreatedLabel.visible = false
return
end if
dateAdded = CreateObject("roDateTime")
dateAdded.FromISO8601String(item.dateCreated)
' Hide dates at or before Unix epoch — server returned no real date
if dateAdded.getYear() <= 1970
m.dateCreatedLabel.visible = false
return
end if
dateAdded.toLocalTime()
m.dateCreatedLabel.text = translate(translationKeys.LabelAdded) + " " + dateAdded.AsDateString("short-month-no-weekday") + " " + formatTime(dateAdded)
' Set X here; Y is computed by anchorDateLabel() which handles both the initial
' placement (using the button group's XML translation + a safe height estimate) and
' later refinements when the button group's actual rendered height is known. Calling
' it here guarantees the label is correctly positioned on the very first render —
' no flash at the top of the screen.
m.dateCreatedLabel.translation = [1920 - 96 - 450, m.dateCreatedLabel.translation[1]]
m.dateCreatedLabel.visible = true
anchorDateLabel()
end sub
' anchorDateLabel: Derive the date label's Y position from the button group's position.
' Returns dateLabelY so callers (e.g. logo positioning) can use it without duplicating the math.
' Safe to call multiple times — idempotent, and safe to call BEFORE the button group has
' finished laying out: falls back to the group's fixed translation + ESTIMATED_BUTTON_ROW_HEIGHT_PX
' so the label never lands off-screen. A later renderTracking callback refines the position
' once the real rendered height is known.
' @return {integer} dateLabelY — the Y coordinate used for the date label
function anchorDateLabel() as integer
buttonY = m.buttonGrp.translation[1]
buttonHeight = ESTIMATED_BUTTON_ROW_HEIGHT_PX
' Refine with the measured height once layout has settled. Ignore the degenerate 0-size
' bounding rect reported during the initial render — that's what was pushing the label
' to y=-30 on the Series detail screen.
buttonRect = m.buttonGrp.boundingRect()
if buttonRect.height > 0
buttonHeight = buttonRect.height
end if
dateLabelY = buttonY + buttonHeight - 30
if isValid(m.dateCreatedLabel) and m.dateCreatedLabel.visible
m.dateCreatedLabel.translation = [m.dateCreatedLabel.translation[0], dateLabelY]
end if
return dateLabelY
end function
' onButtonGrpRendered: Fires after the render thread settles the button group layout.
' Re-anchors the date label and repositions the logo. The track dropdown cluster lives
' inside itemDetails LayoutGroup and is positioned automatically by the layout engine,
' so it doesn't need to be re-placed here.
sub onButtonGrpRendered()
if m.buttonGrp.renderTracking = "none" then return
dateLabelY = anchorDateLabel()
' If the logo is already loaded and visible, reposition it relative to the new dateLabelY.
if isValid(m.itemLogo) and m.itemLogo.visible
logoY = dateLabelY - m.itemLogo.height - 18
m.itemLogo.translation = [m.itemLogo.translation[0], logoY]
end if
end sub
' onLogoLoadStatusChanged: Position logo to the right, with its bottom just above the date label.
' When the date label is hidden, anchors to where the date label would be.
' Images smaller than LOGO_MIN_DISPLAY_HEIGHT are scaled up while preserving aspect ratio,
' unless the logo is very wide/flat, in which case LOGO_MAX_DISPLAY_WIDTH takes precedence.
sub onLogoLoadStatusChanged()
if not isValid(m.itemLogo) then return
if m.itemLogo.loadStatus = "ready"
' Tint placeholder PNGs (white glyph on transparent BG) with a near-page-BG
' color so they recess like a watermark instead of competing with the title.
' Detection by URI prefix: every placeholder asset lives under
' pkg:/images/placeholders/. Real (server-loaded) images need the default
' white blendColor so their native colors render correctly.
if Left(m.itemLogo.uri, 24) = "pkg:/images/placeholders"
m.itemLogo.blendColor = m.global.constants.colorBackgroundSecondary
else
m.itemLogo.blendColor = "0xFFFFFFFF"
end if
logoWidth = m.itemLogo.bitmapWidth
logoHeight = m.itemLogo.bitmapHeight
if logoWidth > 0 and logoHeight > 0
' Compute display size satisfying both constraints where possible.
' LOGO_MAX_DISPLAY_WIDTH is a hard layout constraint (prevents button overlap).
' LOGO_MIN_DISPLAY_HEIGHT is a best-effort aesthetic target — it cannot be
' guaranteed when the logo is too wide/flat to satisfy both simultaneously.
scaleToMinHeight = LOGO_MIN_DISPLAY_HEIGHT / logoHeight
if logoHeight >= LOGO_MIN_DISPLAY_HEIGHT and logoWidth <= LOGO_MAX_DISPLAY_WIDTH
' Native size already satisfies both constraints.
displayWidth = logoWidth
displayHeight = logoHeight
else if int(logoWidth * scaleToMinHeight) <= LOGO_MAX_DISPLAY_WIDTH
' Scaling up to min-height keeps width within max-width — both satisfied.
displayWidth = int(logoWidth * scaleToMinHeight)
displayHeight = LOGO_MIN_DISPLAY_HEIGHT
else
' Very wide/flat logo: max-width is the binding constraint.
' Height will be below LOGO_MIN_DISPLAY_HEIGHT — max-width takes precedence.
displayWidth = LOGO_MAX_DISPLAY_WIDTH
displayHeight = int(logoHeight * LOGO_MAX_DISPLAY_WIDTH / logoWidth)
end if
' Cap portrait images for non-Person/MusicArtist items so the logo top stays below the metadata rows.
' Person and MusicArtist are exempt — their info rows are short and don't reach the right edge.
if isValid(m.top.itemContent) and m.top.itemContent.type <> "Person" and m.top.itemContent.type <> "MusicArtist"
if displayHeight > LOGO_MAX_DISPLAY_HEIGHT
displayWidth = int(displayWidth * LOGO_MAX_DISPLAY_HEIGHT / displayHeight)
displayHeight = LOGO_MAX_DISPLAY_HEIGHT
end if
end if
m.itemLogo.width = displayWidth
m.itemLogo.height = displayHeight
m.itemLogo.loadDisplayMode = "scaleToFit"
rightEdge = 1920 * 0.95
logoX = rightEdge - displayWidth
dateLabelY = anchorDateLabel()
logoY = dateLabelY - displayHeight - 18
m.itemLogo.translation = [logoX, logoY]
m.itemLogo.visible = true
end if
else if m.itemLogo.loadStatus = "failed"
m.itemLogo.uri = ""
m.itemLogo.visible = false
end if
end sub
sub onItemDetailsRendered()
if m.itemDetails.renderTracking = "none" then return
updateTextGradient()
end sub
' updateItemDetailsAnimationTarget: Slide itemDetails so the bottom of itemInfoRows sits just
' above the extras pane. boundingRect() on a child returns LOCAL coords relative to the parent's
' translation point, so we use translation[1] (not boundingRect().y) as the screen origin.
' Called just-in-time from the DOWN key handler to guarantee the layout is fully settled
' (text wrapping complete) before measuring. Eager calculation via renderTracking races
' with async text layout, producing stale targets — especially for long descriptions.
sub updateItemDetailsAnimationTarget()
currentTransY = m.itemDetails.translation[1]
itemInfoRowsLocalRect = m.itemInfoRows.boundingRect()
screenBottomOfInfoRows = currentTransY + itemInfoRowsLocalRect.y + itemInfoRowsLocalRect.height
dY = (extrasLayout.PANEL_OPEN_Y - ITEM_DETAILS_EXTRAS_PADDING) - screenBottomOfInfoRows
targetTransY = currentTransY + dY
startTransX = m.itemDetails.translation[0]
m.itemDetailsSliderInterp.keyValue = [[startTransX, currentTransY], [startTransX, targetTransY]]
end sub
sub updateTextGradient()
if not isValid(m.itemTextGradient) or not isValid(m.itemDetails) then return
globalConstants = m.global.constants
itemDetailsRect = m.itemDetails.boundingRect()
itemDetailsTop = itemDetailsRect.y
extrasGrpY = extrasLayout.PANEL_CLOSED_Y
gradientHeight = extrasGrpY - itemDetailsTop
if gradientHeight > 0
m.itemTextGradient.translation = [0, itemDetailsTop]
m.itemTextGradient.height = gradientHeight
m.itemTextGradient.width = 1920
m.itemTextGradient.startColor = globalConstants.colorBlack + globalConstants.alpha60
m.itemTextGradient.endColor = globalConstants.colorBlack + globalConstants.alpha0
end if
end sub
' getButtonIndex: Find the index of a button by ID in the button group
' @param {string} buttonId - The id of the button to find
' @return {integer} The index of the button, or -1 if not found
function getButtonIndex(buttonId as string) as integer
for i = 0 to m.buttonGrp.getChildCount() - 1
child = m.buttonGrp.getChild(i)
if isValid(child) and child.id = buttonId
return i
end if
end for
return -1
end function
' onRefreshExtrasData: Reload all extras rows when the user explicitly presses Refresh.
sub onRefreshExtrasData()
item = m.top.itemContent
if not isValid(item) or not isValidAndNotEmpty(item.id) then return
if item.type = "Person"
m.extrasGrid.callFunc("loadPersonVideos", item.id)
else
m.extrasGrid.type = item.type
m.extrasGrid.callFunc("loadParts", item)
end if
end sub
sub activateExtras()
m.extrasActive = true
end sub
sub deactivateExtras()
m.extrasActive = false
end sub
' onExtrasTargetTranslationYChanged: Animate the RowList translation to the new target.
' Fired early by onKeyEvent in ExtrasRowList (at key press) so the animation runs in parallel
' with the RowList's floatingFocus animation, and confirmed by onRowItemFocused after focus lands.
sub onExtrasTargetTranslationYChanged()
currentY = m.extrasGrid.translation[1]
targetY = m.extrasGrid.targetTranslationY
if abs(currentY - targetY) < 1 then return
m.gridTranslationInterp.keyValue = [[0, currentY], [0, targetY]]
m.gridAnime.control = "start"
end sub
' setDescriptionInLayout: Add or remove itemDescription from the itemDetails LayoutGroup.
' Invisible nodes still take up space in RSG LayoutGroups, so reparenting is required
' to truly eliminate spacing when there is no description text.
sub setDescriptionInLayout(shouldShow as boolean)
if shouldShow = m.itemDescriptionInLayout then return
if shouldShow
m.itemDetails.insertChild(m.itemDescription, 1)
else
m.itemDetails.removeChild(m.itemDescription)
end if
m.itemDescriptionInLayout = shouldShow
end sub
' setTracksInLayout: Add or remove the 62px itemTracks spacer from the itemDetails
' LayoutGroup. When a track cluster is applicable to the current item, the spacer
' stays in the layout and pushes description bottom up to y=701 so the trackCluster
' sibling can sit at y=713 with 12px breathing pad above and 18px below between the
' description and the button group. For item types without any media tracks
' (Series/Person/etc.), the spacer is removed and description bottom drops back to
' y=775 (matching main's behavior — no cluster, no padding, no regression).
sub setTracksInLayout(shouldShow as boolean)
if shouldShow = m.itemTracksInLayout then return
if shouldShow
m.itemDetails.appendChild(m.itemTracks)
else
m.itemDetails.removeChild(m.itemTracks)
end if
m.itemTracksInLayout = shouldShow
end sub
' focusButtonGroupChild: Focus the button at the current buttonFocused index directly
sub focusButtonGroupChild()
focusIndex = m.buttonGrp.buttonFocused
if focusIndex < 0 or focusIndex >= m.buttonGrp.getChildCount()
return
end if
m.buttonGrp.getChild(focusIndex).setFocus(true)
end sub
' setupButtons: Build the complete button set for the given item type.
' Clears all existing buttons and adds the correct sync buttons in order.
' Loading placeholders are added for async buttons (Series Resume, Person Shuffle) so
' the button row renders at its final width immediately. The async handlers replace or remove
' the placeholders when data arrives.
' Called on every itemContentChanged — the button list is rebuilt from scratch each time.
sub setupButtons(item as object)
' Capture focus state BEFORE clearing — removing buttons nulls out focus, so
' isInFocusChain() would return false even if buttons were focused a moment ago.
wasButtonGroupFocused = m.buttonGrp.isInFocusChain()
isInitialLoad = not m.buttonGrp.visible
' Capture the focused button's ID so we can restore to the same button after the rebuild.
' Falling back to index 0 only when the previously-focused button no longer exists (e.g. Delete
' was removed because canDelete changed) or when this is the initial load.
focusedButtonId = ""
focusIndex = m.buttonGrp.buttonFocused
if focusIndex >= 0 and focusIndex < m.buttonGrp.getChildCount()
focusedBtn = m.buttonGrp.getChild(focusIndex)
if isValid(focusedBtn) then focusedButtonId = focusedBtn.id
end if
' Clear everything from a previous content load
while m.buttonGrp.getChildCount() > 0
m.buttonGrp.removeChild(m.buttonGrp.getChild(0))
end while
' Reset dynamic refs that were pointing into the now-cleared button group
m.shuffleButton = invalid
itemType = item.type
' Series Resume placeholder — shows a loading IconButton at position 0 while the seriesResume
' task fetches the next-up episode. onNextUpEpisodeChanged() replaces or removes it.
' Preserves the episode-specific text (e.g. "Resume S2E3") from prior data if available
' so the user sees a consistent label through the loading transition.
if itemType = "Series"
m.resumeLoadingButton = CreateObject("roSGNode", "IconButton")
m.resumeLoadingButton.id = "resumeLoadingButton"
m.resumeLoadingButton.icon = "pkg:/images/icons/resume_$$RES$$.png"
if isValid(m.top.nextUpEpisode) and isValidAndNotEmpty(m.top.nextUpEpisode.id)
m.resumeLoadingButton.text = getResumeButtonText(m.top.nextUpEpisode)
else
m.resumeLoadingButton.text = translate(translationKeys.ButtonResume)
end if
m.resumeLoadingButton.isLoading = true
m.buttonGrp.appendChild(m.resumeLoadingButton)
end if
' Photo: View button
if itemType = "Photo"
viewPhotoButton = CreateObject("roSGNode", "IconButton")
viewPhotoButton.id = "viewPhotoButton"
viewPhotoButton.icon = "pkg:/images/icons/play_$$RES$$.png"
viewPhotoButton.text = translate(translationKeys.LabelView)
m.buttonGrp.appendChild(viewPhotoButton)
end if
' PhotoAlbum: Slideshow button
if itemType = "PhotoAlbum"
slideshowButton = CreateObject("roSGNode", "IconButton")
slideshowButton.id = "slideshowButton"
slideshowButton.icon = "pkg:/images/icons/play_$$RES$$.png"
slideshowButton.text = translate(translationKeys.LabelSlideshow)
m.buttonGrp.appendChild(slideshowButton)
end if
' Determine if a Program is currently airing by checking startDate/endDate against current time.
' More reliable than item.isLive which depends on the API response being fresh.
programIsLiveNow = false
programIsFuture = false
if itemType = "Program" and isValidAndNotEmpty(item.startDate) and isValidAndNotEmpty(item.endDate)
progStartDt = createObject("roDateTime")
progStartDt.FromISO8601String(item.startDate)
progEndDt = createObject("roDateTime")
progEndDt.FromISO8601String(item.endDate)
progNow = createObject("roDateTime")
nowSec = progNow.AsSeconds()
programIsLiveNow = nowSec >= progStartDt.AsSeconds() and nowSec < progEndDt.AsSeconds()
programIsFuture = nowSec < progStartDt.AsSeconds()
else if itemType = "Program" and item.isLive
' Fallback for programs without start/end dates
programIsLiveNow = true
end if
' TvChannel: Watch button with live progress bar (always shown — channels are always live)
' Program: Watch button only when program is currently airing
showWatch = itemType = "TvChannel" or (itemType = "Program" and programIsLiveNow)
if showWatch
' Determine the program whose start/end times drive the progress bar
liveProg = invalid
if itemType = "TvChannel" and isValid(item.currentProgram)
liveProg = item.currentProgram
else if itemType = "Program"
liveProg = item
end if
' Calculate ticks for progress bar (1 second = 10,000,000 ticks)
hasTimes = isValid(liveProg) and isValidAndNotEmpty(liveProg.startDate) and isValidAndNotEmpty(liveProg.endDate)
if hasTimes
startDt = createObject("roDateTime")
startDt.FromISO8601String(liveProg.startDate)
endDt = createObject("roDateTime")
endDt.FromISO8601String(liveProg.endDate)
now = createObject("roDateTime")
runtimeSeconds& = endDt.AsSeconds() - startDt.AsSeconds()
elapsedSeconds& = now.AsSeconds() - startDt.AsSeconds()
if elapsedSeconds& < 0 then elapsedSeconds& = 0
if elapsedSeconds& > runtimeSeconds& then elapsedSeconds& = runtimeSeconds&
watchChannelButton = CreateObject("roSGNode", "ResumeButton")
watchChannelButton.id = "watchChannelButton"
watchChannelButton.icon = "pkg:/images/icons/play_$$RES$$.png"
watchChannelButton.runtimeTicks = runtimeSeconds& * 10000000&
watchChannelButton.playbackPositionTicks = elapsedSeconds& * 10000000&
else
watchChannelButton = CreateObject("roSGNode", "IconButton")
watchChannelButton.id = "watchChannelButton"
watchChannelButton.icon = "pkg:/images/icons/play_$$RES$$.png"
end if
watchChannelButton.text = translate(translationKeys.LabelWatch)
m.buttonGrp.appendChild(watchChannelButton)
end if
' Record button — TvChannel (always recordable) and Program (live or future)
if m.global.user.policy.enableLiveTvManagement = true
showRecord = itemType = "TvChannel" or (itemType = "Program" and (programIsLiveNow or programIsFuture))
if showRecord
recordButton = CreateObject("roSGNode", "IconButton")
recordButton.id = "recordButton"
recordButton.icon = "pkg:/images/icons/record_$$RES$$.png"
' Check existing recording state — colorError icon when active
isRecording = false
if itemType = "Program" and isValidAndNotEmpty(item.timerId)
isRecording = true
else if itemType = "TvChannel" and isValid(item.currentProgram) and isValidAndNotEmpty(item.currentProgram.timerId)
isRecording = true
end if
if isRecording
recordButton.text = translate(translationKeys.LabelCancelRecording)
recordButton.iconBackground = m.global.constants.colorError
recordButton.iconFocusBackground = m.global.constants.colorError
else
recordButton.text = translate(translationKeys.ButtonRecord)
end if
m.buttonGrp.appendChild(recordButton)
end if
end if
' Play button — standard content types (not Person, Photo, PhotoAlbum, TvChannel, Program)
if itemType <> "Person" and itemType <> "Photo" and itemType <> "PhotoAlbum" and itemType <> "TvChannel" and itemType <> "Program"
playButton = CreateObject("roSGNode", "IconButton")
playButton.id = "playButton"
playButton.icon = "pkg:/images/icons/play_$$RES$$.png"
if itemType = "Series" or itemType = "Season" or itemType = "BoxSet" or itemType = "MusicArtist" or itemType = "MusicAlbum" or itemType = "Playlist"
playButton.text = translate(translationKeys.LabelPlayAll)
else
playButton.text = translate(translationKeys.ButtonPlay)
end if
m.buttonGrp.appendChild(playButton)
end if
' Shuffle button — Series, Season, BoxSet, MusicArtist, MusicAlbum, Playlist, PhotoAlbum (not Audio; Person shuffle is async)
if itemType = "Series" or itemType = "Season" or itemType = "BoxSet" or itemType = "MusicArtist" or itemType = "MusicAlbum" or itemType = "Playlist" or itemType = "PhotoAlbum"
m.shuffleButton = CreateObject("roSGNode", "IconButton")
m.shuffleButton.id = "shuffleButton"
m.shuffleButton.icon = "pkg:/images/icons/shuffle_$$RES$$.png"
m.shuffleButton.text = translate(translationKeys.ButtonShuffle)
m.buttonGrp.appendChild(m.shuffleButton)
end if
' Watched button — not for Person, music types, Playlist, Photo, PhotoAlbum, TvChannel, or Program
' (Playlist Watched is added async by onPlaylistContentKindChanged() once the items chain confirms video items)
if not inArray(itemTypeOrder.NO_WATCHED, itemType)
watchedButton = CreateObject("roSGNode", "IconButton")
watchedButton.id = "watchedButton"
watchedButton.icon = "pkg:/images/icons/check_$$RES$$.png"
watchedButton.text = translate(translationKeys.LabelWatched)
m.buttonGrp.appendChild(watchedButton)
end if
' Person Shuffle placeholder — shows a loading IconButton while the extras chain determines
' whether this person has playable media. onPersonHasMediaChanged() replaces or removes it.
if itemType = "Person"
m.shuffleLoadingButton = CreateObject("roSGNode", "IconButton")
m.shuffleLoadingButton.id = "shuffleLoadingButton"
m.shuffleLoadingButton.icon = "pkg:/images/icons/shuffle_$$RES$$.png"
m.shuffleLoadingButton.text = translate(translationKeys.ButtonShuffle)
m.shuffleLoadingButton.isLoading = true
m.buttonGrp.appendChild(m.shuffleLoadingButton)
' Guard counter: the ExtrasRowList observer fires multiple times — first a reset
' (personHasMedia=false at chain start), then the definitive answer(s). We skip the
' first fire (the reset) so the placeholder stays visible until a real answer arrives.
m.personMediaFireCount = 0
end if
' Instant Mix button — MusicArtist, MusicAlbum, Audio (grouped with play buttons before Favorite)
if itemType = "MusicArtist" or itemType = "MusicAlbum" or itemType = "Audio"
instantMixButton = CreateObject("roSGNode", "IconButton")
instantMixButton.id = "instantMixButton"
instantMixButton.icon = "pkg:/images/icons/instantMix_$$RES$$.png"
instantMixButton.text = translate(translationKeys.LabelInstantMix)
m.buttonGrp.appendChild(instantMixButton)
end if
' Favorite button — all types
favoriteButton = CreateObject("roSGNode", "IconButton")
favoriteButton.id = "favoriteButton"
favoriteButton.icon = "pkg:/images/icons/heart_$$RES$$.png"
favoriteButton.text = translate(translationKeys.LabelFavorite)
m.buttonGrp.appendChild(favoriteButton)
' Go to Series button — Season, Episode, and Program (when program is a series)
if itemType = "Season" or itemType = "Episode" or (itemType = "Program" and isValidAndNotEmpty(item.seriesId))
goToSeriesButton = CreateObject("roSGNode", "IconButton")
goToSeriesButton.id = "goToSeriesButton"
goToSeriesButton.icon = "pkg:/images/icons/tv_$$RES$$.png"
goToSeriesButton.text = translate(translationKeys.LabelGoToSeries)
m.buttonGrp.appendChild(goToSeriesButton)
end if
' Go to Channel button — Program only (navigate to TvChannel details)
if itemType = "Program" and isValidAndNotEmpty(item.channelId)
goToChannelButton = CreateObject("roSGNode", "IconButton")
goToChannelButton.id = "goToChannelButton"
goToChannelButton.icon = "pkg:/images/icons/tv_$$RES$$.png"
goToChannelButton.text = translate(translationKeys.LabelGoToChannel)
m.buttonGrp.appendChild(goToChannelButton)
end if
' Go to Album button — Audio only (when album ID is available)
if itemType = "Audio" and isValidAndNotEmpty(item.albumId)
goToAlbumButton = CreateObject("roSGNode", "IconButton")
goToAlbumButton.id = "goToAlbumButton"
goToAlbumButton.icon = "pkg:/images/icons/album_$$RES$$.png"
goToAlbumButton.text = translate(translationKeys.LabelGoToAlbum)
m.buttonGrp.appendChild(goToAlbumButton)
end if
' Go to Artist button — MusicAlbum only.
' Prefers albumArtists (primary album artist with an ID). Falls back to artistItems when
' there is exactly one contributing artist — avoids the ambiguity of multi-artist compilations.
if itemType = "MusicAlbum"
hasNavigableArtist = false
if isValid(item.albumArtists) and item.albumArtists.count() > 0
firstAlbumArtist = item.albumArtists[0]
if isValid(firstAlbumArtist) and isValidAndNotEmpty(firstAlbumArtist.Id)
hasNavigableArtist = true
end if
end if
if not hasNavigableArtist and isValid(item.artistItems) and item.artistItems.count() = 1
firstArtistItem = item.artistItems[0]
if isValid(firstArtistItem) and isValidAndNotEmpty(firstArtistItem.Id)
hasNavigableArtist = true
end if
end if
if hasNavigableArtist
goToArtistButton = CreateObject("roSGNode", "IconButton")
goToArtistButton.id = "goToArtistButton"
goToArtistButton.icon = "pkg:/images/icons/person_$$RES$$.png"
goToArtistButton.text = translate(translationKeys.LabelGoToArtist)
m.buttonGrp.appendChild(goToArtistButton)
end if
end if
' Trailer button — on refresh, restored from prior run while the async re-check runs.
' onTrailerAvailableChanged() will add/remove it if the server state changed.
' Must come before Delete to match the insertion order used in onTrailerAvailableChanged()
if m.top.trailerAvailable
trailerButton = CreateObject("roSGNode", "IconButton")
trailerButton.id = "trailerButton"
trailerButton.icon = "pkg:/images/icons/playOutline_$$RES$$.png"
trailerButton.text = translate(translationKeys.LabelPlayTrailer)
m.buttonGrp.appendChild(trailerButton)
end if
' Delete button — when server grants permission
if item.canDelete
deleteButton = CreateObject("roSGNode", "IconButton")
deleteButton.id = "deleteButton"
deleteButton.icon = "pkg:/images/icons/delete_$$RES$$.png"
deleteButton.text = translate(translationKeys.ButtonDelete)
m.buttonGrp.appendChild(deleteButton)
end if
' Refresh button — always last
refreshButton = CreateObject("roSGNode", "IconButton")
refreshButton.id = "refreshButton"
refreshButton.icon = "pkg:/images/icons/refresh_$$RES$$.png"
refreshButton.text = translate(translationKeys.ButtonRefresh)
m.buttonGrp.appendChild(refreshButton)
' Show the button group (hidden until first setupButtons call)
m.buttonGrp.visible = true
m.top.lastFocus = m.buttonGrp
' Restore focus to the same button the user was on before the rebuild, identified by ID.
' Falls back to index 0 when the button no longer exists or this is the initial load.
restoredIndex = 0
if focusedButtonId <> ""
restoredIndex = getButtonIndex(focusedButtonId)
if restoredIndex < 0 then restoredIndex = 0
end if
m.buttonGrp.buttonFocused = restoredIndex
if isInitialLoad or wasButtonGroupFocused
focusButtonGroupChild()
end if
end sub
' onPersonHasMediaChanged: Show or hide the Shuffle button once the person extras chain completes.
' On first load, replaces the LoadingButton placeholder from setupButtons().
' On refresh, inserts/removes the Shuffle button directly.
' Fires via alwaysNotify so it triggers even when personHasMedia stays the same value (e.g. on refresh).
sub onPersonHasMediaChanged()
if not isValid(m.top.itemContent) or m.top.itemContent.type <> "Person" then return
loadingButton = m.top.findNode("shuffleLoadingButton")
m.personMediaFireCount++
if m.extrasGrid.personHasMedia
if not isValid(m.shuffleButton)
m.shuffleButton = CreateObject("roSGNode", "IconButton")
m.shuffleButton.id = "shuffleButton"
m.shuffleButton.icon = "pkg:/images/icons/shuffle_$$RES$$.png"
m.shuffleButton.text = translate(translationKeys.ButtonShuffle)
if isValid(loadingButton)
' Swap the LoadingButton placeholder for the real shuffle button.
' The user may have moved focus to another button before async data arrived,
' so capture which button has focus before the remove/insert.
loadingIndex = getButtonIndex("shuffleLoadingButton")
wasFocused = loadingButton.isInFocusChain()
currentFocusIndex = m.buttonGrp.buttonFocused
m.buttonGrp.removeChild(loadingButton)
m.shuffleLoadingButton = invalid
m.buttonGrp.insertChild(m.shuffleButton, loadingIndex)
' Remove then insert at same index is a net-zero index shift,
' so the original focusIndex is still correct.
m.buttonGrp.buttonFocused = currentFocusIndex
if wasFocused
focusButtonGroupChild()
end if
else
' No placeholder (refresh path) — insert before Favorite
currentFocusIndex = m.buttonGrp.buttonFocused
favoriteButtonIndex = getButtonIndex("favoriteButton")
insertIndex = 0
if favoriteButtonIndex >= 0 then insertIndex = favoriteButtonIndex
m.buttonGrp.insertChild(m.shuffleButton, insertIndex)
if currentFocusIndex = 0
m.buttonGrp.buttonFocused = 0
else
m.buttonGrp.buttonFocused = currentFocusIndex + 1
end if
end if
if m.buttonGrp.isInFocusChain()
focusButtonGroupChild()
end if
end if
else if m.personMediaFireCount > 1
' Skip the first false (ExtrasRowList reset at chain start). Any subsequent false is
' a definitive answer from the completed chain — safe to remove the placeholder.
if isValid(loadingButton)
currentFocusIndex = m.buttonGrp.buttonFocused
loadingIndex = getButtonIndex("shuffleLoadingButton")
wasFocused = loadingButton.isInFocusChain()
m.buttonGrp.removeChild(loadingButton)
m.shuffleLoadingButton = invalid
if isValid(loadingIndex) and loadingIndex >= 0 and currentFocusIndex >= loadingIndex
focusIdx = currentFocusIndex - 1
if focusIdx < 0 then focusIdx = 0
m.buttonGrp.buttonFocused = focusIdx
end if
if wasFocused
focusButtonGroupChild()
end if
end if
' Remove the real shuffle button if present (refresh path where media disappeared)
if isValid(m.shuffleButton)
currentFocusIndex = m.buttonGrp.buttonFocused
shuffleIndex = getButtonIndex("shuffleButton")
m.buttonGrp.removeChild(m.shuffleButton)
m.shuffleButton = invalid
if isValid(shuffleIndex) and shuffleIndex >= 0 and currentFocusIndex >= shuffleIndex
focusIndex = currentFocusIndex - 1
if focusIndex < 0 then focusIndex = 0
m.buttonGrp.buttonFocused = focusIndex
end if
if m.buttonGrp.isInFocusChain()
focusButtonGroupChild()
end if
end if
end if
end sub
' onPlaylistContentKindChanged: Drives the Watched button and item-count label based on the
' confirmed content kind of the playlist ("video" / "audio" / "mixed" / "unknown").
' Fires via alwaysNotify so it triggers even when the kind is unchanged (e.g. on refresh).
'
' Watched button: only shown for "video" (has at least one video-type item).
' Label: "Tracks" only when positively confirmed "audio" (all items are Audio type).
' All other kinds — including "unknown" (load failure) and "mixed" — stay "Items".
sub onPlaylistContentKindChanged()
if not isValid(m.top.itemContent) or m.top.itemContent.type <> "Playlist" then return
item = m.top.itemContent
childCount = item.childCount
kind = m.extrasGrid.playlistContentKind
' --- Watched button ---
watchedButton = m.top.findNode("watchedButton")
if kind = "video"
' Video items detected — ensure Watched button exists before Favorite
if not isValid(watchedButton)
watchedButton = CreateObject("roSGNode", "IconButton")
watchedButton.id = "watchedButton"
watchedButton.icon = "pkg:/images/icons/check_$$RES$$.png"
watchedButton.text = translate(translationKeys.LabelWatched)
currentFocusIndex = m.buttonGrp.buttonFocused
favoriteButtonIndex = getButtonIndex("favoriteButton")
insertIndex = 0
if favoriteButtonIndex >= 0 then insertIndex = favoriteButtonIndex
m.buttonGrp.insertChild(watchedButton, insertIndex)
if currentFocusIndex >= insertIndex
' Shift focus index up to track the same button after insertion
m.buttonGrp.buttonFocused = currentFocusIndex + 1
end if
end if
updateWatchedButton()
else
' Not a video playlist — remove Watched button if present
if isValid(watchedButton)
currentFocusIndex = m.buttonGrp.buttonFocused
watchedIndex = getButtonIndex("watchedButton")
m.buttonGrp.removeChild(watchedButton)
if isValid(watchedIndex) and watchedIndex >= 0 and currentFocusIndex >= watchedIndex
focusIndex = currentFocusIndex - 1
if focusIndex < 0 then focusIndex = 0
m.buttonGrp.buttonFocused = focusIndex
end if
if m.buttonGrp.isInFocusChain()
focusButtonGroupChild()
end if
end if
end if
' --- Item count label ---
' "Tracks" only when the playlist is positively confirmed all-audio.
' "unknown" (load failure) and "mixed" both fall through to "Items" — the safe neutral default.
itemCountNode = m.top.findNode("itemCount")
if isValid(itemCountNode) and childCount > 0
if kind = "audio"
itemCountNode.text = translatePlural(translationKeys.LabelTrackCount, childCount, [stri(childCount).trim()])
else
itemCountNode.text = translatePlural(translationKeys.LabelItemCount, childCount, [stri(childCount).trim()])
end if
end if
end sub
function round(f as float) as integer
' BrightScript only has a "floor" round.
' Compare floor to floor+1 to find which is closer.
m = int(f)
n = m + 1
x = abs(f - m)
y = abs(f - n)
if y > x
return m
else
return n
end if
end function
' ============================================
' TRACK DROPDOWN EVENT HANDLERS
' ============================================
' Video dropdown selection fires when the user picks a different MediaSource (alternate
' version). We swap in the new source's streams, re-run audio auto-select, and rebuild
' the audio/subtitle dropdowns against the new source. Focus stays on the video dropdown.
sub onVideoDropdownSelection()
newStreamId = m.videoDropdown.selectedAction
if not isValidAndNotEmpty(newStreamId) then return
if newStreamId = m.top.selectedVideoStreamId then return
m.top.selectedVideoStreamId = newStreamId
item = m.top.itemContent
if not isValid(item) or not isValid(item.mediaSourcesData) then return
mediaSources = item.mediaSourcesData.mediaSources
allStreams = getStreamsForSelectedSource(mediaSources)
SetDefaultAudioTrack(allStreams)
m.subtitleUserOverridden = false
populateTrackDropdowns(mediaSources ?? [], allStreams, item.type)
end sub
' Audio dropdown selection: updates selectedAudioStreamIndex and re-seeds the subtitle
' auto-selection (Smart mode depends on which audio is playing). If the user has already
' manually overridden the subtitle, we leave it alone.
sub onAudioDropdownSelection()
action = m.audioDropdown.selectedAction
if not isValidAndNotEmpty(action) then return
newIndex = action.toInt()
if newIndex = m.top.selectedAudioStreamIndex then return
m.top.selectedAudioStreamIndex = newIndex
if m.subtitleUserOverridden then return
item = m.top.itemContent
if not isValid(item) or not isValid(item.mediaSourcesData) then return
allStreams = getStreamsForSelectedSource(item.mediaSourcesData.mediaSources)
defaultSubIdx = findDefaultSubtitleStreamIndex(allStreams, newIndex)
m.top.selectedSubtitleStreamIndex = defaultSubIdx
' Refresh the subtitle dropdown's selection highlight + trigger text
items = m.subtitleDropdown.items
if isValid(items)
newId = str(defaultSubIdx).trim()
m.subtitleDropdown.selectedItemId = newId
m.subtitleDropdown.triggerText = triggerTextForSelection(items, newId)
end if
end sub
' Subtitle dropdown selection: marks user override so subsequent audio changes don't
' clobber the pick, then stores the new index. Action "-1" = Off.
sub onSubtitleDropdownSelection()
action = m.subtitleDropdown.selectedAction
if not isValidAndNotEmpty(action) then return
m.top.selectedSubtitleStreamIndex = action.toInt()
m.subtitleUserOverridden = true
end sub
' Dropdown navigation: UP from any dropdown goes to description when in layout, else
' is consumed (no-op). parent-level UP handling reused so we don't have three identical
' handlers.
sub onDropdownRequestUp()
if m.itemDescriptionInLayout
m.top.lastFocus = m.itemDescription
m.itemDescription.setFocus(true)
end if
end sub
' DOWN from any dropdown returns focus to the button group.
sub onDropdownRequestDown()
m.top.lastFocus = m.buttonGrp
m.buttonGrp.setFocus(true)
end sub
' LEFT/RIGHT between dropdowns — each dropdown has its own exit handler so we know which
' slot we came from and can target the correct neighbor, skipping non-interactive slots.
sub onVideoDropdownFocusExit()
dir = m.videoDropdown.requestFocusExit
if dir = "left"
focusInteractiveDropdown([m.subtitleDropdown, m.audioDropdown, m.videoDropdown])
else if dir = "right"
focusInteractiveDropdown([m.audioDropdown, m.subtitleDropdown, m.videoDropdown])
end if
end sub
sub onAudioDropdownFocusExit()
dir = m.audioDropdown.requestFocusExit
if dir = "left"
focusInteractiveDropdown([m.videoDropdown, m.subtitleDropdown, m.audioDropdown])
else if dir = "right"
focusInteractiveDropdown([m.subtitleDropdown, m.videoDropdown, m.audioDropdown])
end if
end sub
sub onSubtitleDropdownFocusExit()
dir = m.subtitleDropdown.requestFocusExit
if dir = "left"
focusInteractiveDropdown([m.audioDropdown, m.videoDropdown, m.subtitleDropdown])
else if dir = "right"
focusInteractiveDropdown([m.videoDropdown, m.audioDropdown, m.subtitleDropdown])
end if
end sub
' focusInteractiveDropdown: Walks the given candidate list and focuses the first dropdown
' that is interactive AND visible. The candidate order encodes both direction and wrap
' behavior; the currently-focused dropdown is always last so the nav falls back to it if
' nothing else is interactive (effectively: stay put).
sub focusInteractiveDropdown(candidates as object)
for each candidate in candidates
if isValid(candidate) and candidate.visible and candidate.isInteractive
m.top.lastFocus = candidate
candidate.setFocus(true)
return
end if
end for
end sub
' firstInteractiveDropdown: Returns the first visible, interactive TrackDropdown in
' left-to-right order, or invalid if none exist.
function firstInteractiveDropdown() as object
candidates = [m.videoDropdown, m.audioDropdown, m.subtitleDropdown]
for each candidate in candidates
if isValid(candidate) and candidate.visible and candidate.isInteractive
return candidate
end if
end for
return invalid
end function
' onTrackDropdownFocusChanged: Observer wired to each TrackDropdown's focusedChild.
' Remembers the last dropdown that held focus so the UP-from-buttons handler can
' restore the previous slot on re-entry instead of always defaulting to video.
sub onTrackDropdownFocusChanged()
if isValid(m.videoDropdown) and m.videoDropdown.isInFocusChain()
m.lastFocusedDropdown = m.videoDropdown
else if isValid(m.audioDropdown) and m.audioDropdown.isInFocusChain()
m.lastFocusedDropdown = m.audioDropdown
else if isValid(m.subtitleDropdown) and m.subtitleDropdown.isInFocusChain()
m.lastFocusedDropdown = m.subtitleDropdown
end if
end sub
' onTrackDropdownMenuOpenChanged: Observer wired to each TrackDropdown's menuOpen.
' Toggles the full-screen dimmer iff ANY dropdown's menu is currently open. The
' trackCluster (titles + triggers + the open menu itself) renders ABOVE the dimmer
' in XML sibling order, so only the screen content BEHIND the cluster dims —
' category titles and trigger labels stay full-brightness as the user's "you are
' here" cue while picking a track.
sub onTrackDropdownMenuOpenChanged()
anyOpen = false
if isValid(m.videoDropdown) and m.videoDropdown.menuOpen then anyOpen = true
if isValid(m.audioDropdown) and m.audioDropdown.menuOpen then anyOpen = true
if isValid(m.subtitleDropdown) and m.subtitleDropdown.menuOpen then anyOpen = true
if isValid(m.dropdownDimmer)
m.dropdownDimmer.visible = anyOpen
end if
end sub
' pickTrackDropdownFromButtons: Chooses which TrackDropdown to focus when UP is pressed
' from the button group. Ignores m.lastFocusedDropdown by design — UP-from-buttons
' should ALWAYS land on the dropdown visually above the currently focused button, not
' on wherever the user was last time. Each slot spans 2 buttons: slot 1 sits above
' buttons [0,1], slot 2 above [2,3], slot 3 above [4,5,...]. Widens outward to the
' nearest interactive slot if the preferred one is static/hidden.
'
' CRITICAL: guards on m.trackCluster.visible — when the item type has no media tracks
' (Series/Person/BoxSet/etc.), the cluster is hidden but the individual dropdown nodes
' may still carry stale visible=true / isInteractive=true from a previously-loaded
' item. Without this guard, focus would move to an invisible stale dropdown when the
' user presses UP on those item types.
function pickTrackDropdownFromButtons() as object
if not isValid(m.trackCluster) or not m.trackCluster.visible then return invalid
slots = [m.videoDropdown, m.audioDropdown, m.subtitleDropdown]
buttonIdx = m.buttonGrp.buttonFocused
if not isValid(buttonIdx) or buttonIdx < 0 then buttonIdx = 0
preferredSlot = 0
if buttonIdx >= 4
preferredSlot = 2
else if buttonIdx >= 2
preferredSlot = 1
end if
order = trackClusterFocus.buildSearchOrder(preferredSlot)
for each idx in order
candidate = slots[idx]
if isValid(candidate) and candidate.visible and candidate.isInteractive
return candidate
end if
end for
return invalid
end function
' pickTrackDropdownFromDescription: Chooses which TrackDropdown to focus when DOWN is
' pressed from the description. Restores m.lastFocusedDropdown if still usable so the
' user returns to wherever they were before moving up. Falls back to the leftmost
' interactive dropdown (video → audio → subtitles) for the first-ever entry.
' Also guards on m.trackCluster.visible to match pickTrackDropdownFromButtons above.
function pickTrackDropdownFromDescription() as object
if not isValid(m.trackCluster) or not m.trackCluster.visible then return invalid
if isValid(m.lastFocusedDropdown) and m.lastFocusedDropdown.visible and m.lastFocusedDropdown.isInteractive
return m.lastFocusedDropdown
end if
slots = [m.videoDropdown, m.audioDropdown, m.subtitleDropdown]
for each candidate in slots
if isValid(candidate) and candidate.visible and candidate.isInteractive
return candidate
end if
end for
return invalid
end function
' onSeasonSeriesDataLoaded: Update Season info rows and logo with parent series metadata
sub onSeasonSeriesDataLoaded()
m.loadSeasonSeriesTask.unobserveField("content")
content = m.loadSeasonSeriesTask.content
if not isValid(content) or content.count() = 0 then return
seriesItem = content[0]
if not isValid(seriesItem) then return
m.seasonSeriesData = seriesItem
' Update cache so future visits to any season of this series render immediately
seriesId = m.top.itemContent.seriesId
if isValidAndNotEmpty(seriesId)
m.seasonSeriesCache[seriesId] = seriesItem
end if
' Clear both rows — removeChildrenIndex(num_children, startIndex)
m.infoGroup.removeChildrenIndex(m.infoGroup.getChildCount(), 0)
m.directorGenreGroup.removeChildrenIndex(m.directorGenreGroup.getChildCount(), 0)
m.infoDividerCount = 0
m.directorGenreDividerCount = 0
m.endsAtNode = invalid
m.endsAtDurationSeconds = 0
populateInfoGroupSeason(m.top.itemContent, m.global.user.settings)
' Update logo using series data if the season didn't carry parentLogoItemId
if isValid(m.itemLogo) and not m.itemLogo.visible
setItemLogo(m.seasonSeriesData)
end if
end sub
' onSeriesResumeLoaded: Set nextUpEpisode from seriesResume task result to show/hide the Resume button
sub onSeriesResumeLoaded()
m.loadSeriesResumeTask.unobserveField("content")
content = m.loadSeriesResumeTask.content
m.loadSeriesResumeTask.content = []
if isValid(content) and content.count() > 0
m.top.nextUpEpisode = content[0]
else
' Setting invalid to invalid won't fire onChange, so remove the loading button directly
removeResumeLoadingButton()
m.top.nextUpEpisode = invalid
end if
end sub
' removeResumeLoadingButton: Remove the resume LoadingButton placeholder if still present.
' Called when async data confirms no resume episode exists (nextUpEpisode stays invalid).
sub removeResumeLoadingButton()
loadingButton = m.top.findNode("resumeLoadingButton")
if not isValid(loadingButton) then return
currentFocusIndex = m.buttonGrp.buttonFocused
loadingIndex = getButtonIndex("resumeLoadingButton")
wasFocused = loadingButton.isInFocusChain()
m.buttonGrp.removeChild(loadingButton)
m.resumeLoadingButton = invalid
if isValid(loadingIndex) and loadingIndex >= 0 and currentFocusIndex >= loadingIndex
focusIdx = currentFocusIndex - 1
if focusIdx < 0 then focusIdx = 0
m.buttonGrp.buttonFocused = focusIdx
end if
if wasFocused
focusButtonGroupChild()
end if
end sub
' onLyricsLoaded: Show lyrics in the description area once the async fetch completes (Audio only)
sub onLyricsLoaded()
m.loadLyricsTask.unobserveField("content")
content = m.loadLyricsTask.content
m.loadLyricsTask.content = []
if not isValid(m.top.itemContent) or m.top.itemContent.type <> "Audio" then return
if not isValid(content) or content.count() = 0 or not isValidAndNotEmpty(content[0]) then return
m.itemDescription.text = content[0]
m.itemDescription.visible = true
setDescriptionInLayout(true)
end sub
' onFirstEpisodeLoaded: Start playback of first episode for Series Play button
sub onFirstEpisodeLoaded()
m.loadFirstEpisodeTask.unobserveField("content")
content = m.loadFirstEpisodeTask.content
if isValid(content) and content.count() > 0
m.top.quickPlayNode = content[0]
m.top.quickPlayNode = invalid
else
m.log.warn("Series Play: could not load first episode for series", m.top.itemContent.id)
m.global.sceneManager.callFunc("standardDialog", translate(translationKeys.ErrorPlaybackError), { data: ["<p>" + translate(translationKeys.ErrorCouldNotLoadTheFirstEpisode) + "</p>"] })
end if
end sub
function onKeyEvent(key as string, press as boolean) as boolean
if not press then return false
item = m.top.itemContent
itemType = ""
if isValid(item)
itemType = item.type
end if
' Series-specific button handling — intercept before Main.bs receives buttonSelected
if itemType = "Series" and key = "OK" and m.buttonGrp.isInFocusChain()
focusedButton = m.buttonGrp.getChild(m.buttonGrp.buttonFocused)
if isValid(focusedButton)
if focusedButton.id = "playButton" and isValidAndNotEmpty(item.id)
' Load first episode and set quickPlayNode
m.loadFirstEpisodeTask.itemId = item.id
m.loadFirstEpisodeTask.observeField("content", "onFirstEpisodeLoaded")
m.loadFirstEpisodeTask.control = "RUN"
return true
else if focusedButton.id = "resumeButton" and isValid(m.top.nextUpEpisode) and isValidAndNotEmpty(m.top.nextUpEpisode.id)
m.top.quickPlayNode = m.top.nextUpEpisode
m.top.quickPlayNode = invalid
return true
end if
end if
end if
' DOWN: itemDescription -> track row dropdown (if any interactive), else buttonGrp.
' Uses the "remember last focused" picker so returning to the row after coming back
' from buttons/extras lands the user on the same slot. Falls through to the button
' group only when no interactive dropdown exists. The track row is NEVER skipped.
if key = "down" and m.itemDescription.isInFocusChain()
target = pickTrackDropdownFromDescription()
if isValid(target)
m.top.lastFocus = target
target.setFocus(true)
else
m.top.lastFocus = m.buttonGrp
m.buttonGrp.setFocus(true)
end if
return true
end if
' DOWN: buttonGrp -> extrasGrid
if key = "down" and m.buttonGrp.isInFocusChain()
' Guard: don't open the extras pane when there are no rows — focus would be lost in an empty grid.
' content.getChildCount() is 0 when the async chain is still in-flight or returned no data.
if m.extrasGrid.content.getChildCount() = 0 then return true
m.top.lastFocus = m.extrasGrid
m.extrasGrid.setFocus(true)
' Calculate animation target with the fully-settled layout before activating extras
updateItemDetailsAnimationTarget()
activateExtras()
' Hide description and the entire track cluster before sliding up so they don't
' show during the transition. trackCluster wraps both rows so a single opacity
' toggle is sufficient.
m.itemDescription.opacity = 0
m.trackCluster.opacity = 0
' Animate itemDetails to top (synced with extras slider)
m.itemDetailsSliderInterp.reverse = false
m.itemDetailsSlider.control = "start"
' Extras slider animation — panel always opens to extrasLayout.PANEL_OPEN_Y (306).
' Row positioning within the panel is handled by translating the RowList (ExtrasRowList.bs).
vertSlider = m.top.findNode("VertSlider")
vertSlider.keyValue = [[0, extrasLayout.PANEL_CLOSED_Y], [0, extrasLayout.PANEL_OPEN_Y]]
vertSlider.reverse = false
m.top.findNode("colorSlider").reverse = false
m.top.findNode("pplAnime").control = "start"
return true
end if
' UP: extrasGrid -> buttonGrp
if key = "up" and m.top.findNode("extrasGrid").isInFocusChain()
if m.extrasGrid.itemFocused = 0
m.top.lastFocus = m.buttonGrp
deactivateExtras()
' Restore description and track cluster before sliding back down
m.itemDescription.opacity = 1
m.trackCluster.opacity = 1
' Animate itemDetails back to rest position (synced with extras slider)
m.itemDetailsSliderInterp.reverse = true
m.itemDetailsSlider.control = "start"
' Extras slider animation — panel always closes from extrasLayout.PANEL_OPEN_Y (306).
vertSlider = m.top.findNode("VertSlider")
vertSlider.keyValue = [[0, extrasLayout.PANEL_CLOSED_Y], [0, extrasLayout.PANEL_OPEN_Y]]
vertSlider.reverse = true
m.top.findNode("colorSlider").reverse = true
m.top.findNode("pplAnime").control = "start"
m.buttonGrp.setFocus(true)
return true
end if
end if
' UP: buttonGrp -> track dropdown picked from the button column (ignoring any
' remembered slot — by design, UP-from-buttons ALWAYS targets the slot visually
' above the currently focused button). If no dropdowns are interactive, fall through
' to itemDescription. Navigation ladder: buttons <-> track row <-> description.
if key = "up" and m.buttonGrp.isInFocusChain()
target = pickTrackDropdownFromButtons()
if isValid(target)
m.top.lastFocus = target
target.setFocus(true)
return true
else if m.itemDescriptionInLayout
m.top.lastFocus = m.itemDescription
m.itemDescription.setFocus(true)
return true
end if
end if
if key = "play" and m.extrasGrid.hasFocus()
if isValid(m.extrasGrid.focusedItem)
m.top.quickPlayNode = m.extrasGrid.focusedItem
m.top.quickPlayNode = invalid
return true
end if
end if
return false
end function
' onDestroy: Full teardown releasing all resources before component removal
' Called automatically via JRScreen.beforeViewClose when sgRouter permanently closes this view.
sub onDestroy()
m.log.verbose("onDestroy")
' Tear down ExtrasRowList FIRST — stops in-flight tasks (including any
' LoadChannelProgramsTask started by onProgramsExpired) and unobserves all
' fields before we destroy the texture manager or null out references.
m.extrasGrid.callFunc("onDestroy")
' Unobserve m.port observers set by showScenes.bs CreateItemDetailsGroup().
' These persist until the node is garbage collected, so explicit cleanup
' prevents stale events from reaching main.bs's event loop after the view closes.
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 m.top programmatic observer (may or may not be active depending on flow)
m.top.unobserveField("itemContent")
' Unobserve child node observers
m.itemLogo.unobserveField("loadStatus")
m.itemDetails.unobserveField("renderTracking")
m.extrasGrid.unobserveField("personHasMedia")
m.extrasGrid.unobserveField("playlistContentKind")
m.extrasGrid.unobserveField("targetTranslationY")
' Unobserve TrackDropdown output fields + focusedChild memory observers
if isValid(m.videoDropdown)
m.videoDropdown.unobserveField("selectedAction")
m.videoDropdown.unobserveField("requestFocusReturn")
m.videoDropdown.unobserveField("requestFocusDown")
m.videoDropdown.unobserveField("requestFocusExit")
m.videoDropdown.unobserveField("focusedChild")
m.videoDropdown.unobserveField("menuOpen")
m.videoDropdown.callFunc("onDestroy")
end if
if isValid(m.audioDropdown)
m.audioDropdown.unobserveField("selectedAction")
m.audioDropdown.unobserveField("requestFocusReturn")
m.audioDropdown.unobserveField("requestFocusDown")
m.audioDropdown.unobserveField("requestFocusExit")
m.audioDropdown.unobserveField("focusedChild")
m.audioDropdown.unobserveField("menuOpen")
m.audioDropdown.callFunc("onDestroy")
end if
if isValid(m.subtitleDropdown)
m.subtitleDropdown.unobserveField("selectedAction")
m.subtitleDropdown.unobserveField("requestFocusReturn")
m.subtitleDropdown.unobserveField("requestFocusDown")
m.subtitleDropdown.unobserveField("requestFocusExit")
m.subtitleDropdown.unobserveField("focusedChild")
m.subtitleDropdown.unobserveField("menuOpen")
m.subtitleDropdown.callFunc("onDestroy")
end if
m.lastFocusedDropdown = invalid
m.dropdownDimmer = invalid
if isValid(m.clock)
m.clock.unobserveField("minutes")
m.clock = invalid
end if
' Stop and release task nodes
m.loadFirstEpisodeTask.unobserveField("content")
m.loadFirstEpisodeTask.control = "STOP"
m.loadFirstEpisodeTask = invalid
m.loadSeriesResumeTask.unobserveField("content")
m.loadSeriesResumeTask.control = "STOP"
m.loadSeriesResumeTask = invalid
m.loadSeasonSeriesTask.unobserveField("content")
m.loadSeasonSeriesTask.control = "STOP"
m.loadSeasonSeriesTask = invalid
m.loadLyricsTask.unobserveField("content")
m.loadLyricsTask.control = "STOP"
m.loadLyricsTask = invalid
m.loadDetailsTask.unobserveField("content")
m.loadDetailsTask.control = "STOP"
m.loadDetailsTask = invalid
' Tear down the record task + drop any pending confirmation observer this screen owns
' (record / delete / series-watched).
if isValid(m.recordTask)
m.recordTask.unobserveField("result")
m.recordTask.control = "STOP"
m.recordTask = invalid
end if
if isValid(m.global.sceneManager)
m.global.sceneManager.unobserveFieldScoped("isDataReturned")
end if
' Clear node references
m.extrasGrp = invalid
m.extrasGrid = invalid
m.gridAnime = invalid
m.gridTranslationInterp = invalid
m.infoGroup = invalid
m.itemDescription = invalid
m.buttonGrp = invalid
m.itemLogo = invalid
m.dateCreatedLabel = invalid
m.itemTextGradient = invalid
m.itemDetails = invalid
m.itemDetailsSlider = invalid
m.itemDetailsSliderInterp = invalid
m.itemInfoRows = invalid
m.directorGenreGroup = invalid
m.itemTracks = invalid
m.trackCluster = invalid
m.trackTitles = invalid
m.videoTrackTitle = invalid
m.audioTrackTitle = invalid
m.subtitleTrackTitle = invalid
m.trackDropdowns = invalid
m.videoDropdown = invalid
m.audioDropdown = invalid
m.subtitleDropdown = invalid
m.shuffleButton = invalid
m.shuffleLoadingButton = invalid
m.resumeLoadingButton = invalid
m.endsAtNode = invalid
' Clear data caches
m.seasonSeriesCache = invalid
m.seasonSeriesData = invalid
end sub