import "pkg:/source/constants/imageSize.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/liveTv.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/translate.bs"
sub init()
m.log = new log.Logger("OSD")
m.inactivityTimer = m.top.findNode("inactivityTimer")
m.endsAtTime = m.top.findNode("endsAtTime")
m.videoLogo = m.top.findNode("videoLogo")
' loadWidth/loadHeight bound texture memory to imageSize.LOGO_OSD with AR preserved
' (Roku treats them as a bounding box, not independent axes). noScale renders at the loaded
' size so the Poster auto-sizes to its content, keeping top-left alignment in the safe zone.
' Do NOT set width/height on the Poster node — that forces a fixed layout box and centers the image.
osdLogoSize = imageSize.LOGO_OSD
m.videoLogo.loadWidth = osdLogoSize.width
m.videoLogo.loadHeight = osdLogoSize.height
m.videoTitle = m.top.findNode("videoTitle")
m.videoSubtitleGroup = m.top.findNode("videoSubtitleGroup")
m.top.findNode("endsAtText").text = translate(translationKeys.LabelEndsAt)
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")
' positionTime drives live-TV calibration so the epoch anchor is captured the first time
' VideoPlayerView pushes a position during playback — even if the user pauses immediately
' afterward (e.g. by pressing play to open the OSD).
m.top.observeField("positionTime", "onPositionTimeChanged")
m.isFirstRun = true
m.defaultButtonIndex = 1
m.focusedButtonIndex = 1
m.subtitleDividerCount = 0
' Hybrid behind-live tracking. Roku's m.top.position for live HLS DVR streams advances
' even during pause (it tracks the live edge, not the user's playhead), so we use:
' - position-math during play (handles scrubs while playing)
' - wall-clock math during pause (Roku's auto-advance is invisible to us)
' On pause→play the epoch reference is re-anchored so play-math picks up where
' wall-clock math left off. See source/utils/liveTv.bs::computeLiveTvBehindLive.
m.liveTvCalibrated = false
m.liveTvEpochAtPosition0 = 0 ' play-math reference (epoch for video position=0)
m.liveTvPausedAtEpoch = 0 ' >0 while paused, snapshotted at pause start
m.liveTvAccumulatedBehind = 0 ' snapshot of behind-live at pause start (+ scrub adjustments)
m.liveTvLastPosition = 0 ' baseline for scrub-during-pause delta detection
m.liveTvIsAtLiveEdge = true
' Go to Live button — cached and attached/detached dynamically by setButtonStates()
' and attach/detachGoToLiveButton() so it doesn't reserve a layout slot when hidden.
m.goToLiveButton = invalid
m.goToLiveAttached = false
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
' Reset behind-live state only on channel change; program refreshes keep it.
' New stream → new position=0 origin → must recalibrate and clear any pause snapshot.
previousItemId = isValid(m.itemData) ? m.itemData.id : ""
if item.id <> previousItemId
m.liveTvCalibrated = false
m.liveTvEpochAtPosition0 = 0
m.liveTvPausedAtEpoch = 0
m.liveTvAccumulatedBehind = 0
m.liveTvLastPosition = 0
m.liveTvIsAtLiveEdge = true
end if
' Cache item reference for use in display methods
m.itemData = item
itemType = item.type
' Preserve the original item name from the first load.
' Source-switch reloads fetch metadata for an alternate version whose name
' may include year/edition suffixes (e.g., "Dracula (1931) [Colorized]").
' TvChannel titles always come from currentProgram.name, so caching is unnecessary.
if itemType <> "TvChannel" and not isValidAndNotEmpty(m.originalItemName)
m.originalItemName = item.name
end if
' 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
' Only update video source count from meta if it's higher than the current value.
' During a source-switch reload, the reloaded meta represents a single MediaSource
' and hasMultipleVideoSources will be false, but the original source list is still valid.
metaVideoSources = item.hasMultipleVideoSources ? 2 : 1
if metaVideoSources > m.top.numVideoSources
m.top.numVideoSources = metaVideoSources
end if
' 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")
isTvChannel = m.itemData.type = "TvChannel"
' Disable these buttons as needed
' Keep prev/next enabled for TvChannel — channel list loads async after init
if not isTvChannel and (queueCount = 1 or queueIndex = 0)
itemPrevious = m.buttonMenuLeft.findNode("itemBack")
itemPrevious.isEnabled = false
end if
if not isTvChannel and (queueIndex + 1 >= queueCount)
itemNext = m.buttonMenuLeft.findNode("itemNext")
itemNext.isEnabled = false
end if
' Remove these buttons as needed
' Video Sources - hide for single source or live TV
if m.top.numVideoSources < 2 or isTvChannel
m.buttonMenuLeft.removeChild(m.buttonMenuLeft.findNode("showVideoSourceMenu"))
end if
' 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 — TvChannel has none
if not m.top.hasChapters or isTvChannel
m.buttonMenuLeft.removeChild(m.buttonMenuLeft.findNode("chapterList"))
end if
' Go to Live — detach from layout until the user falls behind the live edge, then
' re-insert via attachGoToLiveButton(). Toggling visible/isEnabled isn't enough:
' LayoutGroup reserves a slot for invisible children, so a hidden button would
' leave a gap in the button row.
if not isTvChannel
existing = m.buttonMenuLeft.findNode("goToLive")
if isValid(existing)
m.buttonMenuLeft.removeChild(existing)
end if
m.goToLiveButton = invalid
m.goToLiveAttached = false
else
if not isValid(m.goToLiveButton)
m.goToLiveButton = m.buttonMenuLeft.findNode("goToLive")
if isValid(m.goToLiveButton)
' XML attaches the button at load time
m.goToLiveAttached = true
end if
end if
if isValid(m.goToLiveButton)
m.goToLiveButton.text = UCase(translate(translationKeys.LabelLive))
detachGoToLiveButton()
end if
end if
end sub
' Insert after itemNext (index 3 in the XML-declared button order) so prev/next stay
' contiguous and the live-edge button appears where channel up/down users look first.
sub attachGoToLiveButton()
if not isValid(m.goToLiveButton) or m.goToLiveAttached then return
m.goToLiveButton.visible = true
m.buttonMenuLeft.insertChild(m.goToLiveButton, 3)
m.goToLiveAttached = true
end sub
sub detachGoToLiveButton()
if not isValid(m.goToLiveButton) or not m.goToLiveAttached then return
m.buttonMenuLeft.removeChild(m.goToLiveButton)
m.goToLiveAttached = false
end sub
sub setEndsAtText()
endsAtText = m.top.findNode("endsAtText")
if m.global.user.settings.uiDesignHideClock
endsAtText.visible = false
m.endsAtTime.text = ""
return
end if
' Live TV: anchor on currentProgram.endDate (broadcast-schedule wall-clock end), then
' shift forward by the user's behind-live offset so the displayed "Ends at" reflects
' when the user will actually finish watching the program. At live edge behindLive=0
' so this collapses to the broadcast end 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)
behindLive = getLiveTvBehindLiveSeconds()
if behindLive > 0
endDt.fromSeconds(endDt.asSeconds() + behindLive)
end if
endDt.toLocalTime()
m.endsAtTime.text = formatTime(endDt)
endsAtText.visible = true
else
endsAtText.visible = false
m.endsAtTime.text = ""
end if
return
end if
' Hide when remainingPositionTime isn't meaningful yet (pre-load or zero duration),
' otherwise "Ends at" would show the current clock time
endTime = int(m.top.remainingPositionTime)
if endTime <= 0
endsAtText.visible = false
m.endsAtTime.text = ""
return
end if
date = CreateObject("roDateTime")
date.fromSeconds(date.asSeconds() + endTime)
date.toLocalTime()
m.endsAtTime.text = formatTime(date)
endsAtText.visible = true
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
' Use preserved original name (m.originalItemName) to avoid alternate version names
' that may include year/edition suffixes (e.g., "Dracula (1931) [Colorized]").
' Falls back to item.name on first load before the original is cached.
title = m.originalItemName ?? item.name
' Append active video source tag when multiple sources exist (e.g., "[B&W]")
if isValidAndNotEmpty(m.top.videoSourceTag)
title += " " + m.top.videoSourceTag
end if
m.videoTitle.text = title
end if
end sub
sub setVideoSubTitle()
' start fresh by removing all subtitle nodes
m.videoSubtitleGroup.removeChildrenIndex(m.videoSubtitleGroup.getChildCount(), 0)
' Reset divider id counter so ids stay bounded across program transitions on live TV.
m.subtitleDividerCount = 0
isAirDateNodeCreated = 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: "S1E2 - Name". Omit S/E segments entirely when the numbers aren't
' known — common for live TV recordings classified as series episodes with no EPG
' numbering — so we don't display placeholder strings like "S?E?? - X".
episodeInfoText = ""
if item.parentIndexNumber > 0
episodeInfoText = episodeInfoText + `${translate(translationKeys.LabelS)}${item.parentIndexNumber}`
end if
if item.indexNumber > 0
episodeInfoText = episodeInfoText + `${translate(translationKeys.LabelE)}${item.indexNumber}`
end if
' Episode number end — additional E entries for multi-episode specials (e.g. S6:E1E2)
if item.indexNumberEnd > 0 and item.indexNumberEnd > item.indexNumber
for i = item.indexNumber + 1 to item.indexNumberEnd
episodeInfoText = episodeInfoText + `${translate(translationKeys.LabelE)}${i}`
end for
end if
' Append episode name when we have S/E context. When we don't, fall back to the bare
' name only if it differs from what's already on the title line — otherwise we'd
' duplicate the title.
if isValidAndNotEmpty(item.name)
if isValidAndNotEmpty(episodeInfoText)
episodeInfoText = episodeInfoText + ` - ${item.name}`
else if m.videoTitle.text <> item.name
episodeInfoText = item.name
end if
end if
if episodeInfoText <> ""
episodeInfoNode = createSubtitleLabelNode("episodeInfo")
episodeInfoNode.text = episodeInfoText
displaySubtitleNode(episodeInfoNode)
end if
else if itemType = "Movie"
' videoAirDate
if item.productionYear > 0
isAirDateNodeCreated = 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. Skip entirely when we have no season/episode
' numbers — the program name is already shown as the video title and we don't want
' to duplicate it (e.g. news bulletins named "INFO" with no S/E numbering).
episodeInfoText = ""
if currentProgram.parentIndexNumber > 0
episodeInfoText = episodeInfoText + `${translate(translationKeys.LabelS)}${currentProgram.parentIndexNumber}`
end if
if currentProgram.indexNumber > 0
episodeInfoText = episodeInfoText + `${translate(translationKeys.LabelE)}${currentProgram.indexNumber}`
end if
if isValidAndNotEmpty(episodeInfoText) and isValidAndNotEmpty(currentProgram.name)
episodeInfoText = episodeInfoText + ` - ${currentProgram.name}`
end if
if isValidAndNotEmpty(episodeInfoText)
episodeInfoNode = createSubtitleLabelNode("episodeInfo")
episodeInfoNode.text = episodeInfoText
displaySubtitleNode(episodeInfoNode)
end if
else if currentProgram.isMovie and currentProgram.productionYear > 0
isAirDateNodeCreated = true
productionYearNode = createSubtitleLabelNode("productionYear")
productionYearNode.text = currentProgram.productionYear.toStr().trim()
displaySubtitleNode(productionYearNode)
end if
end if
' Channel identity (e.g. "CH 4 - NHK World") — mirrors the ItemDetails program info row.
' For TvChannel items, item.name is the channel name.
channelParts = []
if isValidAndNotEmpty(item.channelNumber)
channelParts.push(`${translate(translationKeys.LabelCh)} ${item.channelNumber}`)
end if
if isValidAndNotEmpty(item.name)
channelParts.push(item.name)
end if
if channelParts.count() > 0
channelNumberNode = createSubtitleLabelNode("channelNumber")
channelNumberNode.text = channelParts.join(" - ")
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
shouldShowRatings = false
if m.top.isMovie
' Movie content - respect uiMoviesShowRatings setting
shouldShowRatings = userSettings.uiMoviesShowRatings
else if m.top.isSeries
' Series content - respect uiTvShowsDisableCommunityRating setting
shouldShowRatings = not userSettings.uiTvShowsDisableCommunityRating
else
' Unknown/other content types - show if metadata exists
shouldShowRatings = true
end if
if shouldShowRatings
' 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 isAirDateNodeCreated 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} ` + translate(translationKeys.LabelMin)
else
runTimeText = `${m.top.runTimeMinutes} ` + translate(translationKeys.LabelMins)
end if
runTimeNode.text = runTimeText
displaySubtitleNode(runTimeNode)
end if
end sub
sub onProgressPercentageChanged()
if isLiveTvItem()
updateLiveTvDisplay()
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
' Called from onProgressPercentageChanged() (position updates) and inactiveCheck()
' (1s timer). The timer ensures behind-live derived state ticks while paused.
sub updateLiveTvDisplay()
programStart = parseProgramStartTimeEpoch(m.itemData)
programEnd = parseProgramEndTimeEpoch(m.itemData)
now = CreateObject("roDateTime").AsSeconds()
position = int(m.top.positionTime)
' Backstop: positionTime/playbackState observers normally calibrate this earlier,
' but cover the path where neither has fired yet (e.g. fallback inactivity tick).
calibrateLiveTvIfNeeded()
behindLive = computeLiveTvBehindLive(m.liveTvEpochAtPosition0, m.liveTvCalibrated, position, now, m.liveTvPausedAtEpoch, m.liveTvAccumulatedBehind)
' Pause is an explicit user action — flip to "behind live" UI immediately rather than
' waiting for behindLive math to grow past the threshold. The threshold itself only
' suppresses sub-second rounding flicker during normal play.
m.liveTvIsAtLiveEdge = LCase(m.top.playbackState) = "playing" and behindLive < LIVE_EDGE_THRESHOLD_SECONDS
if m.liveTvIsAtLiveEdge
detachGoToLiveButton()
else
attachGoToLiveButton()
end if
if programStart > 0 and programEnd > programStart
programDuration = programEnd - programStart
if m.liveTvIsAtLiveEdge
elapsed = now - programStart
if elapsed < 0 then elapsed = 0
if elapsed > programDuration then elapsed = programDuration
programProgress = elapsed / programDuration
m.videoPositionTime.text = secondsToTimestamp(elapsed, true)
m.progressBar.width = m.progressBarBackground.width * programProgress
m.videoRemainingTime.text = UCase(translate(translationKeys.LabelLive))
else
userElapsed = (now - behindLive) - programStart
if userElapsed < 0 then userElapsed = 0
if userElapsed > programDuration then userElapsed = programDuration
userProgress = userElapsed / programDuration
m.videoPositionTime.text = secondsToTimestamp(userElapsed, true)
m.progressBar.width = m.progressBarBackground.width * userProgress
m.videoRemainingTime.text = "-" + secondsToTimestamp(behindLive, true)
end if
else
' Fallback when program timing data is missing
m.videoPositionTime.text = secondsToTimestamp(m.top.positionTime, true)
if m.liveTvIsAtLiveEdge
m.progressBar.width = m.progressBarBackground.width
m.videoRemainingTime.text = UCase(translate(translationKeys.LabelLive))
else
m.progressBar.width = m.progressBarBackground.width * m.top.progressPercentage
m.videoRemainingTime.text = "-" + secondsToTimestamp(behindLive, true)
end if
end if
end sub
sub onPlaybackStateChanged()
state = LCase(m.top.playbackState)
if state = "playing"
m.videoPlayPause.icon = "pkg:/images/icons/pause_$$RES$$.png"
calibrateLiveTvIfNeeded()
onLiveTvResumeIfNeeded()
else
m.videoPlayPause.icon = "pkg:/images/icons/play_$$RES$$.png"
if state = "paused" then onLiveTvPauseIfNeeded()
end if
end sub
' Anchor m.liveTvEpochAtPosition0 on first play tick. Idempotent.
sub calibrateLiveTvIfNeeded()
if not isLiveTvItem() then return
if m.liveTvCalibrated then return
if LCase(m.top.playbackState) <> "playing" then return
position = int(m.top.positionTime)
m.liveTvEpochAtPosition0 = CreateObject("roDateTime").AsSeconds() - position
m.liveTvLastPosition = position
m.liveTvCalibrated = true
end sub
' Snapshot the current behindLive into accumulated and start the wall-clock pause counter.
' Wall-clock math takes over from here because Roku's m.top.position keeps advancing
' during pause for live HLS DVR streams (it tracks the live edge, not the user's playhead),
' which would silently zero out position-math.
sub onLiveTvPauseIfNeeded()
if not isLiveTvItem() then return
if not m.liveTvCalibrated then return
if m.liveTvPausedAtEpoch > 0 then return
now = CreateObject("roDateTime").AsSeconds()
position = int(m.top.positionTime)
currentBehind = (now - m.liveTvEpochAtPosition0) - position
if currentBehind < 0 then currentBehind = 0
m.liveTvAccumulatedBehind = currentBehind
m.liveTvPausedAtEpoch = now
m.liveTvLastPosition = position
end sub
' Re-anchor epochAtPosition0 so play-math continues from the same offset where
' wall-clock math left off, then clear pause state.
sub onLiveTvResumeIfNeeded()
if not isLiveTvItem() then return
if m.liveTvPausedAtEpoch = 0 then return
now = CreateObject("roDateTime").AsSeconds()
position = int(m.top.positionTime)
pauseDuration = now - m.liveTvPausedAtEpoch
totalBehind = m.liveTvAccumulatedBehind + pauseDuration
m.liveTvEpochAtPosition0 = now - position - totalBehind
m.liveTvPausedAtEpoch = 0
m.liveTvAccumulatedBehind = 0
m.liveTvLastPosition = position
end sub
sub onPositionTimeChanged()
if not isLiveTvItem() then return
calibrateLiveTvIfNeeded()
' Detect user scrubs during pause via large positionTime deltas. Roku's auto-advance
' during pause arrives in small per-fire increments (<1s) and is filtered out by the
' threshold; user-initiated seeks land as a single large delta.
if m.liveTvPausedAtEpoch > 0
newPos = int(m.top.positionTime)
delta = newPos - m.liveTvLastPosition
m.liveTvLastPosition = newPos
if delta < -2 or delta > 2
' Repartition the pause-state. Without folding the wall-clock pause counter into
' accumulated before applying the scrub, a forward scrub to live edge would zero
' accumulated but the pause counter would keep growing — total behindLive wouldn't
' actually reach 0.
now = CreateObject("roDateTime").AsSeconds()
m.liveTvAccumulatedBehind = repartitionPauseStateOnScrub(m.liveTvAccumulatedBehind, now - m.liveTvPausedAtEpoch, delta)
m.liveTvPausedAtEpoch = now
end if
end if
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()
' Timer runs even while paused so the live TV behind-live counter keeps ticking
m.inactivityTimer.observeField("fire", "inactiveCheck")
m.inactivityTimer.control = "start"
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
' 1s timer: auto-hide on inactivity AND drive the behind-live countdown while paused
sub inactiveCheck()
if m.global.sceneManager.callFunc("isDialogOpen")
return
end if
' onProgressPercentageChanged doesn't fire while paused, so update from the timer instead
if isLiveTvItem()
updateLiveTvDisplay()
setEndsAtText()
end if
deviceInfo = CreateObject("roDeviceInfo")
if deviceInfo.timeSinceLastKeypress() >= m.top.inactiveTimeout
if LCase(m.top.playbackState) = "paused"
return
end if
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.shouldShowChapterList = not m.top.shouldShowChapterList
end if
if LCase(selectedButton.id) = "gotolive"
' VideoPlayerView seeks to LIVE_EDGE_SEEK; clear all behind-live state so the next
' position tick re-anchors epochAtPosition0 to the new live-edge position.
m.liveTvCalibrated = false
m.liveTvEpochAtPosition0 = 0
m.liveTvPausedAtEpoch = 0
m.liveTvAccumulatedBehind = 0
m.liveTvLastPosition = 0
m.liveTvIsAtLiveEdge = true
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.isBold = 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.isBold = 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
function isLiveTvItem() as boolean
return isValid(m.itemData) and m.itemData.type = "TvChannel"
end function
function getLiveTvBehindLiveSeconds() as integer
return computeLiveTvBehindLive(m.liveTvEpochAtPosition0, m.liveTvCalibrated, int(m.top.positionTime), CreateObject("roDateTime").AsSeconds(), m.liveTvPausedAtEpoch, m.liveTvAccumulatedBehind)
end function
' onDestroy: Full teardown releasing all resources before component removal
' Called by VideoPlayerView.onDestroy() since OSD is a child component, not a SceneManager scene
sub onDestroy()
m.log.verbose("onDestroy")
' 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")
m.top.unobserveField("positionTime")
' 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