refactor(spf): split seek-to-live-edge into window derivation + seekable writer

Decompose the seek-to-live-edge behavior (which coupled three concerns) into:
- liveWindowFor (media/live-window.ts) — a pure derivation of the live window
  {start,end,targetDuration}, or null for VOD/ended/unresolved. The single
  source of truth, centralizing all inertness so consumers don't re-derive.
- syncLiveSeekableRange (behaviors/dom) — declares setLiveSeekableRange on each
  window slide, including while paused (the seekable range must stay current
  regardless of play state).
- seekToLiveEdge — now just the one-time HOLD-BACK seek + the window-exit guard,
  consuming liveWindowFor; sheds the seekable/duration writes (keeps the
  mediaSource open-gate so the seek lands in the declared range).

Composed syncLiveSeekableRange before seekToLiveEdge to preserve the
declare-before-seek ordering (a seek outside seekable is clamped). Behavior-
preserving; tests redistributed (7 liveWindowFor + 14 seekToLiveEdge + 5
syncLiveSeekableRange). Deferred follow-ups: clearLiveSeekableRange on
termination; duration-ownership cleanup vs updateMediaSourceDuration; B's
seeked-latch source-reset; window-derives-from-accumulated-segments-while-paused.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-06-25 10:00:20 -07:00
co-authored by Claude Opus 4.8
parent 85e300e999
commit 24fa36e695
7 changed files with 339 additions and 77 deletions
+49
View File
@@ -0,0 +1,49 @@
/**
* Derive the live window of the selected video track — the single source of
* truth for "where is live," consumed by the seek-to-live-edge and
* live-seekable-range behaviors so neither re-derives (or re-presumes) the
* window shape.
*
* Returns `null` when there is no live edge to track: an unresolved
* presentation or track, a track with no segments, or a **complete** playlist
* (VoD, or live that has ended — a finite `Track.duration`). Video and audio
* share the timeline origin, so the video track's window positions both.
*/
import {
getMediaPlaylistMetadata,
isResolvedPresentation,
isResolvedTrack,
type MaybeResolvedPresentation,
} from './types';
import { findTrack } from './utils/tracks';
export interface LiveWindow {
/** Earliest time still in the window (seconds, model timeline). */
start: number;
/** The live edge — latest time in the window (seconds). */
end: number;
/** Playlist target duration (seconds); falls back to the last segment's duration. */
targetDuration: number;
}
export function liveWindowFor(
presentation: MaybeResolvedPresentation | undefined,
videoTrackId: string | undefined
): LiveWindow | null {
if (!isResolvedPresentation(presentation) || !videoTrackId) return null;
const track = findTrack(presentation, 'video', videoTrackId);
if (!track || !isResolvedTrack(track) || track.segments.length === 0) return null;
// Complete playlist (VoD, or live that has ended) → no live edge. `Track.duration`
// is the parser's completeness signal (finite = complete, Infinity = still growing).
if (Number.isFinite(track.duration)) return null;
const { segments } = track;
const last = segments[segments.length - 1]!;
return {
start: segments[0]!.startTime,
end: last.startTime + last.duration,
targetDuration: getMediaPlaylistMetadata(track)?.targetDuration || last.duration,
};
}
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import { liveWindowFor } from '../live-window';
import { MEDIA_PLAYLIST_METADATA_KEY, type Presentation, type VideoTrack } from '../types';
/** 5-segment, 2s window starting at 100: [100, 110]; targetDuration 2. */
function makePresentation(overrides?: Partial<VideoTrack>): Presentation {
const video: VideoTrack = {
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: Number.POSITIVE_INFINITY,
startTime: 100,
startDate: 1000,
segments: [100, 102, 104, 106, 108].map((startTime, i) => ({
id: `segment-${50 + i}`,
url: `${50 + i}.m4s`,
duration: 2,
startTime,
})),
metadata: { [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence: 50, targetDuration: 2, endList: false } },
...overrides,
};
return {
id: 'pres-1',
url: 'https://example.com/master.m3u8',
startTime: 0,
selectionSets: [{ id: 'video-set', type: 'video', switchingSets: [{ id: 'vs', type: 'video', tracks: [video] }] }],
};
}
describe('liveWindowFor', () => {
it('returns the window + target duration for a live track', () => {
expect(liveWindowFor(makePresentation(), 'v-1')).toEqual({ start: 100, end: 110, targetDuration: 2 });
});
it('returns null for a complete (finite-duration) playlist — VoD / ended live', () => {
expect(liveWindowFor(makePresentation({ duration: 110 }), 'v-1')).toBeNull();
});
it('returns null without a resolved presentation', () => {
expect(liveWindowFor(undefined, 'v-1')).toBeNull();
expect(liveWindowFor({ id: 'p', url: 'https://example.com/x.m3u8' }, 'v-1')).toBeNull();
});
it('returns null without a selected video track id', () => {
expect(liveWindowFor(makePresentation(), undefined)).toBeNull();
});
it('returns null when the track id does not resolve', () => {
expect(liveWindowFor(makePresentation(), 'missing')).toBeNull();
});
it('returns null for a track with no segments', () => {
expect(liveWindowFor(makePresentation({ segments: [] }), 'v-1')).toBeNull();
});
it('falls back to the last segment duration when target duration is absent', () => {
const presentation = makePresentation({
metadata: { [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence: 50, endList: false } },
});
expect(liveWindowFor(presentation, 'v-1')).toEqual({ start: 100, end: 110, targetDuration: 2 });
});
});
@@ -1,45 +1,31 @@
/**
* Enter and hold the live window: declare the seekable range, seek the playhead
* in, and reposition it to the live edge if it ever falls outside the window.
* Seek the playhead into the live window and keep it there:
*
* Live segments append at their native PTS, so the buffered window sits at a
* large timestamp while `currentTime` starts at 0. Two problems follow, both
* solved here from the *model* (not from `buffered`, which is empty at
* cold-start — the segment loader only fetches segments overlapping
* `[currentTime, currentTime + bufferDuration]`, so until the playhead is in
* the window nothing loads):
* 1. A one-time seek of `currentTime` to HOLD-BACK behind the live edge
* (default 3 × TARGETDURATION, clamped to the window start) so playback
* begins near the edge and the segment loader dispatches an in-window range
* rather than starting at the back of the DVR window.
* 2. A live-window playhead guard: while playing (`!paused && !seeking &&
* readyState > 0`), reposition `currentTime` to the live edge when it falls
* *outside* the sliding window — a paused playhead the window slid past
* (caught on the `playing` resume) or playback that fell behind on poor
* network (caught on this effect's window-update re-fire, since `timeupdate`
* stops once a stall freezes `currentTime`). In-window pause and DVR
* scrub-back are left untouched (the `window-exit` reposition policy).
*
* 1. `MediaSource.setLiveSeekableRange(windowStart, windowEnd)` — derived from
* the selected video track's anchored segment timeline — so the window is
* seekable (kept current as the window slides across reloads).
* 2. A one-time seek of `currentTime` to HOLD-BACK behind the live edge
* (default 3 × TARGETDURATION, clamped to the window start), so the loader
* dispatches an in-window range and playback can begin near the edge rather
* than at the back of the DVR window.
* 3. A live-window playhead guard: while playing, reposition `currentTime` to
* the live edge when it falls *outside* the sliding window — a paused
* playhead the window slid past (caught on the `playing` resume), or
* playback that fell behind on poor network (caught on this effect's
* window-update re-fire, since `timeupdate` stops once a stall freezes
* `currentTime`). In-window pause and DVR scrub-back are left untouched (the
* `window-exit` reposition policy — the DVR model).
*
* Reads the *selected video track* timeline (anchored to ≈ native PTS by
* `anchorLiveTracks`); video and audio share the origin, so the video window
* positions both. Seeks once per source; re-declares the seekable range on
* each window change.
* The live window comes from `liveWindowFor` (the shared derivation); this
* behavior is inert when it returns `null` (VoD / ended live). Declaring the
* seekable range is a separate concern — see `sync-live-seekable-range`, which
* is composed *before* this behavior so the range exists before we seek into
* it (a seek outside `seekable` is clamped). The `mediaSource` open-gate here
* is the read that ties the seek to that declared range being available.
*/
import { listen } from '@videojs/utils/dom';
import type { Behavior } from '../../../core/composition/create-composition';
import { effect } from '../../../core/signals/effect';
import type { ReadonlySignal } from '../../../core/signals/primitives';
import {
getMediaPlaylistMetadata,
isResolvedPresentation,
isResolvedTrack,
type MaybeResolvedPresentation,
} from '../../../media/types';
import { findTrack } from '../../../media/utils/tracks';
import { liveWindowFor } from '../../../media/live-window';
import type { MaybeResolvedPresentation } from '../../../media/types';
/**
* Multiple of TARGETDURATION to start behind the live edge — the HLS spec
@@ -98,40 +84,13 @@ function seekToLiveEdgeSetup({
return effect(() => {
const mediaElement = context.mediaElement.get();
const mediaSource = context.mediaSource.get();
const presentation = state.presentation.get();
const trackId = state.selectedVideoTrackId?.get();
if (!mediaElement || !mediaSource || !isResolvedPresentation(presentation) || !trackId) return;
if (mediaSource.readyState !== 'open') return;
const liveWindow = liveWindowFor(state.presentation.get(), state.selectedVideoTrackId?.get());
if (!mediaElement || !liveWindow) return;
// Gate on the seekable range being declarable/declared (see file JSDoc):
// sync-live-seekable-range runs first while open, so seeks land in-window.
if (!mediaSource || mediaSource.readyState !== 'open') return;
const track = findTrack(presentation, 'video', trackId);
if (!track || !isResolvedTrack(track) || track.segments.length === 0) return;
// Complete playlist (VoD, or live that has ended) → no live edge to seek to.
// `Track.duration` is the parser's completeness signal (finite = complete,
// Infinity = still growing / live); keeps this behavior inert for VoD in the
// unified engine — a VoD source never declares a live seekable range or seeks.
if (Number.isFinite(track.duration)) return;
const { segments } = track;
const windowStart = segments[0]!.startTime;
const last = segments[segments.length - 1]!;
const windowEnd = last.startTime + last.duration;
try {
// Live duration is unbounded; required for a live seekable range.
if (Number.isNaN(mediaSource.duration)) mediaSource.duration = Number.POSITIVE_INFINITY;
// Re-declared as the window slides so seekable tracks the live window
// (the full DVR range remains seekable; we just start near the edge).
mediaSource.setLiveSeekableRange(windowStart, windowEnd);
} catch {
// readyState raced closed, or duration set rejected — retried on the next window change.
return;
}
// Start near the live edge: HOLD-BACK (default 3 × TARGETDURATION) behind it,
// clamped to the window start. Closer to live than the window start, while
// leaving enough buffered ahead to begin smoothly.
const targetDuration = getMediaPlaylistMetadata(track)?.targetDuration || last.duration;
const { start: windowStart, end: windowEnd, targetDuration } = liveWindow;
const liveEdgeStart = Math.max(windowStart, windowEnd - HOLD_BACK_TARGET_MULTIPLIER * targetDuration);
// Initial entry: seek into the window once — even while paused — so the
@@ -0,0 +1,79 @@
/**
* Mirror the live window into the MediaSource's seekable range. On every
* window update — **including while paused** (the seekable range must stay
* current as the window slides, regardless of play state) — declare
* `setLiveSeekableRange(start, end)` so the browser's `HTMLMediaElement.seekable`
* reflects the live window (without it, `seekable` is empty under
* `duration === Infinity`).
*
* The live window comes from `liveWindowFor` (the shared derivation); inert
* when it returns `null` (VoD / ended live). Composed *before* `seekToLiveEdge`
* so the range is declared before that behavior seeks the playhead into it (a
* seek outside `seekable` is clamped).
*
* Out of scope (deliberate, tracked as follow-ups): `clearLiveSeekableRange()`
* on the live→ended transition; and dropping the defensive `duration` write
* below once duration ownership/ordering with `updateMediaSourceDuration` (the
* canonical, but asynchronous, duration writer) is resolved.
*/
import type { Behavior } from '../../../core/composition/create-composition';
import { effect } from '../../../core/signals/effect';
import type { ReadonlySignal } from '../../../core/signals/primitives';
import { liveWindowFor } from '../../../media/live-window';
import type { MaybeResolvedPresentation } from '../../../media/types';
export interface SyncLiveSeekableRangeState {
presentation?: MaybeResolvedPresentation;
selectedVideoTrackId?: string;
}
export interface SyncLiveSeekableRangeContext {
mediaSource?: MediaSource;
}
function syncLiveSeekableRangeSetup({
state,
context,
}: {
state: {
presentation: ReadonlySignal<SyncLiveSeekableRangeState['presentation']>;
selectedVideoTrackId?: ReadonlySignal<SyncLiveSeekableRangeState['selectedVideoTrackId']>;
};
context: {
mediaSource: ReadonlySignal<SyncLiveSeekableRangeContext['mediaSource']>;
};
}): () => void {
return effect(() => {
const mediaSource = context.mediaSource.get();
const liveWindow = liveWindowFor(state.presentation.get(), state.selectedVideoTrackId?.get());
if (!mediaSource || mediaSource.readyState !== 'open' || !liveWindow) return;
try {
// A live seekable range needs a set duration. `updateMediaSourceDuration`
// is the canonical duration owner, but writes asynchronously — so guard
// here so this doesn't race ahead of it. (Follow-up: resolve ownership.)
if (Number.isNaN(mediaSource.duration)) mediaSource.duration = Number.POSITIVE_INFINITY;
// Re-declared as the window slides so seekable tracks the live window
// (the full DVR range remains seekable; seek-to-live-edge starts near the edge).
mediaSource.setLiveSeekableRange(liveWindow.start, liveWindow.end);
} catch {
// readyState raced closed, or duration set rejected — retried on the next window change.
}
});
}
/**
* Manual `Behavior<>` literal (like `seekToLiveEdge`): declares only
* `presentation` in stateKeys while reading `selectedVideoTrackId` defensively
* (contributed by `switchVideoTrack`), so it composes without a
* stateKeys/type conflict.
*/
export const syncLiveSeekableRange: Behavior<
{ presentation: ReadonlySignal<SyncLiveSeekableRangeState['presentation']> },
{ mediaSource: ReadonlySignal<SyncLiveSeekableRangeContext['mediaSource']> },
object
> = {
stateKeys: ['presentation'],
contextKeys: ['mediaSource'],
setup: syncLiveSeekableRangeSetup,
};
@@ -98,28 +98,24 @@ function run(opts: {
const flush = () => Promise.resolve();
describe('seekToLiveEdge', () => {
it('declares the full seekable window and seeks near the live edge (HOLD-BACK behind)', () => {
it('seeks near the live edge on entry (HOLD-BACK behind)', () => {
const ms = fakeMediaSource();
const el = fakeMediaElement();
const { cleanup } = run({ presentation: makePresentation(), trackId: 'v-1', mediaElement: el, mediaSource: ms });
// Full DVR window stays seekable: [first.startTime, last.startTime + last.duration] = [100, 110].
expect(ms.setLiveSeekableRange).toHaveBeenCalledWith(100, 110);
expect(ms.duration).toBe(Number.POSITIVE_INFINITY);
// Start HOLD-BACK (3 × 2s) behind the edge: 110 6 = 104, not the window start.
expect(el.currentTime).toBe(104);
cleanup();
});
it('does nothing until the MediaSource is open', () => {
it('does not seek until the MediaSource is open', () => {
const ms = fakeMediaSource('closed');
const el = fakeMediaElement();
const { cleanup } = run({ presentation: makePresentation(), trackId: 'v-1', mediaElement: el, mediaSource: ms });
expect(ms.setLiveSeekableRange).not.toHaveBeenCalled();
expect(el.currentTime).toBe(0);
cleanup();
@@ -136,7 +132,6 @@ describe('seekToLiveEdge', () => {
const { cleanup } = run({ presentation, trackId: 'v-1', mediaElement: el, mediaSource: ms });
expect(ms.setLiveSeekableRange).not.toHaveBeenCalled();
expect(el.currentTime).toBe(0);
cleanup();
@@ -148,7 +143,6 @@ describe('seekToLiveEdge', () => {
const { cleanup } = run({ presentation: undefined, trackId: undefined, mediaElement: el, mediaSource: ms });
expect(ms.setLiveSeekableRange).not.toHaveBeenCalled();
expect(el.currentTime).toBe(0);
cleanup();
@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from 'vitest';
import { signal } from '../../../../core/signals/primitives';
import {
type MaybeResolvedPresentation,
MEDIA_PLAYLIST_METADATA_KEY,
type Presentation,
type VideoTrack,
} from '../../../../media/types';
import { syncLiveSeekableRange } from '../sync-live-seekable-range';
function makePresentation(): Presentation {
// 5-segment, 2s window: [100, 110].
const video: VideoTrack = {
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: Number.POSITIVE_INFINITY,
startTime: 100,
startDate: 1000,
segments: [100, 102, 104, 106, 108].map((startTime, i) => ({
id: `segment-${50 + i}`,
url: `${50 + i}.m4s`,
duration: 2,
startTime,
})),
metadata: { [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence: 50, targetDuration: 2, endList: false } },
};
return {
id: 'pres-1',
url: 'https://example.com/master.m3u8',
startTime: 0,
selectionSets: [{ id: 'video-set', type: 'video', switchingSets: [{ id: 'vs', type: 'video', tracks: [video] }] }],
};
}
function fakeMediaSource(readyState: MediaSource['readyState'] = 'open') {
return {
readyState,
duration: Number.NaN,
setLiveSeekableRange: vi.fn(),
} as unknown as MediaSource & { setLiveSeekableRange: ReturnType<typeof vi.fn> };
}
function run(opts: { presentation?: MaybeResolvedPresentation; trackId?: string; mediaSource?: MediaSource }) {
const state = {
presentation: signal<MaybeResolvedPresentation | undefined>(opts.presentation),
selectedVideoTrackId: signal<string | undefined>(opts.trackId),
};
const context = { mediaSource: signal<MediaSource | undefined>(opts.mediaSource) };
return syncLiveSeekableRange.setup({ state, context, config: {} }) as () => void;
}
describe('syncLiveSeekableRange', () => {
it('declares the full live window as seekable and sets Infinity duration', () => {
const ms = fakeMediaSource();
const cleanup = run({ presentation: makePresentation(), trackId: 'v-1', mediaSource: ms });
// [first.startTime, last.startTime + last.duration] = [100, 110].
expect(ms.setLiveSeekableRange).toHaveBeenCalledWith(100, 110);
expect(ms.duration).toBe(Number.POSITIVE_INFINITY);
cleanup();
});
it('does nothing until the MediaSource is open', () => {
const ms = fakeMediaSource('closed');
const cleanup = run({ presentation: makePresentation(), trackId: 'v-1', mediaSource: ms });
expect(ms.setLiveSeekableRange).not.toHaveBeenCalled();
cleanup();
});
it('no-ops for a complete (finite-duration) playlist — VoD / ended live', () => {
const ms = fakeMediaSource();
const presentation = makePresentation();
(presentation.selectionSets[0]!.switchingSets[0]!.tracks[0] as VideoTrack).duration = 110;
const cleanup = run({ presentation, trackId: 'v-1', mediaSource: ms });
expect(ms.setLiveSeekableRange).not.toHaveBeenCalled();
cleanup();
});
it('no-ops without a resolved presentation or selected track', () => {
const ms = fakeMediaSource();
const cleanup = run({ presentation: undefined, trackId: undefined, mediaSource: ms });
expect(ms.setLiveSeekableRange).not.toHaveBeenCalled();
cleanup();
});
it('does not overwrite an already-set (non-NaN) duration', () => {
const ms = fakeMediaSource();
ms.duration = 500; // e.g. updateMediaSourceDuration already wrote it
const cleanup = run({ presentation: makePresentation(), trackId: 'v-1', mediaSource: ms });
expect(ms.duration).toBe(500);
expect(ms.setLiveSeekableRange).toHaveBeenCalledWith(100, 110);
cleanup();
});
});
@@ -48,6 +48,7 @@ import { seekToLiveEdge } from '../../behaviors/dom/seek-to-live-edge';
import { setupAudioBufferActors, setupVideoBufferActors } from '../../behaviors/dom/setup-buffer-actors';
import { setupMediaSource } from '../../behaviors/dom/setup-mediasource';
import { setupTextTrackActors } from '../../behaviors/dom/setup-text-track-actors';
import { syncLiveSeekableRange } from '../../behaviors/dom/sync-live-seekable-range';
import { syncTextTracks } from '../../behaviors/dom/sync-text-tracks';
import { trackCurrentTime } from '../../behaviors/dom/track-current-time';
import { trackLoadTriggers } from '../../behaviors/dom/track-load-triggers';
@@ -426,8 +427,12 @@ export function createSimpleHlsEngine(
loadVideoSegments,
loadAudioSegments,
// Live: declare the seekable window and seek the playhead to the live
// edge once segments land. No-op for complete playlists (VoD / ended).
// Live: declare the seekable window, then seek the playhead to the live
// edge + keep it in-window. No-op for complete playlists (VoD / ended).
// Order matters: the seekable range must be declared (syncLiveSeekableRange)
// before seekToLiveEdge moves the playhead into it (a seek outside
// `seekable` is clamped).
syncLiveSeekableRange,
seekToLiveEdge,
// End of stream coordination