Source: lib/media/drm_engine.js

/**
 * @license
 * Copyright 2016 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

goog.provide('shaka.media.DrmEngine');

goog.require('goog.asserts');
goog.require('shaka.log');
goog.require('shaka.net.NetworkingEngine');
goog.require('shaka.util.ArrayUtils');
goog.require('shaka.util.Error');
goog.require('shaka.util.EventManager');
goog.require('shaka.util.FakeEvent');
goog.require('shaka.util.IDestroyable');
goog.require('shaka.util.ManifestParserUtils');
goog.require('shaka.util.MapUtils');
goog.require('shaka.util.MimeUtils');
goog.require('shaka.util.PublicPromise');
goog.require('shaka.util.StringUtils');
goog.require('shaka.util.Timer');
goog.require('shaka.util.Uint8ArrayUtils');



/**
 * @param {shaka.media.DrmEngine.PlayerInterface} playerInterface
 *
 * @constructor
 * @struct
 * @implements {shaka.util.IDestroyable}
 */
shaka.media.DrmEngine = function(playerInterface) {
  /** @private {?shaka.media.DrmEngine.PlayerInterface} */
  this.playerInterface_ = playerInterface;

  /** @private {Array.<string>} */
  this.supportedTypes_ = null;

  /** @private {MediaKeys} */
  this.mediaKeys_ = null;

  /** @private {HTMLMediaElement} */
  this.video_ = null;

  /** @private {boolean} */
  this.initialized_ = false;

  /** @private {?shakaExtern.DrmInfo} */
  this.currentDrmInfo_ = null;

  /** @private {shaka.util.EventManager} */
  this.eventManager_ = new shaka.util.EventManager();

  /** @private {!Array.<shaka.media.DrmEngine.ActiveSession>} */
  this.activeSessions_ = [];

  /** @private {!Array.<string>} */
  this.offlineSessionIds_ = [];

  /** @private {!shaka.util.PublicPromise} */
  this.allSessionsLoaded_ = new shaka.util.PublicPromise();

  /** @private {?shakaExtern.DrmConfiguration} */
  this.config_ = null;

  /** @private {?function(!shaka.util.Error)} */
  this.onError_ = (function(err) {
    this.allSessionsLoaded_.reject(err);
    playerInterface.onError(err);
  }.bind(this));

  /**
   * The most recent key status information we have.
   * We may not have announced this information to the outside world yet,
   * which we delay to batch up changes and avoid spurious "missing key" errors.
   * @private {!Object.<string, string>}
   */
  this.keyStatusByKeyId_ = {};

  /**
   * The key statuses most recently announced to other classes.
   * We may have more up-to-date information being collected in
   * this.keyStatusByKeyId_, which has not been batched up and released yet.
   * @private {!Object.<string, string>}
   */
  this.announcedKeyStatusByKeyId_ = {};

  /** @private {shaka.util.Timer} */
  this.keyStatusTimer_ = new shaka.util.Timer(
      this.processKeyStatusChanges_.bind(this));

  /**
   * A flag to signal when have started destroying ourselves. This will:
   *  1. Stop later calls to |destroy| from trying to destroy the already
   *     destroyed (or currently destroying) DrmEngine.
   *  2. Stop in-progress async operations from continuing.
   *
   * @private {boolean}
   */
  this.isDestroying_ = false;

  /**
   * A promise that will only resolve once we have finished destroying
   * ourselves, this is used to ensure that subsequent calls to |destroy| don't
   * resolve before the first call to |destroy|.
   *
   * @private {!shaka.util.PublicPromise}
   */
  this.finishedDestroyingPromise_ = new shaka.util.PublicPromise();

  /** @private {boolean} */
  this.isOffline_ = false;

  /** @private {!Array.<!MediaKeyMessageEvent>} */
  this.mediaKeyMessageEvents_ = [];

  /** @private {boolean} */
  this.initialRequestsSent_ = false;

  /** @private {?shaka.util.Timer} */
  this.expirationTimer_ = new shaka.util.Timer(this.pollExpiration_.bind(this));
  this.expirationTimer_.scheduleRepeated(1);

  // Add a catch to the Promise to avoid console logs about uncaught errors.
  this.allSessionsLoaded_.catch(function() {});
};


/**
 * @typedef {{
 *   loaded: boolean,
 *   initData: Uint8Array,
 *   session: !MediaKeySession,
 *   oldExpiration: number,
 *   updatePromise: shaka.util.PublicPromise
 * }}
 *
 * @description A record to track sessions and suppress duplicate init data.
 * @property {boolean} loaded
 *   True once the key status has been updated (to a non-pending state).  This
 *   does not mean the session is 'usable'.
 * @property {Uint8Array} initData
 *   The init data used to create the session.
 * @property {!MediaKeySession} session
 *   The session object.
 * @property {number} oldExpiration
 *   The expiration of the session on the last check.  This is used to fire
 *   an event when it changes.
 * @property {shaka.util.PublicPromise} updatePromise
 *   An optional Promise that will be resolved/rejected on the next update()
 *   call.  This is used to track the 'license-release' message when calling
 *   remove().
 */
shaka.media.DrmEngine.ActiveSession;


/**
 * @typedef {{
 *   netEngine: !shaka.net.NetworkingEngine,
 *   onError: function(!shaka.util.Error),
 *   onKeyStatus: function(!Object.<string,string>),
 *   onExpirationUpdated: function(string,number),
 *   onEvent: function(!Event)
 * }}
 *
 * @property {shaka.net.NetworkingEngine} netEngine
 *   The NetworkingEngine instance to use.  The caller retains ownership.
 * @property {function(!shaka.util.Error)} onError
 *   Called when an error occurs.  If the error is recoverable (see
 *   {@link shaka.util.Error}) then the caller may invoke either
 *   StreamingEngine.switch*() or StreamingEngine.seeked() to attempt recovery.
 * @property {function(!Object.<string,string>)} onKeyStatus
 *   Called when key status changes.  The argument is a map of hex key IDs to
 *   statuses.
 * @property {function(string,number)} onExpirationUpdated
 *   Called when the session expiration value changes.
 * @property {function(!Event)} onEvent
 *   Called when an event occurs that should be sent to the app.
 */
shaka.media.DrmEngine.PlayerInterface;


/** @override */
shaka.media.DrmEngine.prototype.destroy = async function() {
  // If we have started destroying ourselves, wait for the common "I am finished
  // being destroyed" promise to be resolved.
  if (this.isDestroying_) {
    await this.finishedDestroyingPromise_;
  } else {
    this.isDestroying_ = true;
    await this.destroyNow_();
    this.finishedDestroyingPromise_.resolve();
  }
};


/**
 * Destroy this instance of DrmEngine. This assumes that all other checks about
 * "if it should" have passed.
 *
 * @private
 */
shaka.media.DrmEngine.prototype.destroyNow_ = async function() {
  // |eventManager_| should only be |null| after we call |destroy|. Destroy it
  // first so that we will stop responding to events.
  await this.eventManager_.destroy();
  this.eventManager_ = null;

  // Since we are destroying ourselves, we don't want to react to the "all
  // sessions loaded" event.
  this.allSessionsLoaded_.reject();

  // Stop all timers. This will ensure that they do not start any new work while
  // we are destroying ourselves.
  this.expirationTimer_.cancel();
  this.expirationTimer_ = null;

  this.keyStatusTimer_.cancel();
  this.keyStatusTimer_ = null;

  // Close all open sessions.
  const openSessions = this.activeSessions_;
  this.activeSessions_ = [];

  // Close all sessions before we remove media keys from the video element.
  await Promise.all(openSessions.map((session) => {
    return Promise.resolve().then(async () => {
      shaka.log.v1('Closing session', session.sessionId);

      try {
        await shaka.media.DrmEngine.closeSession_(session.session);
      } catch (error) {
        // Ignore errors when closing the sessions. Closing a session that
        // generated no key requests will throw an error.
      }
    });
  }));

  // |video_| will be |null| if we never attached to a video element.
  if (this.video_) {
    goog.asserts.assert(!this.video_.src, 'video src must be removed first!');

    try {
      await this.video_.setMediaKeys(null);
    } catch (error) {
      // Ignore any failures while removing media keys from the video element.
    }

    this.video_ = null;
  }

  // Break references to everything else we hold internally.
  this.currentDrmInfo_ = null;
  this.supportedTypes_ = null;
  this.mediaKeys_ = null;
  this.offlineSessionIds_ = [];
  this.config_ = null;
  this.onError_ = null;
  this.playerInterface_ = null;
};


/**
 * Called by the Player to provide an updated configuration any time it changes.
 * Must be called at least once before init().
 *
 * @param {shakaExtern.DrmConfiguration} config
 */
shaka.media.DrmEngine.prototype.configure = function(config) {
  this.config_ = config;
};


/**
 * Negotiate for a key system and set up MediaKeys.
 * @param {!shakaExtern.Manifest} manifest The manifest is read for MIME type
 *   and DRM information to query EME.  If the 'clearKeys' configuration is
 *   used, the manifest will be modified to force the use of Clear Key.
 * @param {boolean} offline True if we are storing or loading offline content.
 * @return {!Promise} Resolved if/when a key system has been chosen.
 */
shaka.media.DrmEngine.prototype.init = function(manifest, offline) {
  goog.asserts.assert(this.config_,
      'DrmEngine configure() must be called before init()!');

  /** @type {!Object.<string, MediaKeySystemConfiguration>} */
  let configsByKeySystem = {};

  /** @type {!Array.<string>} */
  let keySystemsInOrder = [];

  // If initial manifest contains unencrypted content, drm configuration
  // overrides DrmInfo so drmEngine can be activated. Thus, the player can play
  // encrypted content if live stream switches from unencrypted content to
  // encrypted content during live streams.
  let isEncryptedContent = manifest.periods.some((period) => {
    return period.variants.some((variant) => variant.drmInfos.length);
  });

  // |isOffline_| determines what kind of session to create.  The argument to
  // |prepareMediaKeyConfigs_| determines the kind of CDM to query for.  So
  // we still need persistent state when we are loading offline sessions.
  this.isOffline_ = offline;
  this.offlineSessionIds_ = manifest.offlineSessionIds;
  this.prepareMediaKeyConfigs_(
      manifest, offline || manifest.offlineSessionIds.length > 0,
      configsByKeySystem, keySystemsInOrder);

  if (!keySystemsInOrder.length) {
    // Unencrypted.
    this.initialized_ = true;
    return Promise.resolve();
  }

  return this.queryMediaKeys_(
      configsByKeySystem, keySystemsInOrder, isEncryptedContent);
};


/**
 * Attach MediaKeys to the video element and start processing events.
 * @param {HTMLMediaElement} video
 * @return {!Promise}
 */
shaka.media.DrmEngine.prototype.attach = function(video) {
  if (!this.mediaKeys_) {
    // Unencrypted, or so we think.  We listen for encrypted events in order to
    // warn when the stream is encrypted, even though the manifest does not know
    // it.
    // Don't complain about this twice, so just listenOnce().
    this.eventManager_.listenOnce(video, 'encrypted', function(event) {
      this.onError_(new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.DRM,
          shaka.util.Error.Code.ENCRYPTED_CONTENT_WITHOUT_DRM_INFO));
    }.bind(this));
    return Promise.resolve();
  }

  this.video_ = video;

  this.eventManager_.listenOnce(this.video_, 'play', this.onPlay_.bind(this));

  let setMediaKeys = this.video_.setMediaKeys(this.mediaKeys_);
  setMediaKeys = setMediaKeys.catch(function(exception) {
    return Promise.reject(new shaka.util.Error(
        shaka.util.Error.Severity.CRITICAL,
        shaka.util.Error.Category.DRM,
        shaka.util.Error.Code.FAILED_TO_ATTACH_TO_VIDEO,
        exception.message));
  });

  let setServerCertificate = null;
  if (this.currentDrmInfo_.serverCertificate &&
      this.currentDrmInfo_.serverCertificate.length) {
    setServerCertificate = this.mediaKeys_.setServerCertificate(
        this.currentDrmInfo_.serverCertificate).then(function(supported) {
      if (!supported) {
        shaka.log.warning('Server certificates are not supported by the key' +
                          ' system.  The server certificate has been ignored.');
      }
    }).catch(function(exception) {
      return Promise.reject(new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.DRM,
          shaka.util.Error.Code.INVALID_SERVER_CERTIFICATE,
          exception.message));
    });
  }

  return Promise.all([setMediaKeys, setServerCertificate]).then(() => {
    if (this.isDestroying_) { return Promise.reject(); }

    this.createOrLoad();
    if (!this.currentDrmInfo_.initData.length &&
        !this.offlineSessionIds_.length) {
      // Explicit init data for any one stream or an offline session is
      // sufficient to suppress 'encrypted' events for all streams.
      const cb = (e) =>
          this.newInitData(e.initDataType, new Uint8Array(e.initData));
      this.eventManager_.listen(this.video_, 'encrypted', cb);
    }
  }).catch((error) => {
    if (this.isDestroying_) { return; }
    return Promise.reject(error);
  });
};


/**
 * Removes the given offline sessions and deletes their data.  Must call init()
 * before this.  This will wait until the 'license-release' message is handled
 * and the resulting Promise will be rejected if there is an error with that.
 *
 * @param {!Array.<string>} sessions
 * @return {!Promise}
 */
shaka.media.DrmEngine.prototype.removeSessions = function(sessions) {
  goog.asserts.assert(this.mediaKeys_ || !sessions.length,
                      'Must call init() before removeSessions');
  return Promise.all(sessions.map(function(sessionId) {
    return this.loadOfflineSession_(sessionId).then(function(session) {
      // This will be null on error, such as session not found.
      if (session) {
        let p = new shaka.util.PublicPromise();
        // TODO: Consider adding a timeout to get the 'message' event.
        // Note that the 'message' event will get raised after the remove()
        // promise resolves.

        for (let i = 0; i < this.activeSessions_.length; i++) {
          if (this.activeSessions_[i].session == session) {
            this.activeSessions_[i].updatePromise = p;
            break;
          }
        }
        return Promise.all([session.remove(), p]);
      }
    }.bind(this));
  }.bind(this)));
};


/**
 * Creates the sessions for the init data and waits for them to become ready.
 *
 * @return {!Promise}
 */
shaka.media.DrmEngine.prototype.createOrLoad = function() {
  let initDatas = this.currentDrmInfo_ ? this.currentDrmInfo_.initData : [];
  initDatas.forEach(function(initDataOverride) {
    this.createTemporarySession_(
        initDataOverride.initDataType, initDataOverride.initData);
  }.bind(this));
  this.offlineSessionIds_.forEach(function(sessionId) {
    this.loadOfflineSession_(sessionId);
  }.bind(this));

  if (!initDatas.length && !this.offlineSessionIds_.length) {
    this.allSessionsLoaded_.resolve();
  }
  return this.allSessionsLoaded_;
};


/**
 * Called when new initialization data is encountered.  If this data hasn't
 * been seen yet, this will create a new session for it.
 *
 * @param {string} initDataType
 * @param {!Uint8Array} initData
 */
shaka.media.DrmEngine.prototype.newInitData = function(initDataType, initData) {
  // Aliases:
  const Uint8ArrayUtils = shaka.util.Uint8ArrayUtils;

  // Suppress duplicate init data.
  // Note that some init data are extremely large and can't portably be used as
  // keys in a dictionary.
  for (const session of this.activeSessions_) {
    if (Uint8ArrayUtils.equal(initData, session.initData)) {
      shaka.log.debug('Ignoring duplicate init data.');
      return;
    }
  }

  this.createTemporarySession_(initDataType, initData);
};


/** @return {boolean} */
shaka.media.DrmEngine.prototype.initialized = function() {
  return this.initialized_;
};


/** @return {string} */
shaka.media.DrmEngine.prototype.keySystem = function() {
  return this.currentDrmInfo_ ? this.currentDrmInfo_.keySystem : '';
};


/**
 * Returns an array of the media types supported by the current key system.
 * These will be full mime types (e.g. 'video/webm; codecs="vp8"').
 *
 * @return {Array.<string>}
 */
shaka.media.DrmEngine.prototype.getSupportedTypes = function() {
  return this.supportedTypes_;
};


/**
 * Returns the ID of the sessions currently active.
 *
 * @return {!Array.<string>}
 */
shaka.media.DrmEngine.prototype.getSessionIds = function() {
  return this.activeSessions_.map(function(session) {
    return session.session.sessionId;
  });
};


/**
 * Returns the next expiration time, or Infinity.
 * @return {number}
 */
shaka.media.DrmEngine.prototype.getExpiration = function() {
  let expirations = this.activeSessions_.map(function(session) {
    let expiration = session.session.expiration;
    return isNaN(expiration) ? Infinity : expiration;
  });
  // This will equal Infinity if there are no entries.
  return Math.min.apply(Math, expirations);
};


/**
 * Returns the DrmInfo that was used to initialize the current key system.
 *
 * @return {?shakaExtern.DrmInfo}
 */
shaka.media.DrmEngine.prototype.getDrmInfo = function() {
  return this.currentDrmInfo_;
};


/**
 * Returns the current key statuses.
 *
 * @return {!Object.<string, string>}
 */
shaka.media.DrmEngine.prototype.getKeyStatuses = function() {
  return this.announcedKeyStatusByKeyId_;
};


/**
 * @param {!shakaExtern.Manifest} manifest
 * @param {boolean} offline True if we are storing or loading offline content.
 * @param {!Object.<string, MediaKeySystemConfiguration>} configsByKeySystem
 *   (Output parameter.)  A dictionary of configs, indexed by key system.
 * @param {!Array.<string>} keySystemsInOrder
 *   (Output parameter.)  A list of key systems in the order in which we
 *   encounter them.
 * @see https://goo.gl/nwdYnY for MediaKeySystemConfiguration spec
 * @private
 */
shaka.media.DrmEngine.prototype.prepareMediaKeyConfigs_ =
    function(manifest, offline, configsByKeySystem, keySystemsInOrder) {
  let clearKeyDrmInfo = this.configureClearKey_();
  let configDrmInfos = this.getDrmInfosByConfig_(manifest);

  manifest.periods.forEach(function(period) {
    period.variants.forEach(function(variant) {
      // clearKey config overrides manifest DrmInfo if present.
      // The manifest is modified so that filtering in Player still works.
      if (clearKeyDrmInfo) {
        variant.drmInfos = [clearKeyDrmInfo];
      }

      // If initial manifest contains unencrypted content,
      // drm configuration overrides DrmInfo so drmEngine can be activated.
      // Thus, the player can play encrypted content if live stream switches
      // from unencrypted content to encrypted content during live streams.
      if (configDrmInfos) {
        variant.drmInfos = configDrmInfos;
      }

      variant.drmInfos.forEach(function(drmInfo) {
        this.fillInDrmInfoDefaults_(drmInfo);

        // Chromecast has a variant of PlayReady that uses a different key
        // system ID.  Since manifest parsers convert the standard PlayReady
        // UUID to the standard PlayReady key system ID, here we will switch
        // to the Chromecast version if we are running on that platform.
        // Note that this must come after fillInDrmInfoDefaults_, since the
        // player config uses the standard PlayReady ID for license server
        // configuration.
        if (window.cast && window.cast.__platform__) {
          if (drmInfo.keySystem == 'com.microsoft.playready') {
            drmInfo.keySystem = 'com.chromecast.playready';
          }
        }

        let config = configsByKeySystem[drmInfo.keySystem];
        if (!config) {
          config = {
            // Ignore initDataTypes.
            audioCapabilities: [],
            videoCapabilities: [],
            distinctiveIdentifier: 'optional',
            persistentState: offline ? 'required' : 'optional',
            sessionTypes: [offline ? 'persistent-license' : 'temporary'],
            label: drmInfo.keySystem,
            drmInfos: []  // Tracked by us, ignored by EME.
          };
          configsByKeySystem[drmInfo.keySystem] = config;
          keySystemsInOrder.push(drmInfo.keySystem);
        }

        config.drmInfos.push(drmInfo);

        if (drmInfo.distinctiveIdentifierRequired) {
          config.distinctiveIdentifier = 'required';
        }

        if (drmInfo.persistentStateRequired) {
          config.persistentState = 'required';
        }

        let streams = [];
        if (variant.video) streams.push(variant.video);
        if (variant.audio) streams.push(variant.audio);

        streams.forEach(function(stream) {
          const ContentType = shaka.util.ManifestParserUtils.ContentType;

          /** @type {!Array.<!MediaKeySystemMediaCapability>} */
          let capabilities = (stream.type == ContentType.VIDEO) ?
              config.videoCapabilities : config.audioCapabilities;
          /** @type {string} */
          let robustness = ((stream.type == ContentType.VIDEO) ?
              drmInfo.videoRobustness : drmInfo.audioRobustness) || '';

          let fullMimeType = shaka.util.MimeUtils.getFullType(
              stream.mimeType, stream.codecs);

          capabilities.push({
            robustness: robustness,
            contentType: fullMimeType
          });
        }.bind(this)); // streams.forEach (variant.video, variant.audio)
      }.bind(this)); // variant.drmInfos.forEach
    }.bind(this));  // periods.variants.forEach
  }.bind(this));  // manifest.perios.forEach
};


/**
 * @param {!Object.<string, MediaKeySystemConfiguration>} configsByKeySystem
 *   A dictionary of configs, indexed by key system.
 * @param {!Array.<string>} keySystemsInOrder
 *   A list of key systems in the order in which we should query them.
 *   On a browser which supports multiple key systems, the order may indicate
 *   a real preference for the application.
 * @param {boolean} isEncryptedContent
 *   True if the content is encrypted, false otherwise.
 * @return {!Promise} Resolved if/when a key system has been chosen.
 * @private
 */
shaka.media.DrmEngine.prototype.queryMediaKeys_ =
    function(configsByKeySystem, keySystemsInOrder, isEncryptedContent) {
  if (keySystemsInOrder.length == 1 && keySystemsInOrder[0] == '') {
    return Promise.reject(new shaka.util.Error(
        shaka.util.Error.Severity.CRITICAL,
        shaka.util.Error.Category.DRM,
        shaka.util.Error.Code.NO_RECOGNIZED_KEY_SYSTEMS));
  }

  // Wait to reject this initial Promise until we have built the entire chain.
  let instigator = new shaka.util.PublicPromise();
  let p = instigator;

  // Try key systems with configured license servers first.  We only have to try
  // key systems without configured license servers for diagnostic reasons, so
  // that we can differentiate between "none of these key systems are available"
  // and "some are available, but you did not configure them properly."  The
  // former takes precedence.
  [true, false].forEach(function(shouldHaveLicenseServer) {
    keySystemsInOrder.forEach(function(keySystem) {
      let config = configsByKeySystem[keySystem];

      let hasLicenseServer = config.drmInfos.some(function(info) {
        return !!info.licenseServerUri;
      });
      if (hasLicenseServer != shouldHaveLicenseServer) return;

      // If there are no tracks of a type, these should be not present.
      // Otherwise the query will fail.
      if (config.audioCapabilities.length == 0) {
        delete config.audioCapabilities;
      }
      if (config.videoCapabilities.length == 0) {
        delete config.videoCapabilities;
      }

      p = p.catch(function() {
        if (this.isDestroying_) { return; }
        return navigator.requestMediaKeySystemAccess(keySystem, [config]);
      }.bind(this));
    }.bind(this));
  }.bind(this));

  p = p.catch(function() {
    return Promise.reject(new shaka.util.Error(
        shaka.util.Error.Severity.CRITICAL,
        shaka.util.Error.Category.DRM,
        shaka.util.Error.Code.REQUESTED_KEY_SYSTEM_CONFIG_UNAVAILABLE));
  });

  p = p.then(function(mediaKeySystemAccess) {
    if (this.isDestroying_) { return Promise.reject(); }

    // TODO: Remove once Edge has released a fix for https://goo.gl/qMeV7v
    let isEdge = navigator.userAgent.indexOf('Edge/') >= 0;

    // Store the capabilities of the key system.
    let realConfig = mediaKeySystemAccess.getConfiguration();
    let audioCaps = realConfig.audioCapabilities || [];
    let videoCaps = realConfig.videoCapabilities || [];
    let caps = audioCaps.concat(videoCaps);
    this.supportedTypes_ = caps.map(function(c) { return c.contentType; });
    if (isEdge) {
      // Edge 14 does not report correct capabilities.  It will only report the
      // first MIME type even if the others are supported.  To work around this,
      // set the supported types to null, which Player will use as a signal that
      // the information is not available.
      // See: https://goo.gl/qMeV7v
      this.supportedTypes_ = null;
    }
    goog.asserts.assert(!this.supportedTypes_ || this.supportedTypes_.length,
                        'We should get at least one supported MIME type');

    let originalConfig = configsByKeySystem[mediaKeySystemAccess.keySystem];
    this.createCurrentDrmInfo_(
        mediaKeySystemAccess.keySystem, originalConfig,
        originalConfig.drmInfos);

    if (!this.currentDrmInfo_.licenseServerUri) {
      return Promise.reject(new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.DRM,
          shaka.util.Error.Code.NO_LICENSE_SERVER_GIVEN));
    }

    return mediaKeySystemAccess.createMediaKeys();
  }.bind(this)).then(function(mediaKeys) {
    if (this.isDestroying_) { return Promise.reject(); }
    shaka.log.info('Created MediaKeys object for key system',
                   this.currentDrmInfo_.keySystem);

    this.mediaKeys_ = mediaKeys;
    this.initialized_ = true;
  }.bind(this)).catch(function(exception) {
    if (this.isDestroying_) { return; }

    // Don't rewrap a shaka.util.Error from earlier in the chain:
    this.currentDrmInfo_ = null;
    this.supportedTypes_ = null;
    if (exception instanceof shaka.util.Error) {
      return Promise.reject(exception);
    }

    // We failed to create MediaKeys.  This generally shouldn't happen.
    return Promise.reject(new shaka.util.Error(
        shaka.util.Error.Severity.CRITICAL,
        shaka.util.Error.Category.DRM,
        shaka.util.Error.Code.FAILED_TO_CREATE_CDM,
        exception.message));
  }.bind(this));

  if (!isEncryptedContent) {
    // It doesn't matter if we fail to initialize the drm engine, if we won't
    // actually need it anyway.
    p = p.catch(() => {});
  }

  instigator.reject();
  return p;
};


/**
 * Use this.config_ to fill in missing values in drmInfo.
 * @param {shakaExtern.DrmInfo} drmInfo
 * @private
 */
shaka.media.DrmEngine.prototype.fillInDrmInfoDefaults_ = function(drmInfo) {
  let keySystem = drmInfo.keySystem;

  if (!keySystem) {
    // This is a placeholder from the manifest parser for an unrecognized key
    // system.  Skip this entry, to avoid logging nonsensical errors.
    return;
  }

  if (!drmInfo.licenseServerUri) {
    let server = this.config_.servers[keySystem];
    if (server) {
      drmInfo.licenseServerUri = server;
    }
  }

  if (!drmInfo.keyIds) {
    drmInfo.keyIds = [];
  }

  let advanced = this.config_.advanced[keySystem];
  if (advanced) {
    if (!drmInfo.distinctiveIdentifierRequired) {
      drmInfo.distinctiveIdentifierRequired =
          advanced.distinctiveIdentifierRequired;
    }

    if (!drmInfo.persistentStateRequired) {
      drmInfo.persistentStateRequired = advanced.persistentStateRequired;
    }

    if (!drmInfo.videoRobustness) {
      drmInfo.videoRobustness = advanced.videoRobustness;
    }

    if (!drmInfo.audioRobustness) {
      drmInfo.audioRobustness = advanced.audioRobustness;
    }

    if (!drmInfo.serverCertificate) {
      drmInfo.serverCertificate = advanced.serverCertificate;
    }
  }
};


/**
 * Create a DrmInfo using configured clear keys.
 * The server URI will be a data URI which decodes to a clearkey license.
 * @return {?shakaExtern.DrmInfo} or null if clear keys are not configured.
 * @private
 * @see https://goo.gl/6nPdhF for the spec on the clearkey license format.
 */
shaka.media.DrmEngine.prototype.configureClearKey_ = function() {
  let hasClearKeys = !shaka.util.MapUtils.empty(this.config_.clearKeys);
  if (!hasClearKeys) return null;

  const StringUtils = shaka.util.StringUtils;
  const Uint8ArrayUtils = shaka.util.Uint8ArrayUtils;
  let keys = [];
  let keyIds = [];

  for (let keyIdHex in this.config_.clearKeys) {
    let keyHex = this.config_.clearKeys[keyIdHex];

    let keyId = Uint8ArrayUtils.fromHex(keyIdHex);
    let key = Uint8ArrayUtils.fromHex(keyHex);
    let keyObj = {
      kty: 'oct',
      kid: Uint8ArrayUtils.toBase64(keyId, false),
      k: Uint8ArrayUtils.toBase64(key, false)
    };

    keys.push(keyObj);
    keyIds.push(keyObj.kid);
  }

  let jwkSet = {keys: keys};
  let license = JSON.stringify(jwkSet);

  // Use the keyids init data since is suggested by EME.
  // Suggestion: https://goo.gl/R72xp4
  // Format: https://www.w3.org/TR/eme-initdata-keyids/
  let initDataStr = JSON.stringify({'kids': keyIds});
  let initData = new Uint8Array(StringUtils.toUTF8(initDataStr));
  let initDatas = [{initData: initData, initDataType: 'keyids'}];

  return {
    keySystem: 'org.w3.clearkey',
    licenseServerUri: 'data:application/json;base64,' + window.btoa(license),
    distinctiveIdentifierRequired: false,
    persistentStateRequired: false,
    audioRobustness: '',
    videoRobustness: '',
    serverCertificate: null,
    initData: initDatas,
    keyIds: []
  };
};


/**
 * Returns the DrmInfo that is generated by drm configation.
 * It activates DrmEngine if drm configs have keySystems.
 * @param {!shakaExtern.Manifest} manifest
 * @return {Array.<{shakaExtern.DrmInfo}>}
 * @private
 */
shaka.media.DrmEngine.prototype.getDrmInfosByConfig_ = function(manifest) {
  let config = this.config_;
  let serverKeys = Object.keys(config.servers);

  if (!serverKeys.length) {
    return null;
  }

  let isEncryptedContent = manifest.periods.some(function(period) {
    return period.variants.some(function(variant) {
      return variant.drmInfos.length;
    });
  });

  // We should only create fake DrmInfos when none are provided by the manifest.
  if (isEncryptedContent) {
    return null;
  }

  return serverKeys.map(function(keySystem) {
    return {
      keySystem: keySystem,
      licenseServerUri: config.servers[keySystem],
      distinctiveIdentifierRequired: false,
      persistentStateRequired: false,
      audioRobustness: '',
      videoRobustness: '',
      serverCertificate: null,
      initData: [],
      keyIds: []
    };
  });
};


/**
 * Creates a DrmInfo object describing the settings used to initialize the
 * engine.
 *
 * @param {string} keySystem
 * @param {MediaKeySystemConfiguration} config
 * @param {!Array.<shakaExtern.DrmInfo>} drmInfos
 * @private
 */
shaka.media.DrmEngine.prototype.createCurrentDrmInfo_ = function(
    keySystem, config, drmInfos) {
  /** @type {!Array.<string>} */
  let licenseServers = [];

  /** @type {!Array.<!Uint8Array>} */
  let serverCerts = [];

  /** @type {!Array.<!shakaExtern.InitDataOverride>} */
  let initDatas = [];

  /** @type {!Array.<string>} */
  let keyIds = [];

  this.processDrmInfos_(drmInfos, licenseServers, serverCerts, initDatas,
      keyIds);

  if (serverCerts.length > 1) {
    shaka.log.warning('Multiple unique server certificates found! ' +
                      'Only the first will be used.');
  }

  if (licenseServers.length > 1) {
    shaka.log.warning('Multiple unique license server URIs found! ' +
                      'Only the first will be used.');
  }

  // TODO: This only works when all DrmInfo have the same robustness.
  let audioRobustness =
      config.audioCapabilities ? config.audioCapabilities[0].robustness : '';
  let videoRobustness =
      config.videoCapabilities ? config.videoCapabilities[0].robustness : '';
  this.currentDrmInfo_ = {
    keySystem: keySystem,
    licenseServerUri: licenseServers[0],
    distinctiveIdentifierRequired: (config.distinctiveIdentifier == 'required'),
    persistentStateRequired: (config.persistentState == 'required'),
    audioRobustness: audioRobustness,
    videoRobustness: videoRobustness,
    serverCertificate: serverCerts[0],
    initData: initDatas,
    keyIds: keyIds
  };
};


/**
 * Extract license server, server cert, and init data from DrmInfos, taking
 * care to eliminate duplicates.
 *
 * @param {!Array.<shakaExtern.DrmInfo>} drmInfos
 * @param {!Array.<string>} licenseServers
 * @param {!Array.<!Uint8Array>} serverCerts
 * @param {!Array.<!shakaExtern.InitDataOverride>} initDatas
 * @param {!Array.<string>} keyIds
 * @private
 */
shaka.media.DrmEngine.prototype.processDrmInfos_ =
    function(drmInfos, licenseServers, serverCerts, initDatas, keyIds) {
  /**
   * @param {shakaExtern.InitDataOverride} a
   * @param {shakaExtern.InitDataOverride} b
   * @return {boolean}
   */
  function initDataOverrideEqual(a, b) {
    if (a.keyId && a.keyId == b.keyId) {
      // Two initDatas with the same keyId are considered to be the same,
      // unless that "same keyId" is null.
      return true;
    }
    return a.initDataType == b.initDataType &&
           shaka.util.Uint8ArrayUtils.equal(a.initData, b.initData);
  }

  drmInfos.forEach(function(drmInfo) {
    // Aliases:
    const ArrayUtils = shaka.util.ArrayUtils;
    const Uint8ArrayUtils = shaka.util.Uint8ArrayUtils;

    // Build an array of unique license servers.
    if (licenseServers.indexOf(drmInfo.licenseServerUri) == -1) {
      licenseServers.push(drmInfo.licenseServerUri);
    }

    // Build an array of unique server certs.
    if (drmInfo.serverCertificate) {
      if (ArrayUtils.indexOf(serverCerts, drmInfo.serverCertificate,
                             Uint8ArrayUtils.equal) == -1) {
        serverCerts.push(drmInfo.serverCertificate);
      }
    }

    // Build an array of unique init datas.
    if (drmInfo.initData) {
      drmInfo.initData.forEach(function(initDataOverride) {
        if (ArrayUtils.indexOf(initDatas, initDataOverride,
                               initDataOverrideEqual) == -1) {
          initDatas.push(initDataOverride);
        }
      });
    }

    if (drmInfo.keyIds) {
      for (let i = 0; i < drmInfo.keyIds.length; ++i) {
        if (keyIds.indexOf(drmInfo.keyIds[i]) == -1) {
          keyIds.push(drmInfo.keyIds[i]);
        }
      }
    }
  });
};


/**
 * @param {string} sessionId
 * @return {!Promise.<MediaKeySession>}
 * @private
 */
shaka.media.DrmEngine.prototype.loadOfflineSession_ = function(sessionId) {
  let session;
  try {
    session = this.mediaKeys_.createSession('persistent-license');
  } catch (exception) {
    let error = new shaka.util.Error(
        shaka.util.Error.Severity.CRITICAL,
        shaka.util.Error.Category.DRM,
        shaka.util.Error.Code.FAILED_TO_CREATE_SESSION,
        exception.message);
    this.onError_(error);
    return Promise.reject(error);
  }

  this.eventManager_.listen(session, 'message',
      /** @type {shaka.util.EventManager.ListenerType} */(
          this.onSessionMessage_.bind(this)));
  this.eventManager_.listen(session, 'keystatuseschange',
      this.onKeyStatusesChange_.bind(this));

  let activeSession = {
    initData: null,
    session: session,
    loaded: false,
    oldExpiration: Infinity,
    updatePromise: null
  };
  this.activeSessions_.push(activeSession);

  return session.load(sessionId).then(function(present) {
    if (this.isDestroying_) { return Promise.reject(); }
    shaka.log.v2('Loaded offline session', sessionId, present);

    if (!present) {
      let i = this.activeSessions_.indexOf(activeSession);
      goog.asserts.assert(i >= 0, 'Session must be in the array');
      this.activeSessions_.splice(i, 1);

      this.onError_(new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.DRM,
          shaka.util.Error.Code.OFFLINE_SESSION_REMOVED));
      return;
    }

    // TODO: We should get a key status change event.  Remove once Chrome CDM
    // is fixed.
    activeSession.loaded = true;
    if (this.activeSessions_.every((s) => s.loaded)) {
      this.allSessionsLoaded_.resolve();
    }

    return session;
  }.bind(this), function(error) {
    if (this.isDestroying_) { return; }

    let i = this.activeSessions_.indexOf(activeSession);
    goog.asserts.assert(i >= 0, 'Session must be in the array');
    this.activeSessions_.splice(i, 1);

    this.onError_(new shaka.util.Error(
        shaka.util.Error.Severity.CRITICAL,
        shaka.util.Error.Category.DRM,
        shaka.util.Error.Code.FAILED_TO_CREATE_SESSION,
        error.message));
  }.bind(this));
};


/**
 * @param {string} initDataType
 * @param {!Uint8Array} initData
 * @private
 */
shaka.media.DrmEngine.prototype.createTemporarySession_ =
    function(initDataType, initData) {
  let session;
  try {
    if (this.isOffline_) {
      session = this.mediaKeys_.createSession('persistent-license');
    } else {
      session = this.mediaKeys_.createSession();
    }
  } catch (exception) {
    this.onError_(new shaka.util.Error(
        shaka.util.Error.Severity.CRITICAL,
        shaka.util.Error.Category.DRM,
        shaka.util.Error.Code.FAILED_TO_CREATE_SESSION,
        exception.message));
    return;
  }

  this.eventManager_.listen(session, 'message',
      /** @type {shaka.util.EventManager.ListenerType} */(
          this.onSessionMessage_.bind(this)));
  this.eventManager_.listen(session, 'keystatuseschange',
      this.onKeyStatusesChange_.bind(this));
  this.activeSessions_.push({
    initData: initData,
    session: session,
    loaded: false,
    oldExpiration: Infinity,
    updatePromise: null
  });

  session.generateRequest(initDataType, initData.buffer).catch((error) => {
    if (this.isDestroying_) { return; }

    for (let i = 0; i < this.activeSessions_.length; ++i) {
      if (this.activeSessions_[i].session == session) {
        this.activeSessions_.splice(i, 1);
        break;
      }
    }

    let extended;
    if (error.errorCode && error.errorCode.systemCode) {
      extended = error.errorCode.systemCode;
      if (extended < 0) {
        extended += Math.pow(2, 32);
      }
      extended = '0x' + extended.toString(16);
    }

    this.onError_(new shaka.util.Error(
        shaka.util.Error.Severity.CRITICAL,
        shaka.util.Error.Category.DRM,
        shaka.util.Error.Code.FAILED_TO_GENERATE_LICENSE_REQUEST,
        error.message, error, extended));
  });
};


/**
 * @param {!MediaKeyMessageEvent} event
 * @private
 */
shaka.media.DrmEngine.prototype.onSessionMessage_ = function(event) {
  if (this.delayLicenseRequest_()) {
    this.mediaKeyMessageEvents_.push(event);
  } else {
    this.sendLicenseRequest_(event);
  }
};


/**
 * @return {boolean}
 * @private
 */
shaka.media.DrmEngine.prototype.delayLicenseRequest_ = function() {
  return (this.config_.delayLicenseRequestUntilPlayed &&
          this.video_.paused && !this.initialRequestsSent_);
};


/**
 * Sends a license request.
 * @param {!MediaKeyMessageEvent} event
 * @private
 */
shaka.media.DrmEngine.prototype.sendLicenseRequest_ = function(event) {
  /** @type {!MediaKeySession} */
  let session = event.target;

  let activeSession;
  for (let i = 0; i < this.activeSessions_.length; i++) {
    if (this.activeSessions_[i].session == session) {
      activeSession = this.activeSessions_[i];
      break;
    }
  }

  const requestType = shaka.net.NetworkingEngine.RequestType.LICENSE;
  let request = shaka.net.NetworkingEngine.makeRequest(
      [this.currentDrmInfo_.licenseServerUri], this.config_.retryParameters);
  request.body = event.message;
  request.method = 'POST';
  // NOTE: allowCrossSiteCredentials can be set in a request filter.

  if (this.currentDrmInfo_.keySystem == 'com.microsoft.playready' ||
      this.currentDrmInfo_.keySystem == 'com.chromecast.playready') {
    this.unpackPlayReadyRequest_(request);
  }

  this.playerInterface_.netEngine.request(requestType, request).promise
      .then(function(response) {
        if (this.isDestroying_) { return Promise.reject(); }

        // Request succeeded, now pass the response to the CDM.
        return session.update(response.data).then(function() {
          let event = new shaka.util.FakeEvent('drmsessionupdate');
          this.playerInterface_.onEvent(event);

          if (activeSession) {
            if (activeSession.updatePromise) {
              activeSession.updatePromise.resolve();
            }
            // In case there are no key statuses, consider this session loaded
            // after a reasonable timeout.  It should definitely not take 5
            // seconds to process a license.
            setTimeout(function() {
              activeSession.loaded = true;
              if (this.activeSessions_.every((s) => s.loaded)) {
                this.allSessionsLoaded_.resolve();
              }
            }.bind(this), shaka.media.DrmEngine.SESSION_LOAD_TIMEOUT_ * 1000);
          }
        }.bind(this));
      }.bind(this), function(error) {
        // Ignore destruction errors
        if (this.isDestroying_) { return; }

        // Request failed!
        goog.asserts.assert(error instanceof shaka.util.Error,
                            'Wrong NetworkingEngine error type!');
        let shakaErr = new shaka.util.Error(
            shaka.util.Error.Severity.CRITICAL,
            shaka.util.Error.Category.DRM,
            shaka.util.Error.Code.LICENSE_REQUEST_FAILED,
            error);
        this.onError_(shakaErr);
        if (activeSession && activeSession.updatePromise) {
          activeSession.updatePromise.reject(shakaErr);
        }
      }.bind(this)).catch(function(error) {
        // Ignore destruction errors
        if (this.isDestroying_) { return; }

        // Session update failed!
        let shakaErr = new shaka.util.Error(
            shaka.util.Error.Severity.CRITICAL,
            shaka.util.Error.Category.DRM,
            shaka.util.Error.Code.LICENSE_RESPONSE_REJECTED,
            error.message);
        this.onError_(shakaErr);
        if (activeSession && activeSession.updatePromise) {
          activeSession.updatePromise.reject(shakaErr);
        }
      }.bind(this));
};


/**
 * Unpacks PlayReady license requests.  Modifies the request object.
 * @param {shakaExtern.Request} request
 * @private
 */
shaka.media.DrmEngine.prototype.unpackPlayReadyRequest_ = function(request) {
  // On IE and Edge, the raw license message is UTF-16-encoded XML.  We need to
  // unpack the Challenge element (base64-encoded string containing the actual
  // license request) and any HttpHeader elements (sent as request headers).

  // Example XML:

  // <PlayReadyKeyMessage type="LicenseAcquisition">
  //   <LicenseAcquisition Version="1">
  //     <Challenge encoding="base64encoded">{Base64Data}</Challenge>
  //     <HttpHeaders>
  //       <HttpHeader>
  //         <name>Content-Type</name>
  //         <value>text/xml; charset=utf-8</value>
  //       </HttpHeader>
  //       <HttpHeader>
  //         <name>SOAPAction</name>
  //         <value>http://schemas.microsoft.com/DRM/etc/etc</value>
  //       </HttpHeader>
  //     </HttpHeaders>
  //   </LicenseAcquisition>
  // </PlayReadyKeyMessage>

  let xml = shaka.util.StringUtils.fromUTF16(
      request.body, true /* littleEndian */, true /* noThrow */);
  if (xml.indexOf('PlayReadyKeyMessage') == -1) {
    // This does not appear to be a wrapped message as on IE and Edge.  Some
    // clients do not need this unwrapping, so we will assume this is one of
    // them.  Note that "xml" at this point probably looks like random garbage,
    // since we interpreted UTF-8 as UTF-16.
    shaka.log.debug('PlayReady request is already unwrapped.');
    request.headers['Content-Type'] = 'text/xml; charset=utf-8';
    return;
  }
  shaka.log.debug('Unwrapping PlayReady request.');
  let dom = new DOMParser().parseFromString(xml, 'application/xml');

  // Set request headers.
  let headers = dom.getElementsByTagName('HttpHeader');
  for (let i = 0; i < headers.length; ++i) {
    let name = headers[i].querySelector('name');
    let value = headers[i].querySelector('value');
    goog.asserts.assert(name && value, 'Malformed PlayReady headers!');
    request.headers[name.textContent] = value.textContent;
  }

  // Unpack the base64-encoded challenge.
  let challenge = dom.querySelector('Challenge');
  goog.asserts.assert(challenge, 'Malformed PlayReady challenge!');
  goog.asserts.assert(challenge.getAttribute('encoding') == 'base64encoded',
                      'Unexpected PlayReady challenge encoding!');
  request.body =
      shaka.util.Uint8ArrayUtils.fromBase64(challenge.textContent).buffer;
};


/**
 * @param {!Event} event
 * @private
 * @suppress {invalidCasts} to swap keyId and status
 */
shaka.media.DrmEngine.prototype.onKeyStatusesChange_ = function(event) {
  let session = /** @type {!MediaKeySession} */(event.target);

  // Locate the session in the active sessions list.
  let i;
  for (i = 0; i < this.activeSessions_.length; ++i) {
    if (this.activeSessions_[i].session == session) {
      break;
    }
  }
  const found = i < this.activeSessions_.length;

  let keyStatusMap = session.keyStatuses;
  let hasExpiredKeys = false;

  keyStatusMap.forEach(function(status, keyId) {
    // The spec has changed a few times on the exact order of arguments here.
    // As of 2016-06-30, Edge has the order reversed compared to the current
    // EME spec.  Given the back and forth in the spec, it may not be the only
    // one.  Try to detect this and compensate:
    if (typeof keyId == 'string') {
      let tmp = keyId;
      keyId = /** @type {ArrayBuffer} */(status);
      status = /** @type {string} */(tmp);
    }

    // Microsoft's implementation in Edge seems to present key IDs as
    // little-endian UUIDs, rather than big-endian or just plain array of bytes.
    // standard: 6e 5a 1d 26 - 27 57 - 47 d7 - 80 46 ea a5 d1 d3 4b 5a
    // on Edge:  26 1d 5a 6e - 57 27 - d7 47 - 80 46 ea a5 d1 d3 4b 5a
    // Bug filed: https://goo.gl/gnRSkJ

    // NOTE that we skip this if byteLength != 16.  This is used for the IE11
    // and Edge 12 EME polyfill, which uses single-byte dummy key IDs.
    // However, unlike Edge and Chromecast, Tizen doesn't have this problem.
    if (this.currentDrmInfo_.keySystem == 'com.microsoft.playready' &&
        keyId.byteLength == 16 && !/Tizen/.exec(navigator.userAgent)) {
      // Read out some fields in little-endian:
      let dataView = new DataView(keyId);
      let part0 = dataView.getUint32(0, true /* LE */);
      let part1 = dataView.getUint16(4, true /* LE */);
      let part2 = dataView.getUint16(6, true /* LE */);
      // Write it back in big-endian:
      dataView.setUint32(0, part0, false /* BE */);
      dataView.setUint16(4, part1, false /* BE */);
      dataView.setUint16(6, part2, false /* BE */);
    }

    // Microsoft's implementation in IE11 seems to never set key status to
    // 'usable'.  It is stuck forever at 'status-pending'.  In spite of this,
    // the keys do seem to be usable and content plays correctly.
    // Bug filed: https://goo.gl/fcXEy1
    // Microsoft has fixed the issue on Edge, but it remains in IE.
    if (this.currentDrmInfo_.keySystem == 'com.microsoft.playready' &&
        status == 'status-pending') {
      status = 'usable';
    }

    if (status != 'status-pending') {
      this.activeSessions_[i].loaded = true;
    }

    if (!found) {
      // We can get a key status changed for a closed session after it has been
      // removed from |activeSessions_|.  If it is closed, none of its keys
      // should be usable.
      goog.asserts.assert(
          status != 'usable', 'Usable keys found in closed session');
    }

    if (status == 'expired') {
      hasExpiredKeys = true;
    }

    let keyIdHex = shaka.util.Uint8ArrayUtils.toHex(new Uint8Array(keyId));

    this.keyStatusByKeyId_[keyIdHex] = status;
  }.bind(this));

  // If the session has expired, close it.
  // Some CDMs do not have sub-second time resolution, so the key status may
  // fire with hundreds of milliseconds left until the stated expiration time.
  let msUntilExpiration = session.expiration - Date.now();
  if (msUntilExpiration < 0 || (hasExpiredKeys && msUntilExpiration < 1000)) {
    // If this is part of a remove(), we don't want to close the session until
    // the update is complete.  Otherwise, we will orphan the session.
    if (found && !this.activeSessions_[i].updatePromise) {
      shaka.log.debug('Session has expired', session);
      this.activeSessions_.splice(i, 1);
      session.close().catch(() => {});  // Silence uncaught rejection errors
    }
  }

  const allSessionsLoaded = this.activeSessions_.every((s) => s.loaded);
  if (!allSessionsLoaded) {
    // Don't announce key statuses or resolve the "all loaded" promise until
    // everything is loaded.
    return;
  }

  this.allSessionsLoaded_.resolve();

  // Batch up key status changes before checking them or notifying Player.
  // This handles cases where the statuses of multiple sessions are set
  // simultaneously by the browser before dispatching key status changes for
  // each of them.  By batching these up, we only send one status change event
  // and at most one EXPIRED error on expiration.
  this.keyStatusTimer_.schedule(shaka.media.DrmEngine.KEY_STATUS_BATCH_TIME_);
};


/**
 * @private
 */
shaka.media.DrmEngine.prototype.processKeyStatusChanges_ = function() {
  // Copy the latest key statuses into the publicly-accessible map.
  this.announcedKeyStatusByKeyId_ = {};
  for (let keyId in this.keyStatusByKeyId_) {
    this.announcedKeyStatusByKeyId_[keyId] = this.keyStatusByKeyId_[keyId];
  }

  // If all keys are expired, fire an error.
  function isExpired(keyId, status) {
    return status == 'expired';
  }
  const MapUtils = shaka.util.MapUtils;
  // Note that every() is always true for an empty map,
  // but we shouldn't fire an error for a lack of key status info.
  let allExpired = !MapUtils.empty(this.announcedKeyStatusByKeyId_) &&
                   MapUtils.every(this.announcedKeyStatusByKeyId_, isExpired);

  if (allExpired) {
    this.onError_(new shaka.util.Error(
        shaka.util.Error.Severity.CRITICAL,
        shaka.util.Error.Category.DRM,
        shaka.util.Error.Code.EXPIRED));
  }

  this.playerInterface_.onKeyStatus(this.announcedKeyStatusByKeyId_);
};


/**
 * Returns true if the browser has recent EME APIs.
 *
 * @return {boolean}
 */
shaka.media.DrmEngine.isBrowserSupported = function() {
  let basic =
      !!window.MediaKeys &&
      !!window.navigator &&
      !!window.navigator.requestMediaKeySystemAccess &&
      !!window.MediaKeySystemAccess &&
      !!window.MediaKeySystemAccess.prototype.getConfiguration;

  return basic;
};


/**
 * Returns a Promise to a map of EME support for well-known key systems.
 *
 * @return {!Promise.<!Object.<string, ?shakaExtern.DrmSupportType>>}
 */
shaka.media.DrmEngine.probeSupport = function() {
  goog.asserts.assert(shaka.media.DrmEngine.isBrowserSupported(),
                      'Must have basic EME support');

  let tests = [];
  let testKeySystems = [
    'org.w3.clearkey',
    'com.widevine.alpha',
    'com.microsoft.playready',
    'com.apple.fps.2_0',
    'com.apple.fps.1_0',
    'com.apple.fps',
    'com.adobe.primetime'
  ];

  let basicVideoCapabilities = [
    {contentType: 'video/mp4; codecs="avc1.42E01E"'},
    {contentType: 'video/webm; codecs="vp8"'}
  ];

  let basicConfig = {
    videoCapabilities: basicVideoCapabilities
  };
  let offlineConfig = {
    videoCapabilities: basicVideoCapabilities,
    persistentState: 'required',
    sessionTypes: ['persistent-license']
  };

  // Try the offline config first, then fall back to the basic config.
  let configs = [offlineConfig, basicConfig];

  let support = {};
  testKeySystems.forEach(function(keySystem) {
    let p = navigator.requestMediaKeySystemAccess(keySystem, configs)
        .then(function(access) {
          // Edge doesn't return supported session types, but current versions
          // do not support persistent-license.  If sessionTypes is missing,
          // assume no support for persistent-license.
          // TODO: Polyfill Edge to return known supported session types.
          // Edge bug: https://goo.gl/z0URJ0
          let sessionTypes = access.getConfiguration().sessionTypes;
          let persistentState = sessionTypes ?
              sessionTypes.indexOf('persistent-license') >= 0 : false;

          // Tizen 3.0 doesn't support persistent licenses, but reports that it
          // does.  It doesn't fail until you call update() with a license
          // response, which is way too late.
          // This is a work-around for #894.
          if (navigator.userAgent.indexOf('Tizen 3') >= 0) {
            persistentState = false;
          }

          support[keySystem] = {persistentState: persistentState};
          return access.createMediaKeys();
        }).catch(function() {
          // Either the request failed or createMediaKeys failed.
          // Either way, write null to the support object.
          support[keySystem] = null;
        });
    tests.push(p);
  });

  return Promise.all(tests).then(function() {
    return support;
  });
};


/**
 * @private
 */
shaka.media.DrmEngine.prototype.onPlay_ = function() {
  for (let i = 0; i < this.mediaKeyMessageEvents_.length; i++) {
    this.sendLicenseRequest_(this.mediaKeyMessageEvents_[i]);
  }

  this.initialRequestsSent_ = true;
  this.mediaKeyMessageEvents_ = [];
};


/**
 * Checks if a variant is compatible with the key system.
 * @param {!shakaExtern.Variant} variant
 * @return {boolean}
**/
shaka.media.DrmEngine.prototype.isSupportedByKeySystem = function(variant) {
  let keySystem = this.keySystem();
  return variant.drmInfos.length == 0 ||
      variant.drmInfos.some(function(drmInfo) {
        return drmInfo.keySystem == keySystem;
      });
};


/**
 * Checks if two DrmInfos can be decrypted using the same key system.
 * Clear content is considered compatible with every key system.
 *
 * @param {!Array.<!shakaExtern.DrmInfo>} drms1
 * @param {!Array.<!shakaExtern.DrmInfo>} drms2
 * @return {boolean}
 */
shaka.media.DrmEngine.areDrmCompatible = function(drms1, drms2) {
  if (!drms1.length || !drms2.length) return true;

  return shaka.media.DrmEngine.getCommonDrmInfos(
      drms1, drms2).length > 0;
};


/**
 * Returns an array of drm infos that are present in both input arrays.
 * If one of the arrays is empty, returns the other one since clear
 * content is considered compatible with every drm info.
 *
 * @param {!Array.<!shakaExtern.DrmInfo>} drms1
 * @param {!Array.<!shakaExtern.DrmInfo>} drms2
 * @return {!Array.<!shakaExtern.DrmInfo>}
 */
shaka.media.DrmEngine.getCommonDrmInfos = function(drms1, drms2) {
  if (!drms1.length) return drms2;
  if (!drms2.length) return drms1;

  let commonDrms = [];

  for (let i = 0; i < drms1.length; i++) {
    for (let j = 0; j < drms2.length; j++) {
      // This method is only called to compare drmInfos of a video and an audio
      // adaptations, so we shouldn't have to worry about checking robustness.
      if (drms1[i].keySystem == drms2[j].keySystem) {
        let drm1 = drms1[i];
        let drm2 = drms2[j];
        let initData = [];
        initData = initData.concat(drm1.initData || []);
        initData = initData.concat(drm2.initData || []);
        let keyIds = [];
        keyIds = keyIds.concat(drm1.keyIds);
        keyIds = keyIds.concat(drm2.keyIds);
        let mergedDrm = {
          keySystem: drm1.keySystem,
          licenseServerUri: drm1.licenseServerUri || drm2.licenseServerUri,
          distinctiveIdentifierRequired: drm1.distinctiveIdentifierRequired ||
              drm2.distinctiveIdentifierRequired,
          persistentStateRequired: drm1.persistentStateRequired ||
              drm2.persistentStateRequired,
          videoRobustness: drm1.videoRobustness || drm2.videoRobustness,
          audioRobustness: drm1.audioRobustness || drm2.audioRobustness,
          serverCertificate: drm1.serverCertificate || drm2.serverCertificate,
          initData: initData,
          keyIds: keyIds
        };
        commonDrms.push(mergedDrm);
        break;
      }
    }
  }

  return commonDrms;
};


/**
 * Called in an interval timer to poll the expiration times of the sessions.  We
 * don't get an event from EME when the expiration updates, so we poll it so we
 * can fire an event when it happens.
 * @private
 */
shaka.media.DrmEngine.prototype.pollExpiration_ = function() {
  this.activeSessions_.forEach(function(session) {
    let old = session.oldExpiration;
    let new_ = session.session.expiration;
    if (isNaN(new_)) {
      new_ = Infinity;
    }

    if (new_ != old) {
      this.playerInterface_.onExpirationUpdated(
          session.session.sessionId, new_);
      session.oldExpiration = new_;
    }
  }.bind(this));
};


/**
 * Close a drm session while accounting for a bug in Chrome. Sometimes the
 * Promise returned by close() never resolves.
 *
 * See issue #1093 and https://crbug.com/690583.
 *
 * @param {!MediaKeySession} session
 * @return {!Promise}
 * @private
 */
shaka.media.DrmEngine.closeSession_ = async function(session) {
  /** @type {!Promise.<boolean>} */
  const close = session.close().then(() => true);

  /** @type {!Promise.<boolean>} */
  const timeout = new Promise((resolve) => {
    setTimeout(() => { resolve(false); },
               shaka.media.DrmEngine.CLOSE_TIMEOUT_ * 1000);
  });

  /** @type {boolean} */
  const wasSessionClosed = await Promise.race([close, timeout]);

  if (!wasSessionClosed) {
    shaka.log.warning('Timeout waiting for session close');
  }
};


/**
 * The amount of time, in seconds, we wait to consider a session closed.
 * This allows us to work around Chrome bug https://crbug.com/690583.
 * @private {number}
 */
shaka.media.DrmEngine.CLOSE_TIMEOUT_ = 1;

/**
 * The amount of time, in seconds, we wait to consider session loaded even if no
 * key status information is available.  This allows us to support browsers/CDMs
 * without key statuses.
 * @private {number}
 */
shaka.media.DrmEngine.SESSION_LOAD_TIMEOUT_ = 5;

/**
 * The amount of time, in seconds, we wait to batch up rapid key status changes.
 * This allows us to avoid multiple expiration events in most cases.
 * @private {number}
 */
shaka.media.DrmEngine.KEY_STATUS_BATCH_TIME_ = 0.5;