@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.
- package/LICENSE +28 -0
- package/README.md +472 -0
- package/dist/hls-demo.js +26995 -0
- package/dist/hls-demo.js.map +1 -0
- package/dist/hls.d.mts +4204 -0
- package/dist/hls.d.ts +4204 -0
- package/dist/hls.js +40050 -0
- package/dist/hls.js.d.ts +4204 -0
- package/dist/hls.js.map +1 -0
- package/dist/hls.light.js +27145 -0
- package/dist/hls.light.js.map +1 -0
- package/dist/hls.light.min.js +2 -0
- package/dist/hls.light.min.js.map +1 -0
- package/dist/hls.light.mjs +26392 -0
- package/dist/hls.light.mjs.map +1 -0
- package/dist/hls.min.js +2 -0
- package/dist/hls.min.js.map +1 -0
- package/dist/hls.mjs +38956 -0
- package/dist/hls.mjs.map +1 -0
- package/dist/hls.worker.js +2 -0
- package/dist/hls.worker.js.map +1 -0
- package/package.json +143 -0
- package/src/config.ts +794 -0
- package/src/controller/abr-controller.ts +1019 -0
- package/src/controller/algo-data-controller.ts +794 -0
- package/src/controller/audio-stream-controller.ts +1099 -0
- package/src/controller/audio-track-controller.ts +454 -0
- package/src/controller/base-playlist-controller.ts +438 -0
- package/src/controller/base-stream-controller.ts +2526 -0
- package/src/controller/buffer-controller.ts +2015 -0
- package/src/controller/buffer-operation-queue.ts +159 -0
- package/src/controller/cap-level-controller.ts +367 -0
- package/src/controller/cmcd-controller.ts +422 -0
- package/src/controller/content-steering-controller.ts +622 -0
- package/src/controller/eme-controller.ts +1617 -0
- package/src/controller/error-controller.ts +627 -0
- package/src/controller/fps-controller.ts +146 -0
- package/src/controller/fragment-finders.ts +256 -0
- package/src/controller/fragment-tracker.ts +567 -0
- package/src/controller/gap-controller.ts +719 -0
- package/src/controller/id3-track-controller.ts +488 -0
- package/src/controller/interstitial-player.ts +302 -0
- package/src/controller/interstitials-controller.ts +2895 -0
- package/src/controller/interstitials-schedule.ts +698 -0
- package/src/controller/latency-controller.ts +294 -0
- package/src/controller/level-controller.ts +776 -0
- package/src/controller/stream-controller.ts +1597 -0
- package/src/controller/subtitle-stream-controller.ts +508 -0
- package/src/controller/subtitle-track-controller.ts +617 -0
- package/src/controller/timeline-controller.ts +677 -0
- package/src/crypt/aes-crypto.ts +36 -0
- package/src/crypt/aes-decryptor.ts +339 -0
- package/src/crypt/decrypter-aes-mode.ts +4 -0
- package/src/crypt/decrypter.ts +225 -0
- package/src/crypt/fast-aes-key.ts +39 -0
- package/src/define-plugin.d.ts +17 -0
- package/src/demux/audio/aacdemuxer.ts +126 -0
- package/src/demux/audio/ac3-demuxer.ts +170 -0
- package/src/demux/audio/adts.ts +249 -0
- package/src/demux/audio/base-audio-demuxer.ts +205 -0
- package/src/demux/audio/dolby.ts +21 -0
- package/src/demux/audio/mp3demuxer.ts +85 -0
- package/src/demux/audio/mpegaudio.ts +177 -0
- package/src/demux/chunk-cache.ts +42 -0
- package/src/demux/dummy-demuxed-track.ts +13 -0
- package/src/demux/inject-worker.ts +75 -0
- package/src/demux/mp4demuxer.ts +234 -0
- package/src/demux/sample-aes.ts +198 -0
- package/src/demux/transmuxer-interface.ts +449 -0
- package/src/demux/transmuxer-worker.ts +221 -0
- package/src/demux/transmuxer.ts +560 -0
- package/src/demux/tsdemuxer.ts +1256 -0
- package/src/demux/video/avc-video-parser.ts +401 -0
- package/src/demux/video/base-video-parser.ts +198 -0
- package/src/demux/video/exp-golomb.ts +153 -0
- package/src/demux/video/hevc-video-parser.ts +736 -0
- package/src/empty-es.js +5 -0
- package/src/empty.js +3 -0
- package/src/errors.ts +107 -0
- package/src/events.ts +548 -0
- package/src/exports-default.ts +3 -0
- package/src/exports-named.ts +81 -0
- package/src/hls.ts +1613 -0
- package/src/is-supported.ts +54 -0
- package/src/loader/date-range.ts +207 -0
- package/src/loader/fragment-loader.ts +403 -0
- package/src/loader/fragment.ts +487 -0
- package/src/loader/interstitial-asset-list.ts +162 -0
- package/src/loader/interstitial-event.ts +337 -0
- package/src/loader/key-loader.ts +439 -0
- package/src/loader/level-details.ts +203 -0
- package/src/loader/level-key.ts +259 -0
- package/src/loader/load-stats.ts +17 -0
- package/src/loader/m3u8-parser.ts +1072 -0
- package/src/loader/playlist-loader.ts +839 -0
- package/src/polyfills/number.ts +15 -0
- package/src/remux/aac-helper.ts +81 -0
- package/src/remux/mp4-generator.ts +1380 -0
- package/src/remux/mp4-remuxer.ts +1261 -0
- package/src/remux/passthrough-remuxer.ts +434 -0
- package/src/task-loop.ts +130 -0
- package/src/types/algo.ts +44 -0
- package/src/types/buffer.ts +105 -0
- package/src/types/component-api.ts +20 -0
- package/src/types/demuxer.ts +208 -0
- package/src/types/events.ts +574 -0
- package/src/types/fragment-tracker.ts +23 -0
- package/src/types/level.ts +268 -0
- package/src/types/loader.ts +198 -0
- package/src/types/media-playlist.ts +92 -0
- package/src/types/network-details.ts +3 -0
- package/src/types/remuxer.ts +104 -0
- package/src/types/track.ts +12 -0
- package/src/types/transmuxer.ts +46 -0
- package/src/types/tuples.ts +6 -0
- package/src/types/vtt.ts +11 -0
- package/src/utils/arrays.ts +22 -0
- package/src/utils/attr-list.ts +192 -0
- package/src/utils/binary-search.ts +46 -0
- package/src/utils/buffer-helper.ts +173 -0
- package/src/utils/cea-608-parser.ts +1413 -0
- package/src/utils/chunker.ts +41 -0
- package/src/utils/codecs.ts +314 -0
- package/src/utils/cues.ts +96 -0
- package/src/utils/discontinuities.ts +174 -0
- package/src/utils/encryption-methods-util.ts +21 -0
- package/src/utils/error-helper.ts +95 -0
- package/src/utils/event-listener-helper.ts +16 -0
- package/src/utils/ewma-bandwidth-estimator.ts +97 -0
- package/src/utils/ewma.ts +43 -0
- package/src/utils/fetch-loader.ts +331 -0
- package/src/utils/global.ts +2 -0
- package/src/utils/hash.ts +10 -0
- package/src/utils/hdr.ts +67 -0
- package/src/utils/hex.ts +32 -0
- package/src/utils/imsc1-ttml-parser.ts +261 -0
- package/src/utils/keysystem-util.ts +45 -0
- package/src/utils/level-helper.ts +629 -0
- package/src/utils/logger.ts +120 -0
- package/src/utils/media-option-attributes.ts +49 -0
- package/src/utils/mediacapabilities-helper.ts +301 -0
- package/src/utils/mediakeys-helper.ts +210 -0
- package/src/utils/mediasource-helper.ts +37 -0
- package/src/utils/mp4-tools.ts +1473 -0
- package/src/utils/number.ts +3 -0
- package/src/utils/numeric-encoding-utils.ts +26 -0
- package/src/utils/output-filter.ts +46 -0
- package/src/utils/rendition-helper.ts +505 -0
- package/src/utils/safe-json-stringify.ts +22 -0
- package/src/utils/texttrack-utils.ts +164 -0
- package/src/utils/time-ranges.ts +17 -0
- package/src/utils/timescale-conversion.ts +46 -0
- package/src/utils/utf8-utils.ts +18 -0
- package/src/utils/variable-substitution.ts +105 -0
- package/src/utils/vttcue.ts +384 -0
- package/src/utils/vttparser.ts +497 -0
- package/src/utils/webvtt-parser.ts +166 -0
- package/src/utils/xhr-loader.ts +337 -0
- 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
|
+
}
|