Skip to content
Closed
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
12 changes: 12 additions & 0 deletions apps/e2e/apps/vite/src/text-track-cues.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Text track cues</title>
</head>
<body>
<video muted playsinline preload="auto"></video>
<script type="module" src="/text-track-cues.ts"></script>
</body>
</html>
68 changes: 68 additions & 0 deletions apps/e2e/apps/vite/src/text-track-cues.ts
Original file line number Diff line number Diff line change
@@ -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;
32 changes: 32 additions & 0 deletions apps/e2e/tests/text-track-cues.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
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();
});
});
Loading
Loading