refactor(spf): convert seek-to-live-edge to a reactor

Replace the single effect with a two-state machine (inactive ↔ live) gated on
deriveState (mediaElement + published-hence-open MediaSource + a derivable live
edge). The one-time seek becomes the live state's entry; the window-exit guard
becomes its effects. Entry-once-per-live-entry dissolves the closure `seeked`
latch — a source change drops to inactive and re-entry re-seeks. Drop the
MediaSource readyState check: setupMediaSource publishes the signal only once
open, so the signal's presence is the open gate.

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 ada3100209
commit 2fa3e5c311
2 changed files with 116 additions and 71 deletions
@@ -1,34 +1,44 @@
/**
* Seek the playhead into the live window and keep it there:
* Keep the playhead in the live window, via a two-state reactor gated on the
* preconditions for "we know where live is":
*
* 1. A one-time seek of `currentTime` to the target live latency behind the
* edge (clamped to the window start) so playback begins near the edge and
* the segment loader dispatches an in-window range rather than starting at
* the back of the DVR window. The latency comes from the injected
* `resolveLiveLatency` seam (HLS: `HOLD-BACK`), keeping this behavior free of
* any delivery-format specifics.
* 2. A live-window playhead guard: while playing (`!paused && !seeking &&
* readyState > 0`), 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).
* - **`inactive`** — no media element, no (published, hence open) MediaSource,
* or no live edge (`getLiveEdge` is `null`: VOD, ended, or unresolved). Idle.
* - **`live`** — preconditions met. `entry` seeks `currentTime` once to the
* target live latency behind the edge (clamped to the window start) so
* playback begins near the edge and the loader dispatches an in-window range;
* `effects` runs the window-exit guard.
*
* The live window comes from `liveWindowFromState` (the shared derivation —
* video track when present, else audio); this behavior is inert when it returns
* `null` (VoD / ended live). Declaring the
* seekable range is a separate concern — see `sync-live-seekable-range`, which
* is composed *before* this behavior so the range exists before we seek into
* it (a seek outside `seekable` is clamped). The `mediaSource` open-gate here
* is the read that ties the seek to that declared range being available.
* The two pieces split along the axis a future DVR / EVENT mode will care about:
* the **one-time seek** (`entry`) is the *live-specific* behavior — jump to the
* edge on load; a DVR mode makes it conditional (start in place). The
* **window-exit guard** (`effects`) is the *general windowed-live* behavior —
* applies to sliding-window live, DVR, and EVENT alike. Because the seek is an
* `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).
*
* The latency comes from the injected `resolveLiveLatency` seam (HLS:
* `HOLD-BACK`), so this behavior carries no delivery-format specifics. The
* MediaSource precondition ties the seek to the declared seekable range:
* `sync-live-seekable-range` (composed before this) declares it while the
* MediaSource is open, so seeks land in-window (a seek outside `seekable` is
* clamped). `setupMediaSource` publishes `context.mediaSource` only once open,
* so the signal's *presence* is the open gate — no `readyState` re-check.
*/
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';
import { createMachineReactor, type Reactor } from '../../../core/reactors/create-machine-reactor';
import { computed, type ReadonlySignal } from '../../../core/signals/primitives';
import type { MaybeResolvedPresentation } from '../../../media/types';
import { getLiveEdge, type ResolveLiveLatency } from '../../primitives/live-window';
import { getLiveEdge, type LiveEdge, type ResolveLiveLatency } from '../../primitives/live-window';
/**
* Tolerance (seconds) around the window edges before the guard repositions, so
@@ -69,6 +79,20 @@ export interface SeekToLiveEdgeConfig {
resolveLiveLatency?: ResolveLiveLatency;
}
type SeekToLiveEdgeFsmState = 'inactive' | 'live';
/**
* `'live'` once the seek preconditions hold: a media element, a (published →
* open) MediaSource, and a derivable live edge. `'inactive'` otherwise.
*/
function deriveState(
mediaElement: HTMLMediaElement | undefined,
mediaSource: MediaSource | undefined,
edge: LiveEdge | null
): SeekToLiveEdgeFsmState {
return mediaElement && mediaSource && edge ? 'live' : 'inactive';
}
function seekToLiveEdgeSetup({
state,
context,
@@ -84,53 +108,66 @@ function seekToLiveEdgeSetup({
mediaSource: ReadonlySignal<SeekToLiveEdgeContext['mediaSource']>;
};
config?: SeekToLiveEdgeConfig;
}): () => void {
}): Reactor<SeekToLiveEdgeFsmState | 'destroying' | 'destroyed'> {
const repositionPolicy = config?.repositionPolicy ?? 'window-exit';
let seeked = false;
return effect(() => {
const mediaElement = context.mediaElement.get();
const mediaSource = context.mediaSource.get();
const edge = getLiveEdge({ state, config });
if (!mediaElement || !edge) return;
// Gate on the seekable range being declarable/declared (see file JSDoc):
// sync-live-seekable-range runs first while open, so seeks land in-window.
if (!mediaSource || mediaSource.readyState !== 'open') return;
const derivedStateSignal = computed(() =>
deriveState(context.mediaElement.get(), context.mediaSource.get(), getLiveEdge({ state, config }))
);
const { start: windowStart, end: windowEnd, liveEdgeStart } = edge;
return createMachineReactor<SeekToLiveEdgeFsmState>({
initial: 'inactive',
monitor: () => derivedStateSignal.get(),
states: {
inactive: {},
// 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: {
// One-time seek into the window so the loader dispatches an in-window
// range and preload shows the right frame. Fires once per entry into
// `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;
}
},
// 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();
};
// 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.
effects: () => {
const mediaElement = context.mediaElement.get();
const edge = getLiveEdge({ state, config });
if (!mediaElement || !edge) return;
const { start: windowStart, end: windowEnd, liveEdgeStart } = edge;
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) {
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();
};
},
},
},
});
}
@@ -93,8 +93,10 @@ function run(opts: {
// The engine injects this seam; here a fixed 6s latency (HLS 3 × targetDuration(2))
// stands in so the live-edge start lands at windowEnd 6.
const config = { resolveLiveLatency: () => 6, ...opts.config };
const cleanup = seekToLiveEdge.setup({ state, context, config }) as () => void;
return { cleanup, state, context };
// The manual `Behavior<>` literal widens the setup return to `BehaviorCleanup`;
// narrow back to the reactor's destroy handle for teardown.
const reactor = seekToLiveEdge.setup({ state, context, config }) as { destroy: () => void };
return { cleanup: () => reactor.destroy(), state, context };
}
// Let the effect re-run after a signal write (effects re-run on a microtask).
@@ -113,11 +115,17 @@ describe('seekToLiveEdge', () => {
cleanup();
});
it('does not seek until the MediaSource is open', () => {
const ms = fakeMediaSource('closed');
it('does not seek until the MediaSource is published (open)', () => {
const el = fakeMediaElement();
const { cleanup } = run({ presentation: makePresentation(), trackId: 'v-1', mediaElement: el, mediaSource: ms });
// `setupMediaSource` publishes `context.mediaSource` only once open, so an
// unpublished (absent) MediaSource is the "not ready" gate.
const { cleanup } = run({
presentation: makePresentation(),
trackId: 'v-1',
mediaElement: el,
mediaSource: undefined,
});
expect(el.currentTime).toBe(0);