Skip to content

Source: src/settings/Language.js

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

/**
 * The Language component provides a UI that displays the current media (audio) language, and allows the user to change the language using a menu.
 * @exports module:src/settings/Language
 * @requires lib/util/Menu
 * @author   Frank Kudermann - alphanull
 * @version  1.0.0
 * @license  MIT
 */
export default class Language {

    /**
     * Holds the configuration options for the Language component.
     * @type     {Object}
     * @property {boolean} [showPlaceholder=false]  If enabled, display a 'not available' placeholder text if no languages are available, otherwise completely hide the menu.
     */
    #config = {
        showPlaceholder: false
    };

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

    /**
     * Reference to the parent instance.
     * @type {module:src/controller/Controller}
     */
    #parent;

    /**
     * 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;

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

    /**
     * Array of available language objects.
     * @type {module:src/settings/Language~langObj[]}
     */
    #languages = [];

    /**
     * The currently selected language.
     * @type {module:src/settings/Language~langObj}
     */
    #current = {};

    /**
     * Flag indicating if an external update (e.g. From another component) triggered the language change.
     * @type {boolean}
     */
    #isExternalUpdate = false;

    /**
     * Creates an instance of the Language component.
     * @param {module:src/core/Player} player            Reference to the 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 }) {

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

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

        this.#player = player;
        this.#parent = parent;
        this.#apiKey = apiKey;

        this.#menu = new Menu(
            this.#player,
            {
                target: this.#parent.getElement('top'),
                id: 'language',
                header: this.#player.locale.t('misc.language'),
                showPlaceholder: this.#config.showPlaceholder,
                verticalMenuThreshold: 2,
                selectMenuThreshold: 3,
                onSelected: sel => { this.#toggleLanguage(this.#languages[sel]); }
            }
        );

        this.#subscriptions = [
            ['data/source', this.#onDataSource],
            ['data/ready', this.#onDataReady],
            ['data/nomedia', () => { this.#menu.create([]); }],
            ['media/ready', this.#updateMenu],
            ['language/active', this.#updateMenu],
            ['language/update', this.#onLanguageUpdate]
        ].map(([event, handler]) => this.#player.subscribe(event, handler));

    }

    /**
     * Called when a media source has been selected.
     * @param {module:src/core/Media~metaData} metaData  The new media item data.
     * @listens module:src/core/Data#data/source
     */
    #onDataSource = metaData => {

        this.#current = metaData;

    };

    /**
     * Sets up the component as soon as the media data is available.
     * @param {Object} mediaData           The data object containing media variants.
     * @param {Array}  mediaData.variants  Array of variants.
     * @listens module:src/core/Data#data/ready
     */
    #onDataReady = ({ variants }) => {

        const languages = variants.reduce((acc, variant) => {
            if (variant.language && !acc.includes(variant.language)) {
                acc.push({ language: variant.language });
            }
            return acc;
        }, []);

        this.#isExternalUpdate = false;
        this.#languages = this.#mapLanguages(languages);
        this.#menu.create(this.#languages);

    };

    /**
     * Handles the "language/update" event. Used by Dash and Hls to update the menu with information gathered from the manifests
     * In this case, "externalUpdate" is set to `true`,
     * so a selection of the language will not use the internal data, but rather delegated to the respective (streaming) component.
     * @param {Object}                                 data            Object containing updated language information.
     * @param {module:src/settings/Language~langObj[]} data.languages  Array of language objects, containing language code, id and name (optional).
     * @param {module:src/settings/Language~langObj}   data.current    The currently selected language, containing language code and id (optional).
     * @listens module:src/settings/Language#language/update
     */
    #onLanguageUpdate = ({ languages, current }) => {

        this.#isExternalUpdate = true;
        this.#languages = this.#mapLanguages(languages);
        this.#menu.create(this.#languages);

        if (current) this.#updateMenu(current);

    };

    /**
     * Maps languages array to internal format, adding localized labels.
     * @param   {string[]}                               languages  Array with language codes to map.
     * @returns {module:src/settings/Language~langObj[]}            The mapped array.
     */
    #mapLanguages(languages) {

        return languages.map(langObj => {
            let nativeLang = this.#player.locale.getNativeLang(langObj.language);
            if (nativeLang === langObj.language) {
                nativeLang = this.#player.locale.getNativeLang(langObj.language.split('-')[0].toLowerCase());
                if (!langObj.langName) langObj.langName = langObj.language;
            }
            return {
                value: langObj.language,
                langId: langObj.langId,
                label: nativeLang + (langObj.langName && langObj.langName !== nativeLang ? ` (${langObj.langName})` : '')
            };
        });

    }

    /**
     * Updates the language menu to reflect the current language selection.
     * @param {module:src/core/Media~metaData} metaData  The updated stream object.
     * @listens module:src/settings/Language#language/active
     * @listens module:src/core/Media#media/ready
     */
    #updateMenu = ({ value, language, langId }) => {

        const langVal = value || language,
              selectedIndex = this.#languages.findIndex(lang => (typeof lang.langId === 'undefined' ? lang.value === langVal : lang.langId === langId));

        this.#current.language = langVal;
        this.#current.langId = langId;

        if (selectedIndex > -1) this.#menu.setIndex(selectedIndex);

    };

    /**
     * Handler called when the user changes language in the menu.
     * @param {module:src/settings/Language~langObj} langObj  The language code to set.
     * @fires  module:src/settings/Language#language/selected
     * @fires  module:src/settings/Quality#quality/language/refresh
     */
    #toggleLanguage(langObj) {

        const { value, langId } = langObj;

        if (value === this.#current.language && langId === this.#current.langId) return;

        this.#player.publish('language/selected', langObj, this.#apiKey);

        if (this.#isExternalUpdate) return;

        this.#player.publish('quality/language/refresh', langObj, this.#apiKey);

        const { quality } = this.#player.media.getMetaData(),
              result = this.#player.data.getPreferredMetaData({ preferredQuality: quality, preferredLanguage: value });

        if (!result) this.#player.data.error('[Language] Did not find language in media data');

        this.#player.setConfig({ data: { preferredLanguage: value } });
        this.#player.media.load(result, { rememberState: true, ignoreAutoplay: true, play: !this.#player.getState('media.paused') });

    }

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

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

    }

}

/**
 * This event is fired when the media (audio) language is changed by the user.
 * @event module:src/settings/Language#language/selected
 * @param {module:src/settings/Language~langObj} langObj  The selected language.
 */

/**
 * The Language component listens for this event to react to outside changes to the current lang.
 * Updates the menu accordingly. Used mainly for external control by components as dash and hls.
 * @event module:src/settings/Language#language/active
 * @param {module:src/settings/Language~langObj} langObj  The selected language.
 */

/**
 * The Language component listens for this event to react to outside changes to the available languages.
 * Rebuilds the menu accordingly. Used mainly for external control by components as dash and hls.
 * @event module:src/settings/Language#language/update
 * @param {Array<module:src/settings/Language~langObj>} languages  Array with available languages.
 */

/**
 * A single language data object.
 * @typedef  {Object}  module:src/settings/Language~langObj
 * @property {string} language    The language code.
 * @property {string} [langId]    The language id (used with Dash and Hls to switch streams correctly).
 * @property {string} [langName]  The language name (used for getting additional labelling information).
 */