@stream-io/video-client 1.57.0 → 1.59.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 (130) hide show
  1. package/CHANGELOG.md +28 -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 +1141 -117
  5. package/dist/index.browser.es.js.map +1 -1
  6. package/dist/index.cjs.js +1196 -115
  7. package/dist/index.cjs.js.map +1 -1
  8. package/dist/index.d.ts +5 -0
  9. package/dist/index.es.js +1174 -117
  10. package/dist/index.es.js.map +1 -1
  11. package/dist/src/Call.d.ts +66 -9
  12. package/dist/src/StreamVideoClient.d.ts +9 -1
  13. package/dist/src/coordinator/connection/connection.d.ts +1 -1
  14. package/dist/src/devices/CameraManager.d.ts +8 -1
  15. package/dist/src/devices/DeviceManager.d.ts +2 -1
  16. package/dist/src/devices/MicrophoneManager.d.ts +8 -1
  17. package/dist/src/devices/SpeakerManager.d.ts +2 -1
  18. package/dist/src/events/participant.d.ts +10 -3
  19. package/dist/src/gen/coordinator/index.d.ts +1386 -240
  20. package/dist/src/gen/video/sfu/models/models.d.ts +8 -0
  21. package/dist/src/helpers/MediaStreamRecorder.d.ts +26 -0
  22. package/dist/src/helpers/TypedEventEmitter.d.ts +23 -0
  23. package/dist/src/helpers/concurrency.d.ts +8 -0
  24. package/dist/src/helpers/ensureExhausted.d.ts +1 -1
  25. package/dist/src/helpers/loopback.d.ts +55 -0
  26. package/dist/src/permissions/PermissionsContext.d.ts +2 -2
  27. package/dist/src/reporting/ClientEventReporter.d.ts +0 -2
  28. package/dist/src/rtc/BasePeerConnection.d.ts +3 -1
  29. package/dist/src/rtc/Subscriber.d.ts +5 -0
  30. package/dist/src/rtc/e2ee/E2EEManager.d.ts +12 -0
  31. package/dist/src/rtc/e2ee/EncryptionManager.d.ts +148 -0
  32. package/dist/src/rtc/e2ee/e2ee-worker.d.ts +25 -0
  33. package/dist/src/rtc/e2ee/events.d.ts +163 -0
  34. package/dist/src/rtc/e2ee/transformSupport.d.ts +21 -0
  35. package/dist/src/rtc/helpers/rtcConfiguration.d.ts +2 -2
  36. package/dist/src/rtc/index.d.ts +1 -0
  37. package/dist/src/rtc/mediaEngine.d.ts +23 -0
  38. package/dist/src/rtc/types.d.ts +2 -0
  39. package/dist/src/store/CallState.d.ts +21 -0
  40. package/dist/src/types.d.ts +15 -6
  41. package/index.ts +5 -0
  42. package/package.json +1 -1
  43. package/src/Call.ts +311 -155
  44. package/src/StreamVideoClient.ts +35 -8
  45. package/src/__tests__/Call.lifecycle.test.ts +33 -2
  46. package/src/__tests__/Call.publishing.test.ts +42 -1
  47. package/src/__tests__/Call.test.ts +15 -0
  48. package/src/__tests__/StreamVideoClient.api.test.ts +33 -0
  49. package/src/__tests__/StreamVideoClient.rewatch.test.ts +213 -0
  50. package/src/__tests__/StreamVideoClient.ringing.test.ts +11 -4
  51. package/src/__tests__/StreamVideoClient.test.ts +14 -12
  52. package/src/coordinator/connection/connection.ts +6 -3
  53. package/src/devices/AudioDeviceManager.ts +6 -0
  54. package/src/devices/CameraManager.ts +67 -8
  55. package/src/devices/DeviceManager.ts +24 -4
  56. package/src/devices/MicrophoneManager.ts +66 -8
  57. package/src/devices/ScreenShareManager.ts +3 -0
  58. package/src/devices/SpeakerManager.ts +42 -12
  59. package/src/devices/__tests__/CameraManager.test.ts +27 -1
  60. package/src/devices/__tests__/DeviceManager.test.ts +49 -1
  61. package/src/devices/__tests__/MicrophoneManager.test.ts +23 -1
  62. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +1 -20
  63. package/src/devices/__tests__/SpeakerManager.test.ts +138 -42
  64. package/src/devices/__tests__/mocks.ts +36 -3
  65. package/src/events/__tests__/participant.test.ts +134 -1
  66. package/src/events/call.ts +6 -3
  67. package/src/events/callEventHandlers.ts +9 -3
  68. package/src/events/participant.ts +42 -7
  69. package/src/gen/coordinator/index.ts +1402 -236
  70. package/src/gen/video/sfu/models/models.ts +15 -0
  71. package/src/helpers/MediaStreamRecorder.ts +104 -0
  72. package/src/helpers/TypedEventEmitter.ts +71 -0
  73. package/src/helpers/__tests__/MediaStreamRecorder.test.ts +324 -0
  74. package/src/helpers/__tests__/TypedEventEmitter.test.ts +133 -0
  75. package/src/helpers/__tests__/concurrency.test.ts +74 -0
  76. package/src/helpers/__tests__/loopback.test.ts +367 -0
  77. package/src/helpers/concurrency.ts +24 -0
  78. package/src/helpers/ensureExhausted.ts +4 -2
  79. package/src/helpers/loopback.ts +200 -0
  80. package/src/permissions/PermissionsContext.ts +10 -5
  81. package/src/reporting/ClientEventReporter.ts +15 -28
  82. package/src/reporting/__tests__/ClientEventReporter.test.ts +3 -7
  83. package/src/rtc/BasePeerConnection.ts +12 -1
  84. package/src/rtc/Publisher.ts +8 -0
  85. package/src/rtc/Subscriber.ts +13 -0
  86. package/src/rtc/__tests__/Publisher.test.ts +101 -0
  87. package/src/rtc/__tests__/Subscriber.test.ts +190 -0
  88. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +31 -0
  89. package/src/rtc/e2ee/E2EEManager.ts +12 -0
  90. package/src/rtc/e2ee/EncryptionManager.ts +336 -0
  91. package/src/rtc/e2ee/SPEC.md +586 -0
  92. package/src/rtc/e2ee/__tests__/E2EEManager.contract.test.ts +39 -0
  93. package/src/rtc/e2ee/__tests__/EncryptionManager.test.ts +490 -0
  94. package/src/rtc/e2ee/__tests__/codec.test.ts +247 -0
  95. package/src/rtc/e2ee/__tests__/conformance.test.ts +184 -0
  96. package/src/rtc/e2ee/__tests__/failureTracker.test.ts +45 -0
  97. package/src/rtc/e2ee/__tests__/frameCounter.test.ts +90 -0
  98. package/src/rtc/e2ee/__tests__/keyStore.test.ts +221 -0
  99. package/src/rtc/e2ee/__tests__/notifications.test.ts +166 -0
  100. package/src/rtc/e2ee/__tests__/perf.test.ts +139 -0
  101. package/src/rtc/e2ee/__tests__/queue.test.ts +61 -0
  102. package/src/rtc/e2ee/__tests__/replayWindow.test.ts +133 -0
  103. package/src/rtc/e2ee/__tests__/trailer.test.ts +143 -0
  104. package/src/rtc/e2ee/__tests__/transform-pipeline.test.ts +839 -0
  105. package/src/rtc/e2ee/__tests__/transformSupport.test.ts +65 -0
  106. package/src/rtc/e2ee/e2ee-worker/codec.ts +220 -0
  107. package/src/rtc/e2ee/e2ee-worker/constants.ts +47 -0
  108. package/src/rtc/e2ee/e2ee-worker/decode.ts +159 -0
  109. package/src/rtc/e2ee/e2ee-worker/e2ee-worker-impl.ts +137 -0
  110. package/src/rtc/e2ee/e2ee-worker/encode.ts +147 -0
  111. package/src/rtc/e2ee/e2ee-worker/failureTracker.ts +26 -0
  112. package/src/rtc/e2ee/e2ee-worker/frameCounter.ts +37 -0
  113. package/src/rtc/e2ee/e2ee-worker/keyStore.ts +211 -0
  114. package/src/rtc/e2ee/e2ee-worker/notifications.ts +190 -0
  115. package/src/rtc/e2ee/e2ee-worker/perf.ts +129 -0
  116. package/src/rtc/e2ee/e2ee-worker/queue.ts +12 -0
  117. package/src/rtc/e2ee/e2ee-worker/replayWindow.ts +131 -0
  118. package/src/rtc/e2ee/e2ee-worker/trailer.ts +155 -0
  119. package/src/rtc/e2ee/e2ee-worker/tsconfig.json +8 -0
  120. package/src/rtc/e2ee/e2ee-worker/types.ts +29 -0
  121. package/src/rtc/e2ee/e2ee-worker.ts +27 -0
  122. package/src/rtc/e2ee/events.ts +179 -0
  123. package/src/rtc/e2ee/transformSupport.ts +35 -0
  124. package/src/rtc/helpers/rtcConfiguration.ts +4 -2
  125. package/src/rtc/index.ts +1 -0
  126. package/src/rtc/mediaEngine.ts +44 -0
  127. package/src/rtc/types.ts +2 -0
  128. package/src/store/CallState.ts +33 -1
  129. package/src/store/__tests__/CallState.test.ts +82 -1
  130. 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,12 +33,13 @@ 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,
37
40
  CallRingEvent,
38
41
  CallSettingsResponse,
42
+ CallStateResponseFields,
39
43
  CollectUserFeedbackRequest,
40
44
  CollectUserFeedbackResponse,
41
45
  Credentials,
@@ -76,8 +80,8 @@ import type {
76
80
  RingCallResponse,
77
81
  SendCallEventRequest,
78
82
  SendCallEventResponse,
79
- SendReactionRequest,
80
- SendReactionResponse,
83
+ SendVideoReactionRequest,
84
+ SendVideoReactionResponse,
81
85
  StartClosedCaptionsRequest,
82
86
  StartClosedCaptionsResponse,
83
87
  StartFrameRecordingRequest,
@@ -109,6 +113,8 @@ import type {
109
113
  UpdateCallRequest,
110
114
  UpdateCallResponse,
111
115
  UpdateUserPermissionsRequest,
116
+ UpdateUserPermissionsRequestGrantPermissionsEnum,
117
+ UpdateUserPermissionsRequestRevokePermissionsEnum,
112
118
  UpdateUserPermissionsResponse,
113
119
  } from './gen/coordinator';
114
120
  import { OwnCapability } from './gen/coordinator';
@@ -173,7 +179,11 @@ import {
173
179
  SpeakerManager,
174
180
  } from './devices';
175
181
  import { normalize } from './devices/devicePersistence';
176
- import { hasPending, withoutConcurrency } from './helpers/concurrency';
182
+ import {
183
+ hasPending,
184
+ singleFlight,
185
+ withoutConcurrency,
186
+ } from './helpers/concurrency';
177
187
  import { ensureExhausted } from './helpers/ensureExhausted';
178
188
  import { pushToIfMissing } from './helpers/array';
179
189
  import {
@@ -266,6 +276,7 @@ export class Call {
266
276
 
267
277
  subscriber?: Subscriber;
268
278
  publisher?: Publisher;
279
+ e2eeManager?: E2EEManager;
269
280
 
270
281
  /**
271
282
  * Flag telling whether this call is a "ringing" call.
@@ -328,6 +339,8 @@ export class Call {
328
339
  private allowOwnTracksLoopback = false;
329
340
  private hasJoinedOnce = false;
330
341
  private deviceSettingsAppliedOnce = false;
342
+ private callManagerStarted = false;
343
+ private leaveGeneration = 0;
331
344
  private credentials?: Credentials;
332
345
 
333
346
  private initialized = false;
@@ -351,6 +364,14 @@ export class Call {
351
364
  ClientCapability.SUBSCRIBER_VIDEO_PAUSE,
352
365
  ]);
353
366
 
367
+ /**
368
+ * The in-flight per-call media engine. On web/React this resolves to a thin
369
+ * globals-backed engine (no provider registered); React Native registers a
370
+ * provider that owns a per-call native factory.
371
+ * @internal
372
+ */
373
+ private mediaEnginePromise?: Promise<CallMediaEngine>;
374
+
354
375
  /**
355
376
  * Constructs a new `Call` instance.
356
377
  *
@@ -699,6 +720,8 @@ export class Call {
699
720
  return;
700
721
  }
701
722
 
723
+ this.leaveGeneration += 1;
724
+
702
725
  if (callingState === CallingState.JOINING) {
703
726
  const waitUntilCallJoined = () => {
704
727
  return new Promise<void>((resolve) => {
@@ -735,7 +758,10 @@ export class Call {
735
758
  }
736
759
  }
737
760
  }
738
- globalThis.streamRNVideoSDK?.callingX?.endCall(this);
761
+ globalThis.streamRNVideoSDK?.callingX?.endCall(
762
+ this,
763
+ reason === 'ended' ? 'remote' : undefined,
764
+ );
739
765
 
740
766
  this.statsReporter?.stop();
741
767
  this.statsReporter = undefined;
@@ -799,6 +825,7 @@ export class Call {
799
825
 
800
826
  globalThis.streamRNVideoSDK?.callManager.stop({
801
827
  isRingingTypeCall: this.ringing,
828
+ shouldStopCallManager: this.callManagerStarted,
802
829
  });
803
830
 
804
831
  this.camera.dispose();
@@ -806,6 +833,7 @@ export class Call {
806
833
  this.screenShare.dispose();
807
834
  this.speaker.dispose();
808
835
  this.deviceSettingsAppliedOnce = false;
836
+ this.callManagerStarted = false;
809
837
 
810
838
  const stopOnLeavePromises: Promise<void>[] = [];
811
839
  if (this.camera.stopOnLeave) {
@@ -818,6 +846,23 @@ export class Call {
818
846
  stopOnLeavePromises.push(this.screenShare.disable(true));
819
847
  }
820
848
  await Promise.all(stopOnLeavePromises);
849
+
850
+ // Dispose the per-call media engine last — after peer connections and
851
+ // local tracks are gone — so the backing factory tears down with no
852
+ // owned PCs/tracks. A fresh `join()` builds a new engine.
853
+ if (this.mediaEnginePromise) {
854
+ const enginePromise = this.mediaEnginePromise;
855
+ this.mediaEnginePromise = undefined;
856
+ this.logger.debug('Disposing per-call media factory');
857
+ await enginePromise
858
+ .then((engine) => {
859
+ globalThis.streamRNVideoSDK?.callingX?.unwireAudioEngineSubscription();
860
+ return engine.dispose();
861
+ })
862
+ .catch((err) => {
863
+ this.logger.warn('Failed to dispose media engine', err);
864
+ });
865
+ }
821
866
  });
822
867
  };
823
868
 
@@ -899,8 +944,19 @@ export class Call {
899
944
  // const calls = useCalls().filter((c) => c.ringing);
900
945
  const calls = this.clientStore.calls.filter((c) => c.cid !== this.cid);
901
946
  this.clientStore.setCalls([this, ...calls]);
902
- const skipSpeakerApply = isReactNative();
903
- await this.applyDeviceConfig(settings, false, skipSpeakerApply);
947
+ await this.applyDeviceConfig(settings, { publish: false });
948
+ };
949
+
950
+ /**
951
+ * Applies a backend-provided call state snapshot
952
+ * (call, members, own capabilities) to this instance.
953
+ *
954
+ * @internal an internal method and should not be used outside the SDK.
955
+ */
956
+ updateFromCallStateResponse = (response: CallStateResponseFields) => {
957
+ this.state.updateFromCallResponse(response.call);
958
+ this.state.setMembers(response.members);
959
+ this.state.setOwnCapabilities(response.own_capabilities);
904
960
  };
905
961
 
906
962
  /**
@@ -924,9 +980,7 @@ export class Call {
924
980
  params,
925
981
  );
926
982
 
927
- this.state.updateFromCallResponse(response.call);
928
- this.state.setMembers(response.members);
929
- this.state.setOwnCapabilities(response.own_capabilities);
983
+ this.updateFromCallStateResponse(response);
930
984
 
931
985
  if (params?.ring) {
932
986
  this.ringingSubject.next(true);
@@ -936,17 +990,7 @@ export class Call {
936
990
  this.watching = true;
937
991
  this.clientStore.registerOrUpdateCall(this);
938
992
  }
939
- // 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;
945
- await this.applyDeviceConfig(
946
- response.call.settings,
947
- false,
948
- skipSpeakerApply,
949
- );
993
+ await this.applyDeviceConfig(response.call.settings, { publish: false });
950
994
 
951
995
  return response;
952
996
  };
@@ -964,9 +1008,7 @@ export class Call {
964
1008
  GetOrCreateCallRequest
965
1009
  >(this.streamClientBasePath, data);
966
1010
 
967
- this.state.updateFromCallResponse(response.call);
968
- this.state.setMembers(response.members);
969
- this.state.setOwnCapabilities(response.own_capabilities);
1011
+ this.updateFromCallStateResponse(response);
970
1012
 
971
1013
  if (data?.ring) {
972
1014
  this.ringingSubject.next(true);
@@ -977,17 +1019,7 @@ export class Call {
977
1019
  this.clientStore.registerOrUpdateCall(this);
978
1020
  }
979
1021
 
980
- // 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;
986
- await this.applyDeviceConfig(
987
- response.call.settings,
988
- false,
989
- skipSpeakerApply,
990
- );
1022
+ await this.applyDeviceConfig(response.call.settings, { publish: false });
991
1023
 
992
1024
  return response;
993
1025
  };
@@ -1074,112 +1106,113 @@ export class Call {
1074
1106
  *
1075
1107
  * @returns a promise which resolves once the call join-flow has finished.
1076
1108
  */
1077
- join = async ({
1078
- maxJoinRetries = 3,
1079
- joinResponseTimeout,
1080
- rpcRequestTimeout,
1081
- allowOwnTracksLoopback = false,
1082
- ...data
1083
- }: JoinCallData & {
1084
- maxJoinRetries?: number;
1085
- joinResponseTimeout?: number;
1086
- rpcRequestTimeout?: number;
1087
- allowOwnTracksLoopback?: boolean;
1088
- } = {}): Promise<void> => {
1089
- const callingState = this.state.callingState;
1109
+ join = singleFlight(
1110
+ async ({
1111
+ maxJoinRetries = 3,
1112
+ joinResponseTimeout,
1113
+ rpcRequestTimeout,
1114
+ allowOwnTracksLoopback = false,
1115
+ ...data
1116
+ }: JoinCallData & {
1117
+ maxJoinRetries?: number;
1118
+ joinResponseTimeout?: number;
1119
+ rpcRequestTimeout?: number;
1120
+ allowOwnTracksLoopback?: boolean;
1121
+ } = {}): Promise<void> => {
1122
+ const callingState = this.state.callingState;
1090
1123
 
1091
- if ([CallingState.JOINED, CallingState.JOINING].includes(callingState)) {
1092
- throw new Error(`Illegal State: call.join() shall be called only once`);
1093
- }
1124
+ if ([CallingState.JOINED, CallingState.JOINING].includes(callingState)) {
1125
+ throw new Error(`Illegal State: call.join() shall be called only once`);
1126
+ }
1094
1127
 
1095
- // we need this to be set before the callingx.joinCall() is
1096
- // called to avoid registering the test call in the CallKit/Telecom
1097
- this.allowOwnTracksLoopback = allowOwnTracksLoopback;
1128
+ // we need this to be set before the callingx.joinCall() is
1129
+ // called to avoid registering the test call in the CallKit/Telecom
1130
+ this.allowOwnTracksLoopback = allowOwnTracksLoopback;
1098
1131
 
1099
- if (data?.ring) {
1100
- this.ringingSubject.next(true);
1101
- }
1132
+ if (data?.ring) {
1133
+ this.ringingSubject.next(true);
1134
+ }
1102
1135
 
1103
- const callingX = globalThis.streamRNVideoSDK?.callingX;
1104
- if (callingX) {
1105
- // for Android/iOS, we need to start the call in the callingx library as soon as possible
1106
- await callingX.joinCall(this, this.clientStore.calls);
1107
- }
1136
+ const callingX = globalThis.streamRNVideoSDK?.callingX;
1137
+ if (callingX) {
1138
+ // for Android/iOS, we need to start the call in the callingx library as soon as possible
1139
+ await callingX.joinCall(this, this.clientStore.calls);
1140
+ }
1108
1141
 
1109
- await this.setup();
1142
+ await this.setup();
1110
1143
 
1111
- this.clientEventReporter.registerCall(this.cid, {
1112
- callType: this.type,
1113
- callId: this.id,
1114
- getCallSessionId: () => this.state.session?.id ?? '',
1115
- getSfuId: () => this.credentials?.server.edge_name ?? '',
1116
- getUserSessionId: () => this.sfuClient?.sessionId ?? '',
1117
- });
1144
+ this.clientEventReporter.registerCall(this.cid, {
1145
+ callType: this.type,
1146
+ callId: this.id,
1147
+ getCallSessionId: () => this.state.session?.id ?? '',
1148
+ getSfuId: () => this.credentials?.server.edge_name ?? '',
1149
+ });
1118
1150
 
1119
- this.joinResponseTimeout = joinResponseTimeout;
1120
- this.rpcRequestTimeout = rpcRequestTimeout;
1121
- // we will count the number of join failures per SFU.
1122
- // once the number of failures reaches 2, we will piggyback on the `migrating_from`
1123
- // field to force the coordinator to provide us another SFU
1124
- const sfuJoinFailures = new Map<string, number>();
1125
- const joinData: JoinCallData = data;
1126
- maxJoinRetries = Math.max(maxJoinRetries, 1);
1127
- try {
1128
- await this.clientEventReporter.withJoinLifecycle(
1129
- this.cid,
1130
- 'first-attempt',
1131
- async () => {
1132
- for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
1133
- try {
1134
- this.logger.trace(`Joining call (${attempt})`, this.cid);
1135
- await this.doJoin(data);
1136
- delete joinData.migrating_from;
1137
- delete joinData.migrating_from_list;
1138
- return;
1139
- } catch (err) {
1140
- this.logger.warn(`Failed to join call (${attempt})`, this.cid);
1141
- if (
1142
- (err instanceof ErrorFromResponse && err.unrecoverable) ||
1143
- (err instanceof SfuJoinError && err.unrecoverable)
1144
- ) {
1145
- throw err;
1146
- }
1151
+ this.joinResponseTimeout = joinResponseTimeout;
1152
+ this.rpcRequestTimeout = rpcRequestTimeout;
1153
+ // we will count the number of join failures per SFU.
1154
+ // once the number of failures reaches 2, we will piggyback on the `migrating_from`
1155
+ // field to force the coordinator to provide us another SFU
1156
+ const sfuJoinFailures = new Map<string, number>();
1157
+ const joinData: JoinCallData = data;
1158
+ maxJoinRetries = Math.max(maxJoinRetries, 1);
1159
+ try {
1160
+ await this.clientEventReporter.withJoinLifecycle(
1161
+ this.cid,
1162
+ 'first-attempt',
1163
+ async () => {
1164
+ for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
1165
+ try {
1166
+ this.logger.trace(`Joining call (${attempt})`, this.cid);
1167
+ await this.doJoin(data);
1168
+ delete joinData.migrating_from;
1169
+ delete joinData.migrating_from_list;
1170
+ return;
1171
+ } catch (err) {
1172
+ this.logger.warn(`Failed to join call (${attempt})`, this.cid);
1173
+ if (
1174
+ (err instanceof ErrorFromResponse && err.unrecoverable) ||
1175
+ (err instanceof SfuJoinError && err.unrecoverable)
1176
+ ) {
1177
+ throw err;
1178
+ }
1147
1179
 
1148
- const switchSfu =
1149
- err instanceof SfuJoinError &&
1150
- SfuJoinError.isJoinErrorCode(err.errorEvent);
1151
-
1152
- const sfuId = this.credentials?.server.edge_name;
1153
- if (sfuId) {
1154
- const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
1155
- sfuJoinFailures.set(sfuId, failures);
1156
- if (switchSfu || failures >= 2) {
1157
- joinData.migrating_from = sfuId;
1158
- joinData.migrating_from_list = Array.from(
1159
- sfuJoinFailures.keys(),
1160
- );
1161
- if (attempt < maxJoinRetries - 1) {
1162
- this.clientEventReporter.startCorrelation(
1163
- this.cid,
1164
- 'first-attempt',
1180
+ const switchSfu =
1181
+ err instanceof SfuJoinError &&
1182
+ SfuJoinError.isJoinErrorCode(err.errorEvent);
1183
+
1184
+ const sfuId = this.credentials?.server.edge_name;
1185
+ if (sfuId) {
1186
+ const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
1187
+ sfuJoinFailures.set(sfuId, failures);
1188
+ if (switchSfu || failures >= 2) {
1189
+ joinData.migrating_from = sfuId;
1190
+ joinData.migrating_from_list = Array.from(
1191
+ sfuJoinFailures.keys(),
1165
1192
  );
1193
+ if (attempt < maxJoinRetries - 1) {
1194
+ this.clientEventReporter.startCorrelation(
1195
+ this.cid,
1196
+ 'first-attempt',
1197
+ );
1198
+ }
1166
1199
  }
1167
1200
  }
1168
- }
1169
1201
 
1170
- if (attempt === maxJoinRetries - 1) {
1171
- throw err;
1202
+ if (attempt === maxJoinRetries - 1) {
1203
+ throw err;
1204
+ }
1172
1205
  }
1206
+ await sleep(retryInterval(attempt));
1173
1207
  }
1174
- await sleep(retryInterval(attempt));
1175
- }
1176
- },
1177
- );
1178
- } catch (error) {
1179
- callingX?.endCall(this, 'error');
1180
- throw error;
1181
- }
1182
- };
1208
+ },
1209
+ );
1210
+ } catch (error) {
1211
+ callingX?.endCall(this, 'error');
1212
+ throw error;
1213
+ }
1214
+ },
1215
+ );
1183
1216
 
1184
1217
  /**
1185
1218
  * Will make a single attempt to watch for call related WebSocket events
@@ -1190,12 +1223,26 @@ export class Call {
1190
1223
  private doJoin = async (data?: JoinCallData): Promise<void> => {
1191
1224
  const connectStartTime = Date.now();
1192
1225
  const callingState = this.state.callingState;
1226
+ const joinLeaveGeneration = this.leaveGeneration;
1227
+ const supersededByLeave = () =>
1228
+ this.leaveGeneration !== joinLeaveGeneration;
1193
1229
 
1194
1230
  this.joinCallData = data;
1195
1231
 
1196
1232
  this.logger.debug('Starting join flow');
1197
1233
  this.state.setCallingState(CallingState.JOINING);
1198
1234
 
1235
+ // Ensure the per-call media engine exists before any peer connection
1236
+ // (codec probe, subscriber, publisher) or capture happens, so the WebRTC
1237
+ // globals resolve to the call's factory. Idempotent across
1238
+ // reconnect/migration attempts.
1239
+ await this.ensureMediaFactory();
1240
+
1241
+ const callingX = globalThis.streamRNVideoSDK?.callingX;
1242
+ if (callingX) {
1243
+ callingX.wireAudioEngineSubscription();
1244
+ }
1245
+
1199
1246
  const performingMigration =
1200
1247
  this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
1201
1248
  const performingRejoin =
@@ -1266,6 +1313,11 @@ export class Call {
1266
1313
  // the capabilities of the client (codec support, etc.)
1267
1314
  const { dangerouslyForceCodec, fmtpLine, subscriberFmtpLine } =
1268
1315
  this.clientPublishOptions || {};
1316
+ // skip if a leave superseded this join so codec detection doesn't resolve to a default factory.
1317
+ if (supersededByLeave()) {
1318
+ this.logger.debug('Join superseded by leave; skipping codec detection');
1319
+ return;
1320
+ }
1269
1321
  const [subscriberSdp, publisherSdp] = await Promise.all([
1270
1322
  getGenericSdp('recvonly', dangerouslyForceCodec, subscriberFmtpLine),
1271
1323
  getGenericSdp('sendonly', dangerouslyForceCodec, fmtpLine),
@@ -1325,6 +1377,13 @@ export class Call {
1325
1377
  }
1326
1378
  }
1327
1379
 
1380
+ // If the user left while this join was in flight, bail before re-setting JOINED and before
1381
+ // peer-connection setup below (both run synchronously after this, so one check covers them).
1382
+ if (supersededByLeave()) {
1383
+ this.logger.debug('Join superseded by leave; aborting join flow');
1384
+ return;
1385
+ }
1386
+
1328
1387
  if (!performingMigration) {
1329
1388
  // in MIGRATION, `JOINED` state is set in `this.reconnectMigrate()`
1330
1389
  this.state.setCallingState(CallingState.JOINED);
@@ -1371,12 +1430,24 @@ export class Call {
1371
1430
 
1372
1431
  // device settings should be applied only once, we don't have to
1373
1432
  // re-apply them on later reconnections or server-side data fetches
1374
- if (!this.deviceSettingsAppliedOnce && this.state.settings) {
1375
- await this.applyDeviceConfig(this.state.settings, true, false);
1433
+ if (
1434
+ !this.deviceSettingsAppliedOnce &&
1435
+ this.state.settings &&
1436
+ !supersededByLeave()
1437
+ ) {
1438
+ await this.applyDeviceConfig(this.state.settings, {
1439
+ publish: true,
1440
+ skipSpeakerApply: false,
1441
+ });
1442
+ this.deviceSettingsAppliedOnce = true;
1443
+ }
1444
+
1445
+ if (!this.callManagerStarted && !supersededByLeave()) {
1376
1446
  globalThis.streamRNVideoSDK?.callManager.start({
1377
1447
  isRingingTypeCall: this.ringing,
1448
+ cid: this.cid,
1378
1449
  });
1379
- this.deviceSettingsAppliedOnce = true;
1450
+ this.callManagerStarted = true;
1380
1451
  }
1381
1452
 
1382
1453
  // We shouldn't persist the `ring` and `notify` state after joining the call
@@ -1542,6 +1613,7 @@ export class Call {
1542
1613
  this.sfuStatsReporter = undefined;
1543
1614
  if (closePreviousInstances && this.subscriber) {
1544
1615
  await this.subscriber.dispose();
1616
+ this.state.removeAllOrphanedTracks();
1545
1617
  }
1546
1618
  const basePeerConnectionOptions: BasePeerConnectionOpts = {
1547
1619
  sfuClient,
@@ -1552,6 +1624,7 @@ export class Call {
1552
1624
  enableTracing,
1553
1625
  statsTimestampDriftThresholdMs: reportingIntervalMs / 2,
1554
1626
  clientPublishOptions: this.clientPublishOptions,
1627
+ e2ee: this.e2eeManager,
1555
1628
  onReconnectionNeeded: (kind, reason, peerType) => {
1556
1629
  this.reconnect(kind, reason).catch((err) => {
1557
1630
  const message = `[Reconnect] Error reconnecting, after a ${PeerType[peerType]} error: ${reason}`;
@@ -1628,12 +1701,12 @@ export class Call {
1628
1701
  * Retrieves credentials for joining the call.
1629
1702
  *
1630
1703
  * @internal
1631
- *
1632
1704
  * @param data the join call data.
1633
1705
  */
1634
1706
  doJoinRequest = async (data?: JoinCallData): Promise<JoinCallResponse> => {
1635
1707
  const location = await this.streamClient.getLocationHint();
1636
- const request: JoinCallRequest = { ...data, location };
1708
+ const e2ee = !!this.e2eeManager;
1709
+ const request: JoinCallRequest = { ...data, location, e2ee };
1637
1710
  const joinResponse = await this.streamClient.post<
1638
1711
  JoinCallResponse,
1639
1712
  JoinCallRequest
@@ -1663,6 +1736,39 @@ export class Call {
1663
1736
  return joinResponse;
1664
1737
  };
1665
1738
 
1739
+ /**
1740
+ * Whether the per-call media engine currently exists. True from join until leave.
1741
+ *
1742
+ * @internal an internal getter and should not be used outside the SDK.
1743
+ */
1744
+ get hasMediaEngine(): boolean {
1745
+ return !!this.mediaEnginePromise;
1746
+ }
1747
+
1748
+ /**
1749
+ * Ensures a {@link CallMediaEngine} exists for this call's media session and
1750
+ * returns it. Idempotent: the engine is created once via the registered
1751
+ * provider (see `setCallMediaEngineProvider`) and cached until `leave()`
1752
+ * disposes it. Concurrent callers (e.g. camera + microphone enabling in
1753
+ * parallel) share the same engine because the in-flight creation promise is
1754
+ * cached, never the unresolved result.
1755
+ *
1756
+ * @internal
1757
+ */
1758
+ ensureMediaFactory = async (): Promise<CallMediaEngine> => {
1759
+ if (!this.mediaEnginePromise) {
1760
+ const provider = getCallMediaEngineProvider();
1761
+
1762
+ this.logger.debug(`Requesting per-call media factory creation`);
1763
+ this.mediaEnginePromise = Promise.resolve(provider()).catch((err) => {
1764
+ // Drop the cached rejection so a retried join() can rebuild the engine
1765
+ this.mediaEnginePromise = undefined;
1766
+ throw err;
1767
+ });
1768
+ }
1769
+ return this.mediaEnginePromise;
1770
+ };
1771
+
1666
1772
  /**
1667
1773
  * Handles the closing of the SFU signal connection.
1668
1774
  *
@@ -2312,12 +2418,18 @@ export class Call {
2312
2418
  const streamStateProp = trackTypeToParticipantStreamKey(trackType);
2313
2419
  if (!streamStateProp) continue;
2314
2420
 
2315
- this.state.updateParticipant(sessionId, (p) => ({
2316
- publishedTracks: mediaStream
2317
- ? pushToIfMissing([...p.publishedTracks], trackType)
2318
- : p.publishedTracks.filter((t) => t !== trackType),
2319
- [streamStateProp]: mediaStream,
2320
- }));
2421
+ this.state.updateParticipant(sessionId, (p) => {
2422
+ const currentStream = p[streamStateProp];
2423
+ const isEchoedStream =
2424
+ this.subscriber?.isSelfSubscribedStream(currentStream);
2425
+
2426
+ return {
2427
+ publishedTracks: mediaStream
2428
+ ? pushToIfMissing([...p.publishedTracks], trackType)
2429
+ : p.publishedTracks.filter((t) => t !== trackType),
2430
+ [streamStateProp]: isEchoedStream ? currentStream : mediaStream,
2431
+ };
2432
+ });
2321
2433
  }
2322
2434
  };
2323
2435
 
@@ -2355,6 +2467,31 @@ export class Call {
2355
2467
  this.clientPublishOptions = { ...this.clientPublishOptions, ...options };
2356
2468
  };
2357
2469
 
2470
+ /**
2471
+ * Set the E2EE (end-to-end encryption) manager for this call.
2472
+ *
2473
+ * Must be called before {@link join} so the RTCPeerConnection can be
2474
+ * configured for E2EE.
2475
+ *
2476
+ * The manager is kept across {@link leave} so a rejoin of this same instance
2477
+ * stays encrypted: do not dispose it while this call may be joined again.
2478
+ * A disposed manager throws from `encrypt`/`decrypt` rather than silently
2479
+ * publishing nothing, so re-attach a fresh one instead of reusing it.
2480
+ *
2481
+ * @param e2ee - Any `E2EEManager`. Use `EncryptionManager.create()` for the
2482
+ * built-in AES-GCM scheme, or pass your own implementation.
2483
+ * @throws if called after the peer connections have been built (i.e. after
2484
+ * `join`): those PCs were already configured without an encryptor, so
2485
+ * adopting a manager now would silently publish/receive cleartext for
2486
+ * the live session.
2487
+ */
2488
+ setE2EEManager = (e2ee: E2EEManager) => {
2489
+ if (this.publisher || this.subscriber) {
2490
+ throw new Error('setE2EEManager must be called before join()');
2491
+ }
2492
+ this.e2eeManager = e2ee;
2493
+ };
2494
+
2358
2495
  /**
2359
2496
  * Notifies the SFU that a noise cancellation process has started.
2360
2497
  *
@@ -2453,9 +2590,9 @@ export class Call {
2453
2590
  * @param reaction the reaction to send.
2454
2591
  */
2455
2592
  sendReaction = async (
2456
- reaction: SendReactionRequest,
2457
- ): Promise<SendReactionResponse> => {
2458
- return this.streamClient.post<SendReactionResponse, SendReactionRequest>(
2593
+ reaction: SendVideoReactionRequest,
2594
+ ): Promise<SendVideoReactionResponse> => {
2595
+ return this.streamClient.post(
2459
2596
  `${this.streamClientBasePath}/reaction`,
2460
2597
  reaction,
2461
2598
  );
@@ -2673,7 +2810,7 @@ export class Call {
2673
2810
  ): Promise<RequestPermissionResponse> => {
2674
2811
  const { permissions } = data;
2675
2812
  const canRequestPermissions = permissions.every((permission) =>
2676
- this.permissionsContext.canRequest(permission as OwnCapability),
2813
+ this.permissionsContext.canRequest(permission),
2677
2814
  );
2678
2815
  if (!canRequestPermissions) {
2679
2816
  throw new Error(
@@ -2698,10 +2835,14 @@ export class Call {
2698
2835
  * @param userId the id of the user to grant permissions to.
2699
2836
  * @param permissions the permissions to grant.
2700
2837
  */
2701
- grantPermissions = async (userId: string, permissions: string[]) => {
2838
+ grantPermissions = async (
2839
+ userId: string,
2840
+ permissions: string[] | UpdateUserPermissionsRequestGrantPermissionsEnum[],
2841
+ ) => {
2702
2842
  return this.updateUserPermissions({
2703
2843
  user_id: userId,
2704
- grant_permissions: permissions,
2844
+ grant_permissions:
2845
+ permissions as UpdateUserPermissionsRequestGrantPermissionsEnum[],
2705
2846
  });
2706
2847
  };
2707
2848
 
@@ -2717,10 +2858,14 @@ export class Call {
2717
2858
  * @param userId the id of the user to revoke permissions from.
2718
2859
  * @param permissions the permissions to revoke.
2719
2860
  */
2720
- revokePermissions = async (userId: string, permissions: string[]) => {
2861
+ revokePermissions = async (
2862
+ userId: string,
2863
+ permissions: string[] | UpdateUserPermissionsRequestRevokePermissionsEnum[],
2864
+ ) => {
2721
2865
  return this.updateUserPermissions({
2722
2866
  user_id: userId,
2723
- revoke_permissions: permissions,
2867
+ revoke_permissions:
2868
+ permissions as UpdateUserPermissionsRequestRevokePermissionsEnum[],
2724
2869
  });
2725
2870
  };
2726
2871
 
@@ -3235,18 +3380,29 @@ export class Call {
3235
3380
  */
3236
3381
  applyDeviceConfig = async (
3237
3382
  settings: CallSettingsResponse,
3238
- publish: boolean,
3239
- skipSpeakerApply: boolean,
3383
+ opts: {
3384
+ publish: boolean;
3385
+ skipSpeakerApply?: boolean;
3386
+ withDisabledDevices?: boolean;
3387
+ },
3240
3388
  ) => {
3389
+ const {
3390
+ publish,
3391
+ // Skip speaker setup on RN until joining (explicitly passed as false during join())
3392
+ skipSpeakerApply = isReactNative(),
3393
+ withDisabledDevices,
3394
+ } = opts;
3241
3395
  if (!skipSpeakerApply) {
3242
- this.speaker.apply(settings);
3396
+ await this.speaker
3397
+ .apply(settings)
3398
+ .catch((err) => this.logger.warn('Speaker init failed', err));
3243
3399
  }
3244
- await this.camera.apply(settings.video, publish).catch((err) => {
3245
- this.logger.warn('Camera init failed', err);
3246
- });
3247
- await this.microphone.apply(settings.audio, publish).catch((err) => {
3248
- this.logger.warn('Mic init failed', err);
3249
- });
3400
+ await this.camera
3401
+ .apply(settings.video, publish, withDisabledDevices)
3402
+ .catch((err) => this.logger.warn('Camera init failed', err));
3403
+ await this.microphone
3404
+ .apply(settings.audio, publish, withDisabledDevices)
3405
+ .catch((err) => this.logger.warn('Mic init failed', err));
3250
3406
  };
3251
3407
 
3252
3408
  /**