Skip to content

Source: src/text/SubtitleRendererVTT.js

import DomSmith from '../../lib/dom/DomSmith.js';
import { htmlspecialchars, stripTags, sanitizeHTML } from '../../lib/util/sanitize.js';

/**
 * The SubtitleRendererVTT component handles the rendering and positioning of VTT-based subtitles.
 * It is responsible for displaying subtitle cues on screen, adapting to both horizontal and vertical layouts,
 * and supporting snapping to line grids or absolute positioning. The renderer also performs HTML sanitization.
 * @exports module:src/text/SubtitleRendererVTT
 * @requires lib/dom/DomSmith
 * @requires lib/util/sanitize
 * @author  Frank Kudermann - alphanull
 * @version 1.0.0
 * @license MIT
 */
export default class SubtitleRendererVTT {

    /**
     * Configuration options for the SubtitleRendererVTT component.
     * @type {module:src/text/SubtitleRendererVTT~SubtitleRendererVTTConfig}
     */
    #config = {
        forceSnapToLines: false
    };

    /**
     * The player instance that this renderer belongs to.
     * @type {Object}
     */
    #player;

    /**
     * Maximum number of horizontal subtitle lines.
     * @type {number}
     */
    #linesHorMax = 9;

    /**
     * Map of active horizontal lines. Tracks which lines are in use.
     * @type {Array<{ isActive: boolean }>}
     */
    #linesHorMap = new Array(this.#linesHorMax).fill(true).map(() => ({ isActive: false }));

    /**
     * Maximum number of vertical subtitle lines.
     * @type {number}
     */
    #linesVertMax = 15;

    /**
     * Map of active vertical lines. Tracks which lines are in use.
     * @type {Array<{ isActive: boolean }>}
     */
    #linesVertMap = new Array(this.#linesVertMax).fill(true).map(() => ({ isActive: false }));

    /**
     * DomSmith instance to manage the structure of the subtitle elements.
     * @type {module:lib/dom/DomSmith}
     */
    #dom;

    /**
     * A map containing all currently active subtitle cues.
     * @type {Map}
     */
    #currentCues;

    /**
     * Creates an instance of the SubtitleRendererVTT.
     * @param {module:src/core/Player}    player  Main player instance.
     * @param {module:src/text/Subtitles} parent  The parent Subtitles component.
     */
    constructor(player, parent) {

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

        this.#player = player;

        parent.registerRenderer(this);

        this.#dom = new DomSmith({
            className: 'vip-subtitles-container vip-subtitles-vtt',
            _nodes: [{
                _ref: 'fixed',
                className: 'vip-subtitle-fixed'
            }, {
                _ref: 'lines-vert',
                className: 'vip-subtitle-lines is-vertical',
                _nodes: [...Array(this.#linesVertMax)].map((item, index) => ({
                    className: 'vip-subtitle-line',
                    _ref: `line-ver-${index}`
                }))
            }, {
                _ref: 'lines-hor',
                className: 'vip-subtitle-lines',
                _nodes: [...Array(this.#linesHorMax)].map((item, index) => ({
                    className: 'vip-subtitle-line',
                    _ref: `line-hor-${index}`
                }))
            }]
        }, parent.getElement());

    }

    /**
     * Checks if the subtitle cue has text to be rendered.
     * @param   {Object}  cue  The subtitle cue object.
     * @returns {boolean}      True if the cue contains text, false otherwise.
     */
    canRender(cue) { // eslint-disable-line class-methods-use-this

        return cue && cue.text;

    }

    /**
     * Renders a subtitle cue on the screen, positioning it based on various options.
     * @param {Object} cue          The subtitle cue to render.
     * @param {Map}    currentCues  A map containing all currently active subtitle cues.
     */
    render(cue, currentCues) { // eslint-disable-line max-lines-per-function

        const { text, align, line, snapToLines, vertical, position, size, alignStart, alignEnd, alignMiddle } = cue,
              subTitleEleWrapper = document.createElement('div'),
              subTitleEle = document.createElement('p');

        this.#currentCues = currentCues;

        // Parsing and sanitizing subtitle text
        let parsedText;

        const { allowHTML, background } = this.#player.getConfig('subtitles');

        if (allowHTML === 'basic') parsedText = stripTags(text, '<b><i><u><c>');
        else if (allowHTML === 'all') parsedText = sanitizeHTML(text);
        else parsedText = htmlspecialchars(stripTags(text));

        // Applying background condition based on configuration
        if (background === 'auto' && text.match(/^<img([\w\W]+?)\/?>$/) || background === 'never') {
            subTitleEle.classList.add('no-bg');
        }

        subTitleEle.classList.add('vip-subtitle-text');

        if (allowHTML === 'none') {
            subTitleEle.textContent = parsedText;
        } else {
            subTitleEle.innerHTML = parsedText.replace(/\n/g, '<br>');
        }

        if (!snapToLines) this.#dom.fixed.appendChild(subTitleEleWrapper);
        subTitleEleWrapper.className = 'vip-subtitle-item';
        subTitleEleWrapper.appendChild(subTitleEle);
        subTitleEleWrapper.height = subTitleEleWrapper.getBoundingClientRect().height;
        subTitleEleWrapper.width = subTitleEleWrapper.getBoundingClientRect().width;

        const alignS = align === 'left' || align === 'start' || alignStart,
              alignE = align === 'right' || align === 'end' || alignEnd,
              alignM = align === 'center' || align === 'middle' || alignMiddle,
              isVertical = vertical === 'lr' || vertical === 'rl',
              isLr = vertical === 'lr',
              isRl = vertical === 'rl',
              autoLine = typeof line === 'undefined' || line === 'auto';

        if (alignS) subTitleEleWrapper.classList.add('align-start');
        if (alignE) subTitleEleWrapper.classList.add('align-end');
        if (alignM) subTitleEleWrapper.classList.add('align-middle');

        if (isLr) subTitleEleWrapper.classList.add('vertical-lr');
        if (isRl) subTitleEleWrapper.classList.add('vertical-rl');

        // Handling the position of the subtitle
        if (typeof position !== 'undefined' && position !== 'auto') {
            if (isVertical) {
                if (alignS || alignM) subTitleEle.style.top = `${position}%`;
                if (alignM) subTitleEle.style.top = `${50 - position}%`;
                if (alignE) subTitleEle.style.bottom = `${100 - position}%`;
            } else {
                if (alignS || alignM) subTitleEle.style.left = `${position}%`;
                if (alignM) subTitleEle.style.left = `${50 - position}%`;
                if (alignE) subTitleEle.style.right = `${100 - position}%`;
            }
        }

        // Handling size
        if (typeof size !== 'undefined' && size !== 'auto') {
            if (isVertical) subTitleEle.style.maxHeight = `${size}%`;
            else subTitleEle.style.maxWidth = `${size}%`;
        }

        // Handling snapping to lines (grid positioning)
        if (snapToLines || this.#config.forceSnapToLines) {

            const linesMap = isVertical ? this.#linesVertMap : this.#linesHorMap,
                  maxLines = linesMap.length,
                  targetLine = isLr
                      ? autoLine
                          ? 0
                          : Math.min(Math.max(line >= 0 ? line : maxLines + line - 1, 0), maxLines - 1)
                      : autoLine
                          ? maxLines - 1
                          : isRl
                              ? Math.min(Math.max(line >= 0 ? maxLines - line : maxLines + line - 1, 0), maxLines - 1)
                              : Math.min(Math.max(line >= 0 ? line : maxLines + line - 1, 0), maxLines - 1);

            // Determine and allocate the next available line
            const linesReversed = [...linesMap].reverse();

            if (linesMap[targetLine].isActive) {

                if (autoLine) {

                    const previousFreeLine = isLr
                        ? linesMap.findIndex(findLine => !findLine.isActive)
                        : linesReversed.findIndex(findLine => !findLine.isActive);

                    if (previousFreeLine > -1) {
                        this.#addLine(cue, isLr ? previousFreeLine : maxLines - previousFreeLine - 1, subTitleEleWrapper, isVertical);
                    } else {
                        this.#replaceOldestLine(cue, subTitleEleWrapper, isVertical);
                    }

                } else {

                    const nextFreeLine = isRl
                        ? linesReversed.findIndex(findLine => !findLine.isActive)
                        : linesMap.findIndex(findLine => !findLine.isActive);

                    if (nextFreeLine > -1) {
                        this.#addLine(cue, isRl ? maxLines - nextFreeLine - 1 : nextFreeLine, subTitleEleWrapper, isVertical);

                    } else {

                        const previousFreeLine = isRl
                            ? linesMap.findIndex(findLine => !findLine.isActive)
                            : linesReversed.findIndex(findLine => !findLine.isActive);

                        if (previousFreeLine > -1) {
                            this.#addLine(cue, isLr ? previousFreeLine : maxLines - previousFreeLine - 1, subTitleEleWrapper, isVertical);
                        } else {
                            this.#replaceOldestLine(cue, subTitleEleWrapper, isVertical);
                        }
                    }
                }
            } else {

                this.#addLine(cue, targetLine, subTitleEleWrapper, isVertical);

            }

        } else if (!autoLine) {
            // Absolute positioning when snapping is not enabled
            subTitleEleWrapper.style.position = 'absolute';

            if (isVertical) {
                if (!alignS && !alignE) subTitleEleWrapper.style.height = '100%';

                if (vertical === 'lr') {
                    subTitleEleWrapper.style.left = `${line}%`;
                } else {
                    subTitleEleWrapper.style.right = `${line}%`;
                }

            } else {

                subTitleEleWrapper.style.top = `${line}%`;
                if (!alignS && !alignE) subTitleEleWrapper.style.width = '100%';

            }

            this.#currentCues.set(cue, { ele: subTitleEleWrapper, renderer: this });
        }

    }

    /**
     * Replaces the oldest subtitle line with a new one.
     * @param {Object}      cue         The subtitle cue to replace.
     * @param {HTMLElement} ele         Element representing the subtitle.
     * @param {boolean}     isVertical  Whether the subtitle is displayed vertically.
     */
    #replaceOldestLine(cue, ele, isVertical) {

        let oldest = { startTime: Infinity, index: -1 };

        this.#currentCues.forEach((cueEntry, index) => {
            if (cueEntry.startTime <= oldest.startTime && isVertical === cueEntry.isVertical) {
                oldest = { startTime: cueEntry.startTime, cueEntry, index };
            }
        });

        oldest.cueEntry.ele.parentNode.innerHTML = '';
        this.#currentCues.delete(oldest.index);
        this.#addLine(cue, oldest.cueEntry.line, ele, isVertical);

    }

    /**
     * Adds a subtitle line to the grid.
     * @param {Object}      cue         The subtitle cue object.
     * @param {number}      index       The index of the current line.
     * @param {HTMLElement} ele         Element representing the subtitle.
     * @param {boolean}     isVertical  Whether the subtitle is displayed vertically.
     */
    #addLine(cue, index, ele, isVertical) {

        const parent = isVertical ? this.#dom[`line-ver-${index}`] : this.#dom[`line-hor-${index}`];
        let currentCue = this.#currentCues.get(cue);

        if (isVertical) {
            this.#linesVertMap[index].isActive = true;
            this.#linesVertMap[index].startTime = cue.startTime;
        } else {
            this.#linesHorMap[index].isActive = true;
            this.#linesHorMap[index].startTime = cue.startTime;
        }

        if (!currentCue) {
            currentCue = { renderer: this };
            this.#currentCues.set(cue, currentCue);
        }

        currentCue.line = index;
        currentCue.isVertical = isVertical;
        currentCue.ele = ele;
        currentCue.startTime = cue.startTime;

        parent.appendChild(ele);

    }

    /**
     * Clears the rendered subtitles and resets the line maps.
     */
    clear() {

        this.#linesHorMap = new Array(this.#linesHorMax).fill(true).map(() => ({ isActive: false }));
        this.#linesVertMap = new Array(this.#linesVertMax).fill(true).map(() => ({ isActive: false }));

        this.#dom.fixed.innerHTML = '';

        for (let i = 0; i < this.#linesHorMax; i += 1) {
            this.#dom[`line-hor-${i}`].innerHTML = '';
        }

        for (let i = 0; i < this.#linesVertMax; i += 1) {
            this.#dom[`line-ver-${i}`].innerHTML = '';
        }

    }

    /**
     * Removes a subtitle line from the grid.
     * @param {Object}  params             The parameters for removing a line.
     * @param {number}  [params.line]      The index of the line to remove.
     * @param {boolean} params.isVertical  Whether the subtitle is vertical.
     */
    remove({ line, isVertical }) {

        if (typeof line !== 'undefined') {
            const map = isVertical ? this.#linesVertMap[line] : this.#linesHorMap[line];
            map.isActive = false;
        }

    }

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

        this.clear();
        this.#dom.destroy();

    }

}