mirror of
https://github.com/zoriya/v10.git
synced 2026-08-09 15:46:42 +00:00
feat: add subtitles handling + captions core (#692)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+18
-18
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user