Skip to content

Source: src/text/Subtitles.js

import DomSmith from '../../lib/dom/DomSmith.js';
import srt2webvtt from './srtParser.js';

/**
 * The Subtitles component enables custom and native subtitle rendering with track selection and layout control.
 * It handles dynamic switching, SRT-to-VTT conversion and custom rendering with external renderer registration.
 * @exports module:src/text/Subtitles
 * @requires lib/dom/DomSmith
 * @requires src/text/srtParser
 * @author   Frank Kudermann - alphanull
 * @version  2.0.0
 * @license  MIT
 */
export default class Subtitles {

    /**
     * Holds the configuration options for the Subtitles component.
     * @type     {Object}
     * @property {string}         [mode="custom"]             "custom" mode uses the builtin subtitle engine, while "native" uses the browser engine. Note that not all Browsers may support custom engine, in this case the native fallback is used.
     * @property {string}         [allowHTML="none"]          When used in conjunction with the custom engine, specifies if subtitles formatted in HTML may be used not at all ("none"), only with some basic tags ("basic") or fully ("all"). WARNING: allowing HTML may be a potential security issue and may trigger XSS attacks. Only use this if you are in full control of your subtitles!
     * @property {boolean}        [adaptLayout=false]         If enabled, the layout is synchronized with the UI state of controller and title, meaning that if one of these is shown, subtitles get an additional offset to prevent overlapping. Only works with custom engine.
     * @property {string}         [fontSize="medium"]         The text size of the subtitles, can be "small", "medium" or "big". Only works with custom engine.
     * @property {boolean}        [showFontSizeControl=true]  If enabled, provide a UI to the user to change the subtitle text size. Only works with custom engine.
     * @property {boolean}        [showPlaceholder=false]     If enabled, display a 'not available' placeholder if no subtitles are available, otherwise completely hide the menu.
     * @property {boolean|string} [preferredSubtitles=false]  If enabled, try to display subtitles: if `true`, use the player locale as default, or use a string as language code and try to match with available subs.
     */
    #config = {
        mode: 'custom',
        allowHTML: 'none',
        adaptLayout: true,
        fontSize: 'medium',
        showFontSizeControl: true,
        showPlaceholder: false,
        preferredSubtitles: false
    };

    /**
     * Reference to the main player instance.
     * @type {module:src/core/Player}
     */
    #player;

    /**
     * Reference to the current media element.
     * @type {HTMLMediaElement}
     */
    #mediaElement;

    /**
     * Holds tokens of subscriptions to player events, for later unsubscribe.
     * @type {number[]}
     */
    #subscriptions;

    /**
     * Secret key only known to the player instance and initialized components.
     * Used to be able to restrict access to API methods in conjunction with secure mode.
     * @type {symbol}
     */
    #apiKey;

    /**
     * DomSmith for the main container used by the custom engine.
     * @type {module:lib/dom/DomSmith}
     */
    #dom;

    /**
     * Stores actual subtitle mode. May be different from the configuration if the target browser does not support custom subtitles.
     * @type {"custom"|"native"}
     */
    #mode;

    /**
     * Flag indicating if subtitle was preloaded.
     * @type {boolean}
     */
    #preloaded;

    /**
     * Allowed font size settings.
     * @type {string[]}
     */
    #fontSizes = ['small', 'medium', 'big'];

    /**
     * The current font size.
     * @type {"small"|"medium"|"big"}
     */
    #currentFontSize;

    /**
     * Array holding current subtitle data.
     * @type {module:src/text/Subtitles~SubtitleItem[]}
     */
    #subtitleData = [];

    /**
     * Stores additional renderer components (like subtitle line renderers).
     * @type {Object[]}
     */
    #renderers = [];

    /**
     * Array holding the index of the currently active subtitle.
     * @type {number}
     */
    #currentSubtitle = -1;

    /**
     * Creates an instance of the Subtitles component.
     * @param {module:src/core/Player} player            The main media player instance.
     * @param {module:src/ui/Popup}    parent            Reference to the parent instance (In this case the language popup).
     * @param {Object}                 [options]         Additional options.
     * @param {symbol}                 [options.apiKey]  Token for extended access to the player API.
     */
    constructor(player, parent, { apiKey }) {

        const htmlDefault = this.#config.allowHTML;

        this.#config = player.initConfig('subtitles', this.#config);

        if (!this.#config) return [false];

        // if allowHTML defaults are already set, prevent further change
        if (htmlDefault && apiKey) this.#config.allowHTML = htmlDefault;

        this.#player = player;
        this.#apiKey = apiKey;
        this.#mode = this.#config.mode === 'custom' && !this.#player.getClient('iPhone') ? 'custom' : 'native';
        this.#currentFontSize = this.#config.fontSize;

        this.#dom = new DomSmith({
            _ref: 'root',
            className: `vip-subtitles${this.#config.adaptLayout ? ' adapt-layout' : ''} font-${this.#currentFontSize.toLowerCase()}`,
            'data-sort': 40
        }, this.#player.dom.getElement(apiKey));

        this.#player.setState('media.activeTextTrack', { get: () => this.#currentSubtitle }, this.#apiKey);
        this.#player.subscribe('player/engine/set', this.#onEngineSet);

    }

    /**
     * Registers a renderer object that can handle the actual subtitle layout, like line placement etc.
     * @param {Object} renderer  The renderer to register.
     */
    registerRenderer(renderer) {

        this.#renderers.push(renderer);

    }

    /**
     * Called when the media data is ready, extracts the text track info from "mediaData.text" if present.
     * @param {module:src/core/Data~mediaItem}        mediaData       Current mediaData.
     * @param {module:src/core/Data~mediaItem_text[]} mediaData.text  The Subtitle section of the mediaData.
     * @listens module:src/core/Data#data/ready
     */
    #onDataReady = ({ text }) => {

        let preferredSubtitles = this.#player.getConfig('subtitles.preferredSubtitles');
        if (preferredSubtitles === true) preferredSubtitles = this.#player.getConfig('locale.lang');

        this.#reset();

        let selected = null;

        if (text && text.length) {
            // parse text tracks and filter out subtitles
            for (let i = 0; i < text.length; i += 1) {
                const track = text[i];
                if (track.type === 'subtitles' || track.type === 'captions') {

                    if (!track.language || typeof track.src === 'undefined') {
                        this.#player.data.error('Subtitle tracks must have language and src attributes set.');
                        break;
                    }

                    this.#subtitleData.push(track);

                    if (preferredSubtitles) {
                        if (track.language === preferredSubtitles) selected = i;
                    } else if (track.default && selected === null) selected = i;
                }
            }
        }

        this.#currentSubtitle = selected === null ? preferredSubtitles ? 0 : -1 : selected;

    };

    /**
     * Called when media is ready. If on iOS, we might preload the text tracks.
     * @listens module:src/core/Media#media/ready
     */
    #onMediaReady = async() => {

        this.#mediaElement = this.#player.media.getElement(this.#apiKey);

        // preload all tracks for iOS so we have them ready in the fullscreen menu as well
        if (this.#player.getClient('iOS') && !this.#preloaded) {
            this.#preloaded = true;

            for (let i = 0; i < this.#subtitleData.length; i += 1) {
                const sub = this.#subtitleData[i];
                if (!sub.src) continue;
                const loadTrack = await this.#loadTextTrack(sub.src, {
                    kind: 'subtitles',
                    mode: 'hidden',
                    label: this.#translate(sub.language),
                    lang: sub.language,
                    index: i.toString()
                });
                sub.loading = true;
                sub.track = loadTrack.track;
                sub.trackEle = loadTrack.trackEle;
            }
        }

        this.#toggleSubTitle({ index: this.#currentSubtitle });

    };

    /**
     * Called when "subtitles/update" is published, e.g. After external changes in textTracks.
     * Rebuilds subtitles data from the current text tracks and toggles the correct track if needed.
     * @listens module:src/text/Subtitles#subtitles/update
     */
    #onSubtitleUpdate = () => {

        this.#reset();

        const preferredSubtitles = this.#player.getConfig('subtitles.preferredSubtitles'),
              tracks = this.#player.media.getElement(this.#apiKey).textTracks,
              trackArray = Array.from(tracks),
              subs = trackArray.filter((current, index) => {
                  const exists = trackArray.findIndex(sData => sData.language === current.language);
                  return exists === index && (current.kind === 'subtitles' || current.kind === 'captions');
              });

        this.#subtitleData = subs.map(sub => {
            const { mode, label, language, id, kind } = sub;
            if (mode === 'showing' && this.#mode !== 'native') sub.mode = 'hidden';
            return {
                type: kind.toLowerCase(),
                id,
                label,
                language,
                default: sub.default,
                selected: mode === 'showing' || preferredSubtitles === language,
                track: sub,
                loaded: true
            };
        });

        // replace existing caption/subtitle text entries with the freshly detected ones
        const mediaData = this.#player.data.getMediaData(),
              existingText = mediaData?.text ?? [],
              otherText = existingText.filter(sub => sub.type !== 'captions' && sub.type !== 'subtitles'),
              updatedText = [
                  ...otherText,
                  ...subs.map(({ language, src, kind }) => ({
                      language,
                      src: src ?? null,
                      type: kind.toLowerCase()
                  }))
              ];

        this.#player.data.updateMediaData({ text: updatedText }, { property: 'text' });

        const currentSubtitle = tracks.length ? this.#subtitleData.findIndex(sub => sub.selected) : -1;
        this.#toggleSubTitle({ index: currentSubtitle });

    };

    /**
     * Changes active subtitle display after user interaction.
     * @param   {number}                                 index  The index of the new subtitle.
     * @returns {module:src/text/Subtitles~SubtitleItem}        Returns the selected subtitle data entry.
     * @fires module:src/text/Subtitles#subtitles/active
     */
    #toggleSubTitle = async({ index }) => {

        if (this.#subtitleData.length < 1) {
            if (index < 0) {
                this.#currentSubtitle = -1;
                this.#player.publish('subtitles/active', { index: -1, language: null, type: null }, this.#apiKey);
            }
            return;
        }

        const current = this.#currentSubtitle > -1 ? this.#subtitleData[this.#currentSubtitle] : { language: null, _ref: 'index-0' },
              selected = index > -1 ? this.#subtitleData[index] : { language: null, _ref: 'index-0' };

        if (current.loaded || current.loading) {
            if (this.#mode !== 'native') current.track.removeEventListener('cuechange', this.#renderSubTitles);
            current.track.mode = 'disabled';
        }

        if (index >= 0) {

            // reset renderer state before loading a new track
            this.#renderers.forEach(renderer => renderer.clear?.());

            if (!selected.loading && !selected.loaded && selected.src) {

                const loadTrack = await this.#loadTextTrack(selected.src, {
                    kind: 'subtitles',
                    label: this.#translate(selected.language),
                    lang: selected.language,
                    index: index.toString()
                });

                selected.loading = true;
                selected.track = loadTrack.track;
                selected.trackEle = loadTrack.trackEle;

            } else if (!selected.loading) {
                if (this.#mode !== 'native') selected.track.addEventListener('cuechange', this.#renderSubTitles);
                selected.track.mode = this.#mode === 'native' ? 'showing' : 'hidden';
            }

        } else {
            // disable, so tell the renderer to clear
            this.#renderers.forEach(renderer => renderer.clear());
        }

        this.#currentSubtitle = index;

        const { language, type } = selected;
        this.#player.publish('subtitles/active', { index, language, type }, this.#apiKey);

        return selected;

    };

    /**
     * Loads (and creates) a new text track. (see also {@link https://developer.mozilla.org/de/docs/Web/HTML/Element/track}).
     * @param   {string}                                                    [src]            The URL from where the text track should be loaded. If omitted, a new blank text track is created.
     * @param   {Object}                                                    [options]        Additional options.
     * @param   {string}                                                    [options.kind]   The kind of text track to create. Can be 'subtitles', 'captions', 'descriptions', 'chapters' or 'metadata'.
     * @param   {string}                                                    [options.lang]   Language of the track text data. It must be a valid BCP 47 language tag. If the kind attribute is set to subtitles, then lang must be defined.
     * @param   {string}                                                    [options.label]  A user-readable title of the text track which is used by the browser when listing available text tracks.
     * @param   {Object}                                                    [options.index]  The current id / index of the track.
     * @returns {Promise<{ track: TextTrack, trackEle: HTMLTrackElement }>}                  The newly created text track.
     * @throws  {Error}                                                                      Throws if attempting to load text tracks before the loadedmetadata event.
     */
    #loadTextTrack = async(src, { kind = 'captions', label, lang, index } = {}) => {

        let source = src;

        const ext = src.split(/[#?]/)[0].split('.').pop().trim().toLowerCase();
        if (ext === 'srt') {
            // convert srt to vtt
            try {
                const response = await fetch(src),
                      text = await response.text(),
                      vtt = srt2webvtt(text),
                      blob = new Blob([vtt], { type: 'text/vtt' });
                source = URL.createObjectURL(blob);
            } catch (e) {
                console.error(e); // eslint-disable-line no-console
            }
        }

        if (!source) {

            const track = this.#mediaElement.addTextTrack(kind || 'captions', label, lang);
            if (typeof index !== 'undefined') track.index = index;
            track.mode = 'showing'; // set track to display

            return { track };

        } else if (this.#mediaElement.src !== '' || this.#mediaElement.srcObject) {

            const track = document.createElement('track');
            track.kind = kind;
            track.srclang = lang;
            track.label = label;
            track.track.mode = 'hidden';

            if (ext === 'srt') track.objectURL = source;

            if (typeof index !== 'undefined') {
                track.setAttribute('data-index', index);
                track.track.index = index;
            }

            track.src = source;
            track.addEventListener('load', this.#onTextTrackLoaded);
            track.addEventListener('error', this.#onTextTrackError);

            this.#mediaElement.appendChild(track);

            return {
                track: track.track,
                trackEle: track
            };

        } throw new Error('[VisionPlayer] You cannot load text tracks before a media source was assigned.');

    };

    /**
     * This handler is called if a text track (containing subtitles) was loaded.
     * Shows the track (in native mode), or sets up an event handler for the "cuechange" event when in custom mode.
     * @param {Event} event  The original track 'load' event.
     */
    #onTextTrackLoaded = ({ target: trackEle }) => {

        this.#removeTextTrackEvents(trackEle);

        if (this.#player === null) return;

        const subtitleIndex = Number(trackEle.track.index),
              dataEntry = this.#subtitleData[subtitleIndex];

        dataEntry.trackEle = trackEle;
        dataEntry.track = trackEle.track;
        dataEntry.loading = false;
        dataEntry.loaded = true;

        if (this.#currentSubtitle === subtitleIndex) {
            if (this.#mode !== 'native') trackEle.track.addEventListener('cuechange', this.#renderSubTitles);
            trackEle.track.mode = this.#mode === 'native' ? 'showing' : 'hidden';
        }
    };

    /**
     * This handler is called if a text track (containing subtitles) was loaded.
     * Shows the track (in native mode), or sets up an event handler for the "cuechange" event when in custom mode.
     * @param {Event} event  The original track 'error' event.
     */
    #onTextTrackError = ({ target: trackEle }) => {

        this.#removeTextTrackEvents(trackEle);

        if (this.#player === null) return;

        const subtitleIndex = Number(trackEle.track.index);
        if (this.#subtitleData[subtitleIndex]) {
            this.#subtitleData[subtitleIndex].disabled = true;
            this.#subtitleData[subtitleIndex].error = true;
        }

    };

    /**
     * Removes both 'load' and 'error' events from a given track element.
     * @param {TextTrack} track  The track to remove events from.
     */
    #removeTextTrackEvents(track) {

        track.removeEventListener('load', this.#onTextTrackLoaded);
        track.removeEventListener('error', this.#onTextTrackError);

    }

    /**
     * When using the custom engine, this method listens to the cuechange event, and invokes any fitting renderer.
     * @param {Event} event  The "cuechange" event.
     */
    #renderSubTitles = ({ target }) => {

        const track = target.track || target;

        if (this.#mode === 'native') {
            this.#mode = 'custom';
            track.mode = 'hidden';
        }

        // check for cue changes and forward to renderer(s)
        const activeCues = Array.from(track.activeCues);
        this.#renderers.forEach(renderer => {
            if (renderer.update) renderer.update(activeCues);
        });
    };

    /**
     * If we are on iOS, and using the custom engine, we need special handling when iOS enters fullscreen.
     * As only the video, or audio tag *itself* can go fullscreen, we cannot display using the custom engine here.
     * Instead we have to switch to the native engine in this case, and back to "custom" when leaving fullscreen.
     * @param {Object} data   Event data.
     * @param {string} topic  The event topic.
     * @listens module:src/controller/FullScreen#fullscreen/enter
     * @listens module:src/controller/FullScreen#fullscreen/leave
     */
    #oniOSFullScreen = (data, topic) => {

        if (this.#currentSubtitle < 0) return;

        const currentTrack = this.#subtitleData[this.#currentSubtitle].track,
              { textTracks } = this.#mediaElement;

        if (topic?.endsWith('fullscreen/enter') && currentTrack && this.#mode !== 'native') {

            [...textTracks].forEach((track, index) => {
                track.mode = this.#currentSubtitle === index ? 'showing' : 'hidden';
            });

        } else if (topic?.endsWith('fullscreen/leave') /* && this.#mode !== "native" */) {
            // we have to check if the user changed any tracks while he was in fullscreen
            const currentIndex = [...textTracks].reduce((acc, track, index) => {
                if (track.mode === 'showing') {
                    track.mode = this.#mode === 'native' ? 'showing' : 'hidden';
                    return index;
                }
                track.mode = 'hidden';
                return acc;
            }, -1);

            if (currentIndex !== this.#currentSubtitle) this.#toggleSubTitle({ index: currentIndex });

        }

    };

    /**
     * Applies a new font size to the subtitle container and publishes the change.
     * @param {string} fontSize  The requested font size.
     */
    #applyFontSize = fontSize => {

        if (!fontSize || !this.#fontSizes.includes(fontSize)) return;

        this.#fontSizes.forEach(size => this.#dom.root.classList.remove(`font-${size.toLowerCase()}`));
        this.#currentFontSize = fontSize ?? this.#config.fontSize;
        this.#dom.root.classList.add(`font-${this.#currentFontSize.toLowerCase()}`);

    };

    /**
     * Translates a language code or "off" to a user-readable label.
     * @param   {string|null} language  The language code or null for "off".
     * @returns {string}                A translated language string or the code if not found.
     */
    #translate(language) {

        if (language === null) return this.#player.locale.t('misc.off');
        return this.#player.locale.getNativeLang(language);

    }

    /**
     * Returns the container element used by this component (for custom renderers to attach to).
     * @returns {HTMLElement} The root element of the subtitles component.
     */
    getElement() {

        return this.#dom.root;

    }

    /**
     * Handles clearing state when no media is present.
     */
    #onNoMedia = () => {

        this.#subtitleData = [];
        this.#currentSubtitle = -1;
        this.#renderers.forEach(renderer => renderer.clear?.());

    };

    /**
     * Invoked when the engine is switched.
     * @param {Object} data          The event data.
     * @param {Object} data.from     The previous engine.
     * @param {Object} data.to       The new engine.
     * @param {Object} data.options  The options for the engine switch.
     * @listens module:src/core/Player#player/engine/set
     */
    #onEngineSet = ({ from, to, options }) => {

        const fromCapabilities = from.capabilities.subtitles && from.capabilities.nativeVideoElement,
              toCapabilities = to.capabilities.subtitles && to.capabilities.nativeVideoElement;

        if (!fromCapabilities && toCapabilities) {
            if (!options.resume) {
                const subs = [
                    ['data/ready', this.#onDataReady],
                    ['data/nomedia', this.#onNoMedia],
                    ['media/ready', this.#onMediaReady],
                    ['subtitles/update', this.#onSubtitleUpdate],
                    ['subtitles/selected', this.#toggleSubTitle],
                    ['subtitles/fontsize', this.#applyFontSize]
                ];

                const hasFullscreen = 'fullscreenEnabled' in document || 'webkitFullscreenEnabled' in document;
                if (this.#player.getClient('iOS') && !hasFullscreen) {
                    subs.push(
                        ['fullscreen/enter', this.#oniOSFullScreen],
                        ['fullscreen/leave', this.#oniOSFullScreen]
                    );
                }

                this.#subscriptions = subs.map(([event, handler]) => this.#player.subscribe(event, handler));
            }
        }

        if (fromCapabilities && !toCapabilities) {
            if (!options.suspend) {
                this.#reset();
                this.#player.unsubscribe(this.#subscriptions);
            }
        }

    };

    /**
     * Resets the subtitles component to its initial state.
     */
    #reset() {

        this.#preloaded = false;
        this.#currentSubtitle = -1;
        this.#renderers.forEach(renderer => renderer.clear?.());
        this.#subtitleData.forEach(entry => {
            if (entry.trackEle) this.#removeTextTrackEvents(entry.trackEle);
            if (entry.track) {
                entry.track.mode = 'disabled';
                entry.track.removeEventListener('cuechange', this.#renderSubTitles);
                entry.track = null;
                entry.subtitleEle = null;
                URL.revokeObjectURL(entry.src);
            }
        });
        this.#subtitleData = [];

    }

    /**
     * This method removes all events, subscriptions and DOM nodes created by this component.
     */
    destroy() {

        this.#reset();
        this.#dom.destroy();
        this.#player.unsubscribe(this.#subscriptions);
        this.#player.unsubscribe('player/engine/set', this.#onEngineSet);
        this.#player.removeState('media.activeTextTrack', this.#apiKey);
        this.#player = this.#dom = this.#renderers = this.#apiKey = null;

    }

}

/**
 * Fired when the subtitle font size changes (custom engine).
 * @event module:src/text/Subtitles#subtitles/fontsize
 * @param {Object} fontInfo           Object containing fontInfo.
 * @param {Object} fontInfo.fontSize  New size of the font ('small', 'normal' or 'big').
 */

/**
 * Fired when a subtitle track becomes active.
 * @event module:src/text/Subtitles#subtitles/active
 * @param {Object}    subtitleInfo           Object containing information about the active Subtitle.
 * @param {Object}    subtitleInfo.index     Index of the active subtitle track.
 * @param {TextTrack} subtitleInfo.language  Language code (e.g. "en", "de") of the active subtitle.
 * @param {TextTrack} subtitleInfo.type      MIME type of the active subtitle (e.g. "text/vtt").
 */

/**
 * Fired when a new subtitle track was selected.
 * @event module:src/text/Subtitles#subtitles/selected
 * @param {Object}    subtitleInfo           Object containing information about the selected Subtitle.
 * @param {Object}    subtitleInfo.index     Index of the selected subtitle track.
 * @param {TextTrack} subtitleInfo.language  Language code (e.g. "en", "de") of the selected subtitle.
 * @param {TextTrack} subtitleInfo.type      MIME type of the selected subtitle (e.g. "text/vtt").
 */

/**
 * The Subtitles component listens to this event to react to outside changes to the available subtitles.
 * Rebuilds the internal subtitle data from the current TextTracks and fires the subtitles/update event. Used mainly for external control by components as dash and hls.
 * @event module:src/text/Subtitles#subtitles/update
 */

/**
 * Represents a single subtitle item.
 * @typedef {Object} module:src/text/Subtitles~SubtitleItem
 * @property {string}           type      The type of subtitle track, e.g. "subtitles" or "captions".
 * @property {string}           id        The identifier of the subtitle track (if any).
 * @property {string}           language  The language code of this subtitle track.
 * @property {boolean}          default   Whether this track is the default (as indicated by the media data).
 * @property {boolean}          selected  Whether this track is currently selected or active.
 * @property {TextTrack|Object} track     The underlying browser TextTrack (or custom track object).
 * @property {boolean}          loaded    True if this subtitle track has finished loading.
 */