@scarlett-player/hls 1.8.1 → 1.10.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/light.cjs CHANGED
@@ -32,7 +32,7 @@ var light_exports = {};
32
32
  __export(light_exports, {
33
33
  createHLSPlugin: () => createHLSPlugin,
34
34
  default: () => light_default,
35
- sanitizeUrl: () => sanitizeUrl
35
+ sanitizeUrl: () => import_core.sanitizeUrl
36
36
  });
37
37
  module.exports = __toCommonJS(light_exports);
38
38
 
@@ -45,7 +45,6 @@ __export(hls_loader_light_exports, {
45
45
  isHlsJsSupported: () => isHlsJsSupported,
46
46
  loadHlsJs: () => loadHlsJs,
47
47
  resetLoader: () => resetLoader,
48
- shouldPreferNativeHLS: () => shouldPreferNativeHLS,
49
48
  supportsNativeHLS: () => supportsNativeHLS
50
49
  });
51
50
  var hlsConstructor = null;
@@ -55,13 +54,6 @@ function supportsNativeHLS() {
55
54
  const video = document.createElement("video");
56
55
  return video.canPlayType("application/vnd.apple.mpegurl") !== "";
57
56
  }
58
- function shouldPreferNativeHLS() {
59
- if (!supportsNativeHLS()) return false;
60
- if (typeof navigator === "undefined") return false;
61
- const ua = navigator.userAgent;
62
- const isSafari = /Safari/.test(ua) && !/Chrome/.test(ua) && !/CriOS/.test(ua);
63
- return isSafari;
64
- }
65
57
  function isHlsJsSupported() {
66
58
  if (hlsConstructor) {
67
59
  return hlsConstructor.isSupported();
@@ -111,7 +103,7 @@ function resetLoader() {
111
103
  }
112
104
 
113
105
  // src/create-hls-plugin.ts
114
- var import_core = require("@scarlett-player/core");
106
+ var import_core2 = require("@scarlett-player/core");
115
107
 
116
108
  // src/quality.ts
117
109
  function formatLevel(level) {
@@ -172,6 +164,9 @@ function getInitialBandwidthEstimate(overrideBps) {
172
164
  return HLS_DEFAULT_ESTIMATE;
173
165
  }
174
166
 
167
+ // src/sanitize-url.ts
168
+ var import_core = require("@scarlett-player/core");
169
+
175
170
  // src/event-map.ts
176
171
  var HLS_ERROR_TYPES = {
177
172
  NETWORK_ERROR: "networkError",
@@ -193,15 +188,41 @@ function mapErrorType(hlsType) {
193
188
  }
194
189
  }
195
190
  function parseHlsError(data) {
191
+ const frag = data.frag;
192
+ const response = data.response;
193
+ const context = data.context;
196
194
  return {
197
195
  type: mapErrorType(data.type),
198
196
  details: data.details || "Unknown error",
199
197
  fatal: data.fatal || false,
200
- url: data.url,
198
+ // hls.js puts the request URL in a different place per error type:
199
+ // playlist errors set data.url, while fragment and key load errors set
200
+ // data.url not at all and carry it on data.frag.url and data.response.url.
201
+ // Reading only data.url dropped the failing segment from every diagnostic
202
+ // an origin outage produces - which is the case the diagnostics exist for.
203
+ url: data.url ?? frag?.url ?? response?.url ?? context?.url,
201
204
  reason: data.reason,
202
205
  response: data.response
203
206
  };
204
207
  }
208
+ function audioTrackId(index) {
209
+ return `audio-${index}`;
210
+ }
211
+ function audioTrackIndex(id) {
212
+ if (!id) return -1;
213
+ const match = /^audio-(0|[1-9]\d*)$/.exec(id);
214
+ return match ? Number.parseInt(match[1], 10) : -1;
215
+ }
216
+ function formatAudioTrack(track, index, active) {
217
+ return {
218
+ id: audioTrackId(index),
219
+ // A manifest may declare neither NAME nor LANGUAGE; a numbered fallback
220
+ // still gives the viewer something selectable rather than a blank row.
221
+ label: track.name || track.lang || `Audio ${index + 1}`,
222
+ language: track.lang,
223
+ active
224
+ };
225
+ }
205
226
  function setupHlsEventHandlers(hls, api, callbacks) {
206
227
  const handlers = [];
207
228
  const addHandler = (event, handler) => {
@@ -245,6 +266,24 @@ function setupHlsEventHandlers(hls, api, callbacks) {
245
266
  });
246
267
  callbacks.onLevelSwitched?.(data.level);
247
268
  });
269
+ const publishAudioTracks = (tracks, activeIndex) => {
270
+ const audioTracks = tracks.map(
271
+ (track, index) => formatAudioTrack(track, index, index === activeIndex)
272
+ );
273
+ api.setState("audioTracks", audioTracks);
274
+ api.setState("currentAudioTrack", audioTracks[activeIndex] ?? null);
275
+ };
276
+ addHandler("hlsAudioTracksUpdated", (_event, data) => {
277
+ const tracks = data.audioTracks ?? [];
278
+ api.logger.debug("HLS audio tracks updated", { tracks: tracks.length });
279
+ publishAudioTracks(tracks, hls.audioTrack);
280
+ callbacks.onAudioTracksUpdated?.(tracks);
281
+ });
282
+ addHandler("hlsAudioTrackSwitched", (_event, data) => {
283
+ api.logger.debug("HLS audio track switched", { id: data.id });
284
+ publishAudioTracks(hls.audioTracks ?? [], data.id);
285
+ callbacks.onAudioTrackSwitched?.(data.id);
286
+ });
248
287
  let lastBandwidthUpdate = 0;
249
288
  addHandler("hlsFragLoaded", () => {
250
289
  const now = Date.now();
@@ -265,16 +304,15 @@ function setupHlsEventHandlers(hls, api, callbacks) {
265
304
  if (data.details?.live !== void 0) {
266
305
  api.setState("live", data.details.live);
267
306
  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 });
268
311
  const video = hls.media;
269
- if (video && video.seekable && video.seekable.length > 0) {
270
- const start = video.seekable.start(0);
271
- const end = video.seekable.end(video.seekable.length - 1);
272
- api.setState("seekableRange", { start, end });
273
- const threshold = (data.details.targetduration ?? 3) * 3;
274
- const isAtLiveEdge = end - video.currentTime < threshold;
275
- api.setState("liveEdge", isAtLiveEdge);
276
- const latency = end - video.currentTime;
277
- api.setState("liveLatency", Math.max(0, latency));
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);
278
316
  }
279
317
  }
280
318
  callbacks.onLiveUpdate?.();
@@ -292,14 +330,16 @@ function setupHlsEventHandlers(hls, api, callbacks) {
292
330
  api.logger.error(`HLS fatal error: ${error.details} (type=${error.type})`, {
293
331
  type: error.type,
294
332
  details: error.details,
295
- url: error.url
333
+ status: error.response?.code,
334
+ url: (0, import_core.sanitizeUrl)(error.url)
296
335
  });
297
336
  } else {
298
337
  api.logger.warn(`HLS error: ${error.details} (type=${error.type}, fatal=${error.fatal})`, {
299
338
  type: error.type,
300
339
  details: error.details,
301
340
  fatal: error.fatal,
302
- url: error.url
341
+ status: error.response?.code,
342
+ url: (0, import_core.sanitizeUrl)(error.url)
303
343
  });
304
344
  }
305
345
  callbacks.onError?.(error);
@@ -309,6 +349,8 @@ function setupHlsEventHandlers(hls, api, callbacks) {
309
349
  hls.off(event, handler);
310
350
  }
311
351
  handlers.length = 0;
352
+ api.setState("audioTracks", []);
353
+ api.setState("currentAudioTrack", null);
312
354
  };
313
355
  }
314
356
  function setupVideoEventHandlers(video, api) {
@@ -348,19 +390,27 @@ function setupVideoEventHandlers(video, api) {
348
390
  addHandler("timeupdate", () => {
349
391
  api.setState("currentTime", video.currentTime);
350
392
  api.emit("playback:timeupdate", { currentTime: video.currentTime });
351
- const isLive = api.getState("live");
352
- if (isLive && video.seekable && video.seekable.length > 0) {
353
- const start = video.seekable.start(0);
354
- const end = video.seekable.end(video.seekable.length - 1);
355
- api.setState("seekableRange", { start, end });
356
- const isAtLiveEdge = end - video.currentTime < 10;
357
- api.setState("liveEdge", isAtLiveEdge);
358
- api.setState("liveLatency", Math.max(0, end - 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
+ }
359
402
  }
360
403
  });
361
404
  addHandler("durationchange", () => {
362
- api.setState("duration", video.duration || 0);
363
- api.emit("media:loadedmetadata", { duration: video.duration || 0 });
405
+ const rawDuration = video.duration;
406
+ const isLive = !Number.isFinite(rawDuration) || rawDuration === Infinity;
407
+ if (isLive) {
408
+ api.setState("live", true);
409
+ api.setState("duration", 0);
410
+ } else {
411
+ api.setState("duration", rawDuration || 0);
412
+ }
413
+ api.emit("media:loadedmetadata", { duration: api.getState("duration") });
364
414
  });
365
415
  addHandler("waiting", () => {
366
416
  api.setState("waiting", true);
@@ -495,19 +545,8 @@ function createValidatingPlaylistLoader(Hls) {
495
545
  };
496
546
  }
497
547
 
498
- // src/sanitize-url.ts
499
- function sanitizeUrl(url) {
500
- if (!url) return void 0;
501
- try {
502
- const parsed = new URL(url);
503
- return `${parsed.origin}${parsed.pathname}`;
504
- } catch {
505
- return void 0;
506
- }
507
- }
508
-
509
548
  // src/version.ts
510
- var PKG_VERSION = true ? "1.8.1" : "0.0.0-dev";
549
+ var PKG_VERSION = true ? "1.10.0" : "0.0.0-dev";
511
550
 
512
551
  // src/create-hls-plugin.ts
513
552
  var DEFAULT_CONFIG = {
@@ -567,6 +606,11 @@ function createHLSPluginWith(loader, variant, config) {
567
606
  let onlineListener = null;
568
607
  let reconnectTriggerError = null;
569
608
  let reconnectExhausted = false;
609
+ let isReconnecting = false;
610
+ let isLiveClassified = false;
611
+ let stallWatchdogTimer = null;
612
+ let lastStallCheckTime = 0;
613
+ let lastStallCheckPosition = 0;
570
614
  const applyPoster = () => {
571
615
  if (!video) return;
572
616
  video.poster = api?.getState("poster") || "";
@@ -598,6 +642,10 @@ function createHLSPluginWith(loader, variant, config) {
598
642
  clearTimeout(retryTimeout);
599
643
  retryTimeout = null;
600
644
  }
645
+ if (stallWatchdogTimer) {
646
+ clearTimeout(stallWatchdogTimer);
647
+ stallWatchdogTimer = null;
648
+ }
601
649
  if (hls) {
602
650
  hls.destroy();
603
651
  hls = null;
@@ -658,22 +706,22 @@ function createHLSPluginWith(loader, variant, config) {
658
706
  ];
659
707
  const mapFatalErrorCode = (error) => {
660
708
  if (error.response?.text === PLAYLIST_INVALID_TEXT) {
661
- return import_core.ErrorCode.PLAYLIST_INVALID;
709
+ return import_core2.ErrorCode.PLAYLIST_INVALID;
662
710
  }
663
711
  if (error.details === "bufferFullError") {
664
- return import_core.ErrorCode.MEDIA_BUFFER_FULL;
712
+ return import_core2.ErrorCode.MEDIA_BUFFER_FULL;
665
713
  }
666
714
  if (APPEND_ERROR_DETAILS.includes(error.details)) {
667
- return import_core.ErrorCode.MEDIA_APPEND_ERROR;
715
+ return import_core2.ErrorCode.MEDIA_APPEND_ERROR;
668
716
  }
669
717
  switch (error.type) {
670
718
  case "network":
671
- return import_core.ErrorCode.MEDIA_NETWORK_ERROR;
719
+ return import_core2.ErrorCode.MEDIA_NETWORK_ERROR;
672
720
  case "media":
673
721
  case "mux":
674
- return import_core.ErrorCode.MEDIA_DECODE_ERROR;
722
+ return import_core2.ErrorCode.MEDIA_DECODE_ERROR;
675
723
  default:
676
- return import_core.ErrorCode.PLAYBACK_FAILED;
724
+ return import_core2.ErrorCode.PLAYBACK_FAILED;
677
725
  }
678
726
  };
679
727
  const buildErrorDetail = (error, retriesExhausted) => {
@@ -686,7 +734,7 @@ function createHLSPluginWith(loader, variant, config) {
686
734
  if (typeof error.response?.code === "number" && error.response.code > 0) {
687
735
  detail.httpStatus = error.response.code;
688
736
  }
689
- const url = sanitizeUrl(error.url);
737
+ const url = (0, import_core.sanitizeUrl)(error.url);
690
738
  if (url) {
691
739
  detail.url = url;
692
740
  }
@@ -709,20 +757,20 @@ function createHLSPluginWith(loader, variant, config) {
709
757
  const handleHlsError = (error) => {
710
758
  const Hls = loader.getHlsConstructor();
711
759
  if (!Hls || !hls) return false;
712
- const now = Date.now();
713
- if (now - errorWindowStart > ERROR_WINDOW_MS) {
714
- errorCount = 1;
715
- errorWindowStart = now;
716
- } else {
717
- errorCount++;
718
- }
719
- if (errorCount >= MAX_ERRORS_IN_WINDOW) {
720
- api?.logger.error(`Too many errors (${errorCount} in ${ERROR_WINDOW_MS}ms), giving up`);
721
- emitFatalError(error, true);
722
- teardownPipeline(new Error(error.details));
723
- return true;
724
- }
725
760
  if (error.fatal) {
761
+ const now = Date.now();
762
+ if (now - errorWindowStart > ERROR_WINDOW_MS) {
763
+ errorCount = 1;
764
+ errorWindowStart = now;
765
+ } else {
766
+ errorCount++;
767
+ }
768
+ if (errorCount >= MAX_ERRORS_IN_WINDOW) {
769
+ api?.logger.error(`Too many fatal errors (${errorCount} in ${ERROR_WINDOW_MS}ms), giving up`);
770
+ emitFatalError(error, true);
771
+ teardownPipeline(new Error(error.details));
772
+ return true;
773
+ }
726
774
  api?.logger.error("Fatal HLS error", { type: error.type, details: error.details });
727
775
  switch (error.type) {
728
776
  case "network": {
@@ -1001,9 +1049,15 @@ function createHLSPluginWith(loader, variant, config) {
1001
1049
  if (resolved || session !== loadSession) return;
1002
1050
  resolved = true;
1003
1051
  releaseAbort();
1004
- api?.logger.error(`HLS load timed out after ${timeout_ms}ms`, { src });
1052
+ api?.logger.error(`HLS load timed out after ${timeout_ms}ms`, { src: (0, import_core.sanitizeUrl)(src) });
1053
+ const timeoutError = {
1054
+ type: "network",
1055
+ details: "Video took too long to load (network timeout)",
1056
+ fatal: true
1057
+ };
1058
+ emitFatalError(timeoutError, true);
1005
1059
  teardownPipeline();
1006
- reject(new Error("Video took too long to load (network timeout)"));
1060
+ reject(new Error(timeoutError.details));
1007
1061
  }, timeout_ms);
1008
1062
  }
1009
1063
  hls.attachMedia(videoEl);
@@ -1020,6 +1074,51 @@ function createHLSPluginWith(loader, variant, config) {
1020
1074
  reconnectResumePosition = 0;
1021
1075
  reconnectTriggerError = null;
1022
1076
  reconnectExhausted = false;
1077
+ isReconnecting = false;
1078
+ };
1079
+ const startStallWatchdog = () => {
1080
+ if (!video || stallWatchdogTimer) return;
1081
+ lastStallCheckTime = Date.now();
1082
+ lastStallCheckPosition = video.currentTime;
1083
+ const levelTargetDuration = hls?.targetDuration ?? hls?.levels?.[hls?.currentLevel]?.details?.targetduration ?? 0;
1084
+ const targetDuration = Math.max(15, 4 * (levelTargetDuration || 0) || 15);
1085
+ const checkInterval = Math.min(targetDuration, 3e4);
1086
+ const check = () => {
1087
+ if (!video || !api) return;
1088
+ const isPlaying = api.getState("playing");
1089
+ const isSeeking = api.getState("seeking");
1090
+ if (!isPlaying || isSeeking) {
1091
+ stallWatchdogTimer = setTimeout(check, checkInterval);
1092
+ return;
1093
+ }
1094
+ const elapsed = Date.now() - lastStallCheckTime;
1095
+ const positionDelta = video.currentTime - lastStallCheckPosition;
1096
+ if (positionDelta > 0.5) {
1097
+ lastStallCheckTime = Date.now();
1098
+ lastStallCheckPosition = video.currentTime;
1099
+ } else if (elapsed > targetDuration * 1e3) {
1100
+ api.logger.warn("Playback stall detected \u2014 no timeupdate progress", {
1101
+ elapsed: Math.round(elapsed),
1102
+ currentTime: video.currentTime,
1103
+ targetDuration: Math.round(targetDuration)
1104
+ });
1105
+ const stallError = {
1106
+ type: "network",
1107
+ details: "Playback stalled \u2014 no data received",
1108
+ fatal: true
1109
+ };
1110
+ maybeScheduleReconnect(stallError);
1111
+ return;
1112
+ }
1113
+ stallWatchdogTimer = setTimeout(check, checkInterval);
1114
+ };
1115
+ stallWatchdogTimer = setTimeout(check, checkInterval);
1116
+ };
1117
+ const stopStallWatchdog = () => {
1118
+ if (stallWatchdogTimer) {
1119
+ clearTimeout(stallWatchdogTimer);
1120
+ stallWatchdogTimer = null;
1121
+ }
1023
1122
  };
1024
1123
  const emitReconnectExhausted = (elapsedMs, windowMs) => {
1025
1124
  if (reconnectExhausted) return;
@@ -1030,7 +1129,7 @@ function createHLSPluginWith(loader, variant, config) {
1030
1129
  api?.setState("playbackState", "error");
1031
1130
  api?.setState("buffering", false);
1032
1131
  api?.emit("error", {
1033
- code: trigger ? mapFatalErrorCode(trigger) : import_core.ErrorCode.PLAYBACK_FAILED,
1132
+ code: trigger ? mapFatalErrorCode(trigger) : import_core2.ErrorCode.PLAYBACK_FAILED,
1034
1133
  message: `HLS auto-reconnect gave up after ${attempts} attempts over ${Math.round(elapsedMs / 1e3)}s`,
1035
1134
  fatal: true,
1036
1135
  timestamp: Date.now(),
@@ -1045,17 +1144,30 @@ function createHLSPluginWith(loader, variant, config) {
1045
1144
  const scheduleReconnectAttempt = () => {
1046
1145
  if (reconnectExhausted) return;
1047
1146
  if (reconnectTimer) return;
1048
- const window_ms = mergedConfig.reconnectWindowMs ?? 3e5;
1147
+ const isLive = (api?.getState("live") ?? false) && isLiveClassified;
1148
+ const configWindowMs = mergedConfig.reconnectWindowMs ?? 3e5;
1149
+ const window_ms = isLive ? Infinity : configWindowMs;
1049
1150
  const elapsed_ms = Date.now() - reconnectWindowStart;
1050
1151
  if (elapsed_ms > window_ms) {
1051
1152
  api?.logger.warn(`Auto-reconnect window exhausted after ${reconnectAttempts} attempts`);
1052
1153
  emitReconnectExhausted(elapsed_ms, window_ms);
1053
1154
  return;
1054
1155
  }
1156
+ const LONG_OUTAGE_MS = 6e5;
1157
+ if (isLive && elapsed_ms > LONG_OUTAGE_MS) {
1158
+ api?.emit("error:reconnecting", {
1159
+ attempt: reconnectAttempts + 1,
1160
+ delayMs: 0,
1161
+ elapsedMs: elapsed_ms,
1162
+ windowMs: window_ms,
1163
+ longOutage: true
1164
+ });
1165
+ }
1055
1166
  const base_delay = mergedConfig.reconnectBaseDelayMs ?? 2e3;
1056
1167
  const max_delay = mergedConfig.reconnectMaxDelayMs ?? 3e4;
1057
1168
  const backoff = Math.min(base_delay * Math.pow(2, reconnectAttempts), max_delay);
1058
- const delay = Math.round(backoff * (0.7 + Math.random() * 0.3));
1169
+ const isAtCap = backoff >= max_delay;
1170
+ const delay = isLive && isAtCap ? 3e4 : Math.round(backoff * (0.7 + Math.random() * 0.3));
1059
1171
  api?.logger.info(`Scheduling auto-reconnect attempt ${reconnectAttempts + 1} in ${delay}ms`);
1060
1172
  api?.emit("error:reconnecting", {
1061
1173
  attempt: reconnectAttempts + 1,
@@ -1070,7 +1182,9 @@ function createHLSPluginWith(loader, variant, config) {
1070
1182
  };
1071
1183
  const maybeScheduleReconnect = (error) => {
1072
1184
  if (mergedConfig.autoReconnect === false) return;
1073
- if (!hasPlayedContent || !currentSrc) return;
1185
+ if (!currentSrc) return;
1186
+ const isLive = (api?.getState("live") ?? false) && isLiveClassified;
1187
+ if (!isLive && !hasPlayedContent) return;
1074
1188
  if (error.type !== "network" && error.type !== "media") return;
1075
1189
  if (reconnectWindowStart === 0) {
1076
1190
  reconnectWindowStart = Date.now();
@@ -1081,13 +1195,14 @@ function createHLSPluginWith(loader, variant, config) {
1081
1195
  };
1082
1196
  const attemptReconnect = async () => {
1083
1197
  if (!api || !currentSrc) return;
1198
+ isReconnecting = true;
1084
1199
  const session = ++loadSession;
1085
1200
  reconnectAttempts++;
1086
1201
  const saved_src = currentSrc;
1087
1202
  const was_live = api.getState("live");
1088
1203
  const was_native = isNative;
1089
1204
  const resume_position = reconnectResumePosition;
1090
- api.logger.info(`Auto-reconnect attempt ${reconnectAttempts}`, { src: saved_src });
1205
+ api.logger.info(`Auto-reconnect attempt ${reconnectAttempts}`, { src: (0, import_core.sanitizeUrl)(saved_src) });
1091
1206
  try {
1092
1207
  teardownPipeline(new Error("HLS load cancelled: reconnecting"));
1093
1208
  networkRetryCount = 0;
@@ -1113,14 +1228,21 @@ function createHLSPluginWith(loader, variant, config) {
1113
1228
  });
1114
1229
  api.logger.info("Auto-reconnect succeeded");
1115
1230
  cancelReconnect();
1116
- try {
1117
- await video?.play();
1118
- } catch {
1231
+ const wasPaused = api.getState("paused");
1232
+ if (!wasPaused) {
1233
+ try {
1234
+ await video?.play();
1235
+ } catch {
1236
+ }
1237
+ } else {
1238
+ api.logger.info("Stream reconnected while paused; maintaining pause at live edge");
1119
1239
  }
1120
1240
  } catch {
1121
1241
  if (session !== loadSession) return;
1122
1242
  api?.logger.warn(`Auto-reconnect attempt ${reconnectAttempts} failed`);
1123
1243
  scheduleReconnectAttempt();
1244
+ } finally {
1245
+ isReconnecting = false;
1124
1246
  }
1125
1247
  };
1126
1248
  const plugin = {
@@ -1154,6 +1276,7 @@ function createHLSPluginWith(loader, variant, config) {
1154
1276
  });
1155
1277
  const unsubSeek = api.on("playback:seeking", ({ time }) => {
1156
1278
  if (!video) return;
1279
+ if (!Number.isFinite(time)) return;
1157
1280
  const clampedTime = Math.max(0, Math.min(time, video.duration || 0));
1158
1281
  video.currentTime = clampedTime;
1159
1282
  });
@@ -1206,20 +1329,43 @@ function createHLSPluginWith(loader, variant, config) {
1206
1329
  }
1207
1330
  }
1208
1331
  });
1332
+ const unsubAudioTrack = api.on("track:audio", ({ trackId }) => {
1333
+ if (!hls || isNative) {
1334
+ api?.logger.warn("Audio track selection not available");
1335
+ return;
1336
+ }
1337
+ const index = audioTrackIndex(trackId);
1338
+ const tracks = hls.audioTracks ?? [];
1339
+ if (index < 0 || index >= tracks.length) {
1340
+ api?.logger.warn("Ignoring unknown audio track selection", { trackId });
1341
+ return;
1342
+ }
1343
+ hls.audioTrack = index;
1344
+ api?.logger.debug(`Audio: queued switch to track ${index}`);
1345
+ });
1209
1346
  if (typeof window !== "undefined") {
1210
1347
  onlineListener = () => {
1348
+ const hasActiveReconnect = reconnectTimer !== null || reconnectWindowStart > 0 || isReconnecting || reconnectAttempts > 0 && !reconnectExhausted;
1349
+ if (!hasActiveReconnect) {
1350
+ return;
1351
+ }
1211
1352
  if (reconnectTimer) {
1212
1353
  api?.logger.info("Browser back online, reconnecting immediately");
1213
1354
  clearTimeout(reconnectTimer);
1214
1355
  reconnectTimer = null;
1215
- void attemptReconnect();
1216
1356
  }
1357
+ void attemptReconnect();
1217
1358
  };
1218
1359
  window.addEventListener("online", onlineListener);
1219
1360
  }
1220
1361
  const unsubPoster = api.subscribeToState((event) => {
1221
1362
  if (event.key === "poster") applyPoster();
1222
1363
  });
1364
+ const unsubLive = api.subscribeToState((event) => {
1365
+ if (event.key === "live") {
1366
+ isLiveClassified = true;
1367
+ }
1368
+ });
1223
1369
  api.onDestroy(() => {
1224
1370
  unsubPlay();
1225
1371
  unsubPause();
@@ -1228,8 +1374,20 @@ function createHLSPluginWith(loader, variant, config) {
1228
1374
  unsubMute();
1229
1375
  unsubRate();
1230
1376
  unsubQuality();
1377
+ unsubAudioTrack();
1231
1378
  unsubPoster();
1379
+ unsubLive();
1232
1380
  });
1381
+ const unsubPlayState = api.subscribeToState((event) => {
1382
+ if (event.key === "playing") {
1383
+ if (event.value) {
1384
+ startStallWatchdog();
1385
+ } else {
1386
+ stopStallWatchdog();
1387
+ }
1388
+ }
1389
+ });
1390
+ api.onDestroy(unsubPlayState);
1233
1391
  },
1234
1392
  async destroy() {
1235
1393
  api?.logger.info(`HLS plugin${variant.logSuffix} destroying`);
@@ -1248,10 +1406,12 @@ function createHLSPluginWith(loader, variant, config) {
1248
1406
  },
1249
1407
  async loadSource(src) {
1250
1408
  if (!api) throw new Error("Plugin not initialized");
1251
- api.logger.info(`Loading HLS source${variant.logSuffix}`, { src });
1409
+ api.logger.info(`Loading HLS source${variant.logSuffix}`, { src: (0, import_core.sanitizeUrl)(src) });
1252
1410
  const session = ++loadSession;
1253
1411
  cancelReconnect();
1254
1412
  hasPlayedContent = false;
1413
+ isLiveClassified = false;
1414
+ api.setState("live", false);
1255
1415
  cleanup(new Error("HLS load cancelled: superseded by a new load"));
1256
1416
  currentSrc = src;
1257
1417
  applyPoster();
@@ -1301,14 +1461,24 @@ function createHLSPluginWith(loader, variant, config) {
1301
1461
  return isNative;
1302
1462
  },
1303
1463
  getLiveInfo() {
1304
- if (isNative || !hls) return null;
1305
1464
  const live = api?.getState("live") || false;
1306
1465
  if (!live) return null;
1466
+ if (isNative) {
1467
+ return {
1468
+ isLive: true,
1469
+ latency: 0,
1470
+ targetLatency: 3,
1471
+ drift: 0,
1472
+ liveSyncPosition: video?.seekable?.length ? Math.max(0, video.seekable.end(video.seekable.length - 1) - 3) : void 0
1473
+ };
1474
+ }
1475
+ if (!hls) return null;
1307
1476
  return {
1308
1477
  isLive: true,
1309
1478
  latency: hls.latency || 0,
1310
1479
  targetLatency: hls.targetLatency || 3,
1311
- drift: hls.drift || 0
1480
+ drift: hls.drift || 0,
1481
+ liveSyncPosition: hls.liveSyncPosition ?? (video?.seekable?.length ? Math.max(0, video.seekable.end(video.seekable.length - 1) - 3) : void 0)
1312
1482
  };
1313
1483
  },
1314
1484
  /**
@@ -1334,7 +1504,9 @@ function createHLSPluginWith(loader, variant, config) {
1334
1504
  const currentTime = video?.currentTime || 0;
1335
1505
  const savedSrc = currentSrc;
1336
1506
  const session = ++loadSession;
1507
+ cancelReconnect();
1337
1508
  cleanup(new Error("HLS load cancelled: switching to native HLS"));
1509
+ currentSrc = savedSrc;
1338
1510
  await loadNative(savedSrc);
1339
1511
  if (session !== loadSession) return;
1340
1512
  if (video && currentTime > 0) {
@@ -1371,7 +1543,9 @@ function createHLSPluginWith(loader, variant, config) {
1371
1543
  const currentTime = video?.currentTime || 0;
1372
1544
  const savedSrc = currentSrc;
1373
1545
  const session = ++loadSession;
1546
+ cancelReconnect();
1374
1547
  cleanup(new Error("HLS load cancelled: switching to hls.js"));
1548
+ currentSrc = savedSrc;
1375
1549
  await loadWithHlsJs(savedSrc);
1376
1550
  if (session !== loadSession) return;
1377
1551
  if (video && currentTime > 0) {
package/dist/light.d.cts CHANGED
@@ -1,6 +1,6 @@
1
- import { H as HLSPluginConfig, I as IHLSPlugin } from './sanitize-url-DcZXD_K-.cjs';
2
- export { b as HLSError, c as HLSLiveInfo, a as HLSQualityLevel, s as sanitizeUrl } from './sanitize-url-DcZXD_K-.cjs';
3
- import '@scarlett-player/core';
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';
3
+ export { sanitizeUrl } from '@scarlett-player/core';
4
4
 
5
5
  /**
6
6
  * HLS Provider Plugin - Light Build
package/dist/light.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { H as HLSPluginConfig, I as IHLSPlugin } from './sanitize-url-DcZXD_K-.js';
2
- export { b as HLSError, c as HLSLiveInfo, a as HLSQualityLevel, s as sanitizeUrl } from './sanitize-url-DcZXD_K-.js';
3
- import '@scarlett-player/core';
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';
3
+ export { sanitizeUrl } from '@scarlett-player/core';
4
4
 
5
5
  /**
6
6
  * HLS Provider Plugin - Light Build
package/dist/light.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  __export,
3
3
  createHLSPluginWith,
4
4
  sanitizeUrl
5
- } from "./chunk-VD46AQJB.js";
5
+ } from "./chunk-UB2TS72E.js";
6
6
 
7
7
  // src/hls-loader-light.ts
8
8
  var hls_loader_light_exports = {};
@@ -13,7 +13,6 @@ __export(hls_loader_light_exports, {
13
13
  isHlsJsSupported: () => isHlsJsSupported,
14
14
  loadHlsJs: () => loadHlsJs,
15
15
  resetLoader: () => resetLoader,
16
- shouldPreferNativeHLS: () => shouldPreferNativeHLS,
17
16
  supportsNativeHLS: () => supportsNativeHLS
18
17
  });
19
18
  var hlsConstructor = null;
@@ -23,13 +22,6 @@ function supportsNativeHLS() {
23
22
  const video = document.createElement("video");
24
23
  return video.canPlayType("application/vnd.apple.mpegurl") !== "";
25
24
  }
26
- function shouldPreferNativeHLS() {
27
- if (!supportsNativeHLS()) return false;
28
- if (typeof navigator === "undefined") return false;
29
- const ua = navigator.userAgent;
30
- const isSafari = /Safari/.test(ua) && !/Chrome/.test(ua) && !/CriOS/.test(ua);
31
- return isSafari;
32
- }
33
25
  function isHlsJsSupported() {
34
26
  if (hlsConstructor) {
35
27
  return hlsConstructor.isSupported();