feat: add subtitles handling + captions core (#692)

This commit is contained in:
Wesley Luyten
2026-03-05 17:11:49 -06:00
committed by GitHub
parent b65d537878
commit 5c116065a9
22 changed files with 674 additions and 105 deletions
+2
View File
@@ -4,6 +4,8 @@ export * from './ui/alert-dialog/alert-dialog-core';
export * from './ui/alert-dialog/alert-dialog-data-attrs';
export * from './ui/buffering-indicator/buffering-indicator-core';
export * from './ui/buffering-indicator/buffering-indicator-data-attrs';
export * from './ui/captions-button/captions-button-core';
export * from './ui/captions-button/captions-button-data-attrs';
export * from './ui/controls/controls-core';
export * from './ui/controls/controls-data-attrs';
export * from './ui/fullscreen-button/fullscreen-button-core';
+37
View File
@@ -191,12 +191,43 @@ export interface MediaPlaybackRateState {
setPlaybackRate(rate: number): void;
}
/**
* A text cue.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/VTTCue
*/
export interface MediaTextCue {
startTime: number;
endTime: number;
text: string;
}
/**
* The kind of text track.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/TextTrack/kind
*/
export type TextTrackKind = 'subtitles' | 'captions' | 'descriptions' | 'chapters' | 'metadata';
/**
* The mode of a text track.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/TextTrack/mode
*/
export type TextTrackMode = 'showing' | 'disabled' | 'hidden';
/**
* A text track.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/TextTrack
*/
export interface MediaTextTrack<Kind extends TextTrackKind> {
kind: Kind;
label: string;
language: string;
mode: TextTrackMode;
}
export interface MediaTextTrackState {
/** Cues from the first `kind="chapters"` track. */
chaptersCues: MediaTextCue[];
@@ -204,6 +235,12 @@ export interface MediaTextTrackState {
thumbnailCues: MediaTextCue[];
/** The `<track>` element's `src` for resolving relative cue text URLs. */
thumbnailTrackSrc: string | null;
/** Caption/subtitle tracks that can be selected or toggled. */
subtitlesList: MediaTextTrack<'subtitles' | 'captions'>[];
/** Whether captions/subtitles are currently enabled. */
subtitlesShowing: boolean;
/** Toggle captions/subtitles visibility. Returns the new enabled value. */
toggleSubtitles(forceShow?: boolean): boolean;
}
export interface MediaError {
@@ -0,0 +1,76 @@
import { defaults } from '@videojs/utils/object';
import { isFunction } from '@videojs/utils/predicate';
import type { NonNullableObject } from '@videojs/utils/types';
import type { MediaTextTrackState } from '../../media/state';
export interface CaptionsButtonProps {
/** Custom label for the button. */
label?: string | ((state: CaptionsButtonState) => string) | undefined;
/** Whether the button is disabled. */
disabled?: boolean | undefined;
}
export interface CaptionsButtonState extends Pick<MediaTextTrackState, 'subtitlesShowing'> {
availability: 'available' | 'unavailable';
}
export class CaptionsButtonCore {
static readonly defaultProps: NonNullableObject<CaptionsButtonProps> = {
label: '',
disabled: false,
};
#props = { ...CaptionsButtonCore.defaultProps };
#media: MediaTextTrackState | null = null;
constructor(props?: CaptionsButtonProps) {
if (props) this.setProps(props);
}
setProps(props: CaptionsButtonProps): void {
this.#props = defaults(props, CaptionsButtonCore.defaultProps);
}
getLabel(state: CaptionsButtonState): string {
const { label } = this.#props;
if (isFunction(label)) {
const customLabel = label(state);
if (customLabel) return customLabel;
} else if (label) {
return label;
}
return state.subtitlesShowing ? 'Disable captions' : 'Enable captions';
}
getAttrs(state: CaptionsButtonState) {
return {
'aria-label': this.getLabel(state),
'aria-disabled': this.#props.disabled ? 'true' : undefined,
};
}
setMedia(media: MediaTextTrackState): void {
this.#media = media;
}
getState(): CaptionsButtonState {
const media = this.#media!;
return {
subtitlesShowing: media.subtitlesShowing,
availability: media.subtitlesList.length > 0 ? 'available' : 'unavailable',
};
}
toggle(media: MediaTextTrackState): void {
if (this.#props.disabled) return;
media.toggleSubtitles();
}
}
export namespace CaptionsButtonCore {
export type Props = CaptionsButtonProps;
export type State = CaptionsButtonState;
}
@@ -0,0 +1,9 @@
import type { StateAttrMap } from '../types';
import type { CaptionsButtonState } from './captions-button-core';
export const CaptionsButtonDataAttrs = {
/** Present when captions are enabled. */
subtitlesShowing: 'data-active',
/** Indicates captions availability (`available` or `unavailable`). */
availability: 'data-availability',
} as const satisfies StateAttrMap<CaptionsButtonState>;
@@ -0,0 +1,113 @@
import { describe, expect, it, vi } from 'vitest';
import type { MediaTextTrackState } from '../../../media/state';
import type { CaptionsButtonState } from '../captions-button-core';
import { CaptionsButtonCore } from '../captions-button-core';
function createMediaState(overrides: Partial<MediaTextTrackState> = {}): MediaTextTrackState {
return {
chaptersCues: [],
thumbnailCues: [],
thumbnailTrackSrc: null,
subtitlesList: [],
subtitlesShowing: false,
toggleSubtitles: vi.fn(() => true),
...overrides,
};
}
function createState(overrides: Partial<CaptionsButtonState> = {}): CaptionsButtonState {
return {
subtitlesShowing: false,
availability: 'available',
...overrides,
};
}
describe('CaptionsButtonCore', () => {
describe('getState', () => {
it('projects captions', () => {
const core = new CaptionsButtonCore();
const media = createMediaState({
subtitlesShowing: true,
subtitlesList: [{ kind: 'subtitles', label: 'English', language: 'en', mode: 'showing' }],
});
core.setMedia(media);
const state = core.getState();
expect(state.subtitlesShowing).toBe(true);
});
it('returns available when subtitles exist', () => {
const core = new CaptionsButtonCore();
core.setMedia(
createMediaState({
subtitlesList: [{ kind: 'subtitles', label: 'English', language: 'en', mode: 'disabled' }],
})
);
expect(core.getState().availability).toBe('available');
});
it('returns unavailable when no subtitles', () => {
const core = new CaptionsButtonCore();
core.setMedia(createMediaState({ subtitlesList: [] }));
expect(core.getState().availability).toBe('unavailable');
});
});
describe('getLabel', () => {
it('returns Enable captions when captions are disabled', () => {
const core = new CaptionsButtonCore();
expect(core.getLabel(createState({ subtitlesShowing: false }))).toBe('Enable captions');
});
it('returns Disable captions when captions are enabled', () => {
const core = new CaptionsButtonCore();
expect(core.getLabel(createState({ subtitlesShowing: true }))).toBe('Disable captions');
});
it('returns custom string label', () => {
const core = new CaptionsButtonCore({ label: 'Captions' });
expect(core.getLabel(createState())).toBe('Captions');
});
it('returns custom function label', () => {
const core = new CaptionsButtonCore({
label: (state) => (state.subtitlesShowing ? 'Hide subtitles' : 'Show subtitles'),
});
expect(core.getLabel(createState({ subtitlesShowing: true }))).toBe('Hide subtitles');
});
});
describe('getAttrs', () => {
it('returns aria-label', () => {
const core = new CaptionsButtonCore();
const attrs = core.getAttrs(createState({ subtitlesShowing: false }));
expect(attrs['aria-label']).toBe('Enable captions');
});
it('sets aria-disabled when disabled', () => {
const core = new CaptionsButtonCore({ disabled: true });
const attrs = core.getAttrs(createState());
expect(attrs['aria-disabled']).toBe('true');
});
});
describe('toggle', () => {
it('calls toggleSubtitles when available', () => {
const core = new CaptionsButtonCore();
const media = createMediaState();
core.toggle(media);
expect(media.toggleSubtitles).toHaveBeenCalled();
});
it('does nothing when disabled', () => {
const core = new CaptionsButtonCore({ disabled: true });
const media = createMediaState();
core.toggle(media);
expect(media.toggleSubtitles).not.toHaveBeenCalled();
});
});
});
@@ -129,7 +129,7 @@ declare class CustomAudioElementClass extends HTMLAudioElement implements HTMLAu
static getTemplateHTML: typeof getAudioTemplateHTML;
static shadowRootOptions: ShadowRootInit;
static Events: string[];
readonly nativeEl: HTMLAudioElement;
readonly target: HTMLAudioElement;
attributeChangedCallback(attrName: string, oldValue?: string | null, newValue?: string | null): void;
connectedCallback(): void;
disconnectedCallback(): void;
@@ -142,7 +142,7 @@ declare class CustomVideoElementClass extends HTMLVideoElement implements HTMLVi
static getTemplateHTML: typeof getVideoTemplateHTML;
static shadowRootOptions: ShadowRootInit;
static Events: string[];
readonly nativeEl: HTMLVideoElement;
readonly target: HTMLVideoElement;
attributeChangedCallback(attrName: string, oldValue?: string | null, newValue?: string | null): void;
connectedCallback(): void;
disconnectedCallback(): void;
@@ -239,7 +239,7 @@ export function CustomMediaMixin<T extends Constructor<HTMLElement>>(
// Private fields
#isInit = false;
#nativeEl: HTMLVideoElement | HTMLAudioElement | null = null;
#target: HTMLVideoElement | HTMLAudioElement | null = null;
#childMap = new Map<MediaChild, MediaChild>();
#childObserver?: MutationObserver;
@@ -249,7 +249,7 @@ export function CustomMediaMixin<T extends Constructor<HTMLElement>>(
const val = this.getAttribute(attr);
return val === null ? false : val === '' ? true : val;
}
return this.nativeEl?.[prop as keyof typeof this.nativeEl];
return this.target?.[prop as keyof typeof this.target];
}
set(prop: string, val: any): void {
@@ -263,15 +263,15 @@ export function CustomMediaMixin<T extends Constructor<HTMLElement>>(
return;
}
if (this.nativeEl) {
if (this.target) {
// @ts-expect-error
this.nativeEl[prop as keyof typeof this.nativeEl] = val;
this.target[prop as keyof typeof this.target] = val;
}
}
call(prop: string, ...args: any[]): any {
const nativeFn = this.nativeEl?.[prop as keyof typeof this.nativeEl] as ((...args: any[]) => any) | undefined;
return nativeFn?.apply(this.nativeEl, args);
const nativeFn = this.target?.[prop as keyof typeof this.target] as ((...args: any[]) => any) | undefined;
return nativeFn?.apply(this.target, args);
}
// If the custom element is defined before the custom element's HTML is parsed
@@ -279,10 +279,10 @@ export function CustomMediaMixin<T extends Constructor<HTMLElement>>(
// Wait until initializing in the attributeChangedCallback or
// connectedCallback or accessing any properties.
get nativeEl() {
get target() {
this.#init();
return (
this.#nativeEl ??
this.#target ??
this.querySelector(':scope > [slot=media]') ??
this.querySelector(tag) ??
this.shadowRoot?.querySelector(tag) ??
@@ -290,8 +290,8 @@ export function CustomMediaMixin<T extends Constructor<HTMLElement>>(
);
}
set nativeEl(val: HTMLVideoElement | HTMLAudioElement | null) {
this.#nativeEl = val;
set target(val: HTMLVideoElement | HTMLAudioElement | null) {
this.#target = val;
}
get defaultMuted() {
@@ -323,7 +323,7 @@ export function CustomMediaMixin<T extends Constructor<HTMLElement>>(
// Neither Chrome or Firefox support setting the muted attribute
// after using document.createElement.
// Get around this by setting the muted property manually.
this.nativeEl!.muted = this.hasAttribute('muted');
this.target!.muted = this.hasAttribute('muted');
for (const prop of nativeElProps) {
// @ts-expect-error
@@ -340,7 +340,7 @@ export function CustomMediaMixin<T extends Constructor<HTMLElement>>(
}
handleEvent(event: Event): void {
if (event.target === this.nativeEl) {
if (event.target === this.target) {
this.dispatchEvent(new CustomEvent(event.type, { detail: (event as CustomEvent).detail }));
}
}
@@ -361,7 +361,7 @@ export function CustomMediaMixin<T extends Constructor<HTMLElement>>(
this.#childMap.set(el, clone);
this.#childObserver?.observe(el, { attributes: true });
}
this.nativeEl?.append(clone);
this.target?.append(clone);
this.#enableDefaultTrack(clone as HTMLTrackElement);
});
@@ -426,9 +426,9 @@ export function CustomMediaMixin<T extends Constructor<HTMLElement>>(
}
if (newValue === null) {
this.nativeEl?.removeAttribute(attrName);
} else if (this.nativeEl?.getAttribute(attrName) !== newValue) {
this.nativeEl?.setAttribute(attrName, newValue);
this.target?.removeAttribute(attrName);
} else if (this.target?.getAttribute(attrName) !== newValue) {
this.target?.setAttribute(attrName, newValue);
}
}
@@ -1,11 +1,24 @@
import Hls from 'hls.js';
import { type MediaDelegate, MediaDelegateMixin } from '../../core/media/delegate';
import { MediaProxyMixin } from '../../core/media/proxy';
import { CustomMediaMixin } from './custom-media-element';
import { type MediaDelegate, MediaDelegateMixin } from '../../../core/media/delegate';
import { MediaProxyMixin } from '../../../core/media/proxy';
import { CustomMediaMixin } from '../custom-media-element';
import { HlsMediaTextTracksMixin } from './text-tracks';
export class HlsMediaDelegate implements MediaDelegate {
#engine = new Hls();
const defaultConfig = {
backBufferLength: 30,
renderTextTracksNatively: false,
liveDurationInfinity: true,
capLevelToPlayerSize: true,
capLevelOnFPSDrop: true,
};
export class HlsMediaDelegateBase implements MediaDelegate {
#engine = new Hls(defaultConfig);
get engine(): Hls {
return this.#engine;
}
attach(target: EventTarget): void {
this.#engine.attachMedia(target as HTMLMediaElement);
@@ -24,6 +37,8 @@ export class HlsMediaDelegate implements MediaDelegate {
}
}
const HlsMediaDelegate = HlsMediaTextTracksMixin(HlsMediaDelegateBase);
// This is used by the web component because it needs to extend HTMLElement!
export class HlsCustomMedia extends MediaDelegateMixin(
CustomMediaMixin(globalThis.HTMLElement ?? class {}, { tag: 'video' }),
@@ -0,0 +1,178 @@
import { listen } from '@videojs/utils/dom';
import type { Constructor } from '@videojs/utils/types';
import type { CuesParsedData, NonNativeTextTracksData } from 'hls.js';
import Hls from 'hls.js';
interface HlsEngineHost {
readonly engine: Hls;
attach?(target: EventTarget): void;
detach?(): void;
}
/**
* Bridges hls.js non-native text tracks to native `<track>` elements so the
* rest of the player can treat them like any other text track.
*
* When `renderTextTracksNatively: false`, hls.js fires
* `NON_NATIVE_TEXT_TRACKS_FOUND` with track metadata and `CUES_PARSED` with
* VTTCues. This mixin creates `<track>` elements on the media target and
* forwards cues into them. It also syncs user track-mode changes back to
* hls.js via `engine.subtitleTrack`.
*/
export function HlsMediaTextTracksMixin<Base extends Constructor<HlsEngineHost>>(BaseClass: Base) {
class HlsMediaTextTracks extends (BaseClass as Constructor<HlsEngineHost>) {
#disconnect: AbortController | null = null;
#target: HTMLMediaElement | null = null;
attach(target: EventTarget): void {
super.attach?.(target);
this.#target = target as HTMLMediaElement;
this.#connect();
}
detach(): void {
this.#disconnect?.abort();
this.#disconnect = null;
this.#target = null;
super.detach?.();
}
#connect(): void {
this.#disconnect?.abort();
this.#disconnect = new AbortController();
const { signal } = this.#disconnect;
const { engine } = this;
const media = this.#target!;
const onTracksFound = (_event: string, data: NonNativeTextTracksData) => {
this.#clearTracks();
for (const trackObj of data.tracks) {
const baseTrackObj = trackObj.subtitleTrack ?? trackObj.closedCaptions;
const idx = engine.subtitleTracks.findIndex(({ lang, name, type }) => {
return lang === baseTrackObj?.lang && name === trackObj.label && type.toLowerCase() === trackObj.kind;
});
// NOTE: Undocumented method for determining identifier by hls.js. Relied on for
// ensuring CUES_PARSED events can identify and apply cues to the appropriate track (CJP).
// See: https://github.com/video-dev/hls.js/blob/master/src/controller/timeline-controller.ts#L640
const id = (trackObj._id ?? trackObj.default) ? 'default' : `${trackObj.kind}${idx}`;
addTextTrack(media, trackObj.kind as TextTrackKind, trackObj.label, baseTrackObj?.lang, id, trackObj.default);
}
};
const onCuesParsed = (_event: string, { track, cues }: CuesParsedData) => {
const textTrack = media.textTracks.getTrackById(track);
if (!textTrack) return;
const disabled = textTrack.mode === 'disabled';
if (disabled) {
textTrack.mode = 'hidden';
}
cues.forEach((cue: VTTCue) => {
if (textTrack.cues?.getCueById(cue.id)) return;
textTrack.addCue(cue);
});
if (disabled) {
textTrack.mode = 'disabled';
}
};
const onTextTrackChange = () => {
if (!engine.subtitleTracks.length) return;
const showingTrack = Array.from(media.textTracks).find((textTrack) => {
return textTrack.id && textTrack.mode === 'showing' && ['subtitles', 'captions'].includes(textTrack.kind);
});
if (!showingTrack) return;
const currentHlsTrack = engine.subtitleTracks[engine.subtitleTrack];
// If hls.subtitleTrack is -1 or its id changed compared to the one that is showing load the new subtitle track.
const hlsTrackId = !currentHlsTrack
? undefined
: currentHlsTrack.default
? 'default'
: `${engine.subtitleTracks[engine.subtitleTrack]?.type.toLowerCase()}${engine.subtitleTrack}`;
if (engine.subtitleTrack < 0 || showingTrack?.id !== hlsTrackId) {
const idx = engine.subtitleTracks.findIndex(({ lang, name, type, default: defaultTrack }) => {
return (
(showingTrack.id === 'default' && defaultTrack) ||
(lang === showingTrack.language &&
name === showingTrack.label &&
type.toLowerCase() === showingTrack.kind)
);
});
// After the subtitleTrack is set here, hls.js will load the playlist and CUES_PARSED events will be fired below.
engine.subtitleTrack = idx;
}
if (showingTrack?.id === hlsTrackId) {
// Refresh the cues after a texttrack mode change to fix a Chrome bug causing the captions not to render.
if (showingTrack.cues) {
Array.from(showingTrack.cues).forEach((cue) => {
showingTrack.addCue(cue);
});
}
}
};
engine.on(Hls.Events.NON_NATIVE_TEXT_TRACKS_FOUND, onTracksFound);
engine.on(Hls.Events.CUES_PARSED, onCuesParsed);
listen(media.textTracks, 'change', onTextTrackChange, { signal });
signal.addEventListener(
'abort',
() => {
engine.off(Hls.Events.NON_NATIVE_TEXT_TRACKS_FOUND, onTracksFound);
engine.off(Hls.Events.CUES_PARSED, onCuesParsed);
this.#clearTracks();
},
{ once: true }
);
}
#clearTracks(): void {
const trackEls = this.#target!.querySelectorAll('track[data-removeondestroy]');
trackEls.forEach((trackEl) => trackEl.remove());
}
}
return HlsMediaTextTracks as unknown as Base;
}
function addTextTrack(
mediaEl: HTMLMediaElement,
kind: TextTrackKind,
label: string,
lang?: string,
id?: string,
defaultTrack?: boolean
): TextTrack {
const trackEl = document.createElement('track');
trackEl.kind = kind;
trackEl.label = label;
if (lang) {
// This attribute must be present if the element's kind attribute is in the subtitles state.
trackEl.srclang = lang;
}
if (id) {
trackEl.id = id;
}
if (defaultTrack) {
trackEl.default = true;
}
trackEl.track.mode = ['subtitles', 'captions'].includes(kind) ? 'disabled' : 'hidden';
// Add data attribute to identify tracks that should be removed when switching sources/destroying hls.js instance.
trackEl.setAttribute('data-removeondestroy', '');
mediaEl.append(trackEl);
return trackEl.track as TextTrack;
}
@@ -16,6 +16,23 @@ function createVideo(): HTMLVideoElement {
return document.createElement('video');
}
function mockTextTracks(video: HTMLVideoElement, tracks: TextTrack[]): void {
const list: Partial<TextTrackList> & Record<number, TextTrack> = { length: tracks.length };
for (const [index, track] of tracks.entries()) {
list[index] = track;
}
Object.defineProperty(video, 'textTracks', {
configurable: true,
value: list as TextTrackList,
});
}
function createMockTrack(kind: TextTrackKind, mode: TextTrackMode = 'disabled'): TextTrack {
return { kind, mode, label: '', language: '' } as TextTrack;
}
describe('textTrackFeature', () => {
describe('initial state', () => {
it('has empty initial state', () => {
@@ -26,6 +43,8 @@ describe('textTrackFeature', () => {
expect(store.state.chaptersCues).toEqual([]);
expect(store.state.thumbnailCues).toEqual([]);
expect(store.state.thumbnailTrackSrc).toBeNull();
expect(store.state.subtitlesList).toEqual([]);
expect(store.state.subtitlesShowing).toBe(false);
});
});
@@ -120,6 +139,62 @@ describe('textTrackFeature', () => {
}
});
it('sets subtitlesShowing when a subtitles track is showing', () => {
const video = createVideo();
mockTextTracks(video, [createMockTrack('subtitles', 'showing')]);
const store = createStore<PlayerTarget>()(textTrackFeature);
store.attach({ media: video, container: null });
expect(store.state.subtitlesShowing).toBe(true);
});
it('exposes subtitlesList from captions/subtitles tracks', () => {
const video = createVideo();
const subtitlesTrack = { kind: 'subtitles', mode: 'showing', label: 'English', language: 'en' } as TextTrack;
const captionsTrack = { kind: 'captions', mode: 'disabled', label: 'CC', language: 'en' } as TextTrack;
mockTextTracks(video, [subtitlesTrack, captionsTrack, createMockTrack('metadata', 'showing')]);
const store = createStore<PlayerTarget>()(textTrackFeature);
store.attach({ media: video, container: null });
expect(store.state.subtitlesList).toEqual([
{ kind: 'subtitles', label: 'English', language: 'en', mode: 'showing' },
{ kind: 'captions', label: 'CC', language: 'en', mode: 'disabled' },
]);
});
it('toggleSubtitles() enables and disables caption/subtitle tracks', () => {
const video = createVideo();
const subtitlesTrack = createMockTrack('subtitles');
const captionsTrack = createMockTrack('captions');
mockTextTracks(video, [subtitlesTrack, captionsTrack]);
const store = createStore<PlayerTarget>()(textTrackFeature);
store.attach({ media: video, container: null });
const enabled = store.state.toggleSubtitles();
expect(enabled).toBe(true);
expect(subtitlesTrack.mode).toBe('showing');
expect(captionsTrack.mode).toBe('showing');
const disabled = store.state.toggleSubtitles(false);
expect(disabled).toBe(false);
expect(subtitlesTrack.mode).toBe('disabled');
expect(captionsTrack.mode).toBe('disabled');
});
it('toggleSubtitles() returns false when no subtitle tracks exist', () => {
const video = createVideo();
const metadataTrack = createMockTrack('metadata', 'showing');
mockTextTracks(video, [metadataTrack]);
const store = createStore<PlayerTarget>()(textTrackFeature);
store.attach({ media: video, container: null });
expect(store.state.toggleSubtitles()).toBe(false);
});
it('stops updating after destroy', () => {
const video = createVideo();
const store = createStore<PlayerTarget>()(textTrackFeature);
@@ -1,14 +1,29 @@
import { findTrackElement, listen } from '@videojs/utils/dom';
import { findTrackElement, getSubtitlesTracks, listen } from '@videojs/utils/dom';
import type { MediaTextCue, MediaTextTrackState } from '../../../core/media/state';
import type { MediaTextCue, MediaTextTrack, MediaTextTrackState } from '../../../core/media/state';
import { definePlayerFeature } from '../../feature';
export const textTrackFeature = definePlayerFeature({
name: 'textTrack',
state: (): MediaTextTrackState => ({
state: ({ target }): MediaTextTrackState => ({
chaptersCues: [],
thumbnailCues: [],
thumbnailTrackSrc: null,
subtitlesList: [],
subtitlesShowing: false,
toggleSubtitles(forceShow?: boolean) {
const subtitlesTracks = getSubtitlesTracks(target().media);
if (!subtitlesTracks.length) return false;
const showing = subtitlesTracks.some((track: TextTrack) => track.mode === 'showing');
const nextShowing = forceShow ?? !showing;
for (const track of subtitlesTracks) {
track.mode = nextShowing ? 'showing' : 'disabled';
}
return nextShowing;
},
}),
attach({ target, signal, set }) {
@@ -22,11 +37,26 @@ export const textTrackFeature = definePlayerFeature({
let chaptersTrack: TextTrack | null = null;
let thumbnailTrack: TextTrack | null = null;
const subtitlesList: MediaTextTrack<'subtitles' | 'captions'>[] = [];
let subtitlesShowing = false;
for (let i = 0; i < media.textTracks.length; i++) {
const track = media.textTracks[i]!;
if (!chaptersTrack && track.kind === 'chapters') chaptersTrack = track;
if (!thumbnailTrack && track.kind === 'metadata' && track.label === 'thumbnails') thumbnailTrack = track;
if (track.kind === 'captions' || track.kind === 'subtitles') {
const showing = track.mode === 'showing';
subtitlesList.push({
kind: track.kind,
label: track.label,
language: track.language,
mode: track.mode,
});
if (showing) {
subtitlesShowing = true;
}
}
}
// VTTCue extends TextTrackCue with `text` — cast via `unknown` since
@@ -47,13 +77,13 @@ export const textTrackFeature = definePlayerFeature({
// Listen for <track> load events on tracks that don't have cues yet.
// `addtrack` fires before cues are parsed — we need the `load` event
// on the <track> element to know when cues are ready.
for (const trackEl of media.querySelectorAll('track')) {
for (const trackEl of media.querySelectorAll?.('track') ?? []) {
if (!trackEl.track?.cues?.length) {
listen(trackEl, 'load', sync, { signal: trackCleanup.signal });
}
}
set({ chaptersCues, thumbnailCues, thumbnailTrackSrc });
set({ chaptersCues, thumbnailCues, thumbnailTrackSrc, subtitlesList, subtitlesShowing });
}
sync();