mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
refactor(spf): split seek-to-live-edge into window derivation + seekable writer
Decompose the seek-to-live-edge behavior (which coupled three concerns) into:
- liveWindowFor (media/live-window.ts) — a pure derivation of the live window
{start,end,targetDuration}, or null for VOD/ended/unresolved. The single
source of truth, centralizing all inertness so consumers don't re-derive.
- syncLiveSeekableRange (behaviors/dom) — declares setLiveSeekableRange on each
window slide, including while paused (the seekable range must stay current
regardless of play state).
- seekToLiveEdge — now just the one-time HOLD-BACK seek + the window-exit guard,
consuming liveWindowFor; sheds the seekable/duration writes (keeps the
mediaSource open-gate so the seek lands in the declared range).
Composed syncLiveSeekableRange before seekToLiveEdge to preserve the
declare-before-seek ordering (a seek outside seekable is clamped). Behavior-
preserving; tests redistributed (7 liveWindowFor + 14 seekToLiveEdge + 5
syncLiveSeekableRange). Deferred follow-ups: clearLiveSeekableRange on
termination; duration-ownership cleanup vs updateMediaSourceDuration; B's
seeked-latch source-reset; window-derives-from-accumulated-segments-while-paused.
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
85e300e999
commit
24fa36e695
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Derive the live window of the selected video track — the single source of
|
||||
* truth for "where is live," consumed by the seek-to-live-edge and
|
||||
* live-seekable-range behaviors so neither re-derives (or re-presumes) the
|
||||
* window shape.
|
||||
*
|
||||
* Returns `null` when there is no live edge to track: an unresolved
|
||||
* presentation or track, a track with no segments, or a **complete** playlist
|
||||
* (VoD, or live that has ended — a finite `Track.duration`). Video and audio
|
||||
* share the timeline origin, so the video track's window positions both.
|
||||
*/
|
||||
import {
|
||||
getMediaPlaylistMetadata,
|
||||
isResolvedPresentation,
|
||||
isResolvedTrack,
|
||||
type MaybeResolvedPresentation,
|
||||
} from './types';
|
||||
import { findTrack } from './utils/tracks';
|
||||
|
||||
export interface LiveWindow {
|
||||
/** Earliest time still in the window (seconds, model timeline). */
|
||||
start: number;
|
||||
/** The live edge — latest time in the window (seconds). */
|
||||
end: number;
|
||||
/** Playlist target duration (seconds); falls back to the last segment's duration. */
|
||||
targetDuration: number;
|
||||
}
|
||||
|
||||
export function liveWindowFor(
|
||||
presentation: MaybeResolvedPresentation | undefined,
|
||||
videoTrackId: string | undefined
|
||||
): LiveWindow | null {
|
||||
if (!isResolvedPresentation(presentation) || !videoTrackId) return null;
|
||||
|
||||
const track = findTrack(presentation, 'video', videoTrackId);
|
||||
if (!track || !isResolvedTrack(track) || track.segments.length === 0) return null;
|
||||
|
||||
// Complete playlist (VoD, or live that has ended) → no live edge. `Track.duration`
|
||||
// is the parser's completeness signal (finite = complete, Infinity = still growing).
|
||||
if (Number.isFinite(track.duration)) return null;
|
||||
|
||||
const { segments } = track;
|
||||
const last = segments[segments.length - 1]!;
|
||||
return {
|
||||
start: segments[0]!.startTime,
|
||||
end: last.startTime + last.duration,
|
||||
targetDuration: getMediaPlaylistMetadata(track)?.targetDuration || last.duration,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { liveWindowFor } from '../live-window';
|
||||
import { MEDIA_PLAYLIST_METADATA_KEY, type Presentation, type VideoTrack } from '../types';
|
||||
|
||||
/** 5-segment, 2s window starting at 100: [100, 110]; targetDuration 2. */
|
||||
function makePresentation(overrides?: Partial<VideoTrack>): Presentation {
|
||||
const video: VideoTrack = {
|
||||
type: 'video',
|
||||
id: 'v-1',
|
||||
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: [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 } },
|
||||
...overrides,
|
||||
};
|
||||
return {
|
||||
id: 'pres-1',
|
||||
url: 'https://example.com/master.m3u8',
|
||||
startTime: 0,
|
||||
selectionSets: [{ id: 'video-set', type: 'video', switchingSets: [{ id: 'vs', type: 'video', tracks: [video] }] }],
|
||||
};
|
||||
}
|
||||
|
||||
describe('liveWindowFor', () => {
|
||||
it('returns the window + target duration for a live track', () => {
|
||||
expect(liveWindowFor(makePresentation(), 'v-1')).toEqual({ start: 100, end: 110, targetDuration: 2 });
|
||||
});
|
||||
|
||||
it('returns null for a complete (finite-duration) playlist — VoD / ended live', () => {
|
||||
expect(liveWindowFor(makePresentation({ duration: 110 }), 'v-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null without a resolved presentation', () => {
|
||||
expect(liveWindowFor(undefined, 'v-1')).toBeNull();
|
||||
expect(liveWindowFor({ id: 'p', url: 'https://example.com/x.m3u8' }, 'v-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null without a selected video track id', () => {
|
||||
expect(liveWindowFor(makePresentation(), undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the track id does not resolve', () => {
|
||||
expect(liveWindowFor(makePresentation(), 'missing')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a track with no segments', () => {
|
||||
expect(liveWindowFor(makePresentation({ segments: [] }), 'v-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to the last segment duration when target duration is absent', () => {
|
||||
const presentation = makePresentation({
|
||||
metadata: { [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence: 50, endList: false } },
|
||||
});
|
||||
expect(liveWindowFor(presentation, 'v-1')).toEqual({ start: 100, end: 110, targetDuration: 2 });
|
||||
});
|
||||
});
|
||||
@@ -1,45 +1,31 @@
|
||||
/**
|
||||
* 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.
|
||||
* Seek the playhead into the live window and keep it there:
|
||||
*
|
||||
* 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
|
||||
* solved here from the *model* (not from `buffered`, which is empty at
|
||||
* cold-start — the segment loader only fetches segments overlapping
|
||||
* `[currentTime, currentTime + bufferDuration]`, so until the playhead is in
|
||||
* the window nothing loads):
|
||||
* 1. A one-time seek of `currentTime` to HOLD-BACK behind the live edge
|
||||
* (default 3 × TARGETDURATION, clamped to the window start) so playback
|
||||
* begins near the edge and the segment loader dispatches an in-window range
|
||||
* rather than starting at the back of the DVR window.
|
||||
* 2. A live-window playhead guard: while playing (`!paused && !seeking &&
|
||||
* readyState > 0`), 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).
|
||||
*
|
||||
* 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 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.
|
||||
* 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.
|
||||
* The live window comes from `liveWindowFor` (the shared derivation); this
|
||||
* behavior is inert when it returns `null` (VoD / ended live). Declaring the
|
||||
* seekable range is a separate concern — see `sync-live-seekable-range`, which
|
||||
* is composed *before* this behavior so the range exists before we seek into
|
||||
* it (a seek outside `seekable` is clamped). The `mediaSource` open-gate here
|
||||
* is the read that ties the seek to that declared range being available.
|
||||
*/
|
||||
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';
|
||||
import {
|
||||
getMediaPlaylistMetadata,
|
||||
isResolvedPresentation,
|
||||
isResolvedTrack,
|
||||
type MaybeResolvedPresentation,
|
||||
} from '../../../media/types';
|
||||
import { findTrack } from '../../../media/utils/tracks';
|
||||
import { liveWindowFor } from '../../../media/live-window';
|
||||
import type { MaybeResolvedPresentation } from '../../../media/types';
|
||||
|
||||
/**
|
||||
* Multiple of TARGETDURATION to start behind the live edge — the HLS spec
|
||||
@@ -98,40 +84,13 @@ function seekToLiveEdgeSetup({
|
||||
return effect(() => {
|
||||
const mediaElement = context.mediaElement.get();
|
||||
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;
|
||||
const liveWindow = liveWindowFor(state.presentation.get(), state.selectedVideoTrackId?.get());
|
||||
if (!mediaElement || !liveWindow) return;
|
||||
// Gate on the seekable range being declarable/declared (see file JSDoc):
|
||||
// sync-live-seekable-range runs first while open, so seeks land in-window.
|
||||
if (!mediaSource || mediaSource.readyState !== 'open') return;
|
||||
|
||||
const track = findTrack(presentation, 'video', trackId);
|
||||
if (!track || !isResolvedTrack(track) || track.segments.length === 0) return;
|
||||
|
||||
// Complete playlist (VoD, or live that has ended) → no live edge to seek to.
|
||||
// `Track.duration` is the parser's completeness signal (finite = complete,
|
||||
// Infinity = still growing / live); keeps this behavior inert for VoD in the
|
||||
// unified engine — a VoD source never declares a live seekable range or seeks.
|
||||
if (Number.isFinite(track.duration)) return;
|
||||
|
||||
const { segments } = track;
|
||||
const windowStart = segments[0]!.startTime;
|
||||
const last = segments[segments.length - 1]!;
|
||||
const windowEnd = last.startTime + last.duration;
|
||||
|
||||
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
|
||||
// (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;
|
||||
}
|
||||
|
||||
// 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 { start: windowStart, end: windowEnd, targetDuration } = liveWindow;
|
||||
const liveEdgeStart = Math.max(windowStart, windowEnd - HOLD_BACK_TARGET_MULTIPLIER * targetDuration);
|
||||
|
||||
// Initial entry: seek into the window once — even while paused — so the
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Mirror the live window into the MediaSource's seekable range. On every
|
||||
* window update — **including while paused** (the seekable range must stay
|
||||
* current as the window slides, regardless of play state) — declare
|
||||
* `setLiveSeekableRange(start, end)` so the browser's `HTMLMediaElement.seekable`
|
||||
* reflects the live window (without it, `seekable` is empty under
|
||||
* `duration === Infinity`).
|
||||
*
|
||||
* The live window comes from `liveWindowFor` (the shared derivation); inert
|
||||
* when it returns `null` (VoD / ended live). Composed *before* `seekToLiveEdge`
|
||||
* so the range is declared before that behavior seeks the playhead into it (a
|
||||
* seek outside `seekable` is clamped).
|
||||
*
|
||||
* Out of scope (deliberate, tracked as follow-ups): `clearLiveSeekableRange()`
|
||||
* on the live→ended transition; and dropping the defensive `duration` write
|
||||
* below once duration ownership/ordering with `updateMediaSourceDuration` (the
|
||||
* canonical, but asynchronous, duration writer) is resolved.
|
||||
*/
|
||||
import type { Behavior } from '../../../core/composition/create-composition';
|
||||
import { effect } from '../../../core/signals/effect';
|
||||
import type { ReadonlySignal } from '../../../core/signals/primitives';
|
||||
import { liveWindowFor } from '../../../media/live-window';
|
||||
import type { MaybeResolvedPresentation } from '../../../media/types';
|
||||
|
||||
export interface SyncLiveSeekableRangeState {
|
||||
presentation?: MaybeResolvedPresentation;
|
||||
selectedVideoTrackId?: string;
|
||||
}
|
||||
|
||||
export interface SyncLiveSeekableRangeContext {
|
||||
mediaSource?: MediaSource;
|
||||
}
|
||||
|
||||
function syncLiveSeekableRangeSetup({
|
||||
state,
|
||||
context,
|
||||
}: {
|
||||
state: {
|
||||
presentation: ReadonlySignal<SyncLiveSeekableRangeState['presentation']>;
|
||||
selectedVideoTrackId?: ReadonlySignal<SyncLiveSeekableRangeState['selectedVideoTrackId']>;
|
||||
};
|
||||
context: {
|
||||
mediaSource: ReadonlySignal<SyncLiveSeekableRangeContext['mediaSource']>;
|
||||
};
|
||||
}): () => void {
|
||||
return effect(() => {
|
||||
const mediaSource = context.mediaSource.get();
|
||||
const liveWindow = liveWindowFor(state.presentation.get(), state.selectedVideoTrackId?.get());
|
||||
if (!mediaSource || mediaSource.readyState !== 'open' || !liveWindow) return;
|
||||
|
||||
try {
|
||||
// A live seekable range needs a set duration. `updateMediaSourceDuration`
|
||||
// is the canonical duration owner, but writes asynchronously — so guard
|
||||
// here so this doesn't race ahead of it. (Follow-up: resolve ownership.)
|
||||
if (Number.isNaN(mediaSource.duration)) mediaSource.duration = Number.POSITIVE_INFINITY;
|
||||
// Re-declared as the window slides so seekable tracks the live window
|
||||
// (the full DVR range remains seekable; seek-to-live-edge starts near the edge).
|
||||
mediaSource.setLiveSeekableRange(liveWindow.start, liveWindow.end);
|
||||
} catch {
|
||||
// readyState raced closed, or duration set rejected — retried on the next window change.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual `Behavior<>` literal (like `seekToLiveEdge`): declares only
|
||||
* `presentation` in stateKeys while reading `selectedVideoTrackId` defensively
|
||||
* (contributed by `switchVideoTrack`), so it composes without a
|
||||
* stateKeys/type conflict.
|
||||
*/
|
||||
export const syncLiveSeekableRange: Behavior<
|
||||
{ presentation: ReadonlySignal<SyncLiveSeekableRangeState['presentation']> },
|
||||
{ mediaSource: ReadonlySignal<SyncLiveSeekableRangeContext['mediaSource']> },
|
||||
object
|
||||
> = {
|
||||
stateKeys: ['presentation'],
|
||||
contextKeys: ['mediaSource'],
|
||||
setup: syncLiveSeekableRangeSetup,
|
||||
};
|
||||
@@ -98,28 +98,24 @@ function run(opts: {
|
||||
const flush = () => Promise.resolve();
|
||||
|
||||
describe('seekToLiveEdge', () => {
|
||||
it('declares the full seekable window and seeks near the live edge (HOLD-BACK behind)', () => {
|
||||
it('seeks near the live edge on entry (HOLD-BACK behind)', () => {
|
||||
const ms = fakeMediaSource();
|
||||
const el = fakeMediaElement();
|
||||
|
||||
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);
|
||||
expect(ms.duration).toBe(Number.POSITIVE_INFINITY);
|
||||
// Start HOLD-BACK (3 × 2s) behind the edge: 110 − 6 = 104, not the window start.
|
||||
expect(el.currentTime).toBe(104);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('does nothing until the MediaSource is open', () => {
|
||||
it('does not seek until the MediaSource is open', () => {
|
||||
const ms = fakeMediaSource('closed');
|
||||
const el = fakeMediaElement();
|
||||
|
||||
const { cleanup } = run({ presentation: makePresentation(), trackId: 'v-1', mediaElement: el, mediaSource: ms });
|
||||
|
||||
expect(ms.setLiveSeekableRange).not.toHaveBeenCalled();
|
||||
expect(el.currentTime).toBe(0);
|
||||
|
||||
cleanup();
|
||||
@@ -136,7 +132,6 @@ describe('seekToLiveEdge', () => {
|
||||
|
||||
const { cleanup } = run({ presentation, trackId: 'v-1', mediaElement: el, mediaSource: ms });
|
||||
|
||||
expect(ms.setLiveSeekableRange).not.toHaveBeenCalled();
|
||||
expect(el.currentTime).toBe(0);
|
||||
|
||||
cleanup();
|
||||
@@ -148,7 +143,6 @@ describe('seekToLiveEdge', () => {
|
||||
|
||||
const { cleanup } = run({ presentation: undefined, trackId: undefined, mediaElement: el, mediaSource: ms });
|
||||
|
||||
expect(ms.setLiveSeekableRange).not.toHaveBeenCalled();
|
||||
expect(el.currentTime).toBe(0);
|
||||
|
||||
cleanup();
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { signal } from '../../../../core/signals/primitives';
|
||||
import {
|
||||
type MaybeResolvedPresentation,
|
||||
MEDIA_PLAYLIST_METADATA_KEY,
|
||||
type Presentation,
|
||||
type VideoTrack,
|
||||
} from '../../../../media/types';
|
||||
import { syncLiveSeekableRange } from '../sync-live-seekable-range';
|
||||
|
||||
function makePresentation(): Presentation {
|
||||
// 5-segment, 2s window: [100, 110].
|
||||
const video: VideoTrack = {
|
||||
type: 'video',
|
||||
id: 'v-1',
|
||||
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: [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',
|
||||
url: 'https://example.com/master.m3u8',
|
||||
startTime: 0,
|
||||
selectionSets: [{ id: 'video-set', type: 'video', switchingSets: [{ id: 'vs', type: 'video', tracks: [video] }] }],
|
||||
};
|
||||
}
|
||||
|
||||
function fakeMediaSource(readyState: MediaSource['readyState'] = 'open') {
|
||||
return {
|
||||
readyState,
|
||||
duration: Number.NaN,
|
||||
setLiveSeekableRange: vi.fn(),
|
||||
} as unknown as MediaSource & { setLiveSeekableRange: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
function run(opts: { presentation?: MaybeResolvedPresentation; trackId?: string; mediaSource?: MediaSource }) {
|
||||
const state = {
|
||||
presentation: signal<MaybeResolvedPresentation | undefined>(opts.presentation),
|
||||
selectedVideoTrackId: signal<string | undefined>(opts.trackId),
|
||||
};
|
||||
const context = { mediaSource: signal<MediaSource | undefined>(opts.mediaSource) };
|
||||
return syncLiveSeekableRange.setup({ state, context, config: {} }) as () => void;
|
||||
}
|
||||
|
||||
describe('syncLiveSeekableRange', () => {
|
||||
it('declares the full live window as seekable and sets Infinity duration', () => {
|
||||
const ms = fakeMediaSource();
|
||||
const cleanup = run({ presentation: makePresentation(), trackId: 'v-1', mediaSource: ms });
|
||||
|
||||
// [first.startTime, last.startTime + last.duration] = [100, 110].
|
||||
expect(ms.setLiveSeekableRange).toHaveBeenCalledWith(100, 110);
|
||||
expect(ms.duration).toBe(Number.POSITIVE_INFINITY);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('does nothing until the MediaSource is open', () => {
|
||||
const ms = fakeMediaSource('closed');
|
||||
const cleanup = run({ presentation: makePresentation(), trackId: 'v-1', mediaSource: ms });
|
||||
|
||||
expect(ms.setLiveSeekableRange).not.toHaveBeenCalled();
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('no-ops for a complete (finite-duration) playlist — VoD / ended live', () => {
|
||||
const ms = fakeMediaSource();
|
||||
const presentation = makePresentation();
|
||||
(presentation.selectionSets[0]!.switchingSets[0]!.tracks[0] as VideoTrack).duration = 110;
|
||||
|
||||
const cleanup = run({ presentation, trackId: 'v-1', mediaSource: ms });
|
||||
|
||||
expect(ms.setLiveSeekableRange).not.toHaveBeenCalled();
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('no-ops without a resolved presentation or selected track', () => {
|
||||
const ms = fakeMediaSource();
|
||||
const cleanup = run({ presentation: undefined, trackId: undefined, mediaSource: ms });
|
||||
|
||||
expect(ms.setLiveSeekableRange).not.toHaveBeenCalled();
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('does not overwrite an already-set (non-NaN) duration', () => {
|
||||
const ms = fakeMediaSource();
|
||||
ms.duration = 500; // e.g. updateMediaSourceDuration already wrote it
|
||||
const cleanup = run({ presentation: makePresentation(), trackId: 'v-1', mediaSource: ms });
|
||||
|
||||
expect(ms.duration).toBe(500);
|
||||
expect(ms.setLiveSeekableRange).toHaveBeenCalledWith(100, 110);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,7 @@ 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 { setupTextTrackActors } from '../../behaviors/dom/setup-text-track-actors';
|
||||
import { syncLiveSeekableRange } from '../../behaviors/dom/sync-live-seekable-range';
|
||||
import { syncTextTracks } from '../../behaviors/dom/sync-text-tracks';
|
||||
import { trackCurrentTime } from '../../behaviors/dom/track-current-time';
|
||||
import { trackLoadTriggers } from '../../behaviors/dom/track-load-triggers';
|
||||
@@ -426,8 +427,12 @@ export function createSimpleHlsEngine(
|
||||
loadVideoSegments,
|
||||
loadAudioSegments,
|
||||
|
||||
// Live: declare the seekable window and seek the playhead to the live
|
||||
// edge once segments land. No-op for complete playlists (VoD / ended).
|
||||
// Live: declare the seekable window, then seek the playhead to the live
|
||||
// edge + keep it in-window. No-op for complete playlists (VoD / ended).
|
||||
// Order matters: the seekable range must be declared (syncLiveSeekableRange)
|
||||
// before seekToLiveEdge moves the playhead into it (a seek outside
|
||||
// `seekable` is clamped).
|
||||
syncLiveSeekableRange,
|
||||
seekToLiveEdge,
|
||||
|
||||
// End of stream coordination
|
||||
|
||||
Reference in New Issue
Block a user