@zenvor/hls.js 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +472 -0
  3. package/dist/hls-demo.js +26995 -0
  4. package/dist/hls-demo.js.map +1 -0
  5. package/dist/hls.d.mts +4204 -0
  6. package/dist/hls.d.ts +4204 -0
  7. package/dist/hls.js +40050 -0
  8. package/dist/hls.js.d.ts +4204 -0
  9. package/dist/hls.js.map +1 -0
  10. package/dist/hls.light.js +27145 -0
  11. package/dist/hls.light.js.map +1 -0
  12. package/dist/hls.light.min.js +2 -0
  13. package/dist/hls.light.min.js.map +1 -0
  14. package/dist/hls.light.mjs +26392 -0
  15. package/dist/hls.light.mjs.map +1 -0
  16. package/dist/hls.min.js +2 -0
  17. package/dist/hls.min.js.map +1 -0
  18. package/dist/hls.mjs +38956 -0
  19. package/dist/hls.mjs.map +1 -0
  20. package/dist/hls.worker.js +2 -0
  21. package/dist/hls.worker.js.map +1 -0
  22. package/package.json +143 -0
  23. package/src/config.ts +794 -0
  24. package/src/controller/abr-controller.ts +1019 -0
  25. package/src/controller/algo-data-controller.ts +794 -0
  26. package/src/controller/audio-stream-controller.ts +1099 -0
  27. package/src/controller/audio-track-controller.ts +454 -0
  28. package/src/controller/base-playlist-controller.ts +438 -0
  29. package/src/controller/base-stream-controller.ts +2526 -0
  30. package/src/controller/buffer-controller.ts +2015 -0
  31. package/src/controller/buffer-operation-queue.ts +159 -0
  32. package/src/controller/cap-level-controller.ts +367 -0
  33. package/src/controller/cmcd-controller.ts +422 -0
  34. package/src/controller/content-steering-controller.ts +622 -0
  35. package/src/controller/eme-controller.ts +1617 -0
  36. package/src/controller/error-controller.ts +627 -0
  37. package/src/controller/fps-controller.ts +146 -0
  38. package/src/controller/fragment-finders.ts +256 -0
  39. package/src/controller/fragment-tracker.ts +567 -0
  40. package/src/controller/gap-controller.ts +719 -0
  41. package/src/controller/id3-track-controller.ts +488 -0
  42. package/src/controller/interstitial-player.ts +302 -0
  43. package/src/controller/interstitials-controller.ts +2895 -0
  44. package/src/controller/interstitials-schedule.ts +698 -0
  45. package/src/controller/latency-controller.ts +294 -0
  46. package/src/controller/level-controller.ts +776 -0
  47. package/src/controller/stream-controller.ts +1597 -0
  48. package/src/controller/subtitle-stream-controller.ts +508 -0
  49. package/src/controller/subtitle-track-controller.ts +617 -0
  50. package/src/controller/timeline-controller.ts +677 -0
  51. package/src/crypt/aes-crypto.ts +36 -0
  52. package/src/crypt/aes-decryptor.ts +339 -0
  53. package/src/crypt/decrypter-aes-mode.ts +4 -0
  54. package/src/crypt/decrypter.ts +225 -0
  55. package/src/crypt/fast-aes-key.ts +39 -0
  56. package/src/define-plugin.d.ts +17 -0
  57. package/src/demux/audio/aacdemuxer.ts +126 -0
  58. package/src/demux/audio/ac3-demuxer.ts +170 -0
  59. package/src/demux/audio/adts.ts +249 -0
  60. package/src/demux/audio/base-audio-demuxer.ts +205 -0
  61. package/src/demux/audio/dolby.ts +21 -0
  62. package/src/demux/audio/mp3demuxer.ts +85 -0
  63. package/src/demux/audio/mpegaudio.ts +177 -0
  64. package/src/demux/chunk-cache.ts +42 -0
  65. package/src/demux/dummy-demuxed-track.ts +13 -0
  66. package/src/demux/inject-worker.ts +75 -0
  67. package/src/demux/mp4demuxer.ts +234 -0
  68. package/src/demux/sample-aes.ts +198 -0
  69. package/src/demux/transmuxer-interface.ts +449 -0
  70. package/src/demux/transmuxer-worker.ts +221 -0
  71. package/src/demux/transmuxer.ts +560 -0
  72. package/src/demux/tsdemuxer.ts +1256 -0
  73. package/src/demux/video/avc-video-parser.ts +401 -0
  74. package/src/demux/video/base-video-parser.ts +198 -0
  75. package/src/demux/video/exp-golomb.ts +153 -0
  76. package/src/demux/video/hevc-video-parser.ts +736 -0
  77. package/src/empty-es.js +5 -0
  78. package/src/empty.js +3 -0
  79. package/src/errors.ts +107 -0
  80. package/src/events.ts +548 -0
  81. package/src/exports-default.ts +3 -0
  82. package/src/exports-named.ts +81 -0
  83. package/src/hls.ts +1613 -0
  84. package/src/is-supported.ts +54 -0
  85. package/src/loader/date-range.ts +207 -0
  86. package/src/loader/fragment-loader.ts +403 -0
  87. package/src/loader/fragment.ts +487 -0
  88. package/src/loader/interstitial-asset-list.ts +162 -0
  89. package/src/loader/interstitial-event.ts +337 -0
  90. package/src/loader/key-loader.ts +439 -0
  91. package/src/loader/level-details.ts +203 -0
  92. package/src/loader/level-key.ts +259 -0
  93. package/src/loader/load-stats.ts +17 -0
  94. package/src/loader/m3u8-parser.ts +1072 -0
  95. package/src/loader/playlist-loader.ts +839 -0
  96. package/src/polyfills/number.ts +15 -0
  97. package/src/remux/aac-helper.ts +81 -0
  98. package/src/remux/mp4-generator.ts +1380 -0
  99. package/src/remux/mp4-remuxer.ts +1261 -0
  100. package/src/remux/passthrough-remuxer.ts +434 -0
  101. package/src/task-loop.ts +130 -0
  102. package/src/types/algo.ts +44 -0
  103. package/src/types/buffer.ts +105 -0
  104. package/src/types/component-api.ts +20 -0
  105. package/src/types/demuxer.ts +208 -0
  106. package/src/types/events.ts +574 -0
  107. package/src/types/fragment-tracker.ts +23 -0
  108. package/src/types/level.ts +268 -0
  109. package/src/types/loader.ts +198 -0
  110. package/src/types/media-playlist.ts +92 -0
  111. package/src/types/network-details.ts +3 -0
  112. package/src/types/remuxer.ts +104 -0
  113. package/src/types/track.ts +12 -0
  114. package/src/types/transmuxer.ts +46 -0
  115. package/src/types/tuples.ts +6 -0
  116. package/src/types/vtt.ts +11 -0
  117. package/src/utils/arrays.ts +22 -0
  118. package/src/utils/attr-list.ts +192 -0
  119. package/src/utils/binary-search.ts +46 -0
  120. package/src/utils/buffer-helper.ts +173 -0
  121. package/src/utils/cea-608-parser.ts +1413 -0
  122. package/src/utils/chunker.ts +41 -0
  123. package/src/utils/codecs.ts +314 -0
  124. package/src/utils/cues.ts +96 -0
  125. package/src/utils/discontinuities.ts +174 -0
  126. package/src/utils/encryption-methods-util.ts +21 -0
  127. package/src/utils/error-helper.ts +95 -0
  128. package/src/utils/event-listener-helper.ts +16 -0
  129. package/src/utils/ewma-bandwidth-estimator.ts +97 -0
  130. package/src/utils/ewma.ts +43 -0
  131. package/src/utils/fetch-loader.ts +331 -0
  132. package/src/utils/global.ts +2 -0
  133. package/src/utils/hash.ts +10 -0
  134. package/src/utils/hdr.ts +67 -0
  135. package/src/utils/hex.ts +32 -0
  136. package/src/utils/imsc1-ttml-parser.ts +261 -0
  137. package/src/utils/keysystem-util.ts +45 -0
  138. package/src/utils/level-helper.ts +629 -0
  139. package/src/utils/logger.ts +120 -0
  140. package/src/utils/media-option-attributes.ts +49 -0
  141. package/src/utils/mediacapabilities-helper.ts +301 -0
  142. package/src/utils/mediakeys-helper.ts +210 -0
  143. package/src/utils/mediasource-helper.ts +37 -0
  144. package/src/utils/mp4-tools.ts +1473 -0
  145. package/src/utils/number.ts +3 -0
  146. package/src/utils/numeric-encoding-utils.ts +26 -0
  147. package/src/utils/output-filter.ts +46 -0
  148. package/src/utils/rendition-helper.ts +505 -0
  149. package/src/utils/safe-json-stringify.ts +22 -0
  150. package/src/utils/texttrack-utils.ts +164 -0
  151. package/src/utils/time-ranges.ts +17 -0
  152. package/src/utils/timescale-conversion.ts +46 -0
  153. package/src/utils/utf8-utils.ts +18 -0
  154. package/src/utils/variable-substitution.ts +105 -0
  155. package/src/utils/vttcue.ts +384 -0
  156. package/src/utils/vttparser.ts +497 -0
  157. package/src/utils/webvtt-parser.ts +166 -0
  158. package/src/utils/xhr-loader.ts +337 -0
  159. package/src/version.ts +1 -0
@@ -0,0 +1,719 @@
1
+ import { State } from './base-stream-controller';
2
+ import { ErrorDetails, ErrorTypes } from '../errors';
3
+ import { Events } from '../events';
4
+ import TaskLoop from '../task-loop';
5
+ import { PlaylistLevelType } from '../types/loader';
6
+ import { BufferHelper } from '../utils/buffer-helper';
7
+ import {
8
+ addEventListener,
9
+ removeEventListener,
10
+ } from '../utils/event-listener-helper';
11
+ import { stringify } from '../utils/safe-json-stringify';
12
+ import type { InFlightData } from './base-stream-controller';
13
+ import type { InFlightFragments } from '../hls';
14
+ import type Hls from '../hls';
15
+ import type { FragmentTracker } from './fragment-tracker';
16
+ import type { Fragment, MediaFragment, Part } from '../loader/fragment';
17
+ import type { SourceBufferName } from '../types/buffer';
18
+ import type {
19
+ BufferAppendedData,
20
+ MediaAttachedData,
21
+ MediaDetachingData,
22
+ } from '../types/events';
23
+ import type { ErrorData } from '../types/events';
24
+ import type { BufferInfo } from '../utils/buffer-helper';
25
+
26
+ export const MAX_START_GAP_JUMP = 2.0;
27
+ const TICK_INTERVAL = 100;
28
+
29
+ export default class GapController extends TaskLoop {
30
+ private hls: Hls | null;
31
+ private fragmentTracker: FragmentTracker | null;
32
+ private media: HTMLMediaElement | null = null;
33
+ private mediaSource?: MediaSource;
34
+
35
+ private nudgeRetry: number = 0;
36
+ private skipRetry: number = 0;
37
+ private stallReported: boolean = false;
38
+ private stalled: number | null = null;
39
+ private moved: boolean = false;
40
+ private seeking: boolean = false;
41
+ private buffered: Partial<Record<SourceBufferName, TimeRanges>> = {};
42
+
43
+ private lastCurrentTime: number = 0;
44
+ public ended: number = 0;
45
+ public waiting: number = 0;
46
+
47
+ constructor(hls: Hls, fragmentTracker: FragmentTracker) {
48
+ super('gap-controller', hls.logger);
49
+ this.hls = hls;
50
+ this.fragmentTracker = fragmentTracker;
51
+ this.registerListeners();
52
+ }
53
+
54
+ private registerListeners() {
55
+ const { hls } = this;
56
+ if (hls) {
57
+ hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
58
+ hls.on(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
59
+ hls.on(Events.BUFFER_APPENDED, this.onBufferAppended, this);
60
+ }
61
+ }
62
+
63
+ private unregisterListeners() {
64
+ const { hls } = this;
65
+ if (hls) {
66
+ hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
67
+ hls.off(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
68
+ hls.off(Events.BUFFER_APPENDED, this.onBufferAppended, this);
69
+ }
70
+ }
71
+
72
+ public destroy() {
73
+ super.destroy();
74
+ this.unregisterListeners();
75
+ this.media = this.hls = this.fragmentTracker = null;
76
+ this.mediaSource = undefined;
77
+ }
78
+
79
+ private onMediaAttached(
80
+ event: Events.MEDIA_ATTACHED,
81
+ data: MediaAttachedData,
82
+ ) {
83
+ this.setInterval(TICK_INTERVAL);
84
+ this.mediaSource = data.mediaSource;
85
+ const media = (this.media = data.media);
86
+ addEventListener(media, 'playing', this.onMediaPlaying);
87
+ addEventListener(media, 'waiting', this.onMediaWaiting);
88
+ addEventListener(media, 'ended', this.onMediaEnded);
89
+ }
90
+
91
+ private onMediaDetaching(
92
+ event: Events.MEDIA_DETACHING,
93
+ data: MediaDetachingData,
94
+ ) {
95
+ this.clearInterval();
96
+ const { media } = this;
97
+ if (media) {
98
+ removeEventListener(media, 'playing', this.onMediaPlaying);
99
+ removeEventListener(media, 'waiting', this.onMediaWaiting);
100
+ removeEventListener(media, 'ended', this.onMediaEnded);
101
+ this.media = null;
102
+ }
103
+ this.mediaSource = undefined;
104
+ }
105
+
106
+ private onBufferAppended(
107
+ event: Events.BUFFER_APPENDED,
108
+ data: BufferAppendedData,
109
+ ) {
110
+ this.buffered = data.timeRanges;
111
+ }
112
+
113
+ private onMediaPlaying = () => {
114
+ this.ended = 0;
115
+ this.waiting = 0;
116
+ };
117
+
118
+ private onMediaWaiting = () => {
119
+ if (this.media?.seeking) {
120
+ return;
121
+ }
122
+ this.waiting = self.performance.now();
123
+ this.tick();
124
+ };
125
+
126
+ private onMediaEnded = () => {
127
+ if (this.hls) {
128
+ // ended is set when triggering MEDIA_ENDED so that we do not trigger it again on stall or on tick with media.ended
129
+ this.ended = this.media?.currentTime || 1;
130
+ this.hls.trigger(Events.MEDIA_ENDED, {
131
+ stalled: false,
132
+ });
133
+ }
134
+ };
135
+
136
+ public get hasBuffered(): boolean {
137
+ return Object.keys(this.buffered).length > 0;
138
+ }
139
+
140
+ public tick() {
141
+ if (!this.media?.readyState || !this.hasBuffered) {
142
+ return;
143
+ }
144
+
145
+ const currentTime = this.media.currentTime;
146
+ this.poll(currentTime, this.lastCurrentTime);
147
+ this.lastCurrentTime = currentTime;
148
+ }
149
+
150
+ /**
151
+ * Checks if the playhead is stuck within a gap, and if so, attempts to free it.
152
+ * A gap is an unbuffered range between two buffered ranges (or the start and the first buffered range).
153
+ *
154
+ * @param lastCurrentTime - Previously read playhead position
155
+ */
156
+ public poll(currentTime: number, lastCurrentTime: number) {
157
+ const config = this.hls?.config;
158
+ if (!config) {
159
+ return;
160
+ }
161
+ const media = this.media;
162
+ if (!media) {
163
+ return;
164
+ }
165
+ const { seeking } = media;
166
+ const seeked = this.seeking && !seeking;
167
+ const beginSeek = !this.seeking && seeking;
168
+ const pausedEndedOrHalted =
169
+ (media.paused && !seeking) || media.ended || media.playbackRate === 0;
170
+
171
+ this.seeking = seeking;
172
+
173
+ // The playhead is moving, no-op
174
+ if (currentTime !== lastCurrentTime) {
175
+ if (lastCurrentTime) {
176
+ this.ended = 0;
177
+ }
178
+ this.moved = true;
179
+ if (!seeking) {
180
+ this.skipRetry = this.nudgeRetry = 0;
181
+ // When crossing between buffered video time ranges, but not audio, flush pipeline with seek (Chrome)
182
+ if (
183
+ config.nudgeOnVideoHole &&
184
+ !pausedEndedOrHalted &&
185
+ currentTime > lastCurrentTime
186
+ ) {
187
+ this.nudgeOnVideoHole(currentTime, lastCurrentTime);
188
+ }
189
+ }
190
+ if (this.waiting === 0) {
191
+ this.stallResolved(currentTime);
192
+ }
193
+ return;
194
+ }
195
+
196
+ // Clear stalled state when beginning or finishing seeking so that we don't report stalls coming out of a seek
197
+ if (beginSeek || seeked) {
198
+ if (seeked) {
199
+ this.stallResolved(currentTime);
200
+ }
201
+ return;
202
+ }
203
+
204
+ // The playhead should not be moving
205
+ if (pausedEndedOrHalted) {
206
+ this.skipRetry = this.nudgeRetry = 0;
207
+ this.stallResolved(currentTime);
208
+ // Fire MEDIA_ENDED to workaround event not being dispatched by browser
209
+ if (!this.ended && media.ended && this.hls) {
210
+ this.ended = currentTime || 1;
211
+ this.hls.trigger(Events.MEDIA_ENDED, {
212
+ stalled: false,
213
+ });
214
+ }
215
+ return;
216
+ }
217
+
218
+ if (!BufferHelper.getBuffered(media).length) {
219
+ this.skipRetry = this.nudgeRetry = 0;
220
+ return;
221
+ }
222
+
223
+ // Resolve stalls at buffer holes using the main buffer, whose ranges are the intersections of the A/V sourcebuffers
224
+ const bufferInfo = BufferHelper.bufferInfo(media, currentTime, 0);
225
+ const nextStart = bufferInfo.nextStart || 0;
226
+ const fragmentTracker = this.fragmentTracker;
227
+
228
+ if (seeking && fragmentTracker && this.hls) {
229
+ // Is there a fragment loading/parsing/appending before currentTime?
230
+ const inFlightDependency = getInFlightDependency(
231
+ this.hls.inFlightFragments,
232
+ currentTime,
233
+ );
234
+
235
+ // Waiting for seeking in a buffered range to complete
236
+ const hasEnoughBuffer = bufferInfo.len > MAX_START_GAP_JUMP;
237
+ // Next buffered range is too far ahead to jump to while still seeking
238
+ const noBufferHole =
239
+ !nextStart ||
240
+ inFlightDependency ||
241
+ (nextStart - currentTime > MAX_START_GAP_JUMP &&
242
+ !fragmentTracker.getPartialFragment(currentTime));
243
+ if (hasEnoughBuffer || noBufferHole) {
244
+ return;
245
+ }
246
+ // Reset moved state when seeking to a point in or before a gap/hole
247
+ this.moved = false;
248
+ }
249
+
250
+ // Skip start gaps if we haven't played, but the last poll detected the start of a stall
251
+ // The addition poll gives the browser a chance to jump the gap for us
252
+ const levelDetails = this.hls?.latestLevelDetails;
253
+ if (!this.moved && this.stalled !== null && fragmentTracker) {
254
+ // There is no playable buffer (seeked, waiting for buffer)
255
+ const isBuffered = bufferInfo.len > 0;
256
+ if (!isBuffered && !nextStart) {
257
+ return;
258
+ }
259
+ // Jump start gaps within jump threshold
260
+ const startJump =
261
+ Math.max(nextStart, bufferInfo.start || 0) - currentTime;
262
+
263
+ // When joining a live stream with audio tracks, account for live playlist window sliding by allowing
264
+ // a larger jump over start gaps caused by the audio-stream-controller buffering a start fragment
265
+ // that begins over 1 target duration after the video start position.
266
+ const isLive = !!levelDetails?.live;
267
+ const maxStartGapJump = isLive
268
+ ? levelDetails!.targetduration * 2
269
+ : MAX_START_GAP_JUMP;
270
+ const appended = appendedFragAtPosition(currentTime, fragmentTracker);
271
+ if (startJump > 0 && (startJump <= maxStartGapJump || appended)) {
272
+ if (!media.paused) {
273
+ this._trySkipBufferHole(appended);
274
+ }
275
+ return;
276
+ }
277
+ }
278
+
279
+ // Start tracking stall time
280
+ const detectStallWithCurrentTimeMs = config.detectStallWithCurrentTimeMs;
281
+ const tnow = self.performance.now();
282
+ const tWaiting = this.waiting;
283
+ let stalled = this.stalled;
284
+ if (stalled === null) {
285
+ // Use time of recent "waiting" event
286
+ if (tWaiting > 0 && tnow - tWaiting < detectStallWithCurrentTimeMs) {
287
+ stalled = this.stalled = tWaiting;
288
+ } else {
289
+ this.stalled = tnow;
290
+ return;
291
+ }
292
+ }
293
+
294
+ const stalledDuration = tnow - stalled;
295
+ if (
296
+ !seeking &&
297
+ (stalledDuration >= detectStallWithCurrentTimeMs || tWaiting) &&
298
+ this.hls
299
+ ) {
300
+ // Dispatch MEDIA_ENDED when media.ended/ended event is not signalled at end of stream
301
+ if (
302
+ this.mediaSource?.readyState === 'ended' &&
303
+ !levelDetails?.live &&
304
+ Math.abs(currentTime - (levelDetails?.edge || 0)) < 1
305
+ ) {
306
+ if (this.ended) {
307
+ return;
308
+ }
309
+ this.ended = currentTime || 1;
310
+ this.hls.trigger(Events.MEDIA_ENDED, {
311
+ stalled: true,
312
+ });
313
+ return;
314
+ }
315
+ // Report stalling after trying to fix
316
+ this._reportStall(bufferInfo);
317
+ if (!this.media || (!this.hls as any)) {
318
+ return;
319
+ }
320
+ }
321
+
322
+ const bufferedWithHoles = BufferHelper.bufferInfo(
323
+ media,
324
+ currentTime,
325
+ config.maxBufferHole,
326
+ );
327
+ this._tryFixBufferStall(bufferedWithHoles, stalledDuration, currentTime);
328
+ }
329
+
330
+ private stallResolved(currentTime: number) {
331
+ const stalled = this.stalled;
332
+ if (stalled && this.hls) {
333
+ this.stalled = null;
334
+ // The playhead is now moving, but was previously stalled
335
+ if (this.stallReported) {
336
+ const stalledDuration = self.performance.now() - stalled;
337
+ this.log(
338
+ `playback not stuck anymore @${currentTime}, after ${Math.round(
339
+ stalledDuration,
340
+ )}ms`,
341
+ );
342
+ this.stallReported = false;
343
+ this.waiting = 0;
344
+ this.hls.trigger(Events.STALL_RESOLVED, {});
345
+ }
346
+ }
347
+ }
348
+
349
+ private nudgeOnVideoHole(currentTime: number, lastCurrentTime: number) {
350
+ // Chrome will play one second past a hole in video buffered time ranges without rendering any video from the subsequent range and then stall as long as audio is buffered:
351
+ // https://github.com/video-dev/hls.js/issues/5631
352
+ // https://issues.chromium.org/issues/40280613#comment10
353
+ // Detect the potential for this situation and proactively seek to flush the video pipeline once the playhead passes the start of the video hole.
354
+ // When there are audio and video buffers and currentTime is past the end of the first video buffered range...
355
+ const videoSourceBuffered = this.buffered.video;
356
+ if (
357
+ this.hls &&
358
+ this.media &&
359
+ this.fragmentTracker &&
360
+ this.buffered.audio?.length &&
361
+ videoSourceBuffered &&
362
+ videoSourceBuffered.length > 1 &&
363
+ currentTime > videoSourceBuffered.end(0)
364
+ ) {
365
+ // and audio is buffered at the playhead
366
+ const audioBufferInfo = BufferHelper.bufferedInfo(
367
+ BufferHelper.timeRangesToArray(this.buffered.audio),
368
+ currentTime,
369
+ 0,
370
+ );
371
+ if (audioBufferInfo.len > 1 && lastCurrentTime >= audioBufferInfo.start) {
372
+ const videoTimes = BufferHelper.timeRangesToArray(videoSourceBuffered);
373
+ const lastBufferedIndex = BufferHelper.bufferedInfo(
374
+ videoTimes,
375
+ lastCurrentTime,
376
+ 0,
377
+ ).bufferedIndex;
378
+ // nudge when crossing into another video buffered range (hole).
379
+ if (
380
+ lastBufferedIndex > -1 &&
381
+ lastBufferedIndex < videoTimes.length - 1
382
+ ) {
383
+ const bufferedIndex = BufferHelper.bufferedInfo(
384
+ videoTimes,
385
+ currentTime,
386
+ 0,
387
+ ).bufferedIndex;
388
+ const holeStart = videoTimes[lastBufferedIndex].end;
389
+ const holeEnd = videoTimes[lastBufferedIndex + 1].start;
390
+ if (
391
+ (bufferedIndex === -1 || bufferedIndex > lastBufferedIndex) &&
392
+ holeEnd - holeStart < 1 && // `maxBufferHole` may be too small and setting it to 0 should not disable this feature
393
+ currentTime - holeStart < 2
394
+ ) {
395
+ const error = new Error(
396
+ `nudging playhead to flush pipeline after video hole. currentTime: ${currentTime} hole: ${holeStart} -> ${holeEnd} buffered index: ${bufferedIndex}`,
397
+ );
398
+ this.warn(error.message);
399
+ // Magic number to flush the pipeline without interuption to audio playback:
400
+ this.media.currentTime += 0.000001;
401
+ let frag: MediaFragment | Part | null | undefined =
402
+ appendedFragAtPosition(currentTime, this.fragmentTracker);
403
+ if (frag && 'fragment' in frag) {
404
+ frag = frag.fragment;
405
+ } else if (!frag) {
406
+ frag = undefined;
407
+ }
408
+ const bufferInfo = BufferHelper.bufferInfo(
409
+ this.media,
410
+ currentTime,
411
+ 0,
412
+ );
413
+ this.hls.trigger(Events.ERROR, {
414
+ type: ErrorTypes.MEDIA_ERROR,
415
+ details: ErrorDetails.BUFFER_SEEK_OVER_HOLE,
416
+ fatal: false,
417
+ error,
418
+ reason: error.message,
419
+ frag,
420
+ buffer: bufferInfo.len,
421
+ bufferInfo,
422
+ });
423
+ }
424
+ }
425
+ }
426
+ }
427
+ }
428
+
429
+ /**
430
+ * Detects and attempts to fix known buffer stalling issues.
431
+ * @param bufferInfo - The properties of the current buffer.
432
+ * @param stalledDurationMs - The amount of time Hls.js has been stalling for.
433
+ * @private
434
+ */
435
+ private _tryFixBufferStall(
436
+ bufferInfo: BufferInfo,
437
+ stalledDurationMs: number,
438
+ currentTime: number,
439
+ ) {
440
+ const { fragmentTracker, media } = this;
441
+ const config = this.hls?.config;
442
+ if (!media || !fragmentTracker || !config) {
443
+ return;
444
+ }
445
+
446
+ const levelDetails = this.hls?.latestLevelDetails;
447
+ const appended = appendedFragAtPosition(currentTime, fragmentTracker);
448
+ if (
449
+ appended ||
450
+ (levelDetails?.live && currentTime < levelDetails.fragmentStart)
451
+ ) {
452
+ // Try to skip over the buffer hole caused by a partial fragment
453
+ // This method isn't limited by the size of the gap between buffered ranges
454
+ const targetTime = this._trySkipBufferHole(appended);
455
+ // we return here in this case, meaning
456
+ // the branch below only executes when we haven't seeked to a new position
457
+ if (targetTime || !this.media) {
458
+ return;
459
+ }
460
+ }
461
+
462
+ // if we haven't had to skip over a buffer hole of a partial fragment
463
+ // we may just have to "nudge" the playlist as the browser decoding/rendering engine
464
+ // needs to cross some sort of threshold covering all source-buffers content
465
+ // to start playing properly.
466
+ const bufferedRanges = bufferInfo.buffered;
467
+ const adjacentTraversal = this.adjacentTraversal(bufferInfo, currentTime);
468
+ if (
469
+ ((bufferedRanges &&
470
+ bufferedRanges.length > 1 &&
471
+ bufferInfo.len > config.maxBufferHole) ||
472
+ (bufferInfo.nextStart &&
473
+ (bufferInfo.nextStart - currentTime < config.maxBufferHole ||
474
+ adjacentTraversal))) &&
475
+ (stalledDurationMs > config.highBufferWatchdogPeriod * 1000 ||
476
+ this.waiting)
477
+ ) {
478
+ this.warn('Trying to nudge playhead over buffer-hole');
479
+ // Try to nudge currentTime over a buffer hole if we've been stalling for the configured amount of seconds
480
+ // We only try to jump the hole if it's under the configured size
481
+ this._tryNudgeBuffer(bufferInfo);
482
+ }
483
+ }
484
+
485
+ private adjacentTraversal(bufferInfo: BufferInfo, currentTime: number) {
486
+ const fragmentTracker = this.fragmentTracker;
487
+ const nextStart = bufferInfo.nextStart;
488
+ if (fragmentTracker && nextStart) {
489
+ const current = fragmentTracker.getFragAtPos(
490
+ currentTime,
491
+ PlaylistLevelType.MAIN,
492
+ );
493
+ const next = fragmentTracker.getFragAtPos(
494
+ nextStart,
495
+ PlaylistLevelType.MAIN,
496
+ );
497
+ if (current && next) {
498
+ return next.sn - current.sn < 2;
499
+ }
500
+ }
501
+ return false;
502
+ }
503
+
504
+ /**
505
+ * Triggers a BUFFER_STALLED_ERROR event, but only once per stall period.
506
+ * @param bufferLen - The playhead distance from the end of the current buffer segment.
507
+ * @private
508
+ */
509
+ private _reportStall(bufferInfo: BufferInfo) {
510
+ const { hls, media, stallReported, stalled } = this;
511
+ if (!stallReported && stalled !== null && media && hls) {
512
+ // Report stalled error once
513
+ this.stallReported = true;
514
+ const error = new Error(
515
+ `Playback stalling at @${
516
+ media.currentTime
517
+ } due to low buffer (${stringify(bufferInfo)})`,
518
+ );
519
+ this.warn(error.message);
520
+ hls.trigger(Events.ERROR, {
521
+ type: ErrorTypes.MEDIA_ERROR,
522
+ details: ErrorDetails.BUFFER_STALLED_ERROR,
523
+ fatal: false,
524
+ error,
525
+ buffer: bufferInfo.len,
526
+ bufferInfo,
527
+ stalled: { start: stalled },
528
+ });
529
+ }
530
+ }
531
+
532
+ /**
533
+ * Attempts to fix buffer stalls by jumping over known gaps caused by partial fragments
534
+ * @param appended - The fragment or part found at the current time (where playback is stalling).
535
+ * @private
536
+ */
537
+ private _trySkipBufferHole(appended: MediaFragment | Part | null): number {
538
+ const { fragmentTracker, media } = this;
539
+ const config = this.hls?.config;
540
+ if (!media || !fragmentTracker || !config) {
541
+ return 0;
542
+ }
543
+
544
+ // Check if currentTime is between unbuffered regions of partial fragments
545
+ const currentTime = media.currentTime;
546
+ const bufferInfo = BufferHelper.bufferInfo(media, currentTime, 0);
547
+ const startTime =
548
+ currentTime < bufferInfo.start ? bufferInfo.start : bufferInfo.nextStart;
549
+ if (startTime && this.hls) {
550
+ const bufferStarved = bufferInfo.len <= config.maxBufferHole;
551
+ const waiting =
552
+ bufferInfo.len > 0 && bufferInfo.len < 1 && media.readyState < 3;
553
+ const gapLength = startTime - currentTime;
554
+ if (gapLength > 0 && (bufferStarved || waiting)) {
555
+ // Only allow large gaps to be skipped if it is a start gap, or all fragments in skip range are partial
556
+ if (gapLength > config.maxBufferHole) {
557
+ let startGap = false;
558
+ if (currentTime === 0) {
559
+ const startFrag = fragmentTracker.getAppendedFrag(
560
+ 0,
561
+ PlaylistLevelType.MAIN,
562
+ );
563
+ if (startFrag && startTime < startFrag.end) {
564
+ startGap = true;
565
+ }
566
+ }
567
+ if (!startGap && appended) {
568
+ // Do not seek when selected variant playlist is unloaded
569
+ if (!this.hls.loadLevelObj?.details) {
570
+ return 0;
571
+ }
572
+ // Do not seek when required fragments are inflight or appending
573
+ const inFlightDependency = getInFlightDependency(
574
+ this.hls.inFlightFragments,
575
+ startTime,
576
+ );
577
+ if (inFlightDependency) {
578
+ return 0;
579
+ }
580
+ // Do not seek if we can't walk tracked fragments to end of gap
581
+ let moreToLoad = false;
582
+ let pos = appended.end;
583
+ while (pos < startTime) {
584
+ const provisioned = appendedFragAtPosition(pos, fragmentTracker);
585
+ const duration = provisioned ? provisioned.duration : 0;
586
+ if (duration > 0) {
587
+ pos += duration;
588
+ } else {
589
+ moreToLoad = true;
590
+ break;
591
+ }
592
+ }
593
+ if (moreToLoad) {
594
+ return 0;
595
+ }
596
+ }
597
+ }
598
+ const { nudgeMaxRetry, skipBufferHolePadding } = config;
599
+ const fatal = ++this.skipRetry > nudgeMaxRetry;
600
+ const targetTime =
601
+ Math.max(startTime, currentTime) + skipBufferHolePadding;
602
+ if (!fatal) {
603
+ this.warn(
604
+ `skipping hole, adjusting currentTime from ${currentTime} to ${targetTime}`,
605
+ );
606
+ this.moved = true;
607
+ media.currentTime = targetTime;
608
+ }
609
+ if (!appended?.gap || fatal) {
610
+ const error = new Error(
611
+ fatal
612
+ ? `Playhead still not moving after seeking over buffer hole from ${currentTime} to ${targetTime} after ${config.nudgeMaxRetry} attempts.`
613
+ : `fragment loaded with buffer holes, seeking from ${currentTime} to ${targetTime}`,
614
+ );
615
+ const errorData: ErrorData = {
616
+ type: ErrorTypes.MEDIA_ERROR,
617
+ details: ErrorDetails.BUFFER_SEEK_OVER_HOLE,
618
+ fatal,
619
+ error,
620
+ reason: error.message,
621
+ buffer: bufferInfo.len,
622
+ bufferInfo,
623
+ };
624
+ if (appended) {
625
+ if ('fragment' in appended) {
626
+ errorData.part = appended;
627
+ } else {
628
+ errorData.frag = appended;
629
+ }
630
+ }
631
+ this.hls.trigger(Events.ERROR, errorData);
632
+ }
633
+ return targetTime;
634
+ }
635
+ }
636
+ return 0;
637
+ }
638
+
639
+ /**
640
+ * Attempts to fix buffer stalls by advancing the mediaElement's current time by a small amount.
641
+ * @private
642
+ */
643
+ private _tryNudgeBuffer(bufferInfo: BufferInfo) {
644
+ const { hls, media, nudgeRetry } = this;
645
+ const config = hls?.config;
646
+ if (!media || !config) {
647
+ return 0;
648
+ }
649
+ const currentTime = media.currentTime;
650
+ this.nudgeRetry++;
651
+
652
+ if (nudgeRetry < config.nudgeMaxRetry) {
653
+ const targetTime = currentTime + (nudgeRetry + 1) * config.nudgeOffset;
654
+ // playback stalled in buffered area ... let's nudge currentTime to try to overcome this
655
+ const error = new Error(
656
+ `Nudging 'currentTime' from ${currentTime} to ${targetTime}`,
657
+ );
658
+ this.warn(error.message);
659
+ media.currentTime = targetTime;
660
+ hls.trigger(Events.ERROR, {
661
+ type: ErrorTypes.MEDIA_ERROR,
662
+ details: ErrorDetails.BUFFER_NUDGE_ON_STALL,
663
+ error,
664
+ fatal: false,
665
+ buffer: bufferInfo.len,
666
+ bufferInfo,
667
+ });
668
+ } else {
669
+ const error = new Error(
670
+ `Playhead still not moving while enough data buffered @${currentTime} after ${config.nudgeMaxRetry} nudges`,
671
+ );
672
+ this.error(error.message);
673
+ hls.trigger(Events.ERROR, {
674
+ type: ErrorTypes.MEDIA_ERROR,
675
+ details: ErrorDetails.BUFFER_STALLED_ERROR,
676
+ error,
677
+ fatal: true,
678
+ buffer: bufferInfo.len,
679
+ bufferInfo,
680
+ });
681
+ }
682
+ }
683
+ }
684
+
685
+ function getInFlightDependency(
686
+ inFlightFragments: InFlightFragments,
687
+ currentTime: number,
688
+ ): Fragment | null {
689
+ const main = inFlight(inFlightFragments.main);
690
+ if (main && main.start <= currentTime) {
691
+ return main;
692
+ }
693
+ const audio = inFlight(inFlightFragments.audio);
694
+ if (audio && audio.start <= currentTime) {
695
+ return audio;
696
+ }
697
+ return null;
698
+ }
699
+
700
+ function inFlight(inFlightData: InFlightData | undefined): Fragment | null {
701
+ if (!inFlightData) {
702
+ return null;
703
+ }
704
+ switch (inFlightData.state) {
705
+ case State.IDLE:
706
+ case State.STOPPED:
707
+ case State.ENDED:
708
+ case State.ERROR:
709
+ return null;
710
+ }
711
+ return inFlightData.frag;
712
+ }
713
+
714
+ function appendedFragAtPosition(pos: number, fragmentTracker: FragmentTracker) {
715
+ return (
716
+ fragmentTracker.getAppendedFrag(pos, PlaylistLevelType.MAIN) ||
717
+ fragmentTracker.getPartialFragment(pos)
718
+ );
719
+ }