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,