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