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.device.DeviceFactory');
  11. goog.require('shaka.log');
  12. goog.require('shaka.media.Capabilities');
  13. goog.require('shaka.media.GapJumpingController');
  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|Date} 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 whether the playhead is currently jumping a gap.
  54. *
  55. * @return {boolean}
  56. */
  57. getIsJumpingGap() {}
  58. /**
  59. * Get the number of playback gaps jumped by the GapJumpingController.
  60. *
  61. * @return {number}
  62. */
  63. getGapsJumped() {}
  64. /**
  65. * Get the current playhead position. The position will be restricted to valid
  66. * time ranges.
  67. *
  68. * @return {number}
  69. */
  70. getTime() {}
  71. /**
  72. * Notify the playhead that the buffered ranges have changed.
  73. */
  74. notifyOfBufferingChange() {}
  75. /**
  76. * Check if the player has buffered enough content to make it to the end of
  77. * the presentation.
  78. * @return {boolean}
  79. */
  80. isBufferedToEnd() {}
  81. };
  82. /**
  83. * A playhead implementation that only relies on the media element.
  84. *
  85. * @implements {shaka.media.Playhead}
  86. * @final
  87. */
  88. shaka.media.SrcEqualsPlayhead = class {
  89. /**
  90. * @param {!HTMLMediaElement} mediaElement
  91. */
  92. constructor(mediaElement) {
  93. /** @private {HTMLMediaElement} */
  94. this.mediaElement_ = mediaElement;
  95. /** @private {boolean} */
  96. this.started_ = false;
  97. /** @private {?number|Date} */
  98. this.startTime_ = null;
  99. /** @private {shaka.util.EventManager} */
  100. this.eventManager_ = new shaka.util.EventManager();
  101. }
  102. /** @override */
  103. ready() {
  104. goog.asserts.assert(
  105. this.mediaElement_ != null,
  106. 'Playhead should not be released before calling ready()',
  107. );
  108. // We listen for the canplay event so that we know when we can
  109. // interact with |currentTime|.
  110. // We were using loadeddata before, but if we set time on that event,
  111. // browser may adjust it on its own during live playback.
  112. const onCanPlay = () => {
  113. if (this.startTime_ == null ||
  114. (this.startTime_ == 0 && this.mediaElement_.duration != Infinity)) {
  115. this.started_ = true;
  116. } else {
  117. const currentTime = this.mediaElement_.currentTime;
  118. let newTime = null;
  119. if (typeof this.startTime_ === 'number') {
  120. newTime = this.startTime_;
  121. } else if (this.startTime_ instanceof Date) {
  122. const programStartTime = this.getProgramStartTime_();
  123. if (programStartTime !== null) {
  124. newTime = (this.startTime_.getTime() / 1000.0) - programStartTime;
  125. newTime = this.clampTime_(newTime);
  126. }
  127. }
  128. if (newTime == null) {
  129. this.started_ = true;
  130. return;
  131. }
  132. // Using the currentTime allows using a negative number in Live HLS
  133. if (newTime < 0) {
  134. newTime = Math.max(0, currentTime + newTime);
  135. }
  136. if (currentTime != newTime) {
  137. // Startup is complete only when the video element acknowledges the
  138. // seek.
  139. this.eventManager_.listenOnce(this.mediaElement_, 'seeking', () => {
  140. this.started_ = true;
  141. });
  142. this.mediaElement_.currentTime = newTime;
  143. } else {
  144. this.started_ = true;
  145. }
  146. }
  147. };
  148. shaka.util.MediaReadyState.waitForReadyState(this.mediaElement_,
  149. HTMLMediaElement.HAVE_FUTURE_DATA,
  150. this.eventManager_, () => {
  151. onCanPlay();
  152. });
  153. }
  154. /** @override */
  155. release() {
  156. if (this.eventManager_) {
  157. this.eventManager_.release();
  158. this.eventManager_ = null;
  159. }
  160. this.mediaElement_ = null;
  161. }
  162. /** @override */
  163. setStartTime(startTime) {
  164. // If we have already started playback, ignore updates to the start time.
  165. // This is just to make things consistent.
  166. this.startTime_ = this.started_ ? this.startTime_ : startTime;
  167. }
  168. /** @override */
  169. getTime() {
  170. // If we have not started playback yet, return the start time. However once
  171. // we start playback we assume that we can always return the current time.
  172. let time = this.started_ ?
  173. this.mediaElement_.currentTime :
  174. this.startTime_;
  175. if (time instanceof Date) {
  176. time = (time.getTime() / 1000.0) - (this.getProgramStartTime_() || 0);
  177. time = this.clampTime_(time);
  178. }
  179. // In the case that we have not started playback, but the start time was
  180. // never set, we don't know what the start time should be. To ensure we
  181. // always return a number, we will default back to 0.
  182. return time || 0;
  183. }
  184. /** @override */
  185. getStallsDetected() {
  186. return 0;
  187. }
  188. /** @override */
  189. getGapsJumped() {
  190. return 0;
  191. }
  192. /** @override */
  193. getIsJumpingGap() {
  194. return false;
  195. }
  196. /** @override */
  197. notifyOfBufferingChange() {}
  198. /** @override */
  199. isBufferedToEnd() {
  200. goog.asserts.assert(
  201. this.mediaElement_,
  202. 'We need a video element to get buffering information');
  203. // If we have buffered to the duration of the content, it means we will have
  204. // enough content to buffer to the end of the presentation.
  205. const bufferEnd =
  206. shaka.media.TimeRangesUtils.bufferEnd(this.mediaElement_.buffered);
  207. // Because Safari's native HLS reports slightly inaccurate values for
  208. // bufferEnd here, we use a fudge factor. Without this, we can end up in a
  209. // buffering state at the end of the stream. See issue #2117.
  210. const fudge = 1; // 1000 ms
  211. return bufferEnd != null &&
  212. bufferEnd >= this.mediaElement_.duration - fudge;
  213. }
  214. /**
  215. * @return {?number} program start time in seconds.
  216. * @private
  217. */
  218. getProgramStartTime_() {
  219. if (this.mediaElement_.getStartDate) {
  220. const startDate = this.mediaElement_.getStartDate();
  221. const startTime = startDate.getTime();
  222. if (!isNaN(startTime)) {
  223. return startTime / 1000.0;
  224. }
  225. }
  226. return null;
  227. }
  228. /**
  229. * @param {number} time
  230. * @return {number}
  231. * @private
  232. */
  233. clampTime_(time) {
  234. const seekable = this.mediaElement_.seekable;
  235. if (seekable.length > 0) {
  236. time = Math.max(seekable.start(0), time);
  237. time = Math.min(seekable.end(seekable.length - 1), time);
  238. }
  239. return time;
  240. }
  241. };
  242. /**
  243. * A playhead implementation that relies on the media element and a manifest.
  244. * When provided with a manifest, we can provide more accurate control than
  245. * the SrcEqualsPlayhead.
  246. *
  247. * TODO: Clean up and simplify Playhead. There are too many layers of, methods
  248. * for, and conditions on timestamp adjustment.
  249. *
  250. * @implements {shaka.media.Playhead}
  251. * @final
  252. */
  253. shaka.media.MediaSourcePlayhead = class {
  254. /**
  255. * @param {!HTMLMediaElement} mediaElement
  256. * @param {shaka.extern.Manifest} manifest
  257. * @param {shaka.extern.StreamingConfiguration} config
  258. * @param {?number|Date} startTime
  259. * The playhead's initial position in seconds. If null, defaults to the
  260. * start of the presentation for VOD and the live-edge for live.
  261. * @param {function()} onSeek
  262. * Called when the user agent seeks to a time within the presentation
  263. * timeline.
  264. * @param {function(!Event)} onEvent
  265. * Called when an event is raised to be sent to the application.
  266. */
  267. constructor(mediaElement, manifest, config, startTime, onSeek, onEvent) {
  268. /**
  269. * The seek range must be at least this number of seconds long. If it is
  270. * smaller than this, change it to be this big so we don't repeatedly seek
  271. * to keep within a zero-width window.
  272. *
  273. * This is 3s long, to account for the weaker hardware on platforms like
  274. * Chromecast.
  275. *
  276. * @private {number}
  277. */
  278. this.minSeekRange_ = 3.0;
  279. /** @private {HTMLMediaElement} */
  280. this.mediaElement_ = mediaElement;
  281. /** @private {shaka.media.PresentationTimeline} */
  282. this.timeline_ = manifest.presentationTimeline;
  283. /** @private {?shaka.extern.StreamingConfiguration} */
  284. this.config_ = config;
  285. /** @private {function()} */
  286. this.onSeek_ = onSeek;
  287. /** @private {?number} */
  288. this.lastCorrectiveSeek_ = null;
  289. /** @private {shaka.media.GapJumpingController} */
  290. this.gapController_ = new shaka.media.GapJumpingController(
  291. mediaElement,
  292. manifest.presentationTimeline,
  293. config,
  294. onEvent);
  295. /** @private {shaka.media.VideoWrapper} */
  296. this.videoWrapper_ = new shaka.media.VideoWrapper(
  297. mediaElement,
  298. () => this.onSeeking_(),
  299. (realStartTime) => this.onStarted_(realStartTime),
  300. () => this.getStartTime_(startTime));
  301. /** @type {shaka.util.Timer} */
  302. this.checkWindowTimer_ = new shaka.util.Timer(() => {
  303. this.onPollWindow_();
  304. });
  305. }
  306. /** @override */
  307. ready() {
  308. this.checkWindowTimer_.tickEvery(/* seconds= */ 0.25);
  309. }
  310. /** @override */
  311. release() {
  312. if (this.videoWrapper_) {
  313. this.videoWrapper_.release();
  314. this.videoWrapper_ = null;
  315. }
  316. if (this.gapController_) {
  317. this.gapController_.release();
  318. this.gapController_= null;
  319. }
  320. if (this.checkWindowTimer_) {
  321. this.checkWindowTimer_.stop();
  322. this.checkWindowTimer_ = null;
  323. }
  324. this.config_ = null;
  325. this.timeline_ = null;
  326. this.videoWrapper_ = null;
  327. this.mediaElement_ = null;
  328. this.onSeek_ = () => {};
  329. }
  330. /** @override */
  331. setStartTime(startTime) {
  332. this.videoWrapper_.setTime(this.getStartTime_(startTime));
  333. }
  334. /** @override */
  335. getTime() {
  336. const time = this.videoWrapper_.getTime();
  337. // Although we restrict the video's currentTime elsewhere, clamp it here to
  338. // ensure timing issues don't cause us to return a time outside the segment
  339. // availability window. E.g., the user agent seeks and calls this function
  340. // before we receive the 'seeking' event.
  341. //
  342. // We don't buffer when the livestream video is paused and the playhead time
  343. // is out of the seek range; thus, we do not clamp the current time when the
  344. // video is paused.
  345. // https://github.com/shaka-project/shaka-player/issues/1121
  346. if (this.mediaElement_.readyState > 0 && !this.mediaElement_.paused) {
  347. return this.clampTime_(time);
  348. }
  349. return time;
  350. }
  351. /** @override */
  352. getStallsDetected() {
  353. return this.gapController_.getStallsDetected();
  354. }
  355. /** @override */
  356. getGapsJumped() {
  357. return this.gapController_.getGapsJumped();
  358. }
  359. /** @override */
  360. getIsJumpingGap() {
  361. return this.gapController_.getIsJumpingGap();
  362. }
  363. /**
  364. * Gets the playhead's initial position in seconds.
  365. *
  366. * @param {?number|Date} startTime
  367. * @return {number}
  368. * @private
  369. */
  370. getStartTime_(startTime) {
  371. if (startTime == null) {
  372. if (this.timeline_.getDuration() < Infinity) {
  373. // If the presentation is VOD, or if the presentation is live but has
  374. // finished broadcasting, then start from the beginning.
  375. startTime = this.timeline_.getSeekRangeStart();
  376. } else {
  377. // Otherwise, start near the live-edge.
  378. startTime = this.timeline_.getSeekRangeEnd();
  379. }
  380. } else if (startTime instanceof Date) {
  381. const presentationStartTime =
  382. this.timeline_.getInitialProgramDateTime() ||
  383. this.timeline_.getPresentationStartTime();
  384. goog.asserts.assert(presentationStartTime != null,
  385. 'Presentation start time should not be null!');
  386. startTime = (startTime.getTime() / 1000.0) - presentationStartTime;
  387. } else if (startTime < 0) {
  388. // For live streams, if the startTime is negative, start from a certain
  389. // offset time from the live edge. If the offset from the live edge is
  390. // not available, start from the current available segment start point
  391. // instead, handled by clampTime_().
  392. startTime = this.timeline_.getSeekRangeEnd() + startTime;
  393. }
  394. return this.clampSeekToDuration_(
  395. this.clampTime_(/** @type {number} */(startTime)));
  396. }
  397. /** @override */
  398. notifyOfBufferingChange() {
  399. this.gapController_.onSegmentAppended();
  400. }
  401. /** @override */
  402. isBufferedToEnd() {
  403. goog.asserts.assert(
  404. this.mediaElement_,
  405. 'We need a video element to get buffering information');
  406. goog.asserts.assert(
  407. this.timeline_,
  408. 'We need a timeline to get buffering information');
  409. // Live streams are "buffered to the end" when they have buffered to the
  410. // live edge or beyond (into the region covered by the presentation delay).
  411. if (this.timeline_.isLive()) {
  412. const liveEdge = this.timeline_.getSegmentAvailabilityEnd();
  413. const bufferEnd =
  414. shaka.media.TimeRangesUtils.bufferEnd(this.mediaElement_.buffered);
  415. if (bufferEnd != null && bufferEnd >= liveEdge) {
  416. return true;
  417. }
  418. }
  419. return false;
  420. }
  421. /**
  422. * Called on a recurring timer to keep the playhead from falling outside the
  423. * availability window.
  424. *
  425. * @private
  426. */
  427. onPollWindow_() {
  428. // Don't catch up to the seek range when we are paused or empty.
  429. // The definition of "seeking" says that we are seeking until the buffered
  430. // data intersects with the playhead. If we fall outside of the seek range,
  431. // it doesn't matter if we are in a "seeking" state. We can and should go
  432. // ahead and catch up while seeking.
  433. if (this.mediaElement_.readyState == 0 || this.mediaElement_.paused) {
  434. return;
  435. }
  436. const currentTime = this.videoWrapper_.getTime();
  437. let seekStart = this.timeline_.getSeekRangeStart();
  438. const seekEnd = this.timeline_.getSeekRangeEnd();
  439. if (seekEnd - seekStart < this.minSeekRange_) {
  440. seekStart = seekEnd - this.minSeekRange_;
  441. }
  442. if (currentTime < seekStart) {
  443. // The seek range has moved past the playhead. Move ahead to catch up.
  444. const targetTime = this.reposition_(currentTime);
  445. shaka.log.info('Jumping forward ' + (targetTime - currentTime) +
  446. ' seconds to catch up with the seek range.');
  447. this.mediaElement_.currentTime = targetTime;
  448. }
  449. }
  450. /**
  451. * Called when the video element has started up and is listening for new seeks
  452. *
  453. * @param {number} startTime
  454. * @private
  455. */
  456. onStarted_(startTime) {
  457. this.gapController_.onStarted(startTime);
  458. }
  459. /**
  460. * Handles when a seek happens on the video.
  461. *
  462. * @private
  463. */
  464. onSeeking_() {
  465. this.gapController_.onSeeking();
  466. const currentTime = this.videoWrapper_.getTime();
  467. const targetTime = this.reposition_(currentTime);
  468. const gapLimit = shaka.media.GapJumpingController.BROWSER_GAP_TOLERANCE;
  469. // We don't need to perform corrective seeks for the playhead range when
  470. // MediaSource's setLiveSeekableRange() can handle it for us.
  471. const mightNeedCorrectiveSeek =
  472. !shaka.media.Capabilities.isInfiniteLiveStreamDurationSupported();
  473. if (mightNeedCorrectiveSeek &&
  474. Math.abs(targetTime - currentTime) > gapLimit) {
  475. let canCorrectiveSeek = false;
  476. const seekDelay = shaka.device.DeviceFactory.getDevice().seekDelay();
  477. if (seekDelay) {
  478. // You can only seek like this every so often. This is to prevent an
  479. // infinite loop on systems where changing currentTime takes a
  480. // significant amount of time (e.g. Chromecast).
  481. const time = Date.now() / 1000;
  482. if (!this.lastCorrectiveSeek_ ||
  483. this.lastCorrectiveSeek_ < time - seekDelay) {
  484. this.lastCorrectiveSeek_ = time;
  485. canCorrectiveSeek = true;
  486. }
  487. } else {
  488. canCorrectiveSeek = true;
  489. }
  490. if (canCorrectiveSeek) {
  491. this.videoWrapper_.setTime(targetTime);
  492. return;
  493. }
  494. }
  495. shaka.log.v1('Seek to ' + currentTime);
  496. this.onSeek_();
  497. }
  498. /**
  499. * Clamp seek times and playback start times so that we never seek to the
  500. * presentation duration. Seeking to or starting at duration does not work
  501. * consistently across browsers.
  502. *
  503. * @see https://github.com/shaka-project/shaka-player/issues/979
  504. * @param {number} time
  505. * @return {number} The adjusted seek time.
  506. * @private
  507. */
  508. clampSeekToDuration_(time) {
  509. const duration = this.timeline_.getDuration();
  510. if (time >= duration) {
  511. goog.asserts.assert(this.config_.durationBackoff >= 0,
  512. 'Duration backoff must be non-negative!');
  513. return duration - this.config_.durationBackoff;
  514. }
  515. return time;
  516. }
  517. /**
  518. * Computes a new playhead position that's within the presentation timeline.
  519. *
  520. * @param {number} currentTime
  521. * @return {number} The time to reposition the playhead to.
  522. * @private
  523. */
  524. reposition_(currentTime) {
  525. goog.asserts.assert(
  526. this.config_,
  527. 'Cannot reposition playhead when it has been destroyed');
  528. /** @type {function(number)} */
  529. const isBuffered = (playheadTime) => shaka.media.TimeRangesUtils.isBuffered(
  530. this.mediaElement_.buffered, playheadTime);
  531. const rebufferingGoal = this.config_.rebufferingGoal;
  532. const safeSeekOffset = this.config_.safeSeekOffset;
  533. let start = this.timeline_.getSeekRangeStart();
  534. const end = this.timeline_.getSeekRangeEnd();
  535. const duration = this.timeline_.getDuration();
  536. if (end - start < this.minSeekRange_) {
  537. start = end - this.minSeekRange_;
  538. }
  539. // With live content, the beginning of the availability window is moving
  540. // forward. This means we cannot seek to it since we will "fall" outside
  541. // the window while we buffer. So we define a "safe" region that is far
  542. // enough away. For VOD, |safe == start|.
  543. const safe = this.timeline_.getSafeSeekRangeStart(rebufferingGoal);
  544. // These are the times to seek to rather than the exact destinations. When
  545. // we seek, we will get another event (after a slight delay) and these steps
  546. // will run again. So if we seeked directly to |start|, |start| would move
  547. // on the next call and we would loop forever.
  548. const seekStart = this.timeline_.getSafeSeekRangeStart(safeSeekOffset);
  549. const seekSafe = this.timeline_.getSafeSeekRangeStart(
  550. rebufferingGoal + safeSeekOffset);
  551. if (currentTime >= duration) {
  552. shaka.log.v1('Playhead past duration.');
  553. return this.clampSeekToDuration_(currentTime);
  554. }
  555. if (currentTime > end) {
  556. shaka.log.v1('Playhead past end.');
  557. // We remove the safeSeekEndOffset of the seek end to avoid the player
  558. // to be block at the edge in a live stream
  559. return end - this.config_.safeSeekEndOffset;
  560. }
  561. if (currentTime < start) {
  562. if (this.timeline_.isLive() &&
  563. this.config_.returnToEndOfLiveWindowWhenOutside) {
  564. return end - this.config_.safeSeekEndOffset;
  565. }
  566. if (isBuffered(seekStart)) {
  567. shaka.log.v1('Playhead before start & start is buffered');
  568. return seekStart;
  569. } else {
  570. shaka.log.v1('Playhead before start & start is unbuffered');
  571. return seekSafe;
  572. }
  573. }
  574. if (currentTime >= safe || isBuffered(currentTime)) {
  575. shaka.log.v1('Playhead in safe region or in buffered region.');
  576. return currentTime;
  577. } else {
  578. shaka.log.v1('Playhead outside safe region & in unbuffered region.');
  579. return seekSafe;
  580. }
  581. }
  582. /**
  583. * Clamps the given time to the seek range.
  584. *
  585. * @param {number} time The time in seconds.
  586. * @return {number} The clamped time in seconds.
  587. * @private
  588. */
  589. clampTime_(time) {
  590. const start = this.timeline_.getSeekRangeStart();
  591. if (time < start) {
  592. return start;
  593. }
  594. const end = this.timeline_.getSeekRangeEnd();
  595. if (time > end) {
  596. return end;
  597. }
  598. return time;
  599. }
  600. };