Skip to content

Source: src/streaming/Hls.js

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

let HlsJs;

/**
 * The Hls component integrates the [hls.js](https://github.com/video-dev/hls.js) library into the player for MPEG-HLS streaming, adding Widevine (and optional Fairplay) DRM support.
 * It allows adaptive streaming, real-time error handling, subtitle, language and quality control integration, and reacts to various stream metadata updates.
 * @exports module:src/streaming/Hls
 * @requires src/util/ExtendedMediaError
 * @author    Frank Kudermann - alphanull
 * @version   1.0.0
 * @license   MIT
 */
export default class Hls {

    /**
     * @type     {Object}
     * @property {boolean} [lazyLoadLib=true]  If `true`, the Hls.js library is only loaded when loading the first media item.
     * @property {string}  [libUrl]            Custom URL for the Hls.js library. Defaults to CDN URL if not specified.
     * @property {boolean} [debug=false]       Enables verbose logging from the HLS component.
     */
    #config = {
        lazyLoadLib: true,
        libUrl: 'https://cdn.jsdelivr.net/npm/hls.js@^1.5.19/dist/hls.min.js',
        debug: false
    };

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

    /**
     * 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 Media instance.
     * @type {module:src/core/Media}
     */
    #mediaComponent;

    /**
     * Reference to the dash.js instance.
     * @type {Object}
     */
    #hls;

    /**
     * Reference to the Async Task instance. Used to handle async tasks, which can be cancelled, resolved or rejected.
     * @type {module:lib/util/AsyncTask}
     */
    #loadTask;

    /**
     * Holds metadata information provided by media.load and loaded metadata.
     * @type {module:src/core/Media~metaData}
     */
    #metaData = {};

    /**
     * Promise awaiting the loading of the dash lib.
     * @type {Promise}
     */
    #loadHlsPromise;

    /**
     * Creates an instance of the Hls plugin.
     * @param {module:src/core/Player} player          Reference to the player instance.
     * @param {module:src/core/Media}  mediaComponent  Reference to the engine (video) instance.
     * @param {symbol}                 apiKey          Token for extended access to the player API.
     */
    constructor(player, mediaComponent, { apiKey }) {

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

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

        HlsJs = window.Hls;

        if (!HlsJs && !this.#config.lazyLoadLib) this.#loadHlsJs();

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

        this.#player.constructor.addFormat({
            extensions: ['m3u8'],
            mimeTypeAudio: ['application/x-mpegURL'],
            mimeTypeVideo: ['application/x-mpegURL']
        });

        this.#subscriptions = [
            this.#player.subscribe('data/ready', this.#removeHls)
        ];

        this.#mediaComponent = mediaComponent;
        this.#mediaComponent.registerPlugin(this);

    }

    /**
     * Checks if hls.js can handle the given mimeType and optional DRM system. ('Widevine').
     * @param   {module:src/core/Media~metaData} metaData              The data to test.
     * @param   {string}                         metaData.mimeType     The mime type to test.
     * @param   {string}                         [metaData.drmSystem]  Optional DRM system info.
     * @returns {string}                                               'maybe' or '' (empty).
     */
    canPlay({ mimeType, drmSystem } = {}) { // eslint-disable-line

        return mimeType === 'application/x-mpegURL' && (HlsJs ? HlsJs.isSupported() : true) && (!drmSystem || drmSystem === 'Widevine') ? 'maybe' : '';

    }

    /**
     * Initializes the hls.js library for a given source. Sets up the config (DRM if needed), attaches to the video stream, loads the HLS source.
     * @param   {module:src/core/Media~metaData} metaData            Source Object (currentSource).
     * @param   {string}                         metaData.src        Actual HLS URL to load.
     * @param   {Object}                         [options]           Additional options.
     * @param   {module:lib/util/AsyncTask}      [options.loadTask]  If a source task instance is provided, handle errors using this object.
     * @returns {Promise|undefined}                                  If a source task instance is provided, returns a promise that rejects with a resulting media error.
     * @throws  {Error}                                              If drm scheme is unknown.
     */
    async load({ src }, options = {}) {

        if (!HlsJs) {
            await this.#loadHlsJs();
            if (!HlsJs) HlsJs = window.Hls;
        }

        this.#subscriptions = [
            this.#player.subscribe('language/selected', this.#onLanguageSelected),
            this.#player.subscribe('quality/selected', this.#onQualitySelected),
            this.#player.subscribe('media/ready', this.#onMediaReady, { priority: 99 }),
            this.#player.subscribe('airplay/start', () => {
                // stop loading when AirPlay starts
                const videoEle = this.#player.media.getElement(this.#apiKey);
                this.#hls.stopLoad();
                if (!videoEle.src) { // <-- must be first load!!!
                    videoEle.src = this.#player.media.getMetaData().src;
                    videoEle.load();
                }
            })
        ];

        const qualityConfig = this.#player.getConfig('quality'),
              { drm } = this.#player.data.getMediaData(),
              config = {
                  enableWorker: true,
                  lowLatencyMode: true,
                  backBufferLength: 90,
                  capLevelToPlayerSize: qualityConfig.adaptToSize,
                  ignoreDevicePixelRatio: !qualityConfig.useDeviceRatio
              };

        if (drm) {

            if (drm.Widevine) {

                config.emeEnabled = true;
                config.widevineLicenseUrl = drm.Widevine.licenseUrl;
                config.drmSystemOptions = {
                    audioRobustness: 'SW_SECURE_CRYPTO',
                    videoRobustness: 'SW_SECURE_CRYPTO'
                };
                config.licenseXhrSetup = xhr => {
                    if (drm.Widevine.header) {
                        Object.entries(drm.Widevine.header).forEach(([key, value]) => {
                            xhr.setRequestHeader(key, value);
                        });
                    }
                };

            } else throw new Error('[Hls] Unknown DRM Scheme');
        }

        this.#hls = new HlsJs(config);
        this.#hls.on(HlsJs.Events.ERROR, this.#onHlsError);
        this.#hls.on(HlsJs.Events.AUDIO_TRACK_SWITCHED, this.#onHlsAudioSwitch);
        this.#hls.on(HlsJs.Events.LEVEL_SWITCHED, this.#onHlsQualitySwitch);
        this.#hls.on(HlsJs.Events.LEVEL_LOADED, this.#onHlsLevelLoaded);

        this.#hls.subtitleDisplay = false;
        this.#hls.attachMedia(this.#player.media.getElement(this.#apiKey));
        this.#hls.loadSource(src);

        if (this.#config.debug) {
            Object.keys(HlsJs.Events).forEach(key => this.#hls.on(HlsJs.Events[key], this.#onLogEvent));
        }

        if (options.loadTask) {
            this.#loadTask = options.loadTask;
            return options.loadTask.promise;
        }
    }

    /**
     * Loads dash.js via CDN if not present.
     * Ensures only one load attempt per instance.
     * @returns {Promise<void>}
     */
    #loadHlsJs() {

        if (window.Hls) return Promise.resolve();

        if (this.#loadHlsPromise) return this.#loadHlsPromise;

        this.#loadHlsPromise = new Promise((resolve, reject) => {

            const existing = document.querySelector(`script[src="${this.#config.libUrl}"]`);
            if (existing) {
                if (existing.dataset.loaded) {
                    resolve();
                } else {
                    existing.addEventListener('load', resolve, { once: true });
                    existing.addEventListener('error', reject, { once: true });
                }
                return;
            }

            const script = document.createElement('script');
            script.src = this.#config.libUrl;
            script.async = true;
            script.dataset.loaded = '';

            script.onload = () => {
                script.dataset.loaded = '1';
                resolve();
            };

            script.onerror = error => {
                this.#loadHlsPromise = null;
                script.remove();
                this.#player.dom.getElement(this.#apiKey).classList.add('has-no-media');
                this.#player.publish('data/nomedia', this.#apiKey);
                this.#player.publish('notification', {
                    type: 'error',
                    title: this.#player.locale.t('errors.library.header'),
                    message: this.#player.locale.t('errors.library.dashLoadError')
                }, this.#apiKey);
                reject(new Error('[VisionPlayer] Failed to load dash.js', { cause: error }));
            };

            document.head.appendChild(script);
        });

        return this.#loadHlsPromise;

    }

    /**
     * Called by the media component when metadata has loaded, but before the `media/reafy` event has been sent.
     * Allows the plugin to add additional metadata to the mediaSource object.
     * @param {module:src/core/Media~metaData} metaData  The source to load.
     */
    onLoaded(metaData) {

        const loadLevel = this.#hls.levels?.[this.#hls.loadLevel],
              audioTrack = this.#hls.audioTracks[this.#hls.audioTrack];

        this.#metaData = metaData;

        metaData.isLive = loadLevel?.details?.live === true;
        metaData.frameRate = loadLevel.frameRate;
        metaData.bitRate = loadLevel.bitrate;
        metaData.language = audioTrack?.lang;
        metaData.langName = audioTrack?.name;
        metaData.langId = audioTrack?.id;

    }

    /**
     * Called when a new Hls level is loaded. Updates 'islive' data attribute on the video element accordingly.
     * @param {string} event  The HLS LEVEL_LOADED event.
     * @param {Object} data   Video data.
     */
    #onHlsLevelLoaded = (event, data) => {

        const isLive = Boolean(data?.details?.live);

        this.#metaData.isLive = isLive;
        this.#metaData.frameRate = data?.levelInfo.frameRate;
        this.#metaData.bitRate = data?.levelInfo.bitrate;
        this.#metaData.width = data?.levelInfo.width;
        this.#metaData.height = data?.levelInfo.height;

        this.#player.media.getElement(this.#apiKey).dataset.isLive = isLive ? 'true' : 'false';

    };

    /**
     * Called when the media metadata is loaded to initialize languages/qualities if needed.
     * @listens module:src/core/Media#media/ready
     */
    #onMediaReady = () => {

        const { levels, audioTracks, currentLevel, autoLevelEnabled, nextAutoLevel } = this.#hls,
              qualityData = Array.from(levels.reduce((acc, { height }) => acc.add(height), new Set([null]))), // filter duplicates
              autoLevel = currentLevel === -1 && autoLevelEnabled,
              currentHeight = autoLevel ? levels[nextAutoLevel].height : levels[currentLevel].height;

        if (qualityData.length) {
            this.#player.publish('quality/update', {
                qualityData,
                current: { value: currentHeight }
            }, { async: false }, this.#apiKey);
        }

        this.#player.publish('subtitles/update', null, { async: false }, this.#apiKey);

        if (audioTracks.length > 1) {

            this.#player.publish('language/update', {
                languages: audioTracks.map(track => ({
                    language: track.lang,
                    langId: track.id,
                    langName: track.name })),
                current: {
                    language: audioTracks[this.#hls.audioTrack].lang,
                    langId: audioTracks[this.#hls.audioTrack].id,
                    langName: audioTracks[this.#hls.audioTrack].name
                }
            }, { async: false }, this.#apiKey);
        }
    };

    /**
     * Called when a language is selected in the language menu. Switches the HLS audio track accordingly.
     * @param {module:src/settings/Language~langObj} langObj       Object with selected language information.
     * @param {string}                               langObj.lang  Language code.
     * @param {number}                               langObj.id    Contains language ID.
     * @listens module:src/settings/Language#language/selected
     */
    #onLanguageSelected = langObj => {

        this.#hls.audioTrack = langObj.langId;

        this.#metaData.language = langObj.value;
        this.#metaData.langId = langObj.langId;
        this.#metaData.langName = langObj.langName;

    };

    /**
     * Called when a quality is selected in the quality menu. Finds the matching level and sets it, or 'auto' if not found.
     * @param {string} quality  The chosen resolution, e.g. '720p'.
     * @listens module:src/settings/Quality#quality/selected
     */
    #onQualitySelected = ({ quality }) => {

        const selected = this.#hls.levels.findIndex(level => level.height === quality);

        if (this.#hls.autoLevelEnabled && selected === this.#hls.currentLevel) {
            const { height } = this.#hls.levels[selected];
            this.#player.publish('quality/active', { value: height }, this.#apiKey);
        }

        this.#hls.currentLevel = selected;

    };

    /**
     * Handler for the hls 'AUDIO_TRACK_SWITCHED' event. Publishes 'language/active' to sync the internal track with the player's language state.
     * @param {string} event  The HLS AUDIO_TRACK_SWITCHED event.
     * @param {Object} data   Newly selected audio track.
     * @fires module:src/settings/Language#language/active
     */
    #onHlsAudioSwitch = (event, data) => {

        this.#player.publish('language/active', {
            language: data.lang,
            langId: data.id,
            langName: data.name
        }, this.#apiKey);

    };

    /**
     * Handler for the hls 'LEVEL_SWITCHED' event. Publishes 'quality/active' to sync the internal track with the player's language state.
     * @param {string} event  The HLS LEVEL_SWITCHED event.
     * @param {Object} data   Newly selected level.
     * @fires module:src/settings/Language#language/active
     */
    #onHlsQualitySwitch = (event, data) => {

        const { height } = this.#hls.levels[data.level];
        this.#player.publish('quality/active', { value: height }, this.#apiKey);

    };

    /**
     * Logs Hls.js events if debug is enabled.
     * @param {string} event  The hls debug event.
     * @param {Object} data   The original event data.
     */
    #onLogEvent = (event, data) => { // eslint-disable-line class-methods-use-this
        console.log('[hls]', event, data); // eslint-disable-line no-console
    };

    /**
     * This method is responsible for translating errors coming from the HLS library, so that they can be handled like errors coming from the standard engine
     * This is done by listening to selected events coming from the HLS library, and sending out errors via pubsub with the closest approximation to the standard HTML5 media error codes.
     * In addition, the media error object is simulated by using a customized subclass of the standard error object.
     * @param {Event}   event           The event coming from the HLS library.
     * @param {Object}  data            HLS Library event data.
     * @param {boolean} data.fatal      Indicates a fatal error.
     * @param {string}  data.type       The HLS error type from the lib.
     * @param {string}  [data.details]  Additional error Details.
     * @param {string}  [data.reason]   Reason for error.
     * @fires module:src/core/Media#media/error
     */
    #onHlsError = (event, data) => {

        if (!data.fatal) return;

        let error;

        switch (data.type) {

            case HlsJs.ErrorTypes.NETWORK_ERROR:

                switch (data.details) {
                    case HlsJs.ErrorDetails.LEVEL_LOAD_TIMEOUT:
                    case HlsJs.ErrorDetails.MANIFEST_LOAD_TIMEOUT:
                    case HlsJs.ErrorDetails.FRAG_LOAD_TIMEOUT:
                        error = new ExtendedMediaError(1);
                        break;
                    case HlsJs.ErrorDetails.FRAG_PARSING_ERROR:
                    case HlsJs.ErrorDetails.MANIFEST_PARSING_ERROR:
                        error = new ExtendedMediaError(4);
                        break;
                    default:
                        error = new ExtendedMediaError(2);
                }

                // try to recover network error
                // console.log('fatal network error encountered, try to recover');
                // this.#hls.startLoad();
                break;

            case HlsJs.ErrorTypes.MEDIA_ERROR:

                switch (data.details) {
                    case HlsJs.ErrorDetails.MANIFEST_INCOMPATIBLE_CODECS_ERROR:
                        error = new ExtendedMediaError(3);
                        break;
                    default:
                        error = new ExtendedMediaError(4);
                }

                // console.log('fatal media error encountered, try to recover');
                // this.#hls.recoverMediaError();
                break;

            case HlsJs.ErrorTypes.KEY_SYSTEM_ERROR:
                /* KEY_LOAD_ERROR: 'keyLoadError'
                KEY_LOAD_TIMEOUT: 'keyLoadTimeOut'
                KEY_SYSTEM_LICENSE_REQUEST_FAILED: 'keySystemLicenseRequestFailed'
                KEY_SYSTEM_NO_ACCESS: 'keySystemNoAccess'
                KEY_SYSTEM_NO_INIT_DATA: 'keySystemNoInitData'
                KEY_SYSTEM_NO_KEYS: 'keySystemNoKeys'
                KEY_SYSTEM_NO_SESSION: 'keySystemNoSession' */
                error = new ExtendedMediaError(3);
                break;

            default:
                error = new ExtendedMediaError(4);
                break;
        }

        if (data.details || data.reason) {
            error.message = '';
            if (data.details) error.message += `Details: ${data.details}`;
            if (data.details && data.reason) error.message += ' / ';
            if (data.reason) error.message += `Reason: ${data.reason}`;
        }

        this.#loadTask.reject(error);
        this.#player.media.pause();
        this.#player.publish('media/error', { error }, this.#apiKey);

    };

    /**
     * Removes the current HLS instance if present, unsubscribes from events, etc.
     * @listens module:src/core/Data#data/ready
     */
    #removeHls = () => {

        if (this.#hls) {
            if (this.#config.debug) Object.keys(HlsJs.Events).forEach(key => this.#hls.off(HlsJs.Events[key], this.#onLogEvent));
            this.#hls.off(HlsJs.Events.ERROR, this.#onHlsError);
            this.#hls.off(HlsJs.Events.AUDIO_TRACK_SWITCHED, this.#onHlsAudioSwitch);
            this.#hls.off(HlsJs.Events.LEVEL_SWITCHED, this.#onHlsQualitySwitch);
            this.#hls.destroy();
            this.#hls = null;
            this.#player.unsubscribe(this.#subscriptions);
        }

    };

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

        this.#removeHls();
        this.#mediaComponent.unregisterPlugin(this);
        this.#player.unsubscribe(this.#subscriptions);
        this.#player = this.#hls = this.#mediaComponent = this.#apiKey = null;

    }

}