@codenkay/video-nsgplayer-core 3.0.4 → 5.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.d.cts CHANGED
@@ -452,17 +452,6 @@ type HlsEngineOptions = {
452
452
  logger: Logger;
453
453
  callbacks: HlsEngineCallbacks;
454
454
  };
455
- /**
456
- * Dual-path HLS engine: hls.js when supported, otherwise native Safari HLS.
457
- *
458
- * Signed CDN playback requires auth query params on every HLS request
459
- * (playlists, segments, and AES `enc.key` on CDN hosts).
460
- *
461
- * Optional `streaming.keyProxyUrlBuilder` (same-origin BFF) or
462
- * `streaming.getKeyAccessToken` (Bearer only after signed-query 401/403).
463
- * CDN keys prefer signed query without Authorization (no CORS preflight).
464
- * when query-signed CDN access is insufficient.
465
- */
466
455
  declare class HlsEngine {
467
456
  private readonly options;
468
457
  private hls;
@@ -477,10 +466,17 @@ declare class HlsEngine {
477
466
  private audioTracks;
478
467
  private audioTrackSelection;
479
468
  private nativeTextTrackCleanup;
469
+ private startPositionApplied;
470
+ private autoplayApplied;
480
471
  constructor(options: HlsEngineOptions);
481
472
  start(): Promise<void>;
482
473
  private startWithHlsJs;
483
474
  private handleHlsError;
475
+ /** Pin the next load to the lowest ladder rung after MSE append failures. */
476
+ private lockPlaybackToSafeLevel;
477
+ private clampStartPosition;
478
+ private applyStartPositionOnly;
479
+ private applyAutoplayOnly;
484
480
  private applyStartPositionAndAutoplay;
485
481
  private bindMediaEvents;
486
482
  updateAuthParams(): void;
package/dist/index.d.ts CHANGED
@@ -452,17 +452,6 @@ type HlsEngineOptions = {
452
452
  logger: Logger;
453
453
  callbacks: HlsEngineCallbacks;
454
454
  };
455
- /**
456
- * Dual-path HLS engine: hls.js when supported, otherwise native Safari HLS.
457
- *
458
- * Signed CDN playback requires auth query params on every HLS request
459
- * (playlists, segments, and AES `enc.key` on CDN hosts).
460
- *
461
- * Optional `streaming.keyProxyUrlBuilder` (same-origin BFF) or
462
- * `streaming.getKeyAccessToken` (Bearer only after signed-query 401/403).
463
- * CDN keys prefer signed query without Authorization (no CORS preflight).
464
- * when query-signed CDN access is insufficient.
465
- */
466
455
  declare class HlsEngine {
467
456
  private readonly options;
468
457
  private hls;
@@ -477,10 +466,17 @@ declare class HlsEngine {
477
466
  private audioTracks;
478
467
  private audioTrackSelection;
479
468
  private nativeTextTrackCleanup;
469
+ private startPositionApplied;
470
+ private autoplayApplied;
480
471
  constructor(options: HlsEngineOptions);
481
472
  start(): Promise<void>;
482
473
  private startWithHlsJs;
483
474
  private handleHlsError;
475
+ /** Pin the next load to the lowest ladder rung after MSE append failures. */
476
+ private lockPlaybackToSafeLevel;
477
+ private clampStartPosition;
478
+ private applyStartPositionOnly;
479
+ private applyAutoplayOnly;
484
480
  private applyStartPositionAndAutoplay;
485
481
  private bindMediaEvents;
486
482
  updateAuthParams(): void;
package/dist/index.js CHANGED
@@ -216,7 +216,8 @@ var DEFAULT_PLAYER_CONFIG = {
216
216
  engine: "hls",
217
217
  hls: {
218
218
  enableWorker: true,
219
- lowLatencyMode: true,
219
+ // VOD + AES-128: LL mode causes ABR thrash → bufferAppendError on MSE.
220
+ lowLatencyMode: false,
220
221
  backBufferLength: 90,
221
222
  fatalRecoveryLimit: 3
222
223
  }
@@ -922,29 +923,52 @@ async function fetchEncryptionKeyBytes(url, accessToken, signal) {
922
923
  };
923
924
  }
924
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
+ }
925
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
+ }
926
948
  const stats = createLoaderStats();
927
949
  const end = performance.now();
928
950
  stats.loading.first = stats.loading.start;
929
951
  stats.loading.end = end;
930
- stats.loaded = result.data.byteLength;
931
- stats.total = result.data.byteLength;
952
+ stats.loaded = key.byteLength;
953
+ stats.total = key.byteLength;
932
954
  callbacks.onSuccess(
933
955
  {
934
956
  url: result.url,
935
- data: result.data,
957
+ data: key,
936
958
  code: result.response.status
937
959
  },
938
960
  stats,
939
961
  context,
940
962
  result.response
941
963
  );
964
+ return true;
942
965
  }
943
- async function loadEncryptionKeyEnterprise(context, callbacks, plan, signal, state) {
966
+ async function loadEncryptionKeyEnterprise(context, callbacks, plan, signal, state, lifecycle) {
944
967
  const stats = createLoaderStats();
968
+ const stillCurrent = () => lifecycle.isCurrent() && !signal.aborted;
945
969
  const canTryProxy = Boolean(plan.proxyUrl) && !state.circuitOpen && state.proxyFailures < plan.maxProxyFailures;
946
970
  if (canTryProxy && plan.proxyUrl) {
947
- while (!state.circuitOpen && state.proxyFailures < plan.maxProxyFailures && !signal.aborted) {
971
+ while (!state.circuitOpen && state.proxyFailures < plan.maxProxyFailures && stillCurrent()) {
948
972
  state.proxyFailures += 1;
949
973
  try {
950
974
  const response = await fetch(plan.proxyUrl, {
@@ -954,18 +978,33 @@ async function loadEncryptionKeyEnterprise(context, callbacks, plan, signal, sta
954
978
  cache: "no-store",
955
979
  signal
956
980
  });
981
+ if (!lifecycle.isCurrent()) return "aborted";
957
982
  const contentType = response.headers.get("content-type");
958
983
  const data = await response.arrayBuffer();
984
+ if (!lifecycle.isCurrent()) return "aborted";
959
985
  const head = new TextDecoder().decode(data.slice(0, 64));
960
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
+ }
961
1000
  const end = performance.now();
962
1001
  stats.loading.end = end;
963
- stats.loaded = data.byteLength;
964
- stats.total = data.byteLength;
1002
+ stats.loaded = key.byteLength;
1003
+ stats.total = key.byteLength;
965
1004
  callbacks.onSuccess(
966
1005
  {
967
1006
  url: response.url || plan.proxyUrl,
968
- data,
1007
+ data: key,
969
1008
  code: response.status
970
1009
  },
971
1010
  stats,
@@ -998,25 +1037,22 @@ async function loadEncryptionKeyEnterprise(context, callbacks, plan, signal, sta
998
1037
  return "ok";
999
1038
  } catch (error) {
1000
1039
  if (error instanceof DOMException && error.name === "AbortError") {
1001
- callbacks.onError(
1002
- { code: 0, text: "enc.key proxy aborted/timeout" },
1003
- context,
1004
- null,
1005
- stats
1006
- );
1007
- return "ok";
1040
+ return "aborted";
1008
1041
  }
1009
1042
  state.circuitOpen = true;
1010
1043
  break;
1011
1044
  }
1012
1045
  }
1046
+ if (!lifecycle.isCurrent()) return "aborted";
1013
1047
  state.circuitOpen = true;
1014
1048
  }
1049
+ if (!lifecycle.isCurrent()) return "aborted";
1015
1050
  const queryResult = await fetchEncryptionKeyBytes(
1016
1051
  plan.signedKeyUrl,
1017
1052
  null,
1018
1053
  signal
1019
1054
  );
1055
+ if (!lifecycle.isCurrent()) return "aborted";
1020
1056
  if (queryResult.ok) {
1021
1057
  reportKeySuccess(
1022
1058
  { ...context, url: plan.signedKeyUrl },
@@ -1025,6 +1061,9 @@ async function loadEncryptionKeyEnterprise(context, callbacks, plan, signal, sta
1025
1061
  );
1026
1062
  return "ok";
1027
1063
  }
1064
+ if (queryResult.status === 0 && signal.aborted) {
1065
+ return "aborted";
1066
+ }
1028
1067
  const needsBearer = Boolean(plan.accessToken) && (queryResult.status === 401 || queryResult.status === 403 || // Browser-direct: opaque CORS/network on unsigned GET — try Bearer once
1029
1068
  // Appextech allows ACAO + Authorization (AUTH_MODE=browser, no BFF).
1030
1069
  queryResult.cors || queryResult.status === 0);
@@ -1034,6 +1073,7 @@ async function loadEncryptionKeyEnterprise(context, callbacks, plan, signal, sta
1034
1073
  plan.accessToken,
1035
1074
  signal
1036
1075
  );
1076
+ if (!lifecycle.isCurrent()) return "aborted";
1037
1077
  if (bearerResult.ok) {
1038
1078
  reportKeySuccess(
1039
1079
  { ...context, url: plan.signedKeyUrl },
@@ -1042,6 +1082,9 @@ async function loadEncryptionKeyEnterprise(context, callbacks, plan, signal, sta
1042
1082
  );
1043
1083
  return "ok";
1044
1084
  }
1085
+ if (bearerResult.status === 0 && signal.aborted) {
1086
+ return "aborted";
1087
+ }
1045
1088
  if (bearerResult.cors || bearerResult.status === 0) {
1046
1089
  return "try_xhr";
1047
1090
  }
@@ -1080,17 +1123,34 @@ function createAuthParamsLoader(HlsModule, store, options = {}) {
1080
1123
  constructor(config) {
1081
1124
  this.context = null;
1082
1125
  this.keyAbort = null;
1126
+ /** Bumps on each key load so superseded aborts never call onError. */
1127
+ this.keyLoadGeneration = 0;
1128
+ this.pendingKeyCallbacks = null;
1083
1129
  this.inner = new BaseLoader(config);
1084
1130
  this.stats = this.inner.stats;
1085
1131
  }
1086
- abort() {
1132
+ finishKeyAbort() {
1133
+ const cbs = this.pendingKeyCallbacks;
1134
+ this.pendingKeyCallbacks = null;
1087
1135
  this.keyAbort?.abort();
1088
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();
1089
1149
  this.inner.abort();
1090
1150
  }
1091
1151
  destroy() {
1092
- this.keyAbort?.abort();
1093
- this.keyAbort = null;
1152
+ this.keyLoadGeneration += 1;
1153
+ this.finishKeyAbort();
1094
1154
  this.context = null;
1095
1155
  this.inner.destroy();
1096
1156
  }
@@ -1122,10 +1182,19 @@ function createAuthParamsLoader(HlsModule, store, options = {}) {
1122
1182
  );
1123
1183
  const proxyUrl = proxyConfigured && !keyProxyState.circuitOpen ? rewriteHlsRequestUrl(originalUrl, store, options) : null;
1124
1184
  const accessToken = options.getKeyAccessToken?.() ?? null;
1125
- this.keyAbort?.abort();
1185
+ if (this.keyAbort || this.pendingKeyCallbacks) {
1186
+ this.keyLoadGeneration += 1;
1187
+ this.finishKeyAbort();
1188
+ }
1189
+ const generation = ++this.keyLoadGeneration;
1126
1190
  this.keyAbort = new AbortController();
1191
+ this.pendingKeyCallbacks = callbacks;
1192
+ context.responseType = "arraybuffer";
1127
1193
  const timeoutMs = typeof config?.timeout === "number" && config.timeout > 0 ? config.timeout : 2e4;
1128
1194
  const timer = setTimeout(() => this.keyAbort?.abort(), timeoutMs);
1195
+ const lifecycle = {
1196
+ isCurrent: () => generation === this.keyLoadGeneration
1197
+ };
1129
1198
  void loadEncryptionKeyEnterprise(
1130
1199
  context,
1131
1200
  callbacks,
@@ -1136,13 +1205,22 @@ function createAuthParamsLoader(HlsModule, store, options = {}) {
1136
1205
  maxProxyFailures
1137
1206
  },
1138
1207
  this.keyAbort.signal,
1139
- keyProxyState
1208
+ keyProxyState,
1209
+ lifecycle
1140
1210
  ).then((result) => {
1211
+ if (!lifecycle.isCurrent()) return;
1212
+ if (result === "aborted") {
1213
+ this.finishKeyAbort();
1214
+ return;
1215
+ }
1216
+ this.pendingKeyCallbacks = null;
1141
1217
  if (result === "try_xhr") {
1142
1218
  context.url = signedKeyUrl;
1143
1219
  this.inner.load(context, config, callbacks);
1144
1220
  }
1145
1221
  }).catch((error) => {
1222
+ if (!lifecycle.isCurrent()) return;
1223
+ this.pendingKeyCallbacks = null;
1146
1224
  callbacks.onError(
1147
1225
  {
1148
1226
  code: 0,
@@ -1173,6 +1251,10 @@ function createAuthParamsLoader(HlsModule, store, options = {}) {
1173
1251
  function isAuthHttpStatus(code) {
1174
1252
  return code === 401 || code === 403;
1175
1253
  }
1254
+ function isGeckoBrowser() {
1255
+ if (typeof navigator === "undefined") return false;
1256
+ return /firefox|gecko\//i.test(navigator.userAgent);
1257
+ }
1176
1258
  var HlsEngine = class {
1177
1259
  constructor(options) {
1178
1260
  this.options = options;
@@ -1188,6 +1270,8 @@ var HlsEngine = class {
1188
1270
  this.audioTracks = [];
1189
1271
  this.audioTrackSelection = 0;
1190
1272
  this.nativeTextTrackCleanup = null;
1273
+ this.startPositionApplied = false;
1274
+ this.autoplayApplied = false;
1191
1275
  }
1192
1276
  async start() {
1193
1277
  const { videoEl, playbackUrl, startMuted, volume } = this.options;
@@ -1234,10 +1318,27 @@ var HlsEngine = class {
1234
1318
  getKeyAccessToken,
1235
1319
  keyProxyMaxFailures: this.options.keyProxyMaxFailures
1236
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
+ }
1237
1327
  this.hls = new Hls({
1238
- enableWorker: hlsConfig.enableWorker,
1328
+ enableWorker,
1329
+ // LL mode on short encrypted VOD drives aggressive ABR thrash → MSE errors.
1239
1330
  lowLatencyMode: hlsConfig.lowLatencyMode,
1240
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,
1241
1342
  loader
1242
1343
  });
1243
1344
  this.hls.attachMedia(videoEl);
@@ -1255,7 +1356,11 @@ var HlsEngine = class {
1255
1356
  this.syncHlsSubtitleTracks();
1256
1357
  this.syncHlsAudioTracks();
1257
1358
  callbacks.onStateChange?.("ready");
1258
- 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();
1259
1364
  });
1260
1365
  this.hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, () => {
1261
1366
  if (this.destroyed) return;
@@ -1280,8 +1385,16 @@ var HlsEngine = class {
1280
1385
  callbacks.onAuthRecoveryNeeded?.("key_error");
1281
1386
  if (!data.fatal) return;
1282
1387
  } else if (!data.fatal) {
1388
+ if (!status) {
1389
+ return;
1390
+ }
1283
1391
  callbacks.onFatalError?.(
1284
- 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."
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."
1285
1398
  );
1286
1399
  return;
1287
1400
  }
@@ -1306,30 +1419,67 @@ var HlsEngine = class {
1306
1419
  }
1307
1420
  if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
1308
1421
  this.mediaRecoveryCount += 1;
1309
- if (this.mediaRecoveryCount <= hlsConfig.fatalRecoveryLimit) {
1310
- this.hls?.recoverMediaError();
1422
+ if (this.mediaRecoveryCount > hlsConfig.fatalRecoveryLimit) {
1423
+ callbacks.onFatalError?.(data.details || "Fatal HLS error");
1311
1424
  return;
1312
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;
1313
1434
  }
1314
1435
  callbacks.onFatalError?.(data.details || "Fatal HLS error");
1315
1436
  }
1316
- async applyStartPositionAndAutoplay() {
1317
- if (this.destroyed) return;
1318
- const { videoEl, autoplay, startPosition } = this.options;
1319
- if (typeof startPosition === "number" && startPosition > 0) {
1320
- const seek = () => {
1321
- if (this.destroyed) return;
1322
- try {
1323
- videoEl.currentTime = startPosition;
1324
- } catch {
1325
- }
1326
- };
1327
- if (videoEl.seekable.length > 0) {
1328
- seek();
1329
- } else {
1330
- 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);
1331
1457
  }
1332
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;
1333
1483
  if (!autoplay) return;
1334
1484
  try {
1335
1485
  await videoEl.play();
@@ -1344,6 +1494,10 @@ var HlsEngine = class {
1344
1494
  }
1345
1495
  }
1346
1496
  }
1497
+ async applyStartPositionAndAutoplay() {
1498
+ await this.applyStartPositionOnly();
1499
+ await this.applyAutoplayOnly();
1500
+ }
1347
1501
  bindMediaEvents() {
1348
1502
  const { videoEl, callbacks } = this.options;
1349
1503
  videoEl.addEventListener(
@@ -1587,11 +1741,20 @@ function createVideoPlayer(options) {
1587
1741
  }
1588
1742
  events.emit("progress", { bufferedEnd });
1589
1743
  };
1744
+ const onVolumeChange = () => {
1745
+ if (destroyed) return;
1746
+ events.emit("volumechange", {
1747
+ volume: el.volume,
1748
+ muted: el.muted
1749
+ });
1750
+ };
1590
1751
  el.addEventListener("timeupdate", onTimeUpdate);
1591
1752
  el.addEventListener("progress", onProgress);
1753
+ el.addEventListener("volumechange", onVolumeChange);
1592
1754
  mediaCleanup = () => {
1593
1755
  el.removeEventListener("timeupdate", onTimeUpdate);
1594
1756
  el.removeEventListener("progress", onProgress);
1757
+ el.removeEventListener("volumechange", onVolumeChange);
1595
1758
  };
1596
1759
  };
1597
1760
  const authManager = new AuthManager(
@@ -1644,6 +1807,9 @@ function createVideoPlayer(options) {
1644
1807
  isLiveStream = Boolean(source.live);
1645
1808
  }
1646
1809
  const el = ensureVideoElement();
1810
+ if (isLiveStream) {
1811
+ el.playbackRate = 1;
1812
+ }
1647
1813
  bindMediaProgressEvents(el);
1648
1814
  engine?.destroy();
1649
1815
  engine = new HlsEngine({
@@ -1732,6 +1898,12 @@ function createVideoPlayer(options) {
1732
1898
  }
1733
1899
  });
1734
1900
  await engine.start();
1901
+ if (!destroyed && videoEl) {
1902
+ events.emit("volumechange", {
1903
+ volume: videoEl.volume,
1904
+ muted: videoEl.muted
1905
+ });
1906
+ }
1735
1907
  if (!options.sourceUrl && options.playback) {
1736
1908
  refreshManager?.stop();
1737
1909
  refreshManager = new RefreshManager({
@@ -1851,7 +2023,8 @@ function createVideoPlayer(options) {
1851
2023
  return videoEl?.muted ?? false;
1852
2024
  },
1853
2025
  setPlaybackRate(rate) {
1854
- ensureVideoElement().playbackRate = rate;
2026
+ const el = ensureVideoElement();
2027
+ el.playbackRate = isLiveStream ? 1 : rate;
1855
2028
  },
1856
2029
  getPlaybackRate() {
1857
2030
  return videoEl?.playbackRate ?? 1;