Source: media/source_buffer_manager.js

/**
 * @license
 * Copyright 2015 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.SourceBufferManager');

goog.require('shaka.asserts');
goog.require('shaka.features');
goog.require('shaka.player.Defaults');
goog.require('shaka.util.ContentDatabaseReader');
goog.require('shaka.util.EventManager');
goog.require('shaka.util.IBandwidthEstimator');
goog.require('shaka.util.PublicPromise');
goog.require('shaka.util.Task');
goog.require('shaka.util.TypedBind');



/**
 * Creates a SourceBufferManager (SBM), which manages a SourceBuffer and
 * provides an enhanced interface based on Promises.
 *
 * The SBM manages access to a SourceBuffer object through a fetch operation
 * and a clear operation. It also maintains a "virtual source buffer" to keep
 * track of which segments have been appended to the actual underlying
 * SourceBuffer. The SBM uses this virtual source buffer because it cannot rely
 * on the browser to tell it what is in the underlying SourceBuffer because a
 * SegmentIndex may use PTS (presentation timestamps) and a browser may use
 * DTS (decoding timestamps) or vice-versa.
 *
 * @param {!MediaSource} mediaSource The MediaSource, which must be in the
 *     'open' state.
 * @param {string} fullMimeType The full MIME type of the SourceBuffer to
 *     create, which |mediaSource| must support.
 * @param {!shaka.util.IBandwidthEstimator} estimator A bandwidth estimator to
 *     attach to all requests.
 *
 * @throws {QuotaExceededError} if no more SourceBuffers are allowed.
 *
 * @struct
 * @constructor
 */
shaka.media.SourceBufferManager = function(
    mediaSource, fullMimeType, estimator) {
  shaka.asserts.assert(mediaSource.readyState == 'open',
                       'The MediaSource should be in the \'open\' state.');
  shaka.asserts.assert(fullMimeType.length > 0);
  shaka.asserts.assert(MediaSource.isTypeSupported(fullMimeType));

  var sourceBuffer = mediaSource.addSourceBuffer(fullMimeType);
  shaka.asserts.assert(sourceBuffer, 'SourceBuffer should not be null.');

  /** @private {!MediaSource} */
  this.mediaSource_ = mediaSource;

  /** @private {!SourceBuffer} */
  this.sourceBuffer_ = /** @type {!SourceBuffer} */(sourceBuffer);

  /** @private {!shaka.util.IBandwidthEstimator} */
  this.estimator_ = estimator;

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

  /** @private {shaka.util.ContentDatabaseReader} */
  this.contentDatabase_ = null;

  /**
   * Contains a list of segments that have been inserted into the SourceBuffer.
   * These segments may or may not have been evicted by the browser.
   * @private {!Array.<!shaka.media.SegmentReference>}
   */
  this.inserted_ = [];

  /** @private {number} */
  this.timestampCorrection_ = 0;

  /** @private {shaka.util.Task} */
  this.task_ = null;

  /** @private {shaka.util.PublicPromise} */
  this.operationPromise_ = null;

  /** @private {number} */
  this.segmentRequestTimeout_ = shaka.player.Defaults.SEGMENT_REQUEST_TIMEOUT;

  // For debugging purposes:
  if (!COMPILED) {
    /** @private {string} */
    this.mimeType_ = fullMimeType.split(';')[0];
    shaka.asserts.assert(this.mimeType_.length > 0);
  }

  this.eventManager_.listen(
      this.sourceBuffer_,
      'updateend',
      this.onSourceBufferUpdateEnd_.bind(this));
};


/**
 * A fudge factor to apply to buffered ranges to account for rounding error.
 * @const {number}
 * @private
 */
shaka.media.SourceBufferManager.FUDGE_FACTOR_ = 1 / 60;


/**
 * Destroys the SourceBufferManager.
 * @suppress {checkTypes} to set otherwise non-nullable types to null.
 */
shaka.media.SourceBufferManager.prototype.destroy = function() {
  this.abort().catch(function() {});

  if (this.operationPromise_) {
    this.operationPromise_.destroy();
  }
  this.operationPromise_ = null;
  this.task_ = null;

  if (shaka.features.Offline && this.contentDatabase_) {
    this.contentDatabase_.closeDatabaseConnection();
    this.contentDatabase_ = null;
  }

  this.inserted_ = null;

  this.eventManager_.destroy();
  this.eventManager_ = null;

  this.sourceBuffer_ = null;
  this.mediaSource_ = null;
};


/**
 * Checks if the given timestamp is buffered according to the virtual source
 * buffer.
 *
 * Note that as a SegmentIndex may use PTS and a browser may use DTS or
 * vice-versa, and due to MSE implementation details, isInserted(t) does not
 * imply isBuffered(t) nor does isBuffered(t) imply isInserted(t).
 *
 * @param {number} timestamp The timestamp in seconds.
 * @return {boolean} True if the timestamp is buffered.
 */
shaka.media.SourceBufferManager.prototype.isInserted = function(timestamp) {
  return shaka.media.SegmentReference.find(this.inserted_, timestamp) >= 0;
};


/**
 * Gets the SegmentReference corresponding to the last inserted segment.
 *
 * @return {shaka.media.SegmentReference}
 */
shaka.media.SourceBufferManager.prototype.getLastInserted = function() {
  var length = this.inserted_.length;
  return length > 0 ? this.inserted_[length - 1] : null;
};


/**
 * Checks if the given timestamp is buffered according to the underlying
 * SourceBuffer.
 *
 * @param {number} timestamp The timestamp in seconds.
 * @return {boolean} True if the timestamp is buffered.
 */
shaka.media.SourceBufferManager.prototype.isBuffered = function(timestamp) {
  return this.bufferedAheadOf(timestamp) > 0;
};


/**
 * Computes how far ahead of the given timestamp we have buffered according to
 * the underlying SourceBuffer.
 *
 * @param {number} timestamp The timestamp in seconds.
 * @return {number} in seconds
 */
shaka.media.SourceBufferManager.prototype.bufferedAheadOf =
    function(timestamp) {
  var b = this.sourceBuffer_.buffered;
  for (var i = 0; i < b.length; ++i) {
    var start = b.start(i) - shaka.media.SourceBufferManager.FUDGE_FACTOR_;
    var end = b.end(i) + shaka.media.SourceBufferManager.FUDGE_FACTOR_;
    if (timestamp >= start && timestamp <= end) {
      return b.end(i) - timestamp;
    }
  }
  return 0;
};


/**
 * Fetches the segment corresponding to the given SegmentReference and appends
 * the it to the underlying SourceBuffer. This cannot be called if another
 * operation is in progress.
 *
 * @param {!shaka.media.SegmentReference} reference
 * @param {number} timestampOffset An offset, in seconds, that will be applied
 *     to each timestamp within the segment before appending it to the
 *     underlying SourceBuffer.
 * @param {ArrayBuffer} initData Optional initialization segment that
 *     will be appended to the underlying SourceBuffer before the retrieved
 *     segment.
 * @return {!Promise.<?number>} A promise to a timestamp correction, which may
 *     be null if a timestamp correction could not be computed. A timestamp
 *     correction is computed if the underlying SourceBuffer is initially
 *     empty. The timestamp correction, if one is computed, is not
 *     automatically applied to the virtual source buffer; to apply a timestamp
 *     correction, call correct().
 */
shaka.media.SourceBufferManager.prototype.fetch = function(
    reference, timestampOffset, initData) {
  shaka.log.v1(this.logPrefix_(), 'fetch');

  // Check state.
  shaka.asserts.assert(!this.task_);
  if (this.task_) {
    var error = new Error('Cannot fetch (' + this.mimeType_ + '): ' +
                          'previous operation not complete.');
    error.type = 'stream';
    return Promise.reject(error);
  }

  this.task_ = new shaka.util.Task();

  if (timestampOffset != this.sourceBuffer_.timestampOffset) {
    shaka.log.debug(
        this.logPrefix_(), 'setting timestampOffset to', timestampOffset);
    this.sourceBuffer_.timestampOffset = timestampOffset;
  }

  if (shaka.features.Offline &&
      reference.url.isOfflineUri() && !this.contentDatabase_) {
    this.contentDatabase_ = new shaka.util.ContentDatabaseReader();
    this.task_.append(
        function() {
          return [this.contentDatabase_.setUpDatabase()];
        }.bind(this));
  }

  if (initData) {
    this.task_.append(
        function() {
          return [this.append_(/** @type {!ArrayBuffer} */(initData)),
                  this.abort_.bind(this)];
        }.bind(this));
  }

  this.task_.append(
      function() {
        var refDuration =
            reference.endTime ? (reference.endTime - reference.startTime) : 1;
        var params = new shaka.util.AjaxRequest.Parameters();
        params.maxAttempts = 3;
        params.baseRetryDelayMs = refDuration * 1000;
        params.requestTimeoutMs = this.segmentRequestTimeout_ * 1000;
        params.contentDatabase = this.contentDatabase_;
        return [
          reference.url.fetch(params, this.estimator_),
          shaka.util.FailoverUri.prototype.abortFetch.bind(reference.url)];
      }.bind(this));

  // Sanity check: appendBuffer() should not modify the MediaSource's duration
  // because an appropriate append window should have been set.
  //
  // On some browsers, even with an append window, inserting a segment that
  // ends past the end of the append window can increase the MediaSource's
  // duration (slightly). However, when this occurs it appears that no content
  // is actually buffered past the end of the append window, and subsequently
  // calling endOfStream() resets the MediaSource's duration to the correct
  // value (i.e., the end of the append window).
  //
  // TODO: Determine if this is a browser bug or is actually compliant with the
  // MSE spec.
  if (!COMPILED) {
    var durationBefore;
  }

  this.task_.append(shaka.util.TypedBind(this,
      /** @param {!ArrayBuffer} data */
      function(data) {
        if (!COMPILED) {
          durationBefore = this.mediaSource_.duration;
        }

        shaka.log.debug('Estimated bandwidth:',
            (this.estimator_.getBandwidth() / 1e6).toFixed(2), 'Mbps');
        return [this.append_(data), this.abort_.bind(this)];
      }));

  var computeTimestampCorrection =
      this.sourceBuffer_.buffered.length == 0 &&
      this.inserted_.length == 0;

  /** @type {?number} */
  var timestampCorrection = null;

  this.task_.append(
      function() {
        if (!COMPILED) {
          var durationAfter = this.mediaSource_.duration;
          if (durationAfter != durationBefore) {
            shaka.log.warning(
                this.logPrefix_(),
                'appendBuffer() should not modify the MediaSource\'s duration:',
                'before', durationBefore,
                'after', durationAfter,
                'delta', durationAfter - durationBefore);
          }
        }

        if (this.sourceBuffer_.buffered.length == 0) {
          var error = new Error(
              'Failed to buffer segment (' + this.mimeType_ + ').');
          error.type = 'stream';
          return [Promise.reject(error)];
        }

        if (computeTimestampCorrection) {
          shaka.asserts.assert(this.inserted_.length == 0);
          var expectedTimestamp = reference.startTime;
          var actualTimestamp = this.sourceBuffer_.buffered.start(0);
          timestampCorrection = actualTimestamp - expectedTimestamp;
        }

        var i = shaka.media.SegmentReference.find(
            this.inserted_, reference.startTime);
        if (i >= 0) {
          // The SegmentReference at i has a start time less than |reference|'s.
          this.inserted_.splice(i + 1, 0, reference);
        } else {
          this.inserted_.push(reference);
        }
      }.bind(this));

  return this.startTask_().then(
      function() {
        return Promise.resolve(timestampCorrection);
      }.bind(this));
};


/**
 * Resets the virtual source buffer and clears all media from the underlying
 * SourceBuffer. The returned promise will resolve immediately if there is no
 * media within the underlying SourceBuffer. This cannot be called if another
 * operation is in progress.
 *
 * @return {!Promise}
 */
shaka.media.SourceBufferManager.prototype.clear = function() {
  shaka.log.v1(this.logPrefix_(), 'clear');

  // Check state.
  shaka.asserts.assert(!this.task_);
  if (this.task_) {
    var error = new Error('Cannot clear (' + this.mimeType_ + '): ' +
                          'previous operation not complete.');
    error.type = 'stream';
    return Promise.reject(error);
  }

  this.task_ = new shaka.util.Task();

  this.task_.append(function() {
    var p = this.clear_();
    return [p, this.abort_.bind(this)];
  }.bind(this));

  return this.startTask_();
};


/**
 * Resets the virtual source buffer and clears all media from the underlying
 * SourceBuffer after the given timestamp. The returned promise will resolve
 * immediately if there is no media within the underlying SourceBuffer. This
 * cannot be called if another operation is in progress.
 *
 * @param {number} timestamp
 *
 * @return {!Promise}
 */
shaka.media.SourceBufferManager.prototype.clearAfter = function(timestamp) {
  shaka.log.v1(this.logPrefix_(), 'clearAfter');

  // Check state.
  shaka.asserts.assert(!this.task_);
  if (this.task_) {
    var error = new Error('Cannot clearAfter (' + this.mimeType_ + '): ' +
                          'previous operation not complete.');
    error.type = 'stream';
    return Promise.reject(error);
  }

  this.task_ = new shaka.util.Task();

  this.task_.append(function() {
    var p = this.clearAfter_(timestamp);
    return [p, this.abort_.bind(this)];
  }.bind(this));

  return this.startTask_();
};


/**
 * Aborts the current operation if one exists.
 * The returned promise will never be rejected.
 *
 * @return {!Promise}
 */
shaka.media.SourceBufferManager.prototype.abort = function() {
  shaka.log.v1(this.logPrefix_(), 'abort');
  if (!this.task_) {
    return Promise.resolve();
  }
  return this.task_.abort();
};


/**
 * Corrects each SegmentReference in the virtual source buffer by the given
 * timestamp correction. The previous timestamp correction, if it exists, is
 * replaced.
 *
 * @param {number} timestampCorrection
 */
shaka.media.SourceBufferManager.prototype.correct = function(
    timestampCorrection) {
  var delta = timestampCorrection - this.timestampCorrection_;
  if (delta == 0) {
    return;
  }

  this.inserted_ = shaka.media.SegmentReference.shift(this.inserted_, delta);
  this.timestampCorrection_ = timestampCorrection;

  shaka.log.debug(
      this.logPrefix_(),
      'applied timestamp correction of',
      timestampCorrection,
      'seconds to SourceBufferManager',
      this);
};


/**
 * Emits an error message and returns true if there are multiple buffered
 * ranges; otherwise, does nothing and returns false.
 *
 * @return {boolean}
 */
shaka.media.SourceBufferManager.prototype.detectMultipleBufferedRanges =
    function() {
  if (this.sourceBuffer_.buffered.length > 1) {
    shaka.log.error(
        this.logPrefix_(),
        'multiple buffered ranges detected:',
        'Either the content has gaps in it,',
        'the content\'s segments are not aligned across bitrates,',
        'or the browser has evicted the middle of the buffer.');
    return true;
  } else {
    return false;
  }
};


/**
 * Sets the segment request timeout in seconds.
 *
 * @param {number} timeout
 */
shaka.media.SourceBufferManager.prototype.setSegmentRequestTimeout =
    function(timeout) {
  shaka.asserts.assert(!isNaN(timeout));
  this.segmentRequestTimeout_ = timeout;
};


/**
 * Starts the task and returns a Promise which is resolved/rejected after the
 * task ends and is cleaned up.
 *
 * @return {!Promise}
 * @private
 */
shaka.media.SourceBufferManager.prototype.startTask_ = function() {
  shaka.asserts.assert(this.task_);
  this.task_.start();
  return this.task_.getPromise().then(shaka.util.TypedBind(this,
      function() {
        this.task_ = null;
      })
  ).catch(shaka.util.TypedBind(this,
      /** @param {*} error */
      function(error) {
        shaka.log.v1(this.logPrefix_(), 'task failed!');
        this.task_ = null;
        return Promise.reject(error);
      })
  );
};


/**
 * Append to the source buffer.
 *
 * @param {!ArrayBuffer} data
 * @return {!Promise}
 * @private
 */
shaka.media.SourceBufferManager.prototype.append_ = function(data) {
  shaka.asserts.assert(!this.operationPromise_);
  shaka.asserts.assert(this.task_);

  try {
    // This will trigger an 'updateend' event.
    this.sourceBuffer_.appendBuffer(data);
  } catch (exception) {
    return Promise.reject(exception);
  }

  this.operationPromise_ = new shaka.util.PublicPromise();
  return this.operationPromise_;
};


/**
 * Clear the source buffer.
 *
 * @return {!Promise}
 * @private
 */
shaka.media.SourceBufferManager.prototype.clear_ = function() {
  shaka.asserts.assert(!this.operationPromise_);

  if (this.sourceBuffer_.buffered.length == 0) {
    shaka.log.v1(this.logPrefix_(), 'nothing to clear.');
    shaka.asserts.assert(this.inserted_.length == 0);
    return Promise.resolve();
  }

  try {
    // This will trigger an 'updateend' event.
    this.sourceBuffer_.remove(0, this.mediaSource_.duration);
  } catch (exception) {
    return Promise.reject(exception);
  }

  // Clear |inserted_| immediately since any inserted segments will be
  // gone soon.
  this.inserted_ = [];

  this.operationPromise_ = new shaka.util.PublicPromise();
  return this.operationPromise_;
};


/**
 * Clear the source buffer after the given timestamp (aligned to the next
 * segment boundary).
 *
 * @param {number} timestamp
 *
 * @return {!Promise}
 * @private
 */
shaka.media.SourceBufferManager.prototype.clearAfter_ = function(timestamp) {
  shaka.asserts.assert(!this.operationPromise_);

  if (this.sourceBuffer_.buffered.length == 0) {
    shaka.log.v1(this.logPrefix_(), 'nothing to clear.');
    shaka.asserts.assert(this.inserted_.length == 0);
    return Promise.resolve();
  }

  var index = shaka.media.SegmentReference.find(this.inserted_, timestamp);

  // If no segment found, or it's the last one, bail out gracefully.
  if (index == -1 || index == this.inserted_.length - 1) {
    shaka.log.v1(
        this.logPrefix_(),
        'nothing to clear: no segments on or after timestamp.');
    return Promise.resolve();
  }

  try {
    // This will trigger an 'updateend' event.
    this.sourceBuffer_.remove(
        this.inserted_[index + 1].startTime,
        this.mediaSource_.duration);
  } catch (exception) {
    return Promise.reject(exception);
  }

  this.inserted_ = this.inserted_.slice(0, index + 1);

  this.operationPromise_ = new shaka.util.PublicPromise();
  return this.operationPromise_;
};


/**
 * Abort the current operation on the source buffer.
 *
 * @private
 */
shaka.media.SourceBufferManager.prototype.abort_ = function() {
  shaka.log.v1(this.logPrefix_(), 'abort_');
  shaka.asserts.assert(this.operationPromise_);

  // See {@link http://www.w3.org/TR/media-source/#widl-SourceBuffer-abort-void}
  if (this.mediaSource_.readyState == 'open') {
    this.sourceBuffer_.abort();
  }
};


/**
 * |sourceBuffer_|'s 'updateend' callback.
 *
 * @param {!Event} event
 * @private
 */
shaka.media.SourceBufferManager.prototype.onSourceBufferUpdateEnd_ =
    function(event) {
  shaka.log.v1(this.logPrefix_(), 'onSourceBufferUpdateEnd_');

  shaka.asserts.assert(!this.sourceBuffer_.updating);
  shaka.asserts.assert(this.operationPromise_);

  this.operationPromise_.resolve();
  this.operationPromise_ = null;
};


if (!COMPILED) {
  /**
   * Returns a string with the form 'SBM MIME_TYPE:' for logging purposes.
   *
   * @return {string}
   * @private
   */
  shaka.media.SourceBufferManager.prototype.logPrefix_ = function() {
    return 'SBM ' + this.mimeType_ + ':';
  };
}