mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
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:
co-authored by
Claude Opus 4.8
parent
2fa3e5c311
commit
c60fbb5606
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user