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