/**
* @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;
};