Skip to content

Source: src/text/SubtitlesUi.js

import Menu from '../util/Menu.js';

/**
 * UI component for subtitle selection and font size control.
 * Keeps presentation separated from the subtitle engine and talks via events.
 * @exports module:src/text/SubtitlesUi
 * @requires src/util/Menu
 * @author   Frank Kudermann - alphanull
 * @version  1.0.0
 * @license  MIT
 */
export default class SubtitlesUi {

    /**
     * UI-related configuration derived from the subtitles config.
     * @type {Object}
     */
    #config = {
        mode: 'custom',
        fontSize: 'medium',
        showFontSizeControl: true,
        showPlaceholder: false
    };

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

    /**
     * Reference to the parent popup/controller.
     * @type {Object}
     */
    #parent;

    /**
     * Secret key only known to the player instance and initialized components.
     * @type {symbol}
     */
    #apiKey;

    /**
     * Reference to the subtitle selection menu.
     * @type {module:src/util/Menu}
     */
    #menu;

    /**
     * Reference to the font size menu (optional).
     * @type {module:src/util/Menu}
     */
    #fontMenu;

    /**
     * Available subtitle tracks (without the "off" entry).
     * @type {Array}
     */
    #tracks = [];

    /**
     * Currently active subtitle index (-1 for off).
     * @type {number}
     */
    #currentIndex = -1;

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

    /**
     * Current font size.
     * @type {string}
     */
    #currentFontSize = 'medium';

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

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

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

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

        this.#player = player;
        this.#parent = parent;
        this.#apiKey = apiKey;
        this.#currentFontSize = this.#config.fontSize || 'medium';

        this.#menu = new Menu(
            this.#player,
            {
                target: this.#parent.getElement('center'),
                id: 'subtitles',
                header: this.#player.locale.t('subtitles.header'),
                showPlaceholder: this.#config.showPlaceholder,
                selected: 0,
                highlighted: 0,
                verticalMenuThreshold: 2,
                selectMenuThreshold: 3,
                onSelected: this.#onMenuSelected
            }
        );

        if (this.#config.showFontSizeControl && this.#config.mode === 'custom') {
            this.#fontMenu = new Menu(
                this.#player,
                {
                    target: this.#menu.getDomSmithInstance().menu,
                    id: 'subtitlefont',
                    className: 'vip-menu-sub',
                    selectMenuThreshold: 1,
                    onSelected: this.#onFontSelected
                }
            );

            this.#fontMenu.create(this.#fontSizes.map(size => ({ value: size, label: this.#player.locale.t(`subtitles.fontSize_${size}`) })));
            this.#fontMenu.setIndex(this.#fontSizes.findIndex(size => size === this.#currentFontSize));
        }

        this.#subscriptions = [
            ['subtitles/active', this.#onActive],
            ['data/ready', this.#onDataReady],
            ['data/update', this.#onDataReady],
            ['data/nomedia', this.#onNoMedia]
        ].map(([event, handler]) => this.#player.subscribe(event, handler));

        // initialize empty menus
        this.#menu.create([]);

    }

    /**
     * Resets UI state and builds menu when new media data arrives.
     * @param {Object}                                data       Event data.
     * @param {module:src/core/Data~mediaItem_text[]} data.text  Updated text tracks.
     * @param {string}                                topic      Event topic.
     * @listens module:src/core/Data#data/ready
     * @listens module:src/core/Data#data/update
     */
    #onDataReady = ({ text } = {}, topic) => {

        if (topic?.endsWith('data/update') && typeof text === 'undefined') return;

        this.#resetMenus();

        this.#tracks = Array.isArray(text)
            ? text
                .filter(track => track && (track.type === 'subtitles' || track.type === 'captions' || track.kind === 'subtitles' || track.kind === 'captions') && track.language)
                .map(track => ({
                    language: track.language,
                    label: track.label || this.#player.locale.getNativeLang(track.language) || track.language,
                    type: track.type || track.kind,
                    disabled: false
                }))
            : [];

        this.#createMenu();

    };

    /**
     * Resets UI state when no media is available.
     * @listens module:src/core/Data#data/nomedia
     */
    #onNoMedia = () => {

        this.#resetMenus();

    };

    /**
     * Handles active track notifications from the engine.
     * @param {Object} data        Active payload.
     * @param {number} data.index  Active track index.
     * @listens module:src/text/Subtitles#subtitles/active
     */
    #onActive = ({ index = -1 } = {}) => {

        this.#currentIndex = typeof index === 'number' ? index : -1;
        this.#menu.setIndex(this.#currentIndex + 1);

    };

    /**
     * Handles subtitle menu selections.
     * @param {number} sel  Selected menu index (includes "off" at position 0).
     * @fires module:src/text/Subtitles#subtitles/selected
     */
    #onMenuSelected = sel => {

        const trackIndex = sel - 1,
              track = trackIndex >= 0 ? this.#tracks[trackIndex] : null;

        this.#player.publish('subtitles/selected', {
            index: trackIndex,
            language: track ? track.language : null,
            type: track ? track.type : null
        }, this.#apiKey);

    };

    /**
     * Handles font size menu selections.
     * @param {number} index       Selected index.
     * @param {Object} item        Selected menu item.
     * @param {string} item.value  The font size value.
     * @fires module:src/text/Subtitles#subtitles/fontsize
     */
    #onFontSelected = (index, { value }) => {

        this.#currentFontSize = value ?? this.#config.fontSize;
        this.#player.publish('subtitles/fontsize', this.#currentFontSize, this.#apiKey);

    };

    /**
     * Builds the menu entries based on current tracks.
     */
    #createMenu() {

        const items = [{ value: null, label: this.#player.locale.t('misc.off') }].concat(
            this.#tracks.map(track => ({
                value: track.language,
                label: track.label || track.language,
                disabled: track.disabled
            }))
        );

        this.#menu.create(items);
        this.#menu.setIndex((this.#currentIndex ?? -1) + 1);

    }

    /**
     * Syncs font menu UI selection.
     * @param {string} fontSize  The font size to select.
     */
    #setFontMenu(fontSize) {

        const idx = this.#fontSizes.findIndex(size => size === fontSize);
        if (idx > -1) this.#fontMenu.setIndex(idx);
        this.#currentFontSize = fontSize;

    }

    /**
     * Resets menus to an empty state and restores font selection.
     */
    #resetMenus() {

        this.#tracks = [];
        this.#currentIndex = -1;

        this.#menu.create([]);
        this.#menu.setIndex(0);

        if (this.#fontMenu) this.#setFontMenu(this.#currentFontSize);

    }

    /**
     * Cleans up menus and subscriptions.
     */
    destroy() {

        if (this.#fontMenu) this.#fontMenu.destroy();
        this.#menu.destroy();
        this.#player.unsubscribe(this.#subscriptions);
        this.#player = this.#parent = this.#menu = this.#fontMenu = this.#apiKey = null;

    }

}