mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
1cb1297c07
commit
85e300e999
@@ -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<SeekToLiveEdgeState['presentation']>;
|
||||
@@ -60,7 +90,9 @@ function seekToLiveEdgeSetup({
|
||||
mediaElement: ReadonlySignal<SeekToLiveEdgeContext['mediaElement']>;
|
||||
mediaSource: ReadonlySignal<SeekToLiveEdgeContext['mediaSource']>;
|
||||
};
|
||||
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<SeekToLiveEdgeContext['mediaElement']>;
|
||||
mediaSource: ReadonlySignal<SeekToLiveEdgeContext['mediaSource']>;
|
||||
},
|
||||
object
|
||||
SeekToLiveEdgeConfig
|
||||
> = {
|
||||
stateKeys: ['presentation'],
|
||||
contextKeys: ['mediaElement', 'mediaSource'],
|
||||
|
||||
@@ -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<typeof vi.fn> };
|
||||
}
|
||||
|
||||
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<Pick<FakeMediaElement, 'currentTime' | 'paused' | 'seeking' | 'readyState'>> = {}
|
||||
): 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<HTMLMediaElement | undefined>(opts.mediaElement),
|
||||
mediaSource: signal<MediaSource | undefined>(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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user