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