Source: ui/controls.js

  1. /*! @license
  2. * Shaka Player
  3. * Copyright 2016 Google LLC
  4. * SPDX-License-Identifier: Apache-2.0
  5. */
  6. goog.provide('shaka.ui.Controls');
  7. goog.provide('shaka.ui.ControlsPanel');
  8. goog.require('goog.asserts');
  9. goog.require('shaka.ads.Utils');
  10. goog.require('shaka.cast.CastProxy');
  11. goog.require('shaka.log');
  12. goog.require('shaka.ui.AdCounter');
  13. goog.require('shaka.ui.AdPosition');
  14. goog.require('shaka.ui.BigPlayButton');
  15. goog.require('shaka.ui.ContextMenu');
  16. goog.require('shaka.ui.HiddenFastForwardButton');
  17. goog.require('shaka.ui.HiddenRewindButton');
  18. goog.require('shaka.ui.Locales');
  19. goog.require('shaka.ui.Localization');
  20. goog.require('shaka.ui.SeekBar');
  21. goog.require('shaka.ui.SkipAdButton');
  22. goog.require('shaka.ui.Utils');
  23. goog.require('shaka.ui.VRManager');
  24. goog.require('shaka.util.Dom');
  25. goog.require('shaka.util.EventManager');
  26. goog.require('shaka.util.FakeEvent');
  27. goog.require('shaka.util.FakeEventTarget');
  28. goog.require('shaka.util.IDestroyable');
  29. goog.require('shaka.util.Platform');
  30. goog.require('shaka.util.Timer');
  31. goog.requireType('shaka.Player');
  32. /**
  33. * A container for custom video controls.
  34. * @implements {shaka.util.IDestroyable}
  35. * @export
  36. */
  37. shaka.ui.Controls = class extends shaka.util.FakeEventTarget {
  38. /**
  39. * @param {!shaka.Player} player
  40. * @param {!HTMLElement} videoContainer
  41. * @param {!HTMLMediaElement} video
  42. * @param {?HTMLCanvasElement} vrCanvas
  43. * @param {shaka.extern.UIConfiguration} config
  44. */
  45. constructor(player, videoContainer, video, vrCanvas, config) {
  46. super();
  47. /** @private {boolean} */
  48. this.enabled_ = true;
  49. /** @private {shaka.extern.UIConfiguration} */
  50. this.config_ = config;
  51. /** @private {shaka.cast.CastProxy} */
  52. this.castProxy_ = new shaka.cast.CastProxy(
  53. video, player, this.config_.castReceiverAppId,
  54. this.config_.castAndroidReceiverCompatible);
  55. /** @private {boolean} */
  56. this.castAllowed_ = true;
  57. /** @private {HTMLMediaElement} */
  58. this.video_ = this.castProxy_.getVideo();
  59. /** @private {HTMLMediaElement} */
  60. this.localVideo_ = video;
  61. /** @private {shaka.Player} */
  62. this.player_ = this.castProxy_.getPlayer();
  63. /** @private {shaka.Player} */
  64. this.localPlayer_ = player;
  65. /** @private {!HTMLElement} */
  66. this.videoContainer_ = videoContainer;
  67. /** @private {?HTMLCanvasElement} */
  68. this.vrCanvas_ = vrCanvas;
  69. /** @private {shaka.extern.IAdManager} */
  70. this.adManager_ = this.player_.getAdManager();
  71. /** @private {?shaka.extern.IAd} */
  72. this.ad_ = null;
  73. /** @private {?shaka.extern.IUISeekBar} */
  74. this.seekBar_ = null;
  75. /** @private {boolean} */
  76. this.isSeeking_ = false;
  77. /** @private {!Array<!HTMLElement>} */
  78. this.menus_ = [];
  79. /**
  80. * Individual controls which, when hovered or tab-focused, will force the
  81. * controls to be shown.
  82. * @private {!Array<!Element>}
  83. */
  84. this.showOnHoverControls_ = [];
  85. /** @private {boolean} */
  86. this.recentMouseMovement_ = false;
  87. /**
  88. * This timer is used to detect when the user has stopped moving the mouse
  89. * and we should fade out the ui.
  90. *
  91. * @private {shaka.util.Timer}
  92. */
  93. this.mouseStillTimer_ = new shaka.util.Timer(() => {
  94. this.onMouseStill_();
  95. });
  96. /**
  97. * This timer is used to delay the fading of the UI.
  98. *
  99. * @private {shaka.util.Timer}
  100. */
  101. this.fadeControlsTimer_ = new shaka.util.Timer(() => {
  102. this.controlsContainer_.removeAttribute('shown');
  103. if (this.contextMenu_) {
  104. this.contextMenu_.closeMenu();
  105. }
  106. // If there's an overflow menu open, keep it this way for a couple of
  107. // seconds in case a user immediately initiates another mouse move to
  108. // interact with the menus. If that didn't happen, go ahead and hide
  109. // the menus.
  110. this.hideSettingsMenusTimer_.tickAfter(
  111. /* seconds= */ this.config_.closeMenusDelay);
  112. });
  113. /**
  114. * This timer will be used to hide all settings menus. When the timer ticks
  115. * it will force all controls to invisible.
  116. *
  117. * Rather than calling the callback directly, |Controls| will always call it
  118. * through the timer to avoid conflicts.
  119. *
  120. * @private {shaka.util.Timer}
  121. */
  122. this.hideSettingsMenusTimer_ = new shaka.util.Timer(() => {
  123. for (const menu of this.menus_) {
  124. shaka.ui.Utils.setDisplay(menu, /* visible= */ false);
  125. }
  126. this.dispatchVisibilityEvent_();
  127. });
  128. /**
  129. * This timer is used to regularly update the time and seek range elements
  130. * so that we are communicating the current state as accurately as possibly.
  131. *
  132. * Unlike the other timers, this timer does not "own" the callback because
  133. * this timer is acting like a heartbeat.
  134. *
  135. * @private {shaka.util.Timer}
  136. */
  137. this.timeAndSeekRangeTimer_ = new shaka.util.Timer(() => {
  138. // Suppress timer-based updates if the controls are hidden.
  139. if (this.isOpaque()) {
  140. this.updateTimeAndSeekRange_();
  141. }
  142. });
  143. /** @private {?number} */
  144. this.lastTouchEventTime_ = null;
  145. /** @private {!Array<!shaka.extern.IUIElement>} */
  146. this.elements_ = [];
  147. /** @private {shaka.ui.Localization} */
  148. this.localization_ = shaka.ui.Controls.createLocalization_();
  149. /** @private {shaka.util.EventManager} */
  150. this.eventManager_ = new shaka.util.EventManager();
  151. /** @private {?shaka.ui.VRManager} */
  152. this.vr_ = null;
  153. // Configure and create the layout of the controls
  154. this.configure(this.config_);
  155. this.addEventListeners_();
  156. this.setupMediaSession_();
  157. /**
  158. * The pressed keys set is used to record which keys are currently pressed
  159. * down, so we can know what keys are pressed at the same time.
  160. * Used by the focusInsideOverflowMenu_() function.
  161. * @private {!Set<string>}
  162. */
  163. this.pressedKeys_ = new Set();
  164. // We might've missed a caststatuschanged event from the proxy between
  165. // the controls creation and initializing. Run onCastStatusChange_()
  166. // to ensure we have the casting state right.
  167. this.onCastStatusChange_();
  168. // Start this timer after we are finished initializing everything,
  169. this.timeAndSeekRangeTimer_.tickEvery(this.config_.refreshTickInSeconds);
  170. this.eventManager_.listen(this.localization_,
  171. shaka.ui.Localization.LOCALE_CHANGED, (e) => {
  172. const locale = e['locales'][0];
  173. this.adManager_.setLocale(locale);
  174. this.videoContainer_.setAttribute('lang', locale);
  175. });
  176. this.adManager_.initInterstitial(
  177. this.getClientSideAdContainer(), this.localPlayer_, this.localVideo_);
  178. }
  179. /**
  180. * @override
  181. * @export
  182. */
  183. async destroy() {
  184. if (document.pictureInPictureElement == this.localVideo_) {
  185. await document.exitPictureInPicture();
  186. }
  187. if (this.eventManager_) {
  188. this.eventManager_.release();
  189. this.eventManager_ = null;
  190. }
  191. if (this.mouseStillTimer_) {
  192. this.mouseStillTimer_.stop();
  193. this.mouseStillTimer_ = null;
  194. }
  195. if (this.fadeControlsTimer_) {
  196. this.fadeControlsTimer_.stop();
  197. this.fadeControlsTimer_ = null;
  198. }
  199. if (this.hideSettingsMenusTimer_) {
  200. this.hideSettingsMenusTimer_.stop();
  201. this.hideSettingsMenusTimer_ = null;
  202. }
  203. if (this.timeAndSeekRangeTimer_) {
  204. this.timeAndSeekRangeTimer_.stop();
  205. this.timeAndSeekRangeTimer_ = null;
  206. }
  207. if (this.vr_) {
  208. this.vr_.release();
  209. this.vr_ = null;
  210. }
  211. // Important! Release all child elements before destroying the cast proxy
  212. // or player. This makes sure those destructions will not trigger event
  213. // listeners in the UI which would then invoke the cast proxy or player.
  214. this.releaseChildElements_();
  215. if (this.controlsContainer_) {
  216. this.videoContainer_.removeChild(this.controlsContainer_);
  217. this.controlsContainer_ = null;
  218. }
  219. if (this.castProxy_) {
  220. await this.castProxy_.destroy();
  221. this.castProxy_ = null;
  222. }
  223. if (this.spinnerContainer_) {
  224. this.videoContainer_.removeChild(this.spinnerContainer_);
  225. this.spinnerContainer_ = null;
  226. }
  227. if (this.clientAdContainer_) {
  228. this.videoContainer_.removeChild(this.clientAdContainer_);
  229. this.clientAdContainer_ = null;
  230. }
  231. if (this.localPlayer_) {
  232. await this.localPlayer_.destroy();
  233. this.localPlayer_ = null;
  234. }
  235. this.player_ = null;
  236. this.localVideo_ = null;
  237. this.video_ = null;
  238. this.localization_ = null;
  239. this.pressedKeys_.clear();
  240. this.removeMediaSession_();
  241. // FakeEventTarget implements IReleasable
  242. super.release();
  243. }
  244. /** @private */
  245. releaseChildElements_() {
  246. for (const element of this.elements_) {
  247. element.release();
  248. }
  249. this.elements_ = [];
  250. }
  251. /**
  252. * @param {string} name
  253. * @param {!shaka.extern.IUIElement.Factory} factory
  254. * @export
  255. */
  256. static registerElement(name, factory) {
  257. shaka.ui.ControlsPanel.elementNamesToFactories_.set(name, factory);
  258. }
  259. /**
  260. * @param {!shaka.extern.IUISeekBar.Factory} factory
  261. * @export
  262. */
  263. static registerSeekBar(factory) {
  264. shaka.ui.ControlsPanel.seekBarFactory_ = factory;
  265. }
  266. /**
  267. * This allows the application to inhibit casting.
  268. *
  269. * @param {boolean} allow
  270. * @export
  271. */
  272. allowCast(allow) {
  273. this.castAllowed_ = allow;
  274. this.onCastStatusChange_();
  275. }
  276. /**
  277. * Used by the application to notify the controls that a load operation is
  278. * complete. This allows the controls to recalculate play/paused state, which
  279. * is important for platforms like Android where autoplay is disabled.
  280. * @export
  281. */
  282. loadComplete() {
  283. // If we are on Android or if autoplay is false, video.paused should be
  284. // true. Otherwise, video.paused is false and the content is autoplaying.
  285. this.onPlayStateChange_();
  286. }
  287. /**
  288. * @param {!shaka.extern.UIConfiguration} config
  289. * @export
  290. */
  291. configure(config) {
  292. this.config_ = config;
  293. this.castProxy_.changeReceiverId(config.castReceiverAppId,
  294. config.castAndroidReceiverCompatible);
  295. // Deconstruct the old layout if applicable
  296. if (this.seekBar_) {
  297. this.seekBar_ = null;
  298. }
  299. if (this.playButton_) {
  300. this.playButton_ = null;
  301. }
  302. if (this.contextMenu_) {
  303. this.contextMenu_ = null;
  304. }
  305. if (this.vr_) {
  306. this.vr_.configure(config);
  307. }
  308. if (this.controlsContainer_) {
  309. shaka.util.Dom.removeAllChildren(this.controlsContainer_);
  310. this.releaseChildElements_();
  311. } else {
  312. this.addControlsContainer_();
  313. // The client-side ad container is only created once, and is never
  314. // re-created or uprooted in the DOM, even when the DOM is re-created,
  315. // since that seemingly breaks the IMA SDK.
  316. this.addClientAdContainer_();
  317. goog.asserts.assert(
  318. this.controlsContainer_, 'Should have a controlsContainer_!');
  319. goog.asserts.assert(this.localVideo_, 'Should have a localVideo_!');
  320. goog.asserts.assert(this.player_, 'Should have a player_!');
  321. this.vr_ = new shaka.ui.VRManager(this.controlsContainer_, this.vrCanvas_,
  322. this.localVideo_, this.player_, this.config_);
  323. }
  324. // Create the new layout
  325. this.createDOM_();
  326. // Init the play state
  327. this.onPlayStateChange_();
  328. // Elements that should not propagate clicks (controls panel, menus)
  329. const noPropagationElements = this.videoContainer_.getElementsByClassName(
  330. 'shaka-no-propagation');
  331. for (const element of noPropagationElements) {
  332. const cb = (event) => event.stopPropagation();
  333. this.eventManager_.listen(element, 'click', cb);
  334. this.eventManager_.listen(element, 'dblclick', cb);
  335. if (navigator.maxTouchPoints > 0) {
  336. const touchCb = (event) => {
  337. if (!this.isOpaque()) {
  338. return;
  339. }
  340. event.stopPropagation();
  341. };
  342. this.eventManager_.listen(element, 'touchend', touchCb);
  343. }
  344. }
  345. }
  346. /**
  347. * Enable or disable the custom controls. Enabling disables native
  348. * browser controls.
  349. *
  350. * @param {boolean} enabled
  351. * @export
  352. */
  353. setEnabledShakaControls(enabled) {
  354. this.enabled_ = enabled;
  355. if (enabled) {
  356. this.videoContainer_.setAttribute('shaka-controls', 'true');
  357. // If we're hiding native controls, make sure the video element itself is
  358. // not tab-navigable. Our custom controls will still be tab-navigable.
  359. this.localVideo_.tabIndex = -1;
  360. this.localVideo_.controls = false;
  361. } else {
  362. this.videoContainer_.removeAttribute('shaka-controls');
  363. }
  364. // The effects of play state changes are inhibited while showing native
  365. // browser controls. Recalculate that state now.
  366. this.onPlayStateChange_();
  367. }
  368. /**
  369. * Enable or disable native browser controls. Enabling disables shaka
  370. * controls.
  371. *
  372. * @param {boolean} enabled
  373. * @export
  374. */
  375. setEnabledNativeControls(enabled) {
  376. // If we enable the native controls, the element must be tab-navigable.
  377. // If we disable the native controls, we want to make sure that the video
  378. // element itself is not tab-navigable, so that the element is skipped over
  379. // when tabbing through the page.
  380. this.localVideo_.controls = enabled;
  381. this.localVideo_.tabIndex = enabled ? 0 : -1;
  382. if (enabled) {
  383. this.setEnabledShakaControls(false);
  384. }
  385. }
  386. /**
  387. * @export
  388. * @return {?shaka.extern.IAd}
  389. */
  390. getAd() {
  391. return this.ad_;
  392. }
  393. /**
  394. * @export
  395. * @return {shaka.cast.CastProxy}
  396. */
  397. getCastProxy() {
  398. return this.castProxy_;
  399. }
  400. /**
  401. * @return {shaka.ui.Localization}
  402. * @export
  403. */
  404. getLocalization() {
  405. return this.localization_;
  406. }
  407. /**
  408. * @return {!HTMLElement}
  409. * @export
  410. */
  411. getVideoContainer() {
  412. return this.videoContainer_;
  413. }
  414. /**
  415. * @return {HTMLMediaElement}
  416. * @export
  417. */
  418. getVideo() {
  419. return this.video_;
  420. }
  421. /**
  422. * @return {HTMLMediaElement}
  423. * @export
  424. */
  425. getLocalVideo() {
  426. return this.localVideo_;
  427. }
  428. /**
  429. * @return {shaka.Player}
  430. * @export
  431. */
  432. getPlayer() {
  433. return this.player_;
  434. }
  435. /**
  436. * @return {shaka.Player}
  437. * @export
  438. */
  439. getLocalPlayer() {
  440. return this.localPlayer_;
  441. }
  442. /**
  443. * @return {!HTMLElement}
  444. * @export
  445. */
  446. getControlsContainer() {
  447. goog.asserts.assert(
  448. this.controlsContainer_, 'No controls container after destruction!');
  449. return this.controlsContainer_;
  450. }
  451. /**
  452. * @return {!HTMLElement}
  453. * @export
  454. */
  455. getServerSideAdContainer() {
  456. return this.daiAdContainer_;
  457. }
  458. /**
  459. * @return {!HTMLElement}
  460. * @export
  461. */
  462. getClientSideAdContainer() {
  463. goog.asserts.assert(
  464. this.clientAdContainer_, 'No client ad container after destruction!');
  465. return this.clientAdContainer_;
  466. }
  467. /**
  468. * @return {!shaka.extern.UIConfiguration}
  469. * @export
  470. */
  471. getConfig() {
  472. return this.config_;
  473. }
  474. /**
  475. * @return {boolean}
  476. * @export
  477. */
  478. isSeeking() {
  479. return this.isSeeking_;
  480. }
  481. /**
  482. * @param {boolean} seeking
  483. * @export
  484. */
  485. setSeeking(seeking) {
  486. this.isSeeking_ = seeking;
  487. if (seeking) {
  488. this.mouseStillTimer_.stop();
  489. } else {
  490. this.mouseStillTimer_.tickAfter(/* seconds= */ 3);
  491. }
  492. }
  493. /**
  494. * @return {boolean}
  495. * @export
  496. */
  497. isCastAllowed() {
  498. return this.castAllowed_;
  499. }
  500. /**
  501. * @return {number}
  502. * @export
  503. */
  504. getDisplayTime() {
  505. return this.seekBar_ ? this.seekBar_.getValue() : this.video_.currentTime;
  506. }
  507. /**
  508. * @param {?number} time
  509. * @export
  510. */
  511. setLastTouchEventTime(time) {
  512. this.lastTouchEventTime_ = time;
  513. }
  514. /**
  515. * @return {boolean}
  516. * @export
  517. */
  518. anySettingsMenusAreOpen() {
  519. return this.menus_.some(
  520. (menu) => !menu.classList.contains('shaka-hidden'));
  521. }
  522. /** @export */
  523. hideSettingsMenus() {
  524. this.hideSettingsMenusTimer_.tickNow();
  525. }
  526. /**
  527. * @return {boolean}
  528. * @private
  529. */
  530. shouldUseDocumentFullscreen_() {
  531. if (!document.fullscreenEnabled) {
  532. return false;
  533. }
  534. // When the preferVideoFullScreenInVisionOS configuration value applies,
  535. // we avoid using document fullscreen, even if it is available.
  536. const video = /** @type {HTMLVideoElement} */(this.localVideo_);
  537. if (video.webkitSupportsFullscreen) {
  538. if (this.config_.preferVideoFullScreenInVisionOS &&
  539. shaka.util.Platform.isVisionOS()) {
  540. return false;
  541. }
  542. }
  543. return true;
  544. }
  545. /**
  546. * @return {boolean}
  547. * @private
  548. */
  549. shouldUseDocumentPictureInPicture_() {
  550. return 'documentPictureInPicture' in window &&
  551. this.config_.preferDocumentPictureInPicture;
  552. }
  553. /**
  554. * @return {boolean}
  555. * @export
  556. */
  557. isFullScreenSupported() {
  558. if (this.castProxy_.isCasting()) {
  559. return false;
  560. }
  561. if (this.shouldUseDocumentFullscreen_()) {
  562. return true;
  563. }
  564. if (!this.ad_ || !this.ad_.isUsingAnotherMediaElement()) {
  565. const video = /** @type {HTMLVideoElement} */(this.localVideo_);
  566. if (video.webkitSupportsFullscreen) {
  567. return true;
  568. }
  569. }
  570. return false;
  571. }
  572. /**
  573. * @return {boolean}
  574. * @export
  575. */
  576. isFullScreenEnabled() {
  577. if (this.shouldUseDocumentFullscreen_()) {
  578. return !!document.fullscreenElement;
  579. }
  580. const video = /** @type {HTMLVideoElement} */(this.localVideo_);
  581. if (video.webkitSupportsFullscreen) {
  582. return video.webkitDisplayingFullscreen;
  583. }
  584. return false;
  585. }
  586. /** @private */
  587. async enterFullScreen_() {
  588. try {
  589. if (this.shouldUseDocumentFullscreen_()) {
  590. if (this.isPiPEnabled()) {
  591. await this.togglePiP();
  592. if (this.shouldUseDocumentPictureInPicture_()) {
  593. // This is necessary because we need a small delay when
  594. // executing actions when returning from document PiP.
  595. await new Promise((resolve) => {
  596. new shaka.util.Timer(resolve).tickAfter(0.05);
  597. });
  598. }
  599. }
  600. const fullScreenElement = this.config_.fullScreenElement;
  601. await fullScreenElement.requestFullscreen({navigationUI: 'hide'});
  602. if (this.config_.forceLandscapeOnFullscreen && screen.orientation) {
  603. // Locking to 'landscape' should let it be either
  604. // 'landscape-primary' or 'landscape-secondary' as appropriate.
  605. // We ignore errors from this specific call, since it creates noise
  606. // on desktop otherwise.
  607. try {
  608. await screen.orientation.lock('landscape');
  609. } catch (error) {}
  610. }
  611. } else {
  612. const video = /** @type {HTMLVideoElement} */(this.localVideo_);
  613. if (video.webkitSupportsFullscreen) {
  614. video.webkitEnterFullscreen();
  615. }
  616. }
  617. } catch (error) {
  618. // Entering fullscreen can fail without user interaction.
  619. this.dispatchEvent(new shaka.util.FakeEvent(
  620. 'error', (new Map()).set('detail', error)));
  621. }
  622. }
  623. /** @private */
  624. async exitFullScreen_() {
  625. if (this.shouldUseDocumentFullscreen_()) {
  626. if (screen.orientation) {
  627. screen.orientation.unlock();
  628. }
  629. await document.exitFullscreen();
  630. } else {
  631. const video = /** @type {HTMLVideoElement} */(this.localVideo_);
  632. if (video.webkitSupportsFullscreen) {
  633. video.webkitExitFullscreen();
  634. }
  635. }
  636. }
  637. /** @export */
  638. async toggleFullScreen() {
  639. if (this.isFullScreenEnabled()) {
  640. await this.exitFullScreen_();
  641. } else {
  642. await this.enterFullScreen_();
  643. }
  644. }
  645. /**
  646. * @return {boolean}
  647. * @export
  648. */
  649. isPiPAllowed() {
  650. if (this.castProxy_.isCasting()) {
  651. return false;
  652. }
  653. if (document.pictureInPictureEnabled ||
  654. this.shouldUseDocumentPictureInPicture_()) {
  655. const video = /** @type {HTMLVideoElement} */(this.localVideo_);
  656. return !video.disablePictureInPicture;
  657. }
  658. return false;
  659. }
  660. /**
  661. * @return {boolean}
  662. * @export
  663. */
  664. isPiPEnabled() {
  665. return !!((window.documentPictureInPicture &&
  666. window.documentPictureInPicture.window) ||
  667. document.pictureInPictureElement);
  668. }
  669. /** @export */
  670. async togglePiP() {
  671. try {
  672. if (this.shouldUseDocumentPictureInPicture_()) {
  673. // If you were fullscreen, leave fullscreen first.
  674. if (this.isFullScreenEnabled()) {
  675. await this.exitFullScreen_();
  676. }
  677. await this.toggleDocumentPictureInPicture_();
  678. } else if (!document.pictureInPictureElement) {
  679. // If you were fullscreen, leave fullscreen first.
  680. if (this.isFullScreenEnabled()) {
  681. // When using this PiP API, we can't use an await because in Safari,
  682. // the PiP action wouldn't come from the user's direct input.
  683. // However, this works fine in all browsers.
  684. this.exitFullScreen_();
  685. }
  686. const video = /** @type {HTMLVideoElement} */(this.localVideo_);
  687. await video.requestPictureInPicture();
  688. } else {
  689. await document.exitPictureInPicture();
  690. }
  691. } catch (error) {
  692. this.dispatchEvent(new shaka.util.FakeEvent(
  693. 'error', (new Map()).set('detail', error)));
  694. }
  695. }
  696. /**
  697. * The Document Picture-in-Picture API makes it possible to open an
  698. * always-on-top window that can be populated with arbitrary HTML content.
  699. * https://developer.chrome.com/docs/web-platform/document-picture-in-picture
  700. * @private
  701. */
  702. async toggleDocumentPictureInPicture_() {
  703. // Close Picture-in-Picture window if any.
  704. if (window.documentPictureInPicture.window) {
  705. window.documentPictureInPicture.window.close();
  706. return;
  707. }
  708. // Open a Picture-in-Picture window.
  709. const pipPlayer = this.videoContainer_;
  710. const rectPipPlayer = pipPlayer.getBoundingClientRect();
  711. const pipWindow = await window.documentPictureInPicture.requestWindow({
  712. width: rectPipPlayer.width,
  713. height: rectPipPlayer.height,
  714. });
  715. // Copy style sheets to the Picture-in-Picture window.
  716. this.copyStyleSheetsToWindow_(pipWindow);
  717. // Add placeholder for the player.
  718. const parentPlayer = pipPlayer.parentNode || document.body;
  719. const placeholder = this.videoContainer_.cloneNode(true);
  720. placeholder.style.visibility = 'hidden';
  721. placeholder.style.height = getComputedStyle(pipPlayer).height;
  722. parentPlayer.appendChild(placeholder);
  723. // Make sure player fits in the Picture-in-Picture window.
  724. const styles = document.createElement('style');
  725. styles.append(`[data-shaka-player-container] {
  726. width: 100% !important; max-height: 100%}`);
  727. pipWindow.document.head.append(styles);
  728. // Move player to the Picture-in-Picture window.
  729. pipWindow.document.body.append(pipPlayer);
  730. // Listen for the PiP closing event to move the player back.
  731. this.eventManager_.listenOnce(pipWindow, 'pagehide', () => {
  732. placeholder.replaceWith(/** @type {!Node} */(pipPlayer));
  733. });
  734. }
  735. /** @private */
  736. copyStyleSheetsToWindow_(win) {
  737. const styleSheets = /** @type {!Iterable<*>} */(document.styleSheets);
  738. const allCSS = [...styleSheets]
  739. .map((sheet) => {
  740. try {
  741. return [...sheet.cssRules].map((rule) => rule.cssText).join('');
  742. } catch (e) {
  743. const link = /** @type {!HTMLLinkElement} */(
  744. document.createElement('link'));
  745. link.rel = 'stylesheet';
  746. link.type = sheet.type;
  747. link.media = sheet.media;
  748. link.href = sheet.href;
  749. win.document.head.appendChild(link);
  750. }
  751. return '';
  752. })
  753. .filter(Boolean)
  754. .join('\n');
  755. const style = document.createElement('style');
  756. style.textContent = allCSS;
  757. win.document.head.appendChild(style);
  758. }
  759. /** @export */
  760. showAdUI() {
  761. shaka.ui.Utils.setDisplay(this.adPanel_, true);
  762. shaka.ui.Utils.setDisplay(this.clientAdContainer_, true);
  763. if (this.ad_.hasCustomClick()) {
  764. this.controlsContainer_.setAttribute('ad-active', 'true');
  765. } else {
  766. this.controlsContainer_.removeAttribute('ad-active');
  767. }
  768. }
  769. /** @export */
  770. hideAdUI() {
  771. shaka.ui.Utils.setDisplay(this.adPanel_, false);
  772. shaka.ui.Utils.setDisplay(this.clientAdContainer_, false);
  773. this.controlsContainer_.removeAttribute('ad-active');
  774. }
  775. /**
  776. * Play or pause the current presentation.
  777. */
  778. playPausePresentation() {
  779. if (!this.enabled_) {
  780. return;
  781. }
  782. if (this.ad_) {
  783. this.playPauseAd();
  784. if (this.ad_.isLinear()) {
  785. return;
  786. }
  787. }
  788. if (!this.video_.duration) {
  789. // Can't play yet. Ignore.
  790. return;
  791. }
  792. if (this.presentationIsPaused()) {
  793. // If we are at the end, go back to the beginning.
  794. if (this.player_.isEnded()) {
  795. this.video_.currentTime = this.player_.seekRange().start;
  796. }
  797. this.video_.play();
  798. } else {
  799. this.video_.pause();
  800. }
  801. }
  802. /**
  803. * Play or pause the current ad.
  804. */
  805. playPauseAd() {
  806. if (this.ad_ && this.ad_.isPaused()) {
  807. this.ad_.play();
  808. } else if (this.ad_) {
  809. this.ad_.pause();
  810. }
  811. }
  812. /**
  813. * Return true if the presentation is paused.
  814. *
  815. * @return {boolean}
  816. */
  817. presentationIsPaused() {
  818. // The video element is in a paused state while seeking, but we don't count
  819. // that.
  820. return this.video_.paused && !this.isSeeking();
  821. }
  822. /** @private */
  823. createDOM_() {
  824. this.videoContainer_.classList.add('shaka-video-container');
  825. this.localVideo_.classList.add('shaka-video');
  826. this.addScrimContainer_();
  827. if (this.config_.addBigPlayButton) {
  828. this.addPlayButton_();
  829. }
  830. if (this.config_.customContextMenu) {
  831. this.addContextMenu_();
  832. }
  833. if (!this.spinnerContainer_) {
  834. this.addBufferingSpinner_();
  835. }
  836. if (this.config_.seekOnTaps) {
  837. this.addFastForwardButtonOnControlsContainer_();
  838. this.addRewindButtonOnControlsContainer_();
  839. }
  840. this.addDaiAdContainer_();
  841. this.addControlsButtonPanel_();
  842. this.menus_ = Array.from(
  843. this.videoContainer_.getElementsByClassName('shaka-settings-menu'));
  844. this.menus_.push(...Array.from(
  845. this.videoContainer_.getElementsByClassName('shaka-overflow-menu')));
  846. this.addSeekBar_();
  847. this.showOnHoverControls_ = Array.from(
  848. this.videoContainer_.getElementsByClassName(
  849. 'shaka-show-controls-on-mouse-over'));
  850. }
  851. /** @private */
  852. addControlsContainer_() {
  853. /** @private {HTMLElement} */
  854. this.controlsContainer_ = shaka.util.Dom.createHTMLElement('div');
  855. this.controlsContainer_.classList.add('shaka-controls-container');
  856. this.videoContainer_.appendChild(this.controlsContainer_);
  857. // Use our controls by default, without anyone calling
  858. // setEnabledShakaControls:
  859. this.videoContainer_.setAttribute('shaka-controls', 'true');
  860. this.eventManager_.listen(this.controlsContainer_, 'touchend', (e) => {
  861. this.onContainerTouch_(e);
  862. });
  863. this.eventManager_.listen(this.controlsContainer_, 'click', () => {
  864. this.onContainerClick();
  865. });
  866. this.eventManager_.listen(this.controlsContainer_, 'dblclick', () => {
  867. if (this.config_.doubleClickForFullscreen &&
  868. this.isFullScreenSupported()) {
  869. this.toggleFullScreen();
  870. }
  871. });
  872. }
  873. /** @private */
  874. addPlayButton_() {
  875. const playButtonContainer = shaka.util.Dom.createHTMLElement('div');
  876. playButtonContainer.classList.add('shaka-play-button-container');
  877. this.controlsContainer_.appendChild(playButtonContainer);
  878. /** @private {shaka.ui.BigPlayButton} */
  879. this.playButton_ =
  880. new shaka.ui.BigPlayButton(playButtonContainer, this);
  881. this.elements_.push(this.playButton_);
  882. }
  883. /** @private */
  884. addContextMenu_() {
  885. /** @private {shaka.ui.ContextMenu} */
  886. this.contextMenu_ =
  887. new shaka.ui.ContextMenu(this.controlsButtonPanel_, this);
  888. this.elements_.push(this.contextMenu_);
  889. }
  890. /** @private */
  891. addScrimContainer_() {
  892. // This is the container that gets styled by CSS to have the
  893. // black gradient scrim at the end of the controls.
  894. const scrimContainer = shaka.util.Dom.createHTMLElement('div');
  895. scrimContainer.classList.add('shaka-scrim-container');
  896. this.controlsContainer_.appendChild(scrimContainer);
  897. }
  898. /** @private */
  899. addAdControls_() {
  900. /** @private {!HTMLElement} */
  901. this.adPanel_ = shaka.util.Dom.createHTMLElement('div');
  902. this.adPanel_.classList.add('shaka-ad-controls');
  903. const showAdPanel = this.ad_ != null && this.ad_.isLinear();
  904. shaka.ui.Utils.setDisplay(this.adPanel_, showAdPanel);
  905. this.bottomControls_.appendChild(this.adPanel_);
  906. const adPosition = new shaka.ui.AdPosition(this.adPanel_, this);
  907. this.elements_.push(adPosition);
  908. const adCounter = new shaka.ui.AdCounter(this.adPanel_, this);
  909. this.elements_.push(adCounter);
  910. const skipButton = new shaka.ui.SkipAdButton(this.adPanel_, this);
  911. this.elements_.push(skipButton);
  912. }
  913. /** @private */
  914. addBufferingSpinner_() {
  915. /** @private {HTMLElement} */
  916. this.spinnerContainer_ = shaka.util.Dom.createHTMLElement('div');
  917. this.spinnerContainer_.classList.add('shaka-spinner-container');
  918. this.videoContainer_.appendChild(this.spinnerContainer_);
  919. const spinner = shaka.util.Dom.createHTMLElement('div');
  920. spinner.classList.add('shaka-spinner');
  921. this.spinnerContainer_.appendChild(spinner);
  922. // Svg elements have to be created with the svg xml namespace.
  923. const xmlns = 'http://www.w3.org/2000/svg';
  924. const svg =
  925. /** @type {!HTMLElement} */(document.createElementNS(xmlns, 'svg'));
  926. svg.classList.add('shaka-spinner-svg');
  927. svg.setAttribute('viewBox', '0 0 30 30');
  928. spinner.appendChild(svg);
  929. // These coordinates are relative to the SVG viewBox above. This is
  930. // distinct from the actual display size in the page, since the "S" is for
  931. // "Scalable." The radius of 14.5 is so that the edges of the 1-px-wide
  932. // stroke will touch the edges of the viewBox.
  933. const spinnerCircle = document.createElementNS(xmlns, 'circle');
  934. spinnerCircle.classList.add('shaka-spinner-path');
  935. spinnerCircle.setAttribute('cx', '15');
  936. spinnerCircle.setAttribute('cy', '15');
  937. spinnerCircle.setAttribute('r', '14.5');
  938. spinnerCircle.setAttribute('fill', 'none');
  939. spinnerCircle.setAttribute('stroke-width', '1');
  940. spinnerCircle.setAttribute('stroke-miterlimit', '10');
  941. svg.appendChild(spinnerCircle);
  942. }
  943. /**
  944. * Add fast-forward button on Controls container for moving video some
  945. * seconds ahead when the video is tapped more than once, video seeks ahead
  946. * some seconds for every extra tap.
  947. * @private
  948. */
  949. addFastForwardButtonOnControlsContainer_() {
  950. const hiddenFastForwardContainer = shaka.util.Dom.createHTMLElement('div');
  951. hiddenFastForwardContainer.classList.add(
  952. 'shaka-hidden-fast-forward-container');
  953. this.controlsContainer_.appendChild(hiddenFastForwardContainer);
  954. /** @private {shaka.ui.HiddenFastForwardButton} */
  955. this.hiddenFastForwardButton_ =
  956. new shaka.ui.HiddenFastForwardButton(hiddenFastForwardContainer, this);
  957. this.elements_.push(this.hiddenFastForwardButton_);
  958. }
  959. /**
  960. * Add Rewind button on Controls container for moving video some seconds
  961. * behind when the video is tapped more than once, video seeks behind some
  962. * seconds for every extra tap.
  963. * @private
  964. */
  965. addRewindButtonOnControlsContainer_() {
  966. const hiddenRewindContainer = shaka.util.Dom.createHTMLElement('div');
  967. hiddenRewindContainer.classList.add(
  968. 'shaka-hidden-rewind-container');
  969. this.controlsContainer_.appendChild(hiddenRewindContainer);
  970. /** @private {shaka.ui.HiddenRewindButton} */
  971. this.hiddenRewindButton_ =
  972. new shaka.ui.HiddenRewindButton(hiddenRewindContainer, this);
  973. this.elements_.push(this.hiddenRewindButton_);
  974. }
  975. /** @private */
  976. addControlsButtonPanel_() {
  977. /** @private {!HTMLElement} */
  978. this.bottomControls_ = shaka.util.Dom.createHTMLElement('div');
  979. this.bottomControls_.classList.add('shaka-bottom-controls');
  980. this.bottomControls_.classList.add('shaka-no-propagation');
  981. this.controlsContainer_.appendChild(this.bottomControls_);
  982. // Overflow menus are supposed to hide once you click elsewhere
  983. // on the page. The click event listener on window ensures that.
  984. // However, clicks on the bottom controls don't propagate to the container,
  985. // so we have to explicitly hide the menus onclick here.
  986. this.eventManager_.listen(this.bottomControls_, 'click', (e) => {
  987. // We explicitly deny this measure when clicking on buttons that
  988. // open submenus in the control panel.
  989. if (!e.target['closest']('.shaka-overflow-button')) {
  990. this.hideSettingsMenus();
  991. }
  992. });
  993. this.addAdControls_();
  994. /** @private {!HTMLElement} */
  995. this.controlsButtonPanel_ = shaka.util.Dom.createHTMLElement('div');
  996. this.controlsButtonPanel_.classList.add('shaka-controls-button-panel');
  997. this.controlsButtonPanel_.classList.add(
  998. 'shaka-show-controls-on-mouse-over');
  999. if (this.config_.enableTooltips) {
  1000. this.controlsButtonPanel_.classList.add('shaka-tooltips-on');
  1001. }
  1002. this.bottomControls_.appendChild(this.controlsButtonPanel_);
  1003. // Create the elements specified by controlPanelElements
  1004. for (const name of this.config_.controlPanelElements) {
  1005. if (shaka.ui.ControlsPanel.elementNamesToFactories_.get(name)) {
  1006. const factory =
  1007. shaka.ui.ControlsPanel.elementNamesToFactories_.get(name);
  1008. const element = factory.create(this.controlsButtonPanel_, this);
  1009. this.elements_.push(element);
  1010. } else {
  1011. shaka.log.alwaysWarn('Unrecognized control panel element requested:',
  1012. name);
  1013. }
  1014. }
  1015. }
  1016. /**
  1017. * Adds a container for server side ad UI with IMA SDK.
  1018. *
  1019. * @private
  1020. */
  1021. addDaiAdContainer_() {
  1022. /** @private {!HTMLElement} */
  1023. this.daiAdContainer_ = shaka.util.Dom.createHTMLElement('div');
  1024. this.daiAdContainer_.classList.add('shaka-server-side-ad-container');
  1025. this.controlsContainer_.appendChild(this.daiAdContainer_);
  1026. }
  1027. /**
  1028. * Adds a seekbar depending on the configuration.
  1029. * By default an instance of shaka.ui.SeekBar is created
  1030. * This behaviour can be overridden by providing a SeekBar factory using the
  1031. * registerSeekBarFactory function.
  1032. *
  1033. * @private
  1034. */
  1035. addSeekBar_() {
  1036. if (this.config_.addSeekBar) {
  1037. this.seekBar_ = shaka.ui.ControlsPanel.seekBarFactory_.create(
  1038. this.bottomControls_, this);
  1039. this.elements_.push(this.seekBar_);
  1040. } else {
  1041. // Settings menus need to be positioned lower if the seekbar is absent.
  1042. for (const menu of this.menus_) {
  1043. menu.classList.add('shaka-low-position');
  1044. }
  1045. }
  1046. }
  1047. /**
  1048. * Adds a container for client side ad UI with IMA SDK.
  1049. *
  1050. * @private
  1051. */
  1052. addClientAdContainer_() {
  1053. /** @private {HTMLElement} */
  1054. this.clientAdContainer_ = shaka.util.Dom.createHTMLElement('div');
  1055. this.clientAdContainer_.classList.add('shaka-client-side-ad-container');
  1056. shaka.ui.Utils.setDisplay(this.clientAdContainer_, false);
  1057. this.eventManager_.listen(this.clientAdContainer_, 'click', () => {
  1058. this.onContainerClick();
  1059. });
  1060. this.videoContainer_.appendChild(this.clientAdContainer_);
  1061. }
  1062. /**
  1063. * Adds static event listeners. This should only add event listeners to
  1064. * things that don't change (e.g. Player). Dynamic elements (e.g. controls)
  1065. * should have their event listeners added when they are created.
  1066. *
  1067. * @private
  1068. */
  1069. addEventListeners_() {
  1070. this.eventManager_.listen(this.player_, 'buffering', () => {
  1071. this.onBufferingStateChange_();
  1072. });
  1073. // Set the initial state, as well.
  1074. this.onBufferingStateChange_();
  1075. // Listen for key down events to detect tab and enable outline
  1076. // for focused elements.
  1077. this.eventManager_.listen(window, 'keydown', (e) => {
  1078. this.onWindowKeyDown_(/** @type {!KeyboardEvent} */(e));
  1079. });
  1080. // Listen for click events to dismiss the settings menus.
  1081. this.eventManager_.listen(window, 'click', () => this.hideSettingsMenus());
  1082. // Avoid having multiple submenus open at the same time.
  1083. this.eventManager_.listen(
  1084. this, 'submenuopen', () => {
  1085. this.hideSettingsMenus();
  1086. });
  1087. this.eventManager_.listen(this.video_, 'play', () => {
  1088. this.onPlayStateChange_();
  1089. });
  1090. this.eventManager_.listen(this.video_, 'pause', () => {
  1091. this.onPlayStateChange_();
  1092. });
  1093. this.eventManager_.listen(this.videoContainer_, 'mousemove', (e) => {
  1094. this.onMouseMove_(e);
  1095. });
  1096. this.eventManager_.listen(this.videoContainer_, 'touchmove', (e) => {
  1097. this.onMouseMove_(e);
  1098. }, {passive: true});
  1099. this.eventManager_.listen(this.videoContainer_, 'touchend', (e) => {
  1100. this.onMouseMove_(e);
  1101. }, {passive: true});
  1102. this.eventManager_.listen(this.videoContainer_, 'mouseleave', () => {
  1103. this.onMouseLeave_();
  1104. });
  1105. this.eventManager_.listen(this.videoContainer_, 'wheel', (e) => {
  1106. this.onMouseMove_(e);
  1107. }, {passive: true});
  1108. this.eventManager_.listen(this.castProxy_, 'caststatuschanged', () => {
  1109. this.onCastStatusChange_();
  1110. });
  1111. this.eventManager_.listen(this.vr_, 'vrstatuschanged', () => {
  1112. this.dispatchEvent(new shaka.util.FakeEvent('vrstatuschanged'));
  1113. });
  1114. this.eventManager_.listen(this.videoContainer_, 'keydown', (e) => {
  1115. this.onControlsKeyDown_(/** @type {!KeyboardEvent} */(e));
  1116. });
  1117. this.eventManager_.listen(this.videoContainer_, 'keyup', (e) => {
  1118. this.onControlsKeyUp_(/** @type {!KeyboardEvent} */(e));
  1119. });
  1120. this.eventManager_.listen(
  1121. this.adManager_, shaka.ads.Utils.AD_STARTED, (e) => {
  1122. this.ad_ = (/** @type {!Object} */ (e))['ad'];
  1123. this.showAdUI();
  1124. this.onBufferingStateChange_();
  1125. });
  1126. this.eventManager_.listen(
  1127. this.adManager_, shaka.ads.Utils.AD_STOPPED, () => {
  1128. this.ad_ = null;
  1129. this.hideAdUI();
  1130. this.onBufferingStateChange_();
  1131. });
  1132. if (screen.orientation) {
  1133. this.eventManager_.listen(screen.orientation, 'change', async () => {
  1134. await this.onScreenRotation_();
  1135. });
  1136. }
  1137. }
  1138. /**
  1139. * @private
  1140. */
  1141. setupMediaSession_() {
  1142. if (!this.config_.setupMediaSession || !navigator.mediaSession) {
  1143. return;
  1144. }
  1145. const addMediaSessionHandler = (type, callback) => {
  1146. try {
  1147. navigator.mediaSession.setActionHandler(type, (details) => {
  1148. callback(details);
  1149. });
  1150. } catch (error) {
  1151. shaka.log.debug(
  1152. `The "${type}" media session action is not supported.`);
  1153. }
  1154. };
  1155. const updatePositionState = () => {
  1156. if (this.ad_ && this.ad_.isLinear()) {
  1157. clearPositionState();
  1158. return;
  1159. }
  1160. const seekRange = this.player_.seekRange();
  1161. let duration = seekRange.end - seekRange.start;
  1162. const position = parseFloat(
  1163. (this.video_.currentTime - seekRange.start).toFixed(2));
  1164. if (this.player_.isLive() && Math.abs(duration - position) < 1) {
  1165. // Positive infinity indicates media without a defined end, such as a
  1166. // live stream.
  1167. duration = Infinity;
  1168. }
  1169. try {
  1170. navigator.mediaSession.setPositionState({
  1171. duration: Math.max(0, duration),
  1172. playbackRate: this.video_.playbackRate,
  1173. position: Math.max(0, position),
  1174. });
  1175. } catch (error) {
  1176. shaka.log.v2(
  1177. 'setPositionState in media session is not supported.');
  1178. }
  1179. };
  1180. const clearPositionState = () => {
  1181. try {
  1182. navigator.mediaSession.setPositionState();
  1183. } catch (error) {
  1184. shaka.log.v2(
  1185. 'setPositionState in media session is not supported.');
  1186. }
  1187. };
  1188. const commonHandler = (details) => {
  1189. const keyboardSeekDistance = this.config_.keyboardSeekDistance;
  1190. switch (details.action) {
  1191. case 'pause':
  1192. this.playPausePresentation();
  1193. break;
  1194. case 'play':
  1195. this.playPausePresentation();
  1196. break;
  1197. case 'seekbackward':
  1198. if (details.seekOffset && !isFinite(details.seekOffset)) {
  1199. break;
  1200. }
  1201. if (!this.ad_ || !this.ad_.isLinear()) {
  1202. this.seek_(this.seekBar_.getValue() -
  1203. (details.seekOffset || keyboardSeekDistance));
  1204. }
  1205. break;
  1206. case 'seekforward':
  1207. if (details.seekOffset && !isFinite(details.seekOffset)) {
  1208. break;
  1209. }
  1210. if (!this.ad_ || !this.ad_.isLinear()) {
  1211. this.seek_(this.seekBar_.getValue() +
  1212. (details.seekOffset || keyboardSeekDistance));
  1213. }
  1214. break;
  1215. case 'seekto':
  1216. if (details.seekTime && !isFinite(details.seekTime)) {
  1217. break;
  1218. }
  1219. if (!this.ad_ || !this.ad_.isLinear()) {
  1220. this.seek_(this.player_.seekRange().start + details.seekTime);
  1221. }
  1222. break;
  1223. case 'stop':
  1224. this.player_.unload();
  1225. break;
  1226. case 'enterpictureinpicture':
  1227. if (!this.ad_ || !this.ad_.isLinear()) {
  1228. this.togglePiP();
  1229. }
  1230. break;
  1231. }
  1232. };
  1233. addMediaSessionHandler('pause', commonHandler);
  1234. addMediaSessionHandler('play', commonHandler);
  1235. addMediaSessionHandler('seekbackward', commonHandler);
  1236. addMediaSessionHandler('seekforward', commonHandler);
  1237. addMediaSessionHandler('seekto', commonHandler);
  1238. addMediaSessionHandler('stop', commonHandler);
  1239. if ('documentPictureInPicture' in window ||
  1240. document.pictureInPictureEnabled) {
  1241. addMediaSessionHandler('enterpictureinpicture', commonHandler);
  1242. }
  1243. const playerLoaded = () => {
  1244. if (this.player_.isLive() || this.player_.seekRange().start != 0) {
  1245. updatePositionState();
  1246. this.eventManager_.listen(
  1247. this.video_, 'timeupdate', updatePositionState);
  1248. } else {
  1249. clearPositionState();
  1250. }
  1251. };
  1252. const playerUnloading = () => {
  1253. this.eventManager_.unlisten(
  1254. this.video_, 'timeupdate', updatePositionState);
  1255. };
  1256. if (this.player_.isFullyLoaded()) {
  1257. playerLoaded();
  1258. }
  1259. this.eventManager_.listen(this.player_, 'loaded', playerLoaded);
  1260. this.eventManager_.listen(this.player_, 'unloading', playerUnloading);
  1261. this.eventManager_.listen(this.player_, 'metadata', (event) => {
  1262. const payload = event['payload'];
  1263. if (!payload) {
  1264. return;
  1265. }
  1266. let title;
  1267. if (payload['key'] == 'TIT2' && payload['data']) {
  1268. title = payload['data'];
  1269. }
  1270. let imageUrl;
  1271. if (payload['key'] == 'APIC' && payload['mimeType'] == '-->') {
  1272. imageUrl = payload['data'];
  1273. }
  1274. if (title) {
  1275. let metadata = {
  1276. title: title,
  1277. artwork: [],
  1278. };
  1279. if (navigator.mediaSession.metadata) {
  1280. metadata = navigator.mediaSession.metadata;
  1281. metadata.title = title;
  1282. }
  1283. navigator.mediaSession.metadata = new MediaMetadata(metadata);
  1284. }
  1285. if (imageUrl) {
  1286. const video = /** @type {HTMLVideoElement} */ (this.localVideo_);
  1287. if (imageUrl != video.poster) {
  1288. video.poster = imageUrl;
  1289. }
  1290. let metadata = {
  1291. title: '',
  1292. artwork: [{src: imageUrl}],
  1293. };
  1294. if (navigator.mediaSession.metadata) {
  1295. metadata = navigator.mediaSession.metadata;
  1296. metadata.artwork = [{src: imageUrl}];
  1297. }
  1298. navigator.mediaSession.metadata = new MediaMetadata(metadata);
  1299. }
  1300. });
  1301. }
  1302. /**
  1303. * @private
  1304. */
  1305. removeMediaSession_() {
  1306. if (!this.config_.setupMediaSession || !navigator.mediaSession) {
  1307. return;
  1308. }
  1309. try {
  1310. navigator.mediaSession.setPositionState();
  1311. } catch (error) {}
  1312. const disableMediaSessionHandler = (type) => {
  1313. try {
  1314. navigator.mediaSession.setActionHandler(type, null);
  1315. } catch (error) {}
  1316. };
  1317. disableMediaSessionHandler('pause');
  1318. disableMediaSessionHandler('play');
  1319. disableMediaSessionHandler('seekbackward');
  1320. disableMediaSessionHandler('seekforward');
  1321. disableMediaSessionHandler('seekto');
  1322. disableMediaSessionHandler('stop');
  1323. disableMediaSessionHandler('enterpictureinpicture');
  1324. }
  1325. /**
  1326. * When a mobile device is rotated to landscape layout, and the video is
  1327. * loaded, make the demo app go into fullscreen.
  1328. * Similarly, exit fullscreen when the device is rotated to portrait layout.
  1329. * @private
  1330. */
  1331. async onScreenRotation_() {
  1332. if (!this.video_ ||
  1333. this.video_.readyState == 0 ||
  1334. this.castProxy_.isCasting() ||
  1335. !this.config_.enableFullscreenOnRotation ||
  1336. !this.isFullScreenSupported()) {
  1337. return;
  1338. }
  1339. if (screen.orientation.type.includes('landscape') &&
  1340. !this.isFullScreenEnabled()) {
  1341. await this.enterFullScreen_();
  1342. } else if (screen.orientation.type.includes('portrait') &&
  1343. this.isFullScreenEnabled()) {
  1344. await this.exitFullScreen_();
  1345. }
  1346. }
  1347. /**
  1348. * Hiding the cursor when the mouse stops moving seems to be the only
  1349. * decent UX in fullscreen mode. Since we can't use pure CSS for that,
  1350. * we use events both in and out of fullscreen mode.
  1351. * Showing the control bar when a key is pressed, and hiding it after some
  1352. * time.
  1353. * @param {!Event} event
  1354. * @private
  1355. */
  1356. onMouseMove_(event) {
  1357. // Disable blue outline for focused elements for mouse navigation.
  1358. if (event.type == 'mousemove') {
  1359. this.controlsContainer_.classList.remove('shaka-keyboard-navigation');
  1360. this.computeOpacity();
  1361. }
  1362. if (event.type == 'touchstart' || event.type == 'touchmove' ||
  1363. event.type == 'touchend' || event.type == 'keyup') {
  1364. this.lastTouchEventTime_ = Date.now();
  1365. } else if (this.lastTouchEventTime_ + 1000 < Date.now()) {
  1366. // It has been a while since the last touch event, this is probably a real
  1367. // mouse moving, so treat it like a mouse.
  1368. this.lastTouchEventTime_ = null;
  1369. }
  1370. // When there is a touch, we can get a 'mousemove' event after touch events.
  1371. // This should be treated as part of the touch, which has already been
  1372. // handled.
  1373. if (this.lastTouchEventTime_ && event.type == 'mousemove') {
  1374. return;
  1375. }
  1376. // Use the cursor specified in the CSS file.
  1377. this.videoContainer_.classList.remove('no-cursor');
  1378. this.recentMouseMovement_ = true;
  1379. // Make sure we are not about to hide the settings menus and then force them
  1380. // open.
  1381. this.hideSettingsMenusTimer_.stop();
  1382. if (!this.isOpaque()) {
  1383. // Only update the time and seek range on mouse movement if it's the very
  1384. // first movement and we're about to show the controls. Otherwise, the
  1385. // seek bar will be updated much more rapidly during mouse movement. Do
  1386. // this right before making it visible.
  1387. this.updateTimeAndSeekRange_();
  1388. this.computeOpacity();
  1389. this.dispatchVisibilityEvent_();
  1390. }
  1391. // Hide the cursor when the mouse stops moving.
  1392. // Only applies while the cursor is over the video container.
  1393. this.mouseStillTimer_.stop();
  1394. // Only start a timeout on 'touchend' or for 'mousemove' with no touch
  1395. // events.
  1396. if (event.type == 'touchend' ||
  1397. event.type == 'wheel' ||
  1398. event.type == 'keyup'|| !this.lastTouchEventTime_) {
  1399. this.mouseStillTimer_.tickAfter(/* seconds= */ 3);
  1400. }
  1401. }
  1402. /** @private */
  1403. onMouseLeave_() {
  1404. // We sometimes get 'mouseout' events with touches. Since we can never
  1405. // leave the video element when touching, ignore.
  1406. if (this.lastTouchEventTime_) {
  1407. return;
  1408. }
  1409. // Stop the timer and invoke the callback now to hide the controls. If we
  1410. // don't, the opacity style we set in onMouseMove_ will continue to override
  1411. // the opacity in CSS and force the controls to stay visible.
  1412. this.mouseStillTimer_.tickNow();
  1413. }
  1414. /**
  1415. * This callback is for when we are pretty sure that the mouse has stopped
  1416. * moving (aka the mouse is still). This method should only be called via
  1417. * |mouseStillTimer_|. If this behaviour needs to be invoked directly, use
  1418. * |mouseStillTimer_.tickNow()|.
  1419. *
  1420. * @private
  1421. */
  1422. onMouseStill_() {
  1423. // Hide the cursor.
  1424. this.videoContainer_.classList.add('no-cursor');
  1425. this.recentMouseMovement_ = false;
  1426. this.computeOpacity();
  1427. }
  1428. /**
  1429. * @return {boolean} true if any relevant elements are hovered.
  1430. * @private
  1431. */
  1432. isHovered_() {
  1433. if (!window.matchMedia('hover: hover').matches) {
  1434. // This is primarily a touch-screen device, so the :hover query below
  1435. // doesn't make sense. In spite of this, the :hover query on an element
  1436. // can still return true on such a device after a touch ends.
  1437. // See https://bit.ly/34dBORX for details.
  1438. return false;
  1439. }
  1440. return this.showOnHoverControls_.some((element) => {
  1441. return element.matches(':hover');
  1442. });
  1443. }
  1444. /**
  1445. * Recompute whether the controls should be shown or hidden.
  1446. */
  1447. computeOpacity() {
  1448. const adIsPaused = this.ad_ ? this.ad_.isPaused() : false;
  1449. const videoIsPaused = this.video_.paused && !this.isSeeking_;
  1450. const keyboardNavigationMode = this.controlsContainer_.classList.contains(
  1451. 'shaka-keyboard-navigation');
  1452. // Keep showing the controls if the ad or video is paused, there has been
  1453. // recent mouse movement, we're in keyboard navigation, or one of a special
  1454. // class of elements is hovered.
  1455. if (adIsPaused ||
  1456. ((!this.ad_ || !this.ad_.isLinear()) && videoIsPaused) ||
  1457. this.recentMouseMovement_ ||
  1458. keyboardNavigationMode ||
  1459. this.isHovered_()) {
  1460. // Make sure the state is up-to-date before showing it.
  1461. this.updateTimeAndSeekRange_();
  1462. this.controlsContainer_.setAttribute('shown', 'true');
  1463. this.fadeControlsTimer_.stop();
  1464. } else {
  1465. this.fadeControlsTimer_.tickAfter(/* seconds= */ this.config_.fadeDelay);
  1466. }
  1467. }
  1468. /**
  1469. * @param {!Event} event
  1470. * @private
  1471. */
  1472. onContainerTouch_(event) {
  1473. if (!this.video_.duration) {
  1474. // Can't play yet. Ignore.
  1475. return;
  1476. }
  1477. if (this.isOpaque()) {
  1478. this.lastTouchEventTime_ = Date.now();
  1479. // The controls are showing.
  1480. // Let this event continue and become a click.
  1481. } else {
  1482. // The controls are hidden, so show them.
  1483. this.onMouseMove_(event);
  1484. // Stop this event from becoming a click event.
  1485. event.cancelable && event.preventDefault();
  1486. }
  1487. }
  1488. /**
  1489. * Manage the container click.
  1490. */
  1491. onContainerClick() {
  1492. if (!this.enabled_ || this.isPlayingVR()) {
  1493. return;
  1494. }
  1495. if (this.anySettingsMenusAreOpen()) {
  1496. this.hideSettingsMenusTimer_.tickNow();
  1497. } else if (this.config_.singleClickForPlayAndPause) {
  1498. this.playPausePresentation();
  1499. }
  1500. }
  1501. /** @private */
  1502. onCastStatusChange_() {
  1503. const isCasting = this.castProxy_.isCasting();
  1504. this.dispatchEvent(new shaka.util.FakeEvent(
  1505. 'caststatuschanged', (new Map()).set('newStatus', isCasting)));
  1506. if (isCasting) {
  1507. this.controlsContainer_.setAttribute('casting', 'true');
  1508. } else {
  1509. this.controlsContainer_.removeAttribute('casting');
  1510. }
  1511. this.dispatchVisibilityEvent_();
  1512. }
  1513. /** @private */
  1514. onPlayStateChange_() {
  1515. this.computeOpacity();
  1516. }
  1517. /**
  1518. * Support controls with keyboard inputs.
  1519. * @param {!KeyboardEvent} event
  1520. * @private
  1521. */
  1522. onControlsKeyDown_(event) {
  1523. const activeElement = document.activeElement;
  1524. const isVolumeBar = activeElement && activeElement.classList ?
  1525. activeElement.classList.contains('shaka-volume-bar') : false;
  1526. const isSeekBar = activeElement && activeElement.classList &&
  1527. activeElement.classList.contains('shaka-seek-bar');
  1528. // Show the control panel if it is on focus or any button is pressed.
  1529. if (this.controlsContainer_.contains(activeElement)) {
  1530. this.onMouseMove_(event);
  1531. }
  1532. if (!this.config_.enableKeyboardPlaybackControls) {
  1533. return;
  1534. }
  1535. const keyboardSeekDistance = this.config_.keyboardSeekDistance;
  1536. const keyboardLargeSeekDistance = this.config_.keyboardLargeSeekDistance;
  1537. switch (event.key) {
  1538. case 'ArrowLeft':
  1539. // If it's not focused on the volume bar, move the seek time backward
  1540. // for a few sec. Otherwise, the volume will be adjusted automatically.
  1541. if (this.seekBar_ && isSeekBar && !isVolumeBar &&
  1542. keyboardSeekDistance > 0) {
  1543. event.preventDefault();
  1544. this.seek_(this.seekBar_.getValue() - keyboardSeekDistance);
  1545. }
  1546. break;
  1547. case 'ArrowRight':
  1548. // If it's not focused on the volume bar, move the seek time forward
  1549. // for a few sec. Otherwise, the volume will be adjusted automatically.
  1550. if (this.seekBar_ && isSeekBar && !isVolumeBar &&
  1551. keyboardSeekDistance > 0) {
  1552. event.preventDefault();
  1553. this.seek_(this.seekBar_.getValue() + keyboardSeekDistance);
  1554. }
  1555. break;
  1556. case 'PageDown':
  1557. // PageDown is like ArrowLeft, but has a larger jump distance, and does
  1558. // nothing to volume.
  1559. if (this.seekBar_ && isSeekBar && keyboardSeekDistance > 0) {
  1560. event.preventDefault();
  1561. this.seek_(this.seekBar_.getValue() - keyboardLargeSeekDistance);
  1562. }
  1563. break;
  1564. case 'PageUp':
  1565. // PageDown is like ArrowRight, but has a larger jump distance, and does
  1566. // nothing to volume.
  1567. if (this.seekBar_ && isSeekBar && keyboardSeekDistance > 0) {
  1568. event.preventDefault();
  1569. this.seek_(this.seekBar_.getValue() + keyboardLargeSeekDistance);
  1570. }
  1571. break;
  1572. // Jump to the beginning of the video's seek range.
  1573. case 'Home':
  1574. if (this.seekBar_) {
  1575. this.seek_(this.player_.seekRange().start);
  1576. }
  1577. break;
  1578. // Jump to the end of the video's seek range.
  1579. case 'End':
  1580. if (this.seekBar_) {
  1581. this.seek_(this.player_.seekRange().end);
  1582. }
  1583. break;
  1584. case 'f':
  1585. if (this.isFullScreenSupported()) {
  1586. this.toggleFullScreen();
  1587. }
  1588. break;
  1589. case 'm':
  1590. if (this.ad_ && this.ad_.isLinear()) {
  1591. this.ad_.setMuted(!this.ad_.isMuted());
  1592. } else {
  1593. this.localVideo_.muted = !this.localVideo_.muted;
  1594. }
  1595. break;
  1596. case 'p':
  1597. if (this.isPiPAllowed()) {
  1598. this.togglePiP();
  1599. }
  1600. break;
  1601. // Pause or play by pressing space on the seek bar.
  1602. case ' ':
  1603. if (isSeekBar) {
  1604. this.playPausePresentation();
  1605. }
  1606. break;
  1607. }
  1608. }
  1609. /**
  1610. * Support controls with keyboard inputs.
  1611. * @param {!KeyboardEvent} event
  1612. * @private
  1613. */
  1614. onControlsKeyUp_(event) {
  1615. // When the key is released, remove it from the pressed keys set.
  1616. this.pressedKeys_.delete(event.key);
  1617. }
  1618. /**
  1619. * Called both as an event listener and directly by the controls to initialize
  1620. * the buffering state.
  1621. * @private
  1622. */
  1623. onBufferingStateChange_() {
  1624. if (!this.enabled_) {
  1625. return;
  1626. }
  1627. if (this.ad_ && this.ad_.isClientRendering() && this.ad_.isLinear()) {
  1628. shaka.ui.Utils.setDisplay(this.spinnerContainer_, false);
  1629. return;
  1630. }
  1631. shaka.ui.Utils.setDisplay(
  1632. this.spinnerContainer_, this.player_.isBuffering());
  1633. }
  1634. /**
  1635. * @return {boolean}
  1636. * @export
  1637. */
  1638. isOpaque() {
  1639. if (!this.enabled_) {
  1640. return false;
  1641. }
  1642. return this.controlsContainer_.getAttribute('shown') != null ||
  1643. this.controlsContainer_.getAttribute('casting') != null;
  1644. }
  1645. /**
  1646. * @private
  1647. */
  1648. dispatchVisibilityEvent_() {
  1649. if (this.isOpaque()) {
  1650. this.dispatchEvent(new shaka.util.FakeEvent('showingui'));
  1651. } else {
  1652. this.dispatchEvent(new shaka.util.FakeEvent('hidingui'));
  1653. }
  1654. }
  1655. /**
  1656. * Update the video's current time based on the keyboard operations.
  1657. *
  1658. * @param {number} currentTime
  1659. * @private
  1660. */
  1661. seek_(currentTime) {
  1662. goog.asserts.assert(
  1663. this.seekBar_, 'Caller of seek_ must check for seekBar_ first!');
  1664. this.video_.currentTime = currentTime;
  1665. this.updateTimeAndSeekRange_();
  1666. }
  1667. /**
  1668. * Called when the seek range or current time need to be updated.
  1669. * @private
  1670. */
  1671. updateTimeAndSeekRange_() {
  1672. if (this.seekBar_) {
  1673. this.seekBar_.setValue(this.video_.currentTime);
  1674. this.seekBar_.update();
  1675. if (this.seekBar_.isShowing()) {
  1676. for (const menu of this.menus_) {
  1677. menu.classList.remove('shaka-low-position');
  1678. }
  1679. } else {
  1680. for (const menu of this.menus_) {
  1681. menu.classList.add('shaka-low-position');
  1682. }
  1683. }
  1684. }
  1685. this.dispatchEvent(new shaka.util.FakeEvent('timeandseekrangeupdated'));
  1686. }
  1687. /**
  1688. * Add behaviors for keyboard navigation.
  1689. * 1. Add blue outline for focused elements.
  1690. * 2. Allow exiting overflow settings menus by pressing Esc key.
  1691. * 3. When navigating on overflow settings menu by pressing Tab
  1692. * key or Shift+Tab keys keep the focus inside overflow menu.
  1693. *
  1694. * @param {!KeyboardEvent} event
  1695. * @private
  1696. */
  1697. onWindowKeyDown_(event) {
  1698. // Add the key to the pressed keys set when it's pressed.
  1699. this.pressedKeys_.add(event.key);
  1700. const anySettingsMenusAreOpen = this.anySettingsMenusAreOpen();
  1701. if (event.key == 'Tab') {
  1702. // Enable blue outline for focused elements for keyboard
  1703. // navigation.
  1704. this.controlsContainer_.classList.add('shaka-keyboard-navigation');
  1705. this.computeOpacity();
  1706. this.eventManager_.listen(window, 'mousedown', () => this.onMouseDown_());
  1707. }
  1708. // If escape key was pressed, close any open settings menus.
  1709. if (event.key == 'Escape') {
  1710. this.hideSettingsMenusTimer_.tickNow();
  1711. }
  1712. if (anySettingsMenusAreOpen && this.pressedKeys_.has('Tab')) {
  1713. // If Tab key or Shift+Tab keys are pressed when navigating through
  1714. // an overflow settings menu, keep the focus to loop inside the
  1715. // overflow menu.
  1716. this.keepFocusInMenu_(event);
  1717. }
  1718. }
  1719. /**
  1720. * When the user is using keyboard to navigate inside the overflow settings
  1721. * menu (pressing Tab key to go forward, or pressing Shift + Tab keys to go
  1722. * backward), make sure it's focused only on the elements of the overflow
  1723. * panel.
  1724. *
  1725. * This is called by onWindowKeyDown_() function, when there's a settings
  1726. * overflow menu open, and the Tab key / Shift+Tab keys are pressed.
  1727. *
  1728. * @param {!Event} event
  1729. * @private
  1730. */
  1731. keepFocusInMenu_(event) {
  1732. const openSettingsMenus = this.menus_.filter(
  1733. (menu) => !menu.classList.contains('shaka-hidden'));
  1734. if (!openSettingsMenus.length) {
  1735. // For example, this occurs when you hit escape to close the menu.
  1736. return;
  1737. }
  1738. const settingsMenu = openSettingsMenus[0];
  1739. if (settingsMenu.childNodes.length) {
  1740. // Get the first and the last displaying child element from the overflow
  1741. // menu.
  1742. let firstShownChild = settingsMenu.firstElementChild;
  1743. while (firstShownChild &&
  1744. firstShownChild.classList.contains('shaka-hidden')) {
  1745. firstShownChild = firstShownChild.nextElementSibling;
  1746. }
  1747. let lastShownChild = settingsMenu.lastElementChild;
  1748. while (lastShownChild &&
  1749. lastShownChild.classList.contains('shaka-hidden')) {
  1750. lastShownChild = lastShownChild.previousElementSibling;
  1751. }
  1752. const activeElement = document.activeElement;
  1753. // When only Tab key is pressed, navigate to the next element.
  1754. // If it's currently focused on the last shown child element of the
  1755. // overflow menu, let the focus move to the first child element of the
  1756. // menu.
  1757. // When Tab + Shift keys are pressed at the same time, navigate to the
  1758. // previous element. If it's currently focused on the first shown child
  1759. // element of the overflow menu, let the focus move to the last child
  1760. // element of the menu.
  1761. if (this.pressedKeys_.has('Shift')) {
  1762. if (activeElement == firstShownChild) {
  1763. event.preventDefault();
  1764. lastShownChild.focus();
  1765. }
  1766. } else {
  1767. if (activeElement == lastShownChild) {
  1768. event.preventDefault();
  1769. firstShownChild.focus();
  1770. }
  1771. }
  1772. }
  1773. }
  1774. /**
  1775. * For keyboard navigation, we use blue borders to highlight the active
  1776. * element. If we detect that a mouse is being used, remove the blue border
  1777. * from the active element.
  1778. * @private
  1779. */
  1780. onMouseDown_() {
  1781. this.eventManager_.unlisten(window, 'mousedown');
  1782. }
  1783. /**
  1784. * @export
  1785. */
  1786. showUI() {
  1787. const event = new Event('mousemove', {bubbles: false, cancelable: false});
  1788. this.onMouseMove_(event);
  1789. }
  1790. /**
  1791. * @export
  1792. */
  1793. hideUI() {
  1794. // Stop the timer and invoke the callback now to hide the controls. If we
  1795. // don't, the opacity style we set in onMouseMove_ will continue to override
  1796. // the opacity in CSS and force the controls to stay visible.
  1797. this.mouseStillTimer_.tickNow();
  1798. }
  1799. /**
  1800. * @return {shaka.ui.VRManager}
  1801. */
  1802. getVR() {
  1803. goog.asserts.assert(this.vr_ != null, 'Should have a VR manager!');
  1804. return this.vr_;
  1805. }
  1806. /**
  1807. * Returns if a VR is capable.
  1808. *
  1809. * @return {boolean}
  1810. * @export
  1811. */
  1812. canPlayVR() {
  1813. goog.asserts.assert(this.vr_ != null, 'Should have a VR manager!');
  1814. return this.vr_.canPlayVR();
  1815. }
  1816. /**
  1817. * Returns if a VR is supported.
  1818. *
  1819. * @return {boolean}
  1820. * @export
  1821. */
  1822. isPlayingVR() {
  1823. goog.asserts.assert(this.vr_ != null, 'Should have a VR manager!');
  1824. return this.vr_.isPlayingVR();
  1825. }
  1826. /**
  1827. * Reset VR view.
  1828. */
  1829. resetVR() {
  1830. goog.asserts.assert(this.vr_ != null, 'Should have a VR manager!');
  1831. this.vr_.reset();
  1832. }
  1833. /**
  1834. * Get the angle of the north.
  1835. *
  1836. * @return {?number}
  1837. * @export
  1838. */
  1839. getVRNorth() {
  1840. goog.asserts.assert(this.vr_ != null, 'Should have a VR manager!');
  1841. return this.vr_.getNorth();
  1842. }
  1843. /**
  1844. * Returns the angle of the current field of view displayed in degrees.
  1845. *
  1846. * @return {?number}
  1847. * @export
  1848. */
  1849. getVRFieldOfView() {
  1850. goog.asserts.assert(this.vr_ != null, 'Should have a VR manager!');
  1851. return this.vr_.getFieldOfView();
  1852. }
  1853. /**
  1854. * Changing the field of view increases or decreases the portion of the video
  1855. * that is viewed at one time. If the field of view is decreased, a small
  1856. * part of the video will be seen, but with more detail. If the field of view
  1857. * is increased, a larger part of the video will be seen, but with less
  1858. * detail.
  1859. *
  1860. * @param {number} fieldOfView In degrees
  1861. * @export
  1862. */
  1863. setVRFieldOfView(fieldOfView) {
  1864. goog.asserts.assert(this.vr_ != null, 'Should have a VR manager!');
  1865. this.vr_.setFieldOfView(fieldOfView);
  1866. }
  1867. /**
  1868. * Toggle stereoscopic mode.
  1869. *
  1870. * @export
  1871. */
  1872. toggleStereoscopicMode() {
  1873. goog.asserts.assert(this.vr_ != null, 'Should have a VR manager!');
  1874. this.vr_.toggleStereoscopicMode();
  1875. }
  1876. /**
  1877. * Returns true if stereoscopic mode is enabled.
  1878. *
  1879. * @return {boolean}
  1880. */
  1881. isStereoscopicModeEnabled() {
  1882. goog.asserts.assert(this.vr_ != null, 'Should have a VR manager!');
  1883. return this.vr_.isStereoscopicModeEnabled();
  1884. }
  1885. /**
  1886. * Increment the yaw in X angle in degrees.
  1887. *
  1888. * @param {number} angle In degrees
  1889. * @export
  1890. */
  1891. incrementYaw(angle) {
  1892. goog.asserts.assert(this.vr_ != null, 'Should have a VR manager!');
  1893. this.vr_.incrementYaw(angle);
  1894. }
  1895. /**
  1896. * Increment the pitch in X angle in degrees.
  1897. *
  1898. * @param {number} angle In degrees
  1899. * @export
  1900. */
  1901. incrementPitch(angle) {
  1902. goog.asserts.assert(this.vr_ != null, 'Should have a VR manager!');
  1903. this.vr_.incrementPitch(angle);
  1904. }
  1905. /**
  1906. * Increment the roll in X angle in degrees.
  1907. *
  1908. * @param {number} angle In degrees
  1909. * @export
  1910. */
  1911. incrementRoll(angle) {
  1912. goog.asserts.assert(this.vr_ != null, 'Should have a VR manager!');
  1913. this.vr_.incrementRoll(angle);
  1914. }
  1915. /**
  1916. * Create a localization instance already pre-loaded with all the locales that
  1917. * we support.
  1918. *
  1919. * @return {!shaka.ui.Localization}
  1920. * @private
  1921. */
  1922. static createLocalization_() {
  1923. /** @type {string} */
  1924. const fallbackLocale = 'en';
  1925. /** @type {!shaka.ui.Localization} */
  1926. const localization = new shaka.ui.Localization(fallbackLocale);
  1927. shaka.ui.Locales.addTo(localization);
  1928. localization.changeLocale(navigator.languages || []);
  1929. return localization;
  1930. }
  1931. };
  1932. /**
  1933. * @event shaka.ui.Controls#CastStatusChangedEvent
  1934. * @description Fired upon receiving a 'caststatuschanged' event from
  1935. * the cast proxy.
  1936. * @property {string} type
  1937. * 'caststatuschanged'
  1938. * @property {boolean} newStatus
  1939. * The new status of the application. True for 'is casting' and
  1940. * false otherwise.
  1941. * @exportDoc
  1942. */
  1943. /**
  1944. * @event shaka.ui.Controls#VRStatusChangedEvent
  1945. * @description Fired when VR status change
  1946. * @property {string} type
  1947. * 'vrstatuschanged'
  1948. * @exportDoc
  1949. */
  1950. /**
  1951. * @event shaka.ui.Controls#SubMenuOpenEvent
  1952. * @description Fired when one of the overflow submenus is opened
  1953. * (e. g. language/resolution/subtitle selection).
  1954. * @property {string} type
  1955. * 'submenuopen'
  1956. * @exportDoc
  1957. */
  1958. /**
  1959. * @event shaka.ui.Controls#CaptionSelectionUpdatedEvent
  1960. * @description Fired when the captions/subtitles menu has finished updating.
  1961. * @property {string} type
  1962. * 'captionselectionupdated'
  1963. * @exportDoc
  1964. */
  1965. /**
  1966. * @event shaka.ui.Controls#ResolutionSelectionUpdatedEvent
  1967. * @description Fired when the resolution menu has finished updating.
  1968. * @property {string} type
  1969. * 'resolutionselectionupdated'
  1970. * @exportDoc
  1971. */
  1972. /**
  1973. * @event shaka.ui.Controls#LanguageSelectionUpdatedEvent
  1974. * @description Fired when the audio language menu has finished updating.
  1975. * @property {string} type
  1976. * 'languageselectionupdated'
  1977. * @exportDoc
  1978. */
  1979. /**
  1980. * @event shaka.ui.Controls#ErrorEvent
  1981. * @description Fired when something went wrong with the controls.
  1982. * @property {string} type
  1983. * 'error'
  1984. * @property {!shaka.util.Error} detail
  1985. * An object which contains details on the error. The error's 'category'
  1986. * and 'code' properties will identify the specific error that occurred.
  1987. * In an uncompiled build, you can also use the 'message' and 'stack'
  1988. * properties to debug.
  1989. * @exportDoc
  1990. */
  1991. /**
  1992. * @event shaka.ui.Controls#TimeAndSeekRangeUpdatedEvent
  1993. * @description Fired when the time and seek range elements have finished
  1994. * updating.
  1995. * @property {string} type
  1996. * 'timeandseekrangeupdated'
  1997. * @exportDoc
  1998. */
  1999. /**
  2000. * @event shaka.ui.Controls#UIUpdatedEvent
  2001. * @description Fired after a call to ui.configure() once the UI has finished
  2002. * updating.
  2003. * @property {string} type
  2004. * 'uiupdated'
  2005. * @exportDoc
  2006. */
  2007. /** @private {!Map<string, !shaka.extern.IUIElement.Factory>} */
  2008. shaka.ui.ControlsPanel.elementNamesToFactories_ = new Map();
  2009. /** @private {?shaka.extern.IUISeekBar.Factory} */
  2010. shaka.ui.ControlsPanel.seekBarFactory_ = new shaka.ui.SeekBar.Factory();