Members

(static, constant) LIVE_EDGE_SEEK

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.

Default Value
  • 999999

(static, constant) LIVE_EDGE_THRESHOLD_SECONDS

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.

Default Value
  • 2

(static, constant) LIVE_PROGRESS_MAX_STEP_MS

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.

Default Value
  • 2000

(static, constant) LIVE_RESTART_HEALTHY_MS

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.

Default Value
  • 30000

(static, constant) LIVE_RESTART_MAX

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.

Default Value
  • 3

(static, constant) LIVE_STALL_REPROVE_MS

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.

Default Value
  • 120000

Methods

(static) applyScrubToAccumulatedBehind(accumulated, delta) → {integer}

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).

Parameters:
NameTypeDescription
accumulatedinteger
deltainteger
Returns:
Type: 
integer

(static) buildChannelQueueList(channels, currentChannelId) → {object}

Build a queue array from a Jellyfin /livetv/channels response and locate the currently-playing channel within it.

Parameters:
NameTypeDescription
channelsobject

array of channel AAs as returned by /livetv/channels

currentChannelIdstring

id of the currently-playing channel

Returns:

AA with shape: items=[queueItem...], currentIndex=integer (-1 if currentChannelId not found)

Type: 
object

(static) canRestartLiveChannel(restartCount) → {boolean}

Whether a Live TV channel whose stream ended should be restarted, given its count from nextLiveRestartCount.

Parameters:
NameTypeDescription
restartCountinteger
Returns:
Type: 
boolean

(static) computeLiveTvBehindLive(epochAtPosition0, calibrated, position, now, pausedAtEpoch, accumulated) → {integer}

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.

scrub adjustments made during the current pause)

Parameters:
NameTypeDescription
epochAtPosition0integer

epoch corresponding to video position=0 in the current stream

calibratedboolean

false until first play tick anchors the reference

positioninteger

current video position (seconds), used only during play

nowinteger

current wall-clock epoch

pausedAtEpochinteger

epoch when the current pause started, or 0 if not paused

accumulatedinteger

seconds-behind-live snapshotted at pause start (plus any

Returns:
Type: 
integer

(static) liveGiveUpCauseKey(budget) → {string}

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.

Parameters:
NameTypeDescription
budgetobject

from newLiveRestartBudget

Returns:

a translationKeys value

Type: 
string

(static) liveProgressOnNewStream(progress) → {void}

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.

Parameters:
NameTypeDescription
progressobject

from newLiveProgress

Returns:
Type: 
void

(static) liveProgressOnPosition(progress, positionSeconds) → {integer}

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.

the caller's debug log

Parameters:
NameTypeDescription
progressobject

from newLiveProgress

positionSecondsdouble

the player's position

Returns:

the size of a forward jump this notification made, in milliseconds, or 0, for

Type: 
integer

(static) liveRestartOnEnd(budget, progressMs) → {boolean}

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.

Parameters:
NameTypeDescription
budgetobject

from newLiveRestartBudget

progressMsinteger

how much of the stream this mount played (newLiveProgress)

Returns:
Type: 
boolean

(static) liveRestartOnMount(budget, isLiveRestart) → {void}

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.

Parameters:
NameTypeDescription
budgetobject

from newLiveRestartBudget

isLiveRestartboolean

true when the host is restarting the channel whose stream ended

Returns:
Type: 
void

(static) liveRestartOnStall(budget, progressMs) → {boolean}

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.

Parameters:
NameTypeDescription
budgetobject

from newLiveRestartBudget

progressMsinteger

how much of the stream this mount played (newLiveProgress)

Returns:
Type: 
boolean

(static) newLiveProgress() → {object}

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).

the furthest position reached (-1 before the first usable one); smallestJumpMs is 0 with no jumps

Returns:

{ progressMs, markMs, steps, largestStepMs, jumps, smallestJumpMs }. markMs is

Type: 
object

(static) newLiveRestartBudget() → {object}

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.

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)

Returns:

{ count, isStallRestart, hasPlayed }. count: consecutive restarts;

Type: 
object

(static) nextLiveRestartCount(previousCount, progressMs) → {integer}

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.

Parameters:
NameTypeDescription
previousCountinteger

the count before this end (0 for a fresh channel)

progressMsinteger

how much of the stream this mount played (see newLiveProgress)

Returns:
Type: 
integer

(static) noteLiveMountProgress(budget, progressMs) → {void}

What liveRestartOnEnd and liveRestartOnStall both learn from a mount's progress.

Parameters:
NameTypeDescription
budgetobject
progressMsinteger
Returns:
Type: 
void

(static) parseProgramEndTimeEpoch(meta) → {integer}

Parameters:
NameTypeDescription
metaobject
Returns:
Type: 
integer

(static) parseProgramStartTimeEpoch(meta) → {integer}

Parameters:
NameTypeDescription
metaobject
Returns:
Type: 
integer

(static) recordResultToast(result) → {object}

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.

Parameters:
NameTypeDescription
resultdynamic

RecordProgramTask.result ({ ok, action }), or invalid

Returns:

an AA with text and type for displayToast

Type: 
object

(static) repartitionPauseStateOnScrub(accumulated, pauseDurationSoFar, scrubDelta) → {integer}

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.

Parameters:
NameTypeDescription
accumulatedinteger
pauseDurationSoFarinteger
scrubDeltainteger
Returns:
Type: 
integer

(static) shouldTreatAsEpisode(videotype, meta) → {boolean}

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).

Parameters:
NameTypeDescription
videotypestring

lowercased meta.type

metaobject

JellyfinBaseItem or AA with seriesId field

Returns:
Type: 
boolean