@scarlett-player/hls 1.0.2 → 1.2.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 = {
@@ -214,6 +251,7 @@ function setupHlsEventHandlers(hls, api, callbacks) {
214
251
  lastBandwidthUpdate = now;
215
252
  api.setState("bandwidth", Math.round(hls.bandwidthEstimate));
216
253
  }
254
+ callbacks.onFragLoaded?.();
217
255
  });
218
256
  addHandler("hlsFragBuffered", () => {
219
257
  api.setState("buffering", false);
@@ -404,7 +442,51 @@ function setupVideoEventHandlers(video, api) {
404
442
  };
405
443
  }
406
444
 
407
- // 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
408
490
  var DEFAULT_CONFIG = {
409
491
  debug: false,
410
492
  autoStartLoad: true,
@@ -414,12 +496,28 @@ var DEFAULT_CONFIG = {
414
496
  maxMaxBufferLength: 600,
415
497
  backBufferLength: 30,
416
498
  enableWorker: true,
499
+ capLevelToPlayerSize: true,
500
+ // Error recovery settings
417
501
  maxNetworkRetries: 3,
418
502
  maxMediaRetries: 2,
419
503
  retryDelayMs: 1e3,
420
- 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
421
514
  };
422
- function createHLSPlugin(config) {
515
+ var MANIFEST_PHASE_ERRORS = [
516
+ "manifestLoadError",
517
+ "manifestLoadTimeOut",
518
+ "manifestParsingError"
519
+ ];
520
+ function createHLSPluginWith(loader, variant, config) {
423
521
  const mergedConfig = { ...DEFAULT_CONFIG, ...config };
424
522
  let api = null;
425
523
  let hls = null;
@@ -429,6 +527,8 @@ function createHLSPlugin(config) {
429
527
  let cleanupHlsEvents = null;
430
528
  let cleanupVideoEvents = null;
431
529
  let isAutoQuality = true;
530
+ let loadSession = 0;
531
+ let abortPendingLoad = null;
432
532
  let networkRetryCount = 0;
433
533
  let mediaRetryCount = 0;
434
534
  let retryTimeout = null;
@@ -436,6 +536,12 @@ function createHLSPlugin(config) {
436
536
  let errorWindowStart = 0;
437
537
  const MAX_ERRORS_IN_WINDOW = 10;
438
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;
439
545
  const getOrCreateVideo = () => {
440
546
  if (video) return video;
441
547
  const existing = api?.container.querySelector("video");
@@ -448,10 +554,16 @@ function createHLSPlugin(config) {
448
554
  video.preload = "metadata";
449
555
  video.controls = false;
450
556
  video.playsInline = true;
557
+ const poster = api?.getState("poster");
558
+ if (poster) {
559
+ video.poster = poster;
560
+ }
451
561
  api?.container.appendChild(video);
452
562
  return video;
453
563
  };
454
- const cleanup = () => {
564
+ const teardownPipeline = (reason) => {
565
+ abortPendingLoad?.(reason ?? new Error("HLS load cancelled"));
566
+ abortPendingLoad = null;
455
567
  cleanupHlsEvents?.();
456
568
  cleanupHlsEvents = null;
457
569
  cleanupVideoEvents?.();
@@ -464,6 +576,9 @@ function createHLSPlugin(config) {
464
576
  hls.destroy();
465
577
  hls = null;
466
578
  }
579
+ };
580
+ const cleanup = (reason) => {
581
+ teardownPipeline(reason);
467
582
  currentSrc = null;
468
583
  isNative = false;
469
584
  isAutoQuality = true;
@@ -472,16 +587,30 @@ function createHLSPlugin(config) {
472
587
  errorCount = 0;
473
588
  errorWindowStart = 0;
474
589
  };
475
- 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 = () => ({
476
601
  debug: mergedConfig.debug,
477
602
  autoStartLoad: mergedConfig.autoStartLoad,
478
603
  startPosition: mergedConfig.startPosition,
479
604
  startLevel: -1,
605
+ // Auto quality selection (ABR)
606
+ abrEwmaDefaultEstimate: getInitialBandwidthEstimate(mergedConfig.initialBandwidthEstimate),
480
607
  lowLatencyMode: mergedConfig.lowLatencyMode,
481
608
  maxBufferLength: mergedConfig.maxBufferLength,
482
609
  maxMaxBufferLength: mergedConfig.maxMaxBufferLength,
483
610
  backBufferLength: mergedConfig.backBufferLength,
484
611
  enableWorker: mergedConfig.enableWorker,
612
+ capLevelToPlayerSize: mergedConfig.capLevelToPlayerSize,
613
+ // Minimize hls.js internal retries - we handle retries ourselves
485
614
  fragLoadingMaxRetry: 1,
486
615
  manifestLoadingMaxRetry: 1,
487
616
  levelLoadingMaxRetry: 1,
@@ -492,7 +621,34 @@ function createHLSPlugin(config) {
492
621
  const getRetryDelay = (retryCount) => {
493
622
  const baseDelay = mergedConfig.retryDelayMs ?? 1e3;
494
623
  const backoffFactor = mergedConfig.retryBackoffFactor ?? 2;
495
- 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
+ }
496
652
  };
497
653
  const emitFatalError = (error, retriesExhausted) => {
498
654
  const message = retriesExhausted ? `HLS error: ${error.details} (max retries exceeded)` : `HLS error: ${error.details}`;
@@ -500,15 +656,16 @@ function createHLSPlugin(config) {
500
656
  api?.setState("playbackState", "error");
501
657
  api?.setState("buffering", false);
502
658
  api?.emit("error", {
503
- code: "MEDIA_ERROR",
659
+ code: mapFatalErrorCode(error),
504
660
  message,
505
661
  fatal: true,
506
662
  timestamp: Date.now()
507
663
  });
664
+ maybeScheduleReconnect(error);
508
665
  };
509
666
  const handleHlsError = (error) => {
510
- const Hls = getHlsConstructor();
511
- if (!Hls || !hls) return;
667
+ const Hls = loader.getHlsConstructor();
668
+ if (!Hls || !hls) return false;
512
669
  const now = Date.now();
513
670
  if (now - errorWindowStart > ERROR_WINDOW_MS) {
514
671
  errorCount = 1;
@@ -519,11 +676,8 @@ function createHLSPlugin(config) {
519
676
  if (errorCount >= MAX_ERRORS_IN_WINDOW) {
520
677
  api?.logger.error(`Too many errors (${errorCount} in ${ERROR_WINDOW_MS}ms), giving up`);
521
678
  emitFatalError(error, true);
522
- cleanupHlsEvents?.();
523
- cleanupHlsEvents = null;
524
- hls.destroy();
525
- hls = null;
526
- return;
679
+ teardownPipeline(new Error(error.details));
680
+ return true;
527
681
  }
528
682
  if (error.fatal) {
529
683
  api?.logger.error("Fatal HLS error", { type: error.type, details: error.details });
@@ -533,7 +687,7 @@ function createHLSPlugin(config) {
533
687
  if (networkRetryCount >= maxRetries) {
534
688
  api?.logger.error(`Network error recovery failed after ${networkRetryCount} attempts`);
535
689
  emitFatalError(error, true);
536
- return;
690
+ return true;
537
691
  }
538
692
  networkRetryCount++;
539
693
  const delay = getRetryDelay(networkRetryCount - 1);
@@ -542,8 +696,13 @@ function createHLSPlugin(config) {
542
696
  if (retryTimeout) {
543
697
  clearTimeout(retryTimeout);
544
698
  }
699
+ const isManifestPhase = MANIFEST_PHASE_ERRORS.includes(error.details);
700
+ const retry_session = loadSession;
545
701
  retryTimeout = setTimeout(() => {
546
- if (hls) {
702
+ if (retry_session !== loadSession || !hls) return;
703
+ if (isManifestPhase && currentSrc) {
704
+ hls.loadSource(currentSrc);
705
+ } else {
547
706
  hls.startLoad();
548
707
  }
549
708
  }, delay);
@@ -554,7 +713,7 @@ function createHLSPlugin(config) {
554
713
  if (mediaRetryCount >= maxRetries) {
555
714
  api?.logger.error(`Media error recovery failed after ${mediaRetryCount} attempts`);
556
715
  emitFatalError(error, true);
557
- return;
716
+ return true;
558
717
  }
559
718
  mediaRetryCount++;
560
719
  const delay = getRetryDelay(mediaRetryCount - 1);
@@ -563,39 +722,91 @@ function createHLSPlugin(config) {
563
722
  if (retryTimeout) {
564
723
  clearTimeout(retryTimeout);
565
724
  }
725
+ const retry_session = loadSession;
566
726
  retryTimeout = setTimeout(() => {
567
- if (hls) {
568
- hls.recoverMediaError();
569
- }
727
+ if (retry_session !== loadSession || !hls) return;
728
+ hls.recoverMediaError();
570
729
  }, delay);
571
730
  break;
572
731
  }
573
732
  default:
574
733
  emitFatalError(error, false);
575
- break;
734
+ return true;
576
735
  }
577
736
  }
737
+ return false;
578
738
  };
579
739
  const loadNative = async (src) => {
740
+ const session = loadSession;
580
741
  const videoEl = getOrCreateVideo();
581
742
  isNative = true;
582
743
  if (api) {
583
744
  cleanupVideoEvents = setupVideoEventHandlers(videoEl, api);
584
745
  }
585
746
  return new Promise((resolve, reject) => {
586
- const onLoaded = () => {
747
+ let watchdog = null;
748
+ let settled = false;
749
+ const settle = () => {
750
+ settled = true;
751
+ if (abortPendingLoad === abort) {
752
+ abortPendingLoad = null;
753
+ }
587
754
  videoEl.removeEventListener("loadedmetadata", onLoaded);
588
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
+ };
589
792
  api?.setState("source", { src, type: "application/x-mpegURL" });
590
793
  api?.emit("media:loaded", { src, type: "application/x-mpegURL" });
591
794
  resolve();
592
795
  };
593
796
  const onError = () => {
594
- videoEl.removeEventListener("loadedmetadata", onLoaded);
595
- videoEl.removeEventListener("error", onError);
797
+ if (settled) return;
798
+ settle();
596
799
  const error = videoEl.error;
597
800
  reject(new Error(error?.message || "Failed to load HLS source"));
598
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
+ }
599
810
  videoEl.addEventListener("loadedmetadata", onLoaded);
600
811
  videoEl.addEventListener("error", onError);
601
812
  videoEl.src = src;
@@ -603,10 +814,14 @@ function createHLSPlugin(config) {
603
814
  });
604
815
  };
605
816
  const loadWithHlsJs = async (src) => {
606
- await loadHlsJs();
817
+ const session = loadSession;
818
+ await loader.loadHlsJs();
819
+ if (session !== loadSession) {
820
+ throw new Error("HLS load cancelled");
821
+ }
607
822
  const videoEl = getOrCreateVideo();
608
823
  isNative = false;
609
- hls = createHlsInstance(buildHlsConfig());
824
+ hls = loader.createHlsInstance(buildHlsConfig());
610
825
  if (api) {
611
826
  cleanupVideoEvents = setupVideoEventHandlers(videoEl, api);
612
827
  }
@@ -616,10 +831,33 @@ function createHLSPlugin(config) {
616
831
  return;
617
832
  }
618
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
+ };
619
853
  cleanupHlsEvents = setupHlsEventHandlers(hls, api, {
620
854
  onManifestParsed: () => {
855
+ if (session !== loadSession) return;
621
856
  if (!resolved) {
622
857
  resolved = true;
858
+ releaseAbort();
859
+ clearWatchdog();
860
+ hasPlayedContent = true;
623
861
  api?.setState("source", { src, type: "application/x-mpegURL" });
624
862
  api?.emit("media:loaded", { src, type: "application/x-mpegURL" });
625
863
  resolve();
@@ -628,26 +866,126 @@ function createHLSPlugin(config) {
628
866
  onLevelSwitched: () => {
629
867
  },
630
868
  onError: (error) => {
631
- handleHlsError(error);
632
- if (error.fatal && !resolved && error.type !== "network" && error.type !== "media") {
869
+ if (session !== loadSession) return;
870
+ const terminal = handleHlsError(error);
871
+ if (terminal && !resolved) {
633
872
  resolved = true;
873
+ releaseAbort();
874
+ clearWatchdog();
634
875
  reject(new Error(error.details));
635
876
  }
636
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
+ },
637
886
  getIsAutoQuality: () => isAutoQuality
638
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
+ }
639
899
  hls.attachMedia(videoEl);
640
900
  hls.loadSource(src);
641
901
  });
642
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
+ };
643
981
  const plugin = {
644
982
  id: "hls-provider",
645
- name: "HLS Provider (Light)",
983
+ name: variant.name,
646
984
  version: "1.0.0",
647
985
  type: "provider",
648
- description: "HLS playback provider using hls.js/light (smaller bundle)",
986
+ description: variant.description,
649
987
  canPlay(src) {
650
- if (!isHLSSupported()) return false;
988
+ if (!loader.isHLSSupported()) return false;
651
989
  const url = src.toLowerCase();
652
990
  const urlWithoutQuery = url.split("?")[0].split("#")[0];
653
991
  if (urlWithoutQuery.endsWith(".m3u8")) return true;
@@ -657,7 +995,7 @@ function createHLSPlugin(config) {
657
995
  },
658
996
  async init(pluginApi) {
659
997
  api = pluginApi;
660
- api.logger.info("HLS plugin (light) initialized");
998
+ api.logger.info(`HLS plugin${variant.logSuffix} initialized`);
661
999
  const unsubPlay = api.on("playback:play", async () => {
662
1000
  if (!video) return;
663
1001
  try {
@@ -723,6 +1061,17 @@ function createHLSPlugin(config) {
723
1061
  }
724
1062
  }
725
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
+ }
726
1075
  api.onDestroy(() => {
727
1076
  unsubPlay();
728
1077
  unsubPause();
@@ -734,8 +1083,14 @@ function createHLSPlugin(config) {
734
1083
  });
735
1084
  },
736
1085
  async destroy() {
737
- api?.logger.info("HLS plugin (light) destroying");
738
- 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"));
739
1094
  if (video?.parentNode) {
740
1095
  video.parentNode.removeChild(video);
741
1096
  }
@@ -744,20 +1099,27 @@ function createHLSPlugin(config) {
744
1099
  },
745
1100
  async loadSource(src) {
746
1101
  if (!api) throw new Error("Plugin not initialized");
747
- api.logger.info("Loading HLS source (light)", { src });
748
- 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"));
749
1107
  currentSrc = src;
750
1108
  api.setState("playbackState", "loading");
751
1109
  api.setState("buffering", true);
752
- if (isHlsJsSupported()) {
753
- 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`);
754
1115
  await loadWithHlsJs(src);
755
- } else if (supportsNativeHLS()) {
1116
+ } else if (loader.supportsNativeHLS()) {
756
1117
  api.logger.info("Using native HLS playback (hls.js not supported)");
757
1118
  await loadNative(src);
758
1119
  } else {
759
1120
  throw new Error("HLS playback not supported in this browser");
760
1121
  }
1122
+ if (session !== loadSession) return;
761
1123
  if (video) {
762
1124
  const muted = api.getState("muted");
763
1125
  const volume = api.getState("volume");
@@ -799,12 +1161,17 @@ function createHLSPlugin(config) {
799
1161
  drift: hls.drift || 0
800
1162
  };
801
1163
  },
1164
+ /**
1165
+ * Switch from hls.js to native HLS playback.
1166
+ * Used for AirPlay compatibility in Safari.
1167
+ * Preserves current playback position.
1168
+ */
802
1169
  async switchToNative() {
803
1170
  if (isNative) {
804
1171
  api?.logger.debug("Already using native HLS");
805
1172
  return;
806
1173
  }
807
- if (!supportsNativeHLS()) {
1174
+ if (!loader.supportsNativeHLS()) {
808
1175
  api?.logger.warn("Native HLS not supported in this browser");
809
1176
  return;
810
1177
  }
@@ -816,8 +1183,10 @@ function createHLSPlugin(config) {
816
1183
  const wasPlaying = api?.getState("playing") || false;
817
1184
  const currentTime = video?.currentTime || 0;
818
1185
  const savedSrc = currentSrc;
819
- cleanup();
1186
+ const session = ++loadSession;
1187
+ cleanup(new Error("HLS load cancelled: switching to native HLS"));
820
1188
  await loadNative(savedSrc);
1189
+ if (session !== loadSession) return;
821
1190
  if (video && currentTime > 0) {
822
1191
  video.currentTime = currentTime;
823
1192
  }
@@ -830,12 +1199,16 @@ function createHLSPlugin(config) {
830
1199
  }
831
1200
  api?.logger.info("Switched to native HLS");
832
1201
  },
1202
+ /**
1203
+ * Switch from native HLS back to hls.js.
1204
+ * Restores quality control after AirPlay session ends.
1205
+ */
833
1206
  async switchToHlsJs() {
834
1207
  if (!isNative) {
835
1208
  api?.logger.debug("Already using hls.js");
836
1209
  return;
837
1210
  }
838
- if (!isHlsJsSupported()) {
1211
+ if (!loader.isHlsJsSupported()) {
839
1212
  api?.logger.warn("hls.js not supported in this browser");
840
1213
  return;
841
1214
  }
@@ -847,8 +1220,10 @@ function createHLSPlugin(config) {
847
1220
  const wasPlaying = api?.getState("playing") || false;
848
1221
  const currentTime = video?.currentTime || 0;
849
1222
  const savedSrc = currentSrc;
850
- cleanup();
1223
+ const session = ++loadSession;
1224
+ cleanup(new Error("HLS load cancelled: switching to hls.js"));
851
1225
  await loadWithHlsJs(savedSrc);
1226
+ if (session !== loadSession) return;
852
1227
  if (video && currentTime > 0) {
853
1228
  video.currentTime = currentTime;
854
1229
  }
@@ -864,6 +1239,20 @@ function createHLSPlugin(config) {
864
1239
  };
865
1240
  return plugin;
866
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
+ }
867
1256
  var light_default = createHLSPlugin;
868
1257
  // Annotate the CommonJS export names for ESM import in node:
869
1258
  0 && (module.exports = {