@codenkay/video-nsgplayer-core 3.0.4 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - Fix minor issues
8
+
3
9
  ## 3.0.4
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -40,3 +40,15 @@ await player.play();
40
40
  ```
41
41
 
42
42
  See repository `docs/` for configuration, authentication, and security.
43
+
44
+ ## Samples
45
+
46
+ Framework adapters wrap this engine. Clone a ready playground:
47
+
48
+ | Lab | Repo |
49
+ |-----|------|
50
+ | React (Vite, no BFF) | [nsgplayer-sample-react](https://github.com/codenannu/nsgplayer-sample-react) |
51
+ | Next.js + thin BFF | [nsgplayer-sample-nextjs](https://github.com/codenannu/nsgplayer-sample-nextjs) |
52
+ | Angular (headless) | [nsgplayer-sample-angular](https://github.com/codenannu/nsgplayer-sample-angular) |
53
+
54
+ Full matrix: [docs/samples.md](../../docs/samples.md).
package/dist/index.cjs CHANGED
@@ -283,7 +283,8 @@ var DEFAULT_PLAYER_CONFIG = {
283
283
  engine: "hls",
284
284
  hls: {
285
285
  enableWorker: true,
286
- lowLatencyMode: true,
286
+ // VOD + AES-128: LL mode causes ABR thrash → bufferAppendError on MSE.
287
+ lowLatencyMode: false,
287
288
  backBufferLength: 90,
288
289
  fatalRecoveryLimit: 3
289
290
  }
@@ -989,29 +990,52 @@ async function fetchEncryptionKeyBytes(url, accessToken, signal) {
989
990
  };
990
991
  }
991
992
  }
993
+ var AES_128_KEY_BYTES = 16;
994
+ function normalizeAes128Key(data) {
995
+ if (data.byteLength === AES_128_KEY_BYTES) {
996
+ return data.slice(0);
997
+ }
998
+ return null;
999
+ }
992
1000
  function reportKeySuccess(context, callbacks, result) {
1001
+ const key = normalizeAes128Key(result.data);
1002
+ if (!key) {
1003
+ const stats2 = createLoaderStats();
1004
+ callbacks.onError(
1005
+ {
1006
+ code: 422,
1007
+ text: `enc.key must be ${AES_128_KEY_BYTES} bytes (got ${result.data.byteLength})`
1008
+ },
1009
+ context,
1010
+ result.response,
1011
+ stats2
1012
+ );
1013
+ return false;
1014
+ }
993
1015
  const stats = createLoaderStats();
994
1016
  const end = performance.now();
995
1017
  stats.loading.first = stats.loading.start;
996
1018
  stats.loading.end = end;
997
- stats.loaded = result.data.byteLength;
998
- stats.total = result.data.byteLength;
1019
+ stats.loaded = key.byteLength;
1020
+ stats.total = key.byteLength;
999
1021
  callbacks.onSuccess(
1000
1022
  {
1001
1023
  url: result.url,
1002
- data: result.data,
1024
+ data: key,
1003
1025
  code: result.response.status
1004
1026
  },
1005
1027
  stats,
1006
1028
  context,
1007
1029
  result.response
1008
1030
  );
1031
+ return true;
1009
1032
  }
1010
- async function loadEncryptionKeyEnterprise(context, callbacks, plan, signal, state) {
1033
+ async function loadEncryptionKeyEnterprise(context, callbacks, plan, signal, state, lifecycle) {
1011
1034
  const stats = createLoaderStats();
1035
+ const stillCurrent = () => lifecycle.isCurrent() && !signal.aborted;
1012
1036
  const canTryProxy = Boolean(plan.proxyUrl) && !state.circuitOpen && state.proxyFailures < plan.maxProxyFailures;
1013
1037
  if (canTryProxy && plan.proxyUrl) {
1014
- while (!state.circuitOpen && state.proxyFailures < plan.maxProxyFailures && !signal.aborted) {
1038
+ while (!state.circuitOpen && state.proxyFailures < plan.maxProxyFailures && stillCurrent()) {
1015
1039
  state.proxyFailures += 1;
1016
1040
  try {
1017
1041
  const response = await fetch(plan.proxyUrl, {
@@ -1021,18 +1045,33 @@ async function loadEncryptionKeyEnterprise(context, callbacks, plan, signal, sta
1021
1045
  cache: "no-store",
1022
1046
  signal
1023
1047
  });
1048
+ if (!lifecycle.isCurrent()) return "aborted";
1024
1049
  const contentType = response.headers.get("content-type");
1025
1050
  const data = await response.arrayBuffer();
1051
+ if (!lifecycle.isCurrent()) return "aborted";
1026
1052
  const head = new TextDecoder().decode(data.slice(0, 64));
1027
1053
  if (response.ok && !isProxyUpstreamBlocked(response.status, contentType, head)) {
1054
+ const key = normalizeAes128Key(data);
1055
+ if (!key) {
1056
+ callbacks.onError(
1057
+ {
1058
+ code: 422,
1059
+ text: `enc.key proxy returned ${data.byteLength} bytes (need ${AES_128_KEY_BYTES})`
1060
+ },
1061
+ { ...context, url: plan.proxyUrl },
1062
+ response,
1063
+ stats
1064
+ );
1065
+ return "ok";
1066
+ }
1028
1067
  const end = performance.now();
1029
1068
  stats.loading.end = end;
1030
- stats.loaded = data.byteLength;
1031
- stats.total = data.byteLength;
1069
+ stats.loaded = key.byteLength;
1070
+ stats.total = key.byteLength;
1032
1071
  callbacks.onSuccess(
1033
1072
  {
1034
1073
  url: response.url || plan.proxyUrl,
1035
- data,
1074
+ data: key,
1036
1075
  code: response.status
1037
1076
  },
1038
1077
  stats,
@@ -1065,25 +1104,22 @@ async function loadEncryptionKeyEnterprise(context, callbacks, plan, signal, sta
1065
1104
  return "ok";
1066
1105
  } catch (error) {
1067
1106
  if (error instanceof DOMException && error.name === "AbortError") {
1068
- callbacks.onError(
1069
- { code: 0, text: "enc.key proxy aborted/timeout" },
1070
- context,
1071
- null,
1072
- stats
1073
- );
1074
- return "ok";
1107
+ return "aborted";
1075
1108
  }
1076
1109
  state.circuitOpen = true;
1077
1110
  break;
1078
1111
  }
1079
1112
  }
1113
+ if (!lifecycle.isCurrent()) return "aborted";
1080
1114
  state.circuitOpen = true;
1081
1115
  }
1116
+ if (!lifecycle.isCurrent()) return "aborted";
1082
1117
  const queryResult = await fetchEncryptionKeyBytes(
1083
1118
  plan.signedKeyUrl,
1084
1119
  null,
1085
1120
  signal
1086
1121
  );
1122
+ if (!lifecycle.isCurrent()) return "aborted";
1087
1123
  if (queryResult.ok) {
1088
1124
  reportKeySuccess(
1089
1125
  { ...context, url: plan.signedKeyUrl },
@@ -1092,6 +1128,9 @@ async function loadEncryptionKeyEnterprise(context, callbacks, plan, signal, sta
1092
1128
  );
1093
1129
  return "ok";
1094
1130
  }
1131
+ if (queryResult.status === 0 && signal.aborted) {
1132
+ return "aborted";
1133
+ }
1095
1134
  const needsBearer = Boolean(plan.accessToken) && (queryResult.status === 401 || queryResult.status === 403 || // Browser-direct: opaque CORS/network on unsigned GET — try Bearer once
1096
1135
  // Appextech allows ACAO + Authorization (AUTH_MODE=browser, no BFF).
1097
1136
  queryResult.cors || queryResult.status === 0);
@@ -1101,6 +1140,7 @@ async function loadEncryptionKeyEnterprise(context, callbacks, plan, signal, sta
1101
1140
  plan.accessToken,
1102
1141
  signal
1103
1142
  );
1143
+ if (!lifecycle.isCurrent()) return "aborted";
1104
1144
  if (bearerResult.ok) {
1105
1145
  reportKeySuccess(
1106
1146
  { ...context, url: plan.signedKeyUrl },
@@ -1109,6 +1149,9 @@ async function loadEncryptionKeyEnterprise(context, callbacks, plan, signal, sta
1109
1149
  );
1110
1150
  return "ok";
1111
1151
  }
1152
+ if (bearerResult.status === 0 && signal.aborted) {
1153
+ return "aborted";
1154
+ }
1112
1155
  if (bearerResult.cors || bearerResult.status === 0) {
1113
1156
  return "try_xhr";
1114
1157
  }
@@ -1147,17 +1190,34 @@ function createAuthParamsLoader(HlsModule, store, options = {}) {
1147
1190
  constructor(config) {
1148
1191
  this.context = null;
1149
1192
  this.keyAbort = null;
1193
+ /** Bumps on each key load so superseded aborts never call onError. */
1194
+ this.keyLoadGeneration = 0;
1195
+ this.pendingKeyCallbacks = null;
1150
1196
  this.inner = new BaseLoader(config);
1151
1197
  this.stats = this.inner.stats;
1152
1198
  }
1153
- abort() {
1199
+ finishKeyAbort() {
1200
+ const cbs = this.pendingKeyCallbacks;
1201
+ this.pendingKeyCallbacks = null;
1154
1202
  this.keyAbort?.abort();
1155
1203
  this.keyAbort = null;
1204
+ if (cbs?.onAbort && this.context) {
1205
+ const stats = createLoaderStats();
1206
+ stats.aborted = true;
1207
+ try {
1208
+ cbs.onAbort(stats, this.context, null);
1209
+ } catch {
1210
+ }
1211
+ }
1212
+ }
1213
+ abort() {
1214
+ this.keyLoadGeneration += 1;
1215
+ this.finishKeyAbort();
1156
1216
  this.inner.abort();
1157
1217
  }
1158
1218
  destroy() {
1159
- this.keyAbort?.abort();
1160
- this.keyAbort = null;
1219
+ this.keyLoadGeneration += 1;
1220
+ this.finishKeyAbort();
1161
1221
  this.context = null;
1162
1222
  this.inner.destroy();
1163
1223
  }
@@ -1189,10 +1249,19 @@ function createAuthParamsLoader(HlsModule, store, options = {}) {
1189
1249
  );
1190
1250
  const proxyUrl = proxyConfigured && !keyProxyState.circuitOpen ? rewriteHlsRequestUrl(originalUrl, store, options) : null;
1191
1251
  const accessToken = options.getKeyAccessToken?.() ?? null;
1192
- this.keyAbort?.abort();
1252
+ if (this.keyAbort || this.pendingKeyCallbacks) {
1253
+ this.keyLoadGeneration += 1;
1254
+ this.finishKeyAbort();
1255
+ }
1256
+ const generation = ++this.keyLoadGeneration;
1193
1257
  this.keyAbort = new AbortController();
1258
+ this.pendingKeyCallbacks = callbacks;
1259
+ context.responseType = "arraybuffer";
1194
1260
  const timeoutMs = typeof config?.timeout === "number" && config.timeout > 0 ? config.timeout : 2e4;
1195
1261
  const timer = setTimeout(() => this.keyAbort?.abort(), timeoutMs);
1262
+ const lifecycle = {
1263
+ isCurrent: () => generation === this.keyLoadGeneration
1264
+ };
1196
1265
  void loadEncryptionKeyEnterprise(
1197
1266
  context,
1198
1267
  callbacks,
@@ -1203,13 +1272,22 @@ function createAuthParamsLoader(HlsModule, store, options = {}) {
1203
1272
  maxProxyFailures
1204
1273
  },
1205
1274
  this.keyAbort.signal,
1206
- keyProxyState
1275
+ keyProxyState,
1276
+ lifecycle
1207
1277
  ).then((result) => {
1278
+ if (!lifecycle.isCurrent()) return;
1279
+ if (result === "aborted") {
1280
+ this.finishKeyAbort();
1281
+ return;
1282
+ }
1283
+ this.pendingKeyCallbacks = null;
1208
1284
  if (result === "try_xhr") {
1209
1285
  context.url = signedKeyUrl;
1210
1286
  this.inner.load(context, config, callbacks);
1211
1287
  }
1212
1288
  }).catch((error) => {
1289
+ if (!lifecycle.isCurrent()) return;
1290
+ this.pendingKeyCallbacks = null;
1213
1291
  callbacks.onError(
1214
1292
  {
1215
1293
  code: 0,
@@ -1240,6 +1318,10 @@ function createAuthParamsLoader(HlsModule, store, options = {}) {
1240
1318
  function isAuthHttpStatus(code) {
1241
1319
  return code === 401 || code === 403;
1242
1320
  }
1321
+ function isGeckoBrowser() {
1322
+ if (typeof navigator === "undefined") return false;
1323
+ return /firefox|gecko\//i.test(navigator.userAgent);
1324
+ }
1243
1325
  var HlsEngine = class {
1244
1326
  constructor(options) {
1245
1327
  this.options = options;
@@ -1255,6 +1337,8 @@ var HlsEngine = class {
1255
1337
  this.audioTracks = [];
1256
1338
  this.audioTrackSelection = 0;
1257
1339
  this.nativeTextTrackCleanup = null;
1340
+ this.startPositionApplied = false;
1341
+ this.autoplayApplied = false;
1258
1342
  }
1259
1343
  async start() {
1260
1344
  const { videoEl, playbackUrl, startMuted, volume } = this.options;
@@ -1301,10 +1385,27 @@ var HlsEngine = class {
1301
1385
  getKeyAccessToken,
1302
1386
  keyProxyMaxFailures: this.options.keyProxyMaxFailures
1303
1387
  });
1388
+ const enableWorker = hlsConfig.enableWorker && !isGeckoBrowser();
1389
+ if (hlsConfig.enableWorker && !enableWorker) {
1390
+ this.options.logger.warn(
1391
+ "Disabling hls.js worker on Firefox to avoid MSE bufferAppendError"
1392
+ );
1393
+ }
1304
1394
  this.hls = new Hls({
1305
- enableWorker: hlsConfig.enableWorker,
1395
+ enableWorker,
1396
+ // LL mode on short encrypted VOD drives aggressive ABR thrash → MSE errors.
1306
1397
  lowLatencyMode: hlsConfig.lowLatencyMode,
1307
1398
  backBufferLength: hlsConfig.backBufferLength,
1399
+ // Stabilize ABR: avoid first-frag-only bandwidth test that immediately
1400
+ // hops levels on single-segment Appextech VODs (HAR showed truncated
1401
+ // 720p body vs Content-Length during level switches).
1402
+ testBandwidth: false,
1403
+ abrEwmaDefaultEstimate: 5e5,
1404
+ abrBandWidthFactor: 0.8,
1405
+ abrBandWidthUpFactor: 0.5,
1406
+ maxStarvationDelay: 4,
1407
+ capLevelToPlayerSize: true,
1408
+ startLevel: -1,
1308
1409
  loader
1309
1410
  });
1310
1411
  this.hls.attachMedia(videoEl);
@@ -1322,7 +1423,11 @@ var HlsEngine = class {
1322
1423
  this.syncHlsSubtitleTracks();
1323
1424
  this.syncHlsAudioTracks();
1324
1425
  callbacks.onStateChange?.("ready");
1325
- void this.applyStartPositionAndAutoplay();
1426
+ void this.applyAutoplayOnly();
1427
+ });
1428
+ this.hls.on(Hls.Events.FRAG_BUFFERED, () => {
1429
+ if (this.destroyed || this.startPositionApplied) return;
1430
+ void this.applyStartPositionOnly();
1326
1431
  });
1327
1432
  this.hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, () => {
1328
1433
  if (this.destroyed) return;
@@ -1347,8 +1452,16 @@ var HlsEngine = class {
1347
1452
  callbacks.onAuthRecoveryNeeded?.("key_error");
1348
1453
  if (!data.fatal) return;
1349
1454
  } else if (!data.fatal) {
1455
+ if (!status) {
1456
+ return;
1457
+ }
1350
1458
  callbacks.onFatalError?.(
1351
- status ? `Encryption key load failed (HTTP ${status}).` : "Encryption key load failed (network/CORS). For browser-direct mode the key host must allow CORS (and Authorization if Bearer is required). Otherwise use keyProxyUrlBuilder (same-origin BFF) when the server IP can reach Appextech."
1459
+ `Encryption key load failed (HTTP ${status}).`
1460
+ );
1461
+ return;
1462
+ } else if (!status) {
1463
+ callbacks.onFatalError?.(
1464
+ "Encryption key load aborted or failed. Retry playback; if it persists, check the key proxy / CDN path."
1352
1465
  );
1353
1466
  return;
1354
1467
  }
@@ -1373,30 +1486,67 @@ var HlsEngine = class {
1373
1486
  }
1374
1487
  if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
1375
1488
  this.mediaRecoveryCount += 1;
1376
- if (this.mediaRecoveryCount <= hlsConfig.fatalRecoveryLimit) {
1377
- this.hls?.recoverMediaError();
1489
+ if (this.mediaRecoveryCount > hlsConfig.fatalRecoveryLimit) {
1490
+ callbacks.onFatalError?.(data.details || "Fatal HLS error");
1378
1491
  return;
1379
1492
  }
1493
+ if (data.details === Hls.ErrorDetails.BUFFER_APPEND_ERROR || data.details === Hls.ErrorDetails.BUFFER_FULL_ERROR) {
1494
+ this.lockPlaybackToSafeLevel();
1495
+ }
1496
+ if (this.mediaRecoveryCount === 2) {
1497
+ this.hls?.swapAudioCodec();
1498
+ }
1499
+ this.hls?.recoverMediaError();
1500
+ return;
1380
1501
  }
1381
1502
  callbacks.onFatalError?.(data.details || "Fatal HLS error");
1382
1503
  }
1383
- async applyStartPositionAndAutoplay() {
1384
- if (this.destroyed) return;
1385
- const { videoEl, autoplay, startPosition } = this.options;
1386
- if (typeof startPosition === "number" && startPosition > 0) {
1387
- const seek = () => {
1388
- if (this.destroyed) return;
1389
- try {
1390
- videoEl.currentTime = startPosition;
1391
- } catch {
1392
- }
1393
- };
1394
- if (videoEl.seekable.length > 0) {
1395
- seek();
1396
- } else {
1397
- videoEl.addEventListener("loadedmetadata", seek, { once: true });
1504
+ /** Pin the next load to the lowest ladder rung after MSE append failures. */
1505
+ lockPlaybackToSafeLevel() {
1506
+ if (!this.hls || this.qualities.length === 0) return;
1507
+ try {
1508
+ this.hls.currentLevel = 0;
1509
+ this.hls.loadLevel = 0;
1510
+ this.hls.nextLoadLevel = 0;
1511
+ } catch {
1512
+ }
1513
+ }
1514
+ clampStartPosition(raw, videoEl) {
1515
+ let t = Math.max(0, raw);
1516
+ const duration = videoEl.duration;
1517
+ if (Number.isFinite(duration) && duration > 0) {
1518
+ t = Math.min(t, Math.max(0, duration - 0.35));
1519
+ }
1520
+ if (videoEl.seekable.length > 0) {
1521
+ const end = videoEl.seekable.end(videoEl.seekable.length - 1);
1522
+ if (Number.isFinite(end)) {
1523
+ t = Math.min(t, end);
1398
1524
  }
1399
1525
  }
1526
+ return t;
1527
+ }
1528
+ async applyStartPositionOnly() {
1529
+ if (this.destroyed || this.startPositionApplied) return;
1530
+ const { videoEl, startPosition } = this.options;
1531
+ this.startPositionApplied = true;
1532
+ if (typeof startPosition !== "number" || startPosition <= 0) return;
1533
+ const seek = () => {
1534
+ if (this.destroyed) return;
1535
+ try {
1536
+ videoEl.currentTime = this.clampStartPosition(startPosition, videoEl);
1537
+ } catch {
1538
+ }
1539
+ };
1540
+ if (videoEl.seekable.length > 0 || Number.isFinite(videoEl.duration)) {
1541
+ seek();
1542
+ } else {
1543
+ videoEl.addEventListener("loadedmetadata", seek, { once: true });
1544
+ }
1545
+ }
1546
+ async applyAutoplayOnly() {
1547
+ if (this.destroyed || this.autoplayApplied) return;
1548
+ this.autoplayApplied = true;
1549
+ const { videoEl, autoplay } = this.options;
1400
1550
  if (!autoplay) return;
1401
1551
  try {
1402
1552
  await videoEl.play();
@@ -1411,6 +1561,10 @@ var HlsEngine = class {
1411
1561
  }
1412
1562
  }
1413
1563
  }
1564
+ async applyStartPositionAndAutoplay() {
1565
+ await this.applyStartPositionOnly();
1566
+ await this.applyAutoplayOnly();
1567
+ }
1414
1568
  bindMediaEvents() {
1415
1569
  const { videoEl, callbacks } = this.options;
1416
1570
  videoEl.addEventListener(
@@ -1654,11 +1808,20 @@ function createVideoPlayer(options) {
1654
1808
  }
1655
1809
  events.emit("progress", { bufferedEnd });
1656
1810
  };
1811
+ const onVolumeChange = () => {
1812
+ if (destroyed) return;
1813
+ events.emit("volumechange", {
1814
+ volume: el.volume,
1815
+ muted: el.muted
1816
+ });
1817
+ };
1657
1818
  el.addEventListener("timeupdate", onTimeUpdate);
1658
1819
  el.addEventListener("progress", onProgress);
1820
+ el.addEventListener("volumechange", onVolumeChange);
1659
1821
  mediaCleanup = () => {
1660
1822
  el.removeEventListener("timeupdate", onTimeUpdate);
1661
1823
  el.removeEventListener("progress", onProgress);
1824
+ el.removeEventListener("volumechange", onVolumeChange);
1662
1825
  };
1663
1826
  };
1664
1827
  const authManager = new AuthManager(
@@ -1711,6 +1874,9 @@ function createVideoPlayer(options) {
1711
1874
  isLiveStream = Boolean(source.live);
1712
1875
  }
1713
1876
  const el = ensureVideoElement();
1877
+ if (isLiveStream) {
1878
+ el.playbackRate = 1;
1879
+ }
1714
1880
  bindMediaProgressEvents(el);
1715
1881
  engine?.destroy();
1716
1882
  engine = new HlsEngine({
@@ -1799,6 +1965,12 @@ function createVideoPlayer(options) {
1799
1965
  }
1800
1966
  });
1801
1967
  await engine.start();
1968
+ if (!destroyed && videoEl) {
1969
+ events.emit("volumechange", {
1970
+ volume: videoEl.volume,
1971
+ muted: videoEl.muted
1972
+ });
1973
+ }
1802
1974
  if (!options.sourceUrl && options.playback) {
1803
1975
  refreshManager?.stop();
1804
1976
  refreshManager = new RefreshManager({
@@ -1918,7 +2090,8 @@ function createVideoPlayer(options) {
1918
2090
  return videoEl?.muted ?? false;
1919
2091
  },
1920
2092
  setPlaybackRate(rate) {
1921
- ensureVideoElement().playbackRate = rate;
2093
+ const el = ensureVideoElement();
2094
+ el.playbackRate = isLiveStream ? 1 : rate;
1922
2095
  },
1923
2096
  getPlaybackRate() {
1924
2097
  return videoEl?.playbackRate ?? 1;