shaka-player
Version:
DASH/EME video player library
1,483 lines (1,269 loc) • 188 kB
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.LanguageUtils');
goog.require('shaka.util.ManifestParserUtils');
goog.require('shaka.util.MediaReadyState');
goog.require('shaka.util.MimeUtils');
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');
goog.require('shaka.util.Timer');
/**
* 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_();
/**
* The TextDisplayerFactory that was last used to make a text displayer.
* Stored so that we can tell if a new type of text displayer is desired.
* @private {?shaka.extern.TextDisplayer.Factory}
*/
this.lastTextFactory_;
/** @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;
/** @private {!Array.<function():(!Promise|undefined)>} */
this.cleanupOnUnload_ = [];
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();
// We dispatch the loaded event when the load promise is resolved
this.dispatchEvent(new shaka.util.FakeEvent('loaded'));
};
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.20-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.LoadedEvent
* @description Fired when the player ends the load.
* @property {string} type
* 'loaded'
* @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;
// We do not support iOS 9, 10, or 11, nor those same versions of desktop
// Safari.
const safariVersion = shaka.util.Platform.safariVersion();
if (safariVersion && safariVersion < 12) {
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',
'weba': 'audio/webm',
'mkv': 'video/webm', // Chromium browsers supports it.
'ts': 'video/mp2t',
'ogv': 'video/ogg',
'ogg': 'audio/ogg',
'mpg': 'video/mpeg',
'mpeg': 'video/mpeg',
'm3u8': 'application/x-mpegurl',
'mp3': 'audio/mpeg',
'aac': 'audio/aac',
'flac': 'audio/flac',
'wav': 'audio/wav',
}[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 mediaElement = payload.mediaElement || Platform.anyMediaElement();
const canPlayNatively = mediaElement.canPlayType(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. For Safari, the choice is governed by the
// useNativeHlsOnSafari setting of the streaming config.
return Platform.isApple() && this.config_.streaming.useNativeHlsOnSafari;
}
// 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;
}
// Run any general cleanup tasks now. This should be here at the top, right
// after setting loadMode_, so that internal components still exist as they
// did when the cleanup tasks were registered in the array.
const cleanupTasks = this.cleanupOnUnload_.map((cb) => cb());
this.cleanupOnUnload_ = [];
await Promise.all(cleanupTasks);
// Dispatch the unloading event.
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.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, 'loadedmetadata');
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;
}
if (this.playRateController_) {
this.playRateController_.release();
this.playRateController_ = 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) {
// TODO: Investigate this more. Only reproduces on Firefox 69.
// Introduce a delay before detaching the video source. We are seeing
// spurious Promise rejections involving an AbortError in our tests
// otherwise.
await new Promise(
(resolve) => new shaka.util.Timer(resolve).tickAfter(0.1));
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.lastTextFactory_ = 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();
this.lastTextFactory_ = 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);
}
const manifestConfig =
shaka.util.ObjectUtils.cloneObject(this.config_.manifest);
// Don't read video segments if the player is attached to an audio element
if (wants.mediaElement && wants.mediaElement.nodeName === 'AUDIO') {
manifestConfig.disableVideo = true;
}
this.parser_.configure(manifestConfig);
};
/**
* Parse the manifest