hls.js 1.6.0-beta.2.0.canary.10924 → 1.6.0-beta.2.0.canary.10926

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.
@@ -1,44 +1,149 @@
1
1
  import { State } from './base-stream-controller';
2
2
  import { ErrorDetails, ErrorTypes } from '../errors';
3
3
  import { Events } from '../events';
4
+ import TaskLoop from '../task-loop';
4
5
  import { PlaylistLevelType } from '../types/loader';
5
6
  import { BufferHelper } from '../utils/buffer-helper';
6
- import { Logger } from '../utils/logger';
7
+ import {
8
+ addEventListener,
9
+ removeEventListener,
10
+ } from '../utils/event-listener-helper';
11
+ import type { InFlightData } from './base-stream-controller';
12
+ import type { InFlightFragments } from '../hls';
7
13
  import type Hls from '../hls';
8
14
  import type { FragmentTracker } from './fragment-tracker';
9
- import type { Fragment } from '../loader/fragment';
10
- import type { LevelDetails } from '../loader/level-details';
15
+ import type { Fragment, MediaFragment } from '../loader/fragment';
16
+ import type { SourceBufferName } from '../types/buffer';
17
+ import type {
18
+ BufferAppendedData,
19
+ MediaAttachedData,
20
+ MediaDetachingData,
21
+ } from '../types/events';
11
22
  import type { BufferInfo } from '../utils/buffer-helper';
12
23
 
13
24
  export const MAX_START_GAP_JUMP = 2.0;
14
25
  export const SKIP_BUFFER_HOLE_STEP_SECONDS = 0.1;
15
26
  export const SKIP_BUFFER_RANGE_START = 0.05;
27
+ const TICK_INTERVAL = 100;
16
28
 
17
- export default class GapController extends Logger {
18
- private media: HTMLMediaElement | null = null;
19
- private fragmentTracker: FragmentTracker | null = null;
29
+ export default class GapController extends TaskLoop {
20
30
  private hls: Hls | null = null;
31
+ private fragmentTracker: FragmentTracker | null = null;
32
+ private media: HTMLMediaElement | null = null;
33
+ private mediaSource?: MediaSource;
34
+
21
35
  private nudgeRetry: number = 0;
22
36
  private stallReported: boolean = false;
23
37
  private stalled: number | null = null;
24
38
  private moved: boolean = false;
25
39
  private seeking: boolean = false;
40
+ private buffered: Partial<Record<SourceBufferName, TimeRanges>> = {};
41
+
42
+ private lastCurrentTime: number = 0;
26
43
  public ended: number = 0;
27
44
  public waiting: number = 0;
28
45
 
29
- constructor(
30
- media: HTMLMediaElement,
31
- fragmentTracker: FragmentTracker,
32
- hls: Hls,
33
- ) {
46
+ constructor(hls: Hls, fragmentTracker: FragmentTracker) {
34
47
  super('gap-controller', hls.logger);
35
- this.media = media;
36
- this.fragmentTracker = fragmentTracker;
37
48
  this.hls = hls;
49
+ this.fragmentTracker = fragmentTracker;
50
+ this.registerListeners();
51
+ }
52
+
53
+ private registerListeners() {
54
+ const { hls } = this;
55
+ if (hls) {
56
+ hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
57
+ hls.on(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
58
+ hls.on(Events.BUFFER_APPENDED, this.onBufferAppended, this);
59
+ }
60
+ }
61
+
62
+ private unregisterListeners() {
63
+ const { hls } = this;
64
+ if (hls) {
65
+ hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
66
+ hls.off(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
67
+ hls.off(Events.BUFFER_APPENDED, this.onBufferAppended, this);
68
+ }
38
69
  }
39
70
 
40
71
  public destroy() {
72
+ super.destroy();
73
+ this.unregisterListeners();
41
74
  this.media = this.hls = this.fragmentTracker = null;
75
+ this.mediaSource = undefined;
76
+ }
77
+
78
+ private onMediaAttached(
79
+ event: Events.MEDIA_ATTACHED,
80
+ data: MediaAttachedData,
81
+ ) {
82
+ this.setInterval(TICK_INTERVAL);
83
+ this.mediaSource = data.mediaSource;
84
+ const media = (this.media = data.media);
85
+ addEventListener(media, 'playing', this.onMediaPlaying);
86
+ addEventListener(media, 'waiting', this.onMediaWaiting);
87
+ addEventListener(media, 'ended', this.onMediaEnded);
88
+ }
89
+
90
+ private onMediaDetaching(
91
+ event: Events.MEDIA_DETACHING,
92
+ data: MediaDetachingData,
93
+ ) {
94
+ this.clearInterval();
95
+ const { media } = this;
96
+ if (media) {
97
+ removeEventListener(media, 'playing', this.onMediaPlaying);
98
+ removeEventListener(media, 'waiting', this.onMediaWaiting);
99
+ removeEventListener(media, 'ended', this.onMediaEnded);
100
+ this.media = null;
101
+ }
102
+ this.mediaSource = undefined;
103
+ }
104
+
105
+ private onBufferAppended(
106
+ event: Events.BUFFER_APPENDED,
107
+ data: BufferAppendedData,
108
+ ) {
109
+ this.buffered = data.timeRanges;
110
+ }
111
+
112
+ private onMediaPlaying = () => {
113
+ this.ended = 0;
114
+ this.waiting = 0;
115
+ };
116
+
117
+ private onMediaWaiting = () => {
118
+ if (this.media?.seeking) {
119
+ return;
120
+ }
121
+ this.waiting = self.performance.now();
122
+ this.tick();
123
+ };
124
+
125
+ private onMediaEnded = () => {
126
+ if (this.hls) {
127
+ // ended is set when triggering MEDIA_ENDED so that we do not trigger it again on stall or on tick with media.ended
128
+ this.ended = this.media?.currentTime || 1;
129
+ this.hls.trigger(Events.MEDIA_ENDED, {
130
+ stalled: false,
131
+ });
132
+ }
133
+ };
134
+
135
+ public get hasBuffered(): boolean {
136
+ return Object.keys(this.buffered).length > 0;
137
+ }
138
+
139
+ public tick() {
140
+ if (!this.media?.readyState || !this.hasBuffered) {
141
+ return;
142
+ }
143
+
144
+ const currentTime = this.media.currentTime;
145
+ this.poll(currentTime, this.lastCurrentTime);
146
+ this.lastCurrentTime = currentTime;
42
147
  }
43
148
 
44
149
  /**
@@ -47,20 +152,20 @@ export default class GapController extends Logger {
47
152
  *
48
153
  * @param lastCurrentTime - Previously read playhead position
49
154
  */
50
- public poll(
51
- lastCurrentTime: number,
52
- activeFrag: Fragment | null,
53
- levelDetails: LevelDetails | undefined,
54
- state: string,
55
- ) {
155
+ public poll(currentTime: number, lastCurrentTime: number) {
156
+ const config = this.hls?.config;
157
+ if (!config) {
158
+ return;
159
+ }
56
160
  const { media, stalled } = this;
57
-
58
161
  if (!media) {
59
162
  return;
60
163
  }
61
- const { currentTime, seeking } = media;
164
+ const { seeking } = media;
62
165
  const seeked = this.seeking && !seeking;
63
166
  const beginSeek = !this.seeking && seeking;
167
+ const pausedEndedOrHalted =
168
+ (media.paused && !seeking) || media.ended || media.playbackRate === 0;
64
169
 
65
170
  this.seeking = seeking;
66
171
 
@@ -72,6 +177,14 @@ export default class GapController extends Logger {
72
177
  this.moved = true;
73
178
  if (!seeking) {
74
179
  this.nudgeRetry = 0;
180
+ // When crossing between buffered video time ranges, but not audio, flush pipeline with seek (Chrome)
181
+ if (
182
+ config.nudgeOnVideoHole &&
183
+ !pausedEndedOrHalted &&
184
+ currentTime > lastCurrentTime
185
+ ) {
186
+ this.nudgeOnVideoHole(currentTime, lastCurrentTime);
187
+ }
75
188
  }
76
189
  if (this.waiting === 0) {
77
190
  this.stallResolved(currentTime);
@@ -88,7 +201,7 @@ export default class GapController extends Logger {
88
201
  }
89
202
 
90
203
  // The playhead should not be moving
91
- if ((media.paused && !seeking) || media.ended || media.playbackRate === 0) {
204
+ if (pausedEndedOrHalted) {
92
205
  this.nudgeRetry = 0;
93
206
  this.stallResolved(currentTime);
94
207
  // Fire MEDIA_ENDED to workaround event not being dispatched by browser
@@ -106,28 +219,36 @@ export default class GapController extends Logger {
106
219
  return;
107
220
  }
108
221
 
222
+ // Resolve stalls at buffer holes using the main buffer, whose ranges are the intersections of the A/V sourcebuffers
109
223
  const bufferInfo = BufferHelper.bufferInfo(media, currentTime, 0);
110
224
  const nextStart = bufferInfo.nextStart || 0;
111
225
  const fragmentTracker = this.fragmentTracker;
112
226
 
113
- if (seeking && fragmentTracker) {
227
+ if (seeking && fragmentTracker && this.hls) {
228
+ // Is there a fragment loading/parsing/appending before currentTime?
229
+ const inFlightDependency = getInFlightDependency(
230
+ this.hls.inFlightFragments,
231
+ currentTime,
232
+ );
233
+
114
234
  // Waiting for seeking in a buffered range to complete
115
235
  const hasEnoughBuffer = bufferInfo.len > MAX_START_GAP_JUMP;
116
236
  // Next buffered range is too far ahead to jump to while still seeking
117
- const noBufferGap =
237
+ const noBufferHole =
118
238
  !nextStart ||
119
- (activeFrag && activeFrag.start <= currentTime) ||
239
+ inFlightDependency ||
120
240
  (nextStart - currentTime > MAX_START_GAP_JUMP &&
121
241
  !fragmentTracker.getPartialFragment(currentTime));
122
- if (hasEnoughBuffer || noBufferGap) {
242
+ if (hasEnoughBuffer || noBufferHole) {
123
243
  return;
124
244
  }
125
- // Reset moved state when seeking to a point in or before a gap
245
+ // Reset moved state when seeking to a point in or before a gap/hole
126
246
  this.moved = false;
127
247
  }
128
248
 
129
249
  // Skip start gaps if we haven't played, but the last poll detected the start of a stall
130
250
  // The addition poll gives the browser a chance to jump the gap for us
251
+ const levelDetails = this.hls?.latestLevelDetails;
131
252
  if (!this.moved && this.stalled !== null && fragmentTracker) {
132
253
  // There is no playable buffer (seeked, waiting for buffer)
133
254
  const isBuffered = bufferInfo.len > 0;
@@ -155,10 +276,6 @@ export default class GapController extends Logger {
155
276
  }
156
277
 
157
278
  // Start tracking stall time
158
- const config = this.hls?.config;
159
- if (!config) {
160
- return;
161
- }
162
279
  const detectStallWithCurrentTimeMs = config.detectStallWithCurrentTimeMs;
163
280
  const tnow = self.performance.now();
164
281
  const tWaiting = this.waiting;
@@ -180,7 +297,7 @@ export default class GapController extends Logger {
180
297
  ) {
181
298
  // Dispatch MEDIA_ENDED when media.ended/ended event is not signalled at end of stream
182
299
  if (
183
- state === State.ENDED &&
300
+ this.mediaSource?.readyState === 'ended' &&
184
301
  !levelDetails?.live &&
185
302
  Math.abs(currentTime - (levelDetails?.edge || 0)) < 1
186
303
  ) {
@@ -215,7 +332,7 @@ export default class GapController extends Logger {
215
332
  // The playhead is now moving, but was previously stalled
216
333
  if (this.stallReported) {
217
334
  const stalledDuration = self.performance.now() - stalled;
218
- this.warn(
335
+ this.log(
219
336
  `playback not stuck anymore @${currentTime}, after ${Math.round(
220
337
  stalledDuration,
221
338
  )}ms`,
@@ -227,6 +344,81 @@ export default class GapController extends Logger {
227
344
  }
228
345
  }
229
346
 
347
+ private nudgeOnVideoHole(currentTime: number, lastCurrentTime: number) {
348
+ // 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:
349
+ // https://github.com/video-dev/hls.js/issues/5631
350
+ // https://issues.chromium.org/issues/40280613#comment10
351
+ // Detect the potential for this situation and proactively seek to flush the video pipeline once the playhead passes the start of the video hole.
352
+ // When there are audio and video buffers and currentTime is past the end of the first video buffered range...
353
+ const videoSourceBuffered = this.buffered.video;
354
+ if (
355
+ this.hls &&
356
+ this.media &&
357
+ this.fragmentTracker &&
358
+ this.buffered.audio?.length &&
359
+ videoSourceBuffered &&
360
+ videoSourceBuffered.length > 1 &&
361
+ currentTime > videoSourceBuffered.end(0)
362
+ ) {
363
+ // and audio is buffered at the playhead
364
+ const audioBufferInfo = BufferHelper.bufferedInfo(
365
+ BufferHelper.timeRangesToArray(this.buffered.audio),
366
+ currentTime,
367
+ 0,
368
+ );
369
+ if (audioBufferInfo.len > 1 && lastCurrentTime >= audioBufferInfo.start) {
370
+ const videoTimes = BufferHelper.timeRangesToArray(videoSourceBuffered);
371
+ const lastBufferedIndex = BufferHelper.bufferedInfo(
372
+ videoTimes,
373
+ lastCurrentTime,
374
+ 0,
375
+ ).bufferedIndex;
376
+ // nudge when crossing into another video buffered range (hole).
377
+ if (
378
+ lastBufferedIndex > -1 &&
379
+ lastBufferedIndex < videoTimes.length - 1
380
+ ) {
381
+ const bufferedIndex = BufferHelper.bufferedInfo(
382
+ videoTimes,
383
+ currentTime,
384
+ 0,
385
+ ).bufferedIndex;
386
+ const holeStart = videoTimes[lastBufferedIndex].end;
387
+ const holeEnd = videoTimes[lastBufferedIndex + 1].start;
388
+ if (
389
+ (bufferedIndex === -1 || bufferedIndex > lastBufferedIndex) &&
390
+ holeEnd - holeStart < 1 && // `maxBufferHole` may be too small and setting it to 0 should not disable this feature
391
+ currentTime - holeStart < 2
392
+ ) {
393
+ const error = new Error(
394
+ `nudging playhead to flush pipeline after video hole. currentTime: ${currentTime} hole: ${holeStart} -> ${holeEnd} buffered index: ${bufferedIndex}`,
395
+ );
396
+ this.warn(error.message);
397
+ // Magic number to flush the pipeline without interuption to audio playback:
398
+ this.media.currentTime += 0.000001;
399
+ const frag =
400
+ this.fragmentTracker.getPartialFragment(currentTime) || undefined;
401
+ const bufferInfo = BufferHelper.bufferInfo(
402
+ this.media,
403
+ currentTime,
404
+ 0,
405
+ );
406
+ this.hls.trigger(Events.ERROR, {
407
+ type: ErrorTypes.MEDIA_ERROR,
408
+ details: ErrorDetails.BUFFER_SEEK_OVER_HOLE,
409
+ fatal: false,
410
+ error,
411
+ reason: error.message,
412
+ frag,
413
+ buffer: bufferInfo.len,
414
+ bufferInfo,
415
+ });
416
+ }
417
+ }
418
+ }
419
+ }
420
+ }
421
+
230
422
  /**
231
423
  * Detects and attempts to fix known buffer stalling issues.
232
424
  * @param bufferInfo - The properties of the current buffer.
@@ -268,7 +460,8 @@ export default class GapController extends Logger {
268
460
  bufferInfo.len > config.maxBufferHole) ||
269
461
  (bufferInfo.nextStart &&
270
462
  bufferInfo.nextStart - currentTime < config.maxBufferHole)) &&
271
- stalledDurationMs > config.highBufferWatchdogPeriod * 1000
463
+ (stalledDurationMs > config.highBufferWatchdogPeriod * 1000 ||
464
+ this.waiting)
272
465
  ) {
273
466
  this.warn('Trying to nudge playhead over buffer-hole');
274
467
  // Try to nudge currentTime over a buffer hole if we've been stalling for the configured amount of seconds
@@ -310,7 +503,7 @@ export default class GapController extends Logger {
310
503
  * @param partial - The partial fragment found at the current time (where playback is stalling).
311
504
  * @private
312
505
  */
313
- private _trySkipBufferHole(partial: Fragment | null): number {
506
+ private _trySkipBufferHole(partial: MediaFragment | null): number {
314
507
  const { fragmentTracker, media } = this;
315
508
  const config = this.hls?.config;
316
509
  if (!media || !fragmentTracker || !config) {
@@ -322,7 +515,7 @@ export default class GapController extends Logger {
322
515
  const bufferInfo = BufferHelper.bufferInfo(media, currentTime, 0);
323
516
  const startTime =
324
517
  currentTime < bufferInfo.start ? bufferInfo.start : bufferInfo.nextStart;
325
- if (startTime) {
518
+ if (startTime && this.hls) {
326
519
  const bufferStarved = bufferInfo.len <= config.maxBufferHole;
327
520
  const waiting =
328
521
  bufferInfo.len > 0 && bufferInfo.len < 1 && media.readyState < 3;
@@ -348,6 +541,19 @@ export default class GapController extends Logger {
348
541
  PlaylistLevelType.MAIN,
349
542
  );
350
543
  if (startProvisioned) {
544
+ // Do not seek when selected variant playlist is unloaded
545
+ if (!this.hls.loadLevelObj?.details) {
546
+ return 0;
547
+ }
548
+ // Do not seek when required fragments are inflight or appending
549
+ const inFlightDependency = getInFlightDependency(
550
+ this.hls.inFlightFragments,
551
+ startTime,
552
+ );
553
+ if (inFlightDependency) {
554
+ return 0;
555
+ }
556
+ // Do not seek if we can't walk tracked fragments to end of gap
351
557
  let moreToLoad = false;
352
558
  let pos = startProvisioned.end;
353
559
  while (pos < startTime) {
@@ -374,7 +580,7 @@ export default class GapController extends Logger {
374
580
  );
375
581
  this.moved = true;
376
582
  media.currentTime = targetTime;
377
- if (partial && !partial.gap && this.hls) {
583
+ if (!partial?.gap) {
378
584
  const error = new Error(
379
585
  `fragment loaded with buffer holes, seeking from ${currentTime} to ${targetTime}`,
380
586
  );
@@ -384,7 +590,7 @@ export default class GapController extends Logger {
384
590
  fatal: false,
385
591
  error,
386
592
  reason: error.message,
387
- frag: partial,
593
+ frag: partial || undefined,
388
594
  buffer: bufferInfo.len,
389
595
  bufferInfo,
390
596
  });
@@ -440,3 +646,32 @@ export default class GapController extends Logger {
440
646
  }
441
647
  }
442
648
  }
649
+
650
+ function getInFlightDependency(
651
+ inFlightFragments: InFlightFragments,
652
+ currentTime: number,
653
+ ): Fragment | null {
654
+ const main = inFlight(inFlightFragments.main);
655
+ if (main && main.start <= currentTime) {
656
+ return main;
657
+ }
658
+ const audio = inFlight(inFlightFragments.audio);
659
+ if (audio && audio.start <= currentTime) {
660
+ return audio;
661
+ }
662
+ return null;
663
+ }
664
+
665
+ function inFlight(inFlightData: InFlightData | undefined): Fragment | null {
666
+ if (!inFlightData) {
667
+ return null;
668
+ }
669
+ switch (inFlightData.state) {
670
+ case State.IDLE:
671
+ case State.STOPPED:
672
+ case State.ENDED:
673
+ case State.ERROR:
674
+ return null;
675
+ }
676
+ return inFlightData.frag;
677
+ }
@@ -22,6 +22,10 @@ import {
22
22
  TimelineOccupancy,
23
23
  } from '../loader/interstitial-event';
24
24
  import { BufferHelper } from '../utils/buffer-helper';
25
+ import {
26
+ addEventListener,
27
+ removeEventListener,
28
+ } from '../utils/event-listener-helper';
25
29
  import { hash } from '../utils/hash';
26
30
  import { Logger } from '../utils/logger';
27
31
  import { isCompatibleTrackChange } from '../utils/mediasource-helper';
@@ -226,17 +230,17 @@ export default class InterstitialsController
226
230
  }
227
231
 
228
232
  private onDestroying() {
229
- const media = this.primaryMedia;
233
+ const media = this.primaryMedia || this.media;
230
234
  if (media) {
231
235
  this.removeMediaListeners(media);
232
236
  }
233
237
  }
234
238
 
235
239
  private removeMediaListeners(media: HTMLMediaElement) {
236
- media.removeEventListener('play', this.onPlay);
237
- media.removeEventListener('pause', this.onPause);
238
- media.removeEventListener('seeking', this.onSeeking);
239
- media.removeEventListener('timeupdate', this.onTimeupdate);
240
+ removeEventListener(media, 'play', this.onPlay);
241
+ removeEventListener(media, 'pause', this.onPause);
242
+ removeEventListener(media, 'seeking', this.onSeeking);
243
+ removeEventListener(media, 'timeupdate', this.onTimeupdate);
240
244
  }
241
245
 
242
246
  private onMediaAttaching(
@@ -244,11 +248,10 @@ export default class InterstitialsController
244
248
  data: MediaAttachingData,
245
249
  ) {
246
250
  const media = (this.media = data.media);
247
- this.removeMediaListeners(media);
248
- media.addEventListener('seeking', this.onSeeking);
249
- media.addEventListener('timeupdate', this.onTimeupdate);
250
- media.addEventListener('play', this.onPlay);
251
- media.addEventListener('pause', this.onPause);
251
+ addEventListener(media, 'seeking', this.onSeeking);
252
+ addEventListener(media, 'timeupdate', this.onTimeupdate);
253
+ addEventListener(media, 'play', this.onPlay);
254
+ addEventListener(media, 'pause', this.onPause);
252
255
  }
253
256
 
254
257
  private onMediaAttached(
@@ -1918,7 +1921,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))}`,
1918
1921
  const userConfig = primary.userConfig;
1919
1922
  let videoPreference = userConfig.videoPreference;
1920
1923
  const currentLevel =
1921
- primary.levels[primary.loadLevel] || primary.levels[primary.currentLevel];
1924
+ primary.loadLevelObj || primary.levels[primary.currentLevel];
1922
1925
  if (videoPreference || currentLevel) {
1923
1926
  videoPreference = Object.assign({}, videoPreference);
1924
1927
  if (currentLevel.videoCodec) {
@@ -389,6 +389,10 @@ export default class LevelController extends BasePlaylistController {
389
389
  return this._levels;
390
390
  }
391
391
 
392
+ get loadLevelObj(): Level | null {
393
+ return this.currentLevel;
394
+ }
395
+
392
396
  get level(): number {
393
397
  return this.currentLevelIndex;
394
398
  }