components_movies_MovieDetails.bs
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/utils/config.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/streamSelection.bs"
sub init()
m.log = log.Logger("MovieDetails")
m.extrasGrp = m.top.findnode("extrasGrp")
m.extrasGrid = m.top.findNode("extrasGrid")
m.top.optionsAvailable = false
m.options = m.top.findNode("movieOptions")
m.infoGroup = m.top.findNode("infoGroup")
' Set overview width (full width minus margins)
overview = m.top.findNode("overview")
overview.width = 1920 - 96 - 96
m.details = m.top.findNode("details")
m.tagline = m.top.findNode("tagline")
m.buttonGrp = m.top.findNode("buttons")
m.buttonGrp.callFunc("focus")
m.top.lastFocus = m.buttonGrp
' Options button will be created dynamically as needed
m.optionsButton = invalid
m.isFirstRun = true
m.allowButtonUpdates = true ' Allow button updates on init and when returning to screen
m.top.observeField("itemContent", "itemContentChanged")
m.directorGenreGroup = m.top.findNode("directorGenreGroup")
m.infoDividerCount = 0
m.directorGenreDividerCount = 0
end sub
' OnScreenShown: Callback function when view is presented on screen
'
sub OnScreenShown()
' Set backdrop from movie data (only if itemContent is already loaded)
' This handles the case when returning to an already-loaded screen
if isValid(m.top.itemContent)
if isValid(m.top.itemContent.backdropUrl)
m.global.sceneManager.callFunc("setBackgroundImage", m.top.itemContent.backdropUrl)
end if
end if
' If itemContent not loaded, backdrop will be set in itemContentChanged()
' set focus to button group
if m.extrasGrp.opacity = 1
m.top.lastFocus.setFocus(true)
else
m.buttonGrp.setFocus(true)
end if
if m.isFirstRun
m.isFirstRun = false
else
' Trigger data refresh when returning to screen
m.allowButtonUpdates = true ' Allow button updates for upcoming data refresh
m.top.refreshMovieDetailsData = not m.top.refreshMovieDetailsData
end if
end sub
sub trailerAvailableChanged()
if m.top.trailerAvailable
' add trailor button to button group
trailerButton = CreateObject("roSGNode", "IconButton")
trailerButton.id = "trailerButton"
trailerButton.icon = "pkg:/images/icons/playOutline.png"
trailerButton.text = tr("Play Trailer")
m.buttonGrp.appendChild(trailerButton)
else
' remove trailor button from button group
trailerButton = m.top.findNode("trailerButton")
if isValid(trailerButton)
m.buttonGrp.removeChild(trailerButton)
end if
end if
end sub
' manageResumeButton: Add or remove Resume button based on playback position
'
sub manageResumeButton()
resumeButton = m.top.findNode("resumeButton")
if isValid(m.top.itemContent) and isValid(m.top.itemContent.json) and isValid(m.top.itemContent.json.UserData)
' Validate RunTimeTicks before proceeding - required for progress bar calculation
if not isValid(m.top.itemContent.json.RunTimeTicks) or m.top.itemContent.json.RunTimeTicks <= 0
' Invalid runtime, remove resume button if present and exit
removeResumeButtonWithFocus(resumeButton)
return
end if
if isValid(m.top.itemContent.json.UserData.PlaybackPositionTicks) and m.top.itemContent.json.UserData.PlaybackPositionTicks > 0
' Add resume button if not already present
if not isValid(resumeButton)
' Capture current focus state before modifying button group
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 = m.top.itemContent.json.UserData.PlaybackPositionTicks
resumeButton.runtimeTicks = m.top.itemContent.json.RunTimeTicks
' Insert at index 0 (first position)
m.buttonGrp.insertChild(resumeButton, 0)
' Update focus: move to Resume if previously on Play, otherwise maintain focus on same button
if isValid(currentFocusedButton) and currentFocusedButton.id = "playButton"
' User was on Play button, move them to Resume
m.buttonGrp.buttonFocused = 0
else if isValid(currentFocusIndex) and currentFocusIndex >= 0
' User was on another button, adjust index to account for insertion at index 0
m.buttonGrp.buttonFocused = currentFocusIndex + 1
else
' No previous focus or invalid state, default to Resume
m.buttonGrp.buttonFocused = 0
end if
m.buttonGrp.callFunc("focus")
else
' Resume button already exists, update its tick values (data may have refreshed)
resumeButton.playbackPositionTicks = m.top.itemContent.json.UserData.PlaybackPositionTicks
resumeButton.runtimeTicks = m.top.itemContent.json.RunTimeTicks
end if
else
' Remove resume button if present
removeResumeButtonWithFocus(resumeButton)
end if
else
' No valid data, remove resume button if present
removeResumeButtonWithFocus(resumeButton)
end if
end sub
' removeResumeButtonWithFocus: Helper to remove resume button while preserving focus
' @param {object} resumeButton - The resume button node to remove (if valid)
'
sub removeResumeButtonWithFocus(resumeButton as object)
if not isValid(resumeButton) then return
' Preserve user's focus position when removing Resume button
currentFocusIndex = m.buttonGrp.buttonFocused
' Reset tick values before removing to clean up state
resumeButton.playbackPositionTicks = 0
resumeButton.runtimeTicks = 0
m.buttonGrp.removeChild(resumeButton)
' Adjust focus: if user was on Resume (index 0), move to Play (new index 0)
' Otherwise, shift focus index down by 1 to maintain same logical button
if isValid(currentFocusIndex) and currentFocusIndex > 0
m.buttonGrp.buttonFocused = currentFocusIndex - 1
else
m.buttonGrp.buttonFocused = 0
end if
m.buttonGrp.callFunc("focus")
end sub
' createInfoLabel: Create a label for the info/director rows
' @param labelId - Unique ID for the label
' @return Configured LabelPrimaryMedium node (bold)
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 dividerId - Unique ID for the divider
' @return 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 with dividers
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 director/genre group with dividers
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
' populateInfoGroup: Dynamically populate info and director/genre groups
sub populateInfoGroup()
' Clear existing nodes
m.infoGroup.removeChildrenIndex(m.infoGroup.getChildCount(), 0)
m.directorGenreGroup.removeChildrenIndex(m.directorGenreGroup.getChildCount(), 0)
m.infoDividerCount = 0
m.directorGenreDividerCount = 0
itemData = m.top.itemContent.json
userSettings = m.global.user.settings
' print "itemData:", itemData
' === ROW 1: Info Group ===
' Order: year, officialRating, communityRating, criticRating, runtime, ends-at
' 1. Release Year
if isValid(itemData.productionYear) and itemData.Type <> "Episode"
yearNode = createInfoLabel("releaseYear")
yearNode.text = itemData.productionYear.toStr().trim()
displayInfoNode(yearNode)
end if
' 2. Official Rating (PG, R, etc.)
if isValid(itemData.officialRating)
ratingNode = createInfoLabel("officialRating")
ratingNode.text = itemData.officialRating
displayInfoNode(ratingNode)
end if
' 3. Community Rating (star + number)
if userSettings.uiMoviesShowRatings and isValid(itemData.CommunityRating)
communityRatingNode = CreateObject("roSGNode", "CommunityRating")
communityRatingNode.id = "communityRating"
communityRatingNode.rating = itemData.CommunityRating
displayInfoNode(communityRatingNode)
end if
' 4. Critic Rating (tomato + number)
if userSettings.uiMoviesShowRatings and isValid(itemData.CriticRating)
criticRatingNode = CreateObject("roSGNode", "CriticRating")
criticRatingNode.id = "criticRating"
criticRatingNode.rating = itemData.CriticRating
displayInfoNode(criticRatingNode)
end if
' 5. Runtime
if type(itemData.RunTimeTicks) = "LongInteger"
runtimeNode = createInfoLabel("runtime")
runtimeNode.text = stri(getRuntime()).trim() + " " + tr("mins")
displayInfoNode(runtimeNode)
end if
' 6. Ends At
if type(itemData.RunTimeTicks) = "LongInteger" and userSettings.uiDesignHideClock <> true
endsAtNode = createInfoLabel("ends-at")
endsAtNode.text = tr("Ends at %1").Replace("%1", getEndTime())
displayInfoNode(endsAtNode)
end if
' 7. Aired (Episodes only)
if isValid(itemData.PremiereDate) and itemData.Type = "Episode"
airDate = CreateObject("roDateTime")
airDate.FromISO8601String(itemData.PremiereDate)
airedNode = createInfoLabel("aired")
airedNode.text = tr("Aired") + ": " + airDate.AsDateString("short-month-no-weekday")
displayInfoNode(airedNode)
end if
' === ROW 2: Director & Genres ===
' 1. Director
directors = []
if isValid(itemData.people)
for each person in itemData.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
' 2. Genres (concatenated with " / ")
if isValid(itemData.genres) and itemData.genres.count() > 0
genreNode = createInfoLabel("genre")
genreNode.text = itemData.genres.join(" / ")
displayDirectorGenreNode(genreNode)
else if isValid(itemData.tags) and itemData.tags.count() > 0
' Fallback to tags if no genres
tagNode = createInfoLabel("tag")
tagNode.text = itemData.tags.join(" / ")
displayDirectorGenreNode(tagNode)
end if
end sub
sub itemContentChanged()
' Updates video metadata
item = m.top.itemContent
' Set backdrop from movie data
if isValid(item) and isValid(item.backdropUrl) and item.backdropUrl <> ""
m.global.sceneManager.callFunc("setBackgroundImage", item.backdropUrl)
else
m.global.sceneManager.callFunc("setBackgroundImage", "")
end if
if isValid(item) and isValid(item.json)
userSettings = m.global.user.settings
itemData = item.json
m.top.id = itemData.id
' Set default video source if user hasn't selected one yet
if m.top.selectedVideoStreamId = "" and isValid(itemData.MediaSources)
m.top.selectedVideoStreamId = itemData.MediaSources[0].id
end if
SetDefaultAudioTrack(itemData)
' Populate info rows dynamically
populateInfoGroup()
' Handle all "As Is" fields
m.top.overhangTitle = itemData.name
setFieldText("overview", itemData.overview)
if userSettings.uiDetailsHideTagline = false
if itemData.taglines.count() > 0
setFieldText("tagline", itemData.taglines[0])
end if
else
m.details.removeChild(m.tagline)
end if
updateFavoriteButton()
updateWatchedButton()
SetUpVideoOptions(itemData.mediaSources)
SetUpAudioOptions(itemData.mediaStreams)
updateOptionsButtonVisibility()
' Only manage resume button when explicitly allowed (initial load or returning to screen)
' Prevents button changes during playback transition
if m.allowButtonUpdates
manageResumeButton()
m.allowButtonUpdates = false ' Disable until next OnScreenShown
end if
end if
m.buttonGrp.visible = true
stopLoadingSpinner()
end sub
sub SetUpVideoOptions(streams)
videos = []
codecDetailsSet = false
for i = 0 to streams.Count() - 1
if streams[i].VideoType = "VideoFile"
codec = ""
if isValid(streams[i].mediaStreams) and streams[i].mediaStreams.Count() > 0
' find the first (default) video track to get the codec for the details screen
if codecDetailsSet = false
for index = 0 to streams[i].mediaStreams.Count() - 1
if streams[i].mediaStreams[index].Type = "Video"
setFieldText("video_codec", tr("Video") + ": " + streams[i].mediaStreams[index].displayTitle)
codecDetailsSet = true
exit for
end if
end for
end if
' Find first video stream - MediaStreams[0] might be subtitle/audio
videoStream = getFirstVideoStream(streams[i].mediaStreams)
if isValid(videoStream) and isValid(videoStream.displayTitle)
codec = videoStream.displayTitle
else
codec = tr("N/A")
end if
end if
' Create options for user to switch between video tracks
videos.push({
"Title": streams[i].Name,
"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"
tracks.push({ "Title": streams[i].displayTitle, "Description": streams[i].Title, "Selected": m.top.selectedAudioStreamIndex = i, "StreamIndex": i })
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
sub SetDefaultAudioTrack(itemData)
' Extract MediaStreams from itemData
mediaStreams = invalid
if isValid(itemData.mediaStreams)
mediaStreams = itemData.mediaStreams
else if isValid(itemData.MediaSources) and isValid(itemData.MediaSources[0]) and isValid(itemData.MediaSources[0].MediaStreams)
mediaStreams = itemData.MediaSources[0].MediaStreams
end if
if not isValid(mediaStreams) then return
' Get user settings for audio selection
localUser = m.global.user
' Resolve playDefaultAudioTrack setting (JellyRock override or web client)
playDefault = resolvePlayDefaultAudioTrack(localUser.settings, localUser.config)
' Use new comprehensive audio selection logic
selectedIndex = findBestAudioStreamIndex(mediaStreams, playDefault, localUser.config.audioLanguagePreference)
' Find the audio stream with the selected index to get its displayTitle
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
' Set the selected audio stream index
m.top.selectedAudioStreamIndex = selectedIndex
' Update UI with audio codec information
if isValid(defaultAudioStream) and isValid(defaultAudioStream.displayTitle)
setFieldText("audio_codec", tr("Audio") + ": " + defaultAudioStream.displayTitle)
end if
end sub
sub setFieldText(field, value)
node = m.top.findNode(field)
if not isValid(node) or not isValid(value) then return
' Handle non strings... Which _shouldn't_ happen, but hey
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
itemData = m.top.itemContent.json
' A tick is .1ms, so 1/10,000,000 for ticks to seconds,
' then 1/60 for seconds to minutess... 1/600,000,000
return round(itemData.RunTimeTicks / 600000000.0)
end function
function getEndTime() as string
itemData = m.top.itemContent.json
date = CreateObject("roDateTime")
duration_s = int(itemData.RunTimeTicks / 10000000.0)
date.fromSeconds(date.asSeconds() + duration_s)
date.toLocalTime()
return formatTime(date)
end function
sub updateFavoriteButton()
fave = m.top.itemContent.favorite
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.watched
watchedButton = m.top.findNode("watchedButton")
if isValid(watchedButton)
if watched
watchedButton.selected = true
else
watchedButton.selected = false
end if
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
' Count video tracks
if isValid(m.options.options) and isValid(m.options.options.videos)
videoCount = m.options.options.videos.count()
end if
' Count audio tracks
if isValid(m.options.options) and isValid(m.options.options.audios)
audioCount = m.options.options.audios.count()
end if
' Determine if options button should be shown
shouldShowOptions = videoCount > 1 or audioCount > 1
' Check current state
buttonExists = isValid(m.optionsButton)
if shouldShowOptions and not buttonExists
' Create and add the options button (always right after play button)
' Capture current focus state before modifying button group
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)
' Adjust focus index if user was on a button after the insertion point
if isValid(currentFocusIndex) and currentFocusIndex >= insertIndex
m.buttonGrp.buttonFocused = currentFocusIndex + 1
end if
else
' Play button not found; append options button to end and log warning
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
' Capture current focus state before removing button
currentFocusIndex = m.buttonGrp.buttonFocused
optionsButtonIndex = getButtonIndex("optionsButton")
' Remove the button and clear reference
m.buttonGrp.removeChild(m.optionsButton)
m.optionsButton = invalid
' Adjust focus index if user was on a button after the removed button
if isValid(currentFocusIndex) and isValid(optionsButtonIndex) and currentFocusIndex > optionsButtonIndex
m.buttonGrp.buttonFocused = currentFocusIndex - 1
end if
end if
end sub
' getButtonIndex: Helper to find button index in button group
' @param {string} buttonId - The id of the button to find
' @returns {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
function round(f as float) as integer
' BrightScript only has a "floor" round
' This compares 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
'Check if options updated and any reloading required
sub audioOptionsClosed()
if m.options.audioStreamIndex <> m.top.selectedAudioStreamIndex
m.top.selectedAudioStreamIndex = m.options.audioStreamIndex
setFieldText("audio_codec", tr("Audio") + ": " + m.top.itemContent.json.mediaStreams[m.top.selectedAudioStreamIndex].displayTitle)
end if
m.buttonGrp.callFunc("focus")
end sub
' Check if options were updated and if any reloding is needed...
sub videoOptionsClosed()
if m.options.videoStreamId <> m.top.selectedVideoStreamId
m.top.selectedVideoStreamId = m.options.videoStreamId
setFieldText("video_codec", tr("Video") + ": " + m.options.video_codec)
' Because the video stream has changed (i.e. the actual video)... we need to reload the audio stream choices for that video
m.top.unobservefield("itemContent")
itemData = m.top.itemContent.json
for each mediaSource in itemData.mediaSources
if mediaSource.id = m.top.selectedVideoStreamId
itemData.mediaStreams = []
for i = 0 to mediaSource.mediaStreams.Count() - 1
itemData.mediaStreams.push(mediaSource.mediaStreams[i])
end for
SetDefaultAudioTrack(itemData)
SetUpAudioOptions(itemData.mediaStreams)
exit for
end if
end for
m.top.itemContent.json = itemData
m.top.observeField("itemContent", "itemContentChanged")
end if
m.buttonGrp.callFunc("focus")
end sub
function onKeyEvent(key as string, press as boolean) as boolean
' Due to the way the button pressed event works, need to catch the release for the button as the press is being sent
' directly to the main loop. Will get this sorted in the layout update for Movie Details
if key = "OK" and isValid(m.optionsButton) and m.optionsButton.isInFocusChain()
m.options.visible = true
m.options.setFocus(true)
end if
if key = "down" and m.buttonGrp.isInFocusChain()
m.top.lastFocus = m.extrasGrid
m.extrasGrid.setFocus(true)
m.top.findNode("VertSlider").reverse = false
m.top.findNode("pplAnime").control = "start"
return true
end if
if key = "up" and m.top.findNode("extrasGrid").isInFocusChain()
if m.extrasGrid.itemFocused = 0
m.top.lastFocus = m.buttonGrp
m.top.findNode("VertSlider").reverse = true
m.top.findNode("pplAnime").control = "start"
m.buttonGrp.callFunc("focus")
return true
end if
end if
if not press then return false
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()
print "Play was pressed from the movie details extras slider"
if isValid(m.extrasGrid.focusedItem)
m.top.quickPlayNode = m.extrasGrid.focusedItem
return true
end if
end if
return false
end function