@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 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
- 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", () => {
@@ -448,7 +547,7 @@ function createValidatingPlaylistLoader(Hls) {
448
547
  }
449
548
 
450
549
  // src/version.ts
451
- var PKG_VERSION = true ? "1.11.0" : "0.0.0-dev";
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 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
- });
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: 3,
1550
+ latency: metrics?.latency ?? 0,
1551
+ targetLatency: targetLatency2,
1373
1552
  drift: 0,
1374
- liveSyncPosition: video?.seekable?.length ? Math.max(0, video.seekable.end(video.seekable.length - 1) - 3) : void 0
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: hls.targetLatency || 3,
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) - 3) : void 0)
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
  };