mirror of
https://github.com/zoriya/v10.git
synced 2026-08-14 18:04:49 +00:00
feat(spf): seekToLiveEdge behavior + wire into live engine
Live segments append at native PTS, so the buffered range sits at a large timestamp while currentTime starts at 0 — no data at 0, readyState never advances, playback can't start. seekToLiveEdge watches for the buffered range and seeks the playhead into it (window start) once present. Listens to events that fire without data at currentTime (loadedmetadata/durationchange/progress); once per source. Composed into createLiveHlsEngine. 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
bd6ea19f8d
commit
bbaeb52ed8
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Seek the playhead into the live window once segments are buffered.
|
||||
*
|
||||
* Live segments append at their native PTS, so the buffered range sits at a
|
||||
* large timestamp while `currentTime` starts at 0 — there's no media at 0, so
|
||||
* `readyState` never advances and playback can't start. This watches for the
|
||||
* buffered range to appear and, while the playhead is still before it, seeks
|
||||
* `currentTime` into the window (its start) so playback can begin.
|
||||
*
|
||||
* Fires once per source. `readyState`-gated events (`canplay`/`loadeddata`)
|
||||
* can't be relied on (they need data *at* `currentTime`), so it listens to
|
||||
* events that fire regardless — `loadedmetadata`, `durationchange`, `progress`
|
||||
* — plus an initial check; `emptied` resets it for a reused element.
|
||||
*
|
||||
* Starts at the window start (simplest, guaranteed playable). Live-edge
|
||||
* latency tuning (start near `buffered.end`) is a follow-up.
|
||||
*/
|
||||
import { listen } from '@videojs/utils/dom';
|
||||
import { defineBehavior } from '../../../core/composition/create-composition';
|
||||
import { effect } from '../../../core/signals/effect';
|
||||
import type { ReadonlySignal } from '../../../core/signals/primitives';
|
||||
|
||||
export interface SeekToLiveEdgeContext {
|
||||
mediaElement?: HTMLMediaElement | undefined;
|
||||
}
|
||||
|
||||
function seekToLiveEdgeSetup({
|
||||
context,
|
||||
}: {
|
||||
context: { mediaElement: ReadonlySignal<SeekToLiveEdgeContext['mediaElement']> };
|
||||
}): () => void {
|
||||
return effect(() => {
|
||||
const mediaElement = context.mediaElement.get();
|
||||
if (!mediaElement) return;
|
||||
|
||||
let seeked = false;
|
||||
const trySeek = () => {
|
||||
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 removers = [
|
||||
listen(mediaElement, 'loadedmetadata', trySeek),
|
||||
listen(mediaElement, 'durationchange', trySeek),
|
||||
listen(mediaElement, 'progress', trySeek),
|
||||
listen(mediaElement, 'emptied', () => {
|
||||
seeked = false;
|
||||
}),
|
||||
];
|
||||
return () => {
|
||||
for (const remove of removers) remove();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const seekToLiveEdge = defineBehavior({
|
||||
stateKeys: [],
|
||||
contextKeys: ['mediaElement'],
|
||||
setup: seekToLiveEdgeSetup,
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { signal } from '../../../../core/signals/primitives';
|
||||
import { seekToLiveEdge } from '../seek-to-live-edge';
|
||||
|
||||
function makeFakeMedia(bufferedStart: number | null, currentTime: number): HTMLMediaElement {
|
||||
const buffered =
|
||||
bufferedStart === null
|
||||
? { length: 0, start: () => 0, end: () => 0 }
|
||||
: { length: 1, start: () => bufferedStart, end: () => bufferedStart + 10 };
|
||||
return {
|
||||
currentTime,
|
||||
buffered,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
} as unknown as HTMLMediaElement;
|
||||
}
|
||||
|
||||
function run(media: HTMLMediaElement): () => void {
|
||||
return seekToLiveEdge.setup({
|
||||
state: {},
|
||||
context: { mediaElement: signal<HTMLMediaElement | undefined>(media) },
|
||||
config: {},
|
||||
}) as () => void;
|
||||
}
|
||||
|
||||
describe('seekToLiveEdge', () => {
|
||||
it('seeks the playhead to the buffered window start when it sits before it', () => {
|
||||
const media = makeFakeMedia(1000, 0); // native-PTS gap: currentTime 0, buffered at 1000
|
||||
const cleanup = run(media);
|
||||
expect(media.currentTime).toBe(1000);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('does not seek when the playhead is already inside the buffered window', () => {
|
||||
const media = makeFakeMedia(1000, 1005);
|
||||
const cleanup = run(media);
|
||||
expect(media.currentTime).toBe(1005);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('does not seek when nothing is buffered yet', () => {
|
||||
const media = makeFakeMedia(null, 0);
|
||||
const cleanup = run(media);
|
||||
expect(media.currentTime).toBe(0);
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import { calculatePresentationDuration } from '../../behaviors/calculate-present
|
||||
import { deriveCdnPriority } from '../../behaviors/derive-cdn-priority';
|
||||
import { endOfStream } from '../../behaviors/dom/end-of-stream';
|
||||
import { loadAudioSegments, loadVideoSegments } from '../../behaviors/dom/load-segments';
|
||||
import { seekToLiveEdge } from '../../behaviors/dom/seek-to-live-edge';
|
||||
import { setupAudioBufferActors, setupVideoBufferActors } from '../../behaviors/dom/setup-buffer-actors';
|
||||
import { setupMediaSource } from '../../behaviors/dom/setup-mediasource';
|
||||
import { trackCurrentTime } from '../../behaviors/dom/track-current-time';
|
||||
@@ -102,6 +103,10 @@ export function createLiveHlsEngine(
|
||||
loadVideoSegments,
|
||||
loadAudioSegments,
|
||||
|
||||
// Seek the playhead into the (native-PTS) buffered window once segments
|
||||
// land, so playback can start.
|
||||
seekToLiveEdge,
|
||||
|
||||
// No-op for unbounded live (no EXT-X-ENDLIST), composed for parity.
|
||||
endOfStream,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user