@stream-io/video-client 1.57.0 → 1.58.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.
Files changed (105) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/e2ee-worker.browser.es.js +1342 -0
  3. package/dist/e2ee-worker.browser.es.js.map +1 -0
  4. package/dist/index.browser.es.js +831 -55
  5. package/dist/index.browser.es.js.map +1 -1
  6. package/dist/index.cjs.js +875 -54
  7. package/dist/index.cjs.js.map +1 -1
  8. package/dist/index.d.ts +3 -0
  9. package/dist/index.es.js +864 -55
  10. package/dist/index.es.js.map +1 -1
  11. package/dist/src/Call.d.ts +52 -6
  12. package/dist/src/coordinator/connection/connection.d.ts +1 -1
  13. package/dist/src/devices/CameraManager.d.ts +6 -0
  14. package/dist/src/devices/DeviceManager.d.ts +1 -0
  15. package/dist/src/devices/MicrophoneManager.d.ts +6 -0
  16. package/dist/src/devices/SpeakerManager.d.ts +2 -1
  17. package/dist/src/events/participant.d.ts +10 -3
  18. package/dist/src/gen/coordinator/index.d.ts +1363 -243
  19. package/dist/src/gen/video/sfu/models/models.d.ts +8 -0
  20. package/dist/src/helpers/TypedEventEmitter.d.ts +23 -0
  21. package/dist/src/helpers/ensureExhausted.d.ts +1 -1
  22. package/dist/src/permissions/PermissionsContext.d.ts +2 -2
  23. package/dist/src/rtc/BasePeerConnection.d.ts +3 -1
  24. package/dist/src/rtc/e2ee/E2EEManager.d.ts +12 -0
  25. package/dist/src/rtc/e2ee/EncryptionManager.d.ts +148 -0
  26. package/dist/src/rtc/e2ee/e2ee-worker.d.ts +25 -0
  27. package/dist/src/rtc/e2ee/events.d.ts +163 -0
  28. package/dist/src/rtc/e2ee/transformSupport.d.ts +21 -0
  29. package/dist/src/rtc/helpers/rtcConfiguration.d.ts +2 -2
  30. package/dist/src/rtc/index.d.ts +1 -0
  31. package/dist/src/rtc/mediaEngine.d.ts +23 -0
  32. package/dist/src/rtc/types.d.ts +2 -0
  33. package/dist/src/store/CallState.d.ts +21 -0
  34. package/dist/src/types.d.ts +15 -6
  35. package/index.ts +3 -0
  36. package/package.json +1 -1
  37. package/src/Call.ts +160 -26
  38. package/src/__tests__/Call.test.ts +15 -0
  39. package/src/coordinator/connection/connection.ts +6 -3
  40. package/src/devices/AudioDeviceManager.ts +6 -0
  41. package/src/devices/CameraManager.ts +51 -4
  42. package/src/devices/DeviceManager.ts +17 -0
  43. package/src/devices/MicrophoneManager.ts +55 -5
  44. package/src/devices/ScreenShareManager.ts +3 -0
  45. package/src/devices/SpeakerManager.ts +42 -12
  46. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +1 -20
  47. package/src/devices/__tests__/SpeakerManager.test.ts +138 -42
  48. package/src/devices/__tests__/mocks.ts +36 -3
  49. package/src/events/__tests__/participant.test.ts +134 -1
  50. package/src/events/callEventHandlers.ts +9 -3
  51. package/src/events/participant.ts +42 -7
  52. package/src/gen/coordinator/index.ts +1384 -244
  53. package/src/gen/video/sfu/models/models.ts +15 -0
  54. package/src/helpers/TypedEventEmitter.ts +71 -0
  55. package/src/helpers/__tests__/TypedEventEmitter.test.ts +133 -0
  56. package/src/helpers/ensureExhausted.ts +4 -2
  57. package/src/permissions/PermissionsContext.ts +10 -5
  58. package/src/rtc/BasePeerConnection.ts +12 -1
  59. package/src/rtc/Publisher.ts +8 -0
  60. package/src/rtc/Subscriber.ts +5 -0
  61. package/src/rtc/__tests__/Publisher.test.ts +101 -0
  62. package/src/rtc/__tests__/Subscriber.test.ts +89 -0
  63. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +31 -0
  64. package/src/rtc/e2ee/E2EEManager.ts +12 -0
  65. package/src/rtc/e2ee/EncryptionManager.ts +336 -0
  66. package/src/rtc/e2ee/SPEC.md +586 -0
  67. package/src/rtc/e2ee/__tests__/E2EEManager.contract.test.ts +39 -0
  68. package/src/rtc/e2ee/__tests__/EncryptionManager.test.ts +490 -0
  69. package/src/rtc/e2ee/__tests__/codec.test.ts +247 -0
  70. package/src/rtc/e2ee/__tests__/conformance.test.ts +184 -0
  71. package/src/rtc/e2ee/__tests__/failureTracker.test.ts +45 -0
  72. package/src/rtc/e2ee/__tests__/frameCounter.test.ts +90 -0
  73. package/src/rtc/e2ee/__tests__/keyStore.test.ts +221 -0
  74. package/src/rtc/e2ee/__tests__/notifications.test.ts +166 -0
  75. package/src/rtc/e2ee/__tests__/perf.test.ts +139 -0
  76. package/src/rtc/e2ee/__tests__/queue.test.ts +61 -0
  77. package/src/rtc/e2ee/__tests__/replayWindow.test.ts +133 -0
  78. package/src/rtc/e2ee/__tests__/trailer.test.ts +143 -0
  79. package/src/rtc/e2ee/__tests__/transform-pipeline.test.ts +839 -0
  80. package/src/rtc/e2ee/__tests__/transformSupport.test.ts +65 -0
  81. package/src/rtc/e2ee/e2ee-worker/codec.ts +220 -0
  82. package/src/rtc/e2ee/e2ee-worker/constants.ts +47 -0
  83. package/src/rtc/e2ee/e2ee-worker/decode.ts +159 -0
  84. package/src/rtc/e2ee/e2ee-worker/e2ee-worker-impl.ts +137 -0
  85. package/src/rtc/e2ee/e2ee-worker/encode.ts +147 -0
  86. package/src/rtc/e2ee/e2ee-worker/failureTracker.ts +26 -0
  87. package/src/rtc/e2ee/e2ee-worker/frameCounter.ts +37 -0
  88. package/src/rtc/e2ee/e2ee-worker/keyStore.ts +211 -0
  89. package/src/rtc/e2ee/e2ee-worker/notifications.ts +190 -0
  90. package/src/rtc/e2ee/e2ee-worker/perf.ts +129 -0
  91. package/src/rtc/e2ee/e2ee-worker/queue.ts +12 -0
  92. package/src/rtc/e2ee/e2ee-worker/replayWindow.ts +131 -0
  93. package/src/rtc/e2ee/e2ee-worker/trailer.ts +155 -0
  94. package/src/rtc/e2ee/e2ee-worker/tsconfig.json +8 -0
  95. package/src/rtc/e2ee/e2ee-worker/types.ts +29 -0
  96. package/src/rtc/e2ee/e2ee-worker.ts +27 -0
  97. package/src/rtc/e2ee/events.ts +179 -0
  98. package/src/rtc/e2ee/transformSupport.ts +35 -0
  99. package/src/rtc/helpers/rtcConfiguration.ts +4 -2
  100. package/src/rtc/index.ts +1 -0
  101. package/src/rtc/mediaEngine.ts +44 -0
  102. package/src/rtc/types.ts +2 -0
  103. package/src/store/CallState.ts +33 -1
  104. package/src/store/__tests__/CallState.test.ts +82 -1
  105. package/src/types.ts +23 -5
package/src/Call.ts CHANGED
@@ -2,7 +2,9 @@ import { StreamSfuClient } from './StreamSfuClient';
2
2
  import { SfuJoinError } from './errors';
3
3
  import {
4
4
  BasePeerConnectionOpts,
5
+ type CallMediaEngine,
5
6
  Dispatcher,
7
+ getCallMediaEngineProvider,
6
8
  getGenericSdp,
7
9
  isAudioTrackType,
8
10
  isSfuEvent,
@@ -15,6 +17,7 @@ import {
15
17
  TrackPublishOptions,
16
18
  trackTypeToParticipantStreamKey,
17
19
  } from './rtc';
20
+ import type { E2EEManager } from './rtc/e2ee/E2EEManager';
18
21
  import {
19
22
  registerEventHandlers,
20
23
  registerRingingCallEventHandlers,
@@ -30,7 +33,7 @@ import {
30
33
  getCurrentValue,
31
34
  } from './store/rxUtils';
32
35
  import { ScopedLogger, videoLoggerSystem } from './logger';
33
- import type {
36
+ import {
34
37
  AcceptCallResponse,
35
38
  BlockUserRequest,
36
39
  BlockUserResponse,
@@ -76,8 +79,8 @@ import type {
76
79
  RingCallResponse,
77
80
  SendCallEventRequest,
78
81
  SendCallEventResponse,
79
- SendReactionRequest,
80
- SendReactionResponse,
82
+ SendVideoReactionRequest,
83
+ SendVideoReactionResponse,
81
84
  StartClosedCaptionsRequest,
82
85
  StartClosedCaptionsResponse,
83
86
  StartFrameRecordingRequest,
@@ -109,6 +112,8 @@ import type {
109
112
  UpdateCallRequest,
110
113
  UpdateCallResponse,
111
114
  UpdateUserPermissionsRequest,
115
+ UpdateUserPermissionsRequestGrantPermissionsEnum,
116
+ UpdateUserPermissionsRequestRevokePermissionsEnum,
112
117
  UpdateUserPermissionsResponse,
113
118
  } from './gen/coordinator';
114
119
  import { OwnCapability } from './gen/coordinator';
@@ -266,6 +271,7 @@ export class Call {
266
271
 
267
272
  subscriber?: Subscriber;
268
273
  publisher?: Publisher;
274
+ e2eeManager?: E2EEManager;
269
275
 
270
276
  /**
271
277
  * Flag telling whether this call is a "ringing" call.
@@ -328,6 +334,8 @@ export class Call {
328
334
  private allowOwnTracksLoopback = false;
329
335
  private hasJoinedOnce = false;
330
336
  private deviceSettingsAppliedOnce = false;
337
+ private callManagerStarted = false;
338
+ private leaveGeneration = 0;
331
339
  private credentials?: Credentials;
332
340
 
333
341
  private initialized = false;
@@ -351,6 +359,14 @@ export class Call {
351
359
  ClientCapability.SUBSCRIBER_VIDEO_PAUSE,
352
360
  ]);
353
361
 
362
+ /**
363
+ * The in-flight per-call media engine. On web/React this resolves to a thin
364
+ * globals-backed engine (no provider registered); React Native registers a
365
+ * provider that owns a per-call native factory.
366
+ * @internal
367
+ */
368
+ private mediaEnginePromise?: Promise<CallMediaEngine>;
369
+
354
370
  /**
355
371
  * Constructs a new `Call` instance.
356
372
  *
@@ -699,6 +715,8 @@ export class Call {
699
715
  return;
700
716
  }
701
717
 
718
+ this.leaveGeneration += 1;
719
+
702
720
  if (callingState === CallingState.JOINING) {
703
721
  const waitUntilCallJoined = () => {
704
722
  return new Promise<void>((resolve) => {
@@ -799,6 +817,7 @@ export class Call {
799
817
 
800
818
  globalThis.streamRNVideoSDK?.callManager.stop({
801
819
  isRingingTypeCall: this.ringing,
820
+ shouldStopCallManager: this.callManagerStarted,
802
821
  });
803
822
 
804
823
  this.camera.dispose();
@@ -806,6 +825,7 @@ export class Call {
806
825
  this.screenShare.dispose();
807
826
  this.speaker.dispose();
808
827
  this.deviceSettingsAppliedOnce = false;
828
+ this.callManagerStarted = false;
809
829
 
810
830
  const stopOnLeavePromises: Promise<void>[] = [];
811
831
  if (this.camera.stopOnLeave) {
@@ -818,6 +838,23 @@ export class Call {
818
838
  stopOnLeavePromises.push(this.screenShare.disable(true));
819
839
  }
820
840
  await Promise.all(stopOnLeavePromises);
841
+
842
+ // Dispose the per-call media engine last — after peer connections and
843
+ // local tracks are gone — so the backing factory tears down with no
844
+ // owned PCs/tracks. A fresh `join()` builds a new engine.
845
+ if (this.mediaEnginePromise) {
846
+ const enginePromise = this.mediaEnginePromise;
847
+ this.mediaEnginePromise = undefined;
848
+ this.logger.debug('Disposing per-call media factory');
849
+ await enginePromise
850
+ .then((engine) => {
851
+ globalThis.streamRNVideoSDK?.callingX?.unwireAudioEngineSubscription();
852
+ return engine.dispose();
853
+ })
854
+ .catch((err) => {
855
+ this.logger.warn('Failed to dispose media engine', err);
856
+ });
857
+ }
821
858
  });
822
859
  };
823
860
 
@@ -937,11 +974,7 @@ export class Call {
937
974
  this.clientStore.registerOrUpdateCall(this);
938
975
  }
939
976
  // Skip speaker setup on RN if ringing was requested or the call is already ringing
940
- const skipSpeakerApply = isReactNative()
941
- ? params?.ring === true
942
- ? true
943
- : this.ringing
944
- : false;
977
+ const skipSpeakerApply = isReactNative();
945
978
  await this.applyDeviceConfig(
946
979
  response.call.settings,
947
980
  false,
@@ -978,11 +1011,7 @@ export class Call {
978
1011
  }
979
1012
 
980
1013
  // Skip speaker setup on RN if ringing was requested or the call is already ringing
981
- const skipSpeakerApply = isReactNative()
982
- ? data?.ring === true
983
- ? true
984
- : this.ringing
985
- : false;
1014
+ const skipSpeakerApply = isReactNative();
986
1015
  await this.applyDeviceConfig(
987
1016
  response.call.settings,
988
1017
  false,
@@ -1190,12 +1219,26 @@ export class Call {
1190
1219
  private doJoin = async (data?: JoinCallData): Promise<void> => {
1191
1220
  const connectStartTime = Date.now();
1192
1221
  const callingState = this.state.callingState;
1222
+ const joinLeaveGeneration = this.leaveGeneration;
1223
+ const supersededByLeave = () =>
1224
+ this.leaveGeneration !== joinLeaveGeneration;
1193
1225
 
1194
1226
  this.joinCallData = data;
1195
1227
 
1196
1228
  this.logger.debug('Starting join flow');
1197
1229
  this.state.setCallingState(CallingState.JOINING);
1198
1230
 
1231
+ // Ensure the per-call media engine exists before any peer connection
1232
+ // (codec probe, subscriber, publisher) or capture happens, so the WebRTC
1233
+ // globals resolve to the call's factory. Idempotent across
1234
+ // reconnect/migration attempts.
1235
+ await this.ensureMediaFactory();
1236
+
1237
+ const callingX = globalThis.streamRNVideoSDK?.callingX;
1238
+ if (callingX) {
1239
+ callingX.wireAudioEngineSubscription();
1240
+ }
1241
+
1199
1242
  const performingMigration =
1200
1243
  this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
1201
1244
  const performingRejoin =
@@ -1266,6 +1309,11 @@ export class Call {
1266
1309
  // the capabilities of the client (codec support, etc.)
1267
1310
  const { dangerouslyForceCodec, fmtpLine, subscriberFmtpLine } =
1268
1311
  this.clientPublishOptions || {};
1312
+ // skip if a leave superseded this join so codec detection doesn't resolve to a default factory.
1313
+ if (supersededByLeave()) {
1314
+ this.logger.debug('Join superseded by leave; skipping codec detection');
1315
+ return;
1316
+ }
1269
1317
  const [subscriberSdp, publisherSdp] = await Promise.all([
1270
1318
  getGenericSdp('recvonly', dangerouslyForceCodec, subscriberFmtpLine),
1271
1319
  getGenericSdp('sendonly', dangerouslyForceCodec, fmtpLine),
@@ -1325,6 +1373,13 @@ export class Call {
1325
1373
  }
1326
1374
  }
1327
1375
 
1376
+ // If the user left while this join was in flight, bail before re-setting JOINED and before
1377
+ // peer-connection setup below (both run synchronously after this, so one check covers them).
1378
+ if (supersededByLeave()) {
1379
+ this.logger.debug('Join superseded by leave; aborting join flow');
1380
+ return;
1381
+ }
1382
+
1328
1383
  if (!performingMigration) {
1329
1384
  // in MIGRATION, `JOINED` state is set in `this.reconnectMigrate()`
1330
1385
  this.state.setCallingState(CallingState.JOINED);
@@ -1371,12 +1426,21 @@ export class Call {
1371
1426
 
1372
1427
  // device settings should be applied only once, we don't have to
1373
1428
  // re-apply them on later reconnections or server-side data fetches
1374
- if (!this.deviceSettingsAppliedOnce && this.state.settings) {
1429
+ if (
1430
+ !this.deviceSettingsAppliedOnce &&
1431
+ this.state.settings &&
1432
+ !supersededByLeave()
1433
+ ) {
1375
1434
  await this.applyDeviceConfig(this.state.settings, true, false);
1435
+ this.deviceSettingsAppliedOnce = true;
1436
+ }
1437
+
1438
+ if (!this.callManagerStarted && !supersededByLeave()) {
1376
1439
  globalThis.streamRNVideoSDK?.callManager.start({
1377
1440
  isRingingTypeCall: this.ringing,
1441
+ cid: this.cid,
1378
1442
  });
1379
- this.deviceSettingsAppliedOnce = true;
1443
+ this.callManagerStarted = true;
1380
1444
  }
1381
1445
 
1382
1446
  // We shouldn't persist the `ring` and `notify` state after joining the call
@@ -1542,6 +1606,7 @@ export class Call {
1542
1606
  this.sfuStatsReporter = undefined;
1543
1607
  if (closePreviousInstances && this.subscriber) {
1544
1608
  await this.subscriber.dispose();
1609
+ this.state.removeAllOrphanedTracks();
1545
1610
  }
1546
1611
  const basePeerConnectionOptions: BasePeerConnectionOpts = {
1547
1612
  sfuClient,
@@ -1552,6 +1617,7 @@ export class Call {
1552
1617
  enableTracing,
1553
1618
  statsTimestampDriftThresholdMs: reportingIntervalMs / 2,
1554
1619
  clientPublishOptions: this.clientPublishOptions,
1620
+ e2ee: this.e2eeManager,
1555
1621
  onReconnectionNeeded: (kind, reason, peerType) => {
1556
1622
  this.reconnect(kind, reason).catch((err) => {
1557
1623
  const message = `[Reconnect] Error reconnecting, after a ${PeerType[peerType]} error: ${reason}`;
@@ -1628,12 +1694,12 @@ export class Call {
1628
1694
  * Retrieves credentials for joining the call.
1629
1695
  *
1630
1696
  * @internal
1631
- *
1632
1697
  * @param data the join call data.
1633
1698
  */
1634
1699
  doJoinRequest = async (data?: JoinCallData): Promise<JoinCallResponse> => {
1635
1700
  const location = await this.streamClient.getLocationHint();
1636
- const request: JoinCallRequest = { ...data, location };
1701
+ const e2ee = !!this.e2eeManager;
1702
+ const request: JoinCallRequest = { ...data, location, e2ee };
1637
1703
  const joinResponse = await this.streamClient.post<
1638
1704
  JoinCallResponse,
1639
1705
  JoinCallRequest
@@ -1663,6 +1729,39 @@ export class Call {
1663
1729
  return joinResponse;
1664
1730
  };
1665
1731
 
1732
+ /**
1733
+ * Whether the per-call media engine currently exists. True from join until leave.
1734
+ *
1735
+ * @internal an internal getter and should not be used outside the SDK.
1736
+ */
1737
+ get hasMediaEngine(): boolean {
1738
+ return !!this.mediaEnginePromise;
1739
+ }
1740
+
1741
+ /**
1742
+ * Ensures a {@link CallMediaEngine} exists for this call's media session and
1743
+ * returns it. Idempotent: the engine is created once via the registered
1744
+ * provider (see `setCallMediaEngineProvider`) and cached until `leave()`
1745
+ * disposes it. Concurrent callers (e.g. camera + microphone enabling in
1746
+ * parallel) share the same engine because the in-flight creation promise is
1747
+ * cached, never the unresolved result.
1748
+ *
1749
+ * @internal
1750
+ */
1751
+ ensureMediaFactory = async (): Promise<CallMediaEngine> => {
1752
+ if (!this.mediaEnginePromise) {
1753
+ const provider = getCallMediaEngineProvider();
1754
+
1755
+ this.logger.debug(`Requesting per-call media factory creation`);
1756
+ this.mediaEnginePromise = Promise.resolve(provider()).catch((err) => {
1757
+ // Drop the cached rejection so a retried join() can rebuild the engine
1758
+ this.mediaEnginePromise = undefined;
1759
+ throw err;
1760
+ });
1761
+ }
1762
+ return this.mediaEnginePromise;
1763
+ };
1764
+
1666
1765
  /**
1667
1766
  * Handles the closing of the SFU signal connection.
1668
1767
  *
@@ -2355,6 +2454,31 @@ export class Call {
2355
2454
  this.clientPublishOptions = { ...this.clientPublishOptions, ...options };
2356
2455
  };
2357
2456
 
2457
+ /**
2458
+ * Set the E2EE (end-to-end encryption) manager for this call.
2459
+ *
2460
+ * Must be called before {@link join} so the RTCPeerConnection can be
2461
+ * configured for E2EE.
2462
+ *
2463
+ * The manager is kept across {@link leave} so a rejoin of this same instance
2464
+ * stays encrypted: do not dispose it while this call may be joined again.
2465
+ * A disposed manager throws from `encrypt`/`decrypt` rather than silently
2466
+ * publishing nothing, so re-attach a fresh one instead of reusing it.
2467
+ *
2468
+ * @param e2ee - Any `E2EEManager`. Use `EncryptionManager.create()` for the
2469
+ * built-in AES-GCM scheme, or pass your own implementation.
2470
+ * @throws if called after the peer connections have been built (i.e. after
2471
+ * `join`): those PCs were already configured without an encryptor, so
2472
+ * adopting a manager now would silently publish/receive cleartext for
2473
+ * the live session.
2474
+ */
2475
+ setE2EEManager = (e2ee: E2EEManager) => {
2476
+ if (this.publisher || this.subscriber) {
2477
+ throw new Error('setE2EEManager must be called before join()');
2478
+ }
2479
+ this.e2eeManager = e2ee;
2480
+ };
2481
+
2358
2482
  /**
2359
2483
  * Notifies the SFU that a noise cancellation process has started.
2360
2484
  *
@@ -2453,9 +2577,9 @@ export class Call {
2453
2577
  * @param reaction the reaction to send.
2454
2578
  */
2455
2579
  sendReaction = async (
2456
- reaction: SendReactionRequest,
2457
- ): Promise<SendReactionResponse> => {
2458
- return this.streamClient.post<SendReactionResponse, SendReactionRequest>(
2580
+ reaction: SendVideoReactionRequest,
2581
+ ): Promise<SendVideoReactionResponse> => {
2582
+ return this.streamClient.post(
2459
2583
  `${this.streamClientBasePath}/reaction`,
2460
2584
  reaction,
2461
2585
  );
@@ -2673,7 +2797,7 @@ export class Call {
2673
2797
  ): Promise<RequestPermissionResponse> => {
2674
2798
  const { permissions } = data;
2675
2799
  const canRequestPermissions = permissions.every((permission) =>
2676
- this.permissionsContext.canRequest(permission as OwnCapability),
2800
+ this.permissionsContext.canRequest(permission),
2677
2801
  );
2678
2802
  if (!canRequestPermissions) {
2679
2803
  throw new Error(
@@ -2698,10 +2822,14 @@ export class Call {
2698
2822
  * @param userId the id of the user to grant permissions to.
2699
2823
  * @param permissions the permissions to grant.
2700
2824
  */
2701
- grantPermissions = async (userId: string, permissions: string[]) => {
2825
+ grantPermissions = async (
2826
+ userId: string,
2827
+ permissions: string[] | UpdateUserPermissionsRequestGrantPermissionsEnum[],
2828
+ ) => {
2702
2829
  return this.updateUserPermissions({
2703
2830
  user_id: userId,
2704
- grant_permissions: permissions,
2831
+ grant_permissions:
2832
+ permissions as UpdateUserPermissionsRequestGrantPermissionsEnum[],
2705
2833
  });
2706
2834
  };
2707
2835
 
@@ -2717,10 +2845,14 @@ export class Call {
2717
2845
  * @param userId the id of the user to revoke permissions from.
2718
2846
  * @param permissions the permissions to revoke.
2719
2847
  */
2720
- revokePermissions = async (userId: string, permissions: string[]) => {
2848
+ revokePermissions = async (
2849
+ userId: string,
2850
+ permissions: string[] | UpdateUserPermissionsRequestRevokePermissionsEnum[],
2851
+ ) => {
2721
2852
  return this.updateUserPermissions({
2722
2853
  user_id: userId,
2723
- revoke_permissions: permissions,
2854
+ revoke_permissions:
2855
+ permissions as UpdateUserPermissionsRequestRevokePermissionsEnum[],
2724
2856
  });
2725
2857
  };
2726
2858
 
@@ -3239,7 +3371,9 @@ export class Call {
3239
3371
  skipSpeakerApply: boolean,
3240
3372
  ) => {
3241
3373
  if (!skipSpeakerApply) {
3242
- this.speaker.apply(settings);
3374
+ await this.speaker.apply(settings).catch((err) => {
3375
+ this.logger.warn('Speaker init failed', err);
3376
+ });
3243
3377
  }
3244
3378
  await this.camera.apply(settings.video, publish).catch((err) => {
3245
3379
  this.logger.warn('Camera init failed', err);
@@ -17,6 +17,7 @@ import { Dispatcher } from '../rtc';
17
17
  import { Call } from '../Call';
18
18
  import { StreamVideoParticipant } from '../types';
19
19
  import { TrackType } from '../gen/video/sfu/models/models';
20
+ import type { E2EEManager } from '../rtc/e2ee/E2EEManager';
20
21
 
21
22
  const apiKey = process.env.STREAM_API_KEY!;
22
23
  const secret = process.env.STREAM_SECRET!;
@@ -300,6 +301,20 @@ describe('muting logic', () => {
300
301
  });
301
302
  });
302
303
 
304
+ describe('setE2EEManager', () => {
305
+ it('throws when called after the peer connections exist', () => {
306
+ const call = client.call('default', generateUUIDv4());
307
+ const e2ee = fromPartial<E2EEManager>({});
308
+ // Before join there are no peer connections, so it is accepted.
309
+ expect(() => call.setE2EEManager(e2ee)).not.toThrow();
310
+ // Once a subscriber/publisher exists (i.e. after join) the PCs were already
311
+ // built without the manager; setting it now would leave the live session
312
+ // half-encrypted, so it must be rejected (finding 12).
313
+ call['subscriber'] = fromPartial({});
314
+ expect(() => call.setE2EEManager(e2ee)).toThrow(/before join/);
315
+ });
316
+ });
317
+
303
318
  describe('client event reporting', () => {
304
319
  afterEach(() => {
305
320
  vi.restoreAllMocks();
@@ -9,10 +9,11 @@ import {
9
9
  } from './utils';
10
10
  import type { StreamVideoEvent, UR, WSConnectionError } from './types';
11
11
  import type { LogLevel } from '@stream-io/logger';
12
- import type {
12
+ import {
13
13
  ConnectedEvent,
14
14
  ConnectionErrorEvent,
15
15
  WSAuthMessage,
16
+ WSAuthMessageProductsEnum,
16
17
  } from '../../gen/coordinator';
17
18
  import { makeSafePromise, type SafePromise } from '../../helpers/promise';
18
19
  import { getTimers } from '../../timers';
@@ -529,15 +530,17 @@ export class StableWSConnection {
529
530
  return;
530
531
  }
531
532
 
532
- const authMessage = JSON.stringify({
533
+ const wsAuthMessage: WSAuthMessage = {
533
534
  token,
535
+ products: [WSAuthMessageProductsEnum.VIDEO],
534
536
  user_details: {
535
537
  id: user.id,
536
538
  name: user.name,
537
539
  image: user.image,
538
540
  custom: user.custom,
539
541
  },
540
- } as WSAuthMessage);
542
+ };
543
+ const authMessage = JSON.stringify(wsAuthMessage);
541
544
 
542
545
  this._log(`onopen() - Sending auth message ${authMessage}`, {}, 'trace');
543
546
 
@@ -2,6 +2,7 @@ import { DeviceManager } from './DeviceManager';
2
2
  import { AudioDeviceManagerState } from './AudioDeviceManagerState';
3
3
  import { AudioBitrateProfile } from '../gen/video/sfu/models/models';
4
4
  import { TrackPublishOptions } from '../rtc';
5
+ import { isReactNative } from '../helpers/platforms';
5
6
 
6
7
  /**
7
8
  * Base class for High Fidelity enabled Device Managers.
@@ -17,6 +18,11 @@ export abstract class AudioDeviceManager<
17
18
  if (!this.call.state.settings?.audio.hifi_audio_enabled) {
18
19
  throw new Error('High Fidelity audio is not enabled for this call');
19
20
  }
21
+ if (isReactNative() && this.call.hasMediaEngine) {
22
+ throw new Error(
23
+ 'setAudioBitrateProfile must be called before joining the call.',
24
+ );
25
+ }
20
26
  this.doSetAudioBitrateProfile(profile);
21
27
  this.state.setAudioBitrateProfile(profile);
22
28
  if (this.enabled) {
@@ -124,6 +124,44 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
124
124
  }
125
125
  }
126
126
 
127
+ override enable(): Promise<void> {
128
+ if (isReactNative() && !this.call.hasMediaEngine) {
129
+ this.state.setPendingStatus('enabled');
130
+ return Promise.resolve();
131
+ }
132
+
133
+ return super.enable();
134
+ }
135
+
136
+ override disable(options: { forceStop?: boolean }): Promise<void>;
137
+ override disable(forceStop?: boolean): Promise<void>;
138
+ override async disable(
139
+ forceStopOrOptions?: boolean | { forceStop?: boolean },
140
+ ): Promise<void> {
141
+ if (isReactNative() && !this.call.hasMediaEngine) {
142
+ this.state.setPendingStatus('disabled');
143
+ return;
144
+ }
145
+
146
+ // forward verbatim to the base, narrowing so the right overload is selected
147
+ if (forceStopOrOptions === undefined) return super.disable();
148
+ if (typeof forceStopOrOptions === 'boolean') {
149
+ return super.disable(forceStopOrOptions);
150
+ }
151
+ return super.disable(forceStopOrOptions);
152
+ }
153
+
154
+ override toggle(): Promise<void> {
155
+ if (isReactNative() && !this.call.hasMediaEngine) {
156
+ this.state.setPendingStatus(
157
+ this.state.optimisticStatus === 'enabled' ? 'disabled' : 'enabled',
158
+ );
159
+ return Promise.resolve();
160
+ }
161
+
162
+ return super.toggle();
163
+ }
164
+
127
165
  /**
128
166
  * Applies the video settings to the camera.
129
167
  *
@@ -166,9 +204,15 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
166
204
  }
167
205
  }
168
206
 
169
- const { mediaStream } = this.state;
170
- if (canPublish && publish && this.enabled && mediaStream) {
171
- await this.publishStream(mediaStream);
207
+ if (isReactNative() && publish && canPublish) {
208
+ // On RN the camera is enabled/disabled optimistically before JOINED. Reconcile now
209
+ // acquires the track and publishes it, so it fully owns the publish.
210
+ await this.reconcileOptimisticStatus();
211
+ } else {
212
+ const { mediaStream } = this.state;
213
+ if (canPublish && publish && this.enabled && mediaStream) {
214
+ await this.publishStream(mediaStream);
215
+ }
172
216
  }
173
217
  }
174
218
 
@@ -196,9 +240,12 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
196
240
  return constraints;
197
241
  }
198
242
 
199
- protected override getStream(
243
+ protected override async getStream(
200
244
  constraints: MediaTrackConstraints,
201
245
  ): Promise<MediaStream> {
246
+ // Ensure the call's media factory exists before capture so the resulting
247
+ // track is owned by it (the WebRTC globals resolve to the live factory).
248
+ await this.call.ensureMediaFactory();
202
249
  return getVideoStream(constraints, this.call.tracer);
203
250
  }
204
251
  }
@@ -509,6 +509,23 @@ export abstract class DeviceManager<
509
509
  }
510
510
  }
511
511
 
512
+ protected reconcileOptimisticStatus = async (): Promise<void> => {
513
+ const target = this.state.optimisticStatus;
514
+ await withCancellation(this.statusChangeConcurrencyTag, async (signal) => {
515
+ try {
516
+ if (target === 'enabled' && this.state.status !== 'enabled') {
517
+ await this.unmuteStream();
518
+ if (!signal.aborted) this.state.setStatus('enabled');
519
+ } else if (target === 'disabled' && this.state.status === 'enabled') {
520
+ // mirror whatever disable() does to stop/pause the track per disableMode
521
+ if (!signal.aborted) this.state.setStatus('disabled');
522
+ }
523
+ } finally {
524
+ if (!signal.aborted) this.state.setPendingStatus(this.state.status);
525
+ }
526
+ });
527
+ };
528
+
512
529
  private disableTracks() {
513
530
  this.getTracks().forEach((track) => {
514
531
  if (track.enabled) track.enabled = false;
@@ -76,7 +76,10 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
76
76
  ]) => {
77
77
  try {
78
78
  if (callingState === CallingState.LEFT) {
79
- this.setMutedRecordingPrepared(false);
79
+ // The muted-recording-prepared mode is reset in `callManager.stop()`
80
+ // (during leave, while the call factory is still alive), not here —
81
+ // this subscription fires asynchronously and could land after the
82
+ // factory is disposed, forcing a default-ADM rebuild.
80
83
  await this.stopSpeakingWhileMutedDetection();
81
84
  }
82
85
  if (callingState !== CallingState.JOINED) return;
@@ -187,6 +190,44 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
187
190
  }
188
191
  }
189
192
 
193
+ override enable(): Promise<void> {
194
+ if (isReactNative() && !this.call.hasMediaEngine) {
195
+ this.state.setPendingStatus('enabled');
196
+ return Promise.resolve();
197
+ }
198
+
199
+ return super.enable();
200
+ }
201
+
202
+ override disable(options: { forceStop?: boolean }): Promise<void>;
203
+ override disable(forceStop?: boolean): Promise<void>;
204
+ override async disable(
205
+ forceStopOrOptions?: boolean | { forceStop?: boolean },
206
+ ): Promise<void> {
207
+ if (isReactNative() && !this.call.hasMediaEngine) {
208
+ this.state.setPendingStatus('disabled');
209
+ return;
210
+ }
211
+
212
+ // forward verbatim to the base, narrowing so the right overload is selected
213
+ if (forceStopOrOptions === undefined) return super.disable();
214
+ if (typeof forceStopOrOptions === 'boolean') {
215
+ return super.disable(forceStopOrOptions);
216
+ }
217
+ return super.disable(forceStopOrOptions);
218
+ }
219
+
220
+ override toggle(): Promise<void> {
221
+ if (isReactNative() && !this.call.hasMediaEngine) {
222
+ this.state.setPendingStatus(
223
+ this.state.optimisticStatus === 'enabled' ? 'disabled' : 'enabled',
224
+ );
225
+ return Promise.resolve();
226
+ }
227
+
228
+ return super.toggle();
229
+ }
230
+
190
231
  /**
191
232
  * Enables noise cancellation for the microphone.
192
233
  *
@@ -376,9 +417,15 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
376
417
  }
377
418
  }
378
419
 
379
- const { mediaStream } = this.state;
380
- if (canPublish && publish && this.enabled && mediaStream) {
381
- await this.publishStream(mediaStream);
420
+ if (isReactNative() && publish && canPublish) {
421
+ // On RN the microphone is enabled/disabled optimistically before JOINED. Reconcile now
422
+ // acquires the track and publishes it, so it fully owns the publish.
423
+ await this.reconcileOptimisticStatus();
424
+ } else {
425
+ const { mediaStream } = this.state;
426
+ if (canPublish && publish && this.enabled && mediaStream) {
427
+ await this.publishStream(mediaStream);
428
+ }
382
429
  }
383
430
  }
384
431
 
@@ -386,9 +433,12 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
386
433
  return getAudioDevices(this.call.tracer);
387
434
  }
388
435
 
389
- protected override getStream(
436
+ protected override async getStream(
390
437
  constraints: MediaTrackConstraints,
391
438
  ): Promise<MediaStream> {
439
+ // Ensure the call's media factory exists before capture so the resulting
440
+ // track is owned by it (the WebRTC globals resolve to the live factory).
441
+ await this.call.ensureMediaFactory();
392
442
  return getAudioStream(constraints, this.call.tracer);
393
443
  }
394
444
 
@@ -90,6 +90,9 @@ export class ScreenShareManager extends AudioDeviceManager<
90
90
  if (!this.state.audioEnabled) {
91
91
  constraints.audio = false;
92
92
  }
93
+ // Ensure the call's media factory exists before capture so the resulting
94
+ // track is owned by it (the WebRTC globals resolve to the live factory).
95
+ await this.call.ensureMediaFactory();
93
96
  const stream = await getScreenShareStream(constraints, this.call.tracer);
94
97
  const [track] = stream.getVideoTracks();
95
98
  const { contentHint } = this.state.settings || {};