Skip to content

Source: src/core/Player.js

import { clone, extend, isBoolean, isArray, isObject, isString, isSymbol, isFunction, isUndefined } from '../../lib/util/object.js';
import { publish, subscribe, unsubscribe } from '../../lib/util/publisher.js';

/**
 * The `Player` class is the core entry point of the entire system. It is responsible for instantiating and configuring all registered subcomponents, setting up the DOM environment,
 * handling component state, managing the overall player configuration, and providing the main API for interacting with the player.
 * @exports module:src/core/Player
 * @requires lib/util/publisher
 * @requires lib/util/object
 * @author   Frank Kudermann - alphanull
 * @version  1.1.0
 * @license  MIT
 */
export default class Player {

    /**
     * Holds the global player configuration as the central storage.
     * @type     {Object}
     * @property {boolean}                                                   [secureApi=false]           If secure mode is enabled, certain APIs are restricted to internal use, like getComponents(), getElement() or getMediaElement(). In addition, the instance is sealed after components are intialised.
     * @property {string}                                                    [id='']                     Defines custom player id. To be used in conjunction with the pubsub event system to access events outside the player instance.  If omitted, the id will be autogenerated.
     * @property {boolean|module:src/core/Player~intersectionObserverConfig} [initOnIntersection=false]  Intersection Observer config. If `true`, the player will be initialized only if it is visible in the viewport (using Intersection Observer on the target element). If an object is provided, it will be used as the observer config.
     * @property {boolean}                                                   [initOnIdle=false]          If `true`, the player will be initialized only when the browser is idle.
     */
    #config = {
        player: {
            id: '',
            secureApi: false,
            initOnIntersection: false,
            initOnIdle: false
        }
    };

    /**
     * Internal client ID, used for connecting via PubSub.
     * Can be generated internally or filled with a custom value when instantiating.
     * @type {string}
     */
    #id;

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

    /**
     * Holds functions provided by other components which are private to this class.
     * @type {Object}
     */
    #privateApi = {};

    /**
     * Holds the initialized (live) component tree.
     * @type {Map}
     */
    #components = new Map();

    /**
     * Stores references to any namesets that+0may have been created using `addApi()`.
     * Used to freeze namespaces in secure mode.
     * @type {Set}
     */
    #namespaces = new Set();

    /**
     * Map holding the api methods for proxy access.
     * @type {Map}
     */
    #apiMethods = new Map();

    /**
     * The current state of the player.
     * @type {Object}
     */
    #state = {};

    /**
     * Object containing various information about the client. Can be used by other components for feature detection etc.
     * @type {Object}
     */
    #client;

    /**
     * Intersection Observer instance.
     * @type {IntersectionObserver}
     */
    #intersectionObserver;

    /**
     * Idle callback.
     * @type {number}
     */
    #idleCallback;

    /**
     * Creates a new Player instance.
     * @param  {string|HTMLElement} target          HTML element (or a selector) to which the player should be attached to (or which should be replaced).
     * @param  {Object}             mediaData       The media data object.
     * @param  {Object}             [playerConfig]  Player configuration object.
     * @throws {Error}                              If player id is invalid.
     * @throws {Error}                              If query selector is invalid.
     * @throws {Error}                              If player config is invalid.
     */
    constructor(target, mediaData, playerConfig = {}) {

        const id = playerConfig?.player?.id;
        if (id && !isString(id)) throw new Error('[VisionPlayer] Player id must be empty or a String');
        this.#id = this.#config.player.id = id ?? `${Player.#defaultConfig.player.idPrefix}${Player.#idCounter += 1}`;

        // add some rudimentary client info

        const ua = navigator.userAgent,
              { platform } = navigator;

        this.#client = {
            edge: Boolean(/Edg\//i.test(ua)) && window.chrome,
            safari: Boolean(/Safari/i.test(ua)) && navigator.vendor === 'Apple Computer, Inc.',
            iOS: /iPad|iPhone|iPod/.test(platform) || navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 4,
            iPhone: Boolean(/iPhone/i.test(platform)) || Boolean(/iPod/i.test(platform)),
            iPad: navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 4
        };

        // get target ele

        let targetEle;

        if (typeof target === 'string') {

            try { targetEle = document.querySelector(target); } // eslint-disable-line @stylistic/brace-style
            catch (e) { throw new Error(`[VisionPlayer] Invalid query selector: "${target}". - `, { cause: e }); }
            if (!targetEle) throw new Error(`[VisionPlayer] Query Selector: "${target}" did not yield any results.`);

        } else if (target instanceof HTMLElement) targetEle = target;

        if (!targetEle) throw new Error('[VisionPlayer] Invalid or undefined DOM ele to attach to. Cannot continue.');

        if (playerConfig && !isObject(playerConfig)) throw new Error('[VisionPlayer] Player config must be an object, ignoring.');

        // parse ele for any data to extract and merge mediadata & config from custom and extracted (if present)
        const { extractData, extractConfig } = Player.#extractElementData(targetEle),
              mergedData = extractData && isObject(mediaData) ? extend(extractData, mediaData) : mediaData || extractData,
              mergedConfig = extractConfig ? extend(extractConfig, playerConfig) : playerConfig;

        // merge config from defaults and custom config
        this.#config = extend({}, Player.#defaultConfig, this.#config, mergedConfig);

        // enable secure API by setting the apiKey
        if (Player.#defaultConfig.player.secureApi || this.#config.player.secureApi) {
            this.#apiKey = Symbol('apiKey');
        }

        const initOnIntersectionDefault = Player.#defaultConfig.player?.initOnIntersection,
              initOnIdleDefault = Player.#defaultConfig.player?.initOnIdle;

        // if defaults are already set, prevent further change
        if (this.#apiKey && typeof initOnIntersectionDefault !== 'undefined') this.#config.player.initOnIntersection = initOnIntersectionDefault;
        if (this.#apiKey && typeof initOnIdleDefault !== 'undefined') this.#config.player.initOnIdle = initOnIdleDefault;

        // Pass target ele to dom component via config.
        // TODO: not 100% elegant, maybe try to better avoid coupling to the Dom component
        if (!this.#config.dom) this.#config.dom = {};
        this.#config.dom._targetEle = targetEle;

        // this.#initialise(mergedData);

        if (this.#config.player.initOnIntersection) {
            const ioConfig = isBoolean(this.#config.player.initOnIntersection)
                ? { root: document, rootMargin: '250px', scrollMargin: '0px', threshold: 0 } // default config
                : this.#config.player.initOnIntersection;

            this.#intersectionObserver = new IntersectionObserver(entries => { if (entries[0].isIntersecting) this.#initialise(mergedData); }, ioConfig);
            this.#intersectionObserver.observe(targetEle);
        }

        if (this.#config.player.initOnIdle) {
            const options = this.#config.player.initOnIntersection ? { timeout: 2000 } : { timeout: 200 };
            if (window.requestIdleCallback) this.#idleCallback = window.requestIdleCallback(() => { this.#initialise(mergedData); }, options);
            else this.#idleCallback = window.requestAnimationFrame(() => { this.#initialise(mergedData); });
        }

        if (!this.#config.player.initOnIntersection && !this.#config.player.initOnIdle) this.#initialise(mergedData);
    }

    /**
     * Initializes the player. May be invoked by intersection observer or directly.
     * @param {Object} mergedData  The merged media data.
     */
    #initialise(mergedData) {

        if (this.#config.player.initOnIntersection) this.#intersectionObserver.disconnect();
        if (this.#config.player.initOnIdle) {
            if (window.requestIdleCallback) window.cancelIdleCallback(this.#idleCallback);
            else window.cancelAnimationFrame(this.#idleCallback);
        }

        this.#launchComponents();

        // make instance immutable in secure mode by freezing instance and all namespaces
        if (this.#apiKey) {
            Object.freeze(this);
            this.#namespaces.forEach(namespace => Object.freeze(namespace));
        }
        this.#namespaces.clear();

        this.#privateApi.mountDom();

        this.data.setMediaData(mergedData).catch(error => {
            // catch regular errors from setMediaData and AbortErrors
            // but throw the rest (like TypeError etc)
            const isDataError = error.name === 'DataError',
                  isMediaError = error instanceof MediaError || error.name === 'ExtendedMediaError';
            if (error.name !== 'AbortError' && !isDataError && !isMediaError) throw error;
        });
    }

    /**
     * Launches all registered components by recursively walking the component tree.
     * @private
     * @param {Object} parent          Parent component (equals `this` on the first call).
     * @param {Map}    registeredTree  Static "component registry" in Player.components.
     * @param {Map}    activeTree      Tree of currently instantiated components (under `this.components`).
     */
    #launchComponents(parent = this, registeredTree = Player.#registeredComponents, activeTree = this.#components) {

        for (const [key, entry] of registeredTree) {
            if (!entry.Component || activeTree.has(key)) continue; // skip placeholder with no Component
            const instance = new entry.Component(this, parent, { apiKey: this.#apiKey, config: entry.config });
            if (instance[0] === false) continue; // Skip component if it returns `[0]`;
            activeTree.set(key, { instance, children: new Map() });
            // recurse into children
            if (entry.children.size > 0) {
                this.#launchComponents(instance, registeredTree.get(key).children, activeTree.get(key).children);
            }
        }

    }

    /**
     * Recursively removes components from the component tree, optionally excluding a certain key.
     * @param {Object}   [comps]     The sub-tree of components to remove.
     * @param {string[]} [excludes]  If specified, skip removing components.
     */
    #removeComponents(comps = this.#components, excludes = []) {

        const entries = Array.from(comps.entries());
        // reverse order
        for (let i = entries.length - 1; i >= 0; i -= 1) {
            const [key, { instance, children }] = entries[i];
            if (excludes.includes(key)) continue;
            // first destroy children
            if (children.size > 0) this.#removeComponents(children);
            // then destroy this instance
            if (isFunction(instance.destroy)) instance.destroy();
            comps.delete(key);
        }

    }

    /**
     * Returns a "live" component instance by a path. **Not recommended for external usage**.
     * @param   {string}       componentPath  The path to the desired component, e.g. "ui.scrubber".
     * @param   {symbol}       apiKey         Token needed to grant access in secure mode.
     * @returns {Object|false}                The found component or `false`.
     * @throws  {Error}                       If apiKey does not match in secure mode.
     */
    getComponent(componentPath, apiKey) {

        if (this.#apiKey && this.#apiKey !== apiKey) {
            throw new Error('[Visionplayer] Secure mode: API access denied.');
        }

        const parts = componentPath.split('.');
        let node = { children: this.#components };

        for (const key of parts) {
            const next = node.children.get(key);
            if (!next) return false;
            node = next;
        }

        return node.instance || false;

    }

    /**
     * Initializes a configuration section. If the property does not exist yet, it is created using the provided defaults.
     * Used by components to retrieve and initialize their individual configuration.
     * @param   {string} key              The configuration key to initialize.
     * @param   {Object} [defaults=true]  The default values to apply (used with `extend`). If set to `true`, the current config object is reused as-is.
     * @returns {Object}                  The (possibly newly created) config section associated with the given key.
     */
    initConfig(key, defaults = true) {

        if (isUndefined(this.#config[key]) || isObject(defaults) && this.#config[key] !== false) {
            this.#config[key] = extend(defaults, this.#config[key]);
        }

        return clone(this.#config[key]);

    }

    /**
     * Gets the current config, as a whole or just a fragment based on the searchPath.
     * @param   {string}  [searchPath]  If provided, only return a fragment of the config matching the key. The key can also hold "sub-paths", which are separated by a dot. Example: `rootKey.childKey`.
     * @returns {?Object}               Returns a copy of the desired config object, or null if nothing was found.
     */
    getConfig(searchPath) {

        if (!searchPath) return clone(this.#config);

        const result = searchPath.split('.').reduce((o, key) => o?.[key] ?? null, this.#config);
        return clone(result);

    }

    /**
     * Extends the existing config with the provided object. Optionally re-initializes the player.
     * @param  {string|Object} config          The config object.
     * @param  {boolean}       [reinitialize]  If `true`, remove and re-launch components after changing config.
     * @throws {Error}                         If config is not an object.
     */
    setConfig(config, reinitialize) {

        if (!isObject(config)) throw new Error('[VisionPlayer] setConfig: config argument must be an object');

        let mediaData, currentMediaIndex;

        if (reinitialize) {
            mediaData = clone(this.data.getMediaData('all'));
            currentMediaIndex = this.data.getMediaData('index');
            this.#removeComponents(this.#components, ['dom']);
        }

        for (const key in config) {
            // need to iterate over properties in case instance is frozen
            if (!Object.prototype.hasOwnProperty.call(config, key)) continue;
            const source = config[key],
                  target = this.#config[key];
            this.#config[key] = extend(target, source);
        }

        if (reinitialize) {
            this.#launchComponents();
            this.#privateApi.refreshDom();
            this.data.setMediaData(mediaData, currentMediaIndex);
        }

    }

    /**
     * Returns player client information, either a property selected by the key or a clone of the whole client object.
     * @param   {string}         [key]  The key of the desired client property.
     * @returns {Object|boolean}        The resulting client info.
     */
    getClient(key) {

        return isUndefined(key) ? clone(this.#client) : this.#client?.[key];

    }

    /**
     * Returns player state, either a property selected by the key or a clone of the whole state object.
     * @param   {string}     [namespace]  The namespace of the desired state property.
     * @returns {Object|any}              The resulting state.
     */
    getState(namespace) {

        const result = namespace?.split('.').reduce((o, key) => o?.[key] ?? null, this.#state);
        return isUndefined(result) ? clone(this.#state) : clone(result);

    }

    /**
     * Adds an additional state to the state object.
     * @param  {string} namespace   The namespace of the new state.
     * @param  {Object} descriptor  A property descriptor which must contain a getter that returns the state value. State properties should be readonly, so a setter should not be defined.
     * @param  {symbol} apiKey      Token needed to grant access in secure mode.
     * @throws {Error}              If apiKey does not match in secure mode.
     */
    setState(namespace, descriptor, apiKey) {

        if (this.#apiKey && this.#apiKey !== apiKey) {
            throw new Error('[Visionplayer] Secure mode: API access denied.');
        }

        const nsArray = namespace.split('.');

        nsArray.reduce((acc, name, index) => {
            if (!acc[name] && index < nsArray.length - 1) {
                acc[name] = {};
            } else if (index === nsArray.length - 1) {
                descriptor.configurable = true;
                descriptor.enumerable = true;
                Object.defineProperty(acc, name, descriptor);
            }
            return acc[name];
        }, this.#state);

    }

    /**
     * Removes one or more states from the state object.
     * @param  {Array|string} namespaces  The namespace(s) of the state property to be removed.
     * @param  {symbol}       apiKey      Token needed to grant access in secure mode.
     * @throws {Error}                    If apiKey does not match in secure mode.
     */
    removeState(namespaces, apiKey) {

        if (this.#apiKey && this.#apiKey !== apiKey) {
            throw new Error('[Visionplayer] Secure mode: API access denied.');
        }

        const namespacesArray = isArray(namespaces) ? namespaces : [namespaces];
        namespacesArray.forEach(namespace => {
            const nsArray = namespace.split('.');
            nsArray.slice().reduce(acc => {
                const scope = acc.slice(0, -1).reduce((ac, name) => ac[name], this.#state),
                      currentName = acc.pop();
                if (acc.length === nsArray.length - 1 || nsArray.length === 1) delete scope[currentName];
                return acc;
            }, nsArray.slice(0));
        });

    }

    /**
     * Adds a component method to the player API. This method adds the api method to the *instance*,
     * as opposed to Player.setApi, which adds an API method to the *Class* itself.
     * **NOTE:** This does not check for existing methods with the same name, effectively allowing to override the API.
     * @param  {string}   namespace    The API name(space) of the method.
     * @param  {Function} method       A reference to the method itself.
     * @param  {boolean}  [isPrivate]  If `true`, the method will only be available on the Player class itself.
     * @param  {symbol}   [apiKey]     Token for extended access to the player API.
     * @throws {Error}                 If apiKey does not match in secure mode.
     */
    setApi(namespace, method, isPrivate, apiKey) {

        if (this.#apiKey) {
            const lastArg = arguments[arguments.length - 1], // eslint-disable-line prefer-rest-params
                  secApiKey = apiKey ?? (isSymbol(lastArg) && lastArg.description === 'apiKey' && lastArg);
            if (secApiKey !== this.#apiKey) throw new Error('[Visionplayer] Secure mode: API access denied.');
        }

        if (isPrivate === true) {
            this.#privateApi[namespace] = method;
            return;
        }

        const nsArray = namespace.split('.');
        nsArray.reduce((acc, name, index) => {
            if (!acc[name] && index < nsArray.length - 1) {
                acc[name] = {};
                this.#namespaces.add(acc[name]);
            } else if (index === nsArray.length - 1) {
                if (!acc[name]) {
                    // proxy method so we can change api later even on frozen namespace
                    acc[name] = (...args) => this.#apiMethods.get(namespace)?.(...args);
                }
                this.#apiMethods.set(namespace, method);
            }
            return acc[name];
        }, this);

    }

    /**
     * Removes a method from the players' *instance* API.
     * @param  {Array|string} namespaces  The name(space)s of the method to remove.
     * @param  {symbol}       apiKey      Token needed to grant access in secure mode.
     * @throws {Error}                    If apiKey does not match in secure mode.
     */
    removeApi(namespaces, apiKey) {

        if (this.#apiKey && this.#apiKey !== apiKey) {
            throw new Error('[Visionplayer] Secure mode: API access denied.');
        }

        const namespacesArray = isArray(namespaces) ? namespaces : [namespaces];

        namespacesArray.forEach(namespace => {

            if (this.#privateApi[namespace]) {
                this.#privateApi[namespace] = null;
                return;
            }

            const nsArray = namespace.split('.');
            nsArray.slice().reduce(acc => {
                const scope = acc.slice(0, -1).reduce((ac, name) => ac[name], this),
                      currentName = acc.pop();

                if (acc.length === nsArray.length - 1 || nsArray.length === 1) {
                    this.#apiMethods.delete(namespace);
                    try { delete scope[currentName]; } catch {} // Object was sealed so just skip it
                }

                return acc;

            }, nsArray.slice(0));
        });
    }

    /**
     * This should be used by all components when subscribing to some events.
     * In essence this just calls the external [publisher library]{@link module:lib/util/publisher}, but prefixes each event topic
     * with `'vip'` and the player id, so that they don't need to be added each time manually.
     * @param   {string}   topic                       The topic in which the subscriber is interested. Note that you can use wildcards, ie. The topic `*` will subscribe to all messages.
     * @param   {Function} handler                     The handler to execute when a matching message is found.
     * @param   {Object}   [options]                   Additional options.
     * @param   {boolean}  [options.async]             Specify if we should deliver this  message directly or with a timeout. Overrides the global setting.
     * @param   {boolean}  [options.handleExceptions]  Specify if we should catch any exceptions while sending this message. Overrides the global setting.
     * @param   {boolean}  [options.persist]           If this is set to `true`, the subscriber is notified of any former, persistent messages.
     * @param   {Function} [options.condition]         A function which receives this topic and data just before execution, if present. If this returns anything but `true`, the message is not delivered.
     * @param   {number}   [options.priority]          Specifies with which priority the handler should be executed. The higher the number, the higher the priority. Default is `0`, negative values are allowed.
     * @returns {number}                               The internal token of the new subscriber. Can be used for later unsubscribing.
     */
    subscribe(topic, handler, options) {

        return subscribe(`vip/${this.#id}/${topic}`, handler, options);

    }

    /**
     * This should be used by all components when UNsubscribing some events. In essence this just calls the external [publisher library]{@link module:lib/util/publisher},
     * but prefixes each event topic with "vip" and the player id, so that they don't need to be added each time manually.
     * @param {number|string|Array} topicOrToken  The token or the topic to unsubscribe. In the first case, these also can be in an array to support multiple unsubscriptions.
     * @param {Function}            [handler]     If specified, the message is only unsubscribed if the handler also matches.
     */
    unsubscribe(topicOrToken, handler) {

        if (typeof topicOrToken === 'number' || Array.isArray(topicOrToken)) {
            unsubscribe(topicOrToken, true);
        } else {
            unsubscribe(`vip/${this.#id}/${topicOrToken}`, handler, true);
        }

    }

    /**
     * Publishes a message to all matching subscribers. In essence this just calls the external [publisher library]{@link module:lib/util/publisher},
     * but prefixes each event topic with "vip" and the player id, so that they don't need to be added each time manually.
     * @param  {string}  topic                       The topic of this message, may be separated with `'/'` for subtopics.
     * @param  {Object}  [data]                      The data that should be sent along with the event. Can be basically any javascript object.
     * @param  {Object}  [options={}]                Additional options.
     * @param  {boolean} [options.async]             Specify if we should deliver this  message directly or with a timeout. Overrides the global setting.
     * @param  {boolean} [options.handleExceptions]  Specify if we should catch any exceptions while sending this message. Overrides the global setting.
     * @param  {boolean} [options.persist]           If this is set to `true`, the messages is saved for later subscribers which want to be notified of persistent messages.
     * @param  {boolean} [options.cancelable]        If set to `true`, this message cannot be cancelled (when sending synchronously).
     * @param  {symbol}  [apiKey]                    Token for extended access to the player API.
     * @throws {Error}                               If apiKey does not match in secure mode.
     */
    publish(topic, data, options, apiKey) {

        if (this.#apiKey) {
            const lastArg = arguments[arguments.length - 1], // eslint-disable-line prefer-rest-params
                  secApiKey = apiKey ?? (isSymbol(lastArg) && lastArg.description === 'apiKey' && lastArg);
            if (secApiKey !== this.#apiKey) throw new Error('[Visionplayer] Secure mode: API access denied.');
        }

        publish(`vip/${this.#id}/${topic}`, isSymbol(data) ? null : data, isSymbol(options) || !options ? { /* async: false */ } : options);

    }

    /**
     * This is the "cleanup method" which should be called when removing the player.
     * It is strongly recommended to do so to prevent memory leaks and possible other unwanted side effects.
     * This method removes all component, as well as the player root element.
     */
    destroy() {

        this.#removeComponents();
        if (this.#config.player.initOnIntersection) this.#intersectionObserver.disconnect();
        if (this.#config.player.initOnIdle) {
            if (window.requestIdleCallback) window.cancelIdleCallback(this.#idleCallback);
            else window.cancelAnimationFrame(this.#idleCallback);
        }

        try {
            this.#state = this.#components = this.#config = this.#namespaces = this.#apiKey = this.#apiMethods = null;
        } catch {} // Object was sealed so just skip it

    }

    /**
     * Array holding supported media formats, containing associated extensions and mime type.
     * @type {module:src/core/Player~mediaFormat[]}
     */
    static #formats = [
        {
            extensions: ['mp3'],
            mimeTypeAudio: ['audio/mpeg']
        },
        {
            extensions: ['mp4'],
            mimeTypeAudio: ['audio/mp4'],
            mimeTypeVideo: ['video/mp4']
        },
        {
            extensions: ['m4a'],
            mimeTypeAudio: ['audio/mp4', 'audio/x-m4a']
        },
        {
            extensions: ['m4v'],
            mimeTypeVideo: ['video/mp4']
        },
        {
            extensions: ['webm'],
            mimeTypeAudio: ['audio/webm'],
            mimeTypeVideo: ['video/webm']
        },
        {
            extensions: ['ogv'],
            mimeTypeVideo: ['video/ogg']
        },
        {
            extensions: ['oga'],
            mimeTypeAudio: ['audio/ogg']
        },
        {
            extensions: ['mkv'],
            mimeTypeVideo: ['video/x-matroska']
        },
        {
            extensions: ['mov'],
            mimeTypeVideo: ['video/quicktime']
        },
        {
            extensions: ['wav'],
            mimeTypeAudio: ['audio/wav', 'audio/x-wav']
        },
        {
            extensions: ['flac'],
            mimeTypeAudio: ['audio/flac']
        },
        {
            extensions: ['aif', 'aiff'],
            mimeTypeAudio: ['audio/aiff', 'audio/x-aiff']
        },
        {
            extensions: ['3gpp'],
            mimeTypeVideo: ['video/3gpp'],
            mimeTypeAudio: ['audio/3gpp']
        },
        {
            extensions: ['3gp'],
            mimeTypeVideo: ['video/3gpp'],
            mimeTypeAudio: ['audio/3gpp']
        }
    ];

    /**
     * Holds component objects available to be initialized with each player instance.
     * @type {Object}
     */
    static #registeredComponents = new Map();

    /**
     * Holds global defaults which apply to each instance created.
     * @type {Object}
     */
    static #defaultConfig = { player: { idPrefix: 'vip-' } };

    /**
     * Internal counter appended to autogenerated IDs.
     * @type {number}
     */
    static #idCounter = -1;

    /**
     * Adds (registers) a new component. This method must be invoked on the Class itself,
     * and only components added before creating a player instance will be considered.
     * @param {string} path       The path of the component to add. Path segments are separated by `'.'`.
     * @param {Object} Component  The component object or class to be added.
     * @param {Object} [config]   Additional component config to be injected at build time.
     */
    static addComponent(path, Component, config = {}) {

        let registry = Player.#registeredComponents;
        const parts = path.split('.');
        // ensure nested Maps for children
        for (let i = 0; i < parts.length; i += 1) {
            const key = parts[i];
            if (i === parts.length - 1) {
                // leaf: set actual component
                registry.set(key, { Component, config, children: new Map() });
            } else {
                // intermediate: ensure a placeholder entry
                if (!registry.has(key)) registry.set(key, { children: new Map() });
                registry = registry.get(key).children;
            }
        }

        // hook in case a component needs to do some additional setup
        if (isFunction(Component.initialize)) Component.initialize(Player, path);

    }

    /**
     * Removes a component from the global registry. This method must be invoked on the constructor, not the instance.
     * Only components added *before* creating a player instance will be considered.
     * @param {string} path  The path of the component to add. Path segments are separated by ".".
     */
    static removeComponent(path) {

        const parts = path.split('.'),
              lastPart = parts.slice(-1)[0];

        let node = { children: Player.#registeredComponents };

        for (const key of parts) {
            if (node.children.has(lastPart)) break;
            node = node.children.get(key);
        }

        node.children.delete(lastPart);

    }

    /**
     * Adds a component method to the player API. This method adds the api method to the *Class*,
     * as opposed to the instance setApi method (see below), which adds an API method to the *instance*.
     * **Warning:** this does not check for existing methods with the same name, effectively allowing to override the API.
     * @param {string}   key     The API name of the method.
     * @param {Function} method  A reference to the method itself.
     */
    static setApi(key, method) {

        Player[key] = method;

    }

    /**
     * Removes a method from the players' *Class* API.
     * @param {string} key  The name of the method to remove.
     */
    static removeApi(key) {

        if (key in Player) Player[key] = null;

    }

    /**
     * Sets the default config for new Player instances.
     * @param {Object} config  The default config to set globally.
     */
    static setDefaultConfig(config) {

        Player.#defaultConfig = extend(Player.#defaultConfig, config);

    }

    /**
     * Returns a cloned list of all supported media formats.
     * This list defines which file extensions and MIME types are recognized by the player.
     * Formats can be audio, video, or both. Use this method for inspection or debugging purposes.
     * @returns {module:src/core/Player~mediaFormat[]} A deep clone of the registered format definitions.
     */
    static getFormats() {

        return clone(Player.#formats);

    }

    /**
     * Adds a new format definition to the global format registry.
     * This allows components to register support for additional media types.
     * The format object should contain at least one file extension and a MIME type for either audio or video.
     * @param {module:src/core/Player~mediaFormat} format  The format object to register.
     */
    static addFormat(format) {

        Player.#formats.push(format);

    }

    /**
     * Tries to query and convert HTML Elements that match a certain selector into VisionPlayer instances.
     * Also tries to extract config and Media Data.
     * @param {string} selector  The selector to apply.
     */
    static autoLoad(selector = '[data-vision-player]') {

        if (`${selector}`.toLowerCase() === 'vision-player') {
            console.error(`[VisionPlayer] Invalid selector ${selector}`); // eslint-disable-line no-console
            return;
        }

        const elements = document.querySelectorAll(selector);

        if (!elements) return;

        for (const ele of elements) {

            if (ele.tagName === 'VISION-PLAYER' || ele.closest('vision-player')) continue;

            const { extractData, extractConfig } = Player.#extractElementData(ele);

            if (!extractData) {
                console.error(`[VisionPlayer] No valid media data found: ${ele}`); // eslint-disable-line no-console
                continue;
            }

            new Player(ele, extractData, extractConfig); // eslint-disable-line no-new
        }
    }

    /**
     * Parses elements and extracts meta- and config data. Looks for `data-vip-media` and `data-vip-config` attributes and tries to parse them as string or JSON.
     * If ele is a `video` or `audio` element, also tries to get the media data from additional element properties, including subtitles and poster image.
     * @param   {HTMLElement} ele  The element to parse.
     * @returns {Object}           Returns an object with extracted data & config from the element.
     * @throws  {Error}            Throws if no matching element is found or if the selector is invalid.
     */
    static #extractElementData(ele) {

        let extractConfig = ele.getAttribute('data-vip-config'),
            extractData = ele.getAttribute('data-vip-media');

        if (ele.tagName === 'VISION-PLAYER' || ele.closest('vision-player')) {
            throw new Error('[VisionPlayer] Can`t inject player: target element is part of a VisionPlayer instance');
        }

        try {
            if (extractConfig) extractConfig = JSON.parse(extractConfig);
            if (extractData) extractData = JSON.parse(extractData);
        } catch {}

        const isMediaElement = ele instanceof HTMLVideoElement || ele instanceof HTMLAudioElement;
        if (!isMediaElement) return { extractData, extractConfig };

        // ele is HTML Media Element, so extract all relevant data and then replace it with this player
        const eleData = {
            mediaType: ele instanceof HTMLAudioElement ? 'audio' : 'video',
            src: ele.src,
            overlays: ele.poster ? [{
                type: 'poster',
                src: ele.poster
            }] : null
        };

        // loop through children and extract additional text tracks and / or representations
        [...ele.children].forEach(child => {

            const { tagName, kind, label, src, srclang, type } = child;

            if (tagName === 'TRACK' && src && (kind === 'captions' || kind === 'subtitles')) {
                if (!eleData.text) eleData.text = [];
                eleData.text.push({ type: kind.toLowerCase(), language: srclang, src, label });
            }

            if (tagName === 'SOURCE' && src && !eleData.src) {
                if (!eleData.encodings) eleData.encodings = [];
                if (src) eleData.encodings.push({ src, type });
            }

        });

        const eleConfig = {
            media: {},
            dom: {}
        };

        const width = ele.getAttribute('width'),
              height = ele.getAttribute('height');

        if (ele instanceof HTMLAudioElement) eleConfig.dom.layout = 'controller-only';

        if (width) eleConfig.dom.width = width;
        if (height) eleConfig.dom.height = height;
        if (ele.title) eleData.title = ele.title;

        // modify config if needed based on tag properties
        eleConfig.controller = ele.controls && true;

        ['autoplay', 'loop', 'muted', 'preload', 'crossOrigin'].forEach(prop => {
            if (ele[prop]) {
                const configProp = prop === 'autoplay' ? 'autoPlay' : prop;
                eleConfig.media[configProp] = ele[prop];
            }
        });

        if (ele.disablePictureInPicture) eleConfig.pictureInPicture = false;
        if (ele.disableRemotePlayback) eleConfig.airPlay = eleConfig.chromeCast = false;

        if (ele.controlsList) {
            Array.from(ele.controlsList).forEach(control => {
                switch (control) {
                    case 'nofullscreen':
                        eleConfig.fullScreen = false;
                        break;
                    case 'noremoteplayback':
                        eleConfig.airPlay = eleConfig.chromeCast = false;
                        break;
                    case 'noplaybackrate':
                        eleConfig.playbackRate = false;
                        break;
                }
            });
        }

        // merge with existing data-vip-config and data-vip-media

        extractConfig = isObject(extractConfig)
            ? extend(eleConfig, extractConfig)
            : isString(extractConfig)
                ? extractConfig
                : eleConfig;

        extractData = isObject(extractData)
            ? extend(eleData, extractData)
            : isString(extractData)
                ? extractData
                : eleData;

        return { extractData, extractConfig };

    }

}

/**
 * Defines the Intersection Observer config.
 * @typedef {Object} module:src/core/Player~intersectionObserverConfig
 * @property {HTMLElement} [root=document]       The root element to observe.
 * @property {string}      [rootMargin='250px']  The margin around the root element.
 * @property {string}      [scrollMargin='0px']  The margin around the scroll container.
 * @property {number}      [threshold=0]         The threshold for the intersection ratio.
 */

/**
 * Defines a supported media format for the player. A format is characterized by its file extensions and optionally
 * by the MIME type for audio and/or video. This structure is used by the player to detect whether
 * a given media file is supported natively or by a plugin.
 * @typedef {Object} module:src/core/Player~mediaFormat
 * @property {string[]} extensions       List of associated file extensions (e.g. `['mp4', 'm4v']`).
 * @property {string}   [mimeTypeAudio]  Optional MIME type for audio streams (e.g. `'audio/mp4'`).
 * @property {string}   [mimeTypeVideo]  Optional MIME type for video streams (e.g. `'video/mp4'`).
 */