@scarlett-player/hls 1.6.0 → 1.8.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 +3 -5
- package/dist/{chunk-IFUZZQQE.js → chunk-FN6QUW42.js} +161 -11
- package/dist/index.cjs +164 -13
- package/dist/index.d.cts +4 -3
- package/dist/index.d.ts +4 -3
- package/dist/index.js +5 -3
- package/dist/light.cjs +164 -13
- package/dist/light.d.cts +2 -2
- package/dist/light.d.ts +2 -2
- package/dist/light.js +5 -3
- package/dist/{types-D-HW4lmM.d.cts → sanitize-url-DcZXD_K-.d.cts} +38 -3
- package/dist/{types-D-HW4lmM.d.ts → sanitize-url-DcZXD_K-.d.ts} +38 -3
- package/package.json +8 -7
package/README.md
CHANGED
|
@@ -11,16 +11,14 @@ npm install @scarlett-player/core @scarlett-player/hls
|
|
|
11
11
|
## Usage
|
|
12
12
|
|
|
13
13
|
```typescript
|
|
14
|
-
import {
|
|
14
|
+
import { createPlayer } from '@scarlett-player/core';
|
|
15
15
|
import { createHLSPlugin } from '@scarlett-player/hls';
|
|
16
16
|
|
|
17
|
-
const player =
|
|
17
|
+
const player = await createPlayer({
|
|
18
18
|
container: document.getElementById('player'),
|
|
19
|
+
src: 'https://example.com/video.m3u8',
|
|
19
20
|
plugins: [createHLSPlugin()],
|
|
20
21
|
});
|
|
21
|
-
|
|
22
|
-
await player.init();
|
|
23
|
-
await player.load('https://example.com/video.m3u8');
|
|
24
22
|
```
|
|
25
23
|
|
|
26
24
|
## Features
|
|
@@ -4,6 +4,17 @@ var __export = (target, all) => {
|
|
|
4
4
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
5
5
|
};
|
|
6
6
|
|
|
7
|
+
// src/sanitize-url.ts
|
|
8
|
+
function sanitizeUrl(url) {
|
|
9
|
+
if (!url) return void 0;
|
|
10
|
+
try {
|
|
11
|
+
const parsed = new URL(url);
|
|
12
|
+
return `${parsed.origin}${parsed.pathname}`;
|
|
13
|
+
} catch {
|
|
14
|
+
return void 0;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
7
18
|
// src/create-hls-plugin.ts
|
|
8
19
|
import { ErrorCode } from "@scarlett-player/core";
|
|
9
20
|
|
|
@@ -211,8 +222,14 @@ function setupVideoEventHandlers(video, api) {
|
|
|
211
222
|
video.addEventListener(event, handler);
|
|
212
223
|
handlers.push({ event, handler });
|
|
213
224
|
};
|
|
225
|
+
const syncEndedFromElement = () => {
|
|
226
|
+
if (video.ended || !api.getState("ended")) return;
|
|
227
|
+
api.setState("ended", false);
|
|
228
|
+
api.setState("playbackState", video.paused ? "paused" : "playing");
|
|
229
|
+
};
|
|
214
230
|
addHandler("play", () => {
|
|
215
231
|
api.setState("paused", false);
|
|
232
|
+
syncEndedFromElement();
|
|
216
233
|
});
|
|
217
234
|
addHandler("playing", () => {
|
|
218
235
|
api.setState("playing", true);
|
|
@@ -220,6 +237,7 @@ function setupVideoEventHandlers(video, api) {
|
|
|
220
237
|
api.setState("waiting", false);
|
|
221
238
|
api.setState("buffering", false);
|
|
222
239
|
api.setState("playbackState", "playing");
|
|
240
|
+
syncEndedFromElement();
|
|
223
241
|
});
|
|
224
242
|
addHandler("pause", () => {
|
|
225
243
|
api.setState("playing", false);
|
|
@@ -274,6 +292,7 @@ function setupVideoEventHandlers(video, api) {
|
|
|
274
292
|
});
|
|
275
293
|
addHandler("seeking", () => {
|
|
276
294
|
api.setState("seeking", true);
|
|
295
|
+
syncEndedFromElement();
|
|
277
296
|
});
|
|
278
297
|
addHandler("seeked", () => {
|
|
279
298
|
api.setState("seeking", false);
|
|
@@ -381,6 +400,9 @@ function createValidatingPlaylistLoader(Hls) {
|
|
|
381
400
|
};
|
|
382
401
|
}
|
|
383
402
|
|
|
403
|
+
// src/version.ts
|
|
404
|
+
var PKG_VERSION = true ? "1.7.1" : "0.0.0-dev";
|
|
405
|
+
|
|
384
406
|
// src/create-hls-plugin.ts
|
|
385
407
|
var DEFAULT_CONFIG = {
|
|
386
408
|
debug: false,
|
|
@@ -437,6 +459,12 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
437
459
|
let reconnectWindowStart = 0;
|
|
438
460
|
let reconnectResumePosition = 0;
|
|
439
461
|
let onlineListener = null;
|
|
462
|
+
let reconnectTriggerError = null;
|
|
463
|
+
let reconnectExhausted = false;
|
|
464
|
+
const applyPoster = () => {
|
|
465
|
+
if (!video) return;
|
|
466
|
+
video.poster = api?.getState("poster") || "";
|
|
467
|
+
};
|
|
440
468
|
const getOrCreateVideo = () => {
|
|
441
469
|
if (video) return video;
|
|
442
470
|
const existing = api?.container.querySelector("video");
|
|
@@ -449,10 +477,7 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
449
477
|
video.preload = "metadata";
|
|
450
478
|
video.controls = false;
|
|
451
479
|
video.playsInline = true;
|
|
452
|
-
|
|
453
|
-
if (poster) {
|
|
454
|
-
video.poster = poster;
|
|
455
|
-
}
|
|
480
|
+
applyPoster();
|
|
456
481
|
api?.container.appendChild(video);
|
|
457
482
|
return video;
|
|
458
483
|
};
|
|
@@ -545,6 +570,22 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
545
570
|
return ErrorCode.PLAYBACK_FAILED;
|
|
546
571
|
}
|
|
547
572
|
};
|
|
573
|
+
const buildErrorDetail = (error, retriesExhausted) => {
|
|
574
|
+
const attempts = error.type === "network" ? networkRetryCount : error.type === "media" ? mediaRetryCount : 0;
|
|
575
|
+
const detail = {
|
|
576
|
+
type: error.type,
|
|
577
|
+
retriesExhausted,
|
|
578
|
+
attempts
|
|
579
|
+
};
|
|
580
|
+
if (typeof error.response?.code === "number" && error.response.code > 0) {
|
|
581
|
+
detail.httpStatus = error.response.code;
|
|
582
|
+
}
|
|
583
|
+
const url = sanitizeUrl(error.url);
|
|
584
|
+
if (url) {
|
|
585
|
+
detail.url = url;
|
|
586
|
+
}
|
|
587
|
+
return detail;
|
|
588
|
+
};
|
|
548
589
|
const emitFatalError = (error, retriesExhausted) => {
|
|
549
590
|
const message = retriesExhausted ? `HLS error: ${error.details} (max retries exceeded)` : `HLS error: ${error.details}`;
|
|
550
591
|
api?.logger.error(message, { type: error.type, details: error.details });
|
|
@@ -554,7 +595,8 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
554
595
|
code: mapFatalErrorCode(error),
|
|
555
596
|
message,
|
|
556
597
|
fatal: true,
|
|
557
|
-
timestamp: Date.now()
|
|
598
|
+
timestamp: Date.now(),
|
|
599
|
+
detail: buildErrorDetail(error, retriesExhausted)
|
|
558
600
|
});
|
|
559
601
|
maybeScheduleReconnect(error);
|
|
560
602
|
};
|
|
@@ -631,6 +673,62 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
631
673
|
}
|
|
632
674
|
return false;
|
|
633
675
|
};
|
|
676
|
+
const handleNativeFatalError = (error, resumePosition) => {
|
|
677
|
+
const is_network = error.type === "network";
|
|
678
|
+
const max_retries = is_network ? mergedConfig.maxNetworkRetries ?? 3 : mergedConfig.maxMediaRetries ?? 2;
|
|
679
|
+
const used = is_network ? networkRetryCount : mediaRetryCount;
|
|
680
|
+
if (!currentSrc || used >= max_retries) {
|
|
681
|
+
emitFatalError(error, used >= max_retries);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const resume_position = resumePosition ?? video?.currentTime ?? 0;
|
|
685
|
+
if (is_network) {
|
|
686
|
+
networkRetryCount++;
|
|
687
|
+
} else {
|
|
688
|
+
mediaRetryCount++;
|
|
689
|
+
}
|
|
690
|
+
const attempt = used + 1;
|
|
691
|
+
const delay = getRetryDelay(attempt - 1);
|
|
692
|
+
api?.logger.info(
|
|
693
|
+
`Attempting native ${error.type} error recovery (attempt ${attempt}/${max_retries}) in ${delay}ms`
|
|
694
|
+
);
|
|
695
|
+
api?.emit(is_network ? "error:network" : "error:media", {
|
|
696
|
+
error: new Error(error.details)
|
|
697
|
+
});
|
|
698
|
+
if (retryTimeout) {
|
|
699
|
+
clearTimeout(retryTimeout);
|
|
700
|
+
}
|
|
701
|
+
const retry_session = loadSession;
|
|
702
|
+
retryTimeout = setTimeout(() => {
|
|
703
|
+
if (retry_session !== loadSession) return;
|
|
704
|
+
void recoverNative(error, resume_position);
|
|
705
|
+
}, delay);
|
|
706
|
+
};
|
|
707
|
+
const recoverNative = async (error, resumePosition) => {
|
|
708
|
+
if (!currentSrc) return;
|
|
709
|
+
const session = ++loadSession;
|
|
710
|
+
const saved_src = currentSrc;
|
|
711
|
+
const was_live = api?.getState("live") ?? false;
|
|
712
|
+
try {
|
|
713
|
+
teardownPipeline(new Error("HLS load cancelled: native error recovery"));
|
|
714
|
+
api?.setState("playbackState", "loading");
|
|
715
|
+
await loadNative(saved_src);
|
|
716
|
+
if (session !== loadSession) return;
|
|
717
|
+
if (!was_live && video && resumePosition > 0) {
|
|
718
|
+
video.currentTime = resumePosition;
|
|
719
|
+
}
|
|
720
|
+
api?.setState("playbackState", "ready");
|
|
721
|
+
api?.setState("buffering", false);
|
|
722
|
+
try {
|
|
723
|
+
await video?.play();
|
|
724
|
+
} catch {
|
|
725
|
+
}
|
|
726
|
+
} catch {
|
|
727
|
+
if (session !== loadSession) return;
|
|
728
|
+
api?.logger.warn("Native error recovery attempt failed");
|
|
729
|
+
handleNativeFatalError(error, resumePosition);
|
|
730
|
+
}
|
|
731
|
+
};
|
|
634
732
|
const loadNative = async (src) => {
|
|
635
733
|
const session = loadSession;
|
|
636
734
|
const videoEl = getOrCreateVideo();
|
|
@@ -675,10 +773,21 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
675
773
|
details: media_error?.message || "Native HLS playback error",
|
|
676
774
|
fatal: true
|
|
677
775
|
};
|
|
678
|
-
|
|
776
|
+
handleNativeFatalError(hls_error);
|
|
679
777
|
};
|
|
680
778
|
videoEl.addEventListener("error", onFatalVideoError);
|
|
681
|
-
const
|
|
779
|
+
const onPlayingResetBudget = () => {
|
|
780
|
+
if (networkRetryCount > 0 || mediaRetryCount > 0) {
|
|
781
|
+
api?.logger.debug("Native playback recovered, resetting retry budgets");
|
|
782
|
+
networkRetryCount = 0;
|
|
783
|
+
mediaRetryCount = 0;
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
videoEl.addEventListener("playing", onPlayingResetBudget);
|
|
787
|
+
const removeFatalListener = () => {
|
|
788
|
+
videoEl.removeEventListener("error", onFatalVideoError);
|
|
789
|
+
videoEl.removeEventListener("playing", onPlayingResetBudget);
|
|
790
|
+
};
|
|
682
791
|
const previous_cleanup = cleanupVideoEvents;
|
|
683
792
|
cleanupVideoEvents = () => {
|
|
684
793
|
removeFatalListener();
|
|
@@ -803,12 +912,38 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
803
912
|
reconnectAttempts = 0;
|
|
804
913
|
reconnectWindowStart = 0;
|
|
805
914
|
reconnectResumePosition = 0;
|
|
915
|
+
reconnectTriggerError = null;
|
|
916
|
+
reconnectExhausted = false;
|
|
917
|
+
};
|
|
918
|
+
const emitReconnectExhausted = (elapsedMs, windowMs) => {
|
|
919
|
+
if (reconnectExhausted) return;
|
|
920
|
+
reconnectExhausted = true;
|
|
921
|
+
const attempts = reconnectAttempts;
|
|
922
|
+
const trigger = reconnectTriggerError;
|
|
923
|
+
api?.emit("error:reconnect-exhausted", { attempts, elapsedMs, windowMs });
|
|
924
|
+
api?.setState("playbackState", "error");
|
|
925
|
+
api?.setState("buffering", false);
|
|
926
|
+
api?.emit("error", {
|
|
927
|
+
code: trigger ? mapFatalErrorCode(trigger) : ErrorCode.PLAYBACK_FAILED,
|
|
928
|
+
message: `HLS auto-reconnect gave up after ${attempts} attempts over ${Math.round(elapsedMs / 1e3)}s`,
|
|
929
|
+
fatal: true,
|
|
930
|
+
timestamp: Date.now(),
|
|
931
|
+
detail: {
|
|
932
|
+
type: trigger?.type ?? "other",
|
|
933
|
+
retriesExhausted: true,
|
|
934
|
+
attempts,
|
|
935
|
+
reconnectExhausted: true
|
|
936
|
+
}
|
|
937
|
+
});
|
|
806
938
|
};
|
|
807
939
|
const scheduleReconnectAttempt = () => {
|
|
940
|
+
if (reconnectExhausted) return;
|
|
808
941
|
if (reconnectTimer) return;
|
|
809
942
|
const window_ms = mergedConfig.reconnectWindowMs ?? 3e5;
|
|
810
|
-
|
|
943
|
+
const elapsed_ms = Date.now() - reconnectWindowStart;
|
|
944
|
+
if (elapsed_ms > window_ms) {
|
|
811
945
|
api?.logger.warn(`Auto-reconnect window exhausted after ${reconnectAttempts} attempts`);
|
|
946
|
+
emitReconnectExhausted(elapsed_ms, window_ms);
|
|
812
947
|
return;
|
|
813
948
|
}
|
|
814
949
|
const base_delay = mergedConfig.reconnectBaseDelayMs ?? 2e3;
|
|
@@ -816,7 +951,12 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
816
951
|
const backoff = Math.min(base_delay * Math.pow(2, reconnectAttempts), max_delay);
|
|
817
952
|
const delay = Math.round(backoff * (0.7 + Math.random() * 0.3));
|
|
818
953
|
api?.logger.info(`Scheduling auto-reconnect attempt ${reconnectAttempts + 1} in ${delay}ms`);
|
|
819
|
-
api?.emit("error:reconnecting", {
|
|
954
|
+
api?.emit("error:reconnecting", {
|
|
955
|
+
attempt: reconnectAttempts + 1,
|
|
956
|
+
delayMs: delay,
|
|
957
|
+
elapsedMs: elapsed_ms,
|
|
958
|
+
windowMs: window_ms
|
|
959
|
+
});
|
|
820
960
|
reconnectTimer = setTimeout(() => {
|
|
821
961
|
reconnectTimer = null;
|
|
822
962
|
void attemptReconnect();
|
|
@@ -829,6 +969,7 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
829
969
|
if (reconnectWindowStart === 0) {
|
|
830
970
|
reconnectWindowStart = Date.now();
|
|
831
971
|
reconnectResumePosition = video?.currentTime ?? 0;
|
|
972
|
+
reconnectTriggerError = error;
|
|
832
973
|
}
|
|
833
974
|
scheduleReconnectAttempt();
|
|
834
975
|
};
|
|
@@ -860,7 +1001,10 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
860
1001
|
}
|
|
861
1002
|
api.setState("playbackState", "ready");
|
|
862
1003
|
api.setState("buffering", false);
|
|
863
|
-
api.emit("error:recovered",
|
|
1004
|
+
api.emit("error:recovered", {
|
|
1005
|
+
attempt: reconnectAttempts,
|
|
1006
|
+
elapsedMs: Date.now() - reconnectWindowStart
|
|
1007
|
+
});
|
|
864
1008
|
api.logger.info("Auto-reconnect succeeded");
|
|
865
1009
|
cancelReconnect();
|
|
866
1010
|
try {
|
|
@@ -876,7 +1020,7 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
876
1020
|
const plugin = {
|
|
877
1021
|
id: "hls-provider",
|
|
878
1022
|
name: variant.name,
|
|
879
|
-
version:
|
|
1023
|
+
version: PKG_VERSION,
|
|
880
1024
|
type: "provider",
|
|
881
1025
|
description: variant.description,
|
|
882
1026
|
canPlay(src) {
|
|
@@ -967,6 +1111,9 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
967
1111
|
};
|
|
968
1112
|
window.addEventListener("online", onlineListener);
|
|
969
1113
|
}
|
|
1114
|
+
const unsubPoster = api.subscribeToState((event) => {
|
|
1115
|
+
if (event.key === "poster") applyPoster();
|
|
1116
|
+
});
|
|
970
1117
|
api.onDestroy(() => {
|
|
971
1118
|
unsubPlay();
|
|
972
1119
|
unsubPause();
|
|
@@ -975,6 +1122,7 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
975
1122
|
unsubMute();
|
|
976
1123
|
unsubRate();
|
|
977
1124
|
unsubQuality();
|
|
1125
|
+
unsubPoster();
|
|
978
1126
|
});
|
|
979
1127
|
},
|
|
980
1128
|
async destroy() {
|
|
@@ -1000,6 +1148,7 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
1000
1148
|
hasPlayedContent = false;
|
|
1001
1149
|
cleanup(new Error("HLS load cancelled: superseded by a new load"));
|
|
1002
1150
|
currentSrc = src;
|
|
1151
|
+
applyPoster();
|
|
1003
1152
|
api.setState("playbackState", "loading");
|
|
1004
1153
|
api.setState("buffering", true);
|
|
1005
1154
|
if (api.getState("airplayActive") && loader.supportsNativeHLS()) {
|
|
@@ -1137,5 +1286,6 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
1137
1286
|
|
|
1138
1287
|
export {
|
|
1139
1288
|
__export,
|
|
1289
|
+
sanitizeUrl,
|
|
1140
1290
|
createHLSPluginWith
|
|
1141
1291
|
};
|
package/dist/index.cjs
CHANGED
|
@@ -31,7 +31,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
31
31
|
var index_exports = {};
|
|
32
32
|
__export(index_exports, {
|
|
33
33
|
createHLSPlugin: () => createHLSPlugin,
|
|
34
|
-
default: () => index_default
|
|
34
|
+
default: () => index_default,
|
|
35
|
+
sanitizeUrl: () => sanitizeUrl
|
|
35
36
|
});
|
|
36
37
|
module.exports = __toCommonJS(index_exports);
|
|
37
38
|
|
|
@@ -316,8 +317,14 @@ function setupVideoEventHandlers(video, api) {
|
|
|
316
317
|
video.addEventListener(event, handler);
|
|
317
318
|
handlers.push({ event, handler });
|
|
318
319
|
};
|
|
320
|
+
const syncEndedFromElement = () => {
|
|
321
|
+
if (video.ended || !api.getState("ended")) return;
|
|
322
|
+
api.setState("ended", false);
|
|
323
|
+
api.setState("playbackState", video.paused ? "paused" : "playing");
|
|
324
|
+
};
|
|
319
325
|
addHandler("play", () => {
|
|
320
326
|
api.setState("paused", false);
|
|
327
|
+
syncEndedFromElement();
|
|
321
328
|
});
|
|
322
329
|
addHandler("playing", () => {
|
|
323
330
|
api.setState("playing", true);
|
|
@@ -325,6 +332,7 @@ function setupVideoEventHandlers(video, api) {
|
|
|
325
332
|
api.setState("waiting", false);
|
|
326
333
|
api.setState("buffering", false);
|
|
327
334
|
api.setState("playbackState", "playing");
|
|
335
|
+
syncEndedFromElement();
|
|
328
336
|
});
|
|
329
337
|
addHandler("pause", () => {
|
|
330
338
|
api.setState("playing", false);
|
|
@@ -379,6 +387,7 @@ function setupVideoEventHandlers(video, api) {
|
|
|
379
387
|
});
|
|
380
388
|
addHandler("seeking", () => {
|
|
381
389
|
api.setState("seeking", true);
|
|
390
|
+
syncEndedFromElement();
|
|
382
391
|
});
|
|
383
392
|
addHandler("seeked", () => {
|
|
384
393
|
api.setState("seeking", false);
|
|
@@ -486,6 +495,20 @@ function createValidatingPlaylistLoader(Hls) {
|
|
|
486
495
|
};
|
|
487
496
|
}
|
|
488
497
|
|
|
498
|
+
// src/sanitize-url.ts
|
|
499
|
+
function sanitizeUrl(url) {
|
|
500
|
+
if (!url) return void 0;
|
|
501
|
+
try {
|
|
502
|
+
const parsed = new URL(url);
|
|
503
|
+
return `${parsed.origin}${parsed.pathname}`;
|
|
504
|
+
} catch {
|
|
505
|
+
return void 0;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// src/version.ts
|
|
510
|
+
var PKG_VERSION = true ? "1.7.1" : "0.0.0-dev";
|
|
511
|
+
|
|
489
512
|
// src/create-hls-plugin.ts
|
|
490
513
|
var DEFAULT_CONFIG = {
|
|
491
514
|
debug: false,
|
|
@@ -542,6 +565,12 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
542
565
|
let reconnectWindowStart = 0;
|
|
543
566
|
let reconnectResumePosition = 0;
|
|
544
567
|
let onlineListener = null;
|
|
568
|
+
let reconnectTriggerError = null;
|
|
569
|
+
let reconnectExhausted = false;
|
|
570
|
+
const applyPoster = () => {
|
|
571
|
+
if (!video) return;
|
|
572
|
+
video.poster = api?.getState("poster") || "";
|
|
573
|
+
};
|
|
545
574
|
const getOrCreateVideo = () => {
|
|
546
575
|
if (video) return video;
|
|
547
576
|
const existing = api?.container.querySelector("video");
|
|
@@ -554,10 +583,7 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
554
583
|
video.preload = "metadata";
|
|
555
584
|
video.controls = false;
|
|
556
585
|
video.playsInline = true;
|
|
557
|
-
|
|
558
|
-
if (poster) {
|
|
559
|
-
video.poster = poster;
|
|
560
|
-
}
|
|
586
|
+
applyPoster();
|
|
561
587
|
api?.container.appendChild(video);
|
|
562
588
|
return video;
|
|
563
589
|
};
|
|
@@ -650,6 +676,22 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
650
676
|
return import_core.ErrorCode.PLAYBACK_FAILED;
|
|
651
677
|
}
|
|
652
678
|
};
|
|
679
|
+
const buildErrorDetail = (error, retriesExhausted) => {
|
|
680
|
+
const attempts = error.type === "network" ? networkRetryCount : error.type === "media" ? mediaRetryCount : 0;
|
|
681
|
+
const detail = {
|
|
682
|
+
type: error.type,
|
|
683
|
+
retriesExhausted,
|
|
684
|
+
attempts
|
|
685
|
+
};
|
|
686
|
+
if (typeof error.response?.code === "number" && error.response.code > 0) {
|
|
687
|
+
detail.httpStatus = error.response.code;
|
|
688
|
+
}
|
|
689
|
+
const url = sanitizeUrl(error.url);
|
|
690
|
+
if (url) {
|
|
691
|
+
detail.url = url;
|
|
692
|
+
}
|
|
693
|
+
return detail;
|
|
694
|
+
};
|
|
653
695
|
const emitFatalError = (error, retriesExhausted) => {
|
|
654
696
|
const message = retriesExhausted ? `HLS error: ${error.details} (max retries exceeded)` : `HLS error: ${error.details}`;
|
|
655
697
|
api?.logger.error(message, { type: error.type, details: error.details });
|
|
@@ -659,7 +701,8 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
659
701
|
code: mapFatalErrorCode(error),
|
|
660
702
|
message,
|
|
661
703
|
fatal: true,
|
|
662
|
-
timestamp: Date.now()
|
|
704
|
+
timestamp: Date.now(),
|
|
705
|
+
detail: buildErrorDetail(error, retriesExhausted)
|
|
663
706
|
});
|
|
664
707
|
maybeScheduleReconnect(error);
|
|
665
708
|
};
|
|
@@ -736,6 +779,62 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
736
779
|
}
|
|
737
780
|
return false;
|
|
738
781
|
};
|
|
782
|
+
const handleNativeFatalError = (error, resumePosition) => {
|
|
783
|
+
const is_network = error.type === "network";
|
|
784
|
+
const max_retries = is_network ? mergedConfig.maxNetworkRetries ?? 3 : mergedConfig.maxMediaRetries ?? 2;
|
|
785
|
+
const used = is_network ? networkRetryCount : mediaRetryCount;
|
|
786
|
+
if (!currentSrc || used >= max_retries) {
|
|
787
|
+
emitFatalError(error, used >= max_retries);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
const resume_position = resumePosition ?? video?.currentTime ?? 0;
|
|
791
|
+
if (is_network) {
|
|
792
|
+
networkRetryCount++;
|
|
793
|
+
} else {
|
|
794
|
+
mediaRetryCount++;
|
|
795
|
+
}
|
|
796
|
+
const attempt = used + 1;
|
|
797
|
+
const delay = getRetryDelay(attempt - 1);
|
|
798
|
+
api?.logger.info(
|
|
799
|
+
`Attempting native ${error.type} error recovery (attempt ${attempt}/${max_retries}) in ${delay}ms`
|
|
800
|
+
);
|
|
801
|
+
api?.emit(is_network ? "error:network" : "error:media", {
|
|
802
|
+
error: new Error(error.details)
|
|
803
|
+
});
|
|
804
|
+
if (retryTimeout) {
|
|
805
|
+
clearTimeout(retryTimeout);
|
|
806
|
+
}
|
|
807
|
+
const retry_session = loadSession;
|
|
808
|
+
retryTimeout = setTimeout(() => {
|
|
809
|
+
if (retry_session !== loadSession) return;
|
|
810
|
+
void recoverNative(error, resume_position);
|
|
811
|
+
}, delay);
|
|
812
|
+
};
|
|
813
|
+
const recoverNative = async (error, resumePosition) => {
|
|
814
|
+
if (!currentSrc) return;
|
|
815
|
+
const session = ++loadSession;
|
|
816
|
+
const saved_src = currentSrc;
|
|
817
|
+
const was_live = api?.getState("live") ?? false;
|
|
818
|
+
try {
|
|
819
|
+
teardownPipeline(new Error("HLS load cancelled: native error recovery"));
|
|
820
|
+
api?.setState("playbackState", "loading");
|
|
821
|
+
await loadNative(saved_src);
|
|
822
|
+
if (session !== loadSession) return;
|
|
823
|
+
if (!was_live && video && resumePosition > 0) {
|
|
824
|
+
video.currentTime = resumePosition;
|
|
825
|
+
}
|
|
826
|
+
api?.setState("playbackState", "ready");
|
|
827
|
+
api?.setState("buffering", false);
|
|
828
|
+
try {
|
|
829
|
+
await video?.play();
|
|
830
|
+
} catch {
|
|
831
|
+
}
|
|
832
|
+
} catch {
|
|
833
|
+
if (session !== loadSession) return;
|
|
834
|
+
api?.logger.warn("Native error recovery attempt failed");
|
|
835
|
+
handleNativeFatalError(error, resumePosition);
|
|
836
|
+
}
|
|
837
|
+
};
|
|
739
838
|
const loadNative = async (src) => {
|
|
740
839
|
const session = loadSession;
|
|
741
840
|
const videoEl = getOrCreateVideo();
|
|
@@ -780,10 +879,21 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
780
879
|
details: media_error?.message || "Native HLS playback error",
|
|
781
880
|
fatal: true
|
|
782
881
|
};
|
|
783
|
-
|
|
882
|
+
handleNativeFatalError(hls_error);
|
|
784
883
|
};
|
|
785
884
|
videoEl.addEventListener("error", onFatalVideoError);
|
|
786
|
-
const
|
|
885
|
+
const onPlayingResetBudget = () => {
|
|
886
|
+
if (networkRetryCount > 0 || mediaRetryCount > 0) {
|
|
887
|
+
api?.logger.debug("Native playback recovered, resetting retry budgets");
|
|
888
|
+
networkRetryCount = 0;
|
|
889
|
+
mediaRetryCount = 0;
|
|
890
|
+
}
|
|
891
|
+
};
|
|
892
|
+
videoEl.addEventListener("playing", onPlayingResetBudget);
|
|
893
|
+
const removeFatalListener = () => {
|
|
894
|
+
videoEl.removeEventListener("error", onFatalVideoError);
|
|
895
|
+
videoEl.removeEventListener("playing", onPlayingResetBudget);
|
|
896
|
+
};
|
|
787
897
|
const previous_cleanup = cleanupVideoEvents;
|
|
788
898
|
cleanupVideoEvents = () => {
|
|
789
899
|
removeFatalListener();
|
|
@@ -908,12 +1018,38 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
908
1018
|
reconnectAttempts = 0;
|
|
909
1019
|
reconnectWindowStart = 0;
|
|
910
1020
|
reconnectResumePosition = 0;
|
|
1021
|
+
reconnectTriggerError = null;
|
|
1022
|
+
reconnectExhausted = false;
|
|
1023
|
+
};
|
|
1024
|
+
const emitReconnectExhausted = (elapsedMs, windowMs) => {
|
|
1025
|
+
if (reconnectExhausted) return;
|
|
1026
|
+
reconnectExhausted = true;
|
|
1027
|
+
const attempts = reconnectAttempts;
|
|
1028
|
+
const trigger = reconnectTriggerError;
|
|
1029
|
+
api?.emit("error:reconnect-exhausted", { attempts, elapsedMs, windowMs });
|
|
1030
|
+
api?.setState("playbackState", "error");
|
|
1031
|
+
api?.setState("buffering", false);
|
|
1032
|
+
api?.emit("error", {
|
|
1033
|
+
code: trigger ? mapFatalErrorCode(trigger) : import_core.ErrorCode.PLAYBACK_FAILED,
|
|
1034
|
+
message: `HLS auto-reconnect gave up after ${attempts} attempts over ${Math.round(elapsedMs / 1e3)}s`,
|
|
1035
|
+
fatal: true,
|
|
1036
|
+
timestamp: Date.now(),
|
|
1037
|
+
detail: {
|
|
1038
|
+
type: trigger?.type ?? "other",
|
|
1039
|
+
retriesExhausted: true,
|
|
1040
|
+
attempts,
|
|
1041
|
+
reconnectExhausted: true
|
|
1042
|
+
}
|
|
1043
|
+
});
|
|
911
1044
|
};
|
|
912
1045
|
const scheduleReconnectAttempt = () => {
|
|
1046
|
+
if (reconnectExhausted) return;
|
|
913
1047
|
if (reconnectTimer) return;
|
|
914
1048
|
const window_ms = mergedConfig.reconnectWindowMs ?? 3e5;
|
|
915
|
-
|
|
1049
|
+
const elapsed_ms = Date.now() - reconnectWindowStart;
|
|
1050
|
+
if (elapsed_ms > window_ms) {
|
|
916
1051
|
api?.logger.warn(`Auto-reconnect window exhausted after ${reconnectAttempts} attempts`);
|
|
1052
|
+
emitReconnectExhausted(elapsed_ms, window_ms);
|
|
917
1053
|
return;
|
|
918
1054
|
}
|
|
919
1055
|
const base_delay = mergedConfig.reconnectBaseDelayMs ?? 2e3;
|
|
@@ -921,7 +1057,12 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
921
1057
|
const backoff = Math.min(base_delay * Math.pow(2, reconnectAttempts), max_delay);
|
|
922
1058
|
const delay = Math.round(backoff * (0.7 + Math.random() * 0.3));
|
|
923
1059
|
api?.logger.info(`Scheduling auto-reconnect attempt ${reconnectAttempts + 1} in ${delay}ms`);
|
|
924
|
-
api?.emit("error:reconnecting", {
|
|
1060
|
+
api?.emit("error:reconnecting", {
|
|
1061
|
+
attempt: reconnectAttempts + 1,
|
|
1062
|
+
delayMs: delay,
|
|
1063
|
+
elapsedMs: elapsed_ms,
|
|
1064
|
+
windowMs: window_ms
|
|
1065
|
+
});
|
|
925
1066
|
reconnectTimer = setTimeout(() => {
|
|
926
1067
|
reconnectTimer = null;
|
|
927
1068
|
void attemptReconnect();
|
|
@@ -934,6 +1075,7 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
934
1075
|
if (reconnectWindowStart === 0) {
|
|
935
1076
|
reconnectWindowStart = Date.now();
|
|
936
1077
|
reconnectResumePosition = video?.currentTime ?? 0;
|
|
1078
|
+
reconnectTriggerError = error;
|
|
937
1079
|
}
|
|
938
1080
|
scheduleReconnectAttempt();
|
|
939
1081
|
};
|
|
@@ -965,7 +1107,10 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
965
1107
|
}
|
|
966
1108
|
api.setState("playbackState", "ready");
|
|
967
1109
|
api.setState("buffering", false);
|
|
968
|
-
api.emit("error:recovered",
|
|
1110
|
+
api.emit("error:recovered", {
|
|
1111
|
+
attempt: reconnectAttempts,
|
|
1112
|
+
elapsedMs: Date.now() - reconnectWindowStart
|
|
1113
|
+
});
|
|
969
1114
|
api.logger.info("Auto-reconnect succeeded");
|
|
970
1115
|
cancelReconnect();
|
|
971
1116
|
try {
|
|
@@ -981,7 +1126,7 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
981
1126
|
const plugin = {
|
|
982
1127
|
id: "hls-provider",
|
|
983
1128
|
name: variant.name,
|
|
984
|
-
version:
|
|
1129
|
+
version: PKG_VERSION,
|
|
985
1130
|
type: "provider",
|
|
986
1131
|
description: variant.description,
|
|
987
1132
|
canPlay(src) {
|
|
@@ -1072,6 +1217,9 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
1072
1217
|
};
|
|
1073
1218
|
window.addEventListener("online", onlineListener);
|
|
1074
1219
|
}
|
|
1220
|
+
const unsubPoster = api.subscribeToState((event) => {
|
|
1221
|
+
if (event.key === "poster") applyPoster();
|
|
1222
|
+
});
|
|
1075
1223
|
api.onDestroy(() => {
|
|
1076
1224
|
unsubPlay();
|
|
1077
1225
|
unsubPause();
|
|
@@ -1080,6 +1228,7 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
1080
1228
|
unsubMute();
|
|
1081
1229
|
unsubRate();
|
|
1082
1230
|
unsubQuality();
|
|
1231
|
+
unsubPoster();
|
|
1083
1232
|
});
|
|
1084
1233
|
},
|
|
1085
1234
|
async destroy() {
|
|
@@ -1105,6 +1254,7 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
1105
1254
|
hasPlayedContent = false;
|
|
1106
1255
|
cleanup(new Error("HLS load cancelled: superseded by a new load"));
|
|
1107
1256
|
currentSrc = src;
|
|
1257
|
+
applyPoster();
|
|
1108
1258
|
api.setState("playbackState", "loading");
|
|
1109
1259
|
api.setState("buffering", true);
|
|
1110
1260
|
if (api.getState("airplayActive") && loader.supportsNativeHLS()) {
|
|
@@ -1256,5 +1406,6 @@ function createHLSPlugin(config) {
|
|
|
1256
1406
|
var index_default = createHLSPlugin;
|
|
1257
1407
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1258
1408
|
0 && (module.exports = {
|
|
1259
|
-
createHLSPlugin
|
|
1409
|
+
createHLSPlugin,
|
|
1410
|
+
sanitizeUrl
|
|
1260
1411
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { H as HLSPluginConfig, I as IHLSPlugin } from './
|
|
2
|
-
export { b as HLSError, c as HLSLiveInfo, a as HLSQualityLevel } from './
|
|
1
|
+
import { H as HLSPluginConfig, I as IHLSPlugin } from './sanitize-url-DcZXD_K-.cjs';
|
|
2
|
+
export { b as HLSError, c as HLSLiveInfo, a as HLSQualityLevel, s as sanitizeUrl } from './sanitize-url-DcZXD_K-.cjs';
|
|
3
3
|
import '@scarlett-player/core';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -20,9 +20,10 @@ import '@scarlett-player/core';
|
|
|
20
20
|
*
|
|
21
21
|
* @example
|
|
22
22
|
* ```ts
|
|
23
|
+
* import { createPlayer } from '@scarlett-player/core';
|
|
23
24
|
* import { createHLSPlugin } from '@scarlett-player/hls';
|
|
24
25
|
*
|
|
25
|
-
* const player =
|
|
26
|
+
* const player = await createPlayer({
|
|
26
27
|
* container: document.getElementById('player'),
|
|
27
28
|
* plugins: [createHLSPlugin()],
|
|
28
29
|
* });
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { H as HLSPluginConfig, I as IHLSPlugin } from './
|
|
2
|
-
export { b as HLSError, c as HLSLiveInfo, a as HLSQualityLevel } from './
|
|
1
|
+
import { H as HLSPluginConfig, I as IHLSPlugin } from './sanitize-url-DcZXD_K-.js';
|
|
2
|
+
export { b as HLSError, c as HLSLiveInfo, a as HLSQualityLevel, s as sanitizeUrl } from './sanitize-url-DcZXD_K-.js';
|
|
3
3
|
import '@scarlett-player/core';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -20,9 +20,10 @@ import '@scarlett-player/core';
|
|
|
20
20
|
*
|
|
21
21
|
* @example
|
|
22
22
|
* ```ts
|
|
23
|
+
* import { createPlayer } from '@scarlett-player/core';
|
|
23
24
|
* import { createHLSPlugin } from '@scarlett-player/hls';
|
|
24
25
|
*
|
|
25
|
-
* const player =
|
|
26
|
+
* const player = await createPlayer({
|
|
26
27
|
* container: document.getElementById('player'),
|
|
27
28
|
* plugins: [createHLSPlugin()],
|
|
28
29
|
* });
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
__export,
|
|
3
|
-
createHLSPluginWith
|
|
4
|
-
|
|
3
|
+
createHLSPluginWith,
|
|
4
|
+
sanitizeUrl
|
|
5
|
+
} from "./chunk-FN6QUW42.js";
|
|
5
6
|
|
|
6
7
|
// src/hls-loader.ts
|
|
7
8
|
var hls_loader_exports = {};
|
|
@@ -93,5 +94,6 @@ function createHLSPlugin(config) {
|
|
|
93
94
|
var index_default = createHLSPlugin;
|
|
94
95
|
export {
|
|
95
96
|
createHLSPlugin,
|
|
96
|
-
index_default as default
|
|
97
|
+
index_default as default,
|
|
98
|
+
sanitizeUrl
|
|
97
99
|
};
|
package/dist/light.cjs
CHANGED
|
@@ -31,7 +31,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
31
31
|
var light_exports = {};
|
|
32
32
|
__export(light_exports, {
|
|
33
33
|
createHLSPlugin: () => createHLSPlugin,
|
|
34
|
-
default: () => light_default
|
|
34
|
+
default: () => light_default,
|
|
35
|
+
sanitizeUrl: () => sanitizeUrl
|
|
35
36
|
});
|
|
36
37
|
module.exports = __toCommonJS(light_exports);
|
|
37
38
|
|
|
@@ -316,8 +317,14 @@ function setupVideoEventHandlers(video, api) {
|
|
|
316
317
|
video.addEventListener(event, handler);
|
|
317
318
|
handlers.push({ event, handler });
|
|
318
319
|
};
|
|
320
|
+
const syncEndedFromElement = () => {
|
|
321
|
+
if (video.ended || !api.getState("ended")) return;
|
|
322
|
+
api.setState("ended", false);
|
|
323
|
+
api.setState("playbackState", video.paused ? "paused" : "playing");
|
|
324
|
+
};
|
|
319
325
|
addHandler("play", () => {
|
|
320
326
|
api.setState("paused", false);
|
|
327
|
+
syncEndedFromElement();
|
|
321
328
|
});
|
|
322
329
|
addHandler("playing", () => {
|
|
323
330
|
api.setState("playing", true);
|
|
@@ -325,6 +332,7 @@ function setupVideoEventHandlers(video, api) {
|
|
|
325
332
|
api.setState("waiting", false);
|
|
326
333
|
api.setState("buffering", false);
|
|
327
334
|
api.setState("playbackState", "playing");
|
|
335
|
+
syncEndedFromElement();
|
|
328
336
|
});
|
|
329
337
|
addHandler("pause", () => {
|
|
330
338
|
api.setState("playing", false);
|
|
@@ -379,6 +387,7 @@ function setupVideoEventHandlers(video, api) {
|
|
|
379
387
|
});
|
|
380
388
|
addHandler("seeking", () => {
|
|
381
389
|
api.setState("seeking", true);
|
|
390
|
+
syncEndedFromElement();
|
|
382
391
|
});
|
|
383
392
|
addHandler("seeked", () => {
|
|
384
393
|
api.setState("seeking", false);
|
|
@@ -486,6 +495,20 @@ function createValidatingPlaylistLoader(Hls) {
|
|
|
486
495
|
};
|
|
487
496
|
}
|
|
488
497
|
|
|
498
|
+
// src/sanitize-url.ts
|
|
499
|
+
function sanitizeUrl(url) {
|
|
500
|
+
if (!url) return void 0;
|
|
501
|
+
try {
|
|
502
|
+
const parsed = new URL(url);
|
|
503
|
+
return `${parsed.origin}${parsed.pathname}`;
|
|
504
|
+
} catch {
|
|
505
|
+
return void 0;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// src/version.ts
|
|
510
|
+
var PKG_VERSION = true ? "1.7.1" : "0.0.0-dev";
|
|
511
|
+
|
|
489
512
|
// src/create-hls-plugin.ts
|
|
490
513
|
var DEFAULT_CONFIG = {
|
|
491
514
|
debug: false,
|
|
@@ -542,6 +565,12 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
542
565
|
let reconnectWindowStart = 0;
|
|
543
566
|
let reconnectResumePosition = 0;
|
|
544
567
|
let onlineListener = null;
|
|
568
|
+
let reconnectTriggerError = null;
|
|
569
|
+
let reconnectExhausted = false;
|
|
570
|
+
const applyPoster = () => {
|
|
571
|
+
if (!video) return;
|
|
572
|
+
video.poster = api?.getState("poster") || "";
|
|
573
|
+
};
|
|
545
574
|
const getOrCreateVideo = () => {
|
|
546
575
|
if (video) return video;
|
|
547
576
|
const existing = api?.container.querySelector("video");
|
|
@@ -554,10 +583,7 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
554
583
|
video.preload = "metadata";
|
|
555
584
|
video.controls = false;
|
|
556
585
|
video.playsInline = true;
|
|
557
|
-
|
|
558
|
-
if (poster) {
|
|
559
|
-
video.poster = poster;
|
|
560
|
-
}
|
|
586
|
+
applyPoster();
|
|
561
587
|
api?.container.appendChild(video);
|
|
562
588
|
return video;
|
|
563
589
|
};
|
|
@@ -650,6 +676,22 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
650
676
|
return import_core.ErrorCode.PLAYBACK_FAILED;
|
|
651
677
|
}
|
|
652
678
|
};
|
|
679
|
+
const buildErrorDetail = (error, retriesExhausted) => {
|
|
680
|
+
const attempts = error.type === "network" ? networkRetryCount : error.type === "media" ? mediaRetryCount : 0;
|
|
681
|
+
const detail = {
|
|
682
|
+
type: error.type,
|
|
683
|
+
retriesExhausted,
|
|
684
|
+
attempts
|
|
685
|
+
};
|
|
686
|
+
if (typeof error.response?.code === "number" && error.response.code > 0) {
|
|
687
|
+
detail.httpStatus = error.response.code;
|
|
688
|
+
}
|
|
689
|
+
const url = sanitizeUrl(error.url);
|
|
690
|
+
if (url) {
|
|
691
|
+
detail.url = url;
|
|
692
|
+
}
|
|
693
|
+
return detail;
|
|
694
|
+
};
|
|
653
695
|
const emitFatalError = (error, retriesExhausted) => {
|
|
654
696
|
const message = retriesExhausted ? `HLS error: ${error.details} (max retries exceeded)` : `HLS error: ${error.details}`;
|
|
655
697
|
api?.logger.error(message, { type: error.type, details: error.details });
|
|
@@ -659,7 +701,8 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
659
701
|
code: mapFatalErrorCode(error),
|
|
660
702
|
message,
|
|
661
703
|
fatal: true,
|
|
662
|
-
timestamp: Date.now()
|
|
704
|
+
timestamp: Date.now(),
|
|
705
|
+
detail: buildErrorDetail(error, retriesExhausted)
|
|
663
706
|
});
|
|
664
707
|
maybeScheduleReconnect(error);
|
|
665
708
|
};
|
|
@@ -736,6 +779,62 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
736
779
|
}
|
|
737
780
|
return false;
|
|
738
781
|
};
|
|
782
|
+
const handleNativeFatalError = (error, resumePosition) => {
|
|
783
|
+
const is_network = error.type === "network";
|
|
784
|
+
const max_retries = is_network ? mergedConfig.maxNetworkRetries ?? 3 : mergedConfig.maxMediaRetries ?? 2;
|
|
785
|
+
const used = is_network ? networkRetryCount : mediaRetryCount;
|
|
786
|
+
if (!currentSrc || used >= max_retries) {
|
|
787
|
+
emitFatalError(error, used >= max_retries);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
const resume_position = resumePosition ?? video?.currentTime ?? 0;
|
|
791
|
+
if (is_network) {
|
|
792
|
+
networkRetryCount++;
|
|
793
|
+
} else {
|
|
794
|
+
mediaRetryCount++;
|
|
795
|
+
}
|
|
796
|
+
const attempt = used + 1;
|
|
797
|
+
const delay = getRetryDelay(attempt - 1);
|
|
798
|
+
api?.logger.info(
|
|
799
|
+
`Attempting native ${error.type} error recovery (attempt ${attempt}/${max_retries}) in ${delay}ms`
|
|
800
|
+
);
|
|
801
|
+
api?.emit(is_network ? "error:network" : "error:media", {
|
|
802
|
+
error: new Error(error.details)
|
|
803
|
+
});
|
|
804
|
+
if (retryTimeout) {
|
|
805
|
+
clearTimeout(retryTimeout);
|
|
806
|
+
}
|
|
807
|
+
const retry_session = loadSession;
|
|
808
|
+
retryTimeout = setTimeout(() => {
|
|
809
|
+
if (retry_session !== loadSession) return;
|
|
810
|
+
void recoverNative(error, resume_position);
|
|
811
|
+
}, delay);
|
|
812
|
+
};
|
|
813
|
+
const recoverNative = async (error, resumePosition) => {
|
|
814
|
+
if (!currentSrc) return;
|
|
815
|
+
const session = ++loadSession;
|
|
816
|
+
const saved_src = currentSrc;
|
|
817
|
+
const was_live = api?.getState("live") ?? false;
|
|
818
|
+
try {
|
|
819
|
+
teardownPipeline(new Error("HLS load cancelled: native error recovery"));
|
|
820
|
+
api?.setState("playbackState", "loading");
|
|
821
|
+
await loadNative(saved_src);
|
|
822
|
+
if (session !== loadSession) return;
|
|
823
|
+
if (!was_live && video && resumePosition > 0) {
|
|
824
|
+
video.currentTime = resumePosition;
|
|
825
|
+
}
|
|
826
|
+
api?.setState("playbackState", "ready");
|
|
827
|
+
api?.setState("buffering", false);
|
|
828
|
+
try {
|
|
829
|
+
await video?.play();
|
|
830
|
+
} catch {
|
|
831
|
+
}
|
|
832
|
+
} catch {
|
|
833
|
+
if (session !== loadSession) return;
|
|
834
|
+
api?.logger.warn("Native error recovery attempt failed");
|
|
835
|
+
handleNativeFatalError(error, resumePosition);
|
|
836
|
+
}
|
|
837
|
+
};
|
|
739
838
|
const loadNative = async (src) => {
|
|
740
839
|
const session = loadSession;
|
|
741
840
|
const videoEl = getOrCreateVideo();
|
|
@@ -780,10 +879,21 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
780
879
|
details: media_error?.message || "Native HLS playback error",
|
|
781
880
|
fatal: true
|
|
782
881
|
};
|
|
783
|
-
|
|
882
|
+
handleNativeFatalError(hls_error);
|
|
784
883
|
};
|
|
785
884
|
videoEl.addEventListener("error", onFatalVideoError);
|
|
786
|
-
const
|
|
885
|
+
const onPlayingResetBudget = () => {
|
|
886
|
+
if (networkRetryCount > 0 || mediaRetryCount > 0) {
|
|
887
|
+
api?.logger.debug("Native playback recovered, resetting retry budgets");
|
|
888
|
+
networkRetryCount = 0;
|
|
889
|
+
mediaRetryCount = 0;
|
|
890
|
+
}
|
|
891
|
+
};
|
|
892
|
+
videoEl.addEventListener("playing", onPlayingResetBudget);
|
|
893
|
+
const removeFatalListener = () => {
|
|
894
|
+
videoEl.removeEventListener("error", onFatalVideoError);
|
|
895
|
+
videoEl.removeEventListener("playing", onPlayingResetBudget);
|
|
896
|
+
};
|
|
787
897
|
const previous_cleanup = cleanupVideoEvents;
|
|
788
898
|
cleanupVideoEvents = () => {
|
|
789
899
|
removeFatalListener();
|
|
@@ -908,12 +1018,38 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
908
1018
|
reconnectAttempts = 0;
|
|
909
1019
|
reconnectWindowStart = 0;
|
|
910
1020
|
reconnectResumePosition = 0;
|
|
1021
|
+
reconnectTriggerError = null;
|
|
1022
|
+
reconnectExhausted = false;
|
|
1023
|
+
};
|
|
1024
|
+
const emitReconnectExhausted = (elapsedMs, windowMs) => {
|
|
1025
|
+
if (reconnectExhausted) return;
|
|
1026
|
+
reconnectExhausted = true;
|
|
1027
|
+
const attempts = reconnectAttempts;
|
|
1028
|
+
const trigger = reconnectTriggerError;
|
|
1029
|
+
api?.emit("error:reconnect-exhausted", { attempts, elapsedMs, windowMs });
|
|
1030
|
+
api?.setState("playbackState", "error");
|
|
1031
|
+
api?.setState("buffering", false);
|
|
1032
|
+
api?.emit("error", {
|
|
1033
|
+
code: trigger ? mapFatalErrorCode(trigger) : import_core.ErrorCode.PLAYBACK_FAILED,
|
|
1034
|
+
message: `HLS auto-reconnect gave up after ${attempts} attempts over ${Math.round(elapsedMs / 1e3)}s`,
|
|
1035
|
+
fatal: true,
|
|
1036
|
+
timestamp: Date.now(),
|
|
1037
|
+
detail: {
|
|
1038
|
+
type: trigger?.type ?? "other",
|
|
1039
|
+
retriesExhausted: true,
|
|
1040
|
+
attempts,
|
|
1041
|
+
reconnectExhausted: true
|
|
1042
|
+
}
|
|
1043
|
+
});
|
|
911
1044
|
};
|
|
912
1045
|
const scheduleReconnectAttempt = () => {
|
|
1046
|
+
if (reconnectExhausted) return;
|
|
913
1047
|
if (reconnectTimer) return;
|
|
914
1048
|
const window_ms = mergedConfig.reconnectWindowMs ?? 3e5;
|
|
915
|
-
|
|
1049
|
+
const elapsed_ms = Date.now() - reconnectWindowStart;
|
|
1050
|
+
if (elapsed_ms > window_ms) {
|
|
916
1051
|
api?.logger.warn(`Auto-reconnect window exhausted after ${reconnectAttempts} attempts`);
|
|
1052
|
+
emitReconnectExhausted(elapsed_ms, window_ms);
|
|
917
1053
|
return;
|
|
918
1054
|
}
|
|
919
1055
|
const base_delay = mergedConfig.reconnectBaseDelayMs ?? 2e3;
|
|
@@ -921,7 +1057,12 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
921
1057
|
const backoff = Math.min(base_delay * Math.pow(2, reconnectAttempts), max_delay);
|
|
922
1058
|
const delay = Math.round(backoff * (0.7 + Math.random() * 0.3));
|
|
923
1059
|
api?.logger.info(`Scheduling auto-reconnect attempt ${reconnectAttempts + 1} in ${delay}ms`);
|
|
924
|
-
api?.emit("error:reconnecting", {
|
|
1060
|
+
api?.emit("error:reconnecting", {
|
|
1061
|
+
attempt: reconnectAttempts + 1,
|
|
1062
|
+
delayMs: delay,
|
|
1063
|
+
elapsedMs: elapsed_ms,
|
|
1064
|
+
windowMs: window_ms
|
|
1065
|
+
});
|
|
925
1066
|
reconnectTimer = setTimeout(() => {
|
|
926
1067
|
reconnectTimer = null;
|
|
927
1068
|
void attemptReconnect();
|
|
@@ -934,6 +1075,7 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
934
1075
|
if (reconnectWindowStart === 0) {
|
|
935
1076
|
reconnectWindowStart = Date.now();
|
|
936
1077
|
reconnectResumePosition = video?.currentTime ?? 0;
|
|
1078
|
+
reconnectTriggerError = error;
|
|
937
1079
|
}
|
|
938
1080
|
scheduleReconnectAttempt();
|
|
939
1081
|
};
|
|
@@ -965,7 +1107,10 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
965
1107
|
}
|
|
966
1108
|
api.setState("playbackState", "ready");
|
|
967
1109
|
api.setState("buffering", false);
|
|
968
|
-
api.emit("error:recovered",
|
|
1110
|
+
api.emit("error:recovered", {
|
|
1111
|
+
attempt: reconnectAttempts,
|
|
1112
|
+
elapsedMs: Date.now() - reconnectWindowStart
|
|
1113
|
+
});
|
|
969
1114
|
api.logger.info("Auto-reconnect succeeded");
|
|
970
1115
|
cancelReconnect();
|
|
971
1116
|
try {
|
|
@@ -981,7 +1126,7 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
981
1126
|
const plugin = {
|
|
982
1127
|
id: "hls-provider",
|
|
983
1128
|
name: variant.name,
|
|
984
|
-
version:
|
|
1129
|
+
version: PKG_VERSION,
|
|
985
1130
|
type: "provider",
|
|
986
1131
|
description: variant.description,
|
|
987
1132
|
canPlay(src) {
|
|
@@ -1072,6 +1217,9 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
1072
1217
|
};
|
|
1073
1218
|
window.addEventListener("online", onlineListener);
|
|
1074
1219
|
}
|
|
1220
|
+
const unsubPoster = api.subscribeToState((event) => {
|
|
1221
|
+
if (event.key === "poster") applyPoster();
|
|
1222
|
+
});
|
|
1075
1223
|
api.onDestroy(() => {
|
|
1076
1224
|
unsubPlay();
|
|
1077
1225
|
unsubPause();
|
|
@@ -1080,6 +1228,7 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
1080
1228
|
unsubMute();
|
|
1081
1229
|
unsubRate();
|
|
1082
1230
|
unsubQuality();
|
|
1231
|
+
unsubPoster();
|
|
1083
1232
|
});
|
|
1084
1233
|
},
|
|
1085
1234
|
async destroy() {
|
|
@@ -1105,6 +1254,7 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
1105
1254
|
hasPlayedContent = false;
|
|
1106
1255
|
cleanup(new Error("HLS load cancelled: superseded by a new load"));
|
|
1107
1256
|
currentSrc = src;
|
|
1257
|
+
applyPoster();
|
|
1108
1258
|
api.setState("playbackState", "loading");
|
|
1109
1259
|
api.setState("buffering", true);
|
|
1110
1260
|
if (api.getState("airplayActive") && loader.supportsNativeHLS()) {
|
|
@@ -1256,5 +1406,6 @@ function createHLSPlugin(config) {
|
|
|
1256
1406
|
var light_default = createHLSPlugin;
|
|
1257
1407
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1258
1408
|
0 && (module.exports = {
|
|
1259
|
-
createHLSPlugin
|
|
1409
|
+
createHLSPlugin,
|
|
1410
|
+
sanitizeUrl
|
|
1260
1411
|
});
|
package/dist/light.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { H as HLSPluginConfig, I as IHLSPlugin } from './
|
|
2
|
-
export { b as HLSError, c as HLSLiveInfo, a as HLSQualityLevel } from './
|
|
1
|
+
import { H as HLSPluginConfig, I as IHLSPlugin } from './sanitize-url-DcZXD_K-.cjs';
|
|
2
|
+
export { b as HLSError, c as HLSLiveInfo, a as HLSQualityLevel, s as sanitizeUrl } from './sanitize-url-DcZXD_K-.cjs';
|
|
3
3
|
import '@scarlett-player/core';
|
|
4
4
|
|
|
5
5
|
/**
|
package/dist/light.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { H as HLSPluginConfig, I as IHLSPlugin } from './
|
|
2
|
-
export { b as HLSError, c as HLSLiveInfo, a as HLSQualityLevel } from './
|
|
1
|
+
import { H as HLSPluginConfig, I as IHLSPlugin } from './sanitize-url-DcZXD_K-.js';
|
|
2
|
+
export { b as HLSError, c as HLSLiveInfo, a as HLSQualityLevel, s as sanitizeUrl } from './sanitize-url-DcZXD_K-.js';
|
|
3
3
|
import '@scarlett-player/core';
|
|
4
4
|
|
|
5
5
|
/**
|
package/dist/light.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
__export,
|
|
3
|
-
createHLSPluginWith
|
|
4
|
-
|
|
3
|
+
createHLSPluginWith,
|
|
4
|
+
sanitizeUrl
|
|
5
|
+
} from "./chunk-FN6QUW42.js";
|
|
5
6
|
|
|
6
7
|
// src/hls-loader-light.ts
|
|
7
8
|
var hls_loader_light_exports = {};
|
|
@@ -93,5 +94,6 @@ function createHLSPlugin(config) {
|
|
|
93
94
|
var light_default = createHLSPlugin;
|
|
94
95
|
export {
|
|
95
96
|
createHLSPlugin,
|
|
96
|
-
light_default as default
|
|
97
|
+
light_default as default,
|
|
98
|
+
sanitizeUrl
|
|
97
99
|
};
|
|
@@ -22,9 +22,19 @@ interface HLSPluginConfig {
|
|
|
22
22
|
backBufferLength?: number;
|
|
23
23
|
/** Enable worker for hls.js (better performance) */
|
|
24
24
|
enableWorker?: boolean;
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Max network error retries before giving up (default: 3).
|
|
27
|
+
*
|
|
28
|
+
* Governs both playback branches: hls.js retries the load, and the native
|
|
29
|
+
* (Safari/iOS) path reloads the source and restores position.
|
|
30
|
+
*/
|
|
26
31
|
maxNetworkRetries?: number;
|
|
27
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Max media error retries before giving up (default: 2).
|
|
34
|
+
*
|
|
35
|
+
* Governs both playback branches: hls.js calls recoverMediaError(), and the
|
|
36
|
+
* native (Safari/iOS) path reloads the source and restores position.
|
|
37
|
+
*/
|
|
28
38
|
maxMediaRetries?: number;
|
|
29
39
|
/** Cap quality to player element dimensions (default: true) */
|
|
30
40
|
capLevelToPlayerSize?: boolean;
|
|
@@ -128,4 +138,29 @@ interface IHLSPlugin extends Plugin<HLSPluginConfig> {
|
|
|
128
138
|
switchToHlsJs(): Promise<void>;
|
|
129
139
|
}
|
|
130
140
|
|
|
131
|
-
|
|
141
|
+
/**
|
|
142
|
+
* URL sanitizer for error telemetry.
|
|
143
|
+
*
|
|
144
|
+
* Segment and playlist URLs are the single most useful field when
|
|
145
|
+
* diagnosing a dead stream, and the single most dangerous one to hand to a
|
|
146
|
+
* consumer's telemetry: signed-URL tokens, HMAC signatures, and session ids
|
|
147
|
+
* all live in the query string. Stripping the whole query (and fragment) is
|
|
148
|
+
* the privacy-safe default for EVERY consumer; anyone who genuinely needs
|
|
149
|
+
* the parameters can subscribe to hls.js directly.
|
|
150
|
+
*/
|
|
151
|
+
/**
|
|
152
|
+
* Reduce a URL to origin + pathname, dropping the query string and fragment.
|
|
153
|
+
*
|
|
154
|
+
* @param url - Raw URL from a provider error, if any
|
|
155
|
+
* @returns Sanitized `origin + pathname`, or undefined when there is
|
|
156
|
+
* nothing usable to report (absent or unparseable URL)
|
|
157
|
+
*
|
|
158
|
+
* @example
|
|
159
|
+
* ```ts
|
|
160
|
+
* sanitizeUrl('https://cdn.example.com/live/x.m3u8?token=secret#frag');
|
|
161
|
+
* // 'https://cdn.example.com/live/x.m3u8'
|
|
162
|
+
* ```
|
|
163
|
+
*/
|
|
164
|
+
declare function sanitizeUrl(url: string | undefined | null): string | undefined;
|
|
165
|
+
|
|
166
|
+
export { type HLSPluginConfig as H, type IHLSPlugin as I, type HLSQualityLevel as a, type HLSError as b, type HLSLiveInfo as c, sanitizeUrl as s };
|
|
@@ -22,9 +22,19 @@ interface HLSPluginConfig {
|
|
|
22
22
|
backBufferLength?: number;
|
|
23
23
|
/** Enable worker for hls.js (better performance) */
|
|
24
24
|
enableWorker?: boolean;
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Max network error retries before giving up (default: 3).
|
|
27
|
+
*
|
|
28
|
+
* Governs both playback branches: hls.js retries the load, and the native
|
|
29
|
+
* (Safari/iOS) path reloads the source and restores position.
|
|
30
|
+
*/
|
|
26
31
|
maxNetworkRetries?: number;
|
|
27
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Max media error retries before giving up (default: 2).
|
|
34
|
+
*
|
|
35
|
+
* Governs both playback branches: hls.js calls recoverMediaError(), and the
|
|
36
|
+
* native (Safari/iOS) path reloads the source and restores position.
|
|
37
|
+
*/
|
|
28
38
|
maxMediaRetries?: number;
|
|
29
39
|
/** Cap quality to player element dimensions (default: true) */
|
|
30
40
|
capLevelToPlayerSize?: boolean;
|
|
@@ -128,4 +138,29 @@ interface IHLSPlugin extends Plugin<HLSPluginConfig> {
|
|
|
128
138
|
switchToHlsJs(): Promise<void>;
|
|
129
139
|
}
|
|
130
140
|
|
|
131
|
-
|
|
141
|
+
/**
|
|
142
|
+
* URL sanitizer for error telemetry.
|
|
143
|
+
*
|
|
144
|
+
* Segment and playlist URLs are the single most useful field when
|
|
145
|
+
* diagnosing a dead stream, and the single most dangerous one to hand to a
|
|
146
|
+
* consumer's telemetry: signed-URL tokens, HMAC signatures, and session ids
|
|
147
|
+
* all live in the query string. Stripping the whole query (and fragment) is
|
|
148
|
+
* the privacy-safe default for EVERY consumer; anyone who genuinely needs
|
|
149
|
+
* the parameters can subscribe to hls.js directly.
|
|
150
|
+
*/
|
|
151
|
+
/**
|
|
152
|
+
* Reduce a URL to origin + pathname, dropping the query string and fragment.
|
|
153
|
+
*
|
|
154
|
+
* @param url - Raw URL from a provider error, if any
|
|
155
|
+
* @returns Sanitized `origin + pathname`, or undefined when there is
|
|
156
|
+
* nothing usable to report (absent or unparseable URL)
|
|
157
|
+
*
|
|
158
|
+
* @example
|
|
159
|
+
* ```ts
|
|
160
|
+
* sanitizeUrl('https://cdn.example.com/live/x.m3u8?token=secret#frag');
|
|
161
|
+
* // 'https://cdn.example.com/live/x.m3u8'
|
|
162
|
+
* ```
|
|
163
|
+
*/
|
|
164
|
+
declare function sanitizeUrl(url: string | undefined | null): string | undefined;
|
|
165
|
+
|
|
166
|
+
export { type HLSPluginConfig as H, type IHLSPlugin as I, type HLSQualityLevel as a, type HLSError as b, type HLSLiveInfo as c, sanitizeUrl as s };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scarlett-player/hls",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "HLS Provider Plugin for Scarlett Player",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
"dist"
|
|
23
23
|
],
|
|
24
24
|
"peerDependencies": {
|
|
25
|
-
"@scarlett-player/core": "^1.0
|
|
26
|
-
"hls.js": "^1.
|
|
25
|
+
"@scarlett-player/core": "^1.7.0",
|
|
26
|
+
"hls.js": "^1.6.0"
|
|
27
27
|
},
|
|
28
28
|
"peerDependenciesMeta": {
|
|
29
29
|
"hls.js": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"vitest": "^1.6.0",
|
|
38
38
|
"@vitest/coverage-v8": "^1.6.0",
|
|
39
39
|
"jsdom": "^24.0.0",
|
|
40
|
-
"@scarlett-player/core": "1.
|
|
40
|
+
"@scarlett-player/core": "1.8.0"
|
|
41
41
|
},
|
|
42
42
|
"keywords": [
|
|
43
43
|
"video",
|
|
@@ -58,10 +58,11 @@
|
|
|
58
58
|
},
|
|
59
59
|
"homepage": "https://scarlettplayer.com",
|
|
60
60
|
"scripts": {
|
|
61
|
-
"build": "tsup
|
|
62
|
-
"dev": "tsup
|
|
61
|
+
"build": "tsup",
|
|
62
|
+
"dev": "tsup --watch",
|
|
63
63
|
"test": "vitest --run",
|
|
64
64
|
"test:watch": "vitest",
|
|
65
|
-
"test:coverage": "vitest --coverage"
|
|
65
|
+
"test:coverage": "vitest --coverage",
|
|
66
|
+
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json"
|
|
66
67
|
}
|
|
67
68
|
}
|