From 587c302fe5c9f5f558c7358cd84f6438b9b890b7 Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Tue, 21 Apr 2026 20:27:54 -0700 Subject: [PATCH] feat(core): add HLS stream-type detection and live duration (#1387) --- apps/sandbox/app/shared/sources.ts | 6 + packages/core/src/core/media/state.ts | 17 +- packages/core/src/core/media/types.ts | 26 +++ .../core/src/dom/media/google-cast/types.ts | 3 +- packages/core/src/dom/media/hls/hlsjs.ts | 5 +- packages/core/src/dom/media/hls/index.ts | 40 +++- .../core/src/dom/media/hls/stream-type.ts | 50 +++++ .../src/dom/media/hls/tests/hls-media.test.ts | 208 ++++++++++++++++++ .../core/src/dom/media/native-hls/index.ts | 11 +- .../src/dom/media/native-hls/stream-type.ts | 82 +++++++ packages/core/src/dom/media/predicate.ts | 5 + packages/core/src/dom/media/types.ts | 20 ++ .../src/dom/store/features/feature.parts.ts | 2 + packages/core/src/dom/store/features/index.ts | 1 + .../core/src/dom/store/features/presets.ts | 3 + .../src/dom/store/features/stream-type.ts | 50 +++++ .../store/features/tests/stream-type.test.ts | 112 ++++++++++ .../src/dom/store/features/tests/time.test.ts | 85 ++++++- packages/core/src/dom/store/features/time.ts | 32 ++- packages/core/src/dom/store/selectors.ts | 3 + .../docs/reference/feature-stream-type.mdx | 50 +++++ site/src/docs.config.ts | 1 + 22 files changed, 795 insertions(+), 17 deletions(-) create mode 100644 packages/core/src/dom/media/hls/stream-type.ts create mode 100644 packages/core/src/dom/media/native-hls/stream-type.ts create mode 100644 packages/core/src/dom/store/features/stream-type.ts create mode 100644 packages/core/src/dom/store/features/tests/stream-type.test.ts create mode 100644 site/src/content/docs/reference/feature-stream-type.mdx diff --git a/apps/sandbox/app/shared/sources.ts b/apps/sandbox/app/shared/sources.ts index ad85b66a..752050d1 100644 --- a/apps/sandbox/app/shared/sources.ts +++ b/apps/sandbox/app/shared/sources.ts @@ -31,6 +31,12 @@ export const SOURCES = { type: 'hls', subType: 'mp4', }, + 'hls-live': { + label: 'HLS - Live Stream Big Buck Bunny', + url: 'https://stream.mux.com/v69RSHhFelSm4701snP22dYz2jICy4E4FUyk02rW4gxRM.m3u8', + type: 'hls', + subType: 'mp4', + }, 'mp4-1': { label: 'MP4 - Dancing Dude', url: 'https://stream.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/highest.mp4', diff --git a/packages/core/src/core/media/state.ts b/packages/core/src/core/media/state.ts index d55558da..ad2223cc 100644 --- a/packages/core/src/core/media/state.ts +++ b/packages/core/src/core/media/state.ts @@ -1,3 +1,5 @@ +import type { MediaFeatureAvailability, MediaStreamType } from './types'; + export interface MediaPlaybackState { /** * Whether playback is paused. @@ -37,8 +39,6 @@ export interface MediaPlaybackState { togglePaused(): boolean; } -import type { MediaFeatureAvailability } from './types'; - export interface MediaVolumeState { /** * Volume level from 0 (silent) to 1 (max). @@ -122,6 +122,19 @@ export interface MediaSourceState { loadSource(src: string): string; } +export interface MediaStreamTypeState { + /** + * Current stream delivery type. + * + * Components use this to toggle live-specific UI (e.g. a live indicator, + * a "jump to live edge" affordance, or hiding the time display). + * + * @see {@link MediaStreamTypes} for the canonical string values. + * @see https://github.com/video-dev/media-ui-extensions/blob/main/proposals/0010-stream-type.md + */ + streamType: MediaStreamType; +} + 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 5ec61c11..fa11cf71 100644 --- a/packages/core/src/core/media/types.ts +++ b/packages/core/src/core/media/types.ts @@ -185,6 +185,32 @@ export interface MediaRemotePlaybackCapability { readonly remote: RemotePlaybackLike; } +export interface MediaStreamTypeEvents { + streamtypechange: EventLike; +} + +/** + * Canonical values for {@link MediaStreamType}. + * + * - `ON_DEMAND` — a finite-duration asset (VOD). Scrubbing is generally + * supported across the full timeline. + * - `LIVE` — a live or DVR stream. The seekable window may slide as new + * segments are published, and `duration` is typically `Infinity`. + * - `UNKNOWN` — the stream type has not been determined yet (no source, + * or metadata has not loaded). + */ +export const MediaStreamTypes = { + ON_DEMAND: 'on-demand', + LIVE: 'live', + UNKNOWN: 'unknown', +} as const; + +export type MediaStreamType = (typeof MediaStreamTypes)[keyof typeof MediaStreamTypes]; + +export interface MediaStreamTypeCapability { + readonly streamType: MediaStreamType; +} + interface MediaEvents extends MediaPlaybackEvents {} export interface Media extends MediaPlaybackCapability, EventTargetLike { diff --git a/packages/core/src/dom/media/google-cast/types.ts b/packages/core/src/dom/media/google-cast/types.ts index 80821e01..cf64631a 100644 --- a/packages/core/src/dom/media/google-cast/types.ts +++ b/packages/core/src/dom/media/google-cast/types.ts @@ -1,4 +1,5 @@ import type { RemotePlaybackLike } from '../../../core/media/types'; +import type { StreamType } from '../hls/index'; import type { RemotePlayback } from './remote-playback'; import type { CastOptions } from './utils'; @@ -35,7 +36,7 @@ export interface GoogleCastMediaHost extends EventTarget { readyState: number; volume: number; playbackRate: number; - streamType?: string; + streamType?: StreamType; textTracks: TextTrackList; disableRemotePlayback: boolean; load(): void | Promise; diff --git a/packages/core/src/dom/media/hls/hlsjs.ts b/packages/core/src/dom/media/hls/hlsjs.ts index c59318ec..a4a37450 100644 --- a/packages/core/src/dom/media/hls/hlsjs.ts +++ b/packages/core/src/dom/media/hls/hlsjs.ts @@ -4,6 +4,7 @@ import { HTMLVideoElementHost } from '../video-host'; import { HlsJsMediaErrorsMixin } from './errors'; import { HlsJsMediaMetadataTracksMixin } from './metadata-tracks'; import { HlsJsMediaPreloadMixin } from './preload'; +import { HlsJsMediaStreamTypeMixin } from './stream-type'; import { HlsJsMediaTextTracksMixin } from './text-tracks'; export const defaultHlsConfig: Partial = { @@ -56,5 +57,7 @@ class HlsJsMediaBase extends HTMLVideoElementHost implements MediaEngineHost; debug: boolean; preload: PreloadType; + streamType: StreamType; } export const hlsMediaDefaultProps: HlsMediaProps = { @@ -38,6 +43,7 @@ export const hlsMediaDefaultProps: HlsMediaProps = { config: {}, debug: false, preload: 'metadata', + streamType: MediaStreamTypes.UNKNOWN, }; export class HlsMedia extends HTMLVideoElementHost implements HlsMediaProps { @@ -48,6 +54,8 @@ export class HlsMedia extends HTMLVideoElementHost implements HlsMediaProps { #config = { ...hlsMediaDefaultProps.config }; #debug = hlsMediaDefaultProps.debug; #preload = hlsMediaDefaultProps.preload; + #streamType: StreamType = hlsMediaDefaultProps.streamType; + #isUserStreamType = false; #loadRequested?: Promise | null; #prevEngineProps?: Record | null; @@ -106,6 +114,7 @@ export class HlsMedia extends HTMLVideoElementHost implements HlsMediaProps { this.#requestLoad(); } + /** Preload type (`'none'` / `'metadata'` / `'auto'`). */ get preload() { return this.#preload; } @@ -117,6 +126,25 @@ export class HlsMedia extends HTMLVideoElementHost implements HlsMediaProps { } } + /** Current stream type (`'on-demand'` / `'live'` / `'unknown'`). */ + get streamType(): StreamType { + return this.#delegate?.streamType ?? this.#streamType; + } + + set streamType(value: StreamType) { + this.#isUserStreamType = value !== StreamTypes.UNKNOWN; + + if (this.#delegate) { + this.#delegate.streamType = value; + this.#streamType = this.#delegate.streamType; + return; + } + + if (this.#streamType === value) return; + this.#streamType = value; + this.dispatchEvent(new Event('streamtypechange')); + } + attach(target: HTMLVideoElement) { super.attach(target); this.#delegate?.attach(target); @@ -148,11 +176,17 @@ export class HlsMedia extends HTMLVideoElementHost implements HlsMediaProps { bridgeEvents(this.#delegate, this); - if (this.target) { - this.#delegate.attach(this.target); + // Apply user `streamType` before `attach()` so native delegates do not run + // synchronous duration-based detection first and emit a transient value. + if (this.#isUserStreamType) { + this.#delegate.streamType = this.#streamType; } this.#delegate.preload = this.preload; + + if (this.target) { + this.#delegate.attach(this.target); + } } if (this.#delegate) { @@ -185,6 +219,8 @@ export class HlsMedia extends HTMLVideoElementHost implements HlsMediaProps { this.#delegate = null; this.#prevEngineProps = null; this.#loadRequested = null; + // Delegate teardown already emits `streamtypechange` (bridged); only sync cache. + if (!this.#isUserStreamType) this.#streamType = StreamTypes.UNKNOWN; } } diff --git a/packages/core/src/dom/media/hls/stream-type.ts b/packages/core/src/dom/media/hls/stream-type.ts new file mode 100644 index 00000000..d5246281 --- /dev/null +++ b/packages/core/src/dom/media/hls/stream-type.ts @@ -0,0 +1,50 @@ +import type { Constructor } from '@videojs/utils/types'; +import type { LevelLoadedData } from 'hls.js'; +import Hls from 'hls.js'; +import { type MediaStreamType, MediaStreamTypes } from '../../../core/media/types'; +import type { HlsEngineHost } from './types'; + +export function HlsJsMediaStreamTypeMixin>(BaseClass: Base) { + class HlsJsMediaStreamType extends (BaseClass as Constructor) { + #streamType: MediaStreamType = MediaStreamTypes.UNKNOWN; + #isUserStreamType = false; + + constructor(...args: any[]) { + super(...args); + + this.engine?.on(Hls.Events.MANIFEST_LOADING, () => this.#setDetected(MediaStreamTypes.UNKNOWN)); + this.engine?.on(Hls.Events.DESTROYING, () => this.#setDetected(MediaStreamTypes.UNKNOWN)); + this.engine?.on(Hls.Events.LEVEL_LOADED, (_event: string, data: LevelLoadedData) => { + this.#setDetected(data.details.live ? MediaStreamTypes.LIVE : MediaStreamTypes.ON_DEMAND); + }); + } + + get streamType(): MediaStreamType { + return this.#streamType; + } + + set streamType(value: MediaStreamType) { + if (value === MediaStreamTypes.UNKNOWN) { + this.#isUserStreamType = false; + this.#update(MediaStreamTypes.UNKNOWN); + return; + } + + this.#isUserStreamType = true; + this.#update(value); + } + + #setDetected(value: MediaStreamType): void { + if (this.#isUserStreamType) return; + this.#update(value); + } + + #update(value: MediaStreamType): void { + if (this.#streamType === value) return; + this.#streamType = value; + this.dispatchEvent(new Event('streamtypechange')); + } + } + + return HlsJsMediaStreamType as unknown as Base & Constructor<{ streamType: MediaStreamType }>; +} 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 0cc3320e..80aa0209 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 @@ -1,11 +1,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { MediaError } from '../../../../core/media/media-error'; +import { NativeHlsMedia } from '../../native-hls'; import { HlsMedia, SourceTypes } from '../index'; afterEach(() => { document.body.innerHTML = ''; }); +function fireDurationChange(video: HTMLVideoElement, duration: number) { + Object.defineProperty(video, 'duration', { value: duration, configurable: true }); + video.dispatchEvent(new Event('durationchange')); +} + function fireNativeError(video: HTMLVideoElement, code: number, message = '') { Object.defineProperty(video, 'error', { value: { code, message }, @@ -120,4 +126,206 @@ describe('HlsMedia', () => { expect(video.volume).toBe(0.5); }); }); + + describe('streamType', () => { + it('defaults to `unknown` before load', () => { + const media = new HlsMedia(); + expect(media.streamType).toBe('unknown'); + }); + + it('auto-detects `live` from a native delegate with infinite duration', () => { + const { media, video } = setup(); + + const handler = vi.fn(); + media.addEventListener('streamtypechange', handler); + + fireDurationChange(video, Infinity); + + expect(media.streamType).toBe('live'); + expect(handler).toHaveBeenCalledOnce(); + }); + + it('auto-detects `on-demand` from a native delegate with finite duration', () => { + const { media, video } = setup(); + + const handler = vi.fn(); + media.addEventListener('streamtypechange', handler); + + fireDurationChange(video, 120); + + expect(media.streamType).toBe('on-demand'); + expect(handler).toHaveBeenCalledOnce(); + }); + + it('dedupes `streamtypechange` when the detected value does not change', () => { + const { media, video } = setup(); + + const handler = vi.fn(); + media.addEventListener('streamtypechange', handler); + + fireDurationChange(video, 120); + fireDurationChange(video, 240); + + expect(handler).toHaveBeenCalledOnce(); + }); + + it('dispatches `streamtypechange` once per transition when the engine is recreated', () => { + const { media, video } = setup(); + + const handler = vi.fn(); + media.addEventListener('streamtypechange', handler); + + fireDurationChange(video, Infinity); + expect(media.streamType).toBe('live'); + + handler.mockClear(); + // `debug` is part of `HlsMedia`'s engine props — toggling it recreates the + // native delegate without switching playback engines. + media.debug = true; + media.load(); + + // Teardown: a single `live` → `unknown`, then the new delegate re-detects + // `live` from the same element during `attach`. + expect(handler).toHaveBeenCalledTimes(2); + expect(media.streamType).toBe('live'); + }); + + it('does not emit a transient auto-detected `streamType` before a user override when the native delegate is recreated', () => { + const { media, video } = setup(); + + Object.defineProperty(video, 'duration', { value: 120, configurable: true }); + media.streamType = 'live'; + expect(media.streamType).toBe('live'); + + const seen: string[] = []; + media.addEventListener('streamtypechange', () => { + seen.push(media.streamType); + }); + + // Recreates the native delegate; duration would otherwise sync-detect as `on-demand`. + media.debug = true; + media.load(); + + expect(seen).not.toContain('on-demand'); + expect(media.streamType).toBe('live'); + }); + + it('lets user-set values win over auto-detection', () => { + const { media, video } = setup(); + + const handler = vi.fn(); + media.addEventListener('streamtypechange', handler); + + media.streamType = 'live'; + expect(media.streamType).toBe('live'); + expect(handler).toHaveBeenCalledOnce(); + + fireDurationChange(video, 120); + expect(media.streamType).toBe('live'); + expect(handler).toHaveBeenCalledOnce(); + }); + + it('clears the user override when set back to `unknown`', () => { + const { media, video } = setup(); + + media.streamType = 'live'; + fireDurationChange(video, 120); + expect(media.streamType).toBe('live'); + + media.streamType = 'unknown'; + + expect(media.streamType).toBe('on-demand'); + }); + + it('dispatches `streamtypechange` when set before a delegate exists', () => { + const media = new HlsMedia(); + const handler = vi.fn(); + media.addEventListener('streamtypechange', handler); + + media.streamType = 'live'; + + expect(media.streamType).toBe('live'); + expect(handler).toHaveBeenCalledOnce(); + }); + + it('preserves a user-set value across `load()` on the same engine', () => { + const { media, video } = setup(); + + media.streamType = 'live'; + + const handler = vi.fn(); + media.addEventListener('streamtypechange', handler); + + media.load(); + + expect(media.streamType).toBe('live'); + expect(handler).not.toHaveBeenCalled(); + + fireDurationChange(video, 120); + expect(media.streamType).toBe('live'); + expect(handler).not.toHaveBeenCalled(); + }); + + it('preserves a user-set value across engine recreation', () => { + const { media } = setup(); + + media.streamType = 'live'; + expect(media.streamType).toBe('live'); + + media.preferPlayback = 'mse'; + media.load(); + + expect(media.streamType).toBe('live'); + }); + + it('stops preserving after the user override is cleared with `unknown`', () => { + const { media } = setup(); + + media.streamType = 'live'; + media.streamType = 'unknown'; + + media.preferPlayback = 'mse'; + media.load(); + + expect(media.streamType).toBe('unknown'); + }); + }); +}); + +describe('NativeHlsMedia streamType', () => { + function setupNative() { + const video = document.createElement('video'); + document.body.appendChild(video); + const media = new NativeHlsMedia(); + media.attach(video); + return { media, video }; + } + + it('defaults to `unknown`', () => { + const media = new NativeHlsMedia(); + expect(media.streamType).toBe('unknown'); + }); + + it('detects `live` and fires `streamtypechange`', () => { + const { media, video } = setupNative(); + + const handler = vi.fn(); + media.addEventListener('streamtypechange', handler); + + fireDurationChange(video, Infinity); + + expect(media.streamType).toBe('live'); + expect(handler).toHaveBeenCalledOnce(); + }); + + it('honors a user override and clears it on `unknown`', () => { + const { media, video } = setupNative(); + + media.streamType = 'live'; + fireDurationChange(video, 120); + expect(media.streamType).toBe('live'); + + media.streamType = 'unknown'; + expect(media.streamType).toBe('on-demand'); + }); }); diff --git a/packages/core/src/dom/media/native-hls/index.ts b/packages/core/src/dom/media/native-hls/index.ts index 955b3588..f17e4078 100644 --- a/packages/core/src/dom/media/native-hls/index.ts +++ b/packages/core/src/dom/media/native-hls/index.ts @@ -1,19 +1,26 @@ +import { type MediaStreamType, MediaStreamTypes } from '../../../core/media/types'; import { HTMLVideoElementHost } from '../video-host'; import { NativeHlsMediaErrorsMixin } from './errors'; +import { NativeHlsMediaStreamTypeMixin } from './stream-type'; export type PreloadType = '' | 'none' | 'metadata' | 'auto'; +export type StreamType = MediaStreamType; + +export const StreamTypes = MediaStreamTypes; export interface NativeHlsMediaProps { src: string; preload: PreloadType; + streamType: StreamType; } export const nativeHlsMediaDefaultProps: NativeHlsMediaProps = { src: '', preload: 'metadata', + streamType: MediaStreamTypes.UNKNOWN, }; -class NativeHlsMediaBase extends HTMLVideoElementHost implements NativeHlsMediaProps { +class NativeHlsMediaBase extends HTMLVideoElementHost implements Omit { #src = nativeHlsMediaDefaultProps.src; #preload = nativeHlsMediaDefaultProps.preload; @@ -54,4 +61,4 @@ class NativeHlsMediaBase extends HTMLVideoElementHost implements NativeHlsMediaP } } -export class NativeHlsMedia extends NativeHlsMediaErrorsMixin(NativeHlsMediaBase) {} +export class NativeHlsMedia extends NativeHlsMediaStreamTypeMixin(NativeHlsMediaErrorsMixin(NativeHlsMediaBase)) {} diff --git a/packages/core/src/dom/media/native-hls/stream-type.ts b/packages/core/src/dom/media/native-hls/stream-type.ts new file mode 100644 index 00000000..a7377534 --- /dev/null +++ b/packages/core/src/dom/media/native-hls/stream-type.ts @@ -0,0 +1,82 @@ +import type { Constructor } from '@videojs/utils/types'; +import { type MediaStreamType, MediaStreamTypes } from '../../../core/media/types'; +import type { NativeMediaHost } from './errors'; + +export function NativeHlsMediaStreamTypeMixin>(BaseClass: Base) { + class NativeHlsMediaStreamType extends (BaseClass as Constructor) { + #streamType: MediaStreamType = MediaStreamTypes.UNKNOWN; + #isUserStreamType = false; + #disconnect: AbortController | null = null; + + get streamType(): MediaStreamType { + return this.#streamType; + } + + set streamType(value: MediaStreamType) { + if (value === MediaStreamTypes.UNKNOWN) { + this.#isUserStreamType = false; + this.#setDetected(this.#detect()); + return; + } + + this.#isUserStreamType = true; + this.#update(value); + } + + attach(target: EventTarget): void { + super.attach?.(target); + this.#init(target as HTMLMediaElement); + } + + detach(): void { + this.#destroy(); + this.#setDetected(MediaStreamTypes.UNKNOWN); + super.detach?.(); + } + + destroy(): void { + this.#destroy(); + super.destroy?.(); + } + + #destroy(): void { + this.#disconnect?.abort(); + this.#disconnect = null; + } + + #init(target: HTMLMediaElement): void { + this.#destroy(); + this.#disconnect = new AbortController(); + const { signal } = this.#disconnect; + + const detect = () => this.#setDetected(this.#detect(target)); + + target.addEventListener('durationchange', detect, { signal }); + target.addEventListener('loadedmetadata', detect, { signal }); + target.addEventListener('emptied', () => this.#setDetected(MediaStreamTypes.UNKNOWN), { signal }); + + detect(); + } + + #detect(target: HTMLMediaElement | null = this.target as HTMLMediaElement | null): MediaStreamType { + if (!target) return MediaStreamTypes.UNKNOWN; + const { duration } = target; + if (duration === Infinity) return MediaStreamTypes.LIVE; + if (Number.isFinite(duration) && duration > 0) return MediaStreamTypes.ON_DEMAND; + return MediaStreamTypes.UNKNOWN; + } + + #setDetected(value: MediaStreamType): void { + if (this.#isUserStreamType) return; + this.#update(value); + } + + #update(value: MediaStreamType): void { + if (this.#streamType === value) return; + this.#streamType = value; + this.dispatchEvent(new Event('streamtypechange')); + } + } + + return NativeHlsMediaStreamType as unknown as Base & Constructor<{ streamType: MediaStreamType }>; +} diff --git a/packages/core/src/dom/media/predicate.ts b/packages/core/src/dom/media/predicate.ts index 5faba7a3..400b8055 100644 --- a/packages/core/src/dom/media/predicate.ts +++ b/packages/core/src/dom/media/predicate.ts @@ -8,6 +8,7 @@ import type { MediaRemotePlaybackCapability, MediaSeekCapability, MediaSourceCapability, + MediaStreamTypeCapability, MediaTextTrackCapability, MediaVolumeCapability, } from '../../core/media/types'; @@ -60,6 +61,10 @@ export function isMediaRemotePlaybackCapable(value: unknown): value is MediaRemo return isObject(value) && 'remote' in value && isObject((value as Record).remote); } +export function isMediaStreamTypeCapable(value: unknown): value is MediaStreamTypeCapability { + return isObject(value) && 'streamType' 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 f052b7a8..a8caebb7 100644 --- a/packages/core/src/dom/media/types.ts +++ b/packages/core/src/dom/media/types.ts @@ -9,6 +9,7 @@ import type { MediaPlaybackState, MediaRemotePlaybackState, MediaSourceState, + MediaStreamTypeState, MediaTextTrackState, MediaTimeState, MediaVolumeState, @@ -44,6 +45,7 @@ export type VideoFeatures = [ PlayerFeature, PlayerFeature, PlayerFeature, + PlayerFeature, PlayerFeature, PlayerFeature, PlayerFeature, @@ -59,6 +61,7 @@ export type AudioFeatures = [ PlayerFeature, PlayerFeature, PlayerFeature, + PlayerFeature, PlayerFeature, PlayerFeature, ]; @@ -66,8 +69,25 @@ export type AudioFeatures = [ // TODO: Define background video features (e.g., playback, source, buffer) export type BackgroundFeatures = []; +/** + * Features for a live video player. Structurally identical to + * {@link VideoFeatures} — the "live" presets share the same store but ship a + * skin that omits duration-oriented UI. + */ +export type LiveVideoFeatures = VideoFeatures; + +/** + * Features for a live audio player. Structurally identical to + * {@link AudioFeatures}. + */ +export type LiveAudioFeatures = AudioFeatures; + export type VideoPlayerStore = PlayerStore; export type AudioPlayerStore = PlayerStore; export type BackgroundPlayerStore = PlayerStore; + +export type LiveVideoPlayerStore = PlayerStore; + +export type LiveAudioPlayerStore = PlayerStore; diff --git a/packages/core/src/dom/store/features/feature.parts.ts b/packages/core/src/dom/store/features/feature.parts.ts index 82c05e5b..be482b5b 100644 --- a/packages/core/src/dom/store/features/feature.parts.ts +++ b/packages/core/src/dom/store/features/feature.parts.ts @@ -6,6 +6,7 @@ import { playbackFeature } from './playback'; import { playbackRateFeature } from './playback-rate'; import { remotePlaybackFeature } from './remote-playback'; import { sourceFeature } from './source'; +import { streamTypeFeature } from './stream-type'; import { textTrackFeature } from './text-track'; import { timeFeature } from './time'; import { volumeFeature } from './volume'; @@ -22,6 +23,7 @@ export { playbackRateFeature as playbackRate, remotePlaybackFeature as remotePlayback, sourceFeature as source, + streamTypeFeature as streamType, textTrackFeature as textTrack, timeFeature as time, volumeFeature as volume, diff --git a/packages/core/src/dom/store/features/index.ts b/packages/core/src/dom/store/features/index.ts index 91b189a4..42439b5d 100644 --- a/packages/core/src/dom/store/features/index.ts +++ b/packages/core/src/dom/store/features/index.ts @@ -9,6 +9,7 @@ export * from './playback-rate'; export * from './presets'; export * from './remote-playback'; export * from './source'; +export * from './stream-type'; export * from './text-track'; export * from './time'; export * from './volume'; diff --git a/packages/core/src/dom/store/features/presets.ts b/packages/core/src/dom/store/features/presets.ts index cb8a9c4c..fb4b4161 100644 --- a/packages/core/src/dom/store/features/presets.ts +++ b/packages/core/src/dom/store/features/presets.ts @@ -8,6 +8,7 @@ import { playbackFeature } from './playback'; import { playbackRateFeature } from './playback-rate'; import { remotePlaybackFeature } from './remote-playback'; import { sourceFeature } from './source'; +import { streamTypeFeature } from './stream-type'; import { textTrackFeature } from './text-track'; import { timeFeature } from './time'; import { volumeFeature } from './volume'; @@ -18,6 +19,7 @@ export const videoFeatures: VideoFeatures = [ volumeFeature, timeFeature, sourceFeature, + streamTypeFeature, bufferFeature, fullscreenFeature, pipFeature, @@ -33,6 +35,7 @@ export const audioFeatures: AudioFeatures = [ volumeFeature, timeFeature, sourceFeature, + streamTypeFeature, bufferFeature, errorFeature, ]; diff --git a/packages/core/src/dom/store/features/stream-type.ts b/packages/core/src/dom/store/features/stream-type.ts new file mode 100644 index 00000000..d6b3334b --- /dev/null +++ b/packages/core/src/dom/store/features/stream-type.ts @@ -0,0 +1,50 @@ +import { listen } from '@videojs/utils/dom'; + +import type { MediaStreamTypeState } from '../../../core/media/state'; +import { type MediaStreamType, MediaStreamTypes } from '../../../core/media/types'; +import { definePlayerFeature } from '../../feature'; +import { isMediaBufferCapable, isMediaSeekCapable, isMediaStreamTypeCapable } from '../../media/predicate'; + +export const streamTypeFeature = definePlayerFeature({ + name: 'streamType', + state: (): MediaStreamTypeState => ({ + streamType: MediaStreamTypes.UNKNOWN, + }), + + // Prefer the media's own `streamType` (e.g. `HlsMedia`, which derives it from + // manifest metadata and dispatches `streamtypechange`). For plain elements + // without that capability, fall back to duration-based detection so the + // store still reports `live`/`on-demand` for native MP4 / native HLS. + attach({ target, signal, set }) { + const { media } = target; + + if (isMediaStreamTypeCapable(media)) { + const sync = () => set({ streamType: media.streamType }); + sync(); + listen(media, 'streamtypechange', sync, { signal }); + return; + } + + if (!isMediaSeekCapable(media)) return; + + const detect = (): MediaStreamType => { + const { duration } = media; + if (duration === Number.POSITIVE_INFINITY) return MediaStreamTypes.LIVE; + if (Number.isFinite(duration) && duration > 0) return MediaStreamTypes.ON_DEMAND; + return MediaStreamTypes.UNKNOWN; + }; + + const sync = () => set({ streamType: detect() }); + + sync(); + + listen(media, 'durationchange', sync, { signal }); + listen(media, 'loadedmetadata', sync, { signal }); + listen(media, 'emptied', sync, { signal }); + // `progress` widens the seekable window for DVR streams — treat it as a + // hint that the duration may now be usable. + if (isMediaBufferCapable(media)) { + listen(media, 'progress', sync, { signal }); + } + }, +}); diff --git a/packages/core/src/dom/store/features/tests/stream-type.test.ts b/packages/core/src/dom/store/features/tests/stream-type.test.ts new file mode 100644 index 00000000..45a2e1d2 --- /dev/null +++ b/packages/core/src/dom/store/features/tests/stream-type.test.ts @@ -0,0 +1,112 @@ +import { createStore } from '@videojs/store'; +import { describe, expect, it } from 'vitest'; +import { type MediaStreamType, MediaStreamTypes } from '../../../../core/media/types'; +import type { PlayerTarget } from '../../../media/types'; +import { createMockVideo } from '../../../tests/test-helpers'; +import { streamTypeFeature } from '../stream-type'; + +describe('streamTypeFeature', () => { + describe('fallback (no `streamType` property on media)', () => { + it('defaults to `unknown` when duration is not known', () => { + const video = createMockVideo({ duration: Number.NaN }); + + const store = createStore()(streamTypeFeature); + store.attach({ media: video, container: null }); + + expect(store.state.streamType).toBe(MediaStreamTypes.UNKNOWN); + }); + + it('detects `live` from infinite duration', () => { + const video = createMockVideo({ duration: Number.POSITIVE_INFINITY }); + + const store = createStore()(streamTypeFeature); + store.attach({ media: video, container: null }); + + expect(store.state.streamType).toBe(MediaStreamTypes.LIVE); + }); + + it('detects `on-demand` from a finite duration', () => { + const video = createMockVideo({ duration: 120 }); + + const store = createStore()(streamTypeFeature); + store.attach({ media: video, container: null }); + + expect(store.state.streamType).toBe(MediaStreamTypes.ON_DEMAND); + }); + + it('updates on `durationchange`', () => { + const video = createMockVideo({ duration: Number.NaN }); + + const store = createStore()(streamTypeFeature); + store.attach({ media: video, container: null }); + + expect(store.state.streamType).toBe(MediaStreamTypes.UNKNOWN); + + Object.defineProperty(video, 'duration', { value: 120, configurable: true }); + video.dispatchEvent(new Event('durationchange')); + + expect(store.state.streamType).toBe(MediaStreamTypes.ON_DEMAND); + }); + + it('resets to `unknown` on `emptied`', () => { + const video = createMockVideo({ duration: 120 }); + + const store = createStore()(streamTypeFeature); + store.attach({ media: video, container: null }); + + expect(store.state.streamType).toBe(MediaStreamTypes.ON_DEMAND); + + Object.defineProperty(video, 'duration', { value: Number.NaN, configurable: true }); + video.dispatchEvent(new Event('emptied')); + + expect(store.state.streamType).toBe(MediaStreamTypes.UNKNOWN); + }); + }); + + describe('native (media exposes `streamType`)', () => { + it('reads `streamType` directly when available', () => { + const media = new EventTarget() as EventTarget & { streamType: MediaStreamType }; + media.streamType = MediaStreamTypes.LIVE; + + const store = createStore()(streamTypeFeature); + // The store target accepts any `Media`-shaped object; cast for the test. + store.attach({ media: media as unknown as PlayerTarget['media'], container: null }); + + expect(store.state.streamType).toBe(MediaStreamTypes.LIVE); + }); + + it('syncs on `streamtypechange`', () => { + const media = new EventTarget() as EventTarget & { streamType: MediaStreamType }; + media.streamType = MediaStreamTypes.UNKNOWN; + + const store = createStore()(streamTypeFeature); + store.attach({ media: media as unknown as PlayerTarget['media'], container: null }); + + expect(store.state.streamType).toBe(MediaStreamTypes.UNKNOWN); + + media.streamType = MediaStreamTypes.LIVE; + media.dispatchEvent(new Event('streamtypechange')); + + expect(store.state.streamType).toBe(MediaStreamTypes.LIVE); + + media.streamType = MediaStreamTypes.ON_DEMAND; + media.dispatchEvent(new Event('streamtypechange')); + + expect(store.state.streamType).toBe(MediaStreamTypes.ON_DEMAND); + }); + + it('prefers native `streamType` over duration-based fallback', () => { + // Build an object that has both a finite duration and a user-asserted + // `streamType` — the feature should trust the explicit stream type. + const media = Object.assign(new EventTarget(), { + duration: 120, + streamType: MediaStreamTypes.LIVE, + }); + + const store = createStore()(streamTypeFeature); + store.attach({ media: media as unknown as PlayerTarget['media'], container: null }); + + expect(store.state.streamType).toBe(MediaStreamTypes.LIVE); + }); + }); +}); diff --git a/packages/core/src/dom/store/features/tests/time.test.ts b/packages/core/src/dom/store/features/tests/time.test.ts index 2b26d347..b1c1960a 100644 --- a/packages/core/src/dom/store/features/tests/time.test.ts +++ b/packages/core/src/dom/store/features/tests/time.test.ts @@ -1,7 +1,7 @@ import { createStore } from '@videojs/store'; import { describe, expect, it } from 'vitest'; import type { PlayerTarget } from '../../../media/types'; -import { createMockVideo } from '../../../tests/test-helpers'; +import { createMockVideo, createTimeRanges } from '../../../tests/test-helpers'; import { timeFeature } from '../time'; describe('timeFeature', () => { @@ -74,6 +74,66 @@ describe('timeFeature', () => { expect(store.state.currentTime).toBe(50); }); + it('uses seekable end as duration for live streams (Infinity)', () => { + const video = createMockVideo({ + currentTime: 0, + duration: Number.POSITIVE_INFINITY, + seekable: createTimeRanges([[0, 300]]), + }); + + const store = createStore()(timeFeature); + store.attach({ media: video, container: null }); + + expect(store.state.duration).toBe(300); + }); + + it('returns 0 duration for live streams with no seekable range', () => { + const video = createMockVideo({ + duration: Number.POSITIVE_INFINITY, + seekable: createTimeRanges([]), + }); + + const store = createStore()(timeFeature); + store.attach({ media: video, container: null }); + + expect(store.state.duration).toBe(0); + }); + + it('updates live duration as seekable range grows on progress', () => { + const video = createMockVideo({ + duration: Number.POSITIVE_INFINITY, + seekable: createTimeRanges([[0, 300]]), + }); + + const store = createStore()(timeFeature); + store.attach({ media: video, container: null }); + + expect(store.state.duration).toBe(300); + + Object.defineProperty(video, 'seekable', { + value: createTimeRanges([[10, 320]]), + configurable: true, + }); + video.dispatchEvent(new Event('progress')); + + expect(store.state.duration).toBe(320); + }); + + it('uses end of last seekable range when multiple ranges exist', () => { + const video = createMockVideo({ + duration: Number.POSITIVE_INFINITY, + seekable: createTimeRanges([ + [0, 100], + [150, 300], + ]), + }); + + const store = createStore()(timeFeature); + store.attach({ media: video, container: null }); + + expect(store.state.duration).toBe(300); + }); + it('updates on emptied event', () => { const video = createMockVideo({ currentTime: 30, @@ -223,6 +283,29 @@ describe('timeFeature', () => { expect(store.state.seeking).toBe(true); }); + it('progress during seek does not overwrite optimistic currentTime', () => { + const video = createMockVideo({ + currentTime: 10, + duration: 120, + readyState: HTMLMediaElement.HAVE_METADATA, + }); + + const store = createStore()(timeFeature); + store.attach({ media: video, container: null }); + + store.seek(60); + expect(store.state.currentTime).toBe(60); + expect(store.state.seeking).toBe(true); + + // Browser fires progress (buffering at the seek target) with a stale + // currentTime still reflecting the pre-seek position. + video.currentTime = 12; + video.dispatchEvent(new Event('progress')); + + expect(store.state.currentTime).toBe(60); + expect(store.state.seeking).toBe(true); + }); + it('timeupdate resumes syncing after seeked', async () => { const video = createMockVideo({ currentTime: 10, diff --git a/packages/core/src/dom/store/features/time.ts b/packages/core/src/dom/store/features/time.ts index 25dc41f1..95adb740 100644 --- a/packages/core/src/dom/store/features/time.ts +++ b/packages/core/src/dom/store/features/time.ts @@ -2,7 +2,7 @@ import { listen, onEvent } from '@videojs/utils/dom'; import { noop } from '@videojs/utils/function'; import type { MediaTimeState } from '../../../core/media/state'; import { definePlayerFeature } from '../../feature'; -import { hasMetadata, isMediaSeekCapable, isMediaSourceCapable } from '../../media/predicate'; +import { hasMetadata, isMediaBufferCapable, isMediaSeekCapable, isMediaSourceCapable } from '../../media/predicate'; import { signalKeys } from '../signal-keys'; export const timeFeature = definePlayerFeature({ @@ -38,30 +38,46 @@ export const timeFeature = definePlayerFeature({ if (!isMediaSeekCapable(media)) return; + // For live streams `media.duration` is `Infinity` — fall back to the end + // of the last seekable range, which represents the live edge and tracks + // the sliding DVR window as new segments become available. + const resolveDuration = () => { + const { duration } = media; + if (duration === Number.POSITIVE_INFINITY && isMediaBufferCapable(media)) { + const { seekable } = media; + return seekable.length > 0 ? seekable.end(seekable.length - 1) : 0; + } + return Number.isFinite(duration) ? duration : 0; + }; + const sync = () => set({ currentTime: media.currentTime, - duration: Number.isFinite(media.duration) ? media.duration : 0, + duration: resolveDuration(), seeking: media.seeking, }); // While a seek is in-flight the store holds an optimistic `currentTime` - // that reflects the user's target position. Browser `timeupdate` events - // during seeking carry an unreliable intermediate value that would snap - // the time-slider back to the old position, so skip `currentTime` sync - // from `timeupdate` while the store indicates an active seek. - const onTimeUpdate = () => { + // that reflects the user's target position. Browser `timeupdate` and + // `progress` events during seeking carry an unreliable intermediate + // `currentTime` that would snap the time-slider back to the old + // position, so skip sync from those events while the store indicates an + // active seek. + const syncUnlessSeeking = () => { if (get().seeking) return; sync(); }; sync(); - listen(media, 'timeupdate', onTimeUpdate, { signal }); + listen(media, 'timeupdate', syncUnlessSeeking, { signal }); listen(media, 'durationchange', sync, { signal }); listen(media, 'seeking', sync, { signal }); listen(media, 'seeked', sync, { signal }); listen(media, 'loadedmetadata', sync, { signal }); listen(media, 'emptied', sync, { signal }); + // `progress` fires as the seekable range grows, so the live-edge duration + // tracks the DVR window without requiring a separate durationchange event. + listen(media, 'progress', syncUnlessSeeking, { signal }); }, }); diff --git a/packages/core/src/dom/store/selectors.ts b/packages/core/src/dom/store/selectors.ts index 384a1d8c..7e5c03ce 100644 --- a/packages/core/src/dom/store/selectors.ts +++ b/packages/core/src/dom/store/selectors.ts @@ -9,6 +9,7 @@ import { playbackFeature } from './features/playback'; import { playbackRateFeature } from './features/playback-rate'; import { remotePlaybackFeature } from './features/remote-playback'; import { sourceFeature } from './features/source'; +import { streamTypeFeature } from './features/stream-type'; import { textTrackFeature } from './features/text-track'; import { timeFeature } from './features/time'; import { volumeFeature } from './features/volume'; @@ -31,6 +32,8 @@ export const selectPlaybackRate = createSelector(playbackRateFeature); export const selectRemotePlayback = createSelector(remotePlaybackFeature); /** Select the source state (src, type). */ export const selectSource = createSelector(sourceFeature); +/** Select the stream type state (`'on-demand' | 'live' | 'unknown'`). */ +export const selectStreamType = createSelector(streamTypeFeature); /** Select the text track state (chapters cues, thumbnail cues). */ export const selectTextTrack = createSelector(textTrackFeature); /** Select the time state (currentTime, duration, seek). */ diff --git a/site/src/content/docs/reference/feature-stream-type.mdx b/site/src/content/docs/reference/feature-stream-type.mdx new file mode 100644 index 00000000..6881b71b --- /dev/null +++ b/site/src/content/docs/reference/feature-stream-type.mdx @@ -0,0 +1,50 @@ +--- +title: Stream type +description: Stream delivery type (live / on-demand) 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"; + +Tracks the current stream delivery type so components can toggle live-specific UI (a live indicator, "jump to live edge" affordance, hiding the time display, etc.). + +When the media exposes its own `streamType` (e.g. HLS sources derive it from the manifest), the feature syncs on `streamtypechange`. For plain media elements without that capability, it falls back to duration-based detection: an infinite `duration` reports `'live'`, a finite positive `duration` reports `'on-demand'`, and anything else reports `'unknown'`. + + + +### Selector + + +Pass `selectStreamType` to `usePlayer` to subscribe to stream type state. Returns `undefined` if the stream type feature is not configured. + + + +Pass `selectStreamType` to `PlayerController` to subscribe to stream type state. Returns `undefined` if the stream type feature is not configured. + + + +```tsx title="LiveIndicator.tsx" +import { selectStreamType, usePlayer } from '@videojs/react'; + +function LiveIndicator() { + const stream = usePlayer(selectStreamType); + if (stream?.streamType !== 'live') return null; + + return LIVE; +} +``` + + + +```ts title="live-indicator.ts" +import { createPlayer, MediaElement, selectStreamType } from '@videojs/html'; +import { videoFeatures } from '@videojs/html/video'; + +const { PlayerController, context } = createPlayer({ features: videoFeatures }); + +class LiveIndicator extends MediaElement { + readonly #stream = new PlayerController(this, context, selectStreamType); +} +``` + diff --git a/site/src/docs.config.ts b/site/src/docs.config.ts index 038d804c..9009c249 100644 --- a/site/src/docs.config.ts +++ b/site/src/docs.config.ts @@ -132,6 +132,7 @@ export const sidebar: Sidebar = [ { slug: 'reference/feature-playback-rate' }, { slug: 'reference/feature-remote-playback' }, { slug: 'reference/feature-source' }, + { slug: 'reference/feature-stream-type' }, { slug: 'reference/feature-text-tracks' }, { slug: 'reference/feature-time' }, { slug: 'reference/feature-volume' },