diff --git a/packages/spf/src/playback/behaviors/dom/tests/update-mediasource-duration.test.ts b/packages/spf/src/playback/behaviors/dom/tests/update-mediasource-duration.test.ts index 15c52fbd..9ffa4f2d 100644 --- a/packages/spf/src/playback/behaviors/dom/tests/update-mediasource-duration.test.ts +++ b/packages/spf/src/playback/behaviors/dom/tests/update-mediasource-duration.test.ts @@ -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(); diff --git a/packages/spf/src/playback/behaviors/dom/update-mediasource-duration.ts b/packages/spf/src/playback/behaviors/dom/update-mediasource-duration.ts index 70c34e41..73353b30 100644 --- a/packages/spf/src/playback/behaviors/dom/update-mediasource-duration.ts +++ b/packages/spf/src/playback/behaviors/dom/update-mediasource-duration.ts @@ -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 diff --git a/packages/spf/src/playback/behaviors/reload-track.ts b/packages/spf/src/playback/behaviors/reload-track.ts deleted file mode 100644 index 772f8f38..00000000 --- a/packages/spf/src/playback/behaviors/reload-track.ts +++ /dev/null @@ -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 = { - presentation: Signal; -} & { [P in K]: ReadonlySignal }; - -interface TrackReloadConfig { - 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 { - 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({ - state, - config: { type, selectedKey, fetchResolvableText = defaultFetchResolvableText }, -}: { - state: ReloadTrackStateMap; - config: TrackReloadConfig; -}) { - const derivedStateSignal = computed(() => { - 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({ - 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 } }), -}); diff --git a/packages/spf/src/playback/behaviors/resolve-track.ts b/packages/spf/src/playback/behaviors/resolve-track.ts index fbcb68ff..091fc9cc 100644 --- a/packages/spf/src/playback/behaviors/resolve-track.ts +++ b/packages/spf/src/playback/behaviors/resolve-track.ts @@ -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 = { +type ResolveTrackStateMap = { presentation: Signal; -} & { [P in K]: ReadonlySignal }; +} & { [P in K]: ReadonlySignal } & { [P in E]: ReadonlySignal }; -interface TrackResolutionConfig { +interface TrackResolutionConfig { 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({ +function setupTrackResolution({ state, - config: { selectedKey, findTrackToResolve, fetchResolvableText = defaultFetchResolvableText }, + config: { selectedKey, reloadEpochKey, findTrackToResolve, fetchResolvableText = defaultFetchResolvableText }, }: { - state: ResolveTrackStateMap; - config: TrackResolutionConfig; + state: ResolveTrackStateMap; + config: TrackResolutionConfig; }) { // 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({ // 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({ // 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({ // 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({ 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. diff --git a/packages/spf/src/playback/behaviors/schedule-track-reload.ts b/packages/spf/src/playback/behaviors/schedule-track-reload.ts new file mode 100644 index 00000000..03ce0056 --- /dev/null +++ b/packages/spf/src/playback/behaviors/schedule-track-reload.ts @@ -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 = { + presentation: ReadonlySignal; +} & { [P in K]: ReadonlySignal } & { [P in E]: Signal }; + +interface TrackReloadScheduleConfig { + 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 { + 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({ + state, + config: { type, selectedKey, reloadEpochKey }, +}: { + state: ScheduleTrackReloadStateMap; + config: TrackReloadScheduleConfig; +}) { + const derivedStateSignal = computed(() => { + 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({ + 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 }), +}); diff --git a/packages/spf/src/playback/behaviors/tests/reload-track.test.ts b/packages/spf/src/playback/behaviors/tests/reload-track.test.ts deleted file mode 100644 index 51a6694c..00000000 --- a/packages/spf/src/playback/behaviors/tests/reload-track.test.ts +++ /dev/null @@ -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(presentation), - selectedVideoTrackId: signal('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(makePresentation()), - selectedVideoTrackId: signal(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(presentation), - selectedAudioTrackId: signal('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(makePresentation()), - selectedVideoTrackId: signal('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(); - } - }); -}); diff --git a/packages/spf/src/playback/behaviors/tests/resolve-track.test.ts b/packages/spf/src/playback/behaviors/tests/resolve-track.test.ts index b1b500af..81658b7d 100644 --- a/packages/spf/src/playback/behaviors/tests/resolve-track.test.ts +++ b/packages/spf/src/playback/behaviors/tests/resolve-track.test.ts @@ -22,6 +22,9 @@ function makeState(initial: ResolveTrackState = {}): StateSignals(initial.selectedAudioTrackId), selectedTextTrackId: signal(initial.selectedTextTrackId), failedCdns: signal(initial.failedCdns), + videoReloadEpoch: signal(initial.videoReloadEpoch), + audioReloadEpoch: signal(initial.audioReloadEpoch), + textReloadEpoch: signal(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, diff --git a/packages/spf/src/playback/behaviors/tests/schedule-track-reload.test.ts b/packages/spf/src/playback/behaviors/tests/schedule-track-reload.test.ts new file mode 100644 index 00000000..51c7ec1c --- /dev/null +++ b/packages/spf/src/playback/behaviors/tests/schedule-track-reload.test.ts @@ -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(presentation), + selectedVideoTrackId: signal(trackId), + videoReloadEpoch: signal(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(); + }); +}); diff --git a/packages/spf/src/playback/engines/hls/engine.ts b/packages/spf/src/playback/engines/hls/engine.ts index 90da594f..21147758 100644 --- a/packages/spf/src/playback/engines/hls/engine.ts +++ b/packages/spf/src/playback/engines/hls/engine.ts @@ -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; } /** diff --git a/packages/spf/src/playback/engines/live-hls/engine.ts b/packages/spf/src/playback/engines/live-hls/engine.ts index f10e1133..74e82f69 100644 --- a/packages/spf/src/playback/engines/live-hls/engine.ts +++ b/packages/spf/src/playback/engines/live-hls/engine.ts @@ -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 diff --git a/packages/spf/src/playback/engines/live-playlist-spike/engine.ts b/packages/spf/src/playback/engines/live-playlist-spike/engine.ts index 0a526f06..b7315218 100644 --- a/packages/spf/src/playback/engines/live-playlist-spike/engine.ts +++ b/packages/spf/src/playback/engines/live-playlist-spike/engine.ts @@ -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 }, + } + ); }