Skip to content

Source: src/locale/Locale.js

import { isObject, extend } from '../../lib/util/object.js';
import langMap from './langMap.js';
import langTranslations from './langTranslations.js';
import convertTime from '../util/convertTime.js';

/**
 * The Locale component manages the player’s translation layer.
 * It stores and resolves locale data, enables the registration of custom locales before instance creation, and provides a runtime translation function.
 * This component does not affect UI layout directly but underpins all text translations in UI components.
 * @exports module:src/locale/Locale
 * @requires lib/util/object
 * @requires src/locale/langMap
 * @requires src/locale/langTranslations
 * @requires src/util/convertTime
 * @author  Frank Kudermann - alphanull
 * @version 1.0.0
 * @license MIT
 */
export default class Locale {

    /**
     * Holds the instance configuration for this component.
     * @type     {Object}
     * @property {string} [lang]  Sets the default UI language. Affects which locale is used for translation. If not present, the player tries to find the best suitable locale based on browser language, with a final fallback to `'en'`..
     */
    #config;

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

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

    /**
     * Creates a new instance of the Locale component.
     * @param {module:src/core/Player} player            Reference to the VisionPlayer instance.
     * @param {module:src/core/Player} parent            Reference to the parent instance.
     * @param {Object}                 [options]         Additional options.
     * @param {symbol}                 [options.apiKey]  Token for extended access to the player API.
     */
    constructor(player, parent, { apiKey }) {

        const defaultLocale = Object.keys(Locale.#localeData).find(lang => lang === navigator.language || navigator.language.startsWith(`${lang}-`)) || Locale.#defaults.lang;

        this.#config = player.initConfig('locale', {
            lang: defaultLocale
        });

        if (!Locale.#localeData[this.#config.lang]) this.#config.lang = Locale.#defaults.lang;

        this.#player = player;

        this.#apiKey = apiKey;

        // add RTL support if necessary
        this.#player.dom.getElement(apiKey).classList.toggle('is-rtl', Boolean(Locale.#localeConfig[this.#config.lang]?.rtl));

        this.#player.setApi('locale.t', this.#translate, this.#apiKey);
        this.#player.setApi('locale.getLocalizedTime', this.#getLocalizedTime, this.#apiKey);
        this.#player.setApi('locale.getNativeLang', this.#getNativeLang, this.#apiKey);

    }

    /**
     * Returns a translation based on the delivered translation path.
     * @param   {string} translatePath  Path to the desired translation. Path segments are separated by ".", for example "tree.subtree.property".
     * @param   {Object} vars           Additional vars to translate.
     * @returns {string}                Returns the desired translation. If not found, the key itself is returned instead.
     * @throws  {Error}                 If type of vars data mismatches.
     */
    #translate = (translatePath, vars) => {

        const { lang } = this.#config;

        /**
         * Parses translated string, replaces template fragments with matching vars.
         * If nothing is found, the template fragment is left "as is".
         * @private
         * @memberof module:src/util/Locale
         * @inner
         * @param   {string} translation  The translated string to parse.
         * @param   {Object} variables    The vars to replace.
         * @returns {string}              Parsed string.
         */
        function parseVars(translation, variables) {

            const templateRegex = new RegExp('${(\\w*)}', "g"); // eslint-disable-line

            return translation.replace(
                templateRegex,
                (m, key) => Reflect.has(variables, key) ? variables[key] : m
            );

        }

        /**
         * Searches for a translation.
         * @private
         * @memberof module:src/util/Locale
         * @inner
         * @param   {string}           path  The path of the desired translation.
         * @returns {string | boolean}       Returns the translation string, or "false" if nothing was found.
         */
        function findTranslation(path = '') {

            const pathArray = path.split('.');

            let result = Locale.#localeData[lang];

            while (pathArray.length && (result = result[pathArray[0]])) {
                pathArray.shift();
            }

            return result && typeof result === 'string' || result === '' ? result : false;

        }

        let translated;

        if (vars) {

            if (!isObject(vars)) throw new Error('[Locale] Vars must be an object');

            // plurals? (uses fixed "count" var)
            if (typeof vars.count === 'undefined' || vars.count === null) {
                translated = findTranslation(translatePath);
            } else {
                if (isNaN(parseFloat(vars.count)) || !isFinite(vars.count)) throw new Error('[Locale] Count must be a number');
                translated = vars.count === 1 ? findTranslation(translatePath) : findTranslation(`${translatePath}_plural`);
            }

            // replace vars if translation was found
            if (translated !== false) translated = parseVars(translated, vars);

        } else translated = findTranslation(translatePath);

        return translated === false ? translatePath : translated;

    };

    /**
     * Returns a localized string representing a time duration in hours, minutes, and seconds.
     * This method takes a time value in seconds and converts it to a human-readable format,
     * applying language-specific singular or plural units for hours, minutes, and seconds,
     * depending on the current locale. If `null` or `undefined` is passed, an empty string is returned.
     * @param   {string|number|module:src/util/convertTime~smpteFormat} timeArg  The input value in one of the three accepted formats: seconds, a string or a module:src/core/Player~smpteFormat Object.
     * @returns {string}                                                         Localized time string.
     */
    #getLocalizedTime = timeArg => {

        if (typeof timeArg === 'undefined' || timeArg === null) return '';

        let timeLocalized = '';

        const time = convertTime(Number(timeArg)).smpte;

        if (time.h) timeLocalized += `${time.h} ${this.#player.locale.t('time.hour', { count: time.h })} `;
        timeLocalized += `${time.m} ${this.#player.locale.t('time.minute', { count: time.m })} `;
        timeLocalized += `${time.s} ${this.#player.locale.t('time.second', { count: time.s })}`;

        return timeLocalized;

    };

    /**
     * Translates a language identifier (ISO 639-3 or legacy code) to its native language name.
     * Accepts ISO 639-3 codes (e.g. `'deu'`), legacy codes (e.g. `'ger'`), or 2-letter codes (if present in `langMap`).
     * Returns the native name of the language as displayed in UI elements like language or subtitle menus.
     * If no translation is available, the original language code is returned.
     * @param   {string} lang  The language identifier to translate.
     * @returns {string}       The native name of the language, or the identifier itself if unknown.
     */
    #getNativeLang = lang => { // eslint-disable-line class-methods-use-this

        const translateKey = langMap[lang] || lang,
              translateLang = langTranslations[translateKey];

        return translateLang || lang;
    };

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

        this.#player.removeApi(['locale.t', 'locale.getLocalizedTime', 'locale.getNativeLang'], this.#apiKey);
        this.#player = this.#apiKey = null;

    }

    /**
     * Holds the default locale information.
     * @private
     * @memberof module:src/util/Locale
     * @type {Object}
     */
    static #defaults = { lang: 'en' };

    /**
     * Holds the actual locale data.
     * @private
     * @memberof module:src/util/Locale
     * @type {Object}
     */
    static #localeData = {};

    /**
     * Holds additional global locale configuration.
     * @private
     * @memberof module:src/util/Locale
     * @type {Object}
     */
    static #localeConfig = {};

    /**
     * Component initializer, called by the player automatically when adding this component.
     * Exposes some API calls to the Player constructor.
     * @param {module:src/core/Player} Player  Reference to the player Constructor.
     */
    static initialize(Player) {

        Player.setApi('addLocale', Locale.#addLocale.bind(Locale));
        Player.setApi('setDefaultLocale', Locale.#setDefaultLocale.bind(Locale));
        Player.setApi('setLocaleConfig', Locale.#setLocaleConfig.bind(Locale));

    }

    /**
     * Adds another locale object. This can be a whole object, or just a fragment, specified by the langpath.
     * Note* This method has to be called on the player *constructor* (not the instance), and only has an effect *before* a VisionPlayer instance is created.
     * @param  {Object} translations  JSON like data structure with key / value pairs, extends the existing locale object. Make sure the language code is at the root level of the translations object, like this: `{ "de": { ... } }`.
     * @throws {Error}                Throws error when enountering invalid locale data.
     */
    static #addLocale(translations) {

        if (!isObject(translations)) throw new Error('Locale Data must be Javascript Object.');

        Locale.#localeData = extend(Locale.#localeData, translations);

    }

    /**
     * Sets the player locale.
     * Note* This method has to be called on the player *constructor* (not the instance), and only has an effect *before* a VisionPlayer instance is created.
     * @param {string} lang  The language to set, represented by a (known) language code.
     */
    static #setDefaultLocale(lang) {

        Locale.#defaults.lang = lang;

    }

    /**
     * Sets the config for a certain locale.
     * @param {string} lang    The language tied to the config.
     * @param {Object} config  Config object, currently, specifiying rtl languages is supported.
     */
    static #setLocaleConfig(lang, config) {

        Locale.#localeConfig[lang] = config;

    }

}