From e3d4ff9d681ec4c31247542f7c2136d341e05303 Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Mon, 27 Apr 2026 12:40:07 -0700 Subject: [PATCH] feat(core): add liveEdgeStart and targetLiveWindow properties (#1445) --- packages/core/src/core/media/state.ts | 19 ++ packages/core/src/core/media/types.ts | 24 ++ packages/core/src/dom/media/hls/hlsjs.ts | 7 +- packages/core/src/dom/media/hls/index.ts | 19 ++ packages/core/src/dom/media/hls/live.ts | 68 +++++ .../src/dom/media/hls/tests/hls-media.test.ts | 23 ++ .../core/src/dom/media/hls/tests/live.test.ts | 248 +++++++++++++++ .../core/src/dom/media/native-hls/index.ts | 5 +- .../core/src/dom/media/native-hls/live.ts | 113 +++++++ .../src/dom/media/native-hls/m3u8-utils.ts | 140 +++++++++ .../dom/media/native-hls/tests/live.test.ts | 286 ++++++++++++++++++ .../media/native-hls/tests/m3u8-utils.test.ts | 269 ++++++++++++++++ packages/core/src/dom/media/predicate.ts | 5 + packages/core/src/dom/media/types.ts | 15 +- .../src/dom/store/features/feature.parts.ts | 2 + packages/core/src/dom/store/features/index.ts | 1 + packages/core/src/dom/store/features/live.ts | 58 ++++ .../core/src/dom/store/features/presets.ts | 15 +- .../src/dom/store/features/tests/live.test.ts | 146 +++++++++ packages/core/src/dom/store/selectors.ts | 3 + .../content/docs/reference/feature-live.mdx | 54 ++++ site/src/docs.config.ts | 1 + 22 files changed, 1510 insertions(+), 11 deletions(-) create mode 100644 packages/core/src/dom/media/hls/live.ts create mode 100644 packages/core/src/dom/media/hls/tests/live.test.ts create mode 100644 packages/core/src/dom/media/native-hls/live.ts create mode 100644 packages/core/src/dom/media/native-hls/m3u8-utils.ts create mode 100644 packages/core/src/dom/media/native-hls/tests/live.test.ts create mode 100644 packages/core/src/dom/media/native-hls/tests/m3u8-utils.test.ts create mode 100644 packages/core/src/dom/store/features/live.ts create mode 100644 packages/core/src/dom/store/features/tests/live.test.ts create mode 100644 site/src/content/docs/reference/feature-live.mdx diff --git a/packages/core/src/core/media/state.ts b/packages/core/src/core/media/state.ts index ad2223cc..d4b9151a 100644 --- a/packages/core/src/core/media/state.ts +++ b/packages/core/src/core/media/state.ts @@ -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. diff --git a/packages/core/src/core/media/types.ts b/packages/core/src/core/media/types.ts index 226b249f..057c9d3f 100644 --- a/packages/core/src/core/media/types.ts +++ b/packages/core/src/core/media/types.ts @@ -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 { diff --git a/packages/core/src/dom/media/hls/hlsjs.ts b/packages/core/src/dom/media/hls/hlsjs.ts index a4a37450..d8584dab 100644 --- a/packages/core/src/dom/media/hls/hlsjs.ts +++ b/packages/core/src/dom/media/hls/hlsjs.ts @@ -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>(BaseClass: Base) { + class HlsJsMediaLive extends (BaseClass as Constructor) { + #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 }>; +} diff --git a/packages/core/src/dom/media/hls/tests/hls-media.test.ts b/packages/core/src/dom/media/hls/tests/hls-media.test.ts index 80aa0209..a9fbbdd0 100644 --- a/packages/core/src/dom/media/hls/tests/hls-media.test.ts +++ b/packages/core/src/dom/media/hls/tests/hls-media.test.ts @@ -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', () => { diff --git a/packages/core/src/dom/media/hls/tests/live.test.ts b/packages/core/src/dom/media/hls/tests/live.test.ts new file mode 100644 index 00000000..69488ca0 --- /dev/null +++ b/packages/core/src/dom/media/hls/tests/live.test.ts @@ -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 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) { + 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(); + }); + }); +}); diff --git a/packages/core/src/dom/media/native-hls/index.ts b/packages/core/src/dom/media/native-hls/index.ts index f17e4078..9704ce39 100644 --- a/packages/core/src/dom/media/native-hls/index.ts +++ b/packages/core/src/dom/media/native-hls/index.ts @@ -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>(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) { + #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 }>; +} diff --git a/packages/core/src/dom/media/native-hls/m3u8-utils.ts b/packages/core/src/dom/media/native-hls/m3u8-utils.ts new file mode 100644 index 00000000..8e269f62 --- /dev/null +++ b/packages/core/src/dom/media/native-hls/m3u8-utils.ts @@ -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 { + 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); +} diff --git a/packages/core/src/dom/media/native-hls/tests/live.test.ts b/packages/core/src/dom/media/native-hls/tests/live.test.ts new file mode 100644 index 00000000..3f2724e3 --- /dev/null +++ b/packages/core/src/dom/media/native-hls/tests/live.test.ts @@ -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): 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(); + }); + }); +}); diff --git a/packages/core/src/dom/media/native-hls/tests/m3u8-utils.test.ts b/packages/core/src/dom/media/native-hls/tests/m3u8-utils.test.ts new file mode 100644 index 00000000..26e5c8c1 --- /dev/null +++ b/packages/core/src/dom/media/native-hls/tests/m3u8-utils.test.ts @@ -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): 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 }); + }); +}); diff --git a/packages/core/src/dom/media/predicate.ts b/packages/core/src/dom/media/predicate.ts index 400b8055..face5898 100644 --- a/packages/core/src/dom/media/predicate.ts +++ b/packages/core/src/dom/media/predicate.ts @@ -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( value: unknown ): value is { diff --git a/packages/core/src/dom/media/types.ts b/packages/core/src/dom/media/types.ts index 6838fc2e..2cf8be37 100644 --- a/packages/core/src/dom/media/types.ts +++ b/packages/core/src/dom/media/types.ts @@ -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` so the store exposes `liveEdgeStart` and + * `targetLiveWindow`. */ export type LiveVideoFeatures = [ PlayerFeature, @@ -82,11 +85,14 @@ export type LiveVideoFeatures = [ PlayerFeature, PlayerFeature, PlayerFeature, + PlayerFeature, ]; /** - * 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` so the store exposes `liveEdgeStart` and + * `targetLiveWindow`. */ export type LiveAudioFeatures = [ PlayerFeature, @@ -95,6 +101,7 @@ export type LiveAudioFeatures = [ PlayerFeature, PlayerFeature, PlayerFeature, + PlayerFeature, ]; export type VideoPlayerStore = PlayerStore; diff --git a/packages/core/src/dom/store/features/feature.parts.ts b/packages/core/src/dom/store/features/feature.parts.ts index be482b5b..44c7b15b 100644 --- a/packages/core/src/dom/store/features/feature.parts.ts +++ b/packages/core/src/dom/store/features/feature.parts.ts @@ -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, diff --git a/packages/core/src/dom/store/features/index.ts b/packages/core/src/dom/store/features/index.ts index 42439b5d..65768a9e 100644 --- a/packages/core/src/dom/store/features/index.ts +++ b/packages/core/src/dom/store/features/index.ts @@ -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'; diff --git a/packages/core/src/dom/store/features/live.ts b/packages/core/src/dom/store/features/live.ts new file mode 100644 index 00000000..4b6416cd --- /dev/null +++ b/packages/core/src/dom/store/features/live.ts @@ -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 }); + }, +}); diff --git a/packages/core/src/dom/store/features/presets.ts b/packages/core/src/dom/store/features/presets.ts index 5b04fd5f..c1919724 100644 --- a/packages/core/src/dom/store/features/presets.ts +++ b/packages/core/src/dom/store/features/presets.ts @@ -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, ]; diff --git a/packages/core/src/dom/store/features/tests/live.test.ts b/packages/core/src/dom/store/features/tests/live.test.ts new file mode 100644 index 00000000..e4d997de --- /dev/null +++ b/packages/core/src/dom/store/features/tests/live.test.ts @@ -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 { + 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()(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()(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()(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()(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()(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()(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()(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()(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()(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()(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(); + }); + }); +}); diff --git a/packages/core/src/dom/store/selectors.ts b/packages/core/src/dom/store/selectors.ts index 7e5c03ce..17d28ab9 100644 --- a/packages/core/src/dom/store/selectors.ts +++ b/packages/core/src/dom/store/selectors.ts @@ -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). */ diff --git a/site/src/content/docs/reference/feature-live.mdx b/site/src/content/docs/reference/feature-live.mdx new file mode 100644 index 00000000..54134478 --- /dev/null +++ b/site/src/content/docs/reference/feature-live.mdx @@ -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. + + + +### Selector + + +Pass `selectLive` to `usePlayer` to subscribe to live state. Returns `undefined` if the live feature is not configured. + + + +Pass `selectLive` to `PlayerController` to subscribe to live state. Returns `undefined` if the live feature is not configured. + + + +```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 {atEdge ? 'LIVE' : 'BEHIND LIVE'}; +} +``` + + + +```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); +} +``` + diff --git a/site/src/docs.config.ts b/site/src/docs.config.ts index 7457dc83..68e59a4c 100644 --- a/site/src/docs.config.ts +++ b/site/src/docs.config.ts @@ -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' },