refactor(spf): drive the live-window guard off window updates + a play listener

Rework seek-to-live-edge's guard effect: read the live edge first (the only
tracked dep) so window-update re-fires survive the paused bail, reposition on
each window slide (catches a stall, where timeupdate is silent), and add a
single `play` listener for immediate reposition on resume — `play`, not
`playing`, since after a long pause the playhead sits behind the window at an
unseekable position where `playing` never fires. Drop the now-unused
repositionPolicy seam; the live-edge-only use-case reintroduces it when real.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-06-25 10:00:22 -07:00
co-authored by Claude Opus 4.8
parent 2fa3e5c311
commit c60fbb5606
2 changed files with 63 additions and 91 deletions
@@ -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<SeekToLiveEdgeFsmState | 'destroying' | 'destroyed'> {
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);
},
},
},
@@ -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<Pick<FakeMediaElement, 'currentTime' | 'paused' | 'seeking' | 'readyState'>> = {}
@@ -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();
});
});
});