Files
shaka-player/lib/player.js
T
Joey Parrish 1c4d7bbd85 Improve HDCP error handling
* Suppress WRONG_KEYS errors when output is restricted.  This is a
   false alarm in these cases anyway.
 * Throw a restriction error when the ABR manager fails to choose
   streams for some content types.
 * Rename ALL_STREAMS_RESTRICTED to RESTRICTIONS_CANNOT_BE_MET to
   reflect the way restrictions actually work.  We cannot play if all
   streams of any one type are restricted, not just all streams in the
   period.  (e.g., all video restricted is a failure, even if audio is
   still playable.)

b/30290503

Change-Id: I6de48950d33f241b7df1161052d60b89f962649f
2016-07-25 16:33:08 -07:00

1691 lines
48 KiB
JavaScript

/**
* @license
* Copyright 2016 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');
goog.require('goog.asserts');
goog.require('shaka.abr.SimpleAbrManager');
goog.require('shaka.log');
goog.require('shaka.media.DrmEngine');
goog.require('shaka.media.ManifestParser');
goog.require('shaka.media.MediaSourceEngine');
goog.require('shaka.media.Playhead');
goog.require('shaka.media.SegmentReference');
goog.require('shaka.media.StreamingEngine');
goog.require('shaka.net.NetworkingEngine');
goog.require('shaka.util.CancelableChain');
goog.require('shaka.util.ConfigUtils');
goog.require('shaka.util.Error');
goog.require('shaka.util.EventManager');
goog.require('shaka.util.FakeEvent');
goog.require('shaka.util.FakeEventTarget');
goog.require('shaka.util.IDestroyable');
goog.require('shaka.util.MapUtils');
goog.require('shaka.util.PublicPromise');
goog.require('shaka.util.StreamUtils');
/**
* Construct a Player.
*
* @param {!HTMLMediaElement} video Any existing TextTracks attached to this
* element that were not created by Shaka will be disabled. A new TextTrack
* may be created to display captions or subtitles.
* @param {function(shaka.Player)=} opt_dependencyInjector Optional callback
* which is called to inject mocks into the Player. Used for testing.
*
* @constructor
* @struct
* @implements {shaka.util.IDestroyable}
* @extends {shaka.util.FakeEventTarget}
* @export
*/
shaka.Player = function(video, opt_dependencyInjector) {
shaka.util.FakeEventTarget.call(this);
/** @private {boolean} */
this.destroyed_ = false;
/** @private {HTMLMediaElement} */
this.video_ = video;
/** @private {TextTrack} */
this.textTrack_ = null;
/** @private {shaka.util.EventManager} */
this.eventManager_ = new shaka.util.EventManager();
/** @private {shakaExtern.AbrManager} */
this.defaultAbrManager_ = new shaka.abr.SimpleAbrManager();
/** @private {shaka.net.NetworkingEngine} */
this.networkingEngine_ = null;
/** @private {shaka.media.DrmEngine} */
this.drmEngine_ = null;
/** @private {MediaSource} */
this.mediaSource_ = null;
/** @private {shaka.media.MediaSourceEngine} */
this.mediaSourceEngine_ = null;
/** @private {Promise} */
this.mediaSourceOpen_ = null;
/** @private {shaka.media.Playhead} */
this.playhead_ = null;
/** @private {shaka.media.StreamingEngine} */
this.streamingEngine_ = null;
/** @private {shakaExtern.ManifestParser} */
this.parser_ = null;
/** @private {?shakaExtern.Manifest} */
this.manifest_ = null;
/** @private {?string} */
this.manifestUri_ = null;
/**
* Contains an ID for use with creating streams. The manifest parser should
* start with small IDs, so this starts with a large one.
* @private {number}
*/
this.nextExternalStreamId_ = 1e9;
/** @private {!Array.<number>} */
this.loadingTextStreamIds_ = [];
/** @private {boolean} */
this.buffering_ = false;
/** @private {boolean} */
this.switchingPeriods_ = true;
/** @private {shaka.util.CancelableChain} */
this.loadChain_ = null;
/**
* @private {!Object.<string, {stream: shakaExtern.Stream, clear: boolean}>}
*/
this.deferredSwitches_ = {};
/** @private {?shakaExtern.PlayerConfiguration} */
this.config_ = this.defaultConfig_();
/** @private {{width: number, height: number}} */
this.maxHwRes_ = { width: Infinity, height: Infinity };
/** @private {!Array.<shakaExtern.StreamChoice>} */
this.switchHistory_ = [];
/** @private {number} */
this.playTime_ = 0;
/** @private {number} */
this.bufferingTime_ = 0;
/** @private {number} */
this.lastStatUpdateTimestamp_ = 0;
if (opt_dependencyInjector)
opt_dependencyInjector(this);
this.networkingEngine_ = this.createNetworkingEngine();
this.initialize_();
};
goog.inherits(shaka.Player, shaka.util.FakeEventTarget);
/**
* After destruction, a Player object cannot be used again.
*
* @override
* @export
*/
shaka.Player.prototype.destroy = function() {
this.destroyed_ = true;
var cancelation = Promise.resolve();
if (this.loadChain_) {
// A load is in progress. Cancel it.
cancelation = this.loadChain_.cancel(new shaka.util.Error(
shaka.util.Error.Category.PLAYER,
shaka.util.Error.Code.LOAD_INTERRUPTED));
}
return cancelation.then(function() {
var p = Promise.all([
this.destroyStreaming_(),
this.eventManager_ ? this.eventManager_.destroy() : null,
this.networkingEngine_ ? this.networkingEngine_.destroy() : null
]);
this.video_ = null;
this.textTrack_ = null;
this.eventManager_ = null;
this.defaultAbrManager_ = null;
this.networkingEngine_ = null;
this.config_ = null;
return p;
}.bind(this));
};
/**
* @define {string} A version number taken from git at compile time.
*/
goog.define('GIT_VERSION', 'v2.0.0-beta2-debug');
/**
* @const {string}
* @export
*/
shaka.Player.version = GIT_VERSION;
/**
* @event shaka.Player.ErrorEvent
* @description Fired when a playback error occurs.
* @property {string} type
* 'error'
* @property {!shaka.util.Error} detail
* An object which contains details on the error. The error's 'category' and
* 'code' properties will identify the specific error that occured. In an
* uncompiled build, you can also use the 'message' and 'stack' properties
* to debug.
* @exportDoc
*/
/**
* @event shaka.Player.BufferingEvent
* @description Fired when the player's buffering state changes.
* @property {string} type
* 'buffering'
* @property {boolean} buffering
* True when the Player enters the buffering state.
* False when the Player leaves the buffering state.
* @exportDoc
*/
/**
* @event shaka.Player.LoadingEvent
* @description Fired when the player begins loading.
* Used by the Cast receiver to determine idle state.
* @property {string} type
* 'loading'
* @exportDoc
*/
/**
* @event shaka.Player.UnloadingEvent
* @description Fired when the player unloads or fails to load.
* Used by the Cast receiver to determine idle state.
* @property {string} type
* 'unloading'
* @exportDoc
*/
/**
* @event shaka.Player.TextTrackVisibilityEvent
* @description Fired when text track visibility changes.
* @property {string} type
* 'texttrackvisibility'
* @exportDoc
*/
/**
* @event shaka.Player.TracksChangedEvent
* @description Fired when the list of tracks changes. For example, this will
* happen when changing periods or when track restrictions change.
* @property {string} type
* 'trackschanged'
* @exportDoc
*/
/**
* @event shaka.Player.AdaptationEvent
* @description Fired when an automatic adaptation causes the active tracks
* to change. Does not fire when the application calls selectTrack().
* @property {string} type
* 'adaptation'
* @exportDoc
*/
/** @private {!Object.<string, function():*>} */
shaka.Player.supportPlugins_ = {};
/**
* Registers a plugin callback that will be called with support(). The
* callback will return the value that will be stored in the return value from
* support().
*
* @param {string} name
* @param {function():*} callback
* @export
*/
shaka.Player.registerSupportPlugin = function(name, callback) {
shaka.Player.supportPlugins_[name] = callback;
};
/**
* Return whether the browser provides basic support. If this returns false,
* Shaka Player cannot be used at all. In this case, do not construct a Player
* instance and do not use the library.
*
* @return {boolean}
* @export
*/
shaka.Player.isBrowserSupported = function() {
// Basic features needed for the library to be usable.
var basic = !!window.Promise && !!window.Uint8Array &&
!!Array.prototype.forEach;
return basic &&
shaka.media.MediaSourceEngine.isBrowserSupported() &&
shaka.media.DrmEngine.isBrowserSupported();
};
/**
* Probes the browser to determine what features are supported. This makes a
* number of requests to EME/MSE/etc which may result in user prompts. This
* should only be used for diagnostics.
*
* NOTE: This may show a request to the user for permission.
*
* @see https://goo.gl/ovYLvl
* @return {!Promise.<shakaExtern.SupportType>}
* @export
*/
shaka.Player.probeSupport = function() {
goog.asserts.assert(shaka.Player.isBrowserSupported(),
'Must have basic support');
return shaka.media.DrmEngine.probeSupport().then(function(drm) {
var manifest = shaka.media.ManifestParser.probeSupport();
var media = shaka.media.MediaSourceEngine.probeSupport();
var ret = {
manifest: manifest,
media: media,
drm: drm
};
var plugins = shaka.Player.supportPlugins_;
for (var name in plugins) {
ret[name] = plugins[name]();
}
return ret;
});
};
/**
* Load a manifest.
*
* @param {string} manifestUri
* @param {number=} opt_startTime Optional start time, in seconds, to begin
* playback. Defaults to 0 for VOD and to the live edge for live.
* @param {shakaExtern.ManifestParser.Factory=} opt_manifestParserFactory
* Optional manifest parser factory to override auto-detection or use an
* unregistered parser.
* @return {!Promise} Resolved when the manifest has been loaded and playback
* has begun; rejected when an error occurs or the call was interrupted by
* destroy(), unload() or another call to load().
* @export
*/
shaka.Player.prototype.load = function(manifestUri, opt_startTime,
opt_manifestParserFactory) {
var unloadPromise = this.unload();
var loadChain = new shaka.util.CancelableChain();
this.loadChain_ = loadChain;
this.dispatchEvent(new shaka.util.FakeEvent('loading'));
return loadChain.then(function() {
return unloadPromise;
}).then(function() {
goog.asserts.assert(this.networkingEngine_, 'Must not be destroyed');
return shaka.media.ManifestParser.getFactory(
manifestUri,
this.networkingEngine_,
this.config_.manifest.retryParameters,
opt_manifestParserFactory);
}.bind(this)).then(function(factory) {
this.parser_ = new factory();
this.parser_.configure(this.config_.manifest);
goog.asserts.assert(this.networkingEngine_, 'Must not be destroyed');
return this.parser_.start(
manifestUri,
this.networkingEngine_,
this.filterPeriod_.bind(this),
this.onError_.bind(this));
}.bind(this)).then(function(manifest) {
this.manifest_ = manifest;
this.manifestUri_ = manifestUri;
this.drmEngine_ = this.createDrmEngine();
this.drmEngine_.configure(this.config_.drm);
return this.drmEngine_.init(manifest, false /* isOffline */);
}.bind(this)).then(function() {
// Re-filter the manifest after DRM has been initialized.
this.manifest_.periods.forEach(this.filterPeriod_.bind(this));
this.lastStatUpdateTimestamp_ = Date.now() / 1000;
// Wait for MediaSource to open before continuing.
return Promise.all([
this.drmEngine_.attach(this.video_),
this.mediaSourceOpen_
]);
}.bind(this)).then(function() {
// MediaSource is open, so create the Playhead, MediaSourceEngine, and
// StreamingEngine.
this.playhead_ = this.createPlayhead(opt_startTime);
this.mediaSourceEngine_ = this.createMediaSourceEngine();
this.streamingEngine_ = this.createStreamingEngine();
this.streamingEngine_.configure(this.config_.streaming);
return this.streamingEngine_.init();
}.bind(this)).then(function() {
// Re-filter the manifest after streams have been chosen.
this.manifest_.periods.forEach(this.filterPeriod_.bind(this));
// Dispatch a 'trackschanged' event now that all initial filtering is done.
this.onTracksChanged_();
// Since the first streams just became active, send an adaptation event.
this.onAdaptation_();
this.config_.abr.manager.init(this.switch_.bind(this));
this.loadChain_ = null;
}.bind(this)).finalize().catch(function(error) {
goog.asserts.assert(error instanceof shaka.util.Error,
'Wrong error type!');
shaka.log.debug('load() failed:', error);
// If we haven't started another load, clear the loadChain_ member.
if (this.loadChain_ == loadChain) {
this.loadChain_ = null;
this.dispatchEvent(new shaka.util.FakeEvent('unloading'));
}
return Promise.reject(error);
}.bind(this));
};
/**
* Creates a new instance of DrmEngine. This can be replaced by tests to
* create fake instances instead.
*
* @return {!shaka.media.DrmEngine}
*/
shaka.Player.prototype.createDrmEngine = function() {
goog.asserts.assert(this.networkingEngine_, 'Must not be destroyed');
return new shaka.media.DrmEngine(
this.networkingEngine_,
this.onError_.bind(this),
this.onKeyStatus_.bind(this));
};
/**
* Creates a new instance of NetworkingEngine. This can be replaced by tests
* to create fake instances instead.
*
* @return {!shaka.net.NetworkingEngine}
*/
shaka.Player.prototype.createNetworkingEngine = function() {
return new shaka.net.NetworkingEngine(this.onSegmentDownloaded_.bind(this));
};
/**
* Creates a new instance of Playhead. This can be replaced by tests to create
* fake instances instead.
*
* @param {number=} opt_startTime
* @return {!shaka.media.Playhead}
*/
shaka.Player.prototype.createPlayhead = function(opt_startTime) {
var timeline = this.manifest_.presentationTimeline;
var rebufferingGoal = shaka.media.StreamingEngine.getRebufferingGoal(
this.manifest_, this.config_.streaming, 1 /* scaleFactor */);
return new shaka.media.Playhead(
this.video_, timeline, rebufferingGoal, opt_startTime || null,
this.onBuffering_.bind(this), this.onSeek_.bind(this));
};
/**
* Create and open MediaSource. Potentially slow.
*
* @return {!Promise}
*/
shaka.Player.prototype.createMediaSource = function() {
this.mediaSource_ = new MediaSource();
var ret = new shaka.util.PublicPromise();
this.eventManager_.listen(this.mediaSource_, 'sourceopen', ret.resolve);
this.video_.src = window.URL.createObjectURL(this.mediaSource_);
return ret;
};
/**
* Creates a new instance of MediaSourceEngine. This can be replaced by tests
* to create fake instances instead.
*
* @return {!shaka.media.MediaSourceEngine}
*/
shaka.Player.prototype.createMediaSourceEngine = function() {
return new shaka.media.MediaSourceEngine(
this.video_, this.mediaSource_, this.textTrack_);
};
/**
* Creates a new instance of StreamingEngine. This can be replaced by tests
* to create fake instances instead.
*
* @return {!shaka.media.StreamingEngine}
*/
shaka.Player.prototype.createStreamingEngine = function() {
goog.asserts.assert(this.playhead_, 'Must not be destroyed');
goog.asserts.assert(this.mediaSourceEngine_, 'Must not be destroyed');
goog.asserts.assert(this.manifest_, 'Must not be destroyed');
return new shaka.media.StreamingEngine(
this.playhead_, this.mediaSourceEngine_, this.networkingEngine_,
this.manifest_, this.onChooseStreams_.bind(this),
this.canSwitch_.bind(this), this.onError_.bind(this));
};
/**
* Configure the Player instance.
*
* The config object passed in need not be complete. It will be merged with
* the existing Player configuration.
*
* Config keys and types will be checked. If any problems with the config
* object are found, errors will be reported through logs.
*
* @param {shakaExtern.PlayerConfiguration} config
* @export
*/
shaka.Player.prototype.configure = function(config) {
goog.asserts.assert(this.config_, 'Config must not be null!');
if (config.abr && config.abr.manager &&
config.abr.manager != this.config_.abr.manager) {
this.config_.abr.manager.stop();
config.abr.manager.init(this.switch_.bind(this));
}
shaka.util.ConfigUtils.mergeConfigObjects(
this.config_, config, this.defaultConfig_(), this.configOverrides_(), '');
this.applyConfig_();
};
/**
* Apply config changes.
* @private
*/
shaka.Player.prototype.applyConfig_ = function() {
if (this.parser_) {
this.parser_.configure(this.config_.manifest);
}
if (this.drmEngine_) {
this.drmEngine_.configure(this.config_.drm);
}
if (this.streamingEngine_) {
this.streamingEngine_.configure(this.config_.streaming);
// Need to apply the restrictions to every period.
this.manifest_.periods.forEach(this.filterPeriod_.bind(this));
// May need to choose new streams.
shaka.log.debug('Choosing new streams after changing configuration');
var period = this.streamingEngine_.getCurrentPeriod();
this.chooseStreamsAndSwitch_(period);
}
// Simply enable/disable ABR with each call, since multiple calls to these
// methods have no effect.
if (this.config_.abr.enabled && !this.switchingPeriods_) {
this.config_.abr.manager.enable();
} else {
this.config_.abr.manager.disable();
}
this.config_.abr.manager.setDefaultEstimate(
this.config_.abr.defaultBandwidthEstimate);
};
/**
* Return a copy of the current configuration. Modifications of the returned
* value will not affect the Player's active configuration. You must call
* player.configure() to make changes.
*
* @return {shakaExtern.PlayerConfiguration}
* @export
*/
shaka.Player.prototype.getConfiguration = function() {
goog.asserts.assert(this.config_, 'Config must not be null!');
var ret = this.defaultConfig_();
shaka.util.ConfigUtils.mergeConfigObjects(
ret, this.config_, this.defaultConfig_(), this.configOverrides_(), '');
return ret;
};
/**
* Reset configuration to default.
* @export
*/
shaka.Player.prototype.resetConfiguration = function() {
var config = this.defaultConfig_();
if (config.abr && config.abr.manager &&
config.abr.manager != this.config_.abr.manager) {
this.config_.abr.manager.stop();
config.abr.manager.init(this.switch_.bind(this));
}
// Don't call mergeConfigObjects_(), since that would not reset open-ended
// dictionaries like drm.servers.
this.config_ = this.defaultConfig_();
this.applyConfig_();
};
/**
* @return {shaka.net.NetworkingEngine} A reference to the Player's networking
* engine. Applications may use this to make requests through Shaka's
* networking plugins.
* @export
*/
shaka.Player.prototype.getNetworkingEngine = function() {
return this.networkingEngine_;
};
/**
* @return {?string} If a manifest is loaded, returns the manifest URI given in
* the last call to load(). Otherwise, returns null.
* @export
*/
shaka.Player.prototype.getManifestUri = function() {
return this.manifestUri_;
};
/**
* @return {boolean} True if the current stream is live. False if the stream is
* VOD or if there is no active stream.
* @export
*/
shaka.Player.prototype.isLive = function() {
return this.manifest_ ?
this.manifest_.presentationTimeline.isLive() :
false;
};
/**
* Get the seekable range for the current stream.
* @return {{start: number, end: number}}
* @export
*/
shaka.Player.prototype.seekRange = function() {
var start = 0;
var end = 0;
if (this.manifest_) {
var timeline = this.manifest_.presentationTimeline;
start = timeline.getSegmentAvailabilityStart();
end = timeline.getSeekRangeEnd();
}
return {'start': start, 'end': end};
};
/**
* Get the key system currently being used by EME. This returns the empty
* string if not using EME.
*
* @return {string}
* @export
*/
shaka.Player.prototype.keySystem = function() {
return this.drmEngine_ ? this.drmEngine_.keySystem() : '';
};
/**
* Get the DrmInfo used to initialize EME. This returns null when not using
* EME.
*
* @return {?shakaExtern.DrmInfo}
* @export
*/
shaka.Player.prototype.drmInfo = function() {
return this.drmEngine_ ? this.drmEngine_.getDrmInfo() : null;
};
/**
* @return {boolean} True if the Player is in a buffering state.
* @export
*/
shaka.Player.prototype.isBuffering = function() {
return this.buffering_;
};
/**
* Unload the current manifest and make the Player available for re-use.
*
* @return {!Promise} Resolved when streaming has stopped and the previous
* content, if any, has been unloaded.
* @export
*/
shaka.Player.prototype.unload = function() {
if (this.destroyed_) return Promise.resolve();
this.dispatchEvent(new shaka.util.FakeEvent('unloading'));
if (this.loadChain_) {
// A load is in progress. Cancel it, then reset the streaming system.
var interrupt = new shaka.util.Error(
shaka.util.Error.Category.PLAYER,
shaka.util.Error.Code.LOAD_INTERRUPTED);
return this.loadChain_.cancel(interrupt)
.then(this.resetStreaming_.bind(this));
} else {
// No loads or unloads are in progress.
// Just reset the streaming system if needed.
return this.resetStreaming_();
}
};
/**
* Gets the current effective playback rate. If using trick play, it will
* return the current trick play rate; otherwise, it will return the video
* playback rate.
* @return {number}
* @export
*/
shaka.Player.prototype.getPlaybackRate = function() {
return this.playhead_ ? this.playhead_.getPlaybackRate() : 0;
};
/**
* Skip through the content without playing. Simulated using repeated seeks.
*
* Trick play will be canceled automatically if the playhead hits the beginning
* or end of the seekable range for the content.
*
* @param {number} rate The playback rate to simulate. For example, a rate of
* 2.5 would result in 2.5 seconds of content being skipped every second.
* To trick-play backward, use a negative rate.
* @export
*/
shaka.Player.prototype.trickPlay = function(rate) {
if (this.playhead_)
this.playhead_.setPlaybackRate(rate);
};
/**
* Cancel trick-play.
* @export
*/
shaka.Player.prototype.cancelTrickPlay = function() {
if (this.playhead_)
this.playhead_.setPlaybackRate(1);
};
/**
* Return a list of audio, video, and text tracks available for the current
* Period. If there are multiple Periods, then you must seek to the Period
* before being able to switch.
*
* @return {!Array.<shakaExtern.Track>}
* @export
*/
shaka.Player.prototype.getTracks = function() {
if (!this.streamingEngine_)
return [];
var activeStreams = this.streamingEngine_.getActiveStreams();
var period = this.streamingEngine_.getCurrentPeriod();
return shaka.util.StreamUtils.getTracks(period, activeStreams)
.filter(function(track) {
// Don't show any tracks that are being loaded still.
return this.loadingTextStreamIds_.indexOf(track.id) < 0;
}.bind(this));
};
/**
* Select a specific track. For audio or video, this disables adaptation.
* Note that AdaptationEvents are not fired for manual track selections.
*
* @param {shakaExtern.Track} track
* @param {boolean=} opt_clearBuffer
* @export
*/
shaka.Player.prototype.selectTrack = function(track, opt_clearBuffer) {
if (!this.streamingEngine_)
return;
var period = this.streamingEngine_.getCurrentPeriod();
var data = shaka.util.StreamUtils.findStreamForTrack(period, track);
if (!data) {
shaka.log.error('Unable to find the track with id "' + track.id +
'"; did we change Periods?');
return;
}
var stream = data.stream;
// Double check that the track is allowed to be played.
if (!stream.allowedByApplication || !stream.allowedByKeySystem) {
shaka.log.error('Unable to switch to track with id "' + track.id +
'" because it is restricted.');
return;
}
// Add an entry to the history.
this.switchHistory_.push({
timestamp: Date.now() / 1000,
id: stream.id,
type: track.type,
fromAdaptation: false
});
if (track.type != 'text') {
// Disable ABR for anything other than text track changes.
var config = /** @type {shakaExtern.PlayerConfiguration} */ (
{abr: {enabled: false}});
this.configure(config);
}
var streamsToSwitch = {};
streamsToSwitch[track.type] = stream;
this.deferredSwitch_(streamsToSwitch, opt_clearBuffer);
};
/**
* @return {boolean} True if the current text track is visible.
* @export
*/
shaka.Player.prototype.isTextTrackVisible = function() {
return this.textTrack_.mode == 'showing';
};
/**
* Set the visibility of the current text track, if any.
*
* @param {boolean} on
* @export
*/
shaka.Player.prototype.setTextTrackVisibility = function(on) {
this.textTrack_.mode = on ? 'showing' : 'hidden';
this.onTextTrackVisibility_();
};
/**
* Return playback and adaptation stats.
*
* @return {shakaExtern.Stats}
* @export
*/
shaka.Player.prototype.getStats = function() {
this.updateStats_();
var video = {};
var audio = {};
var videoInfo = this.video_ && this.video_.getVideoPlaybackQuality ?
this.video_.getVideoPlaybackQuality() :
{};
if (this.streamingEngine_) {
var activeStreams = this.streamingEngine_.getActiveStreams();
video = activeStreams['video'] || {};
audio = activeStreams['audio'] || {};
}
return {
width: video.width || 0,
height: video.height || 0,
streamBandwidth: (video.bandwidth + audio.bandwidth) || 0,
decodedFrames: Number(videoInfo.totalVideoFrames),
droppedFrames: Number(videoInfo.droppedVideoFrames),
estimatedBandwidth: this.config_.abr.manager.getBandwidthEstimate(),
playTime: this.playTime_,
bufferingTime: this.bufferingTime_,
switchHistory: this.switchHistory_.slice(0)
};
};
/**
* Adds the given text track to the current Period. Load() must resolve before
* calling. The current Period or the presentation must have a duration. This
* returns a Promise that will resolve when the track can be switched to and
* will resolve with the track that was created.
*
* @param {string} uri
* @param {string} language
* @param {string} kind
* @param {string} mime
* @param {string=} opt_codec
* @return {!Promise.<shakaExtern.Track>}
* @export
*/
shaka.Player.prototype.addTextTrack = function(
uri, language, kind, mime, opt_codec) {
if (!this.streamingEngine_) {
shaka.log.error(
'Must call load() and wait for it to resolve before adding text ' +
'tracks.');
return Promise.reject();
}
// Get the Period duration.
var period = this.streamingEngine_.getCurrentPeriod();
/** @type {number} */
var periodDuration;
for (var i = 0; i < this.manifest_.periods.length; i++) {
if (this.manifest_.periods[i] == period) {
if (i == this.manifest_.periods.length - 1) {
periodDuration = this.manifest_.presentationTimeline.getDuration() -
period.startTime;
if (periodDuration == Number.POSITIVE_INFINITY) {
shaka.log.error(
'The current Period or the presentation must have a duration ' +
'to add external text tracks.');
return Promise.reject();
}
} else {
var nextPeriod = this.manifest_.periods[i + 1];
periodDuration = nextPeriod.startTime - period.startTime;
}
break;
}
}
/** @type {shakaExtern.Stream} */
var stream = {
id: this.nextExternalStreamId_++,
createSegmentIndex: Promise.resolve.bind(Promise),
findSegmentPosition: function(time) { return 1; },
getSegmentReference: function(ref) {
if (ref != 1) return null;
return new shaka.media.SegmentReference(
1, 0, periodDuration, function() { return [uri]; }, 0, null);
},
initSegmentReference: null,
presentationTimeOffset: 0,
mimeType: mime,
codecs: opt_codec || '',
bandwidth: 0,
kind: kind,
encrypted: false,
keyId: null,
language: language,
allowedByApplication: true,
allowedByKeySystem: true
};
/** @type {shakaExtern.StreamSet} */
var streamSet = {
language: language,
type: 'text',
primary: false,
drmInfos: [],
streams: [stream]
};
// Add the stream to the loading list to ensure it isn't switched to while it
// is initializing.
this.loadingTextStreamIds_.push(stream.id);
period.streamSets.push(streamSet);
return this.streamingEngine_.notifyNewStream('text', stream).then(function() {
if (this.destroyed_) return;
// Remove the stream from the loading list.
this.loadingTextStreamIds_.splice(
this.loadingTextStreamIds_.indexOf(stream.id), 1);
shaka.log.debug('Choosing new streams after adding a text stream');
this.chooseStreamsAndSwitch_(period);
this.onTracksChanged_();
return {
id: stream.id,
active: false,
type: 'text',
bandwidth: 0,
language: language,
kind: kind,
width: null,
height: null
};
}.bind(this));
};
/**
* Set the maximum resolution that the platform's hardware can handle.
* This will be called automatically by shaka.cast.CastReceiver to enforce
* limitations of the Chromecast hardware.
*
* @param {number} width
* @param {number} height
* @export
*/
shaka.Player.prototype.setMaxHardwareResolution = function(width, height) {
this.maxHwRes_.width = width;
this.maxHwRes_.height = height;
};
/**
* Initialize the Player.
* @private
*/
shaka.Player.prototype.initialize_ = function() {
// Start the (potentially slow) process of opening MediaSource now.
this.mediaSourceOpen_ = this.createMediaSource();
// If the video element has TextTracks, disable them. If we see one that
// was created by a previous instance of Shaka Player, reuse it.
for (var i = 0; i < this.video_.textTracks.length; ++i) {
var track = this.video_.textTracks[i];
track.mode = 'disabled';
if (track.label == shaka.Player.TextTrackLabel_) {
this.textTrack_ = track;
}
}
if (!this.textTrack_) {
// As far as I can tell, there is no observable difference between setting
// kind to 'subtitles' or 'captions' when creating the TextTrack object.
// The individual text tracks from the manifest will still have their own
// kinds which can be displayed in the app's UI.
this.textTrack_ = this.video_.addTextTrack(
'subtitles', shaka.Player.TextTrackLabel_);
}
this.textTrack_.mode = 'hidden';
// TODO: test that in all cases, the built-in CC controls in the video element
// are toggling our TextTrack.
// Listen for video errors.
this.eventManager_.listen(this.video_, 'error',
this.onVideoError_.bind(this));
};
/**
* Destroy members responsible for streaming.
*
* @return {!Promise}
* @private
*/
shaka.Player.prototype.destroyStreaming_ = function() {
if (this.eventManager_) {
this.eventManager_.unlisten(this.mediaSource_, 'sourceopen');
}
if (this.video_) {
this.video_.removeAttribute('src');
this.video_.load();
}
var p = Promise.all([
this.config_ ? this.config_.abr.manager.stop() : null,
this.drmEngine_ ? this.drmEngine_.destroy() : null,
this.mediaSourceEngine_ ? this.mediaSourceEngine_.destroy() : null,
this.playhead_ ? this.playhead_.destroy() : null,
this.streamingEngine_ ? this.streamingEngine_.destroy() : null,
this.parser_ ? this.parser_.stop() : null
]);
this.drmEngine_ = null;
this.mediaSourceEngine_ = null;
this.playhead_ = null;
this.streamingEngine_ = null;
this.parser_ = null;
this.manifest_ = null;
this.manifestUri_ = null;
this.mediaSourceOpen_ = null;
this.mediaSource_ = null;
this.deferredSwitches_ = {};
this.switchHistory_ = [];
this.playTime_ = 0;
this.bufferingTime_ = 0;
return p;
};
/**
* Reset the streaming system.
* @return {!Promise}
* @private
*/
shaka.Player.prototype.resetStreaming_ = function() {
if (!this.parser_) {
// Nothing is playing, so this is effectively a no-op.
return Promise.resolve();
}
// Destroy the streaming system before we recreate everything.
return this.destroyStreaming_().then(function() {
if (this.destroyed_) return;
// Force an exit from the buffering state.
this.onBuffering_(false);
// Start the (potentially slow) process of opening MediaSource now.
this.mediaSourceOpen_ = this.createMediaSource();
}.bind(this));
};
/**
* @const {string}
* @private
*/
shaka.Player.TextTrackLabel_ = 'Shaka Player TextTrack';
/**
* @return {!Object}
* @private
*/
shaka.Player.prototype.configOverrides_ = function() {
return {
'.drm.servers': '',
'.drm.clearKeys': '',
'.drm.advanced': {
distinctiveIdentifierRequired: false,
persistentStateRequired: false,
videoRobustness: '',
audioRobustness: '',
serverCertificate: null
}
};
};
/**
* @return {shakaExtern.PlayerConfiguration}
* @private
*/
shaka.Player.prototype.defaultConfig_ = function() {
return {
drm: {
retryParameters: shaka.net.NetworkingEngine.defaultRetryParameters(),
// These will all be verified by special cases in mergeConfigObjects_():
servers: {}, // key is arbitrary key system ID, value must be string
clearKeys: {}, // key is arbitrary key system ID, value must be string
advanced: {} // key is arbitrary key system ID, value is a record type
},
manifest: {
retryParameters: shaka.net.NetworkingEngine.defaultRetryParameters(),
dash: {
customScheme: new Function('node', ''),
clockSyncUri: ''
}
},
streaming: {
retryParameters: shaka.net.NetworkingEngine.defaultRetryParameters(),
rebufferingGoal: 2,
bufferingGoal: 30,
bufferBehind: 30
},
abr: {
manager: this.defaultAbrManager_,
enabled: true,
defaultBandwidthEstimate:
shaka.abr.EwmaBandwidthEstimator.DEFAULT_ESTIMATE
},
preferredAudioLanguage: '',
preferredTextLanguage: '',
restrictions: {
minWidth: 0,
maxWidth: Number.POSITIVE_INFINITY,
minHeight: 0,
maxHeight: Number.POSITIVE_INFINITY,
minPixels: 0,
maxPixels: Number.POSITIVE_INFINITY,
minAudioBandwidth: 0,
maxAudioBandwidth: Number.POSITIVE_INFINITY,
minVideoBandwidth: 0,
maxVideoBandwidth: Number.POSITIVE_INFINITY
}
};
};
/**
* @param {shakaExtern.Period} period
* @private
*/
shaka.Player.prototype.filterPeriod_ = function(period) {
goog.asserts.assert(this.video_, 'Must not be destroyed');
var activeStreams =
this.streamingEngine_ ? this.streamingEngine_.getActiveStreams() : {};
shaka.util.StreamUtils.filterPeriod(this.drmEngine_, activeStreams, period);
// Check for playable streams before restrictions to give a different error
// if we have restricted all the tracks rather than there being none.
var hasPlayableStreamSets =
period.streamSets.some(shaka.util.StreamUtils.hasPlayableStreams);
var tracksChanged = shaka.util.StreamUtils.applyRestrictions(
period, this.config_.restrictions, this.maxHwRes_);
if (tracksChanged && !this.loadChain_)
this.onTracksChanged_();
var allStreamsRestricted =
!period.streamSets.some(shaka.util.StreamUtils.hasPlayableStreams);
if (!hasPlayableStreamSets) {
this.onError_(new shaka.util.Error(
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.UNPLAYABLE_PERIOD));
} else if (allStreamsRestricted) {
this.onError_(new shaka.util.Error(
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.RESTRICTIONS_CANNOT_BE_MET));
}
};
/**
* Switches to the given streams, deferring switches if needed.
* @param {!Object.<string, shakaExtern.Stream>} streamsByType
* @param {boolean=} opt_clearBuffer
* @private
*/
shaka.Player.prototype.deferredSwitch_ = function(
streamsByType, opt_clearBuffer) {
for (var type in streamsByType) {
var stream = streamsByType[type];
var clear = opt_clearBuffer || type == 'text';
if (this.switchingPeriods_) {
this.deferredSwitches_[type] = {stream: stream, clear: clear};
} else {
var opt_leaveInBuffer = clear ? 0 : undefined;
this.streamingEngine_.switch(type, stream, opt_leaveInBuffer);
}
}
};
/** @private */
shaka.Player.prototype.updateStats_ = function() {
// Only count while we're loaded.
if (!this.manifest_)
return;
var now = Date.now() / 1000;
if (this.buffering_)
this.bufferingTime_ += (now - this.lastStatUpdateTimestamp_);
else
this.playTime_ += (now - this.lastStatUpdateTimestamp_);
this.lastStatUpdateTimestamp_ = now;
};
/**
* Callback from NetworkingEngine.
*
* @param {number} startTimeMs
* @param {number} endTimeMs
* @param {number} numBytes
* @private
*/
shaka.Player.prototype.onSegmentDownloaded_ = function(
startTimeMs, endTimeMs, numBytes) {
this.config_.abr.manager.segmentDownloaded(startTimeMs, endTimeMs, numBytes);
};
/**
* Callback from Playhead.
*
* @param {boolean} buffering
* @private
*/
shaka.Player.prototype.onBuffering_ = function(buffering) {
// Before setting |buffering_|, update the time spent in the previous state.
this.updateStats_();
this.buffering_ = buffering;
var event = new shaka.util.FakeEvent('buffering', { 'buffering': buffering });
this.dispatchEvent(event);
};
/**
* Callback from Playhead.
*
* @private
*/
shaka.Player.prototype.onSeek_ = function() {
if (this.streamingEngine_) {
this.streamingEngine_.seeked();
}
};
/**
* Chooses streams from the given Period.
*
* @param {!shakaExtern.Period} period
* @param {!Object.<string, shakaExtern.StreamSet>} streamSetsByType
* @param {boolean=} opt_chooseAll If true, choose streams from all stream sets.
* @return {!Object.<string, !shakaExtern.Stream>} A map of stream types to
* chosen streams.
* @private
*/
shaka.Player.prototype.chooseStreams_ =
function(period, streamSetsByType, opt_chooseAll) {
goog.asserts.assert(this.config_, 'Must not be destroyed');
var hasPlayableStreams = shaka.util.StreamUtils.hasPlayableStreams;
// If there are no unrestricted streams, issue an error.
var manifestIsPlayable =
shaka.util.MapUtils.values(streamSetsByType).some(hasPlayableStreams);
if (!manifestIsPlayable) {
this.onError_(new shaka.util.Error(
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.RESTRICTIONS_CANNOT_BE_MET));
return {};
}
// Which StreamSets need to have their chosen stream updated.
var needsUpdate = {};
if (opt_chooseAll) {
// Choose new streams for all types.
needsUpdate = streamSetsByType;
} else {
// Check if any of the active streams is no longer available
// or is using the wrong language.
var activeStreams = this.streamingEngine_.getActiveStreams();
for (var type in activeStreams) {
var stream = activeStreams[type];
if (!stream.allowedByApplication ||
!stream.allowedByKeySystem ||
streamSetsByType[type].language != stream.language) {
needsUpdate[type] = streamSetsByType[type];
}
}
}
if (!shaka.util.MapUtils.empty(needsUpdate)) {
shaka.log.debug('Choosing new streams for', Object.keys(needsUpdate));
var chosen = this.config_.abr.manager.chooseStreams(needsUpdate);
if (!shaka.util.MapUtils.every(needsUpdate,
function(type, set) { return !!chosen[type]; })) {
// Not every type was chosen! This should only happen when output
// restrictions prevent playback of a certain type and there are no
// usable streams for that type.
this.onError_(new shaka.util.Error(
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.RESTRICTIONS_CANNOT_BE_MET));
return {};
}
return chosen;
} else {
shaka.log.debug('No new streams need to be chosen.');
return {};
}
};
/**
* Chooses streams from the given Period and switches to them.
*
* @param {!shakaExtern.Period} period
* @private
*/
shaka.Player.prototype.chooseStreamsAndSwitch_ = function(period) {
goog.asserts.assert(this.config_, 'Must not be destroyed');
var languageMatches = { 'audio': false, 'text': false };
var streamSetsByType = shaka.util.StreamUtils.chooseStreamSets(
period, this.config_, languageMatches);
var chosen = this.chooseStreams_(period, streamSetsByType);
for (var type in chosen) {
var stream = chosen[type];
this.switchHistory_.push({
timestamp: Date.now() / 1000,
id: stream.id,
type: type,
fromAdaptation: true
});
}
// TODO: are these cases where we should avoid clearBuffer at this point?
this.deferredSwitch_(chosen, /* clearBuffer */ true);
// Send an adaptation event so that the UI can show the new language/tracks.
this.onAdaptation_();
if (streamSetsByType['text']) {
// If audio and text tracks have different languages, and the text track
// matches the user's preference, then show the captions.
if (streamSetsByType['audio'] &&
languageMatches['text'] &&
streamSetsByType['text'].language !=
streamSetsByType['audio'].language) {
this.textTrack_.mode = 'showing';
this.onTextTrackVisibility_();
}
}
};
/**
* Callback from StreamingEngine.
*
* @param {!shakaExtern.Period} period
* @return {!Object.<string, !shakaExtern.Stream>} A map of stream types to
* chosen streams.
* @private
*/
shaka.Player.prototype.onChooseStreams_ = function(period) {
shaka.log.debug('onChooseStreams_', period);
goog.asserts.assert(this.config_, 'Must not be destroyed');
// We are switching Periods, so the AbrManager will be disabled. But if we
// want to abr.enabled, we do not want to call AbrManager.enable before
// canSwitch_ is called.
this.switchingPeriods_ = true;
this.config_.abr.manager.disable();
shaka.log.debug('Choosing new streams after period changed');
var streamSetsByType = shaka.util.StreamUtils.chooseStreamSets(
period, this.config_);
shaka.log.v2('onChooseStreams_, streamSetsByType=', streamSetsByType);
var chosen = this.chooseStreams_(
period, streamSetsByType, /* opt_chooseAll */ true);
shaka.log.v2('onChooseStreams_, chosen=', chosen);
// Override the chosen streams with the ones picked in selectTrack.
// NOTE: The apparent race between selectTrack and period transition is
// handled by StreamingEngine, which will re-request tracks for the
// transition if any of these deferred selections are from the wrong period.
for (var type in this.deferredSwitches_) {
// We are choosing initial tracks, so no segments from this Period have
// been downloaded yet.
chosen[type] = this.deferredSwitches_[type].stream;
}
this.deferredSwitches_ = {};
for (var type in chosen) {
var stream = chosen[type];
this.switchHistory_.push({
timestamp: Date.now() / 1000,
id: stream.id,
type: type,
fromAdaptation: true
});
}
// If we are presently loading, we aren't done filtering streams just yet.
// Wait to send a 'trackschanged' event.
if (!this.loadChain_) {
this.onTracksChanged_();
}
return chosen;
};
/**
* Callback from StreamingEngine.
*
* @private
*/
shaka.Player.prototype.canSwitch_ = function() {
shaka.log.debug('canSwitch_');
this.switchingPeriods_ = false;
if (this.config_.abr.enabled)
this.config_.abr.manager.enable();
// If we still have deferred switches, switch now.
for (var type in this.deferredSwitches_) {
var info = this.deferredSwitches_[type];
var opt_leaveInBuffer = info.clear ? 0 : undefined;
this.streamingEngine_.switch(type, info.stream, opt_leaveInBuffer);
}
this.deferredSwitches_ = {};
};
/**
* Callback from AbrManager.
*
* @param {!Object.<string, !shakaExtern.Stream>} streamsByType
* @param {number=} opt_leaveInBuffer
* @private
*/
shaka.Player.prototype.switch_ = function(streamsByType, opt_leaveInBuffer) {
shaka.log.debug('switch_');
goog.asserts.assert(this.config_.abr.enabled,
'AbrManager should not call switch while disabled!');
goog.asserts.assert(!this.switchingPeriods_,
'AbrManager should not call switch while transitioning between Periods!');
// We have adapted to a new stream, record it in the history. Only add if
// we are actually switching the stream.
var oldActive = this.streamingEngine_.getActiveStreams();
for (var type in streamsByType) {
var stream = streamsByType[type];
if (oldActive[type] != stream) {
this.switchHistory_.push({
timestamp: Date.now() / 1000,
id: stream.id,
type: type,
fromAdaptation: true
});
} else {
// If it's the same, remove it from the map.
// This allows us to avoid onAdaptation_() when nothing has changed.
delete streamsByType[type];
}
}
if (shaka.util.MapUtils.empty(streamsByType)) {
// There's nothing to change.
return;
}
if (!this.streamingEngine_) {
// There's no way to change it.
return;
}
for (var type in streamsByType) {
this.streamingEngine_.switch(type, streamsByType[type],
// Only apply opt_leaveInBuffer to video streams.
type == 'video' ? opt_leaveInBuffer : undefined);
}
this.onAdaptation_();
};
/**
* Dispatches a 'adaptation' event.
* @private
*/
shaka.Player.prototype.onAdaptation_ = function() {
// In the next frame, dispatch a 'adaptation' event.
// This gives StreamingEngine time to absorb the changes before the user
// tries to query them.
Promise.resolve().then(function() {
if (this.destroyed_) return;
var event = new shaka.util.FakeEvent('adaptation');
this.dispatchEvent(event);
}.bind(this));
};
/**
* Dispatches a 'trackschanged' event.
* @private
*/
shaka.Player.prototype.onTracksChanged_ = function() {
// In the next frame, dispatch a 'trackschanged' event.
// This gives StreamingEngine time to absorb the changes before the user
// tries to query them.
Promise.resolve().then(function() {
if (this.destroyed_) return;
var event = new shaka.util.FakeEvent('trackschanged');
this.dispatchEvent(event);
}.bind(this));
};
/** @private */
shaka.Player.prototype.onTextTrackVisibility_ = function() {
var event = new shaka.util.FakeEvent('texttrackvisibility');
this.dispatchEvent(event);
};
/**
* @param {!shaka.util.Error} error
* @private
*/
shaka.Player.prototype.onError_ = function(error) {
goog.asserts.assert(error instanceof shaka.util.Error, 'Wrong error type!');
var event = new shaka.util.FakeEvent('error', { 'detail': error });
this.dispatchEvent(event);
};
/**
* @param {!Event} event
* @private
*/
shaka.Player.prototype.onVideoError_ = function(event) {
if (!this.video_.error) return;
var code = this.video_.error.code;
if (code == 1 /* MEDIA_ERR_ABORTED */) {
// Ignore this error code, which should only occur when navigating away or
// deliberately stopping playback of HTTP content.
return;
}
// Extra error information from MS Edge and IE11:
var extended = this.video_.error.msExtendedCode;
if (extended) {
// Convert to unsigned:
if (extended < 0) {
extended += Math.pow(2, 32);
}
// Format as hex:
extended = extended.toString(16);
}
this.onError_(new shaka.util.Error(
shaka.util.Error.Category.MEDIA,
shaka.util.Error.Code.VIDEO_ERROR,
code, extended));
};
/**
* @param {!Object.<string, string>} keyStatusMap A map of hex key IDs to
* statuses.
* @private
*/
shaka.Player.prototype.onKeyStatus_ = function(keyStatusMap) {
goog.asserts.assert(this.streamingEngine_, 'Should have been initialized.');
// NOTE: 'expired' is in the list of allowed statuses because expiration is
// treated as a different condition than tracks being restricted. It is not
// expected that these keys are usable.
var allowedStatuses =
['usable', 'status-pending', 'output-downscaled', 'expired'];
var period = this.streamingEngine_.getCurrentPeriod();
var tracksChanged = false;
period.streamSets.forEach(function(streamSet) {
streamSet.streams.forEach(function(stream) {
var originalAllowed = stream.allowedByKeySystem;
// Only update the status if it is in the map.
if (stream.keyId && stream.keyId in keyStatusMap) {
var keyStatus = keyStatusMap[stream.keyId];
stream.allowedByKeySystem = allowedStatuses.indexOf(keyStatus) >= 0;
}
if (originalAllowed != stream.allowedByKeySystem) {
tracksChanged = true;
}
});
});
shaka.log.debug('Choosing new streams after key status changed');
this.chooseStreamsAndSwitch_(period);
if (tracksChanged)
this.onTracksChanged_();
};