@scarlett-player/hls 1.0.2 → 1.1.1

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.
@@ -137,6 +137,7 @@ function setupHlsEventHandlers(hls, api, callbacks) {
137
137
  lastBandwidthUpdate = now;
138
138
  api.setState("bandwidth", Math.round(hls.bandwidthEstimate));
139
139
  }
140
+ callbacks.onFragLoaded?.();
140
141
  });
141
142
  addHandler("hlsFragBuffered", () => {
142
143
  api.setState("buffering", false);
package/dist/index.cjs CHANGED
@@ -34,6 +34,7 @@ __export(index_exports, {
34
34
  default: () => index_default
35
35
  });
36
36
  module.exports = __toCommonJS(index_exports);
37
+ var import_core = require("@scarlett-player/core");
37
38
 
38
39
  // src/hls-loader.ts
39
40
  var hlsConstructor = null;
@@ -226,6 +227,7 @@ function setupHlsEventHandlers(hls, api, callbacks) {
226
227
  lastBandwidthUpdate = now;
227
228
  api.setState("bandwidth", Math.round(hls.bandwidthEstimate));
228
229
  }
230
+ callbacks.onFragLoaded?.();
229
231
  });
230
232
  addHandler("hlsFragBuffered", () => {
231
233
  api.setState("buffering", false);
@@ -431,8 +433,20 @@ var DEFAULT_CONFIG = {
431
433
  maxNetworkRetries: 3,
432
434
  maxMediaRetries: 2,
433
435
  retryDelayMs: 1e3,
434
- retryBackoffFactor: 2
436
+ retryBackoffFactor: 2,
437
+ // Load watchdog: never leave the viewer on an endless spinner
438
+ loadTimeoutMs: 3e4,
439
+ // Self-healing: reconnect automatically after fatal errors mid-playback
440
+ autoReconnect: true,
441
+ reconnectBaseDelayMs: 2e3,
442
+ reconnectMaxDelayMs: 3e4,
443
+ reconnectWindowMs: 3e5
435
444
  };
445
+ var MANIFEST_PHASE_ERRORS = [
446
+ "manifestLoadError",
447
+ "manifestLoadTimeOut",
448
+ "manifestParsingError"
449
+ ];
436
450
  function createHLSPlugin(config) {
437
451
  const mergedConfig = { ...DEFAULT_CONFIG, ...config };
438
452
  let api = null;
@@ -450,6 +464,12 @@ function createHLSPlugin(config) {
450
464
  let errorWindowStart = 0;
451
465
  const MAX_ERRORS_IN_WINDOW = 10;
452
466
  const ERROR_WINDOW_MS = 5e3;
467
+ let hasPlayedContent = false;
468
+ let reconnectTimer = null;
469
+ let reconnectAttempts = 0;
470
+ let reconnectWindowStart = 0;
471
+ let reconnectResumePosition = 0;
472
+ let onlineListener = null;
453
473
  const getOrCreateVideo = () => {
454
474
  if (video) return video;
455
475
  const existing = api?.container.querySelector("video");
@@ -518,21 +538,33 @@ function createHLSPlugin(config) {
518
538
  const jitter = delay * (0.7 + Math.random() * 0.3);
519
539
  return jitter;
520
540
  };
541
+ const mapFatalErrorCode = (error) => {
542
+ switch (error.type) {
543
+ case "network":
544
+ return import_core.ErrorCode.MEDIA_NETWORK_ERROR;
545
+ case "media":
546
+ case "mux":
547
+ return import_core.ErrorCode.MEDIA_DECODE_ERROR;
548
+ default:
549
+ return import_core.ErrorCode.PLAYBACK_FAILED;
550
+ }
551
+ };
521
552
  const emitFatalError = (error, retriesExhausted) => {
522
553
  const message = retriesExhausted ? `HLS error: ${error.details} (max retries exceeded)` : `HLS error: ${error.details}`;
523
554
  api?.logger.error(message, { type: error.type, details: error.details });
524
555
  api?.setState("playbackState", "error");
525
556
  api?.setState("buffering", false);
526
557
  api?.emit("error", {
527
- code: "MEDIA_ERROR",
558
+ code: mapFatalErrorCode(error),
528
559
  message,
529
560
  fatal: true,
530
561
  timestamp: Date.now()
531
562
  });
563
+ maybeScheduleReconnect(error);
532
564
  };
533
565
  const handleHlsError = (error) => {
534
566
  const Hls = getHlsConstructor();
535
- if (!Hls || !hls) return;
567
+ if (!Hls || !hls) return false;
536
568
  const now = Date.now();
537
569
  if (now - errorWindowStart > ERROR_WINDOW_MS) {
538
570
  errorCount = 1;
@@ -547,7 +579,7 @@ function createHLSPlugin(config) {
547
579
  cleanupHlsEvents = null;
548
580
  hls.destroy();
549
581
  hls = null;
550
- return;
582
+ return true;
551
583
  }
552
584
  if (error.fatal) {
553
585
  api?.logger.error("Fatal HLS error", { type: error.type, details: error.details });
@@ -557,7 +589,7 @@ function createHLSPlugin(config) {
557
589
  if (networkRetryCount >= maxRetries) {
558
590
  api?.logger.error(`Network error recovery failed after ${networkRetryCount} attempts`);
559
591
  emitFatalError(error, true);
560
- return;
592
+ return true;
561
593
  }
562
594
  networkRetryCount++;
563
595
  const delay = getRetryDelay(networkRetryCount - 1);
@@ -566,8 +598,12 @@ function createHLSPlugin(config) {
566
598
  if (retryTimeout) {
567
599
  clearTimeout(retryTimeout);
568
600
  }
601
+ const isManifestPhase = MANIFEST_PHASE_ERRORS.includes(error.details);
569
602
  retryTimeout = setTimeout(() => {
570
- if (hls) {
603
+ if (!hls) return;
604
+ if (isManifestPhase && currentSrc) {
605
+ hls.loadSource(currentSrc);
606
+ } else {
571
607
  hls.startLoad();
572
608
  }
573
609
  }, delay);
@@ -578,7 +614,7 @@ function createHLSPlugin(config) {
578
614
  if (mediaRetryCount >= maxRetries) {
579
615
  api?.logger.error(`Media error recovery failed after ${mediaRetryCount} attempts`);
580
616
  emitFatalError(error, true);
581
- return;
617
+ return true;
582
618
  }
583
619
  mediaRetryCount++;
584
620
  const delay = getRetryDelay(mediaRetryCount - 1);
@@ -596,9 +632,10 @@ function createHLSPlugin(config) {
596
632
  }
597
633
  default:
598
634
  emitFatalError(error, false);
599
- break;
635
+ return true;
600
636
  }
601
637
  }
638
+ return false;
602
639
  };
603
640
  const loadNative = async (src) => {
604
641
  const videoEl = getOrCreateVideo();
@@ -607,19 +644,50 @@ function createHLSPlugin(config) {
607
644
  cleanupVideoEvents = setupVideoEventHandlers(videoEl, api);
608
645
  }
609
646
  return new Promise((resolve, reject) => {
610
- const onLoaded = () => {
647
+ let watchdog = null;
648
+ const settle = () => {
611
649
  videoEl.removeEventListener("loadedmetadata", onLoaded);
612
650
  videoEl.removeEventListener("error", onError);
651
+ if (watchdog !== null) {
652
+ clearTimeout(watchdog);
653
+ watchdog = null;
654
+ }
655
+ };
656
+ const onLoaded = () => {
657
+ settle();
658
+ hasPlayedContent = true;
659
+ const onFatalVideoError = () => {
660
+ const media_error = videoEl.error;
661
+ const hls_error = {
662
+ type: media_error?.code === MediaError.MEDIA_ERR_NETWORK ? "network" : "media",
663
+ details: media_error?.message || "Native HLS playback error",
664
+ fatal: true
665
+ };
666
+ emitFatalError(hls_error, false);
667
+ };
668
+ videoEl.addEventListener("error", onFatalVideoError);
669
+ const removeFatalListener = () => videoEl.removeEventListener("error", onFatalVideoError);
670
+ const previous_cleanup = cleanupVideoEvents;
671
+ cleanupVideoEvents = () => {
672
+ removeFatalListener();
673
+ previous_cleanup?.();
674
+ };
613
675
  api?.setState("source", { src, type: "application/x-mpegURL" });
614
676
  api?.emit("media:loaded", { src, type: "application/x-mpegURL" });
615
677
  resolve();
616
678
  };
617
679
  const onError = () => {
618
- videoEl.removeEventListener("loadedmetadata", onLoaded);
619
- videoEl.removeEventListener("error", onError);
680
+ settle();
620
681
  const error = videoEl.error;
621
682
  reject(new Error(error?.message || "Failed to load HLS source"));
622
683
  };
684
+ const timeout_ms = mergedConfig.loadTimeoutMs ?? 3e4;
685
+ if (timeout_ms > 0) {
686
+ watchdog = setTimeout(() => {
687
+ settle();
688
+ reject(new Error("Video took too long to load (network timeout)"));
689
+ }, timeout_ms);
690
+ }
623
691
  videoEl.addEventListener("loadedmetadata", onLoaded);
624
692
  videoEl.addEventListener("error", onError);
625
693
  videoEl.src = src;
@@ -640,10 +708,19 @@ function createHLSPlugin(config) {
640
708
  return;
641
709
  }
642
710
  let resolved = false;
711
+ let watchdog = null;
712
+ const clearWatchdog = () => {
713
+ if (watchdog !== null) {
714
+ clearTimeout(watchdog);
715
+ watchdog = null;
716
+ }
717
+ };
643
718
  cleanupHlsEvents = setupHlsEventHandlers(hls, api, {
644
719
  onManifestParsed: () => {
645
720
  if (!resolved) {
646
721
  resolved = true;
722
+ clearWatchdog();
723
+ hasPlayedContent = true;
647
724
  api?.setState("source", { src, type: "application/x-mpegURL" });
648
725
  api?.emit("media:loaded", { src, type: "application/x-mpegURL" });
649
726
  resolve();
@@ -652,18 +729,127 @@ function createHLSPlugin(config) {
652
729
  onLevelSwitched: () => {
653
730
  },
654
731
  onError: (error) => {
655
- handleHlsError(error);
656
- if (error.fatal && !resolved && error.type !== "network" && error.type !== "media") {
732
+ const terminal = handleHlsError(error);
733
+ if (terminal && !resolved) {
657
734
  resolved = true;
735
+ clearWatchdog();
658
736
  reject(new Error(error.details));
659
737
  }
660
738
  },
739
+ onFragLoaded: () => {
740
+ if (networkRetryCount > 0 || mediaRetryCount > 0) {
741
+ api?.logger.debug("Playback recovered, resetting retry budgets");
742
+ networkRetryCount = 0;
743
+ mediaRetryCount = 0;
744
+ }
745
+ },
661
746
  getIsAutoQuality: () => isAutoQuality
662
747
  });
748
+ const timeout_ms = mergedConfig.loadTimeoutMs ?? 3e4;
749
+ if (timeout_ms > 0) {
750
+ watchdog = setTimeout(() => {
751
+ if (resolved) return;
752
+ resolved = true;
753
+ api?.logger.error(`HLS load timed out after ${timeout_ms}ms`, { src });
754
+ if (retryTimeout) {
755
+ clearTimeout(retryTimeout);
756
+ retryTimeout = null;
757
+ }
758
+ cleanupHlsEvents?.();
759
+ cleanupHlsEvents = null;
760
+ hls?.destroy();
761
+ hls = null;
762
+ reject(new Error("Video took too long to load (network timeout)"));
763
+ }, timeout_ms);
764
+ }
663
765
  hls.attachMedia(videoEl);
664
766
  hls.loadSource(src);
665
767
  });
666
768
  };
769
+ const cancelReconnect = () => {
770
+ if (reconnectTimer) {
771
+ clearTimeout(reconnectTimer);
772
+ reconnectTimer = null;
773
+ }
774
+ reconnectAttempts = 0;
775
+ reconnectWindowStart = 0;
776
+ reconnectResumePosition = 0;
777
+ };
778
+ const scheduleReconnectAttempt = () => {
779
+ if (reconnectTimer) return;
780
+ const window_ms = mergedConfig.reconnectWindowMs ?? 3e5;
781
+ if (Date.now() - reconnectWindowStart > window_ms) {
782
+ api?.logger.warn(`Auto-reconnect window exhausted after ${reconnectAttempts} attempts`);
783
+ return;
784
+ }
785
+ const base_delay = mergedConfig.reconnectBaseDelayMs ?? 2e3;
786
+ const max_delay = mergedConfig.reconnectMaxDelayMs ?? 3e4;
787
+ const backoff = Math.min(base_delay * Math.pow(2, reconnectAttempts), max_delay);
788
+ const delay = Math.round(backoff * (0.7 + Math.random() * 0.3));
789
+ api?.logger.info(`Scheduling auto-reconnect attempt ${reconnectAttempts + 1} in ${delay}ms`);
790
+ api?.emit("error:reconnecting", { attempt: reconnectAttempts + 1, delayMs: delay });
791
+ reconnectTimer = setTimeout(() => {
792
+ reconnectTimer = null;
793
+ void attemptReconnect();
794
+ }, delay);
795
+ };
796
+ const maybeScheduleReconnect = (error) => {
797
+ if (mergedConfig.autoReconnect === false) return;
798
+ if (!hasPlayedContent || !currentSrc) return;
799
+ if (error.type !== "network" && error.type !== "media") return;
800
+ if (reconnectWindowStart === 0) {
801
+ reconnectWindowStart = Date.now();
802
+ reconnectResumePosition = video?.currentTime ?? 0;
803
+ }
804
+ scheduleReconnectAttempt();
805
+ };
806
+ const attemptReconnect = async () => {
807
+ if (!api || !currentSrc) return;
808
+ reconnectAttempts++;
809
+ const saved_src = currentSrc;
810
+ const was_live = api.getState("live");
811
+ const was_native = isNative;
812
+ const resume_position = reconnectResumePosition;
813
+ api.logger.info(`Auto-reconnect attempt ${reconnectAttempts}`, { src: saved_src });
814
+ try {
815
+ cleanupHlsEvents?.();
816
+ cleanupHlsEvents = null;
817
+ cleanupVideoEvents?.();
818
+ cleanupVideoEvents = null;
819
+ if (retryTimeout) {
820
+ clearTimeout(retryTimeout);
821
+ retryTimeout = null;
822
+ }
823
+ hls?.destroy();
824
+ hls = null;
825
+ networkRetryCount = 0;
826
+ mediaRetryCount = 0;
827
+ errorCount = 0;
828
+ errorWindowStart = 0;
829
+ currentSrc = saved_src;
830
+ api.setState("playbackState", "loading");
831
+ if (was_native && supportsNativeHLS()) {
832
+ await loadNative(saved_src);
833
+ } else {
834
+ await loadWithHlsJs(saved_src);
835
+ }
836
+ if (!was_live && video && resume_position > 0) {
837
+ video.currentTime = resume_position;
838
+ }
839
+ api.setState("playbackState", "ready");
840
+ api.setState("buffering", false);
841
+ api.emit("error:recovered", void 0);
842
+ api.logger.info("Auto-reconnect succeeded");
843
+ cancelReconnect();
844
+ try {
845
+ await video?.play();
846
+ } catch {
847
+ }
848
+ } catch {
849
+ api?.logger.warn(`Auto-reconnect attempt ${reconnectAttempts} failed`);
850
+ scheduleReconnectAttempt();
851
+ }
852
+ };
667
853
  const plugin = {
668
854
  id: "hls-provider",
669
855
  name: "HLS Provider",
@@ -747,6 +933,17 @@ function createHLSPlugin(config) {
747
933
  }
748
934
  }
749
935
  });
936
+ if (typeof window !== "undefined") {
937
+ onlineListener = () => {
938
+ if (reconnectTimer) {
939
+ api?.logger.info("Browser back online, reconnecting immediately");
940
+ clearTimeout(reconnectTimer);
941
+ reconnectTimer = null;
942
+ void attemptReconnect();
943
+ }
944
+ };
945
+ window.addEventListener("online", onlineListener);
946
+ }
750
947
  api.onDestroy(() => {
751
948
  unsubPlay();
752
949
  unsubPause();
@@ -759,6 +956,11 @@ function createHLSPlugin(config) {
759
956
  },
760
957
  async destroy() {
761
958
  api?.logger.info("HLS plugin destroying");
959
+ cancelReconnect();
960
+ if (onlineListener && typeof window !== "undefined") {
961
+ window.removeEventListener("online", onlineListener);
962
+ onlineListener = null;
963
+ }
762
964
  cleanup();
763
965
  if (video?.parentNode) {
764
966
  video.parentNode.removeChild(video);
@@ -769,6 +971,8 @@ function createHLSPlugin(config) {
769
971
  async loadSource(src) {
770
972
  if (!api) throw new Error("Plugin not initialized");
771
973
  api.logger.info("Loading HLS source", { src });
974
+ cancelReconnect();
975
+ hasPlayedContent = false;
772
976
  cleanup();
773
977
  currentSrc = src;
774
978
  api.setState("playbackState", "loading");
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { H as HLSPluginConfig, I as IHLSPlugin } from './types-CRKmNxEW.cjs';
2
- export { a as HLSError, b as HLSLiveInfo, c as HLSQualityLevel } from './types-CRKmNxEW.cjs';
1
+ import { H as HLSPluginConfig, I as IHLSPlugin } from './types-DOzoPHQe.cjs';
2
+ export { b as HLSError, c as HLSLiveInfo, a as HLSQualityLevel } from './types-DOzoPHQe.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-CRKmNxEW.js';
2
- export { a as HLSError, b as HLSLiveInfo, c as HLSQualityLevel } from './types-CRKmNxEW.js';
1
+ import { H as HLSPluginConfig, I as IHLSPlugin } from './types-DOzoPHQe.js';
2
+ export { b as HLSError, c as HLSLiveInfo, a as HLSQualityLevel } from './types-DOzoPHQe.js';
3
3
  import '@scarlett-player/core';
4
4
 
5
5
  /**
package/dist/index.js CHANGED
@@ -4,7 +4,10 @@ import {
4
4
  mapLevels,
5
5
  setupHlsEventHandlers,
6
6
  setupVideoEventHandlers
7
- } from "./chunk-MLOYPPFI.js";
7
+ } from "./chunk-L73PRXS4.js";
8
+
9
+ // src/index.ts
10
+ import { ErrorCode } from "@scarlett-player/core";
8
11
 
9
12
  // src/hls-loader.ts
10
13
  var hlsConstructor = null;
@@ -73,8 +76,20 @@ var DEFAULT_CONFIG = {
73
76
  maxNetworkRetries: 3,
74
77
  maxMediaRetries: 2,
75
78
  retryDelayMs: 1e3,
76
- retryBackoffFactor: 2
79
+ retryBackoffFactor: 2,
80
+ // Load watchdog: never leave the viewer on an endless spinner
81
+ loadTimeoutMs: 3e4,
82
+ // Self-healing: reconnect automatically after fatal errors mid-playback
83
+ autoReconnect: true,
84
+ reconnectBaseDelayMs: 2e3,
85
+ reconnectMaxDelayMs: 3e4,
86
+ reconnectWindowMs: 3e5
77
87
  };
88
+ var MANIFEST_PHASE_ERRORS = [
89
+ "manifestLoadError",
90
+ "manifestLoadTimeOut",
91
+ "manifestParsingError"
92
+ ];
78
93
  function createHLSPlugin(config) {
79
94
  const mergedConfig = { ...DEFAULT_CONFIG, ...config };
80
95
  let api = null;
@@ -92,6 +107,12 @@ function createHLSPlugin(config) {
92
107
  let errorWindowStart = 0;
93
108
  const MAX_ERRORS_IN_WINDOW = 10;
94
109
  const ERROR_WINDOW_MS = 5e3;
110
+ let hasPlayedContent = false;
111
+ let reconnectTimer = null;
112
+ let reconnectAttempts = 0;
113
+ let reconnectWindowStart = 0;
114
+ let reconnectResumePosition = 0;
115
+ let onlineListener = null;
95
116
  const getOrCreateVideo = () => {
96
117
  if (video) return video;
97
118
  const existing = api?.container.querySelector("video");
@@ -160,21 +181,33 @@ function createHLSPlugin(config) {
160
181
  const jitter = delay * (0.7 + Math.random() * 0.3);
161
182
  return jitter;
162
183
  };
184
+ const mapFatalErrorCode = (error) => {
185
+ switch (error.type) {
186
+ case "network":
187
+ return ErrorCode.MEDIA_NETWORK_ERROR;
188
+ case "media":
189
+ case "mux":
190
+ return ErrorCode.MEDIA_DECODE_ERROR;
191
+ default:
192
+ return ErrorCode.PLAYBACK_FAILED;
193
+ }
194
+ };
163
195
  const emitFatalError = (error, retriesExhausted) => {
164
196
  const message = retriesExhausted ? `HLS error: ${error.details} (max retries exceeded)` : `HLS error: ${error.details}`;
165
197
  api?.logger.error(message, { type: error.type, details: error.details });
166
198
  api?.setState("playbackState", "error");
167
199
  api?.setState("buffering", false);
168
200
  api?.emit("error", {
169
- code: "MEDIA_ERROR",
201
+ code: mapFatalErrorCode(error),
170
202
  message,
171
203
  fatal: true,
172
204
  timestamp: Date.now()
173
205
  });
206
+ maybeScheduleReconnect(error);
174
207
  };
175
208
  const handleHlsError = (error) => {
176
209
  const Hls = getHlsConstructor();
177
- if (!Hls || !hls) return;
210
+ if (!Hls || !hls) return false;
178
211
  const now = Date.now();
179
212
  if (now - errorWindowStart > ERROR_WINDOW_MS) {
180
213
  errorCount = 1;
@@ -189,7 +222,7 @@ function createHLSPlugin(config) {
189
222
  cleanupHlsEvents = null;
190
223
  hls.destroy();
191
224
  hls = null;
192
- return;
225
+ return true;
193
226
  }
194
227
  if (error.fatal) {
195
228
  api?.logger.error("Fatal HLS error", { type: error.type, details: error.details });
@@ -199,7 +232,7 @@ function createHLSPlugin(config) {
199
232
  if (networkRetryCount >= maxRetries) {
200
233
  api?.logger.error(`Network error recovery failed after ${networkRetryCount} attempts`);
201
234
  emitFatalError(error, true);
202
- return;
235
+ return true;
203
236
  }
204
237
  networkRetryCount++;
205
238
  const delay = getRetryDelay(networkRetryCount - 1);
@@ -208,8 +241,12 @@ function createHLSPlugin(config) {
208
241
  if (retryTimeout) {
209
242
  clearTimeout(retryTimeout);
210
243
  }
244
+ const isManifestPhase = MANIFEST_PHASE_ERRORS.includes(error.details);
211
245
  retryTimeout = setTimeout(() => {
212
- if (hls) {
246
+ if (!hls) return;
247
+ if (isManifestPhase && currentSrc) {
248
+ hls.loadSource(currentSrc);
249
+ } else {
213
250
  hls.startLoad();
214
251
  }
215
252
  }, delay);
@@ -220,7 +257,7 @@ function createHLSPlugin(config) {
220
257
  if (mediaRetryCount >= maxRetries) {
221
258
  api?.logger.error(`Media error recovery failed after ${mediaRetryCount} attempts`);
222
259
  emitFatalError(error, true);
223
- return;
260
+ return true;
224
261
  }
225
262
  mediaRetryCount++;
226
263
  const delay = getRetryDelay(mediaRetryCount - 1);
@@ -238,9 +275,10 @@ function createHLSPlugin(config) {
238
275
  }
239
276
  default:
240
277
  emitFatalError(error, false);
241
- break;
278
+ return true;
242
279
  }
243
280
  }
281
+ return false;
244
282
  };
245
283
  const loadNative = async (src) => {
246
284
  const videoEl = getOrCreateVideo();
@@ -249,19 +287,50 @@ function createHLSPlugin(config) {
249
287
  cleanupVideoEvents = setupVideoEventHandlers(videoEl, api);
250
288
  }
251
289
  return new Promise((resolve, reject) => {
252
- const onLoaded = () => {
290
+ let watchdog = null;
291
+ const settle = () => {
253
292
  videoEl.removeEventListener("loadedmetadata", onLoaded);
254
293
  videoEl.removeEventListener("error", onError);
294
+ if (watchdog !== null) {
295
+ clearTimeout(watchdog);
296
+ watchdog = null;
297
+ }
298
+ };
299
+ const onLoaded = () => {
300
+ settle();
301
+ hasPlayedContent = true;
302
+ const onFatalVideoError = () => {
303
+ const media_error = videoEl.error;
304
+ const hls_error = {
305
+ type: media_error?.code === MediaError.MEDIA_ERR_NETWORK ? "network" : "media",
306
+ details: media_error?.message || "Native HLS playback error",
307
+ fatal: true
308
+ };
309
+ emitFatalError(hls_error, false);
310
+ };
311
+ videoEl.addEventListener("error", onFatalVideoError);
312
+ const removeFatalListener = () => videoEl.removeEventListener("error", onFatalVideoError);
313
+ const previous_cleanup = cleanupVideoEvents;
314
+ cleanupVideoEvents = () => {
315
+ removeFatalListener();
316
+ previous_cleanup?.();
317
+ };
255
318
  api?.setState("source", { src, type: "application/x-mpegURL" });
256
319
  api?.emit("media:loaded", { src, type: "application/x-mpegURL" });
257
320
  resolve();
258
321
  };
259
322
  const onError = () => {
260
- videoEl.removeEventListener("loadedmetadata", onLoaded);
261
- videoEl.removeEventListener("error", onError);
323
+ settle();
262
324
  const error = videoEl.error;
263
325
  reject(new Error(error?.message || "Failed to load HLS source"));
264
326
  };
327
+ const timeout_ms = mergedConfig.loadTimeoutMs ?? 3e4;
328
+ if (timeout_ms > 0) {
329
+ watchdog = setTimeout(() => {
330
+ settle();
331
+ reject(new Error("Video took too long to load (network timeout)"));
332
+ }, timeout_ms);
333
+ }
265
334
  videoEl.addEventListener("loadedmetadata", onLoaded);
266
335
  videoEl.addEventListener("error", onError);
267
336
  videoEl.src = src;
@@ -282,10 +351,19 @@ function createHLSPlugin(config) {
282
351
  return;
283
352
  }
284
353
  let resolved = false;
354
+ let watchdog = null;
355
+ const clearWatchdog = () => {
356
+ if (watchdog !== null) {
357
+ clearTimeout(watchdog);
358
+ watchdog = null;
359
+ }
360
+ };
285
361
  cleanupHlsEvents = setupHlsEventHandlers(hls, api, {
286
362
  onManifestParsed: () => {
287
363
  if (!resolved) {
288
364
  resolved = true;
365
+ clearWatchdog();
366
+ hasPlayedContent = true;
289
367
  api?.setState("source", { src, type: "application/x-mpegURL" });
290
368
  api?.emit("media:loaded", { src, type: "application/x-mpegURL" });
291
369
  resolve();
@@ -294,18 +372,127 @@ function createHLSPlugin(config) {
294
372
  onLevelSwitched: () => {
295
373
  },
296
374
  onError: (error) => {
297
- handleHlsError(error);
298
- if (error.fatal && !resolved && error.type !== "network" && error.type !== "media") {
375
+ const terminal = handleHlsError(error);
376
+ if (terminal && !resolved) {
299
377
  resolved = true;
378
+ clearWatchdog();
300
379
  reject(new Error(error.details));
301
380
  }
302
381
  },
382
+ onFragLoaded: () => {
383
+ if (networkRetryCount > 0 || mediaRetryCount > 0) {
384
+ api?.logger.debug("Playback recovered, resetting retry budgets");
385
+ networkRetryCount = 0;
386
+ mediaRetryCount = 0;
387
+ }
388
+ },
303
389
  getIsAutoQuality: () => isAutoQuality
304
390
  });
391
+ const timeout_ms = mergedConfig.loadTimeoutMs ?? 3e4;
392
+ if (timeout_ms > 0) {
393
+ watchdog = setTimeout(() => {
394
+ if (resolved) return;
395
+ resolved = true;
396
+ api?.logger.error(`HLS load timed out after ${timeout_ms}ms`, { src });
397
+ if (retryTimeout) {
398
+ clearTimeout(retryTimeout);
399
+ retryTimeout = null;
400
+ }
401
+ cleanupHlsEvents?.();
402
+ cleanupHlsEvents = null;
403
+ hls?.destroy();
404
+ hls = null;
405
+ reject(new Error("Video took too long to load (network timeout)"));
406
+ }, timeout_ms);
407
+ }
305
408
  hls.attachMedia(videoEl);
306
409
  hls.loadSource(src);
307
410
  });
308
411
  };
412
+ const cancelReconnect = () => {
413
+ if (reconnectTimer) {
414
+ clearTimeout(reconnectTimer);
415
+ reconnectTimer = null;
416
+ }
417
+ reconnectAttempts = 0;
418
+ reconnectWindowStart = 0;
419
+ reconnectResumePosition = 0;
420
+ };
421
+ const scheduleReconnectAttempt = () => {
422
+ if (reconnectTimer) return;
423
+ const window_ms = mergedConfig.reconnectWindowMs ?? 3e5;
424
+ if (Date.now() - reconnectWindowStart > window_ms) {
425
+ api?.logger.warn(`Auto-reconnect window exhausted after ${reconnectAttempts} attempts`);
426
+ return;
427
+ }
428
+ const base_delay = mergedConfig.reconnectBaseDelayMs ?? 2e3;
429
+ const max_delay = mergedConfig.reconnectMaxDelayMs ?? 3e4;
430
+ const backoff = Math.min(base_delay * Math.pow(2, reconnectAttempts), max_delay);
431
+ const delay = Math.round(backoff * (0.7 + Math.random() * 0.3));
432
+ api?.logger.info(`Scheduling auto-reconnect attempt ${reconnectAttempts + 1} in ${delay}ms`);
433
+ api?.emit("error:reconnecting", { attempt: reconnectAttempts + 1, delayMs: delay });
434
+ reconnectTimer = setTimeout(() => {
435
+ reconnectTimer = null;
436
+ void attemptReconnect();
437
+ }, delay);
438
+ };
439
+ const maybeScheduleReconnect = (error) => {
440
+ if (mergedConfig.autoReconnect === false) return;
441
+ if (!hasPlayedContent || !currentSrc) return;
442
+ if (error.type !== "network" && error.type !== "media") return;
443
+ if (reconnectWindowStart === 0) {
444
+ reconnectWindowStart = Date.now();
445
+ reconnectResumePosition = video?.currentTime ?? 0;
446
+ }
447
+ scheduleReconnectAttempt();
448
+ };
449
+ const attemptReconnect = async () => {
450
+ if (!api || !currentSrc) return;
451
+ reconnectAttempts++;
452
+ const saved_src = currentSrc;
453
+ const was_live = api.getState("live");
454
+ const was_native = isNative;
455
+ const resume_position = reconnectResumePosition;
456
+ api.logger.info(`Auto-reconnect attempt ${reconnectAttempts}`, { src: saved_src });
457
+ try {
458
+ cleanupHlsEvents?.();
459
+ cleanupHlsEvents = null;
460
+ cleanupVideoEvents?.();
461
+ cleanupVideoEvents = null;
462
+ if (retryTimeout) {
463
+ clearTimeout(retryTimeout);
464
+ retryTimeout = null;
465
+ }
466
+ hls?.destroy();
467
+ hls = null;
468
+ networkRetryCount = 0;
469
+ mediaRetryCount = 0;
470
+ errorCount = 0;
471
+ errorWindowStart = 0;
472
+ currentSrc = saved_src;
473
+ api.setState("playbackState", "loading");
474
+ if (was_native && supportsNativeHLS()) {
475
+ await loadNative(saved_src);
476
+ } else {
477
+ await loadWithHlsJs(saved_src);
478
+ }
479
+ if (!was_live && video && resume_position > 0) {
480
+ video.currentTime = resume_position;
481
+ }
482
+ api.setState("playbackState", "ready");
483
+ api.setState("buffering", false);
484
+ api.emit("error:recovered", void 0);
485
+ api.logger.info("Auto-reconnect succeeded");
486
+ cancelReconnect();
487
+ try {
488
+ await video?.play();
489
+ } catch {
490
+ }
491
+ } catch {
492
+ api?.logger.warn(`Auto-reconnect attempt ${reconnectAttempts} failed`);
493
+ scheduleReconnectAttempt();
494
+ }
495
+ };
309
496
  const plugin = {
310
497
  id: "hls-provider",
311
498
  name: "HLS Provider",
@@ -389,6 +576,17 @@ function createHLSPlugin(config) {
389
576
  }
390
577
  }
391
578
  });
579
+ if (typeof window !== "undefined") {
580
+ onlineListener = () => {
581
+ if (reconnectTimer) {
582
+ api?.logger.info("Browser back online, reconnecting immediately");
583
+ clearTimeout(reconnectTimer);
584
+ reconnectTimer = null;
585
+ void attemptReconnect();
586
+ }
587
+ };
588
+ window.addEventListener("online", onlineListener);
589
+ }
392
590
  api.onDestroy(() => {
393
591
  unsubPlay();
394
592
  unsubPause();
@@ -401,6 +599,11 @@ function createHLSPlugin(config) {
401
599
  },
402
600
  async destroy() {
403
601
  api?.logger.info("HLS plugin destroying");
602
+ cancelReconnect();
603
+ if (onlineListener && typeof window !== "undefined") {
604
+ window.removeEventListener("online", onlineListener);
605
+ onlineListener = null;
606
+ }
404
607
  cleanup();
405
608
  if (video?.parentNode) {
406
609
  video.parentNode.removeChild(video);
@@ -411,6 +614,8 @@ function createHLSPlugin(config) {
411
614
  async loadSource(src) {
412
615
  if (!api) throw new Error("Plugin not initialized");
413
616
  api.logger.info("Loading HLS source", { src });
617
+ cancelReconnect();
618
+ hasPlayedContent = false;
414
619
  cleanup();
415
620
  currentSrc = src;
416
621
  api.setState("playbackState", "loading");
package/dist/light.cjs CHANGED
@@ -214,6 +214,7 @@ function setupHlsEventHandlers(hls, api, callbacks) {
214
214
  lastBandwidthUpdate = now;
215
215
  api.setState("bandwidth", Math.round(hls.bandwidthEstimate));
216
216
  }
217
+ callbacks.onFragLoaded?.();
217
218
  });
218
219
  addHandler("hlsFragBuffered", () => {
219
220
  api.setState("buffering", false);
package/dist/light.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { H as HLSPluginConfig, I as IHLSPlugin } from './types-CRKmNxEW.cjs';
2
- export { a as HLSError, b as HLSLiveInfo, c as HLSQualityLevel } from './types-CRKmNxEW.cjs';
1
+ import { H as HLSPluginConfig, I as IHLSPlugin } from './types-DOzoPHQe.cjs';
2
+ export { b as HLSError, c as HLSLiveInfo, a as HLSQualityLevel } from './types-DOzoPHQe.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-CRKmNxEW.js';
2
- export { a as HLSError, b as HLSLiveInfo, c as HLSQualityLevel } from './types-CRKmNxEW.js';
1
+ import { H as HLSPluginConfig, I as IHLSPlugin } from './types-DOzoPHQe.js';
2
+ export { b as HLSError, c as HLSLiveInfo, a as HLSQualityLevel } from './types-DOzoPHQe.js';
3
3
  import '@scarlett-player/core';
4
4
 
5
5
  /**
package/dist/light.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  mapLevels,
4
4
  setupHlsEventHandlers,
5
5
  setupVideoEventHandlers
6
- } from "./chunk-MLOYPPFI.js";
6
+ } from "./chunk-L73PRXS4.js";
7
7
 
8
8
  // src/hls-loader-light.ts
9
9
  var hlsConstructor = null;
@@ -34,6 +34,24 @@ interface HLSPluginConfig {
34
34
  retryDelayMs?: number;
35
35
  /** Exponential backoff multiplier (default: 2) */
36
36
  retryBackoffFactor?: number;
37
+ /**
38
+ * Watchdog for source loading in milliseconds (default: 30000, 0 disables).
39
+ * If the manifest has not parsed within this window the load fails with an
40
+ * error instead of leaving the viewer on a spinner forever.
41
+ */
42
+ loadTimeoutMs?: number;
43
+ /**
44
+ * Automatically attempt to reconnect after a fatal network/media error
45
+ * once the stream had been playing (default: true). Viewers should not
46
+ * have to press anything when a connection blip resolves itself.
47
+ */
48
+ autoReconnect?: boolean;
49
+ /** First auto-reconnect delay in milliseconds (default: 2000) */
50
+ reconnectBaseDelayMs?: number;
51
+ /** Cap for the auto-reconnect backoff in milliseconds (default: 30000) */
52
+ reconnectMaxDelayMs?: number;
53
+ /** Total window to keep auto-reconnecting in milliseconds (default: 300000 = 5 min) */
54
+ reconnectWindowMs?: number;
37
55
  /** Index signature for PluginConfig compatibility */
38
56
  [key: string]: unknown;
39
57
  }
@@ -102,4 +120,4 @@ interface IHLSPlugin extends Plugin<HLSPluginConfig> {
102
120
  switchToHlsJs(): Promise<void>;
103
121
  }
104
122
 
105
- export type { HLSPluginConfig as H, IHLSPlugin as I, HLSError as a, HLSLiveInfo as b, HLSQualityLevel as c };
123
+ export type { HLSPluginConfig as H, IHLSPlugin as I, HLSQualityLevel as a, HLSError as b, HLSLiveInfo as c };
@@ -34,6 +34,24 @@ interface HLSPluginConfig {
34
34
  retryDelayMs?: number;
35
35
  /** Exponential backoff multiplier (default: 2) */
36
36
  retryBackoffFactor?: number;
37
+ /**
38
+ * Watchdog for source loading in milliseconds (default: 30000, 0 disables).
39
+ * If the manifest has not parsed within this window the load fails with an
40
+ * error instead of leaving the viewer on a spinner forever.
41
+ */
42
+ loadTimeoutMs?: number;
43
+ /**
44
+ * Automatically attempt to reconnect after a fatal network/media error
45
+ * once the stream had been playing (default: true). Viewers should not
46
+ * have to press anything when a connection blip resolves itself.
47
+ */
48
+ autoReconnect?: boolean;
49
+ /** First auto-reconnect delay in milliseconds (default: 2000) */
50
+ reconnectBaseDelayMs?: number;
51
+ /** Cap for the auto-reconnect backoff in milliseconds (default: 30000) */
52
+ reconnectMaxDelayMs?: number;
53
+ /** Total window to keep auto-reconnecting in milliseconds (default: 300000 = 5 min) */
54
+ reconnectWindowMs?: number;
37
55
  /** Index signature for PluginConfig compatibility */
38
56
  [key: string]: unknown;
39
57
  }
@@ -102,4 +120,4 @@ interface IHLSPlugin extends Plugin<HLSPluginConfig> {
102
120
  switchToHlsJs(): Promise<void>;
103
121
  }
104
122
 
105
- export type { HLSPluginConfig as H, IHLSPlugin as I, HLSError as a, HLSLiveInfo as b, HLSQualityLevel as c };
123
+ export type { HLSPluginConfig as H, IHLSPlugin as I, HLSQualityLevel as a, HLSError as b, HLSLiveInfo as c };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scarlett-player/hls",
3
- "version": "1.0.2",
3
+ "version": "1.1.1",
4
4
  "description": "HLS Provider Plugin for Scarlett Player",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -22,7 +22,7 @@
22
22
  "dist"
23
23
  ],
24
24
  "peerDependencies": {
25
- "@scarlett-player/core": "^1.0.2",
25
+ "@scarlett-player/core": "^1.0.3",
26
26
  "hls.js": "^1.5.0"
27
27
  },
28
28
  "peerDependenciesMeta": {
@@ -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.0.2"
40
+ "@scarlett-player/core": "1.1.1"
41
41
  },
42
42
  "keywords": [
43
43
  "video",