refactor(spf): split track loading from live-reload scheduling

Decompose the live media-playlist reload into two single-responsibility
behaviors, matching the content-snapshot ([1]) vs refetch-policy ([3])
category split in live-presentation-modeling.md:

- resolve-track (loader, [1]) now also services live reloads: it watches a
  per-type reload-epoch slot and re-fetches when it advances (gate: load if
  unresolved OR epoch > last serviced), carries the prior snapshot's timeline
  forward, and sets presentation.streamType. ConcurrentRunner id-dedup +
  recording the epoch at schedule time gives drop-if-busy coalescing.
- schedule-track-reload (scheduler, [3]) is what remains of reload-track once
  the fetching is removed: it bumps the reload epoch on a target-duration
  cadence (half on unchanged), inert for VoD via #EXT-X-ENDLIST, and enters on
  track *selection* so its bumps also drive first-resolve retries.

reload-track is removed; the live-hls and live-playlist-spike engines compose
resolve* + schedule* in its place.

Also fix updateMediaSourceDuration for live: it only wrote Infinity when the
MediaSource was already open at entry and returned without scheduling a wait
otherwise. The presentation can resolve to Infinity before setupMediaSource
opens the MediaSource, so the eager write was missed and the first append
pinned a finite live-edge duration — stalling once the window slid past it.
Now it defers to sourceopen when not yet open.

Verified live playback end-to-end (real-time, Infinity duration, seeked to
edge) against a Mux LL-HLS stream.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-06-25 09:59:22 -07:00
co-authored by Claude Opus 4.8
parent 0f10a280e5
commit e268f5a350
11 changed files with 588 additions and 404 deletions
@@ -166,6 +166,32 @@ describe('updateMediaSourceDuration', () => {
reactor.destroy();
});
it('writes Infinity after sourceopen when the MediaSource starts closed (live)', async () => {
// Regression: the live path used to write only if the MediaSource was
// already open at entry, returning without scheduling a wait otherwise. The
// presentation can resolve to Infinity before `setupMediaSource` opens the
// MediaSource, so that eager write was missed — the first append then pinned
// a finite live-edge duration, and appends stalled once the window slid past.
const { state, context, reactor } = setupUpdateMediaSourceDuration();
const mockMediaSource = makeMediaSource({ readyState: 'closed' });
context.mediaSource.set(mockMediaSource);
state.presentation.set({ duration: Number.POSITIVE_INFINITY } as Presentation);
// Awaiting sourceopen — nothing written yet.
await new Promise((resolve) => setTimeout(resolve, 20));
expect(mockMediaSource.duration).toBeNaN();
// MediaSource opens — Infinity is written.
transitionMediaSource(mockMediaSource, 'open', 'sourceopen');
await vi.waitFor(() => {
expect(mockMediaSource.duration).toBe(Number.POSITIVE_INFINITY);
});
reactor.destroy();
});
it('extends duration to match buffered range if needed', async () => {
const { state, context, reactor } = setupUpdateMediaSourceDuration();
@@ -4,13 +4,14 @@
*
* Two paths, by whether the presentation is live:
*
* - **Live** (`presentation.duration === Infinity`): written **synchronously**
* on entry. The presentation declares `Infinity` as soon as it resolves —
* before any segment append — and this behavior is composed before the buffer
* actors, so it runs while the MediaSource is freshly open and empty. Writing
* now (no buffered clamp needed; `Infinity` ≥ any range) gets ahead of the
* first append, which would otherwise set `duration` to the buffered end and
* pin the live stream to a finite (live-edge) duration.
* - **Live** (`presentation.duration === Infinity`): write `Infinity` ahead of
* the first append (no buffered clamp needed; `Infinity` ≥ any range), which
* would otherwise pin `duration` to the buffered (live-edge) end — and once
* the window slides past that, further appends are rejected. Written
* synchronously when the MediaSource is already open; otherwise deferred to
* the next `sourceopen` (the presentation can resolve to `Infinity` before
* `setupMediaSource` opens the MediaSource, so a synchronous-only write would
* miss the window).
*
* - **VoD** (finite): the value is written once, after `mediaSource` is open and
* all SourceBuffers are idle, clamped to be ≥ the highest buffered range (MSE
@@ -97,19 +98,36 @@ function updateMediaSourceDurationSetup({
const presentation = state.presentation.get()!;
const mediaSource = context.mediaSource.get()!;
// Live: the presentation declares `Infinity` as soon as it resolves
// before any segment append. This entry runs while the MediaSource is
// freshly open and still empty (it's composed before the buffer
// actors), so write it now, synchronously: no buffered clamp is needed
// (`Infinity` ≥ any range), and getting ahead of the first append is
// what stops the append pinning a finite (live-edge) duration. The
// async wait-for-idle path below would lose that race — a live loader
// appends continuously, so the buffers are rarely all idle.
// Live: the presentation declares `Infinity` as soon as it resolves.
// Write it ahead of the first append — otherwise the append pins
// `duration` to the buffered (live-edge) end, and once the window
// slides past that value further appends are rejected. No buffered
// clamp is needed (`Infinity` ≥ any range), and the async
// wait-for-idle path below would lose the race (a live loader appends
// continuously, so the buffers are rarely all idle).
if (presentation.duration === Number.POSITIVE_INFINITY) {
if (mediaSource.readyState === 'open' && mediaSource.duration !== Number.POSITIVE_INFINITY) {
if (mediaSource.duration === Number.POSITIVE_INFINITY) return;
// Open already → write synchronously (fastest, gets ahead of appends).
if (mediaSource.readyState === 'open') {
mediaSource.duration = Number.POSITIVE_INFINITY;
return;
}
return;
// Not open yet → wait for `sourceopen`, then write. The presentation
// can resolve to `Infinity` before `setupMediaSource` opens the
// MediaSource; returning here without waiting (as this once did)
// would miss the write entirely and let the first append pin a
// finite duration. The continuation still runs before the
// network-delayed first append; if an append did land first,
// `Infinity` ≥ its range so the write is still valid.
const controller = new AbortController();
void (async () => {
await waitForMediaSourceOpen(mediaSource, controller.signal);
if (controller.signal.aborted || mediaSource.readyState !== 'open') return;
if (mediaSource.duration !== Number.POSITIVE_INFINITY) {
mediaSource.duration = Number.POSITIVE_INFINITY;
}
})();
return () => controller.abort();
}
// VoD: write the finite duration once, while it is still `NaN`. Once
@@ -1,196 +0,0 @@
/**
* Live media-playlist reload loop, per track type.
*
* Once the presentation is resolved and a track of this type is selected,
* repeatedly re-fetch + parse that track's media playlist on a target-duration
* cadence — passing the prior snapshot back in so the parser carries the
* timeline forward — and patch it into `state.presentation` until
* `#EXT-X-ENDLIST`. Drives the live foundation at the playlist layer
* ([live-presentation-modeling.md](../../../internal/design/spf/live-presentation-modeling.md)).
*
* Specialized per type via the shared `setupTrackReload` (mirrors
* `resolve-track`): `reloadVideoTrack` / `reloadAudioTrack` / `reloadTextTrack`,
* each gating on its own `selected*TrackId` + track type, so demuxed audio and
* video reload independently.
*
* Limitations (intentional, for now): selection is read once at loop start
* (mid-stream track switching isn't encoded in the reactor's two states);
* PDT / discontinuity / A/V sync are handled by separate timeline transforms,
* not here.
*/
import { defineBehavior } from '../../core/composition/create-composition';
import { createMachineReactor } from '../../core/reactors/create-machine-reactor';
import { computed, peek, type ReadonlySignal, type Signal, update } from '../../core/signals/primitives';
import { parseMediaPlaylist } from '../../media/hls/parse-media-playlist';
import {
deriveStreamType,
getMediaPlaylistMetadata,
isResolvedPresentation,
isResolvedTrack,
type MaybeResolvedPresentation,
type ResolvedTrack,
type TrackType,
} from '../../media/types';
import { findTrack, updateTrackInPresentation } from '../../media/utils/tracks';
import { fetchResolvableText as defaultFetchResolvableText, type FetchText } from '../../network/fetch';
import { AUDIO_TYPE_CONFIG, TEXT_TYPE_CONFIG, VIDEO_TYPE_CONFIG } from './track-types';
export interface ReloadTrackState {
presentation?: MaybeResolvedPresentation;
selectedVideoTrackId?: string;
selectedAudioTrackId?: string;
selectedTextTrackId?: string;
}
/** Engine-config slice each `reload*` behavior reads. */
export interface ReloadTrackConfig {
/** Playlist-text fetch; defaults to the plain resolvable fetch. */
fetchResolvableText?: FetchText;
}
type SelectedTrackKey = 'selectedVideoTrackId' | 'selectedAudioTrackId' | 'selectedTextTrackId';
type ReloadTrackStateName = 'idle' | 'reloading';
type ReloadTrackStateMap<K extends SelectedTrackKey> = {
presentation: Signal<ReloadTrackState['presentation']>;
} & { [P in K]: ReadonlySignal<ReloadTrackState[P]> };
interface TrackReloadConfig<K extends SelectedTrackKey> {
type: TrackType;
selectedKey: K;
fetchResolvableText?: FetchText;
}
/** Fallback reload cadence when the playlist carries no usable target duration. */
const FALLBACK_TARGET_DURATION = 6;
function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(new DOMException('Aborted', 'AbortError'));
return;
}
const timer = setTimeout(resolve, ms);
signal.addEventListener(
'abort',
() => {
clearTimeout(timer);
reject(new DOMException('Aborted', 'AbortError'));
},
{ once: true }
);
});
}
/** A reload is "live" (new content) when the window slid or grew. */
function snapshotChanged(prev: ResolvedTrack, next: ResolvedTrack): boolean {
return (
getMediaPlaylistMetadata(prev)?.mediaSequence !== getMediaPlaylistMetadata(next)?.mediaSequence ||
prev.segments.length !== next.segments.length
);
}
function setupTrackReload<K extends SelectedTrackKey>({
state,
config: { type, selectedKey, fetchResolvableText = defaultFetchResolvableText },
}: {
state: ReloadTrackStateMap<K>;
config: TrackReloadConfig<K>;
}) {
const derivedStateSignal = computed<ReloadTrackStateName>(() => {
const presentation = state.presentation.get();
const trackId = state[selectedKey].get();
if (!isResolvedPresentation(presentation) || !trackId) return 'idle';
return findTrack(presentation, type, trackId) ? 'reloading' : 'idle';
});
return createMachineReactor<ReloadTrackStateName>({
initial: 'idle',
monitor: () => derivedStateSignal.get(),
states: {
idle: {},
reloading: {
entry: () => {
const ac = new AbortController();
const trackId = state[selectedKey].get()!;
void (async () => {
while (!ac.signal.aborted) {
try {
const presentation = peek(state.presentation);
if (!isResolvedPresentation(presentation)) break;
// The track currently in the presentation is the prior snapshot
// (the unresolved shell on the first pass, the last resolved
// window thereafter); the parser carries its timeline forward.
const previousTrack = findTrack(presentation, type, trackId);
if (!previousTrack) break;
const text = await fetchResolvableText(previousTrack, { signal: ac.signal });
const parsed: ResolvedTrack = parseMediaPlaylist(text, previousTrack);
const meta = getMediaPlaylistMetadata(parsed);
// The first resolve (previous still an unresolved shell) counts as changed.
const changed = !isResolvedTrack(previousTrack) || snapshotChanged(previousTrack, parsed);
update(state.presentation, (current) => {
if (!isResolvedPresentation(current)) return current;
const patched = updateTrackInPresentation(current, parsed);
// streamType is stable across reloads, so recomputing it is harmless.
return { ...patched, streamType: deriveStreamType(meta) };
});
if (meta?.endList) break;
const target = meta?.targetDuration || FALLBACK_TARGET_DURATION;
// Spec: reload ~target duration; half that when the playlist was unchanged.
await sleep((changed ? target : target / 2) * 1000, ac.signal);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') return;
// A transient fetch/parse failure must not kill the loop — a live
// playlist has to keep refreshing. Log and retry on the next
// cadence (the `while` re-checks `aborted`).
// TODO(error-management): route to a state-error slot once one exists.
console.error(`[reload:${type}] media-playlist reload failed; retrying:`, error);
try {
await sleep(FALLBACK_TARGET_DURATION * 1000, ac.signal);
} catch {
return; // aborted during the retry wait
}
}
}
})();
// State-exit (source change / destroy) aborts the loop.
return () => ac.abort();
},
},
},
});
}
const VIDEO_TRACK_RELOAD_CONFIG = { type: VIDEO_TYPE_CONFIG.type, selectedKey: VIDEO_TYPE_CONFIG.selectedKey } as const;
const AUDIO_TRACK_RELOAD_CONFIG = { type: AUDIO_TYPE_CONFIG.type, selectedKey: AUDIO_TYPE_CONFIG.selectedKey } as const;
const TEXT_TRACK_RELOAD_CONFIG = { type: TEXT_TYPE_CONFIG.type, selectedKey: TEXT_TYPE_CONFIG.selectedKey } as const;
/** Reload the selected video track's media playlist on a live cadence. */
export const reloadVideoTrack = defineBehavior({
stateKeys: ['presentation', 'selectedVideoTrackId'],
contextKeys: [],
setup: ({ state, config = {} }: { state: ReloadTrackStateMap<'selectedVideoTrackId'>; config?: ReloadTrackConfig }) =>
setupTrackReload({ state, config: { ...VIDEO_TRACK_RELOAD_CONFIG, ...config } }),
});
/** Reload the selected audio track's media playlist (demuxed audio). */
export const reloadAudioTrack = defineBehavior({
stateKeys: ['presentation', 'selectedAudioTrackId'],
contextKeys: [],
setup: ({ state, config = {} }: { state: ReloadTrackStateMap<'selectedAudioTrackId'>; config?: ReloadTrackConfig }) =>
setupTrackReload({ state, config: { ...AUDIO_TRACK_RELOAD_CONFIG, ...config } }),
});
/** Reload the selected text track's media playlist (live captions). */
export const reloadTextTrack = defineBehavior({
stateKeys: ['presentation', 'selectedTextTrackId'],
contextKeys: [],
setup: ({ state, config = {} }: { state: ReloadTrackStateMap<'selectedTextTrackId'>; config?: ReloadTrackConfig }) =>
setupTrackReload({ state, config: { ...TEXT_TRACK_RELOAD_CONFIG, ...config } }),
});
@@ -4,7 +4,7 @@ import { computed, peek, type ReadonlySignal, type Signal, update } from '../../
import { ConcurrentRunner, Task } from '../../core/tasks/task';
import { NON_FMP4_CONTAINER_MIMES, parseMediaPlaylist } from '../../media/hls/parse-media-playlist';
import type { MaybeResolvedPresentation, PartiallyResolvedTrack, ResolvedTrack } from '../../media/types';
import { isResolvedPresentation, isResolvedTrack } from '../../media/types';
import { deriveStreamType, getMediaPlaylistMetadata, isResolvedPresentation, isResolvedTrack } from '../../media/types';
import type { GetCdnId } from '../../media/utils/cdn';
import { applyContainerMimeType, findTrack, updateTrackInPresentation } from '../../media/utils/tracks';
import { fetchResolvableText as defaultFetchResolvableText, type FetchText } from '../../network/fetch';
@@ -17,9 +17,21 @@ import { AUDIO_TYPE_CONFIG, TEXT_TYPE_CONFIG, VIDEO_TYPE_CONFIG } from '../primi
// `setupTrackResolution` has the same shape as a Behavior `setup` function:
// `({ state, config }) => cleanup`. Each `resolveXTrack` export below calls it
// from inside its own `defineBehavior` setup, supplying its per-type config
// inline. The orchestration — gate on a selection, short-circuit when the
// track is already resolved or missing, schedule the fetch+parse, and patch
// the resolved track back into `state.presentation` — is shared.
// inline. The orchestration — gate on a selection, decide whether a (re)load
// is due, schedule the fetch+parse, and patch the resolved track back into
// `state.presentation` (carrying the prior snapshot's timeline forward) — is
// shared.
//
// This behavior is category [1] "content snapshot" from
// [live-presentation-modeling.md](../../../internal/design/spf/live-presentation-modeling.md):
// it produces the windowed segment list. *When* to (re)fetch is category [3]
// "refetch policy", owned by the sibling `scheduleTrackReload` scheduler, which
// bumps a per-type reload-epoch slot. The loader loads when the track is
// unresolved (the initial resolve — the only trigger for VoD, where no
// scheduler is composed) OR when the reload epoch has advanced past the last
// one it serviced (a live reload). Setting the epoch synchronously at schedule
// time, paired with `ConcurrentRunner`'s id-dedup, coalesces bumps that arrive
// while a fetch is still in flight (drop-if-busy).
// ============================================================================
/**
@@ -32,16 +44,27 @@ export interface ResolveTrackState {
selectedAudioTrackId?: string;
selectedTextTrackId?: string;
failedCdns?: string[];
/**
* Per-type live-reload triggers, owned by `scheduleTrackReload`. A bump
* (monotonic increment) past the last-serviced value tells the loader a
* reload is due. Absent / unchanged for VoD (no scheduler composed).
*/
videoReloadEpoch?: number;
audioReloadEpoch?: number;
textReloadEpoch?: number;
}
type SelectedTrackKey = 'selectedVideoTrackId' | 'selectedAudioTrackId' | 'selectedTextTrackId';
type ReloadEpochKey = 'videoReloadEpoch' | 'audioReloadEpoch' | 'textReloadEpoch';
type ResolveTrackStateMap<K extends SelectedTrackKey> = {
type ResolveTrackStateMap<K extends SelectedTrackKey, E extends ReloadEpochKey> = {
presentation: Signal<ResolveTrackState['presentation']>;
} & { [P in K]: ReadonlySignal<ResolveTrackState[P]> };
} & { [P in K]: ReadonlySignal<ResolveTrackState[P]> } & { [P in E]: ReadonlySignal<ResolveTrackState[P]> };
interface TrackResolutionConfig<K extends SelectedTrackKey> {
interface TrackResolutionConfig<K extends SelectedTrackKey, E extends ReloadEpochKey> {
selectedKey: K;
/** State slot the scheduler bumps to request a live reload of this type. */
reloadEpochKey: E;
findTrackToResolve: (
presentation: MaybeResolvedPresentation,
trackId: string
@@ -59,18 +82,25 @@ interface ResolveTrackConfig {
getCdnId?: GetCdnId;
}
function setupTrackResolution<K extends SelectedTrackKey>({
function setupTrackResolution<K extends SelectedTrackKey, E extends ReloadEpochKey>({
state,
config: { selectedKey, findTrackToResolve, fetchResolvableText = defaultFetchResolvableText },
config: { selectedKey, reloadEpochKey, findTrackToResolve, fetchResolvableText = defaultFetchResolvableText },
}: {
state: ResolveTrackStateMap<K>;
config: TrackResolutionConfig<K>;
state: ResolveTrackStateMap<K, E>;
config: TrackResolutionConfig<K, E>;
}) {
// NOTE: This can/maybe will be pulled into a per-use case factory (e.g. something like createTaskRunner() with args TBD),
// likely eventually passed down via config or a new "definitions" argument. This will allow us to decide if we want our task runner/scheduler
// to e.g. run concurrently (like we currently are), serially with a queue, or abort the previous task and replace it with the newly scheduled one. (CJP).
const runner = new ConcurrentRunner();
// Highest reload epoch already serviced for the selected track. A resolved
// track only re-fetches once the scheduler bumps past this; an unresolved
// track always loads (the initial resolve / a retry of a failed one). Init 0
// so a freshly-mounted already-resolved track isn't re-fetched (matches the
// pre-reload one-shot resolve behavior).
let lastLoadedEpoch = 0;
// Reactor states model the FSM the previous effect-based body was
// hand-rolling. 'presentation-resolved' is entered when the
// presentation is fully parsed (has a Ham id + selectionSets); leaving
@@ -105,13 +135,23 @@ function setupTrackResolution<K extends SelectedTrackKey>({
// changes (presentation-resolved ↔ presentation-unresolved);
// within 'presentation-resolved' we peek (untracked read) so
// internal updates (segments added by sibling tasks) don't
// re-fire the effect.
const presentation = peek(state.presentation);
// re-fire the effect. Tracked reads — `selectedKey` and the reload
// epoch — are taken up front, before any early return, so a later
// epoch bump re-fires this effect.
const trackId = state[selectedKey].get();
const epoch = state[reloadEpochKey].get() ?? 0;
const presentation = peek(state.presentation);
if (!presentation || !trackId) return;
const track = findTrackToResolve(presentation, trackId);
if (!track || isResolvedTrack(track)) return;
if (!track) return;
// Resolved track: skip unless the scheduler requested a reload.
// Unresolved track: always load (initial resolve / failed-resolve retry).
if (isResolvedTrack(track) && epoch <= lastLoadedEpoch) return;
// Mark this epoch serviced synchronously: a same-id task already in
// flight is deduped by the runner, and we don't want to re-schedule
// it on the next effect run.
lastLoadedEpoch = epoch;
runner.schedule(
// NOTE: This can/maybe will be pulled into a per-use case factory (e.g. something like createResolveTrackTask(track, context, config)),
@@ -122,6 +162,9 @@ function setupTrackResolution<K extends SelectedTrackKey>({
// fetch: it trips the CDN on a failed fetch (network error or
// non-OK status). A parse failure is a content issue, not a
// CDN-availability one, so it doesn't trip.
// `track` is the prior snapshot (the unresolved shell on the
// first pass, the last resolved window on a live reload); the
// parser carries its timeline forward.
const text = await fetchResolvableText(track, { signal });
const mediaTrack = parseMediaPlaylist(text, track);
@@ -143,9 +186,12 @@ function setupTrackResolution<K extends SelectedTrackKey>({
// audio↔video (mixed-container sources exist, e.g. muxed-TS
// video + raw-.aac audio), which also keeps per-type
// resolutions' writes disjoint (no race).
return NON_FMP4_CONTAINER_MIMES.has(mediaTrack.mimeType)
const relabeled = NON_FMP4_CONTAINER_MIMES.has(mediaTrack.mimeType)
? applyContainerMimeType(patched, mediaTrack.type, mediaTrack.mimeType)
: patched;
// Stream nature (category [2a]) — stable once a media
// playlist is parsed; recomputing each reload is harmless.
return { ...relabeled, streamType: deriveStreamType(getMediaPlaylistMetadata(mediaTrack)) };
});
},
{ id: track.id }
@@ -164,18 +210,21 @@ function setupTrackResolution<K extends SelectedTrackKey>({
const VIDEO_TRACK_RESOLUTION_CONFIG = {
...VIDEO_TYPE_CONFIG,
reloadEpochKey: 'videoReloadEpoch',
findTrackToResolve: (presentation: MaybeResolvedPresentation, trackId: string) =>
findTrack(presentation, 'video', trackId),
} as const;
const AUDIO_TRACK_RESOLUTION_CONFIG = {
...AUDIO_TYPE_CONFIG,
reloadEpochKey: 'audioReloadEpoch',
findTrackToResolve: (presentation: MaybeResolvedPresentation, trackId: string) =>
findTrack(presentation, 'audio', trackId),
} as const;
const TEXT_TRACK_RESOLUTION_CONFIG = {
...TEXT_TYPE_CONFIG,
reloadEpochKey: 'textReloadEpoch',
findTrackToResolve: (presentation: MaybeResolvedPresentation, trackId: string) =>
findTrack(presentation, 'text', trackId),
} as const;
@@ -190,13 +239,13 @@ const TEXT_TRACK_RESOLUTION_CONFIG = {
* writes the resolved track back into `state.presentation`.
*/
export const resolveVideoTrack = defineBehavior({
stateKeys: ['presentation', 'selectedVideoTrackId'],
stateKeys: ['presentation', 'selectedVideoTrackId', 'videoReloadEpoch'],
contextKeys: [],
setup: ({
state,
config = {},
}: {
state: ResolveTrackStateMap<'selectedVideoTrackId'>;
state: ResolveTrackStateMap<'selectedVideoTrackId', 'videoReloadEpoch'>;
config?: ResolveTrackConfig;
}) => {
// Engine `config` layers over the per-type defaults (mirrors the other
@@ -217,13 +266,13 @@ export const resolveVideoTrack = defineBehavior({
* narrowed to audio.
*/
export const resolveAudioTrack = defineBehavior({
stateKeys: ['presentation', 'selectedAudioTrackId'],
stateKeys: ['presentation', 'selectedAudioTrackId', 'audioReloadEpoch'],
contextKeys: [],
setup: ({
state,
config = {},
}: {
state: ResolveTrackStateMap<'selectedAudioTrackId'>;
state: ResolveTrackStateMap<'selectedAudioTrackId', 'audioReloadEpoch'>;
config?: ResolveTrackConfig;
}) => {
// Key order is load-bearing — see resolveVideoTrack.
@@ -240,13 +289,13 @@ export const resolveAudioTrack = defineBehavior({
* narrowed to text.
*/
export const resolveTextTrack = defineBehavior({
stateKeys: ['presentation', 'selectedTextTrackId'],
stateKeys: ['presentation', 'selectedTextTrackId', 'textReloadEpoch'],
contextKeys: [],
setup: ({
state,
config = {},
}: {
state: ResolveTrackStateMap<'selectedTextTrackId'>;
state: ResolveTrackStateMap<'selectedTextTrackId', 'textReloadEpoch'>;
config?: ResolveTrackConfig;
}) => {
// Key order is load-bearing — see resolveVideoTrack.
@@ -0,0 +1,198 @@
/**
* Live media-playlist reload *scheduling*, per track type.
*
* Category [3] "refetch policy" from
* [live-presentation-modeling.md](../../../internal/design/spf/live-presentation-modeling.md):
* decides *when* a track's media playlist should be re-fetched, without doing
* any fetching itself. Once the presentation is resolved and a track of this
* type is selected, it bumps a per-type reload-epoch slot on a target-duration
* cadence (half that when the last reload was unchanged, per RFC 8216bis
* §6.3.4); the sibling `resolveTrack` loader (category [1]) watches that slot
* and performs the actual fetch+parse+merge.
*
* The split keeps "when to refetch" and "what segments are in the playlist"
* — categories that change at different rates — in separate behaviors.
*
* Inert for VoD: it stays `idle` once the resolved track reports
* `#EXT-X-ENDLIST` (a complete playlist never reloads), so no scheduler-aware
* engine config is needed. It enters on track *selection* (not resolution) so
* its bumps also drive retries of a failed/slow first resolve until the loader
* succeeds.
*
* Specialized per type via the shared `setupTrackReloadSchedule` (mirrors
* `resolve-track`): `scheduleVideoTrackReload` / `scheduleAudioTrackReload` /
* `scheduleTextTrackReload`, each gating on its own `selected*TrackId` + track
* type, so demuxed audio and video reload independently.
*
* Limitations (intentional, for now): selection is read once at loop start
* (mid-stream track switching isn't encoded in the reactor's two states).
*/
import { defineBehavior } from '../../core/composition/create-composition';
import { createMachineReactor } from '../../core/reactors/create-machine-reactor';
import { computed, peek, type ReadonlySignal, type Signal, update } from '../../core/signals/primitives';
import {
getMediaPlaylistMetadata,
isResolvedPresentation,
isResolvedTrack,
type MaybeResolvedPresentation,
type ResolvedTrack,
type TrackType,
} from '../../media/types';
import { findTrack } from '../../media/utils/tracks';
import { AUDIO_TYPE_CONFIG, TEXT_TYPE_CONFIG, VIDEO_TYPE_CONFIG } from '../primitives/track-types';
export interface ScheduleTrackReloadState {
presentation?: MaybeResolvedPresentation;
selectedVideoTrackId?: string;
selectedAudioTrackId?: string;
selectedTextTrackId?: string;
videoReloadEpoch?: number;
audioReloadEpoch?: number;
textReloadEpoch?: number;
}
type SelectedTrackKey = 'selectedVideoTrackId' | 'selectedAudioTrackId' | 'selectedTextTrackId';
type ReloadEpochKey = 'videoReloadEpoch' | 'audioReloadEpoch' | 'textReloadEpoch';
type ScheduleStateName = 'idle' | 'scheduling';
type ScheduleTrackReloadStateMap<K extends SelectedTrackKey, E extends ReloadEpochKey> = {
presentation: ReadonlySignal<ScheduleTrackReloadState['presentation']>;
} & { [P in K]: ReadonlySignal<ScheduleTrackReloadState[P]> } & { [P in E]: Signal<ScheduleTrackReloadState[P]> };
interface TrackReloadScheduleConfig<K extends SelectedTrackKey, E extends ReloadEpochKey> {
type: TrackType;
selectedKey: K;
reloadEpochKey: E;
}
/** Fallback reload cadence when the playlist carries no usable target duration. */
const FALLBACK_TARGET_DURATION = 6;
function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(new DOMException('Aborted', 'AbortError'));
return;
}
const timer = setTimeout(resolve, ms);
signal.addEventListener(
'abort',
() => {
clearTimeout(timer);
reject(new DOMException('Aborted', 'AbortError'));
},
{ once: true }
);
});
}
/** Identity of a reload snapshot — window position + length. Changes when the window slid or grew. */
function snapshotSignature(track: ResolvedTrack): string {
return `${getMediaPlaylistMetadata(track)?.mediaSequence ?? 0}:${track.segments.length}`;
}
function setupTrackReloadSchedule<K extends SelectedTrackKey, E extends ReloadEpochKey>({
state,
config: { type, selectedKey, reloadEpochKey },
}: {
state: ScheduleTrackReloadStateMap<K, E>;
config: TrackReloadScheduleConfig<K, E>;
}) {
const derivedStateSignal = computed<ScheduleStateName>(() => {
const presentation = state.presentation.get();
const trackId = state[selectedKey].get();
if (!isResolvedPresentation(presentation) || !trackId) return 'idle';
const track = findTrack(presentation, type, trackId);
if (!track) return 'idle';
// A complete playlist (VoD, or live that has ended) never reloads. An
// unresolved track keeps us scheduling so the loader's first resolve is
// retried via the epoch bumps.
if (isResolvedTrack(track) && getMediaPlaylistMetadata(track)?.endList) return 'idle';
return 'scheduling';
});
return createMachineReactor<ScheduleStateName>({
initial: 'idle',
monitor: () => derivedStateSignal.get(),
states: {
idle: {},
scheduling: {
entry: () => {
const ac = new AbortController();
const trackId = state[selectedKey].get()!;
void (async () => {
// Signature of the snapshot seen at the previous iteration, to
// detect whether the last reload changed the window.
let lastSignature: string | null = null;
while (!ac.signal.aborted) {
const presentation = peek(state.presentation);
const track = isResolvedPresentation(presentation) ? findTrack(presentation, type, trackId) : undefined;
const meta = track && isResolvedTrack(track) ? getMediaPlaylistMetadata(track) : undefined;
if (meta?.endList) break;
const signature = track && isResolvedTrack(track) ? snapshotSignature(track) : null;
// First pass (no baseline) or a moved window counts as changed →
// full cadence; an unchanged window polls at half cadence.
const changed = signature === null || signature !== lastSignature;
lastSignature = signature;
const target = meta?.targetDuration || FALLBACK_TARGET_DURATION;
try {
await sleep((changed ? target : target / 2) * 1000, ac.signal);
} catch {
return; // aborted during the wait
}
update(state[reloadEpochKey], (epoch) => (epoch ?? 0) + 1);
}
})();
// State-exit (source change / destroy / endList) aborts the loop.
return () => ac.abort();
},
},
},
});
}
const VIDEO_RELOAD_SCHEDULE_CONFIG = {
type: VIDEO_TYPE_CONFIG.type,
selectedKey: VIDEO_TYPE_CONFIG.selectedKey,
reloadEpochKey: 'videoReloadEpoch',
} as const;
const AUDIO_RELOAD_SCHEDULE_CONFIG = {
type: AUDIO_TYPE_CONFIG.type,
selectedKey: AUDIO_TYPE_CONFIG.selectedKey,
reloadEpochKey: 'audioReloadEpoch',
} as const;
const TEXT_RELOAD_SCHEDULE_CONFIG = {
type: TEXT_TYPE_CONFIG.type,
selectedKey: TEXT_TYPE_CONFIG.selectedKey,
reloadEpochKey: 'textReloadEpoch',
} as const;
/** Schedule live reloads of the selected video track's media playlist. */
export const scheduleVideoTrackReload = defineBehavior({
stateKeys: ['presentation', 'selectedVideoTrackId', 'videoReloadEpoch'],
contextKeys: [],
setup: ({ state }: { state: ScheduleTrackReloadStateMap<'selectedVideoTrackId', 'videoReloadEpoch'> }) =>
setupTrackReloadSchedule({ state, config: VIDEO_RELOAD_SCHEDULE_CONFIG }),
});
/** Schedule live reloads of the selected audio track's media playlist (demuxed audio). */
export const scheduleAudioTrackReload = defineBehavior({
stateKeys: ['presentation', 'selectedAudioTrackId', 'audioReloadEpoch'],
contextKeys: [],
setup: ({ state }: { state: ScheduleTrackReloadStateMap<'selectedAudioTrackId', 'audioReloadEpoch'> }) =>
setupTrackReloadSchedule({ state, config: AUDIO_RELOAD_SCHEDULE_CONFIG }),
});
/** Schedule live reloads of the selected text track's media playlist (live captions). */
export const scheduleTextTrackReload = defineBehavior({
stateKeys: ['presentation', 'selectedTextTrackId', 'textReloadEpoch'],
contextKeys: [],
setup: ({ state }: { state: ScheduleTrackReloadStateMap<'selectedTextTrackId', 'textReloadEpoch'> }) =>
setupTrackReloadSchedule({ state, config: TEXT_RELOAD_SCHEDULE_CONFIG }),
});
@@ -1,158 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { signal } from '../../../core/signals/primitives';
import {
isResolvedTrack,
type MaybeResolvedPresentation,
type PartiallyResolvedAudioTrack,
type PartiallyResolvedVideoTrack,
type Presentation,
} from '../../../media/types';
import { findTrack } from '../../../media/utils/tracks';
import { reloadAudioTrack, reloadVideoTrack } from '../reload-track';
afterEach(() => {
vi.restoreAllMocks();
});
const MEDIA_PLAYLIST = `#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-MAP:URI="init.mp4"
#EXTINF:4.0,
seg0.m4s
#EXT-X-ENDLIST`;
const unresolvedVideo: PartiallyResolvedVideoTrack = {
type: 'video',
id: 'v-1',
url: 'https://example.com/video.m3u8',
bandwidth: 1_000_000,
mimeType: 'video/mp4',
codecs: [],
};
const unresolvedAudio: PartiallyResolvedAudioTrack = {
type: 'audio',
id: 'a-1',
url: 'https://example.com/audio.m3u8',
groupId: 'aud',
name: 'Default',
language: 'und',
codecs: ['mp4a.40.2'],
mimeType: 'audio/mp4',
bandwidth: 0,
sampleRate: 48000,
channels: 2,
};
function makePresentation(): Presentation {
return {
id: 'pres-1',
url: 'https://example.com/master.m3u8',
startTime: 0,
selectionSets: [
{ id: 'video-set', type: 'video', switchingSets: [{ id: 'vs', type: 'video', tracks: [unresolvedVideo] }] },
{ id: 'audio-set', type: 'audio', switchingSets: [{ id: 'as', type: 'audio', tracks: [unresolvedAudio] }] },
],
};
}
const fakeFetch = () =>
vi.fn((_addressable: { url: string }, _options?: { signal?: AbortSignal }) => Promise.resolve(MEDIA_PLAYLIST));
describe('reloadVideoTrack', () => {
it('resolves the selected video track and fetches *its* playlist (not audio)', async () => {
const presentation = makePresentation();
const state = {
presentation: signal<MaybeResolvedPresentation | undefined>(presentation),
selectedVideoTrackId: signal<string | undefined>('v-1'),
};
const fetchResolvableText = fakeFetch();
const reactor = reloadVideoTrack.setup({ state, config: { fetchResolvableText } });
await vi.waitFor(() => {
const track = findTrack(state.presentation.get()!, 'video', 'v-1');
expect(track && isResolvedTrack(track)).toBe(true);
});
// Fetched the video playlist; the audio track is left untouched.
expect(fetchResolvableText.mock.calls[0]?.[0]?.url).toContain('video.m3u8');
const audio = findTrack(state.presentation.get()!, 'audio', 'a-1');
expect(audio && isResolvedTrack(audio)).toBe(false);
reactor.destroy();
});
it('stays idle with no selected track', () => {
const fetchResolvableText = fakeFetch();
const state = {
presentation: signal<MaybeResolvedPresentation | undefined>(makePresentation()),
selectedVideoTrackId: signal<string | undefined>(undefined),
};
const reactor = reloadVideoTrack.setup({ state, config: { fetchResolvableText } });
expect(fetchResolvableText).not.toHaveBeenCalled();
reactor.destroy();
});
});
describe('reloadAudioTrack', () => {
it('resolves the selected audio track and fetches *its* playlist (not video)', async () => {
const presentation = makePresentation();
const state = {
presentation: signal<MaybeResolvedPresentation | undefined>(presentation),
selectedAudioTrackId: signal<string | undefined>('a-1'),
};
const fetchResolvableText = fakeFetch();
const reactor = reloadAudioTrack.setup({ state, config: { fetchResolvableText } });
await vi.waitFor(() => {
const track = findTrack(state.presentation.get()!, 'audio', 'a-1');
expect(track && isResolvedTrack(track)).toBe(true);
});
expect(fetchResolvableText.mock.calls[0]?.[0]?.url).toContain('audio.m3u8');
const video = findTrack(state.presentation.get()!, 'video', 'v-1');
expect(video && isResolvedTrack(video)).toBe(false);
reactor.destroy();
});
});
describe('reload resilience', () => {
it('survives a transient fetch failure and retries (does not kill the loop)', async () => {
vi.useFakeTimers();
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
const state = {
presentation: signal<MaybeResolvedPresentation | undefined>(makePresentation()),
selectedVideoTrackId: signal<string | undefined>('v-1'),
};
let calls = 0;
const fetchResolvableText = vi.fn(() => {
calls += 1;
return calls === 1 ? Promise.reject(new TypeError('Failed to fetch')) : Promise.resolve(MEDIA_PLAYLIST);
});
const reactor = reloadVideoTrack.setup({ state, config: { fetchResolvableText } });
// First attempt rejects: logged, but the loop is still alive (track not yet resolved).
await vi.advanceTimersByTimeAsync(0);
expect(calls).toBe(1);
expect(errorSpy).toHaveBeenCalled();
expect(isResolvedTrack(findTrack(state.presentation.get()!, 'video', 'v-1')!)).toBe(false);
// Retry cadence elapses → second attempt succeeds (would never happen if the
// loop had died on the first failure).
await vi.advanceTimersByTimeAsync(6000);
expect(calls).toBe(2);
expect(isResolvedTrack(findTrack(state.presentation.get()!, 'video', 'v-1')!)).toBe(true);
reactor.destroy();
} finally {
vi.useRealTimers();
}
});
});
@@ -22,6 +22,9 @@ function makeState(initial: ResolveTrackState = {}): StateSignals<ResolveTrackSt
selectedAudioTrackId: signal<string | undefined>(initial.selectedAudioTrackId),
selectedTextTrackId: signal<string | undefined>(initial.selectedTextTrackId),
failedCdns: signal<string[] | undefined>(initial.failedCdns),
videoReloadEpoch: signal<number | undefined>(initial.videoReloadEpoch),
audioReloadEpoch: signal<number | undefined>(initial.audioReloadEpoch),
textReloadEpoch: signal<number | undefined>(initial.textReloadEpoch),
};
}
@@ -411,6 +414,94 @@ http://example.com/a-seg1.m4s
});
});
describe('resolveVideoTrack — live reload', () => {
const LIVE_PLAYLIST = `#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-MAP:URI="http://example.com/init.mp4"
#EXTINF:4.0,
http://example.com/seg0.m4s`;
function liveVideoPresentation(): Presentation {
const unresolved: PartiallyResolvedVideoTrack = {
type: 'video',
id: 'track-1',
url: 'http://example.com/variant1.m3u8',
bandwidth: 1_000_000,
mimeType: 'video/mp4',
codecs: [],
};
return {
id: 'pres-1',
url: 'http://example.com/playlist.m3u8',
selectionSets: [
{ id: 'video-set', type: 'video', switchingSets: [{ id: 'sw-1', type: 'video', tracks: [unresolved] }] },
],
startTime: 0,
};
}
it('re-fetches when the reload epoch is bumped', async () => {
const state = makeState({ presentation: liveVideoPresentation(), selectedVideoTrackId: 'track-1' });
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response(LIVE_PLAYLIST));
const reactor = resolveVideoTrack.setup({ state });
await vi.waitFor(() => expect(isResolvedTrack(findTrackById(state.presentation.get()!, 'track-1')!)).toBe(true));
expect(fetchSpy).toHaveBeenCalledTimes(1);
// A scheduler bump re-fetches the (already-resolved) track.
state.videoReloadEpoch.set(1);
await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(2));
// A stale/duplicate bump (≤ last serviced) does not.
state.videoReloadEpoch.set(1);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(fetchSpy).toHaveBeenCalledTimes(2);
reactor.destroy();
});
it('retries an unresolved track on epoch bump after a transient failure', async () => {
const state = makeState({ presentation: liveVideoPresentation(), selectedVideoTrackId: 'track-1' });
let calls = 0;
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
calls += 1;
if (calls === 1) throw new TypeError('Failed to fetch');
return new Response(LIVE_PLAYLIST);
});
const reactor = resolveVideoTrack.setup({ state });
// First attempt fails and settles: track stays unresolved. (Waiting for
// the failed fetch to settle mirrors the scheduler's cadence delay — a bump
// arriving while the task is still in flight would be id-deduped.)
await vi.waitFor(() => expect(calls).toBe(1));
await new Promise((resolve) => setTimeout(resolve, 20));
expect(isResolvedTrack(findTrackById(state.presentation.get()!, 'track-1')!)).toBe(false);
// The scheduler's next bump retries the unresolved track → resolves.
state.videoReloadEpoch.set(1);
await vi.waitFor(() => expect(isResolvedTrack(findTrackById(state.presentation.get()!, 'track-1')!)).toBe(true));
expect(calls).toBe(2);
reactor.destroy();
});
it('sets presentation.streamType from the parsed playlist', async () => {
const state = makeState({ presentation: liveVideoPresentation(), selectedVideoTrackId: 'track-1' });
// No EXT-X-PLAYLIST-TYPE:VOD → live.
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response(LIVE_PLAYLIST));
const reactor = resolveVideoTrack.setup({ state });
await vi.waitFor(() => expect(state.presentation.get()?.streamType).toBe('live'));
reactor.destroy();
});
});
// Helper to find track by ID in presentation
function findTrackById(
presentation: MaybeResolvedPresentation,
@@ -0,0 +1,137 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { signal } from '../../../core/signals/primitives';
import {
type MaybeResolvedPresentation,
MEDIA_PLAYLIST_METADATA_KEY,
type PartiallyResolvedVideoTrack,
type Presentation,
type VideoTrack,
} from '../../../media/types';
import { scheduleVideoTrackReload } from '../schedule-track-reload';
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
const UNRESOLVED_VIDEO: PartiallyResolvedVideoTrack = {
type: 'video',
id: 'v-1',
url: 'https://example.com/video.m3u8',
bandwidth: 1_000_000,
mimeType: 'video/mp4',
codecs: [],
};
function resolvedVideo(opts: { endList?: boolean; segmentCount?: number; mediaSequence?: number } = {}): VideoTrack {
const { endList = false, segmentCount = 3, mediaSequence = 0 } = opts;
return {
type: 'video',
id: 'v-1',
url: 'https://example.com/video.m3u8',
mimeType: 'video/mp4',
codecs: ['avc1.640020'],
bandwidth: 1_000_000,
initialization: { url: 'https://example.com/init.mp4' },
duration: endList ? 12 : Number.POSITIVE_INFINITY,
startTime: 0,
segments: Array.from({ length: segmentCount }, (_, i) => ({
id: `segment-${mediaSequence + i}`,
url: `${mediaSequence + i}.m4s`,
duration: 4,
startTime: i * 4,
})),
metadata: { [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence, targetDuration: 4, endList } },
};
}
function presentationWith(track: VideoTrack | PartiallyResolvedVideoTrack): Presentation {
return {
id: 'pres-1',
url: 'https://example.com/master.m3u8',
startTime: 0,
selectionSets: [{ id: 'video-set', type: 'video', switchingSets: [{ id: 'sw', type: 'video', tracks: [track] }] }],
};
}
function makeState(presentation: MaybeResolvedPresentation, trackId: string | undefined = 'v-1') {
return {
presentation: signal<MaybeResolvedPresentation | undefined>(presentation),
selectedVideoTrackId: signal<string | undefined>(trackId),
videoReloadEpoch: signal<number | undefined>(undefined),
};
}
describe('scheduleVideoTrackReload', () => {
it('bumps the reload epoch on the target-duration cadence', async () => {
vi.useFakeTimers();
const state = makeState(presentationWith(resolvedVideo()));
const reactor = scheduleVideoTrackReload.setup({ state });
expect(state.videoReloadEpoch.get()).toBeUndefined();
await vi.advanceTimersByTimeAsync(4000); // one TARGETDURATION
expect(state.videoReloadEpoch.get()).toBe(1);
reactor.destroy();
});
it('polls at half cadence when the window is unchanged', async () => {
vi.useFakeTimers();
const state = makeState(presentationWith(resolvedVideo()));
const reactor = scheduleVideoTrackReload.setup({ state });
// First reload after a full TARGETDURATION (4s).
await vi.advanceTimersByTimeAsync(4000);
expect(state.videoReloadEpoch.get()).toBe(1);
// Snapshot unchanged (presentation not updated) → next poll at half (2s).
await vi.advanceTimersByTimeAsync(1999);
expect(state.videoReloadEpoch.get()).toBe(1);
await vi.advanceTimersByTimeAsync(1);
expect(state.videoReloadEpoch.get()).toBe(2);
reactor.destroy();
});
it('stays idle for a complete (endList) playlist', async () => {
vi.useFakeTimers();
const state = makeState(presentationWith(resolvedVideo({ endList: true })));
const reactor = scheduleVideoTrackReload.setup({ state });
await vi.advanceTimersByTimeAsync(60_000);
expect(state.videoReloadEpoch.get()).toBeUndefined();
reactor.destroy();
});
it('keeps bumping while the track is unresolved (drives first-resolve retries)', async () => {
vi.useFakeTimers();
const state = makeState(presentationWith(UNRESOLVED_VIDEO));
const reactor = scheduleVideoTrackReload.setup({ state });
// No targetDuration available yet → fallback cadence (6s).
await vi.advanceTimersByTimeAsync(6000);
expect(state.videoReloadEpoch.get()).toBe(1);
await vi.advanceTimersByTimeAsync(6000);
expect(state.videoReloadEpoch.get()).toBe(2);
reactor.destroy();
});
it('stays idle with no selected track', async () => {
vi.useFakeTimers();
const state = makeState(presentationWith(resolvedVideo()));
state.selectedVideoTrackId.set(undefined);
const reactor = scheduleVideoTrackReload.setup({ state });
await vi.advanceTimersByTimeAsync(60_000);
expect(state.videoReloadEpoch.get()).toBeUndefined();
reactor.destroy();
});
});
@@ -103,6 +103,15 @@ export interface SimpleHlsEngineState {
failedCdns?: string[];
currentTime?: number;
loadActivated?: boolean;
/**
* Per-type live-reload triggers. Owned by `scheduleTrackReload` (composed
* only by live engines), which bumps them on a target-duration cadence;
* `resolveTrack` watches them to re-fetch the media playlist. Inert for VoD
* (no scheduler never bumped loader resolves once).
*/
videoReloadEpoch?: number;
audioReloadEpoch?: number;
textReloadEpoch?: number;
}
/**
@@ -26,8 +26,9 @@ import { setupMediaSource } from '../../behaviors/dom/setup-mediasource';
import { trackCurrentTime } from '../../behaviors/dom/track-current-time';
import { trackLoadTriggers } from '../../behaviors/dom/track-load-triggers';
import { updateMediaSourceDuration } from '../../behaviors/dom/update-mediasource-duration';
import { reloadAudioTrack, reloadVideoTrack } from '../../behaviors/reload-track';
import { resolvePresentation } from '../../behaviors/resolve-presentation';
import { resolveAudioTrack, resolveVideoTrack } from '../../behaviors/resolve-track';
import { scheduleAudioTrackReload, scheduleVideoTrackReload } from '../../behaviors/schedule-track-reload';
import { setupFailoverMonitor } from '../../behaviors/setup-failover-monitor';
import { syncPreload } from '../../behaviors/sync-preload';
import { switchAudioTrack, switchVideoTrack } from '../../behaviors/track-switching';
@@ -84,11 +85,15 @@ export function createLiveHlsEngine(
deriveCdnPriority,
setupFailoverMonitor,
// Live reload loop replaces one-shot resolve*: the first iteration
// resolves the selected track, then it re-fetches on a target-duration
// cadence, carrying the timeline forward.
reloadVideoTrack,
reloadAudioTrack,
// Loader (category [1]): resolves the selected track and re-fetches it
// whenever the scheduler bumps its reload epoch, carrying the timeline
// forward.
resolveVideoTrack,
resolveAudioTrack,
// Scheduler (category [3]): bumps the per-type reload epoch on a
// target-duration cadence until #EXT-X-ENDLIST.
scheduleVideoTrackReload,
scheduleAudioTrackReload,
// Anchor selected tracks' timelines to the estimated stream origin so
// segment.startTime ≈ native PTS (what the loader matches currentTime
@@ -22,13 +22,15 @@ import { makeShareSignals, type ShareSignalsConfig } from '../../../core/composi
import { parseMultivariantPlaylist } from '../../../media/hls/parse-multivariant';
import { pickHighestResolutionVideoTrack, type TrackPicker } from '../../../media/primitives/select-tracks';
import type { MaybeResolvedPresentation } from '../../../media/types';
import { reloadVideoTrack } from '../../behaviors/reload-track';
import { type ParsePresentation, resolvePresentation } from '../../behaviors/resolve-presentation';
import { resolveVideoTrack } from '../../behaviors/resolve-track';
import { scheduleVideoTrackReload } from '../../behaviors/schedule-track-reload';
import { type SelectVideoTrackConfig, selectVideoTrack } from '../../behaviors/select-tracks';
export interface LivePlaylistSpikeState {
presentation?: MaybeResolvedPresentation;
selectedVideoTrackId?: string;
videoReloadEpoch?: number;
preload?: 'auto' | 'metadata' | 'none';
loadActivated?: boolean;
}
@@ -58,9 +60,12 @@ export function createLivePlaylistSpikeEngine(
parsePresentation: config.parsePresentation ?? parseMultivariantPlaylist,
};
return createComposition([resolvePresentation, selectVideoTrack, reloadVideoTrack, shareSignals], {
config: finalConfig,
// Spike skips the preload gate — resolve as soon as a url is set.
initialState: { loadActivated: true },
});
return createComposition(
[resolvePresentation, selectVideoTrack, resolveVideoTrack, scheduleVideoTrackReload, shareSignals],
{
config: finalConfig,
// Spike skips the preload gate — resolve as soon as a url is set.
initialState: { loadActivated: true },
}
);
}