Source: lib/media/playhead.js

  1. /*! @license
  2. * Shaka Player
  3. * Copyright 2016 Google LLC
  4. * SPDX-License-Identifier: Apache-2.0
  5. */
  6. goog.provide('shaka.media.MediaSourcePlayhead');
  7. goog.provide('shaka.media.Playhead');
  8. goog.provide('shaka.media.SrcEqualsPlayhead');
  9. goog.require('goog.asserts');
  10. goog.require('shaka.log');
  11. goog.require('shaka.media.GapJumpingController');
  12. goog.require('shaka.media.StallDetector');
  13. goog.require('shaka.media.StallDetector.MediaElementImplementation');
  14. goog.require('shaka.media.TimeRangesUtils');
  15. goog.require('shaka.media.VideoWrapper');
  16. goog.require('shaka.util.EventManager');
  17. goog.require('shaka.util.IReleasable');
  18. goog.require('shaka.util.MediaReadyState');
  19. goog.require('shaka.util.Timer');
  20. goog.requireType('shaka.media.PresentationTimeline');
  21. /**
  22. * Creates a Playhead, which manages the video's current time.
  23. *
  24. * The Playhead provides mechanisms for setting the presentation's start time,
  25. * restricting seeking to valid time ranges, and stopping playback for startup
  26. * and re-buffering.
  27. *
  28. * @extends {shaka.util.IReleasable}
  29. * @interface
  30. */
  31. shaka.media.Playhead = class {
  32. /**
  33. * Called when the Player is ready to begin playback. Anything that depends
  34. * on setStartTime() should be done here, not in the constructor.
  35. *
  36. * @see https://github.com/shaka-project/shaka-player/issues/4244
  37. */
  38. ready() {}
  39. /**
  40. * Set the start time. If the content has already started playback, this will
  41. * be ignored.
  42. *
  43. * @param {number} startTime
  44. */
  45. setStartTime(startTime) {}
  46. /**
  47. * Get the number of playback stalls detected by the StallDetector.
  48. *
  49. * @return {number}
  50. */
  51. getStallsDetected() {}
  52. /**
  53. * Get the number of playback gaps jumped by the GapJumpingController.
  54. *
  55. * @return {number}
  56. */
  57. getGapsJumped() {}
  58. /**
  59. * Get the current playhead position. The position will be restricted to valid
  60. * time ranges.
  61. *
  62. * @return {number}
  63. */
  64. getTime() {}
  65. /**
  66. * Notify the playhead that the buffered ranges have changed.
  67. */
  68. notifyOfBufferingChange() {}
  69. };
  70. /**
  71. * A playhead implementation that only relies on the media element.
  72. *
  73. * @implements {shaka.media.Playhead}
  74. * @final
  75. */
  76. shaka.media.SrcEqualsPlayhead = class {
  77. /**
  78. * @param {!HTMLMediaElement} mediaElement
  79. */
  80. constructor(mediaElement) {
  81. /** @private {HTMLMediaElement} */
  82. this.mediaElement_ = mediaElement;
  83. /** @private {boolean} */
  84. this.started_ = false;
  85. /** @private {?number} */
  86. this.startTime_ = null;
  87. /** @private {shaka.util.EventManager} */
  88. this.eventManager_ = new shaka.util.EventManager();
  89. }
  90. /** @override */
  91. ready() {
  92. goog.asserts.assert(
  93. this.mediaElement_ != null,
  94. 'Playhead should not be released before calling ready()',
  95. );
  96. // We listen for the loaded-data-event so that we know when we can
  97. // interact with |currentTime|.
  98. const onLoaded = () => {
  99. if (this.startTime_ == null ||
  100. (this.startTime_ == 0 && this.mediaElement_.duration != Infinity)) {
  101. this.started_ = true;
  102. } else {
  103. const currentTime = this.mediaElement_.currentTime;
  104. let newTime = this.startTime_;
  105. // Using the currentTime allows using a negative number in Live HLS
  106. if (this.startTime_ < 0) {
  107. newTime = Math.max(0, currentTime + this.startTime_);
  108. }
  109. if (currentTime != newTime) {
  110. // Startup is complete only when the video element acknowledges the
  111. // seek.
  112. this.eventManager_.listenOnce(this.mediaElement_, 'seeking', () => {
  113. this.started_ = true;
  114. });
  115. this.mediaElement_.currentTime = newTime;
  116. } else {
  117. this.started_ = true;
  118. }
  119. }
  120. };
  121. shaka.util.MediaReadyState.waitForReadyState(this.mediaElement_,
  122. HTMLMediaElement.HAVE_CURRENT_DATA,
  123. this.eventManager_, () => {
  124. onLoaded();
  125. });
  126. }
  127. /** @override */
  128. release() {
  129. if (this.eventManager_) {
  130. this.eventManager_.release();
  131. this.eventManager_ = null;
  132. }
  133. this.mediaElement_ = null;
  134. }
  135. /** @override */
  136. setStartTime(startTime) {
  137. // If we have already started playback, ignore updates to the start time.
  138. // This is just to make things consistent.
  139. this.startTime_ = this.started_ ? this.startTime_ : startTime;
  140. }
  141. /** @override */
  142. getTime() {
  143. // If we have not started playback yet, return the start time. However once
  144. // we start playback we assume that we can always return the current time.
  145. const time = this.started_ ?
  146. this.mediaElement_.currentTime :
  147. this.startTime_;
  148. // In the case that we have not started playback, but the start time was
  149. // never set, we don't know what the start time should be. To ensure we
  150. // always return a number, we will default back to 0.
  151. return time || 0;
  152. }
  153. /** @override */
  154. getStallsDetected() {
  155. return 0;
  156. }
  157. /** @override */
  158. getGapsJumped() {
  159. return 0;
  160. }
  161. /** @override */
  162. notifyOfBufferingChange() {}
  163. };
  164. /**
  165. * A playhead implementation that relies on the media element and a manifest.
  166. * When provided with a manifest, we can provide more accurate control than
  167. * the SrcEqualsPlayhead.
  168. *
  169. * TODO: Clean up and simplify Playhead. There are too many layers of, methods
  170. * for, and conditions on timestamp adjustment.
  171. *
  172. * @implements {shaka.media.Playhead}
  173. * @final
  174. */
  175. shaka.media.MediaSourcePlayhead = class {
  176. /**
  177. * @param {!HTMLMediaElement} mediaElement
  178. * @param {shaka.extern.Manifest} manifest
  179. * @param {shaka.extern.StreamingConfiguration} config
  180. * @param {?number} startTime
  181. * The playhead's initial position in seconds. If null, defaults to the
  182. * start of the presentation for VOD and the live-edge for live.
  183. * @param {function()} onSeek
  184. * Called when the user agent seeks to a time within the presentation
  185. * timeline.
  186. * @param {function(!Event)} onEvent
  187. * Called when an event is raised to be sent to the application.
  188. */
  189. constructor(mediaElement, manifest, config, startTime, onSeek, onEvent) {
  190. /**
  191. * The seek range must be at least this number of seconds long. If it is
  192. * smaller than this, change it to be this big so we don't repeatedly seek
  193. * to keep within a zero-width window.
  194. *
  195. * This is 3s long, to account for the weaker hardware on platforms like
  196. * Chromecast.
  197. *
  198. * @private {number}
  199. */
  200. this.minSeekRange_ = 3.0;
  201. /** @private {HTMLMediaElement} */
  202. this.mediaElement_ = mediaElement;
  203. /** @private {shaka.media.PresentationTimeline} */
  204. this.timeline_ = manifest.presentationTimeline;
  205. /** @private {number} */
  206. this.minBufferTime_ = manifest.minBufferTime || 0;
  207. /** @private {?shaka.extern.StreamingConfiguration} */
  208. this.config_ = config;
  209. /** @private {function()} */
  210. this.onSeek_ = onSeek;
  211. /** @private {?number} */
  212. this.lastCorrectiveSeek_ = null;
  213. /** @private {shaka.media.StallDetector} */
  214. this.stallDetector_ =
  215. this.createStallDetector_(mediaElement, config, onEvent);
  216. /** @private {shaka.media.GapJumpingController} */
  217. this.gapController_ = new shaka.media.GapJumpingController(
  218. mediaElement,
  219. manifest.presentationTimeline,
  220. config,
  221. this.stallDetector_,
  222. onEvent);
  223. /** @private {shaka.media.VideoWrapper} */
  224. this.videoWrapper_ = new shaka.media.VideoWrapper(
  225. mediaElement,
  226. () => this.onSeeking_(),
  227. (realStartTime) => this.onStarted_(realStartTime),
  228. () => this.getStartTime_(startTime));
  229. /** @type {shaka.util.Timer} */
  230. this.checkWindowTimer_ = new shaka.util.Timer(() => {
  231. this.onPollWindow_();
  232. });
  233. }
  234. /** @override */
  235. ready() {
  236. this.checkWindowTimer_.tickEvery(/* seconds= */ 0.25);
  237. }
  238. /** @override */
  239. release() {
  240. if (this.videoWrapper_) {
  241. this.videoWrapper_.release();
  242. this.videoWrapper_ = null;
  243. }
  244. if (this.gapController_) {
  245. this.gapController_.release();
  246. this.gapController_= null;
  247. }
  248. if (this.checkWindowTimer_) {
  249. this.checkWindowTimer_.stop();
  250. this.checkWindowTimer_ = null;
  251. }
  252. this.config_ = null;
  253. this.timeline_ = null;
  254. this.videoWrapper_ = null;
  255. this.mediaElement_ = null;
  256. this.onSeek_ = () => {};
  257. }
  258. /** @override */
  259. setStartTime(startTime) {
  260. this.videoWrapper_.setTime(startTime);
  261. }
  262. /** @override */
  263. getTime() {
  264. const time = this.videoWrapper_.getTime();
  265. // Although we restrict the video's currentTime elsewhere, clamp it here to
  266. // ensure timing issues don't cause us to return a time outside the segment
  267. // availability window. E.g., the user agent seeks and calls this function
  268. // before we receive the 'seeking' event.
  269. //
  270. // We don't buffer when the livestream video is paused and the playhead time
  271. // is out of the seek range; thus, we do not clamp the current time when the
  272. // video is paused.
  273. // https://github.com/shaka-project/shaka-player/issues/1121
  274. if (this.mediaElement_.readyState > 0 && !this.mediaElement_.paused) {
  275. return this.clampTime_(time);
  276. }
  277. return time;
  278. }
  279. /** @override */
  280. getStallsDetected() {
  281. return this.stallDetector_ ? this.stallDetector_.getStallsDetected() : 0;
  282. }
  283. /** @override */
  284. getGapsJumped() {
  285. return this.gapController_.getGapsJumped();
  286. }
  287. /**
  288. * Gets the playhead's initial position in seconds.
  289. *
  290. * @param {?number} startTime
  291. * @return {number}
  292. * @private
  293. */
  294. getStartTime_(startTime) {
  295. if (startTime == null) {
  296. if (this.timeline_.getDuration() < Infinity) {
  297. // If the presentation is VOD, or if the presentation is live but has
  298. // finished broadcasting, then start from the beginning.
  299. startTime = this.timeline_.getSeekRangeStart();
  300. } else {
  301. // Otherwise, start near the live-edge.
  302. startTime = this.timeline_.getSeekRangeEnd();
  303. }
  304. } else if (startTime < 0) {
  305. // For live streams, if the startTime is negative, start from a certain
  306. // offset time from the live edge. If the offset from the live edge is
  307. // not available, start from the current available segment start point
  308. // instead, handled by clampTime_().
  309. startTime = this.timeline_.getSeekRangeEnd() + startTime;
  310. }
  311. return this.clampSeekToDuration_(this.clampTime_(startTime));
  312. }
  313. /** @override */
  314. notifyOfBufferingChange() {
  315. this.gapController_.onSegmentAppended();
  316. }
  317. /**
  318. * Called on a recurring timer to keep the playhead from falling outside the
  319. * availability window.
  320. *
  321. * @private
  322. */
  323. onPollWindow_() {
  324. // Don't catch up to the seek range when we are paused or empty.
  325. // The definition of "seeking" says that we are seeking until the buffered
  326. // data intersects with the playhead. If we fall outside of the seek range,
  327. // it doesn't matter if we are in a "seeking" state. We can and should go
  328. // ahead and catch up while seeking.
  329. if (this.mediaElement_.readyState == 0 || this.mediaElement_.paused) {
  330. return;
  331. }
  332. const currentTime = this.videoWrapper_.getTime();
  333. let seekStart = this.timeline_.getSeekRangeStart();
  334. const seekEnd = this.timeline_.getSeekRangeEnd();
  335. if (seekEnd - seekStart < this.minSeekRange_) {
  336. seekStart = seekEnd - this.minSeekRange_;
  337. }
  338. if (currentTime < seekStart) {
  339. // The seek range has moved past the playhead. Move ahead to catch up.
  340. const targetTime = this.reposition_(currentTime);
  341. shaka.log.info('Jumping forward ' + (targetTime - currentTime) +
  342. ' seconds to catch up with the seek range.');
  343. this.mediaElement_.currentTime = targetTime;
  344. }
  345. }
  346. /**
  347. * Called when the video element has started up and is listening for new seeks
  348. *
  349. * @param {number} startTime
  350. * @private
  351. */
  352. onStarted_(startTime) {
  353. this.gapController_.onStarted(startTime);
  354. }
  355. /**
  356. * Handles when a seek happens on the video.
  357. *
  358. * @private
  359. */
  360. onSeeking_() {
  361. this.gapController_.onSeeking();
  362. const currentTime = this.videoWrapper_.getTime();
  363. const targetTime = this.reposition_(currentTime);
  364. const gapLimit = shaka.media.GapJumpingController.BROWSER_GAP_TOLERANCE;
  365. if (Math.abs(targetTime - currentTime) > gapLimit) {
  366. // You can only seek like this every so often. This is to prevent an
  367. // infinite loop on systems where changing currentTime takes a significant
  368. // amount of time (e.g. Chromecast).
  369. const time = Date.now() / 1000;
  370. if (!this.lastCorrectiveSeek_ || this.lastCorrectiveSeek_ < time - 1) {
  371. this.lastCorrectiveSeek_ = time;
  372. this.videoWrapper_.setTime(targetTime);
  373. return;
  374. }
  375. }
  376. shaka.log.v1('Seek to ' + currentTime);
  377. this.onSeek_();
  378. }
  379. /**
  380. * Clamp seek times and playback start times so that we never seek to the
  381. * presentation duration. Seeking to or starting at duration does not work
  382. * consistently across browsers.
  383. *
  384. * @see https://github.com/shaka-project/shaka-player/issues/979
  385. * @param {number} time
  386. * @return {number} The adjusted seek time.
  387. * @private
  388. */
  389. clampSeekToDuration_(time) {
  390. const duration = this.timeline_.getDuration();
  391. if (time >= duration) {
  392. goog.asserts.assert(this.config_.durationBackoff >= 0,
  393. 'Duration backoff must be non-negative!');
  394. return duration - this.config_.durationBackoff;
  395. }
  396. return time;
  397. }
  398. /**
  399. * Computes a new playhead position that's within the presentation timeline.
  400. *
  401. * @param {number} currentTime
  402. * @return {number} The time to reposition the playhead to.
  403. * @private
  404. */
  405. reposition_(currentTime) {
  406. goog.asserts.assert(
  407. this.config_,
  408. 'Cannot reposition playhead when it has beeen destroyed');
  409. /** @type {function(number)} */
  410. const isBuffered = (playheadTime) => shaka.media.TimeRangesUtils.isBuffered(
  411. this.mediaElement_.buffered, playheadTime);
  412. const rebufferingGoal = Math.max(
  413. this.minBufferTime_,
  414. this.config_.rebufferingGoal);
  415. const safeSeekOffset = this.config_.safeSeekOffset;
  416. let start = this.timeline_.getSeekRangeStart();
  417. const end = this.timeline_.getSeekRangeEnd();
  418. const duration = this.timeline_.getDuration();
  419. if (end - start < this.minSeekRange_) {
  420. start = end - this.minSeekRange_;
  421. }
  422. // With live content, the beginning of the availability window is moving
  423. // forward. This means we cannot seek to it since we will "fall" outside
  424. // the window while we buffer. So we define a "safe" region that is far
  425. // enough away. For VOD, |safe == start|.
  426. const safe = this.timeline_.getSafeSeekRangeStart(rebufferingGoal);
  427. // These are the times to seek to rather than the exact destinations. When
  428. // we seek, we will get another event (after a slight delay) and these steps
  429. // will run again. So if we seeked directly to |start|, |start| would move
  430. // on the next call and we would loop forever.
  431. const seekStart = this.timeline_.getSafeSeekRangeStart(safeSeekOffset);
  432. const seekSafe = this.timeline_.getSafeSeekRangeStart(
  433. rebufferingGoal + safeSeekOffset);
  434. if (currentTime >= duration) {
  435. shaka.log.v1('Playhead past duration.');
  436. return this.clampSeekToDuration_(currentTime);
  437. }
  438. if (currentTime > end) {
  439. shaka.log.v1('Playhead past end.');
  440. return end;
  441. }
  442. if (currentTime < start) {
  443. if (isBuffered(seekStart)) {
  444. shaka.log.v1('Playhead before start & start is buffered');
  445. return seekStart;
  446. } else {
  447. shaka.log.v1('Playhead before start & start is unbuffered');
  448. return seekSafe;
  449. }
  450. }
  451. if (currentTime >= safe || isBuffered(currentTime)) {
  452. shaka.log.v1('Playhead in safe region or in buffered region.');
  453. return currentTime;
  454. } else {
  455. shaka.log.v1('Playhead outside safe region & in unbuffered region.');
  456. return seekSafe;
  457. }
  458. }
  459. /**
  460. * Clamps the given time to the seek range.
  461. *
  462. * @param {number} time The time in seconds.
  463. * @return {number} The clamped time in seconds.
  464. * @private
  465. */
  466. clampTime_(time) {
  467. const start = this.timeline_.getSeekRangeStart();
  468. if (time < start) {
  469. return start;
  470. }
  471. const end = this.timeline_.getSeekRangeEnd();
  472. if (time > end) {
  473. return end;
  474. }
  475. return time;
  476. }
  477. /**
  478. * Create and configure a stall detector using the player's streaming
  479. * configuration settings. If the player is configured to have no stall
  480. * detector, this will return |null|.
  481. *
  482. * @param {!HTMLMediaElement} mediaElement
  483. * @param {shaka.extern.StreamingConfiguration} config
  484. * @param {function(!Event)} onEvent
  485. * Called when an event is raised to be sent to the application.
  486. * @return {shaka.media.StallDetector}
  487. * @private
  488. */
  489. createStallDetector_(mediaElement, config, onEvent) {
  490. if (!config.stallEnabled) {
  491. return null;
  492. }
  493. // Cache the values from the config so that changes to the config won't
  494. // change the initialized behaviour.
  495. const threshold = config.stallThreshold;
  496. const skip = config.stallSkip;
  497. // When we see a stall, we will try to "jump-start" playback by moving the
  498. // playhead forward.
  499. const detector = new shaka.media.StallDetector(
  500. new shaka.media.StallDetector.MediaElementImplementation(mediaElement),
  501. threshold, onEvent);
  502. detector.onStall((at, duration) => {
  503. shaka.log.debug(`Stall detected at ${at} for ${duration} seconds.`);
  504. if (skip) {
  505. shaka.log.debug(`Seeking forward ${skip} seconds to break stall.`);
  506. mediaElement.currentTime += skip;
  507. } else {
  508. shaka.log.debug('Pausing and unpausing to break stall.');
  509. mediaElement.pause();
  510. mediaElement.play();
  511. }
  512. });
  513. return detector;
  514. }
  515. };