import "pkg:/source/enums/SubtitleSelection.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/deviceCapabilities.bs"
import "pkg:/source/utils/dialogs.bs"
import "pkg:/source/utils/liveTv.bs"
import "pkg:/source/utils/mediaDisplayTitle.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/playbackReport.bs"
import "pkg:/source/utils/queueBackdropHelper.bs"
import "pkg:/source/utils/stillWatching.bs"
import "pkg:/source/utils/streamSelection.bs"
import "pkg:/source/utils/tasks.bs"
import "pkg:/source/utils/trackPickerOptions.bs"
import "pkg:/source/utils/translate.bs"
' How often the playback report re-reads the session while it is open.
'
' 5s rather than the 10s Jellyfin's web client uses, because that client's panel
' is a cache TTL on an always-open overlay while this is a dialog someone opened
' on purpose, usually because something looked wrong. The figure that matters
' (transcode speed) is a level to be confirmed, and halving the wait for the first
' confirmation is the whole benefit.
'
' TWO GATES keep that from becoming an open-ended poll, and neither invents a
' number:
'
' 1. The timer only STARTS when the report has live rows (a transcode is
' running) or has no session yet (reportNeedsPolling). On a direct play with
' its session in hand nothing in the report can change, so it never polls
' at all, which is most sessions.
' 2. A tick makes no request once the device has been idle longer than the
' screensaver timeout THE USER CONFIGURED. That is their own statement about
' how long counts as away; a device with the screensaver switched off has said
' the opposite, and is never suspended.
'
' Gate 2 is an IDLE threshold, not a claim about what is on screen. Roku suppresses
' the screensaver during video playback, so it may never actually appear here. The
' reason to lean on that number anyway is that the user picked it, not that the
' screen is covered.
'
' Being wrong is cheap in exactly one direction, which is why the threshold can be
' loose: TimeSinceLastKeypress resets itself, so the first tick after any keypress
' fetches again: at most 5s of staleness, no state, no resume path, no key
' handling. Polling forever has no such recovery.
const PLAYBACK_INFO_REFRESH_SECONDS = 5
' Routed host for video playback. See PlayerHostView.xml header.
'
' This file owns the video half of playback: the player factory
' (CreateVideoPlayerView -> mountPlayer), the end-of-playback / queue advancement state
' machine (onPlayerStateChange, host-internal), and the playback-time track-selection dialog
' handlers (onSelect*/process*). The player is a runtime child of this host, so advancement
' destroys + remounts the child.
sub init()
m.log = new log.Logger("PlayerHostView")
' The player is fullscreen: keep the shared overhang hidden while this view is the
' router-active view (JRScene's overhang controller reads isOverhangVisible on mount).
m.top.isOverhangVisible = false
' Reaching this screen is where a video playback start goes (appWaits.leftBehind).
m.top.isPlaybackScreen = true
' Live TV restart budget, carried across mounts: see newLiveRestartBudget.
m.liveRestart = newLiveRestartBudget()
' "Are you still watching?" (stillWatching.bs). The session spans every video this route
' plays; pending means "ask when the newly mounted video starts playing"; pausedForAnswer
' means the prompt timed out and paused it, so the next `playing` is an answer.
m.stillWatching = newStillWatchingSession(UpTime(0))
m.stillWatchingPending = false
m.stillWatchingPausedForAnswer = false
m.stillWatchingDialog = invalid
m.stillWatchingSecondsLeft = 0
m.stillWatchingTimer = m.top.findNode("stillWatchingTimer")
m.stillWatchingTimer.observeField("fire", "onStillWatchingTick")
end sub
' Router lifecycle (via JRScreen's onViewOpen bridge): mount the player for the
' current queue item. The launching code (QueueManager.playQueue -> JRScene) has
' already built the queue, so the host only reads getCurrentItem. Guard against a
' redundant call (for example, screensaver-exit re-fires onScreenShown).
sub onScreenShown()
if not isValid(m.view)
mountPlayer()
end if
end sub
sub onScreenHidden()
end sub
' Take remote focus (overrides JRScreen.restoreScreenFocus). Hand it to the player child if
' mounted; the player also grabs focus itself once content loads (VideoPlayerView.onVideoContentLoaded).
sub restoreScreenFocus()
if isValid(m.view)
m.view.setFocus(true)
else
m.top.setFocus(true)
end if
end sub
' Build + mount the VideoPlayerView as a child of this host. Ported from
' ViewCreator.CreateVideoPlayerView; the only behavioral change is appendChild to
' the host instead of SceneManager.pushScene.
'
' @param {boolean} isLiveRestart - true only from restartLiveChannel; see liveRestartOnMount
sub mountPlayer(isLiveRestart = false as boolean)
liveRestartOnMount(m.liveRestart, isLiveRestart)
m.view = CreateObject("roSGNode", "VideoPlayerView")
' Keep video player invisible during loading to prevent black background from covering backdrop
m.view.visible = false
m.view.observeField("state", "onPlayerStateChange")
m.view.observeField("selectPlaybackInfoPressed", "onSelectPlaybackInfoPressed")
m.view.observeField("selectSubtitlePressed", "onSelectSubtitlePressed")
m.view.observeField("selectAudioPressed", "onSelectAudioPressed")
m.view.observeField("selectVideoSourcePressed", "onSelectVideoSourcePressed")
' Update backdrop for current queue item (handles first item, shuffle, library play, etc.)
updateQueueBackdrop()
m.top.appendChild(m.view)
' Toast "Playing <title>" for a deep-link launch. Fired here (player mount = stable final
' destination, nothing churns after) rather than from ItemDetails mid-transition, which was
' unreliable after a heavy server switch. One-shot: clear the global after showing.
if isValidAndNotEmpty(m.global.deepLinkOpeningTitle)
displayToast(Substitute(translate(translationKeys.MessagePlayingContent), m.global.deepLinkOpeningTitle), "info")
m.global.deepLinkOpeningTitle = ""
end if
end sub
' Tear down the current player child (stop -> report stop to Jellyfin -> onDestroy -> remove).
sub destroyPlayer()
m.stillWatchingPending = false
m.stillWatchingPausedForAnswer = false
if isValid(m.view)
m.view.unobserveField("state")
m.view.unobserveField("selectPlaybackInfoPressed")
m.view.unobserveField("selectSubtitlePressed")
m.view.unobserveField("selectAudioPressed")
m.view.unobserveField("selectVideoSourcePressed")
' Stop so the player reports a stop playstate to Jellyfin (Video.onDestroy alone
' does not). Unobserving state first means the stop won't re-enter onPlayerStateChange.
m.view.control = "stop"
m.view.callFunc("onDestroy")
m.top.removeChild(m.view)
m.view = invalid
end if
m.getPlaybackInfoTask = releaseTask(m.getPlaybackInfoTask, "data")
' Otherwise the first report opened on the NEXT queue item compares its
' completion percentage against the previous item's last figure, silently
' rendering "Idle" over a healthy encoder.
m.lastCompletionPercentage = invalid
end sub
' Host-internal advancement: remount the player for the CURRENT queue position.
' Callers (onPlayerStateChange next-item; skipToCurrentQueueItem for the player's
' next / previous and channel switch) set the position + backdrop first. Replaces ViewCreator's
' clearPreviousScene + playQueue (which created a new pushed scene).
sub playCurrentQueueItem()
destroyPlayer()
mountPlayer()
end sub
' The viewer moved to another queue item (the player's next / previous, a channel switch).
' A button press started the item, the same as the press that opened the player, so the
' still-watching count starts over with it.
sub skipToCurrentQueueItem()
m.stillWatching = newStillWatchingSession(UpTime(0))
playCurrentQueueItem()
end sub
' Restart the Live TV channel whose stream ended. Same remount as playCurrentQueueItem, except
' that it keeps the restart count: every other remount (channel switch, queue advance) resets it.
sub restartLiveChannel()
destroyPlayer()
mountPlayer(true)
end sub
' A deep link arrived while this player is active. Stop + report the player NOW (synchronously)
' WITHOUT navigating. The caller (JRScene.replayDeepLinkReplacingPlayer) then navigates to "/",
' whose route clears the stack, and the router's beforeViewClose -> onDestroy does the full
' teardown. destroyPlayer is idempotent (guards m.view), so that second teardown is a safe no-op.
' This is not a full onDestroy, which would run twice and double-tear-down.
sub teardownForDeepLink()
destroyPlayer()
end sub
' Leave the play route: pops this host off the router history (beforeViewClose ->
' onScreenHidden + onDestroy tear the player down) and resumes the view beneath
' (the item's details, or Home). Called on queue-exhaustion, playback error, and
' voice "stop". Replaces ViewCreator/VideoPlayerView's SceneManager.popScene.
sub exitPlayback()
sgrouter.goBack()
end sub
' Router: this view is being destroyed (goBack, a stack clear such as the deep link's
' navigation to "/", or sign-out resetRouter). Tear down the player child. This file
' makes no fetchAsync call, so the auto-abandon-promises BSC plugin injects no
' abandonApiPromises() here.
sub onDestroy()
m.stillWatchingTimer.unobserveField("fire")
' The picker / info overlays are appended to the SCENE, so they outlive this
' view. Anything that navigates without the user closing the dialog first
' (deep link, session expiry, voice "stop") would strand one over the incoming
' screen. See abandonDialog in source/utils/dialogs.bs.
abandonPlaybackDialogs()
destroyPlayer()
end sub
' Drop the report and any track picker this view opened, delivering nothing. Both
' observers this view owns for the report go too: the timer's `fire` and the
' dialog's `closed`. The dialogs outlive this view (they are appended to the
' SCENE), so an unreleased observer on one would keep calling into a dead scope.
sub abandonPlaybackDialogs()
closeStillWatchingPrompt()
releasePlaybackInfoRefresh()
if isValid(m.reportDialog) then m.reportDialog.unobserveField("closed")
abandonDialog(m.reportDialog)
abandonDialog(m.trackPickerDialog)
m.reportDialog = invalid
m.trackPickerDialog = invalid
m.trackPickerOptions = invalid
end sub
' ===========================================================================
' End-of-playback / queue advancement state machine.
' Ported from ViewCreator.onStateChange; the pop/push has become host-internal
' destroy/remount, and the queue-exhausted pop has become exitPlayback (goBack).
' ===========================================================================
sub onPlayerStateChange()
state = LCase(m.view.state)
if state = "playing" then onStillWatchingPlaying()
if state <> "finished" then return
if not prepareToLeaveMount() then return
queueManager = m.global.queueManager
' Live TV channel that finished -> restart the same channel (same queue position). A live feed
' that drops mid-stream surfaces as `finished`; one that keeps ending without playing gets the
' error instead of a restart loop (liveRestartOnEnd in liveTv.bs).
currentItem = queueManager.callFunc("getCurrentItem")
if isValid(currentItem)
currentItemType = queueManager.callFunc("getItemType", currentItem)
if currentItemType = "tvchannel"
progressMs = currentMountProgressMs()
if liveRestartOnEnd(m.liveRestart, progressMs)
restartLiveChannel()
else
m.log.warn("Live TV channel keeps ending without playing; showing the error instead of restarting", "channelId", textOrEmpty(currentItem.id), "mountsWithoutHealthyPlay", m.liveRestart.count, "progressMs", progressMs)
m.view.callFunc("showLiveStreamStoppedError", liveGiveUpCauseKey(m.liveRestart))
end if
return
end if
end if
' More items in the queue -> advance and play the next one, from its beginning
position = queueManager.callFunc("getPosition")
if position < queueManager.callFunc("getCount") - 1
askStillWatching = shouldAskStillWatching(queueManager.callFunc("getItemByIndex", position + 1))
queueManager.callFunc("advanceTo", position + 1)
updateQueueBackdrop()
playCurrentQueueItem()
' After the remount: destroyPlayer clears it, and it belongs to the newly mounted player.
m.stillWatchingPending = askStillWatching
return
end if
' Queue exhausted -> leave the player and return to the launching view
m.global.audioPlayer.loopMode = ""
exitPlayback()
end sub
' How much of the live stream the current player mount has played, for the Live TV restart rule
' (newLiveProgress in liveTv.bs). The player measures it, since only it sees every position.
function currentMountProgressMs() as integer
if not isValid(m.view) then return 0
progressMs = m.view.callFunc("liveProgressMs")
if not isValid(progressMs) then return 0
return progressMs
end function
' The checks and screen-clearing every exit from the current mount shares: a stream that
' finished (onPlayerStateChange) and a Live TV stream that stalled (onLiveStreamStalled).
' Returns false when a guard says to leave the mount alone, before anything is torn down.
function prepareToLeaveMount() as boolean
' Don't advance mid-retry; the DoVi fallback calls stop which can fire "finished".
if isValid(m.view.isRetrying) and m.view.isRetrying then return false
' Don't advance (or tear anything down) while an error dialog owns the exit.
' Same class of "finished" as the line above, and the same asymmetry it fixes:
' two callers (bufferCheck's stall error and showLiveStreamStalledError) show
' the dialog and stop the stream on the very next line, and before the claim
' nothing held the exit. A `stop` usually surfaces as `stopped`, which
' onPlayerStateChange's state check already ignores. But it can surface as
' `finished`, which is precisely why isRetrying exists, and on that landing the
' teardown below would abandon the dialog and the queue logic would advance to
' the next episode with the error unread.
'
' Bailing does not strand the dialog: the only routes out of the player still
' tear it down. The dialog's own resolution calls exitFromPlaybackError ->
' exitPlayback, and anything that navigates without it (deep link, sign-out)
' reaches VideoPlayerView.onDestroy, which abandons it.
if isValid(m.view.errorDialogOwnsExit) and m.view.errorDialogOwnsExit then return false
' Clear the screen before playback teardown navigates: a dialog left open would
' end up over the incoming screen, having lost focus to it (visible but deaf).
'
' Three calls because the dialogs mean different things. Ours (the playback-info
' report AND VideoPlayerView's error dialog) are ABANDONED: this host owns the
' player, and the scope that would receive either result is about to be torn
' down or has already moved on. Anything else is CANCELLED: a main-thread flow
' (a cast notice, the deep-link server-switch prompt) can open a dialog over the
' player, and it holds state until that dialog answers, so it has to be told.
'
' abandonErrorDialog() has to run before cancelOpenDialog(), and that ordering is
' the general rule even though the guard above now means there is normally
' nothing here to abandon. Both the error dialog and cancelOpenDialog() live on
' the overlay channel (the dialog moved off a raw modal Dialog in the
' dialog-standardization migration), and cancelOpenDialog() delivers a
' CANCELLED result indistinguishable from the user pressing Back, which
' VideoPlayerView's result handler treats as a real dismissal and navigates on.
' Abandoning first drops the dialog and its observer before cancelOpenDialog()
' can reach either.
'
' Kept as the belt to the guard's braces, not as the fix. The claim and the
' dialog are set together (showPlaybackErrorDialog), so reaching this line with
' a live error dialog means something showed one without claiming the exit:
' worth surviving, not worth relying on. The stall path this used to catch is
' now caught by the guard, which is what lets the user actually read the error.
m.view.callFunc("abandonErrorDialog")
' Same teardown as onDestroy: we are dropping the references to dialogs that
' live on the SCENE, so the poll and the report's close observer go with them.
abandonPlaybackDialogs()
cancelOpenDialog()
return true
end function
' VideoPlayerView.bufferCheck saw a Live TV channel's buffering stall. The same two exits as a
' channel that ended: restart it (liveRestartOnStall allows that only after healthy playing) or
' show the error. A guard that declines leaves the player's buffer check running, so it asks again.
' The restart destroys the calling player synchronously, which bufferCheck is written to survive.
sub onLiveStreamStalled()
if not isValid(m.view) then return
if not prepareToLeaveMount() then return
progressMs = currentMountProgressMs()
if liveRestartOnStall(m.liveRestart, progressMs)
m.view.callFunc("reportStallStop")
restartLiveChannel()
else
currentItem = m.global.queueManager.callFunc("getCurrentItem")
channelId = ""
if isValid(currentItem) then channelId = textOrEmpty(currentItem.id)
m.log.warn("Live TV channel stalled without healthy playing; showing the error instead of restarting", "channelId", channelId, "mountsWithoutHealthyPlay", m.liveRestart.count, "progressMs", progressMs)
m.view.callFunc("showLiveStreamStalledError", liveGiveUpCauseKey(m.liveRestart))
end if
end sub
' handleTransport: voice transport delegate. main.bs forwards transport commands
' here (the router-active view is this host, not the inner player); forward to the
' child player's own handler.
function handleTransport(evt as object) as object
' A voice or Jellyfin-app command means someone is there, whatever it asks for, the same
' as a button press. With the prompt up it is also the answer: canceled rather than
' abandoned so the dialog hands focus back to the player as it closes; its result handler
' sees an outside close and only cleans up.
stillWatchingOnActivity(m.stillWatching, UpTime(0))
if isValid(m.stillWatchingDialog) then m.stillWatchingDialog.callFunc("cancelDialog")
if isValid(m.view) then return m.view.callFunc("handleTransport", evt)
return { status: "error.no-media" }
end function
' ===========================================================================
' "Are you still watching?" (issue #982). When to ask is source/utils/stillWatching.bs;
' this is the asking. The next video plays under the prompt; any key answers it, and with
' no answer in STILL_WATCHING_RESPONSE_SECONDS the video pauses with its OSD up.
' ===========================================================================
' The queue is about to move on by itself to `nextItem`. Records the video that ended and
' says whether to ask before the next one. Moving from a Cinema Mode intro to the video it
' plays in front of is the same video starting, not a new one, so it neither counts nor asks.
'
' @param {dynamic} nextItem - the queue item about to play
' @returns {boolean}
function shouldAskStillWatching(nextItem as dynamic) as boolean
if isValid(nextItem) and nextItem.followsIntro = true then return false
if not isValid(m.deviceInfo) then m.deviceInfo = CreateObject("roDeviceInfo")
thresholds = stillWatchingThresholds(textOrEmpty(m.global.user.settings.playbackStillWatching))
ask = stillWatchingOnAdvance(m.stillWatching, thresholds, m.deviceInfo.TimeSinceLastKeypress(), UpTime(0))
#if ENABLE_RTA
' RTA-only: ask now instead of after an hour of playback (globals.bs).
if isValid(m.global.rtaForceStillWatching) and m.global.rtaForceStillWatching then ask = true
#end if
if ask then m.log.info("Asking if still watching", "unattendedVideos", m.stillWatching.unattendedCount)
return ask
end function
' The player reached `playing`: show a pending prompt, or take a resume after a timeout as
' the answer (a voice "play" presses no key, so this is how it is seen).
sub onStillWatchingPlaying()
if m.stillWatchingPausedForAnswer
m.stillWatchingPausedForAnswer = false
stillWatchingOnActivity(m.stillWatching, UpTime(0))
end if
if not m.stillWatchingPending then return
m.stillWatchingPending = false
m.stillWatchingSecondsLeft = STILL_WATCHING_RESPONSE_SECONDS
m.stillWatchingDialog = showStillWatchingDialog(stillWatchingCountdownText(m.stillWatchingSecondsLeft), "onStillWatchingResult")
m.stillWatchingTimer.control = "start"
end sub
' Any key answered the prompt, or something else closed it (a dialog that superseded it,
' a teardown). Only the first is an answer.
sub onStillWatchingResult(event as object)
result = event.getData()
closeStillWatchingPrompt()
if not isValid(result) or result.externallyCancelled = true then return
stillWatchingOnActivity(m.stillWatching, UpTime(0))
end sub
sub onStillWatchingTick()
if not isValid(m.stillWatchingDialog) then return
m.stillWatchingSecondsLeft = m.stillWatchingSecondsLeft - 1
if m.stillWatchingSecondsLeft > 0
' One short line, so rewriting it after the dialog is up is safe (showStillWatchingDialog).
m.stillWatchingDialog.subheading = stillWatchingCountdownText(m.stillWatchingSecondsLeft)
return
end if
' No answer: pause where it is, with the player's own paused OSD, so whoever comes back
' sees a paused video and resumes it the usual way.
m.log.info("No answer to still watching; pausing")
closeStillWatchingPrompt()
if not isValid(m.view) then return
m.stillWatchingPausedForAnswer = true
m.view.callFunc("pauseWithOsd")
end sub
' Stop the countdown and drop the prompt, delivering nothing. Safe when none is open.
sub closeStillWatchingPrompt()
m.stillWatchingTimer.control = "stop"
if not isValid(m.stillWatchingDialog) then return
abandonDialog(m.stillWatchingDialog)
m.stillWatchingDialog = invalid
end sub
function stillWatchingCountdownText(seconds as integer) as string
return translatePlural(translationKeys.LabelPausingIn, seconds, [seconds.toStr()])
end function
' ===========================================================================
' Playback-time track / source / info selection dialogs.
'
' All four go through the standard dialog helpers (source/utils/dialogs.bs): the
' three pickers are JRListDialogs, the info report an OverviewDialog. Each result
' arrives on its own dialog node's `result` field, so every picker has its OWN
' handler. The old path shared SceneManager.returnData between all three and
' told them apart with a `type` string stamped into every option.
'
' Only ONE of these can be open at a time (the OSD is unreachable behind a modal),
' but the report still gets its own slot, `m.reportDialog`, apart from the
' pickers' `m.trackPickerDialog`. Its life starts before its dialog does: after a
' press, a fetch in flight is a report with no dialog yet, and a picker can open
' in that window. A shared slot let the fetch's answer take the picker for the
' report it was waiting for.
' ===========================================================================
' Present a picker built by source/utils/trackPickerOptions.bs. Holding the option
' set is what makes the result usable: JRListDialog answers with an INDEX, and
' `values` is what that index means.
sub showTrackPicker(title as string, options as object, onResult as string)
if options.labels.count() = 0 then return
' A report still being fetched was asked for BEFORE this picker, and the newer
' request wins: without this, its answer opens the report once the picker closes.
m.getPlaybackInfoTask = releaseTask(m.getPlaybackInfoTask, "data")
m.trackPickerOptions = options
m.trackPickerDialog = showListDialog(title, options.labels, onResult, options.defaultIndex, options.selectedIndex)
end sub
' The option the user picked, or invalid if they backed out. Clears the dialog
' slot. Every result handler starts here.
function resolvePickedValue() as object
dialog = m.trackPickerDialog
options = m.trackPickerOptions
m.trackPickerDialog = invalid
m.trackPickerOptions = invalid
if not isValid(dialog) or not isValid(dialog.result) or not isValid(options) then return invalid
index = dialog.result.optionIndex
if index < 0 or index >= options.values.count() then return invalid
return options.values[index]
end function
' onSelectAudioPressed: Display audio selection dialog
sub onSelectAudioPressed()
options = buildAudioTrackOptions(m.view.fullAudioData, m.view.audioIndex)
m.log.debug("onSelectAudioPressed", "currentAudioIndex", m.view.audioIndex, "rows", options.labels.count(), "focus", options.defaultIndex)
showTrackPicker(translate(translationKeys.LabelSelectAudio), options, "onAudioTrackSelected")
end sub
' Audio track selection handler
sub onAudioTrackSelected()
audioIndex = resolvePickedValue()
if not isValid(audioIndex) then return
m.log.info("onAudioTrackSelected", "selectedJellyfinIndex", audioIndex, "previousAudioIndex", m.view.audioIndex)
m.view.audioIndex = audioIndex
end sub
' onSelectVideoSourcePressed: Display video source selection dialog
sub onSelectVideoSourcePressed()
options = buildVideoSourceOptions(m.view.fullVideoSourceData, m.view.mediaSourceId)
m.log.debug("onSelectVideoSourcePressed", "currentSourceId", m.view.mediaSourceId, "rows", options.labels.count(), "focus", options.defaultIndex)
showTrackPicker(translate(translationKeys.LabelSelectVideoSource), options, "onVideoSourceSelected")
end sub
' Video source selection handler. Writing mediaSourceId triggers a video reload,
' so only write it when the source actually changed.
sub onVideoSourceSelected()
sourceId = resolvePickedValue()
if not isValid(sourceId) then return
m.log.info("onVideoSourceSelected", "pickedSourceId", sourceId, "currentSourceId", m.view.mediaSourceId)
if sourceId <> m.view.mediaSourceId then m.view.mediaSourceId = sourceId
end sub
' User requested subtitle selection popup
sub onSelectSubtitlePressed()
options = buildSubtitleTrackOptions(m.view.fullSubtitleData, m.view.selectedSubtitle, m.view.subtitleTrack, m.view.availableSubtitleTracks)
m.log.debug("onSelectSubtitlePressed", "selectedSubtitle", m.view.selectedSubtitle, "rows", options.labels.count(), "focus", options.defaultIndex, "rokuTracks", m.view.availableSubtitleTracks.count())
showTrackPicker(translate(translationKeys.LabelSelectSubtitles), options, "onSubtitleTrackSelected")
end sub
' Subtitle selection handler. `selected` is { index, isEncoded, trackName }. The
' "None" row is the one whose index is SubtitleSelection.NONE, which is why this
' no longer compares the row's LABEL against the literal string "none" (that
' comparison is why the None row could never be translated).
sub onSubtitleTrackSelected()
selected = resolvePickedValue()
if not isValid(selected) then return
' Logged at INFO because this is the path a user reports as "the subtitle did
' not load", and every branch below can end in nothing visibly happening.
m.log.info("onSubtitleTrackSelected", "pickedIndex", selected.index, "isEncoded", selected.isEncoded, "wasIndex", m.view.selectedSubtitle)
' Nothing changed: leave the stream alone.
'
' Both-NONE is not treated as "no change", on purpose: an EXTERNAL subtitle
' track leaves selectedSubtitle at NONE (only in-file tracks are tracked by
' index), so picking None while one is showing hits this with both sides NONE
' and still has real work to do. Clearing subtitleTrack below is what actually
' turns it off.
if m.view.selectedSubtitle <> SubtitleSelection.NONE or selected.index <> SubtitleSelection.NONE
if m.view.selectedSubtitle = selected.index then return
end if
' Find previously selected subtitle and identify if it was encoded
for each item in m.view.fullSubtitleData
if item.index = m.view.selectedSubtitle
m.view.isPreviousSubtitleEncoded = item.IsEncoded
exit for
end if
end for
if selected.index = SubtitleSelection.NONE
m.view.globalCaptionMode = "Off"
m.view.subtitleTrack = ""
' SelectedSubtitle is alwaysNotify: writing the value it already holds still
' fires its observers, so only write it when it actually changes.
if m.view.selectedSubtitle <> SubtitleSelection.NONE
m.view.selectedSubtitle = SubtitleSelection.NONE
end if
return
end if
if selected.isEncoded
' Roku can not natively display these subtitles, so turn off the caption mode on the device
m.view.globalCaptionMode = "Off"
else
' Roku can natively display these subtitles, ensure the caption mode on the device is on
m.view.globalCaptionMode = "On"
' Roku may rearrange subtitle tracks. Look up track based on name to ensure we get the correct index
rokuIndex = availableSubtitleTrackIndex(m.view.availableSubtitleTracks, selected.trackName)
if rokuIndex = SubtitleSelection.NONE
' The one way this path ends with nothing on screen and no error: Roku
' dropped the track we asked for, so there is nothing to switch TO.
m.log.warn("Subtitle not present in Roku's track list; nothing to select", "trackName", selected.trackName, "rokuTracks", m.view.availableSubtitleTracks.count())
return
end if
m.view.subtitleTrack = m.view.availableSubtitleTracks[rokuIndex].TrackName
end if
m.view.selectedSubtitle = selected.index
end sub
' User requested playback info.
'
' Always re-fetches. The report used to be built once by the task and cached for
' the life of the player, which meant a DoVi buffer-overflow fallback
' (transcode -> direct play) left the "i" button confidently describing a
' transcode that had already stopped. Composition is cheap now that it happens
' here rather than on the Task thread, so a fresh answer costs one request.
sub onSelectPlaybackInfoPressed()
startPlaybackInfoLoad()
end sub
' One fetch of the live session, on a NEW node per run (ADR 0037): a node that
' is still running ignores a relaunch. Replaces any run in flight, which is what a
' press wants; the refresh tick lets one finish instead (onPlaybackInfoRefreshDue).
sub startPlaybackInfoLoad()
m.getPlaybackInfoTask = replaceTask(m.getPlaybackInfoTask, "GetPlaybackInfoTask", "data", {})
m.getPlaybackInfoTask.observeField("data", "onPlaybackInfoLoaded")
launchTask(m.getPlaybackInfoTask)
end sub
' The session arrived. Compose the report and either open it or refresh the one
' already open.
sub onPlaybackInfoLoaded(event as object)
' Also drops a fetch that was in flight when the report closed: without the
' release in onPlaybackInfoDialogClosed plus this check, its answer found no
' dialog below and opened the report again by itself.
if not isCurrentTaskEvent(event, m.getPlaybackInfoTask) then return
' Not open yet, and another dialog took the screen while this was in flight (a
' server message, the player's error). It was asked for AFTER the press, so it
' wins: opening the report now would supersede it, and the error dialog reads a
' supersede as a deliberate takeover and parks the player with its message gone.
' The user can press "i" again.
if not isValid(m.reportDialog) and isOverlayDialogOpen() then return
data = event.getData()
session = invalid
if isValid(data) then session = data.session
' A FAILED fetch is not "the transcode ended", and telling them apart matters:
' an invalid session builds a report with no transcode section, which would trip
' the live-rows check below and stop the refresh permanently, one dropped
' request killing the dialog's updates for as long as it stays open. Skip the
' rebuild entirely and let the next tick try again; the dialog keeps showing the
' last good figures, which is what it would show anyway.
if not isValid(session) and isValid(m.reportDialog) then return
report = buildPlaybackReport(playbackReportInputs(session))
if report.sections.count() = 0
' Nothing at all to show: no session AND no cached PlaybackInfo to build the
' static half from. Pressing a button and having literally nothing happen
' reads as a broken button, so say so; that silence is what the old flow's
' "Unable to get playback information" used to cover.
'
' Only when the dialog is not already open. Behind an open one the same rule
' as a failed refresh applies: keep showing the last good report rather than
' throwing a toast over it, and let the next tick try again.
if not isValid(m.reportDialog) then displayToast(translate(translationKeys.ErrorUnableToGetPlaybackInformation), "error")
return
end if
' Remembered AFTER the build, so the build compares against the previous poll
' rather than against itself.
m.lastCompletionPercentage = invalid
if isValid(session) and isValid(session.TranscodingInfo)
m.lastCompletionPercentage = session.TranscodingInfo.CompletionPercentage
end if
if isValid(m.reportDialog)
' Open already: hand it the new model. OverviewDialog reconciles row by row,
' so the live figures change and the scroll position does not.
'
' The status line is refreshed too. It is nearly always the same word, but a
' transcode can END while the report is open (a DoVi buffer-overflow fallback
' does that), and leaving "Transcoding" above a body that has
' rebuilt itself into a direct-play report is the stale answer this whole
' refresh exists to prevent.
m.reportDialog.tagline = report.status
m.reportDialog.sections = report.sections
' The SAME predicate that started the timer decides when to stop it, so the
' two can never drift apart. Two ways to arrive here: a transcode ENDED while
' the report was open (a DoVi buffer-overflow fallback does exactly that), or
' the session we were missing has arrived and turned out to be a direct play.
if not reportNeedsPolling(report, session) then stopPlaybackInfoRefresh()
return
end if
m.reportDialog = showReportDialog(translate(translationKeys.LabelPlaybackInfo), report.status, report.sections)
m.reportDialog.observeField("closed", "onPlaybackInfoDialogClosed")
if reportNeedsPolling(report, session) then startPlaybackInfoRefresh()
end sub
' Everything the pure report builder needs, gathered on the RENDER thread where
' an m.global read costs ~2 µs rather than the ~93 µs it cost inside the task.
function playbackReportInputs(session as dynamic) as object
mediaSource = primaryMediaSource(m.view.cachedPlaybackInfo)
' supportsDolbyVision here is the SAME predicate ItemPostPlaybackInfo uses to
' decide whether to inject the DoVi container profile at all
' (canPlay4k() and the display reporting DolbyVision). Attribution is only
' honest if both sides ask the identical question.
'
' READ ONCE PER HOST, not per call: `m.deviceVideoCapabilities` is never
' cleared, so it spans every item across the whole `mountPlayer()` lifetime of
' this host, not only one mount. This is not a field read: canPlay4k()
' builds an roDeviceInfo AND an roHdmiStatus and then asks the hardware two
' questions (IsHdcpActive, CanDecodeVideo), and getDeviceVideoCapabilities adds
' GetDisplayProperties on top. Re-running that every 5 seconds on the RENDER
' thread, underneath playing video, is exactly the cost this feature moved off
' the Task thread to avoid.
'
' Caching is also the more CORRECT answer, not merely the cheaper one. The
' figure being mirrored is the one ItemPostPlaybackInfo used when it built the
' profile for THIS playback; re-querying could return something else (a
' receiver renegotiating HDCP mid-stream) and attribute the transcode against a
' capability that was never the one asked about.
if not isValid(m.deviceVideoCapabilities) then m.deviceVideoCapabilities = getDeviceVideoCapabilities()
capabilities = m.deviceVideoCapabilities
return {
session: session,
mediaSource: mediaSource,
' The playhead and the previous poll's completion figure are what turn the
' transcode section from a set of raw numbers into an explanation: the first
' gives the encoder's LEAD over the viewer, the second says whether it is
' still moving. Both live here rather than in the pure model because only this
' scope can see the player and the previous poll.
playheadSeconds: m.view.position,
' What the player was handed, after any override of the server's answer (an upstream
' manifest played from its own URL), so "Streaming from" names the source actually used.
playbackUrl: playingUrl(m.view.content),
serverUrl: m.global.server.serverUrl,
previousCompletion: m.lastCompletionPercentage,
audioStreamIndex: m.view.audioIndex,
subtitleStreamIndex: m.view.selectedSubtitle,
settings: m.global.user.settings,
' Gates the Path row, which is admin-only. Read defensively rather than
' assumed present: `policy` is server-authoritative and only populated from a
' real session, so it can legitimately be absent, and absent must mean not an
' administrator, never the other way round.
isAdministrator: userIsAdministrator(),
apiVersion: getApiVersionFromGlobal(),
deviceSupportsDovi: capabilities.supportsDolbyVision,
deviceMaxHeight: capabilities.maxHeight,
doviPreservationBypassed: m.view.isDoviPreservationBypassed,
' True when the device plays the stream itself and Roku lists no audio tracks
' (several tracks muxed into one HLS variant). The app cannot select one then,
' so the report must not describe its pick as playing.
audioTracksUnlisted: not isBooleanTrue(m.view.isTranscoded) and not isValidAndNotEmpty(m.view.availableAudioTracks),
isShortcut: m.view.isShortcut
}
end function
' The URL the Video node is playing, or "" when it has no content.
function playingUrl(content as dynamic) as string
if not isValid(content) then return ""
return textOrEmpty(content.url)
end function
' True only when the signed-in user is a Jellyfin administrator.
'
' Every hop is checked because this decides whether a filesystem path is shown:
' the failure mode of a wrong `true` is disclosing something, and the failure mode
' of a wrong `false` is one missing row.
function userIsAdministrator() as boolean
user = m.global.user
if not isValid(user) then return false
policy = user.policy
if not isValid(policy) then return false
return isBooleanTrue(policy.isAdministrator)
end function
' Keep the live rows live.
'
' Transcode speed is the one figure in the report that is only meaningful RIGHT
' NOW: it says whether the server is keeping ahead of playback, and a value from
' four minutes ago answers nothing. Progress and output bitrate move with it.
' Everything else in the report is fixed for the session and re-resolves to
' the same text, which the dialog then skips.
'
' The timer drives the same task the button does, so there is one fetch path and
' one composition path rather than a second copy for refreshes.
' True when the report contains something that can change while it is open.
'
' Only the transcode section is live (speed, progress, output bitrate); every
' other row is fixed for the session and would re-resolve to identical text. A
' direct-play report therefore has nothing to poll FOR, which is the honest
' reason not to poll rather than a budget someone picked.
function reportHasLiveRows(report as object) as boolean
for each section in report.sections
if section.id = "transcode" then return true
end for
return false
end function
' True when the report still has an unanswered question: either something that
' changes while you watch it, or something we have not been told yet.
'
' Two ways to qualify, and the second is what makes a failed first fetch
' recoverable. Live rows exist only during a transcode. A MISSING SESSION means
' the whole transcode half of the report is unknown rather than absent, so the
' dialog is showing a provisional answer and has to keep asking; the first tick
' that returns a session fills in the status line and the transcode section, and
' if that session turns out to be a direct play this same gate then stops the
' timer on the next pass.
'
' A direct play with a session in hand qualifies as neither, which is the case
' that must never poll, and it is most sessions.
function reportNeedsPolling(report as object, session as dynamic) as boolean
if not isValid(session) then return true
return reportHasLiveRows(report)
end function
sub startPlaybackInfoRefresh()
' The idle threshold is the screensaver timeout the USER configured, read once.
' Preferred over a constant of ours because it is the one duration they have
' already told the device counts as away. Someone who set two minutes wants
' aggressive idling and someone who set an hour does not, and a single number
' here would be wrong for one of them. Zero means they switched the screensaver
' off, which is the opposite instruction, so nothing suspends.
'
' roAppManager is not creatable on the render thread, which is why this goes
' through the existing LoadScreenSaverTimeoutTask rather than being read inline.
' AudioPlayerView already pairs that same task with TimeSinceLastKeypress for its
' own screensaver; this is that pattern reused, not a second one.
if not isValid(m.screenSaverTimeout)
m.screenSaverTimeout = 0
m.screenSaverTimeoutTask = CreateObject("roSGNode", "LoadScreenSaverTimeoutTask")
m.screenSaverTimeoutTask.observeField("content", "onScreensaverTimeoutLoaded")
launchTask(m.screenSaverTimeoutTask)
end if
if not isValid(m.playbackInfoTimer)
m.playbackInfoTimer = CreateObject("roSGNode", "Timer")
m.playbackInfoTimer.repeat = true
m.playbackInfoTimer.duration = PLAYBACK_INFO_REFRESH_SECONDS
m.playbackInfoTimer.observeField("fire", "onPlaybackInfoRefreshDue")
m.top.appendChild(m.playbackInfoTimer)
end if
m.playbackInfoTimer.control = "start"
end sub
sub stopPlaybackInfoRefresh()
if isValid(m.playbackInfoTimer) then m.playbackInfoTimer.control = "stop"
end sub
' Stop the timer AND let it go. Kept distinct from stopPlaybackInfoRefresh
' because closing the dialog only pauses polling (the same timer is reused the
' next time the report is opened), while onDestroy has to release the observer.
sub releasePlaybackInfoRefresh()
if not isValid(m.playbackInfoTimer) then return
m.playbackInfoTimer.control = "stop"
m.playbackInfoTimer.unobserveField("fire")
m.top.removeChild(m.playbackInfoTimer)
m.playbackInfoTimer = invalid
if isValid(m.screenSaverTimeoutTask)
m.screenSaverTimeoutTask.control = "STOP"
m.screenSaverTimeoutTask.unobserveField("content")
m.screenSaverTimeoutTask = invalid
end if
end sub
sub onScreensaverTimeoutLoaded()
timeout = m.screenSaverTimeoutTask.content
if isValid(timeout) then m.screenSaverTimeout = timeout
m.screenSaverTimeoutTask.unobserveField("content")
m.screenSaverTimeoutTask = invalid
end sub
sub onPlaybackInfoRefreshDue()
' Defense in depth, not the primary close path: a supersede by another overlay
' calls cancelDialog() -> closeDialog(), which sets `closed` and fires
' onPlaybackInfoDialogClosed() same as any other close. This guard is here for
' any OTHER way the dialog could leave the scene without that field ever being
' set: stop rather than poll into nothing.
if not isValid(m.reportDialog) or not isValid(m.reportDialog.getParent())
stopPlaybackInfoRefresh()
return
end if
' Idle longer than the user's own screensaver timeout: stop making requests. The
' timer keeps ticking, which costs nothing and is what removes the need for any
' resume path: TimeSinceLastKeypress resets the moment the remote is touched,
' so the very next tick fetches again. A timeout of 0 means the screensaver is
' switched off, and that is an instruction not to idle at all.
if m.screenSaverTimeout > 0
' The handle is cached rather than rebuilt each tick: CreateObject is not
' free and this runs on the render thread while video is playing.
if not isValid(m.deviceInfo) then m.deviceInfo = CreateObject("roDeviceInfo")
if m.deviceInfo.TimeSinceLastKeypress() >= m.screenSaverTimeout then return
end if
' A fetch still running is let finish, not replaced. On a server slower than
' this poll, replacing it every tick would abandon each request before it
' answered and the report would never update again; skipping keeps it updating
' at the server's pace. A fetch waiting in the launch queue counts as running
' (isTaskInFlight): replacing it would send the new one to the back of the queue
' every tick. A refused launch is neither, so it does not block every later tick.
if isTaskInFlight(m.getPlaybackInfoTask) then return
startPlaybackInfoLoad()
end sub
' The dialog closed: stop polling into it and let it go.
'
' It reports that on its own `closed` field. It USED to be observed through
' `parentNode`, on the reasoning that a dialog which dismisses itself by removing
' itself from the scene has a detached parent. But `parentNode` is not a field
' on Node (its observable fields are id, focusable, focusedChild and change), so
' that observer never fired even once. Nothing cleared m.reportDialog, so the
' NEXT press of the "i" button found a dialog it believed was still open and
' quietly refreshed a node that was no longer in the scene: the report opened the
' first time and never again.
sub onPlaybackInfoDialogClosed()
stopPlaybackInfoRefresh()
m.getPlaybackInfoTask = releaseTask(m.getPlaybackInfoTask, "data")
if isValid(m.reportDialog) then m.reportDialog.unobserveField("closed")
m.reportDialog = invalid
end sub