@scarlett-player/hls 1.11.0 → 1.13.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-3KFYKCR2.js} +361 -57
- package/dist/index.cjs +363 -57
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +5 -1
- package/dist/light.cjs +363 -57
- 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
|
|
|
@@ -145,7 +246,7 @@ function setupHlsEventHandlers(hls, api, callbacks) {
|
|
|
145
246
|
api.emit("quality:levels", {
|
|
146
247
|
levels: levels.map((l) => ({ id: l.id, label: l.label }))
|
|
147
248
|
});
|
|
148
|
-
callbacks.onManifestParsed?.(data.levels);
|
|
249
|
+
callbacks.onManifestParsed?.(data.levels, data);
|
|
149
250
|
});
|
|
150
251
|
addHandler("hlsLevelSwitched", (_event, data) => {
|
|
151
252
|
const level = hls.levels[data.level];
|
|
@@ -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", () => {
|
|
@@ -356,12 +455,6 @@ function setupVideoEventHandlers(video, api) {
|
|
|
356
455
|
});
|
|
357
456
|
addHandler("loadedmetadata", () => {
|
|
358
457
|
api.setState("duration", video.duration);
|
|
359
|
-
api.setState("mediaType", video.videoWidth > 0 ? "video" : "audio");
|
|
360
|
-
});
|
|
361
|
-
addHandler("loadeddata", () => {
|
|
362
|
-
if (video.videoWidth > 0) {
|
|
363
|
-
api.setState("mediaType", "video");
|
|
364
|
-
}
|
|
365
458
|
});
|
|
366
459
|
addHandler("error", () => {
|
|
367
460
|
const error = video.error;
|
|
@@ -403,6 +496,119 @@ function setupVideoEventHandlers(video, api) {
|
|
|
403
496
|
};
|
|
404
497
|
}
|
|
405
498
|
|
|
499
|
+
// src/media-type.ts
|
|
500
|
+
var ELEMENT_EVENTS = ["loadedmetadata", "loadeddata", "resize", "playing"];
|
|
501
|
+
var TRACK_LIST_EVENTS = ["addtrack", "removetrack", "change"];
|
|
502
|
+
var HAVE_METADATA = 1;
|
|
503
|
+
function isTrackList(list) {
|
|
504
|
+
return typeof list === "object" && list !== null && typeof list.length === "number";
|
|
505
|
+
}
|
|
506
|
+
function createMediaTypeClassifier(api) {
|
|
507
|
+
let source = null;
|
|
508
|
+
let videoConfirmed = false;
|
|
509
|
+
let audioConfirmed = false;
|
|
510
|
+
let elementReported = false;
|
|
511
|
+
let element = null;
|
|
512
|
+
let elementDisposers = [];
|
|
513
|
+
let published = null;
|
|
514
|
+
let destroyed = false;
|
|
515
|
+
const decide = () => {
|
|
516
|
+
if (videoConfirmed) return "video";
|
|
517
|
+
if (audioConfirmed) return "audio";
|
|
518
|
+
return "unknown";
|
|
519
|
+
};
|
|
520
|
+
const publish = () => {
|
|
521
|
+
const next = decide();
|
|
522
|
+
if (next === published) return;
|
|
523
|
+
published = next;
|
|
524
|
+
api.setState("mediaType", next);
|
|
525
|
+
};
|
|
526
|
+
const readElement = () => {
|
|
527
|
+
const video = element;
|
|
528
|
+
if (!video) return;
|
|
529
|
+
if (elementReported && video.videoWidth > 0) {
|
|
530
|
+
videoConfirmed = true;
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (video.readyState < HAVE_METADATA) return;
|
|
534
|
+
const videoTracks = video.videoTracks;
|
|
535
|
+
const audioTracks = video.audioTracks;
|
|
536
|
+
if (!isTrackList(videoTracks)) return;
|
|
537
|
+
if (videoTracks.length > 0) {
|
|
538
|
+
videoConfirmed = true;
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (isTrackList(audioTracks) && audioTracks.length > 0) {
|
|
542
|
+
audioConfirmed = true;
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
const evaluate = () => {
|
|
546
|
+
if (destroyed) return;
|
|
547
|
+
readElement();
|
|
548
|
+
publish();
|
|
549
|
+
};
|
|
550
|
+
const detachElement = () => {
|
|
551
|
+
for (const off of elementDisposers) off();
|
|
552
|
+
elementDisposers = [];
|
|
553
|
+
element = null;
|
|
554
|
+
};
|
|
555
|
+
return {
|
|
556
|
+
beginSource(src) {
|
|
557
|
+
if (destroyed) return;
|
|
558
|
+
if (source === src) return;
|
|
559
|
+
source = src;
|
|
560
|
+
videoConfirmed = false;
|
|
561
|
+
audioConfirmed = false;
|
|
562
|
+
elementReported = false;
|
|
563
|
+
published = null;
|
|
564
|
+
publish();
|
|
565
|
+
},
|
|
566
|
+
attach(video) {
|
|
567
|
+
if (destroyed) return;
|
|
568
|
+
detachElement();
|
|
569
|
+
element = video;
|
|
570
|
+
elementReported = false;
|
|
571
|
+
for (const event of ELEMENT_EVENTS) {
|
|
572
|
+
const handler = () => {
|
|
573
|
+
elementReported = true;
|
|
574
|
+
evaluate();
|
|
575
|
+
};
|
|
576
|
+
video.addEventListener(event, handler);
|
|
577
|
+
elementDisposers.push(() => video.removeEventListener(event, handler));
|
|
578
|
+
}
|
|
579
|
+
for (const list of [element.videoTracks, element.audioTracks]) {
|
|
580
|
+
if (!isTrackList(list) || typeof list.addEventListener !== "function") continue;
|
|
581
|
+
for (const event of TRACK_LIST_EVENTS) {
|
|
582
|
+
const handler = () => evaluate();
|
|
583
|
+
list.addEventListener(event, handler);
|
|
584
|
+
elementDisposers.push(() => list.removeEventListener?.(event, handler));
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
evaluate();
|
|
588
|
+
},
|
|
589
|
+
noteManifestParsed(data) {
|
|
590
|
+
if (destroyed || !data) return;
|
|
591
|
+
const hasVideo = typeof data.video === "boolean" ? data.video : null;
|
|
592
|
+
const hasAudio = typeof data.audio === "boolean" ? data.audio : null;
|
|
593
|
+
if (hasVideo === true) {
|
|
594
|
+
videoConfirmed = true;
|
|
595
|
+
} else if (hasVideo === false && hasAudio === true) {
|
|
596
|
+
audioConfirmed = true;
|
|
597
|
+
}
|
|
598
|
+
publish();
|
|
599
|
+
},
|
|
600
|
+
evaluate,
|
|
601
|
+
current() {
|
|
602
|
+
return decide();
|
|
603
|
+
},
|
|
604
|
+
destroy() {
|
|
605
|
+
if (destroyed) return;
|
|
606
|
+
destroyed = true;
|
|
607
|
+
detachElement();
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
406
612
|
// src/playlist-validation.ts
|
|
407
613
|
var PLAYLIST_INVALID_TEXT = "Invalid playlist document";
|
|
408
614
|
var MEDIA_PLAYLIST_CONTEXTS = ["level", "audioTrack", "subtitleTrack"];
|
|
@@ -448,7 +654,7 @@ function createValidatingPlaylistLoader(Hls) {
|
|
|
448
654
|
}
|
|
449
655
|
|
|
450
656
|
// src/version.ts
|
|
451
|
-
var PKG_VERSION = true ? "1.
|
|
657
|
+
var PKG_VERSION = true ? "1.12.0" : "0.0.0-dev";
|
|
452
658
|
|
|
453
659
|
// src/create-hls-plugin.ts
|
|
454
660
|
var DEFAULT_CONFIG = {
|
|
@@ -476,6 +682,7 @@ var DEFAULT_CONFIG = {
|
|
|
476
682
|
// Never index a malformed live playlist refresh blindly
|
|
477
683
|
validatePlaylists: true
|
|
478
684
|
};
|
|
685
|
+
var LL_CATCH_UP_PLAYBACK_RATE = 1.1;
|
|
479
686
|
var MANIFEST_PHASE_ERRORS = [
|
|
480
687
|
"manifestLoadError",
|
|
481
688
|
"manifestLoadTimeOut",
|
|
@@ -491,6 +698,10 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
491
698
|
let cleanupHlsEvents = null;
|
|
492
699
|
let cleanupVideoEvents = null;
|
|
493
700
|
let isAutoQuality = true;
|
|
701
|
+
let mediaTypeClassifier = null;
|
|
702
|
+
let lastLevelDetails = null;
|
|
703
|
+
let lastLiveMetrics = null;
|
|
704
|
+
let lastKnownTargetLatency = null;
|
|
494
705
|
let loadSession = 0;
|
|
495
706
|
let abortPendingLoad = null;
|
|
496
707
|
let networkRetryCount = 0;
|
|
@@ -513,6 +724,24 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
513
724
|
let stallWatchdogTimer = null;
|
|
514
725
|
let lastStallCheckTime = 0;
|
|
515
726
|
let lastStallCheckPosition = 0;
|
|
727
|
+
const readLiveMetrics = () => {
|
|
728
|
+
const metrics = hls && !isNative ? computeLiveMetrics({
|
|
729
|
+
kind: "hls",
|
|
730
|
+
hls,
|
|
731
|
+
details: lastLevelDetails,
|
|
732
|
+
lowLatencyRequested: mergedConfig.lowLatencyMode === true
|
|
733
|
+
}) : video ? computeLiveMetrics({
|
|
734
|
+
kind: "media",
|
|
735
|
+
media: video,
|
|
736
|
+
targetLatency: lastKnownTargetLatency ?? void 0,
|
|
737
|
+
lowLatency: lastLiveMetrics?.lowLatency
|
|
738
|
+
}) : null;
|
|
739
|
+
if (metrics) {
|
|
740
|
+
lastLiveMetrics = metrics;
|
|
741
|
+
if (hls && !isNative) lastKnownTargetLatency = metrics.targetLatency;
|
|
742
|
+
}
|
|
743
|
+
return metrics;
|
|
744
|
+
};
|
|
516
745
|
const applyPoster = () => {
|
|
517
746
|
if (!video) return;
|
|
518
747
|
video.poster = api?.getState("poster") || "";
|
|
@@ -562,6 +791,10 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
562
791
|
mediaRetryCount = 0;
|
|
563
792
|
errorCount = 0;
|
|
564
793
|
errorWindowStart = 0;
|
|
794
|
+
lastLevelDetails = null;
|
|
795
|
+
lastLiveMetrics = null;
|
|
796
|
+
lastKnownTargetLatency = null;
|
|
797
|
+
if (api) resetLiveMetrics(api);
|
|
565
798
|
};
|
|
566
799
|
const buildHlsConfig = () => {
|
|
567
800
|
const config2 = buildBaseHlsConfig();
|
|
@@ -573,27 +806,58 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
573
806
|
}
|
|
574
807
|
return config2;
|
|
575
808
|
};
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
809
|
+
const buildLiveHlsConfig = () => {
|
|
810
|
+
const live = {};
|
|
811
|
+
const set = (key, value) => {
|
|
812
|
+
if (value !== void 0) live[key] = value;
|
|
813
|
+
};
|
|
814
|
+
const syncDuration = mergedConfig.liveSyncDuration;
|
|
815
|
+
const syncCount = mergedConfig.liveSyncDurationCount;
|
|
816
|
+
const maxDuration = mergedConfig.liveMaxLatencyDuration;
|
|
817
|
+
const maxCount = mergedConfig.liveMaxLatencyDurationCount;
|
|
818
|
+
const mixed = (syncDuration !== void 0 || maxDuration !== void 0) && (syncCount !== void 0 || maxCount !== void 0);
|
|
819
|
+
const dropCount = mixed && syncDuration !== void 0;
|
|
820
|
+
const dropDuration = mixed && !dropCount;
|
|
821
|
+
if (mixed) {
|
|
822
|
+
api?.logger.warn(
|
|
823
|
+
`Ignoring ${dropCount ? "liveSyncDurationCount/liveMaxLatencyDurationCount" : "liveSyncDuration/liveMaxLatencyDuration"}: hls.js rejects a config mixing seconds-based and count-based live latency options`
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
set("liveSyncDuration", dropDuration ? void 0 : syncDuration);
|
|
827
|
+
set("liveSyncDurationCount", dropCount ? void 0 : syncCount);
|
|
828
|
+
set("liveMaxLatencyDuration", dropDuration ? void 0 : maxDuration);
|
|
829
|
+
set("liveMaxLatencyDurationCount", dropCount ? void 0 : maxCount);
|
|
830
|
+
set("liveDurationInfinity", mergedConfig.liveDurationInfinity);
|
|
831
|
+
set(
|
|
832
|
+
"maxLiveSyncPlaybackRate",
|
|
833
|
+
mergedConfig.maxLiveSyncPlaybackRate ?? (mergedConfig.lowLatencyMode === true ? LL_CATCH_UP_PLAYBACK_RATE : void 0)
|
|
834
|
+
);
|
|
835
|
+
return live;
|
|
836
|
+
};
|
|
837
|
+
const buildBaseHlsConfig = () => {
|
|
838
|
+
return {
|
|
839
|
+
debug: mergedConfig.debug,
|
|
840
|
+
autoStartLoad: mergedConfig.autoStartLoad,
|
|
841
|
+
startPosition: mergedConfig.startPosition,
|
|
842
|
+
startLevel: -1,
|
|
843
|
+
// Auto quality selection (ABR)
|
|
844
|
+
abrEwmaDefaultEstimate: getInitialBandwidthEstimate(mergedConfig.initialBandwidthEstimate),
|
|
845
|
+
lowLatencyMode: mergedConfig.lowLatencyMode,
|
|
846
|
+
maxBufferLength: mergedConfig.maxBufferLength,
|
|
847
|
+
maxMaxBufferLength: mergedConfig.maxMaxBufferLength,
|
|
848
|
+
backBufferLength: mergedConfig.backBufferLength,
|
|
849
|
+
enableWorker: mergedConfig.enableWorker,
|
|
850
|
+
capLevelToPlayerSize: mergedConfig.capLevelToPlayerSize,
|
|
851
|
+
// Minimize hls.js internal retries - we handle retries ourselves
|
|
852
|
+
fragLoadingMaxRetry: 1,
|
|
853
|
+
manifestLoadingMaxRetry: 1,
|
|
854
|
+
levelLoadingMaxRetry: 1,
|
|
855
|
+
fragLoadingRetryDelay: 500,
|
|
856
|
+
manifestLoadingRetryDelay: 500,
|
|
857
|
+
levelLoadingRetryDelay: 500,
|
|
858
|
+
...buildLiveHlsConfig()
|
|
859
|
+
};
|
|
860
|
+
};
|
|
597
861
|
const getRetryDelay = (retryCount) => {
|
|
598
862
|
const baseDelay = mergedConfig.retryDelayMs ?? 1e3;
|
|
599
863
|
const backoffFactor = mergedConfig.retryBackoffFactor ?? 2;
|
|
@@ -790,8 +1054,10 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
790
1054
|
const videoEl = getOrCreateVideo();
|
|
791
1055
|
isNative = true;
|
|
792
1056
|
if (api) {
|
|
793
|
-
cleanupVideoEvents = setupVideoEventHandlers(videoEl, api);
|
|
1057
|
+
cleanupVideoEvents = setupVideoEventHandlers(videoEl, api, readLiveMetrics);
|
|
794
1058
|
}
|
|
1059
|
+
mediaTypeClassifier?.beginSource(src);
|
|
1060
|
+
mediaTypeClassifier?.attach(videoEl);
|
|
795
1061
|
return new Promise((resolve, reject) => {
|
|
796
1062
|
let watchdog = null;
|
|
797
1063
|
let settled = false;
|
|
@@ -883,8 +1149,10 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
883
1149
|
isNative = false;
|
|
884
1150
|
hls = loader.createHlsInstance(buildHlsConfig());
|
|
885
1151
|
if (api) {
|
|
886
|
-
cleanupVideoEvents = setupVideoEventHandlers(videoEl, api);
|
|
1152
|
+
cleanupVideoEvents = setupVideoEventHandlers(videoEl, api, readLiveMetrics);
|
|
887
1153
|
}
|
|
1154
|
+
mediaTypeClassifier?.beginSource(src);
|
|
1155
|
+
mediaTypeClassifier?.attach(videoEl);
|
|
888
1156
|
return new Promise((resolve, reject) => {
|
|
889
1157
|
if (!hls || !api) {
|
|
890
1158
|
reject(new Error("HLS not initialized"));
|
|
@@ -911,8 +1179,9 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
911
1179
|
}
|
|
912
1180
|
};
|
|
913
1181
|
cleanupHlsEvents = setupHlsEventHandlers(hls, api, {
|
|
914
|
-
onManifestParsed: () => {
|
|
1182
|
+
onManifestParsed: (_levels, manifestData) => {
|
|
915
1183
|
if (session !== loadSession) return;
|
|
1184
|
+
mediaTypeClassifier?.noteManifestParsed(manifestData);
|
|
916
1185
|
if (!resolved) {
|
|
917
1186
|
resolved = true;
|
|
918
1187
|
releaseAbort();
|
|
@@ -925,6 +1194,12 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
925
1194
|
},
|
|
926
1195
|
onLevelSwitched: () => {
|
|
927
1196
|
},
|
|
1197
|
+
onLevelDetails: (details) => {
|
|
1198
|
+
if (session !== loadSession) return;
|
|
1199
|
+
lastLevelDetails = details;
|
|
1200
|
+
readLiveMetrics();
|
|
1201
|
+
},
|
|
1202
|
+
isLowLatencyRequested: () => mergedConfig.lowLatencyMode === true,
|
|
928
1203
|
onError: (error) => {
|
|
929
1204
|
if (session !== loadSession) return;
|
|
930
1205
|
const terminal = handleHlsError(error);
|
|
@@ -1165,6 +1440,7 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
1165
1440
|
async init(pluginApi) {
|
|
1166
1441
|
api = pluginApi;
|
|
1167
1442
|
api.logger.info(`HLS plugin${variant.logSuffix} initialized`);
|
|
1443
|
+
mediaTypeClassifier = createMediaTypeClassifier(api);
|
|
1168
1444
|
const unsubPlay = api.on("playback:play", async () => {
|
|
1169
1445
|
if (!video) return;
|
|
1170
1446
|
try {
|
|
@@ -1300,6 +1576,8 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
1300
1576
|
onlineListener = null;
|
|
1301
1577
|
}
|
|
1302
1578
|
cleanup(new Error("HLS load cancelled: player destroyed"));
|
|
1579
|
+
mediaTypeClassifier?.destroy();
|
|
1580
|
+
mediaTypeClassifier = null;
|
|
1303
1581
|
if (video?.parentNode) {
|
|
1304
1582
|
video.parentNode.removeChild(video);
|
|
1305
1583
|
}
|
|
@@ -1362,25 +1640,45 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
1362
1640
|
isNativeHLS() {
|
|
1363
1641
|
return isNative;
|
|
1364
1642
|
},
|
|
1643
|
+
/**
|
|
1644
|
+
* Report the live state of the stream.
|
|
1645
|
+
*
|
|
1646
|
+
* `latency` and `targetLatency` come from hls.js on the MSE path, where
|
|
1647
|
+
* they are measured against `EXT-X-PROGRAM-DATE-TIME` drift when the
|
|
1648
|
+
* manifest carries it. On the native path there is no latency API, so both
|
|
1649
|
+
* are approximations: latency is the distance to `seekable.end`, and the
|
|
1650
|
+
* target is whatever an earlier hls.js session on this source measured
|
|
1651
|
+
* (an AirPlay handoff) before it falls back to 3 seconds. Parking a viewer
|
|
1652
|
+
* of a 2-second-target stream 3 seconds back was the previous behaviour,
|
|
1653
|
+
* and it is a full target latency of drift.
|
|
1654
|
+
*
|
|
1655
|
+
* @returns Live info, or null for VOD and before a pipeline exists
|
|
1656
|
+
*/
|
|
1365
1657
|
getLiveInfo() {
|
|
1366
1658
|
const live = api?.getState("live") || false;
|
|
1367
1659
|
if (!live) return null;
|
|
1660
|
+
const metrics = readLiveMetrics();
|
|
1368
1661
|
if (isNative) {
|
|
1662
|
+
const targetLatency2 = metrics?.targetLatency ?? DEFAULT_TARGET_LATENCY;
|
|
1663
|
+
const seekableEnd = video?.seekable?.length ? video.seekable.end(video.seekable.length - 1) : void 0;
|
|
1369
1664
|
return {
|
|
1370
1665
|
isLive: true,
|
|
1371
|
-
latency: 0,
|
|
1372
|
-
targetLatency:
|
|
1666
|
+
latency: metrics?.latency ?? 0,
|
|
1667
|
+
targetLatency: targetLatency2,
|
|
1373
1668
|
drift: 0,
|
|
1374
|
-
liveSyncPosition:
|
|
1669
|
+
liveSyncPosition: seekableEnd !== void 0 ? Math.max(0, seekableEnd - targetLatency2) : void 0,
|
|
1670
|
+
lowLatency: metrics?.lowLatency ?? false
|
|
1375
1671
|
};
|
|
1376
1672
|
}
|
|
1377
1673
|
if (!hls) return null;
|
|
1674
|
+
const targetLatency = hls.targetLatency || metrics?.targetLatency || DEFAULT_TARGET_LATENCY;
|
|
1378
1675
|
return {
|
|
1379
1676
|
isLive: true,
|
|
1380
1677
|
latency: hls.latency || 0,
|
|
1381
|
-
targetLatency
|
|
1678
|
+
targetLatency,
|
|
1382
1679
|
drift: hls.drift || 0,
|
|
1383
|
-
liveSyncPosition: hls.liveSyncPosition ?? (video?.seekable?.length ? Math.max(0, video.seekable.end(video.seekable.length - 1) -
|
|
1680
|
+
liveSyncPosition: hls.liveSyncPosition ?? (video?.seekable?.length ? Math.max(0, video.seekable.end(video.seekable.length - 1) - targetLatency) : void 0),
|
|
1681
|
+
lowLatency: metrics?.lowLatency ?? false
|
|
1384
1682
|
};
|
|
1385
1683
|
},
|
|
1386
1684
|
/**
|
|
@@ -1405,10 +1703,12 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
1405
1703
|
const wasPlaying = api?.getState("playing") || false;
|
|
1406
1704
|
const currentTime = video?.currentTime || 0;
|
|
1407
1705
|
const savedSrc = currentSrc;
|
|
1706
|
+
const savedTargetLatency = lastKnownTargetLatency;
|
|
1408
1707
|
const session = ++loadSession;
|
|
1409
1708
|
cancelReconnect();
|
|
1410
1709
|
cleanup(new Error("HLS load cancelled: switching to native HLS"));
|
|
1411
1710
|
currentSrc = savedSrc;
|
|
1711
|
+
lastKnownTargetLatency = savedTargetLatency;
|
|
1412
1712
|
await loadNative(savedSrc);
|
|
1413
1713
|
if (session !== loadSession) return;
|
|
1414
1714
|
if (video && currentTime > 0) {
|
|
@@ -1444,10 +1744,12 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
1444
1744
|
const wasPlaying = api?.getState("playing") || false;
|
|
1445
1745
|
const currentTime = video?.currentTime || 0;
|
|
1446
1746
|
const savedSrc = currentSrc;
|
|
1747
|
+
const savedTargetLatency = lastKnownTargetLatency;
|
|
1447
1748
|
const session = ++loadSession;
|
|
1448
1749
|
cancelReconnect();
|
|
1449
1750
|
cleanup(new Error("HLS load cancelled: switching to hls.js"));
|
|
1450
1751
|
currentSrc = savedSrc;
|
|
1752
|
+
lastKnownTargetLatency = savedTargetLatency;
|
|
1451
1753
|
await loadWithHlsJs(savedSrc);
|
|
1452
1754
|
if (session !== loadSession) return;
|
|
1453
1755
|
if (video && currentTime > 0) {
|
|
@@ -1469,5 +1771,7 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
1469
1771
|
export {
|
|
1470
1772
|
__export,
|
|
1471
1773
|
sanitizeUrl,
|
|
1774
|
+
DEFAULT_TARGET_LATENCY,
|
|
1775
|
+
computeLiveMetrics,
|
|
1472
1776
|
createHLSPluginWith
|
|
1473
1777
|
};
|