feat(spf): live media-playlist reload spike + parser timeline (WIP)

POC spike for live HLS at the playlist layer only (no MSE/segments/DOM):

- parser surfaces target-duration / media-sequence / playlist-type / endlist
  into a generic Ham.metadata bag (getMediaPlaylistMetadata accessor), with
  stable media-sequence-derived segment ids
- parseMediaPlaylist(text, previous) takes a resolved-or-unresolved prior
  track and carries the timeline forward across reloads (media-sequence
  overlap + actual durations); first load anchors at 0; targetDuration used
  only as the no-overlap fallback
- duration -> Infinity for unended live (finite once VOD/ENDLIST)
- streamType on Presentation, derived from PLAYLIST-TYPE alone
- reloadTrack behavior + createLivePlaylistSpikeEngine composition

WIP: startTime/timestampOffset basis and cross-track (A/V) sync are still
under research; PROGRAM-DATE-TIME capture and the cross-track alignment
strategy are not yet implemented.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-06-25 09:58:44 -07:00
co-authored by Claude Opus 4.8
parent 820f6fe45c
commit c0c9eb348d
6 changed files with 582 additions and 28 deletions
@@ -1,12 +1,17 @@
import type {
AudioTrack,
PartiallyResolvedAudioTrack,
PartiallyResolvedTextTrack,
PartiallyResolvedTrack,
PartiallyResolvedVideoTrack,
Segment,
TextTrack,
VideoTrack,
import {
type AudioTrack,
getMediaPlaylistMetadata,
isResolvedTrack,
MEDIA_PLAYLIST_METADATA_KEY,
type MediaPlaylistMetadata,
type PartiallyResolvedAudioTrack,
type PartiallyResolvedTextTrack,
type PartiallyResolvedTrack,
type PartiallyResolvedVideoTrack,
type ResolvedTrack,
type Segment,
type TextTrack,
type VideoTrack,
} from '../types';
import { matchTag, parseByteRange, parseExtInfDuration } from './parse-attributes';
import { resolveUrl } from './resolve-url';
@@ -56,23 +61,88 @@ type ResolveTrack<T> = T extends PartiallyResolvedVideoTrack
: never;
/**
* Parse HLS media playlist and resolve track with segments.
* Position a freshly-parsed window (whose segment `startTime`s are snapshot-
* local, i.e. from 0) onto the timeline established by the previous resolved
* snapshot. Carries the timeline forward using the media-sequence overlap and
* the previous window's *actual* segment durations — see
* [live-presentation-modeling.md](../../../../internal/design/spf/live-presentation-modeling.md).
*
* Takes an unresolved track (from multivariant playlist) and media playlist text,
* returns a HAM-compliant resolved track with segments.
* - **Overlap** (`0 <= offset < previous.segments.length`): the new window's
* first segment is the same segment as `previous.segments[offset]`; anchor to
* its start (URLs checked — a mismatch warns).
* - **Sequence went backwards** (non-conformant): reset to the local base.
* - **Full turnover** (no overlap): estimate forward from the previous window's
* end across the unseen gap. This is the *only* place EXT-X-TARGETDURATION is
* used for timing — an upper-bound estimate, since the actual rolled-off
* durations are gone (exact recovery is the deferred PDT decision).
*
* Returns the rebased segments and the track's resulting `startTime`.
*/
function placeOnPreviousTimeline(
previous: ResolvedTrack,
segments: Segment[],
mediaSequence: number,
targetDuration: number
): { segments: Segment[]; startTime: number } {
const prevSegments = previous.segments;
const localBase = segments[0]?.startTime ?? 0;
if (prevSegments.length === 0 || segments.length === 0) {
return { segments, startTime: localBase };
}
const prevMediaSequence = getMediaPlaylistMetadata(previous)?.mediaSequence ?? 0;
const offset = mediaSequence - prevMediaSequence;
let anchor: number;
if (offset >= 0 && offset < prevSegments.length) {
const overlap = prevSegments[offset]!;
if (overlap.url !== segments[0]!.url) {
console.warn(
`[parseMediaPlaylist] media-sequence aligns previous[${offset}] with the new window's first segment, ` +
`but URLs differ (${overlap.url} vs ${segments[0]!.url}); sequence numbers may be unreliable.`
);
}
anchor = overlap.startTime;
} else if (offset < 0) {
console.warn(`[parseMediaPlaylist] media-sequence went backwards (offset ${offset}); resetting timeline.`);
anchor = localBase;
} else {
const last = prevSegments[prevSegments.length - 1]!;
anchor = last.startTime + last.duration + (offset - prevSegments.length) * targetDuration;
console.warn(
`[parseMediaPlaylist] full window turnover (offset ${offset} >= ${prevSegments.length}); estimating from previous end.`
);
}
const shift = anchor - localBase;
const placed =
shift === 0 ? segments : segments.map((segment) => ({ ...segment, startTime: segment.startTime + shift }));
return { segments: placed, startTime: anchor };
}
/**
* Parse an HLS media playlist into a resolved track with segments.
*
* `previous` is what was known about this track before this parse: the
* partially-resolved track from the multivariant playlist on the first resolve,
* or the previously-resolved snapshot on a live reload. Its metadata is carried
* onto the result either way; when it's already resolved (has segments), its
* timeline is carried forward so the new window lands on a stable, advancing
* timeline (see {@link placeOnPreviousTimeline}).
*
* @param text - Media playlist text content
* @param unresolved - Unresolved track from parseMultivariantPlaylist
* @param previous - Prior track state (unresolved shell, or previous resolved snapshot)
* @returns Resolved track with segments (type inferred from input)
*/
export function parseMediaPlaylist<T extends PartiallyResolvedTrack>(
text: string,
unresolved: T | ResolveTrack<T>
previous: T | ResolveTrack<T>
): ResolveTrack<T> {
const lines = text.split(/\r?\n/);
// Segments and resources resolve relative to media playlist URL (per HLS spec)
const baseUrl = unresolved.url;
const baseUrl = previous.url;
// Parse playlist
const segments: Segment[] = [];
@@ -85,6 +155,12 @@ export function parseMediaPlaylist<T extends PartiallyResolvedTrack>(
let segmentIndex = 0;
let previousByteRangeEnd: number | undefined;
// Playlist-level metadata (surfaced for live reload pacing / merge / termination).
let targetDuration = 0;
let mediaSequence = 0;
let playlistType: 'VOD' | 'EVENT' | undefined;
let endList = false;
for (const line of lines) {
const trimmed = line.trim();
@@ -92,11 +168,25 @@ export function parseMediaPlaylist<T extends PartiallyResolvedTrack>(
continue;
}
if (trimmed.startsWith('#EXT-X-TARGETDURATION:')) {
targetDuration = Number.parseInt(trimmed.slice('#EXT-X-TARGETDURATION:'.length), 10) || 0;
continue;
}
if (trimmed.startsWith('#EXT-X-MEDIA-SEQUENCE:')) {
mediaSequence = Number.parseInt(trimmed.slice('#EXT-X-MEDIA-SEQUENCE:'.length), 10) || 0;
continue;
}
if (trimmed.startsWith('#EXT-X-PLAYLIST-TYPE:')) {
const value = trimmed.slice('#EXT-X-PLAYLIST-TYPE:'.length).trim();
playlistType = value === 'VOD' || value === 'EVENT' ? value : undefined;
continue;
}
if (
trimmed === '#EXTM3U' ||
trimmed.startsWith('#EXT-X-VERSION:') ||
trimmed.startsWith('#EXT-X-TARGETDURATION:') ||
trimmed.startsWith('#EXT-X-PLAYLIST-TYPE:') ||
trimmed.startsWith('#EXT-X-INDEPENDENT-SEGMENTS')
) {
continue;
@@ -129,13 +219,14 @@ export function parseMediaPlaylist<T extends PartiallyResolvedTrack>(
}
if (trimmed === '#EXT-X-ENDLIST') {
endList = true;
continue;
}
// Segment URI
if (!trimmed.startsWith('#') && currentDuration > 0) {
const segment: Segment = {
id: `segment-${segmentIndex}`,
id: `segment-${mediaSequence + segmentIndex}`,
url: resolveUrl(trimmed, baseUrl),
duration: currentDuration,
startTime: currentTime,
@@ -159,9 +250,22 @@ export function parseMediaPlaylist<T extends PartiallyResolvedTrack>(
const totalDuration = currentTime;
// `duration` is the track's duration: Infinity while the playlist can still
// grow (unended live), finite once complete (VOD / ENDLIST). It uses the
// actual EXTINF sum, never the target duration.
const complete = endList || playlistType === 'VOD';
const trackDuration = complete ? totalDuration : Number.POSITIVE_INFINITY;
// Position this window on the timeline. First resolve (previous is the
// unresolved shell, no segments) anchors at 0; a live reload (previous is the
// prior resolved snapshot) carries the timeline forward from the overlap.
const placed = isResolvedTrack(previous)
? placeOnPreviousTimeline(previous, segments, mediaSequence, targetDuration)
: { segments, startTime: 0 };
// Build initialization (VTT may not have init segment)
const initialization =
unresolved.type === 'text' && !initSegmentUrl
previous.type === 'text' && !initSegmentUrl
? undefined
: initSegmentUrl
? { url: initSegmentUrl, ...(initSegmentByteRange ? { byteRange: initSegmentByteRange } : {}) }
@@ -173,17 +277,26 @@ export function parseMediaPlaylist<T extends PartiallyResolvedTrack>(
// trips on fMP4, which mandates the map). Relabel from the fMP4 default
// `video/mp4` / `audio/mp4` to the container MIME so capability probing prunes
// it (these containers are currently treated as unplayable; see `canPlayTrack`).
const detectedContainer = initSegmentUrl ? undefined : containerMimeFromSegment(segments[0]?.url);
const mimeType = unresolved.type !== 'text' && detectedContainer ? detectedContainer : unresolved.mimeType;
const detectedContainer = initSegmentUrl ? undefined : containerMimeFromSegment(placed.segments[0]?.url);
const mimeType = previous.type !== 'text' && detectedContainer ? detectedContainer : previous.mimeType;
// Generic resolution: All type-specific fields already on unresolved track from P1
// Just add parsed properties (startTime, duration, segments, initialization)
// Generic resolution: type-specific fields already on `previous`; add the
// parsed properties (startTime, duration, segments, initialization, metadata).
return {
...unresolved,
...previous,
mimeType,
startTime: 0,
duration: totalDuration,
segments,
startTime: placed.startTime,
duration: trackDuration,
segments: placed.segments,
initialization,
metadata: {
...previous.metadata,
[MEDIA_PLAYLIST_METADATA_KEY]: {
targetDuration,
mediaSequence,
playlistType,
endList,
} satisfies MediaPlaylistMetadata,
},
} as unknown as ResolveTrack<T>;
}
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest';
import type { PartiallyResolvedAudioTrack, PartiallyResolvedTextTrack, PartiallyResolvedVideoTrack } from '../../types';
import {
getMediaPlaylistMetadata,
type PartiallyResolvedAudioTrack,
type PartiallyResolvedTextTrack,
type PartiallyResolvedVideoTrack,
} from '../../types';
import { parseMediaPlaylist } from '../parse-media-playlist';
describe('parseMediaPlaylist', () => {
@@ -383,4 +388,158 @@ segment0.mp4
expect(parseMediaPlaylist(playlist, unresolvedVideo).mimeType).toBe('video/mp4');
});
});
describe('Live playlists', () => {
const unresolvedVideo: PartiallyResolvedVideoTrack = {
type: 'video',
id: 'video-0',
url: 'https://example.com/video/playlist.m3u8',
bandwidth: 1400000,
codecs: ['avc1.4d401f'],
mimeType: 'video/mp4',
};
it('reports Infinity duration for an unended live playlist (no ENDLIST, no PLAYLIST-TYPE)', () => {
const playlist = `#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-MAP:URI="init.mp4"
#EXTINF:6.0,
segment0.m4s
#EXTINF:6.0,
segment1.m4s`;
const result = parseMediaPlaylist(playlist, unresolvedVideo);
expect(result.duration).toBe(Number.POSITIVE_INFINITY);
expect(result.startTime).toBe(0);
expect(getMediaPlaylistMetadata(result)?.endList).toBe(false);
});
it('reports Infinity duration for an unended EVENT playlist', () => {
const playlist = `#EXTM3U
#EXT-X-TARGETDURATION:6
#EXT-X-PLAYLIST-TYPE:EVENT
#EXTINF:6.0,
segment0.m4s`;
expect(parseMediaPlaylist(playlist, unresolvedVideo).duration).toBe(Number.POSITIVE_INFINITY);
});
it('anchors startTime at 0 on first parse, with media-sequence-derived segment ids', () => {
const playlist = `#EXTM3U
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:10
#EXTINF:6.0,
segment10.m4s
#EXTINF:6.0,
segment11.m4s`;
const result = parseMediaPlaylist(playlist, unresolvedVideo);
// No previous snapshot → the window anchors at 0 regardless of media sequence.
expect(result.startTime).toBe(0);
expect(result.segments.map((s) => s.startTime)).toEqual([0, 6]);
// Segment ids are media-sequence-derived, so they stay stable across reloads.
expect(result.segments.map((s) => s.id)).toEqual(['segment-10', 'segment-11']);
});
it('treats an ended live playlist as complete with finite duration', () => {
const playlist = `#EXTM3U
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:5
#EXTINF:6.0,
segment5.m4s
#EXTINF:6.0,
segment6.m4s
#EXT-X-ENDLIST`;
const result = parseMediaPlaylist(playlist, unresolvedVideo);
expect(result.duration).toBe(12.0);
expect(result.startTime).toBe(0); // first parse, no previous → anchored at 0
expect(getMediaPlaylistMetadata(result)?.endList).toBe(true);
});
it('carries the timeline forward across reloads as the window slides', () => {
const first = `#EXTM3U
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:6.0,
segment0.m4s
#EXTINF:6.0,
segment1.m4s
#EXTINF:6.0,
segment2.m4s`;
const previous = parseMediaPlaylist(first, unresolvedVideo);
expect(previous.segments.map((s) => s.startTime)).toEqual([0, 6, 12]);
// Window slid by one (media sequence 0 → 1) and gained a segment.
const reload = `#EXTM3U
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:1
#EXTINF:6.0,
segment1.m4s
#EXTINF:6.0,
segment2.m4s
#EXTINF:6.0,
segment3.m4s`;
const next = parseMediaPlaylist(reload, previous);
// segment1 anchors to its prior start (6); the appended segment3 continues at 18.
expect(next.segments.map((s) => s.startTime)).toEqual([6, 12, 18]);
expect(next.segments.map((s) => s.id)).toEqual(['segment-1', 'segment-2', 'segment-3']);
});
it('appends without shifting when nothing rolls off (media sequence unchanged)', () => {
const first = `#EXTM3U
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:6.0,
segment0.m4s
#EXTINF:6.0,
segment1.m4s`;
const previous = parseMediaPlaylist(first, unresolvedVideo);
const reload = `#EXTM3U
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:6.0,
segment0.m4s
#EXTINF:6.0,
segment1.m4s
#EXTINF:6.0,
segment2.m4s`;
const next = parseMediaPlaylist(reload, previous);
expect(next.segments.map((s) => s.startTime)).toEqual([0, 6, 12]);
});
it('estimates the timeline forward on a full window turnover (no overlap)', () => {
const first = `#EXTM3U
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:6.0,
segment0.m4s
#EXTINF:6.0,
segment1.m4s
#EXTINF:6.0,
segment2.m4s`;
const previous = parseMediaPlaylist(first, unresolvedVideo); // ends at 18
// Jump far ahead — no overlap (offset 10 ≥ 3 segments).
const reload = `#EXTM3U
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:10
#EXTINF:6.0,
segment10.m4s
#EXTINF:6.0,
segment11.m4s`;
const next = parseMediaPlaylist(reload, previous);
// anchor = previous end (18) + (offset 10 3) × 6 = 60
expect(next.segments.map((s) => s.startTime)).toEqual([60, 66]);
});
});
});
+68
View File
@@ -16,6 +16,13 @@
*/
export interface Ham {
id: string;
/**
* Format-/protocol-specific values that aren't part of the generic CMAF-HAM
* model — kept in an open bag so the model stays format-neutral (mirrors how
* CMAF-HAM itself stashes protocol extras rather than growing the model).
* Typed reads go through dedicated accessors (e.g. `getMediaPlaylistMetadata`).
*/
metadata?: Record<string, unknown>;
}
/**
@@ -329,6 +336,40 @@ export type Segment = Ham & AddressableObject & TimeSpan;
*/
export const SEGMENT_TIME_EPSILON = 0.0001;
// =============================================================================
// Media Playlist Metadata
// =============================================================================
/**
* Playlist-level metadata surfaced from a parsed media playlist. HLS delivery
* specifics — not part of the generic CMAF-HAM model — so they live under
* `Ham.metadata` (read via `getMediaPlaylistMetadata`) rather than as
* first-class `Track` fields:
*
* - `targetDuration` (`#EXT-X-TARGETDURATION`) — reload-cadence basis.
* - `mediaSequence` (`#EXT-X-MEDIA-SEQUENCE`, default 0) — sequence number of
* `segments[0]`; the join key for merging successive reload snapshots.
* - `playlistType` (`#EXT-X-PLAYLIST-TYPE`) — `VOD` / `EVENT` / undefined.
* - `endList` (`#EXT-X-ENDLIST`) — playlist is complete; stop reloading.
*
* See [live-presentation-modeling.md](../../../../internal/design/spf/live-presentation-modeling.md)
* for how these map onto the protocol-neutral category model.
*/
export interface MediaPlaylistMetadata {
targetDuration: number;
mediaSequence: number;
playlistType?: 'VOD' | 'EVENT';
endList: boolean;
}
/** Key under `Ham.metadata` where {@link MediaPlaylistMetadata} is stored. */
export const MEDIA_PLAYLIST_METADATA_KEY = 'mediaPlaylist';
/** Typed read of the media-playlist metadata stashed in `ham.metadata`. */
export function getMediaPlaylistMetadata(ham: Ham): MediaPlaylistMetadata | undefined {
return ham.metadata?.[MEDIA_PLAYLIST_METADATA_KEY] as MediaPlaylistMetadata | undefined;
}
// =============================================================================
// Media Playlist Info
// =============================================================================
@@ -347,6 +388,27 @@ export interface MediaPlaylistInfo {
endList: boolean;
}
// =============================================================================
// Stream Type
// =============================================================================
/**
* The source's semantic nature — live vs on-demand. A model concept
* (consumer-facing), distinct from completeness / duration: a live stream that
* has *ended* is still `'live'`. See
* [live-presentation-modeling.md](../../../../internal/design/spf/live-presentation-modeling.md).
*/
export type StreamType = 'live' | 'on-demand';
/**
* Derive {@link StreamType} from a media playlist's metadata. Per the model,
* only `#EXT-X-PLAYLIST-TYPE:VOD` marks on-demand; everything else (EVENT, or
* the tag absent) is live — completeness (`endList`) never factors in.
*/
export function deriveStreamType(metadata: MediaPlaylistMetadata | undefined): StreamType {
return metadata?.playlistType === 'VOD' ? 'on-demand' : 'live';
}
// =============================================================================
// Presentation
// =============================================================================
@@ -362,6 +424,12 @@ export type Presentation = Ham &
AddressableObject &
Partial<TimeSpan> & {
selectionSets: SelectionSet[];
/**
* Live vs on-demand — the source's semantic nature. Populated once a media
* playlist is parsed (derived from `#EXT-X-PLAYLIST-TYPE` via
* `deriveStreamType`); orthogonal to duration / completeness.
*/
streamType?: StreamType;
};
/**
@@ -0,0 +1,141 @@
/**
* **POC SPIKE** live media-playlist reload loop.
*
* Drives the live foundation at the playlist layer only (no MSE, no segment
* fetching): once a video track is selected and the presentation is resolved,
* 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`. Validates the model in
* [live-presentation-modeling.md](../../../internal/design/spf/live-presentation-modeling.md).
*
* Spike limitations (intentional): video only; selection is read once at loop
* start (mid-stream track switching isn't handled — the reactor's two states
* don't encode the track id); PDT / discontinuity / A/V sync untouched.
*/
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,
} from '../../media/types';
import { findTrack, updateTrackInPresentation } from '../../media/utils/tracks';
import { fetchResolvableText } from '../../network/fetch';
export interface ReloadTrackState {
presentation?: MaybeResolvedPresentation;
selectedVideoTrackId?: string;
}
type ReloadTrackStateName = 'idle' | 'reloading';
/** 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 reloadTrackSetup({
state,
}: {
state: {
presentation: Signal<ReloadTrackState['presentation']>;
selectedVideoTrackId: ReadonlySignal<ReloadTrackState['selectedVideoTrackId']>;
};
}) {
const derivedStateSignal = computed<ReloadTrackStateName>(() => {
const presentation = state.presentation.get();
const trackId = state.selectedVideoTrackId.get();
if (!isResolvedPresentation(presentation) || !trackId) return 'idle';
return findTrack(presentation, 'video', trackId) ? 'reloading' : 'idle';
});
return createMachineReactor<ReloadTrackStateName>({
initial: 'idle',
monitor: () => derivedStateSignal.get(),
states: {
idle: {},
reloading: {
entry: () => {
const ac = new AbortController();
const trackId = state.selectedVideoTrackId.get()!;
void (async () => {
try {
while (!ac.signal.aborted) {
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, 'video', 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;
// TODO(error-management): route to a state-error slot once one exists.
console.error('[reloadTrack] media-playlist reload failed:', error);
}
})();
// State-exit (source change / destroy) aborts the loop.
return () => ac.abort();
},
},
},
});
}
export const reloadTrack = defineBehavior({
stateKeys: ['presentation', 'selectedVideoTrackId'],
contextKeys: [],
setup: reloadTrackSetup,
});
@@ -0,0 +1,66 @@
/**
* **POC SPIKE** a composition that *just handles playlists*.
*
* The smallest engine that exercises the live foundation
* ([live-presentation-modeling.md](../../../../../internal/design/spf/live-presentation-modeling.md)):
* resolve the multivariant manifest, pick a video rendition, then reload that
* track's media playlist on a target-duration cadence, merging snapshots no
* MSE, no SourceBuffers, no segment fetching, no DOM. Point it at a live stream
* and observe `state.presentation` evolve (segments append / roll off;
* duration / streamType / live edge derivable from the resolved track).
*
* Drive it via `onSignalsReady`: set `presentation = { url }`. Selection and
* reloading then run on their own.
*/
import {
type Composition,
type ContextSignals,
createComposition,
type StateSignals,
} from '../../../core/composition/create-composition';
import { makeShareSignals, type ShareSignalsConfig } from '../../../core/composition/share-signals';
import { parseMultivariantPlaylist } from '../../../media/hls/parse-multivariant';
import { pickHighestResolutionVideoTrack, type TrackPicker } from '../../../media/primitives/select-tracks';
import type { MaybeResolvedPresentation } from '../../../media/types';
import { reloadTrack } from '../../behaviors/reload-track';
import { type ParsePresentation, resolvePresentation } from '../../behaviors/resolve-presentation';
import { type SelectVideoTrackConfig, selectVideoTrack } from '../../behaviors/select-tracks';
export interface LivePlaylistSpikeState {
presentation?: MaybeResolvedPresentation;
selectedVideoTrackId?: string;
preload?: 'auto' | 'metadata' | 'none';
loadActivated?: boolean;
}
export type LivePlaylistSpikeContext = Record<never, never>;
export type LivePlaylistSpikeSignals = {
state: StateSignals<LivePlaylistSpikeState>;
context: ContextSignals<LivePlaylistSpikeContext>;
};
export interface LivePlaylistSpikeConfig extends ShareSignalsConfig<LivePlaylistSpikeState, LivePlaylistSpikeContext> {
/** Video-track picker handed to `selectVideoTrack`. Default: max resolution. */
picker?: TrackPicker<SelectVideoTrackConfig>;
/** Multivariant parser. Defaults to the HLS multivariant-playlist parser. */
parsePresentation?: ParsePresentation;
}
const shareSignals = makeShareSignals<LivePlaylistSpikeState, LivePlaylistSpikeContext>();
export function createLivePlaylistSpikeEngine(
config: LivePlaylistSpikeConfig = {}
): Composition<LivePlaylistSpikeState, LivePlaylistSpikeContext> {
const finalConfig = {
...config,
picker: config.picker ?? pickHighestResolutionVideoTrack,
parsePresentation: config.parsePresentation ?? parseMultivariantPlaylist,
};
return createComposition([resolvePresentation, selectVideoTrack, reloadTrack, shareSignals], {
config: finalConfig,
// Spike skips the preload gate — resolve as soon as a url is set.
initialState: { loadActivated: true },
});
}
@@ -0,0 +1,7 @@
export {
createLivePlaylistSpikeEngine,
type LivePlaylistSpikeConfig,
type LivePlaylistSpikeContext,
type LivePlaylistSpikeSignals,
type LivePlaylistSpikeState,
} from './engine';