Source: media/stream.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.Stream');

goog.require('shaka.asserts');
goog.require('shaka.log');
goog.require('shaka.media.IStream');
goog.require('shaka.media.SourceBufferManager');
goog.require('shaka.media.StreamInfo');
goog.require('shaka.player.Defaults');
goog.require('shaka.util.FakeEvent');
goog.require('shaka.util.FakeEventTarget');
goog.require('shaka.util.IBandwidthEstimator');
goog.require('shaka.util.PublicPromise');
goog.require('shaka.util.TypedBind');



/**
 * @event shaka.media.Stream.EndedEvent
 * @description Fired when the stream ends.
 * @property {string} type 'ended'
 * @property {boolean} bubbles false
 */



/**
 * Creates a Stream, which presents audio and video streams via an
 * HTMLVideoElement.
 *
 * @param {!shaka.util.FakeEventTarget} parent The parent for event bubbling.
 * @param {!HTMLVideoElement} video
 * @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 segment requests.
 *
 * @fires shaka.media.IStream.AdaptationEvent
 * @fires shaka.media.Stream.EndedEvent
 * @fires shaka.player.Player.ErrorEvent
 *
 * @throws {QuotaExceededError} if another SourceBuffer is not allowed.
 *
 * @struct
 * @constructor
 * @implements {shaka.media.IStream}
 * @extends {shaka.util.FakeEventTarget}
 */
shaka.media.Stream = function(
    parent, video, mediaSource, fullMimeType, estimator) {
  shaka.util.FakeEventTarget.call(this, parent);

  /** @private {!HTMLVideoElement} */
  this.video_ = video;

  /** @private {!shaka.media.SourceBufferManager} */
  this.sbm_ = new shaka.media.SourceBufferManager(
      mediaSource, fullMimeType, estimator);

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

  /** @private {shaka.media.StreamInfo} */
  this.streamInfo_ = null;

  /** @private {shaka.media.SegmentIndex} */
  this.segmentIndex_ = null;

  /** @private {ArrayBuffer} */
  this.initData_ = null;

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

  /** @private {?number} */
  this.updateTimer_ = null;

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

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

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

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

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

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

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

  /** @private {number} */
  this.bufferSizeSeconds_ = shaka.player.Defaults.STREAM_BUFFER_SIZE;

  /**
   * Work-around for MSE issues where the stream can get stuck after clearing
   * the buffer or starting mid-stream (as is done for live).  Nudging the
   * playhead seems to get the browser's media pipeline moving again.
   * @private {boolean}
   */
  this.needsNudge_ = false;

  // For debugging purposes:
  if (!COMPILED) {
    /** @private {boolean} */
    this.fetching_ = false;

    /** @private {string} */
    this.mimeType_ = fullMimeType.split(';')[0];
    shaka.asserts.assert(this.mimeType_.length > 0);
  }
};
goog.inherits(shaka.media.Stream, shaka.util.FakeEventTarget);


/**
 * A tiny amount of time, in seconds, used to nudge the video element.  This
 * is used when the buffer has been cleared to get the media pipeline unstuck.
 *
 * @see http://crbug.com/478151
 *
 * @private
 * @const {number}
 */
shaka.media.Stream.NUDGE_ = 0.001;


/**
 * <p>
 * Configures the Stream options.
 * </p>
 *
 * The following configuration options are supported:
 * <ul>
 * <li>
 *   <b>initialStreamBufferSize</b>: number <br>
 *   Sets the amount of content, in seconds, that the Stream must buffer to
 *   ensure smooth playback.
 *
 * <li>
 *   <b>streamBufferSize</b>: number <br>
 *   Sets the maximum amount of content, in seconds, that the Stream will
 *   buffer ahead of the playhead.  If 'initialStreamBufferSize' is larger,
 *   then that value is used.
 *
 * <li>
 *   <b>segmentRequestTimeout</b>: number <br>
 *   Sets the segment request timeout in seconds.
 * </ul>
 *
 * @example
 *     stream.configure({'streamBufferSize': 20});
 *
 * @param {!Object.<string, *>} config A configuration object, which contains
 *     the configuration options as key-value pairs.  All fields should have
 *     already been validated.
 * @override
 */
shaka.media.Stream.prototype.configure = function(config) {
  if (config['initialStreamBufferSize'] != null) {
    this.initialBufferSizeSeconds_ = Number(config['initialStreamBufferSize']);
  }

  if (config['streamBufferSize'] != null) {
    this.bufferSizeSeconds_ = Number(config['streamBufferSize']);
  }

  if (config['segmentRequestTimeout'] != null) {
    this.sbm_.setSegmentRequestTimeout(Number(config['segmentRequestTimeout']));
  }
};


/**
 * @override
 * @suppress {checkTypes} to set otherwise non-nullable types to null.
 */
shaka.media.Stream.prototype.destroy = function() {
  this.cancelUpdateTimer_();

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

  this.streamInfo_ = null;
  this.estimator_ = null;

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

  this.video_ = null;

  this.parent = null;
};


/** @override */
shaka.media.Stream.prototype.getStreamInfo = function() {
  return this.streamInfo_;
};


/** @override */
shaka.media.Stream.prototype.getSegmentIndex = function() {
  return this.segmentIndex_;
};


/**
 * <p>
 * Returns a promise that the Stream will resolve after it has buffered
 * 'initialStreamBufferSize' seconds of content
 * (see {@link shaka.media.Stream#configure}).
 * </p>
 *
 * <p>
 * The Stream will not modify its underlying SourceBuffer while it's in the
 * 'waiting' state (see {@link shaka.media.IStream}).
 * </p>
 *
 * <p>
 * The caller should apply the timestamp correction to the Stream's current
 * StreamInfo's SegmentIndex and future StreamInfo's passed to switch() before
 * it resolves |proceed|.
 * </p>
 *
 * @override
 */
shaka.media.Stream.prototype.started = function(proceed) {
  if (!this.proceeded_) {
    proceed.then(
        function() {
          shaka.asserts.assert(this.started_);
          shaka.asserts.assert(!this.proceeded_);
          shaka.asserts.assert(!this.ended_);
          shaka.log.debug(this.logPrefix_(), 'proceeding...');
          this.proceeded_ = true;
          if (!this.updateTimer_) {
            this.setUpdateTimer_(0);
          }
        }.bind(this)).catch(
        function(error) {
          // The caller should never reject |proceed| unless it is destroyed.
          shaka.asserts.assert(error.type == 'destroy');
        });
  }
  return this.startedPromise_;
};


/**
 * <p>
 * Returns true if the stream has ended; otherwise, returns false.
 * </p>
 *
 * <p>
 * The Stream will not modify its underlying SourceBuffer while it's in the
 * 'ended' state.
 * </p>
 *
 * @override
 */
shaka.media.Stream.prototype.hasEnded = function() {
  return this.ended_;
};


/**
 * <p>
 * Starts presenting the specified stream asynchronously. The stream's MIME
 * type must be compatible with the Stream's underlying SourceBuffer, i.e., the
 * stream must have the same content type, container, and codec as the
 * SourceBuffer, but the stream's codec's profile may vary.
 * </p>
 *
 * <p>
 * Note: |opt_clearBufferOffset| is relative to the video's playhead.
 * </p>
 *
 * @override
 */
shaka.media.Stream.prototype.switch = function(
    streamInfo, clearBuffer, opt_clearBufferOffset) {
  shaka.asserts.assert(
      streamInfo.mimeType == this.mimeType_,
      'The stream\'s type must match the Stream\'s SourceBuffer\'s type.');

  if (streamInfo == this.streamInfo_) {
    shaka.log.debug(this.logPrefix_(), 'already using stream', streamInfo);
    return;
  }

  var async = [
    streamInfo.segmentIndexSource.create(),
    streamInfo.segmentInitSource.create()
  ];

  Promise.all(async).then(shaka.util.TypedBind(this,
      /** @param {!Array} results */
      function(results) {
        if (!this.video_) {
          // We got destroyed.
          return;
        }

        var previousStreamInfo = this.streamInfo_;

        this.streamInfo_ = streamInfo;
        this.segmentIndex_ = results[0];
        this.initData_ = results[1];
        this.switched_ = true;

        if (this.resyncing_) {
          // resync() was called while creating the SegmentIndex and init data.
          // resync() will set the update timer if needed.
          return;
        }

        if (!previousStreamInfo) {
          // Call onUpdate_() asynchronously so it can more easily assert that
          // it was called at an appopriate time.
          this.setUpdateTimer_(0);
        } else if (clearBuffer) {
          this.resync_(true /* forceClear */, opt_clearBufferOffset);
        } else {
          shaka.asserts.assert((this.updateTimer_ != null) || this.fetching_);
        }
      })
  ).catch(shaka.util.TypedBind(this,
      /** @param {*} error */
      function(error) {
        if (error.type == 'aborted') {
          return;
        }

        if (this.proceeded_) {
          var event = shaka.util.FakeEvent.createErrorEvent(error);
          this.dispatchEvent(event);
        } else {
          this.startedPromise_.reject(error);
        }
      })
  );
};


/**
 * Resynchronizes the Stream's current position to the video's playhead.
 * This must be called when the user seeks.
 *
 * @override
 */
shaka.media.Stream.prototype.resync = function() {
  shaka.log.debug(this.logPrefix_(), 'resync');
  return this.resync_(false /* forceClear */);
};


/**
 * @param {boolean} forceClear
 * @param {number=} opt_clearBufferOffset if |forceClear| and
 *     |opt_clearBufferOffset| are truthy, clear the stream buffer from the
 *     given offset (relative to the video's current time) to the end of the
 *     stream.
 * @private
 */
shaka.media.Stream.prototype.resync_ = function(
    forceClear, opt_clearBufferOffset) {
  if (!this.streamInfo_ || this.resyncing_) {
    return;
  }

  // We should either be between updates, fetching a segment, or waiting to
  // proceed after startup.
  shaka.asserts.assert(
      (this.updateTimer_ != null) ||
      this.fetching_ ||
      (this.started_ && !this.proceeded_),
      'Unexpected call to resync_().');

  shaka.asserts.assert(!opt_clearBufferOffset || opt_clearBufferOffset > 0);

  this.resyncing_ = true;
  this.cancelUpdateTimer_();

  this.sbm_.abort().then(shaka.util.TypedBind(this,
      function() {
        shaka.log.v1(this.logPrefix_(), 'abort done.');
        shaka.asserts.assert((this.updateTimer_ == null) && this.resyncing_);
        shaka.asserts.assert(!this.fetching_);

        // Clear the source buffer if we are seeking outside of the currently
        // buffered range.  This seems to make the browser's eviction policy
        // saner and fixes "dead-zone" issues such as #15 and #26.  If seeking
        // within the buffered range, we avoid clearing so that we don't
        // re-download content.
        var currentTime = this.video_.currentTime;
        if (forceClear ||
            !this.sbm_.isBuffered(currentTime) ||
            !this.sbm_.isInserted(currentTime)) {
          shaka.log.debug(this.logPrefix_(), 'clear required.');

          if (opt_clearBufferOffset) {
            return this.sbm_.clearAfter(
                this.video_.currentTime + opt_clearBufferOffset);
          } else {
            shaka.log.debug(this.logPrefix_(), 'nudge needed!');
            this.needsNudge_ = true;
            return this.sbm_.clear();
          }
        } else {
          shaka.log.debug(this.logPrefix_(), 'no clear required.');
          return Promise.resolve();
        }
      })
  ).then(shaka.util.TypedBind(this,
      function() {
        shaka.asserts.assert((this.updateTimer_ == null) && this.resyncing_);
        shaka.asserts.assert(!this.fetching_);
        this.resyncing_ = false;
        this.setUpdateTimer_(0);
      })
  ).catch(shaka.util.TypedBind(this,
      function(error) {
        shaka.asserts.assert(error.type != 'aborted');
        this.resyncing_ = false;

        if (this.proceeded_) {
          var event = shaka.util.FakeEvent.createErrorEvent(error);
          this.dispatchEvent(event);
        } else {
          this.startedPromise_.reject(error);
        }
      })
  );
};


/** @override */
shaka.media.Stream.prototype.isBuffered = function(time) {
  return this.sbm_.isBuffered(time) && this.sbm_.isInserted(time);
};


/**
 * Does nothing since audio and video streams are always enabled.
 *
 * @override
 */
shaka.media.Stream.prototype.setEnabled = function(enabled) {};


/**
 * Always returns true since audio and video streams are always enabled.
 *
 * @override
 */
shaka.media.Stream.prototype.getEnabled = function() {
  return true;
};


/**
 * Gets the Stream's buffering goal, which is the amount of content, in
 * seconds, that the Stream attempts to buffer ahead of the Stream's current
 * position.
 *
 * @return {number} The buffering goal.
 * @private
 */
shaka.media.Stream.prototype.getBufferingGoal_ = function() {
  return this.started_ ?
         Math.max(this.initialBufferSizeSeconds_, this.bufferSizeSeconds_) :
         this.initialBufferSizeSeconds_;
};


/**
 * Update callback.
 *
 * @private
 */
shaka.media.Stream.prototype.onUpdate_ = function() {
  shaka.log.v2(this.logPrefix_(), 'onUpdate_');

  shaka.asserts.assert(this.streamInfo_);
  shaka.asserts.assert(this.segmentIndex_);
  shaka.asserts.assert((this.updateTimer_ != null) && !this.resyncing_);
  shaka.asserts.assert(!this.fetching_);

  if (this.started_ && !this.proceeded_) {
    shaka.log.v1(this.logPrefix_(), 'waiting to proceed...');
    this.updateTimer_ = null;
    return;
  }

  // We should only have one buffered range at any given time, so the next
  // segment we need is after the last one we inserted. However, non-ideal
  // content and/or browser eviction policy may induce multiple buffered
  // ranges in some cases.
  if (this.proceeded_ &&
      !this.ended_ &&
      this.sbm_.detectMultipleBufferedRanges()) {
    // Try to recover by clearing the buffer.
    this.resync_(true /* clearBuffer */);
    return;
  }

  this.updateTimer_ = null;

  // Retain local copies since switch() may be called while fetching.
  var streamInfo = /** @type {!shaka.media.StreamInfo} */ (this.streamInfo_);
  var segmentIndex =
      /** @type {!shaka.media.SegmentIndex} */ (this.segmentIndex_);

  var currentTime = this.video_.currentTime;

  // During startup, if there's a non-zero timestamp correction then the
  // playhead will not align with the start of the first buffered range, as we
  // do not apply the timestamp correction to the SegmentIndexes until after
  // stream startup. So, we must call bufferedAheadOf() with an offset to
  // ensure we compute the correct size of the initial buffered range.
  var bufferStart = this.started_ ?
                    currentTime :
                    currentTime + (this.timestampCorrection_ || 0);
  if (this.sbm_.bufferedAheadOf(bufferStart) >= this.getBufferingGoal_()) {
    shaka.log.v1(this.logPrefix_(), 'buffering goal reached.');
    this.startIfNeeded_();
    // TODO: trigger onUpdate_ when playback rate changes (assuming changed
    // through Player.setPlaybackRate).
    var rate = Math.abs(this.video_.playbackRate) || 1;
    this.setUpdateTimer_(1000 / rate);
    return;
  }

  var reference = this.getNext_(currentTime, segmentIndex);
  if (!reference) {
    shaka.log.v1(
        this.logPrefix_(), 'new segment is not needed or is not available.');

    // We haven't hit our buffering goal yet, but there's nothing left to
    // buffer.
    this.startIfNeeded_();

    if (this.proceeded_ && !this.ended_) {
      shaka.asserts.assert(this.started_);
      shaka.log.debug(this.logPrefix_(), 'stream has ended.');
      this.ended_ = true;
      this.fireEndedEvent_();
    }

    // Check again in a second: the SegmentIndex might be generating
    // SegmentReferences or there might be a manifest update.
    this.setUpdateTimer_(1000);
    return;
  }

  // Fetch and append the next segment. We only fetch a single segment at a
  // time because fetching multiple segments can cause buffering when bandwidth
  // is limited. If we are behind our buffering goal by more than one segment,
  // we should still be able to catch up by requesting single segments.
  if (!COMPILED) {
    this.fetching_ = true;
  }

  if (this.initData_) {
    shaka.log.v1(this.logPrefix_(), 'appending initialization data...');
  }
  shaka.log.v1(this.logPrefix_(), 'fetching segment', reference);
  var fetch = this.sbm_.fetch(
      reference, streamInfo.timestampOffset, this.initData_);

  this.initData_ = null;
  if (this.switched_) {
    this.switched_ = false;
    this.fireAdaptationEvent_(streamInfo);
  }
  this.ended_ = false;

  fetch.then(shaka.util.TypedBind(this,
      /** @param {?number} timestampCorrection */
      function(timestampCorrection) {
        shaka.log.v1(this.logPrefix_(), 'fetch done.');
        shaka.asserts.assert((this.updateTimer_ == null) && !this.resyncing_);
        shaka.asserts.assert(this.fetching_);

        if (!COMPILED) {
          this.fetching_ = false;
        }

        if (this.timestampCorrection_ == null) {
          shaka.asserts.assert(!this.started_);
          shaka.asserts.assert(timestampCorrection != null);
          this.timestampCorrection_ = timestampCorrection;
        }

        if (this.needsNudge_ && this.sbm_.bufferedAheadOf(currentTime) > 0) {
          shaka.log.debug(this.logPrefix_(), 'applying nudge...');
          this.needsNudge_ = false;
          this.video_.currentTime += shaka.media.Stream.NUDGE_;
        }

        this.setUpdateTimer_(0);
      })
  ).catch(shaka.util.TypedBind(this,
      /** @param {*} error */
      function(error) {
        if (!COMPILED) {
          this.fetching_ = false;
        }

        if (error.type == 'aborted') {
          // We were aborted from either destroy() or resync().
          shaka.log.v1(this.logPrefix_(), 'fetch aborted.');
          shaka.asserts.assert(!this.sbm_ || this.resyncing_);
          return;
        }

        var recoverableErrors = [0, 404, 410];
        if (error.type == 'net' &&
            recoverableErrors.indexOf(error.xhr.status) != -1 &&
            this.streamInfo_ /* not yet destroyed */) {
          shaka.log.info(
              this.logPrefix_(), 'retrying segment request in 5 seconds...');
          // Depending on application policy, this could be recoverable,
          // so set a timer on the supposition that the app might not end
          // playback.
          this.setUpdateTimer_(5000);
        }

        // We should not reject |startedPromise_| here since we may still be
        // able to complete startup.
        var event = shaka.util.FakeEvent.createErrorEvent(error);
        this.dispatchEvent(event);
      })
  );
};


/**
 * Finds the next segment that starts at or after |currentTime| from
 * |segmentIndex| that has not been inserted.
 *
 * @param {number} currentTime The time in seconds.
 * @param {!shaka.media.SegmentIndex} segmentIndex
 * @return {shaka.media.SegmentReference}
 * @private
 */
shaka.media.Stream.prototype.getNext_ = function(
    currentTime, segmentIndex) {
  var last = this.sbm_.getLastInserted();
  if (last != null) {
    return last.endTime != null ? segmentIndex.find(last.endTime) : null;
  } else {
    // Return the last SegmentReference if no segments have been inserted so
    // that we will always compute a timestamp correction and resolve started().
    return segmentIndex.find(currentTime) ||
           (segmentIndex.length() ? segmentIndex.last() : null);
  }
};


/**
 * Corrects the SBM and resolves |startedPromise_| if needed.
 *
 * @private
 */
shaka.media.Stream.prototype.startIfNeeded_ = function() {
  if (this.started_ || (this.timestampCorrection_ == null)) {
    // We've already started or we haven't started yet.
    return;
  }
  shaka.asserts.assert(!this.proceeded_);
  shaka.asserts.assert(!this.ended_);

  shaka.log.info(this.logPrefix_(), 'stream has started.');
  this.started_ = true;
  this.sbm_.correct(this.timestampCorrection_);
  this.startedPromise_.resolve(this.timestampCorrection_);
};


/**
 * Fires an AdaptationEvent for the given StreamInfo.
 *
 * @param {!shaka.media.StreamInfo} streamInfo
 * @private
 */
shaka.media.Stream.prototype.fireAdaptationEvent_ = function(streamInfo) {
  var event = shaka.media.Stream.createAdaptationEvent_(streamInfo);
  this.dispatchEvent(event);
};


/**
 * Fires an EndedEvent
 *
 * @private
 */
shaka.media.Stream.prototype.fireEndedEvent_ = function() {
  shaka.asserts.assert(this.ended_);
  var event = shaka.util.FakeEvent.create({ type: 'ended' });
  this.dispatchEvent(event);
};


/**
 * Sets the update timer.
 *
 * @param {number} ms The timeout in milliseconds.
 * @private
 */
shaka.media.Stream.prototype.setUpdateTimer_ = function(ms) {
  shaka.asserts.assert(this.updateTimer_ == null);
  this.updateTimer_ = window.setTimeout(this.onUpdate_.bind(this), ms);
};


/**
 * Cancels the update timer if it is running.
 *
 * @private
 */
shaka.media.Stream.prototype.cancelUpdateTimer_ = function() {
  if (this.updateTimer_ != null) {
    window.clearTimeout(this.updateTimer_);
    this.updateTimer_ = null;
  }
};


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


/**
 * Creates an event object for an AdaptationEvent using the given StreamInfo.
 *
 * @param {!shaka.media.StreamInfo} streamInfo
 * @return {!Event}
 * @private
 */
shaka.media.Stream.createAdaptationEvent_ = function(streamInfo) {
  var contentType = streamInfo.mimeType.split('/')[0];
  var size = (contentType != 'video') ? null : {
    'width': streamInfo.width,
    'height': streamInfo.height
  };
  var event = shaka.util.FakeEvent.create({
    'type': 'adaptation',
    'bubbles': true,
    'contentType': contentType,
    'size': size,
    'bandwidth': streamInfo.bandwidth
  });
  return event;
};