Source: lib/media/playhead.js

  1. /**
  2. * @license
  3. * Copyright 2016 Google Inc.
  4. *
  5. * Licensed under the Apache License, Version 2.0 (the "License");
  6. * you may not use this file except in compliance with the License.
  7. * You may obtain a copy of the License at
  8. *
  9. * http://www.apache.org/licenses/LICENSE-2.0
  10. *
  11. * Unless required by applicable law or agreed to in writing, software
  12. * distributed under the License is distributed on an "AS IS" BASIS,
  13. * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  14. * See the License for the specific language governing permissions and
  15. * limitations under the License.
  16. */
  17. goog.provide('shaka.media.Playhead');
  18. goog.require('goog.asserts');
  19. goog.require('shaka.log');
  20. goog.require('shaka.media.TimeRangesUtils');
  21. goog.require('shaka.util.EventManager');
  22. goog.require('shaka.util.FakeEvent');
  23. goog.require('shaka.util.IDestroyable');
  24. goog.require('shaka.util.StreamUtils');
  25. /**
  26. * Creates a Playhead, which manages the video's current time.
  27. *
  28. * The Playhead provides mechanisms for setting the presentation's start time,
  29. * restricting seeking to valid time ranges, and stopping playback for startup
  30. * and re- buffering.
  31. *
  32. * @param {HTMLMediaElement} video
  33. * @param {shakaExtern.Manifest} manifest
  34. * @param {shakaExtern.StreamingConfiguration} config
  35. * @param {?number} startTime The playhead's initial position in seconds. If
  36. * null, defaults to the start of the presentation for VOD and the live-edge
  37. * for live.
  38. * @param {function()} onSeek Called when the user agent seeks to a time within
  39. * the presentation timeline.
  40. * @param {function(!Event)} onEvent Called when an event is raised to be sent
  41. * to the application.
  42. *
  43. * @constructor
  44. * @struct
  45. * @implements {shaka.util.IDestroyable}
  46. */
  47. shaka.media.Playhead = function(
  48. video, manifest, config, startTime, onSeek, onEvent) {
  49. /** @private {HTMLMediaElement} */
  50. this.video_ = video;
  51. /** @private {?shakaExtern.Manifest} */
  52. this.manifest_ = manifest;
  53. /** @private {?shakaExtern.StreamingConfiguration} */
  54. this.config_ = config;
  55. /**
  56. * The playhead's initial position in seconds, or null if it should
  57. * automatically be calculated later.
  58. * @private {?number}
  59. */
  60. this.startTime_ = startTime;
  61. /** @private {?function()} */
  62. this.onSeek_ = onSeek;
  63. /** @private {?function(!Event)} */
  64. this.onEvent_ = onEvent;
  65. /** @private {shaka.util.EventManager} */
  66. this.eventManager_ = new shaka.util.EventManager();
  67. /** @private {boolean} */
  68. this.buffering_ = false;
  69. /** @private {number} */
  70. this.playbackRate_ = 1;
  71. /** @private {?number} */
  72. this.trickPlayIntervalId_ = null;
  73. /** @private {?number} */
  74. this.gapJumpIntervalId_ = null;
  75. /** @private {number} */
  76. this.prevReadyState_ = video.readyState;
  77. /** @private {boolean} */
  78. this.didFireLargeGap_ = false;
  79. /**
  80. * The wall-clock time (in milliseconds) that the stall occurred. This is
  81. * used to ensure we don't flush the pipeline too often.
  82. * @private {number}
  83. */
  84. this.stallWallTime_ = -1;
  85. /**
  86. * The playhead time where we think a stall occurred. When the ready state
  87. * says we don't have enough data and the playhead stops too long, we assume
  88. * we have stalled.
  89. * @private {number}
  90. */
  91. this.stallPlayheadTime_ = -1;
  92. /**
  93. * True if we have already flushed the pipeline at stallPlayheadTime_.
  94. * Allows us to avoid flushing multiple times for the same stall.
  95. * @private {boolean}
  96. */
  97. this.stallCorrected_ = false;
  98. /** @private {boolean} */
  99. this.hadSegmentAppended_ = false;
  100. // Check if the video has already loaded some metadata.
  101. if (video.readyState > 0) {
  102. this.onLoadedMetadata_();
  103. } else {
  104. this.eventManager_.listenOnce(
  105. video, 'loadedmetadata', this.onLoadedMetadata_.bind(this));
  106. }
  107. var pollGap = this.onPollGapJump_.bind(this);
  108. this.eventManager_.listen(video, 'ratechange', this.onRateChange_.bind(this));
  109. this.eventManager_.listen(video, 'waiting', pollGap);
  110. // We can't trust readyState or 'waiting' events on all platforms. So poll
  111. // the current time and if we are in a gap, jump it.
  112. // See: https://goo.gl/sbSHp9 and https://goo.gl/cuAcYd
  113. this.gapJumpIntervalId_ = setInterval(pollGap, 250);
  114. };
  115. /** @override */
  116. shaka.media.Playhead.prototype.destroy = function() {
  117. var p = this.eventManager_.destroy();
  118. this.eventManager_ = null;
  119. if (this.trickPlayIntervalId_ != null) {
  120. window.clearInterval(this.trickPlayIntervalId_);
  121. this.trickPlayIntervalId_ = null;
  122. }
  123. if (this.gapJumpIntervalId_ != null) {
  124. window.clearInterval(this.gapJumpIntervalId_);
  125. this.gapJumpIntervalId_ = null;
  126. }
  127. this.video_ = null;
  128. this.manifest_ = null;
  129. this.config_ = null;
  130. this.onSeek_ = null;
  131. this.onEvent_ = null;
  132. return p;
  133. };
  134. /** @param {number} startTime */
  135. shaka.media.Playhead.prototype.setStartTime = function(startTime) {
  136. if (this.video_.readyState > 0)
  137. this.video_.currentTime = this.clampTime_(startTime);
  138. else
  139. this.startTime_ = startTime;
  140. };
  141. /**
  142. * Gets the playhead's current (logical) position.
  143. *
  144. * @return {number}
  145. */
  146. shaka.media.Playhead.prototype.getTime = function() {
  147. if (this.video_.readyState > 0) {
  148. // Although we restrict the video's currentTime elsewhere, clamp it here to
  149. // ensure any timing issues (e.g., the user agent seeks and calls this
  150. // function before we receive the 'seeking' event) don't cause us to return
  151. // a time outside the segment availability window.
  152. return this.clampTime_(this.video_.currentTime);
  153. }
  154. return this.getStartTime_();
  155. };
  156. /**
  157. * Gets the playhead's initial position in seconds.
  158. *
  159. * @return {number}
  160. * @private
  161. */
  162. shaka.media.Playhead.prototype.getStartTime_ = function() {
  163. if (this.startTime_) {
  164. return this.clampTime_(this.startTime_);
  165. }
  166. var startTime;
  167. var timeline = this.manifest_.presentationTimeline;
  168. if (timeline.getDuration() < Infinity) {
  169. // If the presentation is VOD, or if the presentation is live but has
  170. // finished broadcasting, then start from the beginning.
  171. startTime = timeline.getSegmentAvailabilityStart();
  172. } else {
  173. // Otherwise, start near the live-edge.
  174. startTime = timeline.getSeekRangeEnd();
  175. }
  176. return startTime;
  177. };
  178. /**
  179. * Stops the playhead for buffering, or resumes the playhead after buffering.
  180. *
  181. * @param {boolean} buffering True to stop the playhead; false to allow it to
  182. * continue.
  183. */
  184. shaka.media.Playhead.prototype.setBuffering = function(buffering) {
  185. if (buffering != this.buffering_) {
  186. this.buffering_ = buffering;
  187. this.setPlaybackRate(this.playbackRate_);
  188. }
  189. };
  190. /**
  191. * Gets the current effective playback rate. This may be negative even if the
  192. * browser does not directly support rewinding.
  193. * @return {number}
  194. */
  195. shaka.media.Playhead.prototype.getPlaybackRate = function() {
  196. return this.playbackRate_;
  197. };
  198. /**
  199. * Sets the playback rate.
  200. * @param {number} rate
  201. */
  202. shaka.media.Playhead.prototype.setPlaybackRate = function(rate) {
  203. if (this.trickPlayIntervalId_ != null) {
  204. window.clearInterval(this.trickPlayIntervalId_);
  205. this.trickPlayIntervalId_ = null;
  206. }
  207. this.playbackRate_ = rate;
  208. // All major browsers support playback rates above zero. Only need fake
  209. // trick play for negative rates.
  210. this.video_.playbackRate = (this.buffering_ || rate < 0) ? 0 : rate;
  211. if (!this.buffering_ && rate < 0) {
  212. // Defer creating the timer until we stop buffering. This function will be
  213. // called again from setBuffering().
  214. this.trickPlayIntervalId_ = window.setInterval(function() {
  215. this.video_.currentTime += rate / 4;
  216. }.bind(this), 250);
  217. }
  218. };
  219. /**
  220. * Called when a segment is appended by StreamingEngine, but not when a clear is
  221. * pending. This means StreamingEngine will continue buffering forward from
  222. * what is buffered. So we know about any gaps before the start.
  223. */
  224. shaka.media.Playhead.prototype.onSegmentAppended = function() {
  225. this.hadSegmentAppended_ = true;
  226. this.onPollGapJump_();
  227. };
  228. /**
  229. * Handles a 'ratechange' event.
  230. *
  231. * @private
  232. */
  233. shaka.media.Playhead.prototype.onRateChange_ = function() {
  234. // NOTE: This will not allow explicitly setting the playback rate to 0 while
  235. // the playback rate is negative. Pause will still work.
  236. var expectedRate =
  237. this.buffering_ || this.playbackRate_ < 0 ? 0 : this.playbackRate_;
  238. // Native controls in Edge trigger a change to playbackRate and set it to 0
  239. // when seeking. If we don't exclude 0 from this check, we will force the
  240. // rate to stay at 0 after a seek with Edge native controls.
  241. // https://github.com/google/shaka-player/issues/951
  242. if (this.video_.playbackRate && this.video_.playbackRate != expectedRate) {
  243. shaka.log.debug('Video playback rate changed to', this.video_.playbackRate);
  244. this.setPlaybackRate(this.video_.playbackRate);
  245. }
  246. };
  247. /**
  248. * Handles a 'loadedmetadata' event.
  249. *
  250. * @private
  251. */
  252. shaka.media.Playhead.prototype.onLoadedMetadata_ = function() {
  253. // Move the real playhead to the start time.
  254. var targetTime = this.getStartTime_();
  255. if (Math.abs(this.video_.currentTime - targetTime) < 0.001) {
  256. this.eventManager_.listen(
  257. this.video_, 'seeking', this.onSeeking_.bind(this));
  258. this.eventManager_.listen(
  259. this.video_, 'playing', this.onPlaying_.bind(this));
  260. } else {
  261. this.eventManager_.listenOnce(
  262. this.video_, 'seeking', this.onSeekingToStartTime_.bind(this));
  263. this.video_.currentTime = targetTime;
  264. }
  265. };
  266. /**
  267. * Handles the 'seeking' event from the initial jump to the start time (if
  268. * there is one).
  269. *
  270. * @private
  271. */
  272. shaka.media.Playhead.prototype.onSeekingToStartTime_ = function() {
  273. goog.asserts.assert(this.video_.readyState > 0,
  274. 'readyState should be greater than 0');
  275. this.eventManager_.listen(this.video_, 'seeking', this.onSeeking_.bind(this));
  276. this.eventManager_.listen(this.video_, 'playing', this.onPlaying_.bind(this));
  277. };
  278. /**
  279. * Called on a recurring timer to check for gaps in the media. This is also
  280. * called in a 'waiting' event.
  281. *
  282. * @private
  283. */
  284. shaka.media.Playhead.prototype.onPollGapJump_ = function() {
  285. if (this.video_.readyState == 0)
  286. return;
  287. // When the ready state changes, we have moved on, so we should fire the large
  288. // gap event if we see one.
  289. if (this.video_.readyState != this.prevReadyState_) {
  290. this.didFireLargeGap_ = false;
  291. this.prevReadyState_ = this.video_.readyState;
  292. }
  293. var smallGapLimit = this.config_.smallGapLimit;
  294. var currentTime = this.video_.currentTime;
  295. var buffered = this.video_.buffered;
  296. // If seeking is not possible, clamp the playhead manually here.
  297. var timeline = this.manifest_.presentationTimeline;
  298. var availabilityStart = timeline.getSegmentAvailabilityStart();
  299. if (currentTime < availabilityStart) {
  300. // The availability window has moved past the playhead.
  301. // Move ahead to catch up.
  302. var targetTime = this.reposition_(currentTime);
  303. shaka.log.info('Jumping forward ' + (targetTime - currentTime) +
  304. ' seconds to catch up with the availability window.');
  305. this.movePlayhead_(currentTime, targetTime);
  306. return;
  307. }
  308. var gapIndex = shaka.media.TimeRangesUtils.getGapIndex(buffered, currentTime);
  309. // The current time is unbuffered or is too far from a gap.
  310. if (gapIndex == null) {
  311. if (this.video_.readyState < 3 && this.video_.playbackRate > 0) {
  312. // Some platforms/browsers can get stuck in the middle of a buffered range
  313. // (e.g. when seeking in a background tab). Flush the media pipeline to
  314. // help.
  315. //
  316. // Flush once we have stopped for more than 1 second inside a buffered
  317. // range. Note that Chromecast takes a few seconds to start playing
  318. // after any kind of seek, so wait 5 seconds between repeated flushes.
  319. if (this.stallPlayheadTime_ != currentTime) {
  320. this.stallPlayheadTime_ = currentTime;
  321. this.stallWallTime_ = Date.now();
  322. this.stallCorrected_ = false;
  323. } else if (!this.stallCorrected_ &&
  324. this.stallWallTime_ < Date.now() - 1000) {
  325. for (var i = 0; i < buffered.length; i++) {
  326. // Ignore the end of the buffered range since it may not play any more
  327. // on all platforms.
  328. if (currentTime >= buffered.start(i) &&
  329. currentTime < buffered.end(i) - 0.5) {
  330. shaka.log.debug(
  331. 'Flushing media pipeline due to stall inside buffered range');
  332. this.video_.currentTime += 0.1;
  333. this.stallPlayheadTime_ = this.video_.currentTime;
  334. this.stallCorrected_ = true;
  335. break;
  336. }
  337. }
  338. }
  339. }
  340. return;
  341. }
  342. // If we are before the first buffered range, this could be an unbuffered
  343. // seek. So wait until a segment is appended so we are sure it is a gap.
  344. if (gapIndex == 0 && !this.hadSegmentAppended_)
  345. return;
  346. // StreamingEngine can buffer past the seek end, but still don't allow seeking
  347. // past it.
  348. var jumpTo = buffered.start(gapIndex);
  349. var seekEnd = this.manifest_.presentationTimeline.getSeekRangeEnd();
  350. if (jumpTo >= seekEnd)
  351. return;
  352. var jumpSize = jumpTo - currentTime;
  353. var isGapSmall = jumpSize <= smallGapLimit;
  354. var jumpLargeGap = false;
  355. if (!isGapSmall && !this.didFireLargeGap_) {
  356. this.didFireLargeGap_ = true;
  357. // Event firing is synchronous.
  358. var event = new shaka.util.FakeEvent(
  359. 'largegap', {'currentTime': currentTime, 'gapSize': jumpSize});
  360. event.cancelable = true;
  361. this.onEvent_(event);
  362. if (this.config_.jumpLargeGaps && !event.defaultPrevented)
  363. jumpLargeGap = true;
  364. else
  365. shaka.log.info('Ignoring large gap at', currentTime);
  366. }
  367. if (isGapSmall || jumpLargeGap) {
  368. if (gapIndex == 0) {
  369. shaka.log.info(
  370. 'Jumping forward', jumpSize,
  371. 'seconds because of gap before start time of', jumpTo);
  372. } else {
  373. shaka.log.info(
  374. 'Jumping forward', jumpSize, 'seconds because of gap starting at',
  375. buffered.end(gapIndex - 1), 'and ending at', jumpTo);
  376. }
  377. this.movePlayhead_(currentTime, jumpTo);
  378. }
  379. };
  380. /**
  381. * Handles a 'seeking' event.
  382. *
  383. * @private
  384. */
  385. shaka.media.Playhead.prototype.onSeeking_ = function() {
  386. goog.asserts.assert(this.video_.readyState > 0,
  387. 'readyState should be greater than 0');
  388. this.hadSegmentAppended_ = false;
  389. var currentTime = this.video_.currentTime;
  390. var targetTime = this.reposition_(currentTime);
  391. if (Math.abs(targetTime - currentTime) > 0.001) {
  392. this.movePlayhead_(currentTime, targetTime);
  393. return;
  394. }
  395. shaka.log.v1('Seek to ' + currentTime);
  396. this.didFireLargeGap_ = false;
  397. this.onSeek_();
  398. };
  399. /**
  400. * Handles a 'playing' event.
  401. *
  402. * @private
  403. */
  404. shaka.media.Playhead.prototype.onPlaying_ = function() {
  405. goog.asserts.assert(this.video_.readyState > 0,
  406. 'readyState should be greater than 0');
  407. var currentTime = this.video_.currentTime;
  408. var targetTime = this.reposition_(currentTime);
  409. if (Math.abs(targetTime - currentTime) > 0.001)
  410. this.movePlayhead_(currentTime, targetTime);
  411. };
  412. /**
  413. * Computes a new playhead position that's within the presentation timeline.
  414. *
  415. * @param {number} currentTime
  416. * @return {number} The time to reposition the playhead to.
  417. * @private
  418. */
  419. shaka.media.Playhead.prototype.reposition_ = function(currentTime) {
  420. goog.asserts.assert(this.manifest_ && this.config_, 'Must not be destroyed');
  421. /** @type {function(number)} */
  422. var isBuffered =
  423. shaka.media.TimeRangesUtils.isBuffered.bind(null, this.video_.buffered);
  424. var rebufferingGoal = shaka.util.StreamUtils.getRebufferingGoal(
  425. this.manifest_, this.config_, 1 /* scaleFactor */);
  426. var timeline = this.manifest_.presentationTimeline;
  427. var start = timeline.getSafeAvailabilityStart(0);
  428. var end = timeline.getSegmentAvailabilityEnd();
  429. // With live content, the beginning of the availability window is moving
  430. // forward. This means we cannot seek to it since we will "fall" outside the
  431. // window while we buffer. So we define a "safe" region that is far enough
  432. // away. For VOD, |safe == start|.
  433. var safe = timeline.getSafeAvailabilityStart(rebufferingGoal);
  434. // These are the times to seek to rather than the exact destinations. When
  435. // we seek, we will get another event (after a slight delay) and these steps
  436. // will run again. So if we seeked directly to |start|, |start| would move
  437. // on the next call and we would loop forever.
  438. //
  439. // Offset by 5 seconds since Chromecast takes a few seconds to start playing
  440. // after a seek, even when buffered.
  441. var seekStart = timeline.getSafeAvailabilityStart(5);
  442. var seekSafe = timeline.getSafeAvailabilityStart(rebufferingGoal + 5);
  443. if (currentTime > end) {
  444. shaka.log.v1('Playhead past end.');
  445. return end;
  446. }
  447. if (currentTime < start) {
  448. if (isBuffered(seekStart)) {
  449. shaka.log.v1('Playhead before start & start is buffered');
  450. return seekStart;
  451. } else {
  452. shaka.log.v1('Playhead before start & start is unbuffered');
  453. return seekSafe;
  454. }
  455. }
  456. if (currentTime >= safe || isBuffered(currentTime)) {
  457. shaka.log.v1('Playhead in safe region or in buffered region.');
  458. return currentTime;
  459. } else {
  460. shaka.log.v1('Playhead outside safe region & in unbuffered region.');
  461. return seekSafe;
  462. }
  463. };
  464. /**
  465. * Moves the playhead to the target time, triggering a call to onSeeking_().
  466. *
  467. * @param {number} currentTime
  468. * @param {number} targetTime
  469. * @private
  470. */
  471. shaka.media.Playhead.prototype.movePlayhead_ = function(
  472. currentTime, targetTime) {
  473. shaka.log.debug('Moving playhead...',
  474. 'currentTime=' + currentTime,
  475. 'targetTime=' + targetTime);
  476. this.video_.currentTime = targetTime;
  477. // Sometimes, IE and Edge ignore re-seeks. Check every 100ms and try
  478. // again if need be, up to 10 tries.
  479. // Delay stats over 100 runs of a re-seeking integration test:
  480. // IE - 0ms - 47%
  481. // IE - 100ms - 63%
  482. // Edge - 0ms - 2%
  483. // Edge - 100ms - 40%
  484. // Edge - 200ms - 32%
  485. // Edge - 300ms - 24%
  486. // Edge - 400ms - 2%
  487. // Chrome - 0ms - 100%
  488. // TODO: File a bug on IE/Edge about this.
  489. var tries = 0;
  490. var recheck = (function() {
  491. if (!this.video_) return;
  492. if (tries++ >= 10) return;
  493. if (this.video_.currentTime == currentTime) {
  494. // Sigh. Try again.
  495. this.video_.currentTime = targetTime;
  496. setTimeout(recheck, 100);
  497. }
  498. }).bind(this);
  499. setTimeout(recheck, 100);
  500. };
  501. /**
  502. * Clamps the given time to the segment availability window.
  503. *
  504. * @param {number} time The time in seconds.
  505. * @return {number} The clamped time in seconds.
  506. * @private
  507. */
  508. shaka.media.Playhead.prototype.clampTime_ = function(time) {
  509. var start = this.manifest_.presentationTimeline.getSegmentAvailabilityStart();
  510. if (time < start) return start;
  511. var end = this.manifest_.presentationTimeline.getSegmentAvailabilityEnd();
  512. if (time > end) return end;
  513. return time;
  514. };