mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
fix(spf): make seekToLiveEdge model-driven (declare seekable range + seek)
The buffered-driven version couldn't fire at cold-start: live segments append at native PTS, so currentTime=0 is outside the buffered window, but the loader only fetches segments overlapping [currentTime, currentTime+bufferDuration], so nothing loads until the playhead is in the window — a deadlock (no buffer → no seek → no load). Now derive the window from the selected video track's anchored timeline: setLiveSeekableRange(windowStart, windowEnd) (re-declared as the window slides) and seek currentTime to the window start once. Verified end-to-end against a live Mux CMAF/LL-HLS stream: automatic playback, native-PTS buffered range matches the seq-0-anchored model (Mux tfdt is stream-relative). 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
4a58e85015
commit
6780a44ccb
@@ -1,68 +1,104 @@
|
|||||||
/**
|
/**
|
||||||
* Seek the playhead into the live window once segments are buffered.
|
* Enter the live window: declare the seekable range and seek the playhead in.
|
||||||
*
|
*
|
||||||
* Live segments append at their native PTS, so the buffered range sits at a
|
* Live segments append at their native PTS, so the buffered window sits at a
|
||||||
* large timestamp while `currentTime` starts at 0 — there's no media at 0, so
|
* large timestamp while `currentTime` starts at 0. Two problems follow, both
|
||||||
* `readyState` never advances and playback can't start. This watches for the
|
* solved here from the *model* (not from `buffered`, which is empty at
|
||||||
* buffered range to appear and, while the playhead is still before it, seeks
|
* cold-start — the segment loader only fetches segments overlapping
|
||||||
* `currentTime` into the window (its start) so playback can begin.
|
* `[currentTime, currentTime + bufferDuration]`, so until the playhead is in
|
||||||
|
* the window nothing loads):
|
||||||
*
|
*
|
||||||
* Fires once per source. `readyState`-gated events (`canplay`/`loadeddata`)
|
* 1. `MediaSource.setLiveSeekableRange(windowStart, windowEnd)` — derived from
|
||||||
* can't be relied on (they need data *at* `currentTime`), so it listens to
|
* the selected video track's anchored segment timeline — so the window is
|
||||||
* events that fire regardless — `loadedmetadata`, `durationchange`, `progress`
|
* seekable (kept current as the window slides across reloads).
|
||||||
* — plus an initial check; `emptied` resets it for a reused element.
|
* 2. A one-time seek of `currentTime` to the window start, so the loader
|
||||||
|
* dispatches an in-window range and playback can begin.
|
||||||
*
|
*
|
||||||
* Starts at the window start (simplest, guaranteed playable). Live-edge
|
* Reads the *selected video track* timeline (anchored to ≈ native PTS by
|
||||||
* latency tuning (start near `buffered.end`) is a follow-up.
|
* `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 { defineBehavior } from '../../../core/composition/create-composition';
|
|
||||||
import { effect } from '../../../core/signals/effect';
|
import { effect } from '../../../core/signals/effect';
|
||||||
import type { ReadonlySignal } from '../../../core/signals/primitives';
|
import type { ReadonlySignal } from '../../../core/signals/primitives';
|
||||||
|
import { isResolvedPresentation, isResolvedTrack, type MaybeResolvedPresentation } from '../../../media/types';
|
||||||
|
import { findTrack } from '../../../media/utils/tracks';
|
||||||
|
|
||||||
|
export interface SeekToLiveEdgeState {
|
||||||
|
presentation?: MaybeResolvedPresentation;
|
||||||
|
selectedVideoTrackId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SeekToLiveEdgeContext {
|
export interface SeekToLiveEdgeContext {
|
||||||
mediaElement?: HTMLMediaElement | undefined;
|
mediaElement?: HTMLMediaElement | undefined;
|
||||||
|
mediaSource?: MediaSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
function seekToLiveEdgeSetup({
|
function seekToLiveEdgeSetup({
|
||||||
|
state,
|
||||||
context,
|
context,
|
||||||
}: {
|
}: {
|
||||||
context: { mediaElement: ReadonlySignal<SeekToLiveEdgeContext['mediaElement']> };
|
state: {
|
||||||
|
presentation: ReadonlySignal<SeekToLiveEdgeState['presentation']>;
|
||||||
|
selectedVideoTrackId?: ReadonlySignal<SeekToLiveEdgeState['selectedVideoTrackId']>;
|
||||||
|
};
|
||||||
|
context: {
|
||||||
|
mediaElement: ReadonlySignal<SeekToLiveEdgeContext['mediaElement']>;
|
||||||
|
mediaSource: ReadonlySignal<SeekToLiveEdgeContext['mediaSource']>;
|
||||||
|
};
|
||||||
}): () => void {
|
}): () => void {
|
||||||
|
let seeked = false;
|
||||||
|
|
||||||
return effect(() => {
|
return effect(() => {
|
||||||
const mediaElement = context.mediaElement.get();
|
const mediaElement = context.mediaElement.get();
|
||||||
if (!mediaElement) return;
|
const mediaSource = context.mediaSource.get();
|
||||||
|
const presentation = state.presentation.get();
|
||||||
|
const trackId = state.selectedVideoTrackId?.get();
|
||||||
|
if (!mediaElement || !mediaSource || !isResolvedPresentation(presentation) || !trackId) return;
|
||||||
|
if (mediaSource.readyState !== 'open') return;
|
||||||
|
|
||||||
let seeked = false;
|
const track = findTrack(presentation, 'video', trackId);
|
||||||
const trySeek = () => {
|
if (!track || !isResolvedTrack(track) || track.segments.length === 0) return;
|
||||||
if (seeked) return;
|
|
||||||
const { buffered } = mediaElement;
|
|
||||||
if (buffered.length === 0) return;
|
|
||||||
const start = buffered.start(0);
|
|
||||||
// Native-PTS live gap: playhead sits before the buffered window.
|
|
||||||
if (mediaElement.currentTime < start) {
|
|
||||||
mediaElement.currentTime = start;
|
|
||||||
seeked = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
trySeek();
|
const { segments } = track;
|
||||||
const removers = [
|
const windowStart = segments[0]!.startTime;
|
||||||
listen(mediaElement, 'loadedmetadata', trySeek),
|
const last = segments[segments.length - 1]!;
|
||||||
listen(mediaElement, 'durationchange', trySeek),
|
const windowEnd = last.startTime + last.duration;
|
||||||
listen(mediaElement, 'progress', trySeek),
|
|
||||||
listen(mediaElement, 'emptied', () => {
|
try {
|
||||||
seeked = false;
|
// Live duration is unbounded; required for a live seekable range.
|
||||||
}),
|
if (Number.isNaN(mediaSource.duration)) mediaSource.duration = Number.POSITIVE_INFINITY;
|
||||||
];
|
// Re-declared as the window slides so seekable tracks the live window.
|
||||||
return () => {
|
mediaSource.setLiveSeekableRange(windowStart, windowEnd);
|
||||||
for (const remove of removers) remove();
|
} catch {
|
||||||
};
|
// readyState raced closed, or duration set rejected — retried on the next window change.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seek into the window once so the loader dispatches an in-window range.
|
||||||
|
if (!seeked && mediaElement.currentTime < windowStart) {
|
||||||
|
mediaElement.currentTime = windowStart;
|
||||||
|
seeked = true;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const seekToLiveEdge = defineBehavior({
|
/**
|
||||||
stateKeys: [],
|
* Manual `Behavior<>` literal (like `anchorLiveTracks` /
|
||||||
contextKeys: ['mediaElement'],
|
* `calculatePresentationDuration`): declares only `presentation` in stateKeys
|
||||||
|
* while reading `selectedVideoTrackId` defensively (contributed by
|
||||||
|
* `switchVideoTrack`), so it composes without a stateKeys/type conflict.
|
||||||
|
*/
|
||||||
|
export const seekToLiveEdge: Behavior<
|
||||||
|
{ presentation: ReadonlySignal<SeekToLiveEdgeState['presentation']> },
|
||||||
|
{
|
||||||
|
mediaElement: ReadonlySignal<SeekToLiveEdgeContext['mediaElement']>;
|
||||||
|
mediaSource: ReadonlySignal<SeekToLiveEdgeContext['mediaSource']>;
|
||||||
|
},
|
||||||
|
object
|
||||||
|
> = {
|
||||||
|
stateKeys: ['presentation'],
|
||||||
|
contextKeys: ['mediaElement', 'mediaSource'],
|
||||||
setup: seekToLiveEdgeSetup,
|
setup: seekToLiveEdgeSetup,
|
||||||
});
|
};
|
||||||
|
|||||||
@@ -1,47 +1,98 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { signal } from '../../../../core/signals/primitives';
|
import { signal } from '../../../../core/signals/primitives';
|
||||||
|
import type { MaybeResolvedPresentation, Presentation, VideoTrack } from '../../../../media/types';
|
||||||
import { seekToLiveEdge } from '../seek-to-live-edge';
|
import { seekToLiveEdge } from '../seek-to-live-edge';
|
||||||
|
|
||||||
function makeFakeMedia(bufferedStart: number | null, currentTime: number): HTMLMediaElement {
|
function makePresentation(): Presentation {
|
||||||
const buffered =
|
const video: VideoTrack = {
|
||||||
bufferedStart === null
|
type: 'video',
|
||||||
? { length: 0, start: () => 0, end: () => 0 }
|
id: 'v-1',
|
||||||
: { length: 1, start: () => bufferedStart, end: () => bufferedStart + 10 };
|
url: 'https://example.com/video.m3u8',
|
||||||
|
mimeType: 'video/mp4',
|
||||||
|
codecs: ['avc1.640020'],
|
||||||
|
bandwidth: 1_000_000,
|
||||||
|
initialization: { url: 'https://example.com/init.mp4' },
|
||||||
|
duration: Number.POSITIVE_INFINITY,
|
||||||
|
startTime: 100,
|
||||||
|
startDate: 1000,
|
||||||
|
segments: [
|
||||||
|
{ id: 'segment-50', url: '50.m4s', duration: 2, startTime: 100 },
|
||||||
|
{ id: 'segment-51', url: '51.m4s', duration: 2, startTime: 102 },
|
||||||
|
{ id: 'segment-52', url: '52.m4s', duration: 2, startTime: 104 },
|
||||||
|
],
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
currentTime,
|
id: 'pres-1',
|
||||||
buffered,
|
url: 'https://example.com/master.m3u8',
|
||||||
addEventListener() {},
|
startTime: 0,
|
||||||
removeEventListener() {},
|
selectionSets: [{ id: 'video-set', type: 'video', switchingSets: [{ id: 'vs', type: 'video', tracks: [video] }] }],
|
||||||
} as unknown as HTMLMediaElement;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function run(media: HTMLMediaElement): () => void {
|
function fakeMediaSource(readyState: MediaSource['readyState'] = 'open') {
|
||||||
return seekToLiveEdge.setup({
|
return {
|
||||||
state: {},
|
readyState,
|
||||||
context: { mediaElement: signal<HTMLMediaElement | undefined>(media) },
|
duration: Number.NaN,
|
||||||
config: {},
|
setLiveSeekableRange: vi.fn(),
|
||||||
}) as () => void;
|
} as unknown as MediaSource & { setLiveSeekableRange: ReturnType<typeof vi.fn> };
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(opts: {
|
||||||
|
presentation?: MaybeResolvedPresentation;
|
||||||
|
trackId?: string;
|
||||||
|
mediaElement?: HTMLMediaElement;
|
||||||
|
mediaSource?: MediaSource;
|
||||||
|
}) {
|
||||||
|
// Built as vars (not inline literals) so the defensively-read
|
||||||
|
// `selectedVideoTrackId` isn't rejected by the excess-property check against
|
||||||
|
// the behavior's declared `{ presentation }` state slice.
|
||||||
|
const state = {
|
||||||
|
presentation: signal<MaybeResolvedPresentation | undefined>(opts.presentation),
|
||||||
|
selectedVideoTrackId: signal<string | undefined>(opts.trackId),
|
||||||
|
};
|
||||||
|
const context = {
|
||||||
|
mediaElement: signal<HTMLMediaElement | undefined>(opts.mediaElement),
|
||||||
|
mediaSource: signal<MediaSource | undefined>(opts.mediaSource),
|
||||||
|
};
|
||||||
|
return seekToLiveEdge.setup({ state, context, config: {} }) as () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('seekToLiveEdge', () => {
|
describe('seekToLiveEdge', () => {
|
||||||
it('seeks the playhead to the buffered window start when it sits before it', () => {
|
it('declares the live seekable range and seeks into the window', () => {
|
||||||
const media = makeFakeMedia(1000, 0); // native-PTS gap: currentTime 0, buffered at 1000
|
const ms = fakeMediaSource();
|
||||||
const cleanup = run(media);
|
const el = { currentTime: 0 } as HTMLMediaElement;
|
||||||
expect(media.currentTime).toBe(1000);
|
|
||||||
|
const cleanup = run({ presentation: makePresentation(), trackId: 'v-1', mediaElement: el, mediaSource: ms });
|
||||||
|
|
||||||
|
// window = [first.startTime, last.startTime + last.duration] = [100, 106].
|
||||||
|
expect(ms.setLiveSeekableRange).toHaveBeenCalledWith(100, 106);
|
||||||
|
expect(ms.duration).toBe(Number.POSITIVE_INFINITY);
|
||||||
|
expect(el.currentTime).toBe(100);
|
||||||
|
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not seek when the playhead is already inside the buffered window', () => {
|
it('does nothing until the MediaSource is open', () => {
|
||||||
const media = makeFakeMedia(1000, 1005);
|
const ms = fakeMediaSource('closed');
|
||||||
const cleanup = run(media);
|
const el = { currentTime: 0 } as HTMLMediaElement;
|
||||||
expect(media.currentTime).toBe(1005);
|
|
||||||
|
const cleanup = run({ presentation: makePresentation(), trackId: 'v-1', mediaElement: el, mediaSource: ms });
|
||||||
|
|
||||||
|
expect(ms.setLiveSeekableRange).not.toHaveBeenCalled();
|
||||||
|
expect(el.currentTime).toBe(0);
|
||||||
|
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not seek when nothing is buffered yet', () => {
|
it('no-ops without a resolved presentation or selected track', () => {
|
||||||
const media = makeFakeMedia(null, 0);
|
const ms = fakeMediaSource();
|
||||||
const cleanup = run(media);
|
const el = { currentTime: 0 } as HTMLMediaElement;
|
||||||
expect(media.currentTime).toBe(0);
|
|
||||||
|
const cleanup = run({ presentation: undefined, trackId: undefined, mediaElement: el, mediaSource: ms });
|
||||||
|
|
||||||
|
expect(ms.setLiveSeekableRange).not.toHaveBeenCalled();
|
||||||
|
expect(el.currentTime).toBe(0);
|
||||||
|
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user