components_video_PlayerHostView.bs

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