feat(spf): seekToLiveEdge behavior + wire into live engine

Live segments append at native PTS, so the buffered range sits at a large
timestamp while currentTime starts at 0 — no data at 0, readyState never
advances, playback can't start. seekToLiveEdge watches for the buffered range
and seeks the playhead into it (window start) once present. Listens to events
that fire without data at currentTime (loadedmetadata/durationchange/progress);
once per source. Composed into createLiveHlsEngine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-06-25 09:58:46 -07:00
co-authored by Claude Opus 4.8
parent bd6ea19f8d
commit bbaeb52ed8
3 changed files with 120 additions and 0 deletions
@@ -0,0 +1,68 @@
/**
* Seek the playhead into the live window once segments are buffered.
*
* Live segments append at their native PTS, so the buffered range sits at a
* large timestamp while `currentTime` starts at 0 — there's no media at 0, so
* `readyState` never advances and playback can't start. This watches for the
* buffered range to appear and, while the playhead is still before it, seeks
* `currentTime` into the window (its start) so playback can begin.
*
* Fires once per source. `readyState`-gated events (`canplay`/`loadeddata`)
* can't be relied on (they need data *at* `currentTime`), so it listens to
* events that fire regardless — `loadedmetadata`, `durationchange`, `progress`
* — plus an initial check; `emptied` resets it for a reused element.
*
* Starts at the window start (simplest, guaranteed playable). Live-edge
* latency tuning (start near `buffered.end`) is a follow-up.
*/
import { listen } from '@videojs/utils/dom';
import { defineBehavior } from '../../../core/composition/create-composition';
import { effect } from '../../../core/signals/effect';
import type { ReadonlySignal } from '../../../core/signals/primitives';
export interface SeekToLiveEdgeContext {
mediaElement?: HTMLMediaElement | undefined;
}
function seekToLiveEdgeSetup({
context,
}: {
context: { mediaElement: ReadonlySignal<SeekToLiveEdgeContext['mediaElement']> };
}): () => void {
return effect(() => {
const mediaElement = context.mediaElement.get();
if (!mediaElement) return;
let seeked = false;
const trySeek = () => {
if (seeked) return;
const { buffered } = mediaElement;
if (buffered.length === 0) return;
const start = buffered.start(0);
// Native-PTS live gap: playhead sits before the buffered window.
if (mediaElement.currentTime < start) {
mediaElement.currentTime = start;
seeked = true;
}
};
trySeek();
const removers = [
listen(mediaElement, 'loadedmetadata', trySeek),
listen(mediaElement, 'durationchange', trySeek),
listen(mediaElement, 'progress', trySeek),
listen(mediaElement, 'emptied', () => {
seeked = false;
}),
];
return () => {
for (const remove of removers) remove();
};
});
}
export const seekToLiveEdge = defineBehavior({
stateKeys: [],
contextKeys: ['mediaElement'],
setup: seekToLiveEdgeSetup,
});
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { signal } from '../../../../core/signals/primitives';
import { seekToLiveEdge } from '../seek-to-live-edge';
function makeFakeMedia(bufferedStart: number | null, currentTime: number): HTMLMediaElement {
const buffered =
bufferedStart === null
? { length: 0, start: () => 0, end: () => 0 }
: { length: 1, start: () => bufferedStart, end: () => bufferedStart + 10 };
return {
currentTime,
buffered,
addEventListener() {},
removeEventListener() {},
} as unknown as HTMLMediaElement;
}
function run(media: HTMLMediaElement): () => void {
return seekToLiveEdge.setup({
state: {},
context: { mediaElement: signal<HTMLMediaElement | undefined>(media) },
config: {},
}) as () => void;
}
describe('seekToLiveEdge', () => {
it('seeks the playhead to the buffered window start when it sits before it', () => {
const media = makeFakeMedia(1000, 0); // native-PTS gap: currentTime 0, buffered at 1000
const cleanup = run(media);
expect(media.currentTime).toBe(1000);
cleanup();
});
it('does not seek when the playhead is already inside the buffered window', () => {
const media = makeFakeMedia(1000, 1005);
const cleanup = run(media);
expect(media.currentTime).toBe(1005);
cleanup();
});
it('does not seek when nothing is buffered yet', () => {
const media = makeFakeMedia(null, 0);
const cleanup = run(media);
expect(media.currentTime).toBe(0);
cleanup();
});
});
@@ -20,6 +20,7 @@ import { calculatePresentationDuration } from '../../behaviors/calculate-present
import { deriveCdnPriority } from '../../behaviors/derive-cdn-priority';
import { endOfStream } from '../../behaviors/dom/end-of-stream';
import { loadAudioSegments, loadVideoSegments } from '../../behaviors/dom/load-segments';
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 { trackCurrentTime } from '../../behaviors/dom/track-current-time';
@@ -102,6 +103,10 @@ export function createLiveHlsEngine(
loadVideoSegments,
loadAudioSegments,
// Seek the playhead into the (native-PTS) buffered window once segments
// land, so playback can start.
seekToLiveEdge,
// No-op for unbounded live (no EXT-X-ENDLIST), composed for parity.
endOfStream,