Source: ui/resolution_selection.js

  1. /*! @license
  2. * Shaka Player
  3. * Copyright 2016 Google LLC
  4. * SPDX-License-Identifier: Apache-2.0
  5. */
  6. goog.provide('shaka.ui.ResolutionSelection');
  7. goog.require('goog.asserts');
  8. goog.require('shaka.Player');
  9. goog.require('shaka.ui.Controls');
  10. goog.require('shaka.ui.Enums');
  11. goog.require('shaka.ui.Locales');
  12. goog.require('shaka.ui.Localization');
  13. goog.require('shaka.ui.OverflowMenu');
  14. goog.require('shaka.ui.Overlay.TrackLabelFormat');
  15. goog.require('shaka.ui.SettingsMenu');
  16. goog.require('shaka.ui.Utils');
  17. goog.require('shaka.util.Dom');
  18. goog.require('shaka.util.FakeEvent');
  19. goog.require('shaka.util.MimeUtils');
  20. goog.requireType('shaka.ui.Controls');
  21. /**
  22. * @extends {shaka.ui.SettingsMenu}
  23. * @final
  24. * @export
  25. */
  26. shaka.ui.ResolutionSelection = class extends shaka.ui.SettingsMenu {
  27. /**
  28. * @param {!HTMLElement} parent
  29. * @param {!shaka.ui.Controls} controls
  30. */
  31. constructor(parent, controls) {
  32. super(parent, controls, shaka.ui.Enums.MaterialDesignIcons.RESOLUTION);
  33. this.button.classList.add('shaka-resolution-button');
  34. this.button.classList.add('shaka-tooltip-status');
  35. this.menu.classList.add('shaka-resolutions');
  36. this.autoQuality = shaka.util.Dom.createHTMLElement('span');
  37. this.autoQuality.classList.add('shaka-current-auto-quality');
  38. this.autoQuality.style.display = 'none';
  39. this.qualityMark = shaka.util.Dom.createHTMLElement('sup');
  40. this.qualityMark.classList.add('shaka-current-quality-mark');
  41. this.qualityMark.style.display = 'none';
  42. if (!Array.from(parent.classList).includes('shaka-overflow-menu')) {
  43. this.overflowQualityMark = shaka.util.Dom.createHTMLElement('span');
  44. this.overflowQualityMark.classList.add(
  45. 'shaka-overflow-playback-rate-mark');
  46. this.button.appendChild(this.overflowQualityMark);
  47. } else if (this.parent.parentElement) {
  48. const parentElement =
  49. shaka.util.Dom.asHTMLElement(this.parent.parentElement);
  50. this.overflowQualityMark = shaka.util.Dom.getElementByClassNameIfItExists(
  51. 'shaka-overflow-quality-mark', parentElement);
  52. }
  53. const spanWrapper = shaka.util.Dom.createHTMLElement('span');
  54. this.button.childNodes[1].appendChild(spanWrapper);
  55. spanWrapper.appendChild(this.currentSelection);
  56. spanWrapper.appendChild(this.autoQuality);
  57. spanWrapper.appendChild(this.qualityMark);
  58. this.eventManager.listen(
  59. this.localization, shaka.ui.Localization.LOCALE_UPDATED, () => {
  60. this.updateLocalizedStrings_();
  61. });
  62. this.eventManager.listen(
  63. this.localization, shaka.ui.Localization.LOCALE_CHANGED, () => {
  64. this.updateLocalizedStrings_();
  65. });
  66. this.eventManager.listen(this.player, 'loading', () => {
  67. this.updateSelection_();
  68. this.updateLabels_();
  69. });
  70. this.eventManager.listen(this.player, 'loaded', () => {
  71. this.updateSelection_();
  72. this.updateLabels_();
  73. });
  74. this.eventManager.listen(this.player, 'unloading', () => {
  75. this.updateSelection_();
  76. this.updateLabels_();
  77. });
  78. this.eventManager.listen(this.player, 'variantchanged', () => {
  79. this.updateSelection_();
  80. this.updateLabels_();
  81. });
  82. this.eventManager.listen(this.player, 'trackschanged', () => {
  83. this.updateSelection_();
  84. this.updateLabels_();
  85. });
  86. this.eventManager.listen(this.player, 'abrstatuschanged', () => {
  87. this.updateSelection_();
  88. this.updateLabels_();
  89. });
  90. this.eventManager.listen(this.player, 'adaptation', () => {
  91. this.updateSelection_();
  92. this.updateLabels_();
  93. });
  94. this.updateSelection_();
  95. }
  96. /** @private */
  97. updateLabels_() {
  98. const abrEnabled = this.player.getConfiguration().abr.enabled;
  99. if (this.player.isAudioOnly()) {
  100. this.qualityMark.textContent = '';
  101. this.qualityMark.style.display = 'none';
  102. if (this.overflowQualityMark) {
  103. this.overflowQualityMark.textContent = '';
  104. this.overflowQualityMark.style.display = 'none';
  105. }
  106. const audioTracks = this.player.getVariantTracks() || [];
  107. const audioTrack = audioTracks.find((track) => track.active);
  108. if (!audioTrack) {
  109. return;
  110. }
  111. if (abrEnabled) {
  112. if (audioTrack.bandwidth) {
  113. this.autoQuality.textContent =
  114. this.getQualityLabel_(audioTrack, audioTracks);
  115. } else {
  116. this.autoQuality.textContent = 'Unknown';
  117. }
  118. this.autoQuality.style.display = '';
  119. } else {
  120. this.autoQuality.style.display = 'none';
  121. }
  122. return;
  123. }
  124. const tracks = this.player.getVideoTracks() || [];
  125. const track = tracks.find((track) => track.active);
  126. if (!track) {
  127. if (this.overflowQualityMark) {
  128. const stats = this.player.getStats();
  129. const mark = this.getQualityMark_(stats.width, stats.height);
  130. this.overflowQualityMark.textContent = mark;
  131. this.overflowQualityMark.style.display = mark !== '' ? '' : 'none';
  132. }
  133. return;
  134. }
  135. if (abrEnabled) {
  136. if (track.height && track.width) {
  137. this.autoQuality.textContent = this.getResolutionLabel_(track, tracks);
  138. } else if (track.bandwidth) {
  139. this.autoQuality.textContent =
  140. Math.round(track.bandwidth / 1000) + ' kbits/s';
  141. } else {
  142. this.autoQuality.textContent = 'Unknown';
  143. }
  144. this.autoQuality.style.display = '';
  145. } else {
  146. this.autoQuality.style.display = 'none';
  147. }
  148. /** @type {string} */
  149. const mark = this.getQualityMark_(track.width, track.height);
  150. this.qualityMark.textContent = mark;
  151. this.qualityMark.style.display = mark !== '' ? '' : 'none';
  152. if (this.overflowQualityMark) {
  153. this.overflowQualityMark.textContent = mark;
  154. this.overflowQualityMark.style.display = mark !== '' ? '' : 'none';
  155. }
  156. }
  157. /**
  158. * @param {?number} width
  159. * @param {?number} height
  160. * @return {string}
  161. * @private
  162. */
  163. getQualityMark_(width, height) {
  164. if (!width || !height) {
  165. return '';
  166. }
  167. let trackHeight = height;
  168. let trackWidth = width;
  169. if (trackHeight > trackWidth) {
  170. // Vertical video.
  171. [trackWidth, trackHeight] = [trackHeight, trackWidth];
  172. }
  173. const aspectRatio = trackWidth / trackHeight;
  174. if (aspectRatio > (16 / 9)) {
  175. trackHeight = Math.round(trackWidth * 9 / 16);
  176. }
  177. const qualityMarks = this.controls.getConfig().qualityMarks;
  178. if (trackHeight >= 8640) {
  179. return trackHeight + 'p';
  180. } else if (trackHeight >= 4320) {
  181. return qualityMarks['4320'];
  182. } else if (trackHeight >= 2160) {
  183. return qualityMarks['2160'];
  184. } else if (trackHeight >= 1440) {
  185. return qualityMarks['1440'];
  186. } else if (trackHeight >= 1080) {
  187. return qualityMarks['1080'];
  188. } else if (trackHeight >= 720) {
  189. return qualityMarks['720'];
  190. }
  191. return '';
  192. }
  193. /** @private */
  194. updateSelection_() {
  195. // Remove old shaka-resolutions
  196. // 1. Save the back to menu button
  197. const backButton = shaka.ui.Utils.getFirstDescendantWithClassName(
  198. this.menu, 'shaka-back-to-overflow-button');
  199. // 2. Remove everything
  200. shaka.util.Dom.removeAllChildren(this.menu);
  201. // 3. Add the backTo Menu button back
  202. this.menu.appendChild(backButton);
  203. // Add new ones
  204. let numberOfTracks = 0;
  205. if (this.player.isAudioOnly()) {
  206. numberOfTracks = this.updateAudioOnlySelection_();
  207. } else {
  208. numberOfTracks = this.updateResolutionSelection_();
  209. }
  210. // Add the Auto button
  211. const autoButton = shaka.util.Dom.createButton();
  212. autoButton.classList.add('shaka-enable-abr-button');
  213. this.eventManager.listen(autoButton, 'click', () => {
  214. const config = {abr: {enabled: true}};
  215. this.player.configure(config);
  216. this.updateSelection_();
  217. });
  218. /** @private {!HTMLElement}*/
  219. this.abrOnSpan_ = shaka.util.Dom.createHTMLElement('span');
  220. this.abrOnSpan_.textContent =
  221. this.localization.resolve(shaka.ui.Locales.Ids.AUTO_QUALITY);
  222. autoButton.appendChild(this.abrOnSpan_);
  223. // If abr is enabled reflect it by marking 'Auto' as selected.
  224. if (this.player.getConfiguration().abr.enabled) {
  225. autoButton.ariaSelected = 'true';
  226. autoButton.appendChild(shaka.ui.Utils.checkmarkIcon());
  227. this.abrOnSpan_.classList.add('shaka-chosen-item');
  228. this.currentSelection.textContent =
  229. this.localization.resolve(shaka.ui.Locales.Ids.AUTO_QUALITY);
  230. }
  231. this.button.setAttribute('shaka-status', this.currentSelection.textContent);
  232. this.menu.appendChild(autoButton);
  233. shaka.ui.Utils.focusOnTheChosenItem(this.menu);
  234. this.controls.dispatchEvent(
  235. new shaka.util.FakeEvent('resolutionselectionupdated'));
  236. this.updateLocalizedStrings_();
  237. shaka.ui.Utils.setDisplay(this.button, numberOfTracks > 0);
  238. }
  239. /**
  240. * @return {number}
  241. * @private
  242. */
  243. updateAudioOnlySelection_() {
  244. const TrackLabelFormat = shaka.ui.Overlay.TrackLabelFormat;
  245. /** @type {!Array<shaka.extern.Track>} */
  246. let tracks = [];
  247. // When played with src=, the variant tracks available from
  248. // player.getVariantTracks() represent languages, not resolutions.
  249. if (this.player.getLoadMode() != shaka.Player.LoadMode.SRC_EQUALS &&
  250. !this.player.isRemotePlayback()) {
  251. tracks = this.player.getVariantTracks() || [];
  252. }
  253. // If there is a selected variant track, then we filter out any tracks in
  254. // a different language. Then we use those remaining tracks to display the
  255. // available resolutions.
  256. const selectedTrack = tracks.find((track) => track.active);
  257. if (selectedTrack) {
  258. tracks = tracks.filter((track) => {
  259. if (track.language != selectedTrack.language) {
  260. return false;
  261. }
  262. if (this.controls.getConfig().showAudioChannelCountVariants &&
  263. track.channelsCount && selectedTrack.channelsCount &&
  264. track.channelsCount != selectedTrack.channelsCount) {
  265. return false;
  266. }
  267. const trackLabelFormat = this.controls.getConfig().trackLabelFormat;
  268. if ((trackLabelFormat == TrackLabelFormat.ROLE ||
  269. trackLabelFormat == TrackLabelFormat.LANGUAGE_ROLE)) {
  270. if (JSON.stringify(track.audioRoles) !=
  271. JSON.stringify(selectedTrack.audioRoles)) {
  272. return false;
  273. }
  274. }
  275. if (trackLabelFormat == TrackLabelFormat.LABEL &&
  276. track.label != selectedTrack.label) {
  277. return false;
  278. }
  279. if (!track.bandwidth) {
  280. return false;
  281. }
  282. return true;
  283. });
  284. }
  285. // Remove duplicate entries with the same quality.
  286. tracks = tracks.filter((track, idx) => {
  287. return tracks.findIndex((t) => t.bandwidth == track.bandwidth) == idx;
  288. });
  289. // Sort the tracks by bandwidth.
  290. tracks.sort((t1, t2) => {
  291. goog.asserts.assert(t1.bandwidth != null, 'Null bandwidth');
  292. goog.asserts.assert(t2.bandwidth != null, 'Null bandwidth');
  293. return t2.bandwidth - t1.bandwidth;
  294. });
  295. const abrEnabled = this.player.getConfiguration().abr.enabled;
  296. // Add new ones
  297. for (const track of tracks) {
  298. const button = shaka.util.Dom.createButton();
  299. button.classList.add('explicit-resolution');
  300. this.eventManager.listen(button, 'click',
  301. () => this.onTrackSelected_(track));
  302. const span = shaka.util.Dom.createHTMLElement('span');
  303. if (track.bandwidth) {
  304. span.textContent = this.getQualityLabel_(track, tracks);
  305. } else {
  306. span.textContent = 'Unknown';
  307. }
  308. button.appendChild(span);
  309. if (!abrEnabled && track == selectedTrack) {
  310. // If abr is disabled, mark the selected track's resolution.
  311. button.ariaSelected = 'true';
  312. button.appendChild(shaka.ui.Utils.checkmarkIcon());
  313. span.classList.add('shaka-chosen-item');
  314. this.currentSelection.textContent = span.textContent;
  315. }
  316. this.menu.appendChild(button);
  317. }
  318. return tracks.length;
  319. }
  320. /**
  321. * @return {number}
  322. * @private
  323. */
  324. updateResolutionSelection_() {
  325. /** @type {!Array<shaka.extern.VideoTrack>} */
  326. let tracks = this.player.getVideoTracks() || [];
  327. const selectedTrack = tracks.find((track) => track.active);
  328. tracks = tracks.filter((track, idx) => {
  329. // Keep the first one with the same height and framerate or bandwidth.
  330. const otherIdx = tracks.findIndex((t) => {
  331. let ret = t.height == track.height &&
  332. t.bandwidth == track.bandwidth &&
  333. t.frameRate == track.frameRate &&
  334. t.hdr == track.hdr &&
  335. t.videoLayout == track.videoLayout;
  336. if (ret && this.controls.getConfig().showVideoCodec &&
  337. t.codecs && track.codecs) {
  338. ret = shaka.util.MimeUtils.getNormalizedCodec(t.codecs) ==
  339. shaka.util.MimeUtils.getNormalizedCodec(track.codecs);
  340. }
  341. return ret;
  342. });
  343. return otherIdx == idx;
  344. });
  345. // Sort the tracks by height or bandwidth depending on content type.
  346. tracks.sort((t1, t2) => {
  347. if (t2.height == t1.height || t1.height == null || t2.height == null) {
  348. return t2.bandwidth - t1.bandwidth;
  349. }
  350. return t2.height - t1.height;
  351. });
  352. const abrEnabled = this.player.getConfiguration().abr.enabled;
  353. // Add new ones
  354. for (const track of tracks) {
  355. const button = shaka.util.Dom.createButton();
  356. button.classList.add('explicit-resolution');
  357. this.eventManager.listen(button, 'click',
  358. () => this.onVideoTrackSelected_(track));
  359. const span = shaka.util.Dom.createHTMLElement('span');
  360. if (track.height && track.width) {
  361. span.textContent = this.getResolutionLabel_(track, tracks);
  362. } else if (track.bandwidth) {
  363. span.textContent = Math.round(track.bandwidth / 1000) + ' kbits/s';
  364. } else {
  365. span.textContent = 'Unknown';
  366. }
  367. button.appendChild(span);
  368. const mark = this.getQualityMark_(track.width, track.height);
  369. if (mark !== '') {
  370. const markEl = shaka.util.Dom.createHTMLElement('sup');
  371. markEl.classList.add('shaka-quality-mark');
  372. markEl.textContent = mark;
  373. button.appendChild(markEl);
  374. }
  375. if (!abrEnabled && track == selectedTrack) {
  376. // If abr is disabled, mark the selected track's resolution.
  377. button.ariaSelected = 'true';
  378. button.appendChild(shaka.ui.Utils.checkmarkIcon());
  379. span.classList.add('shaka-chosen-item');
  380. this.currentSelection.textContent = span.textContent;
  381. }
  382. this.menu.appendChild(button);
  383. }
  384. return tracks.length;
  385. }
  386. /**
  387. * @param {!shaka.extern.VideoTrack} track
  388. * @param {!Array<!shaka.extern.VideoTrack>} tracks
  389. * @return {string}
  390. * @private
  391. */
  392. getResolutionLabel_(track, tracks) {
  393. let trackHeight = track.height || 0;
  394. let trackWidth = track.width || 0;
  395. if (trackHeight > trackWidth) {
  396. // Vertical video.
  397. [trackWidth, trackHeight] = [trackHeight, trackWidth];
  398. }
  399. let height = trackHeight;
  400. const aspectRatio = trackWidth / trackHeight;
  401. if (aspectRatio > (16 / 9)) {
  402. height = Math.round(trackWidth * 9 / 16);
  403. }
  404. let text = height + 'p';
  405. const frameRates = new Set();
  406. for (const item of tracks) {
  407. if (item.frameRate) {
  408. frameRates.add(Math.round(item.frameRate));
  409. }
  410. }
  411. if (frameRates.size > 1) {
  412. const frameRate = track.frameRate;
  413. if (frameRate && (frameRate >= 50 || frameRate <= 20)) {
  414. text += Math.round(track.frameRate);
  415. }
  416. }
  417. if (track.hdr == 'PQ' || track.hdr == 'HLG') {
  418. text += ' HDR';
  419. }
  420. if (track.videoLayout == 'CH-STEREO') {
  421. text += ' 3D';
  422. }
  423. const basicResolutionComparison = (firstTrack, secondTrack) => {
  424. return firstTrack != secondTrack &&
  425. firstTrack.height == secondTrack.height &&
  426. firstTrack.hdr == secondTrack.hdr &&
  427. Math.round(firstTrack.frameRate || 0) ==
  428. Math.round(secondTrack.frameRate || 0);
  429. };
  430. const hasDuplicateResolution = tracks.some((otherTrack) => {
  431. return basicResolutionComparison(track, otherTrack);
  432. });
  433. if (hasDuplicateResolution) {
  434. const hasDuplicateBandwidth = tracks.some((otherTrack) => {
  435. return basicResolutionComparison(track, otherTrack) &&
  436. otherTrack.bandwidth == track.bandwidth;
  437. });
  438. if (!hasDuplicateBandwidth) {
  439. const bandwidth = track.bandwidth;
  440. text += ' (' + Math.round(bandwidth / 1000) + ' kbits/s)';
  441. }
  442. if (this.controls.getConfig().showVideoCodec) {
  443. const getVideoCodecName = (codecs) => {
  444. let name = '';
  445. if (codecs) {
  446. const codec = shaka.util.MimeUtils.getNormalizedCodec(codecs);
  447. if (codec.startsWith('dovi-')) {
  448. name = 'Dolby Vision';
  449. } else {
  450. name = codec.toUpperCase();
  451. }
  452. }
  453. return name ? ' ' + name : name;
  454. };
  455. const hasDuplicateCodec = tracks.some((otherTrack) => {
  456. return basicResolutionComparison(track, otherTrack) &&
  457. getVideoCodecName(otherTrack.codecs) !=
  458. getVideoCodecName(track.codecs);
  459. });
  460. if (hasDuplicateCodec) {
  461. text += getVideoCodecName(track.codecs);
  462. }
  463. }
  464. }
  465. return text;
  466. }
  467. /**
  468. * @param {!shaka.extern.Track} track
  469. * @param {!Array<!shaka.extern.Track>} tracks
  470. * @return {string}
  471. * @private
  472. */
  473. getQualityLabel_(track, tracks) {
  474. let text = Math.round(track.bandwidth / 1000) + ' kbits/s';
  475. if (this.controls.getConfig().showAudioCodec) {
  476. const getCodecName = (codecs) => {
  477. let name = '';
  478. if (codecs) {
  479. const codec = shaka.util.MimeUtils.getNormalizedCodec(codecs);
  480. name = codec.toUpperCase();
  481. }
  482. return name ? ' ' + name : name;
  483. };
  484. const hasDuplicateCodec = tracks.some((otherTrack) => {
  485. return getCodecName(otherTrack.codecs) != getCodecName(track.codecs);
  486. });
  487. if (hasDuplicateCodec) {
  488. text += getCodecName(track.codecs);
  489. }
  490. }
  491. return text;
  492. }
  493. /**
  494. * @param {!shaka.extern.VideoTrack} track
  495. * @private
  496. */
  497. onVideoTrackSelected_(track) {
  498. // Disable abr manager before changing tracks.
  499. const config = {abr: {enabled: false}};
  500. this.player.configure(config);
  501. const clearBuffer = this.controls.getConfig().clearBufferOnQualityChange;
  502. this.player.selectVideoTrack(track, clearBuffer);
  503. }
  504. /**
  505. * @param {!shaka.extern.Track} track
  506. * @private
  507. */
  508. onTrackSelected_(track) {
  509. // Disable abr manager before changing tracks.
  510. const config = {abr: {enabled: false}};
  511. this.player.configure(config);
  512. const clearBuffer = this.controls.getConfig().clearBufferOnQualityChange;
  513. this.player.selectVariantTrack(track, clearBuffer);
  514. }
  515. /**
  516. * @private
  517. */
  518. updateLocalizedStrings_() {
  519. const LocIds = shaka.ui.Locales.Ids;
  520. const locId = this.player.isAudioOnly() ?
  521. LocIds.QUALITY : LocIds.RESOLUTION;
  522. this.button.ariaLabel = this.localization.resolve(locId);
  523. this.backButton.ariaLabel = this.localization.resolve(locId);
  524. this.backSpan.textContent =
  525. this.localization.resolve(locId);
  526. this.nameSpan.textContent =
  527. this.localization.resolve(locId);
  528. this.abrOnSpan_.textContent =
  529. this.localization.resolve(LocIds.AUTO_QUALITY);
  530. if (this.player.getConfiguration().abr.enabled) {
  531. this.currentSelection.textContent =
  532. this.localization.resolve(shaka.ui.Locales.Ids.AUTO_QUALITY);
  533. }
  534. }
  535. };
  536. /**
  537. * @implements {shaka.extern.IUIElement.Factory}
  538. * @final
  539. */
  540. shaka.ui.ResolutionSelection.Factory = class {
  541. /** @override */
  542. create(rootElement, controls) {
  543. return new shaka.ui.ResolutionSelection(rootElement, controls);
  544. }
  545. };
  546. shaka.ui.OverflowMenu.registerElement(
  547. 'quality', new shaka.ui.ResolutionSelection.Factory());
  548. shaka.ui.Controls.registerElement(
  549. 'quality', new shaka.ui.ResolutionSelection.Factory());