@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 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
- const details = data.details;
210
- const start = details.fragmentStart ?? (details.fragments?.[0]?.start ?? 0);
211
- const end = details.edge ?? details.totalduration ?? 0;
212
- api.setState("seekableRange", { start, end });
213
- const video = hls.media;
214
- if (video) {
215
- const latency = Math.max(0, end - video.currentTime);
216
- api.setState("liveLatency", latency);
217
- api.setState("liveEdge", latency < (details.targetduration ?? 3) * 3);
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 (video.seekable && video.seekable.length > 0) {
296
- if (api.getState("live") || !Number.isFinite(video.duration)) {
297
- const start = video.seekable.start(0);
298
- const end = video.seekable.end(video.seekable.length - 1);
299
- api.setState("seekableRange", { start, end });
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.11.0" : "0.0.0-dev";
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 buildBaseHlsConfig = () => ({
577
- debug: mergedConfig.debug,
578
- autoStartLoad: mergedConfig.autoStartLoad,
579
- startPosition: mergedConfig.startPosition,
580
- startLevel: -1,
581
- // Auto quality selection (ABR)
582
- abrEwmaDefaultEstimate: getInitialBandwidthEstimate(mergedConfig.initialBandwidthEstimate),
583
- lowLatencyMode: mergedConfig.lowLatencyMode,
584
- maxBufferLength: mergedConfig.maxBufferLength,
585
- maxMaxBufferLength: mergedConfig.maxMaxBufferLength,
586
- backBufferLength: mergedConfig.backBufferLength,
587
- enableWorker: mergedConfig.enableWorker,
588
- capLevelToPlayerSize: mergedConfig.capLevelToPlayerSize,
589
- // Minimize hls.js internal retries - we handle retries ourselves
590
- fragLoadingMaxRetry: 1,
591
- manifestLoadingMaxRetry: 1,
592
- levelLoadingMaxRetry: 1,
593
- fragLoadingRetryDelay: 500,
594
- manifestLoadingRetryDelay: 500,
595
- levelLoadingRetryDelay: 500
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: 3,
1666
+ latency: metrics?.latency ?? 0,
1667
+ targetLatency: targetLatency2,
1373
1668
  drift: 0,
1374
- liveSyncPosition: video?.seekable?.length ? Math.max(0, video.seekable.end(video.seekable.length - 1) - 3) : void 0
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: hls.targetLatency || 3,
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) - 3) : void 0)
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
  };