Files
shaka-player/lib/player.js
T
Michelle Zhuo 40f9113bb2 Abort requests when network downgrading
When the network becomes slow, we check if stopping the current request
and download the content with lower resolution is faster. If so, abort
the current request and start a new one.

Issue #1051

Change-Id: I588e524469432e362361d1cfbde6cd45c2009959
2019-05-01 20:18:16 +00:00

5407 lines
173 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.Deprecate');
goog.require('shaka.log');
goog.require('shaka.media.ActiveStreamMap');
goog.require('shaka.media.AdaptationSetCriteria');
goog.require('shaka.media.BufferingObserver');
goog.require('shaka.media.DrmEngine');
goog.require('shaka.media.ManifestParser');
goog.require('shaka.media.MediaSourceEngine');
goog.require('shaka.media.MuxJSClosedCaptionParser');
goog.require('shaka.media.NoopCaptionParser');
goog.require('shaka.media.PeriodObserver');
goog.require('shaka.media.PlayRateController');
goog.require('shaka.media.Playhead');
goog.require('shaka.media.PlayheadObserverManager');
goog.require('shaka.media.PreferenceBasedCriteria');
goog.require('shaka.media.RegionObserver');
goog.require('shaka.media.RegionTimeline');
goog.require('shaka.media.SegmentReference');
goog.require('shaka.media.StreamingEngine');
goog.require('shaka.net.NetworkingEngine');
goog.require('shaka.routing.Walker');
goog.require('shaka.text.SimpleTextDisplayer');
goog.require('shaka.util.ArrayUtils');
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.ManifestParserUtils');
goog.require('shaka.util.MimeUtils');
goog.require('shaka.util.MultiMap');
goog.require('shaka.util.Periods');
goog.require('shaka.util.Platform');
goog.require('shaka.util.PlayerConfiguration');
goog.require('shaka.util.Stats');
goog.require('shaka.util.StreamUtils');
/**
* Construct a Player.
*
* @param {HTMLMediaElement=} mediaElement
* When provided, the player will attach to |mediaElement|, similar to
* calling |attach|. When not provided, the player will remain detached.
* @param {function(shaka.Player)=} 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(mediaElement, dependencyInjector) {
shaka.util.FakeEventTarget.call(this);
/** @private {shaka.Player.LoadMode} */
this.loadMode_ = shaka.Player.LoadMode.NOT_LOADED;
/** @private {HTMLMediaElement} */
this.video_ = null;
/**
* Since we may not always have a text displayer created (e.g. before |load|
* is called), we need to track what text visibility SHOULD be so that we can
* ensure that when we create the text displayer. When we create our text
* displayer, we will use this to show (or not show) text as per the user's
* requests.
*
* @private {boolean}
*/
this.isTextVisible_ = false;
/** @private {shaka.util.EventManager} */
this.eventManager_ = new shaka.util.EventManager();
/** @private {shaka.net.NetworkingEngine} */
this.networkingEngine_ = null;
/** @private {shaka.media.DrmEngine} */
this.drmEngine_ = null;
/** @private {shaka.media.MediaSourceEngine} */
this.mediaSourceEngine_ = null;
/** @private {shaka.media.Playhead} */
this.playhead_ = null;
/**
* The playhead observers are used to monitor the position of the playhead and
* some other source of data (e.g. buffered content), and raise events.
*
* @private {shaka.media.PlayheadObserverManager}
*/
this.playheadObservers_ = null;
/**
* This is our control over the playback rate of the media element. This
* provides the missing functionality that we need to provide trick play, for
* example a negative playback rate.
*
* @private {shaka.media.PlayRateController}
*/
this.playRateController_ = null;
// We use the buffering observer and timer to track when we move from having
// enough buffered content to not enough. They only exist when content has
// been loaded and are not re-used between loads.
/** @private {shaka.util.Timer} */
this.bufferPoller_ = null;
/** @private {shaka.media.BufferingObserver} */
this.bufferObserver_ = null;
/** @private {shaka.media.RegionTimeline} */
this.regionTimeline_ = null;
/** @private {shaka.media.StreamingEngine} */
this.streamingEngine_ = null;
/** @private {shaka.extern.ManifestParser} */
this.parser_ = null;
/** @private {?shaka.extern.Manifest} */
this.manifest_ = null;
/** @private {?string} */
this.assetUri_ = null;
/** @private {shaka.extern.AbrManager} */
this.abrManager_ = null;
/**
* The factory that was used to create the abrManager_ instance.
* @private {?shaka.extern.AbrManager.Factory}
*/
this.abrManagerFactory_ = 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 {!Set.<shaka.extern.Stream>} */
this.loadingTextStreams_ = new Set();
/** @private {boolean} */
this.switchingPeriods_ = true;
/** @private {?shaka.extern.Variant} */
this.deferredVariant_ = null;
/** @private {boolean} */
this.deferredVariantClearBuffer_ = false;
/** @private {number} */
this.deferredVariantClearBufferSafeMargin_ = 0;
/** @private {?shaka.extern.Stream} */
this.deferredTextStream_ = null;
/**
* A mapping of which streams are/were active in each period. Used when the
* current period (the one containing playhead) differs from the active
* period (the one being streamed in by streaming engine).
*
* @private {!shaka.media.ActiveStreamMap}
*/
this.activeStreams_ = new shaka.media.ActiveStreamMap();
/** @private {?shaka.extern.PlayerConfiguration} */
this.config_ = this.defaultConfig_();
/** @private {{width: number, height: number}} */
this.maxHwRes_ = {width: Infinity, height: Infinity};
/** @private {shaka.util.Stats} */
this.stats_ = null;
/** @private {!shaka.media.AdaptationSetCriteria} */
this.currentAdaptationSetCriteria_ = new shaka.media.PreferenceBasedCriteria(
this.config_.preferredAudioLanguage,
this.config_.preferredVariantRole,
this.config_.preferredAudioChannelCount);
/** @private {string} */
this.currentTextLanguage_ = this.config_.preferredTextLanguage;
/** @private {string} */
this.currentTextRole_ = this.config_.preferredTextRole;
if (dependencyInjector) {
dependencyInjector(this);
}
this.networkingEngine_ = this.createNetworkingEngine();
// If the browser comes back online after being offline, then try to play
// again.
this.eventManager_.listen(window, 'online', () => {
this.retryStreaming();
});
/** @private {shaka.routing.Node} */
this.detachNode_ = {name: 'detach'};
/** @private {shaka.routing.Node} */
this.attachNode_ = {name: 'attach'};
/** @private {shaka.routing.Node} */
this.unloadNode_ = {name: 'unload'};
/** @private {shaka.routing.Node} */
this.parserNode_ = {name: 'manifest-parser'};
/** @private {shaka.routing.Node} */
this.manifestNode_ = {name: 'manifest'};
/** @private {shaka.routing.Node} */
this.mediaSourceNode_ = {name: 'media-source'};
/** @private {shaka.routing.Node} */
this.drmNode_ = {name: 'drm-engine'};
/** @private {shaka.routing.Node} */
this.loadNode_ = {name: 'load'};
/** @private {shaka.routing.Node} */
this.srcEqualsDrmNode_ = {name: 'src-equals-drm-engine'};
/** @private {shaka.routing.Node} */
this.srcEqualsNode_ = {name: 'src-equals'};
const AbortableOperation = shaka.util.AbortableOperation;
const actions = new Map();
actions.set(this.attachNode_, (has, wants) => {
return AbortableOperation.notAbortable(this.onAttach_(has, wants));
});
actions.set(this.detachNode_, (has, wants) => {
return AbortableOperation.notAbortable(this.onDetach_(has, wants));
});
actions.set(this.unloadNode_, (has, wants) => {
return AbortableOperation.notAbortable(this.onUnload_(has, wants));
});
actions.set(this.mediaSourceNode_, (has, wants) => {
const p = this.onInitializeMediaSourceEngine_(has, wants);
return AbortableOperation.notAbortable(p);
});
actions.set(this.parserNode_, (has, wants) => {
const p = this.onInitializeParser_(has, wants);
return AbortableOperation.notAbortable(p);
});
actions.set(this.manifestNode_, (has, wants) => {
// This action is actually abortable, so unlike the other callbacks, this
// one will return an abortable operation.
return this.onParseManifest_(has, wants);
});
actions.set(this.drmNode_, (has, wants) => {
const p = this.onInitializeDrm_(has, wants);
return AbortableOperation.notAbortable(p);
});
actions.set(this.loadNode_, (has, wants) => {
return AbortableOperation.notAbortable(this.onLoad_(has, wants));
});
actions.set(this.srcEqualsDrmNode_, (has, wants) => {
const p = this.onInitializeSrcEqualsDrm_(has, wants);
return AbortableOperation.notAbortable(p);
});
actions.set(this.srcEqualsNode_, (has, wants) => {
return this.onSrcEquals_(has, wants);
});
/** @private {shaka.routing.Walker.Implementation} */
const walkerImplementation = {
getNext: (at, has, goingTo, wants) => {
return this.getNextStep_(at, has, goingTo, wants);
},
enterNode: (node, has, wants) => {
this.dispatchEvent(new shaka.util.FakeEvent(
/* name= */ 'onstatechange',
/* data= */ {'state': node.name}));
const action = actions.get(node);
return action(has, wants);
},
handleError: async (has, error) => {
shaka.log.warning('The walker saw an error:');
if (error instanceof shaka.util.Error) {
shaka.log.warning('Error Code:', error.code);
} else {
shaka.log.warning('Error Message:', error.message);
shaka.log.warning('Error Stack:', error.stack);
}
// Regardless of what state we were in, if there is an error, we unload.
// This ensures that any initialized system will be torn-down and we will
// go back to a safe foundation. We assume that the media element is
// always safe to use after an error.
await this.onUnload_(has, this.createEmptyPayload_());
// There are only two nodes that come before we start loading content,
// attach and detach. If we have a media element, it means we were
// attached to the element, and we can safely return to the attach state
// (we assume that the video element is always re-usable). We favor
// returning to the attach node since it means that the app won't need to
// re-attach if it saw an error.
return has.mediaElement ? this.attachNode_ : this.detachNode_;
},
onIdle: (node) => {
this.dispatchEvent(new shaka.util.FakeEvent(
/* name= */ 'onstateidle',
/* data= */ {'state': node.name}));
},
};
/** @private {shaka.routing.Walker} */
this.walker_ = new shaka.routing.Walker(
this.detachNode_,
this.createEmptyPayload_(),
walkerImplementation);
// Even though |attach| will start in later interpreter cycles, it should be
// the LAST thing we do in the constructor because conceptually it relies on
// player having been initialized.
if (mediaElement) {
this.attach(mediaElement, /* initializeMediaSource= */ true);
}
};
goog.inherits(shaka.Player, shaka.util.FakeEventTarget);
/**
* After destruction, a Player object cannot be used again.
*
* @override
* @export
*/
shaka.Player.prototype.destroy = async function() {
// Make sure we only execute the destroy logic once.
if (this.loadMode_ == shaka.Player.LoadMode.DESTROYED) {
return;
}
// Mark as "dead". This should stop external-facing calls from changing our
// internal state any more. This will stop calls to |attach|, |detach|, etc.
// from interrupting our final move to the detached state.
this.loadMode_ = shaka.Player.LoadMode.DESTROYED;
// Because we have set |loadMode_| to |DESTROYED| we can't call |detach|. We
// must talk to |this.walker_| directly.
const events = this.walker_.startNewRoute((currentPayload) => {
return {
node: this.detachNode_,
payload: this.createEmptyPayload_(),
interruptible: false,
};
});
// Wait until the detach has finished so that we don't interrupt it by
// calling |destroy| on |this.walker_|. To avoid failing here, we always
// resolve the promise.
await new Promise((resolve) => {
events.onStart = () => {
shaka.log.info('Preparing to destroy walker...');
};
events.onEnd = () => {
resolve();
};
events.onCancel = () => {
goog.asserts.assert(false,
'Our final detach call should never be cancelled.');
resolve();
};
events.onError = () => {
goog.asserts.assert(false,
'Our final detach call should never see an error');
resolve();
};
events.onSkip = () => {
goog.asserts.assert(false,
'Our final detach call should never be skipped');
resolve();
};
});
await this.walker_.destroy();
// Tear-down the event manager to ensure messages stop moving around.
if (this.eventManager_) {
this.eventManager_.release();
this.eventManager_ = null;
}
this.abrManagerFactory_ = null;
this.abrManager_ = null;
this.config_ = null;
if (this.networkingEngine_) {
await this.networkingEngine_.destroy();
this.networkingEngine_ = null;
}
};
/**
* @define {string} A version number taken from git at compile time.
* @export
*/
shaka.Player.version = 'v2.5.0-beta3-uncompiled';
// Initialize the deprecation system using the version string we just set
// on the player.
shaka.Deprecate.init(shaka.Player.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 occurred. In an
* uncompiled build, you can also use the 'message' and 'stack' properties
* to debug.
* @exportDoc
*/
/**
* @event shaka.Player.StateChangeEvent
* @description Fired when the player changes load states.
* @property {string} type
* 'onstatechange'
* @property {string} state
* The name of the state that the player just entered.
* @exportDoc
*/
/**
* @event shaka.Player.StateIdleEvent
* @description Fired when the player has stopped changing states and will
* remain idle until a new state change request (e.g. load, attach, etc.) is
* made.
* @property {string} type
* 'onstateidle'
* @property {string} state
* The name of the state that the player stopped in.
* @exportDoc
*/
/**
* @event shaka.Player.EmsgEvent
* @description Fired when a non-typical emsg is found in a segment.
* @property {string} type
* 'emsg'
* @property {shaka.extern.EmsgInfo} detail
* An object which contains the content of the emsg box.
* @exportDoc
*/
/**
* @event shaka.Player.DrmSessionUpdateEvent
* @description Fired when the CDM has accepted the license response.
* @property {string} type
* 'drmsessionupdate'
* @exportDoc
*/
/**
* @event shaka.Player.TimelineRegionAddedEvent
* @description Fired when a media timeline region is added.
* @property {string} type
* 'timelineregionadded'
* @property {shaka.extern.TimelineRegionInfo} detail
* An object which contains a description of the region.
* @exportDoc
*/
/**
* @event shaka.Player.TimelineRegionEnterEvent
* @description Fired when the playhead enters a timeline region.
* @property {string} type
* 'timelineregionenter'
* @property {shaka.extern.TimelineRegionInfo} detail
* An object which contains a description of the region.
* @exportDoc
*/
/**
* @event shaka.Player.TimelineRegionExitEvent
* @description Fired when the playhead exits a timeline region.
* @property {string} type
* 'timelineregionexit'
* @property {shaka.extern.TimelineRegionInfo} detail
* An object which contains a description of the region.
* @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. The start of loading is
* defined as when the user has communicated intent to load content (i.e.
* Player.load has been called).
* @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 selectVariantTrack()
* selectTextTrack(), selectAudioLanguage() or selectTextLanguage().
* @property {string} type
* 'adaptation'
* @exportDoc
*/
/**
* @event shaka.Player.VariantChangedEvent
* @description Fired when a call from the application caused a variant change.
* Can be triggered by calls to selectVariantTrack() or selectAudioLanguage().
* Does not fire when an automatic adaptation causes a variant change.
* @property {string} type
* 'variantchanged'
* @exportDoc
*/
/**
* @event shaka.Player.TextChangedEvent
* @description Fired when a call from the application caused a text stream
* change. Can be triggered by calls to selectTextTrack() or
* selectTextLanguage().
* @property {string} type
* 'textchanged'
* @exportDoc
*/
/**
* @event shaka.Player.ExpirationUpdatedEvent
* @description Fired when there is a change in the expiration times of an
* EME session.
* @property {string} type
* 'expirationupdated'
* @exportDoc
*/
/**
* @event shaka.Player.LargeGapEvent
* @description Fired when the playhead enters a large gap. If
* |config.streaming.jumpLargeGaps| is set, the default action of this event
* is to jump the gap; this can be prevented by calling preventDefault() on
* the event object.
* @property {string} type
* 'largegap'
* @property {number} currentTime
* The current time of the playhead.
* @property {number} gapSize
* The size of the gap, in seconds.
* @exportDoc
*/
/**
* @event shaka.Player.ManifestParsedEvent
* @description Fired after the manifest has been parsed, but before anything
* else happens. The manifest may contain streams that will be filtered out,
* at this stage of the loading process.
* @property {string} type
* 'manifestparsed'
* @exportDoc
*/
/**
* @event shaka.Player.StreamingEvent
* @description Fired after the manifest has been parsed and track information
* is available, but before streams have been chosen and before any segments
* have been fetched. You may use this event to configure the player based on
* information found in the manifest.
* @property {string} type
* 'streaming'
* @exportDoc
*/
/**
* @event shaka.Player.AbrStatusChangedEvent
* @description Fired when the state of abr has been changed.
* (Enabled or disabled).
* @property {string} type
* 'abrstatuschanged'
* @property {boolean} newStatus
* The new status of the application. True for 'is enabled' and
* false otherwise.
* @exportDoc
*/
/**
* These are the EME key statuses that represent restricted playback.
* 'usable', 'released', 'output-downscaled', 'status-pending' are statuses
* of the usable keys. 'expired' status is being handled separately in
* DrmEngine.
*
* @const {!Array.<string>}
* @private
*/
shaka.Player.restrictedStatuses_ = ['output-restricted', 'internal-error'];
/** @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.
const basicSupport = !!window.Promise && !!window.Uint8Array &&
!!Array.prototype.forEach;
if (!basicSupport) return false;
// DRM support is not strictly necessary, but the APIs at least need to be
// there. Our no-op DRM polyfill should handle that.
// TODO(#1017): Consider making even DrmEngine optional.
const drmSupport = shaka.media.DrmEngine.isBrowserSupported();
if (!drmSupport) return false;
// If we have MediaSource (MSE) support, we should be able to use Shaka.
if (shaka.util.Platform.supportsMediaSource()) return true;
// If we don't have MSE, we _may_ be able to use Shaka. Look for native HLS
// support, and call this platform usable if we have it.
return shaka.util.Platform.supportsMediaType('application/x-mpegurl');
};
/**
* 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://bit.ly/2ywccmH
* @return {!Promise.<shaka.extern.SupportType>}
* @export
*/
shaka.Player.probeSupport = function() {
goog.asserts.assert(shaka.Player.isBrowserSupported(),
'Must have basic support');
return shaka.media.DrmEngine.probeSupport().then(function(drm) {
let manifest = shaka.media.ManifestParser.probeSupport();
let media = shaka.media.MediaSourceEngine.probeSupport();
let ret = {
manifest: manifest,
media: media,
drm: drm,
};
let plugins = shaka.Player.supportPlugins_;
for (let name in plugins) {
ret[name] = plugins[name]();
}
return ret;
});
};
/**
* Tell the player to use |mediaElement| for all |load| requests until |detach|
* or |destroy| are called.
*
* Calling |attach| with |initializedMediaSource=true| will tell the player to
* take the initial load step and initialize media source.
*
* Calls to |attach| will interrupt any in-progress calls to |load| but cannot
* interrupt calls to |attach|, |detach|, or |unload|.
*
* @param {!HTMLMediaElement} mediaElement
* @param {boolean=} initializeMediaSource
* @return {!Promise}
* @export
*/
shaka.Player.prototype.attach = function(mediaElement,
initializeMediaSource = true) {
// Do not allow the player to be used after |destroy| is called.
if (this.loadMode_ == shaka.Player.LoadMode.DESTROYED) {
return Promise.reject(this.createAbortLoadError_());
}
const payload = this.createEmptyPayload_();
payload.mediaElement = mediaElement;
// If the platform does not support media source, we will never want to
// initialize media source.
if (!shaka.util.Platform.supportsMediaSource()) {
initializeMediaSource = false;
}
const destination = initializeMediaSource ?
this.mediaSourceNode_ :
this.attachNode_;
// Do not allow this route to be interrupted because calls after this attach
// call will depend on the media element being attached.
const events = this.walker_.startNewRoute((currentPayload) => {
return {
node: destination,
payload: payload,
interruptible: false,
};
});
// List to the events that can occur with our request.
events.onStart = () => shaka.log.info('Starting attach...');
return this.wrapWalkerListenersWithPromise_(events);
};
/**
* Tell the player to stop using its current media element. If the player is:
* - detached, this will do nothing,
* - attached, this will release the media element,
* - loading, this will abort loading, unload, and release the media element,
* - playing content, this will stop playback, unload, and release the media
* element.
*
* Calls to |detach| will interrupt any in-progress calls to |load| but cannot
* interrupt calls to |attach|, |detach|, or |unload|.
*
* @return {!Promise}
* @export
*/
shaka.Player.prototype.detach = function() {
// Do not allow the player to be used after |destroy| is called.
if (this.loadMode_ == shaka.Player.LoadMode.DESTROYED) {
return Promise.reject(this.createAbortLoadError_());
}
// Tell the walker to go "detached", but do not allow it to be interrupted. If
// it could be interrupted it means that our media element could fall out
// of sync.
const events = this.walker_.startNewRoute((currentPayload) => {
return {
node: this.detachNode_,
payload: this.createEmptyPayload_(),
interruptible: false,
};
});
events.onStart = () => shaka.log.info('Starting detach...');
return this.wrapWalkerListenersWithPromise_(events);
};
/**
* Tell the player to either return to:
* - detached (when it does not have a media element),
* - attached (when it has a media element and |initializedMediaSource=false|)
* - media source initialized (when it has a media element and
* |initializedMediaSource=true|)
*
* Calls to |unload| will interrupt any in-progress calls to |load| but cannot
* interrupt calls to |attach|, |detach|, or |unload|.
*
* @param {boolean=} initializeMediaSource
* @return {!Promise}
* @export
*/
shaka.Player.prototype.unload = function(initializeMediaSource = true) {
// Do not allow the player to be used after |destroy| is called.
if (this.loadMode_ == shaka.Player.LoadMode.DESTROYED) {
return Promise.reject(this.createAbortLoadError_());
}
// If the platform does not support media source, we will never want to
// initialize media source.
if (!shaka.util.Platform.supportsMediaSource()) {
initializeMediaSource = false;
}
// Since we are going either to attached or detached (through unloaded), we
// can't allow it to be interrupted or else we could lose track of what
// media element we are suppose to use.
//
// Using the current payload, we can determine which node we want to go to.
// If we have a media element, we want to go back to attached. If we have no
// media element, we want to go back to detached.
const payload = this.createEmptyPayload_();
const events = this.walker_.startNewRoute((currentPayload) => {
// When someone calls |unload| we can either be before attached or detached
// (there is nothing stopping someone from calling |detach| when we are
// already detached).
//
// If we are attached to the correct element, we can tear down the previous
// playback components and go to the attached media source node depending
// on whether or not the caller wants to pre-init media source.
//
// If we don't have a media element, we assume that we are already at the
// detached node - but only the walker knows that. To ensure we are actually
// there, we tell the walker to go to detach. While this is technically
// unnecessary, it ensures that we are in the state we want to be in and
// ready for the next request.
let destination = null;
if (currentPayload.mediaElement && initializeMediaSource) {
destination = this.mediaSourceNode_;
} else if (currentPayload.mediaElement) {
destination = this.attachNode_;
} else {
destination = this.detachNode_;
}
goog.asserts.assert(destination, 'We should have picked a destination.');
// Copy over the media element because we want to keep using the same
// element - the other values don't matter.
payload.mediaElement = currentPayload.mediaElement;
return {
node: destination,
payload: payload,
interruptible: false,
};
});
events.onStart = () => shaka.log.info('Starting unload...');
return this.wrapWalkerListenersWithPromise_(events);
};
/**
* Tell the player to load the content at |assetUri| and start playback at
* |startTime|. Before calling |load|, a call to |attach| must have succeeded.
*
* Calls to |load| will interrupt any in-progress calls to |load| but cannot
* interrupt calls to |attach|, |detach|, or |unload|.
*
* @param {string} assetUri
* @param {?number=} startTime
* When |startTime| is |null| or |undefined|, playback will start at the
* default start time (startTime=0 for VOD and startTime=liveEdge for LIVE).
* @param {string|shaka.extern.ManifestParser.Factory=} mimeType
* @return {!Promise}
* @export
*/
shaka.Player.prototype.load = function(assetUri, startTime, mimeType) {
// Do not allow the player to be used after |destroy| is called.
if (this.loadMode_ == shaka.Player.LoadMode.DESTROYED) {
return Promise.reject(this.createAbortLoadError_());
}
// We dispatch the loading event when someone calls |load| because we want to
// surface the user intent.
this.dispatchEvent(new shaka.util.FakeEvent('loading'));
// Right away we know what the asset uri and start-of-load time are. We will
// fill-in the rest of the information later.
const payload = this.createEmptyPayload_();
payload.uri = assetUri;
payload.startTimeOfLoad = Date.now() / 1000;
if (mimeType && typeof mimeType != 'string') {
shaka.Deprecate.deprecateFeature(
2, 6,
'Loading with a manifest parser factory',
'Please register a manifest parser and for the mime-type.');
const Factory =
/** @type {shaka.extern.ManifestParser.Factory} */ (mimeType);
payload.factory = () => new Factory();
}
if (mimeType && typeof mimeType == 'string') {
payload.mimeType = /** @type {string} */ (mimeType);
}
// Because we allow |startTime| to be optional, it means that it will be
// |undefined| when not provided. This means that we need to re-map
// |undefined| to |null| while preserving |0| as a meaningful value.
if (startTime !== undefined) {
payload.startTime = startTime;
}
// TODO: Refactor to determine whether it's a manifest or not, and whether or
// not we can play it. Then we could return a better error than
// UNABLE_TO_GUESS_MANIFEST_TYPE for WebM in Safari.
const useSrcEquals = this.shouldUseSrcEquals_(payload);
const destination = useSrcEquals ? this.srcEqualsNode_ : this.loadNode_;
// Allow this request to be interrupted, this will allow other requests to
// cancel a load and quickly start a new load.
const events = this.walker_.startNewRoute((currentPayload) => {
if (currentPayload.mediaElement == null) {
// Because we return null, this "new route" will not be used.
return null;
}
// Keep using whatever media element we have right now.
payload.mediaElement = currentPayload.mediaElement;
return {
node: destination,
payload: payload,
interruptible: true,
};
});
// Load's request is a little different, so we can't use our normal
// listeners-to-promise method. It is the only request where we may skip the
// request, so we need to set the on skip callback to reject with a specific
// error.
events.onStart = () => shaka.log.info('Starting load of ' + assetUri + '...');
return new Promise((resolve, reject) => {
events.onSkip = () => reject(new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.PLAYER,
shaka.util.Error.Code.NO_VIDEO_ELEMENT));
events.onEnd = () => resolve();
events.onCancel = () => reject(this.createAbortLoadError_());
events.onError = (e) => reject(e);
});
};
/**
* Check if src= should be used to load the asset at |uri|. Assume that media
* source is the default option, and that src= is for special cases.
*
* @param {shaka.routing.Payload} payload
* @return {boolean}
* |true| if the content should be loaded with src=, |false| if the content
* should be loaded with MediaSource.
* @private
*/
shaka.Player.prototype.shouldUseSrcEquals_ = function(payload) {
const Platform = shaka.util.Platform;
// If an explicit ManifestParser factory has been given, we can't do src=.
if (payload.factory) {
return false;
}
// If we are using a platform that does not support media source, we will
// fall back to src= to handle all playback.
if (!Platform.supportsMediaSource()) {
return true;
}
// The most accurate way to tell the player how to load the content is via
// MIME type. We can fall back to features of the URI if needed.
let mimeType = payload.mimeType;
const uri = payload.uri || '';
// If we don't have a MIME type, try to guess based on the file extension.
// TODO: Too generic to belong to ManifestParser now. Refactor.
if (!mimeType) {
// Try using the uri extension.
const extension = shaka.media.ManifestParser.getExtension(uri);
mimeType = {
'mp4': 'video/mp4',
'm4v': 'video/mp4',
'm4a': 'audio/mp4',
'webm': 'video/webm',
'ts': 'video/mp2t',
'm3u8': 'application/x-mpegurl',
'mp3': 'audio/mpeg',
'aac': 'audio/aac',
'flac': 'audio/flac',
}[extension];
}
// TODO: The load graph system has a design limitation that requires routing
// destination to be chosen synchronously. This means we can only make the
// right choice about src= consistently if we have a well-known file extension
// or API-provided MIME type. Detection of MIME type from a HEAD request (as
// is done for manifest types) can't be done yet.
if (mimeType) {
// If we have a MIME type, check if the browser can play it natively.
// This will cover both single files and native HLS.
const canPlayNatively = Platform.supportsMediaType(mimeType);
// If we can't play natively, then src= isn't an option.
if (!canPlayNatively) {
return false;
}
const canPlayMediaSource =
shaka.media.ManifestParser.isSupported(uri, mimeType);
// If MediaSource isn't an option, the native option is our only chance.
if (!canPlayMediaSource) {
return true;
}
// If we land here, both are feasible.
goog.asserts.assert(canPlayNatively && canPlayMediaSource,
'Both native and MSE playback should be possible!');
// We would prefer MediaSource in some cases, and src= in others. For
// example, Android has native HLS, but we'd prefer our own MediaSource
// version there. But for Safari desktop, we'd prefer the native one for
// now, because that's the only way we get FairPlay there. So use src= over
// MSE on any Apple platform.
return Platform.isApple();
}
// Unless there are good reasons to use src= (single-file playback or native
// HLS), we prefer MediaSource. So the final return value for choosing src=
// is false.
return false;
};
/**
* This should only be called by the load graph when it is time to attach to
* a media element. The only times this may be called are when we are being
* asked to re-attach to the current media element, or attach to a new media
* element while not attached to a media element.
*
* This method assumes that it is safe for it to execute, the load-graph is
* responsible for ensuring all assumptions are true.
*
* Attaching to a media element is defined as:
* - Registering error listeners to the media element.
* - Caching the video element for use outside of the load graph.
*
* @param {shaka.routing.Payload} has
* @param {shaka.routing.Payload} wants
* @return {!Promise}
* @private
*/
shaka.Player.prototype.onAttach_ = function(has, wants) {
// If we don't have a media element yet, it means we are entering
// "attach" from another node.
//
// If we have a media element, it should match |wants.mediaElement|
// because it means we are going from "attach" to "attach".
//
// These constraints should be maintained and guaranteed by the routing
// logic in |getNextStep_|.
goog.asserts.assert(
has.mediaElement == null || has.mediaElement == wants.mediaElement,
'The routing logic failed. MediaElement requirement failed.');
if (has.mediaElement == null) {
has.mediaElement = wants.mediaElement;
const onError = (error) => this.onVideoError_(error);
this.eventManager_.listen(has.mediaElement, 'error', onError);
}
this.video_ = has.mediaElement;
return Promise.resolve();
};
/**
* This should only be called by the load graph when it is time to detach from
* a media element. The only times this may be called are when we are being
* asked to detach from the current media element, or detach when we are already
* detached.
*
* This method assumes that it is safe for it to execute, the load-graph is
* responsible for ensuring all assumptions are true.
*
* Detaching from a media element is defined as:
* - Removing error listeners from the media element.
* - Dropping the cached reference to the video element.
*
* @param {shaka.routing.Payload} has
* @param {shaka.routing.Payload} wants
* @return {!Promise}
* @private
*/
shaka.Player.prototype.onDetach_ = function(has, wants) {
// If we are going from "detached" to "detached" we wouldn't have
// a media element to detach from.
if (has.mediaElement) {
this.eventManager_.unlisten(has.mediaElement, 'error');
has.mediaElement = null;
}
// Clear our cached copy of the media element.
this.video_ = null;
return Promise.resolve();
};
/**
* This should only be called by the load graph when it is time to unload all
* currently initialized playback components. Unlike the other load actions,
* this action is built to be more general. We need to do this because we don't
* know what state the player will be in before unloading (including after an
* error occurred in the middle of a transition).
*
* This method assumes that any component could be |null| and should be safe to
* call from any point in the load graph.
*
* @param {shaka.routing.Payload} has
* @param {shaka.routing.Payload} wants
* @return {!Promise}
* @private
*/
shaka.Player.prototype.onUnload_ = async function(has, wants) {
// Set the load mode to unload right away so that all the public methods will
// stop using the internal components. We need to make sure that we are not
// overriding the destroyed state because we will unload when we are
// destroying the player.
if (this.loadMode_ != shaka.Player.LoadMode.DESTROYED) {
this.loadMode_ = shaka.Player.LoadMode.NOT_LOADED;
}
this.dispatchEvent(new shaka.util.FakeEvent('unloading'));
// Remove everything that has to do with loading content from our payload
// since we are releasing everything that depended on it.
has.currentTime = null;
has.factory = null;
has.mimeType = null;
has.startTime = null;
has.uri = null;
// In most cases we should have a media element. The one exception would
// be if there was an error and we, by chance, did not have a media element.
if (has.mediaElement) {
this.eventManager_.unlisten(has.mediaElement, 'loadeddata');
this.eventManager_.unlisten(has.mediaElement, 'playing');
this.eventManager_.unlisten(has.mediaElement, 'pause');
this.eventManager_.unlisten(has.mediaElement, 'ended');
this.eventManager_.unlisten(has.mediaElement, 'ratechange');
}
// Some observers use some playback components, shutting down the observers
// first ensures that they don't try to use the playback components
// mid-destroy.
if (this.playheadObservers_) {
this.playheadObservers_.release();
this.playheadObservers_ = null;
}
if (this.bufferPoller_) {
this.bufferPoller_.stop();
this.bufferPoller_ = null;
}
// Stop the parser early. Since it is at the start of the pipeline, it should
// be start early to avoid is pushing new data downstream.
if (this.parser_) {
await this.parser_.stop();
this.parser_ = null;
}
// Abr Manager will tell streaming engine what to do, so we need to stop
// it before we destroy streaming engine. Unlike with the other components,
// we do not release the instance, we will reuse it in later loads.
if (this.abrManager_) {
await this.abrManager_.stop();
}
// Streaming engine will push new data to media source engine, so we need
// to shut it down before destroy media source engine.
if (this.streamingEngine_) {
await this.streamingEngine_.destroy();
this.streamingEngine_ = null;
}
// Playhead is used by StreamingEngine, so we can't destroy this until after
// StreamingEngine has stopped.
if (this.playhead_) {
this.playhead_.release();
this.playhead_ = null;
}
// Media source engine holds onto the media element, and in order to detach
// the media keys (with drm engine), we need to break the connection between
// media source engine and the media element.
if (this.mediaSourceEngine_) {
await this.mediaSourceEngine_.destroy();
this.mediaSourceEngine_ = null;
}
// In order to unload a media element, we need to remove the src attribute and
// and then load again. When we destroy media source engine, this will be done
// for us, but for src=, we need to do it here.
//
// DrmEngine requires this to be done before we destroy DrmEngine itself.
if (has.mediaElement && has.mediaElement.src) {
has.mediaElement.removeAttribute('src');
has.mediaElement.load();
}
if (this.drmEngine_) {
await this.drmEngine_.destroy();
this.drmEngine_ = null;
}
this.activeStreams_.clear();
this.assetUri_ = null;
this.bufferObserver_ = null;
this.loadingTextStreams_.clear();
this.manifest_ = null;
this.stats_ = null;
this.switchingPeriods_ = true;
// Make sure that the app knows of the new buffering state.
this.updateBufferState_();
};
/**
* This should only be called by the load graph when it is time to initialize
* media source engine. The only time this may be called is when we are attached
* to the same media element as in the request.
*
* This method assumes that it is safe for it to execute. The load-graph is
* responsible for ensuring all assumptions are true.
*
* @param {shaka.routing.Payload} has
* @param {shaka.routing.Payload} wants
*
* @return {!Promise}
* @private
*/
shaka.Player.prototype.onInitializeMediaSourceEngine_ = async function(
has, wants) {
goog.asserts.assert(
shaka.util.Platform.supportsMediaSource(),
'We should not be initializing media source on a platform that does ' +
'not support media source.');
goog.asserts.assert(
has.mediaElement,
'We should have a media element when initializing media source.');
goog.asserts.assert(
has.mediaElement == wants.mediaElement,
'|has| and |wants| should have the same media element when ' +
'initializing media source.');
goog.asserts.assert(
this.mediaSourceEngine_ == null,
'We should not have a media source engine yet.');
const closedCaptionsParser =
shaka.media.MuxJSClosedCaptionParser.isSupported() ?
new shaka.media.MuxJSClosedCaptionParser() :
new shaka.media.NoopCaptionParser();
// When changing text visibility we need to update both the text displayer
// and streaming engine because we don't always stream text. To ensure that
// text displayer and streaming engine are always in sync, wait until they are
// both initialized before setting the initial value.
const TextDisplayerFactory = this.config_.textDisplayFactory;
const textDisplayer = new TextDisplayerFactory();
const mediaSourceEngine = this.createMediaSourceEngine(
has.mediaElement, closedCaptionsParser, textDisplayer);
// Wait for media source engine to finish opening. This promise should
// NEVER be rejected as per the media source engine implementation.
await mediaSourceEngine.open();
// Wait until it is ready to actually store the reference.
this.mediaSourceEngine_ = mediaSourceEngine;
};
/**
* Create the parser for the asset located at |wants.uri|. This should only be
* called as part of the load graph.
*
* This method assumes that it is safe for it to execute, the load-graph is
* responsible for ensuring all assumptions are true.
*
* @param {shaka.routing.Payload} has
* @param {shaka.routing.Payload} wants
* @return {!Promise}
* @private
*/
shaka.Player.prototype.onInitializeParser_ = async function(has, wants) {
goog.asserts.assert(
has.mediaElement,
'We should have a media element when initializing the parser.');
goog.asserts.assert(
has.mediaElement == wants.mediaElement,
'|has| and |wants| should have the same media element when ' +
'initializing the parser.');
goog.asserts.assert(
this.networkingEngine_,
'Need networking engine when initializing the parser.');
goog.asserts.assert(
this.config_,
'Need player config when initializing the parser.');
// We are going to "lock-in" the factory, mime type, and uri since they are
// what we are going to use to create our parser and parse the manifest.
has.factory = wants.factory;
has.mimeType = wants.mimeType;
has.uri = wants.uri;
goog.asserts.assert(
has.uri,
'We should have an asset uri when initializing the parsing.');
// Store references to things we asserted so that we don't need to reassert
// them again later.
const assetUri = has.uri;
const networkingEngine = this.networkingEngine_;
// Save the uri so that it can be used outside of the load-graph.
this.assetUri_ = assetUri;
// Create the parser that we will use to parse the manifest.
if (has.factory) {
this.parser_ = has.factory();
} else {
this.parser_ = await shaka.media.ManifestParser.create(
assetUri,
networkingEngine,
this.config_.manifest.retryParameters,
has.mimeType);
}
this.parser_.configure(this.config_.manifest);
};
/**
* Parse the manifest at |has.uri| using the parser that should have already
* been created. This should only be called as part of the load graph.
*
* This method assumes that it is safe for it to execute, the load-graph is
* responsible for ensuring all assumptions are true.
*
* @param {shaka.routing.Payload} has
* @param {shaka.routing.Payload} wants
* @return {!shaka.util.AbortableOperation}
* @private
*/
shaka.Player.prototype.onParseManifest_ = function(has, wants) {
goog.asserts.assert(
has.factory == wants.factory,
'|has| and |wants| should have the same factory when parsing.');
goog.asserts.assert(
has.mimeType == wants.mimeType,
'|has| and |wants| should have the same mime type when parsing.');
goog.asserts.assert(
has.uri == wants.uri,
'|has| and |wants| should have the same uri when parsing.');
goog.asserts.assert(
has.uri,
'|has| should have a valid uri when parsing.');
goog.asserts.assert(
has.uri == this.assetUri_,
'|has.uri| should match the cached asset uri.');
goog.asserts.assert(
this.networkingEngine_,
'Need networking engine to parse manifest.');
goog.asserts.assert(
this.config_,
'Need player config to parse manifest.');
goog.asserts.assert(
this.parser_,
'|this.parser_| should have been set in an earlier step.');
// Store references to things we asserted so that we don't need to reassert
// them again later.
const assetUri = has.uri;
const networkingEngine = this.networkingEngine_;
// This will be needed by the parser once it starts parsing, so we will
// initialize it now even through it appears a little out-of-place.
this.regionTimeline_ = new shaka.media.RegionTimeline();
this.regionTimeline_.setListeners(/* onRegionAdded */ (region) => {
this.onRegionEvent_('timelineregionadded', region);
});
const playerInterface = {
networkingEngine: networkingEngine,
filterNewPeriod: (period) => this.filterNewPeriod_(period),
filterAllPeriods: (periods) => this.filterAllPeriods_(periods),
// Called when the parser finds a timeline region. This can be called
// before we start playback or during playback (live/in-progress manifest).
onTimelineRegionAdded: (region) => this.regionTimeline_.addRegion(region),
onEvent: (event) => this.dispatchEvent(event),
onError: (error) => this.onError_(error),
};
return new shaka.util.AbortableOperation(
/* promise= */ Promise.resolve().then(async () => {
this.manifest_ = await this.parser_.start(assetUri, playerInterface);
// This event is fired after the manifest is parsed, but before any
// filtering takes place.
this.dispatchEvent(new shaka.util.FakeEvent('manifestparsed'));
// We require all manifests to have already one period.
if (this.manifest_.periods.length == 0) {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.NO_PERIODS);
}
// Make sure that all periods are either: audio-only, video-only, or
// audio-video.
shaka.Player.filterForAVVariants_(this.manifest_.periods);
}),
/* onAbort= */ () => {
shaka.log.info('Aborting parser step...');
return this.parser_.stop();
});
};
/**
* This should only be called by the load graph when it is time to initialize
* drmEngine. The only time this may be called is when we are attached a
* media element and have parsed a manifest.
*
* The load-graph is responsible for ensuring all assumptions made by this
* method are valid before executing it.
*
* @param {shaka.routing.Payload} has
* @param {shaka.routing.Payload} wants
* @return {!Promise}
*/
shaka.Player.prototype.onInitializeDrm_ = async function(has, wants) {
goog.asserts.assert(
has.factory == wants.factory,
'The load graph should have ensured the factories matched.');
goog.asserts.assert(
has.mimeType == wants.mimeType,
'The load graph should have ensured the mime types matched.');
goog.asserts.assert(
has.uri == wants.uri,
'The load graph should have ensured the uris matched');
goog.asserts.assert(
this.networkingEngine_,
'|onInitializeDrm_| should never be called after |destroy|');
goog.asserts.assert(
this.config_,
'|onInitializeDrm_| should never be called after |destroy|');
goog.asserts.assert(
this.manifest_,
'|this.manifest_| should have been set in an earlier step.');
this.drmEngine_ = this.createDrmEngine({
netEngine: this.networkingEngine_,
onError: (e) => {
this.onError_(e);
},
onKeyStatus: (map) => {
this.onKeyStatus_(map);
},
onExpirationUpdated: (id, expiration) => {
this.onExpirationUpdated_(id, expiration);
},
onEvent: (e) => {
this.dispatchEvent(e);
},
});
this.drmEngine_.configure(this.config_.drm);
await this.drmEngine_.initForPlayback(
shaka.util.Periods.getAllVariantsFrom(this.manifest_.periods),
this.manifest_.offlineSessionIds);
// Now that we have drm information, filter the manifest (again) so that we
// can ensure we only use variants with the selected key system.
this.filterAllPeriods_(this.manifest_.periods);
};
/**
* This should only be called by the load graph when it is time to load all
* playback components needed for playback. The only times this may be called
* is when we are attached to the same media element as in the request.
*
* This method assumes that it is safe for it to execute, the load-graph is
* responsible for ensuring all assumptions are true.
*
* Loading is defined as:
* - Attaching all playback-related listeners to the media element
* - Initializing playback and observers
* - Initializing ABR Manager
* - Initializing Streaming Engine
* - Starting playback at |wants.startTime|
*
* @param {shaka.routing.Payload} has
* @param {shaka.routing.Payload} wants
* @private
*/
shaka.Player.prototype.onLoad_ = async function(has, wants) {
goog.asserts.assert(
has.factory == wants.factory,
'|has| and |wants| should have the same factory when loading.');
goog.asserts.assert(
has.mimeType == wants.mimeType,
'|has| and |wants| should have the same mime type when loading.');
goog.asserts.assert(
has.uri == wants.uri,
'|has| and |wants| should have the same uri when loading.');
goog.asserts.assert(
has.mediaElement,
'We should have a media element when loading.');
goog.asserts.assert(
wants.startTimeOfLoad,
'|wants| should tell us when the load was originally requested');
// Since we are about to start playback, we will lock in the start time as
// something we are now depending on.
has.startTime = wants.startTime;
// Store a reference to values in |has| after asserting so that closure will
// know that they will still be non-null between calls to await.
const mediaElement = has.mediaElement;
const assetUri = has.uri;
// Save the uri so that it can be used outside of the load-graph.
this.assetUri_ = assetUri;
// Stats are for a single playback/load session. Stats must be initialized
// before we allow calls to |updateStateHistory|.
this.stats_ = new shaka.util.Stats();
const updateStateHistory = () => this.updateStateHistory_();
const onRateChange = () => this.onRateChange_();
this.eventManager_.listen(mediaElement, 'playing', updateStateHistory);
this.eventManager_.listen(mediaElement, 'pause', updateStateHistory);
this.eventManager_.listen(mediaElement, 'ended', updateStateHistory);
this.eventManager_.listen(mediaElement, 'ratechange', onRateChange);
const AbrManagerFactory = this.config_.abrFactory;
if (!this.abrManager_ || this.abrManagerFactory_ != AbrManagerFactory) {
this.abrManagerFactory_ = AbrManagerFactory;
this.abrManager_ = new AbrManagerFactory();
this.abrManager_.configure(this.config_.abr);
}
// TODO: When a manifest update adds a new period, that period's closed
// captions should also be turned into text streams. This should be called
// for each new period as well.
this.createTextStreamsForClosedCaptions_(this.manifest_.periods);
// Copy preferred languages from the config again, in case the config was
// changed between construction and playback.
this.currentAdaptationSetCriteria_ =
new shaka.media.PreferenceBasedCriteria(
this.config_.preferredAudioLanguage,
this.config_.preferredVariantRole,
this.config_.preferredAudioChannelCount);
this.currentTextLanguage_ = this.config_.preferredTextLanguage;
shaka.Player.applyPlayRange_(this.manifest_.presentationTimeline,
this.config_.playRangeStart,
this.config_.playRangeEnd);
await this.drmEngine_.attach(mediaElement);
this.abrManager_.init((variant, clearBuffer, safeMargin) => {
return this.switch_(variant, clearBuffer, safeMargin);
});
this.playhead_ = this.createPlayhead(has.startTime);
this.playheadObservers_ = this.createPlayheadObserversForMSE_();
this.playRateController_ = new shaka.media.PlayRateController({
getRate: () => has.mediaElement.playbackRate,
setRate: (rate) => { has.mediaElement.playbackRate = rate; },
movePlayhead: (delta) => { has.mediaElement.currentTime += delta; },
});
// We need to start the buffer management code near the end because it will
// set the initial buffering state and that depends on other components being
// initialized.
const rebufferThreshold = Math.max(
this.manifest_.minBufferTime, this.config_.streaming.rebufferingGoal);
this.startBufferManagement_(rebufferThreshold);
this.streamingEngine_ = this.createStreamingEngine();
this.streamingEngine_.configure(this.config_.streaming);
// If the content is multi-codec and the browser can play more than one of
// them, choose codecs now before we initialize streaming.
this.chooseCodecsAndFilterManifest_();
// Set the load mode to "loaded with media source" as late as possible so that
// public methods won't try to access internal components until they're all
// initialized. We MUST switch to loaded before calling "streaming" so that
// they can access internal information.
this.loadMode_ = shaka.Player.LoadMode.MEDIA_SOURCE;
// The event must be fired after we filter by restrictions but before the
// active stream is picked to allow those listening for the "streaming" event
// to make changes before streaming starts.
this.dispatchEvent(new shaka.util.FakeEvent('streaming'));
// Start streaming content. This will start the flow of content down to media
// source, including picking the initial streams to play.
await this.streamingEngine_.start();
// We MUST wait until after we create streaming engine to adjust the start
// time because we rely on the active audio and video streams, which are
// selected in |StreamingEngine.init|.
if (this.config_.streaming.startAtSegmentBoundary) {
const startTime = this.playhead_.getTime();
const adjustedTime = this.adjustStartTime_(startTime);
this.playhead_.setStartTime(adjustedTime);
}
// Now that media source and streaming engine are both initialized, we can
// update the text visibility to reflect the cached value. We purposely use
// |updateTextVisibility_| directly to go around the caching that
// |setTextTrackVisibility| provides.
await this.updateTextVisibility_(this.isTextVisible_);
// Re-filter the manifest after streams have been chosen.
this.manifest_.periods.forEach(this.filterNewPeriod_.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_();
// Now that we've filtered out variants that aren't compatible with the
// active one, update abr manager with filtered variants for the current
// period.
/** @type {shaka.extern.Period} */
const currentPeriod = this.getPresentationPeriod_();
const hasPrimary = currentPeriod.variants.some((v) => v.primary);
if (!this.config_.preferredAudioLanguage && !hasPrimary) {
shaka.log.warning('No preferred audio language set. We will choose an ' +
'arbitrary language initially');
}
this.chooseVariant_(currentPeriod.variants);
// Wait for the 'loadeddata' event to measure load() latency.
this.eventManager_.listenOnce(mediaElement, 'loadeddata', () => {
const now = Date.now() / 1000;
const delta = now - wants.startTimeOfLoad;
this.stats_.setLoadLatency(delta);
});
};
/**
* This should only be called by the load graph when it is time to initialize
* drmEngine for src= playbacks.
*
* The load-graph is responsible for ensuring all assumptions made by this
* method are valid before executing it.
*
* @param {shaka.routing.Payload} has
* @param {shaka.routing.Payload} wants
* @return {!Promise}
*/
shaka.Player.prototype.onInitializeSrcEqualsDrm_ = async function(has, wants) {
const ContentType = shaka.util.ManifestParserUtils.ContentType;
goog.asserts.assert(
this.networkingEngine_,
'|onInitializeSrcEqualsDrm_| should never be called after |destroy|');
goog.asserts.assert(
this.config_,
'|onInitializeSrcEqualsDrm_| should never be called after |destroy|');
this.drmEngine_ = this.createDrmEngine({
netEngine: this.networkingEngine_,
onError: (e) => {
this.onError_(e);
},
onKeyStatus: (map) => {
this.onKeyStatus_(map);
},
onExpirationUpdated: (id, expiration) => {
this.onExpirationUpdated_(id, expiration);
},
onEvent: (e) => {
this.dispatchEvent(e);
},
});
this.drmEngine_.configure(this.config_.drm);
// TODO: Instead of feeding DrmEngine with Variants, we should refactor
// DrmEngine so that it takes a minimal config derived from Variants. In
// cases like this one or in removal of stored content, the details are
// largely unimportant. We should have a saner way to initialize DrmEngine.
// That would also insulate DrmEngine from manifest changes in the future.
// For now, that is time-consuming and this synthetic Variant is easy, so I'm
// putting it off. Since this is only expected to be used for native HLS in
// Safari, this should be safe. -JCP
/** @type {shaka.extern.Variant} */
const variant = {
id: 0,
language: 'und',
primary: false,
audio: null,
video: {
id: 0,
originalId: null,
createSegmentIndex: Promise.resolve.bind(Promise),
findSegmentPosition: (time) => null,
getSegmentReference: (ref) => null,
initSegmentReference: null,
presentationTimeOffset: 0,
mimeType: 'video/mp4',
codecs: '',
encrypted: true,
keyId: null,
language: 'und',
label: null,
type: ContentType.VIDEO,
primary: false,
trickModeVideo: null,
emsgSchemeIdUris: null,
roles: [],
channelsCount: null,
closedCaptions: null,
},
bandwidth: 100,
drmInfos: [], // Filled in by DrmEngine config.
allowedByApplication: true,
allowedByKeySystem: true,
};
await this.drmEngine_.initForPlayback([variant], /* offlineSessionIds= */ []);
await this.drmEngine_.attach(has.mediaElement);
};
/**
* This should only be called by the load graph when it is time to set-up the
* media element to play content using src=. The only times this may be called
* is when we are attached to the same media element as in the request.
*
* This method assumes that it is safe for it to execute, the load-graph is
* responsible for ensuring all assumptions are true.
*
* @param {shaka.routing.Payload} has
* @param {shaka.routing.Payload} wants
* @return {!shaka.util.AbortableOperation}
*
* @private
*/
shaka.Player.prototype.onSrcEquals_ = function(has, wants) {
goog.asserts.assert(
has.mediaElement,
'We should have a media element when loading.');
goog.asserts.assert(
wants.uri,
'|has| should have a valid uri when loading.');
goog.asserts.assert(
wants.startTimeOfLoad,
'|wants| should tell us when the load was originally requested');
goog.asserts.assert(
this.video_ == has.mediaElement,
'The video element should match our media element');
// Lock-in the values that we are using so that the routing logic knows what
// we have.
has.uri = wants.uri;
has.startTime = wants.startTime;
// Save the uri so that it can be used outside of the load-graph.
this.assetUri_ = has.uri;
// Stats are for a single playback/load session.
this.stats_ = new shaka.util.Stats();
this.playhead_ = new shaka.media.SrcEqualsPlayhead(has.mediaElement);
if (has.startTime != null) {
this.playhead_.setStartTime(has.startTime);
}
this.playRateController_ = new shaka.media.PlayRateController({
getRate: () => has.mediaElement.playbackRate,
setRate: (rate) => { has.mediaElement.playbackRate = rate; },
movePlayhead: (delta) => { has.mediaElement.currentTime += delta; },
});
// We need to start the buffer management code near the end because it will
// set the initial buffering state and that depends on other components being
// initialized.
const rebufferThreshold = this.config_.streaming.rebufferingGoal;
this.startBufferManagement_(rebufferThreshold);
// Add all media element listeners.
const updateStateHistory = () => this.updateStateHistory_();
this.eventManager_.listen(has.mediaElement, 'playing', updateStateHistory);
this.eventManager_.listen(has.mediaElement, 'pause', updateStateHistory);
this.eventManager_.listen(has.mediaElement, 'ended', updateStateHistory);
// Wait for the 'loadeddata' event to measure load() latency.
this.eventManager_.listenOnce(has.mediaElement, 'loadeddata', () => {
const now = Date.now() / 1000;
const delta = now - wants.startTimeOfLoad;
this.stats_.setLoadLatency(delta);
});
// The audio tracks are only available on Safari at the moment, but this
// drives the tracks API for Safari's native HLS. So when they change,
// fire the corresponding Shaka Player event.
if (this.video_.audioTracks) {
this.eventManager_.listen(
this.video_.audioTracks, 'addtrack', () => this.onTracksChanged_());
this.eventManager_.listen(
this.video_.audioTracks, 'removetrack', () => this.onTracksChanged_());
}
if (this.video_.textTracks) {
// This is a real EventTarget, but the compiler doesn't know that.
// TODO: File a bug or send a PR to the compiler externs to fix this.
const textTracks = /** @type {EventTarget} */(this.video_.textTracks);
this.eventManager_.listen(
textTracks, 'addtrack', () => this.onTracksChanged_());
this.eventManager_.listen(
textTracks, 'removetrack', () => this.onTracksChanged_());
}
// By setting |src| we are done "loading" with src=. We don't need to set the
// current time because |playhead| will do that for us.
has.mediaElement.src = has.uri;
// Set the load mode last so that we know that all our components are
// initialized.
this.loadMode_ = shaka.Player.LoadMode.SRC_EQUALS;
// The event doesn't mean as much for src= playback, since we don't control
// streaming. But we should fire it in this path anyway since some
// applications may be expecting it as a life-cycle event.
this.dispatchEvent(new shaka.util.FakeEvent('streaming'));
// This is fully loaded when we have loaded the first frame.
const fullyLoaded = new shaka.util.PublicPromise();
if (this.video_.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
// Already done!
fullyLoaded.resolve();
} else if (this.video_.error) {
// Already failed!
fullyLoaded.reject(this.videoErrorToShakaError_());
} else {
// Wait for success or failure.
this.eventManager_.listenOnce(this.video_, 'loadeddata', () => {
fullyLoaded.resolve();
});
this.eventManager_.listenOnce(this.video_, 'error', () => {
fullyLoaded.reject(this.videoErrorToShakaError_());
});
}
return new shaka.util.AbortableOperation(fullyLoaded, /* onAbort= */ () => {
const abortedError = new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.PLAYER,
shaka.util.Error.Code.OPERATION_ABORTED);
fullyLoaded.reject(abortedError);
return Promise.resolve(); // Abort complete.
});
};
/**
* Take a series of periods and ensure that they only contain one type of
* variant. The different options are:
* 1. Audio-Video
* 2. Audio-Only
* 3. Video-Only
*
* A manifest can only contain a single type because once we initialize media
* source to expect specific streams, it must always have content for those
* streams. If we were to start period 1 with audio+video but period 2 only had
* audio, media source would block waiting for video content.
*
* @param {!Array.<shaka.extern.Period>} periods
* @private
*/
shaka.Player.filterForAVVariants_ = function(periods) {
const isAVVariant = (variant) => {
// Audio-video variants may include both streams separately or may be single
// multiplexed streams with multiple codecs.
return (variant.video && variant.audio) ||
(variant.video && variant.video.codecs.includes(','));
};
const hasAVVariant = periods.some((period) => {
return period.variants.some(isAVVariant);
});
if (hasAVVariant) {
shaka.log.debug('Found variant with audio and video content, ' +
'so filtering out audio-only content in all periods.');
periods.forEach((period) => {
period.variants = period.variants.filter(isAVVariant);
});
}
};
/**
* In case of multiple usable codecs, choose one based on lowest average
* bandwidth and filter out the rest.
* @private
*/
shaka.Player.prototype.chooseCodecsAndFilterManifest_ = function() {
// Collect a list of variants for all periods.
/** @type {!Array.<shaka.extern.Variant>} */
let variants = this.manifest_.periods.reduce(
(variants, period) => variants.concat(period.variants), []);
// To start, consider a subset of variants based on audio channel preferences.
// For some content (#1013), surround-sound variants will use a different
// codec than stereo variants, so it is important to choose codecs **after**
// considering the audio channel config.
variants = shaka.util.StreamUtils.filterVariantsByAudioChannelCount(
variants, this.config_.preferredAudioChannelCount);
function variantCodecs(variant) {
// Only consider the base of the codec string. For example, these should
// both be considered the same codec: avc1.42c01e, avc1.4d401f
let baseVideoCodec = '';
if (variant.video) {
baseVideoCodec = shaka.util.MimeUtils.getCodecBase(variant.video.codecs);
}
let baseAudioCodec = '';
if (variant.audio) {
baseAudioCodec = shaka.util.MimeUtils.getCodecBase(variant.audio.codecs);
}
return baseVideoCodec + '-' + baseAudioCodec;
}
// Now organize variants into buckets by codecs.
/** @type {!shaka.util.MultiMap.<shaka.extern.Variant>} */
const variantsByCodecs = new shaka.util.MultiMap();
variants.forEach((variant) => {
const group = variantCodecs(variant);
variantsByCodecs.push(group, variant);
});
// Compute the average bandwidth for each group of variants.
// Choose the lowest-bandwidth codecs.
let bestCodecs = null;
let lowestAverageBandwidth = Infinity;
variantsByCodecs.forEach((codecs, variants) => {
let sum = 0;
let num = 0;
variants.forEach(function(variant) {
sum += variant.bandwidth || 0;
++num;
});
let averageBandwidth = sum / num;
shaka.log.debug('codecs', codecs, 'avg bandwidth', averageBandwidth);
if (averageBandwidth < lowestAverageBandwidth) {
bestCodecs = codecs;
lowestAverageBandwidth = averageBandwidth;
}
});
goog.asserts.assert(bestCodecs != null, 'Should have chosen codecs!');
goog.asserts.assert(!isNaN(lowestAverageBandwidth),
'Bandwidth should be a number!');
// Filter out any variants that don't match, forcing AbrManager to choose from
// the most efficient variants possible.
this.manifest_.periods.forEach(function(period) {
period.variants = period.variants.filter(function(variant) {
let codecs = variantCodecs(variant);
if (codecs == bestCodecs) return true;
shaka.log.debug('Dropping Variant (better codec available)', variant);
return false;
});
});
};
/**
* Create a new DrmEngine instance. This may be replaced by tests to create fake
* instances. Configuration and initialization will be handled after
* |createDrmEngine|.
*
* @param {shaka.media.DrmEngine.PlayerInterface} playerInterface
* @return {!shaka.media.DrmEngine}
*/
shaka.Player.prototype.createDrmEngine = function(playerInterface) {
return new shaka.media.DrmEngine(playerInterface);
};
/**
* 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() {
/** @type {function(number, number)} */
const onProgressUpdated_ = (deltaTimeMs, bytesDownloaded) => {
// In some situations, such as during offline storage, the abr manager might
// not yet exist. Therefore, we need to check if abr manager has been
// initialized before using it.
if (this.abrManager_) {
this.abrManager_.segmentDownloaded(deltaTimeMs, bytesDownloaded);
}
};
return new shaka.net.NetworkingEngine(onProgressUpdated_);
};
/**
* Creates a new instance of Playhead. This can be replaced by tests to create
* fake instances instead.
*
* @param {?number} startTime
* @return {!shaka.media.Playhead}
*/
shaka.Player.prototype.createPlayhead = function(startTime) {
goog.asserts.assert(this.manifest_, 'Must have manifest');
goog.asserts.assert(this.video_, 'Must have video');
return new shaka.media.MediaSourcePlayhead(
this.video_,
this.manifest_,
this.config_.streaming,
startTime,
() => this.onSeek_(),
(event) => this.dispatchEvent(event));
};
/**
* Create the observers for MSE playback. These observers are responsible for
* notifying the app and player of specific events during MSE playback.
*
* @return {!shaka.media.PlayheadObserverManager}
* @private
*/
shaka.Player.prototype.createPlayheadObserversForMSE_ = function() {
goog.asserts.assert(this.manifest_, 'Must have manifest');
goog.asserts.assert(this.regionTimeline_, 'Must have region timeline');
goog.asserts.assert(this.video_, 'Must have video element');
// Create the period observer. This will allow us to notify the app when we
// transition between periods.
const periodObserver = new shaka.media.PeriodObserver(this.manifest_);
periodObserver.setListeners((period) => this.onChangePeriod_());
// Create the region observer. This will allow us to notify the app when we
// move in and out of timeline regions.
const regionObserver = new shaka.media.RegionObserver(this.regionTimeline_);
const onEnterRegion = (region, seeking) => {
this.onRegionEvent_('timelineregionenter', region);
};
const onExitRegion = (region, seeking) => {
this.onRegionEvent_('timelineregionexit', region);
};
const onSkipRegion = (region, seeking) => {
// If we are seeking, we don't want to surface the enter/exit events since
// they didn't play through them.
if (!seeking) {
this.onRegionEvent_('timelineregionenter', region);
this.onRegionEvent_('timelineregionexit', region);
}
};
regionObserver.setListeners(onEnterRegion, onExitRegion, onSkipRegion);
// Now that we have all our observers, create a manager for them.
const manager = new shaka.media.PlayheadObserverManager(this.video_);
manager.manage(periodObserver);
manager.manage(regionObserver);
return manager;
};
/**
* Initialize and start the buffering system (observer and timer) so that we can
* monitor our buffer lead during playback.
*
* @param {number} rebufferingGoal
*/
shaka.Player.prototype.startBufferManagement_ = function(rebufferingGoal) {
goog.asserts.assert(
!this.bufferObserver_,
'No buffering observer should exist before initialization.');
goog.asserts.assert(
!this.bufferPoller_,
'No buffer timer should exist before initialization.');
// The threshold to transition back to satisfied when starving.
const starvingThreshold = rebufferingGoal;
// The threshold to transition into starving when satisfied.
// We use a "typical" threshold, unless the rebufferingGoal is unusually low.
// Then we force the value down to half the rebufferingGoal, since
// starvingThreshold must be strictly larger than satisfiedThreshold for the
// logic in BufferingObserver to work correctly.
const satisfiedThreshold =
Math.min(shaka.Player.TYPICAL_BUFFERING_THRESHOLD_, rebufferingGoal / 2);
this.bufferObserver_ = new shaka.media.BufferingObserver(
starvingThreshold, satisfiedThreshold);
// Force us back to a buffering state. This ensure everything is starting in
// the same state.
this.bufferObserver_.setState(shaka.media.BufferingObserver.State.STARVING);
this.updateBufferState_();
// TODO: We should take some time to look into the effects of our
// quarter-second refresh practice. We often use a quarter-second
// but we have no documentation about why.
this.bufferPoller_ = new shaka.util.Timer(() => {
this.pollBufferState_();
}).tickEvery(/* seconds= */ 0.25);
};
/**
* This method is called periodically to check what the buffering observer says
* so that we can update the rest of the buffering behaviours.
*
* @private
*/
shaka.Player.prototype.pollBufferState_ = function() {
goog.asserts.assert(
this.video_,
'Need a media element to update the buffering observer');
goog.asserts.assert(
this.bufferObserver_,
'Need a buffering observer to update');
let bufferedToEnd;
switch (this.loadMode_) {
case shaka.Player.LoadMode.SRC_EQUALS:
bufferedToEnd = this.isBufferedToEndSrc_();
break;
case shaka.Player.LoadMode.MEDIA_SOURCE:
bufferedToEnd = this.isBufferedToEndMS_();
break;
default:
bufferedToEnd = false;
break;
}
const bufferLead = shaka.media.TimeRangesUtils.bufferedAheadOf(
this.video_.buffered,
this.video_.currentTime);
const stateChanged = this.bufferObserver_.update(bufferLead, bufferedToEnd);
// If the state changed, we need to surface the event.
if (stateChanged) {
this.updateBufferState_();
}
};
/**
* Create a new media source engine. This will ONLY be replaced by tests as a
* way to inject fake media source engine instances.
*
* @param {!HTMLMediaElement} mediaElement
* @param {!shaka.media.IClosedCaptionParser} closedCaptionsParser
* @param {!shaka.extern.TextDisplayer} textDisplayer
*
* @return {!shaka.media.MediaSourceEngine}
*/
shaka.Player.prototype.createMediaSourceEngine = function(
mediaElement, closedCaptionsParser, textDisplayer) {
return new shaka.media.MediaSourceEngine(
mediaElement, closedCaptionsParser, textDisplayer);
};
/**
* 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_ && this.mediaSourceEngine_ && this.manifest_,
'Must not be destroyed');
/** @type {shaka.media.StreamingEngine.PlayerInterface} */
let playerInterface = {
getPresentationTime: () => this.playhead_.getTime(),
mediaSourceEngine: this.mediaSourceEngine_,
netEngine: this.networkingEngine_,
onChooseStreams: this.onChooseStreams_.bind(this),
onCanSwitch: this.canSwitch_.bind(this),
onError: this.onError_.bind(this),
onEvent: (event) => this.dispatchEvent(event),
onManifestUpdate: this.onManifestUpdate_.bind(this),
onSegmentAppended: this.onSegmentAppended_.bind(this),
};
return new shaka.media.StreamingEngine(this.manifest_, playerInterface);
};
/**
* 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 and this returns
* false. If there are errors, valid config objects are still set.
*
* @param {string|!Object} config This should either be a field name or an
* object following the form of {@link shaka.extern.PlayerConfiguration},
* where you may omit any field you do not wish to change.
* @param {*=} value This should be provided if the previous parameter
* was a string field name.
* @return {boolean} True if the passed config object was valid, false if there
* were invalid entries.
* @export
*/
shaka.Player.prototype.configure = function(config, value) {
goog.asserts.assert(this.config_, 'Config must not be null!');
goog.asserts.assert(typeof(config) == 'object' || arguments.length == 2,
'String configs should have values!');
// ('fieldName', value) format
if (arguments.length == 2 && typeof(config) == 'string') {
config = this.convertToConfigObject_(config, value);
}
goog.asserts.assert(typeof(config) == 'object', 'Should be an object!');
let ret = shaka.util.PlayerConfiguration.mergeConfigObjects(
this.config_, config, this.defaultConfig_());
this.applyConfig_();
return ret;
};
/**
* Convert config from ('fieldName', value) format to a partial
* shaka.extern.PlayerConfiguration object.
* E. g. from ('manifest.retryParameters.maxAttempts', 1) to
* { manifest: { retryParameters: { maxAttempts: 1 }}}.
*
* @param {string} fieldName
* @param {*} value
* @return {!Object}
* @private
*/
shaka.Player.prototype.convertToConfigObject_ = function(fieldName, value) {
let configObject = {};
let last = configObject;
let searchIndex = 0;
let nameStart = 0;
while (true) { // eslint-disable-line no-constant-condition
let idx = fieldName.indexOf('.', searchIndex);
if (idx < 0) {
break;
}
if (idx == 0 || fieldName[idx - 1] != '\\') {
let part = fieldName.substring(nameStart, idx).replace(/\\\./g, '.');
last[part] = {};
last = last[part];
nameStart = idx + 1;
}
searchIndex = idx + 1;
}
last[fieldName.substring(nameStart).replace(/\\\./g, '.')] = value;
return configObject;
};
/**
* 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.
try {
// this.filterNewPeriod_() may throw.
this.manifest_.periods.forEach(this.filterNewPeriod_.bind(this));
} catch (error) {
this.onError_(error);
}
// If the stream we are playing is restricted, we need to switch.
let activeAudio = this.streamingEngine_.getBufferingAudio();
let activeVideo = this.streamingEngine_.getBufferingVideo();
/** @type {shaka.extern.Period} */
let period = this.getPresentationPeriod_();
let activeVariant = shaka.util.StreamUtils.getVariantByStreams(
activeAudio, activeVideo, period.variants);
if (this.abrManager_ && activeVariant &&
activeVariant.allowedByApplication &&
activeVariant.allowedByKeySystem) {
// Update AbrManager variants to match these new settings.
this.chooseVariant_(period.variants);
} else {
shaka.log.debug('Choosing new streams after changing configuration');
this.chooseStreamsAndSwitch_(period);
}
}
if (this.abrManager_) {
this.abrManager_.configure(this.config_.abr);
// Simply enable/disable ABR with each call, since multiple calls to these
// methods have no effect.
if (this.config_.abr.enabled && !this.switchingPeriods_) {
this.abrManager_.enable();
} else {
this.abrManager_.disable();
}
this.onAbrStatusChanged_();
}
};
/**
* 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 {shaka.extern.PlayerConfiguration}
* @export
*/
shaka.Player.prototype.getConfiguration = function() {
goog.asserts.assert(this.config_, 'Config must not be null!');
let ret = this.defaultConfig_();
shaka.util.PlayerConfiguration.mergeConfigObjects(
ret, this.config_, this.defaultConfig_());
return ret;
};
/**
* Return a reference to the current configuration. Modifications to the
* returned value will affect the Player's active configuration. This method
* is not exported as sharing configuration with external objects is not
* supported.
*
* @return {shaka.extern.PlayerConfiguration}
*/
shaka.Player.prototype.getSharedConfiguration = function() {
goog.asserts.assert(
this.config_, 'Cannot call getSharedConfiguration after call destroy!');
return this.config_;
};
/**
* Reset configuration to default.
* @export
*/
shaka.Player.prototype.resetConfiguration = function() {
goog.asserts.assert(this.config_, 'Cannot be destroyed');
// Remove the old keys so we remove open-ended dictionaries like drm.servers
// but keeps the same object reference.
for (const key in this.config_) {
delete this.config_[key];
}
shaka.util.PlayerConfiguration.mergeConfigObjects(
this.config_, this.defaultConfig_(), this.defaultConfig_());
this.applyConfig_();
};
/**
* Get the current load mode.
*
* @return {shaka.Player.LoadMode}
* @export
*/
shaka.Player.prototype.getLoadMode = function() {
return this.loadMode_;
};
/**
* Get the media element that the player is currently using to play loaded
* content. If the player has not loaded content, this will return |null|.
*
* @return {HTMLMediaElement}
* @export
*/
shaka.Player.prototype.getMediaElement = function() {
return this.video_;
};
/**
* @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_;
};
/**
* Get the uri to the asset that the player has loaded. If the player has not
* loaded content, this will return |null|.
*
* @return {?string}
* @export
*/
shaka.Player.prototype.getAssetUri = function() {
return this.assetUri_;
};
/**
* Get the uri to the asset that the player has loaded. If the player has not
* loaded content, this will return |null|.
*
* @deprecated
* @return {?string}
* @export
*/
shaka.Player.prototype.getManifestUri = function() {
shaka.Deprecate.deprecateFeature(
2, 6, 'getManifestUri', 'Please use "getAssetUri" instead.');
return this.getAssetUri();
};
/**
* Get if the player is playing live content. If the player has not loaded
* content, this will return |false|.
*
* @return {boolean}
* @export
*/
shaka.Player.prototype.isLive = function() {
if (this.manifest_) {
return this.manifest_.presentationTimeline.isLive();
}
// For native HLS, the duration for live streams seems to be Infinity.
if (this.video_ && this.video_.src) {
return this.video_.duration == Infinity;
}
return false;
};
/**
* Get if the player is playing in-progress content. If the player has not
* loaded content, this will return |false|.
*
* @return {boolean}
* @export
*/
shaka.Player.prototype.isInProgress = function() {
return this.manifest_ ?
this.manifest_.presentationTimeline.isInProgress() :
false;
};
/**
* Check if the manifest contains only audio-only content. If the player has not
* loaded content, this will return |false|.
*
* The player does not support content that contain more than one type of
* variants (i.e. mixing audio-only, video-only, audio-video). Content will be
* filtered to only contain one type of variant.
*
* @return {boolean}
* @export
*/
shaka.Player.prototype.isAudioOnly = function() {
if (this.manifest_) {
const periods = this.manifest_.periods;
if (!periods.length) { return false; }
const variants = this.manifest_.periods[0].variants;
if (!variants.length) { return false; }
// Note that if there are some audio-only variants and some audio-video
// variants, the audio-only variants are removed during filtering.
// Therefore if the first variant has no video, that's sufficient to say it
// is audio-only content.
return !variants[0].video;
} else if (this.video_ && this.video_.src) {
// We cast to the more specific HTMLVideoElement to access videoHeight.
// This might be an audio element, though, in which case videoHeight will
// be undefined at runtime. For audio elements, this will always return
// true.
const video = /** @type {HTMLVideoElement} */(this.video_);
return video.videoHeight == 0;
} else {
return false;
}
};
/**
* Get the range of time (in seconds) that seeking is allowed. If the player has
* not loaded content, this will return a range from 0 to 0.
*
* @return {{start: number, end: number}}
* @export
*/
shaka.Player.prototype.seekRange = function() {
if (this.manifest_) {
const timeline = this.manifest_.presentationTimeline;
return {
'start': timeline.getSeekRangeStart(),
'end': timeline.getSeekRangeEnd(),
};
}
// If we have loaded content with src=, we ask the video element for its
// seekable range. This covers both plain mp4s and native HLS playbacks.
if (this.video_ && this.video_.src) {
const seekable = this.video_.seekable;
if (seekable.length) {
return {
'start': seekable.start(0),
'end': seekable.end(seekable.length - 1),
};
}
}
return {'start': 0, 'end': 0};
};
/**
* Get the key system currently used by EME. If EME is not being used, this will
* return an empty string. If the player has not loaded content, this will
* return an empty string.
*
* @return {string}
* @export
*/
shaka.Player.prototype.keySystem = function() {
return this.drmEngine_ ? this.drmEngine_.keySystem() : '';
};
/**
* Get the drm info used to initialize EME. If EME is not being used, this will
* return |null|. If the player is idle or has not initialized EME yet, this
* will return |null|.
*
* @return {?shaka.extern.DrmInfo}
* @export
*/
shaka.Player.prototype.drmInfo = function() {
return this.drmEngine_ ? this.drmEngine_.getDrmInfo() : null;
};
/**
* Get the next known expiration time for any EME session. If the session never
* expires, this will return |Infinity|. If there are no EME sessions, this will
* return |Infinity|. If the player has not loaded content, this will return
* |Infinity|.
*
* @return {number}
* @export
*/
shaka.Player.prototype.getExpiration = function() {
return this.drmEngine_ ? this.drmEngine_.getExpiration() : Infinity;
};
/**
* Check if the player is currently in a buffering state (has too little content
* to play smoothly). If the player has not loaded content, this will return
* |false|.
*
* @return {boolean}
* @export
*/
shaka.Player.prototype.isBuffering = function() {
const State = shaka.media.BufferingObserver.State;
return this.bufferObserver_ ?
this.bufferObserver_.getState() == State.STARVING :
false;
};
/**
* Get the playback rate of what is playing right now. If we are using trick
* play, this will return the trick play rate. If no content is playing, this
* will return 0. If content is buffering, this will return 0.
*
* If the player has not loaded content, this will return a playback rate of
* |0|.
*
* @return {number}
* @export
*/
shaka.Player.prototype.getPlaybackRate = function() {
return this.playRateController_ ?
this.playRateController_.getActiveRate() :
0;
};
/**
* Enable trick play to skip through content without playing by repeatedly
* seeking. For example, a rate of 2.5 would result in 2.5 seconds of content
* being skipped every second. A negative rate will result in moving backwards.
*
* If the player has not loaded content or is still loading content this will be
* a no-op. Wait until |load| has completed before calling.
*
* Trick play will be canceled automatically if the playhead hits the beginning
* or end of the seekable range for the content.
*
* @param {number} rate
* @export
*/
shaka.Player.prototype.trickPlay = function(rate) {
// A playbackRate of 0 is used internally when we are in a buffering state,
// and doesn't make sense for trick play. If you set a rate of 0 for trick
// play, we will reject it and issue a warning. If it happens during a test,
// we will fail the test through this assertion.
goog.asserts.assert(rate != 0, 'Should never set a trick play rate of 0!');
if (rate == 0) {
shaka.log.alwaysWarn('A trick play rate of 0 is unsupported!');
return;
}
if (this.loadMode_ == shaka.Player.LoadMode.SRC_EQUALS) {
this.playRateController_.set(rate);
}
if (this.loadMode_ == shaka.Player.LoadMode.MEDIA_SOURCE) {
this.playRateController_.set(rate);
this.streamingEngine_.setTrickPlay(Math.abs(rate) > 1);
}
};
/**
* Cancel trick-play. If the player has not loaded content or is still loading
* content this will be a no-op.
*
* @export
*/
shaka.Player.prototype.cancelTrickPlay = function() {
if (this.loadMode_ == shaka.Player.LoadMode.SRC_EQUALS) {
this.playRateController_.set(1);
}
if (this.loadMode_ == shaka.Player.LoadMode.MEDIA_SOURCE) {
this.playRateController_.set(1);
this.streamingEngine_.setTrickPlay(false);
}
};
/**
* Return a list of variant tracks that can be switched to in the current
* period. If there are multiple periods, you must seek to the period in order
* to get variants from that period.
*
* If the player has not loaded content, this will return an empty list.
*
* @return {!Array.<shaka.extern.Track>}
* @export
*/
shaka.Player.prototype.getVariantTracks = function() {
if (this.manifest_ && this.playhead_) {
const currentVariant = this.getPresentationVariant_();
const tracks = [];
// Convert each variant to a track.
for (const variant of this.getSelectableVariants_()) {
const track = shaka.util.StreamUtils.variantToTrack(variant);
track.active = variant == currentVariant;
tracks.push(track);
}
return tracks;
} else if (this.video_ && this.video_.audioTracks) {
// Safari's native HLS always shows a single element in videoTracks.
// You can't use that API to change resolutions. But we can use audioTracks
// to generate a variant list that is usable for changing languages.
const audioTracks = Array.from(this.video_.audioTracks);
return audioTracks.map((audio) =>
shaka.util.StreamUtils.html5AudioTrackToTrack(audio));
} else {
return [];
}
};
/**
* Return a list of text tracks that can be switched to in the current period.
* If there are multiple periods, you must seek to a period in order to get
* text tracks from that period.
*
* If the player has not loaded content, this will return an empty list.
*
* @return {!Array.<shaka.extern.Track>}
* @export
*/
shaka.Player.prototype.getTextTracks = function() {
if (this.manifest_ && this.playhead_) {
const currentText = this.getPresentationText_();
const tracks = [];
// Convert all selectable text streams to tracks.
for (const text of this.getSelectableText_()) {
const track = shaka.util.StreamUtils.textStreamToTrack(text);
track.active = text == currentText;
tracks.push(track);
}
return tracks;
} else if (this.video_ && this.video_.src && this.video_.textTracks) {
const textTracks = Array.from(this.video_.textTracks);
const StreamUtils = shaka.util.StreamUtils;
return textTracks.map((text) => StreamUtils.html5TextTrackToTrack(text));
} else {
return [];
}
};
/**
* Select a specific text track from the current period. |track| should come
* from a call to |getTextTracks|. If the track is not found in the current
* period, this will be a no-op. If the player has not loaded content, this will
* be a no-op.
*
* Note that AdaptationEvents are not fired for manual track selections.
*
* @param {shaka.extern.Track} track
* @export
*/
shaka.Player.prototype.selectTextTrack = function(track) {
if (this.manifest_ && this.streamingEngine_) {
/** @type {shaka.extern.Period} */
const period = this.getPresentationPeriod_();
const stream = period.textStreams.find((stream) => stream.id == track.id);
if (!stream) {
shaka.log.error('No stream with id', track.id);
return;
}
// Add entries to the history.
this.addTextStreamToSwitchHistory_(
period, stream, /* fromAdaptation= */ false);
this.switchTextStream_(stream);
// Workaround for https://github.com/google/shaka-player/issues/1299
// When track is selected, back-propogate the language to
// currentTextLanguage_.
this.currentTextLanguage_ = stream.language;
} else if (this.video_ && this.video_.src && this.video_.textTracks) {
const textTracks = Array.from(this.video_.textTracks);
for (const textTrack of textTracks) {
if (shaka.util.StreamUtils.html5TrackId(textTrack) == track.id) {
// Leave the track in 'hidden' if it's selected but not showing.
textTrack.mode = this.isTextVisible_ ? 'showing' : 'hidden';
} else {
// Safari allows multiple text tracks to have mode == 'showing', so be
// explicit in resetting the others.
textTrack.mode = 'disabled';
}
}
this.onTextChanged_();
}
};
/**
* Find the CEA 608/708 text stream embedded in video, and switch to it.
*
* @deprecated
* @export
*/
shaka.Player.prototype.selectEmbeddedTextTrack = function() {
shaka.Deprecate.deprecateFeature(
2, 6,
'selectEmbeddedTextTrack',
[
'If closed captions are signaled in the manifest, a text stream will',
'be created to represent them. Please use SelectTextTrack.',
].join(' '));
const tracks = this.getTextTracks().filter((track) => {
return track.mimeType == shaka.util.MimeUtils.CLOSED_CAPTION_MIMETYPE;
});
if (tracks.length > 0) {
this.selectTextTrack(tracks[0]);
} else {
shaka.log.warning('Unable to find the text track embedded in the video.');
}
};
/**
* @return {boolean} True if we are using any embedded text tracks present.
*
* @deprecated
* @export
*/
shaka.Player.prototype.usingEmbeddedTextTrack = function() {
shaka.Deprecate.deprecateFeature(
2, 6,
'usingEmbeddedTextTrack',
[
'If closed captions are signaled in the manifest, a text stream will',
'be created to represent them. There should be no reason to know if',
'the player is playing embedded text.',
].join(' '));
const activeTrack = this.getTextTracks().filter((track) => {
return track.active;
})[0];
if (activeTrack) {
return activeTrack.mimeType == shaka.util.MimeUtils.CLOSED_CAPTION_MIMETYPE;
}
return false;
};
/**
* Select a specific variant track to play from the current period. |track|
* should come from a call to |getVariantTracks|. If |track| cannot be found
* in the current variant, this will be a no-op. If the player has not loaded
* content, this will be a no-op.
*
* Changing variants will take effect once the currently buffered content has
* been played. To force the change to happen sooner, use |clearBuffer| with
* |safeMargin|. Setting |clearBuffer| to |true| will clear all buffered content
* after |safeMargin|, allowing the new variant to start playing sooner.
*
* Note that AdaptationEvents are not fired for manual track selections.
*
* @param {shaka.extern.Track} track
* @param {boolean=} clearBuffer
* @param {number=} safeMargin Optional amount of buffer (in seconds) to retain
* when clearing the buffer. Useful for switching variant quickly without
* causing a buffering event. Defaults to 0 if not provided. Ignored if
* clearBuffer is false. Can cause hiccups on some browsers if chosen too
* small, e.g. The amount of two segments is a fair minimum to consider as
* safeMargin value.
* @export
*/
shaka.Player.prototype.selectVariantTrack = function(
track, clearBuffer, safeMargin = 0) {
if (this.manifest_ && this.streamingEngine_) {
/** @type {shaka.extern.Period} */
const period = this.getPresentationPeriod_();
if (this.config_.abr.enabled) {
shaka.log.alwaysWarn('Changing tracks while abr manager is enabled ' +
'will likely result in the selected track being ' +
'overriden. Consider disabling abr before calling ' +
'selectVariantTrack().');
}
const variant = period.variants.find((variant) => variant.id == track.id);
if (!variant) {
shaka.log.error('No variant with id', track.id);
return;
}
// Double check that the track is allowed to be played. The track list
// should only contain playable variants, but if restrictions change and
// |selectVariantTrack| is called before the track list is updated, we could
// get a now-restricted variant.
if (!shaka.util.StreamUtils.isPlayable(variant)) {
shaka.log.error('Unable to switch to restricted track', track.id);
return;
}
// Add entries to the history.
this.addVariantToSwitchHistory_(period, variant,
/* fromAdaptation= */ false);
this.switchVariant_(variant, clearBuffer, safeMargin);
// Workaround for https://github.com/google/shaka-player/issues/1299
// When track is selected, back-propogate the language to
// currentAudioLanguage_.
this.currentAdaptationSetCriteria_ = new shaka.media.ExampleBasedCriteria(
variant);
// Update AbrManager variants to match these new settings.
this.chooseVariant_(period.variants);
} else if (this.video_ && this.video_.audioTracks) {
// Safari's native HLS won't let you choose an explicit variant, though you
// can choose audio languages this way.
const audioTracks = Array.from(this.video_.audioTracks);
for (const audioTrack of audioTracks) {
if (shaka.util.StreamUtils.html5TrackId(audioTrack) == track.id) {
// This will reset the "enabled" of other tracks to false.
audioTrack.enabled = true;
}
}
this.onVariantChanged_();
}
};
/**
* Return a list of audio language-role combinations available for the current
* period. If the player has not loaded any content, this will return an empty
* list.
*
* @return {!Array.<shaka.extern.LanguageRole>}
* @export
*/
shaka.Player.prototype.getAudioLanguagesAndRoles = function() {
return shaka.Player.getLanguageAndRolesFrom_(this.getVariantTracks());
};
/**
* Return a list of text language-role combinations available for the current
* period. If the player has not loaded any content, this will be return an
* empty list.
*
* @return {!Array.<shaka.extern.LanguageRole>}
* @export
*/
shaka.Player.prototype.getTextLanguagesAndRoles = function() {
return shaka.Player.getLanguageAndRolesFrom_(this.getTextTracks());
};
/**
* Return a list of audio languages available for the current period. If the
* player has not loaded any content, this will return an empty list.
*
* @return {!Array.<string>}
* @export
*/
shaka.Player.prototype.getAudioLanguages = function() {
return Array.from(shaka.Player.getLanguagesFrom_(this.getVariantTracks()));
};
/**
* Return a list of text languages available for the current period. If the
* player has not loaded any content, this will return an empty list.
*
* @return {!Array.<string>}
* @export
*/
shaka.Player.prototype.getTextLanguages = function() {
return Array.from(shaka.Player.getLanguagesFrom_(this.getTextTracks()));
};
/**
* Sets currentAudioLanguage and currentVariantRole to the selected language and
* role, and chooses a new variant if need be. If the player has not loaded any
* content, this will be a no-op.
*
* @param {string} language
* @param {string=} role
* @export
*/
shaka.Player.prototype.selectAudioLanguage = function(language, role) {
if (this.manifest_ && this.playhead_) {
/** @type {shaka.extern.Period} */
const period = this.getPresentationPeriod_();
this.currentAdaptationSetCriteria_ =
new shaka.media.PreferenceBasedCriteria(language, role || '', 0);
// TODO: Refactor to only change audio and not affect text.
this.chooseStreamsAndSwitch_(period);
} else if (this.video_ && this.video_.audioTracks) {
const audioTracks = Array.from(this.video_.audioTracks);
for (const audioTrack of audioTracks) {
if (audioTrack.language == language) {
// This will reset the "enabled" of other tracks to false.
audioTrack.enabled = true;
}
}
}
};
/**
* Sets currentTextLanguage and currentTextRole to the selected language and
* role, and chooses a new variant if need be. If the player has not loaded any
* content, this will be a no-op.
*
* @param {string} language
* @param {string=} role
* @export
*/
shaka.Player.prototype.selectTextLanguage = function(language, role) {
if (this.manifest_ && this.playhead_) {
/** @type {shaka.extern.Period} */
const period = this.getPresentationPeriod_();
this.currentTextLanguage_ = language;
this.currentTextRole_ = role || '';
// TODO: Refactor to only change text and not affect audio.
this.chooseStreamsAndSwitch_(period);
} else {
const track = this.getTextTracks().filter((t) => t.language == language)[0];
if (track) {
this.selectTextTrack(track);
}
}
};
/**
* Check if the text displayer is enabled.
*
* @return {boolean}
* @export
*/
shaka.Player.prototype.isTextTrackVisible = function() {
const expected = this.isTextVisible_;
if (this.manifest_) {
// Make sure our values are still in-sync.
const actual = this.mediaSourceEngine_.getTextDisplayer().isTextVisible();
goog.asserts.assert(
actual == expected, 'text visibility has fallen out of sync');
// Always return the actual value so that the app has the most accurate
// information (in the case that the values come out of sync in prod).
return actual;
} else if (this.video_ && this.video_.src && this.video_.textTracks) {
const textTracks = Array.from(this.video_.textTracks);
return textTracks.some((t) => t.mode == 'showing');
}
return expected;
};
/**
* Enable or disable the text displayer. If the player is in an unloaded state,
* the request will be applied next time content is loaded.
*
* @param {boolean} isVisible
* @return {!Promise}
* @export
*/
shaka.Player.prototype.setTextTrackVisibility = async function(isVisible) {
const oldVisibilty = this.isTextVisible_;
const newVisibility = isVisible;
if (oldVisibilty == newVisibility) {
return;
}
this.isTextVisible_ = newVisibility;
// We always want to fire the event but we only do something with the text
// visibility change if we have loaded content with media source.
await this.updateTextVisibility_(isVisible);
// We need to fire the event after we have updated everything so that
// everything will be in a stable state when the app responds to the
// event.
this.onTextTrackVisibility_();
// TODO: Move this inside updateTextVisibility_? It's called after all
// but one call to updateTextVisibility_.
};
/**
* Update all the components (streaming engine and media source) to change the
* visibility of the text.
*
* @param {boolean} isVisible
* @return {!Promise}
*/
shaka.Player.prototype.updateTextVisibility_ = async function(isVisible) {
// Hold of on setting the text visibility until we have all the components we
// need. This ensures that they stay in-sync.
if (this.loadMode_ == shaka.Player.LoadMode.MEDIA_SOURCE) {
this.mediaSourceEngine_.getTextDisplayer().setTextVisibility(isVisible);
// When the user wants to see captions, we stream captions. When the user
// doesn't want to see captions, we don't stream captions. This is to avoid
// bandwidth consumption by an unused resource. The app developer can
// override this and configure us to always stream captions.
if (this.config_.streaming.alwaysStreamText) {
return;
}
if (!isVisible) {
this.streamingEngine_.unloadTextStream();
return;
}
// Find the text stream that best matches the user's preferences.
/** @type {shaka.extern.Period} */
const period = this.getPresentationPeriod_();
const streams = shaka.util.StreamUtils.filterStreamsByLanguageAndRole(
period.textStreams, this.currentTextLanguage_, this.currentTextRole_);
// It is possible that there are no streams to play.
if (streams.length == 0) {
return;
}
await this.streamingEngine_.loadNewTextStream(streams[0]);
} else if (this.video_ && this.video_.src && this.video_.textTracks) {
const textTracks = Array.from(this.video_.textTracks);
// Find the active track by looking for one which is not disabled. This is
// the only way to identify the track which is currently displayed.
// Set it to 'showing' or 'hidden' based on isVisible.
for (const textTrack of textTracks) {
if (textTrack.mode != 'disabled') {
textTrack.mode = isVisible ? 'showing' : 'hidden';
}
}
}
};
/**
* Get the current playhead position as a date. This should only be called when
* the player has loaded a live stream. If the player has not loaded a live
* stream, this will return |null|.
*
* @return {Date}
* @export
*/
shaka.Player.prototype.getPlayheadTimeAsDate = function() {
if (!this.isLive()) {
shaka.log.warning('getPlayheadTimeAsDate is for live streams!');
return null;
}
if (this.manifest_) {
const timeline = this.manifest_.presentationTimeline;
const startTime = timeline.getPresentationStartTime();
const presentationTime = this.video_.currentTime;
return new Date(/* ms= */ (startTime + presentationTime) * 1000);
} else if (this.video_ && this.video_.getStartDate) {
// Apple's native HLS gives us getStartDate(), which is only available if
// EXT-X-PROGRAM-DATETIME is in the playlist.
const startDate = this.video_.getStartDate();
if (isNaN(startDate.getTime())) {
shaka.log.warning(
'EXT-X-PROGRAM-DATETIME required to get playhead time as Date!');
return null;
}
return new Date(startDate.getTime() + (this.video_.currentTime * 1000));
} else {
shaka.log.warning('No way to get playhead time as Date!');
return null;
}
};
/**
* Get the presentation start time as a date. This should only be called when
* the player has loaded a live stream. If the player has not loaded a live
* stream, this will return |null|.
*
* @return {Date}
* @export
*/
shaka.Player.prototype.getPresentationStartTimeAsDate = function() {
if (!this.isLive()) {
shaka.log.warning('getPresentationStartTimeAsDate is for live streams!');
return null;
}
if (this.manifest_) {
const timeline = this.manifest_.presentationTimeline;
const startTime = timeline.getPresentationStartTime();
return new Date(/* ms= */ startTime * 1000);
} else if (this.video_ && this.video_.getStartDate) {
// Apple's native HLS gives us getStartDate(), which is only available if
// EXT-X-PROGRAM-DATETIME is in the playlist.
const startDate = this.video_.getStartDate();
if (isNaN(startDate.getTime())) {
shaka.log.warning(
'EXT-X-PROGRAM-DATETIME required to get presentation start time as ' +
'Date!');
return null;
}
return startDate;
} else {
shaka.log.warning('No way to get presentation start time as Date!');
return null;
}
};
/**
* Get information about what the player has buffered. If the player has not
* loaded content or is currently loading content, the buffered content will be
* empty.
*
* @return {shaka.extern.BufferedInfo}
* @export
*/
shaka.Player.prototype.getBufferedInfo = function() {
const info = {
total: [],
audio: [],
video: [],
text: [],
};
if (this.loadMode_ == shaka.Player.LoadMode.SRC_EQUALS) {
const getBufferedInfo = shaka.media.TimeRangesUtils.getBufferedInfo;
info.total = getBufferedInfo(this.video_.buffered);
}
if (this.loadMode_ == shaka.Player.LoadMode.MEDIA_SOURCE) {
this.mediaSourceEngine_.getBufferedInfo(info);
}
return info;
};
/**
* Get statistics for the current playback session. If the player is not playing
* content, this will return an empty stats object.
*
* @return {shaka.extern.Stats}
* @export
*/
shaka.Player.prototype.getStats = function() {
// If the Player is not in a fully-loaded state, then return an empty stats
// blob so that this call will never fail.
const loaded = this.loadMode_ == shaka.Player.LoadMode.MEDIA_SOURCE ||
this.loadMode_ == shaka.Player.LoadMode.SRC_EQUALS;
if (!loaded) {
return shaka.util.Stats.getEmptyBlob();
}
this.updateStateHistory_();
goog.asserts.assert(this.video_, 'If we have stats, we should have video_');
const element = /** @type {!HTMLVideoElement} */ (this.video_);
if (element.getVideoPlaybackQuality) {
const info = element.getVideoPlaybackQuality();
this.stats_.setDroppedFrames(
Number(info.droppedVideoFrames),
Number(info.totalVideoFrames));
}
if (this.loadMode_ == shaka.Player.LoadMode.MEDIA_SOURCE) {
// Event through we are loaded, it is still possible that we don't have a
// presentation variant yet because we set the load mode before we select
// the first variant to stream.
const variant = this.getPresentationVariant_();
if (variant) {
this.stats_.setVariantBandwidth(variant.bandwidth);
}
if (variant && variant.video) {
this.stats_.setResolution(
/* width= */ variant.video.width || NaN,
/* height= */ variant.video.height || NaN);
}
const estimate = this.abrManager_.getBandwidthEstimate();
this.stats_.setBandwidthEstimate(estimate);
}
return this.stats_.getBlob();
};
/**
* 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 with the track that was created, when
* that track can be switched to.
*
* @param {string} uri
* @param {string} language
* @param {string} kind
* @param {string} mime
* @param {string=} codec
* @param {string=} label
* @return {!Promise.<shaka.extern.Track>}
* @export
*/
shaka.Player.prototype.addTextTrack = async function(
uri, language, kind, mime, codec, label) {
// TODO: Add an actual error for this.
if (this.loadMode_ == shaka.Player.LoadMode.SRC_EQUALS) {
shaka.log.error('Cannot add text when loaded with src=');
return Promise.reject();
}
if (this.loadMode_ != shaka.Player.LoadMode.MEDIA_SOURCE) {
shaka.log.error(
'Must call load() and wait for it to resolve before adding text ' +
'tracks.');
return Promise.reject();
}
/** @type {shaka.extern.Period} */
const period = this.getPresentationPeriod_();
const ContentType = shaka.util.ManifestParserUtils.ContentType;
// Get the Period duration.
/** @type {number} */
const periodIndex = this.manifest_.periods.indexOf(period);
/** @type {number} */
const nextPeriodIndex = periodIndex + 1;
/** @type {number} */
const nextPeriodStart = nextPeriodIndex >= this.manifest_.periods.length ?
this.manifest_.presentationTimeline.getDuration() :
this.manifest_.periods[nextPeriodIndex].startTime;
/** @type {number} */
const periodDuration = nextPeriodStart - period.startTime;
if (periodDuration == Infinity) {
return Promise.reject(new shaka.util.Error(
shaka.util.Error.Severity.RECOVERABLE,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.CANNOT_ADD_EXTERNAL_TEXT_TO_LIVE_STREAM));
}
/** @type {!shaka.media.SegmentReference} */
const segmentReference = new shaka.media.SegmentReference(
1, 0, periodDuration, () => [uri], 0, null);
/** @type {shaka.extern.Stream} */
const stream = {
id: this.nextExternalStreamId_++,
originalId: null,
createSegmentIndex: Promise.resolve.bind(Promise),
findSegmentPosition: (time) => 1,
getSegmentReference: (ref) => {
return ref == 1 ? segmentReference : null;
},
initSegmentReference: null,
presentationTimeOffset: 0,
mimeType: mime,
codecs: codec || '',
kind: kind,
encrypted: false,
keyId: null,
language: language,
label: label || null,
type: ContentType.TEXT,
primary: false,
trickModeVideo: null,
emsgSchemeIdUris: null,
roles: [],
channelsCount: null,
closedCaptions: null,
};
// Add the stream to the loading list to ensure it isn't switched to while it
// is initializing.
this.loadingTextStreams_.add(stream);
period.textStreams.push(stream);
await this.streamingEngine_.loadNewTextStream(stream);
goog.asserts.assert(period, 'The period should still be non-null here.');
const activeText = this.streamingEngine_.getBufferingText();
if (activeText) {
// If this was the first text stream, StreamingEngine will start streaming
// it in loadNewTextStream. To reflect this, update the active stream.
this.activeStreams_.useText(period, activeText);
}
// Remove the stream from the loading list.
this.loadingTextStreams_.delete(stream);
shaka.log.debug('Choosing new streams after adding a text stream');
this.chooseStreamsAndSwitch_(period);
this.onTracksChanged_();
return {
id: stream.id,
active: false,
type: ContentType.TEXT,
bandwidth: 0,
language: language,
label: label || null,
kind: kind,
width: null,
height: null,
};
};
/**
* 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;
};
/**
* Retry streaming after a streaming failure has occurred. When the player has
* not loaded content or is loading content, this will be a no-op and will
* return |false|.
*
* If the player has loaded content, and streaming has not seen an error, this
* will return |false|.
*
* if the player has loaded content, and streaming seen an error, but the could
* not resume streaming, this will return |false|.
*
* @return {boolean}
* @export
*/
shaka.Player.prototype.retryStreaming = function() {
return this.loadMode_ == shaka.Player.LoadMode.MEDIA_SOURCE ?
this.streamingEngine_.retry() :
false;
};
/**
* Get the manifest that the player has loaded. If the player has not loaded any
* content, this will return |null|.
*
* @return {?shaka.extern.Manifest}
* @export
*/
shaka.Player.prototype.getManifest = function() {
return this.manifest_;
};
/**
* Get the type of manifest parser that the player is using. If the player has
* not loaded any content, this will return |null|.
*
* @return {?shaka.extern.ManifestParser.Factory}
* @export
*/
shaka.Player.prototype.getManifestParserFactory = function() {
return this.parser_ ? this.parser_.constructor : null;
};
/**
* @param {shaka.extern.Period} period
* @param {shaka.extern.Variant} variant
* @param {boolean} fromAdaptation
* @private
*/
shaka.Player.prototype.addVariantToSwitchHistory_ = function(
period, variant, fromAdaptation) {
this.activeStreams_.useVariant(period, variant);
this.stats_.getSwitchHistory().updateCurrentVariant(variant, fromAdaptation);
};
/**
* @param {shaka.extern.Period} period
* @param {shaka.extern.Stream} textStream
* @param {boolean} fromAdaptation
* @private
*/
shaka.Player.prototype.addTextStreamToSwitchHistory_ = function(
period, textStream, fromAdaptation) {
this.activeStreams_.useText(period, textStream);
this.stats_.getSwitchHistory().updateCurrentText(textStream, fromAdaptation);
};
/**
* @return {shaka.extern.PlayerConfiguration}
* @private
*/
shaka.Player.prototype.defaultConfig_ = function() {
const config = shaka.util.PlayerConfiguration.createDefault();
config.streaming.failureCallback = (error) => {
this.defaultStreamingFailureCallback_(error);
};
// Because this.video_ may not be set when the config is built, the default
// TextDisplay factory must capture a reference to "this" as "self" to use at
// the time we call the factory. Bind can't be used here because we call the
// factory with "new", effectively removing any binding to "this".
const self = this;
config.textDisplayFactory = function() {
return new shaka.text.SimpleTextDisplayer(self.video_);
};
return config;
};
/**
* @param {!shaka.util.Error} error
* @private
*/
shaka.Player.prototype.defaultStreamingFailureCallback_ = function(error) {
let retryErrorCodes = [
shaka.util.Error.Code.BAD_HTTP_STATUS,
shaka.util.Error.Code.HTTP_ERROR,
shaka.util.Error.Code.TIMEOUT,
];
if (this.isLive() && retryErrorCodes.includes(error.code)) {
error.severity = shaka.util.Error.Severity.RECOVERABLE;
shaka.log.warning('Live streaming error. Retrying automatically...');
this.retryStreaming();
}
};
/**
* For CEA closed captions embedded in the video streams, create dummy text
* stream.
* @param {!Array.<!shaka.extern.Period>} periods
* @private
*/
shaka.Player.prototype.createTextStreamsForClosedCaptions_ = function(periods) {
const ContentType = shaka.util.ManifestParserUtils.ContentType;
for (let periodIndex = 0; periodIndex < periods.length; periodIndex++) {
const period = periods[periodIndex];
// A map of the closed captions id and the new dummy text stream.
let closedCaptionsMap = new Map();
for (let variant of period.variants) {
if (variant.video && variant.video.closedCaptions) {
let video = variant.video;
for (const id of video.closedCaptions.keys()) {
if (!closedCaptionsMap.has(id)) {
let textStream = {
id: this.nextExternalStreamId_++, // A globally unique ID.
originalId: id, // The CC ID string, like 'CC1', 'CC3', etc.
createSegmentIndex: Promise.resolve.bind(Promise),
findSegmentPosition: (time) => { return null; },
getSegmentReference: (ref) => { return null; },
initSegmentReference: null,
presentationTimeOffset: 0,
mimeType: shaka.util.MimeUtils.CLOSED_CAPTION_MIMETYPE,
codecs: '',
kind:
shaka.util.ManifestParserUtils.TextStreamKind.CLOSED_CAPTION,
encrypted: false,
keyId: null,
language: video.closedCaptions.get(id),
label: null,
type: ContentType.TEXT,
primary: false,
trickModeVideo: null,
emsgSchemeIdUris: null,
roles: video.roles,
channelsCount: null,
closedCaptions: null,
};
closedCaptionsMap.set(id, textStream);
}
}
}
}
for (const textStream of closedCaptionsMap.values()) {
period.textStreams.push(textStream);
}
}
};
/**
* Filters a list of periods.
* @param {!Array.<!shaka.extern.Period>} periods
* @private
*/
shaka.Player.prototype.filterAllPeriods_ = function(periods) {
goog.asserts.assert(this.video_, 'Must not be destroyed');
const ArrayUtils = shaka.util.ArrayUtils;
const StreamUtils = shaka.util.StreamUtils;
/** @type {?shaka.extern.Stream} */
let activeAudio =
this.streamingEngine_ ? this.streamingEngine_.getBufferingAudio() : null;
/** @type {?shaka.extern.Stream} */
let activeVideo =
this.streamingEngine_ ? this.streamingEngine_.getBufferingVideo() : null;
let filterPeriod = StreamUtils.filterNewPeriod.bind(
null, this.drmEngine_, activeAudio, activeVideo);
periods.forEach(filterPeriod);
let validPeriodsCount = ArrayUtils.count(periods, function(period) {
return period.variants.some(StreamUtils.isPlayable);
});
// If none of the periods are playable, throw CONTENT_UNSUPPORTED_BY_BROWSER.
if (validPeriodsCount == 0) {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.CONTENT_UNSUPPORTED_BY_BROWSER);
}
// If only some of the periods are playable, throw UNPLAYABLE_PERIOD.
if (validPeriodsCount < periods.length) {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.UNPLAYABLE_PERIOD);
}
periods.forEach(function(period) {
let tracksChanged = shaka.util.StreamUtils.applyRestrictions(
period.variants, this.config_.restrictions, this.maxHwRes_);
if (tracksChanged && this.streamingEngine_ &&
this.getPresentationPeriod_() == period) {
this.onTracksChanged_();
}
this.checkRestrictedVariants_(period.variants);
}.bind(this));
};
/**
* Filters a new period.
* @param {shaka.extern.Period} period
* @private
*/
shaka.Player.prototype.filterNewPeriod_ = function(period) {
goog.asserts.assert(this.video_, 'Must not be destroyed');
const StreamUtils = shaka.util.StreamUtils;
/** @type {?shaka.extern.Stream} */
let activeAudio =
this.streamingEngine_ ? this.streamingEngine_.getBufferingAudio() : null;
/** @type {?shaka.extern.Stream} */
let activeVideo =
this.streamingEngine_ ? this.streamingEngine_.getBufferingVideo() : null;
StreamUtils.filterNewPeriod(
this.drmEngine_, activeAudio, activeVideo, period);
/** @type {!Array.<shaka.extern.Variant>} */
let variants = period.variants;
// Check for playable variants before restrictions, so that we can give a
// special error when there were tracks but they were all filtered.
const hasPlayableVariant = variants.some(StreamUtils.isPlayable);
if (!hasPlayableVariant) {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.UNPLAYABLE_PERIOD);
}
this.checkRestrictedVariants_(period.variants);
const tracksChanged = shaka.util.StreamUtils.applyRestrictions(
variants, this.config_.restrictions, this.maxHwRes_);
// Trigger the track change event if the restrictions now prevent use from
// using a variant that we previously thought we could use.
if (tracksChanged && this.streamingEngine_ &&
this.getPresentationPeriod_() == period) {
this.onTracksChanged_();
}
// For new Periods, we may need to create new sessions for any new init data.
const curDrmInfo = this.drmEngine_ ? this.drmEngine_.getDrmInfo() : null;
if (curDrmInfo) {
for (const variant of variants) {
for (const drmInfo of variant.drmInfos) {
// Ignore any data for different key systems.
if (drmInfo.keySystem == curDrmInfo.keySystem) {
for (const initData of (drmInfo.initData || [])) {
this.drmEngine_.newInitData(
initData.initDataType, initData.initData);
}
}
}
}
}
};
/**
* Switches to the given variant, deferring if needed.
* @param {shaka.extern.Variant} variant
* @param {boolean=} clearBuffer
* @param {number=} safeMargin
* @private
*/
shaka.Player.prototype.switchVariant_ =
function(variant, clearBuffer = false, safeMargin = 0) {
if (this.switchingPeriods_) {
// Store this action for later.
this.deferredVariant_ = variant;
this.deferredVariantClearBuffer_ = clearBuffer;
this.deferredVariantClearBufferSafeMargin_ = safeMargin;
} else {
// Act now.
this.streamingEngine_.switchVariant(variant, clearBuffer, safeMargin);
// Dispatch a 'variantchanged' event
this.onVariantChanged_();
}
};
/**
* Switches to the given text stream, deferring if needed.
* @param {shaka.extern.Stream} textStream
* @private
*/
shaka.Player.prototype.switchTextStream_ = function(textStream) {
if (this.switchingPeriods_) {
// Store this action for later.
this.deferredTextStream_ = textStream;
} else {
// Act now.
this.streamingEngine_.switchTextStream(textStream);
this.onTextChanged_();
}
};
/**
* Verifies that the active streams according to the player match those in
* StreamingEngine.
* @private
*/
shaka.Player.prototype.assertCorrectActiveStreams_ = function() {
if (!this.streamingEngine_ || !this.manifest_ || !goog.DEBUG) return;
const activePeriod = this.streamingEngine_.getBufferingPeriod();
/** @type {shaka.extern.Period} */
const currentPeriod = this.getPresentationPeriod_();
if (activePeriod == null || activePeriod != currentPeriod) {
return;
}
let activeAudio = this.streamingEngine_.getBufferingAudio();
let activeVideo = this.streamingEngine_.getBufferingVideo();
let activeText = this.streamingEngine_.getBufferingText();
// If we have deferred variants/text we want to compare against those rather
// than what we are actually streaming.
const expectedAudio = this.deferredVariant_ ?
this.deferredVariant_.audio :
activeAudio;
const expectedVideo = this.deferredVariant_ ?
this.deferredVariant_.video :
activeVideo;
const expectedText = this.deferredTextStream_ || activeText;
const actualVariant = this.activeStreams_.getVariant(currentPeriod);
const actualText = this.activeStreams_.getText(currentPeriod);
goog.asserts.assert(
actualVariant.audio == expectedAudio,
'Inconsistent active audio stream');
goog.asserts.assert(
actualVariant.video == expectedVideo,
'Inconsistent active video stream');
// Because we always set a text stream to be active in the active stream map,
// regardless of whether or not we are actually streaming text, it is possible
// for these to be out of line.
goog.asserts.assert(
expectedText == null || actualText == expectedText,
'Inconsistent active text stream');
};
/**
* @param {number} time
* @return {number}
* @private
*/
shaka.Player.prototype.adjustStartTime_ = function(time) {
/** @type {?shaka.extern.Stream} */
let activeAudio = this.streamingEngine_.getBufferingAudio();
/** @type {?shaka.extern.Stream} */
let activeVideo = this.streamingEngine_.getBufferingVideo();
/** @type {shaka.extern.Period} */
let period = this.getPresentationPeriod_();
// This method is called after StreamingEngine.init resolves, which means that
// all the active streams have had createSegmentIndex called.
function getAdjustedTime(stream, time) {
if (!stream) return null;
let idx = stream.findSegmentPosition(time - period.startTime);
if (idx == null) return null;
let ref = stream.getSegmentReference(idx);
if (!ref) return null;
let refTime = ref.startTime + period.startTime;
goog.asserts.assert(refTime <= time, 'Segment should start before time');
return refTime;
}
let audioStartTime = getAdjustedTime(activeAudio, time);
let videoStartTime = getAdjustedTime(activeVideo, time);
// If we have both video and audio times, pick the larger one. If we picked
// the smaller one, that one will download an entire segment to buffer the
// difference.
if (videoStartTime != null && audioStartTime != null) {
return Math.max(videoStartTime, audioStartTime);
} else if (videoStartTime != null) {
return videoStartTime;
} else if (audioStartTime != null) {
return audioStartTime;
} else {
return time;
}
};
/**
* Update the buffering state to be either "we are buffering" or "we are not
* buffering", firing events to the app as needed.
*
* @private
*/
shaka.Player.prototype.updateBufferState_ = function() {
const isBuffering = this.isBuffering();
// Make sure we have all the components we need before we consider ourselves
// as being loaded.
// TODO: Make the check for "loaded" simpler.
const loaded = this.stats_ && this.bufferObserver_ && this.playhead_;
if (loaded) {
this.playRateController_.setBuffering(isBuffering);
this.updateStateHistory_();
}
// Surface the buffering event so that the app knows if/when we are buffering.
let event = new shaka.util.FakeEvent('buffering', {'buffering': isBuffering});
this.dispatchEvent(event);
};
/**
* Callback from PlayheadObserver.
* @private
*/
shaka.Player.prototype.onChangePeriod_ = function() {
this.onTracksChanged_();
};
/**
* A callback for when the playback rate changes. We need to watch the playback
* rate so that if the playback rate on the media element changes (that was not
* caused by our play rate controller) we can notify the controller so that it
* can stay in-sync with the change.
*
* @private
*/
shaka.Player.prototype.onRateChange_ = function() {
/** @type {number} */
const newRate = this.video_.playbackRate;
// On Edge, when someone seeks using the native controls, it will set the
// playback rate to zero until they finish seeking, after which it will
// return the playback rate.
//
// If the playback rate changes while seeking, Edge will cache the playback
// rate and use it after seeking.
//
// https://github.com/google/shaka-player/issues/951
if (newRate == 0) {
return;
}
// The playback rate has changed. This could be us or someone else.
// If this was us, setting the rate again will be a no-op.
this.playRateController_.set(newRate);
};
/**
* Try updating the state history. If the player has not finished initializing,
* this will be a no-op.
*
* @private
*/
shaka.Player.prototype.updateStateHistory_ = function() {
// If we have not finish initializing, this will be a no-op.
if (!this.stats_) { return; }
if (!this.bufferObserver_) { return; }
const State = shaka.media.BufferingObserver.State;
const history = this.stats_.getStateHistory();
if (this.bufferObserver_.getState() == State.STARVING) {
history.update('buffering');
} else if (this.video_.paused) {
history.update('paused');
} else if (this.video_.ended) {
history.update('ended');
} else {
history.update('playing');
}
};
/**
* Callback from Playhead.
*
* @private
*/
shaka.Player.prototype.onSeek_ = function() {
if (this.playheadObservers_) {
this.playheadObservers_.notifyOfSeek();
}
if (this.streamingEngine_) {
this.streamingEngine_.seeked();
}
};
/**
* Chooses a variant from all possible variants while taking into account
* restrictions, preferences, and ABR.
*
* On error, this dispatches an error event and returns null.
*
* @param {!Array.<shaka.extern.Variant>} allVariants
* @return {?shaka.extern.Variant}
* @private
*/
shaka.Player.prototype.chooseVariant_ = function(allVariants) {
goog.asserts.assert(this.config_, 'Must not be destroyed');
try {
// |variants| are the filtered variants, use |period.variants| so we know
// why they we restricted.
this.checkRestrictedVariants_(allVariants);
} catch (e) {
this.onError_(e);
return null;
}
goog.asserts.assert(
allVariants.length, 'Should have thrown for no Variants.');
const playableVariants = allVariants.filter((variant) => {
return shaka.util.StreamUtils.isPlayable(variant);
});
// Update the abr manager with newly filtered variants.
const adaptationSet = this.currentAdaptationSetCriteria_.create(
playableVariants);
this.abrManager_.setVariants(Array.from(adaptationSet.values()));
return this.abrManager_.chooseVariant();
};
/**
* Choose a text stream from all possible text streams while taking into
* account user preference.
*
* @param {!Array.<shaka.extern.Stream>} textStreams
* @return {?shaka.extern.Stream}
* @private
*/
shaka.Player.prototype.chooseTextStream_ = function(textStreams) {
const subset = shaka.util.StreamUtils.filterStreamsByLanguageAndRole(
textStreams,
this.currentTextLanguage_,
this.currentTextRole_);
return subset[0] || null;
};
/**
* Chooses streams from the given Period and switches to them.
* Called after a config change, a new text stream, a key status event, or an
* explicit language change.
*
* @param {!shaka.extern.Period} period
* @private
*/
shaka.Player.prototype.chooseStreamsAndSwitch_ = function(period) {
goog.asserts.assert(this.config_, 'Must not be destroyed');
// Because we're running this after a config change (manual language change),
// a new text stream, or a key status event, and because switching to an
// active stream is a no-op, it is always okay to clear the buffer here.
const chosenVariant = this.chooseVariant_(period.variants);
if (chosenVariant) {
this.addVariantToSwitchHistory_(
period, chosenVariant, /* fromAdaptation= */ true);
this.switchVariant_(chosenVariant, /* clearBuffers */ true);
}
// Only switch text if we should be streaming text right now.
const chosenText = this.chooseTextStream_(period.textStreams);
if (chosenText && this.shouldStreamText_()) {
this.addTextStreamToSwitchHistory_(
period, chosenText, /* fromAdaptation= */ true);
this.switchTextStream_(chosenText);
}
// Send an adaptation event so that the UI can show the new language/tracks.
this.onAdaptation_();
};
/**
* Callback from StreamingEngine, invoked when a period starts. This method
* must always "succeed" so it may not throw an error. Any errors must be
* routed to |onError|.
*
* @param {!shaka.extern.Period} period
* @return {shaka.media.StreamingEngine.ChosenStreams}
* An object containing the chosen variant and text stream.
* @private
*/
shaka.Player.prototype.onChooseStreams_ = function(period) {
shaka.log.debug('onChooseStreams_', period);
goog.asserts.assert(this.config_, 'Must not be destroyed');
try {
shaka.log.v2('onChooseStreams_, choosing variant from ', period.variants);
shaka.log.v2('onChooseStreams_, choosing text from ', period.textStreams);
const chosen = this.chooseStreams_(period);
shaka.log.v2('onChooseStreams_, chose variant ', chosen.variant);
shaka.log.v2('onChooseStreams_, chose text ', chosen.text);
return chosen;
} catch (e) {
this.onError_(e);
return {variant: null, text: null};
}
};
/**
* This is the internal logic for |onChooseStreams_|. This separation is done
* to allow this implementation to throw errors without consequence.
*
* @param {shaka.extern.Period} period
* The period that we are selecting streams from.
* @return {shaka.media.StreamingEngine.ChosenStreams}
* An object containing the chosen variant and text stream.
* @private
*/
shaka.Player.prototype.chooseStreams_ = function(period) {
// 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.abrManager_.disable();
this.onAbrStatusChanged_();
shaka.log.debug('Choosing new streams after period changed');
let chosenVariant = this.chooseVariant_(period.variants);
let chosenText = this.chooseTextStream_(period.textStreams);
// Ignore deferred variant or text streams only if we are starting a new
// period. In this case, any deferred switches were from an older period, so
// they do not apply. We can still have deferred switches from the current
// period in the case of an early call to select*Track while we are setting up
// the first period. This can happen with the 'streaming' event.
if (this.deferredVariant_) {
if (period.variants.includes(this.deferredVariant_)) {
chosenVariant = this.deferredVariant_;
}
this.deferredVariant_ = null;
}
if (this.deferredTextStream_) {
if (period.textStreams.includes(this.deferredTextStream_)) {
chosenText = this.deferredTextStream_;
}
this.deferredTextStream_ = null;
}
if (chosenVariant) {
this.addVariantToSwitchHistory_(
period, chosenVariant, /* fromAdaptation= */ true);
}
if (chosenText) {
this.addTextStreamToSwitchHistory_(
period, chosenText, /* fromAdaptation= */ true);
}
// Check if we should show text (based on difference between audio and text
// languages). Only check this during startup so we don't "pop-up" captions
// mid playback.
const startingUp = !this.streamingEngine_.getBufferingPeriod();
const chosenAudio = chosenVariant ? chosenVariant.audio : null;
if (startingUp && chosenAudio && chosenText) {
if (this.shouldShowText_(chosenAudio, chosenText)) {
this.setTextTrackVisibility(true);
this.onTextTrackVisibility_();
}
}
// Don't fire a tracks-changed event since we aren't inside the new Period
// yet.
// Don't initialize with a text stream unless we should be streaming text.
if (this.shouldStreamText_()) {
return {variant: chosenVariant, text: chosenText};
} else {
return {variant: chosenVariant, text: null};
}
};
/**
* Check if we should show text on screen automatically.
*
* The text should automatically be shown if the text is language-compatible
* with the user's text language preference, but not compatible with the audio.
*
* For example:
* preferred | chosen | chosen |
* text | text | audio | show
* -----------------------------------
* en-CA | en | jp | true
* en | en-US | fr | true
* fr-CA | en-US | jp | false
* en-CA | en-US | en-US | false
*
* @param {shaka.extern.Stream} audioStream
* @param {shaka.extern.Stream} textStream
* @return {boolean}
* @private
*/
shaka.Player.prototype.shouldShowText_ = function(audioStream, textStream) {
const areLanguageCompatible = shaka.util.LanguageUtils.areLanguageCompatible;
const normalize = shaka.util.LanguageUtils.normalize;
/** @type {string} */
const preferredTextLocale = normalize(this.config_.preferredTextLanguage);
/** @type {string} */
const audioLocale = normalize(audioStream.language);
/** @type {string} */
const textLocale = normalize(textStream.language);
return areLanguageCompatible(textLocale, preferredTextLocale) &&
!areLanguageCompatible(audioLocale, textLocale);
};
/**
* Callback from StreamingEngine, invoked when the period is set up.
*
* @private
*/
shaka.Player.prototype.canSwitch_ = function() {
shaka.log.debug('canSwitch_');
goog.asserts.assert(this.config_, 'Must not be destroyed');
this.switchingPeriods_ = false;
if (this.config_.abr.enabled) {
this.abrManager_.enable();
this.onAbrStatusChanged_();
}
// If we still have deferred switches, switch now.
if (this.deferredVariant_) {
this.streamingEngine_.switchVariant(
this.deferredVariant_, this.deferredVariantClearBuffer_,
this.deferredVariantClearBufferSafeMargin_);
this.deferredVariant_ = null;
}
if (this.deferredTextStream_) {
this.streamingEngine_.switchTextStream(this.deferredTextStream_);
this.deferredTextStream_ = null;
}
};
/**
* Callback from StreamingEngine.
*
* @private
*/
shaka.Player.prototype.onManifestUpdate_ = function() {
if (this.parser_ && this.parser_.update) {
this.parser_.update();
}
};
/**
* Callback from StreamingEngine.
*
* @private
*/
shaka.Player.prototype.onSegmentAppended_ = function() {
// When we append a segment to media source (via streaming engine) we are
// changing what data we have buffered, so notify the playhead of the change.
if (this.playhead_) {
this.playhead_.notifyOfBufferingChange();
}
};
/**
* Callback from AbrManager.
*
* @param {shaka.extern.Variant} variant
* @param {boolean=} clearBuffer
* @param {number=} safeMargin Optional amount of buffer (in seconds) to retain
* when clearing the buffer.
* Defaults to 0 if not provided. Ignored if clearBuffer is false.
* @private
*/
shaka.Player.prototype.switch_ = function(
variant, clearBuffer = false, safeMargin = 0) {
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!');
goog.asserts.assert(this.manifest_, 'We need a manifest to switch variants.');
const period = this.findPeriodWithVariant_(variant);
goog.asserts.assert(period, 'A period should contain the variant.');
this.addVariantToSwitchHistory_(period, variant, /* fromAdaptation */ true);
if (!this.streamingEngine_) {
// There's no way to change it.
return;
}
this.streamingEngine_.switchVariant(variant, clearBuffer, safeMargin);
this.onAdaptation_();
};
/**
* Dispatches an 'adaptation' event.
* @private
*/
shaka.Player.prototype.onAdaptation_ = function() {
// Delay the 'adaptation' event so that StreamingEngine has time to absorb
// the changes before the user tries to query it.
this.delayDispatchEvent_(new shaka.util.FakeEvent('adaptation'));
};
/**
* Dispatches a 'trackschanged' event.
* @private
*/
shaka.Player.prototype.onTracksChanged_ = function() {
// Delay the 'trackschanged' event so StreamingEngine has time to absorb the
// changes before the user tries to query it.
this.delayDispatchEvent_(new shaka.util.FakeEvent('trackschanged'));
};
/**
* Dispatches a 'variantchanged' event.
* @private
*/
shaka.Player.prototype.onVariantChanged_ = function() {
// Delay the 'variantchanged' event so StreamingEngine has time to absorb the
// changes before the user tries to query it.
this.delayDispatchEvent_(new shaka.util.FakeEvent('variantchanged'));
};
/**
* Dispatches a 'textchanged' event.
* @private
*/
shaka.Player.prototype.onTextChanged_ = function() {
// Delay the 'textchanged' event so StreamingEngine time to absorb the
// changes before the user tries to query it.
this.delayDispatchEvent_(new shaka.util.FakeEvent('textchanged'));
};
/** @private */
shaka.Player.prototype.onTextTrackVisibility_ = function() {
let event = new shaka.util.FakeEvent('texttrackvisibility');
this.dispatchEvent(event);
};
/** @private */
shaka.Player.prototype.onAbrStatusChanged_ = function() {
this.dispatchEvent(new shaka.util.FakeEvent('abrstatuschanged', {
newStatus: this.config_.abr.enabled,
}));
};
/**
* @param {!shaka.util.Error} error
* @private
*/
shaka.Player.prototype.onError_ = function(error) {
goog.asserts.assert(error instanceof shaka.util.Error, 'Wrong error type!');
// Errors dispatched after |destroy| is called are not meaningful and should
// be safe to ignore.
if (this.loadMode_ == shaka.Player.LoadMode.DESTROYED) { return; }
let event = new shaka.util.FakeEvent('error', {'detail': error});
this.dispatchEvent(event);
if (event.defaultPrevented) {
error.handled = true;
}
};
/**
* @param {!Event} event
* @private
*/
shaka.Player.prototype.onEvent_ = function(event) {
this.dispatchEvent(event);
};
/**
* When we fire region events, we need to copy the information out of the region
* to break the connection with the player's internal data. We do the copy here
* because this is the transition point between the player and the app.
*
* @param {string} eventName
* @param {shaka.extern.TimelineRegionInfo} region
*
* @private
*/
shaka.Player.prototype.onRegionEvent_ = function(eventName, region) {
// Always make a copy to avoid exposing our internal data to the app.
const clone = {
schemeIdUri: region.schemeIdUri,
value: region.value,
startTime: region.startTime,
endTime: region.endTime,
id: region.id,
eventElement: region.eventElement,
};
this.onEvent_(new shaka.util.FakeEvent(eventName, {detail: clone}));
};
/**
* Turn the media element's error object into a Shaka Player error object.
*
* @return {shaka.util.Error}
* @private
*/
shaka.Player.prototype.videoErrorToShakaError_ = function() {
goog.asserts.assert(this.video_.error, 'Video error expected, but missing!');
if (!this.video_.error) {
return null;
}
const 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 null;
}
// Extra error information from MS Edge and IE11:
let 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);
}
// Extra error information from Chrome:
const message = this.video_.error.message;
return new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MEDIA,
shaka.util.Error.Code.VIDEO_ERROR,
code, extended, message);
};
/**
* @param {!Event} event
* @private
*/
shaka.Player.prototype.onVideoError_ = function(event) {
const error = this.videoErrorToShakaError_();
if (!error) {
return;
}
this.onError_(error);
};
/**
* @param {!Object.<string, string>} keyStatusMap A map of hex key IDs to
* statuses.
* @private
*/
shaka.Player.prototype.onKeyStatus_ = function(keyStatusMap) {
if (!this.streamingEngine_) {
// We can't use this info to manage restrictions in src= mode, so ignore it.
return;
}
const restrictedStatuses = shaka.Player.restrictedStatuses_;
/** @type {shaka.extern.Period} */
const period = this.getPresentationPeriod_();
let tracksChanged = false;
let keyIds = Object.keys(keyStatusMap);
if (keyIds.length == 0) {
shaka.log.warning(
'Got a key status event without any key statuses, so we don\'t know ' +
'the real key statuses. If we don\'t have all the keys, you\'ll need ' +
'to set restrictions so we don\'t select those tracks.');
}
// If EME is using a synthetic key ID, the only key ID is '00' (a single 0
// byte). In this case, it is only used to report global success/failure.
// See note about old platforms in: https://bit.ly/2tpez5Z
let isGlobalStatus = keyIds.length == 1 && keyIds[0] == '00';
if (isGlobalStatus) {
shaka.log.warning(
'Got a synthetic key status event, so we don\'t know the real key ' +
'statuses. If we don\'t have all the keys, you\'ll need to set ' +
'restrictions so we don\'t select those tracks.');
}
// Only filter tracks for keys if we have some key statuses to look at.
if (keyIds.length) {
period.variants.forEach(function(variant) {
const streams = shaka.util.StreamUtils.getVariantStreams(variant);
streams.forEach(function(stream) {
let originalAllowed = variant.allowedByKeySystem;
// Only update if we have a key ID for the stream.
// If the key isn't present, then we don't have that key and the track
// should be restricted.
if (stream.keyId) {
let keyStatus = keyStatusMap[isGlobalStatus ? '00' : stream.keyId];
variant.allowedByKeySystem =
!!keyStatus && !restrictedStatuses.includes(keyStatus);
}
if (originalAllowed != variant.allowedByKeySystem) {
tracksChanged = true;
}
}); // streams.forEach
}); // period.variants.forEach
} // if (keyIds.length)
// TODO: Get StreamingEngine to track variants and create
// getBufferingVariant()
let activeAudio = this.streamingEngine_.getBufferingAudio();
let activeVideo = this.streamingEngine_.getBufferingVideo();
let activeVariant = shaka.util.StreamUtils.getVariantByStreams(
activeAudio, activeVideo, period.variants);
if (activeVariant && !activeVariant.allowedByKeySystem) {
shaka.log.debug('Choosing new streams after key status changed');
this.chooseStreamsAndSwitch_(period);
}
if (tracksChanged) {
this.onTracksChanged_();
this.chooseVariant_(period.variants);
}
};
/**
* Callback from DrmEngine
* @param {string} keyId
* @param {number} expiration
* @private
*/
shaka.Player.prototype.onExpirationUpdated_ = function(keyId, expiration) {
if (this.parser_ && this.parser_.onExpirationUpdated) {
this.parser_.onExpirationUpdated(keyId, expiration);
}
let event = new shaka.util.FakeEvent('expirationupdated');
this.dispatchEvent(event);
};
/**
* @return {boolean} true if we should stream text right now.
* @private
*/
shaka.Player.prototype.shouldStreamText_ = function() {
return this.config_.streaming.alwaysStreamText || this.isTextTrackVisible();
};
/**
* Applies playRangeStart and playRangeEnd to the given timeline. This will
* only affect non-live content.
*
* @param {shaka.media.PresentationTimeline} timeline
* @param {number} playRangeStart
* @param {number} playRangeEnd
*
* @private
*/
shaka.Player.applyPlayRange_ = function(timeline,
playRangeStart,
playRangeEnd) {
if (playRangeStart > 0) {
if (timeline.isLive()) {
shaka.log.warning(
'|playRangeStart| has been configured for live content. ' +
'Ignoring the setting.');
} else {
timeline.setUserSeekStart(playRangeStart);
}
}
// If the playback has been configured to end before the end of the
// presentation, update the duration unless it's live content.
const fullDuration = timeline.getDuration();
if (playRangeEnd < fullDuration) {
if (timeline.isLive()) {
shaka.log.warning(
'|playRangeEnd| has been configured for live content. ' +
'Ignoring the setting.');
} else {
timeline.setDuration(playRangeEnd);
}
}
};
/**
* Checks the given variants and if they are all restricted, throw an
* appropriate exception.
*
* @param {!Array.<shaka.extern.Variant>} variants
* @private
*/
shaka.Player.prototype.checkRestrictedVariants_ = function(variants) {
const restrictedStatuses = shaka.Player.restrictedStatuses_;
const keyStatusMap = this.drmEngine_ ? this.drmEngine_.getKeyStatuses() : {};
const keyIds = Object.keys(keyStatusMap);
const isGlobalStatus = keyIds.length && keyIds[0] == '00';
let hasPlayable = false;
let hasAppRestrict = false;
let missingKeys = [];
let badKeyStatuses = [];
for (let variant of variants) {
// TODO: Combine with onKeyStatus_.
let streams = [];
if (variant.audio) streams.push(variant.audio);
if (variant.video) streams.push(variant.video);
for (let stream of streams) {
if (stream.keyId) {
let keyStatus = keyStatusMap[isGlobalStatus ? '00' : stream.keyId];
if (!keyStatus) {
if (!missingKeys.includes(stream.keyId)) {
missingKeys.push(stream.keyId);
}
} else if (restrictedStatuses.includes(keyStatus)) {
if (!badKeyStatuses.includes(keyStatus)) {
badKeyStatuses.push(keyStatus);
}
}
}
}
if (!variant.allowedByApplication) {
hasAppRestrict = true;
} else if (variant.allowedByKeySystem) {
hasPlayable = true;
}
}
if (!hasPlayable) {
/** @type {shaka.extern.RestrictionInfo} */
let data = {
hasAppRestrictions: hasAppRestrict,
missingKeys: missingKeys,
restrictedKeyStatuses: badKeyStatuses,
};
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.RESTRICTIONS_CANNOT_BE_MET,
data);
}
};
/**
* Fire an event, but wait a little bit so that the immediate execution can
* complete before the event is handled.
*
* @param {!shaka.util.FakeEvent} event
* @private
*/
shaka.Player.prototype.delayDispatchEvent_ = async function(event) {
// Wait until the next interpreter cycle.
await Promise.resolve();
// Only dispatch the event if we are still alive.
if (this.loadMode_ != shaka.Player.LoadMode.DESTROYED) {
this.dispatchEvent(event);
}
};
/**
* Get the normalized languages for a group of tracks.
*
* @param {!Array.<?shaka.extern.Track>} tracks
* @return {!Set.<string>}
* @private
*/
shaka.Player.getLanguagesFrom_ = function(tracks) {
const languages = new Set();
for (const track of tracks) {
if (track.language) {
languages.add(shaka.util.LanguageUtils.normalize(track.language));
} else {
languages.add('und');
}
}
return languages;
};
/**
* Get all permutations of normalized languages and role for a group of tracks.
*
* @param {!Array.<?shaka.extern.Track>} tracks
* @return {!Array.<shaka.extern.LanguageRole>}
* @private
*/
shaka.Player.getLanguageAndRolesFrom_ = function(tracks) {
/** @type {!Map.<string, !Set>} */
const languageToRoles = new Map();
for (const track of tracks) {
let language = 'und';
let roles = [];
if (track.language) {
language = shaka.util.LanguageUtils.normalize(track.language);
}
if (track.type == 'variant') {
roles = track.audioRoles;
} else {
roles = track.roles;
}
if (!roles || !roles.length) {
// We must have an empty role so that we will still get a language-role
// entry from our Map.
roles = [''];
}
if (!languageToRoles.has(language)) {
languageToRoles.set(language, new Set());
}
for (const role of roles) {
languageToRoles.get(language).add(role);
}
}
// Flatten our map to an array of language-role pairs.
const pairings = [];
languageToRoles.forEach((roles, language) => {
for (const role of roles) {
pairings.push({
language: language,
role: role,
});
}
});
return pairings;
};
/**
* Get the variants that the user can select. The variants will be based on
* the period that the playhead is in and what variants are playable.
*
* @return {!Array.<shaka.extern.Variant>}
* @private
*/
shaka.Player.prototype.getSelectableVariants_ = function() {
// Use the period that is currently playing, allowing the change to affect
// the "now".
/** @type {shaka.extern.Period} */
const currentPeriod = this.getPresentationPeriod_();
// If we have been called before we load content or after we have unloaded
// content, then we should return no variants.
if (currentPeriod == null) { return []; }
this.assertCorrectActiveStreams_();
return currentPeriod.variants.filter((variant) => {
return shaka.util.StreamUtils.isPlayable(variant);
});
};
/**
* Get the text streams that the user can select. The streams will be based on
* the period that the playhead is in and what streams have finished loading.
*
* @return {!Array.<shaka.extern.Stream>}
* @private
*/
shaka.Player.prototype.getSelectableText_ = function() {
// Use the period that is currently playing, allowing the change to affect
// the "now".
/** @type {shaka.extern.Period} */
const currentPeriod = this.getPresentationPeriod_();
// If we have been called before we load content or after we have unloaded
// content, then we should return no streams.
if (currentPeriod == null) { return []; }
this.assertCorrectActiveStreams_();
// Don't show return streams that are still loading.
return currentPeriod.textStreams.filter((stream) => {
return !this.loadingTextStreams_.has(stream);
});
};
/**
* Get the period that is on the screen. This will return |null| if nothing
* is loaded.
*
* @return {shaka.extern.Period}
* @private
*/
shaka.Player.prototype.getPresentationPeriod_ = function() {
goog.asserts.assert(this.manifest_ && this.playhead_,
'Only ask for the presentation period when loaded with media source.');
const presentationTime = this.playhead_.getTime();
let lastPeriod = null;
// Periods are ordered by |startTime|. If we always keep the last period that
// started before our presentation time, it means we will have the best guess
// at which period we are presenting.
for (const period of this.manifest_.periods) {
if (period.startTime <= presentationTime) {
lastPeriod = period;
}
}
goog.asserts.assert(lastPeriod, 'Should have found a period.');
return lastPeriod;
};
/**
* Get the variant that we are currently presenting to the user. If we are not
* showing anything, then we will return |null|.
*
* @return {?shaka.extern.Variant}
* @private
*/
shaka.Player.prototype.getPresentationVariant_ = function() {
/** @type {shaka.extern.Period} */
const currentPeriod = this.getPresentationPeriod_();
return this.activeStreams_.getVariant(currentPeriod);
};
/**
* Get the text stream that we are either currently presenting to the user or
* will be presenting will captions are enabled. If we have no text to display,
* this will return |null|.
*
* @return {?shaka.extern.Stream}
* @private
*/
shaka.Player.prototype.getPresentationText_ = function() {
/** @type {shaka.extern.Period} */
const currentPeriod = this.getPresentationPeriod_();
// Can't have a text stream when there is no period.
if (currentPeriod == null) { return null; }
// This is a workaround for the demo page to be able to display the list of
// text tracks. If no text track is currently active, pick the one that's\
// going to be streamed when captions are enabled and mark it as active.
if (!this.activeStreams_.getText(currentPeriod)) {
const textStreams = shaka.util.StreamUtils.filterStreamsByLanguageAndRole(
currentPeriod.textStreams,
this.currentTextLanguage_,
this.currentTextRole_);
if (textStreams.length) {
this.activeStreams_.useText(currentPeriod, textStreams[0]);
}
}
return this.activeStreams_.getText(currentPeriod);
};
/**
* Assuming the player is playing content with media source, check if the player
* has buffered enough content to make it to the end of the presentation.
*
* @return {boolean}
* @private
*/
shaka.Player.prototype.isBufferedToEndMS_ = function() {
goog.asserts.assert(
this.video_,
'We need a video element to get buffering information');
goog.asserts.assert(
this.mediaSourceEngine_,
'We need a media source engine to get buffering information');
goog.asserts.assert(
this.manifest_,
'We need a manifest to get buffering information');
// This is a strong guarantee that we are buffered to the end, because it
// means the playhead is already at that end.
if (this.video_.ended) {
return true;
}
// This means that MediaSource has buffered the final segment in all
// SourceBuffers and is no longer accepting additional segments.
if (this.mediaSourceEngine_.ended()) {
return true;
}
// Live streams are "buffered to the end" when they have buffered to the live
// edge or beyond (into the region covered by the presentation delay).
if (this.manifest_.presentationTimeline.isLive()) {
const liveEdge =
this.manifest_.presentationTimeline.getSegmentAvailabilityEnd();
const bufferEnd =
shaka.media.TimeRangesUtils.bufferEnd(this.video_.buffered);
if (bufferEnd >= liveEdge) {
return true;
}
}
return false;
};
/**
* Assuming the player is playing content with src=, check if the player has
* buffered enough content to make it to the end of the presentation.
*
* @return {boolean}
* @private
*/
shaka.Player.prototype.isBufferedToEndSrc_ = function() {
goog.asserts.assert(
this.video_,
'We need a video element to get buffering information');
// This is a strong guarantee that we are buffered to the end, because it
// means the playhead is already at that end.
if (this.video_.ended) {
return true;
}
// If we have buffered to the duration of the content, it means we will have
// enough content to buffer to the end of the presentation.
const bufferEnd = shaka.media.TimeRangesUtils.bufferEnd(this.video_.buffered);
// Because Safari's native HLS reports slightly inaccurate values for
// bufferEnd here, we use a fudge factor. Without this, we can end up in a
// buffering state at the end of the stream.
// TODO: Try to remove the fudge here once we no longer manage buffering state
// above the browser with playbackRate=0.
const fudge = 0.1; // 100 ms
return bufferEnd >= this.video_.duration - fudge;
};
/**
* Find the period in |this.manifest_| that contains |variant|. If no period
* contains |variant| this will return |null|.
*
* @param {shaka.extern.Variant} variant
* @return {?shaka.extern.Period}
* @private
*/
shaka.Player.prototype.findPeriodWithVariant_ = function(variant) {
for (const period of this.manifest_.periods) {
if (period.variants.includes(variant)) {
return period;
}
}
return null;
};
/**
* Create an error for when we purposely interrupt a load operation.
*
* @return {!shaka.util.Error}
* @private
*/
shaka.Player.prototype.createAbortLoadError_ = function() {
return new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.PLAYER,
shaka.util.Error.Code.LOAD_INTERRUPTED);
};
/**
* Key
* ----------------------
* D : Detach Node
* A : Attach Node
* MS : Media Source Node
* P : Manifest Parser Node
* M : Manifest Node
* DRM : Drm Engine Node
* L : Load Node
* U : Unloading Node
* SRC : Src Equals Node
*
* Graph Topology
* ----------------------
*
* [SRC]-----+
* ^ |
* | v
* [D]<-->[A]<-----[U]
* | ^
* v |
* [MS]------+
* | |
* v |
* [P]-------+
* | |
* v |
* [M]-------+
* | |
* v |
* [DRM]-----+
* | |
* v |
* [L]-------+
*
* @param {!shaka.routing.Node} currentlyAt
* @param {shaka.routing.Payload} currentlyWith
* @param {!shaka.routing.Node} wantsToBeAt
* @param {shaka.routing.Payload} wantsToHave
* @return {?shaka.routing.Node}
* @private
*/
shaka.Player.prototype.getNextStep_ = function(
currentlyAt, currentlyWith, wantsToBeAt, wantsToHave) {
let next = null;
// Detach is very simple, either stay in detach (because |detach| was called
// while in detached) or go somewhere that requires us to attach to an
// element.
if (currentlyAt == this.detachNode_) {
next = wantsToBeAt == this.detachNode_ ?
this.detachNode_ :
this.attachNode_;
}
if (currentlyAt == this.attachNode_) {
next = this.getNextAfterAttach_(wantsToBeAt, currentlyWith, wantsToHave);
}
if (currentlyAt == this.mediaSourceNode_) {
next = this.getNextAfterMediaSource_(
wantsToBeAt, currentlyWith, wantsToHave);
}
if (currentlyAt == this.parserNode_) {
next = this.getNextMatchingAllDependencies_(
/* destination= */ this.loadNode_,
/* next= */ this.manifestNode_,
/* reset= */ this.unloadNode_,
/* goingTo= */ wantsToBeAt,
/* has= */ currentlyWith,
/* wants= */ wantsToHave);
}
if (currentlyAt == this.manifestNode_) {
next = this.getNextMatchingAllDependencies_(
/* destination= */ this.loadNode_,
/* next= */ this.drmNode_,
/* reset= */ this.unloadNode_,
/* goingTo= */ wantsToBeAt,
/* has= */ currentlyWith,
/* wants= */ wantsToHave);
}
// For DRM, we have two options "load" or "unload". If all our constraints are
// met, we can go to "load". If anything is off, we must go back to "unload"
// to reset.
if (currentlyAt == this.drmNode_) {
next = this.getNextMatchingAllDependencies_(
/* destination= */ this.loadNode_,
/* next= */ this.loadNode_,
/* reset= */ this.unloadNode_,
/* goingTo= */ wantsToBeAt,
/* has= */ currentlyWith,
/* wants= */ wantsToHave);
}
// For DRM w/ src= playback, we only care about destination and media element.
if (currentlyAt == this.srcEqualsDrmNode_) {
if (wantsToBeAt == this.srcEqualsNode_ &&
currentlyWith.mediaElement == wantsToHave.mediaElement) {
next = this.srcEqualsNode_;
} else {
next = this.unloadNode_;
}
}
// After we load content, always go through unload because we can't safely
// use components after we have started playback.
if (currentlyAt == this.loadNode_ || currentlyAt == this.srcEqualsNode_) {
next = this.unloadNode_;
}
if (currentlyAt == this.unloadNode_) {
next = this.getNextAfterUnload_(wantsToBeAt, currentlyWith, wantsToHave);
}
goog.asserts.assert(next, 'Missing next step!');
return next;
};
/**
* @param {!shaka.routing.Node} goingTo
* @param {shaka.routing.Payload} has
* @param {shaka.routing.Payload} wants
* @return {?shaka.routing.Node}
* @private
*/
shaka.Player.prototype.getNextAfterAttach_ = function(goingTo, has, wants) {
// Attach and detach are the only two nodes that we can directly go
// back-and-forth between.
if (goingTo == this.detachNode_) { return this.detachNode_; }
// If we are going anywhere other than detach, then we need the media element
// to match, if they don't match, we need to go through detach first.
if (has.mediaElement != wants.mediaElement) { return this.detachNode_; }
// If we are already in attached, and someone calls |attach| again (to the
// same video element), we can handle the redundant request by re-entering
// our current state.
if (goingTo == this.attachNode_) { return this.attachNode_; }
// The next step from attached to loaded is through media source.
if (goingTo == this.mediaSourceNode_ || goingTo == this.loadNode_) {
return this.mediaSourceNode_;
}
// If we are going to src=, then we should set up DRM first. This will
// support cases like FairPlay HLS on Safari.
if (goingTo == this.srcEqualsNode_) {
return this.srcEqualsDrmNode_;
}
// We are missing a rule, the null will get caught by a common check in
// the routing system.
return null;
};
/**
* @param {!shaka.routing.Node} goingTo
* @param {shaka.routing.Payload} has
* @param {shaka.routing.Payload} wants
* @return {?shaka.routing.Node}
* @private
*/
shaka.Player.prototype.getNextAfterMediaSource_ = function(
goingTo, has, wants) {
// We can only go to parse manifest or unload. If we want to go to load and
// we have the right media element, we can go to parse manifest. If we don't,
// no matter where we want to go, we must go through unload.
if (goingTo == this.loadNode_ && has.mediaElement == wants.mediaElement) {
return this.parserNode_;
}
// Right now the unload node is responsible for tearing down all playback
// components (including media source). So since we have created media
// source, we need to unload since our dependencies are not compatible.
//
// TODO: We are structured this way to maintain a historic structure. Going
// forward, there is no reason to restrict ourselves to this. Going
// forward we should explore breaking apart |onUnload| and develop
// more meaningful terminology around tearing down playback resources.
return this.unloadNode_;
};
/**
* After unload there are only two options, attached or detached. This choice is
* based on whether or not we have a media element. If we have a media element,
* then we go to attach. If we don't have a media element, we go to detach.
*
* @param {!shaka.routing.Node} goingTo
* @param {shaka.routing.Payload} has
* @param {shaka.routing.Payload} wants
* @return {?shaka.routing.Node}
* @private
*/
shaka.Player.prototype.getNextAfterUnload_ = function(goingTo, has, wants) {
// If we don't want a media element, detach.
// If we have the wrong media element, detach.
// Otherwise it means we want to attach to a media element and it is safe to
// do so.
return !wants.mediaElement || has.mediaElement != wants.mediaElement ?
this.detachNode_ :
this.attachNode_;
};
/**
* A general method used to handle routing when we can either than one step
* toward our destination (while all our dependencies match) or go to a node
* that will reset us so we can try again.
*
* @param {!shaka.routing.Node} destinationNode
* What |goingTo| must be for us to step toward |nextNode|. Otherwise we will
* go to |resetNode|.
* @param {!shaka.routing.Node} nextNode
* The node we will go to next if |goingTo == destinationNode| and all
* dependencies match.
* @param {!shaka.routing.Node} resetNode
* The node we will go to next if |goingTo != destinationNode| or any
* dependency does not match.
* @param {!shaka.routing.Node} goingTo
* The node that the walker is trying to go to.
* @param {shaka.routing.Payload} has
* The payload that the walker currently has.
* @param {shaka.routing.Payload} wants
* The payload that the walker wants to have when iy gets to |goingTo|.
* @return {shaka.routing.Node}
* @private
*/
shaka.Player.prototype.getNextMatchingAllDependencies_ = function(
destinationNode, nextNode, resetNode, goingTo, has, wants) {
if (goingTo == destinationNode &&
has.mediaElement == wants.mediaElement &&
has.uri == wants.uri &&
has.mimeType == wants.mimeType &&
has.factory == wants.factory) {
return nextNode;
}
return resetNode;
};
/**
* @return {shaka.routing.Payload}
* @private
*/
shaka.Player.prototype.createEmptyPayload_ = function() {
return {
factory: null,
mediaElement: null,
mimeType: null,
startTime: null,
startTimeOfLoad: null,
uri: null,
};
};
/**
* Using a promise, wrap the listeners returned by |Walker.startNewRoute|. This
* will work for most usages in |Player| but should not be used for special
* cases.
*
* This will connect |onCancel|, |onEnd|, |onError|, and |onSkip| with |resolve|
* and |reject| but will leave |onStart| unset.
*
* @param {shaka.routing.Walker.Listeners} listeners
* @return {!Promise}
* @private
*/
shaka.Player.prototype.wrapWalkerListenersWithPromise_ = function(listeners) {
return new Promise((resolve, reject) => {
listeners.onCancel = () => reject(this.createAbortLoadError_());
listeners.onEnd = () => resolve();
listeners.onError = (e) => reject(e);
listeners.onSkip = () => reject(this.createAbortLoadError_());
});
};
/**
* In order to know what method of loading the player used for some content, we
* have this enum. It lets us know if content has not been loaded, loaded with
* media source, or loaded with src equals.
*
* This enum has a low resolution, because it is only meant to express the
* outer limits of the various states that the player is in. For example, when
* someone calls a public method on player, it should not matter if they have
* initialized drm engine, it should only matter if they finished loading
* content.
*
* @enum {number}
* @export
*/
shaka.Player.LoadMode = {
'DESTROYED': 0,
'NOT_LOADED': 1,
'MEDIA_SOURCE': 2,
'SRC_EQUALS': 3,
};
/**
* The typical buffering threshold. When we have less than this buffered (in
* seconds), we enter a buffering state. This specific value is based on manual
* testing and evaluation across a variety of platforms.
*
* To make the buffering logic work in all cases, this "typical" threshold will
* be overridden if the rebufferingGoal configuration is too low.
*
* @const {number}
* @private
*/
shaka.Player.TYPICAL_BUFFERING_THRESHOLD_ = 0.5;