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/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