Source: lib/cast/cast_sender.js

  1. /*! @license
  2. * Shaka Player
  3. * Copyright 2016 Google LLC
  4. * SPDX-License-Identifier: Apache-2.0
  5. */
  6. goog.provide('shaka.cast.CastSender');
  7. goog.require('goog.asserts');
  8. goog.require('shaka.cast.CastUtils');
  9. goog.require('shaka.log');
  10. goog.require('shaka.util.Error');
  11. goog.require('shaka.util.FakeEvent');
  12. goog.require('shaka.util.IDestroyable');
  13. goog.require('shaka.util.PublicPromise');
  14. goog.require('shaka.util.Timer');
  15. /**
  16. * @implements {shaka.util.IDestroyable}
  17. */
  18. shaka.cast.CastSender = class {
  19. /**
  20. * @param {string} receiverAppId The ID of the cast receiver application.
  21. * @param {function()} onStatusChanged A callback invoked when the cast status
  22. * changes.
  23. * @param {function()} onFirstCastStateUpdate A callback invoked when an
  24. * "update" event has been received for the first time.
  25. * @param {function(string, !shaka.util.FakeEvent)} onRemoteEvent A callback
  26. * invoked with target name and event when a remote event is received.
  27. * @param {function()} onResumeLocal A callback invoked when the local player
  28. * should resume playback. Called before the cached remote state is wiped.
  29. * @param {function()} onInitStateRequired A callback to get local player's.
  30. * state. Invoked when casting is initiated from Chrome's cast button.
  31. */
  32. constructor(receiverAppId, onStatusChanged, onFirstCastStateUpdate,
  33. onRemoteEvent, onResumeLocal, onInitStateRequired) {
  34. /** @private {string} */
  35. this.receiverAppId_ = receiverAppId;
  36. /** @private {shaka.util.Timer} */
  37. this.statusChangeTimer_ = new shaka.util.Timer(onStatusChanged);
  38. /** @private {?function()} */
  39. this.onFirstCastStateUpdate_ = onFirstCastStateUpdate;
  40. /** @private {boolean} */
  41. this.hasJoinedExistingSession_ = false;
  42. /** @private {?function(string, !shaka.util.FakeEvent)} */
  43. this.onRemoteEvent_ = onRemoteEvent;
  44. /** @private {?function()} */
  45. this.onResumeLocal_ = onResumeLocal;
  46. /** @private {?function()} */
  47. this.onInitStateRequired_ = onInitStateRequired;
  48. /** @private {boolean} */
  49. this.apiReady_ = false;
  50. /** @private {boolean} */
  51. this.isCasting_ = false;
  52. /** @private {string} */
  53. this.receiverName_ = '';
  54. /** @private {Object} */
  55. this.appData_ = null;
  56. /** @private {?function()} */
  57. this.onConnectionStatusChangedBound_ =
  58. () => this.onConnectionStatusChanged_();
  59. /** @private {?function(string, string)} */
  60. this.onMessageReceivedBound_ = (namespace, serialized) =>
  61. this.onMessageReceived_(namespace, serialized);
  62. /** @private {Object} */
  63. this.cachedProperties_ = {
  64. 'video': {},
  65. 'player': {},
  66. };
  67. /** @private {number} */
  68. this.nextAsyncCallId_ = 0;
  69. /** @private {Object.<string, !shaka.util.PublicPromise>} */
  70. this.asyncCallPromises_ = {};
  71. /** @private {shaka.util.PublicPromise} */
  72. this.castPromise_ = null;
  73. shaka.cast.CastSender.instances_.add(this);
  74. }
  75. /** @override */
  76. destroy() {
  77. shaka.cast.CastSender.instances_.delete(this);
  78. this.rejectAllPromises_();
  79. if (shaka.cast.CastSender.session_) {
  80. this.removeListeners_();
  81. // Don't leave the session, so that this session can be re-used later if
  82. // necessary.
  83. }
  84. if (this.statusChangeTimer_) {
  85. this.statusChangeTimer_.stop();
  86. this.statusChangeTimer_ = null;
  87. }
  88. this.onRemoteEvent_ = null;
  89. this.onResumeLocal_ = null;
  90. this.apiReady_ = false;
  91. this.isCasting_ = false;
  92. this.appData_ = null;
  93. this.cachedProperties_ = null;
  94. this.asyncCallPromises_ = null;
  95. this.castPromise_ = null;
  96. this.onConnectionStatusChangedBound_ = null;
  97. this.onMessageReceivedBound_ = null;
  98. return Promise.resolve();
  99. }
  100. /**
  101. * @return {boolean} True if the cast API is available.
  102. */
  103. apiReady() {
  104. return this.apiReady_;
  105. }
  106. /**
  107. * @return {boolean} True if there are receivers.
  108. */
  109. hasReceivers() {
  110. return shaka.cast.CastSender.hasReceivers_;
  111. }
  112. /**
  113. * @return {boolean} True if we are currently casting.
  114. */
  115. isCasting() {
  116. return this.isCasting_;
  117. }
  118. /**
  119. * @return {string} The name of the Cast receiver device, if isCasting().
  120. */
  121. receiverName() {
  122. return this.receiverName_;
  123. }
  124. /**
  125. * @return {boolean} True if we have a cache of remote properties from the
  126. * receiver.
  127. */
  128. hasRemoteProperties() {
  129. return Object.keys(this.cachedProperties_['video']).length != 0;
  130. }
  131. /** Initialize the Cast API. */
  132. init() {
  133. const CastSender = shaka.cast.CastSender;
  134. if (!this.receiverAppId_.length) {
  135. // Return if no cast receiver id has been provided.
  136. // Nothing will be initialized, no global hooks will be installed.
  137. // If the receiver ID changes before this instance dies, init will be
  138. // called again.
  139. return;
  140. }
  141. // Check for the cast API.
  142. if (!window.chrome || !chrome.cast || !chrome.cast.isAvailable) {
  143. // If the API is not available on this platform or is not ready yet,
  144. // install a hook to be notified when it becomes available.
  145. // If the API becomes available before this instance dies, init will be
  146. // called again.
  147. // A note about this value: In our testing environment, we load both
  148. // uncompiled and compiled code. This global callback in uncompiled mode
  149. // can be overwritten by the same in compiled mode. The two versions will
  150. // each have their own instances_ map. Therefore the callback must have a
  151. // name, as opposed to being anonymous. This way, the CastSender tests
  152. // can invoke the named static method instead of using a global that could
  153. // be overwritten.
  154. if (!window.__onGCastApiAvailable) {
  155. window.__onGCastApiAvailable = shaka.cast.CastSender.onSdkLoaded_;
  156. }
  157. if (window.__onGCastApiAvailable != shaka.cast.CastSender.onSdkLoaded_) {
  158. shaka.log.alwaysWarn('A global Cast SDK hook is already installed! ' +
  159. 'Shaka Player will be unable to receive a notification when the ' +
  160. 'Cast SDK is ready.');
  161. }
  162. return;
  163. }
  164. // The API is now available.
  165. this.apiReady_ = true;
  166. this.statusChangeTimer_.tickNow();
  167. // Use static versions of the API callbacks, since the ChromeCast API is
  168. // static. If we used local versions, we might end up retaining references
  169. // to destroyed players here.
  170. const sessionRequest = new chrome.cast.SessionRequest(this.receiverAppId_);
  171. const apiConfig = new chrome.cast.ApiConfig(sessionRequest,
  172. (session) => CastSender.onExistingSessionJoined_(session),
  173. (availability) => CastSender.onReceiverStatusChanged_(availability),
  174. 'origin_scoped');
  175. // TODO: Have never seen this fail. When would it and how should we react?
  176. chrome.cast.initialize(apiConfig,
  177. () => { shaka.log.debug('CastSender: init'); },
  178. (error) => { shaka.log.error('CastSender: init error', error); });
  179. if (shaka.cast.CastSender.hasReceivers_) {
  180. // Fire a fake cast status change, to simulate the update that
  181. // would be fired normally.
  182. // This is after a brief delay, to give users a chance to add event
  183. // listeners.
  184. this.statusChangeTimer_.tickAfter(shaka.cast.CastSender.STATUS_DELAY);
  185. }
  186. const oldSession = shaka.cast.CastSender.session_;
  187. if (oldSession && oldSession.status != chrome.cast.SessionStatus.STOPPED) {
  188. // The old session still exists, so re-use it.
  189. shaka.log.debug('CastSender: re-using existing connection');
  190. this.onExistingSessionJoined_(oldSession);
  191. } else {
  192. // The session has been canceled in the meantime, so ignore it.
  193. shaka.cast.CastSender.session_ = null;
  194. }
  195. }
  196. /**
  197. * Set application-specific data.
  198. *
  199. * @param {Object} appData Application-specific data to relay to the receiver.
  200. */
  201. setAppData(appData) {
  202. this.appData_ = appData;
  203. if (this.isCasting_) {
  204. this.sendMessage_({
  205. 'type': 'appData',
  206. 'appData': this.appData_,
  207. });
  208. }
  209. }
  210. /**
  211. * @param {shaka.cast.CastUtils.InitStateType} initState Video and player
  212. * state to be sent to the receiver.
  213. * @return {!Promise} Resolved when connected to a receiver. Rejected if the
  214. * connection fails or is canceled by the user.
  215. */
  216. async cast(initState) {
  217. if (!this.apiReady_) {
  218. throw new shaka.util.Error(
  219. shaka.util.Error.Severity.RECOVERABLE,
  220. shaka.util.Error.Category.CAST,
  221. shaka.util.Error.Code.CAST_API_UNAVAILABLE);
  222. }
  223. if (!shaka.cast.CastSender.hasReceivers_) {
  224. throw new shaka.util.Error(
  225. shaka.util.Error.Severity.RECOVERABLE,
  226. shaka.util.Error.Category.CAST,
  227. shaka.util.Error.Code.NO_CAST_RECEIVERS);
  228. }
  229. if (this.isCasting_) {
  230. throw new shaka.util.Error(
  231. shaka.util.Error.Severity.RECOVERABLE,
  232. shaka.util.Error.Category.CAST,
  233. shaka.util.Error.Code.ALREADY_CASTING);
  234. }
  235. this.castPromise_ = new shaka.util.PublicPromise();
  236. chrome.cast.requestSession(
  237. (session) => this.onSessionInitiated_(initState, session),
  238. (error) => this.onConnectionError_(error));
  239. await this.castPromise_;
  240. }
  241. /**
  242. * Shows user a cast dialog where they can choose to stop
  243. * casting. Relies on Chrome to perform disconnect if they do.
  244. * Doesn't do anything if not connected.
  245. */
  246. showDisconnectDialog() {
  247. if (!this.isCasting_) {
  248. return;
  249. }
  250. const initState = this.onInitStateRequired_();
  251. chrome.cast.requestSession(
  252. (session) => this.onSessionInitiated_(initState, session),
  253. (error) => this.onConnectionError_(error));
  254. }
  255. /**
  256. * Forces the receiver app to shut down by disconnecting. Does nothing if not
  257. * connected.
  258. */
  259. forceDisconnect() {
  260. if (!this.isCasting_) {
  261. return;
  262. }
  263. this.rejectAllPromises_();
  264. if (shaka.cast.CastSender.session_) {
  265. this.removeListeners_();
  266. // This can throw if we've already been disconnected somehow.
  267. try {
  268. shaka.cast.CastSender.session_.stop(() => {}, () => {});
  269. } catch (error) {}
  270. shaka.cast.CastSender.session_ = null;
  271. }
  272. // Update casting status.
  273. this.onConnectionStatusChanged_();
  274. }
  275. /**
  276. * Getter for properties of remote objects.
  277. * @param {string} targetName
  278. * @param {string} property
  279. * @return {?}
  280. */
  281. get(targetName, property) {
  282. goog.asserts.assert(targetName == 'video' || targetName == 'player',
  283. 'Unexpected target name');
  284. const CastUtils = shaka.cast.CastUtils;
  285. if (targetName == 'video') {
  286. if (CastUtils.VideoVoidMethods.includes(property)) {
  287. return (...args) => this.remoteCall_(targetName, property, ...args);
  288. }
  289. } else if (targetName == 'player') {
  290. if (CastUtils.PlayerGetterMethodsThatRequireLive[property]) {
  291. const isLive = this.get('player', 'isLive')();
  292. goog.asserts.assert(isLive,
  293. property + ' should be called on a live stream!');
  294. // If the property shouldn't exist, return a fake function so that the
  295. // user doesn't call an undefined function and get a second error.
  296. if (!isLive) {
  297. return () => undefined;
  298. }
  299. }
  300. if (CastUtils.PlayerVoidMethods.includes(property)) {
  301. return (...args) => this.remoteCall_(targetName, property, ...args);
  302. }
  303. if (CastUtils.PlayerPromiseMethods.includes(property)) {
  304. return (...args) =>
  305. this.remoteAsyncCall_(targetName, property, ...args);
  306. }
  307. if (CastUtils.PlayerGetterMethods[property]) {
  308. return () => this.propertyGetter_(targetName, property);
  309. }
  310. }
  311. return this.propertyGetter_(targetName, property);
  312. }
  313. /**
  314. * Setter for properties of remote objects.
  315. * @param {string} targetName
  316. * @param {string} property
  317. * @param {?} value
  318. */
  319. set(targetName, property, value) {
  320. goog.asserts.assert(targetName == 'video' || targetName == 'player',
  321. 'Unexpected target name');
  322. this.cachedProperties_[targetName][property] = value;
  323. this.sendMessage_({
  324. 'type': 'set',
  325. 'targetName': targetName,
  326. 'property': property,
  327. 'value': value,
  328. });
  329. }
  330. /**
  331. * @param {shaka.cast.CastUtils.InitStateType} initState
  332. * @param {chrome.cast.Session} session
  333. * @private
  334. */
  335. onSessionInitiated_(initState, session) {
  336. shaka.log.debug('CastSender: onSessionInitiated');
  337. this.onSessionCreated_(session);
  338. this.sendMessage_({
  339. 'type': 'init',
  340. 'initState': initState,
  341. 'appData': this.appData_,
  342. });
  343. this.castPromise_.resolve();
  344. }
  345. /**
  346. * @param {chrome.cast.Error} error
  347. * @private
  348. */
  349. onConnectionError_(error) {
  350. // Default error code:
  351. let code = shaka.util.Error.Code.UNEXPECTED_CAST_ERROR;
  352. switch (error.code) {
  353. case 'cancel':
  354. code = shaka.util.Error.Code.CAST_CANCELED_BY_USER;
  355. break;
  356. case 'timeout':
  357. code = shaka.util.Error.Code.CAST_CONNECTION_TIMED_OUT;
  358. break;
  359. case 'receiver_unavailable':
  360. code = shaka.util.Error.Code.CAST_RECEIVER_APP_UNAVAILABLE;
  361. break;
  362. }
  363. this.castPromise_.reject(new shaka.util.Error(
  364. shaka.util.Error.Severity.CRITICAL,
  365. shaka.util.Error.Category.CAST,
  366. code,
  367. error));
  368. }
  369. /**
  370. * @param {string} targetName
  371. * @param {string} property
  372. * @return {?}
  373. * @private
  374. */
  375. propertyGetter_(targetName, property) {
  376. goog.asserts.assert(targetName == 'video' || targetName == 'player',
  377. 'Unexpected target name');
  378. return this.cachedProperties_[targetName][property];
  379. }
  380. /**
  381. * @param {string} targetName
  382. * @param {string} methodName
  383. * @param {...*} varArgs
  384. * @private
  385. */
  386. remoteCall_(targetName, methodName, ...varArgs) {
  387. goog.asserts.assert(targetName == 'video' || targetName == 'player',
  388. 'Unexpected target name');
  389. this.sendMessage_({
  390. 'type': 'call',
  391. 'targetName': targetName,
  392. 'methodName': methodName,
  393. 'args': varArgs,
  394. });
  395. }
  396. /**
  397. * @param {string} targetName
  398. * @param {string} methodName
  399. * @param {...*} varArgs
  400. * @return {!Promise}
  401. * @private
  402. */
  403. remoteAsyncCall_(targetName, methodName, ...varArgs) {
  404. goog.asserts.assert(targetName == 'video' || targetName == 'player',
  405. 'Unexpected target name');
  406. const p = new shaka.util.PublicPromise();
  407. const id = this.nextAsyncCallId_.toString();
  408. this.nextAsyncCallId_++;
  409. this.asyncCallPromises_[id] = p;
  410. try {
  411. this.sendMessage_({
  412. 'type': 'asyncCall',
  413. 'targetName': targetName,
  414. 'methodName': methodName,
  415. 'args': varArgs,
  416. 'id': id,
  417. });
  418. } catch (error) {
  419. p.reject(error);
  420. }
  421. return p;
  422. }
  423. /**
  424. * A static version of onExistingSessionJoined_, that calls that method for
  425. * each known instance.
  426. * @param {chrome.cast.Session} session
  427. * @private
  428. */
  429. static onExistingSessionJoined_(session) {
  430. for (const instance of shaka.cast.CastSender.instances_) {
  431. instance.onExistingSessionJoined_(session);
  432. }
  433. }
  434. /**
  435. * @param {chrome.cast.Session} session
  436. * @private
  437. */
  438. onExistingSessionJoined_(session) {
  439. shaka.log.debug('CastSender: onExistingSessionJoined');
  440. const initState = this.onInitStateRequired_();
  441. this.castPromise_ = new shaka.util.PublicPromise();
  442. this.hasJoinedExistingSession_ = true;
  443. this.onSessionInitiated_(initState, session);
  444. }
  445. /**
  446. * A static version of onReceiverStatusChanged_, that calls that method for
  447. * each known instance.
  448. * @param {string} availability
  449. * @private
  450. */
  451. static onReceiverStatusChanged_(availability) {
  452. for (const instance of shaka.cast.CastSender.instances_) {
  453. instance.onReceiverStatusChanged_(availability);
  454. }
  455. }
  456. /**
  457. * @param {string} availability
  458. * @private
  459. */
  460. onReceiverStatusChanged_(availability) {
  461. // The cast API is telling us whether there are any cast receiver devices
  462. // available.
  463. shaka.log.debug('CastSender: receiver status', availability);
  464. shaka.cast.CastSender.hasReceivers_ = availability == 'available';
  465. this.statusChangeTimer_.tickNow();
  466. }
  467. /**
  468. * @param {chrome.cast.Session} session
  469. * @private
  470. */
  471. onSessionCreated_(session) {
  472. shaka.cast.CastSender.session_ = session;
  473. session.addUpdateListener(this.onConnectionStatusChangedBound_);
  474. session.addMessageListener(shaka.cast.CastUtils.SHAKA_MESSAGE_NAMESPACE,
  475. this.onMessageReceivedBound_);
  476. this.onConnectionStatusChanged_();
  477. }
  478. /**
  479. * @private
  480. */
  481. removeListeners_() {
  482. const session = shaka.cast.CastSender.session_;
  483. session.removeUpdateListener(this.onConnectionStatusChangedBound_);
  484. session.removeMessageListener(shaka.cast.CastUtils.SHAKA_MESSAGE_NAMESPACE,
  485. this.onMessageReceivedBound_);
  486. }
  487. /**
  488. * @private
  489. */
  490. onConnectionStatusChanged_() {
  491. const connected = shaka.cast.CastSender.session_ ?
  492. shaka.cast.CastSender.session_.status == 'connected' :
  493. false;
  494. shaka.log.debug('CastSender: connection status', connected);
  495. if (this.isCasting_ && !connected) {
  496. // Tell CastProxy to transfer state back to local player.
  497. this.onResumeLocal_();
  498. // Clear whatever we have cached.
  499. for (const targetName in this.cachedProperties_) {
  500. this.cachedProperties_[targetName] = {};
  501. }
  502. this.rejectAllPromises_();
  503. }
  504. this.isCasting_ = connected;
  505. this.receiverName_ = connected ?
  506. shaka.cast.CastSender.session_.receiver.friendlyName :
  507. '';
  508. this.statusChangeTimer_.tickNow();
  509. }
  510. /**
  511. * Reject any async call promises that are still pending.
  512. * @private
  513. */
  514. rejectAllPromises_() {
  515. for (const id in this.asyncCallPromises_) {
  516. const p = this.asyncCallPromises_[id];
  517. delete this.asyncCallPromises_[id];
  518. // Reject pending async operations as if they were interrupted.
  519. // At the moment, load() is the only async operation we are worried about.
  520. p.reject(new shaka.util.Error(
  521. shaka.util.Error.Severity.RECOVERABLE,
  522. shaka.util.Error.Category.PLAYER,
  523. shaka.util.Error.Code.LOAD_INTERRUPTED));
  524. }
  525. }
  526. /**
  527. * @param {string} namespace
  528. * @param {string} serialized
  529. * @private
  530. */
  531. onMessageReceived_(namespace, serialized) {
  532. // Since this method is in the compiled library, make sure all messages
  533. // passed in here were created with quoted property names.
  534. const message = shaka.cast.CastUtils.deserialize(serialized);
  535. shaka.log.v2('CastSender: message', message);
  536. switch (message['type']) {
  537. case 'event': {
  538. const targetName = message['targetName'];
  539. const event = message['event'];
  540. const fakeEvent = shaka.util.FakeEvent.fromRealEvent(event);
  541. this.onRemoteEvent_(targetName, fakeEvent);
  542. break;
  543. }
  544. case 'update': {
  545. const update = message['update'];
  546. for (const targetName in update) {
  547. const target = this.cachedProperties_[targetName] || {};
  548. for (const property in update[targetName]) {
  549. target[property] = update[targetName][property];
  550. }
  551. }
  552. if (this.hasJoinedExistingSession_) {
  553. this.onFirstCastStateUpdate_();
  554. this.hasJoinedExistingSession_ = false;
  555. }
  556. break;
  557. }
  558. case 'asyncComplete': {
  559. const id = message['id'];
  560. const error = message['error'];
  561. const p = this.asyncCallPromises_[id];
  562. delete this.asyncCallPromises_[id];
  563. goog.asserts.assert(p, 'Unexpected async id');
  564. if (!p) {
  565. break;
  566. }
  567. if (error) {
  568. // This is a hacky way to reconstruct the serialized error.
  569. const reconstructedError = new shaka.util.Error(
  570. error.severity, error.category, error.code);
  571. for (const k in error) {
  572. (/** @type {Object} */(reconstructedError))[k] = error[k];
  573. }
  574. p.reject(reconstructedError);
  575. } else {
  576. p.resolve();
  577. }
  578. break;
  579. }
  580. }
  581. }
  582. /**
  583. * @param {!Object} message
  584. * @private
  585. */
  586. sendMessage_(message) {
  587. // Since this method is in the compiled library, make sure all messages
  588. // passed in here were created with quoted property names.
  589. const serialized = shaka.cast.CastUtils.serialize(message);
  590. const session = shaka.cast.CastSender.session_;
  591. // NOTE: This takes an error callback that we have not seen fire. We don't
  592. // know if it would fire synchronously or asynchronously. Until we know how
  593. // it works, we just log from that callback. But we _have_ seen
  594. // sendMessage() throw synchronously, so we handle that.
  595. try {
  596. session.sendMessage(shaka.cast.CastUtils.SHAKA_MESSAGE_NAMESPACE,
  597. serialized,
  598. () => {}, // success callback
  599. shaka.log.error); // error callback
  600. } catch (error) {
  601. shaka.log.error('Cast session sendMessage threw', error);
  602. // Translate the error
  603. const shakaError = new shaka.util.Error(
  604. shaka.util.Error.Severity.CRITICAL,
  605. shaka.util.Error.Category.CAST,
  606. shaka.util.Error.Code.CAST_CONNECTION_TIMED_OUT,
  607. error);
  608. // Dispatch it through the Player proxy
  609. const fakeEvent = new shaka.util.FakeEvent(
  610. 'error', (new Map()).set('detail', shakaError));
  611. this.onRemoteEvent_('player', fakeEvent);
  612. // Force this session to disconnect and transfer playback to the local
  613. // device
  614. this.forceDisconnect();
  615. // Throw the translated error from this getter/setter/method to the UI/app
  616. throw shakaError;
  617. }
  618. }
  619. };
  620. /** @type {number} */
  621. shaka.cast.CastSender.STATUS_DELAY = 0.02;
  622. /** @private {boolean} */
  623. shaka.cast.CastSender.hasReceivers_ = false;
  624. /** @private {chrome.cast.Session} */
  625. shaka.cast.CastSender.session_ = null;
  626. /**
  627. * A set of all living CastSender instances. The constructor and destroy
  628. * methods will add and remove instances from this set.
  629. *
  630. * This is used to deal with delayed initialization of the Cast SDK. When the
  631. * SDK becomes available, instances will be reinitialized.
  632. *
  633. * @private {!Set.<shaka.cast.CastSender>}
  634. */
  635. shaka.cast.CastSender.instances_ = new Set();
  636. /**
  637. * If the cast SDK is not available yet, it will invoke this callback once it
  638. * becomes available.
  639. *
  640. * @param {boolean} loaded
  641. * @private
  642. */
  643. shaka.cast.CastSender.onSdkLoaded_ = (loaded) => {
  644. if (loaded) {
  645. // Any living instances of CastSender should have their init methods called
  646. // again now that the API is available.
  647. for (const sender of shaka.cast.CastSender.instances_) {
  648. sender.init();
  649. }
  650. }
  651. };