From 6780a44ccb2e2b862358905e261caa82565d3c4e Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Mon, 15 Jun 2026 13:26:13 -0700 Subject: [PATCH] fix(spf): make seekToLiveEdge model-driven (declare seekable range + seek) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The buffered-driven version couldn't fire at cold-start: live segments append at native PTS, so currentTime=0 is outside the buffered window, but the loader only fetches segments overlapping [currentTime, currentTime+bufferDuration], so nothing loads until the playhead is in the window — a deadlock (no buffer → no seek → no load). Now derive the window from the selected video track's anchored timeline: setLiveSeekableRange(windowStart, windowEnd) (re-declared as the window slides) and seek currentTime to the window start once. Verified end-to-end against a live Mux CMAF/LL-HLS stream: automatic playback, native-PTS buffered range matches the seq-0-anchored model (Mux tfdt is stream-relative). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../behaviors/dom/seek-to-live-edge.ts | 124 +++++++++++------- .../dom/tests/seek-to-live-edge.test.ts | 109 +++++++++++---- 2 files changed, 160 insertions(+), 73 deletions(-) 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 index 0afdcd4a..6a153a7a 100644 --- a/packages/spf/src/playback/behaviors/dom/seek-to-live-edge.ts +++ b/packages/spf/src/playback/behaviors/dom/seek-to-live-edge.ts @@ -1,68 +1,104 @@ /** - * Seek the playhead into the live window once segments are buffered. + * Enter the live window: declare the seekable range and seek the playhead in. * - * 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. + * 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): * - * 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. + * 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 the window start, so the loader + * dispatches an in-window range and playback can begin. * - * Starts at the window start (simplest, guaranteed playable). Live-edge - * latency tuning (start near `buffered.end`) is a follow-up. + * 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 { defineBehavior } from '../../../core/composition/create-composition'; +import type { Behavior } from '../../../core/composition/create-composition'; import { effect } from '../../../core/signals/effect'; import type { ReadonlySignal } from '../../../core/signals/primitives'; +import { isResolvedPresentation, isResolvedTrack, type MaybeResolvedPresentation } from '../../../media/types'; +import { findTrack } from '../../../media/utils/tracks'; + +export interface SeekToLiveEdgeState { + presentation?: MaybeResolvedPresentation; + selectedVideoTrackId?: string; +} export interface SeekToLiveEdgeContext { mediaElement?: HTMLMediaElement | undefined; + mediaSource?: MediaSource; } function seekToLiveEdgeSetup({ + state, context, }: { - context: { mediaElement: ReadonlySignal }; + state: { + presentation: ReadonlySignal; + selectedVideoTrackId?: ReadonlySignal; + }; + context: { + mediaElement: ReadonlySignal; + mediaSource: ReadonlySignal; + }; }): () => void { + let seeked = false; + return effect(() => { const mediaElement = context.mediaElement.get(); - if (!mediaElement) return; + 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; - 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; - } - }; + const track = findTrack(presentation, 'video', trackId); + if (!track || !isResolvedTrack(track) || track.segments.length === 0) return; - 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(); - }; + 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. + mediaSource.setLiveSeekableRange(windowStart, windowEnd); + } catch { + // readyState raced closed, or duration set rejected — retried on the next window change. + return; + } + + // Seek into the window once so the loader dispatches an in-window range. + if (!seeked && mediaElement.currentTime < windowStart) { + mediaElement.currentTime = windowStart; + seeked = true; + } }); } -export const seekToLiveEdge = defineBehavior({ - stateKeys: [], - contextKeys: ['mediaElement'], +/** + * Manual `Behavior<>` literal (like `anchorLiveTracks` / + * `calculatePresentationDuration`): declares only `presentation` in stateKeys + * while reading `selectedVideoTrackId` defensively (contributed by + * `switchVideoTrack`), so it composes without a stateKeys/type conflict. + */ +export const seekToLiveEdge: Behavior< + { presentation: ReadonlySignal }, + { + mediaElement: ReadonlySignal; + mediaSource: ReadonlySignal; + }, + object +> = { + stateKeys: ['presentation'], + contextKeys: ['mediaElement', 'mediaSource'], 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 index 26384e96..6ff2c89b 100644 --- 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 @@ -1,47 +1,98 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { signal } from '../../../../core/signals/primitives'; +import type { MaybeResolvedPresentation, Presentation, VideoTrack } from '../../../../media/types'; 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 }; +function makePresentation(): 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: [ + { id: 'segment-50', url: '50.m4s', duration: 2, startTime: 100 }, + { id: 'segment-51', url: '51.m4s', duration: 2, startTime: 102 }, + { id: 'segment-52', url: '52.m4s', duration: 2, startTime: 104 }, + ], + }; return { - currentTime, - buffered, - addEventListener() {}, - removeEventListener() {}, - } as unknown as HTMLMediaElement; + 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 run(media: HTMLMediaElement): () => void { - return seekToLiveEdge.setup({ - state: {}, - context: { mediaElement: signal(media) }, - config: {}, - }) as () => void; +function fakeMediaSource(readyState: MediaSource['readyState'] = 'open') { + return { + readyState, + duration: Number.NaN, + setLiveSeekableRange: vi.fn(), + } as unknown as MediaSource & { setLiveSeekableRange: ReturnType }; +} + +function run(opts: { + presentation?: MaybeResolvedPresentation; + trackId?: string; + mediaElement?: HTMLMediaElement; + mediaSource?: MediaSource; +}) { + // Built as vars (not inline literals) so the defensively-read + // `selectedVideoTrackId` isn't rejected by the excess-property check against + // the behavior's declared `{ presentation }` state slice. + const state = { + presentation: signal(opts.presentation), + selectedVideoTrackId: signal(opts.trackId), + }; + const context = { + mediaElement: signal(opts.mediaElement), + mediaSource: signal(opts.mediaSource), + }; + return seekToLiveEdge.setup({ state, context, 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); + it('declares the live seekable range and seeks into the window', () => { + const ms = fakeMediaSource(); + const el = { currentTime: 0 } as HTMLMediaElement; + + const cleanup = run({ presentation: makePresentation(), trackId: 'v-1', mediaElement: el, mediaSource: ms }); + + // window = [first.startTime, last.startTime + last.duration] = [100, 106]. + expect(ms.setLiveSeekableRange).toHaveBeenCalledWith(100, 106); + expect(ms.duration).toBe(Number.POSITIVE_INFINITY); + expect(el.currentTime).toBe(100); + 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); + it('does nothing until the MediaSource is open', () => { + const ms = fakeMediaSource('closed'); + const el = { currentTime: 0 } as HTMLMediaElement; + + const cleanup = run({ presentation: makePresentation(), trackId: 'v-1', mediaElement: el, mediaSource: ms }); + + expect(ms.setLiveSeekableRange).not.toHaveBeenCalled(); + expect(el.currentTime).toBe(0); + cleanup(); }); - it('does not seek when nothing is buffered yet', () => { - const media = makeFakeMedia(null, 0); - const cleanup = run(media); - expect(media.currentTime).toBe(0); + it('no-ops without a resolved presentation or selected track', () => { + const ms = fakeMediaSource(); + const el = { currentTime: 0 } as HTMLMediaElement; + + const cleanup = run({ presentation: undefined, trackId: undefined, mediaElement: el, mediaSource: ms }); + + expect(ms.setLiveSeekableRange).not.toHaveBeenCalled(); + expect(el.currentTime).toBe(0); + cleanup(); }); });