UNPKG

shaka-player

Version:
1,608 lines (1,330 loc) 77.8 kB
/** * @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(); shaka.asserts.assert(types.length, 'No configs were selected!'); 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