@scarlett-player/hls 1.8.1 → 1.9.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.
@@ -107,6 +107,24 @@ function parseHlsError(data) {
107
107
  response: data.response
108
108
  };
109
109
  }
110
+ function audioTrackId(index) {
111
+ return `audio-${index}`;
112
+ }
113
+ function audioTrackIndex(id) {
114
+ if (!id) return -1;
115
+ const match = /^audio-(0|[1-9]\d*)$/.exec(id);
116
+ return match ? Number.parseInt(match[1], 10) : -1;
117
+ }
118
+ function formatAudioTrack(track, index, active) {
119
+ return {
120
+ id: audioTrackId(index),
121
+ // A manifest may declare neither NAME nor LANGUAGE; a numbered fallback
122
+ // still gives the viewer something selectable rather than a blank row.
123
+ label: track.name || track.lang || `Audio ${index + 1}`,
124
+ language: track.lang,
125
+ active
126
+ };
127
+ }
110
128
  function setupHlsEventHandlers(hls, api, callbacks) {
111
129
  const handlers = [];
112
130
  const addHandler = (event, handler) => {
@@ -150,6 +168,24 @@ function setupHlsEventHandlers(hls, api, callbacks) {
150
168
  });
151
169
  callbacks.onLevelSwitched?.(data.level);
152
170
  });
171
+ const publishAudioTracks = (tracks, activeIndex) => {
172
+ const audioTracks = tracks.map(
173
+ (track, index) => formatAudioTrack(track, index, index === activeIndex)
174
+ );
175
+ api.setState("audioTracks", audioTracks);
176
+ api.setState("currentAudioTrack", audioTracks[activeIndex] ?? null);
177
+ };
178
+ addHandler("hlsAudioTracksUpdated", (_event, data) => {
179
+ const tracks = data.audioTracks ?? [];
180
+ api.logger.debug("HLS audio tracks updated", { tracks: tracks.length });
181
+ publishAudioTracks(tracks, hls.audioTrack);
182
+ callbacks.onAudioTracksUpdated?.(tracks);
183
+ });
184
+ addHandler("hlsAudioTrackSwitched", (_event, data) => {
185
+ api.logger.debug("HLS audio track switched", { id: data.id });
186
+ publishAudioTracks(hls.audioTracks ?? [], data.id);
187
+ callbacks.onAudioTrackSwitched?.(data.id);
188
+ });
153
189
  let lastBandwidthUpdate = 0;
154
190
  addHandler("hlsFragLoaded", () => {
155
191
  const now = Date.now();
@@ -170,16 +206,15 @@ function setupHlsEventHandlers(hls, api, callbacks) {
170
206
  if (data.details?.live !== void 0) {
171
207
  api.setState("live", data.details.live);
172
208
  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 });
173
213
  const video = hls.media;
174
- if (video && video.seekable && video.seekable.length > 0) {
175
- const start = video.seekable.start(0);
176
- const end = video.seekable.end(video.seekable.length - 1);
177
- api.setState("seekableRange", { start, end });
178
- const threshold = (data.details.targetduration ?? 3) * 3;
179
- const isAtLiveEdge = end - video.currentTime < threshold;
180
- api.setState("liveEdge", isAtLiveEdge);
181
- const latency = end - video.currentTime;
182
- api.setState("liveLatency", Math.max(0, latency));
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);
183
218
  }
184
219
  }
185
220
  callbacks.onLiveUpdate?.();
@@ -214,6 +249,8 @@ function setupHlsEventHandlers(hls, api, callbacks) {
214
249
  hls.off(event, handler);
215
250
  }
216
251
  handlers.length = 0;
252
+ api.setState("audioTracks", []);
253
+ api.setState("currentAudioTrack", null);
217
254
  };
218
255
  }
219
256
  function setupVideoEventHandlers(video, api) {
@@ -253,19 +290,27 @@ function setupVideoEventHandlers(video, api) {
253
290
  addHandler("timeupdate", () => {
254
291
  api.setState("currentTime", video.currentTime);
255
292
  api.emit("playback:timeupdate", { currentTime: video.currentTime });
256
- const isLive = api.getState("live");
257
- if (isLive && video.seekable && video.seekable.length > 0) {
258
- const start = video.seekable.start(0);
259
- const end = video.seekable.end(video.seekable.length - 1);
260
- api.setState("seekableRange", { start, end });
261
- const isAtLiveEdge = end - video.currentTime < 10;
262
- api.setState("liveEdge", isAtLiveEdge);
263
- api.setState("liveLatency", Math.max(0, end - video.currentTime));
293
+ if (video.seekable && video.seekable.length > 0) {
294
+ if (api.getState("live") || !Number.isFinite(video.duration)) {
295
+ const start = video.seekable.start(0);
296
+ const end = video.seekable.end(video.seekable.length - 1);
297
+ api.setState("seekableRange", { start, end });
298
+ const latency = Math.max(0, end - video.currentTime);
299
+ api.setState("liveEdge", latency < 10);
300
+ api.setState("liveLatency", latency);
301
+ }
264
302
  }
265
303
  });
266
304
  addHandler("durationchange", () => {
267
- api.setState("duration", video.duration || 0);
268
- api.emit("media:loadedmetadata", { duration: video.duration || 0 });
305
+ const rawDuration = video.duration;
306
+ const isLive = !Number.isFinite(rawDuration) || rawDuration === Infinity;
307
+ if (isLive) {
308
+ api.setState("live", true);
309
+ api.setState("duration", 0);
310
+ } else {
311
+ api.setState("duration", rawDuration || 0);
312
+ }
313
+ api.emit("media:loadedmetadata", { duration: api.getState("duration") });
269
314
  });
270
315
  addHandler("waiting", () => {
271
316
  api.setState("waiting", true);
@@ -401,7 +446,7 @@ function createValidatingPlaylistLoader(Hls) {
401
446
  }
402
447
 
403
448
  // src/version.ts
404
- var PKG_VERSION = true ? "1.8.1" : "0.0.0-dev";
449
+ var PKG_VERSION = true ? "1.9.0" : "0.0.0-dev";
405
450
 
406
451
  // src/create-hls-plugin.ts
407
452
  var DEFAULT_CONFIG = {
@@ -461,6 +506,11 @@ function createHLSPluginWith(loader, variant, config) {
461
506
  let onlineListener = null;
462
507
  let reconnectTriggerError = null;
463
508
  let reconnectExhausted = false;
509
+ let isReconnecting = false;
510
+ let isLiveClassified = false;
511
+ let stallWatchdogTimer = null;
512
+ let lastStallCheckTime = 0;
513
+ let lastStallCheckPosition = 0;
464
514
  const applyPoster = () => {
465
515
  if (!video) return;
466
516
  video.poster = api?.getState("poster") || "";
@@ -492,6 +542,10 @@ function createHLSPluginWith(loader, variant, config) {
492
542
  clearTimeout(retryTimeout);
493
543
  retryTimeout = null;
494
544
  }
545
+ if (stallWatchdogTimer) {
546
+ clearTimeout(stallWatchdogTimer);
547
+ stallWatchdogTimer = null;
548
+ }
495
549
  if (hls) {
496
550
  hls.destroy();
497
551
  hls = null;
@@ -603,20 +657,20 @@ function createHLSPluginWith(loader, variant, config) {
603
657
  const handleHlsError = (error) => {
604
658
  const Hls = loader.getHlsConstructor();
605
659
  if (!Hls || !hls) return false;
606
- const now = Date.now();
607
- if (now - errorWindowStart > ERROR_WINDOW_MS) {
608
- errorCount = 1;
609
- errorWindowStart = now;
610
- } else {
611
- errorCount++;
612
- }
613
- if (errorCount >= MAX_ERRORS_IN_WINDOW) {
614
- api?.logger.error(`Too many errors (${errorCount} in ${ERROR_WINDOW_MS}ms), giving up`);
615
- emitFatalError(error, true);
616
- teardownPipeline(new Error(error.details));
617
- return true;
618
- }
619
660
  if (error.fatal) {
661
+ const now = Date.now();
662
+ if (now - errorWindowStart > ERROR_WINDOW_MS) {
663
+ errorCount = 1;
664
+ errorWindowStart = now;
665
+ } else {
666
+ errorCount++;
667
+ }
668
+ if (errorCount >= MAX_ERRORS_IN_WINDOW) {
669
+ api?.logger.error(`Too many fatal errors (${errorCount} in ${ERROR_WINDOW_MS}ms), giving up`);
670
+ emitFatalError(error, true);
671
+ teardownPipeline(new Error(error.details));
672
+ return true;
673
+ }
620
674
  api?.logger.error("Fatal HLS error", { type: error.type, details: error.details });
621
675
  switch (error.type) {
622
676
  case "network": {
@@ -895,9 +949,15 @@ function createHLSPluginWith(loader, variant, config) {
895
949
  if (resolved || session !== loadSession) return;
896
950
  resolved = true;
897
951
  releaseAbort();
898
- api?.logger.error(`HLS load timed out after ${timeout_ms}ms`, { src });
952
+ api?.logger.error(`HLS load timed out after ${timeout_ms}ms`, { src: sanitizeUrl(src) });
953
+ const timeoutError = {
954
+ type: "network",
955
+ details: "Video took too long to load (network timeout)",
956
+ fatal: true
957
+ };
958
+ emitFatalError(timeoutError, true);
899
959
  teardownPipeline();
900
- reject(new Error("Video took too long to load (network timeout)"));
960
+ reject(new Error(timeoutError.details));
901
961
  }, timeout_ms);
902
962
  }
903
963
  hls.attachMedia(videoEl);
@@ -914,6 +974,51 @@ function createHLSPluginWith(loader, variant, config) {
914
974
  reconnectResumePosition = 0;
915
975
  reconnectTriggerError = null;
916
976
  reconnectExhausted = false;
977
+ isReconnecting = false;
978
+ };
979
+ const startStallWatchdog = () => {
980
+ if (!video || stallWatchdogTimer) return;
981
+ lastStallCheckTime = Date.now();
982
+ lastStallCheckPosition = video.currentTime;
983
+ const levelTargetDuration = hls?.targetDuration ?? hls?.levels?.[hls?.currentLevel]?.details?.targetduration ?? 0;
984
+ const targetDuration = Math.max(15, 4 * (levelTargetDuration || 0) || 15);
985
+ const checkInterval = Math.min(targetDuration, 3e4);
986
+ const check = () => {
987
+ if (!video || !api) return;
988
+ const isPlaying = api.getState("playing");
989
+ const isSeeking = api.getState("seeking");
990
+ if (!isPlaying || isSeeking) {
991
+ stallWatchdogTimer = setTimeout(check, checkInterval);
992
+ return;
993
+ }
994
+ const elapsed = Date.now() - lastStallCheckTime;
995
+ const positionDelta = video.currentTime - lastStallCheckPosition;
996
+ if (positionDelta > 0.5) {
997
+ lastStallCheckTime = Date.now();
998
+ lastStallCheckPosition = video.currentTime;
999
+ } else if (elapsed > targetDuration * 1e3) {
1000
+ api.logger.warn("Playback stall detected \u2014 no timeupdate progress", {
1001
+ elapsed: Math.round(elapsed),
1002
+ currentTime: video.currentTime,
1003
+ targetDuration: Math.round(targetDuration)
1004
+ });
1005
+ const stallError = {
1006
+ type: "network",
1007
+ details: "Playback stalled \u2014 no data received",
1008
+ fatal: true
1009
+ };
1010
+ maybeScheduleReconnect(stallError);
1011
+ return;
1012
+ }
1013
+ stallWatchdogTimer = setTimeout(check, checkInterval);
1014
+ };
1015
+ stallWatchdogTimer = setTimeout(check, checkInterval);
1016
+ };
1017
+ const stopStallWatchdog = () => {
1018
+ if (stallWatchdogTimer) {
1019
+ clearTimeout(stallWatchdogTimer);
1020
+ stallWatchdogTimer = null;
1021
+ }
917
1022
  };
918
1023
  const emitReconnectExhausted = (elapsedMs, windowMs) => {
919
1024
  if (reconnectExhausted) return;
@@ -939,17 +1044,30 @@ function createHLSPluginWith(loader, variant, config) {
939
1044
  const scheduleReconnectAttempt = () => {
940
1045
  if (reconnectExhausted) return;
941
1046
  if (reconnectTimer) return;
942
- const window_ms = mergedConfig.reconnectWindowMs ?? 3e5;
1047
+ const isLive = (api?.getState("live") ?? false) && isLiveClassified;
1048
+ const configWindowMs = mergedConfig.reconnectWindowMs ?? 3e5;
1049
+ const window_ms = isLive ? Infinity : configWindowMs;
943
1050
  const elapsed_ms = Date.now() - reconnectWindowStart;
944
1051
  if (elapsed_ms > window_ms) {
945
1052
  api?.logger.warn(`Auto-reconnect window exhausted after ${reconnectAttempts} attempts`);
946
1053
  emitReconnectExhausted(elapsed_ms, window_ms);
947
1054
  return;
948
1055
  }
1056
+ const LONG_OUTAGE_MS = 6e5;
1057
+ if (isLive && elapsed_ms > LONG_OUTAGE_MS) {
1058
+ api?.emit("error:reconnecting", {
1059
+ attempt: reconnectAttempts + 1,
1060
+ delayMs: 0,
1061
+ elapsedMs: elapsed_ms,
1062
+ windowMs: window_ms,
1063
+ longOutage: true
1064
+ });
1065
+ }
949
1066
  const base_delay = mergedConfig.reconnectBaseDelayMs ?? 2e3;
950
1067
  const max_delay = mergedConfig.reconnectMaxDelayMs ?? 3e4;
951
1068
  const backoff = Math.min(base_delay * Math.pow(2, reconnectAttempts), max_delay);
952
- const delay = Math.round(backoff * (0.7 + Math.random() * 0.3));
1069
+ const isAtCap = backoff >= max_delay;
1070
+ const delay = isLive && isAtCap ? 3e4 : Math.round(backoff * (0.7 + Math.random() * 0.3));
953
1071
  api?.logger.info(`Scheduling auto-reconnect attempt ${reconnectAttempts + 1} in ${delay}ms`);
954
1072
  api?.emit("error:reconnecting", {
955
1073
  attempt: reconnectAttempts + 1,
@@ -964,7 +1082,9 @@ function createHLSPluginWith(loader, variant, config) {
964
1082
  };
965
1083
  const maybeScheduleReconnect = (error) => {
966
1084
  if (mergedConfig.autoReconnect === false) return;
967
- if (!hasPlayedContent || !currentSrc) return;
1085
+ if (!currentSrc) return;
1086
+ const isLive = (api?.getState("live") ?? false) && isLiveClassified;
1087
+ if (!isLive && !hasPlayedContent) return;
968
1088
  if (error.type !== "network" && error.type !== "media") return;
969
1089
  if (reconnectWindowStart === 0) {
970
1090
  reconnectWindowStart = Date.now();
@@ -975,13 +1095,14 @@ function createHLSPluginWith(loader, variant, config) {
975
1095
  };
976
1096
  const attemptReconnect = async () => {
977
1097
  if (!api || !currentSrc) return;
1098
+ isReconnecting = true;
978
1099
  const session = ++loadSession;
979
1100
  reconnectAttempts++;
980
1101
  const saved_src = currentSrc;
981
1102
  const was_live = api.getState("live");
982
1103
  const was_native = isNative;
983
1104
  const resume_position = reconnectResumePosition;
984
- api.logger.info(`Auto-reconnect attempt ${reconnectAttempts}`, { src: saved_src });
1105
+ api.logger.info(`Auto-reconnect attempt ${reconnectAttempts}`, { src: sanitizeUrl(saved_src) });
985
1106
  try {
986
1107
  teardownPipeline(new Error("HLS load cancelled: reconnecting"));
987
1108
  networkRetryCount = 0;
@@ -1007,14 +1128,21 @@ function createHLSPluginWith(loader, variant, config) {
1007
1128
  });
1008
1129
  api.logger.info("Auto-reconnect succeeded");
1009
1130
  cancelReconnect();
1010
- try {
1011
- await video?.play();
1012
- } catch {
1131
+ const wasPaused = api.getState("paused");
1132
+ if (!wasPaused) {
1133
+ try {
1134
+ await video?.play();
1135
+ } catch {
1136
+ }
1137
+ } else {
1138
+ api.logger.info("Stream reconnected while paused; maintaining pause at live edge");
1013
1139
  }
1014
1140
  } catch {
1015
1141
  if (session !== loadSession) return;
1016
1142
  api?.logger.warn(`Auto-reconnect attempt ${reconnectAttempts} failed`);
1017
1143
  scheduleReconnectAttempt();
1144
+ } finally {
1145
+ isReconnecting = false;
1018
1146
  }
1019
1147
  };
1020
1148
  const plugin = {
@@ -1048,6 +1176,7 @@ function createHLSPluginWith(loader, variant, config) {
1048
1176
  });
1049
1177
  const unsubSeek = api.on("playback:seeking", ({ time }) => {
1050
1178
  if (!video) return;
1179
+ if (!Number.isFinite(time)) return;
1051
1180
  const clampedTime = Math.max(0, Math.min(time, video.duration || 0));
1052
1181
  video.currentTime = clampedTime;
1053
1182
  });
@@ -1100,20 +1229,43 @@ function createHLSPluginWith(loader, variant, config) {
1100
1229
  }
1101
1230
  }
1102
1231
  });
1232
+ const unsubAudioTrack = api.on("track:audio", ({ trackId }) => {
1233
+ if (!hls || isNative) {
1234
+ api?.logger.warn("Audio track selection not available");
1235
+ return;
1236
+ }
1237
+ const index = audioTrackIndex(trackId);
1238
+ const tracks = hls.audioTracks ?? [];
1239
+ if (index < 0 || index >= tracks.length) {
1240
+ api?.logger.warn("Ignoring unknown audio track selection", { trackId });
1241
+ return;
1242
+ }
1243
+ hls.audioTrack = index;
1244
+ api?.logger.debug(`Audio: queued switch to track ${index}`);
1245
+ });
1103
1246
  if (typeof window !== "undefined") {
1104
1247
  onlineListener = () => {
1248
+ const hasActiveReconnect = reconnectTimer !== null || reconnectWindowStart > 0 || isReconnecting || reconnectAttempts > 0 && !reconnectExhausted;
1249
+ if (!hasActiveReconnect) {
1250
+ return;
1251
+ }
1105
1252
  if (reconnectTimer) {
1106
1253
  api?.logger.info("Browser back online, reconnecting immediately");
1107
1254
  clearTimeout(reconnectTimer);
1108
1255
  reconnectTimer = null;
1109
- void attemptReconnect();
1110
1256
  }
1257
+ void attemptReconnect();
1111
1258
  };
1112
1259
  window.addEventListener("online", onlineListener);
1113
1260
  }
1114
1261
  const unsubPoster = api.subscribeToState((event) => {
1115
1262
  if (event.key === "poster") applyPoster();
1116
1263
  });
1264
+ const unsubLive = api.subscribeToState((event) => {
1265
+ if (event.key === "live") {
1266
+ isLiveClassified = true;
1267
+ }
1268
+ });
1117
1269
  api.onDestroy(() => {
1118
1270
  unsubPlay();
1119
1271
  unsubPause();
@@ -1122,8 +1274,20 @@ function createHLSPluginWith(loader, variant, config) {
1122
1274
  unsubMute();
1123
1275
  unsubRate();
1124
1276
  unsubQuality();
1277
+ unsubAudioTrack();
1125
1278
  unsubPoster();
1279
+ unsubLive();
1126
1280
  });
1281
+ const unsubPlayState = api.subscribeToState((event) => {
1282
+ if (event.key === "playing") {
1283
+ if (event.value) {
1284
+ startStallWatchdog();
1285
+ } else {
1286
+ stopStallWatchdog();
1287
+ }
1288
+ }
1289
+ });
1290
+ api.onDestroy(unsubPlayState);
1127
1291
  },
1128
1292
  async destroy() {
1129
1293
  api?.logger.info(`HLS plugin${variant.logSuffix} destroying`);
@@ -1142,10 +1306,12 @@ function createHLSPluginWith(loader, variant, config) {
1142
1306
  },
1143
1307
  async loadSource(src) {
1144
1308
  if (!api) throw new Error("Plugin not initialized");
1145
- api.logger.info(`Loading HLS source${variant.logSuffix}`, { src });
1309
+ api.logger.info(`Loading HLS source${variant.logSuffix}`, { src: sanitizeUrl(src) });
1146
1310
  const session = ++loadSession;
1147
1311
  cancelReconnect();
1148
1312
  hasPlayedContent = false;
1313
+ isLiveClassified = false;
1314
+ api.setState("live", false);
1149
1315
  cleanup(new Error("HLS load cancelled: superseded by a new load"));
1150
1316
  currentSrc = src;
1151
1317
  applyPoster();
@@ -1195,14 +1361,24 @@ function createHLSPluginWith(loader, variant, config) {
1195
1361
  return isNative;
1196
1362
  },
1197
1363
  getLiveInfo() {
1198
- if (isNative || !hls) return null;
1199
1364
  const live = api?.getState("live") || false;
1200
1365
  if (!live) return null;
1366
+ if (isNative) {
1367
+ return {
1368
+ isLive: true,
1369
+ latency: 0,
1370
+ targetLatency: 3,
1371
+ drift: 0,
1372
+ liveSyncPosition: video?.seekable?.length ? Math.max(0, video.seekable.end(video.seekable.length - 1) - 3) : void 0
1373
+ };
1374
+ }
1375
+ if (!hls) return null;
1201
1376
  return {
1202
1377
  isLive: true,
1203
1378
  latency: hls.latency || 0,
1204
1379
  targetLatency: hls.targetLatency || 3,
1205
- drift: hls.drift || 0
1380
+ drift: hls.drift || 0,
1381
+ liveSyncPosition: hls.liveSyncPosition ?? (video?.seekable?.length ? Math.max(0, video.seekable.end(video.seekable.length - 1) - 3) : void 0)
1206
1382
  };
1207
1383
  },
1208
1384
  /**
@@ -1228,7 +1404,9 @@ function createHLSPluginWith(loader, variant, config) {
1228
1404
  const currentTime = video?.currentTime || 0;
1229
1405
  const savedSrc = currentSrc;
1230
1406
  const session = ++loadSession;
1407
+ cancelReconnect();
1231
1408
  cleanup(new Error("HLS load cancelled: switching to native HLS"));
1409
+ currentSrc = savedSrc;
1232
1410
  await loadNative(savedSrc);
1233
1411
  if (session !== loadSession) return;
1234
1412
  if (video && currentTime > 0) {
@@ -1265,7 +1443,9 @@ function createHLSPluginWith(loader, variant, config) {
1265
1443
  const currentTime = video?.currentTime || 0;
1266
1444
  const savedSrc = currentSrc;
1267
1445
  const session = ++loadSession;
1446
+ cancelReconnect();
1268
1447
  cleanup(new Error("HLS load cancelled: switching to hls.js"));
1448
+ currentSrc = savedSrc;
1269
1449
  await loadWithHlsJs(savedSrc);
1270
1450
  if (session !== loadSession) return;
1271
1451
  if (video && currentTime > 0) {