@scarlett-player/hls 1.1.1 → 1.4.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.
package/dist/light.cjs CHANGED
@@ -36,6 +36,17 @@ __export(light_exports, {
36
36
  module.exports = __toCommonJS(light_exports);
37
37
 
38
38
  // src/hls-loader-light.ts
39
+ var hls_loader_light_exports = {};
40
+ __export(hls_loader_light_exports, {
41
+ createHlsInstance: () => createHlsInstance,
42
+ getHlsConstructor: () => getHlsConstructor,
43
+ isHLSSupported: () => isHLSSupported,
44
+ isHlsJsSupported: () => isHlsJsSupported,
45
+ loadHlsJs: () => loadHlsJs,
46
+ resetLoader: () => resetLoader,
47
+ shouldPreferNativeHLS: () => shouldPreferNativeHLS,
48
+ supportsNativeHLS: () => supportsNativeHLS
49
+ });
39
50
  var hlsConstructor = null;
40
51
  var loadingPromise = null;
41
52
  function supportsNativeHLS() {
@@ -43,6 +54,13 @@ function supportsNativeHLS() {
43
54
  const video = document.createElement("video");
44
55
  return video.canPlayType("application/vnd.apple.mpegurl") !== "";
45
56
  }
57
+ function shouldPreferNativeHLS() {
58
+ if (!supportsNativeHLS()) return false;
59
+ if (typeof navigator === "undefined") return false;
60
+ const ua = navigator.userAgent;
61
+ const isSafari = /Safari/.test(ua) && !/Chrome/.test(ua) && !/CriOS/.test(ua);
62
+ return isSafari;
63
+ }
46
64
  function isHlsJsSupported() {
47
65
  if (hlsConstructor) {
48
66
  return hlsConstructor.isSupported();
@@ -86,6 +104,13 @@ function createHlsInstance(config) {
86
104
  function getHlsConstructor() {
87
105
  return hlsConstructor;
88
106
  }
107
+ function resetLoader() {
108
+ hlsConstructor = null;
109
+ loadingPromise = null;
110
+ }
111
+
112
+ // src/create-hls-plugin.ts
113
+ var import_core = require("@scarlett-player/core");
89
114
 
90
115
  // src/quality.ts
91
116
  function formatLevel(level) {
@@ -133,6 +158,18 @@ function mapLevels(levels, _currentLevel) {
133
158
  codec: level.codecSet
134
159
  }));
135
160
  }
161
+ function getInitialBandwidthEstimate(overrideBps) {
162
+ const HLS_DEFAULT_ESTIMATE = 5e5;
163
+ if (overrideBps !== void 0 && overrideBps > 0) {
164
+ return overrideBps;
165
+ }
166
+ const connection = navigator.connection;
167
+ if (connection?.downlink && connection.downlink > 0) {
168
+ const bps = connection.downlink * 1e6;
169
+ return Math.round(bps * 0.85);
170
+ }
171
+ return HLS_DEFAULT_ESTIMATE;
172
+ }
136
173
 
137
174
  // src/event-map.ts
138
175
  var HLS_ERROR_TYPES = {
@@ -405,7 +442,51 @@ function setupVideoEventHandlers(video, api) {
405
442
  };
406
443
  }
407
444
 
408
- // src/light.ts
445
+ // src/playlist-validation.ts
446
+ var PLAYLIST_INVALID_TEXT = "Invalid playlist document";
447
+ var MEDIA_PLAYLIST_CONTEXTS = ["level", "audioTrack", "subtitleTrack"];
448
+ function isValidPlaylistDocument(data, contextType) {
449
+ if (typeof data !== "string" || data.length === 0) return false;
450
+ const text = data.trimStart();
451
+ if (!text.startsWith("#EXTM3U")) return false;
452
+ if (contextType && MEDIA_PLAYLIST_CONTEXTS.includes(contextType)) {
453
+ return /^#EXT(?:INF|-X-TARGETDURATION):/m.test(text);
454
+ }
455
+ return true;
456
+ }
457
+ function createValidatingPlaylistLoader(Hls) {
458
+ const BaseLoader = Hls.DefaultConfig.loader;
459
+ return class ValidatingPlaylistLoader extends BaseLoader {
460
+ /**
461
+ * Load a playlist, validating the response document before it reaches
462
+ * the M3U8 parser.
463
+ *
464
+ * @param context - hls.js loader context
465
+ * @param config - hls.js loader config
466
+ * @param callbacks - hls.js loader callbacks
467
+ */
468
+ load(context, config, callbacks) {
469
+ const wrapped = {
470
+ ...callbacks,
471
+ onSuccess: (response, stats, ctx, networkDetails) => {
472
+ if (!isValidPlaylistDocument(response?.data, ctx?.type)) {
473
+ callbacks.onError(
474
+ { code: 0, text: PLAYLIST_INVALID_TEXT },
475
+ ctx,
476
+ networkDetails,
477
+ stats
478
+ );
479
+ return;
480
+ }
481
+ callbacks.onSuccess(response, stats, ctx, networkDetails);
482
+ }
483
+ };
484
+ super.load(context, config, wrapped);
485
+ }
486
+ };
487
+ }
488
+
489
+ // src/create-hls-plugin.ts
409
490
  var DEFAULT_CONFIG = {
410
491
  debug: false,
411
492
  autoStartLoad: true,
@@ -415,12 +496,28 @@ var DEFAULT_CONFIG = {
415
496
  maxMaxBufferLength: 600,
416
497
  backBufferLength: 30,
417
498
  enableWorker: true,
499
+ capLevelToPlayerSize: true,
500
+ // Error recovery settings
418
501
  maxNetworkRetries: 3,
419
502
  maxMediaRetries: 2,
420
503
  retryDelayMs: 1e3,
421
- retryBackoffFactor: 2
504
+ retryBackoffFactor: 2,
505
+ // Load watchdog: never leave the viewer on an endless spinner
506
+ loadTimeoutMs: 3e4,
507
+ // Self-healing: reconnect automatically after fatal errors mid-playback
508
+ autoReconnect: true,
509
+ reconnectBaseDelayMs: 2e3,
510
+ reconnectMaxDelayMs: 3e4,
511
+ reconnectWindowMs: 3e5,
512
+ // Never index a malformed live playlist refresh blindly
513
+ validatePlaylists: true
422
514
  };
423
- function createHLSPlugin(config) {
515
+ var MANIFEST_PHASE_ERRORS = [
516
+ "manifestLoadError",
517
+ "manifestLoadTimeOut",
518
+ "manifestParsingError"
519
+ ];
520
+ function createHLSPluginWith(loader, variant, config) {
424
521
  const mergedConfig = { ...DEFAULT_CONFIG, ...config };
425
522
  let api = null;
426
523
  let hls = null;
@@ -430,6 +527,8 @@ function createHLSPlugin(config) {
430
527
  let cleanupHlsEvents = null;
431
528
  let cleanupVideoEvents = null;
432
529
  let isAutoQuality = true;
530
+ let loadSession = 0;
531
+ let abortPendingLoad = null;
433
532
  let networkRetryCount = 0;
434
533
  let mediaRetryCount = 0;
435
534
  let retryTimeout = null;
@@ -437,6 +536,12 @@ function createHLSPlugin(config) {
437
536
  let errorWindowStart = 0;
438
537
  const MAX_ERRORS_IN_WINDOW = 10;
439
538
  const ERROR_WINDOW_MS = 5e3;
539
+ let hasPlayedContent = false;
540
+ let reconnectTimer = null;
541
+ let reconnectAttempts = 0;
542
+ let reconnectWindowStart = 0;
543
+ let reconnectResumePosition = 0;
544
+ let onlineListener = null;
440
545
  const getOrCreateVideo = () => {
441
546
  if (video) return video;
442
547
  const existing = api?.container.querySelector("video");
@@ -449,10 +554,16 @@ function createHLSPlugin(config) {
449
554
  video.preload = "metadata";
450
555
  video.controls = false;
451
556
  video.playsInline = true;
557
+ const poster = api?.getState("poster");
558
+ if (poster) {
559
+ video.poster = poster;
560
+ }
452
561
  api?.container.appendChild(video);
453
562
  return video;
454
563
  };
455
- const cleanup = () => {
564
+ const teardownPipeline = (reason) => {
565
+ abortPendingLoad?.(reason ?? new Error("HLS load cancelled"));
566
+ abortPendingLoad = null;
456
567
  cleanupHlsEvents?.();
457
568
  cleanupHlsEvents = null;
458
569
  cleanupVideoEvents?.();
@@ -465,6 +576,9 @@ function createHLSPlugin(config) {
465
576
  hls.destroy();
466
577
  hls = null;
467
578
  }
579
+ };
580
+ const cleanup = (reason) => {
581
+ teardownPipeline(reason);
468
582
  currentSrc = null;
469
583
  isNative = false;
470
584
  isAutoQuality = true;
@@ -473,16 +587,30 @@ function createHLSPlugin(config) {
473
587
  errorCount = 0;
474
588
  errorWindowStart = 0;
475
589
  };
476
- const buildHlsConfig = () => ({
590
+ const buildHlsConfig = () => {
591
+ const config2 = buildBaseHlsConfig();
592
+ if (mergedConfig.validatePlaylists !== false) {
593
+ const Hls = loader.getHlsConstructor();
594
+ if (Hls && Hls.DefaultConfig?.loader) {
595
+ config2.pLoader = createValidatingPlaylistLoader(Hls);
596
+ }
597
+ }
598
+ return config2;
599
+ };
600
+ const buildBaseHlsConfig = () => ({
477
601
  debug: mergedConfig.debug,
478
602
  autoStartLoad: mergedConfig.autoStartLoad,
479
603
  startPosition: mergedConfig.startPosition,
480
604
  startLevel: -1,
605
+ // Auto quality selection (ABR)
606
+ abrEwmaDefaultEstimate: getInitialBandwidthEstimate(mergedConfig.initialBandwidthEstimate),
481
607
  lowLatencyMode: mergedConfig.lowLatencyMode,
482
608
  maxBufferLength: mergedConfig.maxBufferLength,
483
609
  maxMaxBufferLength: mergedConfig.maxMaxBufferLength,
484
610
  backBufferLength: mergedConfig.backBufferLength,
485
611
  enableWorker: mergedConfig.enableWorker,
612
+ capLevelToPlayerSize: mergedConfig.capLevelToPlayerSize,
613
+ // Minimize hls.js internal retries - we handle retries ourselves
486
614
  fragLoadingMaxRetry: 1,
487
615
  manifestLoadingMaxRetry: 1,
488
616
  levelLoadingMaxRetry: 1,
@@ -493,7 +621,34 @@ function createHLSPlugin(config) {
493
621
  const getRetryDelay = (retryCount) => {
494
622
  const baseDelay = mergedConfig.retryDelayMs ?? 1e3;
495
623
  const backoffFactor = mergedConfig.retryBackoffFactor ?? 2;
496
- return baseDelay * Math.pow(backoffFactor, retryCount);
624
+ const delay = baseDelay * Math.pow(backoffFactor, retryCount);
625
+ const jitter = delay * (0.7 + Math.random() * 0.3);
626
+ return jitter;
627
+ };
628
+ const APPEND_ERROR_DETAILS = [
629
+ "bufferAppendError",
630
+ "bufferAppendingError",
631
+ "bufferAddCodecError"
632
+ ];
633
+ const mapFatalErrorCode = (error) => {
634
+ if (error.response?.text === PLAYLIST_INVALID_TEXT) {
635
+ return import_core.ErrorCode.PLAYLIST_INVALID;
636
+ }
637
+ if (error.details === "bufferFullError") {
638
+ return import_core.ErrorCode.MEDIA_BUFFER_FULL;
639
+ }
640
+ if (APPEND_ERROR_DETAILS.includes(error.details)) {
641
+ return import_core.ErrorCode.MEDIA_APPEND_ERROR;
642
+ }
643
+ switch (error.type) {
644
+ case "network":
645
+ return import_core.ErrorCode.MEDIA_NETWORK_ERROR;
646
+ case "media":
647
+ case "mux":
648
+ return import_core.ErrorCode.MEDIA_DECODE_ERROR;
649
+ default:
650
+ return import_core.ErrorCode.PLAYBACK_FAILED;
651
+ }
497
652
  };
498
653
  const emitFatalError = (error, retriesExhausted) => {
499
654
  const message = retriesExhausted ? `HLS error: ${error.details} (max retries exceeded)` : `HLS error: ${error.details}`;
@@ -501,15 +656,16 @@ function createHLSPlugin(config) {
501
656
  api?.setState("playbackState", "error");
502
657
  api?.setState("buffering", false);
503
658
  api?.emit("error", {
504
- code: "MEDIA_ERROR",
659
+ code: mapFatalErrorCode(error),
505
660
  message,
506
661
  fatal: true,
507
662
  timestamp: Date.now()
508
663
  });
664
+ maybeScheduleReconnect(error);
509
665
  };
510
666
  const handleHlsError = (error) => {
511
- const Hls = getHlsConstructor();
512
- if (!Hls || !hls) return;
667
+ const Hls = loader.getHlsConstructor();
668
+ if (!Hls || !hls) return false;
513
669
  const now = Date.now();
514
670
  if (now - errorWindowStart > ERROR_WINDOW_MS) {
515
671
  errorCount = 1;
@@ -520,11 +676,8 @@ function createHLSPlugin(config) {
520
676
  if (errorCount >= MAX_ERRORS_IN_WINDOW) {
521
677
  api?.logger.error(`Too many errors (${errorCount} in ${ERROR_WINDOW_MS}ms), giving up`);
522
678
  emitFatalError(error, true);
523
- cleanupHlsEvents?.();
524
- cleanupHlsEvents = null;
525
- hls.destroy();
526
- hls = null;
527
- return;
679
+ teardownPipeline(new Error(error.details));
680
+ return true;
528
681
  }
529
682
  if (error.fatal) {
530
683
  api?.logger.error("Fatal HLS error", { type: error.type, details: error.details });
@@ -534,7 +687,7 @@ function createHLSPlugin(config) {
534
687
  if (networkRetryCount >= maxRetries) {
535
688
  api?.logger.error(`Network error recovery failed after ${networkRetryCount} attempts`);
536
689
  emitFatalError(error, true);
537
- return;
690
+ return true;
538
691
  }
539
692
  networkRetryCount++;
540
693
  const delay = getRetryDelay(networkRetryCount - 1);
@@ -543,8 +696,13 @@ function createHLSPlugin(config) {
543
696
  if (retryTimeout) {
544
697
  clearTimeout(retryTimeout);
545
698
  }
699
+ const isManifestPhase = MANIFEST_PHASE_ERRORS.includes(error.details);
700
+ const retry_session = loadSession;
546
701
  retryTimeout = setTimeout(() => {
547
- if (hls) {
702
+ if (retry_session !== loadSession || !hls) return;
703
+ if (isManifestPhase && currentSrc) {
704
+ hls.loadSource(currentSrc);
705
+ } else {
548
706
  hls.startLoad();
549
707
  }
550
708
  }, delay);
@@ -555,7 +713,7 @@ function createHLSPlugin(config) {
555
713
  if (mediaRetryCount >= maxRetries) {
556
714
  api?.logger.error(`Media error recovery failed after ${mediaRetryCount} attempts`);
557
715
  emitFatalError(error, true);
558
- return;
716
+ return true;
559
717
  }
560
718
  mediaRetryCount++;
561
719
  const delay = getRetryDelay(mediaRetryCount - 1);
@@ -564,39 +722,91 @@ function createHLSPlugin(config) {
564
722
  if (retryTimeout) {
565
723
  clearTimeout(retryTimeout);
566
724
  }
725
+ const retry_session = loadSession;
567
726
  retryTimeout = setTimeout(() => {
568
- if (hls) {
569
- hls.recoverMediaError();
570
- }
727
+ if (retry_session !== loadSession || !hls) return;
728
+ hls.recoverMediaError();
571
729
  }, delay);
572
730
  break;
573
731
  }
574
732
  default:
575
733
  emitFatalError(error, false);
576
- break;
734
+ return true;
577
735
  }
578
736
  }
737
+ return false;
579
738
  };
580
739
  const loadNative = async (src) => {
740
+ const session = loadSession;
581
741
  const videoEl = getOrCreateVideo();
582
742
  isNative = true;
583
743
  if (api) {
584
744
  cleanupVideoEvents = setupVideoEventHandlers(videoEl, api);
585
745
  }
586
746
  return new Promise((resolve, reject) => {
587
- const onLoaded = () => {
747
+ let watchdog = null;
748
+ let settled = false;
749
+ const settle = () => {
750
+ settled = true;
751
+ if (abortPendingLoad === abort) {
752
+ abortPendingLoad = null;
753
+ }
588
754
  videoEl.removeEventListener("loadedmetadata", onLoaded);
589
755
  videoEl.removeEventListener("error", onError);
756
+ if (watchdog !== null) {
757
+ clearTimeout(watchdog);
758
+ watchdog = null;
759
+ }
760
+ };
761
+ const abort = (reason) => {
762
+ if (settled) return;
763
+ settle();
764
+ reject(reason);
765
+ };
766
+ abortPendingLoad = abort;
767
+ const onLoaded = () => {
768
+ if (settled) return;
769
+ if (session !== loadSession) {
770
+ settle();
771
+ reject(new Error("HLS load cancelled"));
772
+ return;
773
+ }
774
+ settle();
775
+ hasPlayedContent = true;
776
+ const onFatalVideoError = () => {
777
+ const media_error = videoEl.error;
778
+ const hls_error = {
779
+ type: media_error?.code === MediaError.MEDIA_ERR_NETWORK ? "network" : "media",
780
+ details: media_error?.message || "Native HLS playback error",
781
+ fatal: true
782
+ };
783
+ emitFatalError(hls_error, false);
784
+ };
785
+ videoEl.addEventListener("error", onFatalVideoError);
786
+ const removeFatalListener = () => videoEl.removeEventListener("error", onFatalVideoError);
787
+ const previous_cleanup = cleanupVideoEvents;
788
+ cleanupVideoEvents = () => {
789
+ removeFatalListener();
790
+ previous_cleanup?.();
791
+ };
590
792
  api?.setState("source", { src, type: "application/x-mpegURL" });
591
793
  api?.emit("media:loaded", { src, type: "application/x-mpegURL" });
592
794
  resolve();
593
795
  };
594
796
  const onError = () => {
595
- videoEl.removeEventListener("loadedmetadata", onLoaded);
596
- videoEl.removeEventListener("error", onError);
797
+ if (settled) return;
798
+ settle();
597
799
  const error = videoEl.error;
598
800
  reject(new Error(error?.message || "Failed to load HLS source"));
599
801
  };
802
+ const timeout_ms = mergedConfig.loadTimeoutMs ?? 3e4;
803
+ if (timeout_ms > 0) {
804
+ watchdog = setTimeout(() => {
805
+ if (settled || session !== loadSession) return;
806
+ settle();
807
+ reject(new Error("Video took too long to load (network timeout)"));
808
+ }, timeout_ms);
809
+ }
600
810
  videoEl.addEventListener("loadedmetadata", onLoaded);
601
811
  videoEl.addEventListener("error", onError);
602
812
  videoEl.src = src;
@@ -604,10 +814,14 @@ function createHLSPlugin(config) {
604
814
  });
605
815
  };
606
816
  const loadWithHlsJs = async (src) => {
607
- await loadHlsJs();
817
+ const session = loadSession;
818
+ await loader.loadHlsJs();
819
+ if (session !== loadSession) {
820
+ throw new Error("HLS load cancelled");
821
+ }
608
822
  const videoEl = getOrCreateVideo();
609
823
  isNative = false;
610
- hls = createHlsInstance(buildHlsConfig());
824
+ hls = loader.createHlsInstance(buildHlsConfig());
611
825
  if (api) {
612
826
  cleanupVideoEvents = setupVideoEventHandlers(videoEl, api);
613
827
  }
@@ -617,10 +831,33 @@ function createHLSPlugin(config) {
617
831
  return;
618
832
  }
619
833
  let resolved = false;
834
+ let watchdog = null;
835
+ const clearWatchdog = () => {
836
+ if (watchdog !== null) {
837
+ clearTimeout(watchdog);
838
+ watchdog = null;
839
+ }
840
+ };
841
+ const abort = (reason) => {
842
+ if (resolved) return;
843
+ resolved = true;
844
+ clearWatchdog();
845
+ reject(reason);
846
+ };
847
+ abortPendingLoad = abort;
848
+ const releaseAbort = () => {
849
+ if (abortPendingLoad === abort) {
850
+ abortPendingLoad = null;
851
+ }
852
+ };
620
853
  cleanupHlsEvents = setupHlsEventHandlers(hls, api, {
621
854
  onManifestParsed: () => {
855
+ if (session !== loadSession) return;
622
856
  if (!resolved) {
623
857
  resolved = true;
858
+ releaseAbort();
859
+ clearWatchdog();
860
+ hasPlayedContent = true;
624
861
  api?.setState("source", { src, type: "application/x-mpegURL" });
625
862
  api?.emit("media:loaded", { src, type: "application/x-mpegURL" });
626
863
  resolve();
@@ -629,26 +866,126 @@ function createHLSPlugin(config) {
629
866
  onLevelSwitched: () => {
630
867
  },
631
868
  onError: (error) => {
632
- handleHlsError(error);
633
- if (error.fatal && !resolved && error.type !== "network" && error.type !== "media") {
869
+ if (session !== loadSession) return;
870
+ const terminal = handleHlsError(error);
871
+ if (terminal && !resolved) {
634
872
  resolved = true;
873
+ releaseAbort();
874
+ clearWatchdog();
635
875
  reject(new Error(error.details));
636
876
  }
637
877
  },
878
+ onFragLoaded: () => {
879
+ if (session !== loadSession) return;
880
+ if (networkRetryCount > 0 || mediaRetryCount > 0) {
881
+ api?.logger.debug("Playback recovered, resetting retry budgets");
882
+ networkRetryCount = 0;
883
+ mediaRetryCount = 0;
884
+ }
885
+ },
638
886
  getIsAutoQuality: () => isAutoQuality
639
887
  });
888
+ const timeout_ms = mergedConfig.loadTimeoutMs ?? 3e4;
889
+ if (timeout_ms > 0) {
890
+ watchdog = setTimeout(() => {
891
+ if (resolved || session !== loadSession) return;
892
+ resolved = true;
893
+ releaseAbort();
894
+ api?.logger.error(`HLS load timed out after ${timeout_ms}ms`, { src });
895
+ teardownPipeline();
896
+ reject(new Error("Video took too long to load (network timeout)"));
897
+ }, timeout_ms);
898
+ }
640
899
  hls.attachMedia(videoEl);
641
900
  hls.loadSource(src);
642
901
  });
643
902
  };
903
+ const cancelReconnect = () => {
904
+ if (reconnectTimer) {
905
+ clearTimeout(reconnectTimer);
906
+ reconnectTimer = null;
907
+ }
908
+ reconnectAttempts = 0;
909
+ reconnectWindowStart = 0;
910
+ reconnectResumePosition = 0;
911
+ };
912
+ const scheduleReconnectAttempt = () => {
913
+ if (reconnectTimer) return;
914
+ const window_ms = mergedConfig.reconnectWindowMs ?? 3e5;
915
+ if (Date.now() - reconnectWindowStart > window_ms) {
916
+ api?.logger.warn(`Auto-reconnect window exhausted after ${reconnectAttempts} attempts`);
917
+ return;
918
+ }
919
+ const base_delay = mergedConfig.reconnectBaseDelayMs ?? 2e3;
920
+ const max_delay = mergedConfig.reconnectMaxDelayMs ?? 3e4;
921
+ const backoff = Math.min(base_delay * Math.pow(2, reconnectAttempts), max_delay);
922
+ const delay = Math.round(backoff * (0.7 + Math.random() * 0.3));
923
+ api?.logger.info(`Scheduling auto-reconnect attempt ${reconnectAttempts + 1} in ${delay}ms`);
924
+ api?.emit("error:reconnecting", { attempt: reconnectAttempts + 1, delayMs: delay });
925
+ reconnectTimer = setTimeout(() => {
926
+ reconnectTimer = null;
927
+ void attemptReconnect();
928
+ }, delay);
929
+ };
930
+ const maybeScheduleReconnect = (error) => {
931
+ if (mergedConfig.autoReconnect === false) return;
932
+ if (!hasPlayedContent || !currentSrc) return;
933
+ if (error.type !== "network" && error.type !== "media") return;
934
+ if (reconnectWindowStart === 0) {
935
+ reconnectWindowStart = Date.now();
936
+ reconnectResumePosition = video?.currentTime ?? 0;
937
+ }
938
+ scheduleReconnectAttempt();
939
+ };
940
+ const attemptReconnect = async () => {
941
+ if (!api || !currentSrc) return;
942
+ const session = ++loadSession;
943
+ reconnectAttempts++;
944
+ const saved_src = currentSrc;
945
+ const was_live = api.getState("live");
946
+ const was_native = isNative;
947
+ const resume_position = reconnectResumePosition;
948
+ api.logger.info(`Auto-reconnect attempt ${reconnectAttempts}`, { src: saved_src });
949
+ try {
950
+ teardownPipeline(new Error("HLS load cancelled: reconnecting"));
951
+ networkRetryCount = 0;
952
+ mediaRetryCount = 0;
953
+ errorCount = 0;
954
+ errorWindowStart = 0;
955
+ currentSrc = saved_src;
956
+ api.setState("playbackState", "loading");
957
+ if (was_native && loader.supportsNativeHLS()) {
958
+ await loadNative(saved_src);
959
+ } else {
960
+ await loadWithHlsJs(saved_src);
961
+ }
962
+ if (session !== loadSession) return;
963
+ if (!was_live && video && resume_position > 0) {
964
+ video.currentTime = resume_position;
965
+ }
966
+ api.setState("playbackState", "ready");
967
+ api.setState("buffering", false);
968
+ api.emit("error:recovered", void 0);
969
+ api.logger.info("Auto-reconnect succeeded");
970
+ cancelReconnect();
971
+ try {
972
+ await video?.play();
973
+ } catch {
974
+ }
975
+ } catch {
976
+ if (session !== loadSession) return;
977
+ api?.logger.warn(`Auto-reconnect attempt ${reconnectAttempts} failed`);
978
+ scheduleReconnectAttempt();
979
+ }
980
+ };
644
981
  const plugin = {
645
982
  id: "hls-provider",
646
- name: "HLS Provider (Light)",
983
+ name: variant.name,
647
984
  version: "1.0.0",
648
985
  type: "provider",
649
- description: "HLS playback provider using hls.js/light (smaller bundle)",
986
+ description: variant.description,
650
987
  canPlay(src) {
651
- if (!isHLSSupported()) return false;
988
+ if (!loader.isHLSSupported()) return false;
652
989
  const url = src.toLowerCase();
653
990
  const urlWithoutQuery = url.split("?")[0].split("#")[0];
654
991
  if (urlWithoutQuery.endsWith(".m3u8")) return true;
@@ -658,7 +995,7 @@ function createHLSPlugin(config) {
658
995
  },
659
996
  async init(pluginApi) {
660
997
  api = pluginApi;
661
- api.logger.info("HLS plugin (light) initialized");
998
+ api.logger.info(`HLS plugin${variant.logSuffix} initialized`);
662
999
  const unsubPlay = api.on("playback:play", async () => {
663
1000
  if (!video) return;
664
1001
  try {
@@ -724,6 +1061,17 @@ function createHLSPlugin(config) {
724
1061
  }
725
1062
  }
726
1063
  });
1064
+ if (typeof window !== "undefined") {
1065
+ onlineListener = () => {
1066
+ if (reconnectTimer) {
1067
+ api?.logger.info("Browser back online, reconnecting immediately");
1068
+ clearTimeout(reconnectTimer);
1069
+ reconnectTimer = null;
1070
+ void attemptReconnect();
1071
+ }
1072
+ };
1073
+ window.addEventListener("online", onlineListener);
1074
+ }
727
1075
  api.onDestroy(() => {
728
1076
  unsubPlay();
729
1077
  unsubPause();
@@ -735,8 +1083,14 @@ function createHLSPlugin(config) {
735
1083
  });
736
1084
  },
737
1085
  async destroy() {
738
- api?.logger.info("HLS plugin (light) destroying");
739
- cleanup();
1086
+ api?.logger.info(`HLS plugin${variant.logSuffix} destroying`);
1087
+ loadSession++;
1088
+ cancelReconnect();
1089
+ if (onlineListener && typeof window !== "undefined") {
1090
+ window.removeEventListener("online", onlineListener);
1091
+ onlineListener = null;
1092
+ }
1093
+ cleanup(new Error("HLS load cancelled: player destroyed"));
740
1094
  if (video?.parentNode) {
741
1095
  video.parentNode.removeChild(video);
742
1096
  }
@@ -745,20 +1099,27 @@ function createHLSPlugin(config) {
745
1099
  },
746
1100
  async loadSource(src) {
747
1101
  if (!api) throw new Error("Plugin not initialized");
748
- api.logger.info("Loading HLS source (light)", { src });
749
- cleanup();
1102
+ api.logger.info(`Loading HLS source${variant.logSuffix}`, { src });
1103
+ const session = ++loadSession;
1104
+ cancelReconnect();
1105
+ hasPlayedContent = false;
1106
+ cleanup(new Error("HLS load cancelled: superseded by a new load"));
750
1107
  currentSrc = src;
751
1108
  api.setState("playbackState", "loading");
752
1109
  api.setState("buffering", true);
753
- if (isHlsJsSupported()) {
754
- api.logger.info("Using hls.js/light for HLS playback");
1110
+ if (api.getState("airplayActive") && loader.supportsNativeHLS()) {
1111
+ api.logger.info("Using native HLS (AirPlay active)");
1112
+ await loadNative(src);
1113
+ } else if (loader.isHlsJsSupported()) {
1114
+ api.logger.info(`Using ${variant.engineLabel} for HLS playback`);
755
1115
  await loadWithHlsJs(src);
756
- } else if (supportsNativeHLS()) {
1116
+ } else if (loader.supportsNativeHLS()) {
757
1117
  api.logger.info("Using native HLS playback (hls.js not supported)");
758
1118
  await loadNative(src);
759
1119
  } else {
760
1120
  throw new Error("HLS playback not supported in this browser");
761
1121
  }
1122
+ if (session !== loadSession) return;
762
1123
  if (video) {
763
1124
  const muted = api.getState("muted");
764
1125
  const volume = api.getState("volume");
@@ -800,12 +1161,17 @@ function createHLSPlugin(config) {
800
1161
  drift: hls.drift || 0
801
1162
  };
802
1163
  },
1164
+ /**
1165
+ * Switch from hls.js to native HLS playback.
1166
+ * Used for AirPlay compatibility in Safari.
1167
+ * Preserves current playback position.
1168
+ */
803
1169
  async switchToNative() {
804
1170
  if (isNative) {
805
1171
  api?.logger.debug("Already using native HLS");
806
1172
  return;
807
1173
  }
808
- if (!supportsNativeHLS()) {
1174
+ if (!loader.supportsNativeHLS()) {
809
1175
  api?.logger.warn("Native HLS not supported in this browser");
810
1176
  return;
811
1177
  }
@@ -817,8 +1183,10 @@ function createHLSPlugin(config) {
817
1183
  const wasPlaying = api?.getState("playing") || false;
818
1184
  const currentTime = video?.currentTime || 0;
819
1185
  const savedSrc = currentSrc;
820
- cleanup();
1186
+ const session = ++loadSession;
1187
+ cleanup(new Error("HLS load cancelled: switching to native HLS"));
821
1188
  await loadNative(savedSrc);
1189
+ if (session !== loadSession) return;
822
1190
  if (video && currentTime > 0) {
823
1191
  video.currentTime = currentTime;
824
1192
  }
@@ -831,12 +1199,16 @@ function createHLSPlugin(config) {
831
1199
  }
832
1200
  api?.logger.info("Switched to native HLS");
833
1201
  },
1202
+ /**
1203
+ * Switch from native HLS back to hls.js.
1204
+ * Restores quality control after AirPlay session ends.
1205
+ */
834
1206
  async switchToHlsJs() {
835
1207
  if (!isNative) {
836
1208
  api?.logger.debug("Already using hls.js");
837
1209
  return;
838
1210
  }
839
- if (!isHlsJsSupported()) {
1211
+ if (!loader.isHlsJsSupported()) {
840
1212
  api?.logger.warn("hls.js not supported in this browser");
841
1213
  return;
842
1214
  }
@@ -848,8 +1220,10 @@ function createHLSPlugin(config) {
848
1220
  const wasPlaying = api?.getState("playing") || false;
849
1221
  const currentTime = video?.currentTime || 0;
850
1222
  const savedSrc = currentSrc;
851
- cleanup();
1223
+ const session = ++loadSession;
1224
+ cleanup(new Error("HLS load cancelled: switching to hls.js"));
852
1225
  await loadWithHlsJs(savedSrc);
1226
+ if (session !== loadSession) return;
853
1227
  if (video && currentTime > 0) {
854
1228
  video.currentTime = currentTime;
855
1229
  }
@@ -865,6 +1239,20 @@ function createHLSPlugin(config) {
865
1239
  };
866
1240
  return plugin;
867
1241
  }
1242
+
1243
+ // src/light.ts
1244
+ function createHLSPlugin(config) {
1245
+ return createHLSPluginWith(
1246
+ hls_loader_light_exports,
1247
+ {
1248
+ name: "HLS Provider (Light)",
1249
+ description: "HLS playback provider using hls.js/light (smaller bundle)",
1250
+ logSuffix: " (light)",
1251
+ engineLabel: "hls.js/light"
1252
+ },
1253
+ config
1254
+ );
1255
+ }
868
1256
  var light_default = createHLSPlugin;
869
1257
  // Annotate the CommonJS export names for ESM import in node:
870
1258
  0 && (module.exports = {