source_utils_liveTv.bs

import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/translate.bs"

' Sentinel seek position used to jump to the live edge of an HLS stream.
' Roku clips this to the manifest's available range, landing at the live edge.
const LIVE_EDGE_SEEK = 999999

' Maximum behind-live offset (seconds) at which we still consider the user to be at
' the live edge. Absorbs 1-tick rounding flicker between the integer wall-clock
' (roDateTime.AsSeconds) and int(m.top.position). Both tick discretely but can be
' out of phase by up to 1 second depending on calibration alignment, which would
' otherwise flicker the UI between LIVE and -0:01 every second during normal play.
const LIVE_EDGE_THRESHOLD_SECONDS = 2

' How many times in a row a Live TV channel is restarted after its stream ends without having
' played healthily, before the player stops restarting it and shows the playback error.
const LIVE_RESTART_MAX = 3

' How much of the stream a mount must have played (see newLiveProgress) for its end or stall to
' count as a feed that dropped: the count resets, and a stall restarts the channel.
const LIVE_RESTART_HEALTHY_MS = 30000

' How much of the stream a mount restarted after a stall must play before its own stall restarts
' the channel again. A restart into a playlist that stopped growing plays what was left past the
' join before it stalls, and that counts as progress: measured 2026-09-23 on a Stick 4K against
' 12.1, 19.5 s with 4 s segments (32 s and 64 s windows alike) and 29.6 s with 10 s segments, so it
' grows with the segment length and soon passes LIVE_RESTART_HEALTHY_MS. Four times the 10 s case.
const LIVE_STALL_REPROVE_MS = 120000

' The largest forward step between two position notifications that still counts as playing (see
' liveProgressOnPosition). Notifications arrive every 0.5 s (the Video node's default
' notificationInterval); the headroom is for a busy render thread delivering them late. A larger
' step is a jump (the live-edge join, a seek) and counts nothing. VideoPlayerView logs every jump
' and a per-mount summary of the steps it saw, which is the evidence for tuning this.
const LIVE_PROGRESS_MAX_STEP_MS = 2000

function parseProgramEndTimeEpoch(meta as object) as integer
  if not isValid(meta) or not isValid(meta.currentProgram) then return 0

  endDateStr = meta.currentProgram.endDate
  if not isValidAndNotEmpty(endDateStr) then return 0

  dt = CreateObject("roDateTime")
  dt.FromISO8601String(endDateStr)
  return dt.AsSeconds()
end function

function parseProgramStartTimeEpoch(meta as object) as integer
  if not isValid(meta) or not isValid(meta.currentProgram) then return 0

  startDateStr = meta.currentProgram.startDate
  if not isValidAndNotEmpty(startDateStr) then return 0

  dt = CreateObject("roDateTime")
  dt.FromISO8601String(startDateStr)
  return dt.AsSeconds()
end function

' Compute seconds the user is behind the live edge using a hybrid play/pause model.
'
' Roku's m.top.position for live HLS DVR streams keeps advancing during pause:
' it tracks the live edge, not the user's playhead. So position-vs-wallclock math
' cancels out to zero during pause and we have to switch strategies based on state:
'
'   During pause: behindLive = accumulated + (now - pausedAtEpoch)
'                  Wall-clock-only: independent of position so Roku's auto-advance
'                  during pause is invisible to us.
'
'   During play:  behindLive = (now - epochAtPosition0) - position
'                  Position-math: self-correcting on scrubs while playing, where
'                  position really tracks the user's playhead.
'
' On the pause-to-play transition the caller re-anchors epochAtPosition0 so play-math
' continues from the same offset where wall-clock math left off.
'
' @param epochAtPosition0 epoch corresponding to video position=0 in the current stream
' @param calibrated       false until first play tick anchors the reference
' @param position         current video position (seconds), used only during play
' @param now              current wall-clock epoch
' @param pausedAtEpoch    epoch when the current pause started, or 0 if not paused
' @param accumulated      seconds-behind-live snapshotted at pause start (plus any
'                          scrub adjustments made during the current pause)
function computeLiveTvBehindLive(epochAtPosition0 as integer, calibrated as boolean, position as integer, now as integer, pausedAtEpoch as integer, accumulated as integer) as integer
  if pausedAtEpoch > 0 then return accumulated + (now - pausedAtEpoch)
  if not calibrated then return 0
  behind = (now - epochAtPosition0) - position
  if behind < 0 then return 0
  return behind
end function

' Apply a scrub-during-pause delta to the accumulated behind-live counter.
' Forward scrub (positive delta) reduces behind; backward scrub (negative) grows it.
' Clamped to >= 0 so a forward scrub past the live edge resolves to 0 (live edge).
function applyScrubToAccumulatedBehind(accumulated as integer, delta as integer) as integer
  result = accumulated - delta
  if result < 0 then return 0
  return result
end function

' Compute the new accumulated baseline after a scrub during pause. Folds the elapsed
' pause duration into accumulated first so the wall-clock pause counter can be reset
' from the scrub moment. Otherwise a forward scrub to live edge would still leave
' the wall-clock counter adding to total behindLive.
'
' Caller is expected to also reset pausedAtEpoch = now after this returns.
function repartitionPauseStateOnScrub(accumulated as integer, pauseDurationSoFar as integer, scrubDelta as integer) as integer
  return applyScrubToAccumulatedBehind(accumulated + pauseDurationSoFar, scrubDelta)
end function

' Build a queue array from a Jellyfin /livetv/channels response and locate the
' currently-playing channel within it.
'
' @param channels         array of channel AAs as returned by /livetv/channels
' @param currentChannelId id of the currently-playing channel
' @return AA with shape: items=[queueItem...], currentIndex=integer (-1 if currentChannelId not found)
function buildChannelQueueList(channels as object, currentChannelId as string) as object
  result = { items: [], currentIndex: -1 }
  if not isValid(channels) then return result

  for i = 0 to channels.count() - 1
    channel = channels[i]
    if not isValid(channel) or not isValidAndNotEmpty(channel.Id) then continue for

    queueItem = {
      id: channel.Id,
      type: "TvChannel"
    }
    if isValidAndNotEmpty(channel.Name)
      queueItem.title = channel.Name
    end if
    if isValidAndNotEmpty(channel.Number)
      queueItem.channelNumber = channel.Number
    end if
    if isValidAndNotEmpty(channel.PrimaryImageTag)
      queueItem.primaryImageTag = channel.PrimaryImageTag
    end if

    result.items.push(queueItem)

    if channel.Id = currentChannelId
      result.currentIndex = result.items.count() - 1
    end if
  end for

  return result
end function

' Decide whether a video should be tagged as ContentNode contenttype="episode".
' Tagging "episode" enables the Next-Episode flow in VideoPlayerView.
'
' Episodes and series items are always episodes. Recordings are episodes only
' when their metadata identifies them as part of a series (seriesId set). A
' standalone recording of a movie or sports event should not trigger the
' Next-Episode prompt.
'
' Recordings without a season/episode number but WITH a seriesId still count
' as series content (for example news bulletins or shows whose EPG omits S/E numbering).
'
' @param videotype lowercased meta.type
' @param meta      JellyfinBaseItem or AA with seriesId field
function shouldTreatAsEpisode(videotype as string, meta as object) as boolean
  if videotype = "episode" or videotype = "series" then return true
  if videotype = "recording" and isValid(meta) and isValidAndNotEmpty(meta.seriesId) then return true
  return false
end function

' The Live TV restart budget PlayerHostView carries across its player mounts, since how much a
' mount played is measured by that mount's player (newLiveProgress). liveRestartOnMount,
' liveRestartOnEnd and liveRestartOnStall hold the whole rule, including when each field resets, so
' it can be tested without a player.
'
' @returns {object} { count, isStallRestart, hasPlayed }. count: consecutive restarts;
'   isStallRestart: the last restart answered a stall and no mount since has played
'   LIVE_STALL_REPROVE_MS; hasPlayed: some mount since the viewer chose the channel made progress,
'   which decides the error's wording (liveGiveUpCauseKey)
function newLiveRestartBudget() as object
  return { count: 0, isStallRestart: false, hasPlayed: false }
end function

' A player mount. Only a Live TV restart keeps the budget, so a first mount, a channel switch and a
' queue advance all start a fresh one.
'
' @param {object} budget - from newLiveRestartBudget
' @param {boolean} isLiveRestart - true when the host is restarting the channel whose stream ended
sub liveRestartOnMount(budget as object, isLiveRestart as boolean)
  if isLiveRestart then return
  budget.count = 0
  budget.isStallRestart = false
  budget.hasPlayed = false
end sub

' The channel's stream ended. Counts this end (nextLiveRestartCount) and returns whether the
' channel should be restarted; false means give up and show the error.
'
' @param {object} budget - from newLiveRestartBudget
' @param {integer} progressMs - how much of the stream this mount played (newLiveProgress)
' @returns {boolean}
function liveRestartOnEnd(budget as object, progressMs as integer) as boolean
  noteLiveMountProgress(budget, progressMs)
  budget.count = nextLiveRestartCount(budget.count, progressMs)
  return canRestartLiveChannel(budget.count)
end function

' The channel's stream stalled (VideoPlayerView.bufferCheck saw no buffering progress). Restarts
' only a mount that had played healthily, the dropped-feed case the restart exists for; any other
' stall returns false (show the error). Unlike an end, a stall is only detected at least 30 s into
' the spinner, so the end budget's three restarts would keep a viewer waiting for minutes.
'
' After a stall restart, "healthily" means LIVE_STALL_REPROVE_MS rather than
' LIVE_RESTART_HEALTHY_MS: a restart into a playlist that stopped growing plays its leftover and
' stalls again, and with long segments that leftover alone would pass the lower bar every time.
'
' @param {object} budget - from newLiveRestartBudget
' @param {integer} progressMs - how much of the stream this mount played (newLiveProgress)
' @returns {boolean}
function liveRestartOnStall(budget as object, progressMs as integer) as boolean
  noteLiveMountProgress(budget, progressMs)
  budget.count = nextLiveRestartCount(budget.count, progressMs)

  requiredMs = LIVE_RESTART_HEALTHY_MS
  if budget.isStallRestart then requiredMs = LIVE_STALL_REPROVE_MS
  if progressMs < requiredMs then return false

  budget.isStallRestart = true
  return true
end function

' What liveRestartOnEnd and liveRestartOnStall both learn from a mount's progress.
sub noteLiveMountProgress(budget as object, progressMs as integer)
  if progressMs > 0 then budget.hasPlayed = true
  if progressMs >= LIVE_STALL_REPROVE_MS then budget.isStallRestart = false
end sub

' The translation key for the error shown when the host gives up on a Live TV channel: a channel
' that never showed any video since the viewer chose it is not sending any, and one that did has
' stopped.
'
' @param {object} budget - from newLiveRestartBudget
' @returns {string} a translationKeys value
function liveGiveUpCauseKey(budget as object) as string
  if budget.hasPlayed then return translationKeys.ErrorPlaybackCauseLiveStreamStopped
  return translationKeys.ErrorPlaybackCauseLiveStreamNoVideo
end function

' How much of a live stream one player mount has played, for the restart rule: the sum of the
' small forward steps between position notifications that took the player past the furthest
' position it had reached. Driven by liveProgressOnPosition from the player's position observer.
'
' Why steps, and not the position at a state change: the first `playing` reports no real position
' (measured on a Stick 4K, 12.1: the live-edge seek sentinel in some runs, 0 in others), and a
' stream that plays until it stalls has no state change in between, so the position there cannot
' anchor anything. Why only NEW ground: at the end of a playlist that stops growing, the player
' replays its window before it buffers, so time spent playing called a stale upstream healthy and
' restarted it forever. Pausing and buffering take no steps, so they count nothing either.
'
' The step fields are diagnostics for LIVE_PROGRESS_MAX_STEP_MS (see VideoPlayerView's log lines).
'
' @returns {object} { progressMs, markMs, steps, largestStepMs, jumps, smallestJumpMs }. markMs is
'   the furthest position reached (-1 before the first usable one); smallestJumpMs is 0 with no jumps
function newLiveProgress() as object
  return { progressMs: 0, markMs: -1, steps: 0, largestStepMs: 0, jumps: 0, smallestJumpMs: 0 }
end function

' One position notification. A forward step of at most LIVE_PROGRESS_MAX_STEP_MS past the furthest
' position so far is playing and adds to the progress; a larger one is a jump (the live-edge join, a
' seek) that moves the mark without counting; a step back (a seek back, a replayed window) counts
' nothing and leaves the mark where it was. The live-edge seek sentinel is not a position at all.
'
' @param {object} progress - from newLiveProgress
' @param {double} positionSeconds - the player's position
' @returns {integer} the size of a forward jump this notification made, in milliseconds, or 0, for
'   the caller's debug log
function liveProgressOnPosition(progress as object, positionSeconds as double) as integer
  if positionSeconds < 0 or positionSeconds >= LIVE_EDGE_SEEK then return 0
  positionMs = int(positionSeconds * 1000)
  if progress.markMs < 0
    progress.markMs = positionMs
    return 0
  end if

  stepMs = positionMs - progress.markMs
  if stepMs <= 0 then return 0
  progress.markMs = positionMs

  if stepMs > LIVE_PROGRESS_MAX_STEP_MS
    progress.jumps += 1
    if progress.smallestJumpMs = 0 or stepMs < progress.smallestJumpMs then progress.smallestJumpMs = stepMs
    return stepMs
  end if

  progress.progressMs += stepMs
  progress.steps += 1
  if stepMs > progress.largestStepMs then progress.largestStepMs = stepMs
  return 0
end function

' A new stream loaded into the same mount (a retry, or an audio or subtitle change that reloads).
' Its positions are on a timeline of their own, joined at the live edge again, so they cannot be
' compared with the mark the previous stream left; keeping it would count nothing until the new
' stream passed the old stream's furthest position. The progress already made stays.
'
' @param {object} progress - from newLiveProgress
sub liveProgressOnNewStream(progress as object)
  progress.markMs = -1
end sub

' The count of consecutive restarts after a Live TV channel's stream ends.
'
' A live feed that drops mid-stream surfaces as `finished`, and the player restarts the channel
' instead of dropping the viewer back to the guide. A stream that ends before it ever plays
' (an on-demand playlist listed as a channel, joined at its end) also surfaces as `finished`, and
' restarting it only loops. So an end after healthy playback resets the count, and any other end
' adds one; canRestartLiveChannel decides whether that count still allows a restart.
'
' @param {integer} previousCount - the count before this end (0 for a fresh channel)
' @param {integer} progressMs - how much of the stream this mount played (see newLiveProgress)
' @returns {integer}
function nextLiveRestartCount(previousCount as integer, progressMs as integer) as integer
  if progressMs >= LIVE_RESTART_HEALTHY_MS then return 0
  return previousCount + 1
end function

' Whether a Live TV channel whose stream ended should be restarted, given its count from
' nextLiveRestartCount.
'
' @param {integer} restartCount
' @returns {boolean}
function canRestartLiveChannel(restartCount as integer) as boolean
  return restartCount <= LIVE_RESTART_MAX
end function

' The toast for a RecordProgramTask result: scheduled or cancelled, and whether it worked. A
' failed result that is not a record reads as a failed cancel, as the task reports it.
'
' @param result - RecordProgramTask.result ({ ok, action }), or invalid
' @return an AA with `text` and `type` for displayToast
function recordResultToast(result as dynamic) as object
  ok = isValid(result) and result.ok = true
  isRecord = isValid(result) and result.action = "record"
  if ok and isRecord then return { text: translate(translationKeys.LabelRecordingScheduled), type: "success" }
  if ok then return { text: translate(translationKeys.LabelRecordingCancelled), type: "success" }
  if isRecord then return { text: translate(translationKeys.ErrorFailedToScheduleRecording), type: "error" }
  return { text: translate(translationKeys.ErrorFailedToCancelRecording), type: "error" }
end function