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/mediaDisplayTitle.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/playbackReport.bs"
import "pkg:/source/utils/queueBackdropHelper.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
' deliberately, 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 — i.e. a transcode is
' running. On direct play 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
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 just reads getCurrentItem. Guard against a
' redundant call (e.g. screensaver-exit re-fires onScreenShown).
sub onScreenShown()
if not isValid(m.view)
mountPlayer()
end if
end sub
sub onScreenHidden()
end sub
' Router: take remote focus. Hand it to the player child if mounted; the player also
' grabs focus itself once content loads (VideoPlayerView.onVideoContentLoaded).
function handleFocus(_data = {} as object) as boolean
if isValid(m.view)
m.view.setFocus(true)
else
m.top.setFocus(true)
end if
return true
end function
' 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.
sub mountPlayer()
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")
m.getPlaybackInfoTask = createObject("roSGNode", "GetPlaybackInfoTask")
m.getPlaybackInfoTask.observeField("data", "onPlaybackInfoLoaded")
' 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()
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
if isValid(m.getPlaybackInfoTask)
m.getPlaybackInfoTask.control = "STOP"
m.getPlaybackInfoTask.unobserveField("data")
m.getPlaybackInfoTask = invalid
end if
' 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 / Live TV restart; VideoPlayerView channel
' switch) set the position + backdrop first. Replaces ViewCreator's
' clearPreviousScene + playQueue (which created a new pushed scene).
sub playCurrentQueueItem()
destroyPlayer()
mountPlayer()
end sub
' A deep link arrived while this player is active. Stop + report the player NOW (synchronously)
' WITHOUT navigating — the caller (JRScene.replayDeepLinkReplacingPlayer) then
' goBacks, whose beforeViewClose -> onDestroy does the full teardown. destroyPlayer is idempotent
' (guards m.view), so that second teardown is a safe no-op — NOT a full onDestroy here, 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 / sign-out resetRouter). Tear down the
' player child. abandonApiPromises() is injected here by the auto-abandon-promises BSC
' plugin (JRScreen.onDestroy's floor doesn't chain to SG-component overrides).
sub onDestroy()
' 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.
' Both observers this view owns for the report have to be released here — the
' timer's `fire` and the dialog's `parentNode`. The dialog outlives this view
' (it is appended to the SCENE), so an unreleased observer on it would keep
' calling into a dead scope.
releasePlaybackInfoRefresh()
if isValid(m.playbackDialog) then m.playbackDialog.unobserveField("closed")
abandonDialog(m.playbackDialog)
m.playbackDialog = invalid
m.trackPickerOptions = invalid
destroyPlayer()
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()
if LCase(m.view.state) <> "finished" then return
' 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
queueManager = m.global.queueManager
' 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() MUST run before cancelOpenDialog(): both the error
' dialog and cancelOpenDialog() live on the overlay channel now (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 the
' observer that would have fired) before cancelOpenDialog() ever gets to it,
' so a stall-path close is inert instead of racing this handler's own
' navigation — the double-exit the old raw Dialog used to produce via its
' undifferentiated `wasClosed` observer.
m.view.callFunc("abandonErrorDialog")
' Same two observers as onDestroy: we are dropping the reference to a dialog
' that lives on the SCENE, so the poll and its close observer go with it.
releasePlaybackInfoRefresh()
if isValid(m.playbackDialog) then m.playbackDialog.unobserveField("closed")
abandonDialog(m.playbackDialog)
m.playbackDialog = invalid
m.trackPickerOptions = invalid
cancelOpenDialog()
' Live TV channel that finished -> restart the same channel (same queue position)
currentItem = queueManager.callFunc("getCurrentItem")
if isValid(currentItem)
currentItemType = queueManager.callFunc("getItemType", currentItem)
if currentItemType = "tvchannel"
playCurrentQueueItem()
return
end if
end if
' More items in the queue -> advance and play the next one
if queueManager.callFunc("getPosition") < queueManager.callFunc("getCount") - 1
queueManager.callFunc("moveForward")
updateQueueBackdrop()
playCurrentQueueItem()
return
end if
' Queue exhausted -> leave the player and return to the launching view
m.global.audioPlayer.loopMode = ""
exitPlayback()
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
if isValid(m.view) then return m.view.callFunc("handleTransport", evt)
return { status: "error.no-media" }
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),
' so they share one node slot, which is also the one thing teardown has to abandon.
' ===========================================================================
' 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
m.trackPickerOptions = options
m.playbackDialog = 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.playbackDialog
options = m.trackPickerOptions
m.playbackDialog = 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 deliberately NOT treated as "no change": 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()
launchTask(m.getPlaybackInfoTask)
end sub
' The session arrived. Compose the report and either open it or refresh the one
' already open.
sub onPlaybackInfoLoaded()
data = m.getPlaybackInfoTask.data
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.playbackDialog) 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.playbackDialog) 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.playbackDialog)
' 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 exactly that — and leaving "Transcoding" above a body that has just
' rebuilt itself into a direct-play report is the stale answer this whole
' refresh exists to prevent.
m.playbackDialog.tagline = report.status
m.playbackDialog.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.playbackDialog = showReportDialog(translate(translationKeys.LabelPlaybackInfo), report.status, report.sections)
m.playbackDialog.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 just 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,
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
}
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 simply 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.playbackDialog) or not isValid(m.playbackDialog.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
launchTask(m.getPlaybackInfoTask)
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.playbackDialog, 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()
if isValid(m.playbackDialog) then m.playbackDialog.unobserveField("closed")
m.playbackDialog = invalid
end sub