@codenkay/video-nsgplayer-core 3.0.2 → 4.0.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.js CHANGED
@@ -146,7 +146,7 @@ var AuthManager = class {
146
146
 
147
147
  // src/config/defaults.ts
148
148
  var PLAYBACK_RATES = [0.5, 0.75, 1, 1.25, 1.5, 2];
149
- var PACKAGE_VERSION = "3.0.2";
149
+ var PACKAGE_VERSION = "3.0.3";
150
150
  var DEFAULT_PLAYER_BEHAVIOR = {
151
151
  autoplay: true,
152
152
  startMuted: true,
@@ -170,7 +170,8 @@ var DEFAULT_CONTROLS = {
170
170
  showSkip: true,
171
171
  showLive: true,
172
172
  seekCueDurationMs: 720,
173
- keyboardShortcuts: true
173
+ keyboardShortcuts: true,
174
+ persistQualityPreference: false
174
175
  };
175
176
  var DEFAULT_THEME = {
176
177
  playIconPreset: "triangle",
@@ -215,7 +216,8 @@ var DEFAULT_PLAYER_CONFIG = {
215
216
  engine: "hls",
216
217
  hls: {
217
218
  enableWorker: true,
218
- lowLatencyMode: true,
219
+ // VOD + AES-128: LL mode causes ABR thrash → bufferAppendError on MSE.
220
+ lowLatencyMode: false,
219
221
  backBufferLength: 90,
220
222
  fatalRecoveryLimit: 3
221
223
  }
@@ -792,7 +794,8 @@ function rememberBuiltin(HlsClass, loader) {
792
794
  function getStashedBuiltinLoader(loader) {
793
795
  return loader[BUILTIN_LOADER_REF];
794
796
  }
795
- function resolveBuiltinHlsLoader(HlsClass) {
797
+ function resolveBuiltinHlsLoader(HlsModule) {
798
+ const HlsClass = HlsModule;
796
799
  const cached = builtinLoaderByHls.get(HlsClass);
797
800
  if (cached) return cached;
798
801
  const start = HlsClass.DefaultConfig.loader;
@@ -920,29 +923,52 @@ async function fetchEncryptionKeyBytes(url, accessToken, signal) {
920
923
  };
921
924
  }
922
925
  }
926
+ var AES_128_KEY_BYTES = 16;
927
+ function normalizeAes128Key(data) {
928
+ if (data.byteLength === AES_128_KEY_BYTES) {
929
+ return data.slice(0);
930
+ }
931
+ return null;
932
+ }
923
933
  function reportKeySuccess(context, callbacks, result) {
934
+ const key = normalizeAes128Key(result.data);
935
+ if (!key) {
936
+ const stats2 = createLoaderStats();
937
+ callbacks.onError(
938
+ {
939
+ code: 422,
940
+ text: `enc.key must be ${AES_128_KEY_BYTES} bytes (got ${result.data.byteLength})`
941
+ },
942
+ context,
943
+ result.response,
944
+ stats2
945
+ );
946
+ return false;
947
+ }
924
948
  const stats = createLoaderStats();
925
949
  const end = performance.now();
926
950
  stats.loading.first = stats.loading.start;
927
951
  stats.loading.end = end;
928
- stats.loaded = result.data.byteLength;
929
- stats.total = result.data.byteLength;
952
+ stats.loaded = key.byteLength;
953
+ stats.total = key.byteLength;
930
954
  callbacks.onSuccess(
931
955
  {
932
956
  url: result.url,
933
- data: result.data,
957
+ data: key,
934
958
  code: result.response.status
935
959
  },
936
960
  stats,
937
961
  context,
938
962
  result.response
939
963
  );
964
+ return true;
940
965
  }
941
- async function loadEncryptionKeyEnterprise(context, callbacks, plan, signal, state) {
966
+ async function loadEncryptionKeyEnterprise(context, callbacks, plan, signal, state, lifecycle) {
942
967
  const stats = createLoaderStats();
968
+ const stillCurrent = () => lifecycle.isCurrent() && !signal.aborted;
943
969
  const canTryProxy = Boolean(plan.proxyUrl) && !state.circuitOpen && state.proxyFailures < plan.maxProxyFailures;
944
970
  if (canTryProxy && plan.proxyUrl) {
945
- while (!state.circuitOpen && state.proxyFailures < plan.maxProxyFailures && !signal.aborted) {
971
+ while (!state.circuitOpen && state.proxyFailures < plan.maxProxyFailures && stillCurrent()) {
946
972
  state.proxyFailures += 1;
947
973
  try {
948
974
  const response = await fetch(plan.proxyUrl, {
@@ -952,18 +978,33 @@ async function loadEncryptionKeyEnterprise(context, callbacks, plan, signal, sta
952
978
  cache: "no-store",
953
979
  signal
954
980
  });
981
+ if (!lifecycle.isCurrent()) return "aborted";
955
982
  const contentType = response.headers.get("content-type");
956
983
  const data = await response.arrayBuffer();
984
+ if (!lifecycle.isCurrent()) return "aborted";
957
985
  const head = new TextDecoder().decode(data.slice(0, 64));
958
986
  if (response.ok && !isProxyUpstreamBlocked(response.status, contentType, head)) {
987
+ const key = normalizeAes128Key(data);
988
+ if (!key) {
989
+ callbacks.onError(
990
+ {
991
+ code: 422,
992
+ text: `enc.key proxy returned ${data.byteLength} bytes (need ${AES_128_KEY_BYTES})`
993
+ },
994
+ { ...context, url: plan.proxyUrl },
995
+ response,
996
+ stats
997
+ );
998
+ return "ok";
999
+ }
959
1000
  const end = performance.now();
960
1001
  stats.loading.end = end;
961
- stats.loaded = data.byteLength;
962
- stats.total = data.byteLength;
1002
+ stats.loaded = key.byteLength;
1003
+ stats.total = key.byteLength;
963
1004
  callbacks.onSuccess(
964
1005
  {
965
1006
  url: response.url || plan.proxyUrl,
966
- data,
1007
+ data: key,
967
1008
  code: response.status
968
1009
  },
969
1010
  stats,
@@ -996,25 +1037,22 @@ async function loadEncryptionKeyEnterprise(context, callbacks, plan, signal, sta
996
1037
  return "ok";
997
1038
  } catch (error) {
998
1039
  if (error instanceof DOMException && error.name === "AbortError") {
999
- callbacks.onError(
1000
- { code: 0, text: "enc.key proxy aborted/timeout" },
1001
- context,
1002
- null,
1003
- stats
1004
- );
1005
- return "ok";
1040
+ return "aborted";
1006
1041
  }
1007
1042
  state.circuitOpen = true;
1008
1043
  break;
1009
1044
  }
1010
1045
  }
1046
+ if (!lifecycle.isCurrent()) return "aborted";
1011
1047
  state.circuitOpen = true;
1012
1048
  }
1049
+ if (!lifecycle.isCurrent()) return "aborted";
1013
1050
  const queryResult = await fetchEncryptionKeyBytes(
1014
1051
  plan.signedKeyUrl,
1015
1052
  null,
1016
1053
  signal
1017
1054
  );
1055
+ if (!lifecycle.isCurrent()) return "aborted";
1018
1056
  if (queryResult.ok) {
1019
1057
  reportKeySuccess(
1020
1058
  { ...context, url: plan.signedKeyUrl },
@@ -1023,13 +1061,19 @@ async function loadEncryptionKeyEnterprise(context, callbacks, plan, signal, sta
1023
1061
  );
1024
1062
  return "ok";
1025
1063
  }
1026
- const needsBearer = Boolean(plan.accessToken) && (queryResult.status === 401 || queryResult.status === 403);
1064
+ if (queryResult.status === 0 && signal.aborted) {
1065
+ return "aborted";
1066
+ }
1067
+ const needsBearer = Boolean(plan.accessToken) && (queryResult.status === 401 || queryResult.status === 403 || // Browser-direct: opaque CORS/network on unsigned GET — try Bearer once
1068
+ // Appextech allows ACAO + Authorization (AUTH_MODE=browser, no BFF).
1069
+ queryResult.cors || queryResult.status === 0);
1027
1070
  if (needsBearer && plan.accessToken) {
1028
1071
  const bearerResult = await fetchEncryptionKeyBytes(
1029
1072
  plan.signedKeyUrl,
1030
1073
  plan.accessToken,
1031
1074
  signal
1032
1075
  );
1076
+ if (!lifecycle.isCurrent()) return "aborted";
1033
1077
  if (bearerResult.ok) {
1034
1078
  reportKeySuccess(
1035
1079
  { ...context, url: plan.signedKeyUrl },
@@ -1038,7 +1082,10 @@ async function loadEncryptionKeyEnterprise(context, callbacks, plan, signal, sta
1038
1082
  );
1039
1083
  return "ok";
1040
1084
  }
1041
- if (bearerResult.cors) {
1085
+ if (bearerResult.status === 0 && signal.aborted) {
1086
+ return "aborted";
1087
+ }
1088
+ if (bearerResult.cors || bearerResult.status === 0) {
1042
1089
  return "try_xhr";
1043
1090
  }
1044
1091
  callbacks.onError(
@@ -1066,7 +1113,8 @@ async function loadEncryptionKeyEnterprise(context, callbacks, plan, signal, sta
1066
1113
  );
1067
1114
  return "ok";
1068
1115
  }
1069
- function createAuthParamsLoader(HlsClass, store, options = {}) {
1116
+ function createAuthParamsLoader(HlsModule, store, options = {}) {
1117
+ const HlsClass = HlsModule;
1070
1118
  const BaseLoader = resolveBuiltinHlsLoader(HlsClass);
1071
1119
  rememberBuiltin(HlsClass, BaseLoader);
1072
1120
  const maxProxyFailures = typeof options.keyProxyMaxFailures === "number" && options.keyProxyMaxFailures >= 0 ? options.keyProxyMaxFailures : KEY_PROXY_MAX_FAILURES;
@@ -1075,17 +1123,34 @@ function createAuthParamsLoader(HlsClass, store, options = {}) {
1075
1123
  constructor(config) {
1076
1124
  this.context = null;
1077
1125
  this.keyAbort = null;
1126
+ /** Bumps on each key load so superseded aborts never call onError. */
1127
+ this.keyLoadGeneration = 0;
1128
+ this.pendingKeyCallbacks = null;
1078
1129
  this.inner = new BaseLoader(config);
1079
1130
  this.stats = this.inner.stats;
1080
1131
  }
1081
- abort() {
1132
+ finishKeyAbort() {
1133
+ const cbs = this.pendingKeyCallbacks;
1134
+ this.pendingKeyCallbacks = null;
1082
1135
  this.keyAbort?.abort();
1083
1136
  this.keyAbort = null;
1137
+ if (cbs?.onAbort && this.context) {
1138
+ const stats = createLoaderStats();
1139
+ stats.aborted = true;
1140
+ try {
1141
+ cbs.onAbort(stats, this.context, null);
1142
+ } catch {
1143
+ }
1144
+ }
1145
+ }
1146
+ abort() {
1147
+ this.keyLoadGeneration += 1;
1148
+ this.finishKeyAbort();
1084
1149
  this.inner.abort();
1085
1150
  }
1086
1151
  destroy() {
1087
- this.keyAbort?.abort();
1088
- this.keyAbort = null;
1152
+ this.keyLoadGeneration += 1;
1153
+ this.finishKeyAbort();
1089
1154
  this.context = null;
1090
1155
  this.inner.destroy();
1091
1156
  }
@@ -1117,10 +1182,19 @@ function createAuthParamsLoader(HlsClass, store, options = {}) {
1117
1182
  );
1118
1183
  const proxyUrl = proxyConfigured && !keyProxyState.circuitOpen ? rewriteHlsRequestUrl(originalUrl, store, options) : null;
1119
1184
  const accessToken = options.getKeyAccessToken?.() ?? null;
1120
- this.keyAbort?.abort();
1185
+ if (this.keyAbort || this.pendingKeyCallbacks) {
1186
+ this.keyLoadGeneration += 1;
1187
+ this.finishKeyAbort();
1188
+ }
1189
+ const generation = ++this.keyLoadGeneration;
1121
1190
  this.keyAbort = new AbortController();
1191
+ this.pendingKeyCallbacks = callbacks;
1192
+ context.responseType = "arraybuffer";
1122
1193
  const timeoutMs = typeof config?.timeout === "number" && config.timeout > 0 ? config.timeout : 2e4;
1123
1194
  const timer = setTimeout(() => this.keyAbort?.abort(), timeoutMs);
1195
+ const lifecycle = {
1196
+ isCurrent: () => generation === this.keyLoadGeneration
1197
+ };
1124
1198
  void loadEncryptionKeyEnterprise(
1125
1199
  context,
1126
1200
  callbacks,
@@ -1131,13 +1205,22 @@ function createAuthParamsLoader(HlsClass, store, options = {}) {
1131
1205
  maxProxyFailures
1132
1206
  },
1133
1207
  this.keyAbort.signal,
1134
- keyProxyState
1208
+ keyProxyState,
1209
+ lifecycle
1135
1210
  ).then((result) => {
1211
+ if (!lifecycle.isCurrent()) return;
1212
+ if (result === "aborted") {
1213
+ this.finishKeyAbort();
1214
+ return;
1215
+ }
1216
+ this.pendingKeyCallbacks = null;
1136
1217
  if (result === "try_xhr") {
1137
1218
  context.url = signedKeyUrl;
1138
1219
  this.inner.load(context, config, callbacks);
1139
1220
  }
1140
1221
  }).catch((error) => {
1222
+ if (!lifecycle.isCurrent()) return;
1223
+ this.pendingKeyCallbacks = null;
1141
1224
  callbacks.onError(
1142
1225
  {
1143
1226
  code: 0,
@@ -1168,6 +1251,10 @@ function createAuthParamsLoader(HlsClass, store, options = {}) {
1168
1251
  function isAuthHttpStatus(code) {
1169
1252
  return code === 401 || code === 403;
1170
1253
  }
1254
+ function isGeckoBrowser() {
1255
+ if (typeof navigator === "undefined") return false;
1256
+ return /firefox|gecko\//i.test(navigator.userAgent);
1257
+ }
1171
1258
  var HlsEngine = class {
1172
1259
  constructor(options) {
1173
1260
  this.options = options;
@@ -1183,6 +1270,8 @@ var HlsEngine = class {
1183
1270
  this.audioTracks = [];
1184
1271
  this.audioTrackSelection = 0;
1185
1272
  this.nativeTextTrackCleanup = null;
1273
+ this.startPositionApplied = false;
1274
+ this.autoplayApplied = false;
1186
1275
  }
1187
1276
  async start() {
1188
1277
  const { videoEl, playbackUrl, startMuted, volume } = this.options;
@@ -1229,10 +1318,27 @@ var HlsEngine = class {
1229
1318
  getKeyAccessToken,
1230
1319
  keyProxyMaxFailures: this.options.keyProxyMaxFailures
1231
1320
  });
1321
+ const enableWorker = hlsConfig.enableWorker && !isGeckoBrowser();
1322
+ if (hlsConfig.enableWorker && !enableWorker) {
1323
+ this.options.logger.warn(
1324
+ "Disabling hls.js worker on Firefox to avoid MSE bufferAppendError"
1325
+ );
1326
+ }
1232
1327
  this.hls = new Hls({
1233
- enableWorker: hlsConfig.enableWorker,
1328
+ enableWorker,
1329
+ // LL mode on short encrypted VOD drives aggressive ABR thrash → MSE errors.
1234
1330
  lowLatencyMode: hlsConfig.lowLatencyMode,
1235
1331
  backBufferLength: hlsConfig.backBufferLength,
1332
+ // Stabilize ABR: avoid first-frag-only bandwidth test that immediately
1333
+ // hops levels on single-segment Appextech VODs (HAR showed truncated
1334
+ // 720p body vs Content-Length during level switches).
1335
+ testBandwidth: false,
1336
+ abrEwmaDefaultEstimate: 5e5,
1337
+ abrBandWidthFactor: 0.8,
1338
+ abrBandWidthUpFactor: 0.5,
1339
+ maxStarvationDelay: 4,
1340
+ capLevelToPlayerSize: true,
1341
+ startLevel: -1,
1236
1342
  loader
1237
1343
  });
1238
1344
  this.hls.attachMedia(videoEl);
@@ -1250,7 +1356,11 @@ var HlsEngine = class {
1250
1356
  this.syncHlsSubtitleTracks();
1251
1357
  this.syncHlsAudioTracks();
1252
1358
  callbacks.onStateChange?.("ready");
1253
- void this.applyStartPositionAndAutoplay();
1359
+ void this.applyAutoplayOnly();
1360
+ });
1361
+ this.hls.on(Hls.Events.FRAG_BUFFERED, () => {
1362
+ if (this.destroyed || this.startPositionApplied) return;
1363
+ void this.applyStartPositionOnly();
1254
1364
  });
1255
1365
  this.hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, () => {
1256
1366
  if (this.destroyed) return;
@@ -1275,8 +1385,16 @@ var HlsEngine = class {
1275
1385
  callbacks.onAuthRecoveryNeeded?.("key_error");
1276
1386
  if (!data.fatal) return;
1277
1387
  } else if (!data.fatal) {
1388
+ if (!status) {
1389
+ return;
1390
+ }
1278
1391
  callbacks.onFatalError?.(
1279
- status ? `Encryption key load failed (HTTP ${status}).` : "Encryption key load failed (network/CORS). Ensure signed query params are applied, or use keyProxyUrlBuilder / getKeyAccessToken when the key host requires API Bearer."
1392
+ `Encryption key load failed (HTTP ${status}).`
1393
+ );
1394
+ return;
1395
+ } else if (!status) {
1396
+ callbacks.onFatalError?.(
1397
+ "Encryption key load aborted or failed. Retry playback; if it persists, check the key proxy / CDN path."
1280
1398
  );
1281
1399
  return;
1282
1400
  }
@@ -1301,30 +1419,67 @@ var HlsEngine = class {
1301
1419
  }
1302
1420
  if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
1303
1421
  this.mediaRecoveryCount += 1;
1304
- if (this.mediaRecoveryCount <= hlsConfig.fatalRecoveryLimit) {
1305
- this.hls?.recoverMediaError();
1422
+ if (this.mediaRecoveryCount > hlsConfig.fatalRecoveryLimit) {
1423
+ callbacks.onFatalError?.(data.details || "Fatal HLS error");
1306
1424
  return;
1307
1425
  }
1426
+ if (data.details === Hls.ErrorDetails.BUFFER_APPEND_ERROR || data.details === Hls.ErrorDetails.BUFFER_FULL_ERROR) {
1427
+ this.lockPlaybackToSafeLevel();
1428
+ }
1429
+ if (this.mediaRecoveryCount === 2) {
1430
+ this.hls?.swapAudioCodec();
1431
+ }
1432
+ this.hls?.recoverMediaError();
1433
+ return;
1308
1434
  }
1309
1435
  callbacks.onFatalError?.(data.details || "Fatal HLS error");
1310
1436
  }
1311
- async applyStartPositionAndAutoplay() {
1312
- if (this.destroyed) return;
1313
- const { videoEl, autoplay, startPosition } = this.options;
1314
- if (typeof startPosition === "number" && startPosition > 0) {
1315
- const seek = () => {
1316
- if (this.destroyed) return;
1317
- try {
1318
- videoEl.currentTime = startPosition;
1319
- } catch {
1320
- }
1321
- };
1322
- if (videoEl.seekable.length > 0) {
1323
- seek();
1324
- } else {
1325
- videoEl.addEventListener("loadedmetadata", seek, { once: true });
1437
+ /** Pin the next load to the lowest ladder rung after MSE append failures. */
1438
+ lockPlaybackToSafeLevel() {
1439
+ if (!this.hls || this.qualities.length === 0) return;
1440
+ try {
1441
+ this.hls.currentLevel = 0;
1442
+ this.hls.loadLevel = 0;
1443
+ this.hls.nextLoadLevel = 0;
1444
+ } catch {
1445
+ }
1446
+ }
1447
+ clampStartPosition(raw, videoEl) {
1448
+ let t = Math.max(0, raw);
1449
+ const duration = videoEl.duration;
1450
+ if (Number.isFinite(duration) && duration > 0) {
1451
+ t = Math.min(t, Math.max(0, duration - 0.35));
1452
+ }
1453
+ if (videoEl.seekable.length > 0) {
1454
+ const end = videoEl.seekable.end(videoEl.seekable.length - 1);
1455
+ if (Number.isFinite(end)) {
1456
+ t = Math.min(t, end);
1326
1457
  }
1327
1458
  }
1459
+ return t;
1460
+ }
1461
+ async applyStartPositionOnly() {
1462
+ if (this.destroyed || this.startPositionApplied) return;
1463
+ const { videoEl, startPosition } = this.options;
1464
+ this.startPositionApplied = true;
1465
+ if (typeof startPosition !== "number" || startPosition <= 0) return;
1466
+ const seek = () => {
1467
+ if (this.destroyed) return;
1468
+ try {
1469
+ videoEl.currentTime = this.clampStartPosition(startPosition, videoEl);
1470
+ } catch {
1471
+ }
1472
+ };
1473
+ if (videoEl.seekable.length > 0 || Number.isFinite(videoEl.duration)) {
1474
+ seek();
1475
+ } else {
1476
+ videoEl.addEventListener("loadedmetadata", seek, { once: true });
1477
+ }
1478
+ }
1479
+ async applyAutoplayOnly() {
1480
+ if (this.destroyed || this.autoplayApplied) return;
1481
+ this.autoplayApplied = true;
1482
+ const { videoEl, autoplay } = this.options;
1328
1483
  if (!autoplay) return;
1329
1484
  try {
1330
1485
  await videoEl.play();
@@ -1339,6 +1494,10 @@ var HlsEngine = class {
1339
1494
  }
1340
1495
  }
1341
1496
  }
1497
+ async applyStartPositionAndAutoplay() {
1498
+ await this.applyStartPositionOnly();
1499
+ await this.applyAutoplayOnly();
1500
+ }
1342
1501
  bindMediaEvents() {
1343
1502
  const { videoEl, callbacks } = this.options;
1344
1503
  videoEl.addEventListener(
@@ -1582,11 +1741,20 @@ function createVideoPlayer(options) {
1582
1741
  }
1583
1742
  events.emit("progress", { bufferedEnd });
1584
1743
  };
1744
+ const onVolumeChange = () => {
1745
+ if (destroyed) return;
1746
+ events.emit("volumechange", {
1747
+ volume: el.volume,
1748
+ muted: el.muted
1749
+ });
1750
+ };
1585
1751
  el.addEventListener("timeupdate", onTimeUpdate);
1586
1752
  el.addEventListener("progress", onProgress);
1753
+ el.addEventListener("volumechange", onVolumeChange);
1587
1754
  mediaCleanup = () => {
1588
1755
  el.removeEventListener("timeupdate", onTimeUpdate);
1589
1756
  el.removeEventListener("progress", onProgress);
1757
+ el.removeEventListener("volumechange", onVolumeChange);
1590
1758
  };
1591
1759
  };
1592
1760
  const authManager = new AuthManager(
@@ -1639,6 +1807,9 @@ function createVideoPlayer(options) {
1639
1807
  isLiveStream = Boolean(source.live);
1640
1808
  }
1641
1809
  const el = ensureVideoElement();
1810
+ if (isLiveStream) {
1811
+ el.playbackRate = 1;
1812
+ }
1642
1813
  bindMediaProgressEvents(el);
1643
1814
  engine?.destroy();
1644
1815
  engine = new HlsEngine({
@@ -1727,6 +1898,12 @@ function createVideoPlayer(options) {
1727
1898
  }
1728
1899
  });
1729
1900
  await engine.start();
1901
+ if (!destroyed && videoEl) {
1902
+ events.emit("volumechange", {
1903
+ volume: videoEl.volume,
1904
+ muted: videoEl.muted
1905
+ });
1906
+ }
1730
1907
  if (!options.sourceUrl && options.playback) {
1731
1908
  refreshManager?.stop();
1732
1909
  refreshManager = new RefreshManager({
@@ -1846,7 +2023,8 @@ function createVideoPlayer(options) {
1846
2023
  return videoEl?.muted ?? false;
1847
2024
  },
1848
2025
  setPlaybackRate(rate) {
1849
- ensureVideoElement().playbackRate = rate;
2026
+ const el = ensureVideoElement();
2027
+ el.playbackRate = isLiveStream ? 1 : rate;
1850
2028
  },
1851
2029
  getPlaybackRate() {
1852
2030
  return videoEl?.playbackRate ?? 1;