feat(spf): per-type live media-playlist reload

Generalize the video-only reload spike into a per-type factory mirroring
resolve-track: reloadVideoTrack / reloadAudioTrack / reloadTextTrack, each
gating on its own selected*TrackId + track type so demuxed audio and video
reload independently. Inject fetchResolvableText via config (parity with
resolve-track, and testable). Update the live-playlist spike to reloadVideoTrack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-06-25 09:58:45 -07:00
co-authored by Claude Opus 4.8
parent 0cec3132a9
commit df4ec77c18
3 changed files with 192 additions and 23 deletions
@@ -1,17 +1,22 @@
/**
* **POC SPIKE** — live media-playlist reload loop.
* Live media-playlist reload loop, per track type.
*
* 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,
* 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`. Validates the model in
* [live-presentation-modeling.md](../../../internal/design/spf/live-presentation-modeling.md).
* `#EXT-X-ENDLIST`. Drives the live foundation at the playlist layer
* ([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.
* 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';
@@ -24,17 +29,38 @@ import {
isResolvedTrack,
type MaybeResolvedPresentation,
type ResolvedTrack,
type TrackType,
} from '../../media/types';
import { findTrack, updateTrackInPresentation } from '../../media/utils/tracks';
import { fetchResolvableText } from '../../network/fetch';
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;
@@ -64,19 +90,18 @@ function snapshotChanged(prev: ResolvedTrack, next: ResolvedTrack): boolean {
);
}
function reloadTrackSetup({
function setupTrackReload<K extends SelectedTrackKey>({
state,
config: { type, selectedKey, fetchResolvableText = defaultFetchResolvableText },
}: {
state: {
presentation: Signal<ReloadTrackState['presentation']>;
selectedVideoTrackId: ReadonlySignal<ReloadTrackState['selectedVideoTrackId']>;
};
state: ReloadTrackStateMap<K>;
config: TrackReloadConfig<K>;
}) {
const derivedStateSignal = computed<ReloadTrackStateName>(() => {
const presentation = state.presentation.get();
const trackId = state.selectedVideoTrackId.get();
const trackId = state[selectedKey].get();
if (!isResolvedPresentation(presentation) || !trackId) return 'idle';
return findTrack(presentation, 'video', trackId) ? 'reloading' : 'idle';
return findTrack(presentation, type, trackId) ? 'reloading' : 'idle';
});
return createMachineReactor<ReloadTrackStateName>({
@@ -87,7 +112,7 @@ function reloadTrackSetup({
reloading: {
entry: () => {
const ac = new AbortController();
const trackId = state.selectedVideoTrackId.get()!;
const trackId = state[selectedKey].get()!;
void (async () => {
try {
@@ -97,7 +122,7 @@ function reloadTrackSetup({
// 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);
const previousTrack = findTrack(presentation, type, trackId);
if (!previousTrack) break;
const text = await fetchResolvableText(previousTrack, { signal: ac.signal });
@@ -122,7 +147,7 @@ function reloadTrackSetup({
} 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);
console.error(`[reload:${type}] media-playlist reload failed:`, error);
}
})();
@@ -134,8 +159,30 @@ function reloadTrackSetup({
});
}
export const reloadTrack = defineBehavior({
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: reloadTrackSetup,
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 } }),
});
@@ -0,0 +1,122 @@
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();
});
});
@@ -22,7 +22,7 @@ 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 { reloadTrack } from '../../behaviors/reload-track';
import { reloadVideoTrack } from '../../behaviors/reload-track';
import { type ParsePresentation, resolvePresentation } from '../../behaviors/resolve-presentation';
import { type SelectVideoTrackConfig, selectVideoTrack } from '../../behaviors/select-tracks';
@@ -58,7 +58,7 @@ export function createLivePlaylistSpikeEngine(
parsePresentation: config.parsePresentation ?? parseMultivariantPlaylist,
};
return createComposition([resolvePresentation, selectVideoTrack, reloadTrack, shareSignals], {
return createComposition([resolvePresentation, selectVideoTrack, reloadVideoTrack, shareSignals], {
config: finalConfig,
// Spike skips the preload gate — resolve as soon as a url is set.
initialState: { loadActivated: true },