Source: ui/seek_bar.js

  1. /*! @license
  2. * Shaka Player
  3. * Copyright 2016 Google LLC
  4. * SPDX-License-Identifier: Apache-2.0
  5. */
  6. goog.provide('shaka.ui.SeekBar');
  7. goog.require('shaka.ads.Utils');
  8. goog.require('shaka.net.NetworkingEngine');
  9. goog.require('shaka.ui.Constants');
  10. goog.require('shaka.ui.Locales');
  11. goog.require('shaka.ui.Localization');
  12. goog.require('shaka.ui.RangeElement');
  13. goog.require('shaka.ui.Utils');
  14. goog.require('shaka.util.Dom');
  15. goog.require('shaka.util.Error');
  16. goog.require('shaka.util.Mp4Parser');
  17. goog.require('shaka.util.Networking');
  18. goog.require('shaka.util.Timer');
  19. goog.requireType('shaka.ui.Controls');
  20. /**
  21. * @extends {shaka.ui.RangeElement}
  22. * @implements {shaka.extern.IUISeekBar}
  23. * @final
  24. * @export
  25. */
  26. shaka.ui.SeekBar = class extends shaka.ui.RangeElement {
  27. /**
  28. * @param {!HTMLElement} parent
  29. * @param {!shaka.ui.Controls} controls
  30. */
  31. constructor(parent, controls) {
  32. super(parent, controls,
  33. [
  34. 'shaka-seek-bar-container',
  35. ],
  36. [
  37. 'shaka-seek-bar',
  38. 'shaka-no-propagation',
  39. 'shaka-show-controls-on-mouse-over',
  40. ]);
  41. /** @private {!HTMLElement} */
  42. this.adMarkerContainer_ = shaka.util.Dom.createHTMLElement('div');
  43. this.adMarkerContainer_.classList.add('shaka-ad-markers');
  44. // Insert the ad markers container as a first child for proper
  45. // positioning.
  46. this.container.insertBefore(
  47. this.adMarkerContainer_, this.container.childNodes[0]);
  48. /** @private {!shaka.extern.UIConfiguration} */
  49. this.config_ = this.controls.getConfig();
  50. /**
  51. * This timer is used to introduce a delay between the user scrubbing across
  52. * the seek bar and the seek being sent to the player.
  53. *
  54. * @private {shaka.util.Timer}
  55. */
  56. this.seekTimer_ = new shaka.util.Timer(() => {
  57. let newCurrentTime = this.getValue();
  58. if (!this.player.isLive()) {
  59. if (newCurrentTime == this.video.duration) {
  60. newCurrentTime -= 0.001;
  61. }
  62. }
  63. this.video.currentTime = newCurrentTime;
  64. });
  65. /**
  66. * The timer is activated for live content and checks if
  67. * new ad breaks need to be marked in the current seek range.
  68. *
  69. * @private {shaka.util.Timer}
  70. */
  71. this.adBreaksTimer_ = new shaka.util.Timer(() => {
  72. this.markAdBreaks_();
  73. });
  74. /**
  75. * When user is scrubbing the seek bar - we should pause the video - see
  76. * https://github.com/google/shaka-player/pull/2898#issuecomment-705229215
  77. * but will conditionally pause or play the video after scrubbing
  78. * depending on its previous state
  79. *
  80. * @private {boolean}
  81. */
  82. this.wasPlaying_ = false;
  83. /** @private {!HTMLElement} */
  84. this.thumbnailContainer_ = shaka.util.Dom.createHTMLElement('div');
  85. this.thumbnailContainer_.id = 'shaka-player-ui-thumbnail-container';
  86. /** @private {!HTMLImageElement} */
  87. this.thumbnailImage_ = /** @type {!HTMLImageElement} */ (
  88. shaka.util.Dom.createHTMLElement('img'));
  89. this.thumbnailImage_.id = 'shaka-player-ui-thumbnail-image';
  90. this.thumbnailImage_.draggable = false;
  91. /** @private {!HTMLElement} */
  92. this.thumbnailTime_ = shaka.util.Dom.createHTMLElement('div');
  93. this.thumbnailTime_.id = 'shaka-player-ui-thumbnail-time';
  94. this.thumbnailContainer_.appendChild(this.thumbnailImage_);
  95. this.thumbnailContainer_.appendChild(this.thumbnailTime_);
  96. this.container.appendChild(this.thumbnailContainer_);
  97. this.timeContainer_ = shaka.util.Dom.createHTMLElement('div');
  98. this.timeContainer_.id = 'shaka-player-ui-time-container';
  99. this.container.appendChild(this.timeContainer_);
  100. /**
  101. * @private {?shaka.extern.Thumbnail}
  102. */
  103. this.lastThumbnail_ = null;
  104. /**
  105. * @private {?shaka.net.NetworkingEngine.PendingRequest}
  106. */
  107. this.lastThumbnailPendingRequest_ = null;
  108. /**
  109. * True if the bar is moving due to touchscreen or keyboard events.
  110. *
  111. * @private {boolean}
  112. */
  113. this.isMoving_ = false;
  114. /**
  115. * The timer is activated to hide the thumbnail.
  116. *
  117. * @private {shaka.util.Timer}
  118. */
  119. this.hideThumbnailTimer_ = new shaka.util.Timer(() => {
  120. this.hideThumbnail_();
  121. });
  122. /** @private {!Array.<!shaka.extern.AdCuePoint>} */
  123. this.adCuePoints_ = [];
  124. this.eventManager.listen(this.localization,
  125. shaka.ui.Localization.LOCALE_UPDATED,
  126. () => this.updateAriaLabel_());
  127. this.eventManager.listen(this.localization,
  128. shaka.ui.Localization.LOCALE_CHANGED,
  129. () => this.updateAriaLabel_());
  130. this.eventManager.listen(
  131. this.adManager, shaka.ads.Utils.AD_STARTED, () => {
  132. if (!this.shouldBeDisplayed_()) {
  133. shaka.ui.Utils.setDisplay(this.container, false);
  134. }
  135. });
  136. this.eventManager.listen(
  137. this.adManager, shaka.ads.Utils.AD_STOPPED, () => {
  138. if (this.shouldBeDisplayed_()) {
  139. shaka.ui.Utils.setDisplay(this.container, true);
  140. }
  141. });
  142. this.eventManager.listen(
  143. this.adManager, shaka.ads.Utils.CUEPOINTS_CHANGED, (e) => {
  144. this.adCuePoints_ = (e)['cuepoints'];
  145. this.onAdCuePointsChanged_();
  146. });
  147. this.eventManager.listen(
  148. this.player, 'unloading', () => {
  149. this.adCuePoints_ = [];
  150. this.onAdCuePointsChanged_();
  151. if (this.lastThumbnailPendingRequest_) {
  152. this.lastThumbnailPendingRequest_.abort();
  153. this.lastThumbnailPendingRequest_ = null;
  154. }
  155. this.lastThumbnail_ = null;
  156. this.hideThumbnail_();
  157. this.hideTime_();
  158. });
  159. this.eventManager.listen(this.bar, 'mousemove', (event) => {
  160. const rect = this.bar.getBoundingClientRect();
  161. const min = parseFloat(this.bar.min);
  162. const max = parseFloat(this.bar.max);
  163. // Pixels from the left of the range element
  164. const mousePosition = event.clientX - rect.left;
  165. // Pixels per unit value of the range element.
  166. const scale = (max - min) / rect.width;
  167. // Mouse position in units, which may be outside the allowed range.
  168. const value = Math.round(min + scale * mousePosition);
  169. if (!this.player.getImageTracks().length) {
  170. this.hideThumbnail_();
  171. this.showTime_(mousePosition, value);
  172. return;
  173. }
  174. this.hideTime_();
  175. this.showThumbnail_(mousePosition, value);
  176. });
  177. this.eventManager.listen(this.container, 'mouseleave', () => {
  178. this.hideTime_();
  179. this.hideThumbnailTimer_.stop();
  180. this.hideThumbnailTimer_.tickAfter(/* seconds= */ 0.25);
  181. });
  182. // Initialize seek state and label.
  183. this.setValue(this.video.currentTime);
  184. this.update();
  185. this.updateAriaLabel_();
  186. if (this.ad) {
  187. // There was already an ad.
  188. shaka.ui.Utils.setDisplay(this.container, false);
  189. }
  190. }
  191. /** @override */
  192. release() {
  193. if (this.seekTimer_) {
  194. this.seekTimer_.stop();
  195. this.seekTimer_ = null;
  196. this.adBreaksTimer_.stop();
  197. this.adBreaksTimer_ = null;
  198. }
  199. super.release();
  200. }
  201. /**
  202. * Called by the base class when user interaction with the input element
  203. * begins.
  204. *
  205. * @override
  206. */
  207. onChangeStart() {
  208. this.wasPlaying_ = !this.video.paused;
  209. this.controls.setSeeking(true);
  210. this.video.pause();
  211. this.hideThumbnailTimer_.stop();
  212. this.isMoving_ = true;
  213. }
  214. /**
  215. * Update the video element's state to match the input element's state.
  216. * Called by the base class when the input element changes.
  217. *
  218. * @override
  219. */
  220. onChange() {
  221. if (!this.video.duration) {
  222. // Can't seek yet. Ignore.
  223. return;
  224. }
  225. // Update the UI right away.
  226. this.update();
  227. // We want to wait until the user has stopped moving the seek bar for a
  228. // little bit to reduce the number of times we ask the player to seek.
  229. //
  230. // To do this, we will start a timer that will fire in a little bit, but if
  231. // we see another seek bar change, we will cancel that timer and re-start
  232. // it.
  233. //
  234. // Calling |start| on an already pending timer will cancel the old request
  235. // and start the new one.
  236. this.seekTimer_.tickAfter(/* seconds= */ 0.125);
  237. if (this.player.getImageTracks().length) {
  238. const min = parseFloat(this.bar.min);
  239. const max = parseFloat(this.bar.max);
  240. const rect = this.bar.getBoundingClientRect();
  241. const value = Math.round(this.getValue());
  242. const scale = (max - min) / rect.width;
  243. const position = (value - min) / scale;
  244. this.showThumbnail_(position, value);
  245. } else {
  246. this.hideThumbnail_();
  247. }
  248. }
  249. /**
  250. * Called by the base class when user interaction with the input element
  251. * ends.
  252. *
  253. * @override
  254. */
  255. onChangeEnd() {
  256. // They just let go of the seek bar, so cancel the timer and manually
  257. // call the event so that we can respond immediately.
  258. this.seekTimer_.tickNow();
  259. this.controls.setSeeking(false);
  260. if (this.wasPlaying_) {
  261. this.video.play();
  262. }
  263. if (this.isMoving_) {
  264. this.isMoving_ = false;
  265. this.hideThumbnailTimer_.stop();
  266. this.hideThumbnailTimer_.tickAfter(/* seconds= */ 0.25);
  267. }
  268. }
  269. /**
  270. * @override
  271. */
  272. isShowing() {
  273. // It is showing by default, so it is hidden if shaka-hidden is in the list.
  274. return !this.container.classList.contains('shaka-hidden');
  275. }
  276. /**
  277. * @override
  278. */
  279. update() {
  280. const colors = this.config_.seekBarColors;
  281. const currentTime = this.getValue();
  282. const bufferedLength = this.video.buffered.length;
  283. const bufferedStart = bufferedLength ? this.video.buffered.start(0) : 0;
  284. const bufferedEnd =
  285. bufferedLength ? this.video.buffered.end(bufferedLength - 1) : 0;
  286. const seekRange = this.player.seekRange();
  287. const seekRangeSize = seekRange.end - seekRange.start;
  288. this.setRange(seekRange.start, seekRange.end);
  289. if (!this.shouldBeDisplayed_()) {
  290. shaka.ui.Utils.setDisplay(this.container, false);
  291. } else {
  292. shaka.ui.Utils.setDisplay(this.container, true);
  293. if (bufferedLength == 0) {
  294. this.container.style.background = colors.base;
  295. } else {
  296. const clampedBufferStart = Math.max(bufferedStart, seekRange.start);
  297. const clampedBufferEnd = Math.min(bufferedEnd, seekRange.end);
  298. const clampedCurrentTime = Math.min(
  299. Math.max(currentTime, seekRange.start),
  300. seekRange.end);
  301. const bufferStartDistance = clampedBufferStart - seekRange.start;
  302. const bufferEndDistance = clampedBufferEnd - seekRange.start;
  303. const playheadDistance = clampedCurrentTime - seekRange.start;
  304. // NOTE: the fallback to zero eliminates NaN.
  305. const bufferStartFraction = (bufferStartDistance / seekRangeSize) || 0;
  306. const bufferEndFraction = (bufferEndDistance / seekRangeSize) || 0;
  307. const playheadFraction = (playheadDistance / seekRangeSize) || 0;
  308. const unbufferedColor =
  309. this.config_.showUnbufferedStart ? colors.base : colors.played;
  310. const gradient = [
  311. 'to right',
  312. this.makeColor_(unbufferedColor, bufferStartFraction),
  313. this.makeColor_(colors.played, bufferStartFraction),
  314. this.makeColor_(colors.played, playheadFraction),
  315. this.makeColor_(colors.buffered, playheadFraction),
  316. this.makeColor_(colors.buffered, bufferEndFraction),
  317. this.makeColor_(colors.base, bufferEndFraction),
  318. ];
  319. this.container.style.background =
  320. 'linear-gradient(' + gradient.join(',') + ')';
  321. }
  322. }
  323. }
  324. /**
  325. * @private
  326. */
  327. markAdBreaks_() {
  328. if (!this.adCuePoints_.length) {
  329. this.adMarkerContainer_.style.background = 'transparent';
  330. this.adBreaksTimer_.stop();
  331. return;
  332. }
  333. const seekRange = this.player.seekRange();
  334. const seekRangeSize = seekRange.end - seekRange.start;
  335. const gradient = ['to right'];
  336. let pointsAsFractions = [];
  337. const adBreakColor = this.config_.seekBarColors.adBreaks;
  338. let postRollAd = false;
  339. for (const point of this.adCuePoints_) {
  340. // Post-roll ads are marked as starting at -1 in CS IMA ads.
  341. if (point.start == -1 && !point.end) {
  342. postRollAd = true;
  343. continue;
  344. }
  345. // Filter point within the seek range. For points with no endpoint
  346. // (client side ads) check that the start point is within range.
  347. if ((!point.end && point.start >= seekRange.start) ||
  348. (typeof point.end == 'number' && point.end > seekRange.start)) {
  349. const startDist =
  350. Math.max(point.start, seekRange.start) - seekRange.start;
  351. const startFrac = (startDist / seekRangeSize) || 0;
  352. // For points with no endpoint assume a 1% length: not too much,
  353. // but enough to be visible on the timeline.
  354. let endFrac = startFrac + 0.01;
  355. if (point.end) {
  356. const endDist = point.end - seekRange.start;
  357. endFrac = (endDist / seekRangeSize) || 0;
  358. }
  359. pointsAsFractions.push({
  360. start: startFrac,
  361. end: endFrac,
  362. });
  363. }
  364. }
  365. pointsAsFractions = pointsAsFractions.sort((a, b) => {
  366. return a.start - b.start;
  367. });
  368. for (const point of pointsAsFractions) {
  369. gradient.push(this.makeColor_('transparent', point.start));
  370. gradient.push(this.makeColor_(adBreakColor, point.start));
  371. gradient.push(this.makeColor_(adBreakColor, point.end));
  372. gradient.push(this.makeColor_('transparent', point.end));
  373. }
  374. if (postRollAd) {
  375. gradient.push(this.makeColor_('transparent', 0.99));
  376. gradient.push(this.makeColor_(adBreakColor, 0.99));
  377. }
  378. this.adMarkerContainer_.style.background =
  379. 'linear-gradient(' + gradient.join(',') + ')';
  380. }
  381. /**
  382. * @param {string} color
  383. * @param {number} fract
  384. * @return {string}
  385. * @private
  386. */
  387. makeColor_(color, fract) {
  388. return color + ' ' + (fract * 100) + '%';
  389. }
  390. /**
  391. * @private
  392. */
  393. onAdCuePointsChanged_() {
  394. const action = () => {
  395. this.markAdBreaks_();
  396. const seekRange = this.player.seekRange();
  397. const seekRangeSize = seekRange.end - seekRange.start;
  398. const minSeekBarWindow =
  399. shaka.ui.Constants.MIN_SEEK_WINDOW_TO_SHOW_SEEKBAR;
  400. // Seek range keeps changing for live content and some of the known
  401. // ad breaks might not be in the seek range now, but get into
  402. // it later.
  403. // If we have a LIVE seekable content, keep checking for ad breaks
  404. // every second.
  405. if (this.player.isLive() && seekRangeSize > minSeekBarWindow) {
  406. this.adBreaksTimer_.tickEvery(/* seconds= */ 0.25);
  407. }
  408. };
  409. if (this.player.isFullyLoaded()) {
  410. action();
  411. } else {
  412. this.eventManager.listenOnce(this.player, 'loaded', action);
  413. }
  414. }
  415. /**
  416. * @return {boolean}
  417. * @private
  418. */
  419. shouldBeDisplayed_() {
  420. // The seek bar should be hidden when the seek window's too small or
  421. // there's an ad playing.
  422. const seekRange = this.player.seekRange();
  423. const seekRangeSize = seekRange.end - seekRange.start;
  424. if (this.player.isLive() &&
  425. (seekRangeSize < shaka.ui.Constants.MIN_SEEK_WINDOW_TO_SHOW_SEEKBAR ||
  426. !isFinite(seekRangeSize))) {
  427. return false;
  428. }
  429. return this.ad == null || !this.ad.isLinear();
  430. }
  431. /** @private */
  432. updateAriaLabel_() {
  433. this.bar.ariaLabel = this.localization.resolve(shaka.ui.Locales.Ids.SEEK);
  434. }
  435. /** @private */
  436. showTime_(pixelPosition, value) {
  437. const offsetTop = -10;
  438. const width = this.timeContainer_.clientWidth;
  439. const height = 20;
  440. this.timeContainer_.style.width = 'auto';
  441. this.timeContainer_.style.height = height + 'px';
  442. this.timeContainer_.style.top = -(height - offsetTop) + 'px';
  443. const leftPosition = Math.min(this.bar.offsetWidth - width,
  444. Math.max(0, pixelPosition - (width / 2)));
  445. this.timeContainer_.style.left = leftPosition + 'px';
  446. this.timeContainer_.style.visibility = 'visible';
  447. const seekRange = this.player.seekRange();
  448. if (this.player.isLive()) {
  449. const totalSeconds = seekRange.end - value;
  450. if (totalSeconds < 1) {
  451. this.timeContainer_.textContent =
  452. this.localization.resolve(shaka.ui.Locales.Ids.LIVE);
  453. } else {
  454. this.timeContainer_.textContent =
  455. '-' + this.timeFormatter_(totalSeconds);
  456. }
  457. } else {
  458. const totalSeconds = value - seekRange.start;
  459. this.timeContainer_.textContent = this.timeFormatter_(totalSeconds);
  460. }
  461. }
  462. /**
  463. * @private
  464. */
  465. async showThumbnail_(pixelPosition, value) {
  466. const thumbnailTrack = this.getThumbnailTrack_();
  467. if (!thumbnailTrack) {
  468. this.hideThumbnail_();
  469. return;
  470. }
  471. if (value < 0) {
  472. value = 0;
  473. }
  474. const seekRange = this.player.seekRange();
  475. const playerValue = Math.max(Math.ceil(seekRange.start),
  476. Math.min(Math.floor(seekRange.end), value));
  477. const thumbnail =
  478. await this.player.getThumbnails(thumbnailTrack.id, playerValue);
  479. if (!thumbnail || !thumbnail.uris.length) {
  480. this.hideThumbnail_();
  481. return;
  482. }
  483. if (this.player.isLive()) {
  484. const totalSeconds = seekRange.end - value;
  485. if (totalSeconds < 1) {
  486. this.thumbnailTime_.textContent =
  487. this.localization.resolve(shaka.ui.Locales.Ids.LIVE);
  488. } else {
  489. this.thumbnailTime_.textContent =
  490. '-' + this.timeFormatter_(totalSeconds);
  491. }
  492. } else {
  493. this.thumbnailTime_.textContent = this.timeFormatter_(value);
  494. }
  495. const offsetTop = -10;
  496. const width = this.thumbnailContainer_.clientWidth;
  497. let height = Math.floor(width * 9 / 16);
  498. this.thumbnailContainer_.style.height = height + 'px';
  499. this.thumbnailContainer_.style.top = -(height - offsetTop) + 'px';
  500. const leftPosition = Math.min(this.bar.offsetWidth - width,
  501. Math.max(0, pixelPosition - (width / 2)));
  502. this.thumbnailContainer_.style.left = leftPosition + 'px';
  503. this.thumbnailContainer_.style.visibility = 'visible';
  504. let uri = thumbnail.uris[0].split('#xywh=')[0];
  505. if (!this.lastThumbnail_ ||
  506. uri !== this.lastThumbnail_.uris[0].split('#xywh=')[0] ||
  507. thumbnail.segment.getStartByte() !=
  508. this.lastThumbnail_.segment.getStartByte() ||
  509. thumbnail.segment.getEndByte() !=
  510. this.lastThumbnail_.segment.getEndByte()) {
  511. this.lastThumbnail_ = thumbnail;
  512. if (this.lastThumbnailPendingRequest_) {
  513. this.lastThumbnailPendingRequest_.abort();
  514. this.lastThumbnailPendingRequest_ = null;
  515. }
  516. if (thumbnailTrack.codecs == 'mjpg' || uri.startsWith('offline:')) {
  517. this.thumbnailImage_.src = shaka.ui.SeekBar.Transparent_Image_;
  518. try {
  519. const requestType = shaka.net.NetworkingEngine.RequestType.SEGMENT;
  520. const type =
  521. shaka.net.NetworkingEngine.AdvancedRequestType.MEDIA_SEGMENT;
  522. const request = shaka.util.Networking.createSegmentRequest(
  523. thumbnail.segment.getUris(),
  524. thumbnail.segment.getStartByte(),
  525. thumbnail.segment.getEndByte(),
  526. this.player.getConfiguration().streaming.retryParameters);
  527. this.lastThumbnailPendingRequest_ = this.player.getNetworkingEngine()
  528. .request(requestType, request, {type});
  529. const response = await this.lastThumbnailPendingRequest_.promise;
  530. this.lastThumbnailPendingRequest_ = null;
  531. if (thumbnailTrack.codecs == 'mjpg') {
  532. const parser = new shaka.util.Mp4Parser()
  533. .box('mdat', shaka.util.Mp4Parser.allData((data) => {
  534. const blob = new Blob([data], {type: 'image/jpeg'});
  535. uri = URL.createObjectURL(blob);
  536. }));
  537. parser.parse(response.data, /* partialOkay= */ false);
  538. } else {
  539. const mimeType = thumbnailTrack.mimeType || 'image/jpeg';
  540. const blob = new Blob([response.data], {type: mimeType});
  541. uri = URL.createObjectURL(blob);
  542. }
  543. } catch (error) {
  544. if (error.code == shaka.util.Error.Code.OPERATION_ABORTED) {
  545. return;
  546. }
  547. throw error;
  548. }
  549. }
  550. try {
  551. this.thumbnailContainer_.removeChild(this.thumbnailImage_);
  552. } catch (e) {
  553. // The image is not a child
  554. }
  555. this.thumbnailImage_ = /** @type {!HTMLImageElement} */ (
  556. shaka.util.Dom.createHTMLElement('img'));
  557. this.thumbnailImage_.id = 'shaka-player-ui-thumbnail-image';
  558. this.thumbnailImage_.draggable = false;
  559. this.thumbnailImage_.src = uri;
  560. this.thumbnailImage_.onload = () => {
  561. if (uri.startsWith('blob:')) {
  562. URL.revokeObjectURL(uri);
  563. }
  564. };
  565. this.thumbnailContainer_.insertBefore(this.thumbnailImage_,
  566. this.thumbnailContainer_.firstChild);
  567. }
  568. const scale = width / thumbnail.width;
  569. if (thumbnail.imageHeight) {
  570. this.thumbnailImage_.height = thumbnail.imageHeight;
  571. } else if (!thumbnail.sprite) {
  572. this.thumbnailImage_.style.height = '100%';
  573. this.thumbnailImage_.style.objectFit = 'contain';
  574. }
  575. if (thumbnail.imageWidth) {
  576. this.thumbnailImage_.width = thumbnail.imageWidth;
  577. } else if (!thumbnail.sprite) {
  578. this.thumbnailImage_.style.width = '100%';
  579. this.thumbnailImage_.style.objectFit = 'contain';
  580. }
  581. this.thumbnailImage_.style.left = '-' + scale * thumbnail.positionX + 'px';
  582. this.thumbnailImage_.style.top = '-' + scale * thumbnail.positionY + 'px';
  583. this.thumbnailImage_.style.transform = 'scale(' + scale + ')';
  584. this.thumbnailImage_.style.transformOrigin = 'left top';
  585. // Update container height and top
  586. height = Math.floor(width * thumbnail.height / thumbnail.width);
  587. this.thumbnailContainer_.style.height = height + 'px';
  588. this.thumbnailContainer_.style.top = -(height - offsetTop) + 'px';
  589. }
  590. /**
  591. * @return {?shaka.extern.Track} The thumbnail track.
  592. * @private
  593. */
  594. getThumbnailTrack_() {
  595. const imageTracks = this.player.getImageTracks();
  596. if (!imageTracks.length) {
  597. return null;
  598. }
  599. const mimeTypesPreference = [
  600. 'image/avif',
  601. 'image/webp',
  602. 'image/jpeg',
  603. 'image/png',
  604. 'image/svg+xml',
  605. ];
  606. for (const mimeType of mimeTypesPreference) {
  607. const estimatedBandwidth = this.player.getStats().estimatedBandwidth;
  608. const bestOptions = imageTracks.filter((track) => {
  609. return track.mimeType.toLowerCase() === mimeType &&
  610. track.bandwidth < estimatedBandwidth * 0.01;
  611. }).sort((a, b) => {
  612. return b.bandwidth - a.bandwidth;
  613. });
  614. if (bestOptions && bestOptions.length) {
  615. return bestOptions[0];
  616. }
  617. }
  618. const mjpgTrack = imageTracks.find((track) => {
  619. return track.mimeType == 'application/mp4' && track.codecs == 'mjpg';
  620. });
  621. return mjpgTrack || imageTracks[0];
  622. }
  623. /**
  624. * @private
  625. */
  626. hideThumbnail_() {
  627. this.thumbnailContainer_.style.visibility = 'hidden';
  628. this.thumbnailTime_.textContent = '';
  629. }
  630. /**
  631. * @private
  632. */
  633. hideTime_() {
  634. this.timeContainer_.style.visibility = 'hidden';
  635. }
  636. /**
  637. * @param {number} totalSeconds
  638. * @private
  639. */
  640. timeFormatter_(totalSeconds) {
  641. const secondsNumber = Math.round(totalSeconds);
  642. const hours = Math.floor(secondsNumber / 3600);
  643. let minutes = Math.floor((secondsNumber - (hours * 3600)) / 60);
  644. let seconds = secondsNumber - (hours * 3600) - (minutes * 60);
  645. if (seconds < 10) {
  646. seconds = '0' + seconds;
  647. }
  648. if (hours > 0) {
  649. if (minutes < 10) {
  650. minutes = '0' + minutes;
  651. }
  652. return hours + ':' + minutes + ':' + seconds;
  653. } else {
  654. return minutes + ':' + seconds;
  655. }
  656. }
  657. };
  658. /**
  659. * @const {string}
  660. * @private
  661. */
  662. shaka.ui.SeekBar.Transparent_Image_ =
  663. 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg"/>';
  664. /**
  665. * @implements {shaka.extern.IUISeekBar.Factory}
  666. * @export
  667. */
  668. shaka.ui.SeekBar.Factory = class {
  669. /**
  670. * Creates a shaka.ui.SeekBar. Use this factory to register the default
  671. * SeekBar when needed
  672. *
  673. * @override
  674. */
  675. create(rootElement, controls) {
  676. return new shaka.ui.SeekBar(rootElement, controls);
  677. }
  678. };