playback-watcher.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636
  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. import window from 'global/window';
  11. import * as Ranges from './ranges';
  12. import logger from './util/logger';
  13. // Set of events that reset the playback-watcher time check logic and clear the timeout
  14. const timerCancelEvents = [
  15. 'seeking',
  16. 'seeked',
  17. 'pause',
  18. 'playing',
  19. 'error'
  20. ];
  21. /**
  22. * @class PlaybackWatcher
  23. */
  24. export default class PlaybackWatcher {
  25. /**
  26. * Represents an PlaybackWatcher object.
  27. *
  28. * @class
  29. * @param {Object} options an object that includes the tech and settings
  30. */
  31. constructor(options) {
  32. this.playlistController_ = options.playlistController;
  33. this.tech_ = options.tech;
  34. this.seekable = options.seekable;
  35. this.allowSeeksWithinUnsafeLiveWindow = options.allowSeeksWithinUnsafeLiveWindow;
  36. this.liveRangeSafeTimeDelta = options.liveRangeSafeTimeDelta;
  37. this.media = options.media;
  38. this.consecutiveUpdates = 0;
  39. this.lastRecordedTime = null;
  40. this.checkCurrentTimeTimeout_ = null;
  41. this.logger_ = logger('PlaybackWatcher');
  42. this.logger_('initialize');
  43. const playHandler = () => this.monitorCurrentTime_();
  44. const canPlayHandler = () => this.monitorCurrentTime_();
  45. const waitingHandler = () => this.techWaiting_();
  46. const cancelTimerHandler = () => this.resetTimeUpdate_();
  47. const pc = this.playlistController_;
  48. const loaderTypes = ['main', 'subtitle', 'audio'];
  49. const loaderChecks = {};
  50. loaderTypes.forEach((type) => {
  51. loaderChecks[type] = {
  52. reset: () => this.resetSegmentDownloads_(type),
  53. updateend: () => this.checkSegmentDownloads_(type)
  54. };
  55. pc[`${type}SegmentLoader_`].on('appendsdone', loaderChecks[type].updateend);
  56. // If a rendition switch happens during a playback stall where the buffer
  57. // isn't changing we want to reset. We cannot assume that the new rendition
  58. // will also be stalled, until after new appends.
  59. pc[`${type}SegmentLoader_`].on('playlistupdate', loaderChecks[type].reset);
  60. // Playback stalls should not be detected right after seeking.
  61. // This prevents one segment playlists (single vtt or single segment content)
  62. // from being detected as stalling. As the buffer will not change in those cases, since
  63. // the buffer is the entire video duration.
  64. this.tech_.on(['seeked', 'seeking'], loaderChecks[type].reset);
  65. });
  66. /**
  67. * We check if a seek was into a gap through the following steps:
  68. * 1. We get a seeking event and we do not get a seeked event. This means that
  69. * a seek was attempted but not completed.
  70. * 2. We run `fixesBadSeeks_` on segment loader appends. This means that we already
  71. * removed everything from our buffer and appended a segment, and should be ready
  72. * to check for gaps.
  73. */
  74. const setSeekingHandlers = (fn) => {
  75. ['main', 'audio'].forEach((type) => {
  76. pc[`${type}SegmentLoader_`][fn]('appended', this.seekingAppendCheck_);
  77. });
  78. };
  79. this.seekingAppendCheck_ = () => {
  80. if (this.fixesBadSeeks_()) {
  81. this.consecutiveUpdates = 0;
  82. this.lastRecordedTime = this.tech_.currentTime();
  83. setSeekingHandlers('off');
  84. }
  85. };
  86. this.clearSeekingAppendCheck_ = () => setSeekingHandlers('off');
  87. this.watchForBadSeeking_ = () => {
  88. this.clearSeekingAppendCheck_();
  89. setSeekingHandlers('on');
  90. };
  91. this.tech_.on('seeked', this.clearSeekingAppendCheck_);
  92. this.tech_.on('seeking', this.watchForBadSeeking_);
  93. this.tech_.on('waiting', waitingHandler);
  94. this.tech_.on(timerCancelEvents, cancelTimerHandler);
  95. this.tech_.on('canplay', canPlayHandler);
  96. /*
  97. An edge case exists that results in gaps not being skipped when they exist at the beginning of a stream. This case
  98. is surfaced in one of two ways:
  99. 1) The `waiting` event is fired before the player has buffered content, making it impossible
  100. to find or skip the gap. The `waiting` event is followed by a `play` event. On first play
  101. we can check if playback is stalled due to a gap, and skip the gap if necessary.
  102. 2) A source with a gap at the beginning of the stream is loaded programatically while the player
  103. is in a playing state. To catch this case, it's important that our one-time play listener is setup
  104. even if the player is in a playing state
  105. */
  106. this.tech_.one('play', playHandler);
  107. // Define the dispose function to clean up our events
  108. this.dispose = () => {
  109. this.clearSeekingAppendCheck_();
  110. this.logger_('dispose');
  111. this.tech_.off('waiting', waitingHandler);
  112. this.tech_.off(timerCancelEvents, cancelTimerHandler);
  113. this.tech_.off('canplay', canPlayHandler);
  114. this.tech_.off('play', playHandler);
  115. this.tech_.off('seeking', this.watchForBadSeeking_);
  116. this.tech_.off('seeked', this.clearSeekingAppendCheck_);
  117. loaderTypes.forEach((type) => {
  118. pc[`${type}SegmentLoader_`].off('appendsdone', loaderChecks[type].updateend);
  119. pc[`${type}SegmentLoader_`].off('playlistupdate', loaderChecks[type].reset);
  120. this.tech_.off(['seeked', 'seeking'], loaderChecks[type].reset);
  121. });
  122. if (this.checkCurrentTimeTimeout_) {
  123. window.clearTimeout(this.checkCurrentTimeTimeout_);
  124. }
  125. this.resetTimeUpdate_();
  126. };
  127. }
  128. /**
  129. * Periodically check current time to see if playback stopped
  130. *
  131. * @private
  132. */
  133. monitorCurrentTime_() {
  134. this.checkCurrentTime_();
  135. if (this.checkCurrentTimeTimeout_) {
  136. window.clearTimeout(this.checkCurrentTimeTimeout_);
  137. }
  138. // 42 = 24 fps // 250 is what Webkit uses // FF uses 15
  139. this.checkCurrentTimeTimeout_ =
  140. window.setTimeout(this.monitorCurrentTime_.bind(this), 250);
  141. }
  142. /**
  143. * Reset stalled download stats for a specific type of loader
  144. *
  145. * @param {string} type
  146. * The segment loader type to check.
  147. *
  148. * @listens SegmentLoader#playlistupdate
  149. * @listens Tech#seeking
  150. * @listens Tech#seeked
  151. */
  152. resetSegmentDownloads_(type) {
  153. const loader = this.playlistController_[`${type}SegmentLoader_`];
  154. if (this[`${type}StalledDownloads_`] > 0) {
  155. this.logger_(`resetting possible stalled download count for ${type} loader`);
  156. }
  157. this[`${type}StalledDownloads_`] = 0;
  158. this[`${type}Buffered_`] = loader.buffered_();
  159. }
  160. /**
  161. * Checks on every segment `appendsdone` to see
  162. * if segment appends are making progress. If they are not
  163. * and we are still downloading bytes. We exclude the playlist.
  164. *
  165. * @param {string} type
  166. * The segment loader type to check.
  167. *
  168. * @listens SegmentLoader#appendsdone
  169. */
  170. checkSegmentDownloads_(type) {
  171. const pc = this.playlistController_;
  172. const loader = pc[`${type}SegmentLoader_`];
  173. const buffered = loader.buffered_();
  174. const isBufferedDifferent = Ranges.isRangeDifferent(this[`${type}Buffered_`], buffered);
  175. this[`${type}Buffered_`] = buffered;
  176. // if another watcher is going to fix the issue or
  177. // the buffered value for this loader changed
  178. // appends are working
  179. if (isBufferedDifferent) {
  180. this.resetSegmentDownloads_(type);
  181. return;
  182. }
  183. this[`${type}StalledDownloads_`]++;
  184. this.logger_(`found #${this[`${type}StalledDownloads_`]} ${type} appends that did not increase buffer (possible stalled download)`, {
  185. playlistId: loader.playlist_ && loader.playlist_.id,
  186. buffered: Ranges.timeRangesToArray(buffered)
  187. });
  188. // after 10 possibly stalled appends with no reset, exclude
  189. if (this[`${type}StalledDownloads_`] < 10) {
  190. return;
  191. }
  192. this.logger_(`${type} loader stalled download exclusion`);
  193. this.resetSegmentDownloads_(type);
  194. this.tech_.trigger({type: 'usage', name: `vhs-${type}-download-exclusion`});
  195. if (type === 'subtitle') {
  196. return;
  197. }
  198. // TODO: should we exclude audio tracks rather than main tracks
  199. // when type is audio?
  200. pc.excludePlaylist({
  201. error: { message: `Excessive ${type} segment downloading detected.` },
  202. playlistExclusionDuration: Infinity
  203. });
  204. }
  205. /**
  206. * The purpose of this function is to emulate the "waiting" event on
  207. * browsers that do not emit it when they are waiting for more
  208. * data to continue playback
  209. *
  210. * @private
  211. */
  212. checkCurrentTime_() {
  213. if (this.tech_.paused() || this.tech_.seeking()) {
  214. return;
  215. }
  216. const currentTime = this.tech_.currentTime();
  217. const buffered = this.tech_.buffered();
  218. if (this.lastRecordedTime === currentTime &&
  219. (!buffered.length ||
  220. currentTime + Ranges.SAFE_TIME_DELTA >= buffered.end(buffered.length - 1))) {
  221. // If current time is at the end of the final buffered region, then any playback
  222. // stall is most likely caused by buffering in a low bandwidth environment. The tech
  223. // should fire a `waiting` event in this scenario, but due to browser and tech
  224. // inconsistencies. Calling `techWaiting_` here allows us to simulate
  225. // responding to a native `waiting` event when the tech fails to emit one.
  226. return this.techWaiting_();
  227. }
  228. if (this.consecutiveUpdates >= 5 &&
  229. currentTime === this.lastRecordedTime) {
  230. this.consecutiveUpdates++;
  231. this.waiting_();
  232. } else if (currentTime === this.lastRecordedTime) {
  233. this.consecutiveUpdates++;
  234. } else {
  235. this.consecutiveUpdates = 0;
  236. this.lastRecordedTime = currentTime;
  237. }
  238. }
  239. /**
  240. * Resets the 'timeupdate' mechanism designed to detect that we are stalled
  241. *
  242. * @private
  243. */
  244. resetTimeUpdate_() {
  245. this.consecutiveUpdates = 0;
  246. }
  247. /**
  248. * Fixes situations where there's a bad seek
  249. *
  250. * @return {boolean} whether an action was taken to fix the seek
  251. * @private
  252. */
  253. fixesBadSeeks_() {
  254. const seeking = this.tech_.seeking();
  255. if (!seeking) {
  256. return false;
  257. }
  258. // TODO: It's possible that these seekable checks should be moved out of this function
  259. // and into a function that runs on seekablechange. It's also possible that we only need
  260. // afterSeekableWindow as the buffered check at the bottom is good enough to handle before
  261. // seekable range.
  262. const seekable = this.seekable();
  263. const currentTime = this.tech_.currentTime();
  264. const isAfterSeekableRange = this.afterSeekableWindow_(
  265. seekable,
  266. currentTime,
  267. this.media(),
  268. this.allowSeeksWithinUnsafeLiveWindow
  269. );
  270. let seekTo;
  271. if (isAfterSeekableRange) {
  272. const seekableEnd = seekable.end(seekable.length - 1);
  273. // sync to live point (if VOD, our seekable was updated and we're simply adjusting)
  274. seekTo = seekableEnd;
  275. }
  276. if (this.beforeSeekableWindow_(seekable, currentTime)) {
  277. const seekableStart = seekable.start(0);
  278. // sync to the beginning of the live window
  279. // provide a buffer of .1 seconds to handle rounding/imprecise numbers
  280. seekTo = seekableStart +
  281. // if the playlist is too short and the seekable range is an exact time (can
  282. // happen in live with a 3 segment playlist), then don't use a time delta
  283. (seekableStart === seekable.end(0) ? 0 : Ranges.SAFE_TIME_DELTA);
  284. }
  285. if (typeof seekTo !== 'undefined') {
  286. this.logger_(`Trying to seek outside of seekable at time ${currentTime} with ` +
  287. `seekable range ${Ranges.printableRange(seekable)}. Seeking to ` +
  288. `${seekTo}.`);
  289. this.tech_.setCurrentTime(seekTo);
  290. return true;
  291. }
  292. const sourceUpdater = this.playlistController_.sourceUpdater_;
  293. const buffered = this.tech_.buffered();
  294. const audioBuffered = sourceUpdater.audioBuffer ? sourceUpdater.audioBuffered() : null;
  295. const videoBuffered = sourceUpdater.videoBuffer ? sourceUpdater.videoBuffered() : null;
  296. const media = this.media();
  297. // verify that at least two segment durations or one part duration have been
  298. // appended before checking for a gap.
  299. const minAppendedDuration = media.partTargetDuration ? media.partTargetDuration :
  300. (media.targetDuration - Ranges.TIME_FUDGE_FACTOR) * 2;
  301. // verify that at least two segment durations have been
  302. // appended before checking for a gap.
  303. const bufferedToCheck = [audioBuffered, videoBuffered];
  304. for (let i = 0; i < bufferedToCheck.length; i++) {
  305. // skip null buffered
  306. if (!bufferedToCheck[i]) {
  307. continue;
  308. }
  309. const timeAhead = Ranges.timeAheadOf(bufferedToCheck[i], currentTime);
  310. // if we are less than two video/audio segment durations or one part
  311. // duration behind we haven't appended enough to call this a bad seek.
  312. if (timeAhead < minAppendedDuration) {
  313. return false;
  314. }
  315. }
  316. const nextRange = Ranges.findNextRange(buffered, currentTime);
  317. // we have appended enough content, but we don't have anything buffered
  318. // to seek over the gap
  319. if (nextRange.length === 0) {
  320. return false;
  321. }
  322. seekTo = nextRange.start(0) + Ranges.SAFE_TIME_DELTA;
  323. this.logger_(`Buffered region starts (${nextRange.start(0)}) ` +
  324. ` just beyond seek point (${currentTime}). Seeking to ${seekTo}.`);
  325. this.tech_.setCurrentTime(seekTo);
  326. return true;
  327. }
  328. /**
  329. * Handler for situations when we determine the player is waiting.
  330. *
  331. * @private
  332. */
  333. waiting_() {
  334. if (this.techWaiting_()) {
  335. return;
  336. }
  337. // All tech waiting checks failed. Use last resort correction
  338. const currentTime = this.tech_.currentTime();
  339. const buffered = this.tech_.buffered();
  340. const currentRange = Ranges.findRange(buffered, currentTime);
  341. // Sometimes the player can stall for unknown reasons within a contiguous buffered
  342. // region with no indication that anything is amiss (seen in Firefox). Seeking to
  343. // currentTime is usually enough to kickstart the player. This checks that the player
  344. // is currently within a buffered region before attempting a corrective seek.
  345. // Chrome does not appear to continue `timeupdate` events after a `waiting` event
  346. // until there is ~ 3 seconds of forward buffer available. PlaybackWatcher should also
  347. // make sure there is ~3 seconds of forward buffer before taking any corrective action
  348. // to avoid triggering an `unknownwaiting` event when the network is slow.
  349. if (currentRange.length && currentTime + 3 <= currentRange.end(0)) {
  350. this.resetTimeUpdate_();
  351. this.tech_.setCurrentTime(currentTime);
  352. this.logger_(`Stopped at ${currentTime} while inside a buffered region ` +
  353. `[${currentRange.start(0)} -> ${currentRange.end(0)}]. Attempting to resume ` +
  354. 'playback by seeking to the current time.');
  355. // unknown waiting corrections may be useful for monitoring QoS
  356. this.tech_.trigger({type: 'usage', name: 'vhs-unknown-waiting'});
  357. return;
  358. }
  359. }
  360. /**
  361. * Handler for situations when the tech fires a `waiting` event
  362. *
  363. * @return {boolean}
  364. * True if an action (or none) was needed to correct the waiting. False if no
  365. * checks passed
  366. * @private
  367. */
  368. techWaiting_() {
  369. const seekable = this.seekable();
  370. const currentTime = this.tech_.currentTime();
  371. if (this.tech_.seeking()) {
  372. // Tech is seeking or already waiting on another action, no action needed
  373. return true;
  374. }
  375. if (this.beforeSeekableWindow_(seekable, currentTime)) {
  376. const livePoint = seekable.end(seekable.length - 1);
  377. this.logger_(`Fell out of live window at time ${currentTime}. Seeking to ` +
  378. `live point (seekable end) ${livePoint}`);
  379. this.resetTimeUpdate_();
  380. this.tech_.setCurrentTime(livePoint);
  381. // live window resyncs may be useful for monitoring QoS
  382. this.tech_.trigger({type: 'usage', name: 'vhs-live-resync'});
  383. return true;
  384. }
  385. const sourceUpdater = this.tech_.vhs.playlistController_.sourceUpdater_;
  386. const buffered = this.tech_.buffered();
  387. const videoUnderflow = this.videoUnderflow_({
  388. audioBuffered: sourceUpdater.audioBuffered(),
  389. videoBuffered: sourceUpdater.videoBuffered(),
  390. currentTime
  391. });
  392. if (videoUnderflow) {
  393. // Even though the video underflowed and was stuck in a gap, the audio overplayed
  394. // the gap, leading currentTime into a buffered range. Seeking to currentTime
  395. // allows the video to catch up to the audio position without losing any audio
  396. // (only suffering ~3 seconds of frozen video and a pause in audio playback).
  397. this.resetTimeUpdate_();
  398. this.tech_.setCurrentTime(currentTime);
  399. // video underflow may be useful for monitoring QoS
  400. this.tech_.trigger({type: 'usage', name: 'vhs-video-underflow'});
  401. return true;
  402. }
  403. const nextRange = Ranges.findNextRange(buffered, currentTime);
  404. // check for gap
  405. if (nextRange.length > 0) {
  406. this.logger_(`Stopped at ${currentTime} and seeking to ${nextRange.start(0)}`);
  407. this.resetTimeUpdate_();
  408. this.skipTheGap_(currentTime);
  409. return true;
  410. }
  411. // All checks failed. Returning false to indicate failure to correct waiting
  412. return false;
  413. }
  414. afterSeekableWindow_(seekable, currentTime, playlist, allowSeeksWithinUnsafeLiveWindow = false) {
  415. if (!seekable.length) {
  416. // we can't make a solid case if there's no seekable, default to false
  417. return false;
  418. }
  419. let allowedEnd = seekable.end(seekable.length - 1) + Ranges.SAFE_TIME_DELTA;
  420. const isLive = !playlist.endList;
  421. const isLLHLS = typeof playlist.partTargetDuration === 'number';
  422. if (isLive && (isLLHLS || allowSeeksWithinUnsafeLiveWindow)) {
  423. allowedEnd = seekable.end(seekable.length - 1) + (playlist.targetDuration * 3);
  424. }
  425. if (currentTime > allowedEnd) {
  426. return true;
  427. }
  428. return false;
  429. }
  430. beforeSeekableWindow_(seekable, currentTime) {
  431. if (seekable.length &&
  432. // can't fall before 0 and 0 seekable start identifies VOD stream
  433. seekable.start(0) > 0 &&
  434. currentTime < seekable.start(0) - this.liveRangeSafeTimeDelta) {
  435. return true;
  436. }
  437. return false;
  438. }
  439. videoUnderflow_({videoBuffered, audioBuffered, currentTime}) {
  440. // audio only content will not have video underflow :)
  441. if (!videoBuffered) {
  442. return;
  443. }
  444. let gap;
  445. // find a gap in demuxed content.
  446. if (videoBuffered.length && audioBuffered.length) {
  447. // in Chrome audio will continue to play for ~3s when we run out of video
  448. // so we have to check that the video buffer did have some buffer in the
  449. // past.
  450. const lastVideoRange = Ranges.findRange(videoBuffered, currentTime - 3);
  451. const videoRange = Ranges.findRange(videoBuffered, currentTime);
  452. const audioRange = Ranges.findRange(audioBuffered, currentTime);
  453. if (audioRange.length && !videoRange.length && lastVideoRange.length) {
  454. gap = {start: lastVideoRange.end(0), end: audioRange.end(0)};
  455. }
  456. // find a gap in muxed content.
  457. } else {
  458. const nextRange = Ranges.findNextRange(videoBuffered, currentTime);
  459. // Even if there is no available next range, there is still a possibility we are
  460. // stuck in a gap due to video underflow.
  461. if (!nextRange.length) {
  462. gap = this.gapFromVideoUnderflow_(videoBuffered, currentTime);
  463. }
  464. }
  465. if (gap) {
  466. this.logger_(`Encountered a gap in video from ${gap.start} to ${gap.end}. ` +
  467. `Seeking to current time ${currentTime}`);
  468. return true;
  469. }
  470. return false;
  471. }
  472. /**
  473. * Timer callback. If playback still has not proceeded, then we seek
  474. * to the start of the next buffered region.
  475. *
  476. * @private
  477. */
  478. skipTheGap_(scheduledCurrentTime) {
  479. const buffered = this.tech_.buffered();
  480. const currentTime = this.tech_.currentTime();
  481. const nextRange = Ranges.findNextRange(buffered, currentTime);
  482. this.resetTimeUpdate_();
  483. if (nextRange.length === 0 ||
  484. currentTime !== scheduledCurrentTime) {
  485. return;
  486. }
  487. this.logger_(
  488. 'skipTheGap_:',
  489. 'currentTime:', currentTime,
  490. 'scheduled currentTime:', scheduledCurrentTime,
  491. 'nextRange start:', nextRange.start(0)
  492. );
  493. // only seek if we still have not played
  494. this.tech_.setCurrentTime(nextRange.start(0) + Ranges.TIME_FUDGE_FACTOR);
  495. this.tech_.trigger({type: 'usage', name: 'vhs-gap-skip'});
  496. }
  497. gapFromVideoUnderflow_(buffered, currentTime) {
  498. // At least in Chrome, if there is a gap in the video buffer, the audio will continue
  499. // playing for ~3 seconds after the video gap starts. This is done to account for
  500. // video buffer underflow/underrun (note that this is not done when there is audio
  501. // buffer underflow/underrun -- in that case the video will stop as soon as it
  502. // encounters the gap, as audio stalls are more noticeable/jarring to a user than
  503. // video stalls). The player's time will reflect the playthrough of audio, so the
  504. // time will appear as if we are in a buffered region, even if we are stuck in a
  505. // "gap."
  506. //
  507. // Example:
  508. // video buffer: 0 => 10.1, 10.2 => 20
  509. // audio buffer: 0 => 20
  510. // overall buffer: 0 => 10.1, 10.2 => 20
  511. // current time: 13
  512. //
  513. // Chrome's video froze at 10 seconds, where the video buffer encountered the gap,
  514. // however, the audio continued playing until it reached ~3 seconds past the gap
  515. // (13 seconds), at which point it stops as well. Since current time is past the
  516. // gap, findNextRange will return no ranges.
  517. //
  518. // To check for this issue, we see if there is a gap that starts somewhere within
  519. // a 3 second range (3 seconds +/- 1 second) back from our current time.
  520. const gaps = Ranges.findGaps(buffered);
  521. for (let i = 0; i < gaps.length; i++) {
  522. const start = gaps.start(i);
  523. const end = gaps.end(i);
  524. // gap is starts no more than 4 seconds back
  525. if (currentTime - start < 4 && currentTime - start > 2) {
  526. return {
  527. start,
  528. end
  529. };
  530. }
  531. }
  532. return null;
  533. }
  534. }