@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/dist/index.cjs CHANGED
@@ -30,6 +30,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ DEFAULT_TARGET_LATENCY: () => DEFAULT_TARGET_LATENCY,
34
+ computeLiveMetrics: () => computeLiveMetrics,
33
35
  createHLSPlugin: () => createHLSPlugin,
34
36
  default: () => index_default,
35
37
  sanitizeUrl: () => import_core.sanitizeUrl
@@ -167,6 +169,107 @@ function getInitialBandwidthEstimate(overrideBps) {
167
169
  // src/sanitize-url.ts
168
170
  var import_core = require("@scarlett-player/core");
169
171
 
172
+ // src/live-metrics.ts
173
+ var DEFAULT_TARGET_LATENCY = 3;
174
+ var MIN_EDGE_TOLERANCE = 1.5;
175
+ var NATIVE_EDGE_TOLERANCE = 7;
176
+ var EPSILON = 0.05;
177
+ function finite(value) {
178
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
179
+ }
180
+ function rangeFromDetails(details) {
181
+ if (!details) return null;
182
+ const start = finite(details.fragmentStart) ?? finite(details.fragments?.[0]?.start) ?? 0;
183
+ const total = finite(details.totalduration);
184
+ const end = finite(details.edge) ?? (total === null ? null : start + total);
185
+ if (end === null) return null;
186
+ return { start, end };
187
+ }
188
+ function rangeFromMedia(media) {
189
+ const seekable = media?.seekable;
190
+ if (!seekable || seekable.length === 0) return null;
191
+ const start = seekable.start(0);
192
+ const end = seekable.end(seekable.length - 1);
193
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
194
+ return { start, end };
195
+ }
196
+ function targetFromDetails(details) {
197
+ if (!details) return null;
198
+ const targetduration = finite(details.targetduration);
199
+ return finite(details.partHoldBack) ?? finite(details.holdBack) ?? (targetduration !== null ? targetduration * 3 : null);
200
+ }
201
+ function edgeTolerance(details, targetLatency) {
202
+ const targetduration = finite(details?.targetduration);
203
+ const half = targetduration !== null ? targetduration / 2 : targetLatency / 2;
204
+ return Math.max(MIN_EDGE_TOLERANCE, finite(details?.partTarget) ?? half);
205
+ }
206
+ function computeLiveMetrics(source) {
207
+ if (source.kind === "hls") {
208
+ const { hls, details } = source;
209
+ const media2 = hls.media;
210
+ const seekableRange2 = rangeFromDetails(details) ?? rangeFromMedia(media2);
211
+ const targetLatency2 = finite(hls.targetLatency) ?? targetFromDetails(details) ?? DEFAULT_TARGET_LATENCY;
212
+ const latency2 = finite(hls.latency) ?? (seekableRange2 && media2 ? Math.max(0, seekableRange2.end - media2.currentTime) : null);
213
+ if (latency2 === null && seekableRange2 === null) return null;
214
+ const resolvedLatency = latency2 ?? 0;
215
+ return {
216
+ latency: resolvedLatency,
217
+ targetLatency: targetLatency2,
218
+ atEdge: resolvedLatency <= targetLatency2 + edgeTolerance(details, targetLatency2),
219
+ seekableRange: seekableRange2,
220
+ // Effective LL, not requested LL: the manifest has to carry parts or
221
+ // advertise blocking reloads, AND the host has to have asked for it.
222
+ lowLatency: source.lowLatencyRequested !== false && (!!details?.partList?.length || details?.canBlockReload === true)
223
+ };
224
+ }
225
+ const { media } = source;
226
+ const seekableRange = rangeFromMedia(media);
227
+ if (!seekableRange) return null;
228
+ const targetLatency = source.targetLatency ?? DEFAULT_TARGET_LATENCY;
229
+ const tolerance = source.targetLatency === void 0 ? NATIVE_EDGE_TOLERANCE : Math.max(MIN_EDGE_TOLERANCE, source.targetLatency / 2);
230
+ const latency = Math.max(0, seekableRange.end - media.currentTime);
231
+ return {
232
+ latency,
233
+ targetLatency,
234
+ atEdge: latency <= targetLatency + tolerance,
235
+ seekableRange,
236
+ lowLatency: source.lowLatency === true
237
+ };
238
+ }
239
+ function applyLiveMetrics(api, metrics) {
240
+ if (!metrics) return;
241
+ const previousLatency = api.getState("liveLatency");
242
+ if (Math.abs(previousLatency - metrics.latency) > EPSILON) {
243
+ api.setState("liveLatency", metrics.latency);
244
+ api.emit("live:latency", { latency: metrics.latency });
245
+ }
246
+ if (api.getState("liveEdge") !== metrics.atEdge) {
247
+ api.setState("liveEdge", metrics.atEdge);
248
+ api.emit("live:edgechange", { atEdge: metrics.atEdge });
249
+ }
250
+ const range = metrics.seekableRange;
251
+ if (range) {
252
+ const previous = api.getState("seekableRange");
253
+ if (!previous || Math.abs(previous.start - range.start) > EPSILON || Math.abs(previous.end - range.end) > EPSILON) {
254
+ api.setState("seekableRange", { start: range.start, end: range.end });
255
+ api.emit("live:seekablerange", { start: range.start, end: range.end });
256
+ }
257
+ }
258
+ if (api.getState("lowLatencyMode") !== metrics.lowLatency) {
259
+ api.setState("lowLatencyMode", metrics.lowLatency);
260
+ api.emit("live:lowlatency", { enabled: metrics.lowLatency });
261
+ }
262
+ }
263
+ function resetLiveMetrics(api) {
264
+ if (api.getState("lowLatencyMode")) {
265
+ api.setState("lowLatencyMode", false);
266
+ api.emit("live:lowlatency", { enabled: false });
267
+ }
268
+ api.setState("liveLatency", 0);
269
+ api.setState("liveEdge", false);
270
+ api.setState("seekableRange", null);
271
+ }
272
+
170
273
  // src/event-map.ts
171
274
  var HLS_ERROR_TYPES = {
172
275
  NETWORK_ERROR: "networkError",
@@ -243,7 +346,7 @@ function setupHlsEventHandlers(hls, api, callbacks) {
243
346
  api.emit("quality:levels", {
244
347
  levels: levels.map((l) => ({ id: l.id, label: l.label }))
245
348
  });
246
- callbacks.onManifestParsed?.(data.levels);
349
+ callbacks.onManifestParsed?.(data.levels, data);
247
350
  });
248
351
  addHandler("hlsLevelSwitched", (_event, data) => {
249
352
  const level = hls.levels[data.level];
@@ -304,16 +407,18 @@ function setupHlsEventHandlers(hls, api, callbacks) {
304
407
  if (data.details?.live !== void 0) {
305
408
  api.setState("live", data.details.live);
306
409
  if (data.details.live) {
307
- const details = data.details;
308
- const start = details.fragmentStart ?? (details.fragments?.[0]?.start ?? 0);
309
- const end = details.edge ?? details.totalduration ?? 0;
310
- api.setState("seekableRange", { start, end });
311
- const video = hls.media;
312
- if (video) {
313
- const latency = Math.max(0, end - video.currentTime);
314
- api.setState("liveLatency", latency);
315
- api.setState("liveEdge", latency < (details.targetduration ?? 3) * 3);
316
- }
410
+ callbacks.onLevelDetails?.(data.details);
411
+ applyLiveMetrics(
412
+ api,
413
+ computeLiveMetrics({
414
+ kind: "hls",
415
+ hls,
416
+ details: data.details,
417
+ lowLatencyRequested: callbacks.isLowLatencyRequested?.() ?? true
418
+ })
419
+ );
420
+ } else {
421
+ resetLiveMetrics(api);
317
422
  }
318
423
  callbacks.onLiveUpdate?.();
319
424
  }
@@ -353,7 +458,7 @@ function setupHlsEventHandlers(hls, api, callbacks) {
353
458
  api.setState("currentAudioTrack", null);
354
459
  };
355
460
  }
356
- function setupVideoEventHandlers(video, api) {
461
+ function setupVideoEventHandlers(video, api, getLiveMetrics) {
357
462
  const handlers = [];
358
463
  const addHandler = (event, handler) => {
359
464
  video.addEventListener(event, handler);
@@ -390,15 +495,11 @@ function setupVideoEventHandlers(video, api) {
390
495
  addHandler("timeupdate", () => {
391
496
  api.setState("currentTime", video.currentTime);
392
497
  api.emit("playback:timeupdate", { currentTime: video.currentTime });
393
- if (video.seekable && video.seekable.length > 0) {
394
- if (api.getState("live") || !Number.isFinite(video.duration)) {
395
- const start = video.seekable.start(0);
396
- const end = video.seekable.end(video.seekable.length - 1);
397
- api.setState("seekableRange", { start, end });
398
- const latency = Math.max(0, end - video.currentTime);
399
- api.setState("liveEdge", latency < 10);
400
- api.setState("liveLatency", latency);
401
- }
498
+ if (api.getState("live") || !Number.isFinite(video.duration)) {
499
+ applyLiveMetrics(
500
+ api,
501
+ getLiveMetrics ? getLiveMetrics() : computeLiveMetrics({ kind: "media", media: video })
502
+ );
402
503
  }
403
504
  });
404
505
  addHandler("durationchange", () => {
@@ -454,12 +555,6 @@ function setupVideoEventHandlers(video, api) {
454
555
  });
455
556
  addHandler("loadedmetadata", () => {
456
557
  api.setState("duration", video.duration);
457
- api.setState("mediaType", video.videoWidth > 0 ? "video" : "audio");
458
- });
459
- addHandler("loadeddata", () => {
460
- if (video.videoWidth > 0) {
461
- api.setState("mediaType", "video");
462
- }
463
558
  });
464
559
  addHandler("error", () => {
465
560
  const error = video.error;
@@ -501,6 +596,119 @@ function setupVideoEventHandlers(video, api) {
501
596
  };
502
597
  }
503
598
 
599
+ // src/media-type.ts
600
+ var ELEMENT_EVENTS = ["loadedmetadata", "loadeddata", "resize", "playing"];
601
+ var TRACK_LIST_EVENTS = ["addtrack", "removetrack", "change"];
602
+ var HAVE_METADATA = 1;
603
+ function isTrackList(list) {
604
+ return typeof list === "object" && list !== null && typeof list.length === "number";
605
+ }
606
+ function createMediaTypeClassifier(api) {
607
+ let source = null;
608
+ let videoConfirmed = false;
609
+ let audioConfirmed = false;
610
+ let elementReported = false;
611
+ let element = null;
612
+ let elementDisposers = [];
613
+ let published = null;
614
+ let destroyed = false;
615
+ const decide = () => {
616
+ if (videoConfirmed) return "video";
617
+ if (audioConfirmed) return "audio";
618
+ return "unknown";
619
+ };
620
+ const publish = () => {
621
+ const next = decide();
622
+ if (next === published) return;
623
+ published = next;
624
+ api.setState("mediaType", next);
625
+ };
626
+ const readElement = () => {
627
+ const video = element;
628
+ if (!video) return;
629
+ if (elementReported && video.videoWidth > 0) {
630
+ videoConfirmed = true;
631
+ return;
632
+ }
633
+ if (video.readyState < HAVE_METADATA) return;
634
+ const videoTracks = video.videoTracks;
635
+ const audioTracks = video.audioTracks;
636
+ if (!isTrackList(videoTracks)) return;
637
+ if (videoTracks.length > 0) {
638
+ videoConfirmed = true;
639
+ return;
640
+ }
641
+ if (isTrackList(audioTracks) && audioTracks.length > 0) {
642
+ audioConfirmed = true;
643
+ }
644
+ };
645
+ const evaluate = () => {
646
+ if (destroyed) return;
647
+ readElement();
648
+ publish();
649
+ };
650
+ const detachElement = () => {
651
+ for (const off of elementDisposers) off();
652
+ elementDisposers = [];
653
+ element = null;
654
+ };
655
+ return {
656
+ beginSource(src) {
657
+ if (destroyed) return;
658
+ if (source === src) return;
659
+ source = src;
660
+ videoConfirmed = false;
661
+ audioConfirmed = false;
662
+ elementReported = false;
663
+ published = null;
664
+ publish();
665
+ },
666
+ attach(video) {
667
+ if (destroyed) return;
668
+ detachElement();
669
+ element = video;
670
+ elementReported = false;
671
+ for (const event of ELEMENT_EVENTS) {
672
+ const handler = () => {
673
+ elementReported = true;
674
+ evaluate();
675
+ };
676
+ video.addEventListener(event, handler);
677
+ elementDisposers.push(() => video.removeEventListener(event, handler));
678
+ }
679
+ for (const list of [element.videoTracks, element.audioTracks]) {
680
+ if (!isTrackList(list) || typeof list.addEventListener !== "function") continue;
681
+ for (const event of TRACK_LIST_EVENTS) {
682
+ const handler = () => evaluate();
683
+ list.addEventListener(event, handler);
684
+ elementDisposers.push(() => list.removeEventListener?.(event, handler));
685
+ }
686
+ }
687
+ evaluate();
688
+ },
689
+ noteManifestParsed(data) {
690
+ if (destroyed || !data) return;
691
+ const hasVideo = typeof data.video === "boolean" ? data.video : null;
692
+ const hasAudio = typeof data.audio === "boolean" ? data.audio : null;
693
+ if (hasVideo === true) {
694
+ videoConfirmed = true;
695
+ } else if (hasVideo === false && hasAudio === true) {
696
+ audioConfirmed = true;
697
+ }
698
+ publish();
699
+ },
700
+ evaluate,
701
+ current() {
702
+ return decide();
703
+ },
704
+ destroy() {
705
+ if (destroyed) return;
706
+ destroyed = true;
707
+ detachElement();
708
+ }
709
+ };
710
+ }
711
+
504
712
  // src/playlist-validation.ts
505
713
  var PLAYLIST_INVALID_TEXT = "Invalid playlist document";
506
714
  var MEDIA_PLAYLIST_CONTEXTS = ["level", "audioTrack", "subtitleTrack"];
@@ -546,7 +754,7 @@ function createValidatingPlaylistLoader(Hls) {
546
754
  }
547
755
 
548
756
  // src/version.ts
549
- var PKG_VERSION = true ? "1.11.0" : "0.0.0-dev";
757
+ var PKG_VERSION = true ? "1.12.0" : "0.0.0-dev";
550
758
 
551
759
  // src/create-hls-plugin.ts
552
760
  var DEFAULT_CONFIG = {
@@ -574,6 +782,7 @@ var DEFAULT_CONFIG = {
574
782
  // Never index a malformed live playlist refresh blindly
575
783
  validatePlaylists: true
576
784
  };
785
+ var LL_CATCH_UP_PLAYBACK_RATE = 1.1;
577
786
  var MANIFEST_PHASE_ERRORS = [
578
787
  "manifestLoadError",
579
788
  "manifestLoadTimeOut",
@@ -589,6 +798,10 @@ function createHLSPluginWith(loader, variant, config) {
589
798
  let cleanupHlsEvents = null;
590
799
  let cleanupVideoEvents = null;
591
800
  let isAutoQuality = true;
801
+ let mediaTypeClassifier = null;
802
+ let lastLevelDetails = null;
803
+ let lastLiveMetrics = null;
804
+ let lastKnownTargetLatency = null;
592
805
  let loadSession = 0;
593
806
  let abortPendingLoad = null;
594
807
  let networkRetryCount = 0;
@@ -611,6 +824,24 @@ function createHLSPluginWith(loader, variant, config) {
611
824
  let stallWatchdogTimer = null;
612
825
  let lastStallCheckTime = 0;
613
826
  let lastStallCheckPosition = 0;
827
+ const readLiveMetrics = () => {
828
+ const metrics = hls && !isNative ? computeLiveMetrics({
829
+ kind: "hls",
830
+ hls,
831
+ details: lastLevelDetails,
832
+ lowLatencyRequested: mergedConfig.lowLatencyMode === true
833
+ }) : video ? computeLiveMetrics({
834
+ kind: "media",
835
+ media: video,
836
+ targetLatency: lastKnownTargetLatency ?? void 0,
837
+ lowLatency: lastLiveMetrics?.lowLatency
838
+ }) : null;
839
+ if (metrics) {
840
+ lastLiveMetrics = metrics;
841
+ if (hls && !isNative) lastKnownTargetLatency = metrics.targetLatency;
842
+ }
843
+ return metrics;
844
+ };
614
845
  const applyPoster = () => {
615
846
  if (!video) return;
616
847
  video.poster = api?.getState("poster") || "";
@@ -660,6 +891,10 @@ function createHLSPluginWith(loader, variant, config) {
660
891
  mediaRetryCount = 0;
661
892
  errorCount = 0;
662
893
  errorWindowStart = 0;
894
+ lastLevelDetails = null;
895
+ lastLiveMetrics = null;
896
+ lastKnownTargetLatency = null;
897
+ if (api) resetLiveMetrics(api);
663
898
  };
664
899
  const buildHlsConfig = () => {
665
900
  const config2 = buildBaseHlsConfig();
@@ -671,27 +906,58 @@ function createHLSPluginWith(loader, variant, config) {
671
906
  }
672
907
  return config2;
673
908
  };
674
- const buildBaseHlsConfig = () => ({
675
- debug: mergedConfig.debug,
676
- autoStartLoad: mergedConfig.autoStartLoad,
677
- startPosition: mergedConfig.startPosition,
678
- startLevel: -1,
679
- // Auto quality selection (ABR)
680
- abrEwmaDefaultEstimate: getInitialBandwidthEstimate(mergedConfig.initialBandwidthEstimate),
681
- lowLatencyMode: mergedConfig.lowLatencyMode,
682
- maxBufferLength: mergedConfig.maxBufferLength,
683
- maxMaxBufferLength: mergedConfig.maxMaxBufferLength,
684
- backBufferLength: mergedConfig.backBufferLength,
685
- enableWorker: mergedConfig.enableWorker,
686
- capLevelToPlayerSize: mergedConfig.capLevelToPlayerSize,
687
- // Minimize hls.js internal retries - we handle retries ourselves
688
- fragLoadingMaxRetry: 1,
689
- manifestLoadingMaxRetry: 1,
690
- levelLoadingMaxRetry: 1,
691
- fragLoadingRetryDelay: 500,
692
- manifestLoadingRetryDelay: 500,
693
- levelLoadingRetryDelay: 500
694
- });
909
+ const buildLiveHlsConfig = () => {
910
+ const live = {};
911
+ const set = (key, value) => {
912
+ if (value !== void 0) live[key] = value;
913
+ };
914
+ const syncDuration = mergedConfig.liveSyncDuration;
915
+ const syncCount = mergedConfig.liveSyncDurationCount;
916
+ const maxDuration = mergedConfig.liveMaxLatencyDuration;
917
+ const maxCount = mergedConfig.liveMaxLatencyDurationCount;
918
+ const mixed = (syncDuration !== void 0 || maxDuration !== void 0) && (syncCount !== void 0 || maxCount !== void 0);
919
+ const dropCount = mixed && syncDuration !== void 0;
920
+ const dropDuration = mixed && !dropCount;
921
+ if (mixed) {
922
+ api?.logger.warn(
923
+ `Ignoring ${dropCount ? "liveSyncDurationCount/liveMaxLatencyDurationCount" : "liveSyncDuration/liveMaxLatencyDuration"}: hls.js rejects a config mixing seconds-based and count-based live latency options`
924
+ );
925
+ }
926
+ set("liveSyncDuration", dropDuration ? void 0 : syncDuration);
927
+ set("liveSyncDurationCount", dropCount ? void 0 : syncCount);
928
+ set("liveMaxLatencyDuration", dropDuration ? void 0 : maxDuration);
929
+ set("liveMaxLatencyDurationCount", dropCount ? void 0 : maxCount);
930
+ set("liveDurationInfinity", mergedConfig.liveDurationInfinity);
931
+ set(
932
+ "maxLiveSyncPlaybackRate",
933
+ mergedConfig.maxLiveSyncPlaybackRate ?? (mergedConfig.lowLatencyMode === true ? LL_CATCH_UP_PLAYBACK_RATE : void 0)
934
+ );
935
+ return live;
936
+ };
937
+ const buildBaseHlsConfig = () => {
938
+ return {
939
+ debug: mergedConfig.debug,
940
+ autoStartLoad: mergedConfig.autoStartLoad,
941
+ startPosition: mergedConfig.startPosition,
942
+ startLevel: -1,
943
+ // Auto quality selection (ABR)
944
+ abrEwmaDefaultEstimate: getInitialBandwidthEstimate(mergedConfig.initialBandwidthEstimate),
945
+ lowLatencyMode: mergedConfig.lowLatencyMode,
946
+ maxBufferLength: mergedConfig.maxBufferLength,
947
+ maxMaxBufferLength: mergedConfig.maxMaxBufferLength,
948
+ backBufferLength: mergedConfig.backBufferLength,
949
+ enableWorker: mergedConfig.enableWorker,
950
+ capLevelToPlayerSize: mergedConfig.capLevelToPlayerSize,
951
+ // Minimize hls.js internal retries - we handle retries ourselves
952
+ fragLoadingMaxRetry: 1,
953
+ manifestLoadingMaxRetry: 1,
954
+ levelLoadingMaxRetry: 1,
955
+ fragLoadingRetryDelay: 500,
956
+ manifestLoadingRetryDelay: 500,
957
+ levelLoadingRetryDelay: 500,
958
+ ...buildLiveHlsConfig()
959
+ };
960
+ };
695
961
  const getRetryDelay = (retryCount) => {
696
962
  const baseDelay = mergedConfig.retryDelayMs ?? 1e3;
697
963
  const backoffFactor = mergedConfig.retryBackoffFactor ?? 2;
@@ -888,8 +1154,10 @@ function createHLSPluginWith(loader, variant, config) {
888
1154
  const videoEl = getOrCreateVideo();
889
1155
  isNative = true;
890
1156
  if (api) {
891
- cleanupVideoEvents = setupVideoEventHandlers(videoEl, api);
1157
+ cleanupVideoEvents = setupVideoEventHandlers(videoEl, api, readLiveMetrics);
892
1158
  }
1159
+ mediaTypeClassifier?.beginSource(src);
1160
+ mediaTypeClassifier?.attach(videoEl);
893
1161
  return new Promise((resolve, reject) => {
894
1162
  let watchdog = null;
895
1163
  let settled = false;
@@ -981,8 +1249,10 @@ function createHLSPluginWith(loader, variant, config) {
981
1249
  isNative = false;
982
1250
  hls = loader.createHlsInstance(buildHlsConfig());
983
1251
  if (api) {
984
- cleanupVideoEvents = setupVideoEventHandlers(videoEl, api);
1252
+ cleanupVideoEvents = setupVideoEventHandlers(videoEl, api, readLiveMetrics);
985
1253
  }
1254
+ mediaTypeClassifier?.beginSource(src);
1255
+ mediaTypeClassifier?.attach(videoEl);
986
1256
  return new Promise((resolve, reject) => {
987
1257
  if (!hls || !api) {
988
1258
  reject(new Error("HLS not initialized"));
@@ -1009,8 +1279,9 @@ function createHLSPluginWith(loader, variant, config) {
1009
1279
  }
1010
1280
  };
1011
1281
  cleanupHlsEvents = setupHlsEventHandlers(hls, api, {
1012
- onManifestParsed: () => {
1282
+ onManifestParsed: (_levels, manifestData) => {
1013
1283
  if (session !== loadSession) return;
1284
+ mediaTypeClassifier?.noteManifestParsed(manifestData);
1014
1285
  if (!resolved) {
1015
1286
  resolved = true;
1016
1287
  releaseAbort();
@@ -1023,6 +1294,12 @@ function createHLSPluginWith(loader, variant, config) {
1023
1294
  },
1024
1295
  onLevelSwitched: () => {
1025
1296
  },
1297
+ onLevelDetails: (details) => {
1298
+ if (session !== loadSession) return;
1299
+ lastLevelDetails = details;
1300
+ readLiveMetrics();
1301
+ },
1302
+ isLowLatencyRequested: () => mergedConfig.lowLatencyMode === true,
1026
1303
  onError: (error) => {
1027
1304
  if (session !== loadSession) return;
1028
1305
  const terminal = handleHlsError(error);
@@ -1263,6 +1540,7 @@ function createHLSPluginWith(loader, variant, config) {
1263
1540
  async init(pluginApi) {
1264
1541
  api = pluginApi;
1265
1542
  api.logger.info(`HLS plugin${variant.logSuffix} initialized`);
1543
+ mediaTypeClassifier = createMediaTypeClassifier(api);
1266
1544
  const unsubPlay = api.on("playback:play", async () => {
1267
1545
  if (!video) return;
1268
1546
  try {
@@ -1398,6 +1676,8 @@ function createHLSPluginWith(loader, variant, config) {
1398
1676
  onlineListener = null;
1399
1677
  }
1400
1678
  cleanup(new Error("HLS load cancelled: player destroyed"));
1679
+ mediaTypeClassifier?.destroy();
1680
+ mediaTypeClassifier = null;
1401
1681
  if (video?.parentNode) {
1402
1682
  video.parentNode.removeChild(video);
1403
1683
  }
@@ -1460,25 +1740,45 @@ function createHLSPluginWith(loader, variant, config) {
1460
1740
  isNativeHLS() {
1461
1741
  return isNative;
1462
1742
  },
1743
+ /**
1744
+ * Report the live state of the stream.
1745
+ *
1746
+ * `latency` and `targetLatency` come from hls.js on the MSE path, where
1747
+ * they are measured against `EXT-X-PROGRAM-DATE-TIME` drift when the
1748
+ * manifest carries it. On the native path there is no latency API, so both
1749
+ * are approximations: latency is the distance to `seekable.end`, and the
1750
+ * target is whatever an earlier hls.js session on this source measured
1751
+ * (an AirPlay handoff) before it falls back to 3 seconds. Parking a viewer
1752
+ * of a 2-second-target stream 3 seconds back was the previous behaviour,
1753
+ * and it is a full target latency of drift.
1754
+ *
1755
+ * @returns Live info, or null for VOD and before a pipeline exists
1756
+ */
1463
1757
  getLiveInfo() {
1464
1758
  const live = api?.getState("live") || false;
1465
1759
  if (!live) return null;
1760
+ const metrics = readLiveMetrics();
1466
1761
  if (isNative) {
1762
+ const targetLatency2 = metrics?.targetLatency ?? DEFAULT_TARGET_LATENCY;
1763
+ const seekableEnd = video?.seekable?.length ? video.seekable.end(video.seekable.length - 1) : void 0;
1467
1764
  return {
1468
1765
  isLive: true,
1469
- latency: 0,
1470
- targetLatency: 3,
1766
+ latency: metrics?.latency ?? 0,
1767
+ targetLatency: targetLatency2,
1471
1768
  drift: 0,
1472
- liveSyncPosition: video?.seekable?.length ? Math.max(0, video.seekable.end(video.seekable.length - 1) - 3) : void 0
1769
+ liveSyncPosition: seekableEnd !== void 0 ? Math.max(0, seekableEnd - targetLatency2) : void 0,
1770
+ lowLatency: metrics?.lowLatency ?? false
1473
1771
  };
1474
1772
  }
1475
1773
  if (!hls) return null;
1774
+ const targetLatency = hls.targetLatency || metrics?.targetLatency || DEFAULT_TARGET_LATENCY;
1476
1775
  return {
1477
1776
  isLive: true,
1478
1777
  latency: hls.latency || 0,
1479
- targetLatency: hls.targetLatency || 3,
1778
+ targetLatency,
1480
1779
  drift: hls.drift || 0,
1481
- liveSyncPosition: hls.liveSyncPosition ?? (video?.seekable?.length ? Math.max(0, video.seekable.end(video.seekable.length - 1) - 3) : void 0)
1780
+ liveSyncPosition: hls.liveSyncPosition ?? (video?.seekable?.length ? Math.max(0, video.seekable.end(video.seekable.length - 1) - targetLatency) : void 0),
1781
+ lowLatency: metrics?.lowLatency ?? false
1482
1782
  };
1483
1783
  },
1484
1784
  /**
@@ -1503,10 +1803,12 @@ function createHLSPluginWith(loader, variant, config) {
1503
1803
  const wasPlaying = api?.getState("playing") || false;
1504
1804
  const currentTime = video?.currentTime || 0;
1505
1805
  const savedSrc = currentSrc;
1806
+ const savedTargetLatency = lastKnownTargetLatency;
1506
1807
  const session = ++loadSession;
1507
1808
  cancelReconnect();
1508
1809
  cleanup(new Error("HLS load cancelled: switching to native HLS"));
1509
1810
  currentSrc = savedSrc;
1811
+ lastKnownTargetLatency = savedTargetLatency;
1510
1812
  await loadNative(savedSrc);
1511
1813
  if (session !== loadSession) return;
1512
1814
  if (video && currentTime > 0) {
@@ -1542,10 +1844,12 @@ function createHLSPluginWith(loader, variant, config) {
1542
1844
  const wasPlaying = api?.getState("playing") || false;
1543
1845
  const currentTime = video?.currentTime || 0;
1544
1846
  const savedSrc = currentSrc;
1847
+ const savedTargetLatency = lastKnownTargetLatency;
1545
1848
  const session = ++loadSession;
1546
1849
  cancelReconnect();
1547
1850
  cleanup(new Error("HLS load cancelled: switching to hls.js"));
1548
1851
  currentSrc = savedSrc;
1852
+ lastKnownTargetLatency = savedTargetLatency;
1549
1853
  await loadWithHlsJs(savedSrc);
1550
1854
  if (session !== loadSession) return;
1551
1855
  if (video && currentTime > 0) {
@@ -1580,6 +1884,8 @@ function createHLSPlugin(config) {
1580
1884
  var index_default = createHLSPlugin;
1581
1885
  // Annotate the CommonJS export names for ESM import in node:
1582
1886
  0 && (module.exports = {
1887
+ DEFAULT_TARGET_LATENCY,
1888
+ computeLiveMetrics,
1583
1889
  createHLSPlugin,
1584
1890
  sanitizeUrl
1585
1891
  });
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { H as HLSPluginConfig, I as IHLSPlugin } from './types-DnvcTuSn.cjs';
2
- export { b as HLSError, c as HLSLiveInfo, a as HLSQualityLevel } from './types-DnvcTuSn.cjs';
1
+ import { H as HLSPluginConfig, I as IHLSPlugin } from './live-metrics-CoYkWhl5.cjs';
2
+ export { D as DEFAULT_TARGET_LATENCY, b as HLSError, c as HLSLiveInfo, a as HLSQualityLevel, f as HlsLevelDetails, L as LiveMetrics, e as LiveMetricsSource, d as computeLiveMetrics } from './live-metrics-CoYkWhl5.cjs';
3
3
  export { sanitizeUrl } from '@scarlett-player/core';
4
4
 
5
5
  /**
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { H as HLSPluginConfig, I as IHLSPlugin } from './types-DnvcTuSn.js';
2
- export { b as HLSError, c as HLSLiveInfo, a as HLSQualityLevel } from './types-DnvcTuSn.js';
1
+ import { H as HLSPluginConfig, I as IHLSPlugin } from './live-metrics-CoYkWhl5.js';
2
+ export { D as DEFAULT_TARGET_LATENCY, b as HLSError, c as HLSLiveInfo, a as HLSQualityLevel, f as HlsLevelDetails, L as LiveMetrics, e as LiveMetricsSource, d as computeLiveMetrics } from './live-metrics-CoYkWhl5.js';
3
3
  export { sanitizeUrl } from '@scarlett-player/core';
4
4
 
5
5
  /**
package/dist/index.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import {
2
+ DEFAULT_TARGET_LATENCY,
2
3
  __export,
4
+ computeLiveMetrics,
3
5
  createHLSPluginWith,
4
6
  sanitizeUrl
5
- } from "./chunk-NVPSLRJY.js";
7
+ } from "./chunk-3KFYKCR2.js";
6
8
 
7
9
  // src/hls-loader.ts
8
10
  var hls_loader_exports = {};
@@ -85,6 +87,8 @@ function createHLSPlugin(config) {
85
87
  }
86
88
  var index_default = createHLSPlugin;
87
89
  export {
90
+ DEFAULT_TARGET_LATENCY,
91
+ computeLiveMetrics,
88
92
  createHLSPlugin,
89
93
  index_default as default,
90
94
  sanitizeUrl