mirror of
https://github.com/zoriya/v10.git
synced 2026-08-14 18:04:49 +00:00
feat(spf): start live playback near the edge (HOLD-BACK)
seekToLiveEdge seeked to the window start (~full DVR window behind live). Start HOLD-BACK behind the edge instead — windowEnd − 3 × TARGETDURATION (the HLS spec default when the playlist omits HOLD-BACK), clamped to the window start. The full DVR window stays seekable; only the initial position moves toward the edge. Verified against a live Mux CMAF stream: starts ~edge instead of ~20s back. 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
a82d9d85c9
commit
cf69a7b7eb
@@ -11,8 +11,10 @@
|
||||
* 1. `MediaSource.setLiveSeekableRange(windowStart, windowEnd)` — derived from
|
||||
* the selected video track's anchored segment timeline — so the window is
|
||||
* seekable (kept current as the window slides across reloads).
|
||||
* 2. A one-time seek of `currentTime` to the window start, so the loader
|
||||
* dispatches an in-window range and playback can begin.
|
||||
* 2. A one-time seek of `currentTime` to HOLD-BACK behind the live edge
|
||||
* (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.
|
||||
*
|
||||
* Reads the *selected video track* timeline (anchored to ≈ native PTS by
|
||||
* `anchorLiveTracks`); video and audio share the origin, so the video window
|
||||
@@ -22,9 +24,20 @@
|
||||
import type { Behavior } from '../../../core/composition/create-composition';
|
||||
import { effect } from '../../../core/signals/effect';
|
||||
import type { ReadonlySignal } from '../../../core/signals/primitives';
|
||||
import { isResolvedPresentation, isResolvedTrack, type MaybeResolvedPresentation } from '../../../media/types';
|
||||
import {
|
||||
getMediaPlaylistMetadata,
|
||||
isResolvedPresentation,
|
||||
isResolvedTrack,
|
||||
type MaybeResolvedPresentation,
|
||||
} from '../../../media/types';
|
||||
import { findTrack } from '../../../media/utils/tracks';
|
||||
|
||||
/**
|
||||
* Multiple of TARGETDURATION to start behind the live edge — the HLS spec
|
||||
* default for HOLD-BACK when the playlist doesn't specify one (RFC 8216bis).
|
||||
*/
|
||||
const HOLD_BACK_TARGET_MULTIPLIER = 3;
|
||||
|
||||
export interface SeekToLiveEdgeState {
|
||||
presentation?: MaybeResolvedPresentation;
|
||||
selectedVideoTrackId?: string;
|
||||
@@ -69,16 +82,23 @@ function seekToLiveEdgeSetup({
|
||||
try {
|
||||
// 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.
|
||||
// Re-declared as the window slides so seekable tracks the live window
|
||||
// (the full DVR range remains seekable; we just start near the edge).
|
||||
mediaSource.setLiveSeekableRange(windowStart, windowEnd);
|
||||
} 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;
|
||||
// Start near the live edge: HOLD-BACK (default 3 × TARGETDURATION) behind it,
|
||||
// clamped to the window start. Closer to live than the window start, while
|
||||
// leaving enough buffered ahead to begin smoothly.
|
||||
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.
|
||||
if (!seeked && mediaElement.currentTime < liveEdgeStart) {
|
||||
mediaElement.currentTime = liveEdgeStart;
|
||||
seeked = true;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { signal } from '../../../../core/signals/primitives';
|
||||
import type { MaybeResolvedPresentation, Presentation, VideoTrack } from '../../../../media/types';
|
||||
import {
|
||||
type MaybeResolvedPresentation,
|
||||
MEDIA_PLAYLIST_METADATA_KEY,
|
||||
type Presentation,
|
||||
type VideoTrack,
|
||||
} from '../../../../media/types';
|
||||
import { 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.
|
||||
const video: VideoTrack = {
|
||||
type: 'video',
|
||||
id: 'v-1',
|
||||
@@ -15,11 +22,13 @@ function makePresentation(): Presentation {
|
||||
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 },
|
||||
],
|
||||
segments: [100, 102, 104, 106, 108].map((startTime, i) => ({
|
||||
id: `segment-${50 + i}`,
|
||||
url: `${50 + i}.m4s`,
|
||||
duration: 2,
|
||||
startTime,
|
||||
})),
|
||||
metadata: { [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence: 50, targetDuration: 2, endList: false } },
|
||||
};
|
||||
return {
|
||||
id: 'pres-1',
|
||||
@@ -58,16 +67,17 @@ function run(opts: {
|
||||
}
|
||||
|
||||
describe('seekToLiveEdge', () => {
|
||||
it('declares the live seekable range and seeks into the window', () => {
|
||||
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 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);
|
||||
// Full DVR window stays seekable: [first.startTime, last.startTime + last.duration] = [100, 110].
|
||||
expect(ms.setLiveSeekableRange).toHaveBeenCalledWith(100, 110);
|
||||
expect(ms.duration).toBe(Number.POSITIVE_INFINITY);
|
||||
expect(el.currentTime).toBe(100);
|
||||
// Start HOLD-BACK (3 × 2s) behind the edge: 110 − 6 = 104, not the window start.
|
||||
expect(el.currentTime).toBe(104);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user