}
* @export
*/
static async probeSupport() {
goog.asserts.assert(shaka.Player.isBrowserSupported(),
'Must have basic support');
const drm = await shaka.media.DrmEngine.probeSupport();
const manifest = shaka.media.ManifestParser.probeSupport();
const media = shaka.media.MediaSourceEngine.probeSupport();
const ret = {
manifest: manifest,
media: media,
drm: drm,
};
const plugins = shaka.Player.supportPlugins_;
for (const 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
*/
attach(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 = shaka.Player.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
*/
detach() {
// 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: shaka.Player.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
*/
unload(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 = shaka.Player.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 (0
* for VOD and liveEdge for LIVE).
* @param {string=} mimeType
* @return {!Promise}
* @export
*/
load(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(this.makeEvent_(shaka.Player.EventName.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 = shaka.Player.createEmptyPayload_();
payload.uri = assetUri;
payload.startTimeOfLoad = Date.now() / 1000;
if (mimeType) {
payload.mimeType = 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,
};
});
// Stats are for a single playback/load session. Stats must be initialized
// before we allow calls to |updateStateHistory|.
this.stats_ = new shaka.util.Stats();
// 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();
// We dispatch the loaded event when the load promise is resolved
this.dispatchEvent(this.makeEvent_(shaka.Player.EventName.Loaded));
};
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
*/
shouldUseSrcEquals_(payload) {
const Platform = shaka.util.Platform;
// 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
*/
onAttach_(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
*/
onDetach_(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
*/
async onUnload_(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(this.makeEvent_(shaka.Player.EventName.Unloading));
// Remove everything that has to do with loading content from our payload
// since we are releasing everything that depended on it.
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;
this.parserFactory_ = 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;
}
if (this.adManager_) {
this.adManager_.onAssetUnload();
}
// In order to unload a media element, we need to remove the src attribute
// 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.assetUri_ = null;
this.bufferObserver_ = null;
if (this.manifest_) {
for (const variant of this.manifest_.variants) {
for (const stream of [variant.audio, variant.video]) {
if (stream && stream.segmentIndex) {
stream.segmentIndex.release();
}
}
}
for (const stream of this.manifest_.textStreams) {
if (stream.segmentIndex) {
stream.segmentIndex.release();
}
}
}
this.manifest_ = null;
this.stats_ = new shaka.util.Stats(); // Replace with a clean stats object.
this.lastTextFactory_ = null;
// 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
*/
async onInitializeMediaSourceEngine_(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 =
shaka.util.Functional.callFactory(textDisplayerFactory);
this.lastTextFactory_ = textDisplayerFactory;
const mediaSourceEngine = this.createMediaSourceEngine(
has.mediaElement,
closedCaptionsParser,
textDisplayer,
(metadata, offset, endTime) => {
this.processTimedMetadataMediaSrc_(metadata, offset, endTime);
});
// 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
*/
async onInitializeParser_(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 mime type and uri since they are
// what we are going to use to create our parser and parse the manifest.
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.
this.parserFactory_ = await shaka.media.ManifestParser.getFactory(
assetUri,
networkingEngine,
this.config_.manifest.retryParameters,
has.mimeType);
goog.asserts.assert(this.parserFactory_, 'Must have manifest parser');
this.parser_ = shaka.util.Functional.callFactory(this.parserFactory_);
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 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
*/
onParseManifest_(has, wants) {
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_(shaka.Player.EventName.TimelineRegionAdded, region);
if (this.adManager_) {
this.adManager_.onDashTimedMetadata(region);
}
});
// TODO (#1391): Once filterManifest_ is async, remove this eslint disable.
/* eslint-disable require-await */
const playerInterface = {
networkingEngine: networkingEngine,
filter: async (manifest) => this.filterManifest_(manifest),
// 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),
};
/* eslint-enable require-await */
const startTime = Date.now() / 1000;
return new shaka.util.AbortableOperation(/* promise= */ (async () => {
this.manifest_ = await this.parser_.start(assetUri, playerInterface);
// This event is fired after the manifest is parsed, but before any
// filtering takes place.
const event = this.makeEvent_(shaka.Player.EventName.ManifestParsed);
this.dispatchEvent(event);
// We require all manifests to have at least one variant.
if (this.manifest_.variants.length == 0) {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.NO_VARIANTS);
}
// Make sure that all variants are either: audio-only, video-only, or
// audio-video.
shaka.Player.filterForAVVariants_(this.manifest_);
const now = Date.now() / 1000;
const delta = now - startTime;
this.stats_.setManifestTime(delta);
})(), /* 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}
* @private
*/
async onInitializeDrm_(has, wants) {
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.');
const startTime = Date.now() / 1000;
let firstEvent = true;
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);
if (e.type == shaka.Player.EventName.DrmSessionUpdate && firstEvent) {
firstEvent = false;
const now = Date.now() / 1000;
const delta = now - startTime;
this.stats_.setDrmTime(delta);
}
},
});
this.drmEngine_.configure(this.config_.drm);
await this.drmEngine_.initForPlayback(
this.manifest_.variants,
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.filterManifest_(this.manifest_);
}
/**
* 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
*/
async onLoad_(has, wants) {
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(
!isNaN(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;
this.playRateController_ = new shaka.media.PlayRateController({
getRate: () => has.mediaElement.playbackRate,
setRate: (rate) => { has.mediaElement.playbackRate = rate; },
movePlayhead: (delta) => { has.mediaElement.currentTime += delta; },
});
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 abrFactory = this.config_.abrFactory;
if (!this.abrManager_ || this.abrManagerFactory_ != abrFactory) {
this.abrManagerFactory_ = abrFactory;
this.abrManager_ = shaka.util.Functional.callFactory(abrFactory);
if (typeof this.abrManager_.playbackRateChanged != 'function') {
shaka.Deprecate.deprecateFeature(4,
'AbrManager',
'Please use an AbrManager with playbackRateChanged function.');
this.abrManager_.playbackRateChanged = (rate) => {};
}
this.abrManager_.configure(this.config_.abr);
}
// TODO: When a manifest update adds a new variant, that variant's closed
// captions should also be turned into text streams. This should be called
// for each new variant as well.
this.createTextStreamsForClosedCaptions_(this.manifest_.variants);
// 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_();
// 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);
// If the content is multi-codec and the browser can play more than one of
// them, choose codecs now before we initialize streaming.
shaka.util.StreamUtils.chooseCodecsAndFilterManifest(
this.manifest_, this.config_.preferredAudioChannelCount);
this.streamingEngine_ = this.createStreamingEngine();
this.streamingEngine_.configure(this.config_.streaming);
// 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(this.makeEvent_(shaka.Player.EventName.Streaming));
// Pick the initial streams to play.
const initialVariant = this.chooseVariant_();
goog.asserts.assert(initialVariant, 'Must choose an initial variant!');
this.addVariantToSwitchHistory_(
initialVariant, /* fromAdaptation= */ true);
this.streamingEngine_.switchVariant(
initialVariant, /* clearBuffer= */ false, /* safeMargin= */ 0);
// Decide if text should be shown automatically.
const initialTextStream = this.chooseTextStream_();
if (initialTextStream) {
this.addTextStreamToSwitchHistory_(
initialTextStream, /* fromAdaptation= */ true);
}
this.setInitialTextState_(initialVariant, initialTextStream);
// Don't initialize with a text stream unless we should be streaming text.
if (initialTextStream && this.shouldStreamText_()) {
this.streamingEngine_.switchTextStream(initialTextStream);
}
// Now that we have initial streams, we may adjust the start time to align
// to a segment boundary.
if (this.config_.streaming.startAtSegmentBoundary) {
const startTime = this.playhead_.getTime();
const adjustedTime =
await this.adjustStartTime_(initialVariant, startTime);
this.playhead_.setStartTime(adjustedTime);
}
// Start streaming content. This will start the flow of content down to
// media source.
await this.streamingEngine_.start();
if (this.config_.abr.enabled) {
this.abrManager_.enable();
this.onAbrStatusChanged_();
}
// Re-filter the manifest after streams have been chosen.
this.filterManifest_(this.manifest_);
// 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.
// NOTE: This may be unnecessary. We've already chosen one codec in
// chooseCodecsAndFilterManifest_ before we started streaming. But it
// doesn't hurt, and this will all change when we start using
// MediaCapabilities and codec switching.
// TODO(#1391): Re-evaluate with MediaCapabilities and codec switching.
this.updateAbrManagerVariants_();
const hasPrimary = this.manifest_.variants.some((v) => v.primary);
if (!this.config_.preferredAudioLanguage && !hasPrimary) {
shaka.log.warning('No preferred audio language set. We have chosen an ' +
'arbitrary language initially');
}
// Wait for the 'loadedmetadata' event to measure load() latency.
this.eventManager_.listenOnce(mediaElement, 'loadedmetadata', () => {
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}
* @private
*/
async onInitializeSrcEqualsDrm_(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|');
const startTime = Date.now() / 1000;
let firstEvent = true;
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);
if (e.type == shaka.Player.EventName.DrmSessionUpdate && firstEvent) {
firstEvent = false;
const now = Date.now() / 1000;
const delta = now - startTime;
this.stats_.setDrmTime(delta);
}
},
});
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(),
segmentIndex: null,
mimeType: 'video/mp4',
codecs: '',
encrypted: true,
drmInfos: [], // Filled in by DrmEngine config.
keyIds: new Set(),
language: 'und',
label: null,
type: ContentType.VIDEO,
primary: false,
trickModeVideo: null,
emsgSchemeIdUris: null,
roles: [],
channelsCount: null,
audioSamplingRate: null,
closedCaptions: null,
},
bandwidth: 100,
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
*/
onSrcEquals_(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(
!isNaN(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;
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_();
const onRateChange = () => this.onRateChange_();
this.eventManager_.listen(has.mediaElement, 'playing', updateStateHistory);
this.eventManager_.listen(has.mediaElement, 'pause', updateStateHistory);
this.eventManager_.listen(has.mediaElement, 'ended', updateStateHistory);
this.eventManager_.listen(has.mediaElement, 'ratechange', onRateChange);
// Wait for the 'loadedmetadata' event to measure load() latency, but only
// if preload is set in a way that would result in this event firing
// automatically. See https://github.com/google/shaka-player/issues/2483
if (this.video_.preload != 'none') {
this.eventManager_.listenOnce(this.video_, 'loadedmetadata', () => {
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_());
this.eventManager_.listen(
this.video_.audioTracks, 'change', () => this.onTracksChanged_());
}
if (this.video_.textTracks) {
this.eventManager_.listen(this.video_.textTracks, 'addtrack', (e) => {
this.onTracksChanged_();
this.processTimedMetadataSrcEqls_(/** @type {!TrackEvent} */(e));
});
this.eventManager_.listen(
this.video_.textTracks, 'removetrack', () => this.onTracksChanged_());
this.eventManager_.listen(
this.video_.textTracks, 'change', () => 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(this.makeEvent_(shaka.Player.EventName.Streaming));
// The "load" Promise is resolved when we have loaded the metadata. If we
// wait for the full data, that won't happen on Safari until the play button
// is hit.
const fullyLoaded = new shaka.util.PublicPromise();
shaka.util.MediaReadyState.waitForReadyState(this.video_,
HTMLMediaElement.HAVE_METADATA,
this.eventManager_,
() => {
fullyLoaded.resolve();
});
// This flag is used below in the language preference setup to check if this
// load was canceled before the necessary events fire.
let unloaded = false;
this.cleanupOnUnload_.push(() => {
unloaded = true;
});
// We can't switch to preferred languages, though, until the data is loaded.
shaka.util.MediaReadyState.waitForReadyState(this.video_,
HTMLMediaElement.HAVE_CURRENT_DATA,
this.eventManager_,
async () => {
// If we have moved on to another piece of content while waiting for
// the above event, we should not change tracks here.
if (unloaded) {
return;
}
this.setupPreferredAudioOnSrc_();
// Applying the text preference too soon can result in it being
// reverted. Wait for native HLS to pick something first.
const textTracks = this.filterTextTracks_();
if (!textTracks.find((t) => t.mode != 'disabled')) {
await new Promise((resolve) => {
this.eventManager_.listenOnce(
this.video_.textTracks, 'change', resolve);
// We expect the event to fire because it does on Safari.
// But in case it doesn't on some other platform or future
// version, move on in 1 second no matter what. This keeps the
// language settings from being completely ignored if something
// goes wrong.
new shaka.util.Timer(resolve).tickAfter(1);
});
}
// If we have moved on to another piece of content while waiting for
// the above event/timer, we should not change tracks here.
if (unloaded) {
return;
}
this.setupPreferredTextOnSrc_();
});
if (this.video_.error) {
// Already failed!
fullyLoaded.reject(this.videoErrorToShakaError_());
} else if (this.video_.preload == 'none') {
shaka.log.alwaysWarn(
'With