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