Source: ui/ui.js

  1. /*! @license
  2. * Shaka Player
  3. * Copyright 2016 Google LLC
  4. * SPDX-License-Identifier: Apache-2.0
  5. */
  6. goog.provide('shaka.ui.Overlay');
  7. goog.provide('shaka.ui.Overlay.FailReasonCode');
  8. goog.provide('shaka.ui.Overlay.TrackLabelFormat');
  9. goog.require('goog.asserts');
  10. goog.require('shaka.Player');
  11. goog.require('shaka.log');
  12. goog.require('shaka.polyfill');
  13. goog.require('shaka.ui.Controls');
  14. goog.require('shaka.util.ConfigUtils');
  15. goog.require('shaka.util.Dom');
  16. goog.require('shaka.util.FakeEvent');
  17. goog.require('shaka.util.IDestroyable');
  18. goog.require('shaka.util.Platform');
  19. /**
  20. * @implements {shaka.util.IDestroyable}
  21. * @export
  22. */
  23. shaka.ui.Overlay = class {
  24. /**
  25. * @param {!shaka.Player} player
  26. * @param {!HTMLElement} videoContainer
  27. * @param {!HTMLMediaElement} video
  28. * @param {?HTMLCanvasElement=} vrCanvas
  29. */
  30. constructor(player, videoContainer, video, vrCanvas = null) {
  31. /** @private {shaka.Player} */
  32. this.player_ = player;
  33. /** @private {HTMLElement} */
  34. this.videoContainer_ = videoContainer;
  35. /** @private {!shaka.extern.UIConfiguration} */
  36. this.config_ = this.defaultConfig_();
  37. // Make sure this container is discoverable and that the UI can be reached
  38. // through it.
  39. videoContainer['dataset']['shakaPlayerContainer'] = '';
  40. videoContainer['ui'] = this;
  41. // Tag the container for mobile platforms, to allow different styles.
  42. if (this.isMobile()) {
  43. videoContainer.classList.add('shaka-mobile');
  44. }
  45. /** @private {shaka.ui.Controls} */
  46. this.controls_ = new shaka.ui.Controls(
  47. player, videoContainer, video, vrCanvas, this.config_);
  48. // Run the initial setup so that no configure() call is required for default
  49. // settings.
  50. this.configure({});
  51. // If the browser's native controls are disabled, use UI TextDisplayer.
  52. if (!video.controls) {
  53. player.setVideoContainer(videoContainer);
  54. }
  55. videoContainer['ui'] = this;
  56. video['ui'] = this;
  57. }
  58. /**
  59. * @override
  60. * @export
  61. */
  62. async destroy() {
  63. if (this.controls_) {
  64. await this.controls_.destroy();
  65. }
  66. this.controls_ = null;
  67. if (this.player_) {
  68. await this.player_.destroy();
  69. }
  70. this.player_ = null;
  71. }
  72. /**
  73. * Detects if this is a mobile platform, in case you want to choose a
  74. * different UI configuration on mobile devices.
  75. *
  76. * @return {boolean}
  77. * @export
  78. */
  79. isMobile() {
  80. return shaka.util.Platform.isMobile();
  81. }
  82. /**
  83. * @return {!shaka.extern.UIConfiguration}
  84. * @export
  85. */
  86. getConfiguration() {
  87. const ret = this.defaultConfig_();
  88. shaka.util.ConfigUtils.mergeConfigObjects(
  89. ret, this.config_, this.defaultConfig_(),
  90. /* overrides= */ {}, /* path= */ '');
  91. return ret;
  92. }
  93. /**
  94. * @param {string|!Object} config This should either be a field name or an
  95. * object following the form of {@link shaka.extern.UIConfiguration}, where
  96. * you may omit any field you do not wish to change.
  97. * @param {*=} value This should be provided if the previous parameter
  98. * was a string field name.
  99. * @export
  100. */
  101. configure(config, value) {
  102. goog.asserts.assert(typeof(config) == 'object' || arguments.length == 2,
  103. 'String configs should have values!');
  104. // ('fieldName', value) format
  105. if (arguments.length == 2 && typeof(config) == 'string') {
  106. config = shaka.util.ConfigUtils.convertToConfigObject(config, value);
  107. }
  108. goog.asserts.assert(typeof(config) == 'object', 'Should be an object!');
  109. shaka.util.ConfigUtils.mergeConfigObjects(
  110. this.config_, config, this.defaultConfig_(),
  111. /* overrides= */ {}, /* path= */ '');
  112. // If a cast receiver app id has been given, add a cast button to the UI
  113. if (this.config_.castReceiverAppId &&
  114. !this.config_.overflowMenuButtons.includes('cast')) {
  115. this.config_.overflowMenuButtons.push('cast');
  116. }
  117. goog.asserts.assert(this.player_ != null, 'Should have a player!');
  118. this.controls_.configure(this.config_);
  119. this.controls_.dispatchEvent(new shaka.util.FakeEvent('uiupdated'));
  120. }
  121. /**
  122. * @return {shaka.ui.Controls}
  123. * @export
  124. */
  125. getControls() {
  126. return this.controls_;
  127. }
  128. /**
  129. * Enable or disable the custom controls.
  130. *
  131. * @param {boolean} enabled
  132. * @export
  133. */
  134. setEnabled(enabled) {
  135. this.controls_.setEnabledShakaControls(enabled);
  136. }
  137. /**
  138. * @return {!shaka.extern.UIConfiguration}
  139. * @private
  140. */
  141. defaultConfig_() {
  142. const config = {
  143. controlPanelElements: [
  144. 'play_pause',
  145. 'time_and_duration',
  146. 'spacer',
  147. 'mute',
  148. 'volume',
  149. 'fullscreen',
  150. 'overflow_menu',
  151. ],
  152. overflowMenuButtons: [
  153. 'captions',
  154. 'quality',
  155. 'language',
  156. 'chapter',
  157. 'picture_in_picture',
  158. 'cast',
  159. 'playback_rate',
  160. 'recenter_vr',
  161. 'toggle_stereoscopic',
  162. ],
  163. statisticsList: [
  164. 'width',
  165. 'height',
  166. 'corruptedFrames',
  167. 'decodedFrames',
  168. 'droppedFrames',
  169. 'drmTimeSeconds',
  170. 'licenseTime',
  171. 'liveLatency',
  172. 'loadLatency',
  173. 'bufferingTime',
  174. 'manifestTimeSeconds',
  175. 'estimatedBandwidth',
  176. 'streamBandwidth',
  177. 'maxSegmentDuration',
  178. 'pauseTime',
  179. 'playTime',
  180. 'completionPercent',
  181. 'manifestSizeBytes',
  182. 'bytesDownloaded',
  183. 'nonFatalErrorCount',
  184. 'manifestPeriodCount',
  185. 'manifestGapCount',
  186. ],
  187. adStatisticsList: [
  188. 'loadTimes',
  189. 'averageLoadTime',
  190. 'started',
  191. 'playedCompletely',
  192. 'skipped',
  193. 'errors',
  194. ],
  195. contextMenuElements: [
  196. 'loop',
  197. 'picture_in_picture',
  198. 'save_video_frame',
  199. 'statistics',
  200. 'ad_statistics',
  201. ],
  202. playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
  203. fastForwardRates: [2, 4, 8, 1],
  204. rewindRates: [-1, -2, -4, -8],
  205. addSeekBar: true,
  206. addBigPlayButton: false,
  207. customContextMenu: false,
  208. castReceiverAppId: '',
  209. castAndroidReceiverCompatible: false,
  210. clearBufferOnQualityChange: true,
  211. showUnbufferedStart: false,
  212. seekBarColors: {
  213. base: 'rgba(255, 255, 255, 0.3)',
  214. buffered: 'rgba(255, 255, 255, 0.54)',
  215. played: 'rgb(255, 255, 255)',
  216. adBreaks: 'rgb(255, 204, 0)',
  217. },
  218. volumeBarColors: {
  219. base: 'rgba(255, 255, 255, 0.54)',
  220. level: 'rgb(255, 255, 255)',
  221. },
  222. trackLabelFormat: shaka.ui.Overlay.TrackLabelFormat.LANGUAGE,
  223. textTrackLabelFormat: shaka.ui.Overlay.TrackLabelFormat.LANGUAGE,
  224. fadeDelay: 0,
  225. doubleClickForFullscreen: true,
  226. singleClickForPlayAndPause: true,
  227. enableKeyboardPlaybackControls: true,
  228. enableFullscreenOnRotation: true,
  229. forceLandscapeOnFullscreen: true,
  230. enableTooltips: false,
  231. keyboardSeekDistance: 5,
  232. keyboardLargeSeekDistance: 60,
  233. fullScreenElement: this.videoContainer_,
  234. preferDocumentPictureInPicture: true,
  235. showAudioChannelCountVariants: true,
  236. seekOnTaps: navigator.maxTouchPoints > 0,
  237. tapSeekDistance: 10,
  238. refreshTickInSeconds: 0.125,
  239. displayInVrMode: false,
  240. defaultVrProjectionMode: 'equirectangular',
  241. setupMediaSession: true,
  242. preferVideoFullScreenInVisionOS: false,
  243. };
  244. // eslint-disable-next-line no-restricted-syntax
  245. if ('remote' in HTMLMediaElement.prototype) {
  246. config.overflowMenuButtons.push('remote');
  247. } else if (window.WebKitPlaybackTargetAvailabilityEvent) {
  248. config.overflowMenuButtons.push('airplay');
  249. }
  250. // On mobile, by default, hide the volume slide and the small play/pause
  251. // button and show the big play/pause button in the center.
  252. // This is in line with default styles in Chrome.
  253. if (this.isMobile()) {
  254. config.addBigPlayButton = true;
  255. config.controlPanelElements = config.controlPanelElements.filter(
  256. (name) => name != 'play_pause' && name != 'volume');
  257. }
  258. // Set this button here to push it at the end.
  259. config.overflowMenuButtons.push('save_video_frame');
  260. return config;
  261. }
  262. /**
  263. * @private
  264. */
  265. static async scanPageForShakaElements_() {
  266. // Install built-in polyfills to patch browser incompatibilities.
  267. shaka.polyfill.installAll();
  268. // Check to see if the browser supports the basic APIs Shaka needs.
  269. if (!shaka.Player.isBrowserSupported()) {
  270. shaka.log.error('Shaka Player does not support this browser. ' +
  271. 'Please see https://tinyurl.com/y7s4j9tr for the list of ' +
  272. 'supported browsers.');
  273. // After scanning the page for elements, fire a special "loaded" event for
  274. // when the load fails. This will allow the page to react to the failure.
  275. shaka.ui.Overlay.dispatchLoadedEvent_('shaka-ui-load-failed',
  276. shaka.ui.Overlay.FailReasonCode.NO_BROWSER_SUPPORT);
  277. return;
  278. }
  279. // Look for elements marked 'data-shaka-player-container'
  280. // on the page. These will be used to create our default
  281. // UI.
  282. const containers = document.querySelectorAll(
  283. '[data-shaka-player-container]');
  284. // Look for elements marked 'data-shaka-player'. They will
  285. // either be used in our default UI or with native browser
  286. // controls.
  287. const videos = document.querySelectorAll(
  288. '[data-shaka-player]');
  289. // Look for elements marked 'data-shaka-player-canvas'
  290. // on the page. These will be used to create our default
  291. // UI.
  292. const canvases = document.querySelectorAll(
  293. '[data-shaka-player-canvas]');
  294. // Look for elements marked 'data-shaka-player-vr-canvas'
  295. // on the page. These will be used to create our default
  296. // UI.
  297. const vrCanvases = document.querySelectorAll(
  298. '[data-shaka-player-vr-canvas]');
  299. if (!videos.length && !containers.length) {
  300. // No elements have been tagged with shaka attributes.
  301. } else if (videos.length && !containers.length) {
  302. // Just the video elements were provided.
  303. for (const video of videos) {
  304. // If the app has already manually created a UI for this element,
  305. // don't create another one.
  306. if (video['ui']) {
  307. continue;
  308. }
  309. goog.asserts.assert(video.tagName.toLowerCase() == 'video',
  310. 'Should be a video element!');
  311. const container = document.createElement('div');
  312. const videoParent = video.parentElement;
  313. videoParent.replaceChild(container, video);
  314. container.appendChild(video);
  315. const {lcevcCanvas, vrCanvas} =
  316. shaka.ui.Overlay.findOrMakeSpecialCanvases_(
  317. container, canvases, vrCanvases);
  318. shaka.ui.Overlay.setupUIandAutoLoad_(
  319. container, video, lcevcCanvas, vrCanvas);
  320. }
  321. } else {
  322. for (const container of containers) {
  323. // If the app has already manually created a UI for this element,
  324. // don't create another one.
  325. if (container['ui']) {
  326. continue;
  327. }
  328. goog.asserts.assert(container.tagName.toLowerCase() == 'div',
  329. 'Container should be a div!');
  330. let currentVideo = null;
  331. for (const video of videos) {
  332. goog.asserts.assert(video.tagName.toLowerCase() == 'video',
  333. 'Should be a video element!');
  334. if (video.parentElement == container) {
  335. currentVideo = video;
  336. break;
  337. }
  338. }
  339. if (!currentVideo) {
  340. currentVideo = document.createElement('video');
  341. currentVideo.setAttribute('playsinline', '');
  342. container.appendChild(currentVideo);
  343. }
  344. const {lcevcCanvas, vrCanvas} =
  345. shaka.ui.Overlay.findOrMakeSpecialCanvases_(
  346. container, canvases, vrCanvases);
  347. try {
  348. // eslint-disable-next-line no-await-in-loop
  349. await shaka.ui.Overlay.setupUIandAutoLoad_(
  350. container, currentVideo, lcevcCanvas, vrCanvas);
  351. } catch (e) {
  352. // This can fail if, for example, not every player file has loaded.
  353. // Ad-block is a likely cause for this sort of failure.
  354. shaka.log.error('Error setting up Shaka Player', e);
  355. shaka.ui.Overlay.dispatchLoadedEvent_('shaka-ui-load-failed',
  356. shaka.ui.Overlay.FailReasonCode.PLAYER_FAILED_TO_LOAD);
  357. return;
  358. }
  359. }
  360. }
  361. // After scanning the page for elements, fire the "loaded" event. This will
  362. // let apps know they can use the UI library programmatically now, even if
  363. // they didn't have any Shaka-related elements declared in their HTML.
  364. shaka.ui.Overlay.dispatchLoadedEvent_('shaka-ui-loaded');
  365. }
  366. /**
  367. * @param {string} eventName
  368. * @param {shaka.ui.Overlay.FailReasonCode=} reasonCode
  369. * @private
  370. */
  371. static dispatchLoadedEvent_(eventName, reasonCode) {
  372. let detail = null;
  373. if (reasonCode != undefined) {
  374. detail = {
  375. 'reasonCode': reasonCode,
  376. };
  377. }
  378. const uiLoadedEvent = new CustomEvent(eventName, {detail});
  379. document.dispatchEvent(uiLoadedEvent);
  380. }
  381. /**
  382. * @param {!Element} container
  383. * @param {!Element} video
  384. * @param {!Element} lcevcCanvas
  385. * @param {!Element} vrCanvas
  386. * @private
  387. */
  388. static async setupUIandAutoLoad_(container, video, lcevcCanvas, vrCanvas) {
  389. // Create the UI
  390. const player = new shaka.Player();
  391. const ui = new shaka.ui.Overlay(player,
  392. shaka.util.Dom.asHTMLElement(container),
  393. shaka.util.Dom.asHTMLMediaElement(video),
  394. shaka.util.Dom.asHTMLCanvasElement(vrCanvas));
  395. // Attach Canvas used for LCEVC Decoding
  396. player.attachCanvas(/** @type {HTMLCanvasElement} */(lcevcCanvas));
  397. // Get and configure cast app id.
  398. let castAppId = '';
  399. // Get and configure cast Android Receiver Compatibility
  400. let castAndroidReceiverCompatible = false;
  401. // Cast receiver id can be specified on either container or video.
  402. // It should not be provided on both. If it was, we will use the last
  403. // one we saw.
  404. if (container['dataset'] &&
  405. container['dataset']['shakaPlayerCastReceiverId']) {
  406. castAppId = container['dataset']['shakaPlayerCastReceiverId'];
  407. castAndroidReceiverCompatible =
  408. container['dataset']['shakaPlayerCastAndroidReceiverCompatible'] ===
  409. 'true';
  410. } else if (video['dataset'] &&
  411. video['dataset']['shakaPlayerCastReceiverId']) {
  412. castAppId = video['dataset']['shakaPlayerCastReceiverId'];
  413. castAndroidReceiverCompatible =
  414. video['dataset']['shakaPlayerCastAndroidReceiverCompatible'] === 'true';
  415. }
  416. if (castAppId.length) {
  417. ui.configure({castReceiverAppId: castAppId,
  418. castAndroidReceiverCompatible: castAndroidReceiverCompatible});
  419. }
  420. if (shaka.util.Dom.asHTMLMediaElement(video).controls) {
  421. ui.getControls().setEnabledNativeControls(true);
  422. }
  423. // Get the source and load it
  424. // Source can be specified either on the video element:
  425. // <video src='foo.m2u8'></video>
  426. // or as a separate element inside the video element:
  427. // <video>
  428. // <source src='foo.m2u8'/>
  429. // </video>
  430. // It should not be specified on both.
  431. const urls = [];
  432. const src = video.getAttribute('src');
  433. if (src) {
  434. urls.push(src);
  435. video.removeAttribute('src');
  436. }
  437. for (const source of video.getElementsByTagName('source')) {
  438. urls.push(/** @type {!HTMLSourceElement} */ (source).src);
  439. video.removeChild(source);
  440. }
  441. await player.attach(shaka.util.Dom.asHTMLMediaElement(video));
  442. for (const url of urls) {
  443. try { // eslint-disable-next-line no-await-in-loop
  444. await ui.getControls().getPlayer().load(url);
  445. break;
  446. } catch (e) {
  447. shaka.log.error('Error auto-loading asset', e);
  448. }
  449. }
  450. }
  451. /**
  452. * @param {!Element} container
  453. * @param {!NodeList.<!Element>} canvases
  454. * @param {!NodeList.<!Element>} vrCanvases
  455. * @return {{lcevcCanvas: !Element, vrCanvas: !Element}}
  456. * @private
  457. */
  458. static findOrMakeSpecialCanvases_(container, canvases, vrCanvases) {
  459. let lcevcCanvas = null;
  460. for (const canvas of canvases) {
  461. goog.asserts.assert(canvas.tagName.toLowerCase() == 'canvas',
  462. 'Should be a canvas element!');
  463. if (canvas.parentElement == container) {
  464. lcevcCanvas = canvas;
  465. break;
  466. }
  467. }
  468. if (!lcevcCanvas) {
  469. lcevcCanvas = document.createElement('canvas');
  470. lcevcCanvas.classList.add('shaka-canvas-container');
  471. container.appendChild(lcevcCanvas);
  472. }
  473. let vrCanvas = null;
  474. for (const canvas of vrCanvases) {
  475. goog.asserts.assert(canvas.tagName.toLowerCase() == 'canvas',
  476. 'Should be a canvas element!');
  477. if (canvas.parentElement == container) {
  478. vrCanvas = canvas;
  479. break;
  480. }
  481. }
  482. if (!vrCanvas) {
  483. vrCanvas = document.createElement('canvas');
  484. vrCanvas.classList.add('shaka-vr-canvas-container');
  485. container.appendChild(vrCanvas);
  486. }
  487. return {
  488. lcevcCanvas,
  489. vrCanvas,
  490. };
  491. }
  492. };
  493. /**
  494. * Describes what information should show up in labels for selecting audio
  495. * variants and text tracks.
  496. *
  497. * @enum {number}
  498. * @export
  499. */
  500. shaka.ui.Overlay.TrackLabelFormat = {
  501. 'LANGUAGE': 0,
  502. 'ROLE': 1,
  503. 'LANGUAGE_ROLE': 2,
  504. 'LABEL': 3,
  505. };
  506. /**
  507. * Describes the possible reasons that the UI might fail to load.
  508. *
  509. * @enum {number}
  510. * @export
  511. */
  512. shaka.ui.Overlay.FailReasonCode = {
  513. 'NO_BROWSER_SUPPORT': 0,
  514. 'PLAYER_FAILED_TO_LOAD': 1,
  515. };
  516. if (document.readyState == 'complete') {
  517. // Don't fire this event synchronously. In a compiled bundle, the "shaka"
  518. // namespace might not be exported to the window until after this point.
  519. (async () => {
  520. await Promise.resolve();
  521. shaka.ui.Overlay.scanPageForShakaElements_();
  522. })();
  523. } else {
  524. window.addEventListener('load', shaka.ui.Overlay.scanPageForShakaElements_);
  525. }