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