From 01cdf65c7de6ff4ac31cbf987763386ba803b857 Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Tue, 23 Jun 2026 17:40:50 -0700 Subject: [PATCH 01/17] feat(core): add cue points component Stores cue points as VTTCues on a hidden metadata text track and dispatches a cuepointchange event as the playhead enters each. Adapted from muxinc/elements playback-core. Refs #1442 --- .../core/src/dom/media/cue-points/index.ts | 199 +++++++++ .../dom/media/cue-points/tests/index.test.ts | 383 ++++++++++++++++++ packages/core/tsdown.config.ts | 1 + 3 files changed, 583 insertions(+) create mode 100644 packages/core/src/dom/media/cue-points/index.ts create mode 100644 packages/core/src/dom/media/cue-points/tests/index.test.ts diff --git a/packages/core/src/dom/media/cue-points/index.ts b/packages/core/src/dom/media/cue-points/index.ts new file mode 100644 index 000000000..a6a64ea85 --- /dev/null +++ b/packages/core/src/dom/media/cue-points/index.ts @@ -0,0 +1,199 @@ +// Adapted from muxinc/elements playback-core `text-tracks.ts` (MIT). + +import { listen } from '@videojs/utils/dom'; +import { isUndefined } from '@videojs/utils/predicate'; +import type { Component, HTMLMediaTargetLike } from '../media-host'; + +/** A timed marker carrying a JSON-serializable payload. `endTime` defaults to the next cue point's start (or media duration). */ +export interface CuePoint { + time: number; + value: Value; + endTime?: number; +} + +export interface CuePointsProps { + label: string; + cuePoints: CuePoint[]; +} + +export const DEFAULT_CUEPOINTS_TRACK_LABEL = 'cuepoints'; + +declare module '../media-host' { + interface MediaComponentConfig { + cuePoints: Partial; + } +} + +/** + * Stores cue points as `VTTCue`s on a hidden `metadata` text track and dispatches a + * `cuepointchange` CustomEvent (active {@link CuePoint} as `detail`) as the playhead enters each. + */ +export class CuePoints implements CuePointsProps, Component { + static readonly configKey = 'cuePoints'; + + #label = DEFAULT_CUEPOINTS_TRACK_LABEL; + #cuePoints: CuePoint[] = []; + #target: HTMLVideoElement | null = null; + #disconnect: AbortController | null = null; + #trackDisconnect: AbortController | null = null; + + constructor(props: Partial> = {}) { + Object.assign(this, props); + } + + get label() { + return this.#label; + } + + set label(value) { + if (this.#label === value) return; + this.#label = value; + void this.#setup(); + } + + get cuePoints() { + const target = this.#target; + if (!target) return [...this.#cuePoints]; + const track = getCuePointsTrack(target, this.#label); + return track?.cues ? Array.from(track.cues, (cue) => toCuePoint(cue as VTTCue)) : []; + } + + set cuePoints(value) { + this.#cuePoints = value ?? []; + void this.#setup(); + } + + get activeCuePoint() { + const target = this.#target; + if (!target) return undefined; + const activeCues = getCuePointsTrack(target, this.#label)?.activeCues; + if (!activeCues?.length) return undefined; + if (activeCues.length === 1) return toCuePoint(activeCues[0] as VTTCue); + // Chromium can leave "lingering" activeCues outside [startTime, endTime); prefer the real one. + const { currentTime } = target; + const active = Array.prototype.find.call( + activeCues, + ({ startTime, endTime }: VTTCue) => startTime <= currentTime && endTime > currentTime + ) as VTTCue | undefined; + return toCuePoint((active ?? activeCues[0]) as VTTCue); + } + + attach(target: HTMLMediaTargetLike) { + this.#target = target as unknown as HTMLVideoElement; + this.#disconnect = new AbortController(); + // The engine clears cues on (re)load, so re-populate on each `loadstart`. + listen(this.#target, 'loadstart', () => this.#setup(), { signal: this.#disconnect.signal }); + void this.#setup(); + } + + detach() { + this.#trackDisconnect?.abort(); + this.#disconnect?.abort(); + this.#trackDisconnect = null; + this.#disconnect = null; + this.#target = null; + } + + destroy() { + this.detach(); + } + + /** Append cue points, keeping any already present. */ + async addCuePoints(cuePoints: CuePoint[]) { + this.#cuePoints = [...this.#cuePoints, ...cuePoints]; + const target = this.#target; + if (!target) return; + const track = await this.#ensureTrack(target); + if (!track) return; + writeCuePoints(target, track, cuePoints); + notifyChange(target); + } + + async #setup() { + const target = this.#target; + if (!target || !this.#disconnect) return; + // Reset per-track listeners so a re-setup doesn't leave duplicate `cuechange` handlers. + this.#trackDisconnect?.abort(); + this.#trackDisconnect = new AbortController(); + const { signal } = this.#trackDisconnect; + const track = await this.#ensureTrack(target); + if (signal.aborted || !track) return; + clearCues(track); + writeCuePoints(target, track, this.#cuePoints); + notifyChange(target); + listen( + track, + 'cuechange', + () => { + const detail = this.activeCuePoint; + if (detail) target.dispatchEvent(new CustomEvent('cuepointchange', { composed: true, bubbles: true, detail })); + }, + { signal } + ); + } + + async #ensureTrack(target: HTMLVideoElement) { + let track: TextTrack | null | undefined = getCuePointsTrack(target, this.#label); + if (!track) { + track = createCuePointsTrack(target, this.#label); + if (!track) return null; + // Let a freshly created track settle before adding cues, or they can vanish. + await new Promise((resolve) => setTimeout(resolve, 0)); + } + if (track.mode !== 'hidden') track.mode = 'hidden'; + return track; + } +} + +function getCuePointsTrack(media: HTMLMediaElement, label: string) { + return ( + Array.from(media.querySelectorAll('track')).find((el) => el.track?.label === label && el.track?.kind === 'metadata') + ?.track ?? undefined + ); +} + +function createCuePointsTrack(media: HTMLMediaElement, label: string): TextTrack | null { + const trackEl = document.createElement('track'); + trackEl.kind = 'metadata'; + trackEl.label = label; + trackEl.setAttribute('data-removeondestroy', ''); + media.append(trackEl); + // `track` may be absent in environments with incomplete DOM support (e.g. jsdom). + const { track } = trackEl; + if (track) track.mode = 'hidden'; + return track ?? null; +} + +function clearCues(track: TextTrack) { + if (!track.cues) return; + for (const cue of Array.from(track.cues)) track.removeCue(cue); +} + +function writeCuePoints(media: HTMLMediaElement, track: TextTrack, cuePoints: CuePoint[]) { + // Insert latest-first so each cue can derive its endTime from the following cue. + [...cuePoints] + .sort((a, b) => b.time - a.time) + .forEach((cuePoint) => { + const text = JSON.stringify(cuePoint.value ?? null); + if (!isUndefined(cuePoint.endTime)) { + track.addCue(new VTTCue(cuePoint.time, cuePoint.endTime, text)); + return; + } + const cues = track.cues; + const afterIndex = cues ? Array.prototype.findIndex.call(cues, (c: VTTCue) => c.startTime >= cuePoint.time) : -1; + const after = afterIndex >= 0 ? (cues?.[afterIndex] as VTTCue | undefined) : undefined; + const previous = afterIndex > 0 ? (cues?.[afterIndex - 1] as VTTCue | undefined) : undefined; + if (previous) previous.endTime = cuePoint.time; + const endTime = after?.startTime ?? (Number.isFinite(media.duration) ? media.duration : Number.MAX_SAFE_INTEGER); + track.addCue(new VTTCue(cuePoint.time, endTime, text)); + }); +} + +function notifyChange(media: HTMLMediaElement) { + // `change` doesn't fire when the cue list changes without the active cue changing. + media.textTracks.dispatchEvent(new Event('change', { bubbles: true, composed: true })); +} + +function toCuePoint(cue: VTTCue): CuePoint { + return { time: cue.startTime, value: JSON.parse(cue.text) as Value }; +} diff --git a/packages/core/src/dom/media/cue-points/tests/index.test.ts b/packages/core/src/dom/media/cue-points/tests/index.test.ts new file mode 100644 index 000000000..8eea19304 --- /dev/null +++ b/packages/core/src/dom/media/cue-points/tests/index.test.ts @@ -0,0 +1,383 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { HTMLMediaTargetLike } from '../../media-host'; +import { CuePoints } from '..'; + +// jsdom does not implement VTTCue or persist text-track cues, so this suite +// runs against small deterministic fakes for the surface the component touches. + +class FakeVTTCue { + id = ''; + constructor( + public startTime: number, + public endTime: number, + public text: string + ) {} +} + +class FakeTextTrack extends EventTarget { + kind = 'metadata'; + label = ''; + mode: 'hidden' | 'showing' | 'disabled' = 'disabled'; + cues: FakeVTTCue[] = []; + activeCues: FakeVTTCue[] = []; + + addCue(cue: FakeVTTCue): void { + this.cues.push(cue); + this.cues.sort((a, b) => a.startTime - b.startTime); + } + + removeCue(cue: FakeVTTCue): void { + this.cues = this.cues.filter((c) => c !== cue); + } +} + +class FakeTrackElement { + track = new FakeTextTrack(); + #attrs = new Map(); + + set kind(value: string) { + this.track.kind = value; + } + set label(value: string) { + this.track.label = value; + } + + setAttribute(name: string, value: string): void { + this.#attrs.set(name, value); + } + getAttribute(name: string): string | null { + return this.#attrs.get(name) ?? null; + } +} + +class FakeMedia extends EventTarget { + currentTime = 0; + duration = Number.NaN; + textTracks = new EventTarget(); + trackEls: FakeTrackElement[] = []; + + append(el: FakeTrackElement): void { + this.trackEls.push(el); + } + + querySelectorAll(selector: string): FakeTrackElement[] { + return selector === 'track' ? this.trackEls : []; + } + + get cuePointsTrack(): FakeTextTrack | undefined { + return this.trackEls[0]?.track; + } +} + +function createMedia(duration = Number.NaN): FakeMedia { + const media = new FakeMedia(); + media.duration = duration; + return media; +} + +function asTarget(media: FakeMedia): HTMLMediaTargetLike { + return media as unknown as HTMLMediaTargetLike; +} + +const flush = () => new Promise((resolve) => setTimeout(resolve, 10)); + +beforeEach(() => { + vi.stubGlobal('VTTCue', FakeVTTCue); + const realCreateElement = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation((tag: string, ...rest: unknown[]) => { + if (tag === 'track') return new FakeTrackElement() as unknown as HTMLElement; + return realCreateElement(tag, ...(rest as [ElementCreationOptions?])); + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('CuePoints', () => { + it('exposes the config key', () => { + expect(CuePoints.configKey).toBe('cuePoints'); + }); + + it('stages cue points before attach and applies them on attach', async () => { + const media = createMedia(100); + const cuePoints = new CuePoints({ cuePoints: [{ time: 10, value: { a: 1 } }] }); + + expect(cuePoints.cuePoints).toEqual([{ time: 10, value: { a: 1 } }]); + + cuePoints.attach(asTarget(media)); + await flush(); + + const track = media.cuePointsTrack!; + expect(track.kind).toBe('metadata'); + expect(track.label).toBe('cuepoints'); + expect(track.mode).toBe('hidden'); + expect(cuePoints.cuePoints).toEqual([{ time: 10, value: { a: 1 } }]); + }); + + it('uses a custom track label', async () => { + const media = createMedia(100); + const cuePoints = new CuePoints({ label: 'ads', cuePoints: [{ time: 5, value: 'x' }] }); + + cuePoints.attach(asTarget(media)); + await flush(); + + expect(media.cuePointsTrack!.label).toBe('ads'); + }); + + describe('writing cue points', () => { + it('derives end times from following cue points and the media duration', async () => { + const media = createMedia(100); + const cuePoints = new CuePoints({ + cuePoints: [ + { time: 10, value: 'a' }, + { time: 20, value: 'b' }, + { time: 30, value: 'c' }, + ], + }); + + cuePoints.attach(asTarget(media)); + await flush(); + + expect(media.cuePointsTrack!.cues.map((c) => [c.startTime, c.endTime])).toEqual([ + [10, 20], + [20, 30], + [30, 100], + ]); + }); + + it('honors an explicit endTime', async () => { + const media = createMedia(100); + const cuePoints = new CuePoints({ cuePoints: [{ time: 10, endTime: 12, value: 'a' }] }); + + cuePoints.attach(asTarget(media)); + await flush(); + + const cue = media.cuePointsTrack!.cues[0]!; + expect([cue.startTime, cue.endTime]).toEqual([10, 12]); + }); + + it('uses MAX_SAFE_INTEGER when duration is not finite', async () => { + const media = createMedia(Number.NaN); + const cuePoints = new CuePoints({ cuePoints: [{ time: 5, value: 'a' }] }); + + cuePoints.attach(asTarget(media)); + await flush(); + + expect(media.cuePointsTrack!.cues[0]!.endTime).toBe(Number.MAX_SAFE_INTEGER); + }); + + it('dispatches a change event on the text track list', async () => { + const media = createMedia(100); + const onChange = vi.fn(); + media.textTracks.addEventListener('change', onChange); + + const cuePoints = new CuePoints({ cuePoints: [{ time: 10, value: 'a' }] }); + cuePoints.attach(asTarget(media)); + await flush(); + + expect(onChange).toHaveBeenCalled(); + }); + }); + + describe('addCuePoints', () => { + it('appends to the existing set and adjusts the previous cue', async () => { + const media = createMedia(100); + const cuePoints = new CuePoints({ + cuePoints: [ + { time: 10, value: 'a' }, + { time: 30, value: 'c' }, + ], + }); + + cuePoints.attach(asTarget(media)); + await flush(); + + await cuePoints.addCuePoints([{ time: 20, value: 'b' }]); + + expect(media.trackEls).toHaveLength(1); + expect(media.cuePointsTrack!.cues.map((c) => [c.startTime, c.endTime])).toEqual([ + [10, 20], + [20, 30], + [30, 100], + ]); + }); + + it('does not mutate the caller array', async () => { + const media = createMedia(100); + const cuePoints = new CuePoints(); + cuePoints.attach(asTarget(media)); + await flush(); + + const input = [ + { time: 30, value: 'c' }, + { time: 10, value: 'a' }, + ]; + await cuePoints.addCuePoints(input); + + expect(input.map((c) => c.time)).toEqual([30, 10]); + }); + }); + + describe('cuePoints setter', () => { + it('replaces previously set cue points', async () => { + const media = createMedia(100); + const cuePoints = new CuePoints({ cuePoints: [{ time: 10, value: 'a' }] }); + cuePoints.attach(asTarget(media)); + await flush(); + + cuePoints.cuePoints = [{ time: 40, value: 'd' }]; + await flush(); + + expect(cuePoints.cuePoints).toEqual([{ time: 40, value: 'd' }]); + }); + + it('clears cue points when set to an empty array', async () => { + const media = createMedia(100); + const cuePoints = new CuePoints({ cuePoints: [{ time: 10, value: 'a' }] }); + cuePoints.attach(asTarget(media)); + await flush(); + + cuePoints.cuePoints = []; + await flush(); + + expect(cuePoints.cuePoints).toEqual([]); + expect(media.cuePointsTrack!.cues).toHaveLength(0); + }); + }); + + describe('activeCuePoint', () => { + it('returns undefined when there are no active cues', async () => { + const media = createMedia(100); + const cuePoints = new CuePoints({ cuePoints: [{ time: 10, value: 'a' }] }); + cuePoints.attach(asTarget(media)); + await flush(); + + expect(cuePoints.activeCuePoint).toBeUndefined(); + }); + + it('returns the single active cue point', async () => { + const media = createMedia(100); + const cuePoints = new CuePoints({ cuePoints: [{ time: 10, value: 'a' }] }); + cuePoints.attach(asTarget(media)); + await flush(); + + media.cuePointsTrack!.activeCues = [new FakeVTTCue(10, 20, JSON.stringify('a'))]; + + expect(cuePoints.activeCuePoint).toEqual({ time: 10, value: 'a' }); + }); + + it('resolves the cue covering the current time among multiple active cues', async () => { + const media = createMedia(100); + media.currentTime = 12; + const cuePoints = new CuePoints({ cuePoints: [{ time: 10, value: 'a' }] }); + cuePoints.attach(asTarget(media)); + await flush(); + + media.cuePointsTrack!.activeCues = [ + new FakeVTTCue(0, 5, JSON.stringify('stale')), + new FakeVTTCue(10, 15, JSON.stringify('current')), + ]; + + expect(cuePoints.activeCuePoint).toEqual({ time: 10, value: 'current' }); + }); + + it('falls back to the first active cue when none cover the current time', async () => { + const media = createMedia(100); + media.currentTime = 7; + const cuePoints = new CuePoints({ cuePoints: [{ time: 10, value: 'a' }] }); + cuePoints.attach(asTarget(media)); + await flush(); + + media.cuePointsTrack!.activeCues = [ + new FakeVTTCue(0, 5, JSON.stringify('first')), + new FakeVTTCue(20, 25, JSON.stringify('second')), + ]; + + expect(cuePoints.activeCuePoint).toEqual({ time: 0, value: 'first' }); + }); + }); + + describe('cuepointchange events', () => { + it('re-emits cuechange as cuepointchange on the media element', async () => { + const media = createMedia(100); + const onCuePointChange = vi.fn(); + media.addEventListener('cuepointchange', onCuePointChange as EventListener); + + const cuePoints = new CuePoints({ cuePoints: [{ time: 5, value: { id: 'x' } }] }); + cuePoints.attach(asTarget(media)); + await flush(); + + const track = media.cuePointsTrack!; + track.activeCues = [new FakeVTTCue(5, 10, JSON.stringify({ id: 'x' }))]; + track.dispatchEvent(new Event('cuechange')); + + expect(onCuePointChange).toHaveBeenCalledTimes(1); + expect((onCuePointChange.mock.calls[0]![0] as CustomEvent).detail).toEqual({ time: 5, value: { id: 'x' } }); + }); + + it('does not emit duplicates after a loadstart re-setup', async () => { + const media = createMedia(100); + const onCuePointChange = vi.fn(); + media.addEventListener('cuepointchange', onCuePointChange as EventListener); + + const cuePoints = new CuePoints({ cuePoints: [{ time: 5, value: 'a' }] }); + cuePoints.attach(asTarget(media)); + await flush(); + + media.dispatchEvent(new Event('loadstart')); + await flush(); + + const track = media.cuePointsTrack!; + track.activeCues = [new FakeVTTCue(5, 10, JSON.stringify('a'))]; + track.dispatchEvent(new Event('cuechange')); + + expect(onCuePointChange).toHaveBeenCalledTimes(1); + }); + }); + + describe('lifecycle', () => { + it('stops emitting and creating tracks after detach', async () => { + const media = createMedia(100); + const onCuePointChange = vi.fn(); + media.addEventListener('cuepointchange', onCuePointChange as EventListener); + + const cuePoints = new CuePoints({ cuePoints: [{ time: 5, value: 'a' }] }); + cuePoints.attach(asTarget(media)); + await flush(); + + const track = media.cuePointsTrack!; + cuePoints.detach(); + + track.activeCues = [new FakeVTTCue(5, 10, JSON.stringify('a'))]; + track.dispatchEvent(new Event('cuechange')); + media.dispatchEvent(new Event('loadstart')); + await flush(); + + expect(onCuePointChange).not.toHaveBeenCalled(); + expect(media.trackEls).toHaveLength(1); + }); + + it('does not write cue points or bind listeners if destroyed before setup completes', async () => { + const media = createMedia(100); + const onCuePointChange = vi.fn(); + media.addEventListener('cuepointchange', onCuePointChange as EventListener); + + const cuePoints = new CuePoints({ cuePoints: [{ time: 5, value: 'a' }] }); + cuePoints.attach(asTarget(media)); + cuePoints.destroy(); + await flush(); + + const track = media.cuePointsTrack; + expect(track?.cues ?? []).toHaveLength(0); + + if (track) { + track.activeCues = [new FakeVTTCue(5, 10, JSON.stringify('a'))]; + track.dispatchEvent(new Event('cuechange')); + } + expect(onCuePointChange).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index f5461c099..ef10482b5 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -12,6 +12,7 @@ const createConfig = (mode: PackageBuildMode): UserConfig => ({ 'dom/media/media-host/index': './src/dom/media/media-host.ts', 'dom/media/custom-media-element/index': './src/dom/media/custom-media-element/index.ts', 'dom/media/media-played-ranges/index': './src/dom/media/media-played-ranges/index.ts', + 'dom/media/cue-points/index': './src/dom/media/cue-points/index.ts', // Media 'dom/media/dash/index': './src/dom/media/dash/index.ts', 'dom/media/hls-js/index': './src/dom/media/hls-js/index.ts', From 654739c37b7c25c98d45c051a410ee1ab2822865 Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Tue, 23 Jun 2026 17:40:56 -0700 Subject: [PATCH 02/17] fix(core): preserve metadata tracks when clearing subtitle tracks The HLS text tracks mixin removed every removable track when subtitles were found, wiping the cue points metadata track owned by another component. Scope the cleanup to caption/subtitle tracks only. Refs #1442 --- .../media/hls-js/tests/text-tracks.test.ts | 85 +++++++++++++++++++ .../core/src/dom/media/hls-js/text-tracks.ts | 7 +- 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/dom/media/hls-js/tests/text-tracks.test.ts diff --git a/packages/core/src/dom/media/hls-js/tests/text-tracks.test.ts b/packages/core/src/dom/media/hls-js/tests/text-tracks.test.ts new file mode 100644 index 000000000..0bee72638 --- /dev/null +++ b/packages/core/src/dom/media/hls-js/tests/text-tracks.test.ts @@ -0,0 +1,85 @@ +import Hls from 'hls.js'; +import { describe, expect, it } from 'vitest'; +import { HTMLVideoElementHost } from '../../video-host'; +import { HlsJsMediaTextTracksMixin } from '../text-tracks'; + +class FakeHost extends HTMLVideoElementHost { + engine: Hls | null; + + constructor(engine: Hls | null = null) { + super(); + this.engine = engine; + } +} + +const HlsJsMediaTextTracks = HlsJsMediaTextTracksMixin(FakeHost); + +function createEngine(): Hls { + const listeners = new Map void>>(); + return { + subtitleTracks: [], + subtitleTrack: -1, + on(event: string, fn: (...args: any[]) => void) { + if (!listeners.has(event)) listeners.set(event, new Set()); + listeners.get(event)!.add(fn); + }, + once(event: string, fn: (...args: any[]) => void) { + const wrapped = (...args: any[]) => { + listeners.get(event)?.delete(wrapped); + fn(...args); + }; + if (!listeners.has(event)) listeners.set(event, new Set()); + listeners.get(event)!.add(wrapped); + }, + 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; +} + +// jsdom's TextTrackList doesn't implement EventTarget, which `#init` needs. +function mockTextTracks(video: HTMLVideoElement): void { + const list = Object.assign(new EventTarget(), { + length: 0, + getTrackById: () => null, + [Symbol.iterator]: function* () {}, + }); + Object.defineProperty(video, 'textTracks', { configurable: true, value: list }); +} + +function addRemovableTrack(video: HTMLVideoElement, kind: string, label = ''): HTMLTrackElement { + const trackEl = document.createElement('track'); + trackEl.kind = kind; + if (label) trackEl.label = label; + trackEl.setAttribute('data-removeondestroy', ''); + video.append(trackEl); + return trackEl; +} + +describe('HlsJsMediaTextTracksMixin', () => { + it('removes its own subtitle tracks but keeps other removable tracks when non-native tracks are found', () => { + const engine = createEngine(); + const host = new HlsJsMediaTextTracks(engine); + const video = document.createElement('video'); + mockTextTracks(video); + + host.attach(video); + // Registers the NON_NATIVE_TEXT_TRACKS_FOUND handler. + (engine as unknown as { emit(event: string, ...args: any[]): void }).emit(Hls.Events.MEDIA_ATTACHED); + + // A cue points metadata track owned by the CuePoints component. + const cueTrack = addRemovableTrack(video, 'metadata', 'cuepoints'); + // A stale subtitle track this mixin owns. + const staleSubtitle = addRemovableTrack(video, 'subtitles'); + + (engine as unknown as { emit(event: string, ...args: any[]): void }).emit(Hls.Events.NON_NATIVE_TEXT_TRACKS_FOUND, { + tracks: [], + }); + + expect(video.contains(cueTrack)).toBe(true); + expect(video.contains(staleSubtitle)).toBe(false); + }); +}); diff --git a/packages/core/src/dom/media/hls-js/text-tracks.ts b/packages/core/src/dom/media/hls-js/text-tracks.ts index 39eb90711..772cbaef8 100644 --- a/packages/core/src/dom/media/hls-js/text-tracks.ts +++ b/packages/core/src/dom/media/hls-js/text-tracks.ts @@ -138,7 +138,12 @@ export function HlsJsMediaTextTracksMixin trackEl.remove()); + // Only remove the subtitle/caption tracks this mixin creates. Other + // removable tracks (e.g. the cue points metadata track) are owned by + // their own components and must not be wiped when subtitles are found. + trackEls.forEach((trackEl) => { + if (isCaptionOrSubtitleTrack(trackEl as HTMLTrackElement)) trackEl.remove(); + }); } } From f51c6284a067a92e1ca02b71bd59c328f6299365 Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Tue, 23 Jun 2026 17:41:02 -0700 Subject: [PATCH 03/17] feat(html): register cue points on hls media elements Refs #1442 --- packages/html/src/media/hlsjs-video/index.ts | 2 ++ packages/html/src/media/mux-audio/index.ts | 2 ++ packages/html/src/media/mux-video/index.ts | 2 ++ 3 files changed, 6 insertions(+) diff --git a/packages/html/src/media/hlsjs-video/index.ts b/packages/html/src/media/hlsjs-video/index.ts index 2e7f429e6..bea855657 100644 --- a/packages/html/src/media/hlsjs-video/index.ts +++ b/packages/html/src/media/hlsjs-video/index.ts @@ -1,3 +1,4 @@ +import { CuePoints } from '@videojs/core/dom/media/cue-points'; import { CustomMediaElement } from '@videojs/core/dom/media/custom-media-element'; import { GoogleCast } from '@videojs/core/dom/media/google-cast'; import { HlsJsMedia } from '@videojs/core/dom/media/hls-js'; @@ -7,6 +8,7 @@ import { MediaAttachMixin } from '../../store/media-attach-mixin'; export class HlsJsVideo extends MediaAttachMixin(CustomMediaElement('video', HlsJsMedia)) { constructor() { super(); + addComponent(this.host, new CuePoints()); addComponent(this.host, new GoogleCast()); } } diff --git a/packages/html/src/media/mux-audio/index.ts b/packages/html/src/media/mux-audio/index.ts index 7bbab861b..181a3b2ab 100644 --- a/packages/html/src/media/mux-audio/index.ts +++ b/packages/html/src/media/mux-audio/index.ts @@ -1,3 +1,4 @@ +import { CuePoints } from '@videojs/core/dom/media/cue-points'; import { CustomMediaElement } from '@videojs/core/dom/media/custom-media-element'; import { GoogleCast } from '@videojs/core/dom/media/google-cast'; import { HlsJsMedia } from '@videojs/core/dom/media/hls-js'; @@ -8,6 +9,7 @@ import { MediaAttachMixin } from '../../store/media-attach-mixin'; export class MuxAudio extends MediaAttachMixin(CustomMediaElement('audio', HlsJsMedia)) { constructor() { super(); + addComponent(this.host, new CuePoints()); addComponent(this.host, new MuxData({ playerSoftwareName: 'mux-audio' })); addComponent(this.host, new GoogleCast()); } diff --git a/packages/html/src/media/mux-video/index.ts b/packages/html/src/media/mux-video/index.ts index 4988a1157..ce46a0555 100644 --- a/packages/html/src/media/mux-video/index.ts +++ b/packages/html/src/media/mux-video/index.ts @@ -1,3 +1,4 @@ +import { CuePoints } from '@videojs/core/dom/media/cue-points'; import { CustomMediaElement } from '@videojs/core/dom/media/custom-media-element'; import { GoogleCast } from '@videojs/core/dom/media/google-cast'; import { HlsJsMedia } from '@videojs/core/dom/media/hls-js'; @@ -8,6 +9,7 @@ import { MediaAttachMixin } from '../../store/media-attach-mixin'; export class MuxVideo extends MediaAttachMixin(CustomMediaElement('video', HlsJsMedia)) { constructor() { super(); + addComponent(this.host, new CuePoints()); addComponent(this.host, new MuxData({ playerSoftwareName: 'mux-video' })); addComponent(this.host, new GoogleCast()); } From 142b9699bbee0ee374594fdee4faa28ba3b7358c Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Tue, 23 Jun 2026 17:41:05 -0700 Subject: [PATCH 04/17] feat(react): register cue points on hls media elements Refs #1442 --- packages/react/src/media/hlsjs-video/index.tsx | 2 ++ packages/react/src/media/mux-audio/index.tsx | 2 ++ packages/react/src/media/mux-video/index.tsx | 2 ++ 3 files changed, 6 insertions(+) diff --git a/packages/react/src/media/hlsjs-video/index.tsx b/packages/react/src/media/hlsjs-video/index.tsx index 0f97e9cab..7a9f7c6f0 100644 --- a/packages/react/src/media/hlsjs-video/index.tsx +++ b/packages/react/src/media/hlsjs-video/index.tsx @@ -1,5 +1,6 @@ 'use client'; +import { CuePoints } from '@videojs/core/dom/media/cue-points'; import { GoogleCast } from '@videojs/core/dom/media/google-cast'; import type { HlsMediaProps } from '@videojs/core/dom/media/hls-js'; import { HlsJsMedia, hlsMediaDefaultProps } from '@videojs/core/dom/media/hls-js'; @@ -22,6 +23,7 @@ export const HlsJsVideo = forwardRef(function ref ) { const media = useMediaInstance(HlsJsMedia, (media) => { + addComponent(media, new CuePoints()); addComponent(media, new GoogleCast()); }); const attachRef = useAttachMedia(media); diff --git a/packages/react/src/media/mux-audio/index.tsx b/packages/react/src/media/mux-audio/index.tsx index 72e2a8ff5..c5ba68aa5 100644 --- a/packages/react/src/media/mux-audio/index.tsx +++ b/packages/react/src/media/mux-audio/index.tsx @@ -1,5 +1,6 @@ 'use client'; +import { CuePoints } from '@videojs/core/dom/media/cue-points'; import { GoogleCast } from '@videojs/core/dom/media/google-cast'; import type { HlsMediaProps } from '@videojs/core/dom/media/hls-js'; import { HlsJsMedia, hlsMediaDefaultProps } from '@videojs/core/dom/media/hls-js'; @@ -20,6 +21,7 @@ export interface MuxAudioProps export const MuxAudio = forwardRef(function MuxAudio({ children, ...props }, ref) { const media = useMediaInstance(HlsJsMedia, (media) => { + addComponent(media, new CuePoints()); addComponent(media, new MuxData({ playerSoftwareName: 'mux-audio' })); addComponent(media, new GoogleCast()); }); diff --git a/packages/react/src/media/mux-video/index.tsx b/packages/react/src/media/mux-video/index.tsx index f4b7bc738..ea89a774b 100644 --- a/packages/react/src/media/mux-video/index.tsx +++ b/packages/react/src/media/mux-video/index.tsx @@ -1,5 +1,6 @@ 'use client'; +import { CuePoints } from '@videojs/core/dom/media/cue-points'; import { GoogleCast } from '@videojs/core/dom/media/google-cast'; import type { HlsMediaProps } from '@videojs/core/dom/media/hls-js'; import { HlsJsMedia, hlsMediaDefaultProps } from '@videojs/core/dom/media/hls-js'; @@ -20,6 +21,7 @@ export interface MuxVideoProps export const MuxVideo = forwardRef(function MuxVideo({ children, ...props }, ref) { const media = useMediaInstance(HlsJsMedia, (media) => { + addComponent(media, new CuePoints()); addComponent(media, new MuxData({ playerSoftwareName: 'mux-video' })); addComponent(media, new GoogleCast()); }); From 1b2e0e219eafaa4fc95f703581793ae4c4448c70 Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Tue, 23 Jun 2026 17:41:10 -0700 Subject: [PATCH 05/17] chore(root): add api-demo app Astro + React playground demonstrating the cue points API and media element state/getters. Adds a dev:api-demo turbo task. Refs #1442 --- apps/api-demo/.gitignore | 15 + apps/api-demo/README.md | 47 +++ apps/api-demo/astro.config.mjs | 23 ++ apps/api-demo/env.d.ts | 2 + apps/api-demo/package.json | 30 ++ apps/api-demo/src/components/Footer.astro | 61 +++ apps/api-demo/src/components/PlayerDemo.tsx | 16 + .../player-demo/add-cue-point-field.tsx | 44 +++ .../src/components/player-demo/constants.ts | 51 +++ .../src/components/player-demo/controls.tsx | 215 +++++++++++ .../src/components/player-demo/demo.tsx | 74 ++++ .../src/components/player-demo/event-log.tsx | 71 ++++ .../src/components/player-demo/fields.tsx | 90 +++++ .../src/components/player-demo/format.ts | 26 ++ .../src/components/player-demo/getters.tsx | 94 +++++ .../src/components/player-demo/icons.tsx | 15 + .../src/components/player-demo/media-log.tsx | 50 +++ .../src/components/player-demo/params.ts | 31 ++ .../src/components/player-demo/player.ts | 11 + .../src/components/player-demo/styles.ts | 10 + .../components/player-demo/track-selects.tsx | 254 ++++++++++++ .../components/player-demo/use-media-state.ts | 85 ++++ apps/api-demo/src/layouts/Base.astro | 38 ++ apps/api-demo/src/pages/index.astro | 43 +++ apps/api-demo/src/styles/global.css | 83 ++++ apps/api-demo/tsconfig.json | 15 + package.json | 1 + pnpm-lock.yaml | 364 +++++++++++++----- 28 files changed, 1772 insertions(+), 87 deletions(-) create mode 100644 apps/api-demo/.gitignore create mode 100644 apps/api-demo/README.md create mode 100644 apps/api-demo/astro.config.mjs create mode 100644 apps/api-demo/env.d.ts create mode 100644 apps/api-demo/package.json create mode 100644 apps/api-demo/src/components/Footer.astro create mode 100644 apps/api-demo/src/components/PlayerDemo.tsx create mode 100644 apps/api-demo/src/components/player-demo/add-cue-point-field.tsx create mode 100644 apps/api-demo/src/components/player-demo/constants.ts create mode 100644 apps/api-demo/src/components/player-demo/controls.tsx create mode 100644 apps/api-demo/src/components/player-demo/demo.tsx create mode 100644 apps/api-demo/src/components/player-demo/event-log.tsx create mode 100644 apps/api-demo/src/components/player-demo/fields.tsx create mode 100644 apps/api-demo/src/components/player-demo/format.ts create mode 100644 apps/api-demo/src/components/player-demo/getters.tsx create mode 100644 apps/api-demo/src/components/player-demo/icons.tsx create mode 100644 apps/api-demo/src/components/player-demo/media-log.tsx create mode 100644 apps/api-demo/src/components/player-demo/params.ts create mode 100644 apps/api-demo/src/components/player-demo/player.ts create mode 100644 apps/api-demo/src/components/player-demo/styles.ts create mode 100644 apps/api-demo/src/components/player-demo/track-selects.tsx create mode 100644 apps/api-demo/src/components/player-demo/use-media-state.ts create mode 100644 apps/api-demo/src/layouts/Base.astro create mode 100644 apps/api-demo/src/pages/index.astro create mode 100644 apps/api-demo/src/styles/global.css create mode 100644 apps/api-demo/tsconfig.json diff --git a/apps/api-demo/.gitignore b/apps/api-demo/.gitignore new file mode 100644 index 000000000..a422a7229 --- /dev/null +++ b/apps/api-demo/.gitignore @@ -0,0 +1,15 @@ +# build output +dist/ + +# generated types +.astro/ + +# dependencies +node_modules/ + +# logs +npm-debug.log* +pnpm-debug.log* + +# macOS-specific files +.DS_Store diff --git a/apps/api-demo/README.md b/apps/api-demo/README.md new file mode 100644 index 000000000..2f2ed9cac --- /dev/null +++ b/apps/api-demo/README.md @@ -0,0 +1,47 @@ +# @videojs/api-demo + +An Astro demo app that showcases the Video.js 10 **media API**. + +The homepage renders the React `HlsVideo` player (with the default skin) and a +panel of controls wired directly to the media instance via `useMedia()`. It is a +hands-on playground for the media API: + +- **Source** — load any HLS (`.m3u8`) URL for testing. +- **Setters / actions** — play/pause, seek (slider + exact time), playback rate, + volume, mute, and text/audio track selection, each calling the API directly + (`media.play()`, `media.currentTime = …`, `media.textTracks[i].mode = …`, …). +- **Getters** — a cloud of every readable property; click one to log its current + value. +- **Message log** — a live, color-coded console: media **events** (yellow), + **actions** (orange), and **getter** reads (magenta). +- **Shareable state** — every action is written to the URL as a query param + (booleans as `0`/`1`), so a configuration can be shared and is restored on + reload. + +Styling, palette, typography (Instrument Sans / IBM Plex Mono / Eurostile), and +footer mirror videojs.org so the page shares the same look and feel. + +## Develop + +```bash +# from the repo root +pnpm --filter @videojs/api-demo dev + +# or build the workspace packages first, then run directly +pnpm build:packages +pnpm --dir apps/api-demo dev +``` + +## Build + +```bash +pnpm --filter @videojs/api-demo build +``` + +## Adding more API demos + +`src/components/PlayerDemo.tsx` is the place to grow this. The media instance +returned by `useMedia()` exposes the full media API (seeking, volume, playback +rate, tracks, stream type, renditions, …) — add new controls alongside the +existing ones, log them as actions, and persist them to the URL to match the +rest of the demo. diff --git a/apps/api-demo/astro.config.mjs b/apps/api-demo/astro.config.mjs new file mode 100644 index 000000000..ad7a0c54d --- /dev/null +++ b/apps/api-demo/astro.config.mjs @@ -0,0 +1,23 @@ +// @ts-check +import react from '@astrojs/react'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'astro/config'; + +// https://astro.build/config +export default defineConfig({ + integrations: [react()], + vite: { + plugins: [tailwindcss()], + optimizeDeps: { + // The workspace media packages ship ESM; let Vite resolve them directly + // instead of pre-bundling (mirrors the main site config). + exclude: ['@videojs/core', '@videojs/react'], + // react-dom (CJS) must be pre-bundled so its named exports are exposed as + // ESM bindings to the @astrojs/react client renderer. + include: ['react-dom', 'react-dom/client'], + }, + resolve: { + dedupe: ['react', 'react-dom'], + }, + }, +}); diff --git a/apps/api-demo/env.d.ts b/apps/api-demo/env.d.ts new file mode 100644 index 000000000..acef35f17 --- /dev/null +++ b/apps/api-demo/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/apps/api-demo/package.json b/apps/api-demo/package.json new file mode 100644 index 000000000..67e79b16e --- /dev/null +++ b/apps/api-demo/package.json @@ -0,0 +1,30 @@ +{ + "name": "@videojs/api-demo", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview", + "astro": "astro", + "check": "astro check" + }, + "dependencies": { + "@videojs/core": "workspace:*", + "@videojs/react": "workspace:*", + "astro": "^6.3.1", + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "devDependencies": { + "@astrojs/check": "^0.9.8", + "@astrojs/react": "^5.0.2", + "@tailwindcss/vite": "^4.2.1", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "tailwindcss": "^4.2.1", + "typescript": "npm:@typescript/typescript6@^6.0.0", + "vite": "^7.0.0" + } +} diff --git a/apps/api-demo/src/components/Footer.astro b/apps/api-demo/src/components/Footer.astro new file mode 100644 index 000000000..436b76f29 --- /dev/null +++ b/apps/api-demo/src/components/Footer.astro @@ -0,0 +1,61 @@ +--- +// Footer adapted from videojs.org (site/src/components/Footer.astro): the mono +// logo on the left and the right-aligned descriptive text with links. +const year = new Date().getFullYear(); +const VIDEOJS_URL = 'https://videojs.org'; +const MUX_URL = 'https://www.mux.com'; +--- + +
+
+
+ + + +
+

+ Video.js is a free and open source HTML5 video player. +
+ Video hosting and streaming sponsored by Mux +

+ The term VIDEO.JS is a registered trademark of Brightcove Inc. +
+ © 2010–{year} Video.js contributors +

+
+ + {/* Decorative colored bars, flush to the bottom edge. */} + +
diff --git a/apps/api-demo/src/components/PlayerDemo.tsx b/apps/api-demo/src/components/PlayerDemo.tsx new file mode 100644 index 000000000..b30991b23 --- /dev/null +++ b/apps/api-demo/src/components/PlayerDemo.tsx @@ -0,0 +1,16 @@ +'use client'; + +import '@videojs/react/video/skin.css'; +import { Demo } from './player-demo/demo'; +import { MediaLogProvider } from './player-demo/media-log'; +import { Player } from './player-demo/player'; + +export default function PlayerDemo() { + return ( + + + + + + ); +} diff --git a/apps/api-demo/src/components/player-demo/add-cue-point-field.tsx b/apps/api-demo/src/components/player-demo/add-cue-point-field.tsx new file mode 100644 index 000000000..4644cfe8a --- /dev/null +++ b/apps/api-demo/src/components/player-demo/add-cue-point-field.tsx @@ -0,0 +1,44 @@ +import type { MediaFull } from '@videojs/core'; +import type { CuePoint } from '@videojs/core/dom/media/cue-points'; +import { useState } from 'react'; +import { useMediaLog } from './media-log'; +import { Player } from './player'; +import { NUMBER_INPUT_CLASS, SET_BUTTON_CLASS } from './styles'; + +/** + * Add a cue point at the current time (entered text = value) by appending to + * the `config.cuePoints` list, which is re-applied through the media config. + */ +export function AddCuePointField({ onAdd }: { onAdd: (cuePoint: CuePoint) => void }) { + const media = Player.useMedia() as MediaFull | null; + const { log } = useMediaLog(); + const [draft, setDraft] = useState(''); + + return ( +
{ + event.preventDefault(); + const value = draft.trim(); + if (!media || !value) return; + + const time = media.currentTime; + onAdd({ time, value }); + log('action', `config.cuePoints += { time: ${time.toFixed(2)}, value: ${JSON.stringify(value)} }`); + setDraft(''); + }} + > + setDraft(event.target.value)} + className={NUMBER_INPUT_CLASS} + /> + +
+ ); +} diff --git a/apps/api-demo/src/components/player-demo/constants.ts b/apps/api-demo/src/components/player-demo/constants.ts new file mode 100644 index 000000000..8decf0a73 --- /dev/null +++ b/apps/api-demo/src/components/player-demo/constants.ts @@ -0,0 +1,51 @@ +import type { MediaFullEvents } from '@videojs/core'; +import type { CuePoint } from '@videojs/core/dom/media/cue-points'; + +export const DEFAULT_SRC = 'https://stream.mux.com/Sc89iWAyNkhJ3P1rQ02nrEdCFTnfT01CZ2KmaEcxXfB008.m3u8'; + +// Default cue points, applied to the media through the `cuePoints` config +// namespace and readable back via the CuePoints component API. +export const DEFAULT_CUE_POINTS: CuePoint[] = [ + { time: 1, value: 'Simple Value' }, + { time: 3, value: { complex: 'Complex Object', duration: 2 } }, + { time: 10, value: true }, + { time: 15, value: { anything: 'That can be serialized to JSON and makes sense for your use case' } }, +]; + +// The most common media events. `timeupdate` and `progress` fire continuously, +// so they are intentionally omitted to keep the log readable. +export const LOGGED_EVENTS = [ + 'loadstart', + 'loadedmetadata', + 'loadeddata', + 'canplay', + 'canplaythrough', + 'play', + 'playing', + 'pause', + 'waiting', + 'seeking', + 'seeked', + 'ratechange', + 'volumechange', + 'durationchange', + 'ended', + 'error', +] as const satisfies readonly (keyof MediaFullEvents)[]; + +// Events that may change the values mirrored in the controls panel. +export const STATE_EVENTS = [ + 'loadstart', + 'loadedmetadata', + 'emptied', + 'durationchange', + 'timeupdate', + 'play', + 'playing', + 'pause', + 'ended', + 'seeking', + 'seeked', + 'ratechange', + 'volumechange', +] as const satisfies readonly (keyof MediaFullEvents)[]; diff --git a/apps/api-demo/src/components/player-demo/controls.tsx b/apps/api-demo/src/components/player-demo/controls.tsx new file mode 100644 index 000000000..05f784f7f --- /dev/null +++ b/apps/api-demo/src/components/player-demo/controls.tsx @@ -0,0 +1,215 @@ +import type { CuePoint } from '@videojs/core/dom/media/cue-points'; +import { AddCuePointField } from './add-cue-point-field'; +import { ApplyNumberField, Field } from './fields'; +import { formatTime } from './format'; +import { PauseIcon, PlayIcon } from './icons'; +import { useMediaLog } from './media-log'; +import { bool, setParam } from './params'; +import { Player, type TracksMedia } from './player'; +import { SET_BUTTON_CLASS, SLIDER_CLASS } from './styles'; +import { AudioTrackSelect, QualitySelect, TextTrackSelect } from './track-selects'; +import { useMediaSnapshot, useRestoreFromParams } from './use-media-state'; + +/** + * The controls panel. Every interaction calls the media API directly, logs the + * call as an action, persists it to a URL param, and mirrors displayed values + * from the media's events. + */ +export function Controls({ onAddCuePoint }: { onAddCuePoint: (cuePoint: CuePoint) => void }) { + const media = Player.useMedia() as TracksMedia | null; + const { log } = useMediaLog(); + const snapshot = useMediaSnapshot(media); + const { paused, currentTime, duration, volume, muted } = snapshot; + + useRestoreFromParams(media); + + const hasDuration = Number.isFinite(duration) && duration > 0; + + const togglePlay = () => { + if (!media) return; + if (media.paused) { + media.play().catch(() => {}); + log('action', 'media.play()'); + setParam('paused', '0'); + } else { + media.pause(); + log('action', 'media.pause()'); + setParam('paused', '1'); + } + }; + + // Enter the given presentation mode, then leave it after 3 seconds. + const presentFor3s = async ( + enter: 'requestFullscreen' | 'requestPictureInPicture', + leave: 'exitFullscreen' | 'exitPictureInPicture' + ) => { + if (!media?.[enter]) return; + try { + await media[enter]?.(); + } catch { + return; + } + log('action', `media.${enter}()`); + window.setTimeout(() => { + media[leave]?.(); + log('action', `media.${leave}()`); + }, 3000); + }; + + return ( +
+ + + + { + if (media) media.currentTime = Number(event.target.value); + }} + onPointerUp={() => { + if (!media) return; + log('action', `media.currentTime = ${media.currentTime.toFixed(1)}`); + setParam('time', media.currentTime.toFixed(1)); + }} + onKeyUp={() => { + if (!media) return; + log('action', `media.currentTime = ${media.currentTime.toFixed(1)}`); + setParam('time', media.currentTime.toFixed(1)); + }} + className={SLIDER_CLASS} + /> + + {formatTime(currentTime)} / {hasDuration ? formatTime(duration) : '–:––'} + + { + if (!media) return; + media.currentTime = value; + log('action', `media.currentTime = ${value}`); + setParam('time', String(value)); + }} + /> + + + + { + if (!media || value <= 0) return; + media.playbackRate = value; + log('action', `media.playbackRate = ${value}`); + setParam('rate', String(value)); + }} + /> + + + +
+ + { + if (!media) return; + media.volume = Number(event.target.value); + if (media.muted) media.muted = false; + }} + onPointerUp={() => { + if (!media) return; + log('action', `media.volume = ${media.volume.toFixed(2)}`); + setParam('volume', media.volume.toFixed(2)); + setParam('muted', bool(media.muted)); + }} + onKeyUp={() => { + if (!media) return; + log('action', `media.volume = ${media.volume.toFixed(2)}`); + setParam('volume', media.volume.toFixed(2)); + setParam('muted', bool(media.muted)); + }} + className={SLIDER_CLASS} + /> +
+ {Math.round((muted ? 0 : volume) * 100)}% +
+ + +
+ + +
+
+ + + + + + + + + + + + + + + + +
+ ); +} diff --git a/apps/api-demo/src/components/player-demo/demo.tsx b/apps/api-demo/src/components/player-demo/demo.tsx new file mode 100644 index 000000000..6b88769bc --- /dev/null +++ b/apps/api-demo/src/components/player-demo/demo.tsx @@ -0,0 +1,74 @@ +import type { CuePoint } from '@videojs/core/dom/media/cue-points'; +import { HlsVideo } from '@videojs/react/media/hls-video'; +import { VideoSkin } from '@videojs/react/video'; +import { type CSSProperties, useMemo, useState } from 'react'; +import { DEFAULT_CUE_POINTS } from './constants'; +import { Controls } from './controls'; +import { EventLog } from './event-log'; +import { SourceField } from './fields'; +import { quote } from './format'; +import { Getters } from './getters'; +import { useMediaLog } from './media-log'; +import { getInitialSrc, posterFor, setParam } from './params'; + +/** Inner demo body — lives inside the player + log providers. */ +export function Demo() { + const { log } = useMediaLog(); + const [src, setSrc] = useState(getInitialSrc); + const [cuePoints, setCuePoints] = useState(DEFAULT_CUE_POINTS); + + // Cue points are applied through the media config; appending here re-applies + // the full list via the `cuePoints` config namespace. + const mediaConfig = useMemo(() => ({ cuePoints: { cuePoints } }), [cuePoints]); + const addCuePoint = (cuePoint: CuePoint) => setCuePoints((prev) => [...prev, cuePoint]); + + const loadSrc = (next: string) => { + setSrc(next); + setParam('src', next); + // A new asset starts fresh; drop persisted position and track selections. + setParam('time', null); + setParam('texttrack', null); + setParam('audiotrack', null); + setParam('quality', null); + log('action', `media.src = ${quote(next)}`); + }; + + return ( +
+ {/* Player + message log */} +
+
+ + + +
+ +
+

Getters

+ +
+
+ + {/* Source + controls */} + +
+ ); +} diff --git a/apps/api-demo/src/components/player-demo/event-log.tsx b/apps/api-demo/src/components/player-demo/event-log.tsx new file mode 100644 index 000000000..0431b87cc --- /dev/null +++ b/apps/api-demo/src/components/player-demo/event-log.tsx @@ -0,0 +1,71 @@ +import type { MediaFull } from '@videojs/core'; +import { useEffect, useRef } from 'react'; +import { LOGGED_EVENTS } from './constants'; +import { entryColorClass, useMediaLog } from './media-log'; +import { Player } from './player'; + +/** + * Renders the live message log. Media events are logged automatically (yellow); + * the controls push their API calls as actions (orange); getter reads are + * logged as values (magenta). + */ +export function EventLog() { + const media = Player.useMedia() as MediaFull | null; + const { entries, log, clear } = useMediaLog(); + const listRef = useRef(null); + + useEffect(() => { + if (!media) return; + + const controller = new AbortController(); + for (const type of LOGGED_EVENTS) { + media.addEventListener(type, () => log('event', type), { signal: controller.signal }); + } + + // `cuepointchange` is a custom event dispatched by the CuePoints component; + // its `detail` is the activated cue point. + (media as unknown as EventTarget).addEventListener( + 'cuepointchange', + (event) => log('event', `cuepointchange → ${JSON.stringify((event as CustomEvent).detail)}`), + { signal: controller.signal } + ); + + return () => controller.abort(); + }, [media, log]); + + // Keep the newest entry in view whenever a new one is appended. + useEffect(() => { + const el = listRef.current; + if (el && entries.length) el.scrollTop = el.scrollHeight; + }, [entries.length]); + + return ( +
+
+ Message Log + +
+
    + {entries.length === 0 ? ( +
  1. Waiting for media events…
  2. + ) : ( + entries.map((entry) => ( +
  3. + {entry.time} + + {entry.kind === 'action' ? '▸ ' : ''} + {entry.label} + +
  4. + )) + )} +
+
+ ); +} diff --git a/apps/api-demo/src/components/player-demo/fields.tsx b/apps/api-demo/src/components/player-demo/fields.tsx new file mode 100644 index 000000000..9dd7b22cd --- /dev/null +++ b/apps/api-demo/src/components/player-demo/fields.tsx @@ -0,0 +1,90 @@ +import { type ReactNode, useState } from 'react'; +import { NUMBER_INPUT_CLASS, SET_BUTTON_CLASS } from './styles'; + +/** A labeled section in the controls panel. */ +export function Field({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +/** + * A number text field with a "Set" button. Holds its own draft value so the + * live media value doesn't fight what the user is typing. + */ +export function ApplyNumberField({ + placeholder, + step, + min, + ariaLabel, + disabled, + onApply, +}: { + placeholder: string; + step?: string; + min?: string; + ariaLabel: string; + disabled?: boolean; + onApply: (value: number) => void; +}) { + const [draft, setDraft] = useState(''); + + return ( +
{ + event.preventDefault(); + const value = Number(draft); + if (draft.trim() !== '' && Number.isFinite(value)) onApply(value); + setDraft(''); + }} + > + setDraft(event.target.value)} + className={NUMBER_INPUT_CLASS} + /> + +
+ ); +} + +/** Source URL input with a Load button for testing arbitrary assets. */ +export function SourceField({ src, onLoad }: { src: string; onLoad: (next: string) => void }) { + const [draft, setDraft] = useState(src); + + return ( +
{ + event.preventDefault(); + const next = draft.trim(); + if (next) onLoad(next); + }} + > + setDraft(event.target.value)} + className={NUMBER_INPUT_CLASS} + /> + +
+ ); +} diff --git a/apps/api-demo/src/components/player-demo/format.ts b/apps/api-demo/src/components/player-demo/format.ts new file mode 100644 index 000000000..5a4fc10a5 --- /dev/null +++ b/apps/api-demo/src/components/player-demo/format.ts @@ -0,0 +1,26 @@ +import type { TimeRangeLike } from '@videojs/core'; + +export function formatTime(seconds: number): string { + if (!Number.isFinite(seconds)) return '0:00'; + const total = Math.max(0, Math.floor(seconds)); + const mins = Math.floor(total / 60); + const secs = total % 60; + return `${mins}:${String(secs).padStart(2, '0')}`; +} + +export function num(value: number): string { + if (Number.isNaN(value)) return 'NaN'; + if (!Number.isFinite(value)) return value > 0 ? 'Infinity' : '-Infinity'; + return Number.isInteger(value) ? String(value) : value.toFixed(3); +} + +export function quote(value: string): string { + return `"${value}"`; +} + +export function ranges(value: TimeRangeLike): string { + if (!value || value.length === 0) return '(empty)'; + const parts: string[] = []; + for (let i = 0; i < value.length; i++) parts.push(`${num(value.start(i))}–${num(value.end(i))}`); + return `[${parts.join(', ')}]`; +} diff --git a/apps/api-demo/src/components/player-demo/getters.tsx b/apps/api-demo/src/components/player-demo/getters.tsx new file mode 100644 index 000000000..a6fd9be94 --- /dev/null +++ b/apps/api-demo/src/components/player-demo/getters.tsx @@ -0,0 +1,94 @@ +import { num, quote, ranges } from './format'; +import { useMediaLog } from './media-log'; +import { Player, type TracksMedia } from './player'; + +// Every readable property on the media instance. Clicking one logs its current +// value to the message log. +const GETTERS: { expr: string; read: (media: TracksMedia) => string }[] = [ + { expr: 'media.paused', read: (m) => String(m.paused) }, + { expr: 'media.ended', read: (m) => String(m.ended) }, + { expr: 'media.seeking', read: (m) => String(m.seeking) }, + { expr: 'media.currentTime', read: (m) => num(m.currentTime) }, + { expr: 'media.duration', read: (m) => num(m.duration) }, + { expr: 'media.videoWidth', read: (m) => num(m.videoWidth) }, + { expr: 'media.videoHeight', read: (m) => num(m.videoHeight) }, + { expr: 'media.volume', read: (m) => num(m.volume) }, + { expr: 'media.muted', read: (m) => String(m.muted) }, + { expr: 'media.defaultMuted', read: (m) => String(m.defaultMuted) }, + { expr: 'media.playbackRate', read: (m) => num(m.playbackRate) }, + { expr: 'media.defaultPlaybackRate', read: (m) => num(m.defaultPlaybackRate) }, + { expr: 'media.readyState', read: (m) => String(m.readyState) }, + { expr: 'media.src', read: (m) => quote(m.src) }, + { expr: 'media.currentSrc', read: (m) => quote(m.currentSrc) }, + { expr: 'media.preload', read: (m) => quote(m.preload) }, + { expr: 'media.crossOrigin', read: (m) => (m.crossOrigin === null ? 'null' : quote(m.crossOrigin)) }, + { expr: 'media.streamType', read: (m) => quote(m.streamType) }, + { expr: 'media.poster', read: (m) => quote(m.poster) }, + { expr: 'media.playsInline', read: (m) => String(m.playsInline) }, + { expr: 'media.loop', read: (m) => String(m.loop) }, + { expr: 'media.autoplay', read: (m) => String(m.autoplay) }, + { expr: 'media.controls', read: (m) => String(m.controls) }, + { expr: 'media.isFullscreen', read: (m) => String(m.isFullscreen) }, + { expr: 'media.isPictureInPicture', read: (m) => String(m.isPictureInPicture) }, + { expr: 'media.disablePictureInPicture', read: (m) => String(m.disablePictureInPicture) }, + { expr: 'media.disableRemotePlayback', read: (m) => String(m.disableRemotePlayback) }, + { expr: 'media.liveEdgeStart', read: (m) => num(m.liveEdgeStart) }, + { expr: 'media.targetLiveWindow', read: (m) => num(m.targetLiveWindow) }, + { expr: 'media.buffered', read: (m) => ranges(m.buffered) }, + { expr: 'media.seekable', read: (m) => ranges(m.seekable) }, + { expr: 'media.played', read: (m) => ranges(m.played) }, + { expr: 'media.textTracks', read: (m) => `${m.textTracks.length} track(s)` }, + { + expr: 'media.config.cuePoints.cuePoints', + read: (m) => { + const ns = m.config.cuePoints as { cuePoints?: unknown } | undefined; + return JSON.stringify(ns?.cuePoints ?? []); + }, + }, + + { + expr: 'media.videoRenditions', + read: (m) => { + const list = m.videoRenditions; + if (!list) return 'undefined'; + const items: string[] = []; + for (let i = 0; i < list.length; i++) items.push(list[i]!.height ? `${list[i]!.height}p` : `#${i}`); + return `[${items.join(', ')}] selectedIndex=${list.selectedIndex}`; + }, + }, + { expr: 'media.remote.state', read: (m) => quote(m.remote.state) }, + { + expr: 'media.error', + read: (m) => (m.error ? `{ code: ${m.error.code}, message: ${quote(m.error.message)} }` : 'null'), + }, +]; + +/** + * Lists every readable media property. Clicking one reads it live from the + * media instance and logs `expr → value` to the message log. + */ +export function Getters() { + const media = Player.useMedia() as TracksMedia | null; + const { log } = useMediaLog(); + + return ( +
+

Click a getter to log its current value.

+
+ {GETTERS.map((getter) => ( + + ))} +
+
+ ); +} diff --git a/apps/api-demo/src/components/player-demo/icons.tsx b/apps/api-demo/src/components/player-demo/icons.tsx new file mode 100644 index 000000000..26c57c1f5 --- /dev/null +++ b/apps/api-demo/src/components/player-demo/icons.tsx @@ -0,0 +1,15 @@ +export function PlayIcon() { + return ( + + ); +} + +export function PauseIcon() { + return ( + + ); +} diff --git a/apps/api-demo/src/components/player-demo/media-log.tsx b/apps/api-demo/src/components/player-demo/media-log.tsx new file mode 100644 index 000000000..e219f3247 --- /dev/null +++ b/apps/api-demo/src/components/player-demo/media-log.tsx @@ -0,0 +1,50 @@ +import { createContext, type ReactNode, useCallback, useContext, useMemo, useRef, useState } from 'react'; + +export type LogKind = 'event' | 'action' | 'getter'; + +export interface LogEntry { + id: number; + time: string; + label: string; + kind: LogKind; +} + +export interface MediaLog { + entries: LogEntry[]; + log: (kind: LogKind, label: string) => void; + clear: () => void; +} + +const MediaLogContext = createContext(null); + +export function useMediaLog(): MediaLog { + const ctx = useContext(MediaLogContext); + if (!ctx) throw new Error('useMediaLog must be used within a MediaLogProvider'); + return ctx; +} + +export function MediaLogProvider({ children }: { children: ReactNode }) { + const [entries, setEntries] = useState([]); + const nextId = useRef(0); + + const log = useCallback((kind: LogKind, label: string) => { + const time = new Date().toLocaleTimeString(undefined, { hour12: false }); + setEntries((prev) => { + const next = [...prev, { id: nextId.current++, time, label, kind }]; + // Keep the log bounded so long sessions don't grow unbounded. + return next.length > 100 ? next.slice(-100) : next; + }); + }, []); + + const clear = useCallback(() => setEntries([]), []); + + const value = useMemo(() => ({ entries, log, clear }), [entries, log, clear]); + + return {children}; +} + +export function entryColorClass(kind: LogKind): string { + if (kind === 'action') return 'text-orange'; + if (kind === 'getter') return 'text-magenta'; + return 'text-bright-yellow'; +} diff --git a/apps/api-demo/src/components/player-demo/params.ts b/apps/api-demo/src/components/player-demo/params.ts new file mode 100644 index 000000000..80dfe481e --- /dev/null +++ b/apps/api-demo/src/components/player-demo/params.ts @@ -0,0 +1,31 @@ +import { DEFAULT_SRC } from './constants'; + +// Actions are persisted as query params so a configuration can be shared / +// restored. Booleans use "0" / "1". + +export function readParams(): URLSearchParams { + if (typeof window === 'undefined') return new URLSearchParams(); + return new URLSearchParams(window.location.search); +} + +export function setParam(key: string, value: string | null): void { + if (typeof window === 'undefined') return; + const url = new URL(window.location.href); + if (value === null || value === '') url.searchParams.delete(key); + else url.searchParams.set(key, value); + window.history.replaceState(window.history.state, '', url); +} + +export function bool(value: boolean): string { + return value ? '1' : '0'; +} + +export function getInitialSrc(): string { + return readParams().get('src') || DEFAULT_SRC; +} + +/** Derive a Mux poster image from a Mux stream URL, when applicable. */ +export function posterFor(src: string): string | undefined { + const match = src.match(/stream\.mux\.com\/([^/.]+)\.m3u8/); + return match ? `https://image.mux.com/${match[1]}/thumbnail.webp` : undefined; +} diff --git a/apps/api-demo/src/components/player-demo/player.ts b/apps/api-demo/src/components/player-demo/player.ts new file mode 100644 index 000000000..22201b87b --- /dev/null +++ b/apps/api-demo/src/components/player-demo/player.ts @@ -0,0 +1,11 @@ +import type { AudioTrackListLike, Video, VideoRenditionListLike } from '@videojs/core'; +import { createPlayer, videoFeatures } from '@videojs/react'; + +export const Player = createPlayer({ features: videoFeatures }); + +// Audio tracks and video renditions are only exposed once the hls.js engine attaches. +// Fullscreen / picture-in-picture live on the video media surface. +export type TracksMedia = Video & { + readonly audioTracks?: AudioTrackListLike; + readonly videoRenditions?: VideoRenditionListLike; +}; diff --git a/apps/api-demo/src/components/player-demo/styles.ts b/apps/api-demo/src/components/player-demo/styles.ts new file mode 100644 index 000000000..942c17ff4 --- /dev/null +++ b/apps/api-demo/src/components/player-demo/styles.ts @@ -0,0 +1,10 @@ +export const SLIDER_CLASS = 'w-full accent-orange disabled:cursor-not-allowed disabled:opacity-50'; + +export const NUMBER_INPUT_CLASS = + 'w-full rounded-xs border border-manila-dark bg-manila-50 px-3 py-1.5 font-mono text-sm text-faded-black focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-orange'; + +export const SET_BUTTON_CLASS = + 'shrink-0 rounded-xs border border-manila-dark px-3 py-1.5 font-display text-xs uppercase tracking-wide transition-colors hover:bg-faded-black hover:text-manila-light disabled:cursor-not-allowed disabled:opacity-50'; + +export const SELECT_CLASS = + 'select-chevron w-full cursor-pointer appearance-none rounded-xs border border-manila-dark bg-manila-50 px-3 py-2 pr-9 text-sm text-faded-black transition-colors hover:bg-manila-dark focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-orange disabled:cursor-not-allowed disabled:opacity-50'; diff --git a/apps/api-demo/src/components/player-demo/track-selects.tsx b/apps/api-demo/src/components/player-demo/track-selects.tsx new file mode 100644 index 000000000..7d1c3b249 --- /dev/null +++ b/apps/api-demo/src/components/player-demo/track-selects.tsx @@ -0,0 +1,254 @@ +import type { AudioTrackListLike, TextTrackListLike } from '@videojs/core'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useMediaLog } from './media-log'; +import { readParams, setParam } from './params'; +import { Player, type TracksMedia } from './player'; +import { SELECT_CLASS } from './styles'; + +function trackLabel(track: { label: string; language: string }, index: number): string { + return track.label || track.language || `Track ${index + 1}`; +} + +function applyTextTrack(list: TextTrackListLike, selected: number): void { + for (let i = 0; i < list.length; i++) { + const track = list[i]!; + if (track.kind === 'subtitles' || track.kind === 'captions') { + track.mode = i === selected ? 'showing' : 'disabled'; + } + } +} + +function applyAudioTrack(list: AudioTrackListLike, selected: number): void { + for (let i = 0; i < list.length; i++) list[i]!.enabled = i === selected; +} + +/** Dropdown for selecting the video rendition (quality), or Auto for ABR. */ +export function QualitySelect() { + const media = Player.useMedia() as TracksMedia | null; + const { log } = useMediaLog(); + const [, setVersion] = useState(0); + const bump = useCallback(() => setVersion((v) => v + 1), []); + const restored = useRef(false); + const list = media?.videoRenditions ?? null; + + useEffect(() => { + if (!media) return; + const controller = new AbortController(); + for (const type of ['loadstart', 'loadedmetadata', 'loadeddata', 'canplay'] as const) { + media.addEventListener(type, bump, { signal: controller.signal }); + } + return () => controller.abort(); + }, [media, bump]); + + useEffect(() => { + if (!list) return; + const controller = new AbortController(); + const tryRestore = () => { + if (restored.current || list.length === 0) return; + restored.current = true; + const param = readParams().get('quality'); + if (param !== null) list.selectedIndex = param === 'auto' ? -1 : Number(param); + }; + const onEvent = () => { + bump(); + tryRestore(); + }; + list.addEventListener('change', onEvent, { signal: controller.signal }); + list.addEventListener('addrendition', onEvent, { signal: controller.signal }); + list.addEventListener('removerendition', onEvent, { signal: controller.signal }); + list.addEventListener('activechange', onEvent, { signal: controller.signal }); + tryRestore(); + return () => controller.abort(); + }, [list, bump]); + + const options: { value: string; label: string }[] = []; + if (list) { + for (let i = 0; i < list.length; i++) { + const rendition = list[i]!; + const label = rendition.height + ? `${rendition.height}p` + : rendition.width + ? `${rendition.width}w` + : `Rendition ${i + 1}`; + options.push({ value: String(i), label }); + } + } + const value = list && list.selectedIndex >= 0 ? String(list.selectedIndex) : 'auto'; + + const onChange = (next: string) => { + if (!list) return; + const index = next === 'auto' ? -1 : Number(next); + list.selectedIndex = index; + log('action', `media.videoRenditions.selectedIndex = ${index}`); + setParam('quality', next); + }; + + return ( + + ); +} + +/** Dropdown for selecting the showing subtitles/captions text track. */ +export function TextTrackSelect() { + const media = Player.useMedia() as TracksMedia | null; + const { log } = useMediaLog(); + const [, setVersion] = useState(0); + const bump = useCallback(() => setVersion((v) => v + 1), []); + const restored = useRef(false); + const list = media?.textTracks ?? null; + + // Tracks can appear after load, so re-read on media load events. + useEffect(() => { + if (!media) return; + const controller = new AbortController(); + for (const type of ['loadstart', 'loadedmetadata', 'loadeddata', 'canplay'] as const) { + media.addEventListener(type, bump, { signal: controller.signal }); + } + return () => controller.abort(); + }, [media, bump]); + + useEffect(() => { + if (!list) return; + const controller = new AbortController(); + const tryRestore = () => { + if (restored.current || list.length === 0) return; + restored.current = true; + const param = readParams().get('texttrack'); + if (param !== null) applyTextTrack(list, param === 'off' ? -1 : Number(param)); + }; + const onEvent = () => { + bump(); + tryRestore(); + }; + list.addEventListener('change', onEvent, { signal: controller.signal }); + list.addEventListener('addtrack', onEvent, { signal: controller.signal }); + list.addEventListener('removetrack', onEvent, { signal: controller.signal }); + tryRestore(); + return () => controller.abort(); + }, [list, bump]); + + const options: { value: string; label: string }[] = []; + let showing = 'off'; + if (list) { + for (let i = 0; i < list.length; i++) { + const track = list[i]!; + if (track.kind === 'subtitles' || track.kind === 'captions') { + options.push({ value: String(i), label: trackLabel(track, i) }); + if (track.mode === 'showing') showing = String(i); + } + } + } + + const onChange = (value: string) => { + if (!list) return; + const selected = value === 'off' ? -1 : Number(value); + applyTextTrack(list, selected); + log('action', selected < 0 ? 'text track → "off"' : `media.textTracks[${selected}].mode = "showing"`); + setParam('texttrack', value); + }; + + return ( + + ); +} + +/** Dropdown for selecting the enabled audio track (hls.js engine only). */ +export function AudioTrackSelect() { + const media = Player.useMedia() as TracksMedia | null; + const { log } = useMediaLog(); + const [, setVersion] = useState(0); + const bump = useCallback(() => setVersion((v) => v + 1), []); + const restored = useRef(false); + const list = media?.audioTracks ?? null; + + useEffect(() => { + if (!media) return; + const controller = new AbortController(); + for (const type of ['loadstart', 'loadedmetadata', 'loadeddata', 'canplay'] as const) { + media.addEventListener(type, bump, { signal: controller.signal }); + } + return () => controller.abort(); + }, [media, bump]); + + useEffect(() => { + if (!list) return; + const controller = new AbortController(); + const tryRestore = () => { + if (restored.current || list.length === 0) return; + restored.current = true; + const param = readParams().get('audiotrack'); + if (param !== null) applyAudioTrack(list, Number(param)); + }; + const onEvent = () => { + bump(); + tryRestore(); + }; + list.addEventListener('change', onEvent, { signal: controller.signal }); + list.addEventListener('addtrack', onEvent, { signal: controller.signal }); + list.addEventListener('removetrack', onEvent, { signal: controller.signal }); + tryRestore(); + return () => controller.abort(); + }, [list, bump]); + + const options: { value: string; label: string }[] = []; + let enabled = -1; + if (list) { + for (let i = 0; i < list.length; i++) { + const track = list[i]!; + options.push({ value: String(i), label: trackLabel(track, i) }); + if (track.enabled) enabled = i; + } + } + const value = options.length === 0 ? '' : String(enabled >= 0 ? enabled : 0); + + const onChange = (next: string) => { + if (!list) return; + const selected = Number(next); + applyAudioTrack(list, selected); + log('action', `media.audioTracks[${selected}].enabled = true`); + setParam('audiotrack', next); + }; + + return ( + + ); +} diff --git a/apps/api-demo/src/components/player-demo/use-media-state.ts b/apps/api-demo/src/components/player-demo/use-media-state.ts new file mode 100644 index 000000000..653e14e38 --- /dev/null +++ b/apps/api-demo/src/components/player-demo/use-media-state.ts @@ -0,0 +1,85 @@ +import type { MediaFull } from '@videojs/core'; +import { useEffect, useState } from 'react'; +import { STATE_EVENTS } from './constants'; +import { readParams } from './params'; + +export interface MediaSnapshot { + paused: boolean; + currentTime: number; + duration: number; + playbackRate: number; + volume: number; + muted: boolean; +} + +const DEFAULT_SNAPSHOT: MediaSnapshot = { + paused: true, + currentTime: 0, + duration: 0, + playbackRate: 1, + volume: 1, + muted: false, +}; + +function readSnapshot(media: MediaFull): MediaSnapshot { + return { + paused: media.paused, + currentTime: media.currentTime, + duration: media.duration, + playbackRate: media.playbackRate, + volume: media.volume, + muted: media.muted, + }; +} + +/** Subscribe to the events that affect the controls and return a live snapshot. */ +export function useMediaSnapshot(media: MediaFull | null): MediaSnapshot { + const [snapshot, setSnapshot] = useState(DEFAULT_SNAPSHOT); + + useEffect(() => { + if (!media) { + setSnapshot(DEFAULT_SNAPSHOT); + return; + } + + const controller = new AbortController(); + const sync = () => setSnapshot(readSnapshot(media)); + + sync(); + for (const type of STATE_EVENTS) { + media.addEventListener(type, sync, { signal: controller.signal }); + } + + return () => controller.abort(); + }, [media]); + + return snapshot; +} + +/** Restore persisted action params onto the media each time metadata loads. */ +export function useRestoreFromParams(media: MediaFull | null) { + useEffect(() => { + if (!media) return; + + const controller = new AbortController(); + const apply = () => { + const params = readParams(); + + const rate = Number(params.get('rate')); + if (params.has('rate') && Number.isFinite(rate) && rate > 0) media.playbackRate = rate; + + const volume = Number(params.get('volume')); + if (params.has('volume') && Number.isFinite(volume)) media.volume = volume; + + if (params.has('muted')) media.muted = params.get('muted') === '1'; + + const time = Number(params.get('time')); + if (params.has('time') && Number.isFinite(time)) media.currentTime = time; + + if (params.get('paused') === '0') media.play().catch(() => {}); + }; + + media.addEventListener('loadedmetadata', apply, { signal: controller.signal }); + return () => controller.abort(); + }, [media]); +} diff --git a/apps/api-demo/src/layouts/Base.astro b/apps/api-demo/src/layouts/Base.astro new file mode 100644 index 000000000..fa918db17 --- /dev/null +++ b/apps/api-demo/src/layouts/Base.astro @@ -0,0 +1,38 @@ +--- +import '../styles/global.css'; + +interface Props { + title: string; + description?: string; +} + +const { title, description = '' } = Astro.props; +--- + + + + + + + {title} + {description && } + + {/* Faster video / poster loading from Mux. */} + + + + + {/* Fonts: match videojs.org (Instrument Sans + IBM Plex Mono). */} + + + + + + + + diff --git a/apps/api-demo/src/pages/index.astro b/apps/api-demo/src/pages/index.astro new file mode 100644 index 000000000..bc7543160 --- /dev/null +++ b/apps/api-demo/src/pages/index.astro @@ -0,0 +1,43 @@ +--- +import Footer from '../components/Footer.astro'; +import PlayerDemo from '../components/PlayerDemo'; +import Base from '../layouts/Base.astro'; +--- + + +
+ + + Media API + + +

+ The Media API, Live +

+ +

+ An interactive playground for the Video.js 10 media API. Drive the + HlsVideo player straight through its + media instance, follow a live color-coded log of events, actions, and getter + reads, and share your setup via the URL. +

+ +
+ +
+
+