diff --git a/apps/e2e/apps/vite/src/text-track-cues.html b/apps/e2e/apps/vite/src/text-track-cues.html new file mode 100644 index 0000000000..0bc58bbbfc --- /dev/null +++ b/apps/e2e/apps/vite/src/text-track-cues.html @@ -0,0 +1,12 @@ + + + + + + Text track cues + + + + + + diff --git a/apps/e2e/apps/vite/src/text-track-cues.ts b/apps/e2e/apps/vite/src/text-track-cues.ts new file mode 100644 index 0000000000..4256e3b0a3 --- /dev/null +++ b/apps/e2e/apps/vite/src/text-track-cues.ts @@ -0,0 +1,68 @@ +import { createTextTrack, type TextTrackHandle } from '@videojs/html'; + +import { MEDIA } from './resources'; + +interface TextTrackCuesResult { + reason: 'cuechange' | 'timeout'; + mode: string; + cues: number; + activeCues: number; + trackCount: number; +} + +declare global { + interface Window { + textTrackCuesResult: TextTrackCuesResult | undefined; + textTrackCuesHandle: TextTrackHandle; + } +} + +const video = document.querySelector('video'); +if (!video) throw new Error('Video element was not found'); + +const handle = createTextTrack(video, { kind: 'metadata', label: 'e2e' }); +if (!handle) throw new Error('Text track could not be created'); + +window.textTrackCuesHandle = handle; + +// SAFETY: the track was created on a native video element, so it is a real TextTrack that dispatches events. +const track = handle.track as TextTrack; + +{ + const resolve = (result: TextTrackCuesResult) => { + window.textTrackCuesResult = result; + }; + const report = (reason: TextTrackCuesResult['reason']): TextTrackCuesResult => ({ + reason, + mode: track.mode, + cues: track.cues?.length ?? 0, + activeCues: track.activeCues?.length ?? 0, + trackCount: video.textTracks.length, + }); + const timeout = setTimeout(() => resolve(report('timeout')), 20_000); + + track.addEventListener( + 'cuechange', + () => { + clearTimeout(timeout); + resolve(report('cuechange')); + }, + { once: true } + ); + + handle.addCue(new VTTCue(0, 2, 'midroll')); + + video.addEventListener( + 'loadedmetadata', + async () => { + // The handle defers the cue until the src-less track settles; wait for it, then seek into the cue so the + // time-marches-on steps run and activate it on the hidden track. + while ((track.cues?.length ?? 0) === 0) await new Promise((resolve) => setTimeout(resolve, 50)); + + video.currentTime = 1; + }, + { once: true } + ); +} + +video.src = MEDIA.mp4.url; diff --git a/apps/e2e/tests/text-track-cues.spec.ts b/apps/e2e/tests/text-track-cues.spec.ts new file mode 100644 index 0000000000..2beb93f8d5 --- /dev/null +++ b/apps/e2e/tests/text-track-cues.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from '@playwright/test'; + +test('activates cues on a programmatic src-less track and removes it on destroy', async ({ page }) => { + await page.goto('/text-track-cues.html'); + + // Poll a window property rather than awaiting a page promise so a dev-server reload cannot strand the evaluation. + await page.waitForFunction(() => window.textTrackCuesResult !== undefined, undefined, { timeout: 30_000 }); + + const result = await page.evaluate(() => window.textTrackCuesResult); + + expect(result).toEqual({ + reason: 'cuechange', + mode: 'hidden', + cues: 1, + activeCues: 1, + trackCount: 1, + }); + + const afterDestroy = await page.evaluate(() => { + window.textTrackCuesHandle.destroy(); + + const video = document.querySelector('video')!; + + return { + mode: window.textTrackCuesHandle.track.mode, + trackElements: video.querySelectorAll('track').length, + trackCount: video.textTracks.length, + }; + }); + + expect(afterDestroy).toEqual({ mode: 'disabled', trackElements: 0, trackCount: 0 }); +}); diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index 809213e68d..619715e331 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -69,6 +69,7 @@ export { i18nContext } from './i18n/context'; export * from './player/context'; export * from './player/create-player'; export { PlayerController, type PlayerControllerHost } from './player/player-controller'; +export * from './player/text-track-controller'; export * from './store/media-attach-mixin'; export * from './store/types'; export { AirPlayButtonElement } from './ui/airplay-button/airplay-button-element'; diff --git a/packages/html/src/player/tests/text-track-controller.test.ts b/packages/html/src/player/tests/text-track-controller.test.ts new file mode 100644 index 0000000000..956bf9a40f --- /dev/null +++ b/packages/html/src/player/tests/text-track-controller.test.ts @@ -0,0 +1,158 @@ +import { ContextProvider } from '@videojs/element/context'; +import type { Media, TextCueLike, TextTrackLike, TextTrackListLike } from '@videojs/media'; +import { afterEach, describe, expect, it } from 'vite-plus/test'; + +import { UIElement } from '../../ui/ui-element'; +import { mediaContext } from '../context'; +import { TextTrackController } from '../text-track-controller'; + +class FakeTextTrack extends EventTarget implements TextTrackLike { + readonly id = ''; + readonly language = ''; + readonly cues: TextCueLike[] = []; + readonly activeCues: TextCueLike[] = []; + + mode: TextTrackLike['mode'] = 'disabled'; + + constructor( + readonly kind: string, + readonly label = '' + ) { + super(); + } + + addCue(cue: TextCueLike): void { + this.cues.push(cue); + } + + removeCue(cue: TextCueLike): void { + const index = this.cues.indexOf(cue); + + if (index >= 0) this.cues.splice(index, 1); + } +} + +class FakeTextTrackList extends EventTarget implements TextTrackListLike { + readonly [index: number]: TextTrackLike; + readonly #tracks: TextTrackLike[] = []; + + get length(): number { + return this.#tracks.length; + } + + add(track: TextTrackLike): void { + this.#tracks.push(track); + this.dispatchEvent(new Event('addtrack')); + } + + remove(track: TextTrackLike): void { + const index = this.#tracks.indexOf(track); + if (index < 0) return; + + this.#tracks.splice(index, 1); + this.dispatchEvent(new Event('removetrack')); + } + + [Symbol.iterator](): Iterator { + return this.#tracks[Symbol.iterator](); + } +} + +class FakeMedia extends EventTarget implements Media { + readonly textTracks = new FakeTextTrackList(); + + addTextTrack(kind: TextTrackLike['kind'], label?: string): FakeTextTrack { + const track = new FakeTextTrack(kind, label); + + this.textTracks.add(track); + + return track; + } + + removeTextTrack(track: TextTrackLike): void { + this.textTracks.remove(track); + } + + play(): Promise { + return Promise.resolve(); + } +} + +class TestMediaProvider extends UIElement { + readonly #provider = new ContextProvider(this, { + context: mediaContext, + initialValue: { media: null, registerMedia: () => () => {} }, + }); + + setMedia(media: FakeMedia | null): void { + this.#provider.setValue({ media, registerMedia: () => () => {} }); + } +} + +class CreatedTrackConsumer extends UIElement { + readonly track = new TextTrackController(this, { kind: 'metadata', label: 'Ads' }); +} + +class ActiveTrackConsumer extends UIElement { + readonly track = new TextTrackController(this, 'chapters'); +} + +customElements.define('test-text-track-provider', TestMediaProvider); +customElements.define('test-created-text-track-consumer', CreatedTrackConsumer); +customElements.define('test-active-text-track-consumer', ActiveTrackConsumer); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('TextTrackController', () => { + it('owns a created track for the host lifetime', () => { + const media = new FakeMedia(); + const provider = new TestMediaProvider(); + const consumer = new CreatedTrackConsumer(); + + provider.append(consumer); + document.body.append(provider); + provider.setMedia(media); + + expect(consumer.track.value).toMatchObject({ kind: 'metadata', label: 'Ads', mode: 'hidden' }); + expect(media.textTracks.length).toBe(1); + + const cue = { startTime: 0, endTime: 1 }; + + consumer.track.addCue(cue); + + expect(consumer.track.cues).toEqual([cue]); + + consumer.remove(); + + expect(media.textTracks.length).toBe(0); + }); + + it('observes hidden tracks and their active cues', () => { + const media = new FakeMedia(); + const track = media.addTextTrack('chapters'); + const cue = { startTime: 0, endTime: 1 }; + + track.mode = 'hidden'; + + const provider = new TestMediaProvider(); + const consumer = new ActiveTrackConsumer(); + + provider.append(consumer); + document.body.append(provider); + provider.setMedia(media); + + expect(consumer.track.value).toBe(track); + + track.activeCues.push(cue); + track.dispatchEvent(new Event('cuechange')); + + expect(consumer.track.activeCues).toEqual([cue]); + + track.mode = 'disabled'; + media.textTracks.dispatchEvent(new Event('change')); + + expect(consumer.track.value).toBeNull(); + }); +}); diff --git a/packages/html/src/player/text-track-controller.ts b/packages/html/src/player/text-track-controller.ts new file mode 100644 index 0000000000..f4d2daa155 --- /dev/null +++ b/packages/html/src/player/text-track-controller.ts @@ -0,0 +1,191 @@ +import type { ReactiveController, ReactiveControllerHost } from '@videojs/element'; +import { ContextConsumer } from '@videojs/element/context'; +import { isMediaTextTrackCapable, type Media, type TextCueLike, type TextTrackLike } from '@videojs/media'; +import { + addTextTrackCue, + type CreateTextTrackOptions, + createTextTrack, + getTextTrackCues, + removeTextTrackCue, + type TextTrackHandle, + type TextTrackKindFilter, + watchActiveTextTrack, + watchTextTrackCues, +} from '@videojs/media/dom'; +import { noop } from '@videojs/utils/function'; +import { isString } from '@videojs/utils/predicate'; + +import { mediaContext } from './context'; + +export type TextTrackControllerHost = ReactiveControllerHost & HTMLElement; +export type TextTrackControllerSource = CreateTextTrackOptions | TextTrackKindFilter; + +/** + * Create a programmatic text track or observe the active track for a kind, including reactive cue snapshots. + * + * An active native track has any mode other than `disabled`; this includes `hidden` chapter and metadata tracks whose + * cues update without being rendered. + * + * @example + * ```ts + * class MetadataConsumer extends UIElement { + * #track = new TextTrackController(this, { + * kind: 'metadata', + * label: 'ad-cues', + * }); + * + * protected override update() { + * console.log(this.#track.activeCues); + * } + * } + * ```; + */ +export class TextTrackController implements ReactiveController { + readonly #host: TextTrackControllerHost; + readonly #source: TextTrackControllerSource; + readonly #consumer: ContextConsumer; + + #connected = false; + #media: Media | null = null; + #handle: TextTrackHandle | null = null; + #track: TextTrackLike | null = null; + #cues: TextCueLike[] = []; + #activeCues: TextCueLike[] = []; + #stopTrack = noop; + #stopCues = noop; + + /** + * Create a text track owned by this controller. + * + * @param host - The host element that owns this controller. + * @param source - Track metadata and initial mode. + * @label Created Track + */ + constructor(host: TextTrackControllerHost, source: CreateTextTrackOptions); + /** + * Observe the active text track matching one or more kinds. + * + * @param host - The host element that owns this controller. + * @param source - Text track kind or kinds to match. + * @label Active Track + */ + constructor(host: TextTrackControllerHost, source: TextTrackKindFilter); + constructor(host: TextTrackControllerHost, source: TextTrackControllerSource) { + this.#host = host; + this.#source = source; + this.#consumer = new ContextConsumer(host, { + context: mediaContext, + subscribe: true, + callback: (value) => { + this.#media = value.media; + + if (this.#connected) this.#connect(); + }, + }); + + host.addController(this); + } + + /** The created or active text track. */ + get value(): TextTrackLike | null { + return this.#track; + } + + /** A fresh snapshot of every cue on the current track. */ + get cues(): TextCueLike[] { + return this.#cues; + } + + /** A fresh snapshot of the cues active at the current playback position. */ + get activeCues(): TextCueLike[] { + return this.#activeCues; + } + + /** Add a cue to the current track. Every observer of the track, including this controller, refreshes. */ + addCue(cue: TextCueLike): void { + if (this.#handle) this.#handle.addCue(cue); + else if (this.#track) addTextTrackCue(this.#track, cue); + } + + /** Remove a cue from the current track. Every observer of the track, including this controller, refreshes. */ + removeCue(cue: TextCueLike): void { + if (this.#handle) this.#handle.removeCue(cue); + else if (this.#track) removeTextTrackCue(this.#track, cue); + } + + hostConnected(): void { + this.#connected = true; + this.#media = this.#consumer.value?.media ?? null; + this.#connect(); + } + + hostDisconnected(): void { + this.#connected = false; + this.#teardown(); + } + + hostDestroyed(): void { + this.#connected = false; + this.#teardown(); + } + + #connect(): void { + this.#teardown(); + + const media = this.#media; + if (!isMediaTextTrackCapable(media)) return; + + if (isCreateOptions(this.#source)) { + this.#handle = createTextTrack(media, this.#source); + this.#setTrack(this.#handle?.track ?? null); + return; + } + + this.#stopTrack = watchActiveTextTrack(media, this.#source, (track) => this.#setTrack(track)); + } + + #setTrack(track: TextTrackLike | null): void { + this.#stopCues(); + this.#stopCues = noop; + this.#track = track; + + if (track) { + this.#stopCues = watchTextTrackCues(this.#media, track, false, () => { + this.#syncCues(); + }); + } else { + this.#syncCues(); + } + } + + #syncCues(): void { + this.#cues = getTextTrackCues(this.#track); + this.#activeCues = getTextTrackCues(this.#track, true); + this.#host.requestUpdate(); + } + + #teardown(): void { + const hadValue = Boolean(this.#track || this.#cues.length || this.#activeCues.length); + + this.#stopTrack(); + this.#stopTrack = noop; + this.#stopCues(); + this.#stopCues = noop; + this.#handle?.destroy(); + this.#handle = null; + this.#track = null; + this.#cues = []; + this.#activeCues = []; + + if (this.#connected && hadValue) this.#host.requestUpdate(); + } +} + +function isCreateOptions(source: TextTrackControllerSource): source is CreateTextTrackOptions { + return !isString(source) && !Array.isArray(source); +} + +export namespace TextTrackController { + export type Host = TextTrackControllerHost; + export type Source = TextTrackControllerSource; +} diff --git a/packages/media/src/core/types.ts b/packages/media/src/core/types.ts index 83689ddb1e..cfc102dbae 100644 --- a/packages/media/src/core/types.ts +++ b/packages/media/src/core/types.ts @@ -242,7 +242,9 @@ export interface TextTrackLike { readonly src?: string; mode: 'showing' | 'disabled' | 'hidden'; readonly cues: TextCueListLike | null; + readonly activeCues?: TextCueListLike | null; addCue?(cue: TextCueLike): void; + removeCue?(cue: TextCueLike): void; } export interface TextTrackListEvents { @@ -261,6 +263,7 @@ export interface TextTrackListLike extends EventTargetLike export interface MediaTextTrackCapability { readonly textTracks: TextTrackListLike; addTextTrack(kind: TextTrackKind, label?: string, language?: string): TextTrackLike; + removeTextTrack?(track: TextTrackLike): void; } // ---------------------------------------- diff --git a/packages/media/src/dom/index.ts b/packages/media/src/dom/index.ts index fcb073fefc..c5825fdac6 100644 --- a/packages/media/src/dom/index.ts +++ b/packages/media/src/dom/index.ts @@ -1 +1,2 @@ export * from './types'; +export * from './text-track'; diff --git a/packages/media/src/dom/media-host/media-host.ts b/packages/media/src/dom/media-host/media-host.ts index d7a3a98282..466a062c24 100644 --- a/packages/media/src/dom/media-host/media-host.ts +++ b/packages/media/src/dom/media-host/media-host.ts @@ -1,4 +1,4 @@ -import type { EventListenerFor, EventType, QueriedElement } from '@videojs/utils/dom'; +import { findTrackElement, type EventListenerFor, type EventType, type QueriedElement } from '@videojs/utils/dom'; import { EMPTY_REMOTE, EMPTY_TEXT_TRACKS, EMPTY_TIME_RANGES } from '../../core/constants'; import { @@ -10,6 +10,7 @@ import { type TextTrackKind, type TextTrackLike, } from '../../core/types'; +import { createTextTrackElement } from '../text-track'; import { getMediaComponents, getMediaOwner, getMediaProp, setMediaProp } from '../utils'; export { addMediaComponent, getMediaComponents, getMediaOwner, getMediaProp, setMediaProp } from '../utils'; @@ -308,9 +309,38 @@ export class HTMLMediaElementHost { + describe('text tracks', () => { + it('creates removable track elements on native media targets', () => { + const descriptor = Object.getOwnPropertyDescriptor(HTMLTrackElement.prototype, 'track'); + const tracks = new WeakMap(); + + Object.defineProperty(HTMLTrackElement.prototype, 'track', { + configurable: true, + get() { + let track = tracks.get(this); + + if (!track) { + // SAFETY: this native-shaped test double covers the properties used by HTMLMediaElementHost. + track = { + kind: this.kind, + label: this.label, + language: this.srclang, + mode: 'disabled', + } as TextTrack; + tracks.set(this, track); + } + + return track; + }, + }); + + const host = new HTMLAudioElementHost(); + const audio = document.createElement('audio'); + + try { + host.attach(audio); + + const track = host.addTextTrack('metadata', 'Ads', 'en'); + const element = audio.querySelector('track'); + + expect(element).toMatchObject({ kind: 'metadata', label: 'Ads', srclang: 'en' }); + expect(element?.track).toBe(track); + expect(track.mode).toBe('hidden'); + + host.removeTextTrack(track); + + expect(audio.querySelector('track')).toBeNull(); + } finally { + if (descriptor) Object.defineProperty(HTMLTrackElement.prototype, 'track', descriptor); + else Reflect.deleteProperty(HTMLTrackElement.prototype, 'track'); + } + }); + }); + describe('component overrides', () => { it('returns the override value when a component exposes the property', () => { const host = new HTMLAudioElementHost(); diff --git a/packages/media/src/dom/tests/text-track.test.ts b/packages/media/src/dom/tests/text-track.test.ts new file mode 100644 index 0000000000..5a3fe25a5c --- /dev/null +++ b/packages/media/src/dom/tests/text-track.test.ts @@ -0,0 +1,331 @@ +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; + +import type { Media, TextCueLike, TextTrackLike, TextTrackListLike } from '../../core/types'; +import { + addTextTrackCue, + createTextTrack, + getActiveTextTrack, + getTextTrackCues, + removeTextTrackCue, + watchActiveTextTrack, + watchTextTrackCues, +} from '../text-track'; + +class FakeTextTrack extends EventTarget implements TextTrackLike { + readonly id = ''; + readonly language = ''; + readonly cues: TextCueLike[] = []; + readonly activeCues: TextCueLike[] = []; + + mode: TextTrackLike['mode'] = 'disabled'; + + constructor( + readonly kind: string, + readonly label = '' + ) { + super(); + } + + addCue(cue: TextCueLike): void { + this.cues.push(cue); + } + + removeCue(cue: TextCueLike): void { + const index = this.cues.indexOf(cue); + + if (index >= 0) this.cues.splice(index, 1); + } +} + +class FakeTextTrackList extends EventTarget implements TextTrackListLike { + readonly [index: number]: TextTrackLike; + readonly #tracks: TextTrackLike[] = []; + + get length(): number { + return this.#tracks.length; + } + + add(track: TextTrackLike): void { + this.#tracks.push(track); + this.#syncIndexes(); + this.dispatchEvent(new Event('addtrack')); + } + + remove(track: TextTrackLike): void { + const index = this.#tracks.indexOf(track); + if (index < 0) return; + + this.#tracks.splice(index, 1); + this.#syncIndexes(); + this.dispatchEvent(new Event('removetrack')); + } + + [Symbol.iterator](): Iterator { + return this.#tracks[Symbol.iterator](); + } + + #syncIndexes(): void { + for (const key of Object.keys(this)) { + if (/^\d+$/.test(key)) Reflect.deleteProperty(this, key); + } + + for (const [index, track] of this.#tracks.entries()) { + Object.defineProperty(this, index, { configurable: true, value: track }); + } + } +} + +class FakeMedia extends EventTarget implements Media { + readonly textTracks = new FakeTextTrackList(); + + addTextTrack(kind: TextTrackLike['kind'], label?: string): FakeTextTrack { + const track = new FakeTextTrack(kind, label); + + this.textTracks.add(track); + + return track; + } + + removeTextTrack(track: TextTrackLike): void { + this.textTracks.remove(track); + } + + play(): Promise { + return Promise.resolve(); + } +} + +describe('createTextTrack', () => { + const trackDescriptor = Object.getOwnPropertyDescriptor(HTMLTrackElement.prototype, 'track'); + const readyStateDescriptor = Object.getOwnPropertyDescriptor(HTMLTrackElement.prototype, 'readyState'); + const readyStates = new WeakMap(); + const tracks = new WeakMap(); + + function mockTrackElements() { + Object.defineProperty(HTMLTrackElement.prototype, 'track', { + configurable: true, + get() { + let track = tracks.get(this); + + if (!track) { + track = new FakeTextTrack(this.kind, this.label); + tracks.set(this, track); + } + + return track; + }, + }); + Object.defineProperty(HTMLTrackElement.prototype, 'readyState', { + configurable: true, + get() { + return readyStates.get(this) ?? 0; + }, + }); + } + + function settle(element: HTMLTrackElement, type: 'load' | 'error') { + readyStates.set(element, type === 'load' ? 2 : 3); + element.dispatchEvent(new Event(type)); + } + + afterEach(() => { + for (const [name, descriptor] of [ + ['track', trackDescriptor], + ['readyState', readyStateDescriptor], + ] as const) { + if (descriptor) Object.defineProperty(HTMLTrackElement.prototype, name, descriptor); + else Reflect.deleteProperty(HTMLTrackElement.prototype, name); + } + }); + + it('creates a hidden track and removes it when destroyed', () => { + const media = new FakeMedia(); + const handle = createTextTrack(media, { kind: 'metadata', label: 'Ads' }); + + expect(handle?.track).toMatchObject({ kind: 'metadata', label: 'Ads', mode: 'hidden' }); + expect(media.textTracks.length).toBe(1); + + handle?.destroy(); + handle?.destroy(); + + expect(handle?.track.mode).toBe('disabled'); + expect(media.textTracks.length).toBe(0); + }); + + it('notifies cue observers when cues are written through the handle', () => { + const media = new FakeMedia(); + const handle = createTextTrack(media, { kind: 'metadata' })!; + const cue = { startTime: 0, endTime: 1 }; + const onChange = vi.fn(); + + watchTextTrackCues(media, handle.track, false, onChange); + handle.addCue(cue); + + expect(onChange).toHaveBeenLastCalledWith([cue]); + + handle.removeCue(cue); + + expect(onChange).toHaveBeenLastCalledWith([]); + }); + + it('queues cue writes until the backing element has loaded', () => { + mockTrackElements(); + + const video = document.createElement('video'); + const handle = createTextTrack(video, { kind: 'metadata', label: 'Ads' })!; + const element = video.querySelector('track')!; + const kept = { startTime: 0, endTime: 1 }; + const dropped = { startTime: 1, endTime: 2 }; + const onChange = vi.fn(); + + watchTextTrackCues(video, handle.track, false, onChange); + + expect(handle.track.mode).toBe('hidden'); + expect(element).toMatchObject({ kind: 'metadata', label: 'Ads' }); + + handle.addCue(kept); + handle.addCue(dropped); + handle.removeCue(dropped); + + expect(handle.track.cues).toEqual([]); + + settle(element, 'error'); + + expect(handle.track.cues).toEqual([kept]); + expect(onChange).toHaveBeenLastCalledWith([kept]); + + handle.destroy(); + + expect(video.querySelector('track')).toBeNull(); + expect(handle.track.mode).toBe('disabled'); + }); + + it('applies a requested disabled mode after the element load settles', () => { + mockTrackElements(); + + const video = document.createElement('video'); + const handle = createTextTrack(video, { kind: 'chapters', mode: 'disabled' })!; + const element = video.querySelector('track')!; + + expect(handle.track.mode).toBe('hidden'); + + settle(element, 'load'); + + expect(handle.track.mode).toBe('disabled'); + }); + + it('writes cues immediately when no backing element exists', () => { + const media = new FakeMedia(); + const handle = createTextTrack(media, { kind: 'metadata', mode: 'showing' })!; + const cue = { startTime: 0, endTime: 1 }; + + handle.addCue(cue); + + expect(handle.track.mode).toBe('showing'); + expect(handle.track.cues).toEqual([cue]); + }); +}); + +describe('addTextTrackCue', () => { + it('adds the cue and notifies observers of the same track only', () => { + const track = new FakeTextTrack('metadata'); + const other = new FakeTextTrack('metadata'); + const cue = { startTime: 0, endTime: 1 }; + const onChange = vi.fn(); + const onOtherChange = vi.fn(); + + const stop = watchTextTrackCues(null, track, false, onChange); + + watchTextTrackCues(null, other, false, onOtherChange); + addTextTrackCue(track, cue); + + expect(track.cues).toEqual([cue]); + expect(onChange).toHaveBeenLastCalledWith([cue]); + expect(onOtherChange).toHaveBeenCalledTimes(1); + + stop(); + removeTextTrackCue(track, cue); + + expect(track.cues).toEqual([]); + expect(onChange).toHaveBeenCalledTimes(2); + }); +}); + +describe('getActiveTextTrack', () => { + it('prefers showing tracks, falls back to hidden tracks, and ignores disabled tracks', () => { + const media = new FakeMedia(); + const captions = media.addTextTrack('captions'); + const chapters = media.addTextTrack('chapters'); + const showingChapters = media.addTextTrack('chapters'); + + captions.mode = 'disabled'; + chapters.mode = 'hidden'; + showingChapters.mode = 'showing'; + + expect(getActiveTextTrack(media, ['captions', 'chapters'])).toBe(showingChapters); + + showingChapters.mode = 'disabled'; + + expect(getActiveTextTrack(media, ['captions', 'chapters'])).toBe(chapters); + }); +}); + +describe('watchActiveTextTrack', () => { + it('notifies when the active track changes and stops after cleanup', () => { + const media = new FakeMedia(); + const captions = media.addTextTrack('captions'); + const onChange = vi.fn(); + const stop = watchActiveTextTrack(media, 'captions', onChange); + + expect(onChange).toHaveBeenLastCalledWith(null); + + captions.mode = 'showing'; + media.textTracks.dispatchEvent(new Event('change')); + + expect(onChange).toHaveBeenLastCalledWith(captions); + + stop(); + captions.mode = 'disabled'; + media.textTracks.dispatchEvent(new Event('change')); + + expect(onChange).toHaveBeenCalledTimes(2); + }); +}); + +describe('getTextTrackCues', () => { + it('returns snapshots of all and active cues', () => { + const track = new FakeTextTrack('metadata'); + const first = { startTime: 0, endTime: 1 }; + const second = { startTime: 1, endTime: 2 }; + + track.cues.push(first, second); + track.activeCues.push(second); + + expect(getTextTrackCues(track)).toEqual([first, second]); + expect(getTextTrackCues(track, true)).toEqual([second]); + }); +}); + +describe('watchTextTrackCues', () => { + it('notifies with fresh cue snapshots on cue changes', () => { + const media = new FakeMedia(); + const track = new FakeTextTrack('metadata'); + const cue = { startTime: 0, endTime: 1 }; + const onChange = vi.fn(); + + const stop = watchTextTrackCues(media, track, false, onChange); + + expect(onChange).toHaveBeenLastCalledWith([]); + + track.cues.push(cue); + track.dispatchEvent(new Event('cuechange')); + + expect(onChange).toHaveBeenLastCalledWith([cue]); + + stop(); + track.cues.length = 0; + track.dispatchEvent(new Event('cuechange')); + + expect(onChange).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/media/src/dom/text-track.ts b/packages/media/src/dom/text-track.ts new file mode 100644 index 0000000000..40181c50b7 --- /dev/null +++ b/packages/media/src/dom/text-track.ts @@ -0,0 +1,295 @@ +import { findTrackElement, listen } from '@videojs/utils/dom'; +import { noop } from '@videojs/utils/function'; + +import type { Media, MediaTextTrackCapability, TextCueLike, TextTrackKind, TextTrackLike } from '../core/types'; + +export type TextTrackKindFilter = TextTrackKind | readonly TextTrackKind[]; + +export interface CreateTextTrackOptions { + /** The kind of timed text represented by the track. */ + kind: TextTrackKind; + /** Human-readable track label. */ + label?: string | undefined; + /** BCP 47 language tag. */ + language?: string | undefined; + /** Initial track mode. Defaults to `hidden`, matching `HTMLMediaElement.addTextTrack()`. */ + mode?: TextTrackLike['mode'] | undefined; +} + +export interface TextTrackHandle { + readonly track: TextTrackLike; + /** Add a cue and notify cue observers. Waits for the backing `` to finish loading when there is one. */ + addCue(cue: TextCueLike): void; + /** Remove a cue and notify cue observers. Drops the cue if it is still waiting to be added. */ + removeCue(cue: TextCueLike): void; + /** Disable the track and remove it from the media. Safe to call more than once. */ + destroy(): void; +} + +const cueListeners = new WeakMap void>>(); + +/** + * Create a removable native text track on a media element. + * + * Element-backed tracks start loading as soon as they leave `disabled` mode, and browsers discard every cue added + * before that load settles. The returned handle queues `addCue()` and `removeCue()` until the backing `` reports + * `load` or `error`, so callers can write cues immediately. + * + * @param media - Media element that will own the track. + * @param options - Track metadata and initial mode. + */ +export function createTextTrack( + media: MediaTextTrackCapability, + options: CreateTextTrackOptions +): TextTrackHandle | null { + const element = isNativeMediaElement(media) + ? createTextTrackElement(media, options.kind, options.label, options.language) + : null; + const track = element ? element.track : media.addTextTrack(options.kind, options.label, options.language); + + if (!track) { + element?.remove(); + return null; + } + + // Wrapped media create the backing element for us; find it so cue writes wait for its load as well. + const backing = element ?? (media instanceof EventTarget ? findTrackElement(media, track) : null); + const mode = options.mode ?? 'hidden'; + const writer = createDeferredCueWriter(track, backing); + + // Leaving `disabled` starts the element load. Apply a requested `disabled` mode once that load has settled so the + // track does not stay unloaded and wipe cues on a later mode change. + track.mode = 'hidden'; + + if (mode !== 'disabled') track.mode = mode; + else writer.whenSettled(() => (track.mode = 'disabled')); + + let destroyed = false; + + return { + track, + addCue: writer.addCue, + removeCue: writer.removeCue, + destroy() { + if (destroyed) return; + + destroyed = true; + writer.dispose(); + track.mode = 'disabled'; + + if (element) element.remove(); + else media.removeTextTrack?.(track); + }, + }; +} + +interface DeferredCueWriter { + addCue(cue: TextCueLike): void; + removeCue(cue: TextCueLike): void; + whenSettled(callback: () => void): void; + dispose(): void; +} + +const TRACK_READY_STATE_LOADED = 2; + +function createDeferredCueWriter(track: TextTrackLike, element: HTMLTrackElement | null): DeferredCueWriter { + const pending: TextCueLike[] = []; + const callbacks: (() => void)[] = []; + let settled = !element || element.readyState >= TRACK_READY_STATE_LOADED; + let stop = noop; + + const settle = () => { + stop(); + stop = noop; + settled = true; + + for (const cue of pending.splice(0)) addTextTrackCue(track, cue); + + for (const callback of callbacks.splice(0)) callback(); + }; + + if (!settled && element) { + const stopLoad = listen(element, 'load', settle); + const stopError = listen(element, 'error', settle); + + stop = () => { + stopLoad(); + stopError(); + }; + } + + return { + addCue(cue) { + if (settled) addTextTrackCue(track, cue); + else pending.push(cue); + }, + removeCue(cue) { + const index = pending.indexOf(cue); + + if (index >= 0) pending.splice(index, 1); + else removeTextTrackCue(track, cue); + }, + whenSettled(callback) { + if (settled) callback(); + else callbacks.push(callback); + }, + dispose() { + stop(); + stop = noop; + pending.length = 0; + callbacks.length = 0; + }, + }; +} + +/** Create the `` backing a programmatically managed native text track. */ +export function createTextTrackElement( + media: HTMLMediaElement, + kind: TextTrackKind, + label?: string, + language?: string +): HTMLTrackElement { + const element = media.ownerDocument.createElement('track'); + + element.kind = kind; + element.label = label ?? ''; + element.srclang = language ?? ''; + media.append(element); + + return element; +} + +/** + * Add a cue to a text track and notify `watchTextTrackCues` observers. + * + * Native `TextTrack.addCue()` fires no event, so observers only learn about programmatic cue changes made through this + * helper or a `TextTrackHandle`. + */ +export function addTextTrackCue(track: TextTrackLike, cue: TextCueLike): void { + track.addCue?.(cue); + notifyCueListeners(track); +} + +/** Remove a cue from a text track and notify `watchTextTrackCues` observers. */ +export function removeTextTrackCue(track: TextTrackLike, cue: TextCueLike): void { + track.removeCue?.(cue); + notifyCueListeners(track); +} + +/** Return the first enabled text track matching the requested kind. */ +export function getActiveTextTrack(media: MediaTextTrackCapability, kind: TextTrackKindFilter): TextTrackLike | null { + const kinds = Array.isArray(kind) ? kind : [kind]; + const matches = Array.from(media.textTracks).filter((track) => kinds.some((kind) => kind === track.kind)); + + return matches.find((track) => track.mode === 'showing') ?? matches.find((track) => track.mode === 'hidden') ?? null; +} + +/** + * Observe the enabled text track matching the requested kind. + * + * Native `hidden` tracks are active: their cues load and update without being rendered. This is the normal mode for + * chapters and metadata tracks. + */ +export function watchActiveTextTrack( + media: MediaTextTrackCapability, + kind: TextTrackKindFilter, + onChange: (track: TextTrackLike | null) => void +): () => void { + let current: TextTrackLike | null | undefined; + + const sync = () => { + const next = getActiveTextTrack(media, kind); + if (next === current) return; + + current = next; + onChange(next); + }; + + sync(); + media.textTracks.addEventListener('addtrack', sync); + media.textTracks.addEventListener('removetrack', sync); + media.textTracks.addEventListener('change', sync); + + return () => { + media.textTracks.removeEventListener('addtrack', sync); + media.textTracks.removeEventListener('removetrack', sync); + media.textTracks.removeEventListener('change', sync); + }; +} + +/** Read either all cues or the currently active cues from a text track. */ +export function getTextTrackCues(track: TextTrackLike | null, active = false): TextCueLike[] { + const cues = active ? track?.activeCues : track?.cues; + + return cues ? Array.from(cues) : []; +} + +/** + * Observe cue snapshots for a text track. + * + * Snapshots refresh on native `cuechange`, when the backing `` loads, when the media starts loading a new + * source, and after cues are added or removed through `addTextTrackCue()`, `removeTextTrackCue()`, or a + * `TextTrackHandle`. + * + * @param media - Media element that owns the track, used to observe source and `` load events. + * @param track - Text track to observe. + * @param active - Whether to observe active cues instead of the complete cue list. + * @param onChange - Receives a fresh cue array whenever the observable state changes. + */ +export function watchTextTrackCues( + media: Media | null, + track: TextTrackLike, + active: boolean, + onChange: (cues: TextCueLike[]) => void +): () => void { + const cleanups: (() => void)[] = []; + const sync = () => onChange(getTextTrackCues(track, active)); + + cleanups.push(listenCueChanges(track, sync)); + + if (track instanceof EventTarget) cleanups.push(listen(track, 'cuechange', sync)); + + if (media instanceof EventTarget) { + const trackElement = findTrackElement(media, track); + + if (trackElement) cleanups.push(listen(trackElement, 'load', sync)); + + cleanups.push(listen(media, 'loadstart', sync)); + } + + sync(); + + return () => { + for (const cleanup of cleanups) cleanup(); + }; +} + +function listenCueChanges(track: TextTrackLike, listener: () => void): () => void { + let listeners = cueListeners.get(track); + + if (!listeners) { + listeners = new Set(); + cueListeners.set(track, listeners); + } + + listeners.add(listener); + + return () => { + listeners.delete(listener); + + if (listeners.size === 0) cueListeners.delete(track); + }; +} + +function notifyCueListeners(track: TextTrackLike): void { + const listeners = cueListeners.get(track); + if (!listeners) return; + + for (const listener of [...listeners]) listener(); +} + +function isNativeMediaElement(media: MediaTextTrackCapability): media is MediaTextTrackCapability & HTMLMediaElement { + const MediaElement = globalThis.HTMLMediaElement; + + return Boolean(MediaElement && media instanceof MediaElement); +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 564fb7a499..e1d7c6ba7a 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -68,6 +68,14 @@ export { usePlayer, usePlayerContext, } from './player/context'; +export { + type CreatedTextTrack, + type UseCreateTextTrackOptions, + useActiveTextCues, + useActiveTextTrack, + useCreateTextTrack, + useTextCues, +} from './player/text-track'; // Player API export { type CreatePlayerConfig, diff --git a/packages/react/src/player/tests/public-exports.test.ts b/packages/react/src/player/tests/public-exports.test.ts index 8595d09176..7a89b0553a 100644 --- a/packages/react/src/player/tests/public-exports.test.ts +++ b/packages/react/src/player/tests/public-exports.test.ts @@ -13,4 +13,11 @@ describe('@videojs/react player exports', () => { expect(ReactApi).toHaveProperty('usePlayerContext'); expect(ReactApi).toHaveProperty('useOptionalContainer'); }); + + it('exports the text track hooks', () => { + expect(ReactApi).toHaveProperty('useCreateTextTrack'); + expect(ReactApi).toHaveProperty('useActiveTextTrack'); + expect(ReactApi).toHaveProperty('useTextCues'); + expect(ReactApi).toHaveProperty('useActiveTextCues'); + }); }); diff --git a/packages/react/src/player/tests/text-track.test.tsx b/packages/react/src/player/tests/text-track.test.tsx new file mode 100644 index 0000000000..062a547a3a --- /dev/null +++ b/packages/react/src/player/tests/text-track.test.tsx @@ -0,0 +1,223 @@ +import { act, cleanup, renderHook } from '@testing-library/react'; +import type { Media, TextCueLike, TextTrackKind, TextTrackLike, TextTrackListLike } from '@videojs/media'; +import type { ReactNode } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; + +import { createMockStore } from '../../testing/mocks'; +import { PlayerContextProvider, type PlayerContextValue } from '../context'; +import { useActiveTextCues, useActiveTextTrack, useCreateTextTrack, useTextCues } from '../text-track'; + +class FakeTextTrack extends EventTarget implements TextTrackLike { + readonly id = ''; + readonly language = ''; + readonly cues: TextCueLike[] = []; + readonly activeCues: TextCueLike[] = []; + + mode: TextTrackLike['mode'] = 'disabled'; + + constructor( + readonly kind: string, + readonly label = '' + ) { + super(); + } + + addCue(cue: TextCueLike): void { + this.cues.push(cue); + } + + removeCue(cue: TextCueLike): void { + const index = this.cues.indexOf(cue); + + if (index >= 0) this.cues.splice(index, 1); + } +} + +class FakeTextTrackList extends EventTarget implements TextTrackListLike { + readonly [index: number]: TextTrackLike; + readonly #tracks: TextTrackLike[] = []; + + get length(): number { + return this.#tracks.length; + } + + add(track: TextTrackLike): void { + this.#tracks.push(track); + this.dispatchEvent(new Event('addtrack')); + } + + remove(track: TextTrackLike): void { + const index = this.#tracks.indexOf(track); + if (index < 0) return; + + this.#tracks.splice(index, 1); + this.dispatchEvent(new Event('removetrack')); + } + + [Symbol.iterator](): Iterator { + return this.#tracks[Symbol.iterator](); + } +} + +class FakeMedia extends EventTarget implements Media { + readonly textTracks = new FakeTextTrackList(); + + addTextTrack(kind: TextTrackLike['kind'], label?: string): FakeTextTrack { + const track = new FakeTextTrack(kind, label); + + this.textTracks.add(track); + + return track; + } + + removeTextTrack(track: TextTrackLike): void { + this.textTracks.remove(track); + } + + play(): Promise { + return Promise.resolve(); + } +} + +function createWrapper(media: FakeMedia) { + const store: unknown = createMockStore(); + + const value: PlayerContextValue = { + // SAFETY: the mock implements the player store behavior used by PlayerContextProvider. + store: store as PlayerContextValue['store'], + media, + setMedia: vi.fn(), + container: null, + setContainer: vi.fn(), + }; + + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +afterEach(cleanup); + +describe('useCreateTextTrack', () => { + it('creates a track for the component lifetime', () => { + const media = new FakeMedia(); + const { result, unmount } = renderHook(() => useCreateTextTrack({ kind: 'metadata', label: 'Ads' }), { + wrapper: createWrapper(media), + }); + + expect(result.current?.track).toMatchObject({ kind: 'metadata', label: 'Ads', mode: 'hidden' }); + expect(media.textTracks.length).toBe(1); + + const track = result.current?.track; + + unmount(); + + expect(track?.mode).toBe('disabled'); + expect(media.textTracks.length).toBe(0); + }); + + it('replaces the owned track when its options change', () => { + const media = new FakeMedia(); + const { result, rerender } = renderHook(({ kind }: { kind: TextTrackKind }) => useCreateTextTrack({ kind }), { + initialProps: { kind: 'metadata' }, + wrapper: createWrapper(media), + }); + const first = result.current?.track; + + rerender({ kind: 'chapters' }); + + expect(first?.mode).toBe('disabled'); + expect(result.current?.track).toMatchObject({ kind: 'chapters', mode: 'hidden' }); + expect(media.textTracks.length).toBe(1); + }); + + it('refreshes cue observers when cues are written through the handle', () => { + const media = new FakeMedia(); + const cue = { startTime: 0, endTime: 1 }; + const { result } = renderHook( + () => { + const created = useCreateTextTrack({ kind: 'metadata' }); + const cues = useTextCues(created?.track ?? null); + + return { created, cues }; + }, + { wrapper: createWrapper(media) } + ); + + expect(result.current.cues).toEqual([]); + + act(() => result.current.created?.addCue(cue)); + + expect(result.current.cues).toEqual([cue]); + + act(() => result.current.created?.removeCue(cue)); + + expect(result.current.cues).toEqual([]); + }); +}); + +describe('useActiveTextTrack', () => { + it('observes hidden tracks and reacts to mode changes', () => { + const media = new FakeMedia(); + const track = media.addTextTrack('chapters'); + + track.mode = 'hidden'; + + const { result, rerender } = renderHook(() => useActiveTextTrack(['captions', 'chapters']), { + wrapper: createWrapper(media), + }); + + expect(result.current).toBe(track); + + rerender(); + + expect(result.current).toBe(track); + + act(() => { + track.mode = 'disabled'; + media.textTracks.dispatchEvent(new Event('change')); + }); + + expect(result.current).toBeNull(); + }); + + it('keeps one subscription across renders with an inline kind array', () => { + const media = new FakeMedia(); + const addEventListener = vi.spyOn(media.textTracks, 'addEventListener'); + const { rerender } = renderHook(() => useActiveTextTrack(['captions', 'subtitles']), { + wrapper: createWrapper(media), + }); + const initialCalls = addEventListener.mock.calls.length; + + rerender(); + rerender(); + + expect(addEventListener.mock.calls.length).toBe(initialCalls); + }); +}); + +describe('useTextCues', () => { + it('observes all and active cue snapshots', () => { + const media = new FakeMedia(); + const track = media.addTextTrack('metadata'); + const first = { startTime: 0, endTime: 1 }; + const second = { startTime: 1, endTime: 2 }; + + track.cues.push(first); + track.activeCues.push(first); + + const { result } = renderHook(() => ({ all: useTextCues(track), active: useActiveTextCues(track) }), { + wrapper: createWrapper(media), + }); + + expect(result.current).toEqual({ all: [first], active: [first] }); + + act(() => { + track.cues.push(second); + track.activeCues.splice(0, 1, second); + track.dispatchEvent(new Event('cuechange')); + }); + + expect(result.current).toEqual({ all: [first, second], active: [second] }); + }); +}); diff --git a/packages/react/src/player/text-track.ts b/packages/react/src/player/text-track.ts new file mode 100644 index 0000000000..fcca96fce1 --- /dev/null +++ b/packages/react/src/player/text-track.ts @@ -0,0 +1,162 @@ +import { + isMediaTextTrackCapable, + type Media, + type TextCueLike, + type TextTrackKind, + type TextTrackLike, +} from '@videojs/media'; +import { + type CreateTextTrackOptions, + createTextTrack, + getActiveTextTrack, + getTextTrackCues, + type TextTrackHandle, + type TextTrackKindFilter, + watchActiveTextTrack, + watchTextTrackCues, +} from '@videojs/media/dom'; +import { noop } from '@videojs/utils/function'; +import { isString } from '@videojs/utils/predicate'; +import { useCallback, useMemo, useSyncExternalStore } from 'react'; + +import { useMedia } from './context'; + +export type UseCreateTextTrackOptions = CreateTextTrackOptions; + +/** + * A text track owned by a component, with cue writers that keep `useTextCues` and `useActiveTextCues` observers in + * sync. + */ +export type CreatedTextTrack = Omit; + +/** + * Create a programmatic native text track owned by the calling component. + * + * The track follows the current player media and is removed when the component unmounts or the options change. Add and + * remove cues through the returned `addCue` and `removeCue` so cue observers refresh; native `TextTrack.addCue()` fires + * no event. + * + * @param options - Track metadata and initial mode. + */ +export function useCreateTextTrack(options: UseCreateTextTrackOptions): CreatedTextTrack | null { + const media = useMedia(); + const { kind, label, language, mode } = options; + const store = useMemo( + () => createOwnedTextTrackStore(media, { kind, label, language, mode }), + [media, kind, label, language, mode] + ); + + return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); +} + +/** + * Observe the active text track matching one or more kinds. + * + * An active native track has any mode other than `disabled`; this includes `hidden` chapter and metadata tracks whose + * cues update without being rendered. + * + * @param kind - Text track kind or kinds to match. + */ +export function useActiveTextTrack(kind: TextTrackKindFilter): TextTrackLike | null { + const media = useMedia(); + const key = isString(kind) ? kind : kind.join(','); + const kinds = useMemo(() => key.split(',') as TextTrackKind[], [key]); + const subscribe = useCallback( + (onChange: () => void) => { + if (!isMediaTextTrackCapable(media)) return noop; + + return watchActiveTextTrack(media, kinds, onChange); + }, + [media, kinds] + ); + const getSnapshot = useCallback( + () => (isMediaTextTrackCapable(media) ? getActiveTextTrack(media, kinds) : null), + [media, kinds] + ); + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +/** + * Observe all cues on a text track. + * + * The returned array is replaced when the native track emits a cue change, its `` element loads, its media + * source starts loading, or cues are added or removed through a `CreatedTextTrack` or `addTextTrackCue()`. + * + * @param track - Text track to observe, or `null` when unavailable. + */ +export function useTextCues(track: TextTrackLike | null): TextCueLike[] { + return useCueSnapshot(track, false); +} + +/** + * Observe cues active at the current playback position. + * + * @param track - Text track to observe, or `null` when unavailable. + */ +export function useActiveTextCues(track: TextTrackLike | null): TextCueLike[] { + return useCueSnapshot(track, true); +} + +function useCueSnapshot(track: TextTrackLike | null, active: boolean): TextCueLike[] { + const media = useMedia(); + const store = useMemo(() => createTextCueStore(media, track, active), [media, track, active]); + + return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); +} + +interface TextCueStore { + getSnapshot(): TextCueLike[]; + subscribe(onChange: () => void): () => void; +} + +interface OwnedTextTrackStore { + getSnapshot(): CreatedTextTrack | null; + subscribe(onChange: () => void): () => void; +} + +function createOwnedTextTrackStore(media: Media | null, options: CreateTextTrackOptions): OwnedTextTrackStore { + let handle: TextTrackHandle | null = null; + let snapshot: CreatedTextTrack | null = null; + const subscribers = new Set<() => void>(); + + return { + getSnapshot: () => snapshot, + subscribe(onChange) { + subscribers.add(onChange); + + if (subscribers.size === 1 && isMediaTextTrackCapable(media)) { + handle = createTextTrack(media, options); + snapshot = handle ? { track: handle.track, addCue: handle.addCue, removeCue: handle.removeCue } : null; + + for (const subscriber of subscribers) subscriber(); + } + + return () => { + subscribers.delete(onChange); + + if (subscribers.size > 0) return; + + handle?.destroy(); + handle = null; + snapshot = null; + }; + }, + }; +} + +function createTextCueStore(media: Media | null, track: TextTrackLike | null, active: boolean): TextCueStore { + let cues = getTextTrackCues(track, active); + + return { + getSnapshot: () => cues, + subscribe(onChange) { + if (!track) return noop; + + return watchTextTrackCues(media, track, active, (next) => { + cues = next; + onChange(); + }); + }, + }; +} diff --git a/packages/utils/src/dom/tests/text-track.test.ts b/packages/utils/src/dom/tests/text-track.test.ts index d240889bd9..fd28757602 100644 --- a/packages/utils/src/dom/tests/text-track.test.ts +++ b/packages/utils/src/dom/tests/text-track.test.ts @@ -78,4 +78,15 @@ describe('findTrackElement', () => { expect(findTrackElement(video, captionsTrack)).toBe(captionsEl); expect(findTrackElement(video, chaptersTrack)).toBe(chaptersEl); }); + + it('finds a track element forwarded through a shadow root', () => { + const host = document.createElement('div'); + const root = host.attachShadow({ mode: 'open' }); + const el = document.createElement('track'); + const track = mockTrackProperty(el); + + root.append(el); + + expect(findTrackElement(host, track)).toBe(el); + }); }); diff --git a/packages/utils/src/dom/text-track.ts b/packages/utils/src/dom/text-track.ts index c15c3fd4f9..d68b6aa9f6 100644 --- a/packages/utils/src/dom/text-track.ts +++ b/packages/utils/src/dom/text-track.ts @@ -1,15 +1,25 @@ export type CaptionOrSubtitleKind = 'captions' | 'subtitles'; +export interface TextTrackIdentity { + readonly kind: string; +} + /** Whether a text track is a captions or subtitles track. */ export function isCaptionOrSubtitleTrack(track: { kind: string }): track is { kind: CaptionOrSubtitleKind } { return track.kind === 'captions' || track.kind === 'subtitles'; } /** Find the `` element that owns the given `TextTrack`. */ -export function findTrackElement(media: EventTarget, track: unknown): HTMLTrackElement | null { - if (!(media instanceof HTMLElement)) return null; +export function findTrackElement(media: EventTarget, track: TextTrackIdentity): HTMLTrackElement | null { + // SAFETY: DOM media wrappers may expose the ParentNode query API without inheriting from HTMLElement. + const root = media as EventTarget & { + querySelectorAll?: (selectors: string) => Iterable; + shadowRoot?: ShadowRoot | null; + }; + + const elements = [...(root.querySelectorAll?.('track') ?? []), ...(root.shadowRoot?.querySelectorAll('track') ?? [])]; - for (const el of media.querySelectorAll('track')) { + for (const el of elements) { if (el.track === track) return el; } diff --git a/site/src/content/docs/reference/feature-text-tracks.mdx b/site/src/content/docs/reference/feature-text-tracks.mdx index 9798a301ab..44d5e4084e 100644 --- a/site/src/content/docs/reference/feature-text-tracks.mdx +++ b/site/src/content/docs/reference/feature-text-tracks.mdx @@ -23,11 +23,11 @@ When a thumbnail track is present, `thumbnailTrackCrossOrigin` reports the media ### Selector -Pass `selectTextTrack` to `usePlayer` to subscribe to text track state. Returns `undefined` if the text tracks feature is not configured. +Pass `selectTextTrack` to `usePlayer` to subscribe to text track state. Returns `undefined` if the text tracks feature is not configured. For direct access to a native track and its cues, use `useActiveTextTrack`. Create a programmatic track with `useCreateTextTrack`. -Pass `selectTextTrack` to `PlayerController` to subscribe to text track state. Returns `undefined` if the text tracks feature is not configured. +Pass `selectTextTrack` to `PlayerController` to subscribe to text track state. Returns `undefined` if the text tracks feature is not configured. Use `TextTrackController` to create or observe one track and its cues. diff --git a/site/src/content/docs/reference/text-track-controller.mdx b/site/src/content/docs/reference/text-track-controller.mdx new file mode 100644 index 0000000000..373ebb43ea --- /dev/null +++ b/site/src/content/docs/reference/text-track-controller.mdx @@ -0,0 +1,61 @@ +--- +title: TextTrackController +description: Reactive controller to create or observe a text track and its cues +--- + +import UtilReference from "@/components/docs/api-reference/UtilReference.astro"; + +`TextTrackController` gives an HTML custom element reactive access to a text track and its cue snapshots. Create an owned track by passing track options, or observe an existing enabled track by passing one or more kinds. + +The controller consumes the media attached to the nearest player. It reconnects when that media changes and requests a host update when its track or cues change. + +## Import + +```ts +import { TextTrackController } from '@videojs/html'; +``` + +## Usage + +### Create a track + +Pass an options object to create a programmatic native track. The controller defaults it to `hidden` mode and removes it when the host disconnects or switches media. + +```ts title="ad-cue-source.ts" +import { TextTrackController, UIElement } from '@videojs/html'; + +class AdCueSourceElement extends UIElement { + readonly #track = new TextTrackController(this, { + kind: 'metadata', + label: 'ad-cues', + }); + + addAdCue(start: number, end: number, id: string) { + this.#track.addCue(new VTTCue(start, end, id)); + } + + protected override update() { + this.dataset.activeAd = this.#track.activeCues[0]?.text ?? ''; + } +} +``` + +### Observe a track + +Pass a kind or array of kinds to follow an existing enabled track. A `showing` track takes precedence over a `hidden` track. + +```ts title="chapter-title.ts" +import { TextTrackController, UIElement } from '@videojs/html'; + +class ChapterTitleElement extends UIElement { + readonly #track = new TextTrackController(this, 'chapters'); + + protected override update() { + this.textContent = this.#track.activeCues[0]?.text ?? ''; + } +} +``` + +Read `.value` for the current track, `.cues` for every cue, and `.activeCues` for cues at the current playback position. Call `.addCue()` and `.removeCue()` to mutate the current track and refresh both snapshots. + + diff --git a/site/src/content/docs/reference/use-active-text-cues.mdx b/site/src/content/docs/reference/use-active-text-cues.mdx new file mode 100644 index 0000000000..d3ce5a9877 --- /dev/null +++ b/site/src/content/docs/reference/use-active-text-cues.mdx @@ -0,0 +1,36 @@ +--- +title: useActiveTextCues +description: Hook to observe text cues at the current playback position +--- + +import UtilReference from "@/components/docs/api-reference/UtilReference.astro"; +import DocsLink from "@/components/docs/DocsLink.astro"; + +`useActiveTextCues` returns a fresh array of the cues active at the current playback position. It returns an empty array while the track is unavailable or no cue is active. + +## Import + +```tsx +import { useActiveTextCues } from '@videojs/react'; +``` + +## Usage + +Find an enabled captions or subtitles track, then render its current cue text. + +```tsx title="CurrentCaption.tsx" +import { useActiveTextCues, useActiveTextTrack } from '@videojs/react'; + +const captionKinds = ['captions', 'subtitles'] as const; + +export function CurrentCaption() { + const track = useActiveTextTrack(captionKinds); + const cues = useActiveTextCues(track); + + return {cues.map((cue) => cue.text).filter(Boolean).join('\n')}; +} +``` + +Use `useTextCues` when you need the complete cue list, such as a chapter menu or transcript. + + diff --git a/site/src/content/docs/reference/use-active-text-track.mdx b/site/src/content/docs/reference/use-active-text-track.mdx new file mode 100644 index 0000000000..c40abbd1d0 --- /dev/null +++ b/site/src/content/docs/reference/use-active-text-track.mdx @@ -0,0 +1,37 @@ +--- +title: useActiveTextTrack +description: Hook to observe the active text track for one or more kinds +--- + +import UtilReference from "@/components/docs/api-reference/UtilReference.astro"; +import DocsLink from "@/components/docs/DocsLink.astro"; + +`useActiveTextTrack` returns the enabled track that matches the requested kind. It updates when tracks are added or removed and when their modes change. + +A `showing` track takes precedence over a `hidden` track. Hidden chapter and metadata tracks still count as active because the browser loads and updates their cues without rendering text. + +## Import + +```tsx +import { useActiveTextTrack } from '@videojs/react'; +``` + +## Usage + +Pass one kind or a stable array of kinds. The hook returns `null` until matching media and a matching enabled track are available. + +```tsx title="CaptionLanguage.tsx" +import { useActiveTextTrack } from '@videojs/react'; + +const captionKinds = ['captions', 'subtitles'] as const; + +export function CaptionLanguage() { + const track = useActiveTextTrack(captionKinds); + + return {track?.label ?? 'Captions off'}; +} +``` + +Pass the returned track to `useTextCues` for every cue or `useActiveTextCues` for cues at the current playback position. + + diff --git a/site/src/content/docs/reference/use-create-text-track.mdx b/site/src/content/docs/reference/use-create-text-track.mdx new file mode 100644 index 0000000000..af2b78e296 --- /dev/null +++ b/site/src/content/docs/reference/use-create-text-track.mdx @@ -0,0 +1,68 @@ +--- +title: useCreateTextTrack +description: Hook to create a programmatic text track for the current media +--- + +import UtilReference from "@/components/docs/api-reference/UtilReference.astro"; +import DocsLink from "@/components/docs/DocsLink.astro"; + +`useCreateTextTrack` creates a native text track owned by the calling component. It follows the media attached to the current player and removes the track when the component unmounts, its options change, or the player switches media. + +The track starts in `hidden` mode unless you pass a different mode. Hidden tracks load and update cues without asking the browser to render them, which fits chapters and metadata. + +## Import + +```tsx +import { useCreateTextTrack } from '@videojs/react'; +``` + +## Usage + +The hook returns `null` until media is available, then an object with the `track` and `addCue` and `removeCue` writers. Write cues through those functions rather than the native track: `TextTrack.addCue()` fires no event, so `useTextCues` and `useActiveTextCues` only refresh for cues written this way. They also wait for the backing native `` to finish loading, because browsers discard cues added before that point. The hook removes the whole track, including its cues, during cleanup. + +```tsx title="Chapters.tsx" +import { useEffect } from 'react'; +import { useCreateTextTrack } from '@videojs/react'; + +export function ChapterTrack() { + const chapters = useCreateTextTrack({ + kind: 'chapters', + label: 'Sections', + }); + + useEffect(() => { + if (!chapters) return; + + const introduction = new VTTCue(0, 30, 'Introduction'); + chapters.addCue(introduction); + + return () => chapters.removeCue(introduction); + }, [chapters]); + + return null; +} +``` + +Pass `chapters.track` to `useTextCues` or `useActiveTextCues` to read the cues back. + +## Use native tracks for WebVTT files + +Render the native lowercase `` element when the cues come from a URL. You do not need a Video.js `Track` component. + +```tsx +import { Video } from '@videojs/react/video'; + + +``` + +Use `useCreateTextTrack` when your application creates cues in JavaScript. Use `useActiveTextTrack` to observe a track supplied by markup, a manifest, or another part of the application. + + diff --git a/site/src/content/docs/reference/use-text-cues.mdx b/site/src/content/docs/reference/use-text-cues.mdx new file mode 100644 index 0000000000..95281f5c52 --- /dev/null +++ b/site/src/content/docs/reference/use-text-cues.mdx @@ -0,0 +1,42 @@ +--- +title: useTextCues +description: Hook to observe every cue on a text track +--- + +import UtilReference from "@/components/docs/api-reference/UtilReference.astro"; +import DocsLink from "@/components/docs/DocsLink.astro"; + +`useTextCues` returns a fresh array containing every cue on a text track. It updates on native `cuechange`, when a native `` finishes loading, when the media starts loading a new source, and when cues are written through `useCreateTextTrack`. Cues added directly with the native `TextTrack.addCue()` do not fire an event and are picked up at the next refresh. + +## Import + +```tsx +import { useTextCues } from '@videojs/react'; +``` + +## Usage + +Find a track with `useActiveTextTrack`, then subscribe to its full cue list. Passing `null` returns an empty array. + +```tsx title="ChapterList.tsx" +import { useActiveTextTrack, useTextCues } from '@videojs/react'; + +export function ChapterList() { + const track = useActiveTextTrack('chapters'); + const cues = useTextCues(track); + + return ( +
    + {cues.map((cue) => ( +
  1. + {cue.text ?? `${cue.startTime}s`} +
  2. + ))} +
+ ); +} +``` + +Use `useActiveTextCues` when you only need cues at the current playback position. + + diff --git a/site/src/docs.config.ts b/site/src/docs.config.ts index a181c43c0e..d9e1c05c39 100644 --- a/site/src/docs.config.ts +++ b/site/src/docs.config.ts @@ -232,9 +232,14 @@ export const sidebar: Sidebar = [ { slug: 'reference/create-player', frameworks: ['react'] }, { slug: 'reference/html-create-player', sidebarLabel: 'createPlayer', frameworks: ['html'] }, { slug: 'reference/player-controller', frameworks: ['html'] }, + { slug: 'reference/text-track-controller', frameworks: ['html'] }, { slug: 'reference/use-player', frameworks: ['react'] }, { slug: 'reference/use-media', frameworks: ['react'] }, { slug: 'reference/use-container', frameworks: ['react'] }, + { slug: 'reference/use-create-text-track', frameworks: ['react'] }, + { slug: 'reference/use-active-text-track', frameworks: ['react'] }, + { slug: 'reference/use-text-cues', frameworks: ['react'] }, + { slug: 'reference/use-active-text-cues', frameworks: ['react'] }, { slug: 'reference/use-store', frameworks: ['react'] }, { slug: 'reference/create-i18n' }, { slug: 'reference/use-translator', frameworks: ['react'] },