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.AdManager');
  8. goog.require('shaka.ui.Constants');
  9. goog.require('shaka.ui.Locales');
  10. goog.require('shaka.ui.Localization');
  11. goog.require('shaka.ui.RangeElement');
  12. goog.require('shaka.ui.Utils');
  13. goog.require('shaka.util.Dom');
  14. goog.require('shaka.util.Timer');
  15. goog.requireType('shaka.ui.Controls');
  16. /**
  17. * @extends {shaka.ui.RangeElement}
  18. * @implements {shaka.extern.IUISeekBar}
  19. * @final
  20. * @export
  21. */
  22. shaka.ui.SeekBar = class extends shaka.ui.RangeElement {
  23. /**
  24. * @param {!HTMLElement} parent
  25. * @param {!shaka.ui.Controls} controls
  26. */
  27. constructor(parent, controls) {
  28. super(parent, controls,
  29. [
  30. 'shaka-seek-bar-container',
  31. ],
  32. [
  33. 'shaka-seek-bar',
  34. 'shaka-no-propagation',
  35. 'shaka-show-controls-on-mouse-over',
  36. ]);
  37. /** @private {!HTMLElement} */
  38. this.adMarkerContainer_ = shaka.util.Dom.createHTMLElement('div');
  39. this.adMarkerContainer_.classList.add('shaka-ad-markers');
  40. // Insert the ad markers container as a first child for proper
  41. // positioning.
  42. this.container.insertBefore(
  43. this.adMarkerContainer_, this.container.childNodes[0]);
  44. /** @private {!shaka.extern.UIConfiguration} */
  45. this.config_ = this.controls.getConfig();
  46. /**
  47. * This timer is used to introduce a delay between the user scrubbing across
  48. * the seek bar and the seek being sent to the player.
  49. *
  50. * @private {shaka.util.Timer}
  51. */
  52. this.seekTimer_ = new shaka.util.Timer(() => {
  53. this.video.currentTime = this.getValue();
  54. });
  55. /**
  56. * The timer is activated for live content and checks if
  57. * new ad breaks need to be marked in the current seek range.
  58. *
  59. * @private {shaka.util.Timer}
  60. */
  61. this.adBreaksTimer_ = new shaka.util.Timer(() => {
  62. this.markAdBreaks_();
  63. });
  64. /**
  65. * When user is scrubbing the seek bar - we should pause the video - see
  66. * https://github.com/google/shaka-player/pull/2898#issuecomment-705229215
  67. * but will conditionally pause or play the video after scrubbing
  68. * depending on its previous state
  69. *
  70. * @private {boolean}
  71. */
  72. this.wasPlaying_ = false;
  73. /** @private {!Array.<!shaka.extern.AdCuePoint>} */
  74. this.adCuePoints_ = [];
  75. this.eventManager.listen(this.localization,
  76. shaka.ui.Localization.LOCALE_UPDATED,
  77. () => this.updateAriaLabel_());
  78. this.eventManager.listen(this.localization,
  79. shaka.ui.Localization.LOCALE_CHANGED,
  80. () => this.updateAriaLabel_());
  81. this.eventManager.listen(
  82. this.adManager, shaka.ads.AdManager.AD_STARTED, () => {
  83. shaka.ui.Utils.setDisplay(this.container, false);
  84. });
  85. this.eventManager.listen(
  86. this.adManager, shaka.ads.AdManager.AD_STOPPED, () => {
  87. if (this.shouldBeDisplayed_()) {
  88. shaka.ui.Utils.setDisplay(this.container, true);
  89. }
  90. });
  91. this.eventManager.listen(
  92. this.adManager, shaka.ads.AdManager.CUEPOINTS_CHANGED, (e) => {
  93. this.adCuePoints_ = (e)['cuepoints'];
  94. this.onAdCuePointsChanged_();
  95. });
  96. this.eventManager.listen(
  97. this.player, 'unloading', () => {
  98. this.adCuePoints_ = [];
  99. this.onAdCuePointsChanged_();
  100. });
  101. // Initialize seek state and label.
  102. this.setValue(this.video.currentTime);
  103. this.update();
  104. this.updateAriaLabel_();
  105. if (this.ad) {
  106. // There was already an ad.
  107. shaka.ui.Utils.setDisplay(this.container, false);
  108. }
  109. }
  110. /** @override */
  111. release() {
  112. if (this.seekTimer_) {
  113. this.seekTimer_.stop();
  114. this.seekTimer_ = null;
  115. this.adBreaksTimer_.stop();
  116. this.adBreaksTimer_ = null;
  117. }
  118. super.release();
  119. }
  120. /**
  121. * Called by the base class when user interaction with the input element
  122. * begins.
  123. *
  124. * @override
  125. */
  126. onChangeStart() {
  127. this.wasPlaying_ = !this.video.paused;
  128. this.controls.setSeeking(true);
  129. this.video.pause();
  130. }
  131. /**
  132. * Update the video element's state to match the input element's state.
  133. * Called by the base class when the input element changes.
  134. *
  135. * @override
  136. */
  137. onChange() {
  138. if (!this.video.duration) {
  139. // Can't seek yet. Ignore.
  140. return;
  141. }
  142. // Update the UI right away.
  143. this.update();
  144. // We want to wait until the user has stopped moving the seek bar for a
  145. // little bit to reduce the number of times we ask the player to seek.
  146. //
  147. // To do this, we will start a timer that will fire in a little bit, but if
  148. // we see another seek bar change, we will cancel that timer and re-start
  149. // it.
  150. //
  151. // Calling |start| on an already pending timer will cancel the old request
  152. // and start the new one.
  153. this.seekTimer_.tickAfter(/* seconds= */ 0.125);
  154. }
  155. /**
  156. * Called by the base class when user interaction with the input element
  157. * ends.
  158. *
  159. * @override
  160. */
  161. onChangeEnd() {
  162. // They just let go of the seek bar, so cancel the timer and manually
  163. // call the event so that we can respond immediately.
  164. this.seekTimer_.tickNow();
  165. this.controls.setSeeking(false);
  166. if (this.wasPlaying_) {
  167. this.video.play();
  168. }
  169. }
  170. /**
  171. * @override
  172. */
  173. isShowing() {
  174. // It is showing by default, so it is hidden if shaka-hidden is in the list.
  175. return !this.container.classList.contains('shaka-hidden');
  176. }
  177. /**
  178. * @override
  179. */
  180. update() {
  181. const colors = this.config_.seekBarColors;
  182. const currentTime = this.getValue();
  183. const bufferedLength = this.video.buffered.length;
  184. const bufferedStart = bufferedLength ? this.video.buffered.start(0) : 0;
  185. const bufferedEnd =
  186. bufferedLength ? this.video.buffered.end(bufferedLength - 1) : 0;
  187. const seekRange = this.player.seekRange();
  188. const seekRangeSize = seekRange.end - seekRange.start;
  189. this.setRange(seekRange.start, seekRange.end);
  190. if (!this.shouldBeDisplayed_()) {
  191. shaka.ui.Utils.setDisplay(this.container, false);
  192. } else {
  193. shaka.ui.Utils.setDisplay(this.container, true);
  194. if (bufferedLength == 0) {
  195. this.container.style.background = colors.base;
  196. } else {
  197. const clampedBufferStart = Math.max(bufferedStart, seekRange.start);
  198. const clampedBufferEnd = Math.min(bufferedEnd, seekRange.end);
  199. const clampedCurrentTime = Math.min(
  200. Math.max(currentTime, seekRange.start),
  201. seekRange.end);
  202. const bufferStartDistance = clampedBufferStart - seekRange.start;
  203. const bufferEndDistance = clampedBufferEnd - seekRange.start;
  204. const playheadDistance = clampedCurrentTime - seekRange.start;
  205. // NOTE: the fallback to zero eliminates NaN.
  206. const bufferStartFraction = (bufferStartDistance / seekRangeSize) || 0;
  207. const bufferEndFraction = (bufferEndDistance / seekRangeSize) || 0;
  208. const playheadFraction = (playheadDistance / seekRangeSize) || 0;
  209. const unbufferedColor =
  210. this.config_.showUnbufferedStart ? colors.base : colors.played;
  211. const gradient = [
  212. 'to right',
  213. this.makeColor_(unbufferedColor, bufferStartFraction),
  214. this.makeColor_(colors.played, bufferStartFraction),
  215. this.makeColor_(colors.played, playheadFraction),
  216. this.makeColor_(colors.buffered, playheadFraction),
  217. this.makeColor_(colors.buffered, bufferEndFraction),
  218. this.makeColor_(colors.base, bufferEndFraction),
  219. ];
  220. this.container.style.background =
  221. 'linear-gradient(' + gradient.join(',') + ')';
  222. }
  223. }
  224. }
  225. /**
  226. * @private
  227. */
  228. markAdBreaks_() {
  229. if (!this.adCuePoints_.length) {
  230. this.adMarkerContainer_.style.background = 'transparent';
  231. return;
  232. }
  233. const seekRange = this.player.seekRange();
  234. const seekRangeSize = seekRange.end - seekRange.start;
  235. const gradient = ['to right'];
  236. const pointsAsFractions = [];
  237. const adBreakColor = this.config_.seekBarColors.adBreaks;
  238. let postRollAd = false;
  239. for (const point of this.adCuePoints_) {
  240. // Post-roll ads are marked as starting at -1 in CS IMA ads.
  241. if (point.start == -1 && !point.end) {
  242. postRollAd = true;
  243. }
  244. // Filter point within the seek range. For points with no endpoint
  245. // (client side ads) check that the start point is within range.
  246. if (point.start >= seekRange.start && point.start < seekRange.end) {
  247. if (point.end && point.end > seekRange.end) {
  248. continue;
  249. }
  250. const startDist = point.start - seekRange.start;
  251. const startFrac = (startDist / seekRangeSize) || 0;
  252. // For points with no endpoint assume a 1% length: not too much,
  253. // but enough to be visible on the timeline.
  254. let endFrac = startFrac + 0.01;
  255. if (point.end) {
  256. const endDist = point.end - seekRange.start;
  257. endFrac = (endDist / seekRangeSize) || 0;
  258. }
  259. pointsAsFractions.push({
  260. start: startFrac,
  261. end: endFrac,
  262. });
  263. }
  264. }
  265. for (const point of pointsAsFractions) {
  266. gradient.push(this.makeColor_('transparent', point.start));
  267. gradient.push(this.makeColor_(adBreakColor, point.start));
  268. gradient.push(this.makeColor_(adBreakColor, point.end));
  269. gradient.push(this.makeColor_('transparent', point.end));
  270. }
  271. if (postRollAd) {
  272. gradient.push(this.makeColor_('transparent', 0.99));
  273. gradient.push(this.makeColor_(adBreakColor, 0.99));
  274. }
  275. this.adMarkerContainer_.style.background =
  276. 'linear-gradient(' + gradient.join(',') + ')';
  277. }
  278. /**
  279. * @param {string} color
  280. * @param {number} fract
  281. * @return {string}
  282. * @private
  283. */
  284. makeColor_(color, fract) {
  285. return color + ' ' + (fract * 100) + '%';
  286. }
  287. /**
  288. * @private
  289. */
  290. onAdCuePointsChanged_() {
  291. this.markAdBreaks_();
  292. const seekRange = this.player.seekRange();
  293. const seekRangeSize = seekRange.end - seekRange.start;
  294. const minSeekBarWindow = shaka.ui.Constants.MIN_SEEK_WINDOW_TO_SHOW_SEEKBAR;
  295. // Seek range keeps changing for live content and some of the known
  296. // ad breaks might not be in the seek range now, but get into
  297. // it later.
  298. // If we have a LIVE seekable content, keep checking for ad breaks
  299. // every second.
  300. if (this.player.isLive() && seekRangeSize > minSeekBarWindow) {
  301. this.adBreaksTimer_.tickEvery(1);
  302. }
  303. }
  304. /**
  305. * @return {boolean}
  306. * @private
  307. */
  308. shouldBeDisplayed_() {
  309. // The seek bar should be hidden when the seek window's too small or
  310. // there's an ad playing.
  311. const seekRange = this.player.seekRange();
  312. const seekRangeSize = seekRange.end - seekRange.start;
  313. if (this.player.isLive() &&
  314. seekRangeSize < shaka.ui.Constants.MIN_SEEK_WINDOW_TO_SHOW_SEEKBAR) {
  315. return false;
  316. }
  317. return this.ad == null;
  318. }
  319. /** @private */
  320. updateAriaLabel_() {
  321. this.bar.ariaLabel = this.localization.resolve(shaka.ui.Locales.Ids.SEEK);
  322. }
  323. };
  324. /**
  325. * @implements {shaka.extern.IUISeekBar.Factory}
  326. * @export
  327. */
  328. shaka.ui.SeekBar.Factory = class {
  329. /**
  330. * Creates a shaka.ui.SeekBar. Use this factory to register the default
  331. * SeekBar when needed
  332. *
  333. * @override
  334. */
  335. create(rootElement, controls) {
  336. return new shaka.ui.SeekBar(rootElement, controls);
  337. }
  338. };