From deaf38fca5dad3eec5bfd4e8ced26abb64a83158 Mon Sep 17 00:00:00 2001 From: Rahim Date: Wed, 2 Sep 2026 21:03:52 -0700 Subject: [PATCH] feat(react): add text track hooks --- packages/react/src/index.ts | 8 + .../src/player/tests/public-exports.test.ts | 7 + .../src/player/tests/text-track.test.tsx | 223 ++++++++++++++++++ packages/react/src/player/text-track.ts | 162 +++++++++++++ .../docs/reference/feature-text-tracks.mdx | 2 +- .../docs/reference/use-active-text-cues.mdx | 36 +++ .../docs/reference/use-active-text-track.mdx | 37 +++ .../docs/reference/use-create-text-track.mdx | 68 ++++++ .../content/docs/reference/use-text-cues.mdx | 42 ++++ site/src/docs.config.ts | 4 + 10 files changed, 588 insertions(+), 1 deletion(-) create mode 100644 packages/react/src/player/tests/text-track.test.tsx create mode 100644 packages/react/src/player/text-track.ts create mode 100644 site/src/content/docs/reference/use-active-text-cues.mdx create mode 100644 site/src/content/docs/reference/use-active-text-track.mdx create mode 100644 site/src/content/docs/reference/use-create-text-track.mdx create mode 100644 site/src/content/docs/reference/use-text-cues.mdx 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/site/src/content/docs/reference/feature-text-tracks.mdx b/site/src/content/docs/reference/feature-text-tracks.mdx index acf8b2381e..44d5e4084e 100644 --- a/site/src/content/docs/reference/feature-text-tracks.mdx +++ b/site/src/content/docs/reference/feature-text-tracks.mdx @@ -23,7 +23,7 @@ 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`. 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 64304abd0b..d9e1c05c39 100644 --- a/site/src/docs.config.ts +++ b/site/src/docs.config.ts @@ -236,6 +236,10 @@ export const sidebar: Sidebar = [ { 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'] },