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
+3 -3
View File
@@ -27,9 +27,9 @@
"default": "./dist/default/dom.js"
},
"./dom/media/*": {
"types": "./dist/dev/dom/media/*.d.ts",
"development": "./dist/dev/dom/media/*.js",
"default": "./dist/default/dom/media/*.js"
"types": "./dist/dev/dom/media/*/index.d.ts",
"development": "./dist/dev/dom/media/*/index.js",
"default": "./dist/default/dom/media/*/index.js"
}
},
"main": "dist/default/index.js",
+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();
+2 -2
View File
@@ -9,8 +9,8 @@ const createConfig = (mode: BuildMode): UserConfig => ({
entry: {
index: './src/core/index.ts',
dom: './src/dom/index.ts',
'dom/media/hls': './src/dom/media/hls.ts',
'dom/media/custom-media-element': './src/dom/media/custom-media-element.ts',
'dom/media/hls/index': './src/dom/media/hls/index.ts',
'dom/media/custom-media-element/index': './src/dom/media/custom-media-element/index.ts',
},
platform: 'neutral',
format: 'es',
@@ -0,0 +1,9 @@
import { CaptionsButtonElement } from '../../ui/captions-button/captions-button-element';
customElements.define(CaptionsButtonElement.tagName, CaptionsButtonElement);
declare global {
interface HTMLElementTagNameMap {
[CaptionsButtonElement.tagName]: CaptionsButtonElement;
}
}
+1
View File
@@ -15,6 +15,7 @@ export * from './store/provider-mixin';
export * from './store/types';
// UI Components
export { BufferingIndicatorElement } from './ui/buffering-indicator/buffering-indicator-element';
export { CaptionsButtonElement } from './ui/captions-button/captions-button-element';
export { ControlsElement } from './ui/controls/controls-element';
export { ControlsGroupElement } from './ui/controls/controls-group-element';
export { FullscreenButtonElement } from './ui/fullscreen-button/fullscreen-button-element';
+1 -1
View File
@@ -13,7 +13,7 @@ export class HlsVideo extends HlsCustomMedia {
// are appended after the custom element is created, we need to
// attach the native element to the Media API after the native element
// is appended to the DOM. This is currently not supported.
this.attach(this.nativeEl);
this.attach(this.target);
}
attributeChangedCallback(attrName: string, oldValue: string | null, newValue: string | null): void {
@@ -0,0 +1,18 @@
import { CaptionsButtonCore, CaptionsButtonDataAttrs, type MediaTextTrackState } from '@videojs/core';
import { selectTextTrack } from '@videojs/core/dom';
import { playerContext } from '../../player/context';
import { PlayerController } from '../../player/player-controller';
import { MediaButtonElement } from '../media-button-element';
export class CaptionsButtonElement extends MediaButtonElement<CaptionsButtonCore> {
static readonly tagName = 'media-captions-button';
protected readonly core = new CaptionsButtonCore();
protected readonly stateAttrMap = CaptionsButtonDataAttrs;
protected readonly mediaState = new PlayerController(this, playerContext, selectTextTrack);
protected activate(state: MediaTextTrackState): void {
this.core.toggle(state);
}
}
@@ -1,78 +1,25 @@
'use client';
import type { StateAttrMap } from '@videojs/core';
import type { ForwardedRef } from 'react';
import { forwardRef, useState } from 'react';
import { CaptionsButtonCore, CaptionsButtonDataAttrs } from '@videojs/core';
import { selectTextTrack } from '@videojs/core/dom';
import type { UIComponentProps } from '../../utils/types';
import { renderElement } from '../../utils/use-render';
import { useButton } from '../hooks/use-button';
import { createMediaButton } from '../create-media-button';
// FIXME: Replace with state/props from core.
export type CaptionsButtonState = {
active: boolean;
};
type CaptionButtonCoreProps = {
/** Custom label for the button. */
label?: string | ((state: CaptionsButtonState) => string) | undefined;
/** Whether the button is disabled. */
disabled?: boolean | undefined;
};
const CaptionButtonDataAttrs = {
/** Present when the captions are active. */
active: 'data-active',
} as const satisfies StateAttrMap<CaptionsButtonState>;
export interface CaptionsButtonProps
extends UIComponentProps<'button', CaptionsButtonCore.State>,
CaptionsButtonCore.Props {}
export interface CaptionsButtonProps extends UIComponentProps<'button', CaptionsButtonState>, CaptionButtonCoreProps {}
const DEBUG = false;
/**
* A button that toggles captions.
*
* @example
* ```tsx
* <CaptionsButton />
*
* <CaptionsButton
* render={(props, state) => (
* <button {...props}>
* {state.active ? <CaptionsOnIcon /> : <CaptionsOffIcon />}
* </button>
* )}
* />
* ```
*/
export const CaptionsButton = forwardRef(function CaptionsButton(
componentProps: CaptionsButtonProps,
forwardedRef: ForwardedRef<HTMLButtonElement>
) {
const { render, className, style, label, disabled, ...elementProps } = componentProps;
// FIXME: Replace with actual captions state
const [isActive, setIsActive] = useState(false);
const { getButtonProps, buttonRef } = useButton({
displayName: 'CaptionsButton',
onActivate: () => setIsActive((active) => !active), // FIXME: Replace with actual toggle logic
isDisabled: () => disabled ?? false,
});
if (!DEBUG) return null;
return renderElement(
'button',
{ render, className, style },
{
state: { active: isActive }, // FIXME: Replace with actual toggle logic
stateAttrMap: CaptionButtonDataAttrs,
ref: [forwardedRef, buttonRef],
props: [elementProps, getButtonProps(), { 'aria-pressed': isActive }],
}
);
/** A button that toggles captions. */
export const CaptionsButton = createMediaButton<CaptionsButtonCore, CaptionsButtonProps>({
displayName: 'CaptionsButton',
core: CaptionsButtonCore,
stateAttrMap: CaptionsButtonDataAttrs,
selector: selectTextTrack,
action: (core, state) => core.toggle(state),
});
export namespace CaptionsButton {
export type Props = CaptionsButtonProps;
export type State = CaptionsButtonState;
export type State = CaptionsButtonCore.State;
}
@@ -1 +1 @@
export { CaptionsButton, type CaptionsButtonProps, type CaptionsButtonState } from './captions-button';
export { CaptionsButton, type CaptionsButtonProps } from './captions-button';
+17 -2
View File
@@ -2,7 +2,8 @@
// http://localhost:5173/react/
import { createPlayer } from '@videojs/react';
import { Video, videoFeatures } from '@videojs/react/video';
import { HlsVideo } from '@videojs/react/media/hls-video';
import { videoFeatures } from '@videojs/react/video';
import { useState } from 'react';
import { createRoot } from 'react-dom/client';
import { SKINS } from '../constants';
@@ -13,8 +14,14 @@ const { Provider } = createPlayer({
features: videoFeatures,
});
const HLS_SOURCES = [
'https://stream.mux.com/VcmKA6aqzIzlg3MayLJDnbF55kX00mds028Z65QxvBYaA.m3u8',
'https://stream.mux.com/Sc89iWAyNkhJ3P1rQ02nrEdCFTnfT01CZ2KmaEcxXfB008.m3u8',
];
function App() {
const [skin, setSkin] = useState<Skin>('default');
const [src, setSrc] = useState(HLS_SOURCES[0]);
return (
<div className="flex flex-col justify-center items-center gap-4 my-4">
@@ -28,9 +35,17 @@ function App() {
<Provider>
<SkinComponent skin={skin} className="w-full aspect-video max-w-4xl mx-auto">
<Video src="https://stream.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/highest.mp4" />
<HlsVideo src={src} />
</SkinComponent>
</Provider>
<button
type="button"
onClick={() => setSrc((current) => (current === HLS_SOURCES[0] ? HLS_SOURCES[1] : HLS_SOURCES[0]))}
className="inline-flex items-center rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-sky-500 active:bg-sky-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400 focus-visible:ring-offset-2"
>
Toggle HLS Source
</button>
</div>
);
}
+1
View File
@@ -1,4 +1,5 @@
@import "tailwindcss";
@source "../src";
@source "../templates";
/* NOTE: This is non-standard since you'd usually eject the Tailwind skin into your own project and not import it from node_modules. */
@source "../node_modules/@videojs/skins";
+1 -1
View File
@@ -14,6 +14,6 @@ export {
supportsIdleCallback,
supportsPopoverAPI,
} from './supports';
export { findTrackElement } from './text-track';
export { findTrackElement, getSubtitlesTracks, getTextTracksList } from './text-track';
export { serializeTimeRanges } from './time-ranges';
export type { CustomElement, CustomElementCallbacks } from './types';
+43
View File
@@ -5,3 +5,46 @@ export function findTrackElement(media: HTMLMediaElement, track: TextTrack): HTM
}
return null;
}
export function getSubtitlesTracks(media: HTMLMediaElement): TextTrack[] {
return getTextTracksList(media, isSubtitleTrack).sort(sortByTextTrackKind);
}
export function getTextTracksList(
media: HTMLMediaElement,
filterPredOrObj: ((textTrack: TextTrack) => boolean) | TextTrack = alwaysTrue
): TextTrack[] {
if (!media?.textTracks) return [];
const filterPred = typeof filterPredOrObj === 'function' ? filterPredOrObj : textTrackObjAsPred(filterPredOrObj);
return (Array.from(media.textTracks) as TextTrack[]).filter(filterPred);
}
export function textTrackObjAsPred(filterObj: any): (textTrack: TextTrack) => boolean {
const preds = Object.entries(filterObj).map(([key, value]) => {
// Translate each key/value pair into a single predicate
return isMatchingPropOf(key, value);
});
// Return a predicate function that takes the array of single key/value pair predicates and asserts that *every* pred in the array is true of the (TextTrack-like) object
return (textTrack) => preds.every((pred) => pred(textTrack));
}
export function isMatchingPropOf(key: string | number, value: any): (candidate: TextTrack) => boolean {
return function matchProp(candidate): boolean {
return (candidate as unknown as Record<string | number, unknown>)[key] === value;
};
}
function isSubtitleTrack(textTrack: TextTrack): boolean {
return ['subtitles', 'captions'].includes(textTrack.kind);
}
function sortByTextTrackKind(a: TextTrack, b: TextTrack): number {
return a.kind >= b.kind ? 1 : -1;
}
function alwaysTrue(): boolean {
return true;
}