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.AdManager');
  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.Locales');
  17. goog.require('shaka.ui.Localization');
  18. goog.require('shaka.ui.SeekBar');
  19. goog.require('shaka.ui.Utils');
  20. goog.require('shaka.util.Dom');
  21. goog.require('shaka.util.EventManager');
  22. goog.require('shaka.util.FakeEvent');
  23. goog.require('shaka.util.FakeEventTarget');
  24. goog.require('shaka.util.IDestroyable');
  25. goog.require('shaka.util.Timer');
  26. goog.requireType('shaka.Player');
  27. /**
  28. * A container for custom video controls.
  29. * @implements {shaka.util.IDestroyable}
  30. * @export
  31. */
  32. shaka.ui.Controls = class extends shaka.util.FakeEventTarget {
  33. /**
  34. * @param {!shaka.Player} player
  35. * @param {!HTMLElement} videoContainer
  36. * @param {!HTMLMediaElement} video
  37. * @param {shaka.extern.UIConfiguration} config
  38. */
  39. constructor(player, videoContainer, video, config) {
  40. super();
  41. /** @private {boolean} */
  42. this.enabled_ = true;
  43. /** @private {shaka.extern.UIConfiguration} */
  44. this.config_ = config;
  45. /** @private {shaka.cast.CastProxy} */
  46. this.castProxy_ = new shaka.cast.CastProxy(
  47. video, player, this.config_.castReceiverAppId,
  48. this.config_.castAndroidReceiverCompatible);
  49. /** @private {boolean} */
  50. this.castAllowed_ = true;
  51. /** @private {HTMLMediaElement} */
  52. this.video_ = this.castProxy_.getVideo();
  53. /** @private {HTMLMediaElement} */
  54. this.localVideo_ = video;
  55. /** @private {shaka.Player} */
  56. this.player_ = this.castProxy_.getPlayer();
  57. /** @private {shaka.Player} */
  58. this.localPlayer_ = player;
  59. /** @private {!HTMLElement} */
  60. this.videoContainer_ = videoContainer;
  61. /** @private {shaka.extern.IAdManager} */
  62. this.adManager_ = this.player_.getAdManager();
  63. /** @private {?shaka.extern.IAd} */
  64. this.ad_ = null;
  65. /** @private {?shaka.extern.IUISeekBar} */
  66. this.seekBar_ = null;
  67. /** @private {boolean} */
  68. this.isSeeking_ = false;
  69. /** @private {!Array.<!HTMLElement>} */
  70. this.menus_ = [];
  71. /**
  72. * Individual controls which, when hovered or tab-focused, will force the
  73. * controls to be shown.
  74. * @private {!Array.<!Element>}
  75. */
  76. this.showOnHoverControls_ = [];
  77. /** @private {boolean} */
  78. this.recentMouseMovement_ = false;
  79. /**
  80. * This timer is used to detect when the user has stopped moving the mouse
  81. * and we should fade out the ui.
  82. *
  83. * @private {shaka.util.Timer}
  84. */
  85. this.mouseStillTimer_ = new shaka.util.Timer(() => {
  86. this.onMouseStill_();
  87. });
  88. /**
  89. * This timer is used to delay the fading of the UI.
  90. *
  91. * @private {shaka.util.Timer}
  92. */
  93. this.fadeControlsTimer_ = new shaka.util.Timer(() => {
  94. this.controlsContainer_.removeAttribute('shown');
  95. // If there's an overflow menu open, keep it this way for a couple of
  96. // seconds in case a user immediately initiates another mouse move to
  97. // interact with the menus. If that didn't happen, go ahead and hide
  98. // the menus.
  99. this.hideSettingsMenusTimer_.tickAfter(/* seconds= */ 2);
  100. });
  101. /**
  102. * This timer will be used to hide all settings menus. When the timer ticks
  103. * it will force all controls to invisible.
  104. *
  105. * Rather than calling the callback directly, |Controls| will always call it
  106. * through the timer to avoid conflicts.
  107. *
  108. * @private {shaka.util.Timer}
  109. */
  110. this.hideSettingsMenusTimer_ = new shaka.util.Timer(() => {
  111. for (const menu of this.menus_) {
  112. shaka.ui.Utils.setDisplay(menu, /* visible= */ false);
  113. }
  114. });
  115. /**
  116. * This timer is used to regularly update the time and seek range elements
  117. * so that we are communicating the current state as accurately as possibly.
  118. *
  119. * Unlike the other timers, this timer does not "own" the callback because
  120. * this timer is acting like a heartbeat.
  121. *
  122. * @private {shaka.util.Timer}
  123. */
  124. this.timeAndSeekRangeTimer_ = new shaka.util.Timer(() => {
  125. // Suppress timer-based updates if the controls are hidden.
  126. if (this.isOpaque()) {
  127. this.updateTimeAndSeekRange_();
  128. }
  129. });
  130. /** @private {?number} */
  131. this.lastTouchEventTime_ = null;
  132. /** @private {!Array.<!shaka.extern.IUIElement>} */
  133. this.elements_ = [];
  134. /** @private {shaka.ui.Localization} */
  135. this.localization_ = shaka.ui.Controls.createLocalization_();
  136. /** @private {shaka.util.EventManager} */
  137. this.eventManager_ = new shaka.util.EventManager();
  138. // Configure and create the layout of the controls
  139. this.configure(this.config_);
  140. this.addEventListeners_();
  141. /**
  142. * The pressed keys set is used to record which keys are currently pressed
  143. * down, so we can know what keys are pressed at the same time.
  144. * Used by the focusInsideOverflowMenu_() function.
  145. * @private {!Set.<string>}
  146. */
  147. this.pressedKeys_ = new Set();
  148. // We might've missed a caststatuschanged event from the proxy between
  149. // the controls creation and initializing. Run onCastStatusChange_()
  150. // to ensure we have the casting state right.
  151. this.onCastStatusChange_();
  152. // Start this timer after we are finished initializing everything,
  153. this.timeAndSeekRangeTimer_.tickEvery(/* seconds= */ 0.125);
  154. this.eventManager_.listen(this.localization_,
  155. shaka.ui.Localization.LOCALE_CHANGED, (e) => {
  156. const locale = e['locales'][0];
  157. this.adManager_.setLocale(locale);
  158. });
  159. }
  160. /**
  161. * @override
  162. * @export
  163. */
  164. async destroy() {
  165. if (document.pictureInPictureElement == this.localVideo_) {
  166. await document.exitPictureInPicture();
  167. }
  168. if (this.eventManager_) {
  169. this.eventManager_.release();
  170. this.eventManager_ = null;
  171. }
  172. if (this.mouseStillTimer_) {
  173. this.mouseStillTimer_.stop();
  174. this.mouseStillTimer_ = null;
  175. }
  176. if (this.fadeControlsTimer_) {
  177. this.fadeControlsTimer_.stop();
  178. this.fadeControlsTimer_ = null;
  179. }
  180. if (this.hideSettingsMenusTimer_) {
  181. this.hideSettingsMenusTimer_.stop();
  182. this.hideSettingsMenusTimer_ = null;
  183. }
  184. if (this.timeAndSeekRangeTimer_) {
  185. this.timeAndSeekRangeTimer_.stop();
  186. this.timeAndSeekRangeTimer_ = null;
  187. }
  188. // Important! Release all child elements before destroying the cast proxy
  189. // or player. This makes sure those destructions will not trigger event
  190. // listeners in the UI which would then invoke the cast proxy or player.
  191. this.releaseChildElements_();
  192. if (this.controlsContainer_) {
  193. this.videoContainer_.removeChild(this.controlsContainer_);
  194. this.controlsContainer_ = null;
  195. }
  196. if (this.castProxy_) {
  197. await this.castProxy_.destroy();
  198. this.castProxy_ = null;
  199. }
  200. if (this.localPlayer_) {
  201. await this.localPlayer_.destroy();
  202. this.localPlayer_ = null;
  203. }
  204. this.player_ = null;
  205. this.localVideo_ = null;
  206. this.video_ = null;
  207. this.localization_ = null;
  208. this.pressedKeys_.clear();
  209. // FakeEventTarget implements IReleasable
  210. super.release();
  211. }
  212. /** @private */
  213. releaseChildElements_() {
  214. for (const element of this.elements_) {
  215. element.release();
  216. }
  217. this.elements_ = [];
  218. }
  219. /**
  220. * @param {string} name
  221. * @param {!shaka.extern.IUIElement.Factory} factory
  222. * @export
  223. */
  224. static registerElement(name, factory) {
  225. shaka.ui.ControlsPanel.elementNamesToFactories_.set(name, factory);
  226. }
  227. /**
  228. * @param {!shaka.extern.IUISeekBar.Factory} factory
  229. * @export
  230. */
  231. static registerSeekBar(factory) {
  232. shaka.ui.ControlsPanel.seekBarFactory_ = factory;
  233. }
  234. /**
  235. * This allows the application to inhibit casting.
  236. *
  237. * @param {boolean} allow
  238. * @export
  239. */
  240. allowCast(allow) {
  241. this.castAllowed_ = allow;
  242. this.onCastStatusChange_();
  243. }
  244. /**
  245. * Used by the application to notify the controls that a load operation is
  246. * complete. This allows the controls to recalculate play/paused state, which
  247. * is important for platforms like Android where autoplay is disabled.
  248. * @export
  249. */
  250. loadComplete() {
  251. // If we are on Android or if autoplay is false, video.paused should be
  252. // true. Otherwise, video.paused is false and the content is autoplaying.
  253. this.onPlayStateChange_();
  254. }
  255. /**
  256. * @param {!shaka.extern.UIConfiguration} config
  257. * @export
  258. */
  259. configure(config) {
  260. this.config_ = config;
  261. this.castProxy_.changeReceiverId(config.castReceiverAppId,
  262. config.castAndroidReceiverCompatible);
  263. // Deconstruct the old layout if applicable
  264. if (this.seekBar_) {
  265. this.seekBar_ = null;
  266. }
  267. if (this.playButton_) {
  268. this.playButton_ = null;
  269. }
  270. if (this.contextMenu_) {
  271. this.contextMenu_ = null;
  272. }
  273. if (this.controlsContainer_) {
  274. shaka.util.Dom.removeAllChildren(this.controlsContainer_);
  275. this.releaseChildElements_();
  276. } else {
  277. this.addControlsContainer_();
  278. // The client-side ad container is only created once, and is never
  279. // re-created or uprooted in the DOM, even when the DOM is re-created,
  280. // since that seemingly breaks the IMA SDK.
  281. this.addClientAdContainer_();
  282. }
  283. // Create the new layout
  284. this.createDOM_();
  285. // Init the play state
  286. this.onPlayStateChange_();
  287. // Elements that should not propagate clicks (controls panel, menus)
  288. const noPropagationElements = this.videoContainer_.getElementsByClassName(
  289. 'shaka-no-propagation');
  290. for (const element of noPropagationElements) {
  291. const cb = (event) => event.stopPropagation();
  292. this.eventManager_.listen(element, 'click', cb);
  293. this.eventManager_.listen(element, 'dblclick', cb);
  294. }
  295. }
  296. /**
  297. * Enable or disable the custom controls. Enabling disables native
  298. * browser controls.
  299. *
  300. * @param {boolean} enabled
  301. * @export
  302. */
  303. setEnabledShakaControls(enabled) {
  304. this.enabled_ = enabled;
  305. if (enabled) {
  306. this.videoContainer_.setAttribute('shaka-controls', 'true');
  307. // If we're hiding native controls, make sure the video element itself is
  308. // not tab-navigable. Our custom controls will still be tab-navigable.
  309. this.localVideo_.tabIndex = -1;
  310. this.localVideo_.controls = false;
  311. } else {
  312. this.videoContainer_.removeAttribute('shaka-controls');
  313. }
  314. // The effects of play state changes are inhibited while showing native
  315. // browser controls. Recalculate that state now.
  316. this.onPlayStateChange_();
  317. }
  318. /**
  319. * Enable or disable native browser controls. Enabling disables shaka
  320. * controls.
  321. *
  322. * @param {boolean} enabled
  323. * @export
  324. */
  325. setEnabledNativeControls(enabled) {
  326. // If we enable the native controls, the element must be tab-navigable.
  327. // If we disable the native controls, we want to make sure that the video
  328. // element itself is not tab-navigable, so that the element is skipped over
  329. // when tabbing through the page.
  330. this.localVideo_.controls = enabled;
  331. this.localVideo_.tabIndex = enabled ? 0 : -1;
  332. if (enabled) {
  333. this.setEnabledShakaControls(false);
  334. }
  335. }
  336. /**
  337. * @export
  338. * @return {?shaka.extern.IAd}
  339. */
  340. getAd() {
  341. return this.ad_;
  342. }
  343. /**
  344. * @export
  345. * @return {shaka.cast.CastProxy}
  346. */
  347. getCastProxy() {
  348. return this.castProxy_;
  349. }
  350. /**
  351. * @return {shaka.ui.Localization}
  352. * @export
  353. */
  354. getLocalization() {
  355. return this.localization_;
  356. }
  357. /**
  358. * @return {!HTMLElement}
  359. * @export
  360. */
  361. getVideoContainer() {
  362. return this.videoContainer_;
  363. }
  364. /**
  365. * @return {HTMLMediaElement}
  366. * @export
  367. */
  368. getVideo() {
  369. return this.video_;
  370. }
  371. /**
  372. * @return {HTMLMediaElement}
  373. * @export
  374. */
  375. getLocalVideo() {
  376. return this.localVideo_;
  377. }
  378. /**
  379. * @return {shaka.Player}
  380. * @export
  381. */
  382. getPlayer() {
  383. return this.player_;
  384. }
  385. /**
  386. * @return {shaka.Player}
  387. * @export
  388. */
  389. getLocalPlayer() {
  390. return this.localPlayer_;
  391. }
  392. /**
  393. * @return {!HTMLElement}
  394. * @export
  395. */
  396. getControlsContainer() {
  397. goog.asserts.assert(
  398. this.controlsContainer_, 'No controls container after destruction!');
  399. return this.controlsContainer_;
  400. }
  401. /**
  402. * @return {!HTMLElement}
  403. * @export
  404. */
  405. getServerSideAdContainer() {
  406. return this.daiAdContainer_;
  407. }
  408. /**
  409. * @return {!HTMLElement}
  410. * @export
  411. */
  412. getClientSideAdContainer() {
  413. return this.clientAdContainer_;
  414. }
  415. /**
  416. * @return {!shaka.extern.UIConfiguration}
  417. * @export
  418. */
  419. getConfig() {
  420. return this.config_;
  421. }
  422. /**
  423. * @return {boolean}
  424. * @export
  425. */
  426. isSeeking() {
  427. return this.isSeeking_;
  428. }
  429. /**
  430. * @param {boolean} seeking
  431. * @export
  432. */
  433. setSeeking(seeking) {
  434. this.isSeeking_ = seeking;
  435. }
  436. /**
  437. * @return {boolean}
  438. * @export
  439. */
  440. isCastAllowed() {
  441. return this.castAllowed_;
  442. }
  443. /**
  444. * @return {number}
  445. * @export
  446. */
  447. getDisplayTime() {
  448. return this.seekBar_ ? this.seekBar_.getValue() : this.video_.currentTime;
  449. }
  450. /**
  451. * @param {?number} time
  452. * @export
  453. */
  454. setLastTouchEventTime(time) {
  455. this.lastTouchEventTime_ = time;
  456. }
  457. /**
  458. * @return {boolean}
  459. * @export
  460. */
  461. anySettingsMenusAreOpen() {
  462. return this.menus_.some(
  463. (menu) => !menu.classList.contains('shaka-hidden'));
  464. }
  465. /** @export */
  466. hideSettingsMenus() {
  467. this.hideSettingsMenusTimer_.tickNow();
  468. }
  469. /**
  470. * @return {boolean}
  471. * @export
  472. */
  473. isFullScreenSupported() {
  474. if (document.fullscreenEnabled) {
  475. return true;
  476. }
  477. const video = /** @type {HTMLVideoElement} */(this.localVideo_);
  478. if (video.webkitSupportsFullscreen) {
  479. return true;
  480. }
  481. return false;
  482. }
  483. /**
  484. * @return {boolean}
  485. * @export
  486. */
  487. isFullScreenEnabled() {
  488. if (document.fullscreenEnabled) {
  489. return !!document.fullscreenElement;
  490. }
  491. const video = /** @type {HTMLVideoElement} */(this.localVideo_);
  492. if (video.webkitSupportsFullscreen) {
  493. return video.webkitDisplayingFullscreen;
  494. }
  495. return false;
  496. }
  497. /** @private */
  498. async enterFullScreen_() {
  499. try {
  500. if (document.fullscreenEnabled) {
  501. if (document.pictureInPictureElement) {
  502. await document.exitPictureInPicture();
  503. }
  504. const fullScreenElement = this.config_.fullScreenElement;
  505. await fullScreenElement.requestFullscreen({navigationUI: 'hide'});
  506. if (this.config_.forceLandscapeOnFullscreen && screen.orientation) {
  507. // Locking to 'landscape' should let it be either
  508. // 'landscape-primary' or 'landscape-secondary' as appropriate.
  509. // We ignore errors from this specific call, since it creates noise
  510. // on desktop otherwise.
  511. try {
  512. await screen.orientation.lock('landscape');
  513. } catch (error) {}
  514. }
  515. } else {
  516. const video = /** @type {HTMLVideoElement} */(this.localVideo_);
  517. if (video.webkitSupportsFullscreen) {
  518. video.webkitEnterFullscreen();
  519. }
  520. }
  521. } catch (error) {
  522. // Entering fullscreen can fail without user interaction.
  523. this.dispatchEvent(new shaka.util.FakeEvent(
  524. 'error', (new Map()).set('detail', error)));
  525. }
  526. }
  527. /** @private */
  528. async exitFullScreen_() {
  529. if (document.fullscreenEnabled) {
  530. if (screen.orientation) {
  531. screen.orientation.unlock();
  532. }
  533. await document.exitFullscreen();
  534. } else {
  535. const video = /** @type {HTMLVideoElement} */(this.localVideo_);
  536. if (video.webkitSupportsFullscreen) {
  537. video.webkitExitFullscreen();
  538. }
  539. }
  540. }
  541. /** @export */
  542. async toggleFullScreen() {
  543. if (this.isFullScreenEnabled()) {
  544. await this.exitFullScreen_();
  545. } else {
  546. await this.enterFullScreen_();
  547. }
  548. }
  549. /**
  550. * @return {boolean}
  551. * @export
  552. */
  553. isPiPAllowed() {
  554. if ('documentPictureInPicture' in window &&
  555. this.config_.preferDocumentPictureInPicture) {
  556. return true;
  557. }
  558. if (document.pictureInPictureEnabled) {
  559. const video = /** @type {HTMLVideoElement} */(this.localVideo_);
  560. return !video.disablePictureInPicture;
  561. }
  562. return false;
  563. }
  564. /**
  565. * @return {boolean}
  566. * @export
  567. */
  568. isPiPEnabled() {
  569. if ('documentPictureInPicture' in window &&
  570. this.config_.preferDocumentPictureInPicture) {
  571. return !!window.documentPictureInPicture.window;
  572. } else {
  573. return !!document.pictureInPictureElement;
  574. }
  575. }
  576. /** @export */
  577. async togglePiP() {
  578. try {
  579. if ('documentPictureInPicture' in window &&
  580. this.config_.preferDocumentPictureInPicture) {
  581. await this.toggleDocumentPictureInPicture_();
  582. } else if (!document.pictureInPictureElement) {
  583. // If you were fullscreen, leave fullscreen first.
  584. if (document.fullscreenElement) {
  585. document.exitFullscreen();
  586. }
  587. const video = /** @type {HTMLVideoElement} */(this.localVideo_);
  588. await video.requestPictureInPicture();
  589. } else {
  590. await document.exitPictureInPicture();
  591. }
  592. } catch (error) {
  593. this.dispatchEvent(new shaka.util.FakeEvent(
  594. 'error', (new Map()).set('detail', error)));
  595. }
  596. }
  597. /**
  598. * The Document Picture-in-Picture API makes it possible to open an
  599. * always-on-top window that can be populated with arbitrary HTML content.
  600. * https://developer.chrome.com/docs/web-platform/document-picture-in-picture
  601. * @private
  602. */
  603. async toggleDocumentPictureInPicture_() {
  604. // Close Picture-in-Picture window if any.
  605. if (window.documentPictureInPicture.window) {
  606. window.documentPictureInPicture.window.close();
  607. return;
  608. }
  609. // Open a Picture-in-Picture window.
  610. const pipPlayer = this.videoContainer_;
  611. const rectPipPlayer = pipPlayer.getBoundingClientRect();
  612. const pipWindow = await window.documentPictureInPicture.requestWindow({
  613. width: rectPipPlayer.width,
  614. height: rectPipPlayer.height,
  615. });
  616. // Copy style sheets to the Picture-in-Picture window.
  617. this.copyStyleSheetsToWindow_(pipWindow);
  618. // Add placeholder for the player.
  619. const parentPlayer = pipPlayer.parentNode || document.body;
  620. const placeholder = this.videoContainer_.cloneNode(true);
  621. placeholder.style.visibility = 'hidden';
  622. placeholder.style.height = getComputedStyle(pipPlayer).height;
  623. parentPlayer.appendChild(placeholder);
  624. // Make sure player fits in the Picture-in-Picture window.
  625. const styles = document.createElement('style');
  626. styles.append(`[data-shaka-player-container] {
  627. width: 100% !important; max-height: 100%}`);
  628. pipWindow.document.head.append(styles);
  629. // Move player to the Picture-in-Picture window.
  630. pipWindow.document.body.append(pipPlayer);
  631. // Listen for the PiP closing event to move the player back.
  632. this.eventManager_.listenOnce(pipWindow, 'pagehide', () => {
  633. placeholder.replaceWith(/** @type {!Node} */(pipPlayer));
  634. });
  635. }
  636. /** @private */
  637. copyStyleSheetsToWindow_(win) {
  638. const styleSheets = /** @type {!Iterable<*>} */(document.styleSheets);
  639. const allCSS = [...styleSheets]
  640. .map((sheet) => {
  641. try {
  642. return [...sheet.cssRules].map((rule) => rule.cssText).join('');
  643. } catch (e) {
  644. const link = /** @type {!HTMLLinkElement} */(
  645. document.createElement('link'));
  646. link.rel = 'stylesheet';
  647. link.type = sheet.type;
  648. link.media = sheet.media;
  649. link.href = sheet.href;
  650. win.document.head.appendChild(link);
  651. }
  652. return '';
  653. })
  654. .filter(Boolean)
  655. .join('\n');
  656. const style = document.createElement('style');
  657. style.textContent = allCSS;
  658. win.document.head.appendChild(style);
  659. }
  660. /** @export */
  661. showAdUI() {
  662. shaka.ui.Utils.setDisplay(this.adPanel_, true);
  663. shaka.ui.Utils.setDisplay(this.clientAdContainer_, true);
  664. this.controlsContainer_.setAttribute('ad-active', 'true');
  665. }
  666. /** @export */
  667. hideAdUI() {
  668. shaka.ui.Utils.setDisplay(this.adPanel_, false);
  669. shaka.ui.Utils.setDisplay(this.clientAdContainer_, false);
  670. this.controlsContainer_.removeAttribute('ad-active');
  671. }
  672. /**
  673. * Play or pause the current presentation.
  674. */
  675. playPausePresentation() {
  676. if (!this.enabled_) {
  677. return;
  678. }
  679. if (!this.video_.duration) {
  680. // Can't play yet. Ignore.
  681. return;
  682. }
  683. this.player_.cancelTrickPlay();
  684. if (this.presentationIsPaused()) {
  685. this.video_.play();
  686. } else {
  687. this.video_.pause();
  688. }
  689. }
  690. /**
  691. * Play or pause the current ad.
  692. */
  693. playPauseAd() {
  694. if (this.ad_ && this.ad_.isPaused()) {
  695. this.ad_.play();
  696. } else if (this.ad_) {
  697. this.ad_.pause();
  698. }
  699. }
  700. /**
  701. * Return true if the presentation is paused.
  702. *
  703. * @return {boolean}
  704. */
  705. presentationIsPaused() {
  706. // The video element is in a paused state while seeking, but we don't count
  707. // that.
  708. return this.video_.paused && !this.isSeeking();
  709. }
  710. /** @private */
  711. createDOM_() {
  712. this.videoContainer_.classList.add('shaka-video-container');
  713. this.localVideo_.classList.add('shaka-video');
  714. this.addScrimContainer_();
  715. if (this.config_.addBigPlayButton) {
  716. this.addPlayButton_();
  717. }
  718. if (this.config_.customContextMenu) {
  719. this.addContextMenu_();
  720. }
  721. if (!this.spinnerContainer_) {
  722. this.addBufferingSpinner_();
  723. }
  724. this.addDaiAdContainer_();
  725. this.addControlsButtonPanel_();
  726. this.menus_ = Array.from(
  727. this.videoContainer_.getElementsByClassName('shaka-settings-menu'));
  728. this.menus_.push(...Array.from(
  729. this.videoContainer_.getElementsByClassName('shaka-overflow-menu')));
  730. this.addSeekBar_();
  731. this.showOnHoverControls_ = Array.from(
  732. this.videoContainer_.getElementsByClassName(
  733. 'shaka-show-controls-on-mouse-over'));
  734. }
  735. /** @private */
  736. addControlsContainer_() {
  737. /** @private {HTMLElement} */
  738. this.controlsContainer_ = shaka.util.Dom.createHTMLElement('div');
  739. this.controlsContainer_.classList.add('shaka-controls-container');
  740. this.videoContainer_.appendChild(this.controlsContainer_);
  741. // Use our controls by default, without anyone calling
  742. // setEnabledShakaControls:
  743. this.videoContainer_.setAttribute('shaka-controls', 'true');
  744. this.eventManager_.listen(this.controlsContainer_, 'touchstart', (e) => {
  745. this.onContainerTouch_(e);
  746. }, {passive: false});
  747. this.eventManager_.listen(this.controlsContainer_, 'click', () => {
  748. this.onContainerClick_();
  749. });
  750. this.eventManager_.listen(this.controlsContainer_, 'dblclick', () => {
  751. if (this.config_.doubleClickForFullscreen &&
  752. this.isFullScreenSupported()) {
  753. this.toggleFullScreen();
  754. }
  755. });
  756. }
  757. /** @private */
  758. addPlayButton_() {
  759. const playButtonContainer = shaka.util.Dom.createHTMLElement('div');
  760. playButtonContainer.classList.add('shaka-play-button-container');
  761. this.controlsContainer_.appendChild(playButtonContainer);
  762. /** @private {shaka.ui.BigPlayButton} */
  763. this.playButton_ =
  764. new shaka.ui.BigPlayButton(playButtonContainer, this);
  765. this.elements_.push(this.playButton_);
  766. }
  767. /** @private */
  768. addContextMenu_() {
  769. /** @private {shaka.ui.ContextMenu} */
  770. this.contextMenu_ =
  771. new shaka.ui.ContextMenu(this.controlsButtonPanel_, this);
  772. this.elements_.push(this.contextMenu_);
  773. }
  774. /** @private */
  775. addScrimContainer_() {
  776. // This is the container that gets styled by CSS to have the
  777. // black gradient scrim at the end of the controls.
  778. const scrimContainer = shaka.util.Dom.createHTMLElement('div');
  779. scrimContainer.classList.add('shaka-scrim-container');
  780. this.controlsContainer_.appendChild(scrimContainer);
  781. }
  782. /** @private */
  783. addAdControls_() {
  784. /** @private {!HTMLElement} */
  785. this.adPanel_ = shaka.util.Dom.createHTMLElement('div');
  786. this.adPanel_.classList.add('shaka-ad-controls');
  787. const showAdPanel = this.ad_ != null && this.ad_.isLinear();
  788. shaka.ui.Utils.setDisplay(this.adPanel_, showAdPanel);
  789. this.bottomControls_.appendChild(this.adPanel_);
  790. const adPosition = new shaka.ui.AdPosition(this.adPanel_, this);
  791. this.elements_.push(adPosition);
  792. const adCounter = new shaka.ui.AdCounter(this.adPanel_, this);
  793. this.elements_.push(adCounter);
  794. }
  795. /** @private */
  796. addBufferingSpinner_() {
  797. /** @private {!HTMLElement} */
  798. this.spinnerContainer_ = shaka.util.Dom.createHTMLElement('div');
  799. this.spinnerContainer_.classList.add('shaka-spinner-container');
  800. this.videoContainer_.appendChild(this.spinnerContainer_);
  801. const spinner = shaka.util.Dom.createHTMLElement('div');
  802. spinner.classList.add('shaka-spinner');
  803. this.spinnerContainer_.appendChild(spinner);
  804. // Svg elements have to be created with the svg xml namespace.
  805. const xmlns = 'http://www.w3.org/2000/svg';
  806. const svg =
  807. /** @type {!HTMLElement} */(document.createElementNS(xmlns, 'svg'));
  808. svg.classList.add('shaka-spinner-svg');
  809. svg.setAttribute('viewBox', '0 0 30 30');
  810. spinner.appendChild(svg);
  811. // These coordinates are relative to the SVG viewBox above. This is
  812. // distinct from the actual display size in the page, since the "S" is for
  813. // "Scalable." The radius of 14.5 is so that the edges of the 1-px-wide
  814. // stroke will touch the edges of the viewBox.
  815. const spinnerCircle = document.createElementNS(xmlns, 'circle');
  816. spinnerCircle.classList.add('shaka-spinner-path');
  817. spinnerCircle.setAttribute('cx', '15');
  818. spinnerCircle.setAttribute('cy', '15');
  819. spinnerCircle.setAttribute('r', '14.5');
  820. spinnerCircle.setAttribute('fill', 'none');
  821. spinnerCircle.setAttribute('stroke-width', '1');
  822. spinnerCircle.setAttribute('stroke-miterlimit', '10');
  823. svg.appendChild(spinnerCircle);
  824. }
  825. /** @private */
  826. addControlsButtonPanel_() {
  827. /** @private {!HTMLElement} */
  828. this.bottomControls_ = shaka.util.Dom.createHTMLElement('div');
  829. this.bottomControls_.classList.add('shaka-bottom-controls');
  830. this.bottomControls_.classList.add('shaka-no-propagation');
  831. this.controlsContainer_.appendChild(this.bottomControls_);
  832. // Overflow menus are supposed to hide once you click elsewhere
  833. // on the page. The click event listener on window ensures that.
  834. // However, clicks on the bottom controls don't propagate to the container,
  835. // so we have to explicitly hide the menus onclick here.
  836. this.eventManager_.listen(this.bottomControls_, 'click', (e) => {
  837. // We explicitly deny this measure when clicking on buttons that
  838. // open submenus in the control panel.
  839. if (!e.target['closest']('.shaka-overflow-button')) {
  840. this.hideSettingsMenus();
  841. }
  842. });
  843. this.addAdControls_();
  844. /** @private {!HTMLElement} */
  845. this.controlsButtonPanel_ = shaka.util.Dom.createHTMLElement('div');
  846. this.controlsButtonPanel_.classList.add('shaka-controls-button-panel');
  847. this.controlsButtonPanel_.classList.add(
  848. 'shaka-show-controls-on-mouse-over');
  849. if (this.config_.enableTooltips) {
  850. this.controlsButtonPanel_.classList.add('shaka-tooltips-on');
  851. }
  852. this.bottomControls_.appendChild(this.controlsButtonPanel_);
  853. // Create the elements specified by controlPanelElements
  854. for (const name of this.config_.controlPanelElements) {
  855. if (shaka.ui.ControlsPanel.elementNamesToFactories_.get(name)) {
  856. const factory =
  857. shaka.ui.ControlsPanel.elementNamesToFactories_.get(name);
  858. const element = factory.create(this.controlsButtonPanel_, this);
  859. this.elements_.push(element);
  860. } else {
  861. shaka.log.alwaysWarn('Unrecognized control panel element requested:',
  862. name);
  863. }
  864. }
  865. }
  866. /**
  867. * Adds a container for server side ad UI with IMA SDK.
  868. *
  869. * @private
  870. */
  871. addDaiAdContainer_() {
  872. /** @private {!HTMLElement} */
  873. this.daiAdContainer_ = shaka.util.Dom.createHTMLElement('div');
  874. this.daiAdContainer_.classList.add('shaka-server-side-ad-container');
  875. this.controlsContainer_.appendChild(this.daiAdContainer_);
  876. }
  877. /**
  878. * Adds a seekbar depending on the configuration.
  879. * By default an instance of shaka.ui.SeekBar is created
  880. * This behaviour can be overriden by providing a SeekBar factory using the
  881. * registerSeekBarFactory function.
  882. *
  883. * @private
  884. */
  885. addSeekBar_() {
  886. if (this.config_.addSeekBar) {
  887. this.seekBar_ = shaka.ui.ControlsPanel.seekBarFactory_.create(
  888. this.bottomControls_, this);
  889. this.elements_.push(this.seekBar_);
  890. } else {
  891. // Settings menus need to be positioned lower if the seekbar is absent.
  892. for (const menu of this.menus_) {
  893. menu.classList.add('shaka-low-position');
  894. }
  895. }
  896. }
  897. /**
  898. * Adds a container for client side ad UI with IMA SDK.
  899. *
  900. * @private
  901. */
  902. addClientAdContainer_() {
  903. /** @private {!HTMLElement} */
  904. this.clientAdContainer_ = shaka.util.Dom.createHTMLElement('div');
  905. this.clientAdContainer_.classList.add('shaka-client-side-ad-container');
  906. shaka.ui.Utils.setDisplay(this.clientAdContainer_, false);
  907. this.eventManager_.listen(this.clientAdContainer_, 'click', () => {
  908. this.onContainerClick_();
  909. });
  910. this.videoContainer_.appendChild(this.clientAdContainer_);
  911. }
  912. /**
  913. * Adds static event listeners. This should only add event listeners to
  914. * things that don't change (e.g. Player). Dynamic elements (e.g. controls)
  915. * should have their event listeners added when they are created.
  916. *
  917. * @private
  918. */
  919. addEventListeners_() {
  920. this.eventManager_.listen(this.player_, 'buffering', () => {
  921. this.onBufferingStateChange_();
  922. });
  923. // Set the initial state, as well.
  924. this.onBufferingStateChange_();
  925. // Listen for key down events to detect tab and enable outline
  926. // for focused elements.
  927. this.eventManager_.listen(window, 'keydown', (e) => {
  928. this.onWindowKeyDown_(/** @type {!KeyboardEvent} */(e));
  929. });
  930. // Listen for click events to dismiss the settings menus.
  931. this.eventManager_.listen(window, 'click', () => this.hideSettingsMenus());
  932. // Avoid having multiple submenus open at the same time.
  933. this.eventManager_.listen(
  934. this, 'submenuopen', () => {
  935. this.hideSettingsMenus();
  936. });
  937. this.eventManager_.listen(this.video_, 'play', () => {
  938. this.onPlayStateChange_();
  939. });
  940. this.eventManager_.listen(this.video_, 'pause', () => {
  941. this.onPlayStateChange_();
  942. });
  943. this.eventManager_.listen(this.videoContainer_, 'mousemove', (e) => {
  944. this.onMouseMove_(e);
  945. });
  946. this.eventManager_.listen(this.videoContainer_, 'touchmove', (e) => {
  947. this.onMouseMove_(e);
  948. }, {passive: true});
  949. this.eventManager_.listen(this.videoContainer_, 'touchend', (e) => {
  950. this.onMouseMove_(e);
  951. }, {passive: true});
  952. this.eventManager_.listen(this.videoContainer_, 'mouseleave', () => {
  953. this.onMouseLeave_();
  954. });
  955. this.eventManager_.listen(this.castProxy_, 'caststatuschanged', () => {
  956. this.onCastStatusChange_();
  957. });
  958. this.eventManager_.listen(this.videoContainer_, 'keydown', (e) => {
  959. this.onControlsKeyDown_(/** @type {!KeyboardEvent} */(e));
  960. });
  961. this.eventManager_.listen(this.videoContainer_, 'keyup', (e) => {
  962. this.onControlsKeyUp_(/** @type {!KeyboardEvent} */(e));
  963. });
  964. this.eventManager_.listen(
  965. this.adManager_, shaka.ads.AdManager.AD_STARTED, (e) => {
  966. this.ad_ = (/** @type {!Object} */ (e))['ad'];
  967. this.showAdUI();
  968. });
  969. this.eventManager_.listen(
  970. this.adManager_, shaka.ads.AdManager.AD_STOPPED, () => {
  971. this.ad_ = null;
  972. this.hideAdUI();
  973. });
  974. if (screen.orientation) {
  975. this.eventManager_.listen(screen.orientation, 'change', async () => {
  976. await this.onScreenRotation_();
  977. });
  978. }
  979. }
  980. /**
  981. * When a mobile device is rotated to landscape layout, and the video is
  982. * loaded, make the demo app go into fullscreen.
  983. * Similarly, exit fullscreen when the device is rotated to portrait layout.
  984. * @private
  985. */
  986. async onScreenRotation_() {
  987. if (!this.video_ ||
  988. this.video_.readyState == 0 ||
  989. this.castProxy_.isCasting() ||
  990. !this.config_.enableFullscreenOnRotation ||
  991. !this.isFullScreenSupported()) {
  992. return;
  993. }
  994. if (screen.orientation.type.includes('landscape') &&
  995. !this.isFullScreenEnabled()) {
  996. await this.enterFullScreen_();
  997. } else if (screen.orientation.type.includes('portrait') &&
  998. this.isFullScreenEnabled()) {
  999. await this.exitFullScreen_();
  1000. }
  1001. }
  1002. /**
  1003. * Hiding the cursor when the mouse stops moving seems to be the only
  1004. * decent UX in fullscreen mode. Since we can't use pure CSS for that,
  1005. * we use events both in and out of fullscreen mode.
  1006. * Showing the control bar when a key is pressed, and hiding it after some
  1007. * time.
  1008. * @param {!Event} event
  1009. * @private
  1010. */
  1011. onMouseMove_(event) {
  1012. // Disable blue outline for focused elements for mouse navigation.
  1013. if (event.type == 'mousemove') {
  1014. this.controlsContainer_.classList.remove('shaka-keyboard-navigation');
  1015. this.computeOpacity();
  1016. }
  1017. if (event.type == 'touchstart' || event.type == 'touchmove' ||
  1018. event.type == 'touchend' || event.type == 'keyup') {
  1019. this.lastTouchEventTime_ = Date.now();
  1020. } else if (this.lastTouchEventTime_ + 1000 < Date.now()) {
  1021. // It has been a while since the last touch event, this is probably a real
  1022. // mouse moving, so treat it like a mouse.
  1023. this.lastTouchEventTime_ = null;
  1024. }
  1025. // When there is a touch, we can get a 'mousemove' event after touch events.
  1026. // This should be treated as part of the touch, which has already been
  1027. // handled.
  1028. if (this.lastTouchEventTime_ && event.type == 'mousemove') {
  1029. return;
  1030. }
  1031. // Use the cursor specified in the CSS file.
  1032. this.videoContainer_.style.cursor = '';
  1033. this.recentMouseMovement_ = true;
  1034. // Make sure we are not about to hide the settings menus and then force them
  1035. // open.
  1036. this.hideSettingsMenusTimer_.stop();
  1037. if (!this.isOpaque()) {
  1038. // Only update the time and seek range on mouse movement if it's the very
  1039. // first movement and we're about to show the controls. Otherwise, the
  1040. // seek bar will be updated much more rapidly during mouse movement. Do
  1041. // this right before making it visible.
  1042. this.updateTimeAndSeekRange_();
  1043. this.computeOpacity();
  1044. }
  1045. // Hide the cursor when the mouse stops moving.
  1046. // Only applies while the cursor is over the video container.
  1047. this.mouseStillTimer_.stop();
  1048. // Only start a timeout on 'touchend' or for 'mousemove' with no touch
  1049. // events.
  1050. if (event.type == 'touchend' ||
  1051. event.type == 'keyup'|| !this.lastTouchEventTime_) {
  1052. this.mouseStillTimer_.tickAfter(/* seconds= */ 3);
  1053. }
  1054. }
  1055. /** @private */
  1056. onMouseLeave_() {
  1057. // We sometimes get 'mouseout' events with touches. Since we can never
  1058. // leave the video element when touching, ignore.
  1059. if (this.lastTouchEventTime_) {
  1060. return;
  1061. }
  1062. // Stop the timer and invoke the callback now to hide the controls. If we
  1063. // don't, the opacity style we set in onMouseMove_ will continue to override
  1064. // the opacity in CSS and force the controls to stay visible.
  1065. this.mouseStillTimer_.tickNow();
  1066. }
  1067. /**
  1068. * This callback is for when we are pretty sure that the mouse has stopped
  1069. * moving (aka the mouse is still). This method should only be called via
  1070. * |mouseStillTimer_|. If this behaviour needs to be invoked directly, use
  1071. * |mouseStillTimer_.tickNow()|.
  1072. *
  1073. * @private
  1074. */
  1075. onMouseStill_() {
  1076. // Hide the cursor.
  1077. this.videoContainer_.style.cursor = 'none';
  1078. this.recentMouseMovement_ = false;
  1079. this.computeOpacity();
  1080. }
  1081. /**
  1082. * @return {boolean} true if any relevant elements are hovered.
  1083. * @private
  1084. */
  1085. isHovered_() {
  1086. if (!window.matchMedia('hover: hover').matches) {
  1087. // This is primarily a touch-screen device, so the :hover query below
  1088. // doesn't make sense. In spite of this, the :hover query on an element
  1089. // can still return true on such a device after a touch ends.
  1090. // See https://bit.ly/34dBORX for details.
  1091. return false;
  1092. }
  1093. return this.showOnHoverControls_.some((element) => {
  1094. return element.matches(':hover');
  1095. });
  1096. }
  1097. /**
  1098. * Recompute whether the controls should be shown or hidden.
  1099. */
  1100. computeOpacity() {
  1101. const adIsPaused = this.ad_ ? this.ad_.isPaused() : false;
  1102. const videoIsPaused = this.video_.paused && !this.isSeeking_;
  1103. const keyboardNavigationMode = this.controlsContainer_.classList.contains(
  1104. 'shaka-keyboard-navigation');
  1105. // Keep showing the controls if the ad or video is paused, there has been
  1106. // recent mouse movement, we're in keyboard navigation, or one of a special
  1107. // class of elements is hovered.
  1108. if (adIsPaused ||
  1109. ((!this.ad_ || !this.ad_.isLinear()) && videoIsPaused) ||
  1110. this.recentMouseMovement_ ||
  1111. keyboardNavigationMode ||
  1112. this.isHovered_()) {
  1113. // Make sure the state is up-to-date before showing it.
  1114. this.updateTimeAndSeekRange_();
  1115. this.controlsContainer_.setAttribute('shown', 'true');
  1116. this.fadeControlsTimer_.stop();
  1117. } else {
  1118. this.fadeControlsTimer_.tickAfter(/* seconds= */ this.config_.fadeDelay);
  1119. }
  1120. }
  1121. /**
  1122. * @param {!Event} event
  1123. * @private
  1124. */
  1125. onContainerTouch_(event) {
  1126. if (!this.video_.duration) {
  1127. // Can't play yet. Ignore.
  1128. return;
  1129. }
  1130. if (this.isOpaque()) {
  1131. this.lastTouchEventTime_ = Date.now();
  1132. // The controls are showing.
  1133. // Let this event continue and become a click.
  1134. } else {
  1135. // The controls are hidden, so show them.
  1136. this.onMouseMove_(event);
  1137. // Stop this event from becoming a click event.
  1138. event.cancelable && event.preventDefault();
  1139. }
  1140. }
  1141. /** @private */
  1142. onContainerClick_() {
  1143. if (!this.enabled_) {
  1144. return;
  1145. }
  1146. if (this.anySettingsMenusAreOpen()) {
  1147. this.hideSettingsMenusTimer_.tickNow();
  1148. } else if (this.config_.singleClickForPlayAndPause) {
  1149. this.onPlayPauseClick_();
  1150. }
  1151. }
  1152. /** @private */
  1153. onPlayPauseClick_() {
  1154. if (this.ad_ && this.ad_.isLinear()) {
  1155. this.playPauseAd();
  1156. } else {
  1157. this.playPausePresentation();
  1158. }
  1159. }
  1160. /** @private */
  1161. onCastStatusChange_() {
  1162. const isCasting = this.castProxy_.isCasting();
  1163. this.dispatchEvent(new shaka.util.FakeEvent(
  1164. 'caststatuschanged', (new Map()).set('newStatus', isCasting)));
  1165. if (isCasting) {
  1166. this.controlsContainer_.setAttribute('casting', 'true');
  1167. } else {
  1168. this.controlsContainer_.removeAttribute('casting');
  1169. }
  1170. }
  1171. /** @private */
  1172. onPlayStateChange_() {
  1173. this.computeOpacity();
  1174. }
  1175. /**
  1176. * Support controls with keyboard inputs.
  1177. * @param {!KeyboardEvent} event
  1178. * @private
  1179. */
  1180. onControlsKeyDown_(event) {
  1181. const activeElement = document.activeElement;
  1182. const isVolumeBar = activeElement && activeElement.classList ?
  1183. activeElement.classList.contains('shaka-volume-bar') : false;
  1184. const isSeekBar = activeElement && activeElement.classList &&
  1185. activeElement.classList.contains('shaka-seek-bar');
  1186. // Show the control panel if it is on focus or any button is pressed.
  1187. if (this.controlsContainer_.contains(activeElement)) {
  1188. this.onMouseMove_(event);
  1189. }
  1190. if (!this.config_.enableKeyboardPlaybackControls) {
  1191. return;
  1192. }
  1193. const keyboardSeekDistance = this.config_.keyboardSeekDistance;
  1194. const keyboardLargeSeekDistance = this.config_.keyboardLargeSeekDistance;
  1195. switch (event.key) {
  1196. case 'ArrowLeft':
  1197. // If it's not focused on the volume bar, move the seek time backward
  1198. // for a few sec. Otherwise, the volume will be adjusted automatically.
  1199. if (this.seekBar_ && !isVolumeBar && keyboardSeekDistance > 0) {
  1200. event.preventDefault();
  1201. this.seek_(this.seekBar_.getValue() - keyboardSeekDistance);
  1202. }
  1203. break;
  1204. case 'ArrowRight':
  1205. // If it's not focused on the volume bar, move the seek time forward
  1206. // for a few sec. Otherwise, the volume will be adjusted automatically.
  1207. if (this.seekBar_ && !isVolumeBar && keyboardSeekDistance > 0) {
  1208. event.preventDefault();
  1209. this.seek_(this.seekBar_.getValue() + keyboardSeekDistance);
  1210. }
  1211. break;
  1212. case 'PageDown':
  1213. // PageDown is like ArrowLeft, but has a larger jump distance, and does
  1214. // nothing to volume.
  1215. if (this.seekBar_ && isSeekBar && keyboardSeekDistance > 0) {
  1216. event.preventDefault();
  1217. this.seek_(this.seekBar_.getValue() - keyboardLargeSeekDistance);
  1218. }
  1219. break;
  1220. case 'PageUp':
  1221. // PageDown is like ArrowRight, but has a larger jump distance, and does
  1222. // nothing to volume.
  1223. if (this.seekBar_ && isSeekBar && keyboardSeekDistance > 0) {
  1224. event.preventDefault();
  1225. this.seek_(this.seekBar_.getValue() + keyboardLargeSeekDistance);
  1226. }
  1227. break;
  1228. // Jump to the beginning of the video's seek range.
  1229. case 'Home':
  1230. if (this.seekBar_) {
  1231. this.seek_(this.player_.seekRange().start);
  1232. }
  1233. break;
  1234. // Jump to the end of the video's seek range.
  1235. case 'End':
  1236. if (this.seekBar_) {
  1237. this.seek_(this.player_.seekRange().end);
  1238. }
  1239. break;
  1240. // Pause or play by pressing space on the seek bar.
  1241. case ' ':
  1242. if (isSeekBar) {
  1243. this.onPlayPauseClick_();
  1244. }
  1245. break;
  1246. }
  1247. }
  1248. /**
  1249. * Support controls with keyboard inputs.
  1250. * @param {!KeyboardEvent} event
  1251. * @private
  1252. */
  1253. onControlsKeyUp_(event) {
  1254. // When the key is released, remove it from the pressed keys set.
  1255. this.pressedKeys_.delete(event.key);
  1256. }
  1257. /**
  1258. * Called both as an event listener and directly by the controls to initialize
  1259. * the buffering state.
  1260. * @private
  1261. */
  1262. onBufferingStateChange_() {
  1263. if (!this.enabled_) {
  1264. return;
  1265. }
  1266. shaka.ui.Utils.setDisplay(
  1267. this.spinnerContainer_, this.player_.isBuffering());
  1268. }
  1269. /**
  1270. * @return {boolean}
  1271. * @export
  1272. */
  1273. isOpaque() {
  1274. if (!this.enabled_) {
  1275. return false;
  1276. }
  1277. return this.controlsContainer_.getAttribute('shown') != null ||
  1278. this.controlsContainer_.getAttribute('casting') != null;
  1279. }
  1280. /**
  1281. * Update the video's current time based on the keyboard operations.
  1282. *
  1283. * @param {number} currentTime
  1284. * @private
  1285. */
  1286. seek_(currentTime) {
  1287. goog.asserts.assert(
  1288. this.seekBar_, 'Caller of seek_ must check for seekBar_ first!');
  1289. this.seekBar_.changeTo(currentTime);
  1290. if (this.isOpaque()) {
  1291. // Only update the time and seek range if it's visible.
  1292. this.updateTimeAndSeekRange_();
  1293. }
  1294. }
  1295. /**
  1296. * Called when the seek range or current time need to be updated.
  1297. * @private
  1298. */
  1299. updateTimeAndSeekRange_() {
  1300. if (this.seekBar_) {
  1301. this.seekBar_.setValue(this.video_.currentTime);
  1302. this.seekBar_.update();
  1303. if (this.seekBar_.isShowing()) {
  1304. for (const menu of this.menus_) {
  1305. menu.classList.remove('shaka-low-position');
  1306. }
  1307. } else {
  1308. for (const menu of this.menus_) {
  1309. menu.classList.add('shaka-low-position');
  1310. }
  1311. }
  1312. }
  1313. this.dispatchEvent(new shaka.util.FakeEvent('timeandseekrangeupdated'));
  1314. }
  1315. /**
  1316. * Add behaviors for keyboard navigation.
  1317. * 1. Add blue outline for focused elements.
  1318. * 2. Allow exiting overflow settings menus by pressing Esc key.
  1319. * 3. When navigating on overflow settings menu by pressing Tab
  1320. * key or Shift+Tab keys keep the focus inside overflow menu.
  1321. *
  1322. * @param {!KeyboardEvent} event
  1323. * @private
  1324. */
  1325. onWindowKeyDown_(event) {
  1326. // Add the key to the pressed keys set when it's pressed.
  1327. this.pressedKeys_.add(event.key);
  1328. const anySettingsMenusAreOpen = this.anySettingsMenusAreOpen();
  1329. if (event.key == 'Tab') {
  1330. // Enable blue outline for focused elements for keyboard
  1331. // navigation.
  1332. this.controlsContainer_.classList.add('shaka-keyboard-navigation');
  1333. this.computeOpacity();
  1334. this.eventManager_.listen(window, 'mousedown', () => this.onMouseDown_());
  1335. }
  1336. // If escape key was pressed, close any open settings menus.
  1337. if (event.key == 'Escape') {
  1338. this.hideSettingsMenusTimer_.tickNow();
  1339. }
  1340. if (anySettingsMenusAreOpen && this.pressedKeys_.has('Tab')) {
  1341. // If Tab key or Shift+Tab keys are pressed when navigating through
  1342. // an overflow settings menu, keep the focus to loop inside the
  1343. // overflow menu.
  1344. this.keepFocusInMenu_(event);
  1345. }
  1346. }
  1347. /**
  1348. * When the user is using keyboard to navigate inside the overflow settings
  1349. * menu (pressing Tab key to go forward, or pressing Shift + Tab keys to go
  1350. * backward), make sure it's focused only on the elements of the overflow
  1351. * panel.
  1352. *
  1353. * This is called by onWindowKeyDown_() function, when there's a settings
  1354. * overflow menu open, and the Tab key / Shift+Tab keys are pressed.
  1355. *
  1356. * @param {!Event} event
  1357. * @private
  1358. */
  1359. keepFocusInMenu_(event) {
  1360. const openSettingsMenus = this.menus_.filter(
  1361. (menu) => !menu.classList.contains('shaka-hidden'));
  1362. if (!openSettingsMenus.length) {
  1363. // For example, this occurs when you hit escape to close the menu.
  1364. return;
  1365. }
  1366. const settingsMenu = openSettingsMenus[0];
  1367. if (settingsMenu.childNodes.length) {
  1368. // Get the first and the last displaying child element from the overflow
  1369. // menu.
  1370. let firstShownChild = settingsMenu.firstElementChild;
  1371. while (firstShownChild &&
  1372. firstShownChild.classList.contains('shaka-hidden')) {
  1373. firstShownChild = firstShownChild.nextElementSibling;
  1374. }
  1375. let lastShownChild = settingsMenu.lastElementChild;
  1376. while (lastShownChild &&
  1377. lastShownChild.classList.contains('shaka-hidden')) {
  1378. lastShownChild = lastShownChild.previousElementSibling;
  1379. }
  1380. const activeElement = document.activeElement;
  1381. // When only Tab key is pressed, navigate to the next elememnt.
  1382. // If it's currently focused on the last shown child element of the
  1383. // overflow menu, let the focus move to the first child element of the
  1384. // menu.
  1385. // When Tab + Shift keys are pressed at the same time, navigate to the
  1386. // previous element. If it's currently focused on the first shown child
  1387. // element of the overflow menu, let the focus move to the last child
  1388. // element of the menu.
  1389. if (this.pressedKeys_.has('Shift')) {
  1390. if (activeElement == firstShownChild) {
  1391. event.preventDefault();
  1392. lastShownChild.focus();
  1393. }
  1394. } else {
  1395. if (activeElement == lastShownChild) {
  1396. event.preventDefault();
  1397. firstShownChild.focus();
  1398. }
  1399. }
  1400. }
  1401. }
  1402. /**
  1403. * For keyboard navigation, we use blue borders to highlight the active
  1404. * element. If we detect that a mouse is being used, remove the blue border
  1405. * from the active element.
  1406. * @private
  1407. */
  1408. onMouseDown_() {
  1409. this.eventManager_.unlisten(window, 'mousedown');
  1410. }
  1411. /**
  1412. * Create a localization instance already pre-loaded with all the locales that
  1413. * we support.
  1414. *
  1415. * @return {!shaka.ui.Localization}
  1416. * @private
  1417. */
  1418. static createLocalization_() {
  1419. /** @type {string} */
  1420. const fallbackLocale = 'en';
  1421. /** @type {!shaka.ui.Localization} */
  1422. const localization = new shaka.ui.Localization(fallbackLocale);
  1423. shaka.ui.Locales.addTo(localization);
  1424. localization.changeLocale(navigator.languages || []);
  1425. return localization;
  1426. }
  1427. };
  1428. /**
  1429. * @event shaka.ui.Controls#CastStatusChangedEvent
  1430. * @description Fired upon receiving a 'caststatuschanged' event from
  1431. * the cast proxy.
  1432. * @property {string} type
  1433. * 'caststatuschanged'
  1434. * @property {boolean} newStatus
  1435. * The new status of the application. True for 'is casting' and
  1436. * false otherwise.
  1437. * @exportDoc
  1438. */
  1439. /**
  1440. * @event shaka.ui.Controls#SubMenuOpenEvent
  1441. * @description Fired when one of the overflow submenus is opened
  1442. * (e. g. language/resolution/subtitle selection).
  1443. * @property {string} type
  1444. * 'submenuopen'
  1445. * @exportDoc
  1446. */
  1447. /**
  1448. * @event shaka.ui.Controls#CaptionSelectionUpdatedEvent
  1449. * @description Fired when the captions/subtitles menu has finished updating.
  1450. * @property {string} type
  1451. * 'captionselectionupdated'
  1452. * @exportDoc
  1453. */
  1454. /**
  1455. * @event shaka.ui.Controls#ResolutionSelectionUpdatedEvent
  1456. * @description Fired when the resolution menu has finished updating.
  1457. * @property {string} type
  1458. * 'resolutionselectionupdated'
  1459. * @exportDoc
  1460. */
  1461. /**
  1462. * @event shaka.ui.Controls#LanguageSelectionUpdatedEvent
  1463. * @description Fired when the audio language menu has finished updating.
  1464. * @property {string} type
  1465. * 'languageselectionupdated'
  1466. * @exportDoc
  1467. */
  1468. /**
  1469. * @event shaka.ui.Controls#ErrorEvent
  1470. * @description Fired when something went wrong with the controls.
  1471. * @property {string} type
  1472. * 'error'
  1473. * @property {!shaka.util.Error} detail
  1474. * An object which contains details on the error. The error's 'category'
  1475. * and 'code' properties will identify the specific error that occurred.
  1476. * In an uncompiled build, you can also use the 'message' and 'stack'
  1477. * properties to debug.
  1478. * @exportDoc
  1479. */
  1480. /**
  1481. * @event shaka.ui.Controls#TimeAndSeekRangeUpdatedEvent
  1482. * @description Fired when the time and seek range elements have finished
  1483. * updating.
  1484. * @property {string} type
  1485. * 'timeandseekrangeupdated'
  1486. * @exportDoc
  1487. */
  1488. /**
  1489. * @event shaka.ui.Controls#UIUpdatedEvent
  1490. * @description Fired after a call to ui.configure() once the UI has finished
  1491. * updating.
  1492. * @property {string} type
  1493. * 'uiupdated'
  1494. * @exportDoc
  1495. */
  1496. /** @private {!Map.<string, !shaka.extern.IUIElement.Factory>} */
  1497. shaka.ui.ControlsPanel.elementNamesToFactories_ = new Map();
  1498. /** @private {?shaka.extern.IUISeekBar.Factory} */
  1499. shaka.ui.ControlsPanel.seekBarFactory_ = new shaka.ui.SeekBar.Factory();