@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/dist/index.cjs.js CHANGED
@@ -87,6 +87,22 @@ const CreateDeviceRequestPushProviderEnum = {
87
87
  HUAWEI: 'huawei',
88
88
  XIAOMI: 'xiaomi',
89
89
  };
90
+ /**
91
+ * @export
92
+ */
93
+ const EncryptionSettingsRequestModeEnum = {
94
+ AVAILABLE: 'available',
95
+ DISABLED: 'disabled',
96
+ AUTO_ON: 'auto-on',
97
+ };
98
+ /**
99
+ * @export
100
+ */
101
+ const EncryptionSettingsResponseModeEnum = {
102
+ AVAILABLE: 'available',
103
+ DISABLED: 'disabled',
104
+ AUTO_ON: 'auto-on',
105
+ };
90
106
  /**
91
107
  * @export
92
108
  */
@@ -113,6 +129,21 @@ const FrameRecordingSettingsResponseModeEnum = {
113
129
  DISABLED: 'disabled',
114
130
  AUTO_ON: 'auto-on',
115
131
  };
132
+ /**
133
+ * @export
134
+ */
135
+ const HLSSettingsRequestQualityTracksEnum = {
136
+ _360P: '360p',
137
+ _480P: '480p',
138
+ _720P: '720p',
139
+ _1080P: '1080p',
140
+ _1440P: '1440p',
141
+ PORTRAIT_360X640: 'portrait-360x640',
142
+ PORTRAIT_480X854: 'portrait-480x854',
143
+ PORTRAIT_720X1280: 'portrait-720x1280',
144
+ PORTRAIT_1080X1920: 'portrait-1080x1920',
145
+ PORTRAIT_1440X2560: 'portrait-1440x2560',
146
+ };
116
147
  /**
117
148
  * @export
118
149
  */
@@ -121,6 +152,17 @@ const IndividualRecordingSettingsRequestModeEnum = {
121
152
  DISABLED: 'disabled',
122
153
  AUTO_ON: 'auto-on',
123
154
  };
155
+ /**
156
+ * @export
157
+ */
158
+ const IndividualRecordingSettingsRequestOutputTypesEnum = {
159
+ AUDIO_ONLY: 'audio_only',
160
+ VIDEO_ONLY: 'video_only',
161
+ AUDIO_VIDEO: 'audio_video',
162
+ SCREENSHARE_AUDIO_ONLY: 'screenshare_audio_only',
163
+ SCREENSHARE_VIDEO_ONLY: 'screenshare_video_only',
164
+ SCREENSHARE_AUDIO_VIDEO: 'screenshare_audio_video',
165
+ };
124
166
  /**
125
167
  * @export
126
168
  */
@@ -279,6 +321,14 @@ const RecordSettingsRequestQualityEnum = {
279
321
  PORTRAIT_1080X1920: 'portrait-1080x1920',
280
322
  PORTRAIT_1440X2560: 'portrait-1440x2560',
281
323
  };
324
+ /**
325
+ * @export
326
+ */
327
+ const RequestPermissionRequestPermissionsEnum = {
328
+ SCREENSHARE: 'screenshare',
329
+ SEND_AUDIO: 'send-audio',
330
+ SEND_VIDEO: 'send-video',
331
+ };
282
332
  /**
283
333
  * @export
284
334
  */
@@ -479,6 +529,59 @@ const TranscriptionSettingsResponseModeEnum = {
479
529
  DISABLED: 'disabled',
480
530
  AUTO_ON: 'auto-on',
481
531
  };
532
+ /**
533
+ * @export
534
+ */
535
+ const TranslationSettingsLanguagesEnum = {
536
+ EN: 'en',
537
+ FR: 'fr',
538
+ ES: 'es',
539
+ DE: 'de',
540
+ IT: 'it',
541
+ NL: 'nl',
542
+ PT: 'pt',
543
+ PL: 'pl',
544
+ CA: 'ca',
545
+ CS: 'cs',
546
+ DA: 'da',
547
+ EL: 'el',
548
+ FI: 'fi',
549
+ ID: 'id',
550
+ JA: 'ja',
551
+ RU: 'ru',
552
+ SV: 'sv',
553
+ TA: 'ta',
554
+ TH: 'th',
555
+ TR: 'tr',
556
+ HU: 'hu',
557
+ RO: 'ro',
558
+ ZH: 'zh',
559
+ AR: 'ar',
560
+ TL: 'tl',
561
+ HE: 'he',
562
+ HI: 'hi',
563
+ HR: 'hr',
564
+ KO: 'ko',
565
+ MS: 'ms',
566
+ NO: 'no',
567
+ UK: 'uk',
568
+ };
569
+ /**
570
+ * @export
571
+ */
572
+ const UpdateUserPermissionsRequestGrantPermissionsEnum = {
573
+ SCREENSHARE: 'screenshare',
574
+ SEND_AUDIO: 'send-audio',
575
+ SEND_VIDEO: 'send-video',
576
+ };
577
+ /**
578
+ * @export
579
+ */
580
+ const UpdateUserPermissionsRequestRevokePermissionsEnum = {
581
+ SCREENSHARE: 'screenshare',
582
+ SEND_AUDIO: 'send-audio',
583
+ SEND_VIDEO: 'send-video',
584
+ };
482
585
  /**
483
586
  * @export
484
587
  */
@@ -495,6 +598,14 @@ const VideoSettingsResponseCameraFacingEnum = {
495
598
  BACK: 'back',
496
599
  EXTERNAL: 'external',
497
600
  };
601
+ /**
602
+ * @export
603
+ */
604
+ const WSAuthMessageProductsEnum = {
605
+ CHAT: 'chat',
606
+ VIDEO: 'video',
607
+ FEEDS: 'feeds',
608
+ };
498
609
 
499
610
  class ErrorFromResponse extends Error {
500
611
  code;
@@ -1492,6 +1603,13 @@ class CallState$Type extends runtime.MessageType {
1492
1603
  repeat: 2 /*RepeatType.UNPACKED*/,
1493
1604
  T: () => Pin,
1494
1605
  },
1606
+ {
1607
+ no: 5,
1608
+ name: 'e2ee_enabled',
1609
+ kind: 'scalar',
1610
+ jsonName: 'e2eeEnabled',
1611
+ T: 8 /*ScalarType.BOOL*/,
1612
+ },
1495
1613
  ]);
1496
1614
  }
1497
1615
  }
@@ -4614,6 +4732,19 @@ const isSvcCodec = (codecOrMimeType) => {
4614
4732
  codecOrMimeType === 'video/av1');
4615
4733
  };
4616
4734
 
4735
+ /**
4736
+ * The default engine: a thin, stateless wrapper over the WebRTC globals.
4737
+ */
4738
+ const defaultGlobalsEngine = {
4739
+ dispose: () => Promise.resolve(true),
4740
+ };
4741
+ const defaultGlobalsEngineProvider = () => defaultGlobalsEngine;
4742
+ let provider = defaultGlobalsEngineProvider;
4743
+ const setCallMediaEngineProvider = (newProvider) => {
4744
+ provider = newProvider;
4745
+ };
4746
+ const getCallMediaEngineProvider = () => provider;
4747
+
4617
4748
  const sfuEventKinds = {
4618
4749
  subscriberOffer: undefined,
4619
4750
  publisherAnswer: undefined,
@@ -4821,7 +4952,9 @@ const parseUfragFromCandidate = (candidate) => {
4821
4952
  };
4822
4953
 
4823
4954
  const ensureExhausted = (x, message) => {
4824
- videoLoggerSystem.getLogger('helpers').warn(message, x);
4955
+ if (message) {
4956
+ videoLoggerSystem.getLogger('helpers').warn(message, x);
4957
+ }
4825
4958
  };
4826
4959
 
4827
4960
  /**
@@ -4946,7 +5079,7 @@ const toIceCandidate = (iceTrickle) => {
4946
5079
  }
4947
5080
  };
4948
5081
 
4949
- const version = "1.57.0";
5082
+ const version = "1.59.0";
4950
5083
  const [major, minor, patch] = version.split('.');
4951
5084
  let sdkInfo = {
4952
5085
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -5203,6 +5336,27 @@ const withoutConcurrency = createRunner(wrapWithContinuationTracking);
5203
5336
  */
5204
5337
  const withCancellation = createRunner(wrapWithCancellation);
5205
5338
  const pendingPromises = new Map();
5339
+ /**
5340
+ * Wraps an async function so concurrent calls share the in-flight promise
5341
+ * instead of queueing another execution.
5342
+ *
5343
+ * @param cb Async function to wrap.
5344
+ * @returns Wrapped async function.
5345
+ */
5346
+ function singleFlight(cb) {
5347
+ let pending;
5348
+ return (...args) => {
5349
+ if (pending)
5350
+ return pending;
5351
+ const promise = cb(...args).finally(() => {
5352
+ if (pending === promise) {
5353
+ pending = undefined;
5354
+ }
5355
+ });
5356
+ pending = promise;
5357
+ return promise;
5358
+ };
5359
+ }
5206
5360
  function hasPending(tag) {
5207
5361
  return pendingPromises.has(tag);
5208
5362
  }
@@ -5929,6 +6083,7 @@ class CallState {
5929
6083
  settingsSubject = new rxjs.BehaviorSubject(undefined);
5930
6084
  transcribingSubject = new rxjs.BehaviorSubject(false);
5931
6085
  captioningSubject = new rxjs.BehaviorSubject(false);
6086
+ e2eeEnabledSubject = new rxjs.BehaviorSubject(false);
5932
6087
  endedBySubject = new rxjs.BehaviorSubject(undefined);
5933
6088
  thumbnailsSubject = new rxjs.BehaviorSubject(undefined);
5934
6089
  membersSubject = new rxjs.BehaviorSubject([]);
@@ -6087,6 +6242,11 @@ class CallState {
6087
6242
  * Will provide the closed captioning state of this call.
6088
6243
  */
6089
6244
  captioning$;
6245
+ /**
6246
+ * Whether end-to-end encryption is active for this call, as reported by the
6247
+ * SFU in the join response.
6248
+ */
6249
+ e2eeEnabled$;
6090
6250
  /**
6091
6251
  * Will provide the user who ended this call.
6092
6252
  */
@@ -6188,6 +6348,7 @@ class CallState {
6188
6348
  this.rawRecording$ = duc(this.rawRecordingSubject);
6189
6349
  this.transcribing$ = duc(this.transcribingSubject);
6190
6350
  this.captioning$ = duc(this.captioningSubject);
6351
+ this.e2eeEnabled$ = duc(this.e2eeEnabledSubject);
6191
6352
  this.eventHandlers = {
6192
6353
  // these events are not updating the call state:
6193
6354
  'call.frame_recording_ready': undefined,
@@ -6290,6 +6451,7 @@ class CallState {
6290
6451
  clearTimeout(taskId);
6291
6452
  this.closedCaptionsTasks.delete(ccKey);
6292
6453
  }
6454
+ this.removeAllOrphanedTracks();
6293
6455
  };
6294
6456
  /**
6295
6457
  * Sets the list of criteria that are used to sort the participants.
@@ -6639,6 +6801,12 @@ class CallState {
6639
6801
  get transcribing() {
6640
6802
  return this.getCurrentValue(this.transcribing$);
6641
6803
  }
6804
+ /**
6805
+ * Whether end-to-end encryption is active for this call.
6806
+ */
6807
+ get e2eeEnabled() {
6808
+ return this.getCurrentValue(this.e2eeEnabled$);
6809
+ }
6642
6810
  /**
6643
6811
  * Will provide the user who ended this call.
6644
6812
  */
@@ -6859,6 +7027,18 @@ class CallState {
6859
7027
  removeOrphanedTrack = (id) => {
6860
7028
  this.orphanedTracks = this.orphanedTracks.filter((o) => o.id !== id);
6861
7029
  };
7030
+ /**
7031
+ * Drops every orphaned track. Call this when the peer connections that own
7032
+ * the stored receivers go away (full leave, reconnect, or migration):
7033
+ * `pc.close()` does not raise the track `ended` event, so the per-track
7034
+ * cleanup never fires and the receivers + their closed PCs would otherwise
7035
+ * leak for the call's lifetime.
7036
+ *
7037
+ * @internal
7038
+ */
7039
+ removeAllOrphanedTracks = () => {
7040
+ this.orphanedTracks = [];
7041
+ };
6862
7042
  /**
6863
7043
  * Takes all orphaned tracks with the given track lookup prefix.
6864
7044
  * All orphaned tracks with the given track lookup prefix are removed from the call state.
@@ -6921,7 +7101,7 @@ class CallState {
6921
7101
  * @param reconnectDetails optional reconnect details.
6922
7102
  */
6923
7103
  updateFromSfuCallState = (callState, currentSessionId, reconnectDetails) => {
6924
- const { participants, participantCount, startedAt, pins } = callState;
7104
+ const { participants, participantCount, startedAt, pins, e2EeEnabled } = callState;
6925
7105
  const localPublishedTracks = reconnectDetails?.announcedTracks.map((t) => t.trackType) ?? [];
6926
7106
  this.setParticipants(() => {
6927
7107
  const participantLookup = this.getParticipantLookupBySessionId();
@@ -6947,6 +7127,7 @@ class CallState {
6947
7127
  this.setAnonymousParticipantCount(participantCount?.anonymous || 0);
6948
7128
  this.setStartedAt(startedAt ? Timestamp.toDate(startedAt) : new Date());
6949
7129
  this.setServerSidePins(pins);
7130
+ this.setCurrentValue(this.e2eeEnabledSubject, e2EeEnabled);
6950
7131
  };
6951
7132
  updateFromMemberRemoved = (event) => {
6952
7133
  this.updateFromCallResponse(event.call);
@@ -7144,6 +7325,37 @@ class CallState {
7144
7325
  };
7145
7326
  }
7146
7327
 
7328
+ /**
7329
+ * Detection and selection policy for the two WebRTC Encoded Transform APIs.
7330
+ * Internal: which one the SDK attaches is an RTC-layer detail. Consumers want
7331
+ * `EncryptionManager.isSupported` instead.
7332
+ */
7333
+ /**
7334
+ * Chrome only, and the reason an RTCPeerConnection carrying E2EE needs the
7335
+ * non-standard `encodedInsertableStreams` flag.
7336
+ */
7337
+ const hasInsertableStreams = () => typeof RTCRtpSender !== 'undefined' &&
7338
+ 'createEncodedStreams' in RTCRtpSender.prototype;
7339
+ /** Whether the standard `RTCRtpScriptTransform` API exists. */
7340
+ const hasScriptTransform = () => typeof RTCRtpScriptTransform !== 'undefined';
7341
+ /**
7342
+ * Which Encoded Transform API to attach E2EE with here.
7343
+ *
7344
+ * - `'insertable'`: legacy Insertable Streams. Used on Chrome, where
7345
+ * `RTCRtpScriptTransform` is still unreliable for E2EE.
7346
+ * - `'script'`: the standard API. Used everywhere else.
7347
+ * - `undefined`: neither exists, so E2EE cannot run.
7348
+ */
7349
+ const preferredTransform = () => {
7350
+ const insertable = hasInsertableStreams();
7351
+ // Chrome's RTCRtpScriptTransform is still unreliable for E2EE.
7352
+ if (isChrome() && insertable)
7353
+ return 'insertable';
7354
+ if (hasScriptTransform())
7355
+ return 'script';
7356
+ return insertable ? 'insertable' : undefined;
7357
+ };
7358
+
7147
7359
  /**
7148
7360
  * Flatten the stats report into an array of stats objects.
7149
7361
  *
@@ -8252,6 +8464,7 @@ class BasePeerConnection {
8252
8464
  state;
8253
8465
  dispatcher;
8254
8466
  clientPublishOptions;
8467
+ e2ee;
8255
8468
  tag;
8256
8469
  sfuClient;
8257
8470
  onReconnectionNeeded;
@@ -8273,13 +8486,14 @@ class BasePeerConnection {
8273
8486
  /**
8274
8487
  * Constructs a new `BasePeerConnection` instance.
8275
8488
  */
8276
- constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, onPeerConnectionStateChange, onRemoteTrackUnmute, tag, enableTracing, clientPublishOptions, iceRestartDelay = 2500, statsTimestampDriftThresholdMs = 0, }) {
8489
+ constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, onPeerConnectionStateChange, onRemoteTrackUnmute, tag, enableTracing, clientPublishOptions, e2ee, iceRestartDelay = 2500, statsTimestampDriftThresholdMs = 0, }) {
8277
8490
  this.peerType = peerType;
8278
8491
  this.sfuClient = sfuClient;
8279
8492
  this.state = state;
8280
8493
  this.dispatcher = dispatcher;
8281
8494
  this.iceRestartDelay = iceRestartDelay;
8282
8495
  this.clientPublishOptions = clientPublishOptions;
8496
+ this.e2ee = e2ee;
8283
8497
  this.tag = tag;
8284
8498
  this.onReconnectionNeeded = onReconnectionNeeded;
8285
8499
  this.onIceConnected = onIceConnected;
@@ -8298,7 +8512,13 @@ class BasePeerConnection {
8298
8512
  }
8299
8513
  }
8300
8514
  createPeerConnection = (connectionConfig) => {
8301
- const pc = new RTCPeerConnection(connectionConfig);
8515
+ const config = { ...connectionConfig };
8516
+ // The legacy Insertable Streams path requires this non-standard flag.
8517
+ if (this.e2ee && hasInsertableStreams()) {
8518
+ // @ts-expect-error not part of the standard lib yet
8519
+ config.encodedInsertableStreams = true;
8520
+ }
8521
+ const pc = new RTCPeerConnection(config);
8302
8522
  pc.addEventListener('icecandidate', this.onIceCandidate);
8303
8523
  pc.addEventListener('icecandidateerror', this.onIceCandidateError);
8304
8524
  pc.addEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
@@ -9052,6 +9272,10 @@ class Publisher extends BasePeerConnection {
9052
9272
  toRTCDegradationPreference(publishOption.degradationPreference) ??
9053
9273
  'maintain-framerate';
9054
9274
  await transceiver.sender.setParameters(params);
9275
+ if (this.e2ee) {
9276
+ this.e2ee.encrypt(transceiver.sender, publishOption.codec?.name.toLowerCase(), TrackType[publishOption.trackType]);
9277
+ this.logger.debug('E2EE encryptor attached to sender');
9278
+ }
9055
9279
  await this.negotiate();
9056
9280
  };
9057
9281
  /**
@@ -9621,6 +9845,11 @@ class Subscriber extends BasePeerConnection {
9621
9845
  throw e;
9622
9846
  }
9623
9847
  };
9848
+ /**
9849
+ * Whether the given stream is one the SFU echoed back to us
9850
+ * (`allowOwnTracksLoopback`) rather than a local capture stream.
9851
+ */
9852
+ isSelfSubscribedStream = (stream) => !!stream && !!this.trackedStreams?.has(stream);
9624
9853
  handleOnTrack = (e) => {
9625
9854
  const { streams, track } = e;
9626
9855
  const [primaryStream] = streams;
@@ -9663,9 +9892,15 @@ class Subscriber extends BasePeerConnection {
9663
9892
  trackLookupPrefix: trackId,
9664
9893
  track: primaryStream,
9665
9894
  trackType,
9895
+ receiver: this.e2ee ? e.receiver : undefined,
9666
9896
  });
9667
9897
  return;
9668
9898
  }
9899
+ else if (this.e2ee) {
9900
+ const { userId } = participantToUpdate;
9901
+ this.e2ee.decrypt(e.receiver, userId, TrackType[trackType]);
9902
+ this.logger.debug('E2EE decryptor attached to receiver', userId);
9903
+ }
9669
9904
  const streamKindProp = trackTypeToParticipantStreamKey(trackType);
9670
9905
  if (!streamKindProp) {
9671
9906
  this.logger.error(`Unknown track type: ${rawTrackType}`);
@@ -9704,6 +9939,8 @@ class Subscriber extends BasePeerConnection {
9704
9939
  const target = this.state.participants.find((p) => p.trackLookupPrefix === trackId);
9705
9940
  if (!target)
9706
9941
  return;
9942
+ if (target.isLocalParticipant)
9943
+ return;
9707
9944
  this.state.updateParticipant(target.sessionId, (p) => {
9708
9945
  const current = p.interruptedTracks ?? [];
9709
9946
  const has = current.includes(trackType);
@@ -10446,7 +10683,6 @@ const watchCallRejected = (call) => {
10446
10683
  */
10447
10684
  const watchCallEnded = (call) => {
10448
10685
  return function onCallEnded() {
10449
- globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
10450
10686
  const { callingState } = call.state;
10451
10687
  if (callingState !== exports.CallingState.IDLE &&
10452
10688
  callingState !== exports.CallingState.LEFT) {
@@ -10455,9 +10691,13 @@ const watchCallEnded = (call) => {
10455
10691
  reason: 'call.ended event received',
10456
10692
  });
10457
10693
  call
10458
- .leave({ message: 'call.ended event received', reject: false })
10694
+ .leave({
10695
+ message: 'call.ended event received',
10696
+ reject: false,
10697
+ reason: 'ended',
10698
+ })
10459
10699
  .catch((err) => {
10460
- call.logger.error('Failed to leave call after call.ended ', err);
10700
+ call.logger.error('Failed to leave call after call.ended', err);
10461
10701
  });
10462
10702
  }
10463
10703
  };
@@ -10637,7 +10877,7 @@ const handleRemoteSoftMute = (call) => {
10637
10877
  /**
10638
10878
  * An event responder which handles the `participantJoined` event.
10639
10879
  */
10640
- const watchParticipantJoined = (state) => {
10880
+ const watchParticipantJoined = (state, e2ee = undefined) => {
10641
10881
  return function onParticipantJoined(e) {
10642
10882
  const { participant } = e;
10643
10883
  if (!participant)
@@ -10650,7 +10890,7 @@ const watchParticipantJoined = (state) => {
10650
10890
  // The SFU would send participant info as part of the `join`
10651
10891
  // response and then follow up with a `participantJoined` event for
10652
10892
  // already announced participants.
10653
- const orphanedTracks = reconcileOrphanedTracks(state, participant);
10893
+ const orphanedTracks = reconcileOrphanedTracks(state, participant, e2ee);
10654
10894
  state.updateOrAddParticipant(participant.sessionId, Object.assign(participant, orphanedTracks, {
10655
10895
  ...(e.isPinned && { pin: { isLocalPin: false, pinnedAt: Date.now() } }),
10656
10896
  viewportVisibilityState: {
@@ -10686,7 +10926,7 @@ const watchParticipantUpdated = (state) => {
10686
10926
  * An event responder which handles the `trackPublished` event.
10687
10927
  * The SFU will send this event when a participant publishes a track.
10688
10928
  */
10689
- const watchTrackPublished = (state) => {
10929
+ const watchTrackPublished = (state, e2ee = undefined) => {
10690
10930
  return function onTrackPublished(e) {
10691
10931
  const { type, sessionId } = e;
10692
10932
  // An optimization for large calls.
@@ -10694,7 +10934,7 @@ const watchTrackPublished = (state) => {
10694
10934
  // events, and instead, it would only provide the participant's information
10695
10935
  // once they start publishing a track.
10696
10936
  if (e.participant) {
10697
- const orphanedTracks = reconcileOrphanedTracks(state, e.participant);
10937
+ const orphanedTracks = reconcileOrphanedTracks(state, e.participant, e2ee);
10698
10938
  const participant = Object.assign(e.participant, orphanedTracks);
10699
10939
  state.updateOrAddParticipant(sessionId, participant);
10700
10940
  }
@@ -10709,12 +10949,12 @@ const watchTrackPublished = (state) => {
10709
10949
  * An event responder which handles the `trackUnpublished` event.
10710
10950
  * The SFU will send this event when a participant unpublishes a track.
10711
10951
  */
10712
- const watchTrackUnpublished = (state) => {
10952
+ const watchTrackUnpublished = (state, e2ee = undefined) => {
10713
10953
  return function onTrackUnpublished(e) {
10714
10954
  const { type, sessionId } = e;
10715
10955
  // An optimization for large calls. See `watchTrackPublished`.
10716
10956
  if (e.participant) {
10717
- const orphanedTracks = reconcileOrphanedTracks(state, e.participant);
10957
+ const orphanedTracks = reconcileOrphanedTracks(state, e.participant, e2ee);
10718
10958
  const participant = Object.assign(e.participant, orphanedTracks);
10719
10959
  state.updateOrAddParticipant(sessionId, participant, (p) => ({
10720
10960
  pausedTracks: p.pausedTracks?.filter((t) => t !== type),
@@ -10733,17 +10973,22 @@ const watchTrackUnpublished = (state) => {
10733
10973
  *
10734
10974
  * @param state the call state.
10735
10975
  * @param participant the participant.
10976
+ * @param e2ee accessor for the call's encryption manager, if any.
10736
10977
  */
10737
- const reconcileOrphanedTracks = (state, participant) => {
10978
+ const reconcileOrphanedTracks = (state, participant, e2ee) => {
10738
10979
  const orphanTracks = state.takeOrphanedTracks(participant.trackLookupPrefix);
10739
10980
  if (!orphanTracks.length)
10740
10981
  return;
10982
+ const manager = e2ee?.();
10741
10983
  const reconciledTracks = {};
10742
10984
  for (const orphan of orphanTracks) {
10743
10985
  const key = trackTypeToParticipantStreamKey(orphan.trackType);
10744
10986
  if (!key)
10745
10987
  continue;
10746
10988
  reconciledTracks[key] = orphan.track;
10989
+ if (manager && orphan.receiver) {
10990
+ manager.decrypt(orphan.receiver, participant.userId, TrackType[orphan.trackType]);
10991
+ }
10747
10992
  }
10748
10993
  return reconciledTracks;
10749
10994
  };
@@ -10799,6 +11044,11 @@ const watchAudioLevelChanged = (dispatcher, state) => {
10799
11044
  */
10800
11045
  const registerEventHandlers = (call, dispatcher) => {
10801
11046
  const state = call.state;
11047
+ // Read lazily on each event: setE2EEManager can run after setup() (an app that
11048
+ // inspects call settings via get()/getOrCreate() before deciding to encrypt has
11049
+ // already triggered it), so a value captured here would be a stale undefined
11050
+ // for the whole call and orphaned tracks would never get a decryptor.
11051
+ const e2ee = () => call.e2eeManager;
10802
11052
  const eventHandlers = [
10803
11053
  call.on('call.ended', watchCallEnded(call)),
10804
11054
  watchSfuCallEnded(call),
@@ -10806,11 +11056,11 @@ const registerEventHandlers = (call, dispatcher) => {
10806
11056
  watchSfuErrorReports(dispatcher),
10807
11057
  watchConnectionQualityChanged(dispatcher, state),
10808
11058
  watchParticipantCountChanged(dispatcher, state),
10809
- call.on('participantJoined', watchParticipantJoined(state)),
11059
+ call.on('participantJoined', watchParticipantJoined(state, e2ee)),
10810
11060
  call.on('participantLeft', watchParticipantLeft(state)),
10811
11061
  call.on('participantUpdated', watchParticipantUpdated(state)),
10812
- call.on('trackPublished', watchTrackPublished(state)),
10813
- call.on('trackUnpublished', watchTrackUnpublished(state)),
11062
+ call.on('trackPublished', watchTrackPublished(state, e2ee)),
11063
+ call.on('trackUnpublished', watchTrackUnpublished(state, e2ee)),
10814
11064
  watchAudioLevelChanged(dispatcher, state),
10815
11065
  watchDominantSpeakerChanged(dispatcher, state),
10816
11066
  call.on('callGrantsUpdated', watchCallGrantsUpdated(state)),
@@ -11938,13 +12188,14 @@ class PermissionsContext {
11938
12188
  return false;
11939
12189
  const { audio, video, screensharing } = settings;
11940
12190
  switch (permission) {
11941
- case OwnCapability.SEND_AUDIO:
12191
+ case RequestPermissionRequestPermissionsEnum.SEND_AUDIO:
11942
12192
  return audio.access_request_enabled;
11943
- case OwnCapability.SEND_VIDEO:
12193
+ case RequestPermissionRequestPermissionsEnum.SEND_VIDEO:
11944
12194
  return video.access_request_enabled;
11945
- case OwnCapability.SCREENSHARE:
12195
+ case RequestPermissionRequestPermissionsEnum.SCREENSHARE:
11946
12196
  return screensharing.access_request_enabled;
11947
12197
  default:
12198
+ ensureExhausted(permission);
11948
12199
  return false;
11949
12200
  }
11950
12201
  };
@@ -12951,6 +13202,27 @@ class DeviceManager {
12951
13202
  this.filters.forEach((entry) => entry.stop?.());
12952
13203
  }
12953
13204
  }
13205
+ reconcileOptimisticStatus = async () => {
13206
+ const target = this.state.optimisticStatus;
13207
+ await withCancellation(this.statusChangeConcurrencyTag, async (signal) => {
13208
+ try {
13209
+ if (target === 'enabled' && this.state.status !== 'enabled') {
13210
+ await this.unmuteStream();
13211
+ if (!signal.aborted)
13212
+ this.state.setStatus('enabled');
13213
+ }
13214
+ else if (target === 'disabled' && this.state.status === 'enabled') {
13215
+ // mirror whatever disable() does to stop/pause the track per disableMode
13216
+ if (!signal.aborted)
13217
+ this.state.setStatus('disabled');
13218
+ }
13219
+ }
13220
+ finally {
13221
+ if (!signal.aborted)
13222
+ this.state.setPendingStatus(this.state.status);
13223
+ }
13224
+ });
13225
+ };
12954
13226
  disableTracks() {
12955
13227
  this.getTracks().forEach((track) => {
12956
13228
  if (track.enabled)
@@ -13231,7 +13503,7 @@ class DeviceManager {
13231
13503
  createSyntheticDevice(selectedDevice, deviceKind);
13232
13504
  writePreferences(currentDevice, deviceKey, muted, storageKey);
13233
13505
  }
13234
- async applyPersistedPreferences(enabledInCallType) {
13506
+ async applyPersistedPreferences(enabledInCallType, forceDisabled = false) {
13235
13507
  const deviceKey = this.trackType === TrackType.AUDIO ? 'microphone' : 'camera';
13236
13508
  const preferences = readPreferences(this.devicePersistence.storageKey);
13237
13509
  const preferenceList = toPreferenceList(preferences[deviceKey]);
@@ -13258,13 +13530,13 @@ class DeviceManager {
13258
13530
  }
13259
13531
  const canPublish = this.call.permissionsContext.canPublish(this.trackType);
13260
13532
  if (typeof muted === 'boolean' && enabledInCallType && canPublish) {
13261
- await this.applyMutedState(muted);
13533
+ await this.applyMutedState(muted, forceDisabled);
13262
13534
  appliedMute = true;
13263
13535
  }
13264
13536
  return appliedDevice || appliedMute;
13265
13537
  }
13266
- async applyMutedState(muted) {
13267
- if (this.state.status !== undefined)
13538
+ async applyMutedState(muted, forceDisabled) {
13539
+ if (forceDisabled || this.state.status !== undefined)
13268
13540
  return;
13269
13541
  if (muted) {
13270
13542
  await this.disable();
@@ -13584,13 +13856,41 @@ class CameraManager extends DeviceManager {
13584
13856
  }
13585
13857
  }
13586
13858
  }
13859
+ enable() {
13860
+ if (isReactNative() && !this.call.hasMediaEngine) {
13861
+ this.state.setPendingStatus('enabled');
13862
+ return Promise.resolve();
13863
+ }
13864
+ return super.enable();
13865
+ }
13866
+ async disable(forceStopOrOptions) {
13867
+ if (isReactNative() && !this.call.hasMediaEngine) {
13868
+ this.state.setPendingStatus('disabled');
13869
+ return;
13870
+ }
13871
+ // forward verbatim to the base, narrowing so the right overload is selected
13872
+ if (forceStopOrOptions === undefined)
13873
+ return super.disable();
13874
+ if (typeof forceStopOrOptions === 'boolean') {
13875
+ return super.disable(forceStopOrOptions);
13876
+ }
13877
+ return super.disable(forceStopOrOptions);
13878
+ }
13879
+ toggle() {
13880
+ if (isReactNative() && !this.call.hasMediaEngine) {
13881
+ this.state.setPendingStatus(this.state.optimisticStatus === 'enabled' ? 'disabled' : 'enabled');
13882
+ return Promise.resolve();
13883
+ }
13884
+ return super.toggle();
13885
+ }
13587
13886
  /**
13588
13887
  * Applies the video settings to the camera.
13589
13888
  *
13590
13889
  * @param settings the video settings to apply.
13591
13890
  * @param publish whether to publish the stream after applying the settings.
13891
+ * @param forceDisabled whether to keep the camera disabled, regardless of server-side or local preferences.
13592
13892
  */
13593
- async apply(settings, publish) {
13893
+ async apply(settings, publish, forceDisabled = false) {
13594
13894
  // Wait for any in progress camera operation
13595
13895
  await this.statusChangeSettled();
13596
13896
  await this.selectTargetResolution(settings.target_resolution);
@@ -13602,8 +13902,7 @@ class CameraManager extends DeviceManager {
13602
13902
  if (shouldApplyDefaults &&
13603
13903
  this.devicePersistence.enabled &&
13604
13904
  permissionState === 'granted') {
13605
- persistedPreferencesApplied =
13606
- await this.applyPersistedPreferences(enabledInCallType);
13905
+ persistedPreferencesApplied = await this.applyPersistedPreferences(enabledInCallType, forceDisabled);
13607
13906
  }
13608
13907
  // apply a direction and enable the camera only if in "pristine" state,
13609
13908
  // and there are no persisted preferences
@@ -13613,13 +13912,23 @@ class CameraManager extends DeviceManager {
13613
13912
  const direction = settings.camera_facing === 'front' ? 'front' : 'back';
13614
13913
  await this.selectDirection(direction, { enableCamera: false });
13615
13914
  }
13616
- if (canPublish && settings.camera_default_on && enabledInCallType) {
13915
+ if (!forceDisabled &&
13916
+ canPublish &&
13917
+ settings.camera_default_on &&
13918
+ enabledInCallType) {
13617
13919
  await this.enable();
13618
13920
  }
13619
13921
  }
13620
- const { mediaStream } = this.state;
13621
- if (canPublish && publish && this.enabled && mediaStream) {
13622
- await this.publishStream(mediaStream);
13922
+ if (isReactNative() && publish && canPublish) {
13923
+ // On RN the camera is enabled/disabled optimistically before JOINED. Reconcile now
13924
+ // acquires the track and publishes it, so it fully owns the publish.
13925
+ await this.reconcileOptimisticStatus();
13926
+ }
13927
+ else {
13928
+ const { mediaStream } = this.state;
13929
+ if (canPublish && publish && this.enabled && mediaStream) {
13930
+ await this.publishStream(mediaStream);
13931
+ }
13623
13932
  }
13624
13933
  }
13625
13934
  getDevices() {
@@ -13638,7 +13947,10 @@ class CameraManager extends DeviceManager {
13638
13947
  }
13639
13948
  return constraints;
13640
13949
  }
13641
- getStream(constraints) {
13950
+ async getStream(constraints) {
13951
+ // Ensure the call's media factory exists before capture so the resulting
13952
+ // track is owned by it (the WebRTC globals resolve to the live factory).
13953
+ await this.call.ensureMediaFactory();
13642
13954
  return getVideoStream(constraints, this.call.tracer);
13643
13955
  }
13644
13956
  }
@@ -13654,6 +13966,9 @@ class AudioDeviceManager extends DeviceManager {
13654
13966
  if (!this.call.state.settings?.audio.hifi_audio_enabled) {
13655
13967
  throw new Error('High Fidelity audio is not enabled for this call');
13656
13968
  }
13969
+ if (isReactNative() && this.call.hasMediaEngine) {
13970
+ throw new Error('setAudioBitrateProfile must be called before joining the call.');
13971
+ }
13657
13972
  this.doSetAudioBitrateProfile(profile);
13658
13973
  this.state.setAudioBitrateProfile(profile);
13659
13974
  if (this.enabled) {
@@ -13926,7 +14241,10 @@ class MicrophoneManager extends AudioDeviceManager {
13926
14241
  ]), async ([callingState, ownCapabilities, deviceId, status, permissionState,]) => {
13927
14242
  try {
13928
14243
  if (callingState === exports.CallingState.LEFT) {
13929
- this.setMutedRecordingPrepared(false);
14244
+ // The muted-recording-prepared mode is reset in `callManager.stop()`
14245
+ // (during leave, while the call factory is still alive), not here —
14246
+ // this subscription fires asynchronously and could land after the
14247
+ // factory is disposed, forcing a default-ADM rebuild.
13930
14248
  await this.stopSpeakingWhileMutedDetection();
13931
14249
  }
13932
14250
  if (callingState !== exports.CallingState.JOINED)
@@ -14026,6 +14344,33 @@ class MicrophoneManager extends AudioDeviceManager {
14026
14344
  this.subscriptions.push(unsubscribe);
14027
14345
  }
14028
14346
  }
14347
+ enable() {
14348
+ if (isReactNative() && !this.call.hasMediaEngine) {
14349
+ this.state.setPendingStatus('enabled');
14350
+ return Promise.resolve();
14351
+ }
14352
+ return super.enable();
14353
+ }
14354
+ async disable(forceStopOrOptions) {
14355
+ if (isReactNative() && !this.call.hasMediaEngine) {
14356
+ this.state.setPendingStatus('disabled');
14357
+ return;
14358
+ }
14359
+ // forward verbatim to the base, narrowing so the right overload is selected
14360
+ if (forceStopOrOptions === undefined)
14361
+ return super.disable();
14362
+ if (typeof forceStopOrOptions === 'boolean') {
14363
+ return super.disable(forceStopOrOptions);
14364
+ }
14365
+ return super.disable(forceStopOrOptions);
14366
+ }
14367
+ toggle() {
14368
+ if (isReactNative() && !this.call.hasMediaEngine) {
14369
+ this.state.setPendingStatus(this.state.optimisticStatus === 'enabled' ? 'disabled' : 'enabled');
14370
+ return Promise.resolve();
14371
+ }
14372
+ return super.toggle();
14373
+ }
14029
14374
  /**
14030
14375
  * Enables noise cancellation for the microphone.
14031
14376
  *
@@ -14165,8 +14510,9 @@ class MicrophoneManager extends AudioDeviceManager {
14165
14510
  * Applies the audio settings to the microphone.
14166
14511
  * @param settings the audio settings to apply.
14167
14512
  * @param publish whether to publish the stream after applying the settings.
14513
+ * @param forceDisabled whether to keep the mic disabled, regardless of server-side or local preferences.
14168
14514
  */
14169
- async apply(settings, publish) {
14515
+ async apply(settings, publish, forceDisabled = false) {
14170
14516
  // Wait for any in progress mic operation
14171
14517
  await this.statusChangeSettled();
14172
14518
  // apply server-side settings only when the device state is pristine
@@ -14178,23 +14524,33 @@ class MicrophoneManager extends AudioDeviceManager {
14178
14524
  if (shouldApplyDefaults &&
14179
14525
  this.devicePersistence.enabled &&
14180
14526
  permissionState === 'granted') {
14181
- persistedPreferencesApplied = await this.applyPersistedPreferences(true);
14527
+ persistedPreferencesApplied = await this.applyPersistedPreferences(true, forceDisabled);
14182
14528
  }
14183
14529
  const canPublish = this.call.permissionsContext.canPublish(this.trackType);
14184
14530
  if (shouldApplyDefaults && !persistedPreferencesApplied) {
14185
- if (canPublish && settings.mic_default_on) {
14531
+ if (!forceDisabled && canPublish && settings.mic_default_on) {
14186
14532
  await this.enable();
14187
14533
  }
14188
14534
  }
14189
- const { mediaStream } = this.state;
14190
- if (canPublish && publish && this.enabled && mediaStream) {
14191
- await this.publishStream(mediaStream);
14535
+ if (isReactNative() && publish && canPublish) {
14536
+ // On RN the microphone is enabled/disabled optimistically before JOINED. Reconcile now
14537
+ // acquires the track and publishes it, so it fully owns the publish.
14538
+ await this.reconcileOptimisticStatus();
14539
+ }
14540
+ else {
14541
+ const { mediaStream } = this.state;
14542
+ if (canPublish && publish && this.enabled && mediaStream) {
14543
+ await this.publishStream(mediaStream);
14544
+ }
14192
14545
  }
14193
14546
  }
14194
14547
  getDevices() {
14195
14548
  return getAudioDevices(this.call.tracer);
14196
14549
  }
14197
- getStream(constraints) {
14550
+ async getStream(constraints) {
14551
+ // Ensure the call's media factory exists before capture so the resulting
14552
+ // track is owned by it (the WebRTC globals resolve to the live factory).
14553
+ await this.call.ensureMediaFactory();
14198
14554
  return getAudioStream(constraints, this.call.tracer);
14199
14555
  }
14200
14556
  doSetAudioBitrateProfile(profile) {
@@ -14403,6 +14759,9 @@ class ScreenShareManager extends AudioDeviceManager {
14403
14759
  if (!this.state.audioEnabled) {
14404
14760
  constraints.audio = false;
14405
14761
  }
14762
+ // Ensure the call's media factory exists before capture so the resulting
14763
+ // track is owned by it (the WebRTC globals resolve to the live factory).
14764
+ await this.call.ensureMediaFactory();
14406
14765
  const stream = await getScreenShareStream(constraints, this.call.tracer);
14407
14766
  const [track] = stream.getVideoTracks();
14408
14767
  const { contentHint } = this.state.settings || {};
@@ -14507,10 +14866,14 @@ class SpeakerManager {
14507
14866
  this.devicePersistence = devicePreferences;
14508
14867
  this.setup();
14509
14868
  }
14510
- apply(settings) {
14511
- return isReactNative() ? this.applyRN(settings) : this.applyWeb();
14869
+ async apply(settings) {
14870
+ if (isReactNative()) {
14871
+ this.applyRN(settings);
14872
+ return;
14873
+ }
14874
+ await this.applyWeb();
14512
14875
  }
14513
- applyWeb() {
14876
+ async applyWeb() {
14514
14877
  const { enabled, storageKey } = this.devicePersistence;
14515
14878
  if (!enabled)
14516
14879
  return;
@@ -14522,8 +14885,22 @@ class SpeakerManager {
14522
14885
  const nextDeviceId = preference.selectedDeviceId === defaultDeviceId
14523
14886
  ? ''
14524
14887
  : preference.selectedDeviceId;
14525
- if (this.state.selectedDevice !== nextDeviceId) {
14526
- this.select(nextDeviceId);
14888
+ if (!nextDeviceId) {
14889
+ if (this.state.selectedDevice !== nextDeviceId) {
14890
+ this.select(nextDeviceId);
14891
+ }
14892
+ return;
14893
+ }
14894
+ const permissionState = await rxjs.firstValueFrom(getAudioBrowserPermission(this.call.tracer).asStateObservable());
14895
+ if (permissionState !== 'granted')
14896
+ return;
14897
+ const devices = await rxjs.firstValueFrom(this.listDevices());
14898
+ const device = this.findDevice(devices, nextDeviceId) ??
14899
+ (preference.selectedDeviceLabel
14900
+ ? devices.find((d) => d.label === preference.selectedDeviceLabel)
14901
+ : undefined);
14902
+ if (device && this.state.selectedDevice !== device.deviceId) {
14903
+ this.select(device.deviceId);
14527
14904
  }
14528
14905
  }
14529
14906
  applyRN(settings) {
@@ -14552,6 +14929,7 @@ class SpeakerManager {
14552
14929
  globalThis.streamRNVideoSDK?.callManager.setup({
14553
14930
  defaultDevice,
14554
14931
  isRingingTypeCall: this.call.ringing,
14932
+ cid: this.call.cid,
14555
14933
  });
14556
14934
  }
14557
14935
  }
@@ -14560,11 +14938,15 @@ class SpeakerManager {
14560
14938
  return;
14561
14939
  this.areSubscriptionsSetUp = true;
14562
14940
  if (deviceIds$ && !isReactNative()) {
14563
- this.subscriptions.push(createSubscription(rxjs.combineLatest([deviceIds$, this.state.selectedDevice$]), ([devices, deviceId]) => {
14941
+ this.subscriptions.push(createSubscription(rxjs.combineLatest([
14942
+ deviceIds$.pipe(rxjs.pairwise()),
14943
+ this.state.selectedDevice$,
14944
+ ]), ([[prevDevices, currentDevices], deviceId]) => {
14564
14945
  if (!deviceId)
14565
14946
  return;
14566
- const device = devices.find((d) => d.deviceId === deviceId && d.kind === 'audiooutput');
14567
- if (!device)
14947
+ const isDisconnected = this.findDevice(prevDevices, deviceId) &&
14948
+ !this.findDevice(currentDevices, deviceId);
14949
+ if (isDisconnected)
14568
14950
  this.select('');
14569
14951
  }));
14570
14952
  }
@@ -14611,6 +14993,7 @@ class SpeakerManager {
14611
14993
  this.subscriptions.forEach((unsubscribe) => unsubscribe());
14612
14994
  this.subscriptions = [];
14613
14995
  this.areSubscriptionsSetUp = false;
14996
+ this.defaultDevice = undefined;
14614
14997
  };
14615
14998
  /**
14616
14999
  * Set the volume of the audio elements
@@ -14645,6 +15028,7 @@ class SpeakerManager {
14645
15028
  return { audioVolume: volume };
14646
15029
  });
14647
15030
  }
15031
+ findDevice = (devices, deviceId) => devices.find((d) => d.deviceId === deviceId && d.kind === 'audiooutput');
14648
15032
  persistSpeakerDevicePreference(selectedDevice) {
14649
15033
  const { storageKey } = this.devicePersistence;
14650
15034
  const devices = getCurrentValue(this.listDevices()) || [];
@@ -14727,6 +15111,7 @@ class Call {
14727
15111
  blockedAudioTracker;
14728
15112
  subscriber;
14729
15113
  publisher;
15114
+ e2eeManager;
14730
15115
  /**
14731
15116
  * Flag telling whether this call is a "ringing" call.
14732
15117
  */
@@ -14782,6 +15167,8 @@ class Call {
14782
15167
  allowOwnTracksLoopback = false;
14783
15168
  hasJoinedOnce = false;
14784
15169
  deviceSettingsAppliedOnce = false;
15170
+ callManagerStarted = false;
15171
+ leaveGeneration = 0;
14785
15172
  credentials;
14786
15173
  initialized = false;
14787
15174
  acceptRejectConcurrencyTag = Symbol('acceptRejectTag');
@@ -14800,6 +15187,13 @@ class Call {
14800
15187
  clientCapabilities = new Set([
14801
15188
  ClientCapability.SUBSCRIBER_VIDEO_PAUSE,
14802
15189
  ]);
15190
+ /**
15191
+ * The in-flight per-call media engine. On web/React this resolves to a thin
15192
+ * globals-backed engine (no provider registered); React Native registers a
15193
+ * provider that owns a per-call native factory.
15194
+ * @internal
15195
+ */
15196
+ mediaEnginePromise;
14803
15197
  /**
14804
15198
  * Constructs a new `Call` instance.
14805
15199
  *
@@ -15069,6 +15463,7 @@ class Call {
15069
15463
  if (callingState === exports.CallingState.LEFT) {
15070
15464
  return;
15071
15465
  }
15466
+ this.leaveGeneration += 1;
15072
15467
  if (callingState === exports.CallingState.JOINING) {
15073
15468
  const waitUntilCallJoined = () => {
15074
15469
  return new Promise((resolve) => {
@@ -15102,7 +15497,7 @@ class Call {
15102
15497
  }
15103
15498
  }
15104
15499
  }
15105
- globalThis.streamRNVideoSDK?.callingX?.endCall(this);
15500
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this, reason === 'ended' ? 'remote' : undefined);
15106
15501
  this.statsReporter?.stop();
15107
15502
  this.statsReporter = undefined;
15108
15503
  const leaveReason = message ?? reason ?? 'user is leaving the call';
@@ -15155,12 +15550,14 @@ class Call {
15155
15550
  this.clientStore.unregisterCall(this);
15156
15551
  globalThis.streamRNVideoSDK?.callManager.stop({
15157
15552
  isRingingTypeCall: this.ringing,
15553
+ shouldStopCallManager: this.callManagerStarted,
15158
15554
  });
15159
15555
  this.camera.dispose();
15160
15556
  this.microphone.dispose();
15161
15557
  this.screenShare.dispose();
15162
15558
  this.speaker.dispose();
15163
15559
  this.deviceSettingsAppliedOnce = false;
15560
+ this.callManagerStarted = false;
15164
15561
  const stopOnLeavePromises = [];
15165
15562
  if (this.camera.stopOnLeave) {
15166
15563
  stopOnLeavePromises.push(this.camera.disable(true));
@@ -15172,6 +15569,22 @@ class Call {
15172
15569
  stopOnLeavePromises.push(this.screenShare.disable(true));
15173
15570
  }
15174
15571
  await Promise.all(stopOnLeavePromises);
15572
+ // Dispose the per-call media engine last — after peer connections and
15573
+ // local tracks are gone — so the backing factory tears down with no
15574
+ // owned PCs/tracks. A fresh `join()` builds a new engine.
15575
+ if (this.mediaEnginePromise) {
15576
+ const enginePromise = this.mediaEnginePromise;
15577
+ this.mediaEnginePromise = undefined;
15578
+ this.logger.debug('Disposing per-call media factory');
15579
+ await enginePromise
15580
+ .then((engine) => {
15581
+ globalThis.streamRNVideoSDK?.callingX?.unwireAudioEngineSubscription();
15582
+ return engine.dispose();
15583
+ })
15584
+ .catch((err) => {
15585
+ this.logger.warn('Failed to dispose media engine', err);
15586
+ });
15587
+ }
15175
15588
  });
15176
15589
  };
15177
15590
  /**
@@ -15247,8 +15660,18 @@ class Call {
15247
15660
  // const calls = useCalls().filter((c) => c.ringing);
15248
15661
  const calls = this.clientStore.calls.filter((c) => c.cid !== this.cid);
15249
15662
  this.clientStore.setCalls([this, ...calls]);
15250
- const skipSpeakerApply = isReactNative();
15251
- await this.applyDeviceConfig(settings, false, skipSpeakerApply);
15663
+ await this.applyDeviceConfig(settings, { publish: false });
15664
+ };
15665
+ /**
15666
+ * Applies a backend-provided call state snapshot
15667
+ * (call, members, own capabilities) to this instance.
15668
+ *
15669
+ * @internal an internal method and should not be used outside the SDK.
15670
+ */
15671
+ updateFromCallStateResponse = (response) => {
15672
+ this.state.updateFromCallResponse(response.call);
15673
+ this.state.setMembers(response.members);
15674
+ this.state.setOwnCapabilities(response.own_capabilities);
15252
15675
  };
15253
15676
  /**
15254
15677
  * Loads the information about the call.
@@ -15261,9 +15684,7 @@ class Call {
15261
15684
  get = async (params) => {
15262
15685
  await this.setup();
15263
15686
  const response = await this.streamClient.get(this.streamClientBasePath, params);
15264
- this.state.updateFromCallResponse(response.call);
15265
- this.state.setMembers(response.members);
15266
- this.state.setOwnCapabilities(response.own_capabilities);
15687
+ this.updateFromCallStateResponse(response);
15267
15688
  if (params?.ring) {
15268
15689
  this.ringingSubject.next(true);
15269
15690
  }
@@ -15271,13 +15692,7 @@ class Call {
15271
15692
  this.watching = true;
15272
15693
  this.clientStore.registerOrUpdateCall(this);
15273
15694
  }
15274
- // Skip speaker setup on RN if ringing was requested or the call is already ringing
15275
- const skipSpeakerApply = isReactNative()
15276
- ? params?.ring === true
15277
- ? true
15278
- : this.ringing
15279
- : false;
15280
- await this.applyDeviceConfig(response.call.settings, false, skipSpeakerApply);
15695
+ await this.applyDeviceConfig(response.call.settings, { publish: false });
15281
15696
  return response;
15282
15697
  };
15283
15698
  /**
@@ -15288,9 +15703,7 @@ class Call {
15288
15703
  getOrCreate = async (data) => {
15289
15704
  await this.setup();
15290
15705
  const response = await this.streamClient.post(this.streamClientBasePath, data);
15291
- this.state.updateFromCallResponse(response.call);
15292
- this.state.setMembers(response.members);
15293
- this.state.setOwnCapabilities(response.own_capabilities);
15706
+ this.updateFromCallStateResponse(response);
15294
15707
  if (data?.ring) {
15295
15708
  this.ringingSubject.next(true);
15296
15709
  }
@@ -15298,13 +15711,7 @@ class Call {
15298
15711
  this.watching = true;
15299
15712
  this.clientStore.registerOrUpdateCall(this);
15300
15713
  }
15301
- // Skip speaker setup on RN if ringing was requested or the call is already ringing
15302
- const skipSpeakerApply = isReactNative()
15303
- ? data?.ring === true
15304
- ? true
15305
- : this.ringing
15306
- : false;
15307
- await this.applyDeviceConfig(response.call.settings, false, skipSpeakerApply);
15714
+ await this.applyDeviceConfig(response.call.settings, { publish: false });
15308
15715
  return response;
15309
15716
  };
15310
15717
  /**
@@ -15368,7 +15775,7 @@ class Call {
15368
15775
  *
15369
15776
  * @returns a promise which resolves once the call join-flow has finished.
15370
15777
  */
15371
- join = async ({ maxJoinRetries = 3, joinResponseTimeout, rpcRequestTimeout, allowOwnTracksLoopback = false, ...data } = {}) => {
15778
+ join = singleFlight(async ({ maxJoinRetries = 3, joinResponseTimeout, rpcRequestTimeout, allowOwnTracksLoopback = false, ...data } = {}) => {
15372
15779
  const callingState = this.state.callingState;
15373
15780
  if ([exports.CallingState.JOINED, exports.CallingState.JOINING].includes(callingState)) {
15374
15781
  throw new Error(`Illegal State: call.join() shall be called only once`);
@@ -15390,7 +15797,6 @@ class Call {
15390
15797
  callId: this.id,
15391
15798
  getCallSessionId: () => this.state.session?.id ?? '',
15392
15799
  getSfuId: () => this.credentials?.server.edge_name ?? '',
15393
- getUserSessionId: () => this.sfuClient?.sessionId ?? '',
15394
15800
  });
15395
15801
  this.joinResponseTimeout = joinResponseTimeout;
15396
15802
  this.rpcRequestTimeout = rpcRequestTimeout;
@@ -15442,7 +15848,7 @@ class Call {
15442
15848
  callingX?.endCall(this, 'error');
15443
15849
  throw error;
15444
15850
  }
15445
- };
15851
+ });
15446
15852
  /**
15447
15853
  * Will make a single attempt to watch for call related WebSocket events
15448
15854
  * and initiate a call session with the server.
@@ -15452,9 +15858,20 @@ class Call {
15452
15858
  doJoin = async (data) => {
15453
15859
  const connectStartTime = Date.now();
15454
15860
  const callingState = this.state.callingState;
15861
+ const joinLeaveGeneration = this.leaveGeneration;
15862
+ const supersededByLeave = () => this.leaveGeneration !== joinLeaveGeneration;
15455
15863
  this.joinCallData = data;
15456
15864
  this.logger.debug('Starting join flow');
15457
15865
  this.state.setCallingState(exports.CallingState.JOINING);
15866
+ // Ensure the per-call media engine exists before any peer connection
15867
+ // (codec probe, subscriber, publisher) or capture happens, so the WebRTC
15868
+ // globals resolve to the call's factory. Idempotent across
15869
+ // reconnect/migration attempts.
15870
+ await this.ensureMediaFactory();
15871
+ const callingX = globalThis.streamRNVideoSDK?.callingX;
15872
+ if (callingX) {
15873
+ callingX.wireAudioEngineSubscription();
15874
+ }
15458
15875
  const performingMigration = this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
15459
15876
  const performingRejoin = this.reconnectStrategy === WebsocketReconnectStrategy.REJOIN;
15460
15877
  const performingFastReconnect = this.reconnectStrategy === WebsocketReconnectStrategy.FAST;
@@ -15509,6 +15926,11 @@ class Call {
15509
15926
  // these are throw-away SDPs that the SFU will use to determine
15510
15927
  // the capabilities of the client (codec support, etc.)
15511
15928
  const { dangerouslyForceCodec, fmtpLine, subscriberFmtpLine } = this.clientPublishOptions || {};
15929
+ // skip if a leave superseded this join so codec detection doesn't resolve to a default factory.
15930
+ if (supersededByLeave()) {
15931
+ this.logger.debug('Join superseded by leave; skipping codec detection');
15932
+ return;
15933
+ }
15512
15934
  const [subscriberSdp, publisherSdp] = await Promise.all([
15513
15935
  getGenericSdp('recvonly', dangerouslyForceCodec, subscriberFmtpLine),
15514
15936
  getGenericSdp('sendonly', dangerouslyForceCodec, fmtpLine),
@@ -15552,6 +15974,12 @@ class Call {
15552
15974
  throw error;
15553
15975
  }
15554
15976
  }
15977
+ // If the user left while this join was in flight, bail before re-setting JOINED and before
15978
+ // peer-connection setup below (both run synchronously after this, so one check covers them).
15979
+ if (supersededByLeave()) {
15980
+ this.logger.debug('Join superseded by leave; aborting join flow');
15981
+ return;
15982
+ }
15555
15983
  if (!performingMigration) {
15556
15984
  // in MIGRATION, `JOINED` state is set in `this.reconnectMigrate()`
15557
15985
  this.state.setCallingState(exports.CallingState.JOINED);
@@ -15589,12 +16017,21 @@ class Call {
15589
16017
  }
15590
16018
  // device settings should be applied only once, we don't have to
15591
16019
  // re-apply them on later reconnections or server-side data fetches
15592
- if (!this.deviceSettingsAppliedOnce && this.state.settings) {
15593
- await this.applyDeviceConfig(this.state.settings, true, false);
16020
+ if (!this.deviceSettingsAppliedOnce &&
16021
+ this.state.settings &&
16022
+ !supersededByLeave()) {
16023
+ await this.applyDeviceConfig(this.state.settings, {
16024
+ publish: true,
16025
+ skipSpeakerApply: false,
16026
+ });
16027
+ this.deviceSettingsAppliedOnce = true;
16028
+ }
16029
+ if (!this.callManagerStarted && !supersededByLeave()) {
15594
16030
  globalThis.streamRNVideoSDK?.callManager.start({
15595
16031
  isRingingTypeCall: this.ringing,
16032
+ cid: this.cid,
15596
16033
  });
15597
- this.deviceSettingsAppliedOnce = true;
16034
+ this.callManagerStarted = true;
15598
16035
  }
15599
16036
  // We shouldn't persist the `ring` and `notify` state after joining the call
15600
16037
  // as it's a one-time event and clashes with the potential reconnection attempts.
@@ -15721,6 +16158,7 @@ class Call {
15721
16158
  this.sfuStatsReporter = undefined;
15722
16159
  if (closePreviousInstances && this.subscriber) {
15723
16160
  await this.subscriber.dispose();
16161
+ this.state.removeAllOrphanedTracks();
15724
16162
  }
15725
16163
  const basePeerConnectionOptions = {
15726
16164
  sfuClient,
@@ -15731,6 +16169,7 @@ class Call {
15731
16169
  enableTracing,
15732
16170
  statsTimestampDriftThresholdMs: reportingIntervalMs / 2,
15733
16171
  clientPublishOptions: this.clientPublishOptions,
16172
+ e2ee: this.e2eeManager,
15734
16173
  onReconnectionNeeded: (kind, reason, peerType) => {
15735
16174
  this.reconnect(kind, reason).catch((err) => {
15736
16175
  const message = `[Reconnect] Error reconnecting, after a ${PeerType[peerType]} error: ${reason}`;
@@ -15796,12 +16235,12 @@ class Call {
15796
16235
  * Retrieves credentials for joining the call.
15797
16236
  *
15798
16237
  * @internal
15799
- *
15800
16238
  * @param data the join call data.
15801
16239
  */
15802
16240
  doJoinRequest = async (data) => {
15803
16241
  const location = await this.streamClient.getLocationHint();
15804
- const request = { ...data, location };
16242
+ const e2ee = !!this.e2eeManager;
16243
+ const request = { ...data, location, e2ee };
15805
16244
  const joinResponse = await this.streamClient.post(`${this.streamClientBasePath}/join`, request);
15806
16245
  this.state.updateFromCallResponse(joinResponse.call);
15807
16246
  this.state.setMembers(joinResponse.members);
@@ -15820,6 +16259,36 @@ class Call {
15820
16259
  }
15821
16260
  return joinResponse;
15822
16261
  };
16262
+ /**
16263
+ * Whether the per-call media engine currently exists. True from join until leave.
16264
+ *
16265
+ * @internal an internal getter and should not be used outside the SDK.
16266
+ */
16267
+ get hasMediaEngine() {
16268
+ return !!this.mediaEnginePromise;
16269
+ }
16270
+ /**
16271
+ * Ensures a {@link CallMediaEngine} exists for this call's media session and
16272
+ * returns it. Idempotent: the engine is created once via the registered
16273
+ * provider (see `setCallMediaEngineProvider`) and cached until `leave()`
16274
+ * disposes it. Concurrent callers (e.g. camera + microphone enabling in
16275
+ * parallel) share the same engine because the in-flight creation promise is
16276
+ * cached, never the unresolved result.
16277
+ *
16278
+ * @internal
16279
+ */
16280
+ ensureMediaFactory = async () => {
16281
+ if (!this.mediaEnginePromise) {
16282
+ const provider = getCallMediaEngineProvider();
16283
+ this.logger.debug(`Requesting per-call media factory creation`);
16284
+ this.mediaEnginePromise = Promise.resolve(provider()).catch((err) => {
16285
+ // Drop the cached rejection so a retried join() can rebuild the engine
16286
+ this.mediaEnginePromise = undefined;
16287
+ throw err;
16288
+ });
16289
+ }
16290
+ return this.mediaEnginePromise;
16291
+ };
15823
16292
  /**
15824
16293
  * Handles the closing of the SFU signal connection.
15825
16294
  *
@@ -16344,12 +16813,16 @@ class Call {
16344
16813
  const streamStateProp = trackTypeToParticipantStreamKey(trackType);
16345
16814
  if (!streamStateProp)
16346
16815
  continue;
16347
- this.state.updateParticipant(sessionId, (p) => ({
16348
- publishedTracks: mediaStream
16349
- ? pushToIfMissing([...p.publishedTracks], trackType)
16350
- : p.publishedTracks.filter((t) => t !== trackType),
16351
- [streamStateProp]: mediaStream,
16352
- }));
16816
+ this.state.updateParticipant(sessionId, (p) => {
16817
+ const currentStream = p[streamStateProp];
16818
+ const isEchoedStream = this.subscriber?.isSelfSubscribedStream(currentStream);
16819
+ return {
16820
+ publishedTracks: mediaStream
16821
+ ? pushToIfMissing([...p.publishedTracks], trackType)
16822
+ : p.publishedTracks.filter((t) => t !== trackType),
16823
+ [streamStateProp]: isEchoedStream ? currentStream : mediaStream,
16824
+ };
16825
+ });
16353
16826
  }
16354
16827
  };
16355
16828
  /**
@@ -16381,6 +16854,30 @@ class Call {
16381
16854
  this.tracer.trace('updatePublishOptions', options);
16382
16855
  this.clientPublishOptions = { ...this.clientPublishOptions, ...options };
16383
16856
  };
16857
+ /**
16858
+ * Set the E2EE (end-to-end encryption) manager for this call.
16859
+ *
16860
+ * Must be called before {@link join} so the RTCPeerConnection can be
16861
+ * configured for E2EE.
16862
+ *
16863
+ * The manager is kept across {@link leave} so a rejoin of this same instance
16864
+ * stays encrypted: do not dispose it while this call may be joined again.
16865
+ * A disposed manager throws from `encrypt`/`decrypt` rather than silently
16866
+ * publishing nothing, so re-attach a fresh one instead of reusing it.
16867
+ *
16868
+ * @param e2ee - Any `E2EEManager`. Use `EncryptionManager.create()` for the
16869
+ * built-in AES-GCM scheme, or pass your own implementation.
16870
+ * @throws if called after the peer connections have been built (i.e. after
16871
+ * `join`): those PCs were already configured without an encryptor, so
16872
+ * adopting a manager now would silently publish/receive cleartext for
16873
+ * the live session.
16874
+ */
16875
+ setE2EEManager = (e2ee) => {
16876
+ if (this.publisher || this.subscriber) {
16877
+ throw new Error('setE2EEManager must be called before join()');
16878
+ }
16879
+ this.e2eeManager = e2ee;
16880
+ };
16384
16881
  /**
16385
16882
  * Notifies the SFU that a noise cancellation process has started.
16386
16883
  *
@@ -17015,16 +17512,21 @@ class Call {
17015
17512
  *
17016
17513
  * @internal
17017
17514
  */
17018
- applyDeviceConfig = async (settings, publish, skipSpeakerApply) => {
17515
+ applyDeviceConfig = async (settings, opts) => {
17516
+ const { publish,
17517
+ // Skip speaker setup on RN until joining (explicitly passed as false during join())
17518
+ skipSpeakerApply = isReactNative(), withDisabledDevices, } = opts;
17019
17519
  if (!skipSpeakerApply) {
17020
- this.speaker.apply(settings);
17520
+ await this.speaker
17521
+ .apply(settings)
17522
+ .catch((err) => this.logger.warn('Speaker init failed', err));
17021
17523
  }
17022
- await this.camera.apply(settings.video, publish).catch((err) => {
17023
- this.logger.warn('Camera init failed', err);
17024
- });
17025
- await this.microphone.apply(settings.audio, publish).catch((err) => {
17026
- this.logger.warn('Mic init failed', err);
17027
- });
17524
+ await this.camera
17525
+ .apply(settings.video, publish, withDisabledDevices)
17526
+ .catch((err) => this.logger.warn('Camera init failed', err));
17527
+ await this.microphone
17528
+ .apply(settings.audio, publish, withDisabledDevices)
17529
+ .catch((err) => this.logger.warn('Mic init failed', err));
17028
17530
  };
17029
17531
  /**
17030
17532
  * Will begin tracking the given element for visibility changes within the
@@ -17696,15 +18198,17 @@ class StableWSConnection {
17696
18198
  this.client.logger.error(`Token not set, can't connect authenticate`);
17697
18199
  return;
17698
18200
  }
17699
- const authMessage = JSON.stringify({
18201
+ const wsAuthMessage = {
17700
18202
  token,
18203
+ products: [WSAuthMessageProductsEnum.VIDEO],
17701
18204
  user_details: {
17702
18205
  id: user.id,
17703
18206
  name: user.name,
17704
18207
  image: user.image,
17705
18208
  custom: user.custom,
17706
18209
  },
17707
- });
18210
+ };
18211
+ const authMessage = JSON.stringify(wsAuthMessage);
17708
18212
  this._log(`onopen() - Sending auth message ${authMessage}`, {}, 'trace');
17709
18213
  this.ws?.send(authMessage);
17710
18214
  this._log('onopen() - onopen callback', { wsID });
@@ -18530,11 +19034,11 @@ class StreamClient {
18530
19034
  return await this.wsConnection.connect(this.defaultWSTimeout);
18531
19035
  };
18532
19036
  getSdkVersion = () => this.options.clientAppIdentifier?.sdkVersion ||
18533
- "1.57.0";
19037
+ "1.59.0";
18534
19038
  getUserAgent = () => {
18535
19039
  if (!this.cachedUserAgent) {
18536
19040
  const { clientAppIdentifier = {} } = this.options;
18537
- const { sdkName = 'js', sdkVersion = "1.57.0", ...extras } = clientAppIdentifier;
19041
+ const { sdkName = 'js', sdkVersion = "1.59.0", ...extras } = clientAppIdentifier;
18538
19042
  this.cachedUserAgent = [
18539
19043
  `stream-video-${sdkName}-v${sdkVersion}`,
18540
19044
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
@@ -18897,7 +19401,6 @@ class ClientEventReporter {
18897
19401
  user_id: this.streamClient.userID || this.coordinatorConnectUserId,
18898
19402
  type: ctx?.callType,
18899
19403
  id: ctx?.callId,
18900
- call_cid: cid,
18901
19404
  stage: 'JoinInitiated',
18902
19405
  join_attempt_id: joinAttemptId,
18903
19406
  ...(coordinatorConnectId && {
@@ -19079,7 +19582,6 @@ class ClientEventReporter {
19079
19582
  startedAt: Date.now(),
19080
19583
  joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
19081
19584
  sfuId: this.getSfuId(cid),
19082
- userSessionId: this.getUserSessionId(cid),
19083
19585
  wasPreviouslyConnected: this.pcEverConnected.get(key) === true,
19084
19586
  lastIceState: iceConnectionState,
19085
19587
  };
@@ -19090,9 +19592,6 @@ class ClientEventReporter {
19090
19592
  peer_connection: role,
19091
19593
  was_previously_connected: pair.wasPreviouslyConnected,
19092
19594
  ...(pair.sfuId && { sfu_id: pair.sfuId }),
19093
- ...(pair.userSessionId && {
19094
- user_session_id: pair.userSessionId,
19095
- }),
19096
19595
  event_type: 'initiated',
19097
19596
  });
19098
19597
  };
@@ -19107,9 +19606,6 @@ class ClientEventReporter {
19107
19606
  peer_connection: role,
19108
19607
  was_previously_connected: pair.wasPreviouslyConnected,
19109
19608
  ...(pair.sfuId && { sfu_id: pair.sfuId }),
19110
- ...(pair.userSessionId && {
19111
- user_session_id: pair.userSessionId,
19112
- }),
19113
19609
  event_type: 'completed',
19114
19610
  outcome: 'success',
19115
19611
  retry_count_attempt: 0,
@@ -19127,9 +19623,6 @@ class ClientEventReporter {
19127
19623
  ...this.sessionIdField(cid),
19128
19624
  peer_connection: role,
19129
19625
  was_previously_connected: pair.wasPreviouslyConnected,
19130
- ...(pair.userSessionId && {
19131
- user_session_id: pair.userSessionId,
19132
- }),
19133
19626
  ...(pair.sfuId && { sfu_id: pair.sfuId }),
19134
19627
  event_type: 'completed',
19135
19628
  outcome: 'failure',
@@ -19142,7 +19635,6 @@ class ClientEventReporter {
19142
19635
  this.peerConnectionPairs.delete(key);
19143
19636
  };
19144
19637
  getSfuId = (cid) => this.callContexts.get(cid)?.getSfuId() ?? '';
19145
- getUserSessionId = (cid) => this.callContexts.get(cid)?.getUserSessionId() ?? '';
19146
19638
  sessionIdField = (cid) => {
19147
19639
  const callSessionId = this.callContexts.get(cid)?.getCallSessionId() ?? '';
19148
19640
  return callSessionId ? { call_session_id: callSessionId } : {};
@@ -19154,7 +19646,6 @@ class ClientEventReporter {
19154
19646
  user_id: this.streamClient.userID || this.coordinatorConnectUserId,
19155
19647
  type: ctx?.callType ?? '',
19156
19648
  id: ctx?.callId ?? '',
19157
- call_cid: cid,
19158
19649
  stage,
19159
19650
  stage_id: pair.sid,
19160
19651
  ...(pair.joinAttemptIdSnapshot && {
@@ -19405,6 +19896,7 @@ class StreamVideoClient {
19405
19896
  }
19406
19897
  else {
19407
19898
  await call.updateFromRingingEvent(e);
19899
+ await call.get();
19408
19900
  }
19409
19901
  }
19410
19902
  else {
@@ -19443,6 +19935,13 @@ class StreamVideoClient {
19443
19935
  this.logger.error(`Failed to init call from event ${e.type}`, err);
19444
19936
  }
19445
19937
  };
19938
+ /**
19939
+ * Queries the API for calls matching the given filters.
19940
+ * @param data the query data.
19941
+ */
19942
+ doQueryCalls = (data) => {
19943
+ return this.streamClient.post('/calls', data);
19944
+ };
19446
19945
  /**
19447
19946
  * Rewatches the given calls with retry logic.
19448
19947
  * @param callsToReWatch array of call IDs to rewatch
@@ -19453,10 +19952,16 @@ class StreamVideoClient {
19453
19952
  for (let attempt = 0; attempt < maxRetries; attempt++) {
19454
19953
  try {
19455
19954
  this.logger.info(`Rewatching calls ${callsToReWatch.join(', ')} attempt ${attempt + 1}`);
19456
- await this.queryCalls({
19955
+ const response = await this.doQueryCalls({
19457
19956
  watch: true,
19458
19957
  filter_conditions: { cid: { $in: callsToReWatch } },
19459
19958
  });
19959
+ for (const c of response.calls) {
19960
+ const call = this.writeableStateStore.findCall(c.call.type, c.call.id);
19961
+ if (call) {
19962
+ call.updateFromCallStateResponse(c);
19963
+ }
19964
+ }
19460
19965
  return;
19461
19966
  }
19462
19967
  catch (err) {
@@ -19599,9 +20104,11 @@ class StreamVideoClient {
19599
20104
  * Will query the API for calls matching the given filters.
19600
20105
  *
19601
20106
  * @param data the query data.
20107
+ * @param opts additional options, for tweaking the API behavior.
19602
20108
  */
19603
- queryCalls = async (data = {}) => {
19604
- const response = await this.streamClient.post('/calls', data);
20109
+ queryCalls = async (data = {}, opts = {}) => {
20110
+ const { withDisabledDevices = true } = opts;
20111
+ const response = await this.doQueryCalls(data);
19605
20112
  const calls = [];
19606
20113
  for (const c of response.calls) {
19607
20114
  const call = new Call({
@@ -19615,7 +20122,10 @@ class StreamVideoClient {
19615
20122
  clientStore: this.writeableStateStore,
19616
20123
  });
19617
20124
  call.state.updateFromCallResponse(c.call);
19618
- await call.applyDeviceConfig(c.call.settings, false, isReactNative());
20125
+ await call.applyDeviceConfig(c.call.settings, {
20126
+ publish: false,
20127
+ withDisabledDevices,
20128
+ });
19619
20129
  if (data.watch) {
19620
20130
  await call.setup();
19621
20131
  this.writeableStateStore.registerCall(call);
@@ -19767,6 +20277,553 @@ const humanize = (n) => {
19767
20277
  return String(n);
19768
20278
  };
19769
20279
 
20280
+ /** Default loopback stream wait timeout. */
20281
+ const LOOPBACK_STREAMS_WAIT_TIMEOUT_MS = 10 * 1000;
20282
+ /** Default loopback recording duration. */
20283
+ const DEFAULT_LOOPBACK_RECORDING_DURATION_MS = 10 * 1000;
20284
+ /** Shortest permitted loopback recording duration. */
20285
+ const MIN_LOOPBACK_RECORDING_DURATION_MS = 5 * 1000;
20286
+ /** Longest permitted loopback recording duration. */
20287
+ const MAX_LOOPBACK_RECORDING_DURATION_MS = 2 * 60 * 1000;
20288
+ /** Thrown when loopback tracks do not arrive in time. */
20289
+ class LoopbackStreamsTimeoutError extends Error {
20290
+ constructor() {
20291
+ super('Timed out waiting for loopback streams. Ensure the call was joined ' +
20292
+ 'with `allowOwnTracksLoopback: true` and that the SFU is configured ' +
20293
+ 'to echo self-sub tracks.');
20294
+ this.name = 'LoopbackStreamsTimeoutError';
20295
+ }
20296
+ }
20297
+ /**
20298
+ * Returns the streams on the given participant that the SFU echoed back to us,
20299
+ * ignoring the local capture streams the publishing path writes to the same
20300
+ * fields.
20301
+ */
20302
+ const getLoopbackStreams = (call, participant) => {
20303
+ const { subscriber } = call ?? {};
20304
+ const { audioStream, videoStream } = participant ?? {};
20305
+ return {
20306
+ loopbackVideoStream: subscriber?.isSelfSubscribedStream(videoStream)
20307
+ ? videoStream
20308
+ : undefined,
20309
+ loopbackAudioStream: subscriber?.isSelfSubscribedStream(audioStream)
20310
+ ? audioStream
20311
+ : undefined,
20312
+ };
20313
+ };
20314
+ /**
20315
+ * Returns loopback tracks when all required tracks are present.
20316
+ *
20317
+ * Prefer {@link waitForLoopbackStreams}, which observes call state until the
20318
+ * tracks arrive. This is the synchronous snapshot it is built from.
20319
+ *
20320
+ * @internal
20321
+ */
20322
+ const getLoopbackTracks = (call, participant, includeVideo) => {
20323
+ if (!participant)
20324
+ return undefined;
20325
+ const { loopbackAudioStream, loopbackVideoStream } = getLoopbackStreams(call, participant);
20326
+ const audioTrack = loopbackAudioStream?.getAudioTracks()[0];
20327
+ const videoTrack = includeVideo
20328
+ ? loopbackVideoStream?.getVideoTracks()[0]
20329
+ : undefined;
20330
+ if (!audioTrack || (includeVideo && !videoTrack)) {
20331
+ return undefined;
20332
+ }
20333
+ return { audioTrack, videoTrack };
20334
+ };
20335
+ /** Waits for the requested loopback tracks. */
20336
+ const waitForLoopbackStreams = (call, opts) => {
20337
+ const callState = call.state;
20338
+ const { includeVideo, signal, timeoutMs = LOOPBACK_STREAMS_WAIT_TIMEOUT_MS, } = opts;
20339
+ return new Promise((resolve, reject) => {
20340
+ const initial = getLoopbackTracks(call, callState.localParticipant, includeVideo);
20341
+ if (initial) {
20342
+ resolve(initial);
20343
+ return;
20344
+ }
20345
+ if (signal.aborted) {
20346
+ resolve(null);
20347
+ return;
20348
+ }
20349
+ let settled = false;
20350
+ const subscriptions = new rxjs.Subscription();
20351
+ const cleanup = () => {
20352
+ if (settled)
20353
+ return;
20354
+ settled = true;
20355
+ clearTimeout(timeoutId);
20356
+ signal.removeEventListener('abort', onAbort);
20357
+ subscriptions.unsubscribe();
20358
+ };
20359
+ const onAbort = () => {
20360
+ cleanup();
20361
+ resolve(null);
20362
+ };
20363
+ const timeoutId = setTimeout(() => {
20364
+ cleanup();
20365
+ reject(new LoopbackStreamsTimeoutError());
20366
+ }, timeoutMs);
20367
+ signal.addEventListener('abort', onAbort);
20368
+ subscriptions.add(callState.localParticipant$.subscribe((participant) => {
20369
+ const ready = getLoopbackTracks(call, participant, includeVideo);
20370
+ if (ready) {
20371
+ cleanup();
20372
+ resolve(ready);
20373
+ }
20374
+ }));
20375
+ });
20376
+ };
20377
+ /** Enables loopback audio while `fn` runs, then restores it. */
20378
+ const withLoopbackAudioEnabled = async (tracks, fn) => {
20379
+ const restore = enableLoopbackAudio(tracks);
20380
+ try {
20381
+ return await fn();
20382
+ }
20383
+ finally {
20384
+ restore();
20385
+ }
20386
+ };
20387
+ /** Enables loopback audio and returns a restore function. */
20388
+ const enableLoopbackAudio = (tracks) => {
20389
+ const { audioTrack } = tracks;
20390
+ const previous = audioTrack?.enabled ?? false;
20391
+ if (audioTrack) {
20392
+ audioTrack.enabled = true;
20393
+ }
20394
+ return () => {
20395
+ if (audioTrack) {
20396
+ audioTrack.enabled = previous;
20397
+ }
20398
+ };
20399
+ };
20400
+ /** Clamps and rounds a loopback recording duration. */
20401
+ const clampLoopbackRecordingDuration = (durationMs) => Math.round(Math.min(MAX_LOOPBACK_RECORDING_DURATION_MS, Math.max(MIN_LOOPBACK_RECORDING_DURATION_MS, durationMs)));
20402
+
20403
+ /** Single-use `MediaRecorder` wrapper that returns an in-memory blob. */
20404
+ class MediaStreamRecorder {
20405
+ recording = promiseWithResolvers();
20406
+ logger = videoLoggerSystem.getLogger('MediaStreamRecorder');
20407
+ chunks = [];
20408
+ timeoutId;
20409
+ recorder;
20410
+ tracks;
20411
+ maxDurationMs;
20412
+ constructor(stream, options = {}) {
20413
+ if (typeof MediaRecorder === 'undefined') {
20414
+ throw new Error('MediaRecorder is not available in this environment');
20415
+ }
20416
+ this.tracks = stream.getTracks();
20417
+ if (this.tracks.length === 0) {
20418
+ throw new Error('cannot record a MediaStream that has no tracks');
20419
+ }
20420
+ this.maxDurationMs = options.maxDurationMs;
20421
+ this.recorder = new MediaRecorder(new MediaStream(this.tracks));
20422
+ this.recorder.ondataavailable = (event) => {
20423
+ if (event.data?.size)
20424
+ this.chunks.push(event.data);
20425
+ };
20426
+ this.recorder.onstop = this.handleStop;
20427
+ this.recorder.onerror = this.handleError;
20428
+ }
20429
+ /** Starts recording and resolves when recording stops. */
20430
+ start = () => {
20431
+ if (this.isSettled()) {
20432
+ throw new Error('MediaStreamRecorder is single-use: this instance has already ' +
20433
+ 'recorded. Construct a new one.');
20434
+ }
20435
+ if (this.recorder.state === 'inactive') {
20436
+ this.tracks.forEach((track) => track.addEventListener('ended', this.stop));
20437
+ if (this.maxDurationMs !== undefined) {
20438
+ this.timeoutId = setTimeout(this.stop, this.maxDurationMs);
20439
+ }
20440
+ this.recorder.start();
20441
+ }
20442
+ return this.recording.promise;
20443
+ };
20444
+ /** Stops recording early. */
20445
+ stop = () => {
20446
+ if (this.recorder.state !== 'inactive')
20447
+ this.recorder.stop();
20448
+ };
20449
+ isSettled = () => this.recording.isResolved() || this.recording.isRejected();
20450
+ detach = () => {
20451
+ clearTimeout(this.timeoutId);
20452
+ this.tracks.forEach((track) => track.removeEventListener('ended', this.stop));
20453
+ };
20454
+ handleStop = () => {
20455
+ this.detach();
20456
+ if (this.chunks.length === 0) {
20457
+ this.logger.warn('MediaRecorder recording produced no data');
20458
+ this.recording.resolve(null);
20459
+ return;
20460
+ }
20461
+ const mimeType = this.chunks[0].type || this.recorder.mimeType;
20462
+ this.recording.resolve({
20463
+ blob: new Blob(this.chunks, { type: mimeType }),
20464
+ });
20465
+ };
20466
+ handleError = (event) => {
20467
+ if (this.isSettled())
20468
+ return;
20469
+ this.detach();
20470
+ const error = event.error;
20471
+ this.logger.error('MediaRecorder error', error);
20472
+ this.recording.reject(error ?? new Error('MediaRecorder failed'));
20473
+ };
20474
+ }
20475
+
20476
+ /**
20477
+ * Tiny, type-safe event emitter.
20478
+ *
20479
+ * Usage styles:
20480
+ * - compose: `private readonly events = new TypedEventEmitter<MyMap>()`
20481
+ * - extend: `class Foo extends TypedEventEmitter<MyMap> {}`
20482
+ *
20483
+ * Listener exceptions (sync throw or rejected promise) are caught and logged,
20484
+ * so one bad listener cannot break dispatch for the rest.
20485
+ */
20486
+ class TypedEventEmitter {
20487
+ logger;
20488
+ byEvent = new Map();
20489
+ constructor(logger = 'TypedEventEmitter') {
20490
+ this.logger =
20491
+ typeof logger === 'string' ? videoLoggerSystem.getLogger(logger) : logger;
20492
+ }
20493
+ on = (event, fn) => {
20494
+ let listeners = this.byEvent.get(event);
20495
+ if (!listeners) {
20496
+ listeners = new Set();
20497
+ this.byEvent.set(event, listeners);
20498
+ }
20499
+ listeners.add(fn);
20500
+ return () => this.off(event, fn);
20501
+ };
20502
+ off = (event, fn) => {
20503
+ const listeners = this.byEvent.get(event);
20504
+ if (!listeners)
20505
+ return;
20506
+ listeners.delete(fn);
20507
+ if (listeners.size === 0)
20508
+ this.byEvent.delete(event);
20509
+ };
20510
+ emit = (event, payload) => {
20511
+ const listeners = this.byEvent.get(event);
20512
+ if (!listeners || listeners.size === 0)
20513
+ return;
20514
+ for (const listener of [...listeners]) {
20515
+ this.invoke(() => listener(payload), event);
20516
+ }
20517
+ };
20518
+ removeAllListeners = (event) => {
20519
+ if (event === undefined) {
20520
+ this.byEvent.clear();
20521
+ }
20522
+ else {
20523
+ this.byEvent.delete(event);
20524
+ }
20525
+ };
20526
+ invoke = (run, event) => {
20527
+ try {
20528
+ const result = run();
20529
+ if (result && typeof result.then === 'function') {
20530
+ result.catch((err) => {
20531
+ this.logger.warn(`Listener for '${String(event)}' rejected`, err);
20532
+ });
20533
+ }
20534
+ }
20535
+ catch (err) {
20536
+ this.logger.warn(`Listener for '${String(event)}' threw`, err);
20537
+ }
20538
+ };
20539
+ }
20540
+
20541
+ /**
20542
+ * Distributes keys to the E2EE Web Worker and attaches encrypt/decrypt
20543
+ * transforms to RTCRtpSenders and RTCRtpReceivers.
20544
+ */
20545
+ class EncryptionManager extends TypedEventEmitter {
20546
+ algorithm;
20547
+ transform;
20548
+ disposed = false;
20549
+ piped;
20550
+ userId;
20551
+ worker;
20552
+ workerUrl;
20553
+ constructor(userId, worker, workerUrl, algorithm, transform) {
20554
+ super('EncryptionManager');
20555
+ this.userId = userId;
20556
+ this.worker = worker;
20557
+ this.workerUrl = workerUrl;
20558
+ this.algorithm = algorithm;
20559
+ this.transform = transform;
20560
+ this.worker.addEventListener('message', this.handleWorkerMessage);
20561
+ this.worker.addEventListener('error', this.handleWorkerError);
20562
+ }
20563
+ /**
20564
+ * Whether this browser has WebRTC Encoded Transforms, which E2EE requires.
20565
+ * Use it to guard UI, or to avoid calling {@link create} where it would throw.
20566
+ */
20567
+ static isSupported = () => {
20568
+ return preferredTransform() !== undefined;
20569
+ };
20570
+ /**
20571
+ * Create an EncryptionManager instance and initialize the E2EE Web Worker.
20572
+ *
20573
+ * @param userId - The local user's ID, normally `call.currentUserId`.
20574
+ * @param options - the create options.
20575
+ * @throws {Error} If the browser lacks Encoded Transforms.
20576
+ *
20577
+ * @example
20578
+ * ```ts
20579
+ * if (EncryptionManager.isSupported()) {
20580
+ * const e2ee = await EncryptionManager.create(call.currentUserId);
20581
+ * call.setE2EEManager(e2ee);
20582
+ * e2ee.setSharedKey(0, keyBytes);
20583
+ * }
20584
+ */
20585
+ static create = async (userId, options) => {
20586
+ const transform = preferredTransform();
20587
+ if (!transform) {
20588
+ throw new Error(`E2EE is not supported in this browser`);
20589
+ }
20590
+ const { e2eeWorker } = await Promise.resolve().then(function () { return e2eeWorker$1; });
20591
+ const blob = new Blob([`(${e2eeWorker.toString()})()`], {
20592
+ type: 'application/javascript',
20593
+ });
20594
+ const url = URL.createObjectURL(blob);
20595
+ let worker;
20596
+ try {
20597
+ worker = new Worker(url, { name: 'stream-video-e2ee' });
20598
+ }
20599
+ catch (err) {
20600
+ // e.g. a CSP `worker-src` without `blob:`. Don't leak the object URL.
20601
+ URL.revokeObjectURL(url);
20602
+ throw err;
20603
+ }
20604
+ const algorithm = options?.algorithm ?? 'AES-128-GCM';
20605
+ return new EncryptionManager(userId, worker, url, algorithm, transform);
20606
+ };
20607
+ /**
20608
+ * {@link dispose} terminates the worker, so `postMessage` becomes a silent
20609
+ * no-op and an attached transform points at a dead worker: frames would stall
20610
+ * forever with no error and no event. Throwing is also fail-closed, since a
20611
+ * caller that swallows it still publishes nothing rather than cleartext.
20612
+ */
20613
+ assertUsable = () => {
20614
+ if (this.disposed)
20615
+ throw new Error(`EncryptionManager is disposed`);
20616
+ };
20617
+ /**
20618
+ * Terminate the worker and release all resources.
20619
+ *
20620
+ * The manager is unusable afterwards and every other method throws. Call
20621
+ * {@link create} for a new one. Safe to call more than once.
20622
+ */
20623
+ dispose = () => {
20624
+ if (this.disposed)
20625
+ return;
20626
+ this.disposed = true;
20627
+ this.piped = undefined;
20628
+ this.worker.removeEventListener('message', this.handleWorkerMessage);
20629
+ this.worker.removeEventListener('error', this.handleWorkerError);
20630
+ this.worker.terminate();
20631
+ URL.revokeObjectURL(this.workerUrl);
20632
+ this.removeAllListeners();
20633
+ };
20634
+ /**
20635
+ * Set a per-user AES-GCM encryption key in the worker's key store.
20636
+ *
20637
+ * Use it when each participant has their own key from a central authority.
20638
+ * The receiver picks the right one by the `keyIndex` in the frame trailer.
20639
+ *
20640
+ * @param userId - The key owner.
20641
+ * @param keyIndex - Increases with each rotation.
20642
+ * @param rawKey - 16 bytes for AES-128-GCM, 32 for AES-256-GCM.
20643
+ */
20644
+ setKey = (userId, keyIndex, rawKey) => {
20645
+ this.assertUsable();
20646
+ this.validateKeyIndex(keyIndex);
20647
+ this.validateKeyLength(rawKey);
20648
+ this.worker.postMessage({ type: 'cmd.set_key', userId, keyIndex, rawKey });
20649
+ };
20650
+ /**
20651
+ * Fallback key for any user without a per-user key. The simplest E2EE mode:
20652
+ * one key for everyone, usually passphrase-derived, no distribution needed.
20653
+ * Setting an epoch makes it active for encryption while older epochs remain
20654
+ * available to decrypt in-flight frames until {@link removeSharedKey}.
20655
+ *
20656
+ * @param keyIndex - An integer 0-255, since one trailer byte carries it.
20657
+ * @param rawKey - 16 bytes for AES-128-GCM, 32 for AES-256-GCM.
20658
+ */
20659
+ setSharedKey = (keyIndex, rawKey) => {
20660
+ this.assertUsable();
20661
+ this.validateKeyIndex(keyIndex);
20662
+ this.validateKeyLength(rawKey);
20663
+ this.worker.postMessage({ type: 'cmd.set_shared_key', keyIndex, rawKey });
20664
+ };
20665
+ /**
20666
+ * Remove one shared-key epoch from the worker's receive key ring.
20667
+ *
20668
+ * If this is the active epoch, shared-key encryption stops until
20669
+ * {@link setSharedKey} succeeds again. An older epoch is not reactivated.
20670
+ *
20671
+ * @param keyIndex - The exact shared-key epoch to remove.
20672
+ */
20673
+ removeSharedKey = (keyIndex) => {
20674
+ this.assertUsable();
20675
+ this.validateKeyIndex(keyIndex);
20676
+ this.worker.postMessage({ type: 'cmd.remove_shared_key', keyIndex });
20677
+ };
20678
+ /**
20679
+ * Retire one of a user's key epochs, leaving their other epochs usable.
20680
+ *
20681
+ * @param userId - The key owner.
20682
+ * @param keyIndex - The exact epoch to remove. Absent epochs are a no-op.
20683
+ */
20684
+ removeKey = (userId, keyIndex) => {
20685
+ this.assertUsable();
20686
+ this.validateKeyIndex(keyIndex);
20687
+ this.worker.postMessage({ type: 'cmd.remove_key', userId, keyIndex });
20688
+ };
20689
+ /**
20690
+ * Drop every key a user holds, revoking their ability to decrypt later
20691
+ * frames. Call it when a participant leaves.
20692
+ *
20693
+ * To retire one rotated epoch instead, use {@link removeKey}.
20694
+ */
20695
+ removeAllKeys = (userId) => {
20696
+ this.assertUsable();
20697
+ this.worker.postMessage({ type: 'cmd.remove_all_keys', userId });
20698
+ };
20699
+ /**
20700
+ * Called by the Publisher when it adds a transceiver.
20701
+ *
20702
+ * @param sender - The sender to encrypt.
20703
+ * @param codec - Codec name, e.g. 'vp8', selecting the clear-byte rules.
20704
+ * @param trackType - Optional label; only groups perf stats.
20705
+ * @internal
20706
+ */
20707
+ encrypt = (sender, codec, trackType) => {
20708
+ this.assertUsable();
20709
+ this.pipe(sender, {
20710
+ operation: 'encode',
20711
+ userId: this.userId,
20712
+ codec,
20713
+ trackType,
20714
+ });
20715
+ };
20716
+ /**
20717
+ * Called by the Subscriber when a remote track arrives.
20718
+ *
20719
+ * @param receiver - The receiver to decrypt.
20720
+ * @param userId - The remote user, for key lookup in the worker.
20721
+ * @param trackType - Optional label; only groups perf stats.
20722
+ * @internal
20723
+ */
20724
+ decrypt = (receiver, userId, trackType) => {
20725
+ this.assertUsable();
20726
+ this.pipe(receiver, { operation: 'decode', userId, trackType });
20727
+ };
20728
+ /** Pipe through the worker's transform, tracking targets to avoid double-piping. */
20729
+ pipe = (target, options) => {
20730
+ if (this.transform === 'script') {
20731
+ target.transform = new RTCRtpScriptTransform(this.worker, options);
20732
+ return;
20733
+ }
20734
+ if ((this.piped ??= new WeakSet()).has(target))
20735
+ return;
20736
+ this.piped.add(target);
20737
+ // @ts-expect-error createEncodedStreams is not in the standard typedefs
20738
+ const { readable, writable } = target.createEncodedStreams();
20739
+ this.worker.postMessage({ type: 'cmd.setup_transform', ...options, readable, writable }, [readable, writable]);
20740
+ };
20741
+ /**
20742
+ * Toggle periodic performance reporting from the E2EE worker.
20743
+ *
20744
+ * While on, the worker emits `e2ee.perf_report` once per second with per-track
20745
+ * FPS and crypto timings. Useful for debugging throughput.
20746
+ */
20747
+ enablePerformanceReporting = (enabled) => {
20748
+ this.assertUsable();
20749
+ this.worker.postMessage({
20750
+ type: 'cmd.enable_performance_reporting',
20751
+ enabled,
20752
+ });
20753
+ };
20754
+ /**
20755
+ * Request a snapshot of the worker's keys. It arrives later as the
20756
+ * `e2ee.key_state` event, listing fingerprints only, never key material.
20757
+ */
20758
+ requestKeyState = () => {
20759
+ this.assertUsable();
20760
+ this.worker.postMessage({ type: 'cmd.request_key_state' });
20761
+ };
20762
+ handleWorkerMessage = (e) => {
20763
+ const { type, ...payload } = e.data ?? {};
20764
+ if (type === 'e2ee.error') {
20765
+ this.logger.error(e.data.message);
20766
+ return;
20767
+ }
20768
+ const event = type;
20769
+ this.logger.debug('Dispatching', event, payload);
20770
+ this.emit(event, payload);
20771
+ };
20772
+ handleWorkerError = (e) => {
20773
+ this.logger.error('Unhandled worker error:', e.message);
20774
+ };
20775
+ validateKeyLength = (rawKey) => {
20776
+ const is256 = this.algorithm === 'AES-256-GCM';
20777
+ const expected = is256 ? 32 : 16;
20778
+ if (rawKey.byteLength !== expected) {
20779
+ throw new Error(`Key must be exactly ${expected} bytes (${is256 ? 'AES-256' : 'AES-128'})`);
20780
+ }
20781
+ };
20782
+ /**
20783
+ * One trailer byte carries the keyIndex. A larger value would truncate to
20784
+ * `keyIndex & 0xFF`, so the receiver would look up the wrong key and fail
20785
+ * every decrypt. Reject it rather than ship a silently broken key epoch.
20786
+ */
20787
+ validateKeyIndex = (keyIndex) => {
20788
+ if (!Number.isInteger(keyIndex) || keyIndex < 0 || keyIndex > 255) {
20789
+ throw new Error(`keyIndex must be an integer between 0 and 255, got ${keyIndex}`);
20790
+ }
20791
+ };
20792
+ }
20793
+
20794
+ /**
20795
+ * E2EE worker. The build populates it.
20796
+ *
20797
+ * The actual implementation lives in `e2ee-worker/e2ee-worker-impl.ts` and its
20798
+ * sibling modules (`constants.ts`, `codec.ts`, `crypto.ts`, `utils.ts`,
20799
+ * `types.ts`), all written as real TypeScript with full type checking
20800
+ * and import support.
20801
+ *
20802
+ * ## How it works
20803
+ *
20804
+ * 1. You edit files in `e2ee-worker/` as normal TypeScript
20805
+ * 2. `yarn build` runs Rollup, which invokes `rollup-plugin-inline-worker`
20806
+ * 3. The plugin bundles `e2ee-worker/e2ee-worker-impl.ts` (and its imports)
20807
+ * into a single function via a nested Rollup + TypeScript build
20808
+ * 4. The function replaces this file's export at build time
20809
+ * 5. `EncryptionManager.create()` creates a Worker from `e2eeWorker.toString()`
20810
+ *
20811
+ * In tests, this module is mocked (see `__tests__/EncryptionManager.test.ts`),
20812
+ * so the placeholder is never evaluated.
20813
+ *
20814
+ * @see e2ee-worker/e2ee-worker-impl.ts - the entry point
20815
+ * @see ../../plugins/rollup-plugin-inline-worker.ts - the Rollup plugin
20816
+ * @see EncryptionManager.ts - consumes e2eeWorker
20817
+ */
20818
+ function e2eeWorker() {
20819
+ // will be populated at build time
20820
+ }
20821
+
20822
+ var e2eeWorker$1 = /*#__PURE__*/Object.freeze({
20823
+ __proto__: null,
20824
+ e2eeWorker: e2eeWorker
20825
+ });
20826
+
19770
20827
  Object.defineProperty(exports, "AxiosError", {
19771
20828
  enumerable: true,
19772
20829
  get: function () { return axios.AxiosError; }
@@ -19789,19 +20846,30 @@ exports.CallTypes = CallTypes;
19789
20846
  exports.CameraManager = CameraManager;
19790
20847
  exports.CameraManagerState = CameraManagerState;
19791
20848
  exports.CreateDeviceRequestPushProviderEnum = CreateDeviceRequestPushProviderEnum;
20849
+ exports.DEFAULT_LOOPBACK_RECORDING_DURATION_MS = DEFAULT_LOOPBACK_RECORDING_DURATION_MS;
19792
20850
  exports.DeviceManager = DeviceManager;
19793
20851
  exports.DeviceManagerState = DeviceManagerState;
19794
20852
  exports.DynascaleManager = DynascaleManager;
20853
+ exports.EncryptionManager = EncryptionManager;
20854
+ exports.EncryptionSettingsRequestModeEnum = EncryptionSettingsRequestModeEnum;
20855
+ exports.EncryptionSettingsResponseModeEnum = EncryptionSettingsResponseModeEnum;
19795
20856
  exports.ErrorFromResponse = ErrorFromResponse;
19796
20857
  exports.FrameRecordingSettingsRequestModeEnum = FrameRecordingSettingsRequestModeEnum;
19797
20858
  exports.FrameRecordingSettingsRequestQualityEnum = FrameRecordingSettingsRequestQualityEnum;
19798
20859
  exports.FrameRecordingSettingsResponseModeEnum = FrameRecordingSettingsResponseModeEnum;
20860
+ exports.HLSSettingsRequestQualityTracksEnum = HLSSettingsRequestQualityTracksEnum;
19799
20861
  exports.IndividualRecordingSettingsRequestModeEnum = IndividualRecordingSettingsRequestModeEnum;
20862
+ exports.IndividualRecordingSettingsRequestOutputTypesEnum = IndividualRecordingSettingsRequestOutputTypesEnum;
19800
20863
  exports.IndividualRecordingSettingsResponseModeEnum = IndividualRecordingSettingsResponseModeEnum;
19801
20864
  exports.IngressAudioEncodingOptionsRequestChannelsEnum = IngressAudioEncodingOptionsRequestChannelsEnum;
19802
20865
  exports.IngressSourceRequestFpsEnum = IngressSourceRequestFpsEnum;
19803
20866
  exports.IngressVideoLayerRequestCodecEnum = IngressVideoLayerRequestCodecEnum;
20867
+ exports.LOOPBACK_STREAMS_WAIT_TIMEOUT_MS = LOOPBACK_STREAMS_WAIT_TIMEOUT_MS;
19804
20868
  exports.LayoutSettingsRequestNameEnum = LayoutSettingsRequestNameEnum;
20869
+ exports.LoopbackStreamsTimeoutError = LoopbackStreamsTimeoutError;
20870
+ exports.MAX_LOOPBACK_RECORDING_DURATION_MS = MAX_LOOPBACK_RECORDING_DURATION_MS;
20871
+ exports.MIN_LOOPBACK_RECORDING_DURATION_MS = MIN_LOOPBACK_RECORDING_DURATION_MS;
20872
+ exports.MediaStreamRecorder = MediaStreamRecorder;
19805
20873
  exports.MicrophoneManager = MicrophoneManager;
19806
20874
  exports.MicrophoneManagerState = MicrophoneManagerState;
19807
20875
  exports.NoiseCancellationSettingsModeEnum = NoiseCancellationSettingsModeEnum;
@@ -19812,6 +20880,7 @@ exports.RawRecordingSettingsRequestModeEnum = RawRecordingSettingsRequestModeEnu
19812
20880
  exports.RawRecordingSettingsResponseModeEnum = RawRecordingSettingsResponseModeEnum;
19813
20881
  exports.RecordSettingsRequestModeEnum = RecordSettingsRequestModeEnum;
19814
20882
  exports.RecordSettingsRequestQualityEnum = RecordSettingsRequestQualityEnum;
20883
+ exports.RequestPermissionRequestPermissionsEnum = RequestPermissionRequestPermissionsEnum;
19815
20884
  exports.RxUtils = rxUtils;
19816
20885
  exports.ScreenShareManager = ScreenShareManager;
19817
20886
  exports.ScreenShareState = ScreenShareState;
@@ -19833,10 +20902,15 @@ exports.TranscriptionSettingsRequestModeEnum = TranscriptionSettingsRequestModeE
19833
20902
  exports.TranscriptionSettingsResponseClosedCaptionModeEnum = TranscriptionSettingsResponseClosedCaptionModeEnum;
19834
20903
  exports.TranscriptionSettingsResponseLanguageEnum = TranscriptionSettingsResponseLanguageEnum;
19835
20904
  exports.TranscriptionSettingsResponseModeEnum = TranscriptionSettingsResponseModeEnum;
20905
+ exports.TranslationSettingsLanguagesEnum = TranslationSettingsLanguagesEnum;
20906
+ exports.UpdateUserPermissionsRequestGrantPermissionsEnum = UpdateUserPermissionsRequestGrantPermissionsEnum;
20907
+ exports.UpdateUserPermissionsRequestRevokePermissionsEnum = UpdateUserPermissionsRequestRevokePermissionsEnum;
19836
20908
  exports.VideoSettingsRequestCameraFacingEnum = VideoSettingsRequestCameraFacingEnum;
19837
20909
  exports.VideoSettingsResponseCameraFacingEnum = VideoSettingsResponseCameraFacingEnum;
19838
20910
  exports.ViewportTracker = ViewportTracker;
20911
+ exports.WSAuthMessageProductsEnum = WSAuthMessageProductsEnum;
19839
20912
  exports.checkIfAudioOutputChangeSupported = checkIfAudioOutputChangeSupported;
20913
+ exports.clampLoopbackRecordingDuration = clampLoopbackRecordingDuration;
19840
20914
  exports.combineComparators = combineComparators;
19841
20915
  exports.conditional = conditional;
19842
20916
  exports.createSoundDetector = createSoundDetector;
@@ -19845,12 +20919,16 @@ exports.descending = descending;
19845
20919
  exports.deviceIds$ = deviceIds$;
19846
20920
  exports.disposeOfMediaStream = disposeOfMediaStream;
19847
20921
  exports.dominantSpeaker = dominantSpeaker;
20922
+ exports.enableLoopbackAudio = enableLoopbackAudio;
19848
20923
  exports.getAudioBrowserPermission = getAudioBrowserPermission;
19849
20924
  exports.getAudioDevices = getAudioDevices;
19850
20925
  exports.getAudioOutputDevices = getAudioOutputDevices;
19851
20926
  exports.getAudioStream = getAudioStream;
20927
+ exports.getCallMediaEngineProvider = getCallMediaEngineProvider;
19852
20928
  exports.getClientDetails = getClientDetails;
19853
20929
  exports.getDeviceState = getDeviceState;
20930
+ exports.getLoopbackStreams = getLoopbackStreams;
20931
+ exports.getLoopbackTracks = getLoopbackTracks;
19854
20932
  exports.getScreenShareStream = getScreenShareStream;
19855
20933
  exports.getSdkInfo = getSdkInfo;
19856
20934
  exports.getVideoBrowserPermission = getVideoBrowserPermission;
@@ -19877,6 +20955,7 @@ exports.reactionType = reactionType;
19877
20955
  exports.resolveDeviceId = resolveDeviceId;
19878
20956
  exports.role = role;
19879
20957
  exports.screenSharing = screenSharing;
20958
+ exports.setCallMediaEngineProvider = setCallMediaEngineProvider;
19880
20959
  exports.setDeviceInfo = setDeviceInfo;
19881
20960
  exports.setOSInfo = setOSInfo;
19882
20961
  exports.setPowerState = setPowerState;
@@ -19886,5 +20965,7 @@ exports.setWebRTCInfo = setWebRTCInfo;
19886
20965
  exports.speakerLayoutSortPreset = speakerLayoutSortPreset;
19887
20966
  exports.speaking = speaking;
19888
20967
  exports.videoLoggerSystem = videoLoggerSystem;
20968
+ exports.waitForLoopbackStreams = waitForLoopbackStreams;
20969
+ exports.withLoopbackAudioEnabled = withLoopbackAudioEnabled;
19889
20970
  exports.withParticipantSource = withParticipantSource;
19890
20971
  //# sourceMappingURL=index.cjs.js.map