Home Reference Source

src/controller/gap-controller.js

  1. import { BufferHelper } from '../utils/buffer-helper';
  2. import { ErrorTypes, ErrorDetails } from '../errors';
  3. import Event from '../events';
  4. import { logger } from '../utils/logger';
  5.  
  6. export const STALL_MINIMUM_DURATION_MS = 250;
  7. export const MAX_START_GAP_JUMP = 2.0;
  8. export const SKIP_BUFFER_HOLE_STEP_SECONDS = 0.1;
  9. export const SKIP_BUFFER_RANGE_START = 0.05;
  10.  
  11. export default class GapController {
  12. constructor (config, media, fragmentTracker, hls) {
  13. this.config = config;
  14. this.media = media;
  15. this.fragmentTracker = fragmentTracker;
  16. this.hls = hls;
  17. this.nudgeRetry = 0;
  18. this.stallReported = false;
  19. this.stalled = null;
  20. this.moved = false;
  21. this.seeking = false;
  22. }
  23.  
  24. /**
  25. * Checks if the playhead is stuck within a gap, and if so, attempts to free it.
  26. * A gap is an unbuffered range between two buffered ranges (or the start and the first buffered range).
  27. *
  28. * @param {number} lastCurrentTime Previously read playhead position
  29. */
  30. poll (lastCurrentTime) {
  31. const { config, media, stalled } = this;
  32. const { currentTime, seeking } = media;
  33. const seeked = this.seeking && !seeking;
  34. const beginSeek = !this.seeking && seeking;
  35.  
  36. this.seeking = seeking;
  37.  
  38. // The playhead is moving, no-op
  39. if (currentTime !== lastCurrentTime) {
  40. this.moved = true;
  41. if (stalled !== null) {
  42. // The playhead is now moving, but was previously stalled
  43. if (this.stallReported) {
  44. const stalledDuration = self.performance.now() - stalled;
  45. logger.warn(`playback not stuck anymore @${currentTime}, after ${Math.round(stalledDuration)}ms`);
  46. this.stallReported = false;
  47. }
  48. this.stalled = null;
  49. this.nudgeRetry = 0;
  50. }
  51. return;
  52. }
  53.  
  54. // Clear stalled state when beginning or finishing seeking so that we don't report stalls coming out of a seek
  55. if (beginSeek || seeked) {
  56. this.stalled = null;
  57. }
  58.  
  59. // The playhead should not be moving
  60. if (media.paused || media.ended || media.playbackRate === 0 || !media.buffered.length) {
  61. return;
  62. }
  63.  
  64. const bufferInfo = BufferHelper.bufferInfo(media, currentTime, 0);
  65. const isBuffered = bufferInfo.len > 0;
  66. const nextStart = bufferInfo.nextStart || 0;
  67.  
  68. // There is no playable buffer (waiting for buffer append)
  69. if (!isBuffered && !nextStart) {
  70. return;
  71. }
  72.  
  73. if (seeking) {
  74. // Waiting for seeking in a buffered range to complete
  75. const hasEnoughBuffer = bufferInfo.len > MAX_START_GAP_JUMP;
  76. // Next buffered range is too far ahead to jump to while still seeking
  77. const noBufferGap = !nextStart || nextStart - currentTime > MAX_START_GAP_JUMP;
  78. if (hasEnoughBuffer || noBufferGap) {
  79. return;
  80. }
  81. // Reset moved state when seeking to a point in or before a gap
  82. this.moved = false;
  83. }
  84.  
  85. // Skip start gaps if we haven't played, but the last poll detected the start of a stall
  86. // The addition poll gives the browser a chance to jump the gap for us
  87. if (!this.moved && this.stalled) {
  88. // Jump start gaps within jump threshold
  89. const startJump = Math.max(nextStart, bufferInfo.start || 0) - currentTime;
  90. if (startJump > 0 && startJump <= MAX_START_GAP_JUMP) {
  91. this._trySkipBufferHole(null);
  92. return;
  93. }
  94. }
  95.  
  96. // Start tracking stall time
  97. const tnow = self.performance.now();
  98. if (stalled === null) {
  99. this.stalled = tnow;
  100. return;
  101. }
  102.  
  103. const stalledDuration = tnow - stalled;
  104. if (!seeking && stalledDuration >= STALL_MINIMUM_DURATION_MS) {
  105. // Report stalling after trying to fix
  106. this._reportStall(bufferInfo.len);
  107. }
  108.  
  109. const bufferedWithHoles = BufferHelper.bufferInfo(media, currentTime, config.maxBufferHole);
  110. this._tryFixBufferStall(bufferedWithHoles, stalledDuration);
  111. }
  112.  
  113. /**
  114. * Detects and attempts to fix known buffer stalling issues.
  115. * @param bufferInfo - The properties of the current buffer.
  116. * @param stalledDurationMs - The amount of time Hls.js has been stalling for.
  117. * @private
  118. */
  119. _tryFixBufferStall (bufferInfo, stalledDurationMs) {
  120. const { config, fragmentTracker, media } = this;
  121. const currentTime = media.currentTime;
  122.  
  123. const partial = fragmentTracker.getPartialFragment(currentTime);
  124. if (partial) {
  125. // Try to skip over the buffer hole caused by a partial fragment
  126. // This method isn't limited by the size of the gap between buffered ranges
  127. const targetTime = this._trySkipBufferHole(partial);
  128. // we return here in this case, meaning
  129. // the branch below only executes when we don't handle a partial fragment
  130. if (targetTime) {
  131. return;
  132. }
  133. }
  134.  
  135. // if we haven't had to skip over a buffer hole of a partial fragment
  136. // we may just have to "nudge" the playlist as the browser decoding/rendering engine
  137. // needs to cross some sort of threshold covering all source-buffers content
  138. // to start playing properly.
  139. if (bufferInfo.len > config.maxBufferHole &&
  140. stalledDurationMs > config.highBufferWatchdogPeriod * 1000) {
  141. logger.warn('Trying to nudge playhead over buffer-hole');
  142. // Try to nudge currentTime over a buffer hole if we've been stalling for the configured amount of seconds
  143. // We only try to jump the hole if it's under the configured size
  144. // Reset stalled so to rearm watchdog timer
  145. this.stalled = null;
  146. this._tryNudgeBuffer();
  147. }
  148. }
  149.  
  150. /**
  151. * Triggers a BUFFER_STALLED_ERROR event, but only once per stall period.
  152. * @param bufferLen - The playhead distance from the end of the current buffer segment.
  153. * @private
  154. */
  155. _reportStall (bufferLen) {
  156. const { hls, media, stallReported } = this;
  157. if (!stallReported) {
  158. // Report stalled error once
  159. this.stallReported = true;
  160. logger.warn(`Playback stalling at @${media.currentTime} due to low buffer`);
  161. hls.trigger(Event.ERROR, {
  162. type: ErrorTypes.MEDIA_ERROR,
  163. details: ErrorDetails.BUFFER_STALLED_ERROR,
  164. fatal: false,
  165. buffer: bufferLen
  166. });
  167. }
  168. }
  169.  
  170. /**
  171. * Attempts to fix buffer stalls by jumping over known gaps caused by partial fragments
  172. * @param partial - The partial fragment found at the current time (where playback is stalling).
  173. * @private
  174. */
  175. _trySkipBufferHole (partial) {
  176. const { config, hls, media } = this;
  177. const currentTime = media.currentTime;
  178. let lastEndTime = 0;
  179. // Check if currentTime is between unbuffered regions of partial fragments
  180. for (let i = 0; i < media.buffered.length; i++) {
  181. const startTime = media.buffered.start(i);
  182. if (currentTime + config.maxBufferHole >= lastEndTime && currentTime < startTime) {
  183. const targetTime = Math.max(startTime + SKIP_BUFFER_RANGE_START, media.currentTime + SKIP_BUFFER_HOLE_STEP_SECONDS);
  184. logger.warn(`skipping hole, adjusting currentTime from ${currentTime} to ${targetTime}`);
  185. this.moved = true;
  186. this.stalled = null;
  187. media.currentTime = targetTime;
  188. if (partial) {
  189. hls.trigger(Event.ERROR, {
  190. type: ErrorTypes.MEDIA_ERROR,
  191. details: ErrorDetails.BUFFER_SEEK_OVER_HOLE,
  192. fatal: false,
  193. reason: `fragment loaded with buffer holes, seeking from ${currentTime} to ${targetTime}`,
  194. frag: partial
  195. });
  196. }
  197. return targetTime;
  198. }
  199. lastEndTime = media.buffered.end(i);
  200. }
  201. return 0;
  202. }
  203.  
  204. /**
  205. * Attempts to fix buffer stalls by advancing the mediaElement's current time by a small amount.
  206. * @private
  207. */
  208. _tryNudgeBuffer () {
  209. const { config, hls, media } = this;
  210. const currentTime = media.currentTime;
  211. const nudgeRetry = (this.nudgeRetry || 0) + 1;
  212. this.nudgeRetry = nudgeRetry;
  213.  
  214. if (nudgeRetry < config.nudgeMaxRetry) {
  215. const targetTime = currentTime + nudgeRetry * config.nudgeOffset;
  216. // playback stalled in buffered area ... let's nudge currentTime to try to overcome this
  217. logger.warn(`Nudging 'currentTime' from ${currentTime} to ${targetTime}`);
  218. media.currentTime = targetTime;
  219.  
  220. hls.trigger(Event.ERROR, {
  221. type: ErrorTypes.MEDIA_ERROR,
  222. details: ErrorDetails.BUFFER_NUDGE_ON_STALL,
  223. fatal: false
  224. });
  225. } else {
  226. logger.error(`Playhead still not moving while enough data buffered @${currentTime} after ${config.nudgeMaxRetry} nudges`);
  227. hls.trigger(Event.ERROR, {
  228. type: ErrorTypes.MEDIA_ERROR,
  229. details: ErrorDetails.BUFFER_STALLED_ERROR,
  230. fatal: true
  231. });
  232. }
  233. }
  234. }