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 8b5d69a6..1d778429 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 @@ -17,13 +17,16 @@ * `entry`, it fires once per entry into `live`; a source change exits to * `inactive`, so the next source re-seeks (no closure latch to reset). * - * Window-exit guard: while playing (`!paused && !seeking && readyState > 0`), - * reposition to the live edge when the playhead 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 the effect's - * window-update re-fire, since `timeupdate` stops once a stall freezes - * `currentTime`). In-window pause / DVR scrub-back are left untouched (the - * `window-exit` reposition policy). + * Window-exit guard: while playing (not paused, not mid-seek), reposition to the + * live edge when the playhead has fallen behind the window start. Two triggers: + * the **window-update re-fire** (the guard reads the live edge, so each reload / + * slide re-runs it — this catches a stall, where `timeupdate` stops but the + * playlist keeps reloading) and a **`play` listener** for immediate reactivity on + * resume, since the reload interval can be seconds. `play`, not `playing`: after + * a long pause the playhead sits behind the window at an unseekable position, + * where the browser stalls and `playing` never fires; `play` fires on the + * paused→false transition regardless, so we snap before the stall. In-window + * pause / DVR scrub-back are left untouched. * * The latency comes from the injected `resolveLiveLatency` seam (HLS: * `HOLD-BACK`), so this behavior carries no delivery-format specifics. The @@ -36,7 +39,7 @@ import { listen } from '@videojs/utils/dom'; import type { Behavior } from '../../../core/composition/create-composition'; import { createMachineReactor, type Reactor } from '../../../core/reactors/create-machine-reactor'; -import { computed, type ReadonlySignal } from '../../../core/signals/primitives'; +import { computed, peek, type ReadonlySignal } from '../../../core/signals/primitives'; import type { MaybeResolvedPresentation } from '../../../media/types'; import { getLiveEdge, type LiveEdge, type ResolveLiveLatency } from '../../primitives/live-window'; @@ -46,15 +49,6 @@ import { getLiveEdge, type LiveEdge, type ResolveLiveLatency } from '../../primi */ 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; @@ -67,8 +61,6 @@ export interface SeekToLiveEdgeContext { } export interface SeekToLiveEdgeConfig { - /** Reposition policy for the live-window guard. Defaults to `'window-exit'`. */ - repositionPolicy?: LiveRepositionPolicy; /** * Resolve the target live latency (seconds the playhead should trail the live * edge) for the timeline-bearing track. Injected by the engine so the latency @@ -109,8 +101,6 @@ function seekToLiveEdgeSetup({ }; config?: SeekToLiveEdgeConfig; }): Reactor { - const repositionPolicy = config?.repositionPolicy ?? 'window-exit'; - const derivedStateSignal = computed(() => deriveState(context.mediaElement.get(), context.mediaSource.get(), getLiveEdge({ state, config })) ); @@ -127,44 +117,40 @@ function seekToLiveEdgeSetup({ // `live`; a source change exits to `inactive`, so a new source re-seeks. // Live-specific — a future DVR mode skips this (starts in place). entry: () => { - const mediaElement = context.mediaElement.get(); - const edge = getLiveEdge({ state, config }); - // The monitor guarantees both while in `live`. - if (!mediaElement || !edge) return; - if (mediaElement.currentTime < edge.liveEdgeStart) { - mediaElement.currentTime = edge.liveEdgeStart; + // The monitor guarantees a media element + live edge while in `live`. + const mediaElement = context.mediaElement.get()!; + const { liveEdgeStart } = getLiveEdge({ state, config })!; + if (mediaElement.currentTime < liveEdgeStart) { + mediaElement.currentTime = liveEdgeStart; } }, - // Window-exit guard. Re-fires on each window update (the primary - // trigger — `timeupdate` stops while a stall freezes `currentTime`) and - // on the secondary media-event listeners. Tracks `mediaElement` too, so - // the listeners re-bind if the element identity changes. + // Window-exit guard. Repositions to the live edge when the playhead has + // fallen behind the window start, while playing. Two triggers: this + // effect's window-update re-fire (it reads the live edge, so each reload + // re-runs it — catches a stall, where `timeupdate` is silent) and a + // `play` listener for immediate reactivity on resume. effects: () => { - const mediaElement = context.mediaElement.get(); - const edge = getLiveEdge({ state, config }); - if (!mediaElement || !edge) return; - - const { start: windowStart, end: windowEnd, liveEdgeStart } = edge; + // Read the live edge first — the only tracked dependency — so window + // slides keep re-firing this effect even while paused. Bailing before + // it (on paused) would drop the dependency and the effect would go + // inert, missing the next slide. The monitor guarantees the edge while + // in `live`; the media element is read untracked (the window update, + // not element identity, is the re-fire trigger). + const { start: windowStart, liveEdgeStart } = getLiveEdge({ state, config })!; + const mediaElement = peek(context.mediaElement)!; const reposition = () => { - // `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) { + // Don't yank a paused viewer or fight an in-flight seek (DVR scrub-back). + if (mediaElement.paused || mediaElement.seeking) return; + if (mediaElement.currentTime < windowStart - REPOSITION_TOLERANCE) { mediaElement.currentTime = liveEdgeStart; } }; reposition(); - const removePlaying = listen(mediaElement, 'playing', reposition); - const removeTimeupdate = listen(mediaElement, 'timeupdate', reposition); - const removeSeeked = listen(mediaElement, 'seeked', reposition); - return () => { - removePlaying(); - removeTimeupdate(); - removeSeeked(); - }; + // `play` (not `playing`): a slid-past playhead is at an unseekable + // position where `playing` would stall and never fire; `play` fires on + // unpause regardless, so we snap before the stall. + return listen(mediaElement, 'play', reposition); }, }, }, 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 f47198c6..697d5a55 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 @@ -57,9 +57,9 @@ type FakeMediaElement = HTMLMediaElement & { }; /** - * 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). + * Event-capable fake: `seekToLiveEdge` attaches a `play` listener, 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> = {} @@ -160,7 +160,7 @@ describe('seekToLiveEdge', () => { }); describe('live-window playhead guard', () => { - function started(config?: SeekToLiveEdgeConfig) { + function started() { const ms = fakeMediaSource(); const el = fakeMediaElement(); const { cleanup, state } = run({ @@ -168,58 +168,53 @@ describe('seekToLiveEdge', () => { 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', () => { + it('leaves the playhead alone when playing inside the window (resume)', () => { const { el, cleanup } = started(); el.paused = false; el.currentTime = 106; // within [100, 110] - el.dispatchEvent(new Event('timeupdate')); + el.dispatchEvent(new Event('play')); expect(el.currentTime).toBe(106); cleanup(); }); - it('repositions to the live edge when the playhead falls behind the window start (playing)', () => { + it('repositions to the live edge on resume when the playhead is behind the window start', () => { const { el, cleanup } = started(); el.paused = false; el.currentTime = 90; // fell behind windowStart (100) - el.dispatchEvent(new Event('playing')); + el.dispatchEvent(new Event('play')); 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(); + it('does not reposition while paused as the window slides; repositions on resume', async () => { + const { el, state, cleanup } = started(); el.currentTime = 90; // window slid past while paused el.paused = true; - el.dispatchEvent(new Event('timeupdate')); + + // A window-update re-fire (playlist reload) while paused must not yank. + state.presentation.set(makePresentation()); + await flush(); expect(el.currentTime).toBe(90); // paused → untouched el.paused = false; - el.dispatchEvent(new Event('playing')); + el.dispatchEvent(new Event('play')); 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(); + it('does not yank an in-window DVR scrub-back across a window update', async () => { + const { el, state, cleanup } = started(); el.paused = false; el.currentTime = 102; // user scrubbed back, still within [100, 110] - el.dispatchEvent(new Event('seeked')); + + state.presentation.set(makePresentation()); // window unchanged → still in window + await flush(); expect(el.currentTime).toBe(102); cleanup(); }); @@ -229,11 +224,11 @@ describe('seekToLiveEdge', () => { el.paused = false; el.currentTime = 90; el.seeking = true; - el.dispatchEvent(new Event('timeupdate')); + el.dispatchEvent(new Event('play')); expect(el.currentTime).toBe(90); // seek in flight → untouched el.seeking = false; - el.dispatchEvent(new Event('seeked')); + el.dispatchEvent(new Event('play')); expect(el.currentTime).toBe(104); cleanup(); }); @@ -242,11 +237,11 @@ describe('seekToLiveEdge', () => { const { el, cleanup } = started(); el.paused = false; el.currentTime = 99.95; // within REPOSITION_TOLERANCE (0.1) of windowStart 100 - el.dispatchEvent(new Event('timeupdate')); + el.dispatchEvent(new Event('play')); expect(el.currentTime).toBe(99.95); // no jitter seek el.currentTime = 99.85; // beyond tolerance (< 100 − 0.1) - el.dispatchEvent(new Event('timeupdate')); + el.dispatchEvent(new Event('play')); expect(el.currentTime).toBe(104); cleanup(); }); @@ -263,7 +258,7 @@ describe('seekToLiveEdge', () => { expect(el.currentTime).toBe(104); // still untouched while paused el.paused = false; - el.dispatchEvent(new Event('playing')); + el.dispatchEvent(new Event('play')); // New window [300, 310] → live edge 310 − 6 = 304. expect(el.currentTime).toBe(304); cleanup(); @@ -278,19 +273,10 @@ describe('seekToLiveEdge', () => { 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. + // The window-update re-fire (not a media event — `timeupdate` is silent + // during a stall) catches it: 104 < new windowStart 200 → snap to 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(); - }); }); });