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 `