@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/README.md +59 -11
- package/dist/chunk-IFUZZQQE.js +1141 -0
- package/dist/index.cjs +396 -43
- package/dist/index.d.cts +6 -8
- package/dist/index.d.ts +6 -8
- package/dist/index.js +34 -486
- package/dist/light.cjs +432 -43
- package/dist/light.d.cts +11 -4
- package/dist/light.d.ts +11 -4
- package/dist/light.js +34 -461
- package/dist/{types-CRKmNxEW.d.cts → types-D-HW4lmM.d.cts} +27 -1
- package/dist/{types-CRKmNxEW.d.ts → types-D-HW4lmM.d.ts} +27 -1
- package/package.json +3 -3
- package/dist/chunk-MLOYPPFI.js +0 -336
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/
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
523
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
595
|
-
|
|
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
|
-
|
|
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
|
-
|
|
632
|
-
|
|
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:
|
|
983
|
+
name: variant.name,
|
|
646
984
|
version: "1.0.0",
|
|
647
985
|
type: "provider",
|
|
648
|
-
description:
|
|
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(
|
|
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(
|
|
738
|
-
|
|
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(
|
|
748
|
-
|
|
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 (
|
|
753
|
-
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`);
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|