Skip to content

Source: src/core/Data.js

import { clone, extend, isObject, isArray, isString, isNumber, isUndefined } from '../../lib/util/object.js';
import AsyncTask from '../../lib/util/AsyncTask.js';
import DataError from '../util/DataError.js';

/**
 * The `Data` component is responsible for managing, parsing, and validating the media metadata used by the player.
 * It supports single media items as well as complex playlist structures, including multiple quality levels, encodings, subtitle tracks, and overlays.
 * It exposes an API for dynamic switching of variants or media entries, integrates MIME-type and capability checks, and handles fallback scenarios for unplayable or malformed data.
 * This component ensures that only valid and playable streams are used, while offering flexibility through configuration options such as lenient parsing or skipping invalid entries.
 * Additionally, it dispatches lifecycle events to signal when media is ready, parsed, or in case of errors.
 * **Note:** this component is **mandatory** and required for normal player operations, so it cannot be switched off.
 * @exports module:src/core/Data
 * @requires lib/util/object
 * @requires lib/util/AsyncTask
 * @requires src/util/DataError
 * @author   Frank Kudermann - alphanull
 * @version  1.0.1
 * @license  MIT
 */
export default class Data {

    /**
     * Contains configuration options for how the media is parsed and validated.
     * @type     {Object}
     * @property {boolean}               [skipInvalidItems=false]           Ignore (skip) invalid media items rather than throwing an error.
     * @property {boolean}               [skipInvalidRepresentations=true]  Ignore invalid representations instead of throwing errors for them.
     * @property {boolean}               [skipEmptyData=false]              Ignore empty media data (eg is `null` or `undefined`) and do not throw an error. Useful if you want to assign mediaData not immediatly on player instantiation.
     * @property {boolean}               [disablePlayCheck=false]           Skip any play checks and trust the source to be playable.
     * @property {boolean}               [lenientPlayCheck=false]           Check only file extensions, but do not use `canPlay`.
     * @property {boolean}               [lenientPlayCheckBlob=true]        Assume blob: URLs are valid without checking.
     * @property {number|string|boolean} [preferredQuality]                 Quality setting that should be preferred when loading new media, or `false` to not set such a preference and use autoselect instead.
     * @property {string|boolean}        [preferredLanguage]                Language that should be preferred when loading new media, `true` to use the player locale as preferred default or `false` to not set any preference at all.
     */
    #config = {
        skipInvalidItems: true,
        skipInvalidRepresentations: false,
        skipEmptyData: false,
        disablePlayCheck: false,
        lenientPlayCheck: false,
        lenientPlayCheckBlob: true,
        preferredQuality: false,
        preferredLanguage: true
    };

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

    /**
     * Contains the players' mediaData.
     * @type     {Object}
     * @property {module:src/core/Data~mediaItem[]} media              Array of media items.
     * @property {number}                           currentMediaIndex  Index of the currently active media item.
     * @property {string|Object<string, string>}    [title]            Title of the playlist.
     * @property {string|Object<string, string>}    [titleSecondary]   Secondary title of the playlist.
     */
    #data = {
        media: [],
        currentMediaIndex: 0,
        title: '',
        titleSecondary: ''
    };

    /**
     * Reference to the root DOM element.
     * @type {HTMLElement}
     */
    #rootEle;

    /**
     * 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 Async Task instance. Used to handle async tasks, which can be cancelled, resolved or rejected.
     * @type {module:lib/util/AsyncTask}
     */
    #setMediaDataTask;

    /**
     * Timeout ID for data error publishing. Used to prevent race conditions when component is destroyed.
     * @type {number}
     */
    #dataErrorTimeoutId;

    /**
     * The previous mediaData argument. Used for handling repeated calls to setMediaData.
     * If two calls have the same mediaData, both will get the same promise. If the new call is different, the old one is cancelled.
     */
    #previousDataArg;

    /**
     * Creates an instance of the Data 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.
     * @throws {Error}                                    If trying to disable this component.
     */
    constructor(player, parent, { apiKey }) {

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

        if (!this.#config) throw new Error('[Visionplayer] Cannot disable the Data component by configuration.');

        if (this.#config.preferredLanguage === true) this.#config.preferredLanguage = player.getConfig('locale.lang');

        this.#rootEle = player.dom.getElement(apiKey);
        this.#apiKey = apiKey;
        this.#player = player;

        // Expose relevant methods via the player's API.
        this.#player.setApi('data.getMediaData', this.#getMediaData, this.#apiKey);
        this.#player.setApi('data.setMediaData', this.#setMediaData, this.#apiKey);
        this.#player.setApi('data.setMediaIndex', this.#setMediaIndex, this.#apiKey);
        this.#player.setApi('data.getPreferredMetaData', this.#getPreferredMetaData, this.#apiKey);
        this.#player.setApi('data.error', this.#dataError, this.#apiKey);

    }

    /**
     * Depending on the selector, returns a specific media item (selector is a number representing the index in the media playlist), the entire data object (selector = 'all'), the current media item (selector = 'current') or the currently active index (selector = 'index').
     * @param   {number|'all'|'index'}                                   [selector]  Either a media based on numerical index, 'all' for all data, or 'index' for the current media index.
     * @returns {module:src/core/Data~mediaItem|Object|number|undefined}             Returns one media item, the entire data object, the current media item, or the current numerical index, depending on the selector.
     */
    #getMediaData = (selector = this.#data.currentMediaIndex) => {

        if (isNumber(selector) && selector >= 0 && selector < this.#data.media.length) {
            return clone(this.#data.media[selector]);
        }

        if (selector === 'all') return clone(this.#data);
        if (selector === 'index') return clone(this.#data.currentMediaIndex);

        this.#dataError(`getMediaData: invalid selector: ${selector}`);

    };

    /**
     * Assigns media data to the player instance. `mediaData` can be a valid data object or a string, in this case the player will either
     * - try to to load it as a media resource directly (if the extension matches a known type) or
     * - try to load it as a mediaData object in JSON format.
     * @param   {module:src/core/Data~mediaItem|string} mediaData  Can be either a string representing an url or a media object.
     * @param   {number}                                [index=0]  The index of the media item to switch to.
     * @returns {Promise}                                          A promise that resolves with the loaded and parsed data or rejects when loading or parsing failed.
     * @fires module:src/core/Data#data/parsed
     * @fires module:src/core/Data#data/nomedia
     */
    #setMediaData = async(mediaData, index = 0) => {

        const prevTask = this.#setMediaDataTask;

        if (mediaData && mediaData === this.#previousDataArg && prevTask?.status === 'pending') return prevTask.promise;
        if (prevTask?.status === 'pending') await prevTask.cancel().catch(() => {});
        if (this.#config.skipEmptyData && !mediaData) return clone(this.#data.media);

        this.#setMediaDataTask = new AsyncTask();

        let mData = this.#previousDataArg = mediaData;

        this.#data = {
            media: [],
            currentMediaIndex: 0,
            title: '',
            titleSecondary: ''
        };

        if (isString(mData) && !this.#addPlayableMetaData({ src: mData })) {
            // presumably no media file, so lets try to load as data
            try {
                mData = await this.#loadMediaData(mData);
            } catch (error) {
                this.#rootEle.classList.add('has-no-media');
                this.#dataError('DATA_ERR_MEDIA_DATA_DENIED', error);
                this.#player.publish('data/nomedia', this.#apiKey);
                this.#setMediaDataTask.reject(new DataError(error.message, { code: 'DATA_ERR_MEDIA_DATA_DENIED', cause: error }));
                return this.#setMediaDataTask.promise;
            }
        }

        const isPlaylist = isArray(mData?.media),
              mediaDataArray = isPlaylist ? mData.media : Array.isArray(mData) ? mData : [mData],
              parsed = [];

        let loadError = {};

        for (const nextID of mediaDataArray) {
            try {
                const mediaItem = await this.#parseMediaDataItem(nextID);
                if (mediaItem) parsed.push(mediaItem);
            } catch (error) {
                /* if (!this.#config.skipInvalidItems || mData.media.length < 2) */ loadError = error;
            }
        }

        if (parsed.length) {

            if ((loadError.code || loadError.message) && !this.#config.skipInvalidItems) this.#dataError('DATA_ERR_INVALID_PLAYLIST_ITEM', loadError);

            this.#data = isPlaylist ? mData : {};
            this.#data.currentMediaIndex = 0;
            this.#data.media = parsed;

            this.#setMediaDataTask.resolve(clone(parsed));
            this.#player.publish('data/parsed', this.#data, { async: false }, this.#apiKey);

            this.#setMediaIndex(index).catch(error => {
                if (error.name !== 'AbortError' && !(error instanceof MediaError || error.name === 'ExtendedMediaError')) throw error;
            });

        } else {

            this.#setMediaDataTask.reject(loadError);
            this.#rootEle.classList.remove('is-audio', 'is-video');

            if (loadError.code || loadError.message) this.#dataError(loadError.code, loadError);

            if (!this.#data.media.length) {
                this.#rootEle.classList.add('has-no-media');
                this.#player.publish('data/nomedia', this.#apiKey);
            }

        }

        return this.#setMediaDataTask.promise;

    };

    /**
     * Switches playback to another media item, with `index` representing the position of the media to switch to in the internal playlist.
     * Additional options can influence switching behavior in the `Media` component, like trying to restore the previous seek position (`rememberState`)
     * or controlling if and how the media is played after switching (`ignoreAutoplay`, `play`).
     * @param   {number}                                 index      The index of the media item to switch to.
     * @param   {module:src/core/Media~mediaLoadOptions} [options]  Optional config to set switch behavior.
     * @returns {Promise}                                           A promise that resolves with the loaded metadata of the switched item data or rejects when loading failed.
     * @fires    module:src/core/Data#data/ready
     */
    #setMediaIndex = async(index, options = {}) => {

        if (!isNumber(index) || index < 0 || index >= this.#data.media.length) {
            this.#dataError('DATA_ERR_INVALID_INDEX', { code: 'DATA_ERR_INVALID_INDEX', message: `setMediaIndex: invalid index: ${index}` });
            throw new DataError(`setMediaIndex: invalid index: ${index}`, { code: 'DATA_ERR_INVALID_INDEX' });
        }

        const { preferredQuality, preferredLanguage } = this.#player.getConfig('data'), // live update instead local config
              autoLang = preferredLanguage === true ? this.#player.getConfig('locale.lang') : false,
              mediaSource = this.#getPreferredMetaData({
                  preferredQuality,
                  preferredLanguage: autoLang || preferredLanguage
              }, this.#data.media[index]);

        if (mediaSource) {

            const selectedMedia = this.#data.media[index];
            this.#data.currentMediaIndex = index;

            this.#rootEle.classList.remove('has-no-media', 'is-audio', 'is-video');
            this.#rootEle.classList.add(selectedMedia.mediaType === 'audio' ? 'is-audio' : 'is-video');

            this.#player.publish('data/source', clone(mediaSource), { async: false }, this.#apiKey);
            this.#player.publish('data/ready', clone(selectedMedia), { async: false }, this.#apiKey);

            return await this.#player.media.load(mediaSource, options);

        } this.#dataError('DATA_ERR_STREAM_NOT_FOUND');

    };

    /**
     * Returns the best matching media variant, considering the user's language and quality preferences.
     * Falls back to the closest possible match if an exact match isn't found.
     * In this case, language preferences have priority over quality preferences.
     * @param   {Object}                         [options]                    Optional preferences.
     * @param   {string}                         [options.preferredLanguage]  The language which is preferred.
     * @param   {string}                         [options.preferredQuality]   The quality setting which is preferred.
     * @param   {module:src/core/Data~mediaItem} [mediaItem]                  Media item to search for, defaults to current active item.
     * @returns {module:src/core/Media~metaData}                              Metadata for a matching media object, or 'false' if nothing suitable was found.
     */
    #getPreferredMetaData = (
        { preferredQuality, preferredLanguage } = {},
        mediaItem = this.#data.media[this.#data.currentMediaIndex]) => {

        const currentMetaData = this.#player.media.getMetaData(),
              prefQuality = isUndefined(preferredQuality) ? currentMetaData?.quality : preferredQuality,
              prefLanguage = isUndefined(preferredLanguage) ? currentMetaData?.language : preferredLanguage,
              matchedQualities = [],
              matchedLanguages = [];

        if (!mediaItem?.variants?.length) return false; // nothing there to find

        let result, perfectMatch,
            defaultItem = {};

        const matchHeight = arr => {
            let playerHeight = this.#player.getState('ui.playerHeight');
            if (!playerHeight && this.#player.ui) {
                this.#player.ui.resize();
                playerHeight = this.#player.getState('ui.playerHeight');
            }

            playerHeight *= window.devicePixelRatio ?? 1;
            const sorted = arr.sort((a, b) => a.height - b.height);
            result = sorted.find(({ height }) => height * 1.2 > playerHeight);
            return result || arr[arr.length - 1]; // still nothing, so just return the last entry (presumable highest qual below player size)
        };

        // spread out array so that each representation is a separate item
        // a bit slower, but easier to parse
        const searchItems = mediaItem.variants.flatMap(variant => {
            if (variant.representations) {
                return variant.representations.map(source => {
                    const { representations, ...base } = variant; // eslint-disable-line no-unused-vars
                    return { ...base, ...source };
                });
            } else if (variant.src) {
                const { representations, ...base } = variant; // eslint-disable-line no-unused-vars
                return [base];
            }
            return [];
        });

        for (const searchItem of searchItems) {
            if (prefQuality && prefQuality === searchItem.quality && prefLanguage === searchItem.language) {
                perfectMatch = searchItem;
                break;
            }
            // add to list of preferred languages or qualities
            if (prefLanguage && prefLanguage === searchItem.language) matchedLanguages.push(searchItem);
            if (prefQuality && prefQuality === searchItem.quality) matchedQualities.push(searchItem);
            // determine default variant if multiple video are supplied
            if (searchItem.default) defaultItem = searchItem;
        }

        if (perfectMatch) {

            result = perfectMatch; // found a perfect match, fine!

        } else if (matchedLanguages.length) {

            if (defaultItem.language === prefLanguage) {
                // found preferredLanguage, but not the quality, so lets see if there was a default set
                result = defaultItem;
            } else {
                // OK, so maybe we can try to match height information with player height
                result = matchHeight(matchedLanguages);
            }

        } else if (matchedQualities.length) {
            // found preferred quality, but no language information, so use default if available or just first entry
            result = defaultItem.quality && defaultItem.quality === prefQuality ? defaultItem : matchedQualities[0];
        } else if (defaultItem.default) {
            // no preferred matches, but at least a default
            result = defaultItem;
        } else {
            // found no directly preferred quality nor language, we can try to find a matching source based on height
            const hasHeight = searchItems.find(searchItem => searchItem.height);
            result = hasHeight ? matchHeight(searchItems) : searchItems[0];
        }

        if (result) result.mediaType = mediaItem.mediaType;

        return result;

    };

    /**
     * Loads media data definition from a given URL into the player.
     * @param   {string}                                  url  The URL pointing to a JSON file describing the media data.
     * @returns {Promise<module:src/core/Data~mediaItem>}      A promise that resolves to the loaded media data.
     */
    async #loadMediaData(url) { // eslint-disable-line class-methods-use-this

        const res = await fetch(url);

        if (!res.ok) {
            const message = `Data could not be loaded due to network error: ${res.status}`;
            throw new DataError(message, { code: `HTTP_ERROR_${res.status}` });
        }

        const mediaData = await res.json();

        return mediaData;

    }

    /**
     * Parses a single media item (either as URL or object) and returns a normalized media data structure.
     * This includes resolving variants & representations, validating MIME types, applying quality/height heuristics,
     * and optionally loading remote JSON if the input is a URL pointing to a JSON file.
     * This method is used internally by `#setMediaData()` and supports both individual items and full playlists.
     * @param   {string|module:src/core/Data~mediaItem}   mediaItem  The media data to parse, either as object or URL string.
     * @returns {Promise<module:src/core/Data~mediaItem>}            A Promise resolving to a valid mediaItem structure.
     * @throws  {Error}                                              If parsing fails or no playable source is found.
     */
    async #parseMediaDataItem(mediaItem) {

        let parsed = {},
            variants;

        if (isString(mediaItem)) {

            const mData = this.#addPlayableMetaData({ src: mediaItem }, [], parsed);

            if (mData) {
                parsed.variants = [mData];
                return parsed;
            }

            if (mediaItem.startsWith('blob:')) {
                return {
                    variants: [{ src: mediaItem }]
                };
            }

            // safe to mutate!
            parsed = await this.#loadMediaData(mediaItem); // eslint-disable-line require-atomic-updates

        } else if (isObject(mediaItem)) {

            parsed = mediaItem;

        } else throw new DataError('Media data item must be an Object or a String', { code: 'DATA_ERR_INVALID_TYPE' });

        const { src, mimeType, encodings, representations } = parsed;

        if (src) {
            if (!isString(src)) throw new DataError('Src must be a String', { code: 'DATA_ERR_INVALID_TYPE' });
            const parsedVariant = { src };
            if (mimeType) parsedVariant.mimeType = mimeType;
            parsed.variants = [parsedVariant];
            delete parsed.src;
            delete parsed.mimeType;
        }

        if (encodings) {
            if (!isArray(encodings)) throw new DataError('Encodings must be an Array', { code: 'DATA_ERR_INVALID_TYPE' });
            parsed.variants = [{ encodings }];
            delete parsed.encodings;
        }

        if (representations) {
            if (!isArray(representations)) throw new DataError('Representations must be an Array', { code: 'DATA_ERR_INVALID_TYPE' });
            parsed.variants = [{ representations }];
            delete parsed.representations;
        }

        if (parsed.variants) ({ variants } = parsed); else throw new DataError('No variants found in media data', { code: 'DATA_ERR_NO_VARIANTS' });

        // check what type of data variants is -> if it's a string or an object, convert to array
        if (isString(variants)) variants = [{ src: variants }];
        else if (isObject(variants)) variants = [variants];
        else if (!isArray(variants)) throw new DataError('Variants must be a string, object or array', { code: 'DATA_ERR_INVALID_TYPE' });

        // if it's an empty array, throw an error
        if (!variants.length) throw new DataError('Variants array is empty', { code: 'DATA_ERR_NO_VARIANTS' });

        // reduces source array by only including representations being actually playable
        // also convert representations to coherent full format
        variants = variants.reduce((parsedArray, variant) => {

            const isValidObject = Boolean(isObject(variant) && (variant.src || variant.encodings || variant.representations));

            if (isString(variant)) {

                if (variant.startsWith('blob:')) {
                    return {
                        variants: [{ src: mediaItem }]
                    };
                }

                this.#addPlayableMetaData({ src: variant }, parsedArray, parsed);

            } else if (isValidObject && !isArray(variant.representations)) {

                this.#addPlayableMetaData(variant, parsedArray, parsed);

                if (variant.height && !variant.quality) variant.quality = variant.height;

            } else if (isValidObject && isArray(variant.representations)) {

                if (!variant.representations.length) throw new DataError('Representations array is empty', { code: 'DATA_ERR_NO_REPRESENTATIONS' });

                // validate each variant object and add a media type
                variant.representations = variant.representations.reduce((reps, source) => {

                    let checkedSource;
                    if (isString(source)) checkedSource = this.#addPlayableMetaData({ src: source }, reps, parsed);
                    else if (isObject(source)) checkedSource = this.#addPlayableMetaData(source, reps, parsed);
                    else throw new DataError('Object source must be object or string', { code: 'DATA_ERR_INVALID_TYPE' });

                    if (checkedSource.height && !checkedSource.quality) checkedSource.quality = checkedSource.height;

                    return reps;

                }, []);

                if (variant.representations.length) {

                    if (variant.representations.length === 1) {
                        const extended = extend(variant, variant.representations[0]);
                        delete extended.representations;
                        if (extended.height && !extended.quality) extended.quality = extended.height;
                        parsedArray.push(extended);
                    } else {
                        parsedArray.push(variant);
                    }

                } else if (!this.#config.skipInvalidRepresentations) throw new DataError('Invalid Representations found', { code: 'DATA_ERR_STREAM_NOT_PLAYABLE' });

            } else {

                if (isObject(variant) && !variant.src) throw new DataError('Variant src is missing', { code: 'DATA_ERR_NO_SRC' });
                throw new DataError('Variant src must be a string or array', { code: 'DATA_ERR_INVALID_TYPE' });

            }

            return parsedArray;

        }, []);

        if (!variants.length) throw new DataError('No playable stream found', { code: 'DATA_ERR_STREAM_NOT_PLAYABLE' });

        parsed.variants = variants;

        return parsed;

    }

    /**
     * This helper function searches a source object for a playable source, i.e. A source which has a mime type which *at least*
     * results in a 'maybe' using the engines 'canPlay' test method. Representations which return a 'probably' are preferred.
     * If no mime type is provided, the method tries to guess it from the source path file ext.
     * @param   {module:src/core/Media~metaData}       metaDataArg  The metaData object to search.
     * @param   {module:src/core/Media~metaData[]}     [pushArray]  If provided, the playable metaData item is pushed there on success.
     * @param   {module:src/core/Data~mediaItem}       [mediaItem]  If provided, the detected mediaType is assigned to it.
     * @returns {module:src/core/Media~metaData|false}              The playable metaData if found, otherwise false.
     * @throws  {Error}                                             Throws various Errors when parsing fails.
     */
    #addPlayableMetaData(metaDataArg, pushArray, mediaItem) {

        let mediaType = mediaItem?.mediaType,
            metaData = metaDataArg,
            mimeType, canPlay;

        /**
         * Small helper function to extract the suffix from any given url.
         * @param   {string} url  The url to parse.
         * @returns {string}      The Suffix (excluding the '.').
         */
        const getSuffix = url => {
            const path = url.split(/[?#]/)[0],
                  idx = path.lastIndexOf('.');
            return idx > -1 ? path.slice(idx + 1).toLowerCase() : '';
        };

        const checkPlayable = ({ src, mimeType: type, drmSystem }) => {

            const ext = getSuffix(src),
                  formats = this.#player.constructor.getFormats(),
                  format = type
                      ? formats.find(fmt => fmt.mimeTypeVideo?.includes(type.split(';')[0]) || fmt.mimeTypeAudio?.includes(type.split(';')[0]))
                      : formats.find(fmt => fmt.extensions.includes(ext)),
                  mimeAudio = format?.mimeTypeAudio?.[0],
                  mimeVideo = format?.mimeTypeVideo?.[0];

            if (this.#config.disablePlayCheck && ext !== 'json') {
                mediaType = 'video'; // just assume it is a video
                return 'couldbe';
            }

            if (format) {
                if (!mediaType) mediaType = mimeAudio && !mimeVideo ? 'audio' : 'video';
                mimeType = mediaType === 'video' ? mimeVideo : mimeAudio;
                if (this.#config.lenientPlayCheck || this.#config.lenientPlayCheckBlob && src.startsWith('blob:')) return 'couldbe';
            }

            if (mediaType === 'audio' && (type || format.mimeTypeAudio)) {
                return this.#player.media.canPlay({ mimeType: type || mimeAudio, drmSystem });
            }

            if (mediaType === 'video' && (type || format.mimeTypeVideo)) {
                return this.#player.media.canPlay({ mimeType: type || mimeVideo, drmSystem });
            }

            return false;

        };

        const { src, encodings } = metaData;

        if (encodings) {

            if (!isArray(metaData.encodings)) throw new DataError('Encodings must be an array', { code: 'DATA_ERR_INVALID_TYPE' });

            let found;

            for (const encoding of encodings) {

                if (!encoding.src) throw new DataError('Encodings must contain at least a src (and preferably a type).', { code: 'DATA_ERR_NO_SRC' });

                const checkPlay = checkPlayable(encoding); // try to guess from url ext

                if (checkPlay === 'probably' || (checkPlay === 'maybe' || checkPlay === 'couldbe') && !found) {
                    found = metaData;
                    canPlay = checkPlay;
                    metaData.mimeType = mimeType;
                    metaData = extend(metaData, encoding);
                }

                if (checkPlay === 'probably' || checkPlay === 'maybe') break; // perfect, use that
            }

        } else if (src) {

            if (!isString(src)) throw new DataError('Object src must be a string', { code: 'DATA_ERR_INVALID_TYPE' });

            canPlay = checkPlayable(metaData);
            metaData.mimeType = mimeType;

        } else throw new DataError('Object metadata has no src or encodings', { code: 'DATA_ERR_NO_SRC' });

        if (!canPlay) return false;

        if (pushArray) pushArray.push(metaData);
        if (mediaItem) mediaItem.mediaType = mediaType;

        return metaData;

    }

    /**
     * This method should be called when a 'data error' occurs. In contrast to a 'media error' which usually indicates problems
     * with playing back certain media (for example, due to network problems or decoding errors),
     * a data error usually means that the player - or some component - wasn't able to correctly and completely parse the data
     * it was provided. A typical case would be some essential things missing from the media data, like no src.
     * Also publishes an appropriate event, so other components can for example display an error message to the user.
     * @param {string} messageOrKey  The message text or translate key.
     * @param {Error}  [error]       Optional error object.
     * @fires module:src/core/Data#data/error
     */
    #dataError = (messageOrKey, error) => {

        const translatePath = `errors.data.${messageOrKey}`,
              translated = this.#player.locale.t ? this.#player.locale.t(translatePath) : translatePath,
              translationFound = this.#player.locale.t && translated !== translatePath;

        // Clear any existing timeout to prevent race conditions
        clearTimeout(this.#dataErrorTimeoutId);

        this.#dataErrorTimeoutId = setTimeout(() => {

            this.#player.publish('data/error', {
                code: error?.code ?? (translationFound ? messageOrKey : 'DATA_ERR'),
                message: translationFound ? translated : this.#player.locale.t('errors.data.unknown'),
                messageSecondary: error ? error.message || error.code : null
            }, this.#apiKey);

        }, 250);

    };

    /**
     * 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.
     */
    destroy() {

        this.#setMediaDataTask?.cancel();
        clearTimeout(this.#dataErrorTimeoutId);
        this.#player.removeApi(['data.getMediaData', 'data.setMediaData', 'data.setMediaIndex', 'data.getPreferredMetaData', 'data.error'], this.#apiKey);
        this.#data = this.#player = this.#apiKey = this.#setMediaDataTask = null;

    }
}

/**
 * The mediaItem is a representation of a single media data item.
 * @typedef  {Object} module:src/core/Data~mediaItem
 * @property {string|Object<string, string>}             [title]           Title of the media item (can be multilingual).
 * @property {string|Object<string, string>}             [titleSecondary]  Secondary title of the media player in multiple languages.
 * @property {module:src/core/Data~mediaItem_variants[]} variants          List of available video variants.
 * @property {module:src/core/Data~mediaItem_text[]}     [text]            List of subtitle tracks.
 * @property {module:src/core/Data~mediaItem_overlay[]}  [overlays]        List of  overlays displayed in the player.
 * @property {module:src/core/Data~mediaItem_chapter[]}  [chapters]        List of video chapters.
 * @property {module:src/core/Data~mediaItem_thumbnail}  [thumbnails]      The thumbnail representation of this media item.
 */

/**
 * The variants item is a representation of a single variant data item.
 * @typedef  {Object} module:src/core/Data~mediaItem_variants
 * @property {string}        language                              Language of the variant.
 * @property {boolean}       [default]                             Whether this variant is marked as default.
 * @property {Object[]}      representations                       Available  representations for this variant.
 * @property {number}        [representations.height]              Video height in pixels.
 * @property {number}        [representations.width]               Video width in pixels (optional).
 * @property {number|string} [representations.quality]             Quality designation (if available).
 * @property {Object[]}      [representations.encodings]           List of encodings available for this resolution.
 * @property {string}        [representations.encodings.mimeType]  The MIME type of the video encoding.
 * @property {string}        [representations.encodings.src]       Source URL of the encoded video.
 */

/**
 * The overlaysItem is a representation of a single overlay data item.
 * @typedef  {Object} module:src/core/Data~mediaItem_overlay
 * @property {string} type         Type of overlay (e.g., 'poster', 'image').
 * @property {string} src          Source URL of the overlay.
 * @property {string} [className]  Optional CSS class for styling the overlay.
 * @property {string} [alt]        Alternative text for the overlay image.
 * @property {string} [placement]  Placement of the overlay on the screen.
 * @property {number} [margin]     Margin around the overlay.
 * @property {number} [cueIn]      Timestamp (in seconds) when the overlay appears.
 * @property {number} [cueOut]     Timestamp (in seconds) when the overlay disappears.
 */

/**
 * The textItem is a representation of a single text data item.
 * @typedef  {Object} module:src/core/Data~mediaItem_text
 * @property {string}  [type='subtitles']  Type of subtitle track (e.g., 'subtitles', 'captions' etc).
 * @property {string}  language            Language of the subtitle track.
 * @property {string}  src                 Source URL of the subtitle file.
 * @property {boolean} [default]           Whether this is the default subtitle track.
 */

/**
 * The chapterItem is a representation of a single chapter data item.
 * @typedef  {Object} module:src/core/Data~mediaItem_chapter
 * @property {string|Object<string, string>} title  Chapter title (can be multilingual).
 * @property {number}                        start  Start time of the chapter in seconds.
 */

/**
 * The thumbnailsItem is a representation of a single thumbnail data item.
 * @typedef  {Object} module:src/core/Data~mediaItem_thumbnail
 * @property {string|Object} src              Thumbnail image source(s), either direct src or object for multiple languages.
 * @property {number}        gridX            Number of thumbnail columns.
 * @property {number}        gridY            Number of thumbnail rows.
 * @property {number}        timeDelta        Time difference between thumbnails.
 * @property {number}        [timeDeltaHigh]  High-resolution thumbnail time difference.
 */

/**
 * Fired when the player data was parsed and is available for further consumption.
 * @event module:src/core/Data#data/parsed
 * @param {Object}                           data                    Reference to the data store.
 * @param {module:src/core/Data~mediaItem[]} data.media              Array of media items.
 * @param {number}                           data.currentMediaIndex  Index of the currently active media item.
 * @param {string|Object<string, string>}    [data.title]            Title of the playlist.
 * @param {string|Object<string, string>}    [data.titleSecondary]   Secondary title of the playlist.
 */

/**
 * Fired when a media item has been assigned (but media is not fully loaded yet).
 * @event module:src/core/Data#data/ready
 * @param {module:src/core/Data~mediaItem} mediaItem  The new media item data.
 */

/**
 * Fired when a media source has been selected, but before the actual load.
 * @event module:src/core/Data#data/source
 * @param {module:src/core/Media~metaData} metaData  The new media metadata.
 */

/**
 * Fired when a data related error occurs (for example a parsing error due to wrong media data format).
 * @event  module:src/core/Data#data/error
 * @param {Object} msgObj                   The data error message object.
 * @param {string} msgObj.code              The error code.
 * @param {string} msgObj.message           The error message.
 * @param {string} msgObj.messageSecondary  Optional error message from original error.
 */

/**
 * Fired when no usable media data is found.
 * @event module:src/core/Data#data/nomedia
 */