From bbaeb52ed88177b33791e89acb91c2cc6672b062 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Mon, 15 Jun 2026 13:00:12 -0700 Subject: [PATCH] feat(spf): seekToLiveEdge behavior + wire into live engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../behaviors/dom/seek-to-live-edge.ts | 68 +++++++++++++++++++ .../dom/tests/seek-to-live-edge.test.ts | 47 +++++++++++++ .../src/playback/engines/live-hls/engine.ts | 5 ++ 3 files changed, 120 insertions(+) create mode 100644 packages/spf/src/playback/behaviors/dom/seek-to-live-edge.ts create mode 100644 packages/spf/src/playback/behaviors/dom/tests/seek-to-live-edge.test.ts diff --git a/packages/spf/src/playback/behaviors/dom/seek-to-live-edge.ts b/packages/spf/src/playback/behaviors/dom/seek-to-live-edge.ts new file mode 100644 index 00000000..0afdcd4a --- /dev/null +++ b/packages/spf/src/playback/behaviors/dom/seek-to-live-edge.ts @@ -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 }; +}): () => 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, +}); diff --git a/packages/spf/src/playback/behaviors/dom/tests/seek-to-live-edge.test.ts b/packages/spf/src/playback/behaviors/dom/tests/seek-to-live-edge.test.ts new file mode 100644 index 00000000..26384e96 --- /dev/null +++ b/packages/spf/src/playback/behaviors/dom/tests/seek-to-live-edge.test.ts @@ -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(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(); + }); +}); diff --git a/packages/spf/src/playback/engines/live-hls/engine.ts b/packages/spf/src/playback/engines/live-hls/engine.ts index 65661485..e8d94069 100644 --- a/packages/spf/src/playback/engines/live-hls/engine.ts +++ b/packages/spf/src/playback/engines/live-hls/engine.ts @@ -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,