/** * @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.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.} */ 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 (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.} 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 (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, Number.POSITIVE_INFINITY); } 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, Number.POSITIVE_INFINITY); } 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_ + ':'; }; }