@f8team/reel-core 1.0.3 → 1.0.4
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/CHANGELOG.md +6 -0
- package/dist/index.cjs +246 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +246 -12
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @f8team/reel-core
|
|
2
2
|
|
|
3
|
+
## 1.0.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- b476560: Harden HLS playback against repeated fragment request storms by bounding HLS load retries, detecting looped fragment loads, aborting stuck loaders, and surfacing retryable player errors instead of leaving playback stuck in loading.
|
|
8
|
+
|
|
3
9
|
## 1.0.3
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/dist/index.cjs
CHANGED
|
@@ -7,6 +7,7 @@ var RETRYABLE_BY_DEFAULT = {
|
|
|
7
7
|
decode: false,
|
|
8
8
|
unauthorized: false,
|
|
9
9
|
unsupported: false,
|
|
10
|
+
"hls-fragment-loop": true,
|
|
10
11
|
internal: false
|
|
11
12
|
};
|
|
12
13
|
function createPlayerError(input) {
|
|
@@ -266,6 +267,54 @@ function resolveWithCredentials(policy, url) {
|
|
|
266
267
|
}
|
|
267
268
|
|
|
268
269
|
// src/sources/hls.ts
|
|
270
|
+
var REPEATED_FRAGMENT_LOAD_LIMIT = 4;
|
|
271
|
+
var MEDIA_RECOVERY_LIMIT = 1;
|
|
272
|
+
function makeLoadPolicy(options) {
|
|
273
|
+
return {
|
|
274
|
+
default: {
|
|
275
|
+
maxTimeToFirstByteMs: options.maxTimeToFirstByteMs,
|
|
276
|
+
maxLoadTimeMs: options.maxLoadTimeMs,
|
|
277
|
+
timeoutRetry: options.timeoutRetry,
|
|
278
|
+
errorRetry: options.errorRetry
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
function makeLinearRetry(maxNumRetry, retryDelayMs, maxRetryDelayMs) {
|
|
283
|
+
return {
|
|
284
|
+
maxNumRetry,
|
|
285
|
+
retryDelayMs,
|
|
286
|
+
maxRetryDelayMs,
|
|
287
|
+
backoff: "linear"
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
function makeHlsLoadPolicies() {
|
|
291
|
+
return {
|
|
292
|
+
manifestLoadPolicy: makeLoadPolicy({
|
|
293
|
+
maxTimeToFirstByteMs: 1e4,
|
|
294
|
+
maxLoadTimeMs: 2e4,
|
|
295
|
+
timeoutRetry: makeLinearRetry(1, 0, 0),
|
|
296
|
+
errorRetry: makeLinearRetry(1, 1e3, 8e3)
|
|
297
|
+
}),
|
|
298
|
+
playlistLoadPolicy: makeLoadPolicy({
|
|
299
|
+
maxTimeToFirstByteMs: 1e4,
|
|
300
|
+
maxLoadTimeMs: 2e4,
|
|
301
|
+
timeoutRetry: makeLinearRetry(1, 0, 0),
|
|
302
|
+
errorRetry: makeLinearRetry(2, 1e3, 8e3)
|
|
303
|
+
}),
|
|
304
|
+
keyLoadPolicy: makeLoadPolicy({
|
|
305
|
+
maxTimeToFirstByteMs: 8e3,
|
|
306
|
+
maxLoadTimeMs: 2e4,
|
|
307
|
+
timeoutRetry: makeLinearRetry(1, 1e3, 8e3),
|
|
308
|
+
errorRetry: makeLinearRetry(2, 1e3, 8e3)
|
|
309
|
+
}),
|
|
310
|
+
fragLoadPolicy: makeLoadPolicy({
|
|
311
|
+
maxTimeToFirstByteMs: 1e4,
|
|
312
|
+
maxLoadTimeMs: 3e4,
|
|
313
|
+
timeoutRetry: makeLinearRetry(1, 0, 0),
|
|
314
|
+
errorRetry: makeLinearRetry(2, 1e3, 4e3)
|
|
315
|
+
})
|
|
316
|
+
};
|
|
317
|
+
}
|
|
269
318
|
var defaultLoad = () => {
|
|
270
319
|
return import('hls.js').then((m) => m.default);
|
|
271
320
|
};
|
|
@@ -290,13 +339,17 @@ var HlsLoader = class {
|
|
|
290
339
|
this.rejectAttach = null;
|
|
291
340
|
this.manifestTimeout = null;
|
|
292
341
|
this.aborted = false;
|
|
342
|
+
this.lastLoadingFragmentKey = null;
|
|
343
|
+
this.repeatedFragmentLoadCount = 0;
|
|
344
|
+
this.repeatedFragmentErrorFired = false;
|
|
345
|
+
this.mediaRecoveryCount = 0;
|
|
293
346
|
/** `false` idle; `'auto'` → first `LEVEL_SWITCHED` clears; otherwise await this level index. */
|
|
294
347
|
this.qualityAwait = false;
|
|
295
348
|
this.qualitySwitchTimer = null;
|
|
296
349
|
/** Tracks consecutive menu selections so redundant "Auto → Auto" skips the spinner. */
|
|
297
350
|
this.lastChosenQualityWasAuto = false;
|
|
298
351
|
/**
|
|
299
|
-
* `null`
|
|
352
|
+
* `null` - user chose ABR auto (menu shows "Auto"). Real hls.js still exposes
|
|
300
353
|
* `currentLevel >= 0` for the playing rendition; we must not mirror that into UI state.
|
|
301
354
|
*/
|
|
302
355
|
this.manualLevelIndex = null;
|
|
@@ -352,6 +405,7 @@ var HlsLoader = class {
|
|
|
352
405
|
attachWithHlsJs(Hls, video, source) {
|
|
353
406
|
const hls = new Hls({
|
|
354
407
|
enableWorker: true,
|
|
408
|
+
...makeHlsLoadPolicies(),
|
|
355
409
|
xhrSetup: (xhr, url) => this.installXhrWatcher(xhr, url, source)
|
|
356
410
|
});
|
|
357
411
|
this.hls = hls;
|
|
@@ -372,27 +426,44 @@ var HlsLoader = class {
|
|
|
372
426
|
this.onLevelEventuallyStabilized(...args);
|
|
373
427
|
this.refreshActiveQuality();
|
|
374
428
|
};
|
|
429
|
+
const onFragmentLoading = (...args) => {
|
|
430
|
+
this.onFragmentLoading(...args);
|
|
431
|
+
};
|
|
432
|
+
const onFragmentBuffered = (...args) => {
|
|
433
|
+
this.resetFragmentLoopGuardFromEvent(...args);
|
|
434
|
+
};
|
|
375
435
|
const onError = (..._args) => {
|
|
376
|
-
const data = _args
|
|
436
|
+
const data = this.getHlsEventData(_args);
|
|
377
437
|
const status = data.response?.code;
|
|
438
|
+
if (this.tryRecoverMediaError(Hls, data)) {
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
378
441
|
const isHttpError = typeof status === "number" && status >= 400;
|
|
379
|
-
|
|
380
|
-
|
|
442
|
+
const isTerminalHlsError = this.isTerminalHlsError(data);
|
|
443
|
+
if (data.fatal || isHttpError || isTerminalHlsError) {
|
|
444
|
+
const message = this.formatHlsErrorMessage(data, status);
|
|
445
|
+
this.stopHlsLoading();
|
|
381
446
|
this.options.onError?.({
|
|
447
|
+
code: this.getHlsErrorCode(data, status),
|
|
382
448
|
message,
|
|
383
449
|
cause: data,
|
|
384
450
|
status,
|
|
385
|
-
url:
|
|
451
|
+
url: this.getErrorUrl(data),
|
|
452
|
+
retryable: this.isRetryableHlsError(data, status)
|
|
386
453
|
});
|
|
387
454
|
settle(() => reject(new Error(message)));
|
|
388
455
|
}
|
|
389
456
|
};
|
|
390
457
|
hls.on(Hls.Events.MANIFEST_PARSED, onParsed);
|
|
391
458
|
hls.on(Hls.Events.LEVEL_SWITCHED, onSwitched);
|
|
459
|
+
hls.on(Hls.Events.FRAG_LOADING, onFragmentLoading);
|
|
460
|
+
hls.on(Hls.Events.FRAG_BUFFERED, onFragmentBuffered);
|
|
392
461
|
hls.on(Hls.Events.ERROR, onError);
|
|
393
462
|
this.detachListeners.push(
|
|
394
463
|
() => hls.off(Hls.Events.MANIFEST_PARSED, onParsed),
|
|
395
464
|
() => hls.off(Hls.Events.LEVEL_SWITCHED, onSwitched),
|
|
465
|
+
() => hls.off(Hls.Events.FRAG_LOADING, onFragmentLoading),
|
|
466
|
+
() => hls.off(Hls.Events.FRAG_BUFFERED, onFragmentBuffered),
|
|
396
467
|
() => hls.off(Hls.Events.ERROR, onError),
|
|
397
468
|
() => settle(() => reject(makeAbortError()))
|
|
398
469
|
);
|
|
@@ -400,6 +471,7 @@ var HlsLoader = class {
|
|
|
400
471
|
if (timeoutMs > 0) {
|
|
401
472
|
this.manifestTimeout = setTimeout(() => {
|
|
402
473
|
const message = `HLS manifest timeout after ${timeoutMs}ms`;
|
|
474
|
+
this.stopHlsLoading();
|
|
403
475
|
this.options.onError?.({ message, cause: { type: "timeout" }, url: source.src });
|
|
404
476
|
settle(() => reject(new Error(message)));
|
|
405
477
|
}, timeoutMs);
|
|
@@ -414,7 +486,7 @@ var HlsLoader = class {
|
|
|
414
486
|
}
|
|
415
487
|
/**
|
|
416
488
|
* Wire the readystatechange watcher and remove it as soon as the XHR
|
|
417
|
-
* completes
|
|
489
|
+
* completes - without this cleanup hls.js (which spawns one XHR per segment)
|
|
418
490
|
* leaks a closure per request (A1).
|
|
419
491
|
*/
|
|
420
492
|
installXhrWatcher(xhr, url, source) {
|
|
@@ -440,10 +512,149 @@ var HlsLoader = class {
|
|
|
440
512
|
};
|
|
441
513
|
xhr.addEventListener("readystatechange", watcher);
|
|
442
514
|
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
515
|
+
getHlsEventData(args) {
|
|
516
|
+
const data = args[1] ?? args[0];
|
|
517
|
+
return data !== null && typeof data === "object" ? data : {};
|
|
518
|
+
}
|
|
519
|
+
getFragmentUrl(data) {
|
|
520
|
+
return data.frag?.url ?? data.frag?.relurl ?? data.response?.url ?? data.url;
|
|
521
|
+
}
|
|
522
|
+
getErrorUrl(data) {
|
|
523
|
+
return this.getFragmentUrl(data) ?? this.currentSource?.src;
|
|
524
|
+
}
|
|
525
|
+
getFragmentKey(data) {
|
|
526
|
+
const url = this.getFragmentUrl(data);
|
|
527
|
+
const frag = data.frag;
|
|
528
|
+
if (!frag) return url ?? null;
|
|
529
|
+
const type = frag.type ?? "main";
|
|
530
|
+
const level = frag.level ?? "unknown-level";
|
|
531
|
+
const sn = frag.sn ?? "unknown-sn";
|
|
532
|
+
const cc = frag.cc ?? "unknown-cc";
|
|
533
|
+
const start = frag.start ?? frag.startPTS ?? "unknown-start";
|
|
534
|
+
return `${url ?? "unknown-url"}:${type}:${level}:${sn}:${cc}:${start}`;
|
|
535
|
+
}
|
|
536
|
+
isTerminalHlsError(data) {
|
|
537
|
+
switch (data.details) {
|
|
538
|
+
case "manifestParsingError":
|
|
539
|
+
case "manifestIncompatibleCodecsError":
|
|
540
|
+
case "levelEmptyError":
|
|
541
|
+
case "levelParsingError":
|
|
542
|
+
case "fragParsingError":
|
|
543
|
+
case "fragDecryptError":
|
|
544
|
+
case "keyLoadError":
|
|
545
|
+
case "keyLoadTimeOut":
|
|
546
|
+
case "bufferFullError":
|
|
547
|
+
return true;
|
|
548
|
+
default:
|
|
549
|
+
return false;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
formatHlsErrorMessage(data, status) {
|
|
553
|
+
if (data.fatal) {
|
|
554
|
+
const type2 = data.type ?? "unknown";
|
|
555
|
+
const details2 = data.details ? ` (${data.details})` : "";
|
|
556
|
+
return `HLS fatal error: ${type2}${details2}`;
|
|
557
|
+
}
|
|
558
|
+
if (typeof status === "number" && status >= 400) {
|
|
559
|
+
return `HLS load error: HTTP ${status}${data.details ? ` (${data.details})` : ""}`;
|
|
560
|
+
}
|
|
561
|
+
const type = data.type ?? "unknown";
|
|
562
|
+
const details = data.details ? ` (${data.details})` : "";
|
|
563
|
+
return `HLS error: ${type}${details}`;
|
|
564
|
+
}
|
|
565
|
+
getHlsErrorCode(data, status) {
|
|
566
|
+
if (status === 401 || status === 403) return "unauthorized";
|
|
567
|
+
switch (data.details) {
|
|
568
|
+
case "manifestParsingError":
|
|
569
|
+
case "manifestIncompatibleCodecsError":
|
|
570
|
+
case "levelParsingError":
|
|
571
|
+
case "fragParsingError":
|
|
572
|
+
case "fragDecryptError":
|
|
573
|
+
return "decode";
|
|
574
|
+
default:
|
|
575
|
+
return "network";
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
isRetryableHlsError(data, status) {
|
|
579
|
+
if (status === 401 || status === 403) return false;
|
|
580
|
+
if (typeof status === "number" && status >= 400 && status < 500) return false;
|
|
581
|
+
switch (data.details) {
|
|
582
|
+
case "manifestParsingError":
|
|
583
|
+
case "manifestIncompatibleCodecsError":
|
|
584
|
+
case "levelParsingError":
|
|
585
|
+
case "fragParsingError":
|
|
586
|
+
case "fragDecryptError":
|
|
587
|
+
return false;
|
|
588
|
+
default:
|
|
589
|
+
return true;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
isRecoverableMediaError(Hls, data) {
|
|
593
|
+
if (data.type !== Hls.ErrorTypes.MEDIA_ERROR && data.type !== "mediaError") {
|
|
594
|
+
return false;
|
|
595
|
+
}
|
|
596
|
+
if (!data.fatal) return false;
|
|
597
|
+
if (this.mediaRecoveryCount >= MEDIA_RECOVERY_LIMIT) return false;
|
|
598
|
+
if (typeof this.hls?.recoverMediaError !== "function") return false;
|
|
599
|
+
switch (data.details) {
|
|
600
|
+
case void 0:
|
|
601
|
+
case "bufferAppendError":
|
|
602
|
+
case "bufferAppendingError":
|
|
603
|
+
case "bufferStalledError":
|
|
604
|
+
case "bufferSeekOverHole":
|
|
605
|
+
case "bufferNudgeOnStall":
|
|
606
|
+
case "bufferFullError":
|
|
607
|
+
return true;
|
|
608
|
+
default:
|
|
609
|
+
return false;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
tryRecoverMediaError(Hls, data) {
|
|
613
|
+
if (!this.isRecoverableMediaError(Hls, data)) return false;
|
|
614
|
+
this.mediaRecoveryCount += 1;
|
|
615
|
+
this.resetFragmentLoopGuard();
|
|
616
|
+
this.completeQualityAwaitIfBusy();
|
|
617
|
+
try {
|
|
618
|
+
this.hls?.recoverMediaError?.();
|
|
619
|
+
return true;
|
|
620
|
+
} catch {
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
onFragmentLoading(...args) {
|
|
625
|
+
if (this.repeatedFragmentErrorFired) return;
|
|
626
|
+
const data = this.getHlsEventData(args);
|
|
627
|
+
const key = this.getFragmentKey(data);
|
|
628
|
+
if (!key) return;
|
|
629
|
+
if (key === this.lastLoadingFragmentKey) {
|
|
630
|
+
this.repeatedFragmentLoadCount += 1;
|
|
631
|
+
} else {
|
|
632
|
+
this.lastLoadingFragmentKey = key;
|
|
633
|
+
this.repeatedFragmentLoadCount = 1;
|
|
634
|
+
}
|
|
635
|
+
if (this.repeatedFragmentLoadCount < REPEATED_FRAGMENT_LOAD_LIMIT) return;
|
|
636
|
+
this.repeatedFragmentErrorFired = true;
|
|
637
|
+
this.stopHlsLoading();
|
|
638
|
+
const url = this.getFragmentUrl(data);
|
|
639
|
+
this.options.onError?.({
|
|
640
|
+
code: "hls-fragment-loop",
|
|
641
|
+
message: `HLS fragment repeatedly loading: ${url ?? key}`,
|
|
642
|
+
cause: data,
|
|
643
|
+
url
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
resetFragmentLoopGuardFromEvent(...args) {
|
|
647
|
+
const data = this.getHlsEventData(args);
|
|
648
|
+
if (this.getFragmentKey(data)) {
|
|
649
|
+
this.resetFragmentLoopGuard();
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
resetFragmentLoopGuard() {
|
|
653
|
+
this.lastLoadingFragmentKey = null;
|
|
654
|
+
this.repeatedFragmentLoadCount = 0;
|
|
655
|
+
this.repeatedFragmentErrorFired = false;
|
|
656
|
+
}
|
|
657
|
+
abortLiveXhrs() {
|
|
447
658
|
for (const xhr of [...this.liveXhrs]) {
|
|
448
659
|
try {
|
|
449
660
|
xhr.abort();
|
|
@@ -451,6 +662,20 @@ var HlsLoader = class {
|
|
|
451
662
|
}
|
|
452
663
|
}
|
|
453
664
|
this.liveXhrs.clear();
|
|
665
|
+
}
|
|
666
|
+
stopHlsLoading() {
|
|
667
|
+
this.completeQualityAwaitIfBusy();
|
|
668
|
+
try {
|
|
669
|
+
this.hls?.stopLoad?.();
|
|
670
|
+
} catch {
|
|
671
|
+
}
|
|
672
|
+
this.abortLiveXhrs();
|
|
673
|
+
}
|
|
674
|
+
abort() {
|
|
675
|
+
if (this.aborted) return;
|
|
676
|
+
this.aborted = true;
|
|
677
|
+
this.clearManifestTimeout();
|
|
678
|
+
this.abortLiveXhrs();
|
|
454
679
|
const reject = this.rejectAttach;
|
|
455
680
|
this.rejectAttach = null;
|
|
456
681
|
reject?.(makeAbortError());
|
|
@@ -486,6 +711,8 @@ var HlsLoader = class {
|
|
|
486
711
|
this.aborted = false;
|
|
487
712
|
this.lastChosenQualityWasAuto = false;
|
|
488
713
|
this.manualLevelIndex = null;
|
|
714
|
+
this.mediaRecoveryCount = 0;
|
|
715
|
+
this.resetFragmentLoopGuard();
|
|
489
716
|
}
|
|
490
717
|
clearQualitySwitchTimerOnly() {
|
|
491
718
|
if (this.qualitySwitchTimer !== null) {
|
|
@@ -607,7 +834,7 @@ var HlsLoader = class {
|
|
|
607
834
|
const locked = this.qualities[this.manualLevelIndex] ?? null;
|
|
608
835
|
this.options.onActiveQuality?.(locked, false);
|
|
609
836
|
}
|
|
610
|
-
/** @internal
|
|
837
|
+
/** @internal - for tests. */
|
|
611
838
|
_getInternalState() {
|
|
612
839
|
return { source: this.currentSource, qualities: this.qualities };
|
|
613
840
|
}
|
|
@@ -1248,7 +1475,14 @@ function createPlayer(options = {}, internal = {}) {
|
|
|
1248
1475
|
onUnauthorized: (e) => emitUnauthorized(e),
|
|
1249
1476
|
onQualities: (q) => setQualities(q),
|
|
1250
1477
|
onActiveQuality: (q, auto) => setActiveQuality(q, auto),
|
|
1251
|
-
onError: (err) =>
|
|
1478
|
+
onError: (err) => {
|
|
1479
|
+
const playerError = createPlayerError({ ...err, code: err.code ?? "network" });
|
|
1480
|
+
if (status === "loading") {
|
|
1481
|
+
dispatch({ type: "loadFailed", error: playerError });
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
dispatch({ type: "runtimeError", error: playerError });
|
|
1485
|
+
},
|
|
1252
1486
|
onQualitySwitch: (active) => bus.emit("qualityswitch", { active })
|
|
1253
1487
|
})
|
|
1254
1488
|
);
|