feat(spf): reposition the live playhead to the edge when it exits the window

Extend seek-to-live-edge with a live-window playhead guard: while playing
(!paused && !seeking && readyState>0), reposition currentTime to the live edge
(holdback) when it falls outside the sliding window [windowStart, windowEnd]
(0.1s tolerance). Covers a paused-too-long playhead the window slid past
(caught on the playing resume) and playback that fell behind on poor network
(caught on the effect's window-update re-fire, since timeupdate stops once a
stall freezes currentTime). In-window pause and DVR scrub-back are untouched.

The one-time initial seek is preserved (latched) and the guard reuses the same
liveEdgeStart, keeping the live playhead position single-owned. repositionPolicy
is a behavior-scoped seam (default 'window-exit'); the edge-only 'on-resume'
branch is inert, reserved for a future live-edge-only-mode use-case.

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 1cb1297c07
commit 85e300e999
2 changed files with 247 additions and 22 deletions
@@ -1,5 +1,6 @@
/**
* Enter the live window: declare the seekable range and seek the playhead in.
* 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.
*
* 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
@@ -15,12 +16,20 @@
* (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.
*/
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';
@@ -38,6 +47,21 @@ import { findTrack } from '../../../media/utils/tracks';
*/
const HOLD_BACK_TARGET_MULTIPLIER = 3;
/**
* Tolerance (seconds) around the window edges before the guard repositions, so
* boundary / floating-point noise doesn't trigger a spurious seek.
*/
const REPOSITION_TOLERANCE = 0.1;
/**
* When the live-window guard repositions the playhead to the live edge.
* - `'window-exit'` (default; DVR model): only when the playhead is outside the
* sliding window. In-window pause / scrub-back is left untouched.
* - `'on-resume'`: edge-only — always snap to the live edge on resume. A future
* use-case variant (live-edge-only mode); not yet implemented.
*/
export type LiveRepositionPolicy = 'window-exit' | 'on-resume';
export interface SeekToLiveEdgeState {
presentation?: MaybeResolvedPresentation;
selectedVideoTrackId?: string;
@@ -48,9 +72,15 @@ export interface SeekToLiveEdgeContext {
mediaSource?: MediaSource;
}
export interface SeekToLiveEdgeConfig {
/** Reposition policy for the live-window guard. Defaults to `'window-exit'`. */
repositionPolicy?: LiveRepositionPolicy;
}
function seekToLiveEdgeSetup({
state,
context,
config,
}: {
state: {
presentation: ReadonlySignal<SeekToLiveEdgeState['presentation']>;
@@ -60,7 +90,9 @@ function seekToLiveEdgeSetup({
mediaElement: ReadonlySignal<SeekToLiveEdgeContext['mediaElement']>;
mediaSource: ReadonlySignal<SeekToLiveEdgeContext['mediaSource']>;
};
config?: SeekToLiveEdgeConfig;
}): () => void {
const repositionPolicy = config?.repositionPolicy ?? 'window-exit';
let seeked = false;
return effect(() => {
@@ -102,11 +134,38 @@ function seekToLiveEdgeSetup({
const targetDuration = getMediaPlaylistMetadata(track)?.targetDuration || last.duration;
const liveEdgeStart = Math.max(windowStart, windowEnd - HOLD_BACK_TARGET_MULTIPLIER * targetDuration);
// Seek to the live edge once, so the loader dispatches an in-window range.
// Initial entry: seek into the window once — even while paused — so the
// loader dispatches an in-window range and preload shows the right frame.
// Latched so a later reload never re-yanks a paused user who scrubbed back.
if (!seeked && mediaElement.currentTime < liveEdgeStart) {
mediaElement.currentTime = liveEdgeStart;
seeked = true;
}
// Live-window playhead guard. Repositions to the live edge when the playhead
// falls outside the sliding window while playing. Runs now (this effect
// re-fires on each window-update / reload — the primary trigger, since
// `timeupdate` stops while a stall freezes `currentTime`) and on the
// secondary media-event triggers below.
const guard = () => {
// `on-resume` (edge-only) is a future use-case variant; only the DVR
// `window-exit` policy is implemented today.
if (repositionPolicy !== 'window-exit') return;
if (mediaElement.paused || mediaElement.seeking || mediaElement.readyState === 0) return;
const { currentTime } = mediaElement;
if (currentTime < windowStart - REPOSITION_TOLERANCE || currentTime > windowEnd + REPOSITION_TOLERANCE) {
mediaElement.currentTime = liveEdgeStart;
}
};
guard();
const removePlaying = listen(mediaElement, 'playing', guard);
const removeTimeupdate = listen(mediaElement, 'timeupdate', guard);
const removeSeeked = listen(mediaElement, 'seeked', guard);
return () => {
removePlaying();
removeTimeupdate();
removeSeeked();
};
});
}
@@ -122,7 +181,7 @@ export const seekToLiveEdge: Behavior<
mediaElement: ReadonlySignal<SeekToLiveEdgeContext['mediaElement']>;
mediaSource: ReadonlySignal<SeekToLiveEdgeContext['mediaSource']>;
},
object
SeekToLiveEdgeConfig
> = {
stateKeys: ['presentation'],
contextKeys: ['mediaElement', 'mediaSource'],
@@ -6,11 +6,14 @@ import {
type Presentation,
type VideoTrack,
} from '../../../../media/types';
import { seekToLiveEdge } from '../seek-to-live-edge';
import { type SeekToLiveEdgeConfig, seekToLiveEdge } from '../seek-to-live-edge';
function makePresentation(): Presentation {
// 5-segment, 2s window: [100, 110]. HOLD-BACK = 3 × targetDuration(2) = 6,
// so the live-edge start is 110 6 = 104.
/**
* 5-segment, 2s window starting at `startTime`: `[startTime, startTime + 10]`.
* HOLD-BACK = 3 × targetDuration(2) = 6, so the live-edge start is
* `(startTime + 10) 6 = startTime + 4` (104 for the default 100).
*/
function makePresentation(startTime = 100, mediaSequence = 50): Presentation {
const video: VideoTrack = {
type: 'video',
id: 'v-1',
@@ -20,15 +23,15 @@ function makePresentation(): Presentation {
bandwidth: 1_000_000,
initialization: { url: 'https://example.com/init.mp4' },
duration: Number.POSITIVE_INFINITY,
startTime: 100,
startTime,
startDate: 1000,
segments: [100, 102, 104, 106, 108].map((startTime, i) => ({
id: `segment-${50 + i}`,
url: `${50 + i}.m4s`,
segments: [0, 2, 4, 6, 8].map((offset, i) => ({
id: `segment-${mediaSequence + i}`,
url: `${mediaSequence + i}.m4s`,
duration: 2,
startTime,
startTime: startTime + offset,
})),
metadata: { [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence: 50, targetDuration: 2, endList: false } },
metadata: { [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence, targetDuration: 2, endList: false } },
};
return {
id: 'pres-1',
@@ -46,11 +49,35 @@ function fakeMediaSource(readyState: MediaSource['readyState'] = 'open') {
} as unknown as MediaSource & { setLiveSeekableRange: ReturnType<typeof vi.fn> };
}
type FakeMediaElement = HTMLMediaElement & {
currentTime: number;
paused: boolean;
seeking: boolean;
readyState: HTMLMediaElement['readyState'];
};
/**
* Event-capable fake: `seekToLiveEdge` attaches `playing` / `timeupdate` /
* `seeked` listeners, so the element must be a real `EventTarget`. Defaults to
* paused + `readyState` HAVE_ENOUGH_DATA (the post-initial-seek resting state).
*/
function fakeMediaElement(
init: Partial<Pick<FakeMediaElement, 'currentTime' | 'paused' | 'seeking' | 'readyState'>> = {}
): FakeMediaElement {
return Object.assign(new EventTarget(), {
currentTime: init.currentTime ?? 0,
paused: init.paused ?? true,
seeking: init.seeking ?? false,
readyState: init.readyState ?? 4,
}) as unknown as FakeMediaElement;
}
function run(opts: {
presentation?: MaybeResolvedPresentation;
trackId?: string;
mediaElement?: HTMLMediaElement;
mediaSource?: MediaSource;
config?: SeekToLiveEdgeConfig;
}) {
// Built as vars (not inline literals) so the defensively-read
// `selectedVideoTrackId` isn't rejected by the excess-property check against
@@ -63,15 +90,19 @@ function run(opts: {
mediaElement: signal<HTMLMediaElement | undefined>(opts.mediaElement),
mediaSource: signal<MediaSource | undefined>(opts.mediaSource),
};
return seekToLiveEdge.setup({ state, context, config: {} }) as () => void;
const cleanup = seekToLiveEdge.setup({ state, context, config: opts.config ?? {} }) as () => void;
return { cleanup, state, context };
}
// Let the effect re-run after a signal write (effects re-run on a microtask).
const flush = () => Promise.resolve();
describe('seekToLiveEdge', () => {
it('declares the full seekable window and seeks near the live edge (HOLD-BACK behind)', () => {
const ms = fakeMediaSource();
const el = { currentTime: 0 } as HTMLMediaElement;
const el = fakeMediaElement();
const cleanup = run({ presentation: makePresentation(), trackId: 'v-1', mediaElement: el, mediaSource: ms });
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);
@@ -84,9 +115,9 @@ describe('seekToLiveEdge', () => {
it('does nothing until the MediaSource is open', () => {
const ms = fakeMediaSource('closed');
const el = { currentTime: 0 } as HTMLMediaElement;
const el = fakeMediaElement();
const cleanup = run({ presentation: makePresentation(), trackId: 'v-1', mediaElement: el, mediaSource: ms });
const { cleanup } = run({ presentation: makePresentation(), trackId: 'v-1', mediaElement: el, mediaSource: ms });
expect(ms.setLiveSeekableRange).not.toHaveBeenCalled();
expect(el.currentTime).toBe(0);
@@ -96,14 +127,14 @@ describe('seekToLiveEdge', () => {
it('no-ops for a complete (finite-duration) playlist — VoD / ended live', () => {
const ms = fakeMediaSource();
const el = { currentTime: 0 } as HTMLMediaElement;
const el = fakeMediaElement();
const presentation = makePresentation();
// Complete playlist → parser sets a finite Track.duration.
const video = presentation.selectionSets[0]!.switchingSets[0]!.tracks[0] as VideoTrack;
video.duration = 110;
const cleanup = run({ presentation, trackId: 'v-1', mediaElement: el, mediaSource: ms });
const { cleanup } = run({ presentation, trackId: 'v-1', mediaElement: el, mediaSource: ms });
expect(ms.setLiveSeekableRange).not.toHaveBeenCalled();
expect(el.currentTime).toBe(0);
@@ -113,13 +144,148 @@ describe('seekToLiveEdge', () => {
it('no-ops without a resolved presentation or selected track', () => {
const ms = fakeMediaSource();
const el = { currentTime: 0 } as HTMLMediaElement;
const el = fakeMediaElement();
const cleanup = run({ presentation: undefined, trackId: undefined, mediaElement: el, mediaSource: ms });
const { cleanup } = run({ presentation: undefined, trackId: undefined, mediaElement: el, mediaSource: ms });
expect(ms.setLiveSeekableRange).not.toHaveBeenCalled();
expect(el.currentTime).toBe(0);
cleanup();
});
describe('live-window playhead guard', () => {
function started(config?: SeekToLiveEdgeConfig) {
const ms = fakeMediaSource();
const el = fakeMediaElement();
const { cleanup, state } = run({
presentation: makePresentation(),
trackId: 'v-1',
mediaElement: el,
mediaSource: ms,
config,
});
// Initial entry seeked into the window at the live edge (104). Window [100, 110].
expect(el.currentTime).toBe(104);
return { el, ms, state, cleanup };
}
it('leaves the playhead alone when playing inside the window', () => {
const { el, cleanup } = started();
el.paused = false;
el.currentTime = 106; // within [100, 110]
el.dispatchEvent(new Event('timeupdate'));
expect(el.currentTime).toBe(106);
cleanup();
});
it('repositions to the live edge when the playhead falls behind the window start (playing)', () => {
const { el, cleanup } = started();
el.paused = false;
el.currentTime = 90; // fell behind windowStart (100)
el.dispatchEvent(new Event('playing'));
expect(el.currentTime).toBe(104);
cleanup();
});
it('repositions to the live edge when the playhead overruns the window end (playing)', () => {
const { el, cleanup } = started();
el.paused = false;
el.currentTime = 120; // past windowEnd (110)
el.dispatchEvent(new Event('timeupdate'));
expect(el.currentTime).toBe(104);
cleanup();
});
it('does not reposition while paused; repositions on resume', () => {
const { el, cleanup } = started();
el.currentTime = 90; // window slid past while paused
el.paused = true;
el.dispatchEvent(new Event('timeupdate'));
expect(el.currentTime).toBe(90); // paused → untouched
el.paused = false;
el.dispatchEvent(new Event('playing'));
expect(el.currentTime).toBe(104); // resume snaps into the window
cleanup();
});
it('does not yank an in-window DVR scrub-back (playing)', () => {
const { el, cleanup } = started();
el.paused = false;
el.currentTime = 102; // user scrubbed back, still within [100, 110]
el.dispatchEvent(new Event('seeked'));
expect(el.currentTime).toBe(102);
cleanup();
});
it('does not reposition while a seek is in progress; repositions once it settles', () => {
const { el, cleanup } = started();
el.paused = false;
el.currentTime = 90;
el.seeking = true;
el.dispatchEvent(new Event('timeupdate'));
expect(el.currentTime).toBe(90); // seek in flight → untouched
el.seeking = false;
el.dispatchEvent(new Event('seeked'));
expect(el.currentTime).toBe(104);
cleanup();
});
it('tolerates a sub-threshold boundary excursion without a jitter seek', () => {
const { el, cleanup } = started();
el.paused = false;
el.currentTime = 99.95; // within REPOSITION_TOLERANCE (0.1) of windowStart 100
el.dispatchEvent(new Event('timeupdate'));
expect(el.currentTime).toBe(99.95); // no jitter seek
el.currentTime = 99.85; // beyond tolerance (< 100 0.1)
el.dispatchEvent(new Event('timeupdate'));
expect(el.currentTime).toBe(104);
cleanup();
});
it('does not reposition while paused as the window slides across reloads; snaps in on resume', async () => {
const { el, state, cleanup } = started();
el.paused = true;
// Window slides forward past the frozen paused playhead over several reloads.
state.presentation.set(makePresentation(200, 100));
await flush();
state.presentation.set(makePresentation(300, 150));
await flush();
expect(el.currentTime).toBe(104); // still untouched while paused
el.paused = false;
el.dispatchEvent(new Event('playing'));
// New window [300, 310] → live edge 310 6 = 304.
expect(el.currentTime).toBe(304);
cleanup();
});
it('repositions on the window-update re-fire when playback has stalled behind the window', async () => {
const { el, state, cleanup } = started();
el.paused = false; // a stall is not a pause; currentTime is frozen at 104
el.readyState = 2; // HAVE_CURRENT_DATA — buffer drained
// The window slides forward via reloads while currentTime stays frozen.
state.presentation.set(makePresentation(200, 100));
await flush();
// The effect's window-update re-fire (not timeupdate, which is silent during
// a stall) catches it: 104 < new windowStart 200 → snap to new live edge 204.
expect(el.currentTime).toBe(204);
cleanup();
});
it('does not implement the on-resume (edge-only) policy yet — no reposition', () => {
const { el, cleanup } = started({ repositionPolicy: 'on-resume' });
el.paused = false;
el.currentTime = 90; // would snap to edge under window-exit
el.dispatchEvent(new Event('playing'));
expect(el.currentTime).toBe(90); // on-resume is a future variant; guard is inert
cleanup();
});
});
});