mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(core): add liveEdgeStart and targetLiveWindow properties (#1445)
This commit is contained in:
@@ -135,6 +135,25 @@ export interface MediaStreamTypeState {
|
||||
streamType: MediaStreamType;
|
||||
}
|
||||
|
||||
export interface MediaLiveState {
|
||||
/**
|
||||
* Presentation time marking the start of the Live Edge Window.
|
||||
*
|
||||
* Playing at the live edge when `currentTime >= liveEdgeStart`. `NaN`
|
||||
* when the stream isn't live or the value is unknown.
|
||||
*
|
||||
* @see https://github.com/video-dev/media-ui-extensions/blob/main/proposals/0007-live-edge.md
|
||||
*/
|
||||
liveEdgeStart: number;
|
||||
/**
|
||||
* Offset representing the seekable range size for live content.
|
||||
*
|
||||
* `0` for standard latency live, `Infinity` for DVR, `NaN` for on-demand
|
||||
* or unknown.
|
||||
*/
|
||||
targetLiveWindow: number;
|
||||
}
|
||||
|
||||
export interface MediaBufferState {
|
||||
/**
|
||||
* Buffered time ranges as [start, end] tuples.
|
||||
|
||||
@@ -215,6 +215,30 @@ export interface MediaStreamTypeCapability {
|
||||
readonly streamType: MediaStreamType;
|
||||
}
|
||||
|
||||
export interface MediaLiveEvents {
|
||||
targetlivewindowchange: EventLike;
|
||||
}
|
||||
|
||||
export interface MediaLiveCapability {
|
||||
/**
|
||||
* Presentation time marking the start of the Live Edge Window. Playing at
|
||||
* the live edge when `currentTime >= liveEdgeStart`. `NaN` when the stream
|
||||
* isn't live or the value is unknown.
|
||||
*
|
||||
* Derived — no dedicated change event; re-read when `seekable`,
|
||||
* `targetLiveWindow`, or `streamType` change.
|
||||
*
|
||||
* @see https://github.com/video-dev/media-ui-extensions/blob/main/proposals/0007-live-edge.md
|
||||
*/
|
||||
readonly liveEdgeStart: number;
|
||||
/**
|
||||
* Offset representing the seekable range size for live content. `0` for
|
||||
* standard latency live, `Infinity` for DVR, `NaN` for on-demand or
|
||||
* unknown. Fires `targetlivewindowchange` when the value changes.
|
||||
*/
|
||||
readonly targetLiveWindow: number;
|
||||
}
|
||||
|
||||
interface MediaEvents extends MediaPlaybackEvents {}
|
||||
|
||||
export interface Media extends MediaPlaybackCapability, EventTargetLike<MediaEvents> {
|
||||
|
||||
@@ -2,6 +2,7 @@ import Hls, { type HlsConfig } from 'hls.js';
|
||||
import type { MediaEngineHost } from '../../../core/media/types';
|
||||
import { HTMLVideoElementHost } from '../video-host';
|
||||
import { HlsJsMediaErrorsMixin } from './errors';
|
||||
import { HlsJsMediaLiveMixin } from './live';
|
||||
import { HlsJsMediaMetadataTracksMixin } from './metadata-tracks';
|
||||
import { HlsJsMediaPreloadMixin } from './preload';
|
||||
import { HlsJsMediaStreamTypeMixin } from './stream-type';
|
||||
@@ -57,7 +58,9 @@ class HlsJsMediaBase extends HTMLVideoElementHost implements MediaEngineHost<Hls
|
||||
}
|
||||
|
||||
export class HlsJsMedia extends HlsJsMediaPreloadMixin(
|
||||
HlsJsMediaStreamTypeMixin(
|
||||
HlsJsMediaMetadataTracksMixin(HlsJsMediaTextTracksMixin(HlsJsMediaErrorsMixin(HlsJsMediaBase)))
|
||||
HlsJsMediaLiveMixin(
|
||||
HlsJsMediaStreamTypeMixin(
|
||||
HlsJsMediaMetadataTracksMixin(HlsJsMediaTextTracksMixin(HlsJsMediaErrorsMixin(HlsJsMediaBase)))
|
||||
)
|
||||
)
|
||||
) {}
|
||||
|
||||
@@ -145,6 +145,25 @@ export class HlsMedia extends HTMLVideoElementHost implements HlsMediaProps {
|
||||
this.dispatchEvent(new Event('streamtypechange'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Presentation time marking the start of the Live Edge Window.
|
||||
*
|
||||
* Derived from the delegate on every read; `NaN` when no delegate is
|
||||
* attached or the stream is not live.
|
||||
*/
|
||||
get liveEdgeStart() {
|
||||
return this.#delegate?.liveEdgeStart ?? Number.NaN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seekable range size for live content. `0` for standard live, `Infinity`
|
||||
* for DVR, `NaN` for on-demand or unknown. Fires `targetlivewindowchange`
|
||||
* when the value changes (bridged from the delegate).
|
||||
*/
|
||||
get targetLiveWindow() {
|
||||
return this.#delegate?.targetLiveWindow ?? Number.NaN;
|
||||
}
|
||||
|
||||
attach(target: HTMLVideoElement) {
|
||||
super.attach(target);
|
||||
this.#delegate?.attach(target);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Constructor } from '@videojs/utils/types';
|
||||
import type { LevelLoadedData } from 'hls.js';
|
||||
import Hls from 'hls.js';
|
||||
import type { HlsEngineHost } from './types';
|
||||
|
||||
export function HlsJsMediaLiveMixin<Base extends Constructor<HlsEngineHost>>(BaseClass: Base) {
|
||||
class HlsJsMediaLive extends (BaseClass as Constructor<HlsEngineHost>) {
|
||||
#targetLiveWindow = Number.NaN;
|
||||
#liveEdgeStartOffset: number | undefined;
|
||||
|
||||
constructor(...args: any[]) {
|
||||
super(...args);
|
||||
|
||||
const { engine } = this;
|
||||
engine?.on(Hls.Events.MANIFEST_LOADING, () => this.#reset());
|
||||
engine?.on(Hls.Events.DESTROYING, () => this.#reset());
|
||||
engine?.on(Hls.Events.LEVEL_LOADED, (_event: string, data: LevelLoadedData) => {
|
||||
this.#derive(data.details);
|
||||
});
|
||||
}
|
||||
|
||||
get targetLiveWindow() {
|
||||
return this.#targetLiveWindow;
|
||||
}
|
||||
|
||||
// Derived from seekable + offset at read time. No cached state, no event.
|
||||
get liveEdgeStart() {
|
||||
if (this.#liveEdgeStartOffset === undefined) return Number.NaN;
|
||||
const { target } = this;
|
||||
if (!target) return Number.NaN;
|
||||
const { seekable } = target;
|
||||
if (!seekable.length) return Number.NaN;
|
||||
return seekable.end(seekable.length - 1) - this.#liveEdgeStartOffset;
|
||||
}
|
||||
|
||||
#derive(details: LevelLoadedData['details']) {
|
||||
if (!details.live) return this.#reset();
|
||||
|
||||
// `EVENT` playlists retain all segments, so the seekable window can grow
|
||||
// without bound (DVR). Standard live keeps a fixed sliding window.
|
||||
const targetLiveWindow = details.type === 'EVENT' ? Number.POSITIVE_INFINITY : 0;
|
||||
|
||||
// Prefer manifest-declared HOLD-BACK / PART-HOLD-BACK when present;
|
||||
// otherwise fall back to the per-spec multiples of the target durations.
|
||||
// See https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-12
|
||||
const lowLatency = !!details.partList?.length;
|
||||
this.#liveEdgeStartOffset = lowLatency
|
||||
? details.partHoldBack || details.partTarget * 2
|
||||
: details.holdBack || details.targetduration * 3;
|
||||
|
||||
this.#setTargetLiveWindow(targetLiveWindow);
|
||||
}
|
||||
|
||||
#reset() {
|
||||
this.#liveEdgeStartOffset = undefined;
|
||||
this.#setTargetLiveWindow(Number.NaN);
|
||||
}
|
||||
|
||||
#setTargetLiveWindow(value: number) {
|
||||
if (Object.is(this.#targetLiveWindow, value)) return;
|
||||
this.#targetLiveWindow = value;
|
||||
this.dispatchEvent(new Event('targetlivewindowchange'));
|
||||
}
|
||||
}
|
||||
|
||||
return HlsJsMediaLive as unknown as Base &
|
||||
Constructor<{ readonly liveEdgeStart: number; readonly targetLiveWindow: number }>;
|
||||
}
|
||||
@@ -290,6 +290,29 @@ describe('HlsMedia', () => {
|
||||
expect(media.streamType).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('live edge', () => {
|
||||
it('defaults to `NaN` for both values before load', () => {
|
||||
const media = new HlsMedia();
|
||||
expect(media.liveEdgeStart).toBeNaN();
|
||||
expect(media.targetLiveWindow).toBeNaN();
|
||||
});
|
||||
|
||||
it('forwards `NaN` from the native delegate', () => {
|
||||
const { media } = setup();
|
||||
expect(media.liveEdgeStart).toBeNaN();
|
||||
expect(media.targetLiveWindow).toBeNaN();
|
||||
});
|
||||
|
||||
it('returns `NaN` again after destroy', () => {
|
||||
const { media } = setup();
|
||||
|
||||
media.destroy();
|
||||
|
||||
expect(media.liveEdgeStart).toBeNaN();
|
||||
expect(media.targetLiveWindow).toBeNaN();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('NativeHlsMedia streamType', () => {
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import Hls from 'hls.js';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { HlsJsMediaLiveMixin } from '../live';
|
||||
import type { HlsEngineHost } from '../types';
|
||||
|
||||
function createEngine(): Hls {
|
||||
const listeners = new Map<string, Set<(...args: any[]) => void>>();
|
||||
return {
|
||||
on(event: string, fn: (...args: any[]) => void) {
|
||||
if (!listeners.has(event)) listeners.set(event, new Set());
|
||||
listeners.get(event)!.add(fn);
|
||||
},
|
||||
off(event: string, fn: (...args: any[]) => void) {
|
||||
listeners.get(event)?.delete(fn);
|
||||
},
|
||||
emit(event: string, ...args: any[]) {
|
||||
for (const fn of listeners.get(event) ?? []) fn(event, ...args);
|
||||
},
|
||||
} as unknown as Hls;
|
||||
}
|
||||
|
||||
class FakeHost extends EventTarget implements HlsEngineHost {
|
||||
engine: Hls | null;
|
||||
target: HTMLMediaElement | null = null;
|
||||
|
||||
constructor(engine: Hls | null = null) {
|
||||
super();
|
||||
this.engine = engine;
|
||||
}
|
||||
}
|
||||
|
||||
const HlsJsMediaLive = HlsJsMediaLiveMixin(FakeHost);
|
||||
|
||||
// Minimal LevelDetails shape — only the fields the mixin reads.
|
||||
function levelDetails(overrides: Record<string, unknown>) {
|
||||
return {
|
||||
live: false,
|
||||
type: null,
|
||||
partList: null,
|
||||
partHoldBack: 0,
|
||||
partTarget: 0,
|
||||
holdBack: 0,
|
||||
targetduration: 6,
|
||||
totalduration: 0,
|
||||
...overrides,
|
||||
} as any;
|
||||
}
|
||||
|
||||
function emitLevelLoaded(engine: Hls, details: unknown) {
|
||||
(engine as any).emit(Hls.Events.LEVEL_LOADED, { details });
|
||||
}
|
||||
|
||||
function setTargetSeekable(host: { target: HTMLMediaElement | null }, ranges: [number, number][]) {
|
||||
const video = document.createElement('video');
|
||||
Object.defineProperty(video, 'seekable', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return {
|
||||
length: ranges.length,
|
||||
start: (i: number) => ranges[i]?.[0] ?? 0,
|
||||
end: (i: number) => ranges[i]?.[1] ?? 0,
|
||||
} as TimeRanges;
|
||||
},
|
||||
});
|
||||
host.target = video;
|
||||
return video;
|
||||
}
|
||||
|
||||
describe('HlsJsMediaLiveMixin', () => {
|
||||
describe('defaults', () => {
|
||||
it('starts with `NaN` for both values and no event', () => {
|
||||
const engine = createEngine();
|
||||
const host = new HlsJsMediaLive(engine);
|
||||
|
||||
expect(host.targetLiveWindow).toBeNaN();
|
||||
expect(host.liveEdgeStart).toBeNaN();
|
||||
});
|
||||
});
|
||||
|
||||
describe('targetLiveWindow derivation', () => {
|
||||
it('is `0` for standard live', () => {
|
||||
const engine = createEngine();
|
||||
const host = new HlsJsMediaLive(engine);
|
||||
|
||||
const handler = vi.fn();
|
||||
host.addEventListener('targetlivewindowchange', handler);
|
||||
|
||||
emitLevelLoaded(engine, levelDetails({ live: true, type: null, holdBack: 18 }));
|
||||
|
||||
expect(host.targetLiveWindow).toBe(0);
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('is `Infinity` for an `EVENT` playlist (DVR)', () => {
|
||||
const engine = createEngine();
|
||||
const host = new HlsJsMediaLive(engine);
|
||||
|
||||
emitLevelLoaded(engine, levelDetails({ live: true, type: 'EVENT', holdBack: 18 }));
|
||||
|
||||
expect(host.targetLiveWindow).toBe(Number.POSITIVE_INFINITY);
|
||||
});
|
||||
|
||||
it('is `NaN` for non-live playlists', () => {
|
||||
const engine = createEngine();
|
||||
const host = new HlsJsMediaLive(engine);
|
||||
|
||||
emitLevelLoaded(engine, levelDetails({ live: false, type: 'VOD' }));
|
||||
|
||||
expect(host.targetLiveWindow).toBeNaN();
|
||||
});
|
||||
|
||||
it('dedupes `targetlivewindowchange` when the value does not change', () => {
|
||||
const engine = createEngine();
|
||||
const host = new HlsJsMediaLive(engine);
|
||||
|
||||
const handler = vi.fn();
|
||||
host.addEventListener('targetlivewindowchange', handler);
|
||||
|
||||
emitLevelLoaded(engine, levelDetails({ live: true, holdBack: 18 }));
|
||||
emitLevelLoaded(engine, levelDetails({ live: true, holdBack: 18 }));
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('liveEdgeStart derivation', () => {
|
||||
it('uses `holdBack` for standard live (`seekable.end - holdBack`)', () => {
|
||||
const engine = createEngine();
|
||||
const host = new HlsJsMediaLive(engine);
|
||||
setTargetSeekable(host, [[0, 60]]);
|
||||
|
||||
emitLevelLoaded(engine, levelDetails({ live: true, holdBack: 18, targetduration: 6 }));
|
||||
|
||||
expect(host.liveEdgeStart).toBe(42);
|
||||
});
|
||||
|
||||
it('falls back to `targetduration * 3` when `holdBack` is absent', () => {
|
||||
const engine = createEngine();
|
||||
const host = new HlsJsMediaLive(engine);
|
||||
setTargetSeekable(host, [[0, 60]]);
|
||||
|
||||
emitLevelLoaded(engine, levelDetails({ live: true, holdBack: 0, targetduration: 6 }));
|
||||
|
||||
expect(host.liveEdgeStart).toBe(42);
|
||||
});
|
||||
|
||||
it('uses `partHoldBack` for low-latency live', () => {
|
||||
const engine = createEngine();
|
||||
const host = new HlsJsMediaLive(engine);
|
||||
setTargetSeekable(host, [[0, 60]]);
|
||||
|
||||
emitLevelLoaded(engine, levelDetails({ live: true, partList: [{}], partHoldBack: 2, partTarget: 0.5 }));
|
||||
|
||||
expect(host.liveEdgeStart).toBe(58);
|
||||
});
|
||||
|
||||
it('falls back to `partTarget * 2` when `partHoldBack` is absent', () => {
|
||||
const engine = createEngine();
|
||||
const host = new HlsJsMediaLive(engine);
|
||||
setTargetSeekable(host, [[0, 60]]);
|
||||
|
||||
emitLevelLoaded(engine, levelDetails({ live: true, partList: [{}], partHoldBack: 0, partTarget: 0.5 }));
|
||||
|
||||
expect(host.liveEdgeStart).toBe(59);
|
||||
});
|
||||
|
||||
it('is `NaN` when no seekable range is available', () => {
|
||||
const engine = createEngine();
|
||||
const host = new HlsJsMediaLive(engine);
|
||||
setTargetSeekable(host, []);
|
||||
|
||||
emitLevelLoaded(engine, levelDetails({ live: true, holdBack: 18 }));
|
||||
|
||||
expect(host.liveEdgeStart).toBeNaN();
|
||||
});
|
||||
|
||||
it('is `NaN` when the stream is not live', () => {
|
||||
const engine = createEngine();
|
||||
const host = new HlsJsMediaLive(engine);
|
||||
setTargetSeekable(host, [[0, 60]]);
|
||||
|
||||
emitLevelLoaded(engine, levelDetails({ live: false, type: 'VOD' }));
|
||||
|
||||
expect(host.liveEdgeStart).toBeNaN();
|
||||
});
|
||||
|
||||
it('reflects the current `seekable` on every read', () => {
|
||||
const engine = createEngine();
|
||||
const host = new HlsJsMediaLive(engine);
|
||||
|
||||
let end = 60;
|
||||
const video = document.createElement('video');
|
||||
Object.defineProperty(video, 'seekable', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return {
|
||||
length: 1,
|
||||
start: () => 0,
|
||||
end: () => end,
|
||||
} as TimeRanges;
|
||||
},
|
||||
});
|
||||
host.target = video;
|
||||
|
||||
emitLevelLoaded(engine, levelDetails({ live: true, holdBack: 18 }));
|
||||
|
||||
expect(host.liveEdgeStart).toBe(42);
|
||||
|
||||
end = 120;
|
||||
expect(host.liveEdgeStart).toBe(102);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('resets on `MANIFEST_LOADING`', () => {
|
||||
const engine = createEngine();
|
||||
const host = new HlsJsMediaLive(engine);
|
||||
setTargetSeekable(host, [[0, 60]]);
|
||||
|
||||
emitLevelLoaded(engine, levelDetails({ live: true, holdBack: 18 }));
|
||||
expect(host.targetLiveWindow).toBe(0);
|
||||
|
||||
const handler = vi.fn();
|
||||
host.addEventListener('targetlivewindowchange', handler);
|
||||
|
||||
(engine as any).emit(Hls.Events.MANIFEST_LOADING);
|
||||
|
||||
expect(host.targetLiveWindow).toBeNaN();
|
||||
expect(host.liveEdgeStart).toBeNaN();
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('resets on `DESTROYING`', () => {
|
||||
const engine = createEngine();
|
||||
const host = new HlsJsMediaLive(engine);
|
||||
setTargetSeekable(host, [[0, 60]]);
|
||||
|
||||
emitLevelLoaded(engine, levelDetails({ live: true, holdBack: 18 }));
|
||||
expect(host.targetLiveWindow).toBe(0);
|
||||
|
||||
(engine as any).emit(Hls.Events.DESTROYING);
|
||||
|
||||
expect(host.targetLiveWindow).toBeNaN();
|
||||
expect(host.liveEdgeStart).toBeNaN();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type MediaStreamType, MediaStreamTypes } from '../../../core/media/types';
|
||||
import { HTMLVideoElementHost } from '../video-host';
|
||||
import { NativeHlsMediaErrorsMixin } from './errors';
|
||||
import { NativeHlsMediaLiveMixin } from './live';
|
||||
import { NativeHlsMediaStreamTypeMixin } from './stream-type';
|
||||
|
||||
export type PreloadType = '' | 'none' | 'metadata' | 'auto';
|
||||
@@ -61,4 +62,6 @@ class NativeHlsMediaBase extends HTMLVideoElementHost implements Omit<NativeHlsM
|
||||
}
|
||||
}
|
||||
|
||||
export class NativeHlsMedia extends NativeHlsMediaStreamTypeMixin(NativeHlsMediaErrorsMixin(NativeHlsMediaBase)) {}
|
||||
export class NativeHlsMedia extends NativeHlsMediaLiveMixin(
|
||||
NativeHlsMediaStreamTypeMixin(NativeHlsMediaErrorsMixin(NativeHlsMediaBase))
|
||||
) {}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { Constructor } from '@videojs/utils/types';
|
||||
import type { NativeMediaHost } from './errors';
|
||||
import { getStreamInfoFromSrc, looksLikeM3u8 } from './m3u8-utils';
|
||||
|
||||
export function NativeHlsMediaLiveMixin<Base extends Constructor<NativeMediaHost>>(BaseClass: Base) {
|
||||
// Native HLS does not expose manifest-level `HOLD-BACK` / `PART-HOLD-BACK`
|
||||
// through a JS API, so we fetch the m3u8 ourselves and parse the relevant
|
||||
// tags to derive `targetLiveWindow` and `liveEdgeStart` — mirroring the
|
||||
// approach in `muxinc/elements`.
|
||||
//
|
||||
// See https://github.com/muxinc/elements/blob/main/packages/playback-core/src/index.ts
|
||||
class NativeHlsMediaLive extends (BaseClass as Constructor<NativeMediaHost>) {
|
||||
#targetLiveWindow = Number.NaN;
|
||||
#liveEdgeStartOffset: number | undefined;
|
||||
#disconnect: AbortController | null = null;
|
||||
#currentSrc = '';
|
||||
|
||||
get targetLiveWindow() {
|
||||
return this.#targetLiveWindow;
|
||||
}
|
||||
|
||||
// Derived on each read from the current `seekable.end` and cached offset.
|
||||
get liveEdgeStart() {
|
||||
if (this.#liveEdgeStartOffset === undefined) return Number.NaN;
|
||||
const target = this.target as HTMLMediaElement | null;
|
||||
if (!target) return Number.NaN;
|
||||
const { seekable, buffered } = target;
|
||||
// Native HLS on Chrome doesn't fill the `seekable` property, so we use the `buffered` property instead.
|
||||
const ranges = seekable.length ? seekable : buffered;
|
||||
if (!ranges.length) return Number.NaN;
|
||||
return ranges.end(ranges.length - 1) - this.#liveEdgeStartOffset;
|
||||
}
|
||||
|
||||
attach(target: EventTarget) {
|
||||
super.attach?.(target);
|
||||
this.#init(target as HTMLMediaElement);
|
||||
}
|
||||
|
||||
detach() {
|
||||
this.#destroy();
|
||||
super.detach?.();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.#destroy();
|
||||
super.destroy?.();
|
||||
}
|
||||
|
||||
#destroy() {
|
||||
this.#disconnect?.abort();
|
||||
this.#disconnect = null;
|
||||
this.#currentSrc = '';
|
||||
this.#liveEdgeStartOffset = undefined;
|
||||
this.#setTargetLiveWindow(Number.NaN);
|
||||
}
|
||||
|
||||
#init(target: HTMLMediaElement) {
|
||||
this.#destroy();
|
||||
this.#disconnect = new AbortController();
|
||||
const { signal } = this.#disconnect;
|
||||
|
||||
// `loadstart` fires when the element starts loading a new source — the
|
||||
// right moment to kick off our parallel fetch. If the src has already
|
||||
// been set (e.g. preload='auto' on a prior frame), pick it up now.
|
||||
target.addEventListener('loadstart', () => this.#refresh(target), { signal });
|
||||
target.addEventListener(
|
||||
'emptied',
|
||||
() => {
|
||||
this.#currentSrc = '';
|
||||
this.#liveEdgeStartOffset = undefined;
|
||||
this.#setTargetLiveWindow(Number.NaN);
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
|
||||
if (target.currentSrc || target.src) this.#refresh(target);
|
||||
}
|
||||
|
||||
async #refresh(target: HTMLMediaElement) {
|
||||
const src = target.currentSrc || target.src;
|
||||
// Only inspect HLS sources. `looksLikeM3u8` is permissive: a query
|
||||
// string or path containing `.m3u8` is enough.
|
||||
if (!src || !looksLikeM3u8(src) || src === this.#currentSrc) return;
|
||||
|
||||
this.#currentSrc = src;
|
||||
// Optimistically reset — we're about to compute fresh values.
|
||||
this.#liveEdgeStartOffset = undefined;
|
||||
this.#setTargetLiveWindow(Number.NaN);
|
||||
|
||||
const signal = this.#disconnect?.signal;
|
||||
try {
|
||||
const info = await getStreamInfoFromSrc(src, signal);
|
||||
// Bail if we've been torn down or the src changed mid-fetch.
|
||||
if (signal?.aborted) return;
|
||||
if ((target.currentSrc || target.src) !== src) return;
|
||||
|
||||
this.#liveEdgeStartOffset = info.liveEdgeStartOffset;
|
||||
this.#setTargetLiveWindow(info.targetLiveWindow);
|
||||
} catch {
|
||||
// Network / CORS / parse errors leave values at `NaN`.
|
||||
}
|
||||
}
|
||||
|
||||
#setTargetLiveWindow(value: number) {
|
||||
if (Object.is(this.#targetLiveWindow, value)) return;
|
||||
this.#targetLiveWindow = value;
|
||||
this.dispatchEvent(new Event('targetlivewindowchange'));
|
||||
}
|
||||
}
|
||||
|
||||
return NativeHlsMediaLive as unknown as Base &
|
||||
Constructor<{ readonly liveEdgeStart: number; readonly targetLiveWindow: number }>;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Utilities for parsing HLS m3u8 playlists.
|
||||
//
|
||||
// Native HLS playback does not expose manifest-level information like
|
||||
// `HOLD-BACK` / `PART-HOLD-BACK` through a JS API, so consumers that need it
|
||||
// (e.g. the live-edge mixin) fetch the playlist themselves and parse the
|
||||
// relevant tags here.
|
||||
//
|
||||
// Mirrors the approach in `muxinc/elements/playback-core`. See:
|
||||
// - https://github.com/muxinc/elements/blob/main/packages/playback-core/src/index.ts
|
||||
// - https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-12
|
||||
|
||||
export interface StreamInfo {
|
||||
/**
|
||||
* Offset representing the seekable range size for live content.
|
||||
* `0` for standard latency live, `Infinity` for DVR, `NaN` for on-demand.
|
||||
*/
|
||||
targetLiveWindow: number;
|
||||
/**
|
||||
* Offset (seconds) from `seekable.end` at which the live edge window begins.
|
||||
* `undefined` when the stream is not live.
|
||||
*/
|
||||
liveEdgeStartOffset: number | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` when `src` looks like an HLS playlist URL. Permissive: a
|
||||
* path or query string containing `.m3u8` is enough.
|
||||
*/
|
||||
export function looksLikeM3u8(src: string) {
|
||||
return src.toLowerCase().includes('.m3u8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` when the playlist text is a multivariant (master) playlist.
|
||||
*
|
||||
* The presence of `#EXT-X-STREAM-INF` is conclusive — media playlists only
|
||||
* contain `#EXTINF` segment tags.
|
||||
*/
|
||||
export function isMultivariantPlaylist(playlist: string) {
|
||||
return playlist.includes('#EXT-X-STREAM-INF');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the first media playlist URL referenced by a multivariant
|
||||
* playlist, relative to `baseUrl`. Returns `null` when none is found or the
|
||||
* URL cannot be parsed.
|
||||
*/
|
||||
export function resolveFirstMediaPlaylistUrl(multivariant: string, baseUrl: string): string | null {
|
||||
const lines = multivariant.split(/\r?\n/);
|
||||
const start = lines.findIndex((l) => l.startsWith('#EXT-X-STREAM-INF'));
|
||||
if (start === -1) return null;
|
||||
|
||||
// The URI appears on the first non-blank, non-comment line that follows.
|
||||
const uri = lines
|
||||
.slice(start + 1)
|
||||
.map((l) => l.trim())
|
||||
.find((l) => l && !l.startsWith('#'));
|
||||
if (!uri) return null;
|
||||
|
||||
try {
|
||||
return new URL(uri, baseUrl).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the subset of media-playlist tags needed to derive live edge state:
|
||||
* `#EXT-X-PLAYLIST-TYPE`, `#EXT-X-ENDLIST`, `#EXT-X-TARGETDURATION`,
|
||||
* `#EXT-X-PART-INF`.
|
||||
*
|
||||
* See spec:
|
||||
* - VOD or `#EXT-X-ENDLIST` present → on-demand, `targetLiveWindow = NaN`.
|
||||
* - `EVENT` playlist → DVR, `targetLiveWindow = Infinity`.
|
||||
* - Otherwise → standard live sliding window, `targetLiveWindow = 0`.
|
||||
*
|
||||
* The edge offset is `PART-TARGET * 2` for low-latency live and
|
||||
* `TARGETDURATION * 3` otherwise.
|
||||
*/
|
||||
export function parseStreamInfo(playlist: string): StreamInfo {
|
||||
const lines = playlist.split(/\r?\n/);
|
||||
|
||||
let playlistType: string | undefined;
|
||||
let hasEndList = false;
|
||||
let targetDuration: number | undefined;
|
||||
let partTarget: number | undefined;
|
||||
|
||||
for (const raw of lines) {
|
||||
const line = raw.trim();
|
||||
if (line.startsWith('#EXT-X-PLAYLIST-TYPE:')) {
|
||||
playlistType = line.slice('#EXT-X-PLAYLIST-TYPE:'.length).trim().toUpperCase();
|
||||
} else if (line === '#EXT-X-ENDLIST') {
|
||||
hasEndList = true;
|
||||
} else if (line.startsWith('#EXT-X-TARGETDURATION:')) {
|
||||
const value = Number(line.slice('#EXT-X-TARGETDURATION:'.length));
|
||||
if (Number.isFinite(value)) targetDuration = value;
|
||||
} else if (line.startsWith('#EXT-X-PART-INF')) {
|
||||
const match = /PART-TARGET\s*=\s*([0-9.]+)/i.exec(line);
|
||||
if (match) {
|
||||
const value = Number(match[1]);
|
||||
if (Number.isFinite(value)) partTarget = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (playlistType === 'VOD' || hasEndList) {
|
||||
return { targetLiveWindow: Number.NaN, liveEdgeStartOffset: undefined };
|
||||
}
|
||||
|
||||
const targetLiveWindow = playlistType === 'EVENT' ? Number.POSITIVE_INFINITY : 0;
|
||||
|
||||
const liveEdgeStartOffset =
|
||||
partTarget !== undefined ? partTarget * 2 : targetDuration !== undefined ? targetDuration * 3 : undefined;
|
||||
|
||||
return { targetLiveWindow, liveEdgeStartOffset };
|
||||
}
|
||||
|
||||
async function fetchPlaylist(url: string, init: RequestInit): Promise<{ text: string; url: string }> {
|
||||
const response = await fetch(url, init);
|
||||
if (!response.ok) throw new Error(`Failed to fetch playlist (${response.status}): ${url}`);
|
||||
return { text: await response.text(), url: response.url || url };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the HLS playlist at `src`, following the first variant if it's a
|
||||
* multivariant playlist, and parses it into a {@link StreamInfo}.
|
||||
*
|
||||
* @throws when the fetch fails or no media playlist URL can be resolved.
|
||||
*/
|
||||
export async function getStreamInfoFromSrc(src: string, signal?: AbortSignal): Promise<StreamInfo> {
|
||||
const init: RequestInit = signal ? { signal } : {};
|
||||
const { text, url } = await fetchPlaylist(src, init);
|
||||
|
||||
if (!isMultivariantPlaylist(text)) return parseStreamInfo(text);
|
||||
|
||||
const mediaUrl = resolveFirstMediaPlaylistUrl(text, url);
|
||||
if (!mediaUrl) throw new Error('No media playlist URL found in multivariant playlist');
|
||||
const media = await fetchPlaylist(mediaUrl, init);
|
||||
return parseStreamInfo(media.text);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { NativeMediaHost } from '../errors';
|
||||
import { NativeHlsMediaLiveMixin } from '../live';
|
||||
|
||||
class FakeHost extends EventTarget implements NativeMediaHost {
|
||||
#target: HTMLMediaElement | null = null;
|
||||
|
||||
get target() {
|
||||
return this.#target;
|
||||
}
|
||||
|
||||
attach(target: HTMLMediaElement): void {
|
||||
if (!target || this.#target === target) return;
|
||||
this.#target = target;
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
this.#target = null;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.#target = null;
|
||||
}
|
||||
}
|
||||
|
||||
const NativeHlsMediaLive = NativeHlsMediaLiveMixin(FakeHost);
|
||||
|
||||
function createVideoWithSrc(src: string, seekableEnd: number | null = null): HTMLVideoElement {
|
||||
const video = document.createElement('video');
|
||||
// JSDOM doesn't actually load the src; we just need the attribute to stick.
|
||||
Object.defineProperty(video, 'currentSrc', { configurable: true, value: src });
|
||||
if (seekableEnd !== null) {
|
||||
Object.defineProperty(video, 'seekable', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return {
|
||||
length: 1,
|
||||
start: () => 0,
|
||||
end: () => seekableEnd,
|
||||
} as TimeRanges;
|
||||
},
|
||||
});
|
||||
}
|
||||
return video;
|
||||
}
|
||||
|
||||
function mockFetch(responses: Record<string, string | { status: number; body?: string }>): void {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: string | URL | Request) => {
|
||||
const url = input instanceof Request ? input.url : input.toString();
|
||||
const entry = responses[url] ?? responses[Object.keys(responses).find((key) => url.endsWith(key)) ?? ''];
|
||||
if (entry === undefined) {
|
||||
return new Response('not found', { status: 404 });
|
||||
}
|
||||
if (typeof entry === 'string') {
|
||||
return new Response(entry, { status: 200 });
|
||||
}
|
||||
return new Response(entry.body ?? '', { status: entry.status });
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function flushPromises() {
|
||||
// Allow the async fetch chain inside the mixin to settle.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('NativeHlsMediaLiveMixin', () => {
|
||||
describe('defaults', () => {
|
||||
it('returns `NaN` for both properties before a playlist is parsed', () => {
|
||||
const host = new NativeHlsMediaLive();
|
||||
expect(host.liveEdgeStart).toBeNaN();
|
||||
expect(host.targetLiveWindow).toBeNaN();
|
||||
});
|
||||
});
|
||||
|
||||
describe('standard live', () => {
|
||||
const playlist = [
|
||||
'#EXTM3U',
|
||||
'#EXT-X-VERSION:6',
|
||||
'#EXT-X-TARGETDURATION:6',
|
||||
'#EXT-X-MEDIA-SEQUENCE:0',
|
||||
'#EXTINF:6.0,',
|
||||
'segment0.ts',
|
||||
].join('\n');
|
||||
|
||||
it('derives `targetLiveWindow=0` and offset from `#EXT-X-TARGETDURATION`', async () => {
|
||||
mockFetch({ 'https://example.com/live.m3u8': playlist });
|
||||
|
||||
const host = new NativeHlsMediaLive();
|
||||
const video = createVideoWithSrc('https://example.com/live.m3u8', 60);
|
||||
|
||||
const handler = vi.fn();
|
||||
host.addEventListener('targetlivewindowchange', handler);
|
||||
|
||||
host.attach(video);
|
||||
video.dispatchEvent(new Event('loadstart'));
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(host.targetLiveWindow).toBe(0);
|
||||
// seekable.end(60) - (targetDuration 6 * 3) = 42
|
||||
expect(host.liveEdgeStart).toBe(42);
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DVR (EVENT playlist)', () => {
|
||||
const playlist = [
|
||||
'#EXTM3U',
|
||||
'#EXT-X-VERSION:6',
|
||||
'#EXT-X-PLAYLIST-TYPE:EVENT',
|
||||
'#EXT-X-TARGETDURATION:6',
|
||||
'#EXT-X-MEDIA-SEQUENCE:0',
|
||||
'#EXTINF:6.0,',
|
||||
'segment0.ts',
|
||||
].join('\n');
|
||||
|
||||
it('derives `targetLiveWindow=Infinity`', async () => {
|
||||
mockFetch({ 'https://example.com/dvr.m3u8': playlist });
|
||||
|
||||
const host = new NativeHlsMediaLive();
|
||||
const video = createVideoWithSrc('https://example.com/dvr.m3u8', 60);
|
||||
|
||||
host.attach(video);
|
||||
video.dispatchEvent(new Event('loadstart'));
|
||||
await flushPromises();
|
||||
|
||||
expect(host.targetLiveWindow).toBe(Number.POSITIVE_INFINITY);
|
||||
expect(host.liveEdgeStart).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VOD playlist', () => {
|
||||
const playlist = [
|
||||
'#EXTM3U',
|
||||
'#EXT-X-VERSION:6',
|
||||
'#EXT-X-PLAYLIST-TYPE:VOD',
|
||||
'#EXT-X-TARGETDURATION:6',
|
||||
'#EXTINF:6.0,',
|
||||
'segment0.ts',
|
||||
'#EXT-X-ENDLIST',
|
||||
].join('\n');
|
||||
|
||||
it('leaves `targetLiveWindow=NaN` for on-demand', async () => {
|
||||
mockFetch({ 'https://example.com/vod.m3u8': playlist });
|
||||
|
||||
const host = new NativeHlsMediaLive();
|
||||
const video = createVideoWithSrc('https://example.com/vod.m3u8', 60);
|
||||
|
||||
host.attach(video);
|
||||
video.dispatchEvent(new Event('loadstart'));
|
||||
await flushPromises();
|
||||
|
||||
expect(host.targetLiveWindow).toBeNaN();
|
||||
expect(host.liveEdgeStart).toBeNaN();
|
||||
});
|
||||
});
|
||||
|
||||
describe('low-latency live', () => {
|
||||
const playlist = [
|
||||
'#EXTM3U',
|
||||
'#EXT-X-VERSION:9',
|
||||
'#EXT-X-TARGETDURATION:4',
|
||||
'#EXT-X-PART-INF:PART-TARGET=0.5',
|
||||
'#EXTINF:4.0,',
|
||||
'segment0.ts',
|
||||
].join('\n');
|
||||
|
||||
it('uses `PART-TARGET * 2` for the offset', async () => {
|
||||
mockFetch({ 'https://example.com/ll.m3u8': playlist });
|
||||
|
||||
const host = new NativeHlsMediaLive();
|
||||
const video = createVideoWithSrc('https://example.com/ll.m3u8', 60);
|
||||
|
||||
host.attach(video);
|
||||
video.dispatchEvent(new Event('loadstart'));
|
||||
await flushPromises();
|
||||
|
||||
expect(host.targetLiveWindow).toBe(0);
|
||||
// 60 - (0.5 * 2) = 59
|
||||
expect(host.liveEdgeStart).toBe(59);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multivariant playlist', () => {
|
||||
const master = ['#EXTM3U', '#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=1280x720', 'media.m3u8'].join('\n');
|
||||
const media = ['#EXTM3U', '#EXT-X-VERSION:6', '#EXT-X-TARGETDURATION:6', '#EXTINF:6.0,', 'segment0.ts'].join('\n');
|
||||
|
||||
it('follows the first `#EXT-X-STREAM-INF` to the media playlist', async () => {
|
||||
mockFetch({
|
||||
'https://example.com/master.m3u8': master,
|
||||
'https://example.com/media.m3u8': media,
|
||||
});
|
||||
|
||||
const host = new NativeHlsMediaLive();
|
||||
const video = createVideoWithSrc('https://example.com/master.m3u8', 60);
|
||||
|
||||
host.attach(video);
|
||||
video.dispatchEvent(new Event('loadstart'));
|
||||
await flushPromises();
|
||||
|
||||
expect(host.targetLiveWindow).toBe(0);
|
||||
expect(host.liveEdgeStart).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-HLS sources', () => {
|
||||
it('does not fetch for `.mp4` sources', async () => {
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
const host = new NativeHlsMediaLive();
|
||||
const video = createVideoWithSrc('https://example.com/video.mp4', 60);
|
||||
|
||||
host.attach(video);
|
||||
video.dispatchEvent(new Event('loadstart'));
|
||||
await flushPromises();
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(host.targetLiveWindow).toBeNaN();
|
||||
expect(host.liveEdgeStart).toBeNaN();
|
||||
});
|
||||
});
|
||||
|
||||
describe('errors and teardown', () => {
|
||||
it('leaves values at `NaN` on fetch failure', async () => {
|
||||
mockFetch({ 'https://example.com/missing.m3u8': { status: 404 } });
|
||||
|
||||
const host = new NativeHlsMediaLive();
|
||||
const video = createVideoWithSrc('https://example.com/missing.m3u8', 60);
|
||||
|
||||
host.attach(video);
|
||||
video.dispatchEvent(new Event('loadstart'));
|
||||
await flushPromises();
|
||||
|
||||
expect(host.targetLiveWindow).toBeNaN();
|
||||
expect(host.liveEdgeStart).toBeNaN();
|
||||
});
|
||||
|
||||
it('resets to `NaN` on `emptied`', async () => {
|
||||
const playlist = ['#EXTM3U', '#EXT-X-TARGETDURATION:6', '#EXTINF:6.0,', 'segment0.ts'].join('\n');
|
||||
mockFetch({ 'https://example.com/live.m3u8': playlist });
|
||||
|
||||
const host = new NativeHlsMediaLive();
|
||||
const video = createVideoWithSrc('https://example.com/live.m3u8', 60);
|
||||
|
||||
host.attach(video);
|
||||
video.dispatchEvent(new Event('loadstart'));
|
||||
await flushPromises();
|
||||
|
||||
expect(host.targetLiveWindow).toBe(0);
|
||||
|
||||
video.dispatchEvent(new Event('emptied'));
|
||||
|
||||
expect(host.targetLiveWindow).toBeNaN();
|
||||
expect(host.liveEdgeStart).toBeNaN();
|
||||
});
|
||||
|
||||
it('resets to `NaN` after `destroy`', async () => {
|
||||
const playlist = ['#EXTM3U', '#EXT-X-TARGETDURATION:6', '#EXTINF:6.0,', 'segment0.ts'].join('\n');
|
||||
mockFetch({ 'https://example.com/live.m3u8': playlist });
|
||||
|
||||
const host = new NativeHlsMediaLive();
|
||||
const video = createVideoWithSrc('https://example.com/live.m3u8', 60);
|
||||
|
||||
host.attach(video);
|
||||
video.dispatchEvent(new Event('loadstart'));
|
||||
await flushPromises();
|
||||
|
||||
expect(host.targetLiveWindow).toBe(0);
|
||||
|
||||
host.destroy();
|
||||
|
||||
expect(host.targetLiveWindow).toBeNaN();
|
||||
expect(host.liveEdgeStart).toBeNaN();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getStreamInfoFromSrc,
|
||||
isMultivariantPlaylist,
|
||||
looksLikeM3u8,
|
||||
parseStreamInfo,
|
||||
resolveFirstMediaPlaylistUrl,
|
||||
} from '../m3u8-utils';
|
||||
|
||||
function mockFetch(responses: Record<string, string | { status: number; body?: string; url?: string }>): void {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: string | URL | Request) => {
|
||||
const url = input instanceof Request ? input.url : input.toString();
|
||||
const entry = responses[url] ?? responses[Object.keys(responses).find((key) => url.endsWith(key)) ?? ''];
|
||||
if (entry === undefined) {
|
||||
return new Response('not found', { status: 404 });
|
||||
}
|
||||
if (typeof entry === 'string') {
|
||||
return new Response(entry, { status: 200 });
|
||||
}
|
||||
const response = new Response(entry.body ?? '', { status: entry.status });
|
||||
if (entry.url) Object.defineProperty(response, 'url', { value: entry.url });
|
||||
return response;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('looksLikeM3u8', () => {
|
||||
it('matches URLs ending in `.m3u8`', () => {
|
||||
expect(looksLikeM3u8('https://example.com/stream.m3u8')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches URLs with `.m3u8` in the path', () => {
|
||||
expect(looksLikeM3u8('https://example.com/stream.m3u8?token=abc')).toBe(true);
|
||||
});
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
expect(looksLikeM3u8('https://example.com/STREAM.M3U8')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns `false` for `.mp4`', () => {
|
||||
expect(looksLikeM3u8('https://example.com/video.mp4')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns `false` for empty strings', () => {
|
||||
expect(looksLikeM3u8('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMultivariantPlaylist', () => {
|
||||
it('returns `true` when `#EXT-X-STREAM-INF` is present', () => {
|
||||
const playlist = ['#EXTM3U', '#EXT-X-STREAM-INF:BANDWIDTH=2000000', 'media.m3u8'].join('\n');
|
||||
expect(isMultivariantPlaylist(playlist)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns `false` for a media playlist', () => {
|
||||
const playlist = ['#EXTM3U', '#EXT-X-TARGETDURATION:6', '#EXTINF:6.0,', 'segment0.ts'].join('\n');
|
||||
expect(isMultivariantPlaylist(playlist)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveFirstMediaPlaylistUrl', () => {
|
||||
it('resolves a relative URI against `baseUrl`', () => {
|
||||
const playlist = ['#EXTM3U', '#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=1280x720', 'media.m3u8'].join('\n');
|
||||
|
||||
const url = resolveFirstMediaPlaylistUrl(playlist, 'https://example.com/master.m3u8');
|
||||
|
||||
expect(url).toBe('https://example.com/media.m3u8');
|
||||
});
|
||||
|
||||
it('preserves an absolute URI as-is', () => {
|
||||
const playlist = ['#EXTM3U', '#EXT-X-STREAM-INF:BANDWIDTH=2000000', 'https://cdn.example.com/path/media.m3u8'].join(
|
||||
'\n'
|
||||
);
|
||||
|
||||
const url = resolveFirstMediaPlaylistUrl(playlist, 'https://example.com/master.m3u8');
|
||||
|
||||
expect(url).toBe('https://cdn.example.com/path/media.m3u8');
|
||||
});
|
||||
|
||||
it('skips blank and comment lines after `#EXT-X-STREAM-INF`', () => {
|
||||
const playlist = ['#EXTM3U', '#EXT-X-STREAM-INF:BANDWIDTH=2000000', '', '# a comment', 'media.m3u8'].join('\n');
|
||||
|
||||
expect(resolveFirstMediaPlaylistUrl(playlist, 'https://example.com/master.m3u8')).toBe(
|
||||
'https://example.com/media.m3u8'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the first variant when there are multiple', () => {
|
||||
const playlist = [
|
||||
'#EXTM3U',
|
||||
'#EXT-X-STREAM-INF:BANDWIDTH=1000000',
|
||||
'low.m3u8',
|
||||
'#EXT-X-STREAM-INF:BANDWIDTH=2000000',
|
||||
'high.m3u8',
|
||||
].join('\n');
|
||||
|
||||
expect(resolveFirstMediaPlaylistUrl(playlist, 'https://example.com/master.m3u8')).toBe(
|
||||
'https://example.com/low.m3u8'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns `null` when no variant URI is found', () => {
|
||||
const playlist = ['#EXTM3U', '#EXT-X-STREAM-INF:BANDWIDTH=2000000'].join('\n');
|
||||
expect(resolveFirstMediaPlaylistUrl(playlist, 'https://example.com/master.m3u8')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns `null` when no `#EXT-X-STREAM-INF` tag is present', () => {
|
||||
const playlist = ['#EXTM3U', '#EXT-X-TARGETDURATION:6'].join('\n');
|
||||
expect(resolveFirstMediaPlaylistUrl(playlist, 'https://example.com/master.m3u8')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseStreamInfo', () => {
|
||||
it('returns `targetLiveWindow=0` and `targetduration * 3` for standard live', () => {
|
||||
const playlist = ['#EXTM3U', '#EXT-X-VERSION:6', '#EXT-X-TARGETDURATION:6', '#EXTINF:6.0,', 'segment0.ts'].join(
|
||||
'\n'
|
||||
);
|
||||
|
||||
expect(parseStreamInfo(playlist)).toEqual({
|
||||
targetLiveWindow: 0,
|
||||
liveEdgeStartOffset: 18,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns `targetLiveWindow=Infinity` for `EVENT` playlists', () => {
|
||||
const playlist = [
|
||||
'#EXTM3U',
|
||||
'#EXT-X-PLAYLIST-TYPE:EVENT',
|
||||
'#EXT-X-TARGETDURATION:6',
|
||||
'#EXTINF:6.0,',
|
||||
'segment0.ts',
|
||||
].join('\n');
|
||||
|
||||
expect(parseStreamInfo(playlist)).toEqual({
|
||||
targetLiveWindow: Number.POSITIVE_INFINITY,
|
||||
liveEdgeStartOffset: 18,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns `NaN` / `undefined` for `VOD` playlists', () => {
|
||||
const playlist = [
|
||||
'#EXTM3U',
|
||||
'#EXT-X-PLAYLIST-TYPE:VOD',
|
||||
'#EXT-X-TARGETDURATION:6',
|
||||
'#EXTINF:6.0,',
|
||||
'segment0.ts',
|
||||
'#EXT-X-ENDLIST',
|
||||
].join('\n');
|
||||
|
||||
const info = parseStreamInfo(playlist);
|
||||
expect(info.targetLiveWindow).toBeNaN();
|
||||
expect(info.liveEdgeStartOffset).toBeUndefined();
|
||||
});
|
||||
|
||||
it('treats `#EXT-X-ENDLIST` (without `VOD`) as on-demand', () => {
|
||||
const playlist = ['#EXTM3U', '#EXT-X-TARGETDURATION:6', '#EXTINF:6.0,', 'segment0.ts', '#EXT-X-ENDLIST'].join('\n');
|
||||
|
||||
const info = parseStreamInfo(playlist);
|
||||
expect(info.targetLiveWindow).toBeNaN();
|
||||
expect(info.liveEdgeStartOffset).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses `PART-TARGET * 2` for low-latency live', () => {
|
||||
const playlist = [
|
||||
'#EXTM3U',
|
||||
'#EXT-X-VERSION:9',
|
||||
'#EXT-X-TARGETDURATION:4',
|
||||
'#EXT-X-PART-INF:PART-TARGET=0.5',
|
||||
'#EXTINF:4.0,',
|
||||
'segment0.ts',
|
||||
].join('\n');
|
||||
|
||||
expect(parseStreamInfo(playlist)).toEqual({
|
||||
targetLiveWindow: 0,
|
||||
liveEdgeStartOffset: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers `PART-TARGET` over `TARGETDURATION` when both are present', () => {
|
||||
const playlist = [
|
||||
'#EXTM3U',
|
||||
'#EXT-X-TARGETDURATION:4',
|
||||
'#EXT-X-PART-INF:PART-TARGET=0.5',
|
||||
'#EXTINF:4.0,',
|
||||
'segment0.ts',
|
||||
].join('\n');
|
||||
|
||||
expect(parseStreamInfo(playlist).liveEdgeStartOffset).toBe(1);
|
||||
});
|
||||
|
||||
it('leaves `liveEdgeStartOffset` undefined when neither tag is present', () => {
|
||||
const playlist = ['#EXTM3U', '#EXTINF:6.0,', 'segment0.ts'].join('\n');
|
||||
expect(parseStreamInfo(playlist).liveEdgeStartOffset).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores extra whitespace and CRLF line endings', () => {
|
||||
const playlist = ['#EXTM3U', '#EXT-X-TARGETDURATION: 6 ', '#EXTINF:6.0,', 'segment0.ts'].join('\r\n');
|
||||
|
||||
expect(parseStreamInfo(playlist).liveEdgeStartOffset).toBe(18);
|
||||
});
|
||||
|
||||
it('parses `PART-TARGET` case-insensitively', () => {
|
||||
const playlist = [
|
||||
'#EXTM3U',
|
||||
'#EXT-X-TARGETDURATION:4',
|
||||
'#EXT-X-PART-INF:part-target=0.25',
|
||||
'#EXTINF:4.0,',
|
||||
'segment0.ts',
|
||||
].join('\n');
|
||||
|
||||
expect(parseStreamInfo(playlist).liveEdgeStartOffset).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStreamInfoFromSrc', () => {
|
||||
const media = ['#EXTM3U', '#EXT-X-TARGETDURATION:6', '#EXTINF:6.0,', 'segment0.ts'].join('\n');
|
||||
|
||||
it('parses a media playlist directly', async () => {
|
||||
mockFetch({ 'https://example.com/live.m3u8': media });
|
||||
|
||||
const info = await getStreamInfoFromSrc('https://example.com/live.m3u8');
|
||||
|
||||
expect(info).toEqual({ targetLiveWindow: 0, liveEdgeStartOffset: 18 });
|
||||
});
|
||||
|
||||
it('follows the first variant of a multivariant playlist', async () => {
|
||||
const master = ['#EXTM3U', '#EXT-X-STREAM-INF:BANDWIDTH=2000000', 'media.m3u8'].join('\n');
|
||||
mockFetch({
|
||||
'https://example.com/master.m3u8': master,
|
||||
'https://example.com/media.m3u8': media,
|
||||
});
|
||||
|
||||
const info = await getStreamInfoFromSrc('https://example.com/master.m3u8');
|
||||
|
||||
expect(info).toEqual({ targetLiveWindow: 0, liveEdgeStartOffset: 18 });
|
||||
});
|
||||
|
||||
it('throws when the initial fetch fails', async () => {
|
||||
mockFetch({ 'https://example.com/live.m3u8': { status: 500 } });
|
||||
|
||||
await expect(getStreamInfoFromSrc('https://example.com/live.m3u8')).rejects.toThrow(/500/);
|
||||
});
|
||||
|
||||
it('throws when a multivariant playlist has no variant URI', async () => {
|
||||
const master = ['#EXTM3U', '#EXT-X-STREAM-INF:BANDWIDTH=2000000'].join('\n');
|
||||
mockFetch({ 'https://example.com/master.m3u8': master });
|
||||
|
||||
await expect(getStreamInfoFromSrc('https://example.com/master.m3u8')).rejects.toThrow(/No media playlist URL/);
|
||||
});
|
||||
|
||||
it('passes the abort signal to fetch', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
const fetchSpy = vi.fn(async () => new Response(media, { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
await getStreamInfoFromSrc('https://example.com/live.m3u8', controller.signal).catch(() => {});
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith('https://example.com/live.m3u8', { signal: controller.signal });
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { isFunction, isObject } from '@videojs/utils/predicate';
|
||||
import type {
|
||||
MediaBufferCapability,
|
||||
MediaErrorCapability,
|
||||
MediaLiveCapability,
|
||||
MediaPauseCapability,
|
||||
MediaPlaybackRateCapability,
|
||||
MediaRemotePlaybackCapability,
|
||||
@@ -65,6 +66,10 @@ export function isMediaStreamTypeCapable(value: unknown): value is MediaStreamTy
|
||||
return isObject(value) && 'streamType' in value;
|
||||
}
|
||||
|
||||
export function isMediaLiveCapable(value: unknown): value is MediaLiveCapability {
|
||||
return isObject(value) && 'liveEdgeStart' in value && 'targetLiveWindow' in value;
|
||||
}
|
||||
|
||||
export function isQuerySelectorAllCapable<T extends string>(
|
||||
value: unknown
|
||||
): value is {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
MediaControlsState,
|
||||
MediaErrorState,
|
||||
MediaFullscreenState,
|
||||
MediaLiveState,
|
||||
MediaPictureInPictureState,
|
||||
MediaPlaybackRateState,
|
||||
MediaPlaybackState,
|
||||
@@ -67,8 +68,10 @@ export type AudioFeatures = [
|
||||
export type BackgroundFeatures = [];
|
||||
|
||||
/**
|
||||
* Features for a live video player. Mirrors {@link VideoFeatures} without the
|
||||
* playback-rate feature, which isn't meaningful for live streams.
|
||||
* Features for a live video player. Mirrors {@link VideoFeatures} but drops
|
||||
* the playback-rate feature (not meaningful for live) and adds
|
||||
* `PlayerFeature<MediaLiveState>` so the store exposes `liveEdgeStart` and
|
||||
* `targetLiveWindow`.
|
||||
*/
|
||||
export type LiveVideoFeatures = [
|
||||
PlayerFeature<MediaPlaybackState>,
|
||||
@@ -82,11 +85,14 @@ export type LiveVideoFeatures = [
|
||||
PlayerFeature<MediaControlsState>,
|
||||
PlayerFeature<MediaTextTrackState>,
|
||||
PlayerFeature<MediaErrorState>,
|
||||
PlayerFeature<MediaLiveState>,
|
||||
];
|
||||
|
||||
/**
|
||||
* Features for a live audio player. Mirrors {@link AudioFeatures} without the
|
||||
* playback-rate feature, which isn't meaningful for live streams.
|
||||
* Features for a live audio player. Mirrors {@link AudioFeatures} but drops
|
||||
* the playback-rate feature (not meaningful for live) and adds
|
||||
* `PlayerFeature<MediaLiveState>` so the store exposes `liveEdgeStart` and
|
||||
* `targetLiveWindow`.
|
||||
*/
|
||||
export type LiveAudioFeatures = [
|
||||
PlayerFeature<MediaPlaybackState>,
|
||||
@@ -95,6 +101,7 @@ export type LiveAudioFeatures = [
|
||||
PlayerFeature<MediaSourceState>,
|
||||
PlayerFeature<MediaBufferState>,
|
||||
PlayerFeature<MediaErrorState>,
|
||||
PlayerFeature<MediaLiveState>,
|
||||
];
|
||||
|
||||
export type VideoPlayerStore = PlayerStore<VideoFeatures>;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { bufferFeature } from './buffer';
|
||||
import { controlsFeature } from './controls';
|
||||
import { fullscreenFeature } from './fullscreen';
|
||||
import { liveFeature } from './live';
|
||||
import { pipFeature } from './pip';
|
||||
import { playbackFeature } from './playback';
|
||||
import { playbackRateFeature } from './playback-rate';
|
||||
@@ -18,6 +19,7 @@ export {
|
||||
bufferFeature as buffer,
|
||||
controlsFeature as controls,
|
||||
fullscreenFeature as fullscreen,
|
||||
liveFeature as live,
|
||||
pipFeature as pip,
|
||||
playbackFeature as playback,
|
||||
playbackRateFeature as playbackRate,
|
||||
|
||||
@@ -3,6 +3,7 @@ export * from './controls';
|
||||
export * from './error';
|
||||
export * as features from './feature.parts';
|
||||
export * from './fullscreen';
|
||||
export * from './live';
|
||||
export * from './pip';
|
||||
export * from './playback';
|
||||
export * from './playback-rate';
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { listen } from '@videojs/utils/dom';
|
||||
|
||||
import type { MediaLiveState } from '../../../core/media/state';
|
||||
import { definePlayerFeature } from '../../feature';
|
||||
import { isMediaLiveCapable } from '../../media/predicate';
|
||||
|
||||
/**
|
||||
* Player feature exposing `liveEdgeStart` and `targetLiveWindow` in store
|
||||
* state for media that implements `MediaLiveCapability` (currently
|
||||
* `HlsMedia` and its delegates).
|
||||
*
|
||||
* - `liveEdgeStart` — presentation time marking the start of the Live Edge
|
||||
* Window. Playing at the live edge when `currentTime >= liveEdgeStart`.
|
||||
* `NaN` when the stream isn't live or the value is unknown.
|
||||
* - `targetLiveWindow` — `0` for standard latency live, `Infinity` for DVR,
|
||||
* `NaN` for on-demand or unknown.
|
||||
*
|
||||
* Included by the {@link liveVideoFeatures} and {@link liveAudioFeatures}
|
||||
* presets; apps can also compose it into a custom preset.
|
||||
*
|
||||
* @see https://github.com/video-dev/media-ui-extensions/blob/main/proposals/0007-live-edge.md
|
||||
*/
|
||||
export const liveFeature = definePlayerFeature({
|
||||
name: 'live',
|
||||
state: (): MediaLiveState => ({
|
||||
liveEdgeStart: Number.NaN,
|
||||
targetLiveWindow: Number.NaN,
|
||||
}),
|
||||
|
||||
// `liveEdgeStart` is derived from `seekable.end` and the target offset —
|
||||
// no dedicated event — so we re-read it whenever any of its inputs
|
||||
// (seekable, targetLiveWindow, streamType, currentTime) might have changed.
|
||||
//
|
||||
// `timeupdate` is what keeps the cached value moving with the live edge
|
||||
// during playback; `progress`/`canplay` cover buffer/metadata transitions.
|
||||
attach({ target, signal, set }) {
|
||||
const { media } = target;
|
||||
|
||||
if (!isMediaLiveCapable(media)) return;
|
||||
|
||||
const sync = () =>
|
||||
set({
|
||||
liveEdgeStart: media.liveEdgeStart,
|
||||
targetLiveWindow: media.targetLiveWindow,
|
||||
});
|
||||
|
||||
sync();
|
||||
|
||||
listen(media, 'targetlivewindowchange', sync, { signal });
|
||||
listen(media, 'streamtypechange', sync, { signal });
|
||||
listen(media, 'loadedmetadata', sync, { signal });
|
||||
listen(media, 'canplay', sync, { signal });
|
||||
listen(media, 'progress', sync, { signal });
|
||||
listen(media, 'durationchange', sync, { signal });
|
||||
listen(media, 'timeupdate', sync, { signal });
|
||||
listen(media, 'emptied', sync, { signal });
|
||||
},
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { bufferFeature } from './buffer';
|
||||
import { controlsFeature } from './controls';
|
||||
import { errorFeature } from './error';
|
||||
import { fullscreenFeature } from './fullscreen';
|
||||
import { liveFeature } from './live';
|
||||
import { pipFeature } from './pip';
|
||||
import { playbackFeature } from './playback';
|
||||
import { playbackRateFeature } from './playback-rate';
|
||||
@@ -47,8 +48,10 @@ export const audioFeatures: AudioFeatures = [
|
||||
export const backgroundFeatures: BackgroundFeatures = [];
|
||||
|
||||
/**
|
||||
* Features for a live video player. Mirrors {@link videoFeatures} without the
|
||||
* playback-rate feature, which isn't meaningful for live streams.
|
||||
* Features for a live video player. Mirrors {@link videoFeatures} but drops
|
||||
* {@link playbackRateFeature} (not meaningful for live) and adds
|
||||
* {@link liveFeature} so store consumers can read `liveEdgeStart` and
|
||||
* `targetLiveWindow`.
|
||||
*/
|
||||
export const liveVideoFeatures: LiveVideoFeatures = [
|
||||
playbackFeature,
|
||||
@@ -62,11 +65,14 @@ export const liveVideoFeatures: LiveVideoFeatures = [
|
||||
controlsFeature,
|
||||
textTrackFeature,
|
||||
errorFeature,
|
||||
liveFeature,
|
||||
];
|
||||
|
||||
/**
|
||||
* Features for a live audio player. Mirrors {@link audioFeatures} without the
|
||||
* playback-rate feature, which isn't meaningful for live streams.
|
||||
* Features for a live audio player. Mirrors {@link audioFeatures} but drops
|
||||
* {@link playbackRateFeature} (not meaningful for live) and adds
|
||||
* {@link liveFeature} so store consumers can read `liveEdgeStart` and
|
||||
* `targetLiveWindow`.
|
||||
*/
|
||||
export const liveAudioFeatures: LiveAudioFeatures = [
|
||||
playbackFeature,
|
||||
@@ -75,4 +81,5 @@ export const liveAudioFeatures: LiveAudioFeatures = [
|
||||
sourceFeature,
|
||||
bufferFeature,
|
||||
errorFeature,
|
||||
liveFeature,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { createStore } from '@videojs/store';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { PlayerTarget } from '../../../media/types';
|
||||
import { createMockVideo } from '../../../tests/test-helpers';
|
||||
import { liveFeature } from '../live';
|
||||
|
||||
interface LiveCapableMedia extends EventTarget {
|
||||
liveEdgeStart: number;
|
||||
targetLiveWindow: number;
|
||||
}
|
||||
|
||||
function createLiveMedia(initial: Partial<LiveCapableMedia> = {}): LiveCapableMedia {
|
||||
const target = new EventTarget() as LiveCapableMedia;
|
||||
target.liveEdgeStart = initial.liveEdgeStart ?? Number.NaN;
|
||||
target.targetLiveWindow = initial.targetLiveWindow ?? Number.NaN;
|
||||
return target;
|
||||
}
|
||||
|
||||
describe('liveFeature', () => {
|
||||
describe('fallback (media without live-edge properties)', () => {
|
||||
it('stays at `NaN` / `NaN` when the media is not live-edge capable', () => {
|
||||
const video = createMockVideo({ duration: 120 });
|
||||
|
||||
const store = createStore<PlayerTarget>()(liveFeature);
|
||||
store.attach({ media: video, container: null });
|
||||
|
||||
expect(store.state.liveEdgeStart).toBeNaN();
|
||||
expect(store.state.targetLiveWindow).toBeNaN();
|
||||
});
|
||||
});
|
||||
|
||||
describe('capable media', () => {
|
||||
it('reads initial values on attach', () => {
|
||||
const media = createLiveMedia({ liveEdgeStart: 42, targetLiveWindow: 0 });
|
||||
|
||||
const store = createStore<PlayerTarget>()(liveFeature);
|
||||
store.attach({ media: media as unknown as PlayerTarget['media'], container: null });
|
||||
|
||||
expect(store.state.liveEdgeStart).toBe(42);
|
||||
expect(store.state.targetLiveWindow).toBe(0);
|
||||
});
|
||||
|
||||
it('re-reads both on `targetlivewindowchange`', () => {
|
||||
const media = createLiveMedia({ liveEdgeStart: 42, targetLiveWindow: 0 });
|
||||
|
||||
const store = createStore<PlayerTarget>()(liveFeature);
|
||||
store.attach({ media: media as unknown as PlayerTarget['media'], container: null });
|
||||
|
||||
media.liveEdgeStart = 102;
|
||||
media.targetLiveWindow = Number.POSITIVE_INFINITY;
|
||||
media.dispatchEvent(new Event('targetlivewindowchange'));
|
||||
|
||||
expect(store.state.liveEdgeStart).toBe(102);
|
||||
expect(store.state.targetLiveWindow).toBe(Number.POSITIVE_INFINITY);
|
||||
});
|
||||
|
||||
it('re-reads `liveEdgeStart` on `progress`', () => {
|
||||
const media = createLiveMedia({ liveEdgeStart: 42, targetLiveWindow: 0 });
|
||||
|
||||
const store = createStore<PlayerTarget>()(liveFeature);
|
||||
store.attach({ media: media as unknown as PlayerTarget['media'], container: null });
|
||||
|
||||
media.liveEdgeStart = 100;
|
||||
media.dispatchEvent(new Event('progress'));
|
||||
|
||||
expect(store.state.liveEdgeStart).toBe(100);
|
||||
});
|
||||
|
||||
it('re-reads `liveEdgeStart` on `durationchange`', () => {
|
||||
const media = createLiveMedia({ liveEdgeStart: 42, targetLiveWindow: 0 });
|
||||
|
||||
const store = createStore<PlayerTarget>()(liveFeature);
|
||||
store.attach({ media: media as unknown as PlayerTarget['media'], container: null });
|
||||
|
||||
media.liveEdgeStart = 200;
|
||||
media.dispatchEvent(new Event('durationchange'));
|
||||
|
||||
expect(store.state.liveEdgeStart).toBe(200);
|
||||
});
|
||||
|
||||
it('re-reads `liveEdgeStart` on `loadedmetadata`', () => {
|
||||
const media = createLiveMedia({ liveEdgeStart: Number.NaN, targetLiveWindow: 0 });
|
||||
|
||||
const store = createStore<PlayerTarget>()(liveFeature);
|
||||
store.attach({ media: media as unknown as PlayerTarget['media'], container: null });
|
||||
|
||||
media.liveEdgeStart = 50;
|
||||
media.dispatchEvent(new Event('loadedmetadata'));
|
||||
|
||||
expect(store.state.liveEdgeStart).toBe(50);
|
||||
});
|
||||
|
||||
it('re-reads `liveEdgeStart` on `canplay`', () => {
|
||||
const media = createLiveMedia({ liveEdgeStart: Number.NaN, targetLiveWindow: 0 });
|
||||
|
||||
const store = createStore<PlayerTarget>()(liveFeature);
|
||||
store.attach({ media: media as unknown as PlayerTarget['media'], container: null });
|
||||
|
||||
media.liveEdgeStart = 40;
|
||||
media.dispatchEvent(new Event('canplay'));
|
||||
|
||||
expect(store.state.liveEdgeStart).toBe(40);
|
||||
});
|
||||
|
||||
it('re-reads `liveEdgeStart` on `timeupdate` (tracks moving live edge)', () => {
|
||||
const media = createLiveMedia({ liveEdgeStart: 42, targetLiveWindow: 0 });
|
||||
|
||||
const store = createStore<PlayerTarget>()(liveFeature);
|
||||
store.attach({ media: media as unknown as PlayerTarget['media'], container: null });
|
||||
|
||||
media.liveEdgeStart = 43;
|
||||
media.dispatchEvent(new Event('timeupdate'));
|
||||
expect(store.state.liveEdgeStart).toBe(43);
|
||||
|
||||
media.liveEdgeStart = 44;
|
||||
media.dispatchEvent(new Event('timeupdate'));
|
||||
expect(store.state.liveEdgeStart).toBe(44);
|
||||
});
|
||||
|
||||
it('re-reads `liveEdgeStart` on `streamtypechange`', () => {
|
||||
const media = createLiveMedia({ liveEdgeStart: 42, targetLiveWindow: 0 });
|
||||
|
||||
const store = createStore<PlayerTarget>()(liveFeature);
|
||||
store.attach({ media: media as unknown as PlayerTarget['media'], container: null });
|
||||
|
||||
media.liveEdgeStart = Number.NaN;
|
||||
media.dispatchEvent(new Event('streamtypechange'));
|
||||
|
||||
expect(store.state.liveEdgeStart).toBeNaN();
|
||||
});
|
||||
|
||||
it('resets on `emptied`', () => {
|
||||
const media = createLiveMedia({ liveEdgeStart: 42, targetLiveWindow: 0 });
|
||||
|
||||
const store = createStore<PlayerTarget>()(liveFeature);
|
||||
store.attach({ media: media as unknown as PlayerTarget['media'], container: null });
|
||||
|
||||
media.liveEdgeStart = Number.NaN;
|
||||
media.targetLiveWindow = Number.NaN;
|
||||
media.dispatchEvent(new Event('emptied'));
|
||||
|
||||
expect(store.state.liveEdgeStart).toBeNaN();
|
||||
expect(store.state.targetLiveWindow).toBeNaN();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { bufferFeature } from './features/buffer';
|
||||
import { controlsFeature } from './features/controls';
|
||||
import { errorFeature } from './features/error';
|
||||
import { fullscreenFeature } from './features/fullscreen';
|
||||
import { liveFeature } from './features/live';
|
||||
import { pipFeature } from './features/pip';
|
||||
import { playbackFeature } from './features/playback';
|
||||
import { playbackRateFeature } from './features/playback-rate';
|
||||
@@ -22,6 +23,8 @@ export const selectControls = createSelector(controlsFeature);
|
||||
export const selectError = createSelector(errorFeature);
|
||||
/** Select the fullscreen state (fullscreen active, availability). */
|
||||
export const selectFullscreen = createSelector(fullscreenFeature);
|
||||
/** Select the live state (`liveEdgeStart`, `targetLiveWindow`). */
|
||||
export const selectLive = createSelector(liveFeature);
|
||||
/** Select the PiP state (picture-in-picture active, availability). */
|
||||
export const selectPiP = createSelector(pipFeature);
|
||||
/** Select the playback state (paused, ended, play, pause, toggle). */
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Live
|
||||
description: Live edge state for the player store
|
||||
---
|
||||
|
||||
import FeatureReference from "@/components/docs/api-reference/FeatureReference.astro";
|
||||
import DocsLink from "@/components/docs/DocsLink.astro";
|
||||
import FrameworkCase from "@/components/docs/FrameworkCase.astro";
|
||||
|
||||
Exposes live-edge state so components can render a "jump to live" button, detect DVR vs. standard live, or hide live-only UI for on-demand sources.
|
||||
|
||||
`liveEdgeStart` is the presentation time at which the Live Edge Window begins — playback is "at the live edge" when `currentTime >= liveEdgeStart`. `targetLiveWindow` reports the seekable range size: `0` for standard latency live, `Infinity` for DVR, `NaN` for on-demand or unknown. Both values are `NaN` when the media doesn't expose live-edge state (anything other than an HLS source today).
|
||||
|
||||
The live feature is included by the `liveVideoFeatures` and `liveAudioFeatures` presets; apps that build a custom preset can compose it in directly.
|
||||
|
||||
<FeatureReference feature="live" />
|
||||
|
||||
### Selector
|
||||
|
||||
<FrameworkCase frameworks={["react"]}>
|
||||
Pass `selectLive` to <DocsLink slug="reference/use-player">`usePlayer`</DocsLink> to subscribe to live state. Returns `undefined` if the live feature is not configured.
|
||||
</FrameworkCase>
|
||||
|
||||
<FrameworkCase frameworks={["html"]}>
|
||||
Pass `selectLive` to <DocsLink slug="reference/player-controller">`PlayerController`</DocsLink> to subscribe to live state. Returns `undefined` if the live feature is not configured.
|
||||
</FrameworkCase>
|
||||
|
||||
<FrameworkCase frameworks={["react"]}>
|
||||
```tsx title="LiveEdgeIndicator.tsx"
|
||||
import { selectLive, selectTime, usePlayer } from '@videojs/react';
|
||||
|
||||
function LiveEdgeIndicator() {
|
||||
const live = usePlayer(selectLive);
|
||||
const time = usePlayer(selectTime);
|
||||
if (!live || Number.isNaN(live.targetLiveWindow)) return null;
|
||||
|
||||
const atEdge = time != null && time.currentTime >= live.liveEdgeStart;
|
||||
return <span className="live-indicator">{atEdge ? 'LIVE' : 'BEHIND LIVE'}</span>;
|
||||
}
|
||||
```
|
||||
</FrameworkCase>
|
||||
|
||||
<FrameworkCase frameworks={["html"]}>
|
||||
```ts title="live-edge-button.ts"
|
||||
import { createPlayer, MediaElement, selectLive } from '@videojs/html';
|
||||
import { liveVideoFeatures } from '@videojs/html/live-video';
|
||||
|
||||
const { PlayerController, context } = createPlayer({ features: liveVideoFeatures });
|
||||
|
||||
class LiveEdgeButton extends MediaElement {
|
||||
readonly #live = new PlayerController(this, context, selectLive);
|
||||
}
|
||||
```
|
||||
</FrameworkCase>
|
||||
@@ -127,6 +127,7 @@ export const sidebar: Sidebar = [
|
||||
{ slug: 'reference/feature-controls' },
|
||||
{ slug: 'reference/feature-error' },
|
||||
{ slug: 'reference/feature-fullscreen' },
|
||||
{ slug: 'reference/feature-live' },
|
||||
{ slug: 'reference/feature-pip', sidebarLabel: 'Picture-in-picture' },
|
||||
{ slug: 'reference/feature-playback' },
|
||||
{ slug: 'reference/feature-playback-rate' },
|
||||
|
||||
Reference in New Issue
Block a user