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