/**
 * @file videojs-http-streaming.js
 *
 * The main file for the VHS project.
 * License: https://github.com/videojs/videojs-http-streaming/blob/main/LICENSE
 */
import document from 'global/document';
import window from 'global/window';
import PlaylistLoader from './playlist-loader';
import Playlist from './playlist';
import xhrFactory from './xhr';
import { simpleTypeFromSourceType } from '@videojs/vhs-utils/es/media-types.js';
import * as utils from './bin-utils';
import {
  getProgramTime,
  seekToProgramTime
} from './util/time';
import { timeRangesToArray } from './ranges';
import videojs from 'video.js';
import { PlaylistController } from './playlist-controller';
import Config from './config';
import renditionSelectionMixin from './rendition-mixin';
import PlaybackWatcher from './playback-watcher';
import SourceUpdater from './source-updater';
import reloadSourceOnError from './reload-source-on-error';
import {
  lastBandwidthSelector,
  lowestBitrateCompatibleVariantSelector,
  movingAverageBandwidthSelector,
  comparePlaylistBandwidth,
  comparePlaylistResolution
} from './playlist-selectors.js';
import {
  browserSupportsCodec,
  getMimeForCodec,
  parseCodecs
} from '@videojs/vhs-utils/es/codecs.js';
import { unwrapCodecList } from './util/codecs.js';
import logger from './util/logger';
import {SAFE_TIME_DELTA} from './ranges';
import {merge} from './util/vjs-compat';

// IMPORTANT:
// keep these at the bottom they are replaced at build time
// because webpack and rollup without plugins do not support json
// and we do not want to break our users
import {version as vhsVersion} from '../package.json';
import {version as muxVersion} from 'mux.js/package.json';
import {version as mpdVersion} from 'mpd-parser/package.json';
import {version as m3u8Version} from 'm3u8-parser/package.json';
import {version as aesVersion} from 'aes-decrypter/package.json';

const Vhs = {
  PlaylistLoader,
  Playlist,
  utils,

  STANDARD_PLAYLIST_SELECTOR: lastBandwidthSelector,
  INITIAL_PLAYLIST_SELECTOR: lowestBitrateCompatibleVariantSelector,
  lastBandwidthSelector,
  movingAverageBandwidthSelector,
  comparePlaylistBandwidth,
  comparePlaylistResolution,

  xhr: xhrFactory()
};

// Define getter/setters for config properties
Object.keys(Config).forEach((prop) => {
  Object.defineProperty(Vhs, prop, {
    get() {
      videojs.log.warn(`using Vhs.${prop} is UNSAFE be sure you know what you are doing`);
      return Config[prop];
    },
    set(value) {
      videojs.log.warn(`using Vhs.${prop} is UNSAFE be sure you know what you are doing`);

      if (typeof value !== 'number' || value < 0) {
        videojs.log.warn(`value of Vhs.${prop} must be greater than or equal to 0`);
        return;
      }

      Config[prop] = value;
    }
  });
});

export const LOCAL_STORAGE_KEY = 'videojs-vhs';

/**
 * Updates the selectedIndex of the QualityLevelList when a mediachange happens in vhs.
 *
 * @param {QualityLevelList} qualityLevels The QualityLevelList to update.
 * @param {PlaylistLoader} playlistLoader PlaylistLoader containing the new media info.
 * @function handleVhsMediaChange
 */
const handleVhsMediaChange = function(qualityLevels, playlistLoader) {
  const newPlaylist = playlistLoader.media();
  let selectedIndex = -1;

  for (let i = 0; i < qualityLevels.length; i++) {
    if (qualityLevels[i].id === newPlaylist.id) {
      selectedIndex = i;
      break;
    }
  }

  qualityLevels.selectedIndex_ = selectedIndex;
  qualityLevels.trigger({
    selectedIndex,
    type: 'change'
  });
};

/**
 * Adds quality levels to list once playlist metadata is available
 *
 * @param {QualityLevelList} qualityLevels The QualityLevelList to attach events to.
 * @param {Object} vhs Vhs object to listen to for media events.
 * @function handleVhsLoadedMetadata
 */
const handleVhsLoadedMetadata = function(qualityLevels, vhs) {
  vhs.representations().forEach((rep) => {
    qualityLevels.addQualityLevel(rep);
  });
  handleVhsMediaChange(qualityLevels, vhs.playlists);
};

// VHS is a source handler, not a tech. Make sure attempts to use it
// as one do not cause exceptions.
Vhs.canPlaySource = function() {
  return videojs.log.warn('VHS is no longer a tech. Please remove it from ' +
    'your player\'s techOrder.');
};

const emeKeySystems = (keySystemOptions, mainPlaylist, audioPlaylist) => {
  if (!keySystemOptions) {
    return keySystemOptions;
  }

  let codecs = {};

  if (mainPlaylist && mainPlaylist.attributes && mainPlaylist.attributes.CODECS) {
    codecs = unwrapCodecList(parseCodecs(mainPlaylist.attributes.CODECS));
  }

  if (audioPlaylist && audioPlaylist.attributes && audioPlaylist.attributes.CODECS) {
    codecs.audio = audioPlaylist.attributes.CODECS;
  }

  const videoContentType = getMimeForCodec(codecs.video);
  const audioContentType = getMimeForCodec(codecs.audio);

  // upsert the content types based on the selected playlist
  const keySystemContentTypes = {};

  for (const keySystem in keySystemOptions) {
    keySystemContentTypes[keySystem] = {};

    if (audioContentType) {
      keySystemContentTypes[keySystem].audioContentType = audioContentType;
    }
    if (videoContentType) {
      keySystemContentTypes[keySystem].videoContentType = videoContentType;
    }

    // Default to using the video playlist's PSSH even though they may be different, as
    // videojs-contrib-eme will only accept one in the options.
    //
    // This shouldn't be an issue for most cases as early intialization will handle all
    // unique PSSH values, and if they aren't, then encrypted events should have the
    // specific information needed for the unique license.
    if (mainPlaylist.contentProtection &&
        mainPlaylist.contentProtection[keySystem] &&
        mainPlaylist.contentProtection[keySystem].pssh) {
      keySystemContentTypes[keySystem].pssh =
        mainPlaylist.contentProtection[keySystem].pssh;
    }

    // videojs-contrib-eme accepts the option of specifying: 'com.some.cdm': 'url'
    // so we need to prevent overwriting the URL entirely
    if (typeof keySystemOptions[keySystem] === 'string') {
      keySystemContentTypes[keySystem].url = keySystemOptions[keySystem];
    }
  }

  return merge(keySystemOptions, keySystemContentTypes);
};

/**
 * @typedef {Object} KeySystems
 *
 * keySystems configuration for https://github.com/videojs/videojs-contrib-eme
 * Note: not all options are listed here.
 *
 * @property {Uint8Array} [pssh]
 *           Protection System Specific Header
 */

/**
 * Goes through all the playlists and collects an array of KeySystems options objects
 * containing each playlist's keySystems and their pssh values, if available.
 *
 * @param {Object[]} playlists
 *        The playlists to look through
 * @param {string[]} keySystems
 *        The keySystems to collect pssh values for
 *
 * @return {KeySystems[]}
 *         An array of KeySystems objects containing available key systems and their
 *         pssh values
 */
const getAllPsshKeySystemsOptions = (playlists, keySystems) => {
  return playlists.reduce((keySystemsArr, playlist) => {
    if (!playlist.contentProtection) {
      return keySystemsArr;
    }

    const keySystemsOptions = keySystems.reduce((keySystemsObj, keySystem) => {
      const keySystemOptions = playlist.contentProtection[keySystem];

      if (keySystemOptions && keySystemOptions.pssh) {
        keySystemsObj[keySystem] = { pssh: keySystemOptions.pssh };
      }

      return keySystemsObj;
    }, {});

    if (Object.keys(keySystemsOptions).length) {
      keySystemsArr.push(keySystemsOptions);
    }

    return keySystemsArr;
  }, []);
};

/**
 * Returns a promise that waits for the
 * [eme plugin](https://github.com/videojs/videojs-contrib-eme) to create a key session.
 *
 * Works around https://bugs.chromium.org/p/chromium/issues/detail?id=895449 in non-IE11
 * browsers.
 *
 * As per the above ticket, this is particularly important for Chrome, where, if
 * unencrypted content is appended before encrypted content and the key session has not
 * been created, a MEDIA_ERR_DECODE will be thrown once the encrypted content is reached
 * during playback.
 *
 * @param {Object} player
 *        The player instance
 * @param {Object[]} sourceKeySystems
 *        The key systems options from the player source
 * @param {Object} [audioMedia]
 *        The active audio media playlist (optional)
 * @param {Object[]} mainPlaylists
 *        The playlists found on the main playlist object
 *
 * @return {Object}
 *         Promise that resolves when the key session has been created
 */
export const waitForKeySessionCreation = ({
  player,
  sourceKeySystems,
  audioMedia,
  mainPlaylists
}) => {
  if (!player.eme.initializeMediaKeys) {
    return Promise.resolve();
  }

  // TODO should all audio PSSH values be initialized for DRM?
  //
  // All unique video rendition pssh values are initialized for DRM, but here only
  // the initial audio playlist license is initialized. In theory, an encrypted
  // event should be fired if the user switches to an alternative audio playlist
  // where a license is required, but this case hasn't yet been tested. In addition, there
  // may be many alternate audio playlists unlikely to be used (e.g., multiple different
  // languages).
  const playlists = audioMedia ? mainPlaylists.concat([audioMedia]) : mainPlaylists;

  const keySystemsOptionsArr = getAllPsshKeySystemsOptions(
    playlists,
    Object.keys(sourceKeySystems)
  );

  const initializationFinishedPromises = [];
  const keySessionCreatedPromises = [];

  // Since PSSH values are interpreted as initData, EME will dedupe any duplicates. The
  // only place where it should not be deduped is for ms-prefixed APIs, but
  // the existence of modern EME APIs in addition to
  // ms-prefixed APIs on Edge should prevent this from being a concern.
  // initializeMediaKeys also won't use the webkit-prefixed APIs.
  keySystemsOptionsArr.forEach((keySystemsOptions) => {
    keySessionCreatedPromises.push(new Promise((resolve, reject) => {
      player.tech_.one('keysessioncreated', resolve);
    }));

    initializationFinishedPromises.push(new Promise((resolve, reject) => {
      player.eme.initializeMediaKeys({
        keySystems: keySystemsOptions
      }, (err) => {
        if (err) {
          reject(err);
          return;
        }
        resolve();
      });
    }));
  });

  // The reasons Promise.race is chosen over Promise.any:
  //
  // * Promise.any is only available in Safari 14+.
  // * None of these promises are expected to reject. If they do reject, it might be
  //   better here for the race to surface the rejection, rather than mask it by using
  //   Promise.any.
  return Promise.race([
    // If a session was previously created, these will all finish resolving without
    // creating a new session, otherwise it will take until the end of all license
    // requests, which is why the key session check is used (to make setup much faster).
    Promise.all(initializationFinishedPromises),
    // Once a single session is created, the browser knows DRM will be used.
    Promise.race(keySessionCreatedPromises)
  ]);
};

/**
 * If the [eme](https://github.com/videojs/videojs-contrib-eme) plugin is available, and
 * there are keySystems on the source, sets up source options to prepare the source for
 * eme.
 *
 * @param {Object} player
 *        The player instance
 * @param {Object[]} sourceKeySystems
 *        The key systems options from the player source
 * @param {Object} media
 *        The active media playlist
 * @param {Object} [audioMedia]
 *        The active audio media playlist (optional)
 *
 * @return {boolean}
 *         Whether or not options were configured and EME is available
 */
const setupEmeOptions = ({
  player,
  sourceKeySystems,
  media,
  audioMedia
}) => {
  const sourceOptions = emeKeySystems(sourceKeySystems, media, audioMedia);

  if (!sourceOptions) {
    return false;
  }

  player.currentSource().keySystems = sourceOptions;

  // eme handles the rest of the setup, so if it is missing
  // do nothing.
  if (sourceOptions && !player.eme) {
    videojs.log.warn('DRM encrypted source cannot be decrypted without a DRM plugin');
    return false;
  }

  return true;
};

const getVhsLocalStorage = () => {
  if (!window.localStorage) {
    return null;
  }

  const storedObject = window.localStorage.getItem(LOCAL_STORAGE_KEY);

  if (!storedObject) {
    return null;
  }

  try {
    return JSON.parse(storedObject);
  } catch (e) {
    // someone may have tampered with the value
    return null;
  }
};

const updateVhsLocalStorage = (options) => {
  if (!window.localStorage) {
    return false;
  }

  let objectToStore = getVhsLocalStorage();

  objectToStore = objectToStore ? merge(objectToStore, options) : options;

  try {
    window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(objectToStore));
  } catch (e) {
    // Throws if storage is full (e.g., always on iOS 5+ Safari private mode, where
    // storage is set to 0).
    // https://developer.mozilla.org/en-US/docs/Web/API/Storage/setItem#Exceptions
    // No need to perform any operation.
    return false;
  }

  return objectToStore;
};

/**
 * Parses VHS-supported media types from data URIs. See
 * https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs
 * for information on data URIs.
 *
 * @param {string} dataUri
 *        The data URI
 *
 * @return {string|Object}
 *         The parsed object/string, or the original string if no supported media type
 *         was found
 */
const expandDataUri = (dataUri) => {
  if (dataUri.toLowerCase().indexOf('data:application/vnd.videojs.vhs+json,') === 0) {
    return JSON.parse(dataUri.substring(dataUri.indexOf(',') + 1));
  }
  // no known case for this data URI, return the string as-is
  return dataUri;
};

/**
 * Adds a request hook to an xhr object
 *
 * @param {Object} xhr object to add the onRequest hook to
 * @param {function} callback hook function for an xhr request
 */
const addOnRequestHook = (xhr, callback) => {
  if (!xhr._requestCallbackSet) {
    xhr._requestCallbackSet = new Set();
  }
  xhr._requestCallbackSet.add(callback);
};

/**
 * Adds a response hook to an xhr object
 *
 * @param {Object} xhr object to add the onResponse hook to
 * @param {function} callback hook function for an xhr response
 */
const addOnResponseHook = (xhr, callback) => {
  if (!xhr._responseCallbackSet) {
    xhr._responseCallbackSet = new Set();
  }
  xhr._responseCallbackSet.add(callback);
};

/**
 * Removes a request hook on an xhr object, deletes the onRequest set if empty.
 *
 * @param {Object} xhr object to remove the onRequest hook from
 * @param {function} callback hook function to remove
 */
const removeOnRequestHook = (xhr, callback) => {
  if (!xhr._requestCallbackSet) {
    return;
  }
  xhr._requestCallbackSet.delete(callback);
  if (!xhr._requestCallbackSet.size) {
    delete xhr._requestCallbackSet;
  }
};

/**
 * Removes a response hook on an xhr object, deletes the onResponse set if empty.
 *
 * @param {Object} xhr object to remove the onResponse hook from
 * @param {function} callback hook function to remove
 */
const removeOnResponseHook = (xhr, callback) => {
  if (!xhr._responseCallbackSet) {
    return;
  }
  xhr._responseCallbackSet.delete(callback);
  if (!xhr._responseCallbackSet.size) {
    delete xhr._responseCallbackSet;
  }
};

/**
 * Whether the browser has built-in HLS support.
 */
Vhs.supportsNativeHls = (function() {
  if (!document || !document.createElement) {
    return false;
  }

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

  // native HLS is definitely not supported if HTML5 video isn't
  if (!videojs.getTech('Html5').isSupported()) {
    return false;
  }

  // HLS manifests can go by many mime-types
  const canPlay = [
    // Apple santioned
    'application/vnd.apple.mpegurl',
    // Apple sanctioned for backwards compatibility
    'audio/mpegurl',
    // Very common
    'audio/x-mpegurl',
    // Very common
    'application/x-mpegurl',
    // Included for completeness
    'video/x-mpegurl',
    'video/mpegurl',
    'application/mpegurl'
  ];

  return canPlay.some(function(canItPlay) {
    return (/maybe|probably/i).test(video.canPlayType(canItPlay));
  });
}());

Vhs.supportsNativeDash = (function() {
  if (!document || !document.createElement || !videojs.getTech('Html5').isSupported()) {
    return false;
  }

  return (/maybe|probably/i).test(document.createElement('video').canPlayType('application/dash+xml'));
}());

Vhs.supportsTypeNatively = (type) => {
  if (type === 'hls') {
    return Vhs.supportsNativeHls;
  }

  if (type === 'dash') {
    return Vhs.supportsNativeDash;
  }

  return false;
};

/**
 * VHS is a source handler, not a tech. Make sure attempts to use it
 * as one do not cause exceptions.
 */
Vhs.isSupported = function() {
  return videojs.log.warn('VHS is no longer a tech. Please remove it from ' +
    'your player\'s techOrder.');
};

/**
 * A global function for setting an onRequest hook
 *
 * @param {function} callback for request modifiction
 */
Vhs.xhr.onRequest = function(callback) {
  addOnRequestHook(Vhs.xhr, callback);
};

/**
 * A global function for setting an onResponse hook
 *
 * @param {callback} callback for response data retrieval
 */
Vhs.xhr.onResponse = function(callback) {
  addOnResponseHook(Vhs.xhr, callback);
};

/**
 * Deletes a global onRequest callback if it exists
 *
 * @param {function} callback to delete from the global set
 */
Vhs.xhr.offRequest = function(callback) {
  removeOnRequestHook(Vhs.xhr, callback);
};

/**
 * Deletes a global onResponse callback if it exists
 *
 * @param {function} callback to delete from the global set
 */
Vhs.xhr.offResponse = function(callback) {
  removeOnResponseHook(Vhs.xhr, callback);
};

const Component = videojs.getComponent('Component');

/**
 * The Vhs Handler object, where we orchestrate all of the parts
 * of VHS to interact with video.js
 *
 * @class VhsHandler
 * @extends videojs.Component
 * @param {Object} source the soruce object
 * @param {Tech} tech the parent tech object
 * @param {Object} options optional and required options
 */
class VhsHandler extends Component {
  constructor(source, tech, options) {
    super(tech, options.vhs);

    // if a tech level `initialBandwidth` option was passed
    // use that over the VHS level `bandwidth` option
    if (typeof options.initialBandwidth === 'number') {
      this.options_.bandwidth = options.initialBandwidth;
    }

    this.logger_ = logger('VhsHandler');

    // we need access to the player in some cases,
    // so, get it from Video.js via the `playerId`
    if (tech.options_ && tech.options_.playerId) {
      const _player = videojs.getPlayer(tech.options_.playerId);

      this.player_ = _player;
    }

    this.tech_ = tech;
    this.source_ = source;
    this.stats = {};
    this.ignoreNextSeekingEvent_ = false;
    this.setOptions_();

    if (this.options_.overrideNative &&
      tech.overrideNativeAudioTracks &&
      tech.overrideNativeVideoTracks) {
      tech.overrideNativeAudioTracks(true);
      tech.overrideNativeVideoTracks(true);
    } else if (this.options_.overrideNative &&
      (tech.featuresNativeVideoTracks || tech.featuresNativeAudioTracks)) {
      // overriding native VHS only works if audio tracks have been emulated
      // error early if we're misconfigured
      throw new Error('Overriding native VHS requires emulated tracks. ' +
        'See https://git.io/vMpjB');
    }

    // listen for fullscreenchange events for this player so that we
    // can adjust our quality selection quickly
    this.on(document, [
      'fullscreenchange', 'webkitfullscreenchange',
      'mozfullscreenchange', 'MSFullscreenChange'
    ], (event) => {
      const fullscreenElement = document.fullscreenElement ||
        document.webkitFullscreenElement ||
        document.mozFullScreenElement ||
        document.msFullscreenElement;

      if (fullscreenElement && fullscreenElement.contains(this.tech_.el())) {
        this.playlistController_.fastQualityChange_();
      } else {
        // When leaving fullscreen, since the in page pixel dimensions should be smaller
        // than full screen, see if there should be a rendition switch down to preserve
        // bandwidth.
        this.playlistController_.checkABR_();
      }
    });

    this.on(this.tech_, 'seeking', function() {
      if (this.ignoreNextSeekingEvent_) {
        this.ignoreNextSeekingEvent_ = false;
        return;
      }

      this.setCurrentTime(this.tech_.currentTime());
    });

    this.on(this.tech_, 'error', function() {
      // verify that the error was real and we are loaded
      // enough to have pc loaded.
      if (this.tech_.error() && this.playlistController_) {
        this.playlistController_.pauseLoading();
      }
    });

    this.on(this.tech_, 'play', this.play);
  }

  /**
   * Set VHS options based on options from configuration, as well as partial
   * options to be passed at a later time.
   *
   * @param {Object} options A partial chunk of config options
   */
  setOptions_(options = {}) {
    this.options_ = merge(this.options_, options);

    // defaults
    this.options_.withCredentials = this.options_.withCredentials || false;
    this.options_.limitRenditionByPlayerDimensions = this.options_.limitRenditionByPlayerDimensions === false ? false : true;
    this.options_.useDevicePixelRatio = this.options_.useDevicePixelRatio || false;
    this.options_.useBandwidthFromLocalStorage =
      typeof this.source_.useBandwidthFromLocalStorage !== 'undefined' ?
        this.source_.useBandwidthFromLocalStorage :
        this.options_.useBandwidthFromLocalStorage || false;
    this.options_.useForcedSubtitles = this.options_.useForcedSubtitles || false;
    this.options_.useNetworkInformationApi = this.options_.useNetworkInformationApi || false;
    this.options_.useDtsForTimestampOffset = this.options_.useDtsForTimestampOffset || false;
    this.options_.customTagParsers = this.options_.customTagParsers || [];
    this.options_.customTagMappers = this.options_.customTagMappers || [];
    this.options_.cacheEncryptionKeys = this.options_.cacheEncryptionKeys || false;
    this.options_.llhls = this.options_.llhls === false ? false : true;
    this.options_.bufferBasedABR = this.options_.bufferBasedABR || false;

    if (typeof this.options_.playlistExclusionDuration !== 'number') {
      this.options_.playlistExclusionDuration = 60;
    }

    if (typeof this.options_.bandwidth !== 'number') {
      if (this.options_.useBandwidthFromLocalStorage) {
        const storedObject = getVhsLocalStorage();

        if (storedObject && storedObject.bandwidth) {
          this.options_.bandwidth = storedObject.bandwidth;
          this.tech_.trigger({type: 'usage', name: 'vhs-bandwidth-from-local-storage'});
        }
        if (storedObject && storedObject.throughput) {
          this.options_.throughput = storedObject.throughput;
          this.tech_.trigger({type: 'usage', name: 'vhs-throughput-from-local-storage'});
        }
      }
    }
    // if bandwidth was not set by options or pulled from local storage, start playlist
    // selection at a reasonable bandwidth
    if (typeof this.options_.bandwidth !== 'number') {
      this.options_.bandwidth = Config.INITIAL_BANDWIDTH;
    }

    // If the bandwidth number is unchanged from the initial setting
    // then this takes precedence over the enableLowInitialPlaylist option
    this.options_.enableLowInitialPlaylist =
      this.options_.enableLowInitialPlaylist &&
      this.options_.bandwidth === Config.INITIAL_BANDWIDTH;

    // grab options passed to player.src
    [
      'withCredentials',
      'useDevicePixelRatio',
      'customPixelRatio',
      'limitRenditionByPlayerDimensions',
      'bandwidth',
      'customTagParsers',
      'customTagMappers',
      'cacheEncryptionKeys',
      'playlistSelector',
      'initialPlaylistSelector',
      'bufferBasedABR',
      'liveRangeSafeTimeDelta',
      'llhls',
      'useForcedSubtitles',
      'useNetworkInformationApi',
      'useDtsForTimestampOffset',
      'exactManifestTimings',
      'leastPixelDiffSelector'
    ].forEach((option) => {
      if (typeof this.source_[option] !== 'undefined') {
        this.options_[option] = this.source_[option];
      }
    });

    this.limitRenditionByPlayerDimensions = this.options_.limitRenditionByPlayerDimensions;
    this.useDevicePixelRatio = this.options_.useDevicePixelRatio;

    const customPixelRatio = this.options_.customPixelRatio;

    // Ensure the custom pixel ratio is a number greater than or equal to 0
    if (typeof customPixelRatio === 'number' && customPixelRatio >= 0) {
      this.customPixelRatio = customPixelRatio;
    }
  }
  // alias for public method to set options
  setOptions(options = {}) {
    this.setOptions_(options);
  }
  /**
   * called when player.src gets called, handle a new source
   *
   * @param {Object} src the source object to handle
   */
  src(src, type) {
    // do nothing if the src is falsey
    if (!src) {
      return;
    }
    this.setOptions_();
    // add main playlist controller options
    this.options_.src = expandDataUri(this.source_.src);
    this.options_.tech = this.tech_;
    this.options_.externVhs = Vhs;
    this.options_.sourceType = simpleTypeFromSourceType(type);

    // Whenever we seek internally, we should update the tech
    this.options_.seekTo = (time) => {
      this.tech_.setCurrentTime(time);
    };

    this.playlistController_ = new PlaylistController(this.options_);

    const playbackWatcherOptions = merge(
      {
        liveRangeSafeTimeDelta: SAFE_TIME_DELTA
      },
      this.options_,
      {
        seekable: () => this.seekable(),
        media: () => this.playlistController_.media(),
        playlistController: this.playlistController_
      }
    );

    this.playbackWatcher_ = new PlaybackWatcher(playbackWatcherOptions);

    this.playlistController_.on('error', () => {
      const player = videojs.players[this.tech_.options_.playerId];
      let error = this.playlistController_.error;

      if (typeof error === 'object' && !error.code) {
        error.code = 3;
      } else if (typeof error === 'string') {
        error = {message: error, code: 3};
      }

      player.error(error);
    });

    const defaultSelector = this.options_.bufferBasedABR ?
      Vhs.movingAverageBandwidthSelector(0.55) : Vhs.STANDARD_PLAYLIST_SELECTOR;

    // `this` in selectPlaylist should be the VhsHandler for backwards
    // compatibility with < v2
    this.playlistController_.selectPlaylist = this.selectPlaylist ?
      this.selectPlaylist.bind(this) :
      defaultSelector.bind(this);

    this.playlistController_.selectInitialPlaylist =
      Vhs.INITIAL_PLAYLIST_SELECTOR.bind(this);

    // re-expose some internal objects for backwards compatibility with < v2
    this.playlists = this.playlistController_.mainPlaylistLoader_;
    this.mediaSource = this.playlistController_.mediaSource;

    // Proxy assignment of some properties to the main playlist
    // controller. Using a custom property for backwards compatibility
    // with < v2
    Object.defineProperties(this, {
      selectPlaylist: {
        get() {
          return this.playlistController_.selectPlaylist;
        },
        set(selectPlaylist) {
          this.playlistController_.selectPlaylist = selectPlaylist.bind(this);
        }
      },
      throughput: {
        get() {
          return this.playlistController_.mainSegmentLoader_.throughput.rate;
        },
        set(throughput) {
          this.playlistController_.mainSegmentLoader_.throughput.rate = throughput;
          // By setting `count` to 1 the throughput value becomes the starting value
          // for the cumulative average
          this.playlistController_.mainSegmentLoader_.throughput.count = 1;
        }
      },
      bandwidth: {
        get() {
          let playerBandwidthEst = this.playlistController_.mainSegmentLoader_.bandwidth;

          const networkInformation = window.navigator.connection || window.navigator.mozConnection || window.navigator.webkitConnection;
          const tenMbpsAsBitsPerSecond = 10e6;

          if (this.options_.useNetworkInformationApi && networkInformation) {
            // downlink returns Mbps
            // https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation/downlink
            const networkInfoBandwidthEstBitsPerSec = networkInformation.downlink * 1000 * 1000;

            // downlink maxes out at 10 Mbps. In the event that both networkInformationApi and the player
            // estimate a bandwidth greater than 10 Mbps, use the larger of the two estimates to ensure that
            // high quality streams are not filtered out.
            if (networkInfoBandwidthEstBitsPerSec >= tenMbpsAsBitsPerSecond && playerBandwidthEst >= tenMbpsAsBitsPerSecond) {
              playerBandwidthEst = Math.max(playerBandwidthEst, networkInfoBandwidthEstBitsPerSec);
            } else {
              playerBandwidthEst = networkInfoBandwidthEstBitsPerSec;
            }
          }

          return playerBandwidthEst;
        },
        set(bandwidth) {
          this.playlistController_.mainSegmentLoader_.bandwidth = bandwidth;
          // setting the bandwidth manually resets the throughput counter
          // `count` is set to zero that current value of `rate` isn't included
          // in the cumulative average
          this.playlistController_.mainSegmentLoader_.throughput = {
            rate: 0,
            count: 0
          };
        }
      },
      /**
       * `systemBandwidth` is a combination of two serial processes bit-rates. The first
       * is the network bitrate provided by `bandwidth` and the second is the bitrate of
       * the entire process after that - decryption, transmuxing, and appending - provided
       * by `throughput`.
       *
       * Since the two process are serial, the overall system bandwidth is given by:
       *   sysBandwidth = 1 / (1 / bandwidth + 1 / throughput)
       */
      systemBandwidth: {
        get() {
          const invBandwidth = 1 / (this.bandwidth || 1);
          let invThroughput;

          if (this.throughput > 0) {
            invThroughput = 1 / this.throughput;
          } else {
            invThroughput = 0;
          }

          const systemBitrate = Math.floor(1 / (invBandwidth + invThroughput));

          return systemBitrate;
        },
        set() {
          videojs.log.error('The "systemBandwidth" property is read-only');
        }
      }
    });

    if (this.options_.bandwidth) {
      this.bandwidth = this.options_.bandwidth;
    }
    if (this.options_.throughput) {
      this.throughput = this.options_.throughput;
    }

    Object.defineProperties(this.stats, {
      bandwidth: {
        get: () => this.bandwidth || 0,
        enumerable: true
      },
      mediaRequests: {
        get: () => this.playlistController_.mediaRequests_() || 0,
        enumerable: true
      },
      mediaRequestsAborted: {
        get: () => this.playlistController_.mediaRequestsAborted_() || 0,
        enumerable: true
      },
      mediaRequestsTimedout: {
        get: () => this.playlistController_.mediaRequestsTimedout_() || 0,
        enumerable: true
      },
      mediaRequestsErrored: {
        get: () => this.playlistController_.mediaRequestsErrored_() || 0,
        enumerable: true
      },
      mediaTransferDuration: {
        get: () => this.playlistController_.mediaTransferDuration_() || 0,
        enumerable: true
      },
      mediaBytesTransferred: {
        get: () => this.playlistController_.mediaBytesTransferred_() || 0,
        enumerable: true
      },
      mediaSecondsLoaded: {
        get: () => this.playlistController_.mediaSecondsLoaded_() || 0,
        enumerable: true
      },
      mediaAppends: {
        get: () => this.playlistController_.mediaAppends_() || 0,
        enumerable: true
      },
      mainAppendsToLoadedData: {
        get: () => this.playlistController_.mainAppendsToLoadedData_() || 0,
        enumerable: true
      },
      audioAppendsToLoadedData: {
        get: () => this.playlistController_.audioAppendsToLoadedData_() || 0,
        enumerable: true
      },
      appendsToLoadedData: {
        get: () => this.playlistController_.appendsToLoadedData_() || 0,
        enumerable: true
      },
      timeToLoadedData: {
        get: () => this.playlistController_.timeToLoadedData_() || 0,
        enumerable: true
      },
      buffered: {
        get: () => timeRangesToArray(this.tech_.buffered()),
        enumerable: true
      },
      currentTime: {
        get: () => this.tech_.currentTime(),
        enumerable: true
      },
      currentSource: {
        get: () => this.tech_.currentSource_,
        enumerable: true
      },
      currentTech: {
        get: () => this.tech_.name_,
        enumerable: true
      },
      duration: {
        get: () => this.tech_.duration(),
        enumerable: true
      },
      main: {
        get: () => this.playlists.main,
        enumerable: true
      },
      playerDimensions: {
        get: () => this.tech_.currentDimensions(),
        enumerable: true
      },
      seekable: {
        get: () => timeRangesToArray(this.tech_.seekable()),
        enumerable: true
      },
      timestamp: {
        get: () => Date.now(),
        enumerable: true
      },
      videoPlaybackQuality: {
        get: () => this.tech_.getVideoPlaybackQuality(),
        enumerable: true
      }
    });

    this.tech_.one(
      'canplay',
      this.playlistController_.setupFirstPlay.bind(this.playlistController_)
    );

    this.tech_.on('bandwidthupdate', () => {
      if (this.options_.useBandwidthFromLocalStorage) {
        updateVhsLocalStorage({
          bandwidth: this.bandwidth,
          throughput: Math.round(this.throughput)
        });
      }
    });

    this.playlistController_.on('selectedinitialmedia', () => {
      // Add the manual rendition mix-in to VhsHandler
      renditionSelectionMixin(this);
    });

    this.playlistController_.sourceUpdater_.on('createdsourcebuffers', () => {
      this.setupEme_();
    });

    // the bandwidth of the primary segment loader is our best
    // estimate of overall bandwidth
    this.on(this.playlistController_, 'progress', function() {
      this.tech_.trigger('progress');
    });

    // In the live case, we need to ignore the very first `seeking` event since
    // that will be the result of the seek-to-live behavior
    this.on(this.playlistController_, 'firstplay', function() {
      this.ignoreNextSeekingEvent_ = true;
    });

    this.setupQualityLevels_();

    // do nothing if the tech has been disposed already
    // this can occur if someone sets the src in player.ready(), for instance
    if (!this.tech_.el()) {
      return;
    }

    this.mediaSourceUrl_ = window.URL.createObjectURL(this.playlistController_.mediaSource);

    this.tech_.src(this.mediaSourceUrl_);
  }

  createKeySessions_() {
    const audioPlaylistLoader =
      this.playlistController_.mediaTypes_.AUDIO.activePlaylistLoader;

    this.logger_('waiting for EME key session creation');
    waitForKeySessionCreation({
      player: this.player_,
      sourceKeySystems: this.source_.keySystems,
      audioMedia: audioPlaylistLoader && audioPlaylistLoader.media(),
      mainPlaylists: this.playlists.main.playlists
    }).then(() => {
      this.logger_('created EME key session');
      this.playlistController_.sourceUpdater_.initializedEme();
    }).catch((err) => {
      this.logger_('error while creating EME key session', err);
      this.player_.error({
        message: 'Failed to initialize media keys for EME',
        code: 3,
        metadata: {
          errorType: videojs.Error.EMEKeySessionCreationError
        }
      });
    });
  }

  handleWaitingForKey_() {
    // If waitingforkey is fired, it's possible that the data that's necessary to retrieve
    // the key is in the manifest. While this should've happened on initial source load, it
    // may happen again in live streams where the keys change, and the manifest info
    // reflects the update.
    //
    // Because videojs-contrib-eme compares the PSSH data we send to that of PSSH data it's
    // already requested keys for, we don't have to worry about this generating extraneous
    // requests.
    this.logger_('waitingforkey fired, attempting to create any new key sessions');
    this.createKeySessions_();
  }

  /**
   * If necessary and EME is available, sets up EME options and waits for key session
   * creation.
   *
   * This function also updates the source updater so taht it can be used, as for some
   * browsers, EME must be configured before content is appended (if appending unencrypted
   * content before encrypted content).
   */
  setupEme_() {
    const audioPlaylistLoader =
      this.playlistController_.mediaTypes_.AUDIO.activePlaylistLoader;

    const didSetupEmeOptions = setupEmeOptions({
      player: this.player_,
      sourceKeySystems: this.source_.keySystems,
      media: this.playlists.media(),
      audioMedia: audioPlaylistLoader && audioPlaylistLoader.media()
    });

    this.player_.tech_.on('keystatuschange', (e) => {
      this.playlistController_.updatePlaylistByKeyStatus(e.keyId, e.status);
    });

    this.handleWaitingForKey_ = this.handleWaitingForKey_.bind(this);
    this.player_.tech_.on('waitingforkey', this.handleWaitingForKey_);

    if (!didSetupEmeOptions) {
      // If EME options were not set up, we've done all we could to initialize EME.
      this.playlistController_.sourceUpdater_.initializedEme();
      return;
    }

    this.createKeySessions_();
  }

  /**
   * Initializes the quality levels and sets listeners to update them.
   *
   * @method setupQualityLevels_
   * @private
   */
  setupQualityLevels_() {
    const player = videojs.players[this.tech_.options_.playerId];

    // if there isn't a player or there isn't a qualityLevels plugin
    // or qualityLevels_ listeners have already been setup, do nothing.
    if (!player || !player.qualityLevels || this.qualityLevels_) {
      return;
    }

    this.qualityLevels_ = player.qualityLevels();

    this.playlistController_.on('selectedinitialmedia', () => {
      handleVhsLoadedMetadata(this.qualityLevels_, this);
    });

    this.playlists.on('mediachange', () => {
      handleVhsMediaChange(this.qualityLevels_, this.playlists);
    });
  }

  /**
   * return the version
   */
  static version() {
    return {
      '@videojs/http-streaming': vhsVersion,
      'mux.js': muxVersion,
      'mpd-parser': mpdVersion,
      'm3u8-parser': m3u8Version,
      'aes-decrypter': aesVersion
    };
  }

  /**
   * return the version
   */
  version() {
    return this.constructor.version();
  }

  canChangeType() {
    return SourceUpdater.canChangeType();
  }

  /**
   * Begin playing the video.
   */
  play() {
    this.playlistController_.play();
  }

  /**
   * a wrapper around the function in PlaylistController
   */
  setCurrentTime(currentTime) {
    this.playlistController_.setCurrentTime(currentTime);
  }

  /**
   * a wrapper around the function in PlaylistController
   */
  duration() {
    return this.playlistController_.duration();
  }

  /**
   * a wrapper around the function in PlaylistController
   */
  seekable() {
    return this.playlistController_.seekable();
  }

  /**
   * Abort all outstanding work and cleanup.
   */
  dispose() {
    if (this.playbackWatcher_) {
      this.playbackWatcher_.dispose();
    }
    if (this.playlistController_) {
      this.playlistController_.dispose();
    }
    if (this.qualityLevels_) {
      this.qualityLevels_.dispose();
    }

    if (this.tech_ && this.tech_.vhs) {
      delete this.tech_.vhs;
    }

    if (this.mediaSourceUrl_ && window.URL.revokeObjectURL) {
      window.URL.revokeObjectURL(this.mediaSourceUrl_);
      this.mediaSourceUrl_ = null;
    }

    if (this.tech_) {
      this.tech_.off('waitingforkey', this.handleWaitingForKey_);
    }

    super.dispose();
  }

  convertToProgramTime(time, callback) {
    return getProgramTime({
      playlist: this.playlistController_.media(),
      time,
      callback
    });
  }

  // the player must be playing before calling this
  seekToProgramTime(programTime, callback, pauseAfterSeek = true, retryCount = 2) {
    return seekToProgramTime({
      programTime,
      playlist: this.playlistController_.media(),
      retryCount,
      pauseAfterSeek,
      seekTo: this.options_.seekTo,
      tech: this.options_.tech,
      callback
    });
  }

  /**
   * Adds the onRequest, onResponse, offRequest and offResponse functions
   * to the VhsHandler xhr Object.
   */
  setupXhrHooks_() {
    /**
     * A player function for setting an onRequest hook
     *
     * @param {function} callback for request modifiction
     */
    this.xhr.onRequest = (callback) => {
      addOnRequestHook(this.xhr, callback);
    };

    /**
     * A player function for setting an onResponse hook
     *
     * @param {callback} callback for response data retrieval
     */
    this.xhr.onResponse = (callback) => {
      addOnResponseHook(this.xhr, callback);
    };

    /**
     * Deletes a player onRequest callback if it exists
     *
     * @param {function} callback to delete from the player set
     */
    this.xhr.offRequest = (callback) => {
      removeOnRequestHook(this.xhr, callback);
    };

    /**
     * Deletes a player onResponse callback if it exists
     *
     * @param {function} callback to delete from the player set
     */
    this.xhr.offResponse = (callback) => {
      removeOnResponseHook(this.xhr, callback);
    };

    // Trigger an event on the player to notify the user that vhs is ready to set xhr hooks.
    // This allows hooks to be set before the source is set to vhs when handleSource is called.
    this.player_.trigger('xhr-hooks-ready');
  }
}

/**
 * The Source Handler object, which informs video.js what additional
 * MIME types are supported and sets up playback. It is registered
 * automatically to the appropriate tech based on the capabilities of
 * the browser it is running in. It is not necessary to use or modify
 * this object in normal usage.
 */
const VhsSourceHandler = {
  name: 'videojs-http-streaming',
  VERSION: vhsVersion,
  canHandleSource(srcObj, options = {}) {
    const localOptions = merge(videojs.options, options);

    return VhsSourceHandler.canPlayType(srcObj.type, localOptions);
  },
  handleSource(source, tech, options = {}) {
    const localOptions = merge(videojs.options, options);

    tech.vhs = new VhsHandler(source, tech, localOptions);
    tech.vhs.xhr = xhrFactory();
    tech.vhs.setupXhrHooks_();
    tech.vhs.src(source.src, source.type);
    return tech.vhs;
  },
  canPlayType(type, options) {
    const simpleType = simpleTypeFromSourceType(type);

    if (!simpleType) {
      return '';
    }

    const overrideNative = VhsSourceHandler.getOverrideNative(options);
    const supportsTypeNatively = Vhs.supportsTypeNatively(simpleType);
    const canUseMsePlayback = !supportsTypeNatively || overrideNative;

    return canUseMsePlayback ? 'maybe' : '';
  },
  getOverrideNative(options = {}) {
    const { vhs = {} } = options;
    const defaultOverrideNative = !(videojs.browser.IS_ANY_SAFARI || videojs.browser.IS_IOS);
    const { overrideNative = defaultOverrideNative } = vhs;

    return overrideNative;
  }
};

/**
 * Check to see if the native MediaSource object exists and supports
 * an MP4 container with both H.264 video and AAC-LC audio.
 *
 * @return {boolean} if  native media sources are supported
 */
const supportsNativeMediaSources = () => {
  return browserSupportsCodec('avc1.4d400d,mp4a.40.2');
};

// register source handlers with the appropriate techs
if (supportsNativeMediaSources()) {
  videojs.getTech('Html5').registerSourceHandler(VhsSourceHandler, 0);
}

videojs.VhsHandler = VhsHandler;
videojs.VhsSourceHandler = VhsSourceHandler;
videojs.Vhs = Vhs;
if (!videojs.use) {
  videojs.registerComponent('Vhs', Vhs);
}
videojs.options.vhs = videojs.options.vhs || {};

if (!videojs.getPlugin || !videojs.getPlugin('reloadSourceOnError')) {
  videojs.registerPlugin('reloadSourceOnError', reloadSourceOnError);
}

export {
  Vhs,
  VhsHandler,
  VhsSourceHandler,
  emeKeySystems,
  simpleTypeFromSourceType,
  expandDataUri,
  setupEmeOptions,
  getAllPsshKeySystemsOptions
};
