import "pkg:/source/api/Image.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
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/streamSelection.bs"
' Y translation extrasGrp reaches after sliding open (ExtrasSlider.xml VertSlider end keyValue)
const EXTRAS_GRP_TARGET_Y = 306
' Gap between bottom of itemInfoRows and top of extras pane when extras are open
const ITEM_DETAILS_EXTRAS_PADDING = 48
' 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
sub init()
m.log = log.Logger("ItemDetails")
m.extrasGrp = m.top.findNode("extrasGrp")
m.extrasGrid = m.top.findNode("extrasGrid")
m.top.optionsAvailable = false
m.options = m.top.findNode("itemOptions")
m.infoGroup = m.top.findNode("infoGroup")
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")
' LoadingButton placeholder gives focus a home before itemContent arrives.
' setupButtons() clears it and builds the real button set once item type is known.
loadingButton = CreateObject("roSGNode", "LoadingButton")
loadingButton.id = "loadingButton"
m.buttonGrp.appendChild(loadingButton)
m.buttonGrp.setFocus(true)
m.top.lastFocus = m.buttonGrp
' 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")
' 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")
' itemDetails slide animation - synced with extras slider
m.itemTracks = m.top.findNode("itemTracks")
m.itemTracksInLayout = true ' itemTracks is a static XML child, so it starts in layout
m.itemDetailsSlider = m.top.findNode("itemDetailsSlider")
m.itemDetailsSliderInterp = m.top.findNode("itemDetailsSliderInterp")
m.extrasActive = false
m.animationTargetCalculated = 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.optionsButton = invalid
m.shuffleButton = invalid
' 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 = {}
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 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()
' 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
m.top.lastFocus.setFocus(true)
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 trailerAvailableChanged()
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.png"
trailerButton.text = tr("Play Trailer")
' 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
sub nextUpEpisodeChanged()
item = m.top.nextUpEpisode
resumeButton = m.top.findNode("resumeButton")
if isValid(item) and isValidAndNotEmpty(item.id)
resumeText = getResumeButtonText(item)
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.png"
resumeButton.text = resumeText
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 or description when the button arrives asynchronously.
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
end if
else
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 tr("Resume") + " S" + item.parentIndexNumber.toStr() + "E" + item.indexNumber.toStr()
end if
return tr("Resume")
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.png"
resumeButton.text = tr("Resume")
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.bold = 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.bold = true
return labelNode
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 = tr("Biographical information for this person is not currently available.")
end if
m.itemDescription.tagline = ""
m.itemDescription.maxLines = 8
m.itemDescription.dialogTitle = item.name
m.itemDescription.visible = true
setDescriptionInLayout(true)
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
' Movie, Video, Recording
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() + " " + tr("mins")
displayInfoNode(runtimeNode)
end if
if item.runTimeTicks > 0 and not userSettings.uiDesignHideClock
m.endsAtNode = createInfoLabel("ends-at")
m.endsAtDurationSeconds = int(m.top.itemContent.runTimeTicks / 10000000.0)
m.endsAtNode.text = tr("Ends at %1").Replace("%1", getEndTime())
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 = tr("Directed by %1").Replace("%1", directors.join(", "))
displayDirectorGenreNode(directorNode)
end if
end sub
' populateInfoGroupMusicVideo: Info rows for MusicVideo
' Row 1: Year · Official Rating · Runtime · Ends At
' Row 2: Genres · By: 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() + " " + tr("mins")
displayInfoNode(runtimeNode)
end if
if item.runTimeTicks > 0 and not userSettings.uiDesignHideClock
m.endsAtNode = createInfoLabel("ends-at")
m.endsAtDurationSeconds = int(m.top.itemContent.runTimeTicks / 10000000.0)
m.endsAtNode.text = tr("Ends at %1").Replace("%1", getEndTime())
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 = tr("By: %1").Replace("%1", 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 = tr("Directed by %1").Replace("%1", 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() + " " + tr("mins")
displayInfoNode(runtimeNode)
end if
if item.runTimeTicks > 0 and not userSettings.uiDesignHideClock
m.endsAtNode = createInfoLabel("ends-at")
m.endsAtDurationSeconds = int(m.top.itemContent.runTimeTicks / 10000000.0)
m.endsAtNode.text = tr("Ends at %1").Replace("%1", getEndTime())
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 = tr("Directed by %1").Replace("%1", 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 + " - " + tr("Present")
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() + " " + tr("mins")
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.endsAtNode = createInfoLabel("ends-at")
m.endsAtDurationSeconds = int(m.top.itemContent.runTimeTicks / 10000000.0)
m.endsAtNode.text = tr("Ends at %1").Replace("%1", getEndTime())
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")
movieLabel = tr("Movie")
if item.childCount <> 1 then movieLabel = tr("Movies")
movieCountNode.text = stri(item.childCount).trim() + " " + movieLabel
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() + " " + tr("mins")
displayInfoNode(runtimeNode)
end if
if isValid(seriesData) and seriesData.runTimeTicks > 0 and not userSettings.uiDesignHideClock
m.endsAtNode = createInfoLabel("ends-at")
' Compute end time directly from series runTimeTicks — do NOT swap m.top.itemContent,
' as that fires itemContentChanged() 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.text = tr("Ends at %1").Replace("%1", formatTime(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 = stri(item.childCount).trim() + " " + tr("episodes")
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 + " · " + stri(age).trim() + " " + tr("years old")
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")
movieLabel = tr("Movie")
if item.movieCount <> 1 then movieLabel = tr("Movies")
movieCountNode.text = stri(item.movieCount).trim() + " " + movieLabel
displayDirectorGenreNode(movieCountNode)
end if
if item.episodeCount > 0
episodeCountNode = createInfoLabel("personEpisodeCount")
episodeLabel = tr("Episode")
if item.episodeCount <> 1 then episodeLabel = tr("Episodes")
episodeCountNode.text = stri(item.episodeCount).trim() + " " + episodeLabel
displayDirectorGenreNode(episodeCountNode)
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 + " " + tr("at") + " " + 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 + " " + tr("on") + " " + studio
else
words = studio
end if
end if
return words
end function
sub itemContentChanged()
m.animationTargetCalculated = false
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] })
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)
' Media tracks and stream options are only relevant for playable types
' Use reparenting instead of visible=false — invisible nodes still take up LayoutGroup space
setTracksInLayout(item.type <> "Series" and item.type <> "Season" and item.type <> "Person" and item.type <> "BoxSet")
if item.type <> "Series" and item.type <> "Season" and item.type <> "Person" and item.type <> "BoxSet"
if m.top.selectedVideoStreamId = "" and isValidAndNotEmpty(item.mediaSourceId)
m.top.selectedVideoStreamId = item.mediaSourceId
end if
allStreams = []
mediaSources = invalid
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)
SetUpVideoOptions(mediaSources ?? [])
SetUpAudioOptions(allStreams)
SetUpSubtitleDisplay(allStreams, m.top.selectedAudioStreamIndex)
updateOptionsButtonVisibility()
else
' Series/Season/Person: ensure options button is removed if it was left from a previous load
if isValid(m.optionsButton)
m.buttonGrp.removeChild(m.optionsButton)
m.optionsButton = invalid
end if
end if
populateInfoGroup()
populateDescriptionGroup()
setFieldText("videoTitle", item.name)
updateFavoriteButton()
updateWatchedButton()
' Non-Series/Season types: Resume based on playbackPositionTicks
' Series: Resume based on nextUpEpisode (managed by nextUpEpisodeChanged observer)
' Season: No resume — NextUp API does not support SeasonId filtering
if item.type <> "Series" and item.type <> "Season"
manageResumeButton()
end if
' Series: kick off background fetch of resume/next-up episode for the Resume button
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.top.nextUpEpisode = invalid
m.loadSeriesResumeTask.itemId = item.id
m.loadSeriesResumeTask.observeField("content", "onSeriesResumeLoaded")
m.loadSeriesResumeTask.control = "RUN"
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
stopLoadingSpinner()
' 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
sub SetUpVideoOptions(streams)
videos = []
codecDetailsSet = false
hasMultipleSources = streams.Count() > 1
for i = 0 to streams.Count() - 1
if streams[i].VideoType = "VideoFile"
codec = formatVideoSourceTitle(streams[i], hasMultipleSources)
if not codecDetailsSet
setFieldText("video_codec", tr("Video") + ": " + codec)
codecDetailsSet = true
end if
videos.push({
"Title": codec,
"Description": tr("Video"),
"Selected": m.top.selectedVideoStreamId = streams[i].id,
"StreamID": streams[i].id,
"video_codec": codec
})
end if
end for
if streams.count() > 1
m.top.findnode("video_codec_count").text = "+" + stri(streams.Count() - 1).trim()
end if
options = {}
options.videos = videos
m.options.options = options
end sub
sub SetUpAudioOptions(streams)
tracks = []
for i = 0 to streams.Count() - 1
if streams[i].Type = "Audio"
' Use server-side stream.index for both the pre-selection check and the StreamIndex value
' carried to ItemOptions. Jellyfin indices span all stream types and must match what
' ItemPostPlaybackInfo expects.
if isValid(streams[i].index)
tracks.push({ "Title": formatAudioDisplayTitle(streams[i]), "Description": streams[i].Title, "Selected": m.top.selectedAudioStreamIndex = streams[i].index, "StreamIndex": streams[i].index })
end if
end if
end for
if tracks.count() > 1
m.top.findnode("audio_codec_count").text = "+" + stri(tracks.Count() - 1).trim()
end if
options = {}
if isValid(m.options.options.videos)
options.videos = m.options.options.videos
end if
options.audios = tracks
m.options.options = options
end sub
' SetUpSubtitleDisplay: Display the subtitle stream that will be auto-selected during playback.
' Uses findDefaultSubtitleStreamIndex() to match the same selection logic as playback
' (subtitle mode, language preference, text-only preference, smart mode audio matching).
' Shows "+N" count if multiple subtitle tracks exist.
'
' @param {object} streams - Array of MediaStream objects (all types: video, audio, subtitle)
' @param {integer} selectedAudioStreamIndex - Server-side index of the selected audio stream
sub SetUpSubtitleDisplay(streams, selectedAudioStreamIndex as integer)
subtitleCountNode = m.top.findNode("subtitle_codec_count")
if isValid(subtitleCountNode) then subtitleCountNode.text = ""
if not isValid(streams) then return
subtitleCount = 0
for i = 0 to streams.count() - 1
if isValid(streams[i].Type) and LCase(streams[i].Type) = "subtitle"
subtitleCount++
end if
end for
if subtitleCount = 0
setFieldText("subtitle_codec", tr("Subtitles") + ": " + tr("None"))
return
end if
selectedIndex = findDefaultSubtitleStreamIndex(streams, selectedAudioStreamIndex)
if selectedIndex >= 0
for i = 0 to streams.count() - 1
if isValid(streams[i].index) and streams[i].index = selectedIndex
setFieldText("subtitle_codec", tr("Subtitles") + ": " + formatSubtitleDisplayTitle(streams[i]))
exit for
end if
end for
else
setFieldText("subtitle_codec", tr("Subtitles") + ": " + tr("Off"))
end if
if subtitleCount > 1
m.top.findNode("subtitle_codec_count").text = "+" + stri(subtitleCount - 1).trim()
end if
end sub
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, localUser.config.audioLanguagePreference)
defaultAudioStream = invalid
for each stream in mediaStreams
if LCase(stream.Type) = "audio" and isValid(stream.index) and stream.index = selectedIndex
defaultAudioStream = stream
exit for
end if
end for
m.top.selectedAudioStreamIndex = selectedIndex
if isValid(defaultAudioStream)
setFieldText("audio_codec", tr("Audio") + ": " + formatAudioDisplayTitle(defaultAudioStream))
end if
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
function getEndTime() as string
date = CreateObject("roDateTime")
duration_s = int(m.top.itemContent.runTimeTicks / 10000000.0)
date.fromSeconds(date.asSeconds() + duration_s)
date.toLocalTime()
return formatTime(date)
end function
' 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()
m.endsAtNode.text = tr("Ends at %1").Replace("%1", formatTime(date))
end sub
sub updateFavoriteButton()
fave = m.top.itemContent.isFavorite
favoriteButton = m.top.findNode("favoriteButton")
if isValid(favoriteButton)
if isValid(fave) and fave
favoriteButton.selected = true
else
favoriteButton.selected = 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.selected = true
else
watchedButton.selected = false
end if
end if
end sub
' setItemLogo: Set the item logo image URL if available.
' Fallback chains by type:
' - Movie/Series: Logo → primaryImageTag (poster, compact size)
' - Episode/Season/Recording: primaryImageTag (item thumb/poster) → parentLogoImageTag (series logo) → seriesPrimaryImageTag → hide
' - Video/MusicVideo: primaryImageTag (poster)
' - Person: primaryImageTag (portrait photo, tall) → silhouette icon
' @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 icon
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 = "pkg:/images/icons/baseline_person_white_48dp.png"
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
if isValidAndNotEmpty(item.primaryImageTag)
imgParams = { maxHeight: 212, maxWidth: 500, quality: 90, tag: item.primaryImageTag }
m.itemLogo.uri = ImageURL(item.id, "Primary", imgParams)
return
end if
m.itemLogo.visible = false
m.itemLogo.uri = ""
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.
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.visible = false
m.itemLogo.uri = ""
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
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.visible = false
m.itemLogo.uri = ""
end if
else
m.itemLogo.visible = false
m.itemLogo.uri = ""
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 = tr("Added") + " " + dateAdded.AsDateString("short-month-no-weekday") + " " + formatTime(dateAdded)
' Position at bottom right: screen is 1920x1080, with 96px padding
buttonRect = m.buttonGrp.boundingRect()
buttonBottom = buttonRect.y + buttonRect.height
m.dateCreatedLabel.translation = [1920 - 96 - 450, buttonBottom - 30]
m.dateCreatedLabel.visible = true
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"
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 items so the logo top stays below the metadata rows.
' Person photos 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"
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
if isValid(m.dateCreatedLabel) and m.dateCreatedLabel.visible
' Anchor logo bottom 18px above the date label
dateLabelY = m.dateCreatedLabel.translation[1]
else
' Date label hidden — use the same position it would occupy
buttonRect = m.buttonGrp.boundingRect()
buttonBottom = buttonRect.y + buttonRect.height
dateLabelY = buttonBottom - 30
end if
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()
' Only calculate animation target once per content load — itemInfoRows height is stable after metadata is set.
if not m.animationTargetCalculated
m.animationTargetCalculated = true
updateItemDetailsAnimationTarget()
end if
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.
sub updateItemDetailsAnimationTarget()
if m.extrasActive then return
currentTransY = m.itemDetails.translation[1]
itemInfoRowsLocalRect = m.itemInfoRows.boundingRect()
screenBottomOfInfoRows = currentTransY + itemInfoRowsLocalRect.y + itemInfoRowsLocalRect.height
dY = (EXTRAS_GRP_TARGET_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 = 972
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
' updateOptionsButtonVisibility: Create/remove options button based on available tracks.
' Creates button if there are multiple video or audio tracks to choose from.
' Removes button if there is 1 or fewer video tracks AND 1 or fewer audio tracks.
sub updateOptionsButtonVisibility()
videoCount = 0
audioCount = 0
if isValid(m.options.options) and isValid(m.options.options.videos)
videoCount = m.options.options.videos.count()
end if
if isValid(m.options.options) and isValid(m.options.options.audios)
audioCount = m.options.options.audios.count()
end if
shouldShowOptions = videoCount > 1 or audioCount > 1
buttonExists = isValid(m.optionsButton)
if shouldShowOptions and not buttonExists
currentFocusIndex = m.buttonGrp.buttonFocused
m.optionsButton = CreateObject("roSGNode", "IconButton")
m.optionsButton.id = "optionsButton"
m.optionsButton.icon = "pkg:/images/icons/settings.png"
m.optionsButton.text = tr("Options")
playButtonIndex = getButtonIndex("playButton")
if playButtonIndex >= 0
insertIndex = playButtonIndex + 1
m.buttonGrp.insertChild(m.optionsButton, insertIndex)
if isValid(currentFocusIndex) and currentFocusIndex >= insertIndex
m.buttonGrp.buttonFocused = currentFocusIndex + 1
end if
else
m.log.warn("playButton not found in button group; appending optionsButton to end")
m.buttonGrp.appendChild(m.optionsButton)
end if
else if not shouldShowOptions and buttonExists
currentFocusIndex = m.buttonGrp.buttonFocused
optionsButtonIndex = getButtonIndex("optionsButton")
m.buttonGrp.removeChild(m.optionsButton)
m.optionsButton = invalid
if isValid(currentFocusIndex) and isValid(optionsButtonIndex) and currentFocusIndex > optionsButtonIndex
focusIndex = currentFocusIndex - 1
if focusIndex < 0 then focusIndex = 0
m.buttonGrp.buttonFocused = focusIndex
end if
focusButtonGroupChild()
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
' 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 itemTracks from the itemDetails LayoutGroup.
' Invisible nodes still take up space in RSG LayoutGroups, so reparenting is required
' to truly eliminate spacing when tracks are not applicable (e.g. Series type).
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 (including the LoadingButton placeholder from init()) and adds
' the correct sync buttons in order. Async buttons (Series Resume, Trailer, Person Shuffle)
' are handled separately by their existing observers.
' 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()
' 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 (LoadingButton).
focusedButtonId = ""
focusIndex = m.buttonGrp.buttonFocused
if focusIndex >= 0 and focusIndex < m.buttonGrp.getChildCount()
focusedBtn = m.buttonGrp.getChild(focusIndex)
if isValid(focusedBtn) and focusedBtn.id <> "loadingButton" then focusedButtonId = focusedBtn.id
end if
' Clear everything (LoadingButton placeholder + any buttons 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.optionsButton = invalid
m.shuffleButton = invalid
itemType = item.type
' Play button — all types except Person
if itemType <> "Person"
playButton = CreateObject("roSGNode", "IconButton")
playButton.id = "playButton"
playButton.icon = "pkg:/images/icons/play.png"
if itemType = "Series" or itemType = "Season" or itemType = "BoxSet"
playButton.text = tr("Play All")
else
playButton.text = tr("Play")
end if
m.buttonGrp.appendChild(playButton)
end if
' Watched button — all types except Person
if itemType <> "Person"
watchedButton = CreateObject("roSGNode", "IconButton")
watchedButton.id = "watchedButton"
watchedButton.icon = "pkg:/images/icons/check.png"
watchedButton.text = tr("Watched")
m.buttonGrp.appendChild(watchedButton)
end if
' Shuffle button — Series, Season, and BoxSet (Person shuffle is async via onPersonHasMediaChanged)
if itemType = "Series" or itemType = "Season" or itemType = "BoxSet"
m.shuffleButton = CreateObject("roSGNode", "IconButton")
m.shuffleButton.id = "shuffleButton"
m.shuffleButton.icon = "pkg:/images/icons/shuffle.png"
m.shuffleButton.text = tr("Shuffle")
m.buttonGrp.appendChild(m.shuffleButton)
end if
' Favorite button — all types
favoriteButton = CreateObject("roSGNode", "IconButton")
favoriteButton.id = "favoriteButton"
favoriteButton.icon = "pkg:/images/icons/heart.png"
favoriteButton.text = tr("Favorite")
m.buttonGrp.appendChild(favoriteButton)
' Go to Series button — Season and Episode only
if itemType = "Season" or itemType = "Episode"
goToSeriesButton = CreateObject("roSGNode", "IconButton")
goToSeriesButton.id = "goToSeriesButton"
goToSeriesButton.icon = "pkg:/images/icons/tv.png"
goToSeriesButton.text = tr("Go to Series")
m.buttonGrp.appendChild(goToSeriesButton)
end if
' Trailer button — restore on refresh (on first load, trailerAvailableChanged adds it async)
' Must come before Delete to match the insertion order used in trailerAvailableChanged()
if m.top.trailerAvailable
trailerButton = CreateObject("roSGNode", "IconButton")
trailerButton.id = "trailerButton"
trailerButton.icon = "pkg:/images/icons/playOutline.png"
trailerButton.text = tr("Play Trailer")
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.png"
deleteButton.text = tr("Delete")
m.buttonGrp.appendChild(deleteButton)
end if
' Refresh button — always last
refreshButton = CreateObject("roSGNode", "IconButton")
refreshButton.id = "refreshButton"
refreshButton.icon = "pkg:/images/icons/refresh.png"
refreshButton.text = tr("Refresh")
m.buttonGrp.appendChild(refreshButton)
' Person Shuffle: if personHasMedia was already set before this refresh, restore the button now.
' On first load this is a no-op; onPersonHasMediaChanged fires later via the extras chain.
if itemType = "Person"
onPersonHasMediaChanged()
end if
' 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 wasButtonGroupFocused
focusButtonGroupChild()
end if
end sub
' onPersonHasMediaChanged: Show or hide the Shuffle button once the person extras chain completes.
' 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
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.png"
m.shuffleButton.text = tr("Shuffle")
' Insert before Favorite so button order is: Shuffle · Favorite · Refresh
currentFocusIndex = m.buttonGrp.buttonFocused
favoriteButtonIndex = getButtonIndex("favoriteButton")
insertIndex = 0
if favoriteButtonIndex >= 0 then insertIndex = favoriteButtonIndex
m.buttonGrp.insertChild(m.shuffleButton, insertIndex)
if currentFocusIndex = 0
' User is on the default button (index 0) — Shuffle takes that position, follow it.
m.buttonGrp.buttonFocused = 0
if m.buttonGrp.isInFocusChain()
focusButtonGroupChild()
end if
else
' User moved focus away from the default — shift index to track their button, don't steal.
m.buttonGrp.buttonFocused = currentFocusIndex + 1
end if
end if
else
if isValid(m.shuffleButton)
currentFocusIndex = m.buttonGrp.buttonFocused
shuffleIndex = getButtonIndex("shuffleButton")
m.buttonGrp.removeChild(m.shuffleButton)
m.shuffleButton = invalid
' Shuffle was at or before the focused button — shift the index down by one so
' buttonFocused still tracks the same button after the removal.
if isValid(shuffleIndex) and shuffleIndex >= 0 and currentFocusIndex >= shuffleIndex
focusIndex = currentFocusIndex - 1
if focusIndex < 0 then focusIndex = 0
m.buttonGrp.buttonFocused = focusIndex
end if
' Re-anchor Roku RSG focus if the button group is active — the removed node may
' have been the physically-focused child, leaving focus orphaned.
if m.buttonGrp.isInFocusChain()
focusButtonGroupChild()
end if
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
' audioOptionsClosed: Sync audio stream selection back from ItemOptions popup
sub audioOptionsClosed()
if m.options.audioStreamIndex <> m.top.selectedAudioStreamIndex
m.top.selectedAudioStreamIndex = m.options.audioStreamIndex
for each stream in m.top.itemContent.audioStreams
if stream.index = m.top.selectedAudioStreamIndex
setFieldText("audio_codec", tr("Audio") + ": " + formatAudioDisplayTitle(stream))
exit for
end if
end for
end if
m.buttonGrp.setFocus(true)
end sub
' videoOptionsClosed: Sync video stream selection back from ItemOptions popup;
' reload audio/subtitle options for the newly selected source
sub videoOptionsClosed()
if m.options.videoStreamId <> m.top.selectedVideoStreamId
m.top.selectedVideoStreamId = m.options.videoStreamId
setFieldText("video_codec", tr("Video") + ": " + m.options.video_codec)
m.top.unobserveField("itemContent")
mediaSources = m.top.itemContent.mediaSourcesData.mediaSources
for each mediaSource in mediaSources
if mediaSource.id = m.top.selectedVideoStreamId
streams = mediaSource.MediaStreams ?? []
SetDefaultAudioTrack(streams)
SetUpAudioOptions(streams)
SetUpSubtitleDisplay(streams, m.top.selectedAudioStreamIndex)
exit for
end if
end for
m.top.observeField("itemContent", "itemContentChanged")
end if
m.buttonGrp.setFocus(true)
end sub
' 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)
' Row heights changed — reset so onItemDetailsRendered() recalculates the extras animation target
m.animationTargetCalculated = false
' 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
m.top.nextUpEpisode = invalid
end if
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]
else
m.log.warn("Series Play: could not load first episode for series", m.top.itemContent.id)
m.global.sceneManager.callFunc("standardDialog", tr("Playback Error"), { data: ["<p>" + tr("Could not load the first episode.") + "</p>"] })
end if
end sub
function onKeyEvent(key as string, press as boolean) as boolean
' Options popup: intercept OK release on optionsButton
if key = "OK" and isValid(m.optionsButton) and m.optionsButton.isInFocusChain()
m.options.visible = true
m.options.setFocus(true)
return true
end if
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
return true
end if
end if
end if
' DOWN: itemDescription -> buttonGrp
if key = "down" and m.itemDescription.isInFocusChain()
m.top.lastFocus = m.buttonGrp
m.buttonGrp.setFocus(true)
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)
activateExtras()
' Hide description and tracks before sliding up so they don't show during the transition
m.itemDescription.opacity = 0
m.itemTracks.opacity = 0
' Animate itemDetails to top (synced with extras slider)
m.itemDetailsSliderInterp.reverse = false
m.itemDetailsSlider.control = "start"
' Extras slider animation
m.top.findNode("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 tracks before sliding back down
m.itemDescription.opacity = 1
m.itemTracks.opacity = 1
' Animate itemDetails back to rest position (synced with extras slider)
m.itemDetailsSliderInterp.reverse = true
m.itemDetailsSlider.control = "start"
' Extras slider animation
m.top.findNode("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 -> itemDescription (if in layout, i.e. has content)
if key = "up" and m.buttonGrp.isInFocusChain()
if m.itemDescriptionInLayout
m.top.lastFocus = m.itemDescription
m.itemDescription.setFocus(true)
return true
end if
end if
if key = "back"
if m.options.visible = true
m.options.visible = false
videoOptionsClosed()
audioOptionsClosed()
return true
end if
else if key = "play" and m.extrasGrid.hasFocus()
if isValid(m.extrasGrid.focusedItem)
m.top.quickPlayNode = m.extrasGrid.focusedItem
return true
end if
end if
return false
end function
' destroy: Full teardown releasing all resources before component removal
' Called automatically by SceneManager.popScene() / clearScenes()
sub destroy()
m.log.verbose("destroy")
' 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")
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
' Clear node references
m.extrasGrp = invalid
m.extrasGrid = invalid
m.options = invalid
m.infoGroup = invalid
m.itemDescription = invalid
m.buttonGrp = invalid
m.itemLogo = invalid
m.dateCreatedLabel = invalid
m.itemTextGradient = invalid
m.itemDetails = invalid
m.itemTracks = invalid
m.itemDetailsSlider = invalid
m.itemDetailsSliderInterp = invalid
m.itemInfoRows = invalid
m.directorGenreGroup = invalid
m.optionsButton = invalid
m.shuffleButton = invalid
m.endsAtNode = invalid
' Clear data caches
m.seasonSeriesCache = invalid
m.seasonSeriesData = invalid
end sub