From 85e300e9993b674931017eaccc09a14b8075a5fe Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Tue, 23 Jun 2026 10:20:05 -0700 Subject: [PATCH] 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) --- .../behaviors/dom/seek-to-live-edge.ts | 65 +++++- .../dom/tests/seek-to-live-edge.test.ts | 204 ++++++++++++++++-- 2 files changed, 247 insertions(+), 22 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 b75621be..a7e56bf4 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,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; @@ -60,7 +90,9 @@ function seekToLiveEdgeSetup({ mediaElement: ReadonlySignal; mediaSource: ReadonlySignal; }; + 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; mediaSource: ReadonlySignal; }, - object + SeekToLiveEdgeConfig > = { stateKeys: ['presentation'], contextKeys: ['mediaElement', 'mediaSource'], 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 b23c5aec..8e5f5460 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 @@ -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 }; } +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> = {} +): 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(opts.mediaElement), mediaSource: signal(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(); + }); + }); });