Source: videojs-dash.js

import window from 'global/window';
import videojs from 'video.js';
import dashjs from 'dashjs';
import setupAudioTracks from './setup-audio-tracks';
import setupTextTracks from './setup-text-tracks';
import document from 'global/document';

/**
 * videojs-contrib-dash
 *
 * Use Dash.js to playback DASH content inside of Video.js via a SourceHandler
 */
class Html5DashJS {
  constructor(source, tech, options) {
    // Get options from tech if not provided for backwards compatibility
    options = options || tech.options_;

    this.player = videojs(options.playerId);
    this.player.dash = this.player.dash || {};

    this.tech_ = tech;
    this.el_ = tech.el();
    this.elParent_ = this.el_.parentNode;
    this.hasFiniteDuration_ = false;

    // Do nothing if the src is falsey
    if (!source.src) {
      return;
    }

    // While the manifest is loading and Dash.js has not finished initializing
    // we must defer events and functions calls with isReady_ and then `triggerReady`
    // again later once everything is setup
    tech.isReady_ = false;

    if (Html5DashJS.updateSourceData) {
      videojs.log.warn('updateSourceData has been deprecated.' +
        ' Please switch to using hook("updatesource", callback).');
      source = Html5DashJS.updateSourceData(source);
    }

    // call updatesource hooks
    Html5DashJS.hooks('updatesource').forEach((hook) => {
      source = hook(source);
    });

    const manifestSource = source.src;

    this.keySystemOptions_ = Html5DashJS.buildDashJSProtData(source.keySystemOptions);

    this.player.dash.mediaPlayer = dashjs.MediaPlayer().create();

    this.mediaPlayer_ = this.player.dash.mediaPlayer;

    // For whatever reason, we need to call setTextDefaultEnabled(false) to get
    // VTT captions to show, even though we're doing virtually the same thing
    // in setup-text-tracks.js
    this.mediaPlayer_.setTextDefaultEnabled(false);

    // Log MedaPlayer messages through video.js
    if (Html5DashJS.useVideoJSDebug) {
      videojs.log.warn('useVideoJSDebug has been deprecated.' +
        ' Please switch to using hook("beforeinitialize", callback).');
      Html5DashJS.useVideoJSDebug(this.mediaPlayer_);
    }

    if (Html5DashJS.beforeInitialize) {
      videojs.log.warn('beforeInitialize has been deprecated.' +
        ' Please switch to using hook("beforeinitialize", callback).');
      Html5DashJS.beforeInitialize(this.player, this.mediaPlayer_);
    }

    Html5DashJS.hooks('beforeinitialize').forEach((hook) => {
      hook(this.player, this.mediaPlayer_);
    });

    // Must run controller before these two lines or else there is no
    // element to bind to.
    this.mediaPlayer_.initialize();

    // Retrigger a dash.js-specific error event as a player error
    // See src/streaming/utils/ErrorHandler.js in dash.js code
    // Handled with error (playback is stopped):
    // - capabilityError
    // - downloadError
    // - manifestError
    // - mediaSourceError
    // - mediaKeySessionError
    // Not handled:
    // - timedTextError (video can still play)
    // - mediaKeyMessageError (only fires under 'might not work' circumstances)
    this.retriggerError_ = (event) => {
      if (event.error === 'capability' && event.event === 'mediasource') {
        // No support for MSE
        this.player.error({
          code: 4,
          message: 'The media cannot be played because it requires a feature ' +
            'that your browser does not support.'
        });

      } else if (event.error === 'manifestError' && (
        // Manifest type not supported
        (event.event.id === 'createParser') ||
        // Codec(s) not supported
        (event.event.id === 'codec') ||
        // No streams available to stream
        (event.event.id === 'nostreams') ||
        // Error creating Stream object
        (event.event.id === 'nostreamscomposed') ||
        // syntax error parsing the manifest
        (event.event.id === 'parse') ||
        // a stream has multiplexed audio+video
        (event.event.id === 'multiplexedrep')
      )) {
        // These errors have useful error messages, so we forward it on
        this.player.error({code: 4, message: event.event.message});

      } else if (event.error === 'mediasource') {
        // This error happens when dash.js fails to allocate a SourceBuffer
        // OR the underlying video element throws a `MediaError`.
        // If it's a buffer allocation fail, the message states which buffer
        // (audio/video/text) failed allocation.
        // If it's a `MediaError`, dash.js inspects the error object for
        // additional information to append to the error type.
        if (event.event.match('MEDIA_ERR_ABORTED')) {
          this.player.error({code: 1, message: event.event});
        } else if (event.event.match('MEDIA_ERR_NETWORK')) {
          this.player.error({code: 2, message: event.event});
        } else if (event.event.match('MEDIA_ERR_DECODE')) {
          this.player.error({code: 3, message: event.event});
        } else if (event.event.match('MEDIA_ERR_SRC_NOT_SUPPORTED')) {
          this.player.error({code: 4, message: event.event});
        } else if (event.event.match('MEDIA_ERR_ENCRYPTED')) {
          this.player.error({code: 5, message: event.event});
        } else if (event.event.match('UNKNOWN')) {
          // We shouldn't ever end up here, since this would mean a
          // `MediaError` thrown by the video element that doesn't comply
          // with the W3C spec. But, since we should handle the error,
          // throwing a MEDIA_ERR_SRC_NOT_SUPPORTED is probably the
          // most reasonable thing to do.
          this.player.error({code: 4, message: event.event});
        } else {
          // Buffer allocation error
          this.player.error({code: 4, message: event.event});
        }

      } else if (event.error === 'capability' && event.event === 'encryptedmedia') {
        // Browser doesn't support EME
        this.player.error({
          code: 5,
          message: 'The media cannot be played because it requires encryption ' +
            'features that your browser does not support.'
        });

      } else if (event.error === 'key_session') {
        // This block handles pretty much all errors thrown by the
        // encryption subsystem
        this.player.error({
          code: 5,
          message: event.event
        });

      } else if (event.error === 'download') {
        this.player.error({
          code: 2,
          message: 'The media playback was aborted because too many consecutive ' +
            'download errors occurred.'
        });

      } else if (event.error === 'mssError') {
        this.player.error({
          code: 3,
          message: event.event
        });

      } else {
        // ignore the error
        return;
      }

      // only reset the dash player in 10ms async, so that the rest of the
      // calling function finishes
      setTimeout(() => {
        this.mediaPlayer_.reset();
      }, 10);
    };

    this.mediaPlayer_.on(dashjs.MediaPlayer.events.ERROR, this.retriggerError_);

    this.getDuration_ = (event) => {
      const periods = event.data.Period_asArray;
      const oldHasFiniteDuration = this.hasFiniteDuration_;

      if (event.data.mediaPresentationDuration || periods[periods.length - 1].duration) {
        this.hasFiniteDuration_ = true;
      } else {
        // in case we run into a weird situation where we're VOD but then
        // switch to live
        this.hasFiniteDuration_ = false;
      }

      if (this.hasFiniteDuration_ !== oldHasFiniteDuration) {
        this.player.trigger('durationchange');
      }
    };

    this.mediaPlayer_.on(dashjs.MediaPlayer.events.MANIFEST_LOADED, this.getDuration_);

    // Apply all dash options that are set
    if (options.dash) {
      Object.keys(options.dash).forEach((key) => {
        const dashOptionsKey = 'set' + key.charAt(0).toUpperCase() + key.slice(1);
        let value = options.dash[key];

        if (this.mediaPlayer_.hasOwnProperty(dashOptionsKey)) {
          // Providing a key without `set` prefix is now deprecated.
          videojs.log.warn('Using dash options in videojs-contrib-dash without the set prefix ' +
            `has been deprecated. Change '${key}' to '${dashOptionsKey}'`);

          // Set key so it will still work
          key = dashOptionsKey;
        }

        if (!this.mediaPlayer_.hasOwnProperty(key)) {
          videojs.log.warn(
            `Warning: dash configuration option unrecognized: ${key}`
          );

          return;
        }

        // Guarantee `value` is an array
        if (!Array.isArray(value)) {
          value = [value];
        }

        this.mediaPlayer_[key](...value);
      });
    }

    this.mediaPlayer_.attachView(this.el_);

    // Dash.js autoplays by default, video.js will handle autoplay
    this.mediaPlayer_.setAutoPlay(false);

    // Setup audio tracks
    setupAudioTracks.call(null, this.player, tech);

    // Setup text tracks
    setupTextTracks.call(null, this.player, tech, options);

    // Attach the source with any protection data
    this.mediaPlayer_.setProtectionData(this.keySystemOptions_);
    this.mediaPlayer_.attachSource(manifestSource);

    this.tech_.triggerReady();
  }

  /*
   * Iterate over the `keySystemOptions` array and convert each object into
   * the type of object Dash.js expects in the `protData` argument.
   *
   * Also rename 'licenseUrl' property in the options to an 'serverURL' property
   */
  static buildDashJSProtData(keySystemOptions) {
    const output = {};

    if (!keySystemOptions || !Array.isArray(keySystemOptions)) {
      return null;
    }

    for (let i = 0; i < keySystemOptions.length; i++) {
      const keySystem = keySystemOptions[i];
      const options = videojs.mergeOptions({}, keySystem.options);

      if (options.licenseUrl) {
        options.serverURL = options.licenseUrl;
        delete options.licenseUrl;
      }

      output[keySystem.name] = options;
    }

    return output;
  }

  dispose() {
    if (this.mediaPlayer_) {
      this.mediaPlayer_.off(dashjs.MediaPlayer.events.ERROR, this.retriggerError_);
      this.mediaPlayer_.off(dashjs.MediaPlayer.events.MANIFEST_LOADED, this.getDuration_);
      this.mediaPlayer_.reset();
    }

    if (this.player.dash) {
      delete this.player.dash;
    }
  }

  duration() {
    if (this.mediaPlayer_.isDynamic() && !this.hasFiniteDuration_) {
      return Infinity;
    }
    return this.mediaPlayer_.duration();

  }

  /**
   * Get a list of hooks for a specific lifecycle
   *
   * @param {string} type the lifecycle to get hooks from
   * @param {Function|Function[]} [hook] Optionally add a hook tothe lifecycle
   * @return {Array} an array of hooks or epty if none
   * @method hooks
   */
  static hooks(type, hook) {
    Html5DashJS.hooks_[type] = Html5DashJS.hooks_[type] || [];

    if (hook) {
      Html5DashJS.hooks_[type] = Html5DashJS.hooks_[type].concat(hook);
    }

    return Html5DashJS.hooks_[type];
  }

  /**
   * Add a function hook to a specific dash lifecycle
   *
   * @param {string} type the lifecycle to hook the function to
   * @param {Function|Function[]} hook the function or array of functions to attach
   * @method hook
   */
  static hook(type, hook) {
    Html5DashJS.hooks(type, hook);
  }

  /**
   * Remove a hook from a specific dash lifecycle.
   *
   * @param {string} type the lifecycle that the function hooked to
   * @param {Function} hook The hooked function to remove
   * @return {boolean} True if the function was removed, false if not found
   * @method removeHook
   */
  static removeHook(type, hook) {
    const index = Html5DashJS.hooks(type).indexOf(hook);

    if (index === -1) {
      return false;
    }

    Html5DashJS.hooks_[type] = Html5DashJS.hooks_[type].slice();
    Html5DashJS.hooks_[type].splice(index, 1);

    return true;
  }
}

Html5DashJS.hooks_ = {};

const canHandleKeySystems = function(source) {
  // copy the source
  source = JSON.parse(JSON.stringify(source));

  if (Html5DashJS.updateSourceData) {
    videojs.log.warn('updateSourceData has been deprecated.' +
      ' Please switch to using hook("updatesource", callback).');
    source = Html5DashJS.updateSourceData(source);
  }

  // call updatesource hooks
  Html5DashJS.hooks('updatesource').forEach((hook) => {
    source = hook(source);
  });

  const videoEl = document.createElement('video');

  if (source.keySystemOptions &&
    !(window.navigator.requestMediaKeySystemAccess ||
      // IE11 Win 8.1
      videoEl.msSetMediaKeys)) {
    return false;
  }

  return true;
};

videojs.DashSourceHandler = function() {
  return {
    canHandleSource(source) {
      const dashExtRE = /\.mpd/i;

      if (!canHandleKeySystems(source)) {
        return '';
      }

      if (videojs.DashSourceHandler.canPlayType(source.type)) {
        return 'probably';
      } else if (dashExtRE.test(source.src)) {
        return 'maybe';
      }
      return '';

    },

    handleSource(source, tech, options) {
      return new Html5DashJS(source, tech, options);
    },

    canPlayType(type) {
      return videojs.DashSourceHandler.canPlayType(type);
    }
  };
};

videojs.DashSourceHandler.canPlayType = function(type) {
  const dashTypeRE = /^application\/dash\+xml/i;

  if (dashTypeRE.test(type)) {
    return 'probably';
  }

  return '';
};

// Only add the SourceHandler if the browser supports MediaSourceExtensions
if (window.MediaSource) {
  videojs.getTech('Html5').registerSourceHandler(videojs.DashSourceHandler(), 0);
}

videojs.Html5DashJS = Html5DashJS;
export default Html5DashJS;