Files
shaka-player/lib/cast/cast_sender.js
T
Joey Parrish f539147d48 fix: Correct license headers in compiled output
This fixes all the license headers in the main library, which corrects
the appearance of the main license in the compiled output.

It seems that the `!` in the header forces the compiler to keep it in
the output.  I believe older compiler releases did this purely based
on `@license`.

Issue #2638

Change-Id: I7f0e918caad10c9af689c9d07672b7fe9be7b2f3
2020-06-09 16:05:09 -07:00

762 lines
22 KiB
JavaScript

/*! @license
* Shaka Player
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
goog.provide('shaka.cast.CastSender');
goog.require('goog.asserts');
goog.require('shaka.cast.CastUtils');
goog.require('shaka.log');
goog.require('shaka.util.Error');
goog.require('shaka.util.FakeEvent');
goog.require('shaka.util.IDestroyable');
goog.require('shaka.util.PublicPromise');
/**
* @implements {shaka.util.IDestroyable}
*/
shaka.cast.CastSender = class {
/**
* @param {string} receiverAppId The ID of the cast receiver application.
* @param {function()} onStatusChanged A callback invoked when the cast status
* changes.
* @param {function()} onFirstCastStateUpdate A callback invoked when an
* "update" event has been received for the first time.
* @param {function(string, !shaka.util.FakeEvent)} onRemoteEvent A callback
* invoked with target name and event when a remote event is received.
* @param {function()} onResumeLocal A callback invoked when the local player
* should resume playback. Called before the cached remote state is wiped.
* @param {function()} onInitStateRequired A callback to get local player's.
* state. Invoked when casting is initiated from Chrome's cast button.
*/
constructor(receiverAppId, onStatusChanged, onFirstCastStateUpdate,
onRemoteEvent, onResumeLocal, onInitStateRequired) {
/** @private {string} */
this.receiverAppId_ = receiverAppId;
/** @private {shaka.util.Timer} */
this.statusChangeTimer_ = new shaka.util.Timer(onStatusChanged);
/** @private {?function()} */
this.onFirstCastStateUpdate_ = onFirstCastStateUpdate;
/** @private {boolean} */
this.hasJoinedExistingSession_ = false;
/** @private {?function(string, !shaka.util.FakeEvent)} */
this.onRemoteEvent_ = onRemoteEvent;
/** @private {?function()} */
this.onResumeLocal_ = onResumeLocal;
/** @private {?function()} */
this.onInitStateRequired_ = onInitStateRequired;
/** @private {boolean} */
this.apiReady_ = false;
/** @private {boolean} */
this.isCasting_ = false;
/** @private {string} */
this.receiverName_ = '';
/** @private {Object} */
this.appData_ = null;
/** @private {?function()} */
this.onConnectionStatusChangedBound_ =
() => this.onConnectionStatusChanged_();
/** @private {?function(string, string)} */
this.onMessageReceivedBound_ = (namespace, serialized) =>
this.onMessageReceived_(namespace, serialized);
/** @private {Object} */
this.cachedProperties_ = {
'video': {},
'player': {},
};
/** @private {number} */
this.nextAsyncCallId_ = 0;
/** @private {Object.<string, !shaka.util.PublicPromise>} */
this.asyncCallPromises_ = {};
/** @private {shaka.util.PublicPromise} */
this.castPromise_ = null;
shaka.cast.CastSender.instances_.add(this);
}
/** @override */
destroy() {
shaka.cast.CastSender.instances_.delete(this);
this.rejectAllPromises_();
if (shaka.cast.CastSender.session_) {
this.removeListeners_();
// Don't leave the session, so that this session can be re-used later if
// necessary.
}
if (this.statusChangeTimer_) {
this.statusChangeTimer_.stop();
this.statusChangeTimer_ = null;
}
this.onRemoteEvent_ = null;
this.onResumeLocal_ = null;
this.apiReady_ = false;
this.isCasting_ = false;
this.appData_ = null;
this.cachedProperties_ = null;
this.asyncCallPromises_ = null;
this.castPromise_ = null;
this.onConnectionStatusChangedBound_ = null;
this.onMessageReceivedBound_ = null;
return Promise.resolve();
}
/**
* @return {boolean} True if the cast API is available.
*/
apiReady() {
return this.apiReady_;
}
/**
* @return {boolean} True if there are receivers.
*/
hasReceivers() {
return shaka.cast.CastSender.hasReceivers_;
}
/**
* @return {boolean} True if we are currently casting.
*/
isCasting() {
return this.isCasting_;
}
/**
* @return {string} The name of the Cast receiver device, if isCasting().
*/
receiverName() {
return this.receiverName_;
}
/**
* @return {boolean} True if we have a cache of remote properties from the
* receiver.
*/
hasRemoteProperties() {
return Object.keys(this.cachedProperties_['video']).length != 0;
}
/** Initialize the Cast API. */
init() {
const CastSender = shaka.cast.CastSender;
// Check for the cast API and the receiver id.
if (!window.chrome || !chrome.cast || !chrome.cast.isAvailable ||
!this.receiverAppId_.length) {
// Return if either:
// 1) The API is not available on this platform or is not ready yet.
// 2) No cast receiver id has been provided.
// If the API becomes available before this instance dies, or the reveiver
// id changes, init() will be called again.
return;
}
// The API is now available.
this.apiReady_ = true;
this.statusChangeTimer_.tickNow();
// Use static versions of the API callbacks, since the ChromeCast API is
// static. If we used local versions, we might end up retaining references
// to destroyed players here.
const sessionRequest = new chrome.cast.SessionRequest(this.receiverAppId_);
const apiConfig = new chrome.cast.ApiConfig(sessionRequest,
(session) => CastSender.onExistingSessionJoined_(session),
(availability) => CastSender.onReceiverStatusChanged_(availability),
'origin_scoped');
// TODO: Have never seen this fail. When would it and how should we react?
chrome.cast.initialize(apiConfig,
() => { shaka.log.debug('CastSender: init'); },
(error) => { shaka.log.error('CastSender: init error', error); });
if (shaka.cast.CastSender.hasReceivers_) {
// Fire a fake cast status change, to simulate the update that
// would be fired normally.
// This is after a brief delay, to give users a chance to add event
// listeners.
this.statusChangeTimer_.tickAfter(shaka.cast.CastSender.STATUS_DELAY);
}
const oldSession = shaka.cast.CastSender.session_;
if (oldSession && oldSession.status != chrome.cast.SessionStatus.STOPPED) {
// The old session still exists, so re-use it.
shaka.log.debug('CastSender: re-using existing connection');
this.onExistingSessionJoined_(oldSession);
} else {
// The session has been canceled in the meantime, so ignore it.
shaka.cast.CastSender.session_ = null;
}
}
/**
* Set application-specific data.
*
* @param {Object} appData Application-specific data to relay to the receiver.
*/
setAppData(appData) {
this.appData_ = appData;
if (this.isCasting_) {
this.sendMessage_({
'type': 'appData',
'appData': this.appData_,
});
}
}
/**
* @param {shaka.cast.CastUtils.InitStateType} initState Video and player
* state to be sent to the receiver.
* @return {!Promise} Resolved when connected to a receiver. Rejected if the
* connection fails or is canceled by the user.
*/
async cast(initState) {
if (!this.apiReady_) {
throw new shaka.util.Error(
shaka.util.Error.Severity.RECOVERABLE,
shaka.util.Error.Category.CAST,
shaka.util.Error.Code.CAST_API_UNAVAILABLE);
}
if (!shaka.cast.CastSender.hasReceivers_) {
throw new shaka.util.Error(
shaka.util.Error.Severity.RECOVERABLE,
shaka.util.Error.Category.CAST,
shaka.util.Error.Code.NO_CAST_RECEIVERS);
}
if (this.isCasting_) {
throw new shaka.util.Error(
shaka.util.Error.Severity.RECOVERABLE,
shaka.util.Error.Category.CAST,
shaka.util.Error.Code.ALREADY_CASTING);
}
this.castPromise_ = new shaka.util.PublicPromise();
chrome.cast.requestSession(
(session) => this.onSessionInitiated_(initState, session),
(error) => this.onConnectionError_(error));
await this.castPromise_;
}
/**
* Shows user a cast dialog where they can choose to stop
* casting. Relies on Chrome to perform disconnect if they do.
* Doesn't do anything if not connected.
*/
showDisconnectDialog() {
if (!this.isCasting_) {
return;
}
const initState = this.onInitStateRequired_();
chrome.cast.requestSession(
(session) => this.onSessionInitiated_(initState, session),
(error) => this.onConnectionError_(error));
}
/**
* Forces the receiver app to shut down by disconnecting. Does nothing if not
* connected.
*/
forceDisconnect() {
if (!this.isCasting_) {
return;
}
this.rejectAllPromises_();
if (shaka.cast.CastSender.session_) {
this.removeListeners_();
// This can throw if we've already been disconnected somehow.
try {
shaka.cast.CastSender.session_.stop(() => {}, () => {});
} catch (error) {}
shaka.cast.CastSender.session_ = null;
}
// Update casting status.
this.onConnectionStatusChanged_();
}
/**
* Getter for properties of remote objects.
* @param {string} targetName
* @param {string} property
* @return {?}
*/
get(targetName, property) {
goog.asserts.assert(targetName == 'video' || targetName == 'player',
'Unexpected target name');
const CastUtils = shaka.cast.CastUtils;
if (targetName == 'video') {
if (CastUtils.VideoVoidMethods.includes(property)) {
return (...args) => this.remoteCall_(targetName, property, ...args);
}
} else if (targetName == 'player') {
if (CastUtils.PlayerGetterMethodsThatRequireLive[property]) {
const isLive = this.get('player', 'isLive')();
goog.asserts.assert(isLive,
property + ' should be called on a live stream!');
// If the property shouldn't exist, return a fake function so that the
// user doesn't call an undefined function and get a second error.
if (!isLive) {
return () => undefined;
}
}
if (CastUtils.PlayerVoidMethods.includes(property)) {
return (...args) => this.remoteCall_(targetName, property, ...args);
}
if (CastUtils.PlayerPromiseMethods.includes(property)) {
return (...args) =>
this.remoteAsyncCall_(targetName, property, ...args);
}
if (CastUtils.PlayerGetterMethods[property]) {
return () => this.propertyGetter_(targetName, property);
}
}
return this.propertyGetter_(targetName, property);
}
/**
* Setter for properties of remote objects.
* @param {string} targetName
* @param {string} property
* @param {?} value
*/
set(targetName, property, value) {
goog.asserts.assert(targetName == 'video' || targetName == 'player',
'Unexpected target name');
this.cachedProperties_[targetName][property] = value;
this.sendMessage_({
'type': 'set',
'targetName': targetName,
'property': property,
'value': value,
});
}
/**
* @param {shaka.cast.CastUtils.InitStateType} initState
* @param {chrome.cast.Session} session
* @private
*/
onSessionInitiated_(initState, session) {
shaka.log.debug('CastSender: onSessionInitiated');
this.onSessionCreated_(session);
this.sendMessage_({
'type': 'init',
'initState': initState,
'appData': this.appData_,
});
this.castPromise_.resolve();
}
/**
* @param {chrome.cast.Error} error
* @private
*/
onConnectionError_(error) {
// Default error code:
let code = shaka.util.Error.Code.UNEXPECTED_CAST_ERROR;
switch (error.code) {
case 'cancel':
code = shaka.util.Error.Code.CAST_CANCELED_BY_USER;
break;
case 'timeout':
code = shaka.util.Error.Code.CAST_CONNECTION_TIMED_OUT;
break;
case 'receiver_unavailable':
code = shaka.util.Error.Code.CAST_RECEIVER_APP_UNAVAILABLE;
break;
}
this.castPromise_.reject(new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.CAST,
code,
error));
}
/**
* @param {string} targetName
* @param {string} property
* @return {?}
* @private
*/
propertyGetter_(targetName, property) {
goog.asserts.assert(targetName == 'video' || targetName == 'player',
'Unexpected target name');
return this.cachedProperties_[targetName][property];
}
/**
* @param {string} targetName
* @param {string} methodName
* @param {...*} varArgs
* @private
*/
remoteCall_(targetName, methodName, ...varArgs) {
goog.asserts.assert(targetName == 'video' || targetName == 'player',
'Unexpected target name');
this.sendMessage_({
'type': 'call',
'targetName': targetName,
'methodName': methodName,
'args': varArgs,
});
}
/**
* @param {string} targetName
* @param {string} methodName
* @param {...*} varArgs
* @return {!Promise}
* @private
*/
remoteAsyncCall_(targetName, methodName, ...varArgs) {
goog.asserts.assert(targetName == 'video' || targetName == 'player',
'Unexpected target name');
const p = new shaka.util.PublicPromise();
const id = this.nextAsyncCallId_.toString();
this.nextAsyncCallId_++;
this.asyncCallPromises_[id] = p;
try {
this.sendMessage_({
'type': 'asyncCall',
'targetName': targetName,
'methodName': methodName,
'args': varArgs,
'id': id,
});
} catch (error) {
p.reject(error);
}
return p;
}
/**
* A static version of onExistingSessionJoined_, that calls that method for
* each known instance.
* @param {chrome.cast.Session} session
* @private
*/
static onExistingSessionJoined_(session) {
for (const instance of shaka.cast.CastSender.instances_) {
instance.onExistingSessionJoined_(session);
}
}
/**
* @param {chrome.cast.Session} session
* @private
*/
onExistingSessionJoined_(session) {
shaka.log.debug('CastSender: onExistingSessionJoined');
const initState = this.onInitStateRequired_();
this.castPromise_ = new shaka.util.PublicPromise();
this.hasJoinedExistingSession_ = true;
this.onSessionInitiated_(initState, session);
}
/**
* A static version of onReceiverStatusChanged_, that calls that method for
* each known instance.
* @param {string} availability
* @private
*/
static onReceiverStatusChanged_(availability) {
for (const instance of shaka.cast.CastSender.instances_) {
instance.onReceiverStatusChanged_(availability);
}
}
/**
* @param {string} availability
* @private
*/
onReceiverStatusChanged_(availability) {
// The cast API is telling us whether there are any cast receiver devices
// available.
shaka.log.debug('CastSender: receiver status', availability);
shaka.cast.CastSender.hasReceivers_ = availability == 'available';
this.statusChangeTimer_.tickNow();
}
/**
* @param {chrome.cast.Session} session
* @private
*/
onSessionCreated_(session) {
shaka.cast.CastSender.session_ = session;
session.addUpdateListener(this.onConnectionStatusChangedBound_);
session.addMessageListener(shaka.cast.CastUtils.SHAKA_MESSAGE_NAMESPACE,
this.onMessageReceivedBound_);
this.onConnectionStatusChanged_();
}
/**
* @private
*/
removeListeners_() {
const session = shaka.cast.CastSender.session_;
session.removeUpdateListener(this.onConnectionStatusChangedBound_);
session.removeMessageListener(shaka.cast.CastUtils.SHAKA_MESSAGE_NAMESPACE,
this.onMessageReceivedBound_);
}
/**
* @private
*/
onConnectionStatusChanged_() {
const connected = shaka.cast.CastSender.session_ ?
shaka.cast.CastSender.session_.status == 'connected' :
false;
shaka.log.debug('CastSender: connection status', connected);
if (this.isCasting_ && !connected) {
// Tell CastProxy to transfer state back to local player.
this.onResumeLocal_();
// Clear whatever we have cached.
for (const targetName in this.cachedProperties_) {
this.cachedProperties_[targetName] = {};
}
this.rejectAllPromises_();
}
this.isCasting_ = connected;
this.receiverName_ = connected ?
shaka.cast.CastSender.session_.receiver.friendlyName :
'';
this.statusChangeTimer_.tickNow();
}
/**
* Reject any async call promises that are still pending.
* @private
*/
rejectAllPromises_() {
for (const id in this.asyncCallPromises_) {
const p = this.asyncCallPromises_[id];
delete this.asyncCallPromises_[id];
// Reject pending async operations as if they were interrupted.
// At the moment, load() is the only async operation we are worried about.
p.reject(new shaka.util.Error(
shaka.util.Error.Severity.RECOVERABLE,
shaka.util.Error.Category.PLAYER,
shaka.util.Error.Code.LOAD_INTERRUPTED));
}
}
/**
* @param {string} namespace
* @param {string} serialized
* @private
*/
onMessageReceived_(namespace, serialized) {
// Since this method is in the compiled library, make sure all messages
// passed in here were created with quoted property names.
const message = shaka.cast.CastUtils.deserialize(serialized);
shaka.log.v2('CastSender: message', message);
switch (message['type']) {
case 'event': {
const targetName = message['targetName'];
const event = message['event'];
const fakeEvent = new shaka.util.FakeEvent(event['type'], event);
this.onRemoteEvent_(targetName, fakeEvent);
break;
}
case 'update': {
const update = message['update'];
for (const targetName in update) {
const target = this.cachedProperties_[targetName] || {};
for (const property in update[targetName]) {
target[property] = update[targetName][property];
}
}
if (this.hasJoinedExistingSession_) {
this.onFirstCastStateUpdate_();
this.hasJoinedExistingSession_ = false;
}
break;
}
case 'asyncComplete': {
const id = message['id'];
const error = message['error'];
const p = this.asyncCallPromises_[id];
delete this.asyncCallPromises_[id];
goog.asserts.assert(p, 'Unexpected async id');
if (!p) {
break;
}
if (error) {
// This is a hacky way to reconstruct the serialized error.
const reconstructedError = new shaka.util.Error(
error.severity, error.category, error.code);
for (const k in error) {
(/** @type {Object} */(reconstructedError))[k] = error[k];
}
p.reject(reconstructedError);
} else {
p.resolve();
}
break;
}
}
}
/**
* @param {!Object} message
* @private
*/
sendMessage_(message) {
// Since this method is in the compiled library, make sure all messages
// passed in here were created with quoted property names.
const serialized = shaka.cast.CastUtils.serialize(message);
const session = shaka.cast.CastSender.session_;
// NOTE: This takes an error callback that we have not seen fire. We don't
// know if it would fire synchronously or asynchronously. Until we know how
// it works, we just log from that callback. But we _have_ seen
// sendMessage() throw synchronously, so we handle that.
try {
session.sendMessage(shaka.cast.CastUtils.SHAKA_MESSAGE_NAMESPACE,
serialized,
() => {}, // success callback
shaka.log.error); // error callback
} catch (error) {
shaka.log.error('Cast session sendMessage threw', error);
// Translate the error
const shakaError = new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.CAST,
shaka.util.Error.Code.CAST_CONNECTION_TIMED_OUT,
error);
// Dispatch it through the Player proxy
const fakeEvent = new shaka.util.FakeEvent(
'error', {'detail': shakaError});
this.onRemoteEvent_('player', fakeEvent);
// Force this session to disconnect and transfer playback to the local
// device
this.forceDisconnect();
// Throw the translated error from this getter/setter/method to the UI/app
throw shakaError;
}
}
};
/** @type {number} */
shaka.cast.CastSender.STATUS_DELAY = 0.02;
/** @private {boolean} */
shaka.cast.CastSender.hasReceivers_ = false;
/** @private {chrome.cast.Session} */
shaka.cast.CastSender.session_ = null;
/**
* A set of all living CastSender instances. The constructor and destroy
* methods will add and remove instances from this set.
*
* This is used to deal with delayed initialization of the Cast SDK. When the
* SDK becomes available, instances will be reinitialized.
*
* @private {!Set.<shaka.cast.CastSender>}
*/
shaka.cast.CastSender.instances_ = new Set();
/**
* If the cast SDK is not available yet, it will invoke this callback once it
* becomes available.
*
* @param {boolean} loaded
* @private
*/
shaka.cast.CastSender.onSdkLoaded_ = (loaded) => {
if (loaded) {
// Any living instances of CastSender should have their init methods called
// again now that the API is available.
for (const sender of shaka.cast.CastSender.instances_) {
sender.init();
}
}
};
// The cast SDK will invoke this global callback. Since in our testing
// environment, we load both uncompiled and compiled code, this global callback
// in uncompiled mode can be overwritten by the same in compiled mode. The two
// versions will each have their own instances_ map.
// Therefore we gave the callback a name that the CastSender tests could invoke
// instead of using a global that could be overwritten.
window.__onGCastApiAvailable = shaka.cast.CastSender.onSdkLoaded_;