1. 1 : /**
  2. 2 : * @file seek-bar.js
  3. 3 : */
  4. 4 : import Slider from '../../slider/slider.js';
  5. 5 : import Component from '../../component.js';
  6. 6 : import {IS_IOS, IS_ANDROID} from '../../utils/browser.js';
  7. 7 : import * as Dom from '../../utils/dom.js';
  8. 8 : import * as Fn from '../../utils/fn.js';
  9. 9 : import formatTime from '../../utils/format-time.js';
  10. 10 : import {silencePromise} from '../../utils/promise';
  11. 11 : import keycode from 'keycode';
  12. 12 : import document from 'global/document';
  13. 13 :
  14. 14 : import './load-progress-bar.js';
  15. 15 : import './play-progress-bar.js';
  16. 16 : import './mouse-time-display.js';
  17. 17 :
  18. 18 : // The number of seconds the `step*` functions move the timeline.
  19. 19 : const STEP_SECONDS = 5;
  20. 20 :
  21. 21 : // The multiplier of STEP_SECONDS that PgUp/PgDown move the timeline.
  22. 22 : const PAGE_KEY_MULTIPLIER = 12;
  23. 23 :
  24. 24 : /**
  25. 25 : * Seek bar and container for the progress bars. Uses {@link PlayProgressBar}
  26. 26 : * as its `bar`.
  27. 27 : *
  28. 28 : * @extends Slider
  29. 29 : */
  30. 30 : class SeekBar extends Slider {
  31. 31 :
  32. 32 : /**
  33. 33 : * Creates an instance of this class.
  34. 34 : *
  35. 35 : * @param {Player} player
  36. 36 : * The `Player` that this class should be attached to.
  37. 37 : *
  38. 38 : * @param {Object} [options]
  39. 39 : * The key/value store of player options.
  40. 40 : */
  41. 41 : constructor(player, options) {
  42. 42 : super(player, options);
  43. 43 : this.setEventHandlers_();
  44. 44 : }
  45. 45 :
  46. 46 : /**
  47. 47 : * Sets the event handlers
  48. 48 : *
  49. 49 : * @private
  50. 50 : */
  51. 51 : setEventHandlers_() {
  52. 52 : this.update_ = Fn.bind(this, this.update);
  53. 53 : this.update = Fn.throttle(this.update_, Fn.UPDATE_REFRESH_INTERVAL);
  54. 54 :
  55. 55 : this.on(this.player_, ['ended', 'durationchange', 'timeupdate'], this.update);
  56. 56 : if (this.player_.liveTracker) {
  57. 57 : this.on(this.player_.liveTracker, 'liveedgechange', this.update);
  58. 58 : }
  59. 59 :
  60. 60 : // when playing, let's ensure we smoothly update the play progress bar
  61. 61 : // via an interval
  62. 62 : this.updateInterval = null;
  63. 63 :
  64. 64 : this.enableIntervalHandler_ = (e) => this.enableInterval_(e);
  65. 65 : this.disableIntervalHandler_ = (e) => this.disableInterval_(e);
  66. 66 :
  67. 67 : this.on(this.player_, ['playing'], this.enableIntervalHandler_);
  68. 68 :
  69. 69 : this.on(this.player_, ['ended', 'pause', 'waiting'], this.disableIntervalHandler_);
  70. 70 :
  71. 71 : // we don't need to update the play progress if the document is hidden,
  72. 72 : // also, this causes the CPU to spike and eventually crash the page on IE11.
  73. 73 : if ('hidden' in document && 'visibilityState' in document) {
  74. 74 : this.on(document, 'visibilitychange', this.toggleVisibility_);
  75. 75 : }
  76. 76 : }
  77. 77 :
  78. 78 : toggleVisibility_(e) {
  79. 79 : if (document.visibilityState === 'hidden') {
  80. 80 : this.cancelNamedAnimationFrame('SeekBar#update');
  81. 81 : this.cancelNamedAnimationFrame('Slider#update');
  82. 82 : this.disableInterval_(e);
  83. 83 : } else {
  84. 84 : if (!this.player_.ended() && !this.player_.paused()) {
  85. 85 : this.enableInterval_();
  86. 86 : }
  87. 87 :
  88. 88 : // we just switched back to the page and someone may be looking, so, update ASAP
  89. 89 : this.update();
  90. 90 : }
  91. 91 : }
  92. 92 :
  93. 93 : enableInterval_() {
  94. 94 : if (this.updateInterval) {
  95. 95 : return;
  96. 96 :
  97. 97 : }
  98. 98 : this.updateInterval = this.setInterval(this.update, Fn.UPDATE_REFRESH_INTERVAL);
  99. 99 : }
  100. 100 :
  101. 101 : disableInterval_(e) {
  102. 102 : if (this.player_.liveTracker && this.player_.liveTracker.isLive() && e && e.type !== 'ended') {
  103. 103 : return;
  104. 104 : }
  105. 105 :
  106. 106 : if (!this.updateInterval) {
  107. 107 : return;
  108. 108 : }
  109. 109 :
  110. 110 : this.clearInterval(this.updateInterval);
  111. 111 : this.updateInterval = null;
  112. 112 : }
  113. 113 :
  114. 114 : /**
  115. 115 : * Create the `Component`'s DOM element
  116. 116 : *
  117. 117 : * @return {Element}
  118. 118 : * The element that was created.
  119. 119 : */
  120. 120 : createEl() {
  121. 121 : return super.createEl('div', {
  122. 122 : className: 'vjs-progress-holder'
  123. 123 : }, {
  124. 124 : 'aria-label': this.localize('Progress Bar')
  125. 125 : });
  126. 126 : }
  127. 127 :
  128. 128 : /**
  129. 129 : * This function updates the play progress bar and accessibility
  130. 130 : * attributes to whatever is passed in.
  131. 131 : *
  132. 132 : * @param {EventTarget~Event} [event]
  133. 133 : * The `timeupdate` or `ended` event that caused this to run.
  134. 134 : *
  135. 135 : * @listens Player#timeupdate
  136. 136 : *
  137. 137 : * @return {number}
  138. 138 : * The current percent at a number from 0-1
  139. 139 : */
  140. 140 : update(event) {
  141. 141 : // ignore updates while the tab is hidden
  142. 142 : if (document.visibilityState === 'hidden') {
  143. 143 : return;
  144. 144 : }
  145. 145 :
  146. 146 : const percent = super.update();
  147. 147 :
  148. 148 : this.requestNamedAnimationFrame('SeekBar#update', () => {
  149. 149 : const currentTime = this.player_.ended() ?
  150. 150 : this.player_.duration() : this.getCurrentTime_();
  151. 151 : const liveTracker = this.player_.liveTracker;
  152. 152 : let duration = this.player_.duration();
  153. 153 :
  154. 154 : if (liveTracker && liveTracker.isLive()) {
  155. 155 : duration = this.player_.liveTracker.liveCurrentTime();
  156. 156 : }
  157. 157 :
  158. 158 : if (this.percent_ !== percent) {
  159. 159 : // machine readable value of progress bar (percentage complete)
  160. 160 : this.el_.setAttribute('aria-valuenow', (percent * 100).toFixed(2));
  161. 161 : this.percent_ = percent;
  162. 162 : }
  163. 163 :
  164. 164 : if (this.currentTime_ !== currentTime || this.duration_ !== duration) {
  165. 165 : // human readable value of progress bar (time complete)
  166. 166 : this.el_.setAttribute(
  167. 167 : 'aria-valuetext',
  168. 168 : this.localize(
  169. 169 : 'progress bar timing: currentTime={1} duration={2}',
  170. 170 : [formatTime(currentTime, duration),
  171. 171 : formatTime(duration, duration)],
  172. 172 : '{1} of {2}'
  173. 173 : )
  174. 174 : );
  175. 175 :
  176. 176 : this.currentTime_ = currentTime;
  177. 177 : this.duration_ = duration;
  178. 178 : }
  179. 179 :
  180. 180 : // update the progress bar time tooltip with the current time
  181. 181 : if (this.bar) {
  182. 182 : this.bar.update(Dom.getBoundingClientRect(this.el()), this.getProgress());
  183. 183 : }
  184. 184 : });
  185. 185 :
  186. 186 : return percent;
  187. 187 : }
  188. 188 :
  189. 189 : /**
  190. 190 : * Prevent liveThreshold from causing seeks to seem like they
  191. 191 : * are not happening from a user perspective.
  192. 192 : *
  193. 193 : * @param {number} ct
  194. 194 : * current time to seek to
  195. 195 : */
  196. 196 : userSeek_(ct) {
  197. 197 : if (this.player_.liveTracker && this.player_.liveTracker.isLive()) {
  198. 198 : this.player_.liveTracker.nextSeekedFromUser();
  199. 199 : }
  200. 200 :
  201. 201 : this.player_.currentTime(ct);
  202. 202 : }
  203. 203 :
  204. 204 : /**
  205. 205 : * Get the value of current time but allows for smooth scrubbing,
  206. 206 : * when player can't keep up.
  207. 207 : *
  208. 208 : * @return {number}
  209. 209 : * The current time value to display
  210. 210 : *
  211. 211 : * @private
  212. 212 : */
  213. 213 : getCurrentTime_() {
  214. 214 : return (this.player_.scrubbing()) ?
  215. 215 : this.player_.getCache().currentTime :
  216. 216 : this.player_.currentTime();
  217. 217 : }
  218. 218 :
  219. 219 : /**
  220. 220 : * Get the percentage of media played so far.
  221. 221 : *
  222. 222 : * @return {number}
  223. 223 : * The percentage of media played so far (0 to 1).
  224. 224 : */
  225. 225 : getPercent() {
  226. 226 : const currentTime = this.getCurrentTime_();
  227. 227 : let percent;
  228. 228 : const liveTracker = this.player_.liveTracker;
  229. 229 :
  230. 230 : if (liveTracker && liveTracker.isLive()) {
  231. 231 : percent = (currentTime - liveTracker.seekableStart()) / liveTracker.liveWindow();
  232. 232 :
  233. 233 : // prevent the percent from changing at the live edge
  234. 234 : if (liveTracker.atLiveEdge()) {
  235. 235 : percent = 1;
  236. 236 : }
  237. 237 : } else {
  238. 238 : percent = currentTime / this.player_.duration();
  239. 239 : }
  240. 240 :
  241. 241 : return percent;
  242. 242 : }
  243. 243 :
  244. 244 : /**
  245. 245 : * Handle mouse down on seek bar
  246. 246 : *
  247. 247 : * @param {EventTarget~Event} event
  248. 248 : * The `mousedown` event that caused this to run.
  249. 249 : *
  250. 250 : * @listens mousedown
  251. 251 : */
  252. 252 : handleMouseDown(event) {
  253. 253 : if (!Dom.isSingleLeftClick(event)) {
  254. 254 : return;
  255. 255 : }
  256. 256 :
  257. 257 : // Stop event propagation to prevent double fire in progress-control.js
  258. 258 : event.stopPropagation();
  259. 259 :
  260. 260 : this.videoWasPlaying = !this.player_.paused();
  261. 261 : this.player_.pause();
  262. 262 :
  263. 263 : super.handleMouseDown(event);
  264. 264 : }
  265. 265 :
  266. 266 : /**
  267. 267 : * Handle mouse move on seek bar
  268. 268 : *
  269. 269 : * @param {EventTarget~Event} event
  270. 270 : * The `mousemove` event that caused this to run.
  271. 271 : * @param {boolean} mouseDown this is a flag that should be set to true if `handleMouseMove` is called directly. It allows us to skip things that should not happen if coming from mouse down but should happen on regular mouse move handler. Defaults to false
  272. 272 : *
  273. 273 : * @listens mousemove
  274. 274 : */
  275. 275 : handleMouseMove(event, mouseDown = false) {
  276. 276 : if (!Dom.isSingleLeftClick(event)) {
  277. 277 : return;
  278. 278 : }
  279. 279 :
  280. 280 : if (!mouseDown && !this.player_.scrubbing()) {
  281. 281 : this.player_.scrubbing(true);
  282. 282 : }
  283. 283 :
  284. 284 : let newTime;
  285. 285 : const distance = this.calculateDistance(event);
  286. 286 : const liveTracker = this.player_.liveTracker;
  287. 287 :
  288. 288 : if (!liveTracker || !liveTracker.isLive()) {
  289. 289 : newTime = distance * this.player_.duration();
  290. 290 :
  291. 291 : // Don't let video end while scrubbing.
  292. 292 : if (newTime === this.player_.duration()) {
  293. 293 : newTime = newTime - 0.1;
  294. 294 : }
  295. 295 : } else {
  296. 296 :
  297. 297 : if (distance >= 0.99) {
  298. 298 : liveTracker.seekToLiveEdge();
  299. 299 : return;
  300. 300 : }
  301. 301 : const seekableStart = liveTracker.seekableStart();
  302. 302 : const seekableEnd = liveTracker.liveCurrentTime();
  303. 303 :
  304. 304 : newTime = seekableStart + (distance * liveTracker.liveWindow());
  305. 305 :
  306. 306 : // Don't let video end while scrubbing.
  307. 307 : if (newTime >= seekableEnd) {
  308. 308 : newTime = seekableEnd;
  309. 309 : }
  310. 310 :
  311. 311 : // Compensate for precision differences so that currentTime is not less
  312. 312 : // than seekable start
  313. 313 : if (newTime <= seekableStart) {
  314. 314 : newTime = seekableStart + 0.1;
  315. 315 : }
  316. 316 :
  317. 317 : // On android seekableEnd can be Infinity sometimes,
  318. 318 : // this will cause newTime to be Infinity, which is
  319. 319 : // not a valid currentTime.
  320. 320 : if (newTime === Infinity) {
  321. 321 : return;
  322. 322 : }
  323. 323 : }
  324. 324 :
  325. 325 : // Set new time (tell player to seek to new time)
  326. 326 : this.userSeek_(newTime);
  327. 327 : }
  328. 328 :
  329. 329 : enable() {
  330. 330 : super.enable();
  331. 331 : const mouseTimeDisplay = this.getChild('mouseTimeDisplay');
  332. 332 :
  333. 333 : if (!mouseTimeDisplay) {
  334. 334 : return;
  335. 335 : }
  336. 336 :
  337. 337 : mouseTimeDisplay.show();
  338. 338 : }
  339. 339 :
  340. 340 : disable() {
  341. 341 : super.disable();
  342. 342 : const mouseTimeDisplay = this.getChild('mouseTimeDisplay');
  343. 343 :
  344. 344 : if (!mouseTimeDisplay) {
  345. 345 : return;
  346. 346 : }
  347. 347 :
  348. 348 : mouseTimeDisplay.hide();
  349. 349 : }
  350. 350 :
  351. 351 : /**
  352. 352 : * Handle mouse up on seek bar
  353. 353 : *
  354. 354 : * @param {EventTarget~Event} event
  355. 355 : * The `mouseup` event that caused this to run.
  356. 356 : *
  357. 357 : * @listens mouseup
  358. 358 : */
  359. 359 : handleMouseUp(event) {
  360. 360 : super.handleMouseUp(event);
  361. 361 :
  362. 362 : // Stop event propagation to prevent double fire in progress-control.js
  363. 363 : if (event) {
  364. 364 : event.stopPropagation();
  365. 365 : }
  366. 366 : this.player_.scrubbing(false);
  367. 367 :
  368. 368 : /**
  369. 369 : * Trigger timeupdate because we're done seeking and the time has changed.
  370. 370 : * This is particularly useful for if the player is paused to time the time displays.
  371. 371 : *
  372. 372 : * @event Tech#timeupdate
  373. 373 : * @type {EventTarget~Event}
  374. 374 : */
  375. 375 : this.player_.trigger({ type: 'timeupdate', target: this, manuallyTriggered: true });
  376. 376 : if (this.videoWasPlaying) {
  377. 377 : silencePromise(this.player_.play());
  378. 378 : } else {
  379. 379 : // We're done seeking and the time has changed.
  380. 380 : // If the player is paused, make sure we display the correct time on the seek bar.
  381. 381 : this.update_();
  382. 382 : }
  383. 383 : }
  384. 384 :
  385. 385 : /**
  386. 386 : * Move more quickly fast forward for keyboard-only users
  387. 387 : */
  388. 388 : stepForward() {
  389. 389 : this.userSeek_(this.player_.currentTime() + STEP_SECONDS);
  390. 390 : }
  391. 391 :
  392. 392 : /**
  393. 393 : * Move more quickly rewind for keyboard-only users
  394. 394 : */
  395. 395 : stepBack() {
  396. 396 : this.userSeek_(this.player_.currentTime() - STEP_SECONDS);
  397. 397 : }
  398. 398 :
  399. 399 : /**
  400. 400 : * Toggles the playback state of the player
  401. 401 : * This gets called when enter or space is used on the seekbar
  402. 402 : *
  403. 403 : * @param {EventTarget~Event} event
  404. 404 : * The `keydown` event that caused this function to be called
  405. 405 : *
  406. 406 : */
  407. 407 : handleAction(event) {
  408. 408 : if (this.player_.paused()) {
  409. 409 : this.player_.play();
  410. 410 : } else {
  411. 411 : this.player_.pause();
  412. 412 : }
  413. 413 : }
  414. 414 :
  415. 415 : /**
  416. 416 : * Called when this SeekBar has focus and a key gets pressed down.
  417. 417 : * Supports the following keys:
  418. 418 : *
  419. 419 : * Space or Enter key fire a click event
  420. 420 : * Home key moves to start of the timeline
  421. 421 : * End key moves to end of the timeline
  422. 422 : * Digit "0" through "9" keys move to 0%, 10% ... 80%, 90% of the timeline
  423. 423 : * PageDown key moves back a larger step than ArrowDown
  424. 424 : * PageUp key moves forward a large step
  425. 425 : *
  426. 426 : * @param {EventTarget~Event} event
  427. 427 : * The `keydown` event that caused this function to be called.
  428. 428 : *
  429. 429 : * @listens keydown
  430. 430 : */
  431. 431 : handleKeyDown(event) {
  432. 432 : const liveTracker = this.player_.liveTracker;
  433. 433 :
  434. 434 : if (keycode.isEventKey(event, 'Space') || keycode.isEventKey(event, 'Enter')) {
  435. 435 : event.preventDefault();
  436. 436 : event.stopPropagation();
  437. 437 : this.handleAction(event);
  438. 438 : } else if (keycode.isEventKey(event, 'Home')) {
  439. 439 : event.preventDefault();
  440. 440 : event.stopPropagation();
  441. 441 : this.userSeek_(0);
  442. 442 : } else if (keycode.isEventKey(event, 'End')) {
  443. 443 : event.preventDefault();
  444. 444 : event.stopPropagation();
  445. 445 : if (liveTracker && liveTracker.isLive()) {
  446. 446 : this.userSeek_(liveTracker.liveCurrentTime());
  447. 447 : } else {
  448. 448 : this.userSeek_(this.player_.duration());
  449. 449 : }
  450. 450 : } else if (/^[0-9]$/.test(keycode(event))) {
  451. 451 : event.preventDefault();
  452. 452 : event.stopPropagation();
  453. 453 : const gotoFraction = (keycode.codes[keycode(event)] - keycode.codes['0']) * 10.0 / 100.0;
  454. 454 :
  455. 455 : if (liveTracker && liveTracker.isLive()) {
  456. 456 : this.userSeek_(liveTracker.seekableStart() + (liveTracker.liveWindow() * gotoFraction));
  457. 457 : } else {
  458. 458 : this.userSeek_(this.player_.duration() * gotoFraction);
  459. 459 : }
  460. 460 : } else if (keycode.isEventKey(event, 'PgDn')) {
  461. 461 : event.preventDefault();
  462. 462 : event.stopPropagation();
  463. 463 : this.userSeek_(this.player_.currentTime() - (STEP_SECONDS * PAGE_KEY_MULTIPLIER));
  464. 464 : } else if (keycode.isEventKey(event, 'PgUp')) {
  465. 465 : event.preventDefault();
  466. 466 : event.stopPropagation();
  467. 467 : this.userSeek_(this.player_.currentTime() + (STEP_SECONDS * PAGE_KEY_MULTIPLIER));
  468. 468 : } else {
  469. 469 : // Pass keydown handling up for unsupported keys
  470. 470 : super.handleKeyDown(event);
  471. 471 : }
  472. 472 : }
  473. 473 :
  474. 474 : dispose() {
  475. 475 : this.disableInterval_();
  476. 476 :
  477. 477 : this.off(this.player_, ['ended', 'durationchange', 'timeupdate'], this.update);
  478. 478 : if (this.player_.liveTracker) {
  479. 479 : this.off(this.player_.liveTracker, 'liveedgechange', this.update);
  480. 480 : }
  481. 481 :
  482. 482 : this.off(this.player_, ['playing'], this.enableIntervalHandler_);
  483. 483 : this.off(this.player_, ['ended', 'pause', 'waiting'], this.disableIntervalHandler_);
  484. 484 :
  485. 485 : // we don't need to update the play progress if the document is hidden,
  486. 486 : // also, this causes the CPU to spike and eventually crash the page on IE11.
  487. 487 : if ('hidden' in document && 'visibilityState' in document) {
  488. 488 : this.off(document, 'visibilitychange', this.toggleVisibility_);
  489. 489 : }
  490. 490 :
  491. 491 : super.dispose();
  492. 492 : }
  493. 493 : }
  494. 494 :
  495. 495 : /**
  496. 496 : * Default options for the `SeekBar`
  497. 497 : *
  498. 498 : * @type {Object}
  499. 499 : * @private
  500. 500 : */
  501. 501 : SeekBar.prototype.options_ = {
  502. 502 : children: [
  503. 503 : 'loadProgressBar',
  504. 504 : 'playProgressBar'
  505. 505 : ],
  506. 506 : barName: 'playProgressBar'
  507. 507 : };
  508. 508 :
  509. 509 : // MouseTimeDisplay tooltips should not be added to a player on mobile devices
  510. 510 : if (!IS_IOS && !IS_ANDROID) {
  511. 511 : SeekBar.prototype.options_.children.splice(1, 0, 'mouseTimeDisplay');
  512. 512 : }
  513. 513 :
  514. 514 : Component.registerComponent('SeekBar', SeekBar);
  515. 515 : export default SeekBar;