/**
* @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.player.StreamVideoSource');
goog.require('shaka.asserts');
goog.require('shaka.features');
goog.require('shaka.log');
goog.require('shaka.media.IAbrManager');
goog.require('shaka.media.IStream');
goog.require('shaka.media.ManifestInfo');
goog.require('shaka.media.ManifestUpdater');
goog.require('shaka.media.PeriodInfo');
goog.require('shaka.media.SimpleAbrManager');
goog.require('shaka.media.Stream');
goog.require('shaka.media.StreamInfo');
goog.require('shaka.media.StreamInfoProcessor');
goog.require('shaka.media.StreamSetInfo');
goog.require('shaka.media.TextStream');
goog.require('shaka.player.AudioTrack');
goog.require('shaka.player.Defaults');
goog.require('shaka.player.IVideoSource');
goog.require('shaka.player.Restrictions');
goog.require('shaka.player.VideoTrack');
goog.require('shaka.util.EmeUtils');
goog.require('shaka.util.EventManager');
goog.require('shaka.util.FakeEventTarget');
goog.require('shaka.util.IBandwidthEstimator');
goog.require('shaka.util.LanguageUtils');
goog.require('shaka.util.MapUtils');
goog.require('shaka.util.PublicPromise');
goog.require('shaka.util.StringUtils');
/**
* @event shaka.player.StreamVideoSource.SeekRangeChangedEvent
* @description Fired when the seekable range changes.
* @property {string} type 'seekrangechanged'
* @property {boolean} bubbles true
* @property {number} start The earliest time that can be seeked to, in seconds.
* @property {number} end The latest time that can be seeked to, in seconds.
* @export
*/
/**
* @event shaka.player.StreamVideoSource.TracksChangedEvent
* @description Fired when one or more audio, video, or text tracks become
* available or unavailable.
* @property {string} type 'trackschanged'
* @property {boolean} bubbles true
* @export
*/
/**
* Creates a StreamVideoSource.
* The new StreamVideoSource takes ownership of |manifestInfo|.
*
* @param {shaka.media.ManifestInfo} manifestInfo
* @param {!shaka.util.IBandwidthEstimator} estimator
* @param {!shaka.media.IAbrManager} abrManager
*
* @fires shaka.player.StreamVideoSource.SeekRangeChangedEvent
* @listens shaka.media.Stream.EndedEvent
* @listens shaka.media.Stream.StartedEvent
* @listens shaka.util.IBandwidthEstimator.BandwidthEvent
*
* @constructor
* @struct
* @implements {shaka.player.IVideoSource}
* @extends {shaka.util.FakeEventTarget}
* @export
*/
shaka.player.StreamVideoSource = function(manifestInfo, estimator, abrManager) {
shaka.util.FakeEventTarget.call(this, null);
/** @protected {shaka.media.ManifestInfo} */
this.manifestInfo = manifestInfo;
/** @protected {!shaka.util.IBandwidthEstimator} */
this.estimator = estimator;
/** @protected {!shaka.util.EventManager} */
this.eventManager = new shaka.util.EventManager();
/** @protected {!MediaSource} */
this.mediaSource = new MediaSource();
/** @protected {HTMLVideoElement} */
this.video = null;
/** @protected {number} */
this.mpdRequestTimeout = shaka.player.Defaults.MPD_REQUEST_TIMEOUT;
/**
* All usable StreamSetInfos from the manifest. Each StreamInfo contained
* within is mutually compatible with all other StreamInfos of the same type.
* Populated in selectConfigurations().
* @protected {!shaka.util.MultiMap.<!shaka.media.StreamSetInfo>}
* TODO(story 1890046): Support multiple periods.
*/
this.streamSetsByType = new shaka.util.MultiMap();
/** @private {!shaka.media.IAbrManager} */
this.abrManager_ = abrManager;
this.abrManager_.initialize(estimator, this);
/** @private {boolean} */
this.loaded_ = false;
/** @private {string} */
this.lang_ = shaka.player.Defaults.PREFERRED_LANGUAGE;
/** @private {boolean} */
this.subsNeeded_ = false;
/** @private {shaka.player.Stats} */
this.stats_ = null;
/** @private {!shaka.util.PublicPromise} */
this.attachPromise_ = new shaka.util.PublicPromise();
/** @private {!shaka.player.Restrictions} */
this.restrictions_ = new shaka.player.Restrictions();
/** @private {?number} */
this.ignoreSeek_ = null;
/** @private {number} */
this.originalPlaybackRate_ = 1;
/** @private {!Object.<string, !shaka.media.IStream>} */
this.streamsByType_ = {};
/** @private {!shaka.util.PublicPromise} */
this.proceedPromise_ = new shaka.util.PublicPromise();
/** @private {number} */
this.liveEndTime_ = 0;
/** @private {number} */
this.liveStreamEndTimeout_ = shaka.player.Defaults.STREAM_BUFFER_SIZE;
/** @private {?number} */
this.liveEndedTimer_ = null;
/** @private {boolean} */
this.jumpToLive_ = false;
/** @private {boolean} */
this.canSwitch_ = false;
/**
* @private {!Object.<string, {streamInfo: !shaka.media.StreamInfo,
* clearBuffer: boolean,
* clearBufferOffset: (number|undefined)}>}
*/
this.deferredSwitches_ = {};
/** @private {?number} */
this.updateTimer_ = null;
/** @private {?number} */
this.seekRangeTimer_ = null;
/** @private {?number} */
this.playbackStartTime_ = null;
/** @private {!Object.<string, *>} */
this.streamConfig_ = {};
};
goog.inherits(shaka.player.StreamVideoSource, shaka.util.FakeEventTarget);
/**
* @const {number}
* @private
*/
shaka.player.StreamVideoSource.MIN_UPDATE_PERIOD_ = 3;
/**
* @const {number}
* @private
*/
shaka.player.StreamVideoSource.SEEK_TOLERANCE_ = 0.01;
/**
* @const {number}
* @private
*/
shaka.player.StreamVideoSource.SEEK_OFFSET_ = 0.5;
/**
* <p>
* Configures the StreamVideoSource options.
* </p>
*
* The following configuration options are supported:
* <ul>
* <li>
* <b>enableAdaptation</b>: boolean <br>
* Enables or disables automatic bitrate adaptation.
*
* <li>
* <b>streamBufferSize</b>: number <br>
* Sets the maximum amount of content, in seconds, that audio and video
* streams will buffer ahead of the playhead. For DASH streams, this will
* be overridden if 'minBufferTime' is larger.
*
* <li>
* <b>mpdRequestTimeout</b>: number <br>
* Sets the MPD request timeout in seconds. A value of zero indicates no
* timeout.
*
* <li>
* <b>segmentRequestTimeout</b>: number <br>
* Sets the segment request timeout in seconds. A value of zero indicates no
* timeout.
*
* <li>
* <b>preferredLanguage</b>: string <br>
* Sets the preferred language (the default is 'en').
*
* <li>
* <b>restrictions</b>: shaka.player.Restrictions <br>
* Sets the video track restrictions.
* </ul>
*
* @example
* streamVideoSouce.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.player.StreamVideoSource.prototype.configure = function(config) {
if (config['streamBufferSize'] != null) {
this.streamConfig_['streamBufferSize'] = config['streamBufferSize'];
}
if (config['segmentRequestTimeout'] != null) {
this.streamConfig_['segmentRequestTimeout'] =
config['segmentRequestTimeout'];
}
this.configureStreams_();
if (config['enableAdaptation'] != null) {
this.abrManager_.enable(Boolean(config['enableAdaptation']));
}
if (config['mpdRequestTimeout'] != null) {
this.mpdRequestTimeout = Number(config['mpdRequestTimeout']);
}
if (config['liveStreamEndTimeout'] != null) {
this.liveStreamEndTimeout_ = Number(config['liveStreamEndTimeout']);
}
if (config['preferredLanguage'] != null) {
this.lang_ =
shaka.util.LanguageUtils.normalize(String(config['preferredLanguage']));
}
if (config['restrictions'] != null) {
var restrictions = /** @type {!shaka.player.Restrictions} */(
config['restrictions']);
this.restrictions_ = restrictions;
if (this.loaded_) {
this.applyRestrictions_();
}
}
};
/**
* @override
* @suppress {checkTypes} to set otherwise non-nullable types to null.
*/
shaka.player.StreamVideoSource.prototype.destroy = function() {
this.attachPromise_.destroy();
this.proceedPromise_.destroy();
this.attachPromise_ = null;
this.proceedPromise_ = null;
this.cancelSeekRangeTimer_();
this.cancelUpdateTimer_();
this.deferredSwitches_ = null;
this.eventManager.destroy();
this.eventManager = null;
shaka.util.MapUtils.values(this.streamsByType_).forEach(
function(stream) {
stream.destroy();
});
this.streamsByType_ = null;
this.streamSetsByType = null;
if (this.manifestInfo) {
this.manifestInfo.destroy();
this.manifestInfo = null;
}
this.abrManager_.destroy();
this.abrManager_ = null;
this.estimator = null;
this.mediaSource = null;
this.video = null;
this.stats_ = null;
this.restrictions_ = null;
this.parent = null;
};
/** @override */
shaka.player.StreamVideoSource.prototype.attach = function(player, video) {
if (!this.loaded_) {
var error = new Error('Cannot call attach() right now.');
error.type = 'app';
return Promise.reject(error);
}
this.parent = player;
this.video = video;
this.stats_ = player.getStats();
// The "sourceopen" event fires after setting the video element's "src"
// attribute.
this.eventManager.listen(
this.mediaSource,
'sourceopen',
this.onMediaSourceOpen_.bind(this));
this.eventManager.listen(
this.estimator,
'bandwidth',
this.onBandwidth_.bind(this));
if (shaka.features.Live && this.manifestInfo.live) {
this.eventManager.listen(
player,
'bufferingStart',
this.onBufferingStart_.bind(this));
this.eventManager.listen(
player,
'bufferingEnd',
this.onBufferingEnd_.bind(this));
}
// When re-using a video tag in Chrome, mediaKeys can get cleared by Chrome
// when src is set for the second (or subsequent) time. This feels like a
// bug in Chrome.
// See also: http://crbug.com/459702
// To work around this, back up the old value and ensure that it is set again
// before the attach promise is resolved. This fixes bug #18614098.
var backupMediaKeys = this.video.mediaKeys;
this.video.src = window.URL.createObjectURL(this.mediaSource);
var restorePromise = this.video.setMediaKeys(backupMediaKeys);
// Return a promise which encompasses both attach and the restoration of
// mediaKeys.
return Promise.all([this.attachPromise_, restorePromise]);
};
/** @override */
shaka.player.StreamVideoSource.prototype.load = function() {
if (this.loaded_) {
var error = new Error('Cannot call load() right now.');
error.type = 'app';
return Promise.reject(error);
}
if (!this.manifestInfo || this.manifestInfo.periodInfos.length == 0) {
var error = new Error('The manifest does not specify any content.');
error.type = 'stream';
return Promise.reject(error);
}
if (!shaka.features.Live && this.manifestInfo.live) {
var error = new Error('Live manifest support not enabled.');
error.type = 'app';
return Promise.reject(error);
}
var periodInfos = this.manifestInfo.periodInfos;
(new shaka.media.StreamInfoProcessor()).process(periodInfos);
// TODO(story 1890046): Support multiple periods.
if (this.manifestInfo.periodInfos.length == 0 ||
this.manifestInfo.periodInfos[0].streamSetInfos.length == 0) {
var error = new Error('The manifest specifies content that cannot ' +
'be displayed on this browser/platform.');
error.type = 'stream';
return Promise.reject(error);
}
this.loaded_ = true;
// Set the Streams' initial buffer sizes.
this.streamConfig_['initialStreamBufferSize'] =
this.manifestInfo.minBufferTime;
this.configureStreams_();
this.applyRestrictions_();
return Promise.resolve();
};
if (shaka.features.Live) {
/**
* Updates the manifest.
*
* @param {boolean} useLocal Whether to update using the local manifest.
* @private
*/
shaka.player.StreamVideoSource.prototype.onUpdateManifest_ =
function(useLocal) {
shaka.asserts.assert(this.loaded_);
shaka.asserts.assert(this.manifestInfo.updatePeriod != null);
shaka.asserts.assert(this.manifestInfo.updateUrl != null);
shaka.log.info('Updating manifest...');
var startTime = Date.now();
this.updateTimer_ = null;
/** @type {shaka.media.ManifestUpdater} */
var updater = null;
var url = /** @type {!shaka.util.FailoverUri} */
(this.manifestInfo.updateUrl);
var p = useLocal ?
this.onUpdateLocalManifest() :
this.onUpdateManifest(url);
p.then(shaka.util.TypedBind(this,
/** @param {!shaka.media.ManifestInfo} newManifestInfo */
function(newManifestInfo) {
updater = new shaka.media.ManifestUpdater(newManifestInfo);
return updater.update(
/** @type {!shaka.media.ManifestInfo} */ (this.manifestInfo));
})
).then(shaka.util.TypedBind(this,
/** @param {!Array.<!shaka.media.StreamInfo>} removedStreamInfos */
function(removedStreamInfos) {
shaka.log.info('Manifest updated!');
updater.destroy();
updater = null;
for (var i = 0; i < removedStreamInfos.length; ++i) {
// ManifestUpdater will have already removed the StreamInfo from the
// manifest, but if the StreamInfo is currently being used then we
// need to switch to another StreamInfo.
this.removeStream_(removedStreamInfos[i]);
}
// Reconfigure the Streams because |minBufferTime| may have changed.
this.streamConfig_['initialStreamBufferSize'] =
this.manifestInfo.minBufferTime;
this.configureStreams_();
this.applyRestrictions_();
if (shaka.util.MapUtils.empty(this.streamsByType_)) {
// createAndStartStreams_() failed the first time it was called.
// When createAndStartStreams_() succeeds then beginPlayback_()
// will call onUpdateManifest_().
this.createAndStartStreams_();
} else {
// Ensure the next update occurs within |manifestInfo.updatePeriod|
// seconds by taking into account the time it took to update the
// manifest.
var endTime = Date.now();
this.setUpdateTimer_((endTime - startTime) / 1000.0);
}
})
).catch(shaka.util.TypedBind(this,
/** @param {*} error */
function(error) {
if (updater) {
updater.destroy();
updater = null;
}
if (error.type != 'aborted') {
var event = shaka.util.FakeEvent.createErrorEvent(error);
this.dispatchEvent(event);
// Try updating again, but ensure we haven't been destroyed.
if (this.manifestInfo) {
this.setUpdateTimer_(0);
}
}
})
);
};
/**
* Update manifest hook. The caller takes ownership of the returned manifest.
*
* @param {!shaka.util.FailoverUri} url
* @return {!Promise.<!shaka.media.ManifestInfo>}
* @protected
*/
shaka.player.StreamVideoSource.prototype.onUpdateManifest = function(url) {
shaka.asserts.notImplemented();
var error = 'Cannot update manifest with this VideoSource implementation.';
error.type = 'stream';
return Promise.reject(error);
};
/**
* Update local manifest hook. The caller takes ownership of the returned
* manifest. This CANNOT return this.manifestInfo.
*
* @return {!Promise.<!shaka.media.ManifestInfo>}
* @protected
*/
shaka.player.StreamVideoSource.prototype.onUpdateLocalManifest = function() {
shaka.asserts.notImplemented();
var error = 'Cannot update manifest with this VideoSource implementation.';
error.type = 'stream';
return Promise.reject(error);
};
/**
* Sets the update timer to updated the manifest using the local copy.
*
* @private
*/
shaka.player.StreamVideoSource.prototype.setUpdateLocalManifest_ =
function() {
shaka.asserts.assert(!this.updateTimer_);
shaka.asserts.assert(this.manifestInfo.live);
shaka.asserts.assert(this.manifestInfo.availabilityStartTime);
var currentTime = Date.now() / 1000;
var ast = this.manifestInfo.availabilityStartTime;
var updateInterval = Math.max(ast - currentTime,
shaka.player.StreamVideoSource.MIN_UPDATE_PERIOD_);
shaka.log.debug('updateLocalInterval', updateInterval);
var callback = this.onUpdateManifest_.bind(this, true);
this.updateTimer_ = window.setTimeout(callback, 1000 * updateInterval);
};
/**
* Sets the update timer. Does nothing if the manifest does not specify
* an update period.
*
* @param {number} offset An offset, in seconds, to apply to the manifest's
* update period.
* @private
*/
shaka.player.StreamVideoSource.prototype.setUpdateTimer_ = function(offset) {
if (this.manifestInfo.updatePeriod == null) {
return;
}
shaka.asserts.assert(this.updateTimer_ == null);
var updatePeriod =
Math.max(this.manifestInfo.updatePeriod,
shaka.player.StreamVideoSource.MIN_UPDATE_PERIOD_);
var updateInterval = Math.max(updatePeriod - offset, 0);
shaka.log.debug('updateInterval', updateInterval);
var callback = this.onUpdateManifest_.bind(this, false);
this.updateTimer_ = window.setTimeout(callback, 1000 * updateInterval);
};
} // shaka.features.Live
/**
* Removes the given StreamInfo. Handles removing an active stream.
*
* @param {!shaka.media.StreamInfo} streamInfo
* @private
*/
shaka.player.StreamVideoSource.prototype.removeStream_ = function(streamInfo) {
var type = streamInfo.getContentType();
var stream = this.streamsByType_[type];
if (stream && (stream.getStreamInfo() == streamInfo)) {
var usableStreamSetInfos =
this.streamSetsByType.get(streamInfo.getContentType());
var newStreamInfos = usableStreamSetInfos
.map(function(streamSetInfo) { return streamSetInfo.streamInfos; })
.reduce(function(all, part) { return all.concat(part); }, [])
.filter(function(streamInfo) { return streamInfo.usable(); });
if (newStreamInfos.length == 0) {
shaka.log.error(
'The stream', streamInfo.id,
'was removed but an alternate stream does not exist.');
// Raise an error but don't explicity stop playback (leave that to the
// application).
var error =
new Error('All usable streams have been removed from the manifest.');
error.type = 'app';
var errorEvent = shaka.util.FakeEvent.createErrorEvent(error);
this.dispatchEvent(errorEvent);
return;
}
if (this.deferredSwitches_[type].streamInfo == streamInfo) {
delete this.deferredSwitches_[type];
}
// Just ignore |canSwitch_| and switch right now.
stream.switch(newStreamInfos[0], true /* clearBuffer */);
streamInfo.destroy();
}
shaka.log.info('Removed stream', streamInfo.id);
streamInfo.destroy();
};
/**
* @override
* @export
*/
shaka.player.StreamVideoSource.prototype.getVideoTracks = function() {
if (!this.streamSetsByType.has('video')) {
return [];
}
var stream = this.streamsByType_['video'];
var activeStreamInfo = stream ? stream.getStreamInfo() : null;
var activeId = activeStreamInfo ? activeStreamInfo.uniqueId : 0;
/** @type {!Array.<!shaka.player.VideoTrack>} */
var tracks = [];
var videoSets = this.streamSetsByType.get('video');
for (var i = 0; i < videoSets.length; ++i) {
var streamSetInfo = videoSets[i];
for (var j = 0; j < streamSetInfo.streamInfos.length; ++j) {
var streamInfo = streamSetInfo.streamInfos[j];
if (!streamInfo.usable()) continue;
var id = streamInfo.uniqueId;
var bandwidth = streamInfo.bandwidth;
var width = streamInfo.width;
var height = streamInfo.height;
var videoTrack =
new shaka.player.VideoTrack(id, bandwidth, width, height);
if (id == activeId) {
videoTrack.active = true;
}
tracks.push(videoTrack);
}
}
return tracks;
};
/**
* @override
* @export
*/
shaka.player.StreamVideoSource.prototype.getAudioTracks = function() {
if (!this.streamSetsByType.has('audio')) {
return [];
}
var stream = this.streamsByType_['audio'];
var activeStreamInfo = stream ? stream.getStreamInfo() : null;
var activeId = activeStreamInfo ? activeStreamInfo.uniqueId : 0;
/** @type {!Array.<!shaka.player.AudioTrack>} */
var tracks = [];
var audioSets = this.streamSetsByType.get('audio');
for (var i = 0; i < audioSets.length; ++i) {
var streamSetInfo = audioSets[i];
var lang = streamSetInfo.lang;
for (var j = 0; j < streamSetInfo.streamInfos.length; ++j) {
var streamInfo = streamSetInfo.streamInfos[j];
if (!streamInfo.usable()) continue;
var id = streamInfo.uniqueId;
var bandwidth = streamInfo.bandwidth;
var audioTrack = new shaka.player.AudioTrack(id, bandwidth, lang);
if (id == activeId) {
audioTrack.active = true;
}
tracks.push(audioTrack);
}
}
return tracks;
};
/**
* @override
* @export
*/
shaka.player.StreamVideoSource.prototype.getTextTracks = function() {
if (!this.streamSetsByType.has('text')) {
return [];
}
var stream = this.streamsByType_['text'];
var activeStreamInfo = stream ? stream.getStreamInfo() : null;
var activeId = activeStreamInfo ? activeStreamInfo.uniqueId : 0;
/** @type {!Array.<!shaka.player.TextTrack>} */
var tracks = [];
var textSets = this.streamSetsByType.get('text');
for (var i = 0; i < textSets.length; ++i) {
var streamSetInfo = textSets[i];
var lang = streamSetInfo.lang;
for (var j = 0; j < streamSetInfo.streamInfos.length; ++j) {
var streamInfo = streamSetInfo.streamInfos[j];
var id = streamInfo.uniqueId;
var textTrack = new shaka.player.TextTrack(id, lang);
if (id == activeId) {
textTrack.active = true;
shaka.asserts.assert(stream != null);
textTrack.enabled = stream.getEnabled();
}
tracks.push(textTrack);
}
}
return tracks;
};
/** @override */
shaka.player.StreamVideoSource.prototype.getBufferingGoal = function() {
return Number(this.streamConfig_['initialStreamBufferSize']);
};
/** @override */
shaka.player.StreamVideoSource.prototype.getConfigurations =
function() {
// TODO(story 1890046): Support multiple periods.
return this.loaded_ ? this.manifestInfo.periodInfos[0].getConfigs() : [];
};
/** @override */
shaka.player.StreamVideoSource.prototype.selectConfigurations = function(
configs) {
if (!this.loaded_) {
shaka.log.warning('Cannot call selectConfigurations() right now.');
return;
}
// Map the stream sets by ID.
var streamSetsById = {};
// TODO(story 1890046): Support multiple periods.
var period = this.manifestInfo.periodInfos[0];
for (var i = 0; i < period.streamSetInfos.length; ++i) {
var streamSet = period.streamSetInfos[i];
streamSetsById[streamSet.uniqueId] = streamSet;
}
// Use the IDs to convert the map of configs into a map of stream sets.
this.streamSetsByType.clear();
var types = configs.keys();
for (var i = 0; i < types.length; ++i) {
var type = types[i];
var cfgList = configs.get(type);
if (type == 'video') {
// We only choose one video stream set.
var id = cfgList[0].id;
this.streamSetsByType.push(type, streamSetsById[id]);
} else if (type == 'audio') {
// We choose mutually compatible stream sets for audio.
var basicMimeType = cfgList[0].getBasicMimeType();
for (var j = 0; j < cfgList.length; ++j) {
var cfg = cfgList[j];
if (cfg.getBasicMimeType() != basicMimeType) continue;
this.streamSetsByType.push(type, streamSetsById[cfg.id]);
}
} else {
// We choose all stream sets otherwise.
for (var j = 0; j < cfgList.length; ++j) {
var id = cfgList[j].id;
this.streamSetsByType.push(type, streamSetsById[id]);
}
}
}
// Assume subs will be needed.
this.subsNeeded_ = true;
var LanguageUtils = shaka.util.LanguageUtils;
var audioSets = this.streamSetsByType.get('audio');
if (audioSets) {
this.sortByLanguage_(audioSets);
this.streamSetsByType.set('audio', audioSets);
// If the manifest did not specify a language, assume it is the right one.
// This means that content creators who omit language because they serve a
// monolingual demographic will not have annoyed users who have to disable
// subtitles every single time they play a video.
var lang = audioSets[0].lang || this.lang_;
// If the audio language matches the user's language preference, then subs
// are not needed.
if (LanguageUtils.match(LanguageUtils.MatchType.MAX, this.lang_, lang)) {
this.subsNeeded_ = false;
}
}
var textSets = this.streamSetsByType.get('text');
if (textSets) {
this.sortByLanguage_(textSets);
this.streamSetsByType.set('text', textSets);
var lang = textSets[0].lang || this.lang_;
// If there is no text track to match the user's language preference,
// do not turn subs on by default.
if (!LanguageUtils.match(LanguageUtils.MatchType.MAX, this.lang_, lang)) {
this.subsNeeded_ = false;
}
}
};
/** @override */
shaka.player.StreamVideoSource.prototype.selectVideoTrack =
function(id, clearBuffer, opt_clearBufferOffset) {
return this.selectTrack_('video', id, clearBuffer, opt_clearBufferOffset);
};
/** @override */
shaka.player.StreamVideoSource.prototype.selectAudioTrack =
function(id, clearBuffer, opt_clearBufferOffset) {
return this.selectTrack_('audio', id, clearBuffer, opt_clearBufferOffset);
};
/** @override */
shaka.player.StreamVideoSource.prototype.selectTextTrack =
function(id, clearBuffer) {
return this.selectTrack_('text', id, clearBuffer);
};
/** @override */
shaka.player.StreamVideoSource.prototype.enableTextTrack = function(enabled) {
var textStream = this.streamsByType_['text'];
if (textStream) {
textStream.setEnabled(enabled);
}
};
/** @override */
shaka.player.StreamVideoSource.prototype.setPlaybackStartTime =
function(startTime) {
this.playbackStartTime_ = startTime;
};
/**
* Applies the video track restrictions, if any.
*
* @private
*/
shaka.player.StreamVideoSource.prototype.applyRestrictions_ = function() {
shaka.asserts.assert(this.manifestInfo);
shaka.asserts.assert(this.loaded_);
if (!this.restrictions_) {
return;
}
var tracksChanged = false;
// Note that the *Info objects contained within this.manifestInfo are the same
// objects contained within this.streamSetsByType.
for (var i = 0; i < this.manifestInfo.periodInfos.length; ++i) {
var periodInfo = this.manifestInfo.periodInfos[i];
for (var j = 0; j < periodInfo.streamSetInfos.length; ++j) {
var streamSetInfo = periodInfo.streamSetInfos[j];
if (streamSetInfo.contentType != 'video') continue;
for (var k = 0; k < streamSetInfo.streamInfos.length; ++k) {
var streamInfo = streamSetInfo.streamInfos[k];
var originalAllowed = streamInfo.allowedByApplication;
streamInfo.allowedByApplication = true;
if (this.restrictions_.maxWidth &&
streamInfo.width > this.restrictions_.maxWidth) {
streamInfo.allowedByApplication = false;
}
if (this.restrictions_.maxHeight &&
streamInfo.height > this.restrictions_.maxHeight) {
streamInfo.allowedByApplication = false;
}
if (this.restrictions_.minHeight &&
streamInfo.height < this.restrictions_.minHeight) {
streamInfo.allowedByApplication = false;
}
if (this.restrictions_.maxBandwidth &&
streamInfo.bandwidth > this.restrictions_.maxBandwidth) {
streamInfo.allowedByApplication = false;
}
if (this.restrictions_.minBandwidth &&
streamInfo.bandwidth < this.restrictions_.minBandwidth) {
streamInfo.allowedByApplication = false;
}
if (originalAllowed == streamInfo.allowedByApplication) continue;
shaka.log.info(
streamInfo.allowedByApplication ? 'Permitting' : 'Restricting',
'stream', streamInfo.id + '.',
'The application has applied new video track restrictions.');
tracksChanged = true;
} // for k
} // for j
} // for i
// If selectConfigurations() has not been called yet then there are no tracks
// yet.
if (this.streamSetsByType.getAll().length == 0 || !tracksChanged) {
return;
}
this.fireTracksChangedEvent_();
shaka.asserts.assert(this.streamSetsByType.has('video'));
var videoTracks = this.getVideoTracks();
if (videoTracks.length > 0) {
return;
}
// Raise an error but don't explicity stop playback (leave that to the
// application).
var error = new Error('The application has restricted all video tracks!');
error.type = 'app';
var errorEvent = shaka.util.FakeEvent.createErrorEvent(error);
this.dispatchEvent(errorEvent);
};
/** @override */
shaka.player.StreamVideoSource.prototype.getSessionIds = function() {
return [];
};
/** @override */
shaka.player.StreamVideoSource.prototype.isOffline = function() {
return false;
};
/** @override */
shaka.player.StreamVideoSource.prototype.isLive = function() {
return this.manifestInfo ? this.manifestInfo.live : false;
};
/** @override */
shaka.player.StreamVideoSource.prototype.onKeyStatusesChange = function(
keyStatusByKeyId) {
if (!COMPILED) {
for (var keyId in keyStatusByKeyId) {
var prettyKeyId = shaka.util.StringUtils.formatHexString(keyId);
shaka.log.debug(
'Key status:', prettyKeyId + ': ' + keyStatusByKeyId[keyId]);
}
}
var tracksChanged = false;
var streamInfosByKeyId = new shaka.util.MultiMap();
var streamSetInfos = this.streamSetsByType.getAll();
for (var i = 0; i < streamSetInfos.length; ++i) {
var streamSetInfo = streamSetInfos[i];
for (var j = 0; j < streamSetInfo.streamInfos.length; ++j) {
var streamInfo = streamSetInfo.streamInfos[j];
streamInfo.keyIds.forEach(
function(keyId) { streamInfosByKeyId.push(keyId, streamInfo); });
}
}
for (var keyId in keyStatusByKeyId) {
var keyStatus = keyStatusByKeyId[keyId];
var message = shaka.util.EmeUtils.getKeyStatusErrorMessage(keyStatus);
var streamInfos = streamInfosByKeyId.get(keyId);
if (!streamInfos) {
var prettyKeyId = shaka.util.StringUtils.formatHexString(keyId);
shaka.log.debug('Key', prettyKeyId, 'was not specified in the manifest.');
if (message) {
shaka.log.warning('Key', prettyKeyId, 'is not usable.', message);
}
continue;
}
for (var i = 0; i < streamInfos.length; ++i) {
var streamInfo = streamInfos[i];
var originalAllowed = streamInfo.allowedByKeySystem;
streamInfo.allowedByKeySystem = !message;
if (originalAllowed == streamInfo.allowedByKeySystem) continue;
shaka.log.info(
streamInfo.allowedByKeySystem ? 'Permitting' : 'Restricting',
'stream', streamInfo.id + '.', message || '');
tracksChanged = true;
}
}
if (!tracksChanged) {
return;
}
this.fireTracksChangedEvent_();
var audioTracks = this.getAudioTracks();
var videoTracks = this.getVideoTracks();
var noAudio = this.streamSetsByType.has('audio') && (audioTracks.length == 0);
var noVideo = this.streamSetsByType.has('video') && (videoTracks.length == 0);
if (!noAudio && !noVideo) {
return;
}
// Raise an error but don't explicitly stop playback (leave that to the
// application).
var suffix;
if (noAudio && noVideo) {
suffix = 'audio and video tracks.';
} else if (noAudio) {
suffix = 'audio tracks.';
} else {
suffix = 'video tracks.';
}
var error = new Error('The key system has restricted all ' + suffix);
error.type = 'drm';
var errorEvent = shaka.util.FakeEvent.createErrorEvent(error);
this.dispatchEvent(errorEvent);
};
/**
* Fires a 'trackschanged' event.
*
* @private
*/
shaka.player.StreamVideoSource.prototype.fireTracksChangedEvent_ = function() {
var event = shaka.util.FakeEvent.create({
'type': 'trackschanged',
'bubbles': true
});
this.dispatchEvent(event);
};
/**
* Select a track by ID.
*
* @param {string} type The type of track to change, such as 'video', 'audio',
* or 'text'.
* @param {number} id The |uniqueId| field of the desired StreamInfo.
* @param {boolean} clearBuffer
* @param {number=} opt_clearBufferOffset if |clearBuffer| and
* |opt_clearBufferOffset| are truthy, clear the stream buffer from the
* offset (in front of video currentTime) to the end of the stream.
*
* @return {boolean} True on success.
* @private
*/
shaka.player.StreamVideoSource.prototype.selectTrack_ =
function(type, id, clearBuffer, opt_clearBufferOffset) {
if (!this.streamSetsByType.has(type)) {
shaka.log.warning(
'Cannot select', type, 'track', id,
'because there are no', type, 'tracks.');
return false;
}
if (!this.streamsByType_[type]) {
shaka.log.warning(
'Cannot select', type, 'track', id,
'because there are no', type, 'streams yet.');
return false;
}
var sets = this.streamSetsByType.get(type);
for (var i = 0; i < sets.length; ++i) {
var streamSetInfo = sets[i];
for (var j = 0; j < streamSetInfo.streamInfos.length; ++j) {
var streamInfo = streamSetInfo.streamInfos[j];
if (streamInfo.uniqueId != id) continue;
if (!streamInfo.allowedByKeySystem) {
shaka.log.warning(
'Cannot select', type, 'track', id,
'because the track is not allowed by the key system.');
return false;
}
if (!streamInfo.allowedByApplication) {
shaka.log.warning(
'Cannot select', type, 'track', id,
'because the track is not allowed by the application.');
return false;
}
if (type != 'text' && !this.canSwitch_) {
// Note that stream switching is disabled until all SegmentIndexes have
// been created. This ensures that gaps do not get introduced into the
// buffer. For example, if all SegmentIndexes are behind by 5 seconds,
// and we switch to a StreamInfo whose SegmentIndex has not been
// corrected yet, the next segment will be offset by +5 seconds, which
// will create a gap in the buffer.
//
// However, we still want to process these stream switches, so defer
// them until later, see onAllStreamsStarted_().
//
// Note that if clearBuffer is true for any switch of a particular type
// then any deferred switch of that type will also set clearBuffer to
// true.
var tuple = this.deferredSwitches_[type];
this.deferredSwitches_[type] = {
streamInfo: streamInfo,
clearBuffer: (tuple != null && tuple.clearBuffer) || clearBuffer,
clearBufferOffset:
(tuple != null && tuple.clearBufferOffset) ||
opt_clearBufferOffset
};
return true;
}
shaka.asserts.assert(this.stats_);
this.stats_.logStreamChange(streamInfo);
this.streamsByType_[type].switch(
streamInfo, clearBuffer, opt_clearBufferOffset);
return true;
}
}
shaka.log.warning(
'Cannot select', type, 'track', id, 'because it does not exist.');
return false;
};
/**
* Move the best language match to the front of the array.
*
* @param {!Array.<!shaka.media.StreamSetInfo>} streamSets
* @private
*/
shaka.player.StreamVideoSource.prototype.sortByLanguage_ =
function(streamSets) {
// Alias.
var LanguageUtils = shaka.util.LanguageUtils;
// Do a fuzzy match and stop on the lowest successful fuzz level.
for (var fuzz = LanguageUtils.MatchType.MIN;
fuzz <= LanguageUtils.MatchType.MAX;
++fuzz) {
for (var i = 0; i < streamSets.length; ++i) {
var set = streamSets[i];
if (LanguageUtils.match(fuzz, this.lang_, set.lang)) {
// It's a match, so this set should go to the front.
streamSets.splice(i, 1);
streamSets.splice(0, 0, set);
return;
}
}
}
// If no languages matched, move the "main" set, if any, to the front.
for (var i = 0; i < streamSets.length; ++i) {
var set = streamSets[i];
if (set.main) {
streamSets.splice(i, 1);
streamSets.splice(0, 0, set);
return;
}
}
};
/**
* Called when the MediaSource transitions into the 'open' state. Only
* called after load() has been called.
*
* @param {!Event} event
* @private
*/
shaka.player.StreamVideoSource.prototype.onMediaSourceOpen_ = function(event) {
this.eventManager.unlisten(this.mediaSource, 'sourceopen');
this.createAndStartStreams_().then(shaka.util.TypedBind(this,
function() {
if (this.attachPromise_) {
this.attachPromise_.resolve();
}
})
).catch(shaka.util.TypedBind(this,
/** @param {*} error */
function(error) {
if (this.attachPromise_) {
this.attachPromise_.reject(error);
}
})
);
};
/**
* Instantiates the Streams and begins stream startup.
*
* Stream startup consists of starting a Stream for each available content
* type, waiting for each of these Streams to complete their initialization
* sequence (see {@link shaka.media.IStream}), applying the global timestamp
* correction, and then beginning playback.
*
* This function resolves the returned Promise when it instantiates and starts
* the initial set of Streams; at this time, each Stream (i.e., each Stream in
* this.streamsByType_) will be in the 'startup' state; however, actual
* playback will not have begun, and the video's playback rate should be zero.
*
* When each Stream completes startup we call
* {@link shaka.player.StreamVideoSource#onAllStreamsStarted_}; at this time,
* each Stream will be in the 'waiting' state.
*
* @return {!Promise} A Promise that resolves when this function creates
* the initial set of Streams and begins stream startup. If the manifest
* specifies live content then this function will always resolve the
* returned Promise; otherwise, this function may reject the returned
* Promise.
* @private
*/
shaka.player.StreamVideoSource.prototype.createAndStartStreams_ = function() {
/** @type {!Array.<!shaka.media.StreamSetInfo>} */
var selectedStreamSetInfos = [];
// For each desired type, select the first StreamSetInfo.
var desiredTypes = ['audio', 'video', 'text'];
for (var i = 0; i < desiredTypes.length; ++i) {
var type = desiredTypes[i];
if (this.streamSetsByType.has(type)) {
selectedStreamSetInfos.push(this.streamSetsByType.get(type)[0]);
}
}
/** @type {!Object.<string, !shaka.media.StreamInfo>} */
var selectedStreamInfosByType =
this.selectStreamInfos_(selectedStreamSetInfos);
for (var i = 0; i < desiredTypes.length; ++i) {
var type = desiredTypes[i];
if (this.streamSetsByType.has(type) && !selectedStreamInfosByType[type]) {
var error = new Error(
'Unable to select an initial ' + type + ' stream: ' +
'all ' + type + ' streams have been restricted ' +
'(by the application or by the key system).');
error.type = 'stream';
return Promise.reject(error);
}
}
// Create/fetch the SegmentIndex for each selected StreamInfo.
var async = shaka.util.MapUtils.values(selectedStreamInfosByType).map(
function(streamInfo) {
return streamInfo.segmentIndexSource.create();
});
return Promise.all(async).then(shaka.util.TypedBind(this,
/** @param {!Array.<!shaka.media.SegmentIndex>} segmentIndexes */
function(segmentIndexes) {
// Ensure all streams are available.
if (!segmentIndexes.every(function(index) { return index.length(); })) {
shaka.log.debug('At least one SegmentIndex is empty.');
var error = new Error('Some streams are not available.');
error.type = 'stream';
return Promise.reject(error);
}
// Compute the initial stream limits.
var streamLimits = this.computeStreamLimits_(segmentIndexes);
if (!streamLimits) {
// This may occur if the manifest is not well formed or if the
// streams have just become available, such that the streams' media
// timelines do not intersect, i.e., the streams do not share any
// timestamps in common.
var error = new Error('Some streams are not available.');
error.type = 'stream';
return Promise.reject(error);
}
// Create the Stream objects.
if (!this.createStreams_(selectedStreamInfosByType)) {
var error = new Error('Failed to create Stream objects.');
error.type = 'stream';
return Promise.reject(error);
}
this.abrManager_.start();
this.startStreams_(selectedStreamInfosByType, streamLimits);
return Promise.resolve();
})
).catch(shaka.util.TypedBind(this,
/** @param {*} error */
function(error) {
if (error.type == 'aborted') {
return;
}
shaka.asserts.assert(shaka.util.MapUtils.empty(this.streamsByType_));
// If the manifest specifies live content then suppress the error, we
// will try to create and start the streams again from
// onUpdateManifest_().
if (this.manifestInfo.live) {
shaka.log.warning(error.message);
// If @availabilityStartTime is in the future, simply re-process the
// local manifest once it become available; otherwise fetch a new
// manifest and process that.
if (shaka.util.Clock.now() <
this.manifestInfo.availabilityStartTime) {
this.setUpdateLocalManifest_();
} else {
this.setUpdateTimer_(0);
}
return Promise.resolve();
} else {
return Promise.reject(error);
}
})
);
};
/**
* Selects the initial StreamInfos from the given StreamSetsInfos.
*
* @param {!Array.<!shaka.media.StreamSetInfo>} streamSetInfos
* @return {!Object.<string, !shaka.media.StreamInfo>}
* @private
*/
shaka.player.StreamVideoSource.prototype.selectStreamInfos_ = function(
streamSetInfos) {
/** @type {!Object.<string, !shaka.media.StreamInfo>} */
var selectedStreamInfosByType = {};
for (var i = 0; i < streamSetInfos.length; ++i) {
var streamSetInfo = streamSetInfos[i];
var streamInfo = null;
if (streamSetInfo.contentType == 'video') {
// Ask AbrManager which video StreamInfo to start with.
var trackId = this.abrManager_.getInitialVideoTrackId();
if (trackId == null) {
shaka.log.debug('No initial video streams to select.');
continue;
}
var streamInfos = streamSetInfo.streamInfos.filter(
function(streamInfo) { return streamInfo.uniqueId == trackId; });
// Ensure AbrManager selected a video StreamInfo from |streamSetInfo|.
if (streamInfos.length == 0) {
shaka.log.debug(
'No initial video streams to select from the selected ' +
'StreamSetInfo.');
continue;
}
shaka.asserts.assert(streamInfos.length == 1);
shaka.asserts.assert(streamInfos[0].usable());
streamInfo = streamInfos[0];
} else if (streamSetInfo.contentType == 'audio') {
var usableStreamInfos = streamSetInfo.streamInfos.filter(
function(streamInfo) { return streamInfo.usable(); });
if (usableStreamInfos.length == 0) {
shaka.log.debug('No initial audio streams to select.');
continue;
}
// In lieu of audio adaptation, choose the middle stream from the
// usable ones. If we have high, medium, and low quality audio, this
// is medium. If we only have high and low, this is high.
var index = Math.floor(usableStreamInfos.length / 2);
streamInfo = streamSetInfo.streamInfos[index];
} else if (streamSetInfo.streamInfos.length > 0) {
streamInfo = streamSetInfo.streamInfos[0];
}
shaka.asserts.assert(streamInfo);
selectedStreamInfosByType[streamSetInfo.contentType] =
/** @type {!shaka.media.StreamInfo} */(streamInfo);
}
return selectedStreamInfosByType;
};
/**
* Creates the initial set of Stream objects. Populates |streamsByType_| on
* success.
*
* @param {!Object.<string, !shaka.media.StreamInfo>} streamInfosByType
* @return {boolean} True on success; otherwise, return false.
* @private
*/
shaka.player.StreamVideoSource.prototype.createStreams_ = function(
streamInfosByType) {
/** @type {!Object.<string, !shaka.media.IStream>} */
var streamsByType = {};
for (var type in streamInfosByType) {
var streamInfo = streamInfosByType[type];
var stream = type == 'text' ?
this.createTextStream_() :
this.createStream_(streamInfo);
if (!stream) {
var fullMimeType = streamInfo.getFullMimeType();
shaka.log.error('Failed to create', fullMimeType, 'stream.');
shaka.util.MapUtils.values(streamsByType).forEach(
function(stream) {
stream.destroy();
});
return false;
}
streamsByType[type] = stream;
}
this.streamsByType_ = streamsByType;
return true;
};
/**
* Creates a Stream object.
*
* @param {!shaka.media.StreamInfo} streamInfo
* @return {!shaka.media.Stream}
* @private
*/
shaka.player.StreamVideoSource.prototype.createStream_ = function(streamInfo) {
shaka.asserts.assert(this.video);
var fullMimeType = streamInfo.getFullMimeType();
var stream = new shaka.media.Stream(
this,
/** @type {!HTMLVideoElement} */(this.video),
this.mediaSource,
streamInfo.getFullMimeType(),
this.estimator);
stream.configure(this.streamConfig_);
return stream;
};
/**
* Creates a TextStream object.
*
* @return {!shaka.media.TextStream}
* @private
*/
shaka.player.StreamVideoSource.prototype.createTextStream_ = function() {
shaka.asserts.assert(this.video);
var video = /** @type {!HTMLVideoElement} */ (this.video);
return new shaka.media.TextStream(this, video);
};
/**
* Starts each Stream.
*
* @param {!Object.<string, !shaka.media.StreamInfo>} streamInfosByType
* @param {{start: number, end: number, last: number}} streamLimits The initial
* stream limits.
* @private
*/
shaka.player.StreamVideoSource.prototype.startStreams_ = function(
streamInfosByType, streamLimits) {
// If we apply a global timestamp correction after the Streams complete
// startup then we will also have to adjust the video's current time (see
// onAllStreamsStarted_), so don't begin playback yet.
this.originalPlaybackRate_ = this.video.playbackRate;
this.video.playbackRate = 0;
this.setUpMediaSource_(streamLimits);
// Determine the stream start time.
var streamStartTime;
// If a specific start time was set, and it's within the stream limits, use
// that as the start time.
if (this.playbackStartTime_ &&
this.playbackStartTime_ <= streamLimits.end &&
this.playbackStartTime_ >= streamLimits.start) {
streamStartTime = this.playbackStartTime_;
} else if (this.manifestInfo.live) {
shaka.asserts.assert(streamLimits.end != Number.POSITIVE_INFINITY);
streamStartTime = streamLimits.end;
this.jumpToLive_ = true;
} else {
streamStartTime = streamLimits.start;
}
shaka.log.info('Starting each stream from', streamStartTime);
// Start listening to 'seeking' events right away as we must handle seeking
// during stream startup.
this.eventManager.listen(this.video, 'seeking', this.onSeeking_.bind(this));
if (this.video.currentTime != streamStartTime) {
// Set the video's current time before starting the streams so that the
// streams start buffering at the stream start time.
this.video.currentTime = streamStartTime;
// Ignore the resulting 'seeking' event since there's no need to resync the
// streams before buffering (see onSeeking_).
this.ignoreSeek_ = streamStartTime;
shaka.log.debug('Ignoring pending seek to', this.ignoreSeek_);
}
// Inform the application of the initial seek range.
this.fireSeekRangeChangedEvent_(streamLimits.start, streamLimits.end);
// Start the streams.
var async = [];
for (var type in this.streamsByType_) {
var stream = this.streamsByType_[type];
async.push(stream.started(this.proceedPromise_));
this.eventManager.listen(
stream,
'ended',
this.onStreamEnded_.bind(this));
var streamInfo = streamInfosByType[type];
this.stats_.logStreamChange(streamInfo);
stream.switch(streamInfo, false /* clearBuffer */);
}
Promise.all(async).then(
this.onAllStreamsStarted_.bind(this)
).catch(shaka.util.TypedBind(this,
/** @param {*} error */
function(error) {
// One or more Streams encountered an unrecoverable error during stream
// startup. There's nothing else to do.
shaka.asserts.assert(error.type != 'aborted');
if (error.type != 'destroy') {
shaka.log.error('Stream startup failed!');
var event = shaka.util.FakeEvent.createErrorEvent(error);
this.dispatchEvent(event);
}
})
);
// Enable the subtitle display by default iff the subs are needed.
this.enableTextTrack(this.subsNeeded_);
};
/**
* Called when each Stream has completed startup.
*
* Computes a global timestamp correction and immediately applies it to the
* initial set of SegmentIndexes and then begins playback. In parallel,
* creates/fetches all SegmentIndexes and fetches all initialization segments.
*
* @param {!Array.<number>} timestampCorrections The initial set of streams'
* timestamp corrections.
* @private
*/
shaka.player.StreamVideoSource.prototype.onAllStreamsStarted_ = function(
timestampCorrections) {
shaka.log.info('All Streams have completed startup!');
shaka.asserts.assert(
timestampCorrections &&
timestampCorrections.length == Object.keys(this.streamsByType_).length,
'There should be a timestamp correction for each Stream.');
// Compute a global timestamp correction (TSC) to correct the
// SegmentIndexes.
//
// For example, consider two streams, where the manifest specified that they
// should start at 2 seconds but the first one is offset by 2 seconds and the
// second one is offset by 3 seconds. After we buffer 2 segments we would see
// [1] below (if the SegmentIndexes were accurate, we would see [2] below).
//
// Legend:
// a: 1st segment's content.
// b: 2nd segment's content.
//
// [1]
// <---|---|---aaaaaaaabbbbbbbb--------->
// <---|---|-----aaaaaaaabbbbbbbb------->
// 0 2 4 5 6 8 A B
//
// [2]
// <---|---aaaaaaaabbbbbbbb------------->
// <---|---aaaaaaaabbbbbbbb------------->
// 0 2 4 6 8 A B
//
// These timestamp offsets exist because a manifest may not provide exact
// timestamp signalling, especially for live streams. For example, for DASH,
// inaccurate timestamp signalling is explicitly allowed (i.e., MPD start
// times are approximations of media presentation start times).
//
// If we did not compensate for these timestamp offsets then we would
// encounter two issues:
// 1. the user could seek such that the playhead could end up in an
// unbuffered region, e.g., if the user seeks to 6.1 seconds then each
// Stream would fetch segment two, which would leave the playhead in an
// unbuffered region;
// 2. the playhead would be behind the first buffered range before playback
// even begins.
// Note that if the timestamp offsets were negative, we would encounter
// similar issues.
//
// So, to compensate for these timestamp offsets we use TSCs: if a stream is
// offset by N seconds then it requires an N second TSC.
//
// We compute a single global TSC, which is the maximum TSC among the initial
// set of streams' TSCs, and then correct every SegmentIndex with the global
// TSC. So, in the future when the Streams fetch segments that should start
// at M seconds, the segments will either start exactly at M seconds or at
// some small amount of time before M seconds. Furthermore, we adjust the
// playhead before we begin playback to ensure the playhead is within a
// buffered range (see beginPlayback_).
//
// We assume the TSCs between streams are similar, so this solution is not
// perfect as it does not solve issue 1 in all cases; however, this
// assumption is almost always valid and it frees us from having to compute a
// TSC for every stream that we switch to, which is difficult when we have
// already buffered one or more segments.
var minTimestampCorrection = Number.POSITIVE_INFINITY;
var maxTimestampCorrection = Number.NEGATIVE_INFINITY;
for (var i = 0; i < timestampCorrections.length; ++i) {
var tsc = timestampCorrections[i];
minTimestampCorrection = Math.min(minTimestampCorrection, tsc);
maxTimestampCorrection = Math.max(maxTimestampCorrection, tsc);
}
shaka.log.info('Timestamp correction', maxTimestampCorrection);
// |minTimestampCorrection| and |maxTimestampCorrection| should have the
// same sign.
if (minTimestampCorrection * maxTimestampCorrection < 0) {
shaka.log.warning(
'Some streams\' media timestamps are ahead of their SegmentIndexes,',
'while other streams\' timestamps are behind.',
'The content may have errors in it.');
}
// Correct the initial set of SegmentIndexes and then begin playback.
var segmentIndexes = this.getSegmentIndexes_();
for (var i = 0; i < segmentIndexes.length; ++i) {
segmentIndexes[i].correct(maxTimestampCorrection);
}
this.beginPlayback_(segmentIndexes, maxTimestampCorrection);
// In parallel, create/fetch all SegmentIndexes and fetch all initialization
// segments so that they are available to the Streams right away. This
// reduces latency when stream switching.
var async = this.streamSetsByType.getAll()
.map(function(streamSetInfo) { return streamSetInfo.streamInfos; })
.reduce(function(all, part) { return all.concat(part); }, [])
.map(function(streamInfo) {
var async = [streamInfo.segmentIndexSource.create()];
if (streamInfo.segmentInitSource) {
async.push(streamInfo.segmentInitSource.create());
}
return Promise.all(async);
});
Promise.all(async).then(shaka.util.TypedBind(this,
/** @param {!Array.<!Array>} results */
function(results) {
for (var i = 0; i < results.length; ++i) {
/** @type {!shaka.media.SegmentIndex} */
var segmentIndex = results[i][0];
segmentIndex.correct(maxTimestampCorrection);
}
shaka.log.debug(
'Created/fetched all SegmentIndexes and initialization segments!');
// Enable stream switching and process all deferred switches.
this.canSwitch_ = true;
this.processDeferredSwitches_();
})
).catch(shaka.util.TypedBind(this,
/** @param {*} error */
function(error) {
if (error.type != 'aborted') {
var event = shaka.util.FakeEvent.createErrorEvent(error);
this.dispatchEvent(event);
}
})
);
};
/**
* Processes all deferred switches.
*
* @private
*/
shaka.player.StreamVideoSource.prototype.processDeferredSwitches_ = function() {
for (var type in this.deferredSwitches_) {
var tuple = this.deferredSwitches_[type];
var stream = this.streamsByType_[type];
shaka.asserts.assert(stream);
shaka.asserts.assert(this.stats_);
this.stats_.logStreamChange(tuple.streamInfo);
stream.switch(tuple.streamInfo, tuple.clearBuffer, tuple.clearBufferOffset);
}
this.deferredSwitches_ = {};
};
/**
* Corrects the MediaSource's duration and append windows, moves the playhead
* to the start of the first buffered range, and then starts playback by
* signalling each Stream to proceed.
*
* @param {!Array.<!shaka.media.SegmentIndex>} segmentIndexes The initial set
* of SegmentIndexes.
* @param {number} timestampCorrection The global timestamp correction.
* @private
*/
shaka.player.StreamVideoSource.prototype.beginPlayback_ = function(
segmentIndexes, timestampCorrection) {
shaka.log.debug('beginPlayback_');
var streamLimits = this.computeStreamLimits_(segmentIndexes);
shaka.asserts.assert(streamLimits, 'Stream limits should not be null.');
if (streamLimits) {
this.setUpMediaSource_(streamLimits);
this.fireSeekRangeChangedEvent_(streamLimits.start, streamLimits.end);
}
// If there is a global timestamp correction then the playhead may be in an
// unbuffered region (see onAllStreamsStarted_), so adjust the video's
// current time as necessary. If |timestampCorrection| is 0 then don't modify
// video.currentTime so we don't fire an unnecessary 'seeking' event.
var correctedCurrentTime;
if (timestampCorrection != 0) {
shaka.log.debug(
'Adjusting video.currentTime by', timestampCorrection, 'seconds.');
correctedCurrentTime = this.video.currentTime + timestampCorrection;
this.video.currentTime = correctedCurrentTime;
// Ignore the resulting 'seeking' event since there's no need to resync the
// streams (see onSeeking_).
this.ignoreSeek_ = correctedCurrentTime;
shaka.log.debug('Ignoring pending seek to', this.ignoreSeek_);
} else {
correctedCurrentTime = this.video.currentTime;
}
shaka.asserts.assert(correctedCurrentTime != null);
// Sanity check: check that |correctedCurrentTime| is within the stream
// limits.
if (!COMPILED && streamLimits) {
// For live content, if the available bandwidth is really low (e.g., lower
// than the bandwidth specified in the manifest) then it's possible for
// |correctedCurrentTime| to be legitimately outside of the stream limits,
// i.e., the seek window may move past the corrected playhead.
//
// Note: since video.currentTime may have less precision than
// |timestampCorrection|, include a tolerance.
var tolerance = 10e-6;
if (this.manifestInfo.live &&
correctedCurrentTime < streamLimits.start - tolerance) {
shaka.log.debug(
'correctedCurrentTime (' + correctedCurrentTime + ')',
'is outside of the stream limits before beginning playback!');
}
if ((!this.manifestInfo.live &&
correctedCurrentTime < streamLimits.start - tolerance) ||
(correctedCurrentTime > streamLimits.end + tolerance)) {
shaka.log.error(
'correctedCurrentTime (' + correctedCurrentTime + ')',
'should be within the stream limits',
[streamLimits.start, streamLimits.end]);
}
}
if (this.jumpToLive_ && streamLimits) {
shaka.log.debug('Jumping to live across',
streamLimits.end - this.video.currentTime, 'seconds');
this.video.currentTime = streamLimits.end;
}
// TODO: If the playback rate is set by the application between the set and
// load of originalPlaybackRate_, that rate will be ignored. Fix this race
// between StreamVideoSource and the application. In the mean time,
// applications should use setPlaybackRate either before loading the source
// or after playback begins.
this.video.playbackRate = this.originalPlaybackRate_;
if (this.manifestInfo.live && this.manifestInfo.updatePeriod != null) {
// Ensure the next update occurs within |manifestInfo.updatePeriod| seconds
// by taking into account the time it took to start the streams.
shaka.asserts.assert(this.updateTimer_ == null);
this.setUpdateTimer_(0);
}
this.setSeekRangeTimer_();
this.proceedPromise_.resolve();
};
/**
* Sets the MediaSource's duration and the SourceBuffers' append windows.
* Before calling this function, the MediaSource must be in the 'open' state.
* This function may be called any number of times.
*
* @param {{start: number, end: number, last: number}} streamLimits The current
* stream limits.
* @private
*/
shaka.player.StreamVideoSource.prototype.setUpMediaSource_ = function(
streamLimits) {
shaka.asserts.assert(this.mediaSource.readyState == 'open',
'The MediaSource should be in the \'open\' state.');
// We need to set the MediaSource's duration so that we can append segments
// and allow the user to seek.
if (this.manifestInfo.live) {
// For live content we usually don't know the content's duration, so we
// don't need to set the duration to a precise value. We should be able to
// set the MediaSource's duration to POSITIVE_INFINITY but on some browsers
// this does not work as intended. So, just set the MediaSource's duration
// to a "large" value.
if (isNaN(this.mediaSource.duration)) {
this.mediaSource.duration = streamLimits.end + (60 * 60 * 24 * 30);
}
} else {
// For static content we know the content's duration, and we must set the
// duration to a precise value so we don't modify the video's duration
// during playback (e.g., by inserting a segment into the SourceBuffer that
// ends after the MediaSource's duration) and so we can support normal
// end-of-stream behavior (i.e., the browser should pause the video when
// the playhead reaches the end of the video, and the browser should be
// able to loop the video).
//
// So, set the MediaSource's duration to the stream end time. However, note
// that some streams (either from the initial set of streams or from the
// set of all streams) may start before the stream start time or may end
// before the stream end time because the streams' media timelines may not
// be aligned with eachother (this is normal, and is true both before and
// after we apply the global timestamp correction). If we insert a segment
// into the SourceBuffer that ends after the MediaSource's duration then
// the MediaSource's duration will increase, and if the MediaSource's
// duration increases beyond the seekable range (by some non-negligible
// amount) then the playhead will be able to move into an unseekable range
// during playback, which will interfere with normal end-of-stream behavior
// and buffering detection (see #155).
//
// So, set the SourceBuffers' append windows so that the MediaSource's
// duration cannot increase past the stream end time.
//
// TODO: If the new duration is less than the old duration then changing
// the duration starts the 'duration change algorithm'.
// See {@link http://www.w3.org/TR/media-source/#duration-change-algorithm}.
// Handle this case.
if (isNaN(this.mediaSource.duration) ||
streamLimits.end > this.mediaSource.duration) {
shaka.log.debug('Setting MediaSource duration to', streamLimits.end);
this.mediaSource.duration = streamLimits.end;
for (var i = 0; i < this.mediaSource.sourceBuffers.length; ++i) {
this.mediaSource.sourceBuffers[i].appendWindowEnd = streamLimits.end;
}
}
}
};
/**
* Computes a new seek range and fires a 'seekrangechanged' event. Also clamps
* the playhead to the seek start time during playback.
*
* @private
*/
shaka.player.StreamVideoSource.prototype.onUpdateSeekRange_ = function() {
this.seekRangeTimer_ = null;
this.setSeekRangeTimer_();
var streamLimits = this.computeStreamLimits_(this.getSegmentIndexes_());
shaka.asserts.assert(streamLimits, 'Stream limits should not be null.');
if (!streamLimits) {
return;
}
if (this.manifestInfo.live && this.liveEndTime_ != streamLimits.last) {
this.liveEndTime_ = streamLimits.last;
if (this.liveEndedTimer_ != null) {
shaka.log.debug('Not the end of the live stream.');
window.clearTimeout(this.liveEndedTimer_);
this.liveEndedTimer_ = null;
}
}
this.fireSeekRangeChangedEvent_(streamLimits.start, streamLimits.end);
if (this.video.paused) {
return;
}
// Clamping the playhead to the right here ensures that if the user pauses
// and then plays the video then the playhead is moved into the seekable
// range. Note that if the playhead moves to the right of the seekable range
// during playback then some of the streams must be buffering, so there's no
// need to clamp the playhead.
// TODO: Add live integration test that covers this case.
var currentTime = this.video.currentTime;
var start = streamLimits.start;
var end = streamLimits.end;
if (this.clampPlayheadToRight_(currentTime, start, end)) {
shaka.log.warning(
'Playhead is outside of the seekable range:',
'seekable', [start, end],
'attempted', currentTime,
'Adjusting...');
// If the video's current time was clamped then there will be a
// 'seeking' event which is handled by onSeeking_().
}
};
/**
* Fires a 'seekrangechanged' event.
*
* @param {number} start
* @param {number} end
* @private
*/
shaka.player.StreamVideoSource.prototype.fireSeekRangeChangedEvent_ = function(
start, end) {
var event = shaka.util.FakeEvent.create({
'type': 'seekrangechanged',
'bubbles': true,
'start': start,
'end': end
});
shaka.log.v1('Seek range', [start, end], end - start);
this.dispatchEvent(event);
};
/**
* Video seeking callback.
*
* @param {!Event} event
* @private
*/
shaka.player.StreamVideoSource.prototype.onSeeking_ = function(event) {
shaka.log.v1('onSeeking_', event);
var currentTime = this.video.currentTime;
if (this.ignoreSeek_ != null) {
var tolerance = shaka.player.StreamVideoSource.SEEK_TOLERANCE_;
if ((currentTime >= this.ignoreSeek_ - tolerance) &&
(currentTime <= this.ignoreSeek_ + tolerance)) {
shaka.log.debug('Ignored seek to', this.ignoreSeek_);
this.ignoreSeek_ = null;
return;
}
this.ignoreSeek_ = null;
}
var streamLimits = this.computeStreamLimits_(this.getSegmentIndexes_());
shaka.asserts.assert(streamLimits, 'Stream limits should not be null.');
if (!streamLimits) {
return;
}
var start = streamLimits.start;
var end = streamLimits.end;
if (this.clampPlayheadToRight_(currentTime, start, end) ||
this.clampPlayheadToLeft_(currentTime, end)) {
shaka.log.warning(
'Playhead has been moved outside of the seekable range:',
'seekable', [start, end],
'attempted', currentTime,
'Adjusting...');
// If the video's current time was clamped then there will be another
// 'seeking' event, so this function will get called again and the 'else'
// branch below will be executed.
} else {
for (var type in this.streamsByType_) {
this.streamsByType_[type].resync();
}
}
};
/**
* Clamps the video's current time to the right of the given start time
* (inclusive).
*
* @param {number} currentTime The video's current time.
* @param {number} start The start time in seconds.
* @param {number} end The end time in seconds, which must be provided to
* ensure that the playhead is not adjusted too far right.
* @return {boolean} True if the video's current was clamped, in which case a
* 'seeking' event will be fired by the video.
* @private
*/
shaka.player.StreamVideoSource.prototype.clampPlayheadToRight_ = function(
currentTime, start, end) {
if (currentTime >= start - shaka.player.StreamVideoSource.SEEK_TOLERANCE_) {
return false;
}
// For live content, if we re-position the playhead too close to the seek
// start time then we may end up outside of the seek range again, as the seek
// window may be moving or we may have to buffer after we re-position. So,
// re-position the playhead ahead of the seek start time to compensate.
var compensation = 0;
if (this.manifestInfo.live) {
compensation = shaka.player.StreamVideoSource.SEEK_OFFSET_;
// Search all of the streams to see if they have buffered the start time.
// If one of them hasn't, then add some compensation.
for (var type in this.streamsByType_) {
if (!this.streamsByType_[type].isBuffered(start + compensation)) {
compensation = this.manifestInfo.minBufferTime;
break;
}
}
}
this.video.currentTime = Math.min(start + compensation, end);
return true;
};
/**
* Clamps the video's current time to the left of the given end time
* (inclusive).
*
* @param {number} currentTime The video's current time.
* @param {number} end The end time in seconds.
* @return {boolean} True if the video's current was clamped, in which case a
* 'seeking' event will be fired by the video.
* @private
*/
shaka.player.StreamVideoSource.prototype.clampPlayheadToLeft_ = function(
currentTime, end) {
if (currentTime <= end + shaka.player.StreamVideoSource.SEEK_TOLERANCE_) {
return false;
}
this.video.currentTime = end;
return true;
};
/**
* Stream ended callback.
*
* @param {!Event} event
* @private
*/
shaka.player.StreamVideoSource.prototype.onStreamEnded_ = function(event) {
if (this.manifestInfo.live) {
return;
}
for (var type in this.streamsByType_) {
if (!this.streamsByType_[type].hasEnded()) {
// Not all streams have ended.
return;
}
}
this.endOfStream_();
};
/**
* The buffering start event callback.
*
* @param {!Event} event
* @private
*/
shaka.player.StreamVideoSource.prototype.onBufferingStart_ = function(event) {
shaka.asserts.assert(this.manifestInfo.live);
var ended = shaka.util.MapUtils.values(this.streamsByType_)
.every(function(stream) { return stream.hasEnded(); });
if (ended) {
// We are in a buffering state and all the streams have ended. We assume
// that either the manifest does not update or it has updated recently
// enough to give the newest segments; therefore, we are probably at the
// end of the live stream.
shaka.log.info('Possible end of live stream.');
this.liveEndedTimer_ =
window.setTimeout(this.endOfStream_.bind(this),
1000 * this.liveStreamEndTimeout_);
}
};
/**
* The buffering end event callback.
*
* @param {!Event} event
* @private
*/
shaka.player.StreamVideoSource.prototype.onBufferingEnd_ = function(event) {
shaka.asserts.assert(this.manifestInfo.live);
if (this.liveEndedTimer_ != null) {
shaka.log.debug('Not the end of the live stream.');
window.clearTimeout(this.liveEndedTimer_);
this.liveEndedTimer_ = null;
}
};
/**
* Bandwidth statistics update callback.
*
* @param {!Event} event
* @private
*/
shaka.player.StreamVideoSource.prototype.onBandwidth_ = function(event) {
shaka.asserts.assert(this.stats_);
this.stats_.logBandwidth(this.estimator.getBandwidth());
};
/**
* Signals the end of the video.
*
* @private
*/
shaka.player.StreamVideoSource.prototype.endOfStream_ = function() {
// |mediaSource| must be in the 'open' state before calling endOfStream().
shaka.asserts.assert(this.mediaSource.readyState == 'open',
'The MediaSource should be in the \'open\' state.');
// Sanity check: endOfStream() sets the MediaSource's duration to the end of
// the video's buffered range. However, it should not actually modify the
// MediaSource's duration (significantly) because the streams' last segments
// should end very close to the MediaSource's duration.
// See {@link http://www.w3.org/TR/media-source/#end-of-stream-algorithm}.
//
// Note that if the current streams are the initial streams then one of the
// stream's last segment should end exactly at the MediaSource's duration.
// However, if this is not the case then either one of the stream's last
// segment ends after the MediaSource's duration, in which case the append
// window will cut it off, so the MediaSource's duration won't be modified;
// or both of the streams' last segments end before the MediaSource's
// duration, which for well formed content should still not modify the
// MediaSource's duration.
if (!COMPILED) {
var durationBefore = this.mediaSource.duration;
var durationAfter;
}
shaka.log.info('Signalling end-of-stream.');
this.liveEndedTimer_ = null;
this.mediaSource.endOfStream();
if (!COMPILED) {
var durationAfter = this.mediaSource.duration;
if (durationAfter != durationBefore) {
shaka.log.warning(
'endOfStream() should not modify the MediaSource\'s duration:',
'before', durationBefore,
'after', durationAfter,
'delta', durationAfter - durationBefore);
}
var updating = false;
for (var i = 0; i < this.mediaSource.sourceBuffers.length; ++i) {
updating = this.mediaSource.sourceBuffers[i].updating;
}
shaka.asserts.assert(
!updating,
'endOfStream() should not trigger the duration change algorithm.');
}
};
/**
* Gets the active Streams' SegmentIndexes.
*
* @return {!Array.<!shaka.media.SegmentIndex>}
* @private
*/
shaka.player.StreamVideoSource.prototype.getSegmentIndexes_ = function() {
return shaka.util.MapUtils.values(this.streamsByType_)
.map(function(stream) { return stream.getSegmentIndex(); })
.filter(function(index) { return index != null; });
};
/**
* Computes the stream limits, i.e., a stream start time and stream end time,
* of the given SegmentIndexes. The stream limits define the video's seekable
* range, so the video's current time should always be within the stream
* limits.
*
* The stream limits are a subset of each individual SegmentIndex's seek range;
* however, the converse is not true.
*
* @param {!Array.<!shaka.media.SegmentIndex>} segmentIndexes
* @return {?{start: number, end: number, last: number}} The stream limits on
* success; otherwise, return null if a stream end time could not be
* computed or the streams' media timelines do not intersect.
* @private
*/
shaka.player.StreamVideoSource.prototype.computeStreamLimits_ = function(
segmentIndexes) {
shaka.asserts.assert(this.manifestInfo);
var startTime = 0;
var endTime = Number.POSITIVE_INFINITY;
var lastTime = Number.POSITIVE_INFINITY;
for (var i = 0; i < segmentIndexes.length; ++i) {
var seekRange = segmentIndexes[i].getSeekRange();
startTime = Math.max(startTime, seekRange.start);
if (seekRange.end != null) {
endTime = Math.min(endTime, seekRange.end);
}
if (segmentIndexes[i].length()) {
lastTime = Math.min(lastTime, segmentIndexes[i].last().endTime);
}
}
// Fallback to the period's duration if necessary.
if (endTime == Number.POSITIVE_INFINITY) {
// TODO(story 1890046): Support multiple periods.
var period = this.manifestInfo.periodInfos[0];
if (period.duration) {
endTime = (period.start || 0) + period.duration;
} else {
shaka.log.debug('Failed to compute a stream end time.');
return null;
}
}
if (this.manifestInfo.live) {
// Ensure that we can actually buffer the minimum buffer size by offsetting
// the stream end time.
var offset = this.manifestInfo.minBufferTime;
endTime = Math.max(endTime - offset, startTime);
}
if (startTime > endTime) {
shaka.log.debug('The streams\' media timelines do not intersect.');
return null;
}
return { start: startTime, end: endTime, last: lastTime };
};
/**
* Cancels the update timer, if any.
*
* @private
*/
shaka.player.StreamVideoSource.prototype.cancelUpdateTimer_ = function() {
if (this.updateTimer_) {
window.clearTimeout(this.updateTimer_);
this.updateTimer_ = null;
}
};
/**
* Sets the seek range timer.
*
* @private
*/
shaka.player.StreamVideoSource.prototype.setSeekRangeTimer_ = function() {
if (!this.manifestInfo.live) {
return;
}
shaka.asserts.assert(this.seekRangeTimer_ == null);
var callback = this.onUpdateSeekRange_.bind(this);
this.seekRangeTimer_ = window.setTimeout(callback, 1000);
};
/**
* Cancels the seek range timer, if any.
*
* @private
*/
shaka.player.StreamVideoSource.prototype.cancelSeekRangeTimer_ = function() {
if (this.seekRangeTimer_) {
window.clearTimeout(this.seekRangeTimer_);
this.seekRangeTimer_ = null;
}
};
/**
* Configures each Stream with |streamConfig_|
*
* @private
*/
shaka.player.StreamVideoSource.prototype.configureStreams_ = function() {
for (var type in this.streamsByType_) {
var stream = this.streamsByType_[type];
stream.configure(this.streamConfig_);
}
};