feat(core): add liveEdgeStart and targetLiveWindow properties (#1445)

This commit is contained in:
Wesley Luyten
2026-04-27 12:40:07 -07:00
committed by GitHub
parent bb72a84dc1
commit e3d4ff9d68
22 changed files with 1510 additions and 11 deletions
+19
View File
@@ -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.
+24
View File
@@ -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> {
+5 -2
View File
@@ -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)))
)
)
) {}
+19
View File
@@ -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);
+68
View File
@@ -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 });
});
});
+5
View File
@@ -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 {
+11 -4
View File
@@ -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();
});
});
});
+3
View File
@@ -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>
+1
View File
@@ -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' },