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