Source: lib/cast/cast_proxy.js

  1. /*! @license
  2. * Shaka Player
  3. * Copyright 2016 Google LLC
  4. * SPDX-License-Identifier: Apache-2.0
  5. */
  6. goog.provide('shaka.cast.CastProxy');
  7. goog.require('goog.asserts');
  8. goog.require('shaka.Player');
  9. goog.require('shaka.cast.CastSender');
  10. goog.require('shaka.cast.CastUtils');
  11. goog.require('shaka.log');
  12. goog.require('shaka.util.Error');
  13. goog.require('shaka.util.EventManager');
  14. goog.require('shaka.util.FakeEvent');
  15. goog.require('shaka.util.FakeEventTarget');
  16. goog.require('shaka.util.IDestroyable');
  17. /**
  18. * @event shaka.cast.CastProxy.CastStatusChangedEvent
  19. * @description Fired when cast status changes. The status change will be
  20. * reflected in canCast() and isCasting().
  21. * @property {string} type
  22. * 'caststatuschanged'
  23. * @exportDoc
  24. */
  25. /**
  26. * @summary A proxy to switch between local and remote playback for Chromecast
  27. * in a way that is transparent to the app's controls.
  28. *
  29. * @implements {shaka.util.IDestroyable}
  30. * @export
  31. */
  32. shaka.cast.CastProxy = class extends shaka.util.FakeEventTarget {
  33. /**
  34. * @param {!HTMLMediaElement} video The local video element associated with
  35. * the local Player instance.
  36. * @param {!shaka.Player} player A local Player instance.
  37. * @param {string} receiverAppId The ID of the cast receiver application.
  38. * If blank, casting will not be available, but the proxy will still
  39. * function otherwise.
  40. */
  41. constructor(video, player, receiverAppId) {
  42. super();
  43. /** @private {HTMLMediaElement} */
  44. this.localVideo_ = video;
  45. /** @private {shaka.Player} */
  46. this.localPlayer_ = player;
  47. /** @private {Object} */
  48. this.videoProxy_ = null;
  49. /** @private {Object} */
  50. this.playerProxy_ = null;
  51. /** @private {shaka.util.FakeEventTarget} */
  52. this.videoEventTarget_ = null;
  53. /** @private {shaka.util.FakeEventTarget} */
  54. this.playerEventTarget_ = null;
  55. /** @private {shaka.util.EventManager} */
  56. this.eventManager_ = null;
  57. /** @private {string} */
  58. this.receiverAppId_ = receiverAppId;
  59. /** @private {!Map} */
  60. this.compiledToExternNames_ = new Map();
  61. /** @private {shaka.cast.CastSender} */
  62. this.sender_ = new shaka.cast.CastSender(
  63. receiverAppId,
  64. () => this.onCastStatusChanged_(),
  65. () => this.onFirstCastStateUpdate_(),
  66. (targetName, event) => this.onRemoteEvent_(targetName, event),
  67. () => this.onResumeLocal_(),
  68. () => this.getInitState_());
  69. this.init_();
  70. }
  71. /**
  72. * Destroys the proxy and the underlying local Player.
  73. *
  74. * @param {boolean=} forceDisconnect If true, force the receiver app to shut
  75. * down by disconnecting. Does nothing if not connected.
  76. * @override
  77. * @export
  78. */
  79. destroy(forceDisconnect) {
  80. if (forceDisconnect) {
  81. this.sender_.forceDisconnect();
  82. }
  83. if (this.eventManager_) {
  84. this.eventManager_.release();
  85. this.eventManager_ = null;
  86. }
  87. const waitFor = [];
  88. if (this.localPlayer_) {
  89. waitFor.push(this.localPlayer_.destroy());
  90. this.localPlayer_ = null;
  91. }
  92. if (this.sender_) {
  93. waitFor.push(this.sender_.destroy());
  94. this.sender_ = null;
  95. }
  96. this.localVideo_ = null;
  97. this.videoProxy_ = null;
  98. this.playerProxy_ = null;
  99. // FakeEventTarget implements IReleasable
  100. super.release();
  101. return Promise.all(waitFor);
  102. }
  103. /**
  104. * Get a proxy for the video element that delegates to local and remote video
  105. * elements as appropriate.
  106. *
  107. * @suppress {invalidCasts} to cast proxy Objects to unrelated types
  108. * @return {!HTMLMediaElement}
  109. * @export
  110. */
  111. getVideo() {
  112. return /** @type {!HTMLMediaElement} */(this.videoProxy_);
  113. }
  114. /**
  115. * Get a proxy for the Player that delegates to local and remote Player
  116. * objects as appropriate.
  117. *
  118. * @suppress {invalidCasts} to cast proxy Objects to unrelated types
  119. * @return {!shaka.Player}
  120. * @export
  121. */
  122. getPlayer() {
  123. return /** @type {!shaka.Player} */(this.playerProxy_);
  124. }
  125. /**
  126. * @return {boolean} True if the cast API is available and there are
  127. * receivers.
  128. * @export
  129. */
  130. canCast() {
  131. return this.sender_.apiReady() && this.sender_.hasReceivers();
  132. }
  133. /**
  134. * @return {boolean} True if we are currently casting.
  135. * @export
  136. */
  137. isCasting() {
  138. return this.sender_.isCasting();
  139. }
  140. /**
  141. * @return {string} The name of the Cast receiver device, if isCasting().
  142. * @export
  143. */
  144. receiverName() {
  145. return this.sender_.receiverName();
  146. }
  147. /**
  148. * @return {!Promise} Resolved when connected to a receiver. Rejected if the
  149. * connection fails or is canceled by the user.
  150. * @export
  151. */
  152. async cast() {
  153. const initState = this.getInitState_();
  154. // TODO: transfer manually-selected tracks?
  155. // TODO: transfer side-loaded text tracks?
  156. await this.sender_.cast(initState);
  157. if (!this.localPlayer_) {
  158. // We've already been destroyed.
  159. return;
  160. }
  161. // Unload the local manifest when casting succeeds.
  162. await this.localPlayer_.unload();
  163. }
  164. /**
  165. * Set application-specific data.
  166. *
  167. * @param {Object} appData Application-specific data to relay to the receiver.
  168. * @export
  169. */
  170. setAppData(appData) {
  171. this.sender_.setAppData(appData);
  172. }
  173. /**
  174. * Show a dialog where user can choose to disconnect from the cast connection.
  175. * @export
  176. */
  177. suggestDisconnect() {
  178. this.sender_.showDisconnectDialog();
  179. }
  180. /**
  181. * Force the receiver app to shut down by disconnecting.
  182. * @export
  183. */
  184. forceDisconnect() {
  185. this.sender_.forceDisconnect();
  186. }
  187. /**
  188. * @param {string} newAppId
  189. * @export
  190. */
  191. async changeReceiverId(newAppId) {
  192. if (newAppId == this.receiverAppId_) {
  193. // Nothing to change
  194. return;
  195. }
  196. this.receiverAppId_ = newAppId;
  197. // Destroy the old sender
  198. this.sender_.forceDisconnect();
  199. await this.sender_.destroy();
  200. this.sender_ = null;
  201. // Create the new one
  202. this.sender_ = new shaka.cast.CastSender(
  203. newAppId,
  204. () => this.onCastStatusChanged_(),
  205. () => this.onFirstCastStateUpdate_(),
  206. (targetName, event) => this.onRemoteEvent_(targetName, event),
  207. () => this.onResumeLocal_(),
  208. () => this.getInitState_());
  209. this.sender_.init();
  210. }
  211. /**
  212. * Initialize the Proxies and the Cast sender.
  213. * @private
  214. */
  215. init_() {
  216. this.sender_.init();
  217. this.eventManager_ = new shaka.util.EventManager();
  218. for (const name of shaka.cast.CastUtils.VideoEvents) {
  219. this.eventManager_.listen(this.localVideo_, name,
  220. (event) => this.videoProxyLocalEvent_(event));
  221. }
  222. for (const key in shaka.Player.EventName) {
  223. const name = shaka.Player.EventName[key];
  224. this.eventManager_.listen(this.localPlayer_, name,
  225. (event) => this.playerProxyLocalEvent_(event));
  226. }
  227. // We would like to use Proxy here, but it is not supported on Safari.
  228. this.videoProxy_ = {};
  229. for (const k in this.localVideo_) {
  230. Object.defineProperty(this.videoProxy_, k, {
  231. configurable: false,
  232. enumerable: true,
  233. get: () => this.videoProxyGet_(k),
  234. set: (value) => { this.videoProxySet_(k, value); },
  235. });
  236. }
  237. this.playerProxy_ = {};
  238. this.iterateOverPlayerMethods_((name, method) => {
  239. goog.asserts.assert(this.playerProxy_, 'Must have player proxy!');
  240. Object.defineProperty(this.playerProxy_, name, {
  241. configurable: false,
  242. enumerable: true,
  243. get: () => this.playerProxyGet_(name),
  244. });
  245. });
  246. if (COMPILED) {
  247. this.mapCompiledToUncompiledPlayerMethodNames_();
  248. }
  249. this.videoEventTarget_ = new shaka.util.FakeEventTarget();
  250. this.videoEventTarget_.dispatchTarget =
  251. /** @type {EventTarget} */(this.videoProxy_);
  252. this.playerEventTarget_ = new shaka.util.FakeEventTarget();
  253. this.playerEventTarget_.dispatchTarget =
  254. /** @type {EventTarget} */(this.playerProxy_);
  255. }
  256. /**
  257. * Maps compiled to uncompiled player names so we can figure out
  258. * which method to call in compiled build, while casting.
  259. * @private
  260. */
  261. mapCompiledToUncompiledPlayerMethodNames_() {
  262. // In compiled mode, UI tries to access player methods by their internal
  263. // renamed names, but the proxy object doesn't know about those. See
  264. // https://github.com/shaka-project/shaka-player/issues/2130 for details.
  265. const methodsToNames = new Map();
  266. this.iterateOverPlayerMethods_((name, method) => {
  267. if (methodsToNames.has(method)) {
  268. // If two method names, point to the same method, add them to the
  269. // map as aliases of each other.
  270. const name2 = methodsToNames.get(method);
  271. // Assumes that the compiled name is shorter
  272. if (name.length < name2.length) {
  273. this.compiledToExternNames_.set(name, name2);
  274. } else {
  275. this.compiledToExternNames_.set(name2, name);
  276. }
  277. } else {
  278. methodsToNames.set(method, name);
  279. }
  280. });
  281. }
  282. /**
  283. * Iterates over all of the methods of the player, including inherited methods
  284. * from FakeEventTarget.
  285. * @param {function(string, function())} operation
  286. * @private
  287. */
  288. iterateOverPlayerMethods_(operation) {
  289. goog.asserts.assert(this.localPlayer_, 'Must have player!');
  290. const player = /** @type {!Object} */ (this.localPlayer_);
  291. // Avoid accessing any over-written methods in the prototype chain.
  292. const seenNames = new Set();
  293. /**
  294. * @param {string} name
  295. * @return {boolean}
  296. */
  297. function shouldAddToTheMap(name) {
  298. if (name == 'constructor') {
  299. // Don't proxy the constructor.
  300. return false;
  301. }
  302. const method = /** @type {Object} */(player)[name];
  303. if (typeof method != 'function') {
  304. // Don't proxy non-methods.
  305. return false;
  306. }
  307. // Add if the map does not already have it
  308. return !seenNames.has(name);
  309. }
  310. // First, look at the methods on the object itself, so this can properly
  311. // proxy any methods not on the prototype (for example, in the mock player).
  312. for (const key in player) {
  313. if (shouldAddToTheMap(key)) {
  314. seenNames.add(key);
  315. operation(key, player[key]);
  316. }
  317. }
  318. // The exact length of the prototype chain might vary; for resiliency, this
  319. // will just look at the entire chain, rather than assuming a set length.
  320. let proto = /** @type {!Object} */ (Object.getPrototypeOf(player));
  321. const objProto = /** @type {!Object} */ (Object.getPrototypeOf({}));
  322. while (proto && proto != objProto) { // Don't proxy Object methods.
  323. for (const name of Object.getOwnPropertyNames(proto)) {
  324. if (shouldAddToTheMap(name)) {
  325. seenNames.add(name);
  326. operation(name, (player)[name]);
  327. }
  328. }
  329. proto = /** @type {!Object} */ (Object.getPrototypeOf(proto));
  330. }
  331. }
  332. /**
  333. * @return {shaka.cast.CastUtils.InitStateType} initState Video and player
  334. * state to be sent to the receiver.
  335. * @private
  336. */
  337. getInitState_() {
  338. const initState = {
  339. 'video': {},
  340. 'player': {},
  341. 'playerAfterLoad': {},
  342. 'manifest': this.localPlayer_.getAssetUri(),
  343. 'startTime': null,
  344. };
  345. // Pause local playback before capturing state.
  346. this.localVideo_.pause();
  347. for (const name of shaka.cast.CastUtils.VideoInitStateAttributes) {
  348. initState['video'][name] = this.localVideo_[name];
  349. }
  350. // If the video is still playing, set the startTime.
  351. // Has no effect if nothing is loaded.
  352. if (!this.localVideo_.ended) {
  353. initState['startTime'] = this.localVideo_.currentTime;
  354. }
  355. for (const pair of shaka.cast.CastUtils.PlayerInitState) {
  356. const getter = pair[0];
  357. const setter = pair[1];
  358. const value = /** @type {Object} */(this.localPlayer_)[getter]();
  359. initState['player'][setter] = value;
  360. }
  361. for (const pair of shaka.cast.CastUtils.PlayerInitAfterLoadState) {
  362. const getter = pair[0];
  363. const setter = pair[1];
  364. const value = /** @type {Object} */(this.localPlayer_)[getter]();
  365. initState['playerAfterLoad'][setter] = value;
  366. }
  367. return initState;
  368. }
  369. /**
  370. * Dispatch an event to notify the app that the status has changed.
  371. * @private
  372. */
  373. onCastStatusChanged_() {
  374. const event = new shaka.util.FakeEvent('caststatuschanged');
  375. this.dispatchEvent(event);
  376. }
  377. /**
  378. * Dispatch a synthetic play or pause event to ensure that the app correctly
  379. * knows that the player is playing, if joining an existing receiver.
  380. * @private
  381. */
  382. onFirstCastStateUpdate_() {
  383. const type = this.videoProxy_['paused'] ? 'pause' : 'play';
  384. const fakeEvent = new shaka.util.FakeEvent(type);
  385. this.videoEventTarget_.dispatchEvent(fakeEvent);
  386. }
  387. /**
  388. * Transfer remote state back and resume local playback.
  389. * @private
  390. */
  391. onResumeLocal_() {
  392. // Transfer back the player state.
  393. for (const pair of shaka.cast.CastUtils.PlayerInitState) {
  394. const getter = pair[0];
  395. const setter = pair[1];
  396. const value = this.sender_.get('player', getter)();
  397. /** @type {Object} */(this.localPlayer_)[setter](value);
  398. }
  399. // Get the most recent manifest URI and ended state.
  400. const assetUri = this.sender_.get('player', 'getAssetUri')();
  401. const ended = this.sender_.get('video', 'ended');
  402. let manifestReady = Promise.resolve();
  403. const autoplay = this.localVideo_.autoplay;
  404. let startTime = null;
  405. // If the video is still playing, set the startTime.
  406. // Has no effect if nothing is loaded.
  407. if (!ended) {
  408. startTime = this.sender_.get('video', 'currentTime');
  409. }
  410. // Now load the manifest, if present.
  411. if (assetUri) {
  412. // Don't autoplay the content until we finish setting up initial state.
  413. this.localVideo_.autoplay = false;
  414. manifestReady = this.localPlayer_.load(assetUri, startTime);
  415. }
  416. // Get the video state into a temp variable since we will apply it async.
  417. const videoState = {};
  418. for (const name of shaka.cast.CastUtils.VideoInitStateAttributes) {
  419. videoState[name] = this.sender_.get('video', name);
  420. }
  421. // Finally, take on video state and player's "after load" state.
  422. manifestReady.then(() => {
  423. if (!this.localVideo_) {
  424. // We've already been destroyed.
  425. return;
  426. }
  427. for (const name of shaka.cast.CastUtils.VideoInitStateAttributes) {
  428. this.localVideo_[name] = videoState[name];
  429. }
  430. for (const pair of shaka.cast.CastUtils.PlayerInitAfterLoadState) {
  431. const getter = pair[0];
  432. const setter = pair[1];
  433. const value = this.sender_.get('player', getter)();
  434. /** @type {Object} */(this.localPlayer_)[setter](value);
  435. }
  436. // Restore the original autoplay setting.
  437. this.localVideo_.autoplay = autoplay;
  438. if (assetUri) {
  439. // Resume playback with transferred state.
  440. this.localVideo_.play();
  441. }
  442. }, (error) => {
  443. // Pass any errors through to the app.
  444. goog.asserts.assert(error instanceof shaka.util.Error,
  445. 'Wrong error type!');
  446. const eventType = shaka.Player.EventName.Error;
  447. const data = (new Map()).set('detail', error);
  448. const event = new shaka.util.FakeEvent(eventType, data);
  449. this.localPlayer_.dispatchEvent(event);
  450. });
  451. }
  452. /**
  453. * @param {string} name
  454. * @return {?}
  455. * @private
  456. */
  457. videoProxyGet_(name) {
  458. if (name == 'addEventListener') {
  459. return (type, listener, options) => {
  460. return this.videoEventTarget_.addEventListener(type, listener, options);
  461. };
  462. }
  463. if (name == 'removeEventListener') {
  464. return (type, listener, options) => {
  465. return this.videoEventTarget_.removeEventListener(
  466. type, listener, options);
  467. };
  468. }
  469. // If we are casting, but the first update has not come in yet, use local
  470. // values, but not local methods.
  471. if (this.sender_.isCasting() && !this.sender_.hasRemoteProperties()) {
  472. const value = this.localVideo_[name];
  473. if (typeof value != 'function') {
  474. return value;
  475. }
  476. }
  477. // Use local values and methods if we are not casting.
  478. if (!this.sender_.isCasting()) {
  479. let value = this.localVideo_[name];
  480. if (typeof value == 'function') {
  481. // eslint-disable-next-line no-restricted-syntax
  482. value = value.bind(this.localVideo_);
  483. }
  484. return value;
  485. }
  486. return this.sender_.get('video', name);
  487. }
  488. /**
  489. * @param {string} name
  490. * @param {?} value
  491. * @private
  492. */
  493. videoProxySet_(name, value) {
  494. if (!this.sender_.isCasting()) {
  495. this.localVideo_[name] = value;
  496. return;
  497. }
  498. this.sender_.set('video', name, value);
  499. }
  500. /**
  501. * @param {!Event} event
  502. * @private
  503. */
  504. videoProxyLocalEvent_(event) {
  505. if (this.sender_.isCasting()) {
  506. // Ignore any unexpected local events while casting. Events can still be
  507. // fired by the local video and Player when we unload() after the Cast
  508. // connection is complete.
  509. return;
  510. }
  511. // Convert this real Event into a FakeEvent for dispatch from our
  512. // FakeEventListener.
  513. const fakeEvent = shaka.util.FakeEvent.fromRealEvent(event);
  514. this.videoEventTarget_.dispatchEvent(fakeEvent);
  515. }
  516. /**
  517. * @param {string} name
  518. * @return {?}
  519. * @private
  520. */
  521. playerProxyGet_(name) {
  522. // If name is a shortened compiled name, get the original version
  523. // from our map.
  524. if (this.compiledToExternNames_.has(name)) {
  525. name = this.compiledToExternNames_.get(name);
  526. }
  527. if (name == 'addEventListener') {
  528. return (type, listener, options) => {
  529. return this.playerEventTarget_.addEventListener(
  530. type, listener, options);
  531. };
  532. }
  533. if (name == 'removeEventListener') {
  534. return (type, listener, options) => {
  535. return this.playerEventTarget_.removeEventListener(
  536. type, listener, options);
  537. };
  538. }
  539. if (name == 'getMediaElement') {
  540. return () => this.videoProxy_;
  541. }
  542. if (name == 'getSharedConfiguration') {
  543. shaka.log.warning(
  544. 'Can\'t share configuration across a network. Returning copy.');
  545. return this.sender_.get('player', 'getConfiguration');
  546. }
  547. if (name == 'getNetworkingEngine') {
  548. // Always returns a local instance, in case you need to make a request.
  549. // Issues a warning, in case you think you are making a remote request
  550. // or affecting remote filters.
  551. if (this.sender_.isCasting()) {
  552. shaka.log.warning('NOTE: getNetworkingEngine() is always local!');
  553. }
  554. return () => this.localPlayer_.getNetworkingEngine();
  555. }
  556. if (name == 'getDrmEngine') {
  557. // Always returns a local instance.
  558. if (this.sender_.isCasting()) {
  559. shaka.log.warning('NOTE: getDrmEngine() is always local!');
  560. }
  561. return () => this.localPlayer_.getDrmEngine();
  562. }
  563. if (name == 'getAdManager') {
  564. // Always returns a local instance.
  565. if (this.sender_.isCasting()) {
  566. shaka.log.warning('NOTE: getAdManager() is always local!');
  567. }
  568. return () => this.localPlayer_.getAdManager();
  569. }
  570. if (name == 'setVideoContainer') {
  571. // Always returns a local instance.
  572. if (this.sender_.isCasting()) {
  573. shaka.log.warning('NOTE: setVideoContainer() is always local!');
  574. }
  575. return (container) => this.localPlayer_.setVideoContainer(container);
  576. }
  577. if (this.sender_.isCasting()) {
  578. // These methods are unavailable or otherwise stubbed during casting.
  579. if (name == 'getManifest' || name == 'drmInfo') {
  580. return () => {
  581. shaka.log.alwaysWarn(name + '() does not work while casting!');
  582. return null;
  583. };
  584. }
  585. if (name == 'attach' || name == 'detach') {
  586. return () => {
  587. shaka.log.alwaysWarn(name + '() does not work while casting!');
  588. return Promise.resolve();
  589. };
  590. }
  591. } // if (this.sender_.isCasting())
  592. // If we are casting, but the first update has not come in yet, use local
  593. // getters, but not local methods.
  594. if (this.sender_.isCasting() && !this.sender_.hasRemoteProperties()) {
  595. if (shaka.cast.CastUtils.PlayerGetterMethods[name] ||
  596. shaka.cast.CastUtils.LargePlayerGetterMethods[name]) {
  597. const value = /** @type {Object} */(this.localPlayer_)[name];
  598. goog.asserts.assert(typeof value == 'function',
  599. 'only methods on Player');
  600. // eslint-disable-next-line no-restricted-syntax
  601. return value.bind(this.localPlayer_);
  602. }
  603. }
  604. // Use local getters and methods if we are not casting.
  605. if (!this.sender_.isCasting()) {
  606. const value = /** @type {Object} */(this.localPlayer_)[name];
  607. goog.asserts.assert(typeof value == 'function',
  608. 'only methods on Player');
  609. // eslint-disable-next-line no-restricted-syntax
  610. return value.bind(this.localPlayer_);
  611. }
  612. return this.sender_.get('player', name);
  613. }
  614. /**
  615. * @param {!Event} event
  616. * @private
  617. */
  618. playerProxyLocalEvent_(event) {
  619. if (this.sender_.isCasting()) {
  620. // Ignore any unexpected local events while casting.
  621. return;
  622. }
  623. this.playerEventTarget_.dispatchEvent(event);
  624. }
  625. /**
  626. * @param {string} targetName
  627. * @param {!shaka.util.FakeEvent} event
  628. * @private
  629. */
  630. onRemoteEvent_(targetName, event) {
  631. goog.asserts.assert(this.sender_.isCasting(),
  632. 'Should only receive remote events while casting');
  633. if (!this.sender_.isCasting()) {
  634. // Ignore any unexpected remote events.
  635. return;
  636. }
  637. if (targetName == 'video') {
  638. this.videoEventTarget_.dispatchEvent(event);
  639. } else if (targetName == 'player') {
  640. this.playerEventTarget_.dispatchEvent(event);
  641. }
  642. }
  643. };