@scarlett-player/hls 1.5.1 → 1.7.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.
@@ -4,6 +4,17 @@ var __export = (target, all) => {
4
4
  __defProp(target, name, { get: all[name], enumerable: true });
5
5
  };
6
6
 
7
+ // src/sanitize-url.ts
8
+ function sanitizeUrl(url) {
9
+ if (!url) return void 0;
10
+ try {
11
+ const parsed = new URL(url);
12
+ return `${parsed.origin}${parsed.pathname}`;
13
+ } catch {
14
+ return void 0;
15
+ }
16
+ }
17
+
7
18
  // src/create-hls-plugin.ts
8
19
  import { ErrorCode } from "@scarlett-player/core";
9
20
 
@@ -437,6 +448,8 @@ function createHLSPluginWith(loader, variant, config) {
437
448
  let reconnectWindowStart = 0;
438
449
  let reconnectResumePosition = 0;
439
450
  let onlineListener = null;
451
+ let reconnectTriggerError = null;
452
+ let reconnectExhausted = false;
440
453
  const getOrCreateVideo = () => {
441
454
  if (video) return video;
442
455
  const existing = api?.container.querySelector("video");
@@ -545,6 +558,22 @@ function createHLSPluginWith(loader, variant, config) {
545
558
  return ErrorCode.PLAYBACK_FAILED;
546
559
  }
547
560
  };
561
+ const buildErrorDetail = (error, retriesExhausted) => {
562
+ const attempts = error.type === "network" ? networkRetryCount : error.type === "media" ? mediaRetryCount : 0;
563
+ const detail = {
564
+ type: error.type,
565
+ retriesExhausted,
566
+ attempts
567
+ };
568
+ if (typeof error.response?.code === "number" && error.response.code > 0) {
569
+ detail.httpStatus = error.response.code;
570
+ }
571
+ const url = sanitizeUrl(error.url);
572
+ if (url) {
573
+ detail.url = url;
574
+ }
575
+ return detail;
576
+ };
548
577
  const emitFatalError = (error, retriesExhausted) => {
549
578
  const message = retriesExhausted ? `HLS error: ${error.details} (max retries exceeded)` : `HLS error: ${error.details}`;
550
579
  api?.logger.error(message, { type: error.type, details: error.details });
@@ -554,7 +583,8 @@ function createHLSPluginWith(loader, variant, config) {
554
583
  code: mapFatalErrorCode(error),
555
584
  message,
556
585
  fatal: true,
557
- timestamp: Date.now()
586
+ timestamp: Date.now(),
587
+ detail: buildErrorDetail(error, retriesExhausted)
558
588
  });
559
589
  maybeScheduleReconnect(error);
560
590
  };
@@ -631,6 +661,62 @@ function createHLSPluginWith(loader, variant, config) {
631
661
  }
632
662
  return false;
633
663
  };
664
+ const handleNativeFatalError = (error, resumePosition) => {
665
+ const is_network = error.type === "network";
666
+ const max_retries = is_network ? mergedConfig.maxNetworkRetries ?? 3 : mergedConfig.maxMediaRetries ?? 2;
667
+ const used = is_network ? networkRetryCount : mediaRetryCount;
668
+ if (!currentSrc || used >= max_retries) {
669
+ emitFatalError(error, used >= max_retries);
670
+ return;
671
+ }
672
+ const resume_position = resumePosition ?? video?.currentTime ?? 0;
673
+ if (is_network) {
674
+ networkRetryCount++;
675
+ } else {
676
+ mediaRetryCount++;
677
+ }
678
+ const attempt = used + 1;
679
+ const delay = getRetryDelay(attempt - 1);
680
+ api?.logger.info(
681
+ `Attempting native ${error.type} error recovery (attempt ${attempt}/${max_retries}) in ${delay}ms`
682
+ );
683
+ api?.emit(is_network ? "error:network" : "error:media", {
684
+ error: new Error(error.details)
685
+ });
686
+ if (retryTimeout) {
687
+ clearTimeout(retryTimeout);
688
+ }
689
+ const retry_session = loadSession;
690
+ retryTimeout = setTimeout(() => {
691
+ if (retry_session !== loadSession) return;
692
+ void recoverNative(error, resume_position);
693
+ }, delay);
694
+ };
695
+ const recoverNative = async (error, resumePosition) => {
696
+ if (!currentSrc) return;
697
+ const session = ++loadSession;
698
+ const saved_src = currentSrc;
699
+ const was_live = api?.getState("live") ?? false;
700
+ try {
701
+ teardownPipeline(new Error("HLS load cancelled: native error recovery"));
702
+ api?.setState("playbackState", "loading");
703
+ await loadNative(saved_src);
704
+ if (session !== loadSession) return;
705
+ if (!was_live && video && resumePosition > 0) {
706
+ video.currentTime = resumePosition;
707
+ }
708
+ api?.setState("playbackState", "ready");
709
+ api?.setState("buffering", false);
710
+ try {
711
+ await video?.play();
712
+ } catch {
713
+ }
714
+ } catch {
715
+ if (session !== loadSession) return;
716
+ api?.logger.warn("Native error recovery attempt failed");
717
+ handleNativeFatalError(error, resumePosition);
718
+ }
719
+ };
634
720
  const loadNative = async (src) => {
635
721
  const session = loadSession;
636
722
  const videoEl = getOrCreateVideo();
@@ -675,10 +761,21 @@ function createHLSPluginWith(loader, variant, config) {
675
761
  details: media_error?.message || "Native HLS playback error",
676
762
  fatal: true
677
763
  };
678
- emitFatalError(hls_error, false);
764
+ handleNativeFatalError(hls_error);
679
765
  };
680
766
  videoEl.addEventListener("error", onFatalVideoError);
681
- const removeFatalListener = () => videoEl.removeEventListener("error", onFatalVideoError);
767
+ const onPlayingResetBudget = () => {
768
+ if (networkRetryCount > 0 || mediaRetryCount > 0) {
769
+ api?.logger.debug("Native playback recovered, resetting retry budgets");
770
+ networkRetryCount = 0;
771
+ mediaRetryCount = 0;
772
+ }
773
+ };
774
+ videoEl.addEventListener("playing", onPlayingResetBudget);
775
+ const removeFatalListener = () => {
776
+ videoEl.removeEventListener("error", onFatalVideoError);
777
+ videoEl.removeEventListener("playing", onPlayingResetBudget);
778
+ };
682
779
  const previous_cleanup = cleanupVideoEvents;
683
780
  cleanupVideoEvents = () => {
684
781
  removeFatalListener();
@@ -803,12 +900,38 @@ function createHLSPluginWith(loader, variant, config) {
803
900
  reconnectAttempts = 0;
804
901
  reconnectWindowStart = 0;
805
902
  reconnectResumePosition = 0;
903
+ reconnectTriggerError = null;
904
+ reconnectExhausted = false;
905
+ };
906
+ const emitReconnectExhausted = (elapsedMs, windowMs) => {
907
+ if (reconnectExhausted) return;
908
+ reconnectExhausted = true;
909
+ const attempts = reconnectAttempts;
910
+ const trigger = reconnectTriggerError;
911
+ api?.emit("error:reconnect-exhausted", { attempts, elapsedMs, windowMs });
912
+ api?.setState("playbackState", "error");
913
+ api?.setState("buffering", false);
914
+ api?.emit("error", {
915
+ code: trigger ? mapFatalErrorCode(trigger) : ErrorCode.PLAYBACK_FAILED,
916
+ message: `HLS auto-reconnect gave up after ${attempts} attempts over ${Math.round(elapsedMs / 1e3)}s`,
917
+ fatal: true,
918
+ timestamp: Date.now(),
919
+ detail: {
920
+ type: trigger?.type ?? "other",
921
+ retriesExhausted: true,
922
+ attempts,
923
+ reconnectExhausted: true
924
+ }
925
+ });
806
926
  };
807
927
  const scheduleReconnectAttempt = () => {
928
+ if (reconnectExhausted) return;
808
929
  if (reconnectTimer) return;
809
930
  const window_ms = mergedConfig.reconnectWindowMs ?? 3e5;
810
- if (Date.now() - reconnectWindowStart > window_ms) {
931
+ const elapsed_ms = Date.now() - reconnectWindowStart;
932
+ if (elapsed_ms > window_ms) {
811
933
  api?.logger.warn(`Auto-reconnect window exhausted after ${reconnectAttempts} attempts`);
934
+ emitReconnectExhausted(elapsed_ms, window_ms);
812
935
  return;
813
936
  }
814
937
  const base_delay = mergedConfig.reconnectBaseDelayMs ?? 2e3;
@@ -816,7 +939,12 @@ function createHLSPluginWith(loader, variant, config) {
816
939
  const backoff = Math.min(base_delay * Math.pow(2, reconnectAttempts), max_delay);
817
940
  const delay = Math.round(backoff * (0.7 + Math.random() * 0.3));
818
941
  api?.logger.info(`Scheduling auto-reconnect attempt ${reconnectAttempts + 1} in ${delay}ms`);
819
- api?.emit("error:reconnecting", { attempt: reconnectAttempts + 1, delayMs: delay });
942
+ api?.emit("error:reconnecting", {
943
+ attempt: reconnectAttempts + 1,
944
+ delayMs: delay,
945
+ elapsedMs: elapsed_ms,
946
+ windowMs: window_ms
947
+ });
820
948
  reconnectTimer = setTimeout(() => {
821
949
  reconnectTimer = null;
822
950
  void attemptReconnect();
@@ -829,6 +957,7 @@ function createHLSPluginWith(loader, variant, config) {
829
957
  if (reconnectWindowStart === 0) {
830
958
  reconnectWindowStart = Date.now();
831
959
  reconnectResumePosition = video?.currentTime ?? 0;
960
+ reconnectTriggerError = error;
832
961
  }
833
962
  scheduleReconnectAttempt();
834
963
  };
@@ -860,7 +989,10 @@ function createHLSPluginWith(loader, variant, config) {
860
989
  }
861
990
  api.setState("playbackState", "ready");
862
991
  api.setState("buffering", false);
863
- api.emit("error:recovered", void 0);
992
+ api.emit("error:recovered", {
993
+ attempt: reconnectAttempts,
994
+ elapsedMs: Date.now() - reconnectWindowStart
995
+ });
864
996
  api.logger.info("Auto-reconnect succeeded");
865
997
  cancelReconnect();
866
998
  try {
@@ -1137,5 +1269,6 @@ function createHLSPluginWith(loader, variant, config) {
1137
1269
 
1138
1270
  export {
1139
1271
  __export,
1272
+ sanitizeUrl,
1140
1273
  createHLSPluginWith
1141
1274
  };
package/dist/index.cjs CHANGED
@@ -31,7 +31,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  createHLSPlugin: () => createHLSPlugin,
34
- default: () => index_default
34
+ default: () => index_default,
35
+ sanitizeUrl: () => sanitizeUrl
35
36
  });
36
37
  module.exports = __toCommonJS(index_exports);
37
38
 
@@ -486,6 +487,17 @@ function createValidatingPlaylistLoader(Hls) {
486
487
  };
487
488
  }
488
489
 
490
+ // src/sanitize-url.ts
491
+ function sanitizeUrl(url) {
492
+ if (!url) return void 0;
493
+ try {
494
+ const parsed = new URL(url);
495
+ return `${parsed.origin}${parsed.pathname}`;
496
+ } catch {
497
+ return void 0;
498
+ }
499
+ }
500
+
489
501
  // src/create-hls-plugin.ts
490
502
  var DEFAULT_CONFIG = {
491
503
  debug: false,
@@ -542,6 +554,8 @@ function createHLSPluginWith(loader, variant, config) {
542
554
  let reconnectWindowStart = 0;
543
555
  let reconnectResumePosition = 0;
544
556
  let onlineListener = null;
557
+ let reconnectTriggerError = null;
558
+ let reconnectExhausted = false;
545
559
  const getOrCreateVideo = () => {
546
560
  if (video) return video;
547
561
  const existing = api?.container.querySelector("video");
@@ -650,6 +664,22 @@ function createHLSPluginWith(loader, variant, config) {
650
664
  return import_core.ErrorCode.PLAYBACK_FAILED;
651
665
  }
652
666
  };
667
+ const buildErrorDetail = (error, retriesExhausted) => {
668
+ const attempts = error.type === "network" ? networkRetryCount : error.type === "media" ? mediaRetryCount : 0;
669
+ const detail = {
670
+ type: error.type,
671
+ retriesExhausted,
672
+ attempts
673
+ };
674
+ if (typeof error.response?.code === "number" && error.response.code > 0) {
675
+ detail.httpStatus = error.response.code;
676
+ }
677
+ const url = sanitizeUrl(error.url);
678
+ if (url) {
679
+ detail.url = url;
680
+ }
681
+ return detail;
682
+ };
653
683
  const emitFatalError = (error, retriesExhausted) => {
654
684
  const message = retriesExhausted ? `HLS error: ${error.details} (max retries exceeded)` : `HLS error: ${error.details}`;
655
685
  api?.logger.error(message, { type: error.type, details: error.details });
@@ -659,7 +689,8 @@ function createHLSPluginWith(loader, variant, config) {
659
689
  code: mapFatalErrorCode(error),
660
690
  message,
661
691
  fatal: true,
662
- timestamp: Date.now()
692
+ timestamp: Date.now(),
693
+ detail: buildErrorDetail(error, retriesExhausted)
663
694
  });
664
695
  maybeScheduleReconnect(error);
665
696
  };
@@ -736,6 +767,62 @@ function createHLSPluginWith(loader, variant, config) {
736
767
  }
737
768
  return false;
738
769
  };
770
+ const handleNativeFatalError = (error, resumePosition) => {
771
+ const is_network = error.type === "network";
772
+ const max_retries = is_network ? mergedConfig.maxNetworkRetries ?? 3 : mergedConfig.maxMediaRetries ?? 2;
773
+ const used = is_network ? networkRetryCount : mediaRetryCount;
774
+ if (!currentSrc || used >= max_retries) {
775
+ emitFatalError(error, used >= max_retries);
776
+ return;
777
+ }
778
+ const resume_position = resumePosition ?? video?.currentTime ?? 0;
779
+ if (is_network) {
780
+ networkRetryCount++;
781
+ } else {
782
+ mediaRetryCount++;
783
+ }
784
+ const attempt = used + 1;
785
+ const delay = getRetryDelay(attempt - 1);
786
+ api?.logger.info(
787
+ `Attempting native ${error.type} error recovery (attempt ${attempt}/${max_retries}) in ${delay}ms`
788
+ );
789
+ api?.emit(is_network ? "error:network" : "error:media", {
790
+ error: new Error(error.details)
791
+ });
792
+ if (retryTimeout) {
793
+ clearTimeout(retryTimeout);
794
+ }
795
+ const retry_session = loadSession;
796
+ retryTimeout = setTimeout(() => {
797
+ if (retry_session !== loadSession) return;
798
+ void recoverNative(error, resume_position);
799
+ }, delay);
800
+ };
801
+ const recoverNative = async (error, resumePosition) => {
802
+ if (!currentSrc) return;
803
+ const session = ++loadSession;
804
+ const saved_src = currentSrc;
805
+ const was_live = api?.getState("live") ?? false;
806
+ try {
807
+ teardownPipeline(new Error("HLS load cancelled: native error recovery"));
808
+ api?.setState("playbackState", "loading");
809
+ await loadNative(saved_src);
810
+ if (session !== loadSession) return;
811
+ if (!was_live && video && resumePosition > 0) {
812
+ video.currentTime = resumePosition;
813
+ }
814
+ api?.setState("playbackState", "ready");
815
+ api?.setState("buffering", false);
816
+ try {
817
+ await video?.play();
818
+ } catch {
819
+ }
820
+ } catch {
821
+ if (session !== loadSession) return;
822
+ api?.logger.warn("Native error recovery attempt failed");
823
+ handleNativeFatalError(error, resumePosition);
824
+ }
825
+ };
739
826
  const loadNative = async (src) => {
740
827
  const session = loadSession;
741
828
  const videoEl = getOrCreateVideo();
@@ -780,10 +867,21 @@ function createHLSPluginWith(loader, variant, config) {
780
867
  details: media_error?.message || "Native HLS playback error",
781
868
  fatal: true
782
869
  };
783
- emitFatalError(hls_error, false);
870
+ handleNativeFatalError(hls_error);
784
871
  };
785
872
  videoEl.addEventListener("error", onFatalVideoError);
786
- const removeFatalListener = () => videoEl.removeEventListener("error", onFatalVideoError);
873
+ const onPlayingResetBudget = () => {
874
+ if (networkRetryCount > 0 || mediaRetryCount > 0) {
875
+ api?.logger.debug("Native playback recovered, resetting retry budgets");
876
+ networkRetryCount = 0;
877
+ mediaRetryCount = 0;
878
+ }
879
+ };
880
+ videoEl.addEventListener("playing", onPlayingResetBudget);
881
+ const removeFatalListener = () => {
882
+ videoEl.removeEventListener("error", onFatalVideoError);
883
+ videoEl.removeEventListener("playing", onPlayingResetBudget);
884
+ };
787
885
  const previous_cleanup = cleanupVideoEvents;
788
886
  cleanupVideoEvents = () => {
789
887
  removeFatalListener();
@@ -908,12 +1006,38 @@ function createHLSPluginWith(loader, variant, config) {
908
1006
  reconnectAttempts = 0;
909
1007
  reconnectWindowStart = 0;
910
1008
  reconnectResumePosition = 0;
1009
+ reconnectTriggerError = null;
1010
+ reconnectExhausted = false;
1011
+ };
1012
+ const emitReconnectExhausted = (elapsedMs, windowMs) => {
1013
+ if (reconnectExhausted) return;
1014
+ reconnectExhausted = true;
1015
+ const attempts = reconnectAttempts;
1016
+ const trigger = reconnectTriggerError;
1017
+ api?.emit("error:reconnect-exhausted", { attempts, elapsedMs, windowMs });
1018
+ api?.setState("playbackState", "error");
1019
+ api?.setState("buffering", false);
1020
+ api?.emit("error", {
1021
+ code: trigger ? mapFatalErrorCode(trigger) : import_core.ErrorCode.PLAYBACK_FAILED,
1022
+ message: `HLS auto-reconnect gave up after ${attempts} attempts over ${Math.round(elapsedMs / 1e3)}s`,
1023
+ fatal: true,
1024
+ timestamp: Date.now(),
1025
+ detail: {
1026
+ type: trigger?.type ?? "other",
1027
+ retriesExhausted: true,
1028
+ attempts,
1029
+ reconnectExhausted: true
1030
+ }
1031
+ });
911
1032
  };
912
1033
  const scheduleReconnectAttempt = () => {
1034
+ if (reconnectExhausted) return;
913
1035
  if (reconnectTimer) return;
914
1036
  const window_ms = mergedConfig.reconnectWindowMs ?? 3e5;
915
- if (Date.now() - reconnectWindowStart > window_ms) {
1037
+ const elapsed_ms = Date.now() - reconnectWindowStart;
1038
+ if (elapsed_ms > window_ms) {
916
1039
  api?.logger.warn(`Auto-reconnect window exhausted after ${reconnectAttempts} attempts`);
1040
+ emitReconnectExhausted(elapsed_ms, window_ms);
917
1041
  return;
918
1042
  }
919
1043
  const base_delay = mergedConfig.reconnectBaseDelayMs ?? 2e3;
@@ -921,7 +1045,12 @@ function createHLSPluginWith(loader, variant, config) {
921
1045
  const backoff = Math.min(base_delay * Math.pow(2, reconnectAttempts), max_delay);
922
1046
  const delay = Math.round(backoff * (0.7 + Math.random() * 0.3));
923
1047
  api?.logger.info(`Scheduling auto-reconnect attempt ${reconnectAttempts + 1} in ${delay}ms`);
924
- api?.emit("error:reconnecting", { attempt: reconnectAttempts + 1, delayMs: delay });
1048
+ api?.emit("error:reconnecting", {
1049
+ attempt: reconnectAttempts + 1,
1050
+ delayMs: delay,
1051
+ elapsedMs: elapsed_ms,
1052
+ windowMs: window_ms
1053
+ });
925
1054
  reconnectTimer = setTimeout(() => {
926
1055
  reconnectTimer = null;
927
1056
  void attemptReconnect();
@@ -934,6 +1063,7 @@ function createHLSPluginWith(loader, variant, config) {
934
1063
  if (reconnectWindowStart === 0) {
935
1064
  reconnectWindowStart = Date.now();
936
1065
  reconnectResumePosition = video?.currentTime ?? 0;
1066
+ reconnectTriggerError = error;
937
1067
  }
938
1068
  scheduleReconnectAttempt();
939
1069
  };
@@ -965,7 +1095,10 @@ function createHLSPluginWith(loader, variant, config) {
965
1095
  }
966
1096
  api.setState("playbackState", "ready");
967
1097
  api.setState("buffering", false);
968
- api.emit("error:recovered", void 0);
1098
+ api.emit("error:recovered", {
1099
+ attempt: reconnectAttempts,
1100
+ elapsedMs: Date.now() - reconnectWindowStart
1101
+ });
969
1102
  api.logger.info("Auto-reconnect succeeded");
970
1103
  cancelReconnect();
971
1104
  try {
@@ -1256,5 +1389,6 @@ function createHLSPlugin(config) {
1256
1389
  var index_default = createHLSPlugin;
1257
1390
  // Annotate the CommonJS export names for ESM import in node:
1258
1391
  0 && (module.exports = {
1259
- createHLSPlugin
1392
+ createHLSPlugin,
1393
+ sanitizeUrl
1260
1394
  });
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { H as HLSPluginConfig, I as IHLSPlugin } from './types-D-HW4lmM.cjs';
2
- export { b as HLSError, c as HLSLiveInfo, a as HLSQualityLevel } from './types-D-HW4lmM.cjs';
1
+ import { H as HLSPluginConfig, I as IHLSPlugin } from './sanitize-url-DcZXD_K-.cjs';
2
+ export { b as HLSError, c as HLSLiveInfo, a as HLSQualityLevel, s as sanitizeUrl } from './sanitize-url-DcZXD_K-.cjs';
3
3
  import '@scarlett-player/core';
4
4
 
5
5
  /**
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { H as HLSPluginConfig, I as IHLSPlugin } from './types-D-HW4lmM.js';
2
- export { b as HLSError, c as HLSLiveInfo, a as HLSQualityLevel } from './types-D-HW4lmM.js';
1
+ import { H as HLSPluginConfig, I as IHLSPlugin } from './sanitize-url-DcZXD_K-.js';
2
+ export { b as HLSError, c as HLSLiveInfo, a as HLSQualityLevel, s as sanitizeUrl } from './sanitize-url-DcZXD_K-.js';
3
3
  import '@scarlett-player/core';
4
4
 
5
5
  /**
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import {
2
2
  __export,
3
- createHLSPluginWith
4
- } from "./chunk-IFUZZQQE.js";
3
+ createHLSPluginWith,
4
+ sanitizeUrl
5
+ } from "./chunk-NU6WD6KW.js";
5
6
 
6
7
  // src/hls-loader.ts
7
8
  var hls_loader_exports = {};
@@ -93,5 +94,6 @@ function createHLSPlugin(config) {
93
94
  var index_default = createHLSPlugin;
94
95
  export {
95
96
  createHLSPlugin,
96
- index_default as default
97
+ index_default as default,
98
+ sanitizeUrl
97
99
  };
package/dist/light.cjs CHANGED
@@ -31,7 +31,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var light_exports = {};
32
32
  __export(light_exports, {
33
33
  createHLSPlugin: () => createHLSPlugin,
34
- default: () => light_default
34
+ default: () => light_default,
35
+ sanitizeUrl: () => sanitizeUrl
35
36
  });
36
37
  module.exports = __toCommonJS(light_exports);
37
38
 
@@ -486,6 +487,17 @@ function createValidatingPlaylistLoader(Hls) {
486
487
  };
487
488
  }
488
489
 
490
+ // src/sanitize-url.ts
491
+ function sanitizeUrl(url) {
492
+ if (!url) return void 0;
493
+ try {
494
+ const parsed = new URL(url);
495
+ return `${parsed.origin}${parsed.pathname}`;
496
+ } catch {
497
+ return void 0;
498
+ }
499
+ }
500
+
489
501
  // src/create-hls-plugin.ts
490
502
  var DEFAULT_CONFIG = {
491
503
  debug: false,
@@ -542,6 +554,8 @@ function createHLSPluginWith(loader, variant, config) {
542
554
  let reconnectWindowStart = 0;
543
555
  let reconnectResumePosition = 0;
544
556
  let onlineListener = null;
557
+ let reconnectTriggerError = null;
558
+ let reconnectExhausted = false;
545
559
  const getOrCreateVideo = () => {
546
560
  if (video) return video;
547
561
  const existing = api?.container.querySelector("video");
@@ -650,6 +664,22 @@ function createHLSPluginWith(loader, variant, config) {
650
664
  return import_core.ErrorCode.PLAYBACK_FAILED;
651
665
  }
652
666
  };
667
+ const buildErrorDetail = (error, retriesExhausted) => {
668
+ const attempts = error.type === "network" ? networkRetryCount : error.type === "media" ? mediaRetryCount : 0;
669
+ const detail = {
670
+ type: error.type,
671
+ retriesExhausted,
672
+ attempts
673
+ };
674
+ if (typeof error.response?.code === "number" && error.response.code > 0) {
675
+ detail.httpStatus = error.response.code;
676
+ }
677
+ const url = sanitizeUrl(error.url);
678
+ if (url) {
679
+ detail.url = url;
680
+ }
681
+ return detail;
682
+ };
653
683
  const emitFatalError = (error, retriesExhausted) => {
654
684
  const message = retriesExhausted ? `HLS error: ${error.details} (max retries exceeded)` : `HLS error: ${error.details}`;
655
685
  api?.logger.error(message, { type: error.type, details: error.details });
@@ -659,7 +689,8 @@ function createHLSPluginWith(loader, variant, config) {
659
689
  code: mapFatalErrorCode(error),
660
690
  message,
661
691
  fatal: true,
662
- timestamp: Date.now()
692
+ timestamp: Date.now(),
693
+ detail: buildErrorDetail(error, retriesExhausted)
663
694
  });
664
695
  maybeScheduleReconnect(error);
665
696
  };
@@ -736,6 +767,62 @@ function createHLSPluginWith(loader, variant, config) {
736
767
  }
737
768
  return false;
738
769
  };
770
+ const handleNativeFatalError = (error, resumePosition) => {
771
+ const is_network = error.type === "network";
772
+ const max_retries = is_network ? mergedConfig.maxNetworkRetries ?? 3 : mergedConfig.maxMediaRetries ?? 2;
773
+ const used = is_network ? networkRetryCount : mediaRetryCount;
774
+ if (!currentSrc || used >= max_retries) {
775
+ emitFatalError(error, used >= max_retries);
776
+ return;
777
+ }
778
+ const resume_position = resumePosition ?? video?.currentTime ?? 0;
779
+ if (is_network) {
780
+ networkRetryCount++;
781
+ } else {
782
+ mediaRetryCount++;
783
+ }
784
+ const attempt = used + 1;
785
+ const delay = getRetryDelay(attempt - 1);
786
+ api?.logger.info(
787
+ `Attempting native ${error.type} error recovery (attempt ${attempt}/${max_retries}) in ${delay}ms`
788
+ );
789
+ api?.emit(is_network ? "error:network" : "error:media", {
790
+ error: new Error(error.details)
791
+ });
792
+ if (retryTimeout) {
793
+ clearTimeout(retryTimeout);
794
+ }
795
+ const retry_session = loadSession;
796
+ retryTimeout = setTimeout(() => {
797
+ if (retry_session !== loadSession) return;
798
+ void recoverNative(error, resume_position);
799
+ }, delay);
800
+ };
801
+ const recoverNative = async (error, resumePosition) => {
802
+ if (!currentSrc) return;
803
+ const session = ++loadSession;
804
+ const saved_src = currentSrc;
805
+ const was_live = api?.getState("live") ?? false;
806
+ try {
807
+ teardownPipeline(new Error("HLS load cancelled: native error recovery"));
808
+ api?.setState("playbackState", "loading");
809
+ await loadNative(saved_src);
810
+ if (session !== loadSession) return;
811
+ if (!was_live && video && resumePosition > 0) {
812
+ video.currentTime = resumePosition;
813
+ }
814
+ api?.setState("playbackState", "ready");
815
+ api?.setState("buffering", false);
816
+ try {
817
+ await video?.play();
818
+ } catch {
819
+ }
820
+ } catch {
821
+ if (session !== loadSession) return;
822
+ api?.logger.warn("Native error recovery attempt failed");
823
+ handleNativeFatalError(error, resumePosition);
824
+ }
825
+ };
739
826
  const loadNative = async (src) => {
740
827
  const session = loadSession;
741
828
  const videoEl = getOrCreateVideo();
@@ -780,10 +867,21 @@ function createHLSPluginWith(loader, variant, config) {
780
867
  details: media_error?.message || "Native HLS playback error",
781
868
  fatal: true
782
869
  };
783
- emitFatalError(hls_error, false);
870
+ handleNativeFatalError(hls_error);
784
871
  };
785
872
  videoEl.addEventListener("error", onFatalVideoError);
786
- const removeFatalListener = () => videoEl.removeEventListener("error", onFatalVideoError);
873
+ const onPlayingResetBudget = () => {
874
+ if (networkRetryCount > 0 || mediaRetryCount > 0) {
875
+ api?.logger.debug("Native playback recovered, resetting retry budgets");
876
+ networkRetryCount = 0;
877
+ mediaRetryCount = 0;
878
+ }
879
+ };
880
+ videoEl.addEventListener("playing", onPlayingResetBudget);
881
+ const removeFatalListener = () => {
882
+ videoEl.removeEventListener("error", onFatalVideoError);
883
+ videoEl.removeEventListener("playing", onPlayingResetBudget);
884
+ };
787
885
  const previous_cleanup = cleanupVideoEvents;
788
886
  cleanupVideoEvents = () => {
789
887
  removeFatalListener();
@@ -908,12 +1006,38 @@ function createHLSPluginWith(loader, variant, config) {
908
1006
  reconnectAttempts = 0;
909
1007
  reconnectWindowStart = 0;
910
1008
  reconnectResumePosition = 0;
1009
+ reconnectTriggerError = null;
1010
+ reconnectExhausted = false;
1011
+ };
1012
+ const emitReconnectExhausted = (elapsedMs, windowMs) => {
1013
+ if (reconnectExhausted) return;
1014
+ reconnectExhausted = true;
1015
+ const attempts = reconnectAttempts;
1016
+ const trigger = reconnectTriggerError;
1017
+ api?.emit("error:reconnect-exhausted", { attempts, elapsedMs, windowMs });
1018
+ api?.setState("playbackState", "error");
1019
+ api?.setState("buffering", false);
1020
+ api?.emit("error", {
1021
+ code: trigger ? mapFatalErrorCode(trigger) : import_core.ErrorCode.PLAYBACK_FAILED,
1022
+ message: `HLS auto-reconnect gave up after ${attempts} attempts over ${Math.round(elapsedMs / 1e3)}s`,
1023
+ fatal: true,
1024
+ timestamp: Date.now(),
1025
+ detail: {
1026
+ type: trigger?.type ?? "other",
1027
+ retriesExhausted: true,
1028
+ attempts,
1029
+ reconnectExhausted: true
1030
+ }
1031
+ });
911
1032
  };
912
1033
  const scheduleReconnectAttempt = () => {
1034
+ if (reconnectExhausted) return;
913
1035
  if (reconnectTimer) return;
914
1036
  const window_ms = mergedConfig.reconnectWindowMs ?? 3e5;
915
- if (Date.now() - reconnectWindowStart > window_ms) {
1037
+ const elapsed_ms = Date.now() - reconnectWindowStart;
1038
+ if (elapsed_ms > window_ms) {
916
1039
  api?.logger.warn(`Auto-reconnect window exhausted after ${reconnectAttempts} attempts`);
1040
+ emitReconnectExhausted(elapsed_ms, window_ms);
917
1041
  return;
918
1042
  }
919
1043
  const base_delay = mergedConfig.reconnectBaseDelayMs ?? 2e3;
@@ -921,7 +1045,12 @@ function createHLSPluginWith(loader, variant, config) {
921
1045
  const backoff = Math.min(base_delay * Math.pow(2, reconnectAttempts), max_delay);
922
1046
  const delay = Math.round(backoff * (0.7 + Math.random() * 0.3));
923
1047
  api?.logger.info(`Scheduling auto-reconnect attempt ${reconnectAttempts + 1} in ${delay}ms`);
924
- api?.emit("error:reconnecting", { attempt: reconnectAttempts + 1, delayMs: delay });
1048
+ api?.emit("error:reconnecting", {
1049
+ attempt: reconnectAttempts + 1,
1050
+ delayMs: delay,
1051
+ elapsedMs: elapsed_ms,
1052
+ windowMs: window_ms
1053
+ });
925
1054
  reconnectTimer = setTimeout(() => {
926
1055
  reconnectTimer = null;
927
1056
  void attemptReconnect();
@@ -934,6 +1063,7 @@ function createHLSPluginWith(loader, variant, config) {
934
1063
  if (reconnectWindowStart === 0) {
935
1064
  reconnectWindowStart = Date.now();
936
1065
  reconnectResumePosition = video?.currentTime ?? 0;
1066
+ reconnectTriggerError = error;
937
1067
  }
938
1068
  scheduleReconnectAttempt();
939
1069
  };
@@ -965,7 +1095,10 @@ function createHLSPluginWith(loader, variant, config) {
965
1095
  }
966
1096
  api.setState("playbackState", "ready");
967
1097
  api.setState("buffering", false);
968
- api.emit("error:recovered", void 0);
1098
+ api.emit("error:recovered", {
1099
+ attempt: reconnectAttempts,
1100
+ elapsedMs: Date.now() - reconnectWindowStart
1101
+ });
969
1102
  api.logger.info("Auto-reconnect succeeded");
970
1103
  cancelReconnect();
971
1104
  try {
@@ -1256,5 +1389,6 @@ function createHLSPlugin(config) {
1256
1389
  var light_default = createHLSPlugin;
1257
1390
  // Annotate the CommonJS export names for ESM import in node:
1258
1391
  0 && (module.exports = {
1259
- createHLSPlugin
1392
+ createHLSPlugin,
1393
+ sanitizeUrl
1260
1394
  });
package/dist/light.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { H as HLSPluginConfig, I as IHLSPlugin } from './types-D-HW4lmM.cjs';
2
- export { b as HLSError, c as HLSLiveInfo, a as HLSQualityLevel } from './types-D-HW4lmM.cjs';
1
+ import { H as HLSPluginConfig, I as IHLSPlugin } from './sanitize-url-DcZXD_K-.cjs';
2
+ export { b as HLSError, c as HLSLiveInfo, a as HLSQualityLevel, s as sanitizeUrl } from './sanitize-url-DcZXD_K-.cjs';
3
3
  import '@scarlett-player/core';
4
4
 
5
5
  /**
package/dist/light.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { H as HLSPluginConfig, I as IHLSPlugin } from './types-D-HW4lmM.js';
2
- export { b as HLSError, c as HLSLiveInfo, a as HLSQualityLevel } from './types-D-HW4lmM.js';
1
+ import { H as HLSPluginConfig, I as IHLSPlugin } from './sanitize-url-DcZXD_K-.js';
2
+ export { b as HLSError, c as HLSLiveInfo, a as HLSQualityLevel, s as sanitizeUrl } from './sanitize-url-DcZXD_K-.js';
3
3
  import '@scarlett-player/core';
4
4
 
5
5
  /**
package/dist/light.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import {
2
2
  __export,
3
- createHLSPluginWith
4
- } from "./chunk-IFUZZQQE.js";
3
+ createHLSPluginWith,
4
+ sanitizeUrl
5
+ } from "./chunk-NU6WD6KW.js";
5
6
 
6
7
  // src/hls-loader-light.ts
7
8
  var hls_loader_light_exports = {};
@@ -93,5 +94,6 @@ function createHLSPlugin(config) {
93
94
  var light_default = createHLSPlugin;
94
95
  export {
95
96
  createHLSPlugin,
96
- light_default as default
97
+ light_default as default,
98
+ sanitizeUrl
97
99
  };
@@ -22,9 +22,19 @@ interface HLSPluginConfig {
22
22
  backBufferLength?: number;
23
23
  /** Enable worker for hls.js (better performance) */
24
24
  enableWorker?: boolean;
25
- /** Max network error retries before giving up (default: 3) */
25
+ /**
26
+ * Max network error retries before giving up (default: 3).
27
+ *
28
+ * Governs both playback branches: hls.js retries the load, and the native
29
+ * (Safari/iOS) path reloads the source and restores position.
30
+ */
26
31
  maxNetworkRetries?: number;
27
- /** Max media error retries before giving up (default: 2) */
32
+ /**
33
+ * Max media error retries before giving up (default: 2).
34
+ *
35
+ * Governs both playback branches: hls.js calls recoverMediaError(), and the
36
+ * native (Safari/iOS) path reloads the source and restores position.
37
+ */
28
38
  maxMediaRetries?: number;
29
39
  /** Cap quality to player element dimensions (default: true) */
30
40
  capLevelToPlayerSize?: boolean;
@@ -128,4 +138,29 @@ interface IHLSPlugin extends Plugin<HLSPluginConfig> {
128
138
  switchToHlsJs(): Promise<void>;
129
139
  }
130
140
 
131
- export type { HLSPluginConfig as H, IHLSPlugin as I, HLSQualityLevel as a, HLSError as b, HLSLiveInfo as c };
141
+ /**
142
+ * URL sanitizer for error telemetry.
143
+ *
144
+ * Segment and playlist URLs are the single most useful field when
145
+ * diagnosing a dead stream, and the single most dangerous one to hand to a
146
+ * consumer's telemetry: signed-URL tokens, HMAC signatures, and session ids
147
+ * all live in the query string. Stripping the whole query (and fragment) is
148
+ * the privacy-safe default for EVERY consumer; anyone who genuinely needs
149
+ * the parameters can subscribe to hls.js directly.
150
+ */
151
+ /**
152
+ * Reduce a URL to origin + pathname, dropping the query string and fragment.
153
+ *
154
+ * @param url - Raw URL from a provider error, if any
155
+ * @returns Sanitized `origin + pathname`, or undefined when there is
156
+ * nothing usable to report (absent or unparseable URL)
157
+ *
158
+ * @example
159
+ * ```ts
160
+ * sanitizeUrl('https://cdn.example.com/live/x.m3u8?token=secret#frag');
161
+ * // 'https://cdn.example.com/live/x.m3u8'
162
+ * ```
163
+ */
164
+ declare function sanitizeUrl(url: string | undefined | null): string | undefined;
165
+
166
+ export { type HLSPluginConfig as H, type IHLSPlugin as I, type HLSQualityLevel as a, type HLSError as b, type HLSLiveInfo as c, sanitizeUrl as s };
@@ -22,9 +22,19 @@ interface HLSPluginConfig {
22
22
  backBufferLength?: number;
23
23
  /** Enable worker for hls.js (better performance) */
24
24
  enableWorker?: boolean;
25
- /** Max network error retries before giving up (default: 3) */
25
+ /**
26
+ * Max network error retries before giving up (default: 3).
27
+ *
28
+ * Governs both playback branches: hls.js retries the load, and the native
29
+ * (Safari/iOS) path reloads the source and restores position.
30
+ */
26
31
  maxNetworkRetries?: number;
27
- /** Max media error retries before giving up (default: 2) */
32
+ /**
33
+ * Max media error retries before giving up (default: 2).
34
+ *
35
+ * Governs both playback branches: hls.js calls recoverMediaError(), and the
36
+ * native (Safari/iOS) path reloads the source and restores position.
37
+ */
28
38
  maxMediaRetries?: number;
29
39
  /** Cap quality to player element dimensions (default: true) */
30
40
  capLevelToPlayerSize?: boolean;
@@ -128,4 +138,29 @@ interface IHLSPlugin extends Plugin<HLSPluginConfig> {
128
138
  switchToHlsJs(): Promise<void>;
129
139
  }
130
140
 
131
- export type { HLSPluginConfig as H, IHLSPlugin as I, HLSQualityLevel as a, HLSError as b, HLSLiveInfo as c };
141
+ /**
142
+ * URL sanitizer for error telemetry.
143
+ *
144
+ * Segment and playlist URLs are the single most useful field when
145
+ * diagnosing a dead stream, and the single most dangerous one to hand to a
146
+ * consumer's telemetry: signed-URL tokens, HMAC signatures, and session ids
147
+ * all live in the query string. Stripping the whole query (and fragment) is
148
+ * the privacy-safe default for EVERY consumer; anyone who genuinely needs
149
+ * the parameters can subscribe to hls.js directly.
150
+ */
151
+ /**
152
+ * Reduce a URL to origin + pathname, dropping the query string and fragment.
153
+ *
154
+ * @param url - Raw URL from a provider error, if any
155
+ * @returns Sanitized `origin + pathname`, or undefined when there is
156
+ * nothing usable to report (absent or unparseable URL)
157
+ *
158
+ * @example
159
+ * ```ts
160
+ * sanitizeUrl('https://cdn.example.com/live/x.m3u8?token=secret#frag');
161
+ * // 'https://cdn.example.com/live/x.m3u8'
162
+ * ```
163
+ */
164
+ declare function sanitizeUrl(url: string | undefined | null): string | undefined;
165
+
166
+ export { type HLSPluginConfig as H, type IHLSPlugin as I, type HLSQualityLevel as a, type HLSError as b, type HLSLiveInfo as c, sanitizeUrl as s };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scarlett-player/hls",
3
- "version": "1.5.1",
3
+ "version": "1.7.0",
4
4
  "description": "HLS Provider Plugin for Scarlett Player",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -37,7 +37,7 @@
37
37
  "vitest": "^1.6.0",
38
38
  "@vitest/coverage-v8": "^1.6.0",
39
39
  "jsdom": "^24.0.0",
40
- "@scarlett-player/core": "1.5.1"
40
+ "@scarlett-player/core": "1.7.0"
41
41
  },
42
42
  "keywords": [
43
43
  "video",