@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/README.md +59 -11
- package/dist/chunk-IFUZZQQE.js +1141 -0
- package/dist/index.cjs +202 -53
- package/dist/index.d.cts +6 -8
- package/dist/index.d.ts +6 -8
- package/dist/index.js +34 -691
- package/dist/light.cjs +431 -43
- package/dist/light.d.cts +11 -4
- package/dist/light.d.ts +11 -4
- package/dist/light.js +34 -461
- package/dist/{types-DOzoPHQe.d.cts → types-D-HW4lmM.d.cts} +8 -0
- package/dist/{types-DOzoPHQe.d.ts → types-D-HW4lmM.d.ts} +8 -0
- package/package.json +2 -2
- package/dist/chunk-L73PRXS4.js +0 -337
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/
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
524
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
596
|
-
|
|
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
|
-
|
|
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
|
-
|
|
633
|
-
|
|
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:
|
|
983
|
+
name: variant.name,
|
|
647
984
|
version: "1.0.0",
|
|
648
985
|
type: "provider",
|
|
649
|
-
description:
|
|
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(
|
|
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(
|
|
739
|
-
|
|
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(
|
|
749
|
-
|
|
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 (
|
|
754
|
-
api.logger.info("Using
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|