Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/html/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
158 changes: 158 additions & 0 deletions packages/html/src/player/tests/text-track-controller.test.ts
Original file line number Diff line number Diff line change
@@ -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<TextTrackLike> {
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<void> {
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();
});
});
191 changes: 191 additions & 0 deletions packages/html/src/player/text-track-controller.ts
Original file line number Diff line number Diff line change
@@ -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<typeof mediaContext, TextTrackControllerHost>;

#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;
}
2 changes: 1 addition & 1 deletion site/src/content/docs/reference/feature-text-tracks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Pass `selectTextTrack` to <DocsLink slug="reference/use-player">`usePlayer`</Doc
</FrameworkCase>

<FrameworkCase frameworks={["html"]}>
Pass `selectTextTrack` to <DocsLink slug="reference/player-controller">`PlayerController`</DocsLink> to subscribe to text track state. Returns `undefined` if the text tracks feature is not configured.
Pass `selectTextTrack` to <DocsLink slug="reference/player-controller">`PlayerController`</DocsLink> to subscribe to text track state. Returns `undefined` if the text tracks feature is not configured. Use <DocsLink slug="reference/text-track-controller">`TextTrackController`</DocsLink> to create or observe one track and its cues.
</FrameworkCase>

<FrameworkCase frameworks={["react"]}>
Expand Down
Loading
Loading