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