@scarlett-player/hls 1.0.1 → 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);
@@ -282,6 +283,11 @@ function setupVideoEventHandlers(video, api) {
282
283
  api.setState("duration", video.duration);
283
284
  api.setState("mediaType", video.videoWidth > 0 ? "video" : "audio");
284
285
  });
286
+ addHandler("loadeddata", () => {
287
+ if (video.videoWidth > 0) {
288
+ api.setState("mediaType", "video");
289
+ }
290
+ });
285
291
  addHandler("error", () => {
286
292
  const error = video.error;
287
293
  if (error) {
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);
@@ -371,6 +373,11 @@ function setupVideoEventHandlers(video, api) {
371
373
  api.setState("duration", video.duration);
372
374
  api.setState("mediaType", video.videoWidth > 0 ? "video" : "audio");
373
375
  });
376
+ addHandler("loadeddata", () => {
377
+ if (video.videoWidth > 0) {
378
+ api.setState("mediaType", "video");
379
+ }
380
+ });
374
381
  addHandler("error", () => {
375
382
  const error = video.error;
376
383
  if (error) {
@@ -426,8 +433,20 @@ var DEFAULT_CONFIG = {
426
433
  maxNetworkRetries: 3,
427
434
  maxMediaRetries: 2,
428
435
  retryDelayMs: 1e3,
429
- 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
430
444
  };
445
+ var MANIFEST_PHASE_ERRORS = [
446
+ "manifestLoadError",
447
+ "manifestLoadTimeOut",
448
+ "manifestParsingError"
449
+ ];
431
450
  function createHLSPlugin(config) {
432
451
  const mergedConfig = { ...DEFAULT_CONFIG, ...config };
433
452
  let api = null;
@@ -445,6 +464,12 @@ function createHLSPlugin(config) {
445
464
  let errorWindowStart = 0;
446
465
  const MAX_ERRORS_IN_WINDOW = 10;
447
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;
448
473
  const getOrCreateVideo = () => {
449
474
  if (video) return video;
450
475
  const existing = api?.container.querySelector("video");
@@ -513,21 +538,33 @@ function createHLSPlugin(config) {
513
538
  const jitter = delay * (0.7 + Math.random() * 0.3);
514
539
  return jitter;
515
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
+ };
516
552
  const emitFatalError = (error, retriesExhausted) => {
517
553
  const message = retriesExhausted ? `HLS error: ${error.details} (max retries exceeded)` : `HLS error: ${error.details}`;
518
554
  api?.logger.error(message, { type: error.type, details: error.details });
519
555
  api?.setState("playbackState", "error");
520
556
  api?.setState("buffering", false);
521
557
  api?.emit("error", {
522
- code: "MEDIA_ERROR",
558
+ code: mapFatalErrorCode(error),
523
559
  message,
524
560
  fatal: true,
525
561
  timestamp: Date.now()
526
562
  });
563
+ maybeScheduleReconnect(error);
527
564
  };
528
565
  const handleHlsError = (error) => {
529
566
  const Hls = getHlsConstructor();
530
- if (!Hls || !hls) return;
567
+ if (!Hls || !hls) return false;
531
568
  const now = Date.now();
532
569
  if (now - errorWindowStart > ERROR_WINDOW_MS) {
533
570
  errorCount = 1;
@@ -542,7 +579,7 @@ function createHLSPlugin(config) {
542
579
  cleanupHlsEvents = null;
543
580
  hls.destroy();
544
581
  hls = null;
545
- return;
582
+ return true;
546
583
  }
547
584
  if (error.fatal) {
548
585
  api?.logger.error("Fatal HLS error", { type: error.type, details: error.details });
@@ -552,7 +589,7 @@ function createHLSPlugin(config) {
552
589
  if (networkRetryCount >= maxRetries) {
553
590
  api?.logger.error(`Network error recovery failed after ${networkRetryCount} attempts`);
554
591
  emitFatalError(error, true);
555
- return;
592
+ return true;
556
593
  }
557
594
  networkRetryCount++;
558
595
  const delay = getRetryDelay(networkRetryCount - 1);
@@ -561,8 +598,12 @@ function createHLSPlugin(config) {
561
598
  if (retryTimeout) {
562
599
  clearTimeout(retryTimeout);
563
600
  }
601
+ const isManifestPhase = MANIFEST_PHASE_ERRORS.includes(error.details);
564
602
  retryTimeout = setTimeout(() => {
565
- if (hls) {
603
+ if (!hls) return;
604
+ if (isManifestPhase && currentSrc) {
605
+ hls.loadSource(currentSrc);
606
+ } else {
566
607
  hls.startLoad();
567
608
  }
568
609
  }, delay);
@@ -573,7 +614,7 @@ function createHLSPlugin(config) {
573
614
  if (mediaRetryCount >= maxRetries) {
574
615
  api?.logger.error(`Media error recovery failed after ${mediaRetryCount} attempts`);
575
616
  emitFatalError(error, true);
576
- return;
617
+ return true;
577
618
  }
578
619
  mediaRetryCount++;
579
620
  const delay = getRetryDelay(mediaRetryCount - 1);
@@ -591,9 +632,10 @@ function createHLSPlugin(config) {
591
632
  }
592
633
  default:
593
634
  emitFatalError(error, false);
594
- break;
635
+ return true;
595
636
  }
596
637
  }
638
+ return false;
597
639
  };
598
640
  const loadNative = async (src) => {
599
641
  const videoEl = getOrCreateVideo();
@@ -602,19 +644,50 @@ function createHLSPlugin(config) {
602
644
  cleanupVideoEvents = setupVideoEventHandlers(videoEl, api);
603
645
  }
604
646
  return new Promise((resolve, reject) => {
605
- const onLoaded = () => {
647
+ let watchdog = null;
648
+ const settle = () => {
606
649
  videoEl.removeEventListener("loadedmetadata", onLoaded);
607
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
+ };
608
675
  api?.setState("source", { src, type: "application/x-mpegURL" });
609
676
  api?.emit("media:loaded", { src, type: "application/x-mpegURL" });
610
677
  resolve();
611
678
  };
612
679
  const onError = () => {
613
- videoEl.removeEventListener("loadedmetadata", onLoaded);
614
- videoEl.removeEventListener("error", onError);
680
+ settle();
615
681
  const error = videoEl.error;
616
682
  reject(new Error(error?.message || "Failed to load HLS source"));
617
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
+ }
618
691
  videoEl.addEventListener("loadedmetadata", onLoaded);
619
692
  videoEl.addEventListener("error", onError);
620
693
  videoEl.src = src;
@@ -635,10 +708,19 @@ function createHLSPlugin(config) {
635
708
  return;
636
709
  }
637
710
  let resolved = false;
711
+ let watchdog = null;
712
+ const clearWatchdog = () => {
713
+ if (watchdog !== null) {
714
+ clearTimeout(watchdog);
715
+ watchdog = null;
716
+ }
717
+ };
638
718
  cleanupHlsEvents = setupHlsEventHandlers(hls, api, {
639
719
  onManifestParsed: () => {
640
720
  if (!resolved) {
641
721
  resolved = true;
722
+ clearWatchdog();
723
+ hasPlayedContent = true;
642
724
  api?.setState("source", { src, type: "application/x-mpegURL" });
643
725
  api?.emit("media:loaded", { src, type: "application/x-mpegURL" });
644
726
  resolve();
@@ -647,18 +729,127 @@ function createHLSPlugin(config) {
647
729
  onLevelSwitched: () => {
648
730
  },
649
731
  onError: (error) => {
650
- handleHlsError(error);
651
- if (error.fatal && !resolved && error.type !== "network" && error.type !== "media") {
732
+ const terminal = handleHlsError(error);
733
+ if (terminal && !resolved) {
652
734
  resolved = true;
735
+ clearWatchdog();
653
736
  reject(new Error(error.details));
654
737
  }
655
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
+ },
656
746
  getIsAutoQuality: () => isAutoQuality
657
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
+ }
658
765
  hls.attachMedia(videoEl);
659
766
  hls.loadSource(src);
660
767
  });
661
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
+ };
662
853
  const plugin = {
663
854
  id: "hls-provider",
664
855
  name: "HLS Provider",
@@ -742,6 +933,17 @@ function createHLSPlugin(config) {
742
933
  }
743
934
  }
744
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
+ }
745
947
  api.onDestroy(() => {
746
948
  unsubPlay();
747
949
  unsubPause();
@@ -754,6 +956,11 @@ function createHLSPlugin(config) {
754
956
  },
755
957
  async destroy() {
756
958
  api?.logger.info("HLS plugin destroying");
959
+ cancelReconnect();
960
+ if (onlineListener && typeof window !== "undefined") {
961
+ window.removeEventListener("online", onlineListener);
962
+ onlineListener = null;
963
+ }
757
964
  cleanup();
758
965
  if (video?.parentNode) {
759
966
  video.parentNode.removeChild(video);
@@ -764,6 +971,8 @@ function createHLSPlugin(config) {
764
971
  async loadSource(src) {
765
972
  if (!api) throw new Error("Plugin not initialized");
766
973
  api.logger.info("Loading HLS source", { src });
974
+ cancelReconnect();
975
+ hasPlayedContent = false;
767
976
  cleanup();
768
977
  currentSrc = src;
769
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-OEPNMGNQ.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);
@@ -359,6 +360,11 @@ function setupVideoEventHandlers(video, api) {
359
360
  api.setState("duration", video.duration);
360
361
  api.setState("mediaType", video.videoWidth > 0 ? "video" : "audio");
361
362
  });
363
+ addHandler("loadeddata", () => {
364
+ if (video.videoWidth > 0) {
365
+ api.setState("mediaType", "video");
366
+ }
367
+ });
362
368
  addHandler("error", () => {
363
369
  const error = video.error;
364
370
  if (error) {
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-OEPNMGNQ.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.1",
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.1",
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.1"
40
+ "@scarlett-player/core": "1.1.1"
41
41
  },
42
42
  "keywords": [
43
43
  "video",