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