Home Reference Source

src/controller/gap-controller.ts

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