@scarlett-player/hls 1.11.0 → 1.12.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 +75 -1
- package/dist/{chunk-NVPSLRJY.js → chunk-47MU7IVX.js} +237 -49
- package/dist/index.cjs +239 -49
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +5 -1
- package/dist/light.cjs +239 -49
- package/dist/light.d.cts +2 -2
- package/dist/light.d.ts +2 -2
- package/dist/light.js +5 -1
- package/dist/live-metrics-CoYkWhl5.d.cts +388 -0
- package/dist/live-metrics-CoYkWhl5.d.ts +388 -0
- package/package.json +2 -2
- package/dist/types-DnvcTuSn.d.cts +0 -143
- package/dist/types-DnvcTuSn.d.ts +0 -143
package/README.md
CHANGED
|
@@ -25,6 +25,8 @@ const player = await createPlayer({
|
|
|
25
25
|
|
|
26
26
|
- Adaptive bitrate streaming with quality level selection
|
|
27
27
|
- Live stream support with DVR
|
|
28
|
+
- Low-latency HLS (LL-HLS): part loading, latency catch-up, and live metrics
|
|
29
|
+
reported through player state
|
|
28
30
|
- Native Safari HLS fallback (and hls.js lazy loading everywhere else)
|
|
29
31
|
- Self-healing error recovery: bounded retries with jittered backoff,
|
|
30
32
|
auto-reconnect after mid-playback failures (VOD resumes at position, live
|
|
@@ -55,9 +57,18 @@ createHLSPlugin({
|
|
|
55
57
|
// Loading
|
|
56
58
|
autoStartLoad: true,
|
|
57
59
|
startPosition: -1,
|
|
58
|
-
lowLatencyMode: false,
|
|
59
60
|
loadTimeoutMs: 30000, // Load watchdog; 0 disables
|
|
60
61
|
|
|
62
|
+
// Live / low latency (see below). Every key here except lowLatencyMode is
|
|
63
|
+
// left to hls.js unless you set it - none is ever passed as undefined.
|
|
64
|
+
lowLatencyMode: false,
|
|
65
|
+
liveSyncDuration: undefined, // Target latency in seconds
|
|
66
|
+
liveSyncDurationCount: undefined, // ...or as a count of target durations
|
|
67
|
+
liveMaxLatencyDuration: undefined, // Seek forward past this latency
|
|
68
|
+
liveMaxLatencyDurationCount: undefined,
|
|
69
|
+
maxLiveSyncPlaybackRate: undefined, // Defaults to 1.1 when lowLatencyMode
|
|
70
|
+
liveDurationInfinity: undefined, // Report live duration as Infinity
|
|
71
|
+
|
|
61
72
|
// Error recovery
|
|
62
73
|
maxNetworkRetries: 3,
|
|
63
74
|
maxMediaRetries: 2,
|
|
@@ -73,6 +84,69 @@ createHLSPlugin({
|
|
|
73
84
|
});
|
|
74
85
|
```
|
|
75
86
|
|
|
87
|
+
## Low-Latency HLS
|
|
88
|
+
|
|
89
|
+
`lowLatencyMode` is off by default and opt-in, so upgrading changes nothing for
|
|
90
|
+
an existing consumer. Turning it on does more than set the hls.js flag:
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
createHLSPlugin({ lowLatencyMode: true });
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- hls.js loads `EXT-X-PART` parts and issues blocking playlist reloads.
|
|
97
|
+
- **Latency catch-up is enabled.** hls.js ships `maxLiveSyncPlaybackRate: 1`,
|
|
98
|
+
which disables catch-up entirely, so LL-HLS would otherwise parse and load
|
|
99
|
+
parts and then let latency settle wherever the buffer landed and never pull
|
|
100
|
+
it back. The plugin defaults it to `1.1` when (and only when) low latency is
|
|
101
|
+
requested. Override it - `1.05` is gentler and slower to recover.
|
|
102
|
+
- `liveSyncDuration` / `liveSyncDurationCount` override the target latency the
|
|
103
|
+
manifest asks for; `liveMaxLatencyDuration` / `liveMaxLatencyDurationCount`
|
|
104
|
+
set the latency at which the player seeks forward instead of speeding up.
|
|
105
|
+
|
|
106
|
+
**The manifest has to support it.** Low latency needs
|
|
107
|
+
`#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=<n>` and
|
|
108
|
+
`#EXT-X-PART` (declared with `#EXT-X-PART-INF:PART-TARGET=<n>`). Setting the
|
|
109
|
+
flag against a plain live playlist changes nothing but the config.
|
|
110
|
+
|
|
111
|
+
### What the player reports
|
|
112
|
+
|
|
113
|
+
| Key | Meaning |
|
|
114
|
+
|---|---|
|
|
115
|
+
| `live` | The playlist has no `EXT-X-ENDLIST` |
|
|
116
|
+
| `liveLatency` | Seconds behind the live edge |
|
|
117
|
+
| `liveEdge` | `latency <= targetLatency + max(1.5, partTarget ?? targetduration / 2)` |
|
|
118
|
+
| `seekableRange` | The DVR window, from the playlist rather than `video.seekable` |
|
|
119
|
+
| `lowLatencyMode` | Low latency is EFFECTIVE - see below |
|
|
120
|
+
|
|
121
|
+
Each has a matching event: `live:latency`, `live:edgechange`,
|
|
122
|
+
`live:seekablerange`, `live:lowlatency`. All four fire only on change.
|
|
123
|
+
|
|
124
|
+
`lowLatencyMode` reports effect, not intent: it is true only when the manifest
|
|
125
|
+
actually carries parts (or advertises blocking reloads) **and** the config
|
|
126
|
+
requested low latency. A host that sets the flag against a plain live manifest
|
|
127
|
+
gets no LL badge, and neither does an LL manifest played without the flag,
|
|
128
|
+
because hls.js will not load its parts either way.
|
|
129
|
+
|
|
130
|
+
`getLiveInfo()` returns the same truth from the provider directly:
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
const provider = player.getPlugin('hls-provider');
|
|
134
|
+
provider.getLiveInfo();
|
|
135
|
+
// { isLive: true, latency: 1.53, targetLatency: 1.5, drift: 1.0,
|
|
136
|
+
// liveSyncPosition: 24.55, lowLatency: true }
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
On native Safari HLS there is no latency API, so `latency` is the distance to
|
|
140
|
+
`video.seekable.end` - a buffer distance, not a measured latency - and the edge
|
|
141
|
+
threshold stays deliberately loose. Treat those numbers as an approximation.
|
|
142
|
+
|
|
143
|
+
### Rejoining the live edge
|
|
144
|
+
|
|
145
|
+
Call `player.seekToLive()`, or emit `live:seektolive` from a control. Both land
|
|
146
|
+
on the provider's `liveSyncPosition` before falling back to the end of the
|
|
147
|
+
seekable range. Under low latency that distinction matters: the end of the
|
|
148
|
+
seekable range is past the last loaded part, and seeking there stalls.
|
|
149
|
+
|
|
76
150
|
## Error Recovery
|
|
77
151
|
|
|
78
152
|
Recoverable errors retry with jittered exponential backoff; the retry budget
|
|
@@ -7,6 +7,107 @@ var __export = (target, all) => {
|
|
|
7
7
|
// src/sanitize-url.ts
|
|
8
8
|
import { sanitizeUrl } from "@scarlett-player/core";
|
|
9
9
|
|
|
10
|
+
// src/live-metrics.ts
|
|
11
|
+
var DEFAULT_TARGET_LATENCY = 3;
|
|
12
|
+
var MIN_EDGE_TOLERANCE = 1.5;
|
|
13
|
+
var NATIVE_EDGE_TOLERANCE = 7;
|
|
14
|
+
var EPSILON = 0.05;
|
|
15
|
+
function finite(value) {
|
|
16
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
17
|
+
}
|
|
18
|
+
function rangeFromDetails(details) {
|
|
19
|
+
if (!details) return null;
|
|
20
|
+
const start = finite(details.fragmentStart) ?? finite(details.fragments?.[0]?.start) ?? 0;
|
|
21
|
+
const total = finite(details.totalduration);
|
|
22
|
+
const end = finite(details.edge) ?? (total === null ? null : start + total);
|
|
23
|
+
if (end === null) return null;
|
|
24
|
+
return { start, end };
|
|
25
|
+
}
|
|
26
|
+
function rangeFromMedia(media) {
|
|
27
|
+
const seekable = media?.seekable;
|
|
28
|
+
if (!seekable || seekable.length === 0) return null;
|
|
29
|
+
const start = seekable.start(0);
|
|
30
|
+
const end = seekable.end(seekable.length - 1);
|
|
31
|
+
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
|
|
32
|
+
return { start, end };
|
|
33
|
+
}
|
|
34
|
+
function targetFromDetails(details) {
|
|
35
|
+
if (!details) return null;
|
|
36
|
+
const targetduration = finite(details.targetduration);
|
|
37
|
+
return finite(details.partHoldBack) ?? finite(details.holdBack) ?? (targetduration !== null ? targetduration * 3 : null);
|
|
38
|
+
}
|
|
39
|
+
function edgeTolerance(details, targetLatency) {
|
|
40
|
+
const targetduration = finite(details?.targetduration);
|
|
41
|
+
const half = targetduration !== null ? targetduration / 2 : targetLatency / 2;
|
|
42
|
+
return Math.max(MIN_EDGE_TOLERANCE, finite(details?.partTarget) ?? half);
|
|
43
|
+
}
|
|
44
|
+
function computeLiveMetrics(source) {
|
|
45
|
+
if (source.kind === "hls") {
|
|
46
|
+
const { hls, details } = source;
|
|
47
|
+
const media2 = hls.media;
|
|
48
|
+
const seekableRange2 = rangeFromDetails(details) ?? rangeFromMedia(media2);
|
|
49
|
+
const targetLatency2 = finite(hls.targetLatency) ?? targetFromDetails(details) ?? DEFAULT_TARGET_LATENCY;
|
|
50
|
+
const latency2 = finite(hls.latency) ?? (seekableRange2 && media2 ? Math.max(0, seekableRange2.end - media2.currentTime) : null);
|
|
51
|
+
if (latency2 === null && seekableRange2 === null) return null;
|
|
52
|
+
const resolvedLatency = latency2 ?? 0;
|
|
53
|
+
return {
|
|
54
|
+
latency: resolvedLatency,
|
|
55
|
+
targetLatency: targetLatency2,
|
|
56
|
+
atEdge: resolvedLatency <= targetLatency2 + edgeTolerance(details, targetLatency2),
|
|
57
|
+
seekableRange: seekableRange2,
|
|
58
|
+
// Effective LL, not requested LL: the manifest has to carry parts or
|
|
59
|
+
// advertise blocking reloads, AND the host has to have asked for it.
|
|
60
|
+
lowLatency: source.lowLatencyRequested !== false && (!!details?.partList?.length || details?.canBlockReload === true)
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const { media } = source;
|
|
64
|
+
const seekableRange = rangeFromMedia(media);
|
|
65
|
+
if (!seekableRange) return null;
|
|
66
|
+
const targetLatency = source.targetLatency ?? DEFAULT_TARGET_LATENCY;
|
|
67
|
+
const tolerance = source.targetLatency === void 0 ? NATIVE_EDGE_TOLERANCE : Math.max(MIN_EDGE_TOLERANCE, source.targetLatency / 2);
|
|
68
|
+
const latency = Math.max(0, seekableRange.end - media.currentTime);
|
|
69
|
+
return {
|
|
70
|
+
latency,
|
|
71
|
+
targetLatency,
|
|
72
|
+
atEdge: latency <= targetLatency + tolerance,
|
|
73
|
+
seekableRange,
|
|
74
|
+
lowLatency: source.lowLatency === true
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function applyLiveMetrics(api, metrics) {
|
|
78
|
+
if (!metrics) return;
|
|
79
|
+
const previousLatency = api.getState("liveLatency");
|
|
80
|
+
if (Math.abs(previousLatency - metrics.latency) > EPSILON) {
|
|
81
|
+
api.setState("liveLatency", metrics.latency);
|
|
82
|
+
api.emit("live:latency", { latency: metrics.latency });
|
|
83
|
+
}
|
|
84
|
+
if (api.getState("liveEdge") !== metrics.atEdge) {
|
|
85
|
+
api.setState("liveEdge", metrics.atEdge);
|
|
86
|
+
api.emit("live:edgechange", { atEdge: metrics.atEdge });
|
|
87
|
+
}
|
|
88
|
+
const range = metrics.seekableRange;
|
|
89
|
+
if (range) {
|
|
90
|
+
const previous = api.getState("seekableRange");
|
|
91
|
+
if (!previous || Math.abs(previous.start - range.start) > EPSILON || Math.abs(previous.end - range.end) > EPSILON) {
|
|
92
|
+
api.setState("seekableRange", { start: range.start, end: range.end });
|
|
93
|
+
api.emit("live:seekablerange", { start: range.start, end: range.end });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (api.getState("lowLatencyMode") !== metrics.lowLatency) {
|
|
97
|
+
api.setState("lowLatencyMode", metrics.lowLatency);
|
|
98
|
+
api.emit("live:lowlatency", { enabled: metrics.lowLatency });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function resetLiveMetrics(api) {
|
|
102
|
+
if (api.getState("lowLatencyMode")) {
|
|
103
|
+
api.setState("lowLatencyMode", false);
|
|
104
|
+
api.emit("live:lowlatency", { enabled: false });
|
|
105
|
+
}
|
|
106
|
+
api.setState("liveLatency", 0);
|
|
107
|
+
api.setState("liveEdge", false);
|
|
108
|
+
api.setState("seekableRange", null);
|
|
109
|
+
}
|
|
110
|
+
|
|
10
111
|
// src/create-hls-plugin.ts
|
|
11
112
|
import { ErrorCode } from "@scarlett-player/core";
|
|
12
113
|
|
|
@@ -206,16 +307,18 @@ function setupHlsEventHandlers(hls, api, callbacks) {
|
|
|
206
307
|
if (data.details?.live !== void 0) {
|
|
207
308
|
api.setState("live", data.details.live);
|
|
208
309
|
if (data.details.live) {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
310
|
+
callbacks.onLevelDetails?.(data.details);
|
|
311
|
+
applyLiveMetrics(
|
|
312
|
+
api,
|
|
313
|
+
computeLiveMetrics({
|
|
314
|
+
kind: "hls",
|
|
315
|
+
hls,
|
|
316
|
+
details: data.details,
|
|
317
|
+
lowLatencyRequested: callbacks.isLowLatencyRequested?.() ?? true
|
|
318
|
+
})
|
|
319
|
+
);
|
|
320
|
+
} else {
|
|
321
|
+
resetLiveMetrics(api);
|
|
219
322
|
}
|
|
220
323
|
callbacks.onLiveUpdate?.();
|
|
221
324
|
}
|
|
@@ -255,7 +358,7 @@ function setupHlsEventHandlers(hls, api, callbacks) {
|
|
|
255
358
|
api.setState("currentAudioTrack", null);
|
|
256
359
|
};
|
|
257
360
|
}
|
|
258
|
-
function setupVideoEventHandlers(video, api) {
|
|
361
|
+
function setupVideoEventHandlers(video, api, getLiveMetrics) {
|
|
259
362
|
const handlers = [];
|
|
260
363
|
const addHandler = (event, handler) => {
|
|
261
364
|
video.addEventListener(event, handler);
|
|
@@ -292,15 +395,11 @@ function setupVideoEventHandlers(video, api) {
|
|
|
292
395
|
addHandler("timeupdate", () => {
|
|
293
396
|
api.setState("currentTime", video.currentTime);
|
|
294
397
|
api.emit("playback:timeupdate", { currentTime: video.currentTime });
|
|
295
|
-
if (
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const latency = Math.max(0, end - video.currentTime);
|
|
301
|
-
api.setState("liveEdge", latency < 10);
|
|
302
|
-
api.setState("liveLatency", latency);
|
|
303
|
-
}
|
|
398
|
+
if (api.getState("live") || !Number.isFinite(video.duration)) {
|
|
399
|
+
applyLiveMetrics(
|
|
400
|
+
api,
|
|
401
|
+
getLiveMetrics ? getLiveMetrics() : computeLiveMetrics({ kind: "media", media: video })
|
|
402
|
+
);
|
|
304
403
|
}
|
|
305
404
|
});
|
|
306
405
|
addHandler("durationchange", () => {
|
|
@@ -448,7 +547,7 @@ function createValidatingPlaylistLoader(Hls) {
|
|
|
448
547
|
}
|
|
449
548
|
|
|
450
549
|
// src/version.ts
|
|
451
|
-
var PKG_VERSION = true ? "1.
|
|
550
|
+
var PKG_VERSION = true ? "1.12.0" : "0.0.0-dev";
|
|
452
551
|
|
|
453
552
|
// src/create-hls-plugin.ts
|
|
454
553
|
var DEFAULT_CONFIG = {
|
|
@@ -476,6 +575,7 @@ var DEFAULT_CONFIG = {
|
|
|
476
575
|
// Never index a malformed live playlist refresh blindly
|
|
477
576
|
validatePlaylists: true
|
|
478
577
|
};
|
|
578
|
+
var LL_CATCH_UP_PLAYBACK_RATE = 1.1;
|
|
479
579
|
var MANIFEST_PHASE_ERRORS = [
|
|
480
580
|
"manifestLoadError",
|
|
481
581
|
"manifestLoadTimeOut",
|
|
@@ -491,6 +591,9 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
491
591
|
let cleanupHlsEvents = null;
|
|
492
592
|
let cleanupVideoEvents = null;
|
|
493
593
|
let isAutoQuality = true;
|
|
594
|
+
let lastLevelDetails = null;
|
|
595
|
+
let lastLiveMetrics = null;
|
|
596
|
+
let lastKnownTargetLatency = null;
|
|
494
597
|
let loadSession = 0;
|
|
495
598
|
let abortPendingLoad = null;
|
|
496
599
|
let networkRetryCount = 0;
|
|
@@ -513,6 +616,24 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
513
616
|
let stallWatchdogTimer = null;
|
|
514
617
|
let lastStallCheckTime = 0;
|
|
515
618
|
let lastStallCheckPosition = 0;
|
|
619
|
+
const readLiveMetrics = () => {
|
|
620
|
+
const metrics = hls && !isNative ? computeLiveMetrics({
|
|
621
|
+
kind: "hls",
|
|
622
|
+
hls,
|
|
623
|
+
details: lastLevelDetails,
|
|
624
|
+
lowLatencyRequested: mergedConfig.lowLatencyMode === true
|
|
625
|
+
}) : video ? computeLiveMetrics({
|
|
626
|
+
kind: "media",
|
|
627
|
+
media: video,
|
|
628
|
+
targetLatency: lastKnownTargetLatency ?? void 0,
|
|
629
|
+
lowLatency: lastLiveMetrics?.lowLatency
|
|
630
|
+
}) : null;
|
|
631
|
+
if (metrics) {
|
|
632
|
+
lastLiveMetrics = metrics;
|
|
633
|
+
if (hls && !isNative) lastKnownTargetLatency = metrics.targetLatency;
|
|
634
|
+
}
|
|
635
|
+
return metrics;
|
|
636
|
+
};
|
|
516
637
|
const applyPoster = () => {
|
|
517
638
|
if (!video) return;
|
|
518
639
|
video.poster = api?.getState("poster") || "";
|
|
@@ -562,6 +683,10 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
562
683
|
mediaRetryCount = 0;
|
|
563
684
|
errorCount = 0;
|
|
564
685
|
errorWindowStart = 0;
|
|
686
|
+
lastLevelDetails = null;
|
|
687
|
+
lastLiveMetrics = null;
|
|
688
|
+
lastKnownTargetLatency = null;
|
|
689
|
+
if (api) resetLiveMetrics(api);
|
|
565
690
|
};
|
|
566
691
|
const buildHlsConfig = () => {
|
|
567
692
|
const config2 = buildBaseHlsConfig();
|
|
@@ -573,27 +698,58 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
573
698
|
}
|
|
574
699
|
return config2;
|
|
575
700
|
};
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
701
|
+
const buildLiveHlsConfig = () => {
|
|
702
|
+
const live = {};
|
|
703
|
+
const set = (key, value) => {
|
|
704
|
+
if (value !== void 0) live[key] = value;
|
|
705
|
+
};
|
|
706
|
+
const syncDuration = mergedConfig.liveSyncDuration;
|
|
707
|
+
const syncCount = mergedConfig.liveSyncDurationCount;
|
|
708
|
+
const maxDuration = mergedConfig.liveMaxLatencyDuration;
|
|
709
|
+
const maxCount = mergedConfig.liveMaxLatencyDurationCount;
|
|
710
|
+
const mixed = (syncDuration !== void 0 || maxDuration !== void 0) && (syncCount !== void 0 || maxCount !== void 0);
|
|
711
|
+
const dropCount = mixed && syncDuration !== void 0;
|
|
712
|
+
const dropDuration = mixed && !dropCount;
|
|
713
|
+
if (mixed) {
|
|
714
|
+
api?.logger.warn(
|
|
715
|
+
`Ignoring ${dropCount ? "liveSyncDurationCount/liveMaxLatencyDurationCount" : "liveSyncDuration/liveMaxLatencyDuration"}: hls.js rejects a config mixing seconds-based and count-based live latency options`
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
set("liveSyncDuration", dropDuration ? void 0 : syncDuration);
|
|
719
|
+
set("liveSyncDurationCount", dropCount ? void 0 : syncCount);
|
|
720
|
+
set("liveMaxLatencyDuration", dropDuration ? void 0 : maxDuration);
|
|
721
|
+
set("liveMaxLatencyDurationCount", dropCount ? void 0 : maxCount);
|
|
722
|
+
set("liveDurationInfinity", mergedConfig.liveDurationInfinity);
|
|
723
|
+
set(
|
|
724
|
+
"maxLiveSyncPlaybackRate",
|
|
725
|
+
mergedConfig.maxLiveSyncPlaybackRate ?? (mergedConfig.lowLatencyMode === true ? LL_CATCH_UP_PLAYBACK_RATE : void 0)
|
|
726
|
+
);
|
|
727
|
+
return live;
|
|
728
|
+
};
|
|
729
|
+
const buildBaseHlsConfig = () => {
|
|
730
|
+
return {
|
|
731
|
+
debug: mergedConfig.debug,
|
|
732
|
+
autoStartLoad: mergedConfig.autoStartLoad,
|
|
733
|
+
startPosition: mergedConfig.startPosition,
|
|
734
|
+
startLevel: -1,
|
|
735
|
+
// Auto quality selection (ABR)
|
|
736
|
+
abrEwmaDefaultEstimate: getInitialBandwidthEstimate(mergedConfig.initialBandwidthEstimate),
|
|
737
|
+
lowLatencyMode: mergedConfig.lowLatencyMode,
|
|
738
|
+
maxBufferLength: mergedConfig.maxBufferLength,
|
|
739
|
+
maxMaxBufferLength: mergedConfig.maxMaxBufferLength,
|
|
740
|
+
backBufferLength: mergedConfig.backBufferLength,
|
|
741
|
+
enableWorker: mergedConfig.enableWorker,
|
|
742
|
+
capLevelToPlayerSize: mergedConfig.capLevelToPlayerSize,
|
|
743
|
+
// Minimize hls.js internal retries - we handle retries ourselves
|
|
744
|
+
fragLoadingMaxRetry: 1,
|
|
745
|
+
manifestLoadingMaxRetry: 1,
|
|
746
|
+
levelLoadingMaxRetry: 1,
|
|
747
|
+
fragLoadingRetryDelay: 500,
|
|
748
|
+
manifestLoadingRetryDelay: 500,
|
|
749
|
+
levelLoadingRetryDelay: 500,
|
|
750
|
+
...buildLiveHlsConfig()
|
|
751
|
+
};
|
|
752
|
+
};
|
|
597
753
|
const getRetryDelay = (retryCount) => {
|
|
598
754
|
const baseDelay = mergedConfig.retryDelayMs ?? 1e3;
|
|
599
755
|
const backoffFactor = mergedConfig.retryBackoffFactor ?? 2;
|
|
@@ -790,7 +946,7 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
790
946
|
const videoEl = getOrCreateVideo();
|
|
791
947
|
isNative = true;
|
|
792
948
|
if (api) {
|
|
793
|
-
cleanupVideoEvents = setupVideoEventHandlers(videoEl, api);
|
|
949
|
+
cleanupVideoEvents = setupVideoEventHandlers(videoEl, api, readLiveMetrics);
|
|
794
950
|
}
|
|
795
951
|
return new Promise((resolve, reject) => {
|
|
796
952
|
let watchdog = null;
|
|
@@ -883,7 +1039,7 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
883
1039
|
isNative = false;
|
|
884
1040
|
hls = loader.createHlsInstance(buildHlsConfig());
|
|
885
1041
|
if (api) {
|
|
886
|
-
cleanupVideoEvents = setupVideoEventHandlers(videoEl, api);
|
|
1042
|
+
cleanupVideoEvents = setupVideoEventHandlers(videoEl, api, readLiveMetrics);
|
|
887
1043
|
}
|
|
888
1044
|
return new Promise((resolve, reject) => {
|
|
889
1045
|
if (!hls || !api) {
|
|
@@ -925,6 +1081,12 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
925
1081
|
},
|
|
926
1082
|
onLevelSwitched: () => {
|
|
927
1083
|
},
|
|
1084
|
+
onLevelDetails: (details) => {
|
|
1085
|
+
if (session !== loadSession) return;
|
|
1086
|
+
lastLevelDetails = details;
|
|
1087
|
+
readLiveMetrics();
|
|
1088
|
+
},
|
|
1089
|
+
isLowLatencyRequested: () => mergedConfig.lowLatencyMode === true,
|
|
928
1090
|
onError: (error) => {
|
|
929
1091
|
if (session !== loadSession) return;
|
|
930
1092
|
const terminal = handleHlsError(error);
|
|
@@ -1362,25 +1524,45 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
1362
1524
|
isNativeHLS() {
|
|
1363
1525
|
return isNative;
|
|
1364
1526
|
},
|
|
1527
|
+
/**
|
|
1528
|
+
* Report the live state of the stream.
|
|
1529
|
+
*
|
|
1530
|
+
* `latency` and `targetLatency` come from hls.js on the MSE path, where
|
|
1531
|
+
* they are measured against `EXT-X-PROGRAM-DATE-TIME` drift when the
|
|
1532
|
+
* manifest carries it. On the native path there is no latency API, so both
|
|
1533
|
+
* are approximations: latency is the distance to `seekable.end`, and the
|
|
1534
|
+
* target is whatever an earlier hls.js session on this source measured
|
|
1535
|
+
* (an AirPlay handoff) before it falls back to 3 seconds. Parking a viewer
|
|
1536
|
+
* of a 2-second-target stream 3 seconds back was the previous behaviour,
|
|
1537
|
+
* and it is a full target latency of drift.
|
|
1538
|
+
*
|
|
1539
|
+
* @returns Live info, or null for VOD and before a pipeline exists
|
|
1540
|
+
*/
|
|
1365
1541
|
getLiveInfo() {
|
|
1366
1542
|
const live = api?.getState("live") || false;
|
|
1367
1543
|
if (!live) return null;
|
|
1544
|
+
const metrics = readLiveMetrics();
|
|
1368
1545
|
if (isNative) {
|
|
1546
|
+
const targetLatency2 = metrics?.targetLatency ?? DEFAULT_TARGET_LATENCY;
|
|
1547
|
+
const seekableEnd = video?.seekable?.length ? video.seekable.end(video.seekable.length - 1) : void 0;
|
|
1369
1548
|
return {
|
|
1370
1549
|
isLive: true,
|
|
1371
|
-
latency: 0,
|
|
1372
|
-
targetLatency:
|
|
1550
|
+
latency: metrics?.latency ?? 0,
|
|
1551
|
+
targetLatency: targetLatency2,
|
|
1373
1552
|
drift: 0,
|
|
1374
|
-
liveSyncPosition:
|
|
1553
|
+
liveSyncPosition: seekableEnd !== void 0 ? Math.max(0, seekableEnd - targetLatency2) : void 0,
|
|
1554
|
+
lowLatency: metrics?.lowLatency ?? false
|
|
1375
1555
|
};
|
|
1376
1556
|
}
|
|
1377
1557
|
if (!hls) return null;
|
|
1558
|
+
const targetLatency = hls.targetLatency || metrics?.targetLatency || DEFAULT_TARGET_LATENCY;
|
|
1378
1559
|
return {
|
|
1379
1560
|
isLive: true,
|
|
1380
1561
|
latency: hls.latency || 0,
|
|
1381
|
-
targetLatency
|
|
1562
|
+
targetLatency,
|
|
1382
1563
|
drift: hls.drift || 0,
|
|
1383
|
-
liveSyncPosition: hls.liveSyncPosition ?? (video?.seekable?.length ? Math.max(0, video.seekable.end(video.seekable.length - 1) -
|
|
1564
|
+
liveSyncPosition: hls.liveSyncPosition ?? (video?.seekable?.length ? Math.max(0, video.seekable.end(video.seekable.length - 1) - targetLatency) : void 0),
|
|
1565
|
+
lowLatency: metrics?.lowLatency ?? false
|
|
1384
1566
|
};
|
|
1385
1567
|
},
|
|
1386
1568
|
/**
|
|
@@ -1405,10 +1587,12 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
1405
1587
|
const wasPlaying = api?.getState("playing") || false;
|
|
1406
1588
|
const currentTime = video?.currentTime || 0;
|
|
1407
1589
|
const savedSrc = currentSrc;
|
|
1590
|
+
const savedTargetLatency = lastKnownTargetLatency;
|
|
1408
1591
|
const session = ++loadSession;
|
|
1409
1592
|
cancelReconnect();
|
|
1410
1593
|
cleanup(new Error("HLS load cancelled: switching to native HLS"));
|
|
1411
1594
|
currentSrc = savedSrc;
|
|
1595
|
+
lastKnownTargetLatency = savedTargetLatency;
|
|
1412
1596
|
await loadNative(savedSrc);
|
|
1413
1597
|
if (session !== loadSession) return;
|
|
1414
1598
|
if (video && currentTime > 0) {
|
|
@@ -1444,10 +1628,12 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
1444
1628
|
const wasPlaying = api?.getState("playing") || false;
|
|
1445
1629
|
const currentTime = video?.currentTime || 0;
|
|
1446
1630
|
const savedSrc = currentSrc;
|
|
1631
|
+
const savedTargetLatency = lastKnownTargetLatency;
|
|
1447
1632
|
const session = ++loadSession;
|
|
1448
1633
|
cancelReconnect();
|
|
1449
1634
|
cleanup(new Error("HLS load cancelled: switching to hls.js"));
|
|
1450
1635
|
currentSrc = savedSrc;
|
|
1636
|
+
lastKnownTargetLatency = savedTargetLatency;
|
|
1451
1637
|
await loadWithHlsJs(savedSrc);
|
|
1452
1638
|
if (session !== loadSession) return;
|
|
1453
1639
|
if (video && currentTime > 0) {
|
|
@@ -1469,5 +1655,7 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
1469
1655
|
export {
|
|
1470
1656
|
__export,
|
|
1471
1657
|
sanitizeUrl,
|
|
1658
|
+
DEFAULT_TARGET_LATENCY,
|
|
1659
|
+
computeLiveMetrics,
|
|
1472
1660
|
createHLSPluginWith
|
|
1473
1661
|
};
|