import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/utils/misc.bs"
sub init()
m.log = log.Logger("OSD")
m.inactivityTimer = m.top.findNode("inactivityTimer")
m.endsAtTime = m.top.findNode("endsAtTime")
m.videoLogo = m.top.findNode("videoLogo")
m.videoTitle = m.top.findNode("videoTitle")
m.videoSubtitleGroup = m.top.findNode("videoSubtitleGroup")
m.videoPlayPause = m.top.findNode("videoPlayPause")
m.videoPositionTime = m.top.findNode("videoPositionTime")
m.videoRemainingTime = m.top.findNode("videoRemainingTime")
m.progressBar = m.top.findNode("progressBar")
m.progressBarBackground = m.top.findNode("progressBarBackground")
m.clock = m.top.findNode("clock")
if isValid(m.clock)
m.clock.observeField("minutes", "setEndsAtText")
end if
m.top.observeField("itemData", "onItemDataChanged")
m.top.observeField("visible", "onVisibleChanged")
m.top.observeField("hasFocus", "onFocusChanged")
m.top.observeField("progressPercentage", "onProgressPercentageChanged")
m.top.observeField("playbackState", "onPlaybackStateChanged")
m.isFirstRun = true
m.defaultButtonIndex = 1
m.focusedButtonIndex = 1
m.subtitleDividerCount = 0
m.buttonMenuRight = m.top.findNode("buttonMenuRight")
m.buttonMenuLeft = m.top.findNode("buttonMenuLeft")
m.buttonMenuLeft.buttonFocused = m.defaultButtonIndex
end sub
' onItemDataChanged: Reads typed fields from JellyfinBaseItem node and populates OSD display.
'
' Replaces the old JSON-parsing setFields(). The item node is immutable - all metadata
' is read directly from typed fields (item.type, item.name, item.communityRating, etc.)
'
' Content Type Detection (isMovie/isSeries):
' These flags determine which user settings control ratings display:
' - isMovie=true: Uses uiMoviesShowRatings setting
' - isSeries=true: Uses uiTvShowsDisableCommunityRating setting
'
' Detection Priority (applied in order):
' 1. API flags: Uses isMovie/isSeries from JellyfinBaseItem if provided
' 2. Flag validation: Ensures mutual exclusivity (both cannot be true)
' 3. Type-based: Recording types always classified as series content
' 4. Metadata heuristic: Presence of seriesName/parentIndexNumber indicates series
' 5. Default fallback: Assumes movie content if no other indicators
'
' @return {void}
sub onItemDataChanged()
item = m.top.itemData
if not isValid(item) or not isValidAndNotEmpty(item.id) then return
' Cache item reference for use in display methods
m.itemData = item
itemType = item.type
' Determine isMovie/isSeries flags for ratings display settings.
' For TvChannel, use the currently-airing program's flags if available.
if itemType = "TvChannel" and isValid(item.currentProgram)
isMovieFlag = item.currentProgram.isMovie
isSeriesFlag = item.currentProgram.isSeries
else
isMovieFlag = item.isMovie
isSeriesFlag = item.isSeries
end if
' Validate mutual exclusivity: both flags cannot be true
if isMovieFlag and isSeriesFlag
' API provided conflicting flags - prioritize isSeries as it's more specific
isMovieFlag = false
end if
' Fallback heuristic: if no flags provided, infer from metadata
if not isMovieFlag and not isSeriesFlag
' Recording types are episode-like content (same as Episode handling elsewhere)
if itemType = "Recording"
isSeriesFlag = true
else if isValidAndNotEmpty(item.seriesName) or item.parentIndexNumber > 0
isSeriesFlag = true
else
isMovieFlag = true
end if
end if
m.top.isMovie = isMovieFlag
m.top.isSeries = isSeriesFlag
' Chapters
m.top.hasChapters = item.chapterCount > 0
' Stream counts - read pre-computed values from item node
m.top.numAudioStreams = item.audioStreamCount
' Runtime — for TvChannel, use the currently-airing program's runtime if available
runTimeTicks = item.runTimeTicks
if itemType = "TvChannel" and isValid(item.currentProgram) and item.currentProgram.runTimeTicks > 0
runTimeTicks = item.currentProgram.runTimeTicks
end if
if runTimeTicks > 0
m.top.runTimeMinutes = ticksToMinutes(runTimeTicks)
else
m.top.runTimeMinutes = 0
end if
setButtonStates()
populateData()
end sub
sub populateData()
setVideoLogoGroup()
setVideoTitle()
setVideoSubTitle()
end sub
' setButtonStates: Disable previous/next buttons if needed and remove any other unneeded buttons
sub setButtonStates()
queueCount = m.global.queueManager.callFunc("getCount")
queueIndex = m.global.queueManager.callFunc("getPosition")
' Disable these buttons as needed
' Item Previous
if queueCount = 1 or queueIndex = 0
itemPrevious = m.buttonMenuLeft.findNode("itemBack")
itemPrevious.enabled = false
end if
' Item Next
if queueIndex + 1 >= queueCount
itemNext = m.buttonMenuLeft.findNode("itemNext")
itemNext.enabled = false
end if
' Remove these buttons as needed
' Audio Track
if m.top.numAudioStreams < 2
m.buttonMenuLeft.removeChild(m.buttonMenuLeft.findNode("showAudioMenu"))
end if
' Subtitles
if not m.itemData.hasSubtitles
m.buttonMenuLeft.removeChild(m.buttonMenuLeft.findNode("showSubtitleMenu"))
end if
' Chapters
if not m.top.hasChapters
m.buttonMenuLeft.removeChild(m.buttonMenuLeft.findNode("chapterList"))
end if
end sub
sub setEndsAtText()
endsAtText = m.top.findNode("endsAtText")
if m.global.user.settings.uiDesignHideClock
endsAtText.visible = false
m.endsAtTime.text = ""
return
end if
' For live TV channels, use the currently-airing program's EndDate for end time.
' remainingPositionTime ≈ 0 for live streams so the default calculation would show current time.
if isValid(m.itemData) and m.itemData.type = "TvChannel"
currentProgram = m.itemData.currentProgram
if isValid(currentProgram) and isValidAndNotEmpty(currentProgram.endDate)
endDt = CreateObject("roDateTime")
endDt.FromISO8601String(currentProgram.endDate)
endDt.toLocalTime()
m.endsAtTime.text = formatTime(endDt)
endsAtText.visible = true
else
endsAtText.visible = false
m.endsAtTime.text = ""
end if
return
end if
' Calculate endsAtTime based on remainingPositionTime
date = CreateObject("roDateTime")
endTime = int(m.top.remainingPositionTime)
date.fromSeconds(date.asSeconds() + endTime)
date.toLocalTime()
m.endsAtTime.text = formatTime(date)
end sub
sub setVideoLogoGroup()
m.videoLogo.uri = m.top.videoLogo
end sub
sub setVideoTitle()
item = m.itemData
' For TvChannel, display the currently-airing program name rather than the channel name
if item.type = "TvChannel" and isValid(item.currentProgram) and isValidAndNotEmpty(item.currentProgram.name)
m.videoTitle.text = item.currentProgram.name
else
m.videoTitle.text = item.name
end if
end sub
sub setVideoSubTitle()
' start fresh by removing all subtitle nodes
m.videoSubtitleGroup.removeChildrenIndex(m.videoSubtitleGroup.getChildCount(), 0)
airDateNodeCreated = false
item = m.itemData
itemType = item.type
' For TvChannel, ratings and dates are sourced from the currently-airing program when available
metaItem = item
if itemType = "TvChannel" and isValid(item.currentProgram)
metaItem = item.currentProgram
end if
' EPISODE
if itemType = "Episode" or itemType = "Recording"
' Title
if isValidAndNotEmpty(item.seriesName)
m.videoTitle.text = item.seriesName
end if
' episodeInfo
episodeInfoText = ""
'
' Season number
if item.parentIndexNumber > 0
episodeInfoText = episodeInfoText + `${tr("S")}${item.parentIndexNumber}`
else
episodeInfoText = episodeInfoText + `${tr("S")}?`
end if
' Episode number
if item.indexNumber > 0
episodeInfoText = episodeInfoText + `${tr("E")}${item.indexNumber}`
else
episodeInfoText = episodeInfoText + `${tr("E")}??`
end if
' Episode number end
if item.indexNumberEnd > 0 and item.indexNumberEnd > item.indexNumber
' add entry for every episode eg. S6:E1E2
for i = item.indexNumber + 1 to item.indexNumberEnd
episodeInfoText = episodeInfoText + `${tr("E")}${item.indexNumberEnd}`
end for
end if
' Episode name
if isValidAndNotEmpty(item.name)
episodeInfoText = episodeInfoText + ` - ${item.name}`
end if
if episodeInfoText <> ""
episodeInfoNode = createSubtitleLabelNode("episodeInfo")
episodeInfoNode.text = episodeInfoText
displaySubtitleNode(episodeInfoNode)
end if
else if itemType = "Movie"
' videoAirDate
if item.productionYear > 0
airDateNodeCreated = true
productionYearNode = createSubtitleLabelNode("productionYear")
productionYearNode.text = item.productionYear.toStr().trim()
displaySubtitleNode(productionYearNode)
end if
else if itemType = "TvChannel"
' Display currently-airing program metadata (episode info or movie year)
currentProgram = item.currentProgram
if isValid(currentProgram)
if currentProgram.isSeries
' Episode info: S1E2 - Episode Name
episodeInfoText = ""
if currentProgram.parentIndexNumber > 0
episodeInfoText = episodeInfoText + `${tr("S")}${currentProgram.parentIndexNumber}`
end if
if currentProgram.indexNumber > 0
episodeInfoText = episodeInfoText + `${tr("E")}${currentProgram.indexNumber}`
end if
if isValidAndNotEmpty(currentProgram.name)
if isValidAndNotEmpty(episodeInfoText)
episodeInfoText = episodeInfoText + ` - ${currentProgram.name}`
else
episodeInfoText = currentProgram.name
end if
end if
if isValidAndNotEmpty(episodeInfoText)
episodeInfoNode = createSubtitleLabelNode("episodeInfo")
episodeInfoNode.text = episodeInfoText
displaySubtitleNode(episodeInfoNode)
end if
else if currentProgram.isMovie and currentProgram.productionYear > 0
airDateNodeCreated = true
productionYearNode = createSubtitleLabelNode("productionYear")
productionYearNode.text = currentProgram.productionYear.toStr().trim()
displaySubtitleNode(productionYearNode)
end if
end if
' Channel number (e.g. "CH 4") — always shown when available
if isValidAndNotEmpty(item.channelNumber)
channelNumberNode = createSubtitleLabelNode("channelNumber")
channelNumberNode.text = `${tr("CH")} ${item.channelNumber}`
displaySubtitleNode(channelNumberNode)
end if
end if
' append these to all video types
'
userSettings = m.global.user.settings
' Official Rating
if isValidAndNotEmpty(metaItem.officialRating)
officialRatingNode = createSubtitleLabelNode("officialRating")
officialRatingNode.text = metaItem.officialRating
displaySubtitleNode(officialRatingNode)
end if
' Determine if ratings should be shown based on content type and user settings
showRatings = false
if m.top.isMovie
' Movie content - respect uiMoviesShowRatings setting
showRatings = userSettings.uiMoviesShowRatings
else if m.top.isSeries
' Series content - respect uiTvShowsDisableCommunityRating setting
showRatings = not userSettings.uiTvShowsDisableCommunityRating
else
' Unknown/other content types - show if metadata exists
showRatings = true
end if
if showRatings
' communityRating (star + rating)
if metaItem.communityRating <> 0
communityRatingNode = CreateObject("roSGNode", "CommunityRating")
communityRatingNode.id = "communityRating"
communityRatingNode.rating = metaItem.communityRating
communityRatingNode.iconSize = 30
displaySubtitleNode(communityRatingNode)
end if
' criticRating (tomato + rating)
if metaItem.criticRating <> 0
criticRatingNode = CreateObject("roSGNode", "CriticRating")
criticRatingNode.id = "criticRating"
criticRatingNode.rating = metaItem.criticRating
criticRatingNode.iconSize = 30
displaySubtitleNode(criticRatingNode)
end if
end if
' videoAirDate if needed
if not airDateNodeCreated and isValidAndNotEmpty(metaItem.premiereDate)
premiereDateNode = createSubtitleLabelNode("videoAirDate")
premiereDateNode.text = formatIsoDateVideo(metaItem.premiereDate)
displaySubtitleNode(premiereDateNode)
end if
' videoRunTime
if m.top.runTimeMinutes <> 0
runTimeNode = createSubtitleLabelNode("videoRunTime")
if m.top.runTimeMinutes < 2
runTimeText = `${m.top.runTimeMinutes} ` + tr("min")
else
runTimeText = `${m.top.runTimeMinutes} ` + tr("mins")
end if
runTimeNode.text = runTimeText
displaySubtitleNode(runTimeNode)
end if
end sub
sub onProgressPercentageChanged()
' change progress bar for live tv
itemType = ""
if isValid(m.itemData)
itemType = m.itemData.type
end if
if itemType = "TvChannel"
m.videoPositionTime.text = secondsToTimestamp(m.top.positionTime, true)
m.videoRemainingTime.text = tr("LIVE")
m.progressBar.width = m.progressBarBackground.width ' set to full width
else
m.videoPositionTime.text = secondsToTimestamp(m.top.positionTime, true)
m.videoRemainingTime.text = "-" + secondsToTimestamp(m.top.remainingPositionTime, true)
m.progressBar.width = m.progressBarBackground.width * m.top.progressPercentage
end if
setEndsAtText()
end sub
sub onPlaybackStateChanged()
if LCase(m.top.playbackState) = "playing"
m.videoPlayPause.icon = "pkg:/images/icons/pause.png"
return
end if
m.videoPlayPause.icon = "pkg:/images/icons/play.png"
end sub
sub resetFocusToDefaultButton()
' Remove focus from previously selected button
for each child in m.buttonMenuLeft.getChildren(-1, 0)
if isValid(child)
child.setFocus(false)
end if
end for
for each child in m.buttonMenuRight.getChildren(-1, 0)
if isValid(child)
child.setFocus(false)
end if
end for
' Set focus back to the default button
m.buttonMenuLeft.setFocus(true)
m.focusedButtonIndex = m.defaultButtonIndex
m.buttonMenuLeft.getChild(m.defaultButtonIndex).setFocus(true)
m.buttonMenuLeft.buttonFocused = m.defaultButtonIndex
end sub
sub onVisibleChanged()
if m.top.visible
resetFocusToDefaultButton()
if m.top.playbackState <> "paused"
m.inactivityTimer.observeField("fire", "inactiveCheck")
m.inactivityTimer.control = "start"
end if
else
m.inactivityTimer.control = "stop"
m.inactivityTimer.unobserveField("fire")
end if
end sub
sub onFocusChanged()
if m.top.hasfocus
m.buttonMenuLeft.setFocus(true)
end if
end sub
' inactiveCheck: Checks if the time since last keypress is greater than or equal to the allowed inactive time of the menu.
sub inactiveCheck()
' If user is currently seeing a dialog box, ignore inactive check
if m.global.sceneManager.callFunc("isDialogOpen")
return
end if
deviceInfo = CreateObject("roDeviceInfo")
if deviceInfo.timeSinceLastKeypress() >= m.top.inactiveTimeout
m.top.action = "hide"
end if
end sub
sub onButtonSelected()
if m.buttonMenuLeft.isInFocusChain()
selectedButton = m.buttonMenuLeft.getChild(m.buttonMenuLeft.buttonFocused)
else if m.buttonMenuRight.isInFocusChain()
selectedButton = m.buttonMenuRight.getChild(m.buttonMenuRight.buttonFocused)
else
return
end if
if LCase(selectedButton.id) = "chapterlist"
m.top.showChapterList = not m.top.showChapterList
end if
m.top.action = selectedButton.id
end sub
function createSubtitleLabelNode(labelId as string) as object
labelNode = CreateObject("roSGNode", "LabelPrimaryMedium")
labelNode.id = labelId
labelNode.horizAlign = "left"
labelNode.vertAlign = "center"
labelNode.width = 0
labelNode.height = 0
labelNode.bold = true
return labelNode
end function
function createSubtitleDividerNode() as object
m.subtitleDividerCount++
labelNode = CreateObject("roSGNode", "LabelPrimarySmall")
labelNode.id = "divider" + m.subtitleDividerCount.toStr()
labelNode.horizAlign = "left"
labelNode.vertAlign = "center"
labelNode.width = 0
labelNode.height = 40
labelNode.text = "•"
labelNode.bold = true
return labelNode
end function
sub displaySubtitleNode(node as object)
if not isValid(node) then return
subtitleChildrenCount = m.videoSubtitleGroup.getChildCount()
if subtitleChildrenCount > 0
' add a divider
dividerNode = createSubtitleDividerNode()
m.videoSubtitleGroup.appendChild(dividerNode)
end if
m.videoSubtitleGroup.appendChild(node)
end sub
sub OnScreenShown()
if m.isFirstRun
m.isFirstRun = false
else
m.clock.callFunc("resetTime")
end if
end sub
function onKeyEvent(key as string, press as boolean) as boolean
if not press then return false
if key = "play"
m.top.action = "videoplaypause"
return true
end if
if key = "OK"
onButtonSelected()
return true
end if
if key = "back" and m.top.visible
m.top.action = "hide"
return true
end if
if (key = "rewind" or key = "fastforward") and m.top.visible
m.top.action = "hide"
return false
end if
return false
end function
' destroy: Full teardown releasing all resources before component removal
' Called by VideoPlayerView.destroy() since OSD is a child component, not a SceneManager scene
sub destroy()
m.log.verbose("destroy")
' Unobserve all m.top observers
m.top.unobserveField("itemData")
m.top.unobserveField("visible")
m.top.unobserveField("hasFocus")
m.top.unobserveField("progressPercentage")
m.top.unobserveField("playbackState")
' Unobserve clock (guarded — may be invalid if clock node not found in init)
if isValid(m.clock)
m.clock.unobserveField("minutes")
m.clock = invalid
end if
' Stop inactivity timer (may already be stopped by onVisibleChanged)
m.inactivityTimer.unobserveField("fire")
m.inactivityTimer.control = "stop"
m.inactivityTimer = invalid
' Clear node references
m.endsAtTime = invalid
m.videoLogo = invalid
m.videoTitle = invalid
m.videoSubtitleGroup = invalid
m.videoPlayPause = invalid
m.videoPositionTime = invalid
m.videoRemainingTime = invalid
m.progressBar = invalid
m.progressBarBackground = invalid
m.buttonMenuRight = invalid
m.buttonMenuLeft = invalid
' Release cached item data reference
m.itemData = invalid
end sub