playback-watcher.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  1. /**
  2. * @file playback-watcher.js
  3. *
  4. * Playback starts, and now my watch begins. It shall not end until my death. I shall
  5. * take no wait, hold no uncleared timeouts, father no bad seeks. I shall wear no crowns
  6. * and win no glory. I shall live and die at my post. I am the corrector of the underflow.
  7. * I am the watcher of gaps. I am the shield that guards the realms of seekable. I pledge
  8. * my life and honor to the Playback Watch, for this Player and all the Players to come.
  9. */
  10. 'use strict';
  11. Object.defineProperty(exports, '__esModule', {
  12. value: true
  13. });
  14. var _createClass = (function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ('value' in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; })();
  15. function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; }
  16. function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError('Cannot call a class as a function'); } }
  17. var _globalWindow = require('global/window');
  18. var _globalWindow2 = _interopRequireDefault(_globalWindow);
  19. var _ranges = require('./ranges');
  20. var _ranges2 = _interopRequireDefault(_ranges);
  21. var _videoJs = require('video.js');
  22. var _videoJs2 = _interopRequireDefault(_videoJs);
  23. // Set of events that reset the playback-watcher time check logic and clear the timeout
  24. var timerCancelEvents = ['seeking', 'seeked', 'pause', 'playing', 'error'];
  25. /**
  26. * @class PlaybackWatcher
  27. */
  28. var PlaybackWatcher = (function () {
  29. /**
  30. * Represents an PlaybackWatcher object.
  31. * @constructor
  32. * @param {object} options an object that includes the tech and settings
  33. */
  34. function PlaybackWatcher(options) {
  35. var _this = this;
  36. _classCallCheck(this, PlaybackWatcher);
  37. this.tech_ = options.tech;
  38. this.seekable = options.seekable;
  39. this.consecutiveUpdates = 0;
  40. this.lastRecordedTime = null;
  41. this.timer_ = null;
  42. this.checkCurrentTimeTimeout_ = null;
  43. if (options.debug) {
  44. this.logger_ = _videoJs2['default'].log.bind(_videoJs2['default'], 'playback-watcher ->');
  45. }
  46. this.logger_('initialize');
  47. var canPlayHandler = function canPlayHandler() {
  48. return _this.monitorCurrentTime_();
  49. };
  50. var waitingHandler = function waitingHandler() {
  51. return _this.techWaiting_();
  52. };
  53. var cancelTimerHandler = function cancelTimerHandler() {
  54. return _this.cancelTimer_();
  55. };
  56. var fixesBadSeeksHandler = function fixesBadSeeksHandler() {
  57. return _this.fixesBadSeeks_();
  58. };
  59. this.tech_.on('seekablechanged', fixesBadSeeksHandler);
  60. this.tech_.on('waiting', waitingHandler);
  61. this.tech_.on(timerCancelEvents, cancelTimerHandler);
  62. this.tech_.on('canplay', canPlayHandler);
  63. // Define the dispose function to clean up our events
  64. this.dispose = function () {
  65. _this.logger_('dispose');
  66. _this.tech_.off('seekablechanged', fixesBadSeeksHandler);
  67. _this.tech_.off('waiting', waitingHandler);
  68. _this.tech_.off(timerCancelEvents, cancelTimerHandler);
  69. _this.tech_.off('canplay', canPlayHandler);
  70. if (_this.checkCurrentTimeTimeout_) {
  71. _globalWindow2['default'].clearTimeout(_this.checkCurrentTimeTimeout_);
  72. }
  73. _this.cancelTimer_();
  74. };
  75. }
  76. /**
  77. * Periodically check current time to see if playback stopped
  78. *
  79. * @private
  80. */
  81. _createClass(PlaybackWatcher, [{
  82. key: 'monitorCurrentTime_',
  83. value: function monitorCurrentTime_() {
  84. this.checkCurrentTime_();
  85. if (this.checkCurrentTimeTimeout_) {
  86. _globalWindow2['default'].clearTimeout(this.checkCurrentTimeTimeout_);
  87. }
  88. // 42 = 24 fps // 250 is what Webkit uses // FF uses 15
  89. this.checkCurrentTimeTimeout_ = _globalWindow2['default'].setTimeout(this.monitorCurrentTime_.bind(this), 250);
  90. }
  91. /**
  92. * The purpose of this function is to emulate the "waiting" event on
  93. * browsers that do not emit it when they are waiting for more
  94. * data to continue playback
  95. *
  96. * @private
  97. */
  98. }, {
  99. key: 'checkCurrentTime_',
  100. value: function checkCurrentTime_() {
  101. if (this.tech_.seeking() && this.fixesBadSeeks_()) {
  102. this.consecutiveUpdates = 0;
  103. this.lastRecordedTime = this.tech_.currentTime();
  104. return;
  105. }
  106. if (this.tech_.paused() || this.tech_.seeking()) {
  107. return;
  108. }
  109. var currentTime = this.tech_.currentTime();
  110. var buffered = this.tech_.buffered();
  111. if (this.lastRecordedTime === currentTime && (!buffered.length || currentTime + _ranges2['default'].SAFE_TIME_DELTA >= buffered.end(buffered.length - 1))) {
  112. // If current time is at the end of the final buffered region, then any playback
  113. // stall is most likely caused by buffering in a low bandwidth environment. The tech
  114. // should fire a `waiting` event in this scenario, but due to browser and tech
  115. // inconsistencies (e.g. The Flash tech does not fire a `waiting` event when the end
  116. // of the buffer is reached and has fallen off the live window). Calling
  117. // `techWaiting_` here allows us to simulate responding to a native `waiting` event
  118. // when the tech fails to emit one.
  119. return this.techWaiting_();
  120. }
  121. if (this.consecutiveUpdates >= 5 && currentTime === this.lastRecordedTime) {
  122. this.consecutiveUpdates++;
  123. this.waiting_();
  124. } else if (currentTime === this.lastRecordedTime) {
  125. this.consecutiveUpdates++;
  126. } else {
  127. this.consecutiveUpdates = 0;
  128. this.lastRecordedTime = currentTime;
  129. }
  130. }
  131. /**
  132. * Cancels any pending timers and resets the 'timeupdate' mechanism
  133. * designed to detect that we are stalled
  134. *
  135. * @private
  136. */
  137. }, {
  138. key: 'cancelTimer_',
  139. value: function cancelTimer_() {
  140. this.consecutiveUpdates = 0;
  141. if (this.timer_) {
  142. this.logger_('cancelTimer_');
  143. clearTimeout(this.timer_);
  144. }
  145. this.timer_ = null;
  146. }
  147. /**
  148. * Fixes situations where there's a bad seek
  149. *
  150. * @return {Boolean} whether an action was taken to fix the seek
  151. * @private
  152. */
  153. }, {
  154. key: 'fixesBadSeeks_',
  155. value: function fixesBadSeeks_() {
  156. var seeking = this.tech_.seeking();
  157. var seekable = this.seekable();
  158. var currentTime = this.tech_.currentTime();
  159. var seekTo = undefined;
  160. if (seeking && this.afterSeekableWindow_(seekable, currentTime)) {
  161. var seekableEnd = seekable.end(seekable.length - 1);
  162. // sync to live point (if VOD, our seekable was updated and we're simply adjusting)
  163. seekTo = seekableEnd;
  164. }
  165. if (seeking && this.beforeSeekableWindow_(seekable, currentTime)) {
  166. var seekableStart = seekable.start(0);
  167. // sync to the beginning of the live window
  168. // provide a buffer of .1 seconds to handle rounding/imprecise numbers
  169. seekTo = seekableStart + _ranges2['default'].SAFE_TIME_DELTA;
  170. }
  171. if (typeof seekTo !== 'undefined') {
  172. this.logger_('Trying to seek outside of seekable at time ' + currentTime + ' with ' + ('seekable range ' + _ranges2['default'].printableRange(seekable) + '. Seeking to ') + (seekTo + '.'));
  173. this.tech_.setCurrentTime(seekTo);
  174. return true;
  175. }
  176. return false;
  177. }
  178. /**
  179. * Handler for situations when we determine the player is waiting.
  180. *
  181. * @private
  182. */
  183. }, {
  184. key: 'waiting_',
  185. value: function waiting_() {
  186. if (this.techWaiting_()) {
  187. return;
  188. }
  189. // All tech waiting checks failed. Use last resort correction
  190. var currentTime = this.tech_.currentTime();
  191. var buffered = this.tech_.buffered();
  192. var currentRange = _ranges2['default'].findRange(buffered, currentTime);
  193. // Sometimes the player can stall for unknown reasons within a contiguous buffered
  194. // region with no indication that anything is amiss (seen in Firefox). Seeking to
  195. // currentTime is usually enough to kickstart the player. This checks that the player
  196. // is currently within a buffered region before attempting a corrective seek.
  197. // Chrome does not appear to continue `timeupdate` events after a `waiting` event
  198. // until there is ~ 3 seconds of forward buffer available. PlaybackWatcher should also
  199. // make sure there is ~3 seconds of forward buffer before taking any corrective action
  200. // to avoid triggering an `unknownwaiting` event when the network is slow.
  201. if (currentRange.length && currentTime + 3 <= currentRange.end(0)) {
  202. this.cancelTimer_();
  203. this.tech_.setCurrentTime(currentTime);
  204. this.logger_('Stopped at ' + currentTime + ' while inside a buffered region ' + ('[' + currentRange.start(0) + ' -> ' + currentRange.end(0) + ']. Attempting to resume ') + 'playback by seeking to the current time.');
  205. // unknown waiting corrections may be useful for monitoring QoS
  206. this.tech_.trigger({ type: 'usage', name: 'hls-unknown-waiting' });
  207. return;
  208. }
  209. }
  210. /**
  211. * Handler for situations when the tech fires a `waiting` event
  212. *
  213. * @return {Boolean}
  214. * True if an action (or none) was needed to correct the waiting. False if no
  215. * checks passed
  216. * @private
  217. */
  218. }, {
  219. key: 'techWaiting_',
  220. value: function techWaiting_() {
  221. var seekable = this.seekable();
  222. var currentTime = this.tech_.currentTime();
  223. if (this.tech_.seeking() && this.fixesBadSeeks_()) {
  224. // Tech is seeking or bad seek fixed, no action needed
  225. return true;
  226. }
  227. if (this.tech_.seeking() || this.timer_ !== null) {
  228. // Tech is seeking or already waiting on another action, no action needed
  229. return true;
  230. }
  231. if (this.beforeSeekableWindow_(seekable, currentTime)) {
  232. var livePoint = seekable.end(seekable.length - 1);
  233. this.logger_('Fell out of live window at time ' + currentTime + '. Seeking to ' + ('live point (seekable end) ' + livePoint));
  234. this.cancelTimer_();
  235. this.tech_.setCurrentTime(livePoint);
  236. // live window resyncs may be useful for monitoring QoS
  237. this.tech_.trigger({ type: 'usage', name: 'hls-live-resync' });
  238. return true;
  239. }
  240. var buffered = this.tech_.buffered();
  241. var nextRange = _ranges2['default'].findNextRange(buffered, currentTime);
  242. if (this.videoUnderflow_(nextRange, buffered, currentTime)) {
  243. // Even though the video underflowed and was stuck in a gap, the audio overplayed
  244. // the gap, leading currentTime into a buffered range. Seeking to currentTime
  245. // allows the video to catch up to the audio position without losing any audio
  246. // (only suffering ~3 seconds of frozen video and a pause in audio playback).
  247. this.cancelTimer_();
  248. this.tech_.setCurrentTime(currentTime);
  249. // video underflow may be useful for monitoring QoS
  250. this.tech_.trigger({ type: 'usage', name: 'hls-video-underflow' });
  251. return true;
  252. }
  253. // check for gap
  254. if (nextRange.length > 0) {
  255. var difference = nextRange.start(0) - currentTime;
  256. this.logger_('Stopped at ' + currentTime + ', setting timer for ' + difference + ', seeking ' + ('to ' + nextRange.start(0)));
  257. this.timer_ = setTimeout(this.skipTheGap_.bind(this), difference * 1000, currentTime);
  258. return true;
  259. }
  260. // All checks failed. Returning false to indicate failure to correct waiting
  261. return false;
  262. }
  263. }, {
  264. key: 'afterSeekableWindow_',
  265. value: function afterSeekableWindow_(seekable, currentTime) {
  266. if (!seekable.length) {
  267. // we can't make a solid case if there's no seekable, default to false
  268. return false;
  269. }
  270. if (currentTime > seekable.end(seekable.length - 1) + _ranges2['default'].SAFE_TIME_DELTA) {
  271. return true;
  272. }
  273. return false;
  274. }
  275. }, {
  276. key: 'beforeSeekableWindow_',
  277. value: function beforeSeekableWindow_(seekable, currentTime) {
  278. if (seekable.length &&
  279. // can't fall before 0 and 0 seekable start identifies VOD stream
  280. seekable.start(0) > 0 && currentTime < seekable.start(0) - _ranges2['default'].SAFE_TIME_DELTA) {
  281. return true;
  282. }
  283. return false;
  284. }
  285. }, {
  286. key: 'videoUnderflow_',
  287. value: function videoUnderflow_(nextRange, buffered, currentTime) {
  288. if (nextRange.length === 0) {
  289. // Even if there is no available next range, there is still a possibility we are
  290. // stuck in a gap due to video underflow.
  291. var gap = this.gapFromVideoUnderflow_(buffered, currentTime);
  292. if (gap) {
  293. this.logger_('Encountered a gap in video from ' + gap.start + ' to ' + gap.end + '. ' + ('Seeking to current time ' + currentTime));
  294. return true;
  295. }
  296. }
  297. return false;
  298. }
  299. /**
  300. * Timer callback. If playback still has not proceeded, then we seek
  301. * to the start of the next buffered region.
  302. *
  303. * @private
  304. */
  305. }, {
  306. key: 'skipTheGap_',
  307. value: function skipTheGap_(scheduledCurrentTime) {
  308. var buffered = this.tech_.buffered();
  309. var currentTime = this.tech_.currentTime();
  310. var nextRange = _ranges2['default'].findNextRange(buffered, currentTime);
  311. this.cancelTimer_();
  312. if (nextRange.length === 0 || currentTime !== scheduledCurrentTime) {
  313. return;
  314. }
  315. this.logger_('skipTheGap_:', 'currentTime:', currentTime, 'scheduled currentTime:', scheduledCurrentTime, 'nextRange start:', nextRange.start(0));
  316. // only seek if we still have not played
  317. this.tech_.setCurrentTime(nextRange.start(0) + _ranges2['default'].TIME_FUDGE_FACTOR);
  318. this.tech_.trigger({ type: 'usage', name: 'hls-gap-skip' });
  319. }
  320. }, {
  321. key: 'gapFromVideoUnderflow_',
  322. value: function gapFromVideoUnderflow_(buffered, currentTime) {
  323. // At least in Chrome, if there is a gap in the video buffer, the audio will continue
  324. // playing for ~3 seconds after the video gap starts. This is done to account for
  325. // video buffer underflow/underrun (note that this is not done when there is audio
  326. // buffer underflow/underrun -- in that case the video will stop as soon as it
  327. // encounters the gap, as audio stalls are more noticeable/jarring to a user than
  328. // video stalls). The player's time will reflect the playthrough of audio, so the
  329. // time will appear as if we are in a buffered region, even if we are stuck in a
  330. // "gap."
  331. //
  332. // Example:
  333. // video buffer: 0 => 10.1, 10.2 => 20
  334. // audio buffer: 0 => 20
  335. // overall buffer: 0 => 10.1, 10.2 => 20
  336. // current time: 13
  337. //
  338. // Chrome's video froze at 10 seconds, where the video buffer encountered the gap,
  339. // however, the audio continued playing until it reached ~3 seconds past the gap
  340. // (13 seconds), at which point it stops as well. Since current time is past the
  341. // gap, findNextRange will return no ranges.
  342. //
  343. // To check for this issue, we see if there is a gap that starts somewhere within
  344. // a 3 second range (3 seconds +/- 1 second) back from our current time.
  345. var gaps = _ranges2['default'].findGaps(buffered);
  346. for (var i = 0; i < gaps.length; i++) {
  347. var start = gaps.start(i);
  348. var end = gaps.end(i);
  349. // gap is starts no more than 4 seconds back
  350. if (currentTime - start < 4 && currentTime - start > 2) {
  351. return {
  352. start: start,
  353. end: end
  354. };
  355. }
  356. }
  357. return null;
  358. }
  359. /**
  360. * A debugging logger noop that is set to console.log only if debugging
  361. * is enabled globally
  362. *
  363. * @private
  364. */
  365. }, {
  366. key: 'logger_',
  367. value: function logger_() {}
  368. }]);
  369. return PlaybackWatcher;
  370. })();
  371. exports['default'] = PlaybackWatcher;
  372. module.exports = exports['default'];