Files
shaka-player/lib/media/media_source_engine.js
T
Joey Parrish cd2d25cbb2 Convert TextSourceBuffer to TextEngine
This changes the text APIs to correctly handle buffered ranges of
segmented text.

b/25517444

Related to issue #150

Change-Id: I3a11b87e8d93376a5012566deb3bf0d015f52391
2016-02-17 00:19:49 +00:00

640 lines
19 KiB
JavaScript

/**
* @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.MediaSourceEngine');
goog.require('shaka.asserts');
goog.require('shaka.media.TextEngine');
goog.require('shaka.media.TimeRangesUtils');
goog.require('shaka.util.EventManager');
goog.require('shaka.util.IDestroyable');
goog.require('shaka.util.PublicPromise');
/**
* MediaSourceEngine wraps all operations on MediaSource and SourceBuffers.
* All asynchronous operations return a Promise, and all operations are
* internally synchronized and serialized as needed. Operations that can
* be done in parallel will be done in parallel.
*
* @param {MediaSource} mediaSource The MediaSource, which must be in the
* 'open' state.
* @param {TextTrack} textTrack The TextTrack to use for subtitles/captions.
*
* @struct
* @constructor
* @implements {shaka.util.IDestroyable}
*/
shaka.media.MediaSourceEngine = function(mediaSource, textTrack) {
shaka.asserts.assert(mediaSource.readyState == 'open',
'The MediaSource should be in the \'open\' state.');
/** @private {MediaSource} */
this.mediaSource_ = mediaSource;
/** @private {TextTrack} */
this.textTrack_ = textTrack;
/** @private {!Object.<string, SourceBuffer>} */
this.sourceBuffers_ = {};
/** @private {shaka.media.TextEngine} */
this.textEngine_ = null;
/**
* @private {!Object.<string,
* !Array.<shaka.media.MediaSourceEngine.Operation>>}
*/
this.queues_ = {};
/** @private {shaka.util.EventManager} */
this.eventManager_ = new shaka.util.EventManager();
/** @private {boolean} */
this.destroyed_ = false;
};
/**
* @typedef {{
* start: function(),
* p: !shaka.util.PublicPromise
* }}
*
* @summary An operation in queue.
* @property {function()} start
* The function which starts the operation.
* @property {!shaka.util.PublicPromise} p
* The PublicPromise which is associated with this operation.
*/
shaka.media.MediaSourceEngine.Operation;
/**
* Checks if a certain type is supported.
*
* @param {string} mimeType
* @return {boolean}
*/
shaka.media.MediaSourceEngine.isTypeSupported = function(mimeType) {
return shaka.media.TextEngine.isTypeSupported(mimeType) ||
MediaSource.isTypeSupported(mimeType);
};
/**
* Returns a map of MediaSource support for well-known types.
*
* @return {!Object.<string, boolean>}
*/
shaka.media.MediaSourceEngine.support = function() {
// Every object in the support hierarchy has a "basic" member.
// All "basic" members must be true for the library to be usable.
var support = {'basic': !!window.MediaSource};
if (support['basic']) {
var testMimeTypes = [
// MP4 types
'video/mp4; codecs="avc1.42E01E"',
'audio/mp4; codecs="mp4a.40.2"',
// WebM types
'video/webm; codecs="vp8"',
'video/webm; codecs="vp9"',
'audio/webm; codecs="vorbis"',
'audio/webm; codecs="opus"',
// MPEG2 TS types (video/ is also used for audio: http://goo.gl/tYHXiS)
'video/mp2t; codecs="avc1.42E01E"',
'video/mp2t; codecs="mp4a.40.2"',
// WebVTT types
'text/vtt',
'application/mp4; codecs="wvtt"',
// TTML types
'application/ttml+xml',
'application/mp4; codecs="stpp"'
];
testMimeTypes.forEach(function(type) {
support[type] = shaka.media.MediaSourceEngine.isTypeSupported(type);
var basicType = type.split(';')[0];
support[basicType] = support[basicType] || support[type];
});
}
return support;
};
/**
* @override
*/
shaka.media.MediaSourceEngine.prototype.destroy = function() {
this.destroyed_ = true;
var cleanup = [];
for (var contentType in this.queues_) {
// Make a local copy of the queue and the first item.
var q = this.queues_[contentType];
var inProgress = q[0];
// Drop everything else out of the queue.
this.queues_[contentType] = q.slice(0, 1);
// We will wait for this item to complete/fail.
if (inProgress) {
cleanup.push(inProgress.p.catch(function() {}));
}
// The rest will be rejected silently if possible.
for (var i = 1; i < q.length; ++i) {
q[i].p.catch(function() {});
q[i].p.reject();
}
}
return Promise.all(cleanup).then(function() {
this.eventManager_.destroy();
this.eventManager_ = null;
this.mediaSource_ = null;
this.textTrack_ = null;
this.textEngine_ = null;
this.sourceBuffers_ = {};
if (!COMPILED) {
for (var contentType in this.queues_) {
shaka.asserts.assert(
this.queues_[contentType].length == 0,
contentType + ' queue should be empty after destroy!');
}
}
this.queues_ = {};
}.bind(this));
};
/**
* @param {!Object.<string, string>} typeConfig A map of content types to full
* MIME types. For example: { 'audio': 'audio/webm; codecs="vorbis"',
* 'video': 'video/webm; codecs="vp9"', 'text': 'text/vtt' }.
* All types given must be supported.
*
* @throws InvalidAccessError if blank MIME types are given
* @throws NotSupportedError if unsupported MIME types are given
* @throws QuotaExceededError if the browser can't support that many buffers
*
* @suppress {unnecessaryCasts}
*/
shaka.media.MediaSourceEngine.prototype.init = function(typeConfig) {
for (var contentType in typeConfig) {
var mimeType = typeConfig[contentType];
shaka.asserts.assert(
shaka.media.MediaSourceEngine.isTypeSupported(mimeType),
'Type negotiation should happen before MediaSourceEngine.init!');
if (contentType == 'text') {
this.textEngine_ = new shaka.media.TextEngine(this.textTrack_, mimeType);
} else {
var sourceBuffer = this.mediaSource_.addSourceBuffer(mimeType);
this.eventManager_.listen(
sourceBuffer, 'error', this.onError_.bind(this, contentType));
this.eventManager_.listen(
sourceBuffer, 'updateend', this.onUpdateEnd_.bind(this, contentType));
this.sourceBuffers_[contentType] = sourceBuffer;
this.queues_[contentType] = [];
}
}
};
/**
* Gets the first timestamp in buffer for the given content type.
*
* @param {string} contentType
* @return {?number} The timestamp in seconds, or null if nothing is buffered.
*/
shaka.media.MediaSourceEngine.prototype.bufferStart = function(contentType) {
if (contentType == 'text') {
return this.textEngine_.bufferStart();
}
return shaka.media.TimeRangesUtils.bufferStart(
this.sourceBuffers_[contentType].buffered);
};
/**
* Gets the last timestamp in buffer for the given content type.
*
* @param {string} contentType
* @return {?number} The timestamp in seconds, or null if nothing is buffered.
*/
shaka.media.MediaSourceEngine.prototype.bufferEnd = function(contentType) {
if (contentType == 'text') {
return this.textEngine_.bufferEnd();
}
return shaka.media.TimeRangesUtils.bufferEnd(
this.sourceBuffers_[contentType].buffered);
};
/**
* Computes how far ahead of the given timestamp is buffered for the given
* content type.
*
* @param {string} contentType
* @param {number} time
* @return {number} The amount of time buffered ahead in seconds.
*/
shaka.media.MediaSourceEngine.prototype.bufferedAheadOf =
function(contentType, time) {
if (contentType == 'text') {
return this.textEngine_.bufferedAheadOf(time);
}
return shaka.media.TimeRangesUtils.bufferedAheadOf(
this.sourceBuffers_[contentType].buffered, time);
};
/**
* Enqueue an operation to append data to the SourceBuffer.
* Start and end times are needed for TextEngine, but not for MediaSource.
* Start and end times may be null for initialization segments.
*
* @param {string} contentType
* @param {!ArrayBuffer|!ArrayBufferView} data
* @param {?number} startTime
* @param {?number} endTime
* @return {!Promise}
*/
shaka.media.MediaSourceEngine.prototype.appendBuffer =
function(contentType, data, startTime, endTime) {
if (contentType == 'text') {
shaka.asserts.assert(startTime != null && endTime != null,
'text streams do not have init segments!');
return this.textEngine_.appendBuffer(
data, /** @type {number} */(startTime), /** @type {number} */(endTime));
}
return this.enqueueOperation_(
contentType,
this.append_.bind(this, contentType, data));
};
/**
* Enqueue an operation to remove data from the SourceBuffer.
*
* @param {string} contentType
* @param {number} startTime
* @param {number} endTime
* @return {!Promise}
*/
shaka.media.MediaSourceEngine.prototype.remove =
function(contentType, startTime, endTime) {
// On IE11, this operation would be permitted, but would have no effect!
// See https://github.com/google/shaka-player/issues/251
shaka.asserts.assert(endTime < Number.MAX_VALUE,
'remove() with MAX_VALUE or POSITIVE_INFINITY is not IE-compatible!');
if (contentType == 'text') {
return this.textEngine_.remove(startTime, endTime);
}
return this.enqueueOperation_(
contentType,
this.remove_.bind(this, contentType, startTime, endTime));
};
/**
* Enqueue an operation to clear the SourceBuffer.
*
* @param {string} contentType
* @return {!Promise}
*/
shaka.media.MediaSourceEngine.prototype.clear = function(contentType) {
if (contentType == 'text') {
return this.textEngine_.remove(0, Number.POSITIVE_INFINITY);
}
// Note that not all platforms allow clearing to Number.POSITIVE_INFINITY.
return this.enqueueOperation_(
contentType,
this.remove_.bind(this, contentType, 0, this.mediaSource_.duration));
};
/**
* Sets the timestamp offset for the given content type.
*
* @param {string} contentType
* @param {number} timestampOffset The timestamp offset. Segments which start
* at time t will be inserted at time t + timestampOffset instead. This
* value does not affect segments which have already been inserted.
* @return {!Promise}
*/
shaka.media.MediaSourceEngine.prototype.setTimestampOffset = function(
contentType, timestampOffset) {
if (contentType == 'text') {
this.textEngine_.setTimestampOffset(timestampOffset);
return Promise.resolve();
}
return this.enqueueOperation_(
contentType,
this.setTimestampOffset_.bind(this, contentType, timestampOffset));
};
/**
* Sets the append window end for the given content type.
*
* @param {string} contentType
* @param {number} appendWindowEnd The timestamp to set the append window end
* to. Media beyond this value will be truncated.
* @return {!Promise}
*/
shaka.media.MediaSourceEngine.prototype.setAppendWindowEnd = function(
contentType, appendWindowEnd) {
if (contentType == 'text') {
this.textEngine_.setAppendWindowEnd(appendWindowEnd);
return Promise.resolve();
}
return this.enqueueOperation_(
contentType,
this.setAppendWindowEnd_.bind(this, contentType, appendWindowEnd));
};
/**
* @param {string=} opt_reason Valid reasons are 'network' and 'decode'.
* @return {!Promise}
* @see http://w3c.github.io/media-source/#idl-def-EndOfStreamError
*/
shaka.media.MediaSourceEngine.prototype.endOfStream = function(opt_reason) {
return this.enqueueBlockingOperation_(function() {
// Chrome won't let me pass undefined, but it will let me omit the
// argument. Firefox does not have this problem.
// TODO: File a bug about this.
if (opt_reason) {
this.mediaSource_.endOfStream(opt_reason);
} else {
this.mediaSource_.endOfStream();
}
}.bind(this));
};
/**
* We only support increasing duration at this time. Decreasing duration
* causes the MSE removal algorithm to run, which results in an 'updateend'
* event. Supporting this scenario would be complicated, and is not currently
* needed.
*
* @param {number} duration
* @return {!Promise}
*/
shaka.media.MediaSourceEngine.prototype.setDuration = function(duration) {
shaka.asserts.assert(isNaN(this.mediaSource_.duration) ||
this.mediaSource_.duration <= duration,
'duration cannot decrease: ' +
this.mediaSource_.duration + ' -> ' + duration);
return this.enqueueBlockingOperation_(function() {
this.mediaSource_.duration = duration;
}.bind(this));
};
/**
* Append data to the SourceBuffer.
* @param {string} contentType
* @param {!ArrayBuffer|!ArrayBufferView} data
* @throws QuotaExceededError if the browser's buffer is full
* @private
*/
shaka.media.MediaSourceEngine.prototype.append_ =
function(contentType, data) {
// This will trigger an 'updateend' event.
this.sourceBuffers_[contentType].appendBuffer(data);
};
/**
* Remove data from the SourceBuffer.
* @param {string} contentType
* @param {number} startTime
* @param {number} endTime
* @private
*/
shaka.media.MediaSourceEngine.prototype.remove_ =
function(contentType, startTime, endTime) {
// This will trigger an 'updateend' event.
this.sourceBuffers_[contentType].remove(startTime, endTime);
};
/**
* Set the SourceBuffer's timestamp offset.
* @param {string} contentType
* @param {number} timestampOffset
* @private
*/
shaka.media.MediaSourceEngine.prototype.setTimestampOffset_ =
function(contentType, timestampOffset) {
this.sourceBuffers_[contentType].timestampOffset = timestampOffset;
// Fake 'updateend' event to resolve the operation.
this.onUpdateEnd_(contentType);
};
/**
* Set the SourceBuffer's append window end.
* @param {string} contentType
* @param {number} appendWindowEnd
* @private
*/
shaka.media.MediaSourceEngine.prototype.setAppendWindowEnd_ =
function(contentType, appendWindowEnd) {
this.sourceBuffers_[contentType].appendWindowEnd = appendWindowEnd;
// Fake 'updateend' event to resolve the operation.
this.onUpdateEnd_(contentType);
};
/**
* @param {string} contentType
* @param {!Event} event
* @private
*/
shaka.media.MediaSourceEngine.prototype.onError_ =
function(contentType, event) {
var operation = this.queues_[contentType][0];
shaka.asserts.assert(operation, 'Spurious error event!');
shaka.asserts.assert(!this.sourceBuffers_[contentType].updating,
'SourceBuffer should not be updating on error!');
operation.p.reject(event);
// Do not pop from queue. An 'updateend' event will fire next, and to avoid
// synchronizing these two event handlers, we will allow that one to pop from
// the queue as normal. Note that because the operation has already been
// rejected, the call to resolve() in the 'updateend' handler will have no
// effect.
};
/**
* @param {string} contentType
* @private
*/
shaka.media.MediaSourceEngine.prototype.onUpdateEnd_ = function(contentType) {
var operation = this.queues_[contentType][0];
shaka.asserts.assert(operation, 'Spurious updateend event!');
shaka.asserts.assert(!this.sourceBuffers_[contentType].updating,
'SourceBuffer should not be updating on updateend!');
operation.p.resolve();
this.popFromQueue_(contentType);
};
/**
* Enqueue an operation and start it if appropriate.
*
* @param {string} contentType
* @param {function()} start
* @return {!Promise}
* @private
*/
shaka.media.MediaSourceEngine.prototype.enqueueOperation_ =
function(contentType, start) {
if (this.destroyed_) return Promise.reject();
var operation = {
start: start,
p: new shaka.util.PublicPromise()
};
this.queues_[contentType].push(operation);
if (this.queues_[contentType].length == 1) {
try {
operation.start();
} catch (exception) {
operation.p.reject(exception);
this.popFromQueue_(contentType);
}
}
return operation.p;
};
/**
* Enqueue an operation which must block all other operations on all
* SourceBuffers.
*
* @param {function()} run
* @return {!Promise}
* @private
*/
shaka.media.MediaSourceEngine.prototype.enqueueBlockingOperation_ =
function(run) {
if (this.destroyed_) return Promise.reject();
var allWaiters = [];
// Enqueue a 'wait' operation onto each queue.
// This operation signals its readiness when it starts.
// When all wait operations are ready, the real operation takes place.
for (var contentType in this.sourceBuffers_) {
var ready = new shaka.util.PublicPromise();
var operation = {
start: function(ready) { ready.resolve(); }.bind(null, ready),
p: ready
};
this.queues_[contentType].push(operation);
allWaiters.push(ready);
if (this.queues_[contentType].length == 1) {
operation.start();
}
}
// Return a Promise to the real operation, which waits to begin until there
// are no other in-progress operations on any SourceBuffers.
return Promise.all(allWaiters).then(function() {
if (!COMPILED) {
// If we did it correctly, nothing is updating.
for (var contentType in this.sourceBuffers_) {
shaka.asserts.assert(
this.sourceBuffers_[contentType].updating == false,
'SourceBuffers should not be updating after a blocking op!');
}
}
var ret;
// Run the real operation, which is synchronous.
try {
run();
} catch (exception) {
ret = Promise.reject(exception);
}
// Unblock the queues.
for (var contentType in this.sourceBuffers_) {
this.popFromQueue_(contentType);
}
return ret;
}.bind(this), function() {
// One of the waiters failed, which means we've been destroyed.
shaka.asserts.assert(this.destroyed_, 'Should be destroyed by now');
// We haven't popped from the queue. Canceled waiters have been removed by
// destroy. What's left now should just be resolved waiters. In uncompiled
// mode, we will maintain good hygiene and make sure the assert at the end
// of destroy passes. In compiled mode, the queues are wiped in destroy.
if (!COMPILED) {
for (var contentType in this.sourceBuffers_) {
if (this.queues_[contentType].length) {
shaka.asserts.assert(
this.queues_[contentType].length == 1,
'Should be at most one item in queue!');
shaka.asserts.assert(
allWaiters.indexOf(this.queues_[contentType][0].p) != -1,
'The item in queue should be one of our waiters!');
this.queues_[contentType].shift();
}
}
}
return Promise.reject();
}.bind(this));
};
/**
* Pop from the front of the queue and start a new operation.
* @param {string} contentType
* @private
*/
shaka.media.MediaSourceEngine.prototype.popFromQueue_ = function(contentType) {
// Remove the in-progress operation, which is now complete.
this.queues_[contentType].shift();
// Retrieve the next operation, if any, from the queue and start it.
var next = this.queues_[contentType][0];
if (next) {
try {
next.start();
} catch (exception) {
next.p.reject(exception);
this.popFromQueue_(contentType);
}
}
};