@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
@@ -7,7 +7,7 @@ import { TwirpFetchTransport, TwirpErrorCode } from '@protobuf-ts/twirp-transpor
7
7
  import * as scopedLogger from '@stream-io/logger';
8
8
  export { LogLevelEnum } from '@stream-io/logger';
9
9
  import { parse, write } from 'sdp-transform';
10
- import { Subject, Observable, combineLatest, BehaviorSubject, ReplaySubject, shareReplay, map, distinctUntilChanged, startWith, takeWhile, distinctUntilKeyChanged, fromEventPattern, concatMap, merge, from, fromEvent, tap, debounceTime, pairwise, firstValueFrom, of } from 'rxjs';
10
+ import { Subject, Observable, combineLatest, BehaviorSubject, ReplaySubject, shareReplay, map, distinctUntilChanged, startWith, takeWhile, distinctUntilKeyChanged, fromEventPattern, concatMap, merge, from, fromEvent, tap, debounceTime, pairwise, firstValueFrom, of, Subscription } from 'rxjs';
11
11
  import { UAParser } from 'ua-parser-js';
12
12
  import { WorkerTimer } from '@stream-io/worker-timer';
13
13
 
@@ -67,6 +67,22 @@ const CreateDeviceRequestPushProviderEnum = {
67
67
  HUAWEI: 'huawei',
68
68
  XIAOMI: 'xiaomi',
69
69
  };
70
+ /**
71
+ * @export
72
+ */
73
+ const EncryptionSettingsRequestModeEnum = {
74
+ AVAILABLE: 'available',
75
+ DISABLED: 'disabled',
76
+ AUTO_ON: 'auto-on',
77
+ };
78
+ /**
79
+ * @export
80
+ */
81
+ const EncryptionSettingsResponseModeEnum = {
82
+ AVAILABLE: 'available',
83
+ DISABLED: 'disabled',
84
+ AUTO_ON: 'auto-on',
85
+ };
70
86
  /**
71
87
  * @export
72
88
  */
@@ -93,6 +109,21 @@ const FrameRecordingSettingsResponseModeEnum = {
93
109
  DISABLED: 'disabled',
94
110
  AUTO_ON: 'auto-on',
95
111
  };
112
+ /**
113
+ * @export
114
+ */
115
+ const HLSSettingsRequestQualityTracksEnum = {
116
+ _360P: '360p',
117
+ _480P: '480p',
118
+ _720P: '720p',
119
+ _1080P: '1080p',
120
+ _1440P: '1440p',
121
+ PORTRAIT_360X640: 'portrait-360x640',
122
+ PORTRAIT_480X854: 'portrait-480x854',
123
+ PORTRAIT_720X1280: 'portrait-720x1280',
124
+ PORTRAIT_1080X1920: 'portrait-1080x1920',
125
+ PORTRAIT_1440X2560: 'portrait-1440x2560',
126
+ };
96
127
  /**
97
128
  * @export
98
129
  */
@@ -101,6 +132,17 @@ const IndividualRecordingSettingsRequestModeEnum = {
101
132
  DISABLED: 'disabled',
102
133
  AUTO_ON: 'auto-on',
103
134
  };
135
+ /**
136
+ * @export
137
+ */
138
+ const IndividualRecordingSettingsRequestOutputTypesEnum = {
139
+ AUDIO_ONLY: 'audio_only',
140
+ VIDEO_ONLY: 'video_only',
141
+ AUDIO_VIDEO: 'audio_video',
142
+ SCREENSHARE_AUDIO_ONLY: 'screenshare_audio_only',
143
+ SCREENSHARE_VIDEO_ONLY: 'screenshare_video_only',
144
+ SCREENSHARE_AUDIO_VIDEO: 'screenshare_audio_video',
145
+ };
104
146
  /**
105
147
  * @export
106
148
  */
@@ -259,6 +301,14 @@ const RecordSettingsRequestQualityEnum = {
259
301
  PORTRAIT_1080X1920: 'portrait-1080x1920',
260
302
  PORTRAIT_1440X2560: 'portrait-1440x2560',
261
303
  };
304
+ /**
305
+ * @export
306
+ */
307
+ const RequestPermissionRequestPermissionsEnum = {
308
+ SCREENSHARE: 'screenshare',
309
+ SEND_AUDIO: 'send-audio',
310
+ SEND_VIDEO: 'send-video',
311
+ };
262
312
  /**
263
313
  * @export
264
314
  */
@@ -459,6 +509,59 @@ const TranscriptionSettingsResponseModeEnum = {
459
509
  DISABLED: 'disabled',
460
510
  AUTO_ON: 'auto-on',
461
511
  };
512
+ /**
513
+ * @export
514
+ */
515
+ const TranslationSettingsLanguagesEnum = {
516
+ EN: 'en',
517
+ FR: 'fr',
518
+ ES: 'es',
519
+ DE: 'de',
520
+ IT: 'it',
521
+ NL: 'nl',
522
+ PT: 'pt',
523
+ PL: 'pl',
524
+ CA: 'ca',
525
+ CS: 'cs',
526
+ DA: 'da',
527
+ EL: 'el',
528
+ FI: 'fi',
529
+ ID: 'id',
530
+ JA: 'ja',
531
+ RU: 'ru',
532
+ SV: 'sv',
533
+ TA: 'ta',
534
+ TH: 'th',
535
+ TR: 'tr',
536
+ HU: 'hu',
537
+ RO: 'ro',
538
+ ZH: 'zh',
539
+ AR: 'ar',
540
+ TL: 'tl',
541
+ HE: 'he',
542
+ HI: 'hi',
543
+ HR: 'hr',
544
+ KO: 'ko',
545
+ MS: 'ms',
546
+ NO: 'no',
547
+ UK: 'uk',
548
+ };
549
+ /**
550
+ * @export
551
+ */
552
+ const UpdateUserPermissionsRequestGrantPermissionsEnum = {
553
+ SCREENSHARE: 'screenshare',
554
+ SEND_AUDIO: 'send-audio',
555
+ SEND_VIDEO: 'send-video',
556
+ };
557
+ /**
558
+ * @export
559
+ */
560
+ const UpdateUserPermissionsRequestRevokePermissionsEnum = {
561
+ SCREENSHARE: 'screenshare',
562
+ SEND_AUDIO: 'send-audio',
563
+ SEND_VIDEO: 'send-video',
564
+ };
462
565
  /**
463
566
  * @export
464
567
  */
@@ -475,6 +578,14 @@ const VideoSettingsResponseCameraFacingEnum = {
475
578
  BACK: 'back',
476
579
  EXTERNAL: 'external',
477
580
  };
581
+ /**
582
+ * @export
583
+ */
584
+ const WSAuthMessageProductsEnum = {
585
+ CHAT: 'chat',
586
+ VIDEO: 'video',
587
+ FEEDS: 'feeds',
588
+ };
478
589
 
479
590
  class ErrorFromResponse extends Error {
480
591
  code;
@@ -1472,6 +1583,13 @@ class CallState$Type extends MessageType {
1472
1583
  repeat: 2 /*RepeatType.UNPACKED*/,
1473
1584
  T: () => Pin,
1474
1585
  },
1586
+ {
1587
+ no: 5,
1588
+ name: 'e2ee_enabled',
1589
+ kind: 'scalar',
1590
+ jsonName: 'e2eeEnabled',
1591
+ T: 8 /*ScalarType.BOOL*/,
1592
+ },
1475
1593
  ]);
1476
1594
  }
1477
1595
  }
@@ -4594,6 +4712,19 @@ const isSvcCodec = (codecOrMimeType) => {
4594
4712
  codecOrMimeType === 'video/av1');
4595
4713
  };
4596
4714
 
4715
+ /**
4716
+ * The default engine: a thin, stateless wrapper over the WebRTC globals.
4717
+ */
4718
+ const defaultGlobalsEngine = {
4719
+ dispose: () => Promise.resolve(true),
4720
+ };
4721
+ const defaultGlobalsEngineProvider = () => defaultGlobalsEngine;
4722
+ let provider = defaultGlobalsEngineProvider;
4723
+ const setCallMediaEngineProvider = (newProvider) => {
4724
+ provider = newProvider;
4725
+ };
4726
+ const getCallMediaEngineProvider = () => provider;
4727
+
4597
4728
  const sfuEventKinds = {
4598
4729
  subscriberOffer: undefined,
4599
4730
  publisherAnswer: undefined,
@@ -4801,7 +4932,9 @@ const parseUfragFromCandidate = (candidate) => {
4801
4932
  };
4802
4933
 
4803
4934
  const ensureExhausted = (x, message) => {
4804
- videoLoggerSystem.getLogger('helpers').warn(message, x);
4935
+ if (message) {
4936
+ videoLoggerSystem.getLogger('helpers').warn(message, x);
4937
+ }
4805
4938
  };
4806
4939
 
4807
4940
  /**
@@ -4926,7 +5059,7 @@ const toIceCandidate = (iceTrickle) => {
4926
5059
  }
4927
5060
  };
4928
5061
 
4929
- const version = "1.57.0";
5062
+ const version = "1.59.0";
4930
5063
  const [major, minor, patch] = version.split('.');
4931
5064
  let sdkInfo = {
4932
5065
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -5183,6 +5316,27 @@ const withoutConcurrency = createRunner(wrapWithContinuationTracking);
5183
5316
  */
5184
5317
  const withCancellation = createRunner(wrapWithCancellation);
5185
5318
  const pendingPromises = new Map();
5319
+ /**
5320
+ * Wraps an async function so concurrent calls share the in-flight promise
5321
+ * instead of queueing another execution.
5322
+ *
5323
+ * @param cb Async function to wrap.
5324
+ * @returns Wrapped async function.
5325
+ */
5326
+ function singleFlight(cb) {
5327
+ let pending;
5328
+ return (...args) => {
5329
+ if (pending)
5330
+ return pending;
5331
+ const promise = cb(...args).finally(() => {
5332
+ if (pending === promise) {
5333
+ pending = undefined;
5334
+ }
5335
+ });
5336
+ pending = promise;
5337
+ return promise;
5338
+ };
5339
+ }
5186
5340
  function hasPending(tag) {
5187
5341
  return pendingPromises.has(tag);
5188
5342
  }
@@ -5909,6 +6063,7 @@ class CallState {
5909
6063
  settingsSubject = new BehaviorSubject(undefined);
5910
6064
  transcribingSubject = new BehaviorSubject(false);
5911
6065
  captioningSubject = new BehaviorSubject(false);
6066
+ e2eeEnabledSubject = new BehaviorSubject(false);
5912
6067
  endedBySubject = new BehaviorSubject(undefined);
5913
6068
  thumbnailsSubject = new BehaviorSubject(undefined);
5914
6069
  membersSubject = new BehaviorSubject([]);
@@ -6067,6 +6222,11 @@ class CallState {
6067
6222
  * Will provide the closed captioning state of this call.
6068
6223
  */
6069
6224
  captioning$;
6225
+ /**
6226
+ * Whether end-to-end encryption is active for this call, as reported by the
6227
+ * SFU in the join response.
6228
+ */
6229
+ e2eeEnabled$;
6070
6230
  /**
6071
6231
  * Will provide the user who ended this call.
6072
6232
  */
@@ -6168,6 +6328,7 @@ class CallState {
6168
6328
  this.rawRecording$ = duc(this.rawRecordingSubject);
6169
6329
  this.transcribing$ = duc(this.transcribingSubject);
6170
6330
  this.captioning$ = duc(this.captioningSubject);
6331
+ this.e2eeEnabled$ = duc(this.e2eeEnabledSubject);
6171
6332
  this.eventHandlers = {
6172
6333
  // these events are not updating the call state:
6173
6334
  'call.frame_recording_ready': undefined,
@@ -6270,6 +6431,7 @@ class CallState {
6270
6431
  clearTimeout(taskId);
6271
6432
  this.closedCaptionsTasks.delete(ccKey);
6272
6433
  }
6434
+ this.removeAllOrphanedTracks();
6273
6435
  };
6274
6436
  /**
6275
6437
  * Sets the list of criteria that are used to sort the participants.
@@ -6619,6 +6781,12 @@ class CallState {
6619
6781
  get transcribing() {
6620
6782
  return this.getCurrentValue(this.transcribing$);
6621
6783
  }
6784
+ /**
6785
+ * Whether end-to-end encryption is active for this call.
6786
+ */
6787
+ get e2eeEnabled() {
6788
+ return this.getCurrentValue(this.e2eeEnabled$);
6789
+ }
6622
6790
  /**
6623
6791
  * Will provide the user who ended this call.
6624
6792
  */
@@ -6839,6 +7007,18 @@ class CallState {
6839
7007
  removeOrphanedTrack = (id) => {
6840
7008
  this.orphanedTracks = this.orphanedTracks.filter((o) => o.id !== id);
6841
7009
  };
7010
+ /**
7011
+ * Drops every orphaned track. Call this when the peer connections that own
7012
+ * the stored receivers go away (full leave, reconnect, or migration):
7013
+ * `pc.close()` does not raise the track `ended` event, so the per-track
7014
+ * cleanup never fires and the receivers + their closed PCs would otherwise
7015
+ * leak for the call's lifetime.
7016
+ *
7017
+ * @internal
7018
+ */
7019
+ removeAllOrphanedTracks = () => {
7020
+ this.orphanedTracks = [];
7021
+ };
6842
7022
  /**
6843
7023
  * Takes all orphaned tracks with the given track lookup prefix.
6844
7024
  * All orphaned tracks with the given track lookup prefix are removed from the call state.
@@ -6901,7 +7081,7 @@ class CallState {
6901
7081
  * @param reconnectDetails optional reconnect details.
6902
7082
  */
6903
7083
  updateFromSfuCallState = (callState, currentSessionId, reconnectDetails) => {
6904
- const { participants, participantCount, startedAt, pins } = callState;
7084
+ const { participants, participantCount, startedAt, pins, e2EeEnabled } = callState;
6905
7085
  const localPublishedTracks = reconnectDetails?.announcedTracks.map((t) => t.trackType) ?? [];
6906
7086
  this.setParticipants(() => {
6907
7087
  const participantLookup = this.getParticipantLookupBySessionId();
@@ -6927,6 +7107,7 @@ class CallState {
6927
7107
  this.setAnonymousParticipantCount(participantCount?.anonymous || 0);
6928
7108
  this.setStartedAt(startedAt ? Timestamp.toDate(startedAt) : new Date());
6929
7109
  this.setServerSidePins(pins);
7110
+ this.setCurrentValue(this.e2eeEnabledSubject, e2EeEnabled);
6930
7111
  };
6931
7112
  updateFromMemberRemoved = (event) => {
6932
7113
  this.updateFromCallResponse(event.call);
@@ -7124,6 +7305,37 @@ class CallState {
7124
7305
  };
7125
7306
  }
7126
7307
 
7308
+ /**
7309
+ * Detection and selection policy for the two WebRTC Encoded Transform APIs.
7310
+ * Internal: which one the SDK attaches is an RTC-layer detail. Consumers want
7311
+ * `EncryptionManager.isSupported` instead.
7312
+ */
7313
+ /**
7314
+ * Chrome only, and the reason an RTCPeerConnection carrying E2EE needs the
7315
+ * non-standard `encodedInsertableStreams` flag.
7316
+ */
7317
+ const hasInsertableStreams = () => typeof RTCRtpSender !== 'undefined' &&
7318
+ 'createEncodedStreams' in RTCRtpSender.prototype;
7319
+ /** Whether the standard `RTCRtpScriptTransform` API exists. */
7320
+ const hasScriptTransform = () => typeof RTCRtpScriptTransform !== 'undefined';
7321
+ /**
7322
+ * Which Encoded Transform API to attach E2EE with here.
7323
+ *
7324
+ * - `'insertable'`: legacy Insertable Streams. Used on Chrome, where
7325
+ * `RTCRtpScriptTransform` is still unreliable for E2EE.
7326
+ * - `'script'`: the standard API. Used everywhere else.
7327
+ * - `undefined`: neither exists, so E2EE cannot run.
7328
+ */
7329
+ const preferredTransform = () => {
7330
+ const insertable = hasInsertableStreams();
7331
+ // Chrome's RTCRtpScriptTransform is still unreliable for E2EE.
7332
+ if (isChrome() && insertable)
7333
+ return 'insertable';
7334
+ if (hasScriptTransform())
7335
+ return 'script';
7336
+ return insertable ? 'insertable' : undefined;
7337
+ };
7338
+
7127
7339
  /**
7128
7340
  * Flatten the stats report into an array of stats objects.
7129
7341
  *
@@ -8232,6 +8444,7 @@ class BasePeerConnection {
8232
8444
  state;
8233
8445
  dispatcher;
8234
8446
  clientPublishOptions;
8447
+ e2ee;
8235
8448
  tag;
8236
8449
  sfuClient;
8237
8450
  onReconnectionNeeded;
@@ -8253,13 +8466,14 @@ class BasePeerConnection {
8253
8466
  /**
8254
8467
  * Constructs a new `BasePeerConnection` instance.
8255
8468
  */
8256
- constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, onPeerConnectionStateChange, onRemoteTrackUnmute, tag, enableTracing, clientPublishOptions, iceRestartDelay = 2500, statsTimestampDriftThresholdMs = 0, }) {
8469
+ constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, onPeerConnectionStateChange, onRemoteTrackUnmute, tag, enableTracing, clientPublishOptions, e2ee, iceRestartDelay = 2500, statsTimestampDriftThresholdMs = 0, }) {
8257
8470
  this.peerType = peerType;
8258
8471
  this.sfuClient = sfuClient;
8259
8472
  this.state = state;
8260
8473
  this.dispatcher = dispatcher;
8261
8474
  this.iceRestartDelay = iceRestartDelay;
8262
8475
  this.clientPublishOptions = clientPublishOptions;
8476
+ this.e2ee = e2ee;
8263
8477
  this.tag = tag;
8264
8478
  this.onReconnectionNeeded = onReconnectionNeeded;
8265
8479
  this.onIceConnected = onIceConnected;
@@ -8278,7 +8492,13 @@ class BasePeerConnection {
8278
8492
  }
8279
8493
  }
8280
8494
  createPeerConnection = (connectionConfig) => {
8281
- const pc = new RTCPeerConnection(connectionConfig);
8495
+ const config = { ...connectionConfig };
8496
+ // The legacy Insertable Streams path requires this non-standard flag.
8497
+ if (this.e2ee && hasInsertableStreams()) {
8498
+ // @ts-expect-error not part of the standard lib yet
8499
+ config.encodedInsertableStreams = true;
8500
+ }
8501
+ const pc = new RTCPeerConnection(config);
8282
8502
  pc.addEventListener('icecandidate', this.onIceCandidate);
8283
8503
  pc.addEventListener('icecandidateerror', this.onIceCandidateError);
8284
8504
  pc.addEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
@@ -9032,6 +9252,10 @@ class Publisher extends BasePeerConnection {
9032
9252
  toRTCDegradationPreference(publishOption.degradationPreference) ??
9033
9253
  'maintain-framerate';
9034
9254
  await transceiver.sender.setParameters(params);
9255
+ if (this.e2ee) {
9256
+ this.e2ee.encrypt(transceiver.sender, publishOption.codec?.name.toLowerCase(), TrackType[publishOption.trackType]);
9257
+ this.logger.debug('E2EE encryptor attached to sender');
9258
+ }
9035
9259
  await this.negotiate();
9036
9260
  };
9037
9261
  /**
@@ -9601,6 +9825,11 @@ class Subscriber extends BasePeerConnection {
9601
9825
  throw e;
9602
9826
  }
9603
9827
  };
9828
+ /**
9829
+ * Whether the given stream is one the SFU echoed back to us
9830
+ * (`allowOwnTracksLoopback`) rather than a local capture stream.
9831
+ */
9832
+ isSelfSubscribedStream = (stream) => !!stream && !!this.trackedStreams?.has(stream);
9604
9833
  handleOnTrack = (e) => {
9605
9834
  const { streams, track } = e;
9606
9835
  const [primaryStream] = streams;
@@ -9643,9 +9872,15 @@ class Subscriber extends BasePeerConnection {
9643
9872
  trackLookupPrefix: trackId,
9644
9873
  track: primaryStream,
9645
9874
  trackType,
9875
+ receiver: this.e2ee ? e.receiver : undefined,
9646
9876
  });
9647
9877
  return;
9648
9878
  }
9879
+ else if (this.e2ee) {
9880
+ const { userId } = participantToUpdate;
9881
+ this.e2ee.decrypt(e.receiver, userId, TrackType[trackType]);
9882
+ this.logger.debug('E2EE decryptor attached to receiver', userId);
9883
+ }
9649
9884
  const streamKindProp = trackTypeToParticipantStreamKey(trackType);
9650
9885
  if (!streamKindProp) {
9651
9886
  this.logger.error(`Unknown track type: ${rawTrackType}`);
@@ -9684,6 +9919,8 @@ class Subscriber extends BasePeerConnection {
9684
9919
  const target = this.state.participants.find((p) => p.trackLookupPrefix === trackId);
9685
9920
  if (!target)
9686
9921
  return;
9922
+ if (target.isLocalParticipant)
9923
+ return;
9687
9924
  this.state.updateParticipant(target.sessionId, (p) => {
9688
9925
  const current = p.interruptedTracks ?? [];
9689
9926
  const has = current.includes(trackType);
@@ -10426,7 +10663,6 @@ const watchCallRejected = (call) => {
10426
10663
  */
10427
10664
  const watchCallEnded = (call) => {
10428
10665
  return function onCallEnded() {
10429
- globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
10430
10666
  const { callingState } = call.state;
10431
10667
  if (callingState !== CallingState.IDLE &&
10432
10668
  callingState !== CallingState.LEFT) {
@@ -10435,9 +10671,13 @@ const watchCallEnded = (call) => {
10435
10671
  reason: 'call.ended event received',
10436
10672
  });
10437
10673
  call
10438
- .leave({ message: 'call.ended event received', reject: false })
10674
+ .leave({
10675
+ message: 'call.ended event received',
10676
+ reject: false,
10677
+ reason: 'ended',
10678
+ })
10439
10679
  .catch((err) => {
10440
- call.logger.error('Failed to leave call after call.ended ', err);
10680
+ call.logger.error('Failed to leave call after call.ended', err);
10441
10681
  });
10442
10682
  }
10443
10683
  };
@@ -10617,7 +10857,7 @@ const handleRemoteSoftMute = (call) => {
10617
10857
  /**
10618
10858
  * An event responder which handles the `participantJoined` event.
10619
10859
  */
10620
- const watchParticipantJoined = (state) => {
10860
+ const watchParticipantJoined = (state, e2ee = undefined) => {
10621
10861
  return function onParticipantJoined(e) {
10622
10862
  const { participant } = e;
10623
10863
  if (!participant)
@@ -10630,7 +10870,7 @@ const watchParticipantJoined = (state) => {
10630
10870
  // The SFU would send participant info as part of the `join`
10631
10871
  // response and then follow up with a `participantJoined` event for
10632
10872
  // already announced participants.
10633
- const orphanedTracks = reconcileOrphanedTracks(state, participant);
10873
+ const orphanedTracks = reconcileOrphanedTracks(state, participant, e2ee);
10634
10874
  state.updateOrAddParticipant(participant.sessionId, Object.assign(participant, orphanedTracks, {
10635
10875
  ...(e.isPinned && { pin: { isLocalPin: false, pinnedAt: Date.now() } }),
10636
10876
  viewportVisibilityState: {
@@ -10666,7 +10906,7 @@ const watchParticipantUpdated = (state) => {
10666
10906
  * An event responder which handles the `trackPublished` event.
10667
10907
  * The SFU will send this event when a participant publishes a track.
10668
10908
  */
10669
- const watchTrackPublished = (state) => {
10909
+ const watchTrackPublished = (state, e2ee = undefined) => {
10670
10910
  return function onTrackPublished(e) {
10671
10911
  const { type, sessionId } = e;
10672
10912
  // An optimization for large calls.
@@ -10674,7 +10914,7 @@ const watchTrackPublished = (state) => {
10674
10914
  // events, and instead, it would only provide the participant's information
10675
10915
  // once they start publishing a track.
10676
10916
  if (e.participant) {
10677
- const orphanedTracks = reconcileOrphanedTracks(state, e.participant);
10917
+ const orphanedTracks = reconcileOrphanedTracks(state, e.participant, e2ee);
10678
10918
  const participant = Object.assign(e.participant, orphanedTracks);
10679
10919
  state.updateOrAddParticipant(sessionId, participant);
10680
10920
  }
@@ -10689,12 +10929,12 @@ const watchTrackPublished = (state) => {
10689
10929
  * An event responder which handles the `trackUnpublished` event.
10690
10930
  * The SFU will send this event when a participant unpublishes a track.
10691
10931
  */
10692
- const watchTrackUnpublished = (state) => {
10932
+ const watchTrackUnpublished = (state, e2ee = undefined) => {
10693
10933
  return function onTrackUnpublished(e) {
10694
10934
  const { type, sessionId } = e;
10695
10935
  // An optimization for large calls. See `watchTrackPublished`.
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, (p) => ({
10700
10940
  pausedTracks: p.pausedTracks?.filter((t) => t !== type),
@@ -10713,17 +10953,22 @@ const watchTrackUnpublished = (state) => {
10713
10953
  *
10714
10954
  * @param state the call state.
10715
10955
  * @param participant the participant.
10956
+ * @param e2ee accessor for the call's encryption manager, if any.
10716
10957
  */
10717
- const reconcileOrphanedTracks = (state, participant) => {
10958
+ const reconcileOrphanedTracks = (state, participant, e2ee) => {
10718
10959
  const orphanTracks = state.takeOrphanedTracks(participant.trackLookupPrefix);
10719
10960
  if (!orphanTracks.length)
10720
10961
  return;
10962
+ const manager = e2ee?.();
10721
10963
  const reconciledTracks = {};
10722
10964
  for (const orphan of orphanTracks) {
10723
10965
  const key = trackTypeToParticipantStreamKey(orphan.trackType);
10724
10966
  if (!key)
10725
10967
  continue;
10726
10968
  reconciledTracks[key] = orphan.track;
10969
+ if (manager && orphan.receiver) {
10970
+ manager.decrypt(orphan.receiver, participant.userId, TrackType[orphan.trackType]);
10971
+ }
10727
10972
  }
10728
10973
  return reconciledTracks;
10729
10974
  };
@@ -10779,6 +11024,11 @@ const watchAudioLevelChanged = (dispatcher, state) => {
10779
11024
  */
10780
11025
  const registerEventHandlers = (call, dispatcher) => {
10781
11026
  const state = call.state;
11027
+ // Read lazily on each event: setE2EEManager can run after setup() (an app that
11028
+ // inspects call settings via get()/getOrCreate() before deciding to encrypt has
11029
+ // already triggered it), so a value captured here would be a stale undefined
11030
+ // for the whole call and orphaned tracks would never get a decryptor.
11031
+ const e2ee = () => call.e2eeManager;
10782
11032
  const eventHandlers = [
10783
11033
  call.on('call.ended', watchCallEnded(call)),
10784
11034
  watchSfuCallEnded(call),
@@ -10786,11 +11036,11 @@ const registerEventHandlers = (call, dispatcher) => {
10786
11036
  watchSfuErrorReports(dispatcher),
10787
11037
  watchConnectionQualityChanged(dispatcher, state),
10788
11038
  watchParticipantCountChanged(dispatcher, state),
10789
- call.on('participantJoined', watchParticipantJoined(state)),
11039
+ call.on('participantJoined', watchParticipantJoined(state, e2ee)),
10790
11040
  call.on('participantLeft', watchParticipantLeft(state)),
10791
11041
  call.on('participantUpdated', watchParticipantUpdated(state)),
10792
- call.on('trackPublished', watchTrackPublished(state)),
10793
- call.on('trackUnpublished', watchTrackUnpublished(state)),
11042
+ call.on('trackPublished', watchTrackPublished(state, e2ee)),
11043
+ call.on('trackUnpublished', watchTrackUnpublished(state, e2ee)),
10794
11044
  watchAudioLevelChanged(dispatcher, state),
10795
11045
  watchDominantSpeakerChanged(dispatcher, state),
10796
11046
  call.on('callGrantsUpdated', watchCallGrantsUpdated(state)),
@@ -11918,13 +12168,14 @@ class PermissionsContext {
11918
12168
  return false;
11919
12169
  const { audio, video, screensharing } = settings;
11920
12170
  switch (permission) {
11921
- case OwnCapability.SEND_AUDIO:
12171
+ case RequestPermissionRequestPermissionsEnum.SEND_AUDIO:
11922
12172
  return audio.access_request_enabled;
11923
- case OwnCapability.SEND_VIDEO:
12173
+ case RequestPermissionRequestPermissionsEnum.SEND_VIDEO:
11924
12174
  return video.access_request_enabled;
11925
- case OwnCapability.SCREENSHARE:
12175
+ case RequestPermissionRequestPermissionsEnum.SCREENSHARE:
11926
12176
  return screensharing.access_request_enabled;
11927
12177
  default:
12178
+ ensureExhausted(permission);
11928
12179
  return false;
11929
12180
  }
11930
12181
  };
@@ -12931,6 +13182,27 @@ class DeviceManager {
12931
13182
  this.filters.forEach((entry) => entry.stop?.());
12932
13183
  }
12933
13184
  }
13185
+ reconcileOptimisticStatus = async () => {
13186
+ const target = this.state.optimisticStatus;
13187
+ await withCancellation(this.statusChangeConcurrencyTag, async (signal) => {
13188
+ try {
13189
+ if (target === 'enabled' && this.state.status !== 'enabled') {
13190
+ await this.unmuteStream();
13191
+ if (!signal.aborted)
13192
+ this.state.setStatus('enabled');
13193
+ }
13194
+ else if (target === 'disabled' && this.state.status === 'enabled') {
13195
+ // mirror whatever disable() does to stop/pause the track per disableMode
13196
+ if (!signal.aborted)
13197
+ this.state.setStatus('disabled');
13198
+ }
13199
+ }
13200
+ finally {
13201
+ if (!signal.aborted)
13202
+ this.state.setPendingStatus(this.state.status);
13203
+ }
13204
+ });
13205
+ };
12934
13206
  disableTracks() {
12935
13207
  this.getTracks().forEach((track) => {
12936
13208
  if (track.enabled)
@@ -13211,7 +13483,7 @@ class DeviceManager {
13211
13483
  createSyntheticDevice(selectedDevice, deviceKind);
13212
13484
  writePreferences(currentDevice, deviceKey, muted, storageKey);
13213
13485
  }
13214
- async applyPersistedPreferences(enabledInCallType) {
13486
+ async applyPersistedPreferences(enabledInCallType, forceDisabled = false) {
13215
13487
  const deviceKey = this.trackType === TrackType.AUDIO ? 'microphone' : 'camera';
13216
13488
  const preferences = readPreferences(this.devicePersistence.storageKey);
13217
13489
  const preferenceList = toPreferenceList(preferences[deviceKey]);
@@ -13238,13 +13510,13 @@ class DeviceManager {
13238
13510
  }
13239
13511
  const canPublish = this.call.permissionsContext.canPublish(this.trackType);
13240
13512
  if (typeof muted === 'boolean' && enabledInCallType && canPublish) {
13241
- await this.applyMutedState(muted);
13513
+ await this.applyMutedState(muted, forceDisabled);
13242
13514
  appliedMute = true;
13243
13515
  }
13244
13516
  return appliedDevice || appliedMute;
13245
13517
  }
13246
- async applyMutedState(muted) {
13247
- if (this.state.status !== undefined)
13518
+ async applyMutedState(muted, forceDisabled) {
13519
+ if (forceDisabled || this.state.status !== undefined)
13248
13520
  return;
13249
13521
  if (muted) {
13250
13522
  await this.disable();
@@ -13564,13 +13836,41 @@ class CameraManager extends DeviceManager {
13564
13836
  }
13565
13837
  }
13566
13838
  }
13839
+ enable() {
13840
+ if (isReactNative() && !this.call.hasMediaEngine) {
13841
+ this.state.setPendingStatus('enabled');
13842
+ return Promise.resolve();
13843
+ }
13844
+ return super.enable();
13845
+ }
13846
+ async disable(forceStopOrOptions) {
13847
+ if (isReactNative() && !this.call.hasMediaEngine) {
13848
+ this.state.setPendingStatus('disabled');
13849
+ return;
13850
+ }
13851
+ // forward verbatim to the base, narrowing so the right overload is selected
13852
+ if (forceStopOrOptions === undefined)
13853
+ return super.disable();
13854
+ if (typeof forceStopOrOptions === 'boolean') {
13855
+ return super.disable(forceStopOrOptions);
13856
+ }
13857
+ return super.disable(forceStopOrOptions);
13858
+ }
13859
+ toggle() {
13860
+ if (isReactNative() && !this.call.hasMediaEngine) {
13861
+ this.state.setPendingStatus(this.state.optimisticStatus === 'enabled' ? 'disabled' : 'enabled');
13862
+ return Promise.resolve();
13863
+ }
13864
+ return super.toggle();
13865
+ }
13567
13866
  /**
13568
13867
  * Applies the video settings to the camera.
13569
13868
  *
13570
13869
  * @param settings the video settings to apply.
13571
13870
  * @param publish whether to publish the stream after applying the settings.
13871
+ * @param forceDisabled whether to keep the camera disabled, regardless of server-side or local preferences.
13572
13872
  */
13573
- async apply(settings, publish) {
13873
+ async apply(settings, publish, forceDisabled = false) {
13574
13874
  // Wait for any in progress camera operation
13575
13875
  await this.statusChangeSettled();
13576
13876
  await this.selectTargetResolution(settings.target_resolution);
@@ -13582,8 +13882,7 @@ class CameraManager extends DeviceManager {
13582
13882
  if (shouldApplyDefaults &&
13583
13883
  this.devicePersistence.enabled &&
13584
13884
  permissionState === 'granted') {
13585
- persistedPreferencesApplied =
13586
- await this.applyPersistedPreferences(enabledInCallType);
13885
+ persistedPreferencesApplied = await this.applyPersistedPreferences(enabledInCallType, forceDisabled);
13587
13886
  }
13588
13887
  // apply a direction and enable the camera only if in "pristine" state,
13589
13888
  // and there are no persisted preferences
@@ -13593,13 +13892,23 @@ class CameraManager extends DeviceManager {
13593
13892
  const direction = settings.camera_facing === 'front' ? 'front' : 'back';
13594
13893
  await this.selectDirection(direction, { enableCamera: false });
13595
13894
  }
13596
- if (canPublish && settings.camera_default_on && enabledInCallType) {
13895
+ if (!forceDisabled &&
13896
+ canPublish &&
13897
+ settings.camera_default_on &&
13898
+ enabledInCallType) {
13597
13899
  await this.enable();
13598
13900
  }
13599
13901
  }
13600
- const { mediaStream } = this.state;
13601
- if (canPublish && publish && this.enabled && mediaStream) {
13602
- await this.publishStream(mediaStream);
13902
+ if (isReactNative() && publish && canPublish) {
13903
+ // On RN the camera is enabled/disabled optimistically before JOINED. Reconcile now
13904
+ // acquires the track and publishes it, so it fully owns the publish.
13905
+ await this.reconcileOptimisticStatus();
13906
+ }
13907
+ else {
13908
+ const { mediaStream } = this.state;
13909
+ if (canPublish && publish && this.enabled && mediaStream) {
13910
+ await this.publishStream(mediaStream);
13911
+ }
13603
13912
  }
13604
13913
  }
13605
13914
  getDevices() {
@@ -13618,7 +13927,10 @@ class CameraManager extends DeviceManager {
13618
13927
  }
13619
13928
  return constraints;
13620
13929
  }
13621
- getStream(constraints) {
13930
+ async getStream(constraints) {
13931
+ // Ensure the call's media factory exists before capture so the resulting
13932
+ // track is owned by it (the WebRTC globals resolve to the live factory).
13933
+ await this.call.ensureMediaFactory();
13622
13934
  return getVideoStream(constraints, this.call.tracer);
13623
13935
  }
13624
13936
  }
@@ -13634,6 +13946,9 @@ class AudioDeviceManager extends DeviceManager {
13634
13946
  if (!this.call.state.settings?.audio.hifi_audio_enabled) {
13635
13947
  throw new Error('High Fidelity audio is not enabled for this call');
13636
13948
  }
13949
+ if (isReactNative() && this.call.hasMediaEngine) {
13950
+ throw new Error('setAudioBitrateProfile must be called before joining the call.');
13951
+ }
13637
13952
  this.doSetAudioBitrateProfile(profile);
13638
13953
  this.state.setAudioBitrateProfile(profile);
13639
13954
  if (this.enabled) {
@@ -13906,7 +14221,10 @@ class MicrophoneManager extends AudioDeviceManager {
13906
14221
  ]), async ([callingState, ownCapabilities, deviceId, status, permissionState,]) => {
13907
14222
  try {
13908
14223
  if (callingState === CallingState.LEFT) {
13909
- this.setMutedRecordingPrepared(false);
14224
+ // The muted-recording-prepared mode is reset in `callManager.stop()`
14225
+ // (during leave, while the call factory is still alive), not here —
14226
+ // this subscription fires asynchronously and could land after the
14227
+ // factory is disposed, forcing a default-ADM rebuild.
13910
14228
  await this.stopSpeakingWhileMutedDetection();
13911
14229
  }
13912
14230
  if (callingState !== CallingState.JOINED)
@@ -14006,6 +14324,33 @@ class MicrophoneManager extends AudioDeviceManager {
14006
14324
  this.subscriptions.push(unsubscribe);
14007
14325
  }
14008
14326
  }
14327
+ enable() {
14328
+ if (isReactNative() && !this.call.hasMediaEngine) {
14329
+ this.state.setPendingStatus('enabled');
14330
+ return Promise.resolve();
14331
+ }
14332
+ return super.enable();
14333
+ }
14334
+ async disable(forceStopOrOptions) {
14335
+ if (isReactNative() && !this.call.hasMediaEngine) {
14336
+ this.state.setPendingStatus('disabled');
14337
+ return;
14338
+ }
14339
+ // forward verbatim to the base, narrowing so the right overload is selected
14340
+ if (forceStopOrOptions === undefined)
14341
+ return super.disable();
14342
+ if (typeof forceStopOrOptions === 'boolean') {
14343
+ return super.disable(forceStopOrOptions);
14344
+ }
14345
+ return super.disable(forceStopOrOptions);
14346
+ }
14347
+ toggle() {
14348
+ if (isReactNative() && !this.call.hasMediaEngine) {
14349
+ this.state.setPendingStatus(this.state.optimisticStatus === 'enabled' ? 'disabled' : 'enabled');
14350
+ return Promise.resolve();
14351
+ }
14352
+ return super.toggle();
14353
+ }
14009
14354
  /**
14010
14355
  * Enables noise cancellation for the microphone.
14011
14356
  *
@@ -14145,8 +14490,9 @@ class MicrophoneManager extends AudioDeviceManager {
14145
14490
  * Applies the audio settings to the microphone.
14146
14491
  * @param settings the audio settings to apply.
14147
14492
  * @param publish whether to publish the stream after applying the settings.
14493
+ * @param forceDisabled whether to keep the mic disabled, regardless of server-side or local preferences.
14148
14494
  */
14149
- async apply(settings, publish) {
14495
+ async apply(settings, publish, forceDisabled = false) {
14150
14496
  // Wait for any in progress mic operation
14151
14497
  await this.statusChangeSettled();
14152
14498
  // apply server-side settings only when the device state is pristine
@@ -14158,23 +14504,33 @@ class MicrophoneManager extends AudioDeviceManager {
14158
14504
  if (shouldApplyDefaults &&
14159
14505
  this.devicePersistence.enabled &&
14160
14506
  permissionState === 'granted') {
14161
- persistedPreferencesApplied = await this.applyPersistedPreferences(true);
14507
+ persistedPreferencesApplied = await this.applyPersistedPreferences(true, forceDisabled);
14162
14508
  }
14163
14509
  const canPublish = this.call.permissionsContext.canPublish(this.trackType);
14164
14510
  if (shouldApplyDefaults && !persistedPreferencesApplied) {
14165
- if (canPublish && settings.mic_default_on) {
14511
+ if (!forceDisabled && canPublish && settings.mic_default_on) {
14166
14512
  await this.enable();
14167
14513
  }
14168
14514
  }
14169
- const { mediaStream } = this.state;
14170
- if (canPublish && publish && this.enabled && mediaStream) {
14171
- await this.publishStream(mediaStream);
14515
+ if (isReactNative() && publish && canPublish) {
14516
+ // On RN the microphone is enabled/disabled optimistically before JOINED. Reconcile now
14517
+ // acquires the track and publishes it, so it fully owns the publish.
14518
+ await this.reconcileOptimisticStatus();
14519
+ }
14520
+ else {
14521
+ const { mediaStream } = this.state;
14522
+ if (canPublish && publish && this.enabled && mediaStream) {
14523
+ await this.publishStream(mediaStream);
14524
+ }
14172
14525
  }
14173
14526
  }
14174
14527
  getDevices() {
14175
14528
  return getAudioDevices(this.call.tracer);
14176
14529
  }
14177
- getStream(constraints) {
14530
+ async getStream(constraints) {
14531
+ // Ensure the call's media factory exists before capture so the resulting
14532
+ // track is owned by it (the WebRTC globals resolve to the live factory).
14533
+ await this.call.ensureMediaFactory();
14178
14534
  return getAudioStream(constraints, this.call.tracer);
14179
14535
  }
14180
14536
  doSetAudioBitrateProfile(profile) {
@@ -14383,6 +14739,9 @@ class ScreenShareManager extends AudioDeviceManager {
14383
14739
  if (!this.state.audioEnabled) {
14384
14740
  constraints.audio = false;
14385
14741
  }
14742
+ // Ensure the call's media factory exists before capture so the resulting
14743
+ // track is owned by it (the WebRTC globals resolve to the live factory).
14744
+ await this.call.ensureMediaFactory();
14386
14745
  const stream = await getScreenShareStream(constraints, this.call.tracer);
14387
14746
  const [track] = stream.getVideoTracks();
14388
14747
  const { contentHint } = this.state.settings || {};
@@ -14487,10 +14846,14 @@ class SpeakerManager {
14487
14846
  this.devicePersistence = devicePreferences;
14488
14847
  this.setup();
14489
14848
  }
14490
- apply(settings) {
14491
- return isReactNative() ? this.applyRN(settings) : this.applyWeb();
14849
+ async apply(settings) {
14850
+ if (isReactNative()) {
14851
+ this.applyRN(settings);
14852
+ return;
14853
+ }
14854
+ await this.applyWeb();
14492
14855
  }
14493
- applyWeb() {
14856
+ async applyWeb() {
14494
14857
  const { enabled, storageKey } = this.devicePersistence;
14495
14858
  if (!enabled)
14496
14859
  return;
@@ -14502,8 +14865,22 @@ class SpeakerManager {
14502
14865
  const nextDeviceId = preference.selectedDeviceId === defaultDeviceId
14503
14866
  ? ''
14504
14867
  : preference.selectedDeviceId;
14505
- if (this.state.selectedDevice !== nextDeviceId) {
14506
- this.select(nextDeviceId);
14868
+ if (!nextDeviceId) {
14869
+ if (this.state.selectedDevice !== nextDeviceId) {
14870
+ this.select(nextDeviceId);
14871
+ }
14872
+ return;
14873
+ }
14874
+ const permissionState = await firstValueFrom(getAudioBrowserPermission(this.call.tracer).asStateObservable());
14875
+ if (permissionState !== 'granted')
14876
+ return;
14877
+ const devices = await firstValueFrom(this.listDevices());
14878
+ const device = this.findDevice(devices, nextDeviceId) ??
14879
+ (preference.selectedDeviceLabel
14880
+ ? devices.find((d) => d.label === preference.selectedDeviceLabel)
14881
+ : undefined);
14882
+ if (device && this.state.selectedDevice !== device.deviceId) {
14883
+ this.select(device.deviceId);
14507
14884
  }
14508
14885
  }
14509
14886
  applyRN(settings) {
@@ -14532,6 +14909,7 @@ class SpeakerManager {
14532
14909
  globalThis.streamRNVideoSDK?.callManager.setup({
14533
14910
  defaultDevice,
14534
14911
  isRingingTypeCall: this.call.ringing,
14912
+ cid: this.call.cid,
14535
14913
  });
14536
14914
  }
14537
14915
  }
@@ -14540,11 +14918,15 @@ class SpeakerManager {
14540
14918
  return;
14541
14919
  this.areSubscriptionsSetUp = true;
14542
14920
  if (deviceIds$ && !isReactNative()) {
14543
- this.subscriptions.push(createSubscription(combineLatest([deviceIds$, this.state.selectedDevice$]), ([devices, deviceId]) => {
14921
+ this.subscriptions.push(createSubscription(combineLatest([
14922
+ deviceIds$.pipe(pairwise()),
14923
+ this.state.selectedDevice$,
14924
+ ]), ([[prevDevices, currentDevices], deviceId]) => {
14544
14925
  if (!deviceId)
14545
14926
  return;
14546
- const device = devices.find((d) => d.deviceId === deviceId && d.kind === 'audiooutput');
14547
- if (!device)
14927
+ const isDisconnected = this.findDevice(prevDevices, deviceId) &&
14928
+ !this.findDevice(currentDevices, deviceId);
14929
+ if (isDisconnected)
14548
14930
  this.select('');
14549
14931
  }));
14550
14932
  }
@@ -14591,6 +14973,7 @@ class SpeakerManager {
14591
14973
  this.subscriptions.forEach((unsubscribe) => unsubscribe());
14592
14974
  this.subscriptions = [];
14593
14975
  this.areSubscriptionsSetUp = false;
14976
+ this.defaultDevice = undefined;
14594
14977
  };
14595
14978
  /**
14596
14979
  * Set the volume of the audio elements
@@ -14625,6 +15008,7 @@ class SpeakerManager {
14625
15008
  return { audioVolume: volume };
14626
15009
  });
14627
15010
  }
15011
+ findDevice = (devices, deviceId) => devices.find((d) => d.deviceId === deviceId && d.kind === 'audiooutput');
14628
15012
  persistSpeakerDevicePreference(selectedDevice) {
14629
15013
  const { storageKey } = this.devicePersistence;
14630
15014
  const devices = getCurrentValue(this.listDevices()) || [];
@@ -14707,6 +15091,7 @@ class Call {
14707
15091
  blockedAudioTracker;
14708
15092
  subscriber;
14709
15093
  publisher;
15094
+ e2eeManager;
14710
15095
  /**
14711
15096
  * Flag telling whether this call is a "ringing" call.
14712
15097
  */
@@ -14762,6 +15147,8 @@ class Call {
14762
15147
  allowOwnTracksLoopback = false;
14763
15148
  hasJoinedOnce = false;
14764
15149
  deviceSettingsAppliedOnce = false;
15150
+ callManagerStarted = false;
15151
+ leaveGeneration = 0;
14765
15152
  credentials;
14766
15153
  initialized = false;
14767
15154
  acceptRejectConcurrencyTag = Symbol('acceptRejectTag');
@@ -14780,6 +15167,13 @@ class Call {
14780
15167
  clientCapabilities = new Set([
14781
15168
  ClientCapability.SUBSCRIBER_VIDEO_PAUSE,
14782
15169
  ]);
15170
+ /**
15171
+ * The in-flight per-call media engine. On web/React this resolves to a thin
15172
+ * globals-backed engine (no provider registered); React Native registers a
15173
+ * provider that owns a per-call native factory.
15174
+ * @internal
15175
+ */
15176
+ mediaEnginePromise;
14783
15177
  /**
14784
15178
  * Constructs a new `Call` instance.
14785
15179
  *
@@ -15049,6 +15443,7 @@ class Call {
15049
15443
  if (callingState === CallingState.LEFT) {
15050
15444
  return;
15051
15445
  }
15446
+ this.leaveGeneration += 1;
15052
15447
  if (callingState === CallingState.JOINING) {
15053
15448
  const waitUntilCallJoined = () => {
15054
15449
  return new Promise((resolve) => {
@@ -15082,7 +15477,7 @@ class Call {
15082
15477
  }
15083
15478
  }
15084
15479
  }
15085
- globalThis.streamRNVideoSDK?.callingX?.endCall(this);
15480
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this, reason === 'ended' ? 'remote' : undefined);
15086
15481
  this.statsReporter?.stop();
15087
15482
  this.statsReporter = undefined;
15088
15483
  const leaveReason = message ?? reason ?? 'user is leaving the call';
@@ -15135,12 +15530,14 @@ class Call {
15135
15530
  this.clientStore.unregisterCall(this);
15136
15531
  globalThis.streamRNVideoSDK?.callManager.stop({
15137
15532
  isRingingTypeCall: this.ringing,
15533
+ shouldStopCallManager: this.callManagerStarted,
15138
15534
  });
15139
15535
  this.camera.dispose();
15140
15536
  this.microphone.dispose();
15141
15537
  this.screenShare.dispose();
15142
15538
  this.speaker.dispose();
15143
15539
  this.deviceSettingsAppliedOnce = false;
15540
+ this.callManagerStarted = false;
15144
15541
  const stopOnLeavePromises = [];
15145
15542
  if (this.camera.stopOnLeave) {
15146
15543
  stopOnLeavePromises.push(this.camera.disable(true));
@@ -15152,6 +15549,22 @@ class Call {
15152
15549
  stopOnLeavePromises.push(this.screenShare.disable(true));
15153
15550
  }
15154
15551
  await Promise.all(stopOnLeavePromises);
15552
+ // Dispose the per-call media engine last — after peer connections and
15553
+ // local tracks are gone — so the backing factory tears down with no
15554
+ // owned PCs/tracks. A fresh `join()` builds a new engine.
15555
+ if (this.mediaEnginePromise) {
15556
+ const enginePromise = this.mediaEnginePromise;
15557
+ this.mediaEnginePromise = undefined;
15558
+ this.logger.debug('Disposing per-call media factory');
15559
+ await enginePromise
15560
+ .then((engine) => {
15561
+ globalThis.streamRNVideoSDK?.callingX?.unwireAudioEngineSubscription();
15562
+ return engine.dispose();
15563
+ })
15564
+ .catch((err) => {
15565
+ this.logger.warn('Failed to dispose media engine', err);
15566
+ });
15567
+ }
15155
15568
  });
15156
15569
  };
15157
15570
  /**
@@ -15227,8 +15640,18 @@ class Call {
15227
15640
  // const calls = useCalls().filter((c) => c.ringing);
15228
15641
  const calls = this.clientStore.calls.filter((c) => c.cid !== this.cid);
15229
15642
  this.clientStore.setCalls([this, ...calls]);
15230
- const skipSpeakerApply = isReactNative();
15231
- await this.applyDeviceConfig(settings, false, skipSpeakerApply);
15643
+ await this.applyDeviceConfig(settings, { publish: false });
15644
+ };
15645
+ /**
15646
+ * Applies a backend-provided call state snapshot
15647
+ * (call, members, own capabilities) to this instance.
15648
+ *
15649
+ * @internal an internal method and should not be used outside the SDK.
15650
+ */
15651
+ updateFromCallStateResponse = (response) => {
15652
+ this.state.updateFromCallResponse(response.call);
15653
+ this.state.setMembers(response.members);
15654
+ this.state.setOwnCapabilities(response.own_capabilities);
15232
15655
  };
15233
15656
  /**
15234
15657
  * Loads the information about the call.
@@ -15241,9 +15664,7 @@ class Call {
15241
15664
  get = async (params) => {
15242
15665
  await this.setup();
15243
15666
  const response = await this.streamClient.get(this.streamClientBasePath, params);
15244
- this.state.updateFromCallResponse(response.call);
15245
- this.state.setMembers(response.members);
15246
- this.state.setOwnCapabilities(response.own_capabilities);
15667
+ this.updateFromCallStateResponse(response);
15247
15668
  if (params?.ring) {
15248
15669
  this.ringingSubject.next(true);
15249
15670
  }
@@ -15251,13 +15672,7 @@ class Call {
15251
15672
  this.watching = true;
15252
15673
  this.clientStore.registerOrUpdateCall(this);
15253
15674
  }
15254
- // Skip speaker setup on RN if ringing was requested or the call is already ringing
15255
- const skipSpeakerApply = isReactNative()
15256
- ? params?.ring === true
15257
- ? true
15258
- : this.ringing
15259
- : false;
15260
- await this.applyDeviceConfig(response.call.settings, false, skipSpeakerApply);
15675
+ await this.applyDeviceConfig(response.call.settings, { publish: false });
15261
15676
  return response;
15262
15677
  };
15263
15678
  /**
@@ -15268,9 +15683,7 @@ class Call {
15268
15683
  getOrCreate = async (data) => {
15269
15684
  await this.setup();
15270
15685
  const response = await this.streamClient.post(this.streamClientBasePath, data);
15271
- this.state.updateFromCallResponse(response.call);
15272
- this.state.setMembers(response.members);
15273
- this.state.setOwnCapabilities(response.own_capabilities);
15686
+ this.updateFromCallStateResponse(response);
15274
15687
  if (data?.ring) {
15275
15688
  this.ringingSubject.next(true);
15276
15689
  }
@@ -15278,13 +15691,7 @@ class Call {
15278
15691
  this.watching = true;
15279
15692
  this.clientStore.registerOrUpdateCall(this);
15280
15693
  }
15281
- // Skip speaker setup on RN if ringing was requested or the call is already ringing
15282
- const skipSpeakerApply = isReactNative()
15283
- ? data?.ring === true
15284
- ? true
15285
- : this.ringing
15286
- : false;
15287
- await this.applyDeviceConfig(response.call.settings, false, skipSpeakerApply);
15694
+ await this.applyDeviceConfig(response.call.settings, { publish: false });
15288
15695
  return response;
15289
15696
  };
15290
15697
  /**
@@ -15348,7 +15755,7 @@ class Call {
15348
15755
  *
15349
15756
  * @returns a promise which resolves once the call join-flow has finished.
15350
15757
  */
15351
- join = async ({ maxJoinRetries = 3, joinResponseTimeout, rpcRequestTimeout, allowOwnTracksLoopback = false, ...data } = {}) => {
15758
+ join = singleFlight(async ({ maxJoinRetries = 3, joinResponseTimeout, rpcRequestTimeout, allowOwnTracksLoopback = false, ...data } = {}) => {
15352
15759
  const callingState = this.state.callingState;
15353
15760
  if ([CallingState.JOINED, CallingState.JOINING].includes(callingState)) {
15354
15761
  throw new Error(`Illegal State: call.join() shall be called only once`);
@@ -15370,7 +15777,6 @@ class Call {
15370
15777
  callId: this.id,
15371
15778
  getCallSessionId: () => this.state.session?.id ?? '',
15372
15779
  getSfuId: () => this.credentials?.server.edge_name ?? '',
15373
- getUserSessionId: () => this.sfuClient?.sessionId ?? '',
15374
15780
  });
15375
15781
  this.joinResponseTimeout = joinResponseTimeout;
15376
15782
  this.rpcRequestTimeout = rpcRequestTimeout;
@@ -15422,7 +15828,7 @@ class Call {
15422
15828
  callingX?.endCall(this, 'error');
15423
15829
  throw error;
15424
15830
  }
15425
- };
15831
+ });
15426
15832
  /**
15427
15833
  * Will make a single attempt to watch for call related WebSocket events
15428
15834
  * and initiate a call session with the server.
@@ -15432,9 +15838,20 @@ class Call {
15432
15838
  doJoin = async (data) => {
15433
15839
  const connectStartTime = Date.now();
15434
15840
  const callingState = this.state.callingState;
15841
+ const joinLeaveGeneration = this.leaveGeneration;
15842
+ const supersededByLeave = () => this.leaveGeneration !== joinLeaveGeneration;
15435
15843
  this.joinCallData = data;
15436
15844
  this.logger.debug('Starting join flow');
15437
15845
  this.state.setCallingState(CallingState.JOINING);
15846
+ // Ensure the per-call media engine exists before any peer connection
15847
+ // (codec probe, subscriber, publisher) or capture happens, so the WebRTC
15848
+ // globals resolve to the call's factory. Idempotent across
15849
+ // reconnect/migration attempts.
15850
+ await this.ensureMediaFactory();
15851
+ const callingX = globalThis.streamRNVideoSDK?.callingX;
15852
+ if (callingX) {
15853
+ callingX.wireAudioEngineSubscription();
15854
+ }
15438
15855
  const performingMigration = this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
15439
15856
  const performingRejoin = this.reconnectStrategy === WebsocketReconnectStrategy.REJOIN;
15440
15857
  const performingFastReconnect = this.reconnectStrategy === WebsocketReconnectStrategy.FAST;
@@ -15489,6 +15906,11 @@ class Call {
15489
15906
  // these are throw-away SDPs that the SFU will use to determine
15490
15907
  // the capabilities of the client (codec support, etc.)
15491
15908
  const { dangerouslyForceCodec, fmtpLine, subscriberFmtpLine } = this.clientPublishOptions || {};
15909
+ // skip if a leave superseded this join so codec detection doesn't resolve to a default factory.
15910
+ if (supersededByLeave()) {
15911
+ this.logger.debug('Join superseded by leave; skipping codec detection');
15912
+ return;
15913
+ }
15492
15914
  const [subscriberSdp, publisherSdp] = await Promise.all([
15493
15915
  getGenericSdp('recvonly', dangerouslyForceCodec, subscriberFmtpLine),
15494
15916
  getGenericSdp('sendonly', dangerouslyForceCodec, fmtpLine),
@@ -15532,6 +15954,12 @@ class Call {
15532
15954
  throw error;
15533
15955
  }
15534
15956
  }
15957
+ // If the user left while this join was in flight, bail before re-setting JOINED and before
15958
+ // peer-connection setup below (both run synchronously after this, so one check covers them).
15959
+ if (supersededByLeave()) {
15960
+ this.logger.debug('Join superseded by leave; aborting join flow');
15961
+ return;
15962
+ }
15535
15963
  if (!performingMigration) {
15536
15964
  // in MIGRATION, `JOINED` state is set in `this.reconnectMigrate()`
15537
15965
  this.state.setCallingState(CallingState.JOINED);
@@ -15569,12 +15997,21 @@ class Call {
15569
15997
  }
15570
15998
  // device settings should be applied only once, we don't have to
15571
15999
  // re-apply them on later reconnections or server-side data fetches
15572
- if (!this.deviceSettingsAppliedOnce && this.state.settings) {
15573
- await this.applyDeviceConfig(this.state.settings, true, false);
16000
+ if (!this.deviceSettingsAppliedOnce &&
16001
+ this.state.settings &&
16002
+ !supersededByLeave()) {
16003
+ await this.applyDeviceConfig(this.state.settings, {
16004
+ publish: true,
16005
+ skipSpeakerApply: false,
16006
+ });
16007
+ this.deviceSettingsAppliedOnce = true;
16008
+ }
16009
+ if (!this.callManagerStarted && !supersededByLeave()) {
15574
16010
  globalThis.streamRNVideoSDK?.callManager.start({
15575
16011
  isRingingTypeCall: this.ringing,
16012
+ cid: this.cid,
15576
16013
  });
15577
- this.deviceSettingsAppliedOnce = true;
16014
+ this.callManagerStarted = true;
15578
16015
  }
15579
16016
  // We shouldn't persist the `ring` and `notify` state after joining the call
15580
16017
  // as it's a one-time event and clashes with the potential reconnection attempts.
@@ -15701,6 +16138,7 @@ class Call {
15701
16138
  this.sfuStatsReporter = undefined;
15702
16139
  if (closePreviousInstances && this.subscriber) {
15703
16140
  await this.subscriber.dispose();
16141
+ this.state.removeAllOrphanedTracks();
15704
16142
  }
15705
16143
  const basePeerConnectionOptions = {
15706
16144
  sfuClient,
@@ -15711,6 +16149,7 @@ class Call {
15711
16149
  enableTracing,
15712
16150
  statsTimestampDriftThresholdMs: reportingIntervalMs / 2,
15713
16151
  clientPublishOptions: this.clientPublishOptions,
16152
+ e2ee: this.e2eeManager,
15714
16153
  onReconnectionNeeded: (kind, reason, peerType) => {
15715
16154
  this.reconnect(kind, reason).catch((err) => {
15716
16155
  const message = `[Reconnect] Error reconnecting, after a ${PeerType[peerType]} error: ${reason}`;
@@ -15776,12 +16215,12 @@ class Call {
15776
16215
  * Retrieves credentials for joining the call.
15777
16216
  *
15778
16217
  * @internal
15779
- *
15780
16218
  * @param data the join call data.
15781
16219
  */
15782
16220
  doJoinRequest = async (data) => {
15783
16221
  const location = await this.streamClient.getLocationHint();
15784
- const request = { ...data, location };
16222
+ const e2ee = !!this.e2eeManager;
16223
+ const request = { ...data, location, e2ee };
15785
16224
  const joinResponse = await this.streamClient.post(`${this.streamClientBasePath}/join`, request);
15786
16225
  this.state.updateFromCallResponse(joinResponse.call);
15787
16226
  this.state.setMembers(joinResponse.members);
@@ -15800,6 +16239,36 @@ class Call {
15800
16239
  }
15801
16240
  return joinResponse;
15802
16241
  };
16242
+ /**
16243
+ * Whether the per-call media engine currently exists. True from join until leave.
16244
+ *
16245
+ * @internal an internal getter and should not be used outside the SDK.
16246
+ */
16247
+ get hasMediaEngine() {
16248
+ return !!this.mediaEnginePromise;
16249
+ }
16250
+ /**
16251
+ * Ensures a {@link CallMediaEngine} exists for this call's media session and
16252
+ * returns it. Idempotent: the engine is created once via the registered
16253
+ * provider (see `setCallMediaEngineProvider`) and cached until `leave()`
16254
+ * disposes it. Concurrent callers (e.g. camera + microphone enabling in
16255
+ * parallel) share the same engine because the in-flight creation promise is
16256
+ * cached, never the unresolved result.
16257
+ *
16258
+ * @internal
16259
+ */
16260
+ ensureMediaFactory = async () => {
16261
+ if (!this.mediaEnginePromise) {
16262
+ const provider = getCallMediaEngineProvider();
16263
+ this.logger.debug(`Requesting per-call media factory creation`);
16264
+ this.mediaEnginePromise = Promise.resolve(provider()).catch((err) => {
16265
+ // Drop the cached rejection so a retried join() can rebuild the engine
16266
+ this.mediaEnginePromise = undefined;
16267
+ throw err;
16268
+ });
16269
+ }
16270
+ return this.mediaEnginePromise;
16271
+ };
15803
16272
  /**
15804
16273
  * Handles the closing of the SFU signal connection.
15805
16274
  *
@@ -16324,12 +16793,16 @@ class Call {
16324
16793
  const streamStateProp = trackTypeToParticipantStreamKey(trackType);
16325
16794
  if (!streamStateProp)
16326
16795
  continue;
16327
- this.state.updateParticipant(sessionId, (p) => ({
16328
- publishedTracks: mediaStream
16329
- ? pushToIfMissing([...p.publishedTracks], trackType)
16330
- : p.publishedTracks.filter((t) => t !== trackType),
16331
- [streamStateProp]: mediaStream,
16332
- }));
16796
+ this.state.updateParticipant(sessionId, (p) => {
16797
+ const currentStream = p[streamStateProp];
16798
+ const isEchoedStream = this.subscriber?.isSelfSubscribedStream(currentStream);
16799
+ return {
16800
+ publishedTracks: mediaStream
16801
+ ? pushToIfMissing([...p.publishedTracks], trackType)
16802
+ : p.publishedTracks.filter((t) => t !== trackType),
16803
+ [streamStateProp]: isEchoedStream ? currentStream : mediaStream,
16804
+ };
16805
+ });
16333
16806
  }
16334
16807
  };
16335
16808
  /**
@@ -16361,6 +16834,30 @@ class Call {
16361
16834
  this.tracer.trace('updatePublishOptions', options);
16362
16835
  this.clientPublishOptions = { ...this.clientPublishOptions, ...options };
16363
16836
  };
16837
+ /**
16838
+ * Set the E2EE (end-to-end encryption) manager for this call.
16839
+ *
16840
+ * Must be called before {@link join} so the RTCPeerConnection can be
16841
+ * configured for E2EE.
16842
+ *
16843
+ * The manager is kept across {@link leave} so a rejoin of this same instance
16844
+ * stays encrypted: do not dispose it while this call may be joined again.
16845
+ * A disposed manager throws from `encrypt`/`decrypt` rather than silently
16846
+ * publishing nothing, so re-attach a fresh one instead of reusing it.
16847
+ *
16848
+ * @param e2ee - Any `E2EEManager`. Use `EncryptionManager.create()` for the
16849
+ * built-in AES-GCM scheme, or pass your own implementation.
16850
+ * @throws if called after the peer connections have been built (i.e. after
16851
+ * `join`): those PCs were already configured without an encryptor, so
16852
+ * adopting a manager now would silently publish/receive cleartext for
16853
+ * the live session.
16854
+ */
16855
+ setE2EEManager = (e2ee) => {
16856
+ if (this.publisher || this.subscriber) {
16857
+ throw new Error('setE2EEManager must be called before join()');
16858
+ }
16859
+ this.e2eeManager = e2ee;
16860
+ };
16364
16861
  /**
16365
16862
  * Notifies the SFU that a noise cancellation process has started.
16366
16863
  *
@@ -16995,16 +17492,21 @@ class Call {
16995
17492
  *
16996
17493
  * @internal
16997
17494
  */
16998
- applyDeviceConfig = async (settings, publish, skipSpeakerApply) => {
17495
+ applyDeviceConfig = async (settings, opts) => {
17496
+ const { publish,
17497
+ // Skip speaker setup on RN until joining (explicitly passed as false during join())
17498
+ skipSpeakerApply = isReactNative(), withDisabledDevices, } = opts;
16999
17499
  if (!skipSpeakerApply) {
17000
- this.speaker.apply(settings);
17500
+ await this.speaker
17501
+ .apply(settings)
17502
+ .catch((err) => this.logger.warn('Speaker init failed', err));
17001
17503
  }
17002
- await this.camera.apply(settings.video, publish).catch((err) => {
17003
- this.logger.warn('Camera init failed', err);
17004
- });
17005
- await this.microphone.apply(settings.audio, publish).catch((err) => {
17006
- this.logger.warn('Mic init failed', err);
17007
- });
17504
+ await this.camera
17505
+ .apply(settings.video, publish, withDisabledDevices)
17506
+ .catch((err) => this.logger.warn('Camera init failed', err));
17507
+ await this.microphone
17508
+ .apply(settings.audio, publish, withDisabledDevices)
17509
+ .catch((err) => this.logger.warn('Mic init failed', err));
17008
17510
  };
17009
17511
  /**
17010
17512
  * Will begin tracking the given element for visibility changes within the
@@ -17678,15 +18180,17 @@ class StableWSConnection {
17678
18180
  this.client.logger.error(`Token not set, can't connect authenticate`);
17679
18181
  return;
17680
18182
  }
17681
- const authMessage = JSON.stringify({
18183
+ const wsAuthMessage = {
17682
18184
  token,
18185
+ products: [WSAuthMessageProductsEnum.VIDEO],
17683
18186
  user_details: {
17684
18187
  id: user.id,
17685
18188
  name: user.name,
17686
18189
  image: user.image,
17687
18190
  custom: user.custom,
17688
18191
  },
17689
- });
18192
+ };
18193
+ const authMessage = JSON.stringify(wsAuthMessage);
17690
18194
  this._log(`onopen() - Sending auth message ${authMessage}`, {}, 'trace');
17691
18195
  this.ws?.send(authMessage);
17692
18196
  this._log('onopen() - onopen callback', { wsID });
@@ -18512,11 +19016,11 @@ class StreamClient {
18512
19016
  return await this.wsConnection.connect(this.defaultWSTimeout);
18513
19017
  };
18514
19018
  getSdkVersion = () => this.options.clientAppIdentifier?.sdkVersion ||
18515
- "1.57.0";
19019
+ "1.59.0";
18516
19020
  getUserAgent = () => {
18517
19021
  if (!this.cachedUserAgent) {
18518
19022
  const { clientAppIdentifier = {} } = this.options;
18519
- const { sdkName = 'js', sdkVersion = "1.57.0", ...extras } = clientAppIdentifier;
19023
+ const { sdkName = 'js', sdkVersion = "1.59.0", ...extras } = clientAppIdentifier;
18520
19024
  this.cachedUserAgent = [
18521
19025
  `stream-video-${sdkName}-v${sdkVersion}`,
18522
19026
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
@@ -18879,7 +19383,6 @@ class ClientEventReporter {
18879
19383
  user_id: this.streamClient.userID || this.coordinatorConnectUserId,
18880
19384
  type: ctx?.callType,
18881
19385
  id: ctx?.callId,
18882
- call_cid: cid,
18883
19386
  stage: 'JoinInitiated',
18884
19387
  join_attempt_id: joinAttemptId,
18885
19388
  ...(coordinatorConnectId && {
@@ -19061,7 +19564,6 @@ class ClientEventReporter {
19061
19564
  startedAt: Date.now(),
19062
19565
  joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
19063
19566
  sfuId: this.getSfuId(cid),
19064
- userSessionId: this.getUserSessionId(cid),
19065
19567
  wasPreviouslyConnected: this.pcEverConnected.get(key) === true,
19066
19568
  lastIceState: iceConnectionState,
19067
19569
  };
@@ -19072,9 +19574,6 @@ class ClientEventReporter {
19072
19574
  peer_connection: role,
19073
19575
  was_previously_connected: pair.wasPreviouslyConnected,
19074
19576
  ...(pair.sfuId && { sfu_id: pair.sfuId }),
19075
- ...(pair.userSessionId && {
19076
- user_session_id: pair.userSessionId,
19077
- }),
19078
19577
  event_type: 'initiated',
19079
19578
  });
19080
19579
  };
@@ -19089,9 +19588,6 @@ class ClientEventReporter {
19089
19588
  peer_connection: role,
19090
19589
  was_previously_connected: pair.wasPreviouslyConnected,
19091
19590
  ...(pair.sfuId && { sfu_id: pair.sfuId }),
19092
- ...(pair.userSessionId && {
19093
- user_session_id: pair.userSessionId,
19094
- }),
19095
19591
  event_type: 'completed',
19096
19592
  outcome: 'success',
19097
19593
  retry_count_attempt: 0,
@@ -19109,9 +19605,6 @@ class ClientEventReporter {
19109
19605
  ...this.sessionIdField(cid),
19110
19606
  peer_connection: role,
19111
19607
  was_previously_connected: pair.wasPreviouslyConnected,
19112
- ...(pair.userSessionId && {
19113
- user_session_id: pair.userSessionId,
19114
- }),
19115
19608
  ...(pair.sfuId && { sfu_id: pair.sfuId }),
19116
19609
  event_type: 'completed',
19117
19610
  outcome: 'failure',
@@ -19124,7 +19617,6 @@ class ClientEventReporter {
19124
19617
  this.peerConnectionPairs.delete(key);
19125
19618
  };
19126
19619
  getSfuId = (cid) => this.callContexts.get(cid)?.getSfuId() ?? '';
19127
- getUserSessionId = (cid) => this.callContexts.get(cid)?.getUserSessionId() ?? '';
19128
19620
  sessionIdField = (cid) => {
19129
19621
  const callSessionId = this.callContexts.get(cid)?.getCallSessionId() ?? '';
19130
19622
  return callSessionId ? { call_session_id: callSessionId } : {};
@@ -19136,7 +19628,6 @@ class ClientEventReporter {
19136
19628
  user_id: this.streamClient.userID || this.coordinatorConnectUserId,
19137
19629
  type: ctx?.callType ?? '',
19138
19630
  id: ctx?.callId ?? '',
19139
- call_cid: cid,
19140
19631
  stage,
19141
19632
  stage_id: pair.sid,
19142
19633
  ...(pair.joinAttemptIdSnapshot && {
@@ -19387,6 +19878,7 @@ class StreamVideoClient {
19387
19878
  }
19388
19879
  else {
19389
19880
  await call.updateFromRingingEvent(e);
19881
+ await call.get();
19390
19882
  }
19391
19883
  }
19392
19884
  else {
@@ -19425,6 +19917,13 @@ class StreamVideoClient {
19425
19917
  this.logger.error(`Failed to init call from event ${e.type}`, err);
19426
19918
  }
19427
19919
  };
19920
+ /**
19921
+ * Queries the API for calls matching the given filters.
19922
+ * @param data the query data.
19923
+ */
19924
+ doQueryCalls = (data) => {
19925
+ return this.streamClient.post('/calls', data);
19926
+ };
19428
19927
  /**
19429
19928
  * Rewatches the given calls with retry logic.
19430
19929
  * @param callsToReWatch array of call IDs to rewatch
@@ -19435,10 +19934,16 @@ class StreamVideoClient {
19435
19934
  for (let attempt = 0; attempt < maxRetries; attempt++) {
19436
19935
  try {
19437
19936
  this.logger.info(`Rewatching calls ${callsToReWatch.join(', ')} attempt ${attempt + 1}`);
19438
- await this.queryCalls({
19937
+ const response = await this.doQueryCalls({
19439
19938
  watch: true,
19440
19939
  filter_conditions: { cid: { $in: callsToReWatch } },
19441
19940
  });
19941
+ for (const c of response.calls) {
19942
+ const call = this.writeableStateStore.findCall(c.call.type, c.call.id);
19943
+ if (call) {
19944
+ call.updateFromCallStateResponse(c);
19945
+ }
19946
+ }
19442
19947
  return;
19443
19948
  }
19444
19949
  catch (err) {
@@ -19581,9 +20086,11 @@ class StreamVideoClient {
19581
20086
  * Will query the API for calls matching the given filters.
19582
20087
  *
19583
20088
  * @param data the query data.
20089
+ * @param opts additional options, for tweaking the API behavior.
19584
20090
  */
19585
- queryCalls = async (data = {}) => {
19586
- const response = await this.streamClient.post('/calls', data);
20091
+ queryCalls = async (data = {}, opts = {}) => {
20092
+ const { withDisabledDevices = true } = opts;
20093
+ const response = await this.doQueryCalls(data);
19587
20094
  const calls = [];
19588
20095
  for (const c of response.calls) {
19589
20096
  const call = new Call({
@@ -19597,7 +20104,10 @@ class StreamVideoClient {
19597
20104
  clientStore: this.writeableStateStore,
19598
20105
  });
19599
20106
  call.state.updateFromCallResponse(c.call);
19600
- await call.applyDeviceConfig(c.call.settings, false, isReactNative());
20107
+ await call.applyDeviceConfig(c.call.settings, {
20108
+ publish: false,
20109
+ withDisabledDevices,
20110
+ });
19601
20111
  if (data.watch) {
19602
20112
  await call.setup();
19603
20113
  this.writeableStateStore.registerCall(call);
@@ -19749,5 +20259,519 @@ const humanize = (n) => {
19749
20259
  return String(n);
19750
20260
  };
19751
20261
 
19752
- export { AudioSettingsRequestDefaultDeviceEnum, AudioSettingsResponseDefaultDeviceEnum, browsers as Browsers, Call, CallRecordingFailedEventRecordingTypeEnum, CallRecordingReadyEventRecordingTypeEnum, CallRecordingStartedEventRecordingTypeEnum, CallRecordingStoppedEventRecordingTypeEnum, CallState, CallType, CallTypes, CallingState, CameraManager, CameraManagerState, CreateDeviceRequestPushProviderEnum, DebounceType, DeviceManager, DeviceManagerState, DynascaleManager, ErrorFromResponse, FrameRecordingSettingsRequestModeEnum, FrameRecordingSettingsRequestQualityEnum, FrameRecordingSettingsResponseModeEnum, IndividualRecordingSettingsRequestModeEnum, IndividualRecordingSettingsResponseModeEnum, IngressAudioEncodingOptionsRequestChannelsEnum, IngressSourceRequestFpsEnum, IngressVideoLayerRequestCodecEnum, LayoutSettingsRequestNameEnum, MicrophoneManager, MicrophoneManagerState, NoiseCancellationSettingsModeEnum, OwnCapability, RTMPBroadcastRequestQualityEnum, RTMPSettingsRequestQualityEnum, RawRecordingSettingsRequestModeEnum, RawRecordingSettingsResponseModeEnum, RecordSettingsRequestModeEnum, RecordSettingsRequestQualityEnum, rxUtils as RxUtils, ScreenShareManager, ScreenShareState, events as SfuEvents, SfuJoinError, models as SfuModels, SfuTimeoutError, SpeakerManager, SpeakerState, StartClosedCaptionsRequestLanguageEnum, StartTranscriptionRequestLanguageEnum, StreamSfuClient, StreamVideoClient, StreamVideoReadOnlyStateStore, StreamVideoWriteableStateStore, TranscriptionSettingsRequestClosedCaptionModeEnum, TranscriptionSettingsRequestLanguageEnum, TranscriptionSettingsRequestModeEnum, TranscriptionSettingsResponseClosedCaptionModeEnum, TranscriptionSettingsResponseLanguageEnum, TranscriptionSettingsResponseModeEnum, VideoSettingsRequestCameraFacingEnum, VideoSettingsResponseCameraFacingEnum, ViewportTracker, VisibilityState, checkIfAudioOutputChangeSupported, combineComparators, conditional, createSoundDetector, defaultSortPreset, descending, deviceIds$, disposeOfMediaStream, dominantSpeaker, getAudioBrowserPermission, getAudioDevices, getAudioOutputDevices, getAudioStream, getClientDetails, getDeviceState, getScreenShareStream, getSdkInfo, getVideoBrowserPermission, getVideoDevices, getVideoStream, getWebRTCInfo, hasAudio$1 as hasAudio, hasInterruptedTrack, hasPausedTrack, hasScreenShare, hasScreenShareAudio, hasVideo, humanize, isPinned, livestreamOrAudioRoomSortPreset, logToConsole, name, noopComparator, paginatedLayoutSortPreset, pinned, publishingAudio, publishingVideo, reactionType, resolveDeviceId, role, screenSharing, setDeviceInfo, setOSInfo, setPowerState, setSdkInfo, setThermalState, setWebRTCInfo, speakerLayoutSortPreset, speaking, videoLoggerSystem, withParticipantSource };
20262
+ /** Default loopback stream wait timeout. */
20263
+ const LOOPBACK_STREAMS_WAIT_TIMEOUT_MS = 10 * 1000;
20264
+ /** Default loopback recording duration. */
20265
+ const DEFAULT_LOOPBACK_RECORDING_DURATION_MS = 10 * 1000;
20266
+ /** Shortest permitted loopback recording duration. */
20267
+ const MIN_LOOPBACK_RECORDING_DURATION_MS = 5 * 1000;
20268
+ /** Longest permitted loopback recording duration. */
20269
+ const MAX_LOOPBACK_RECORDING_DURATION_MS = 2 * 60 * 1000;
20270
+ /** Thrown when loopback tracks do not arrive in time. */
20271
+ class LoopbackStreamsTimeoutError extends Error {
20272
+ constructor() {
20273
+ super('Timed out waiting for loopback streams. Ensure the call was joined ' +
20274
+ 'with `allowOwnTracksLoopback: true` and that the SFU is configured ' +
20275
+ 'to echo self-sub tracks.');
20276
+ this.name = 'LoopbackStreamsTimeoutError';
20277
+ }
20278
+ }
20279
+ /**
20280
+ * Returns the streams on the given participant that the SFU echoed back to us,
20281
+ * ignoring the local capture streams the publishing path writes to the same
20282
+ * fields.
20283
+ */
20284
+ const getLoopbackStreams = (call, participant) => {
20285
+ const { subscriber } = call ?? {};
20286
+ const { audioStream, videoStream } = participant ?? {};
20287
+ return {
20288
+ loopbackVideoStream: subscriber?.isSelfSubscribedStream(videoStream)
20289
+ ? videoStream
20290
+ : undefined,
20291
+ loopbackAudioStream: subscriber?.isSelfSubscribedStream(audioStream)
20292
+ ? audioStream
20293
+ : undefined,
20294
+ };
20295
+ };
20296
+ /**
20297
+ * Returns loopback tracks when all required tracks are present.
20298
+ *
20299
+ * Prefer {@link waitForLoopbackStreams}, which observes call state until the
20300
+ * tracks arrive. This is the synchronous snapshot it is built from.
20301
+ *
20302
+ * @internal
20303
+ */
20304
+ const getLoopbackTracks = (call, participant, includeVideo) => {
20305
+ if (!participant)
20306
+ return undefined;
20307
+ const { loopbackAudioStream, loopbackVideoStream } = getLoopbackStreams(call, participant);
20308
+ const audioTrack = loopbackAudioStream?.getAudioTracks()[0];
20309
+ const videoTrack = includeVideo
20310
+ ? loopbackVideoStream?.getVideoTracks()[0]
20311
+ : undefined;
20312
+ if (!audioTrack || (includeVideo && !videoTrack)) {
20313
+ return undefined;
20314
+ }
20315
+ return { audioTrack, videoTrack };
20316
+ };
20317
+ /** Waits for the requested loopback tracks. */
20318
+ const waitForLoopbackStreams = (call, opts) => {
20319
+ const callState = call.state;
20320
+ const { includeVideo, signal, timeoutMs = LOOPBACK_STREAMS_WAIT_TIMEOUT_MS, } = opts;
20321
+ return new Promise((resolve, reject) => {
20322
+ const initial = getLoopbackTracks(call, callState.localParticipant, includeVideo);
20323
+ if (initial) {
20324
+ resolve(initial);
20325
+ return;
20326
+ }
20327
+ if (signal.aborted) {
20328
+ resolve(null);
20329
+ return;
20330
+ }
20331
+ let settled = false;
20332
+ const subscriptions = new Subscription();
20333
+ const cleanup = () => {
20334
+ if (settled)
20335
+ return;
20336
+ settled = true;
20337
+ clearTimeout(timeoutId);
20338
+ signal.removeEventListener('abort', onAbort);
20339
+ subscriptions.unsubscribe();
20340
+ };
20341
+ const onAbort = () => {
20342
+ cleanup();
20343
+ resolve(null);
20344
+ };
20345
+ const timeoutId = setTimeout(() => {
20346
+ cleanup();
20347
+ reject(new LoopbackStreamsTimeoutError());
20348
+ }, timeoutMs);
20349
+ signal.addEventListener('abort', onAbort);
20350
+ subscriptions.add(callState.localParticipant$.subscribe((participant) => {
20351
+ const ready = getLoopbackTracks(call, participant, includeVideo);
20352
+ if (ready) {
20353
+ cleanup();
20354
+ resolve(ready);
20355
+ }
20356
+ }));
20357
+ });
20358
+ };
20359
+ /** Enables loopback audio while `fn` runs, then restores it. */
20360
+ const withLoopbackAudioEnabled = async (tracks, fn) => {
20361
+ const restore = enableLoopbackAudio(tracks);
20362
+ try {
20363
+ return await fn();
20364
+ }
20365
+ finally {
20366
+ restore();
20367
+ }
20368
+ };
20369
+ /** Enables loopback audio and returns a restore function. */
20370
+ const enableLoopbackAudio = (tracks) => {
20371
+ const { audioTrack } = tracks;
20372
+ const previous = audioTrack?.enabled ?? false;
20373
+ if (audioTrack) {
20374
+ audioTrack.enabled = true;
20375
+ }
20376
+ return () => {
20377
+ if (audioTrack) {
20378
+ audioTrack.enabled = previous;
20379
+ }
20380
+ };
20381
+ };
20382
+ /** Clamps and rounds a loopback recording duration. */
20383
+ const clampLoopbackRecordingDuration = (durationMs) => Math.round(Math.min(MAX_LOOPBACK_RECORDING_DURATION_MS, Math.max(MIN_LOOPBACK_RECORDING_DURATION_MS, durationMs)));
20384
+
20385
+ /** Single-use `MediaRecorder` wrapper that returns an in-memory blob. */
20386
+ class MediaStreamRecorder {
20387
+ recording = promiseWithResolvers();
20388
+ logger = videoLoggerSystem.getLogger('MediaStreamRecorder');
20389
+ chunks = [];
20390
+ timeoutId;
20391
+ recorder;
20392
+ tracks;
20393
+ maxDurationMs;
20394
+ constructor(stream, options = {}) {
20395
+ if (typeof MediaRecorder === 'undefined') {
20396
+ throw new Error('MediaRecorder is not available in this environment');
20397
+ }
20398
+ this.tracks = stream.getTracks();
20399
+ if (this.tracks.length === 0) {
20400
+ throw new Error('cannot record a MediaStream that has no tracks');
20401
+ }
20402
+ this.maxDurationMs = options.maxDurationMs;
20403
+ this.recorder = new MediaRecorder(new MediaStream(this.tracks));
20404
+ this.recorder.ondataavailable = (event) => {
20405
+ if (event.data?.size)
20406
+ this.chunks.push(event.data);
20407
+ };
20408
+ this.recorder.onstop = this.handleStop;
20409
+ this.recorder.onerror = this.handleError;
20410
+ }
20411
+ /** Starts recording and resolves when recording stops. */
20412
+ start = () => {
20413
+ if (this.isSettled()) {
20414
+ throw new Error('MediaStreamRecorder is single-use: this instance has already ' +
20415
+ 'recorded. Construct a new one.');
20416
+ }
20417
+ if (this.recorder.state === 'inactive') {
20418
+ this.tracks.forEach((track) => track.addEventListener('ended', this.stop));
20419
+ if (this.maxDurationMs !== undefined) {
20420
+ this.timeoutId = setTimeout(this.stop, this.maxDurationMs);
20421
+ }
20422
+ this.recorder.start();
20423
+ }
20424
+ return this.recording.promise;
20425
+ };
20426
+ /** Stops recording early. */
20427
+ stop = () => {
20428
+ if (this.recorder.state !== 'inactive')
20429
+ this.recorder.stop();
20430
+ };
20431
+ isSettled = () => this.recording.isResolved() || this.recording.isRejected();
20432
+ detach = () => {
20433
+ clearTimeout(this.timeoutId);
20434
+ this.tracks.forEach((track) => track.removeEventListener('ended', this.stop));
20435
+ };
20436
+ handleStop = () => {
20437
+ this.detach();
20438
+ if (this.chunks.length === 0) {
20439
+ this.logger.warn('MediaRecorder recording produced no data');
20440
+ this.recording.resolve(null);
20441
+ return;
20442
+ }
20443
+ const mimeType = this.chunks[0].type || this.recorder.mimeType;
20444
+ this.recording.resolve({
20445
+ blob: new Blob(this.chunks, { type: mimeType }),
20446
+ });
20447
+ };
20448
+ handleError = (event) => {
20449
+ if (this.isSettled())
20450
+ return;
20451
+ this.detach();
20452
+ const error = event.error;
20453
+ this.logger.error('MediaRecorder error', error);
20454
+ this.recording.reject(error ?? new Error('MediaRecorder failed'));
20455
+ };
20456
+ }
20457
+
20458
+ /**
20459
+ * Tiny, type-safe event emitter.
20460
+ *
20461
+ * Usage styles:
20462
+ * - compose: `private readonly events = new TypedEventEmitter<MyMap>()`
20463
+ * - extend: `class Foo extends TypedEventEmitter<MyMap> {}`
20464
+ *
20465
+ * Listener exceptions (sync throw or rejected promise) are caught and logged,
20466
+ * so one bad listener cannot break dispatch for the rest.
20467
+ */
20468
+ class TypedEventEmitter {
20469
+ logger;
20470
+ byEvent = new Map();
20471
+ constructor(logger = 'TypedEventEmitter') {
20472
+ this.logger =
20473
+ typeof logger === 'string' ? videoLoggerSystem.getLogger(logger) : logger;
20474
+ }
20475
+ on = (event, fn) => {
20476
+ let listeners = this.byEvent.get(event);
20477
+ if (!listeners) {
20478
+ listeners = new Set();
20479
+ this.byEvent.set(event, listeners);
20480
+ }
20481
+ listeners.add(fn);
20482
+ return () => this.off(event, fn);
20483
+ };
20484
+ off = (event, fn) => {
20485
+ const listeners = this.byEvent.get(event);
20486
+ if (!listeners)
20487
+ return;
20488
+ listeners.delete(fn);
20489
+ if (listeners.size === 0)
20490
+ this.byEvent.delete(event);
20491
+ };
20492
+ emit = (event, payload) => {
20493
+ const listeners = this.byEvent.get(event);
20494
+ if (!listeners || listeners.size === 0)
20495
+ return;
20496
+ for (const listener of [...listeners]) {
20497
+ this.invoke(() => listener(payload), event);
20498
+ }
20499
+ };
20500
+ removeAllListeners = (event) => {
20501
+ if (event === undefined) {
20502
+ this.byEvent.clear();
20503
+ }
20504
+ else {
20505
+ this.byEvent.delete(event);
20506
+ }
20507
+ };
20508
+ invoke = (run, event) => {
20509
+ try {
20510
+ const result = run();
20511
+ if (result && typeof result.then === 'function') {
20512
+ result.catch((err) => {
20513
+ this.logger.warn(`Listener for '${String(event)}' rejected`, err);
20514
+ });
20515
+ }
20516
+ }
20517
+ catch (err) {
20518
+ this.logger.warn(`Listener for '${String(event)}' threw`, err);
20519
+ }
20520
+ };
20521
+ }
20522
+
20523
+ /**
20524
+ * Distributes keys to the E2EE Web Worker and attaches encrypt/decrypt
20525
+ * transforms to RTCRtpSenders and RTCRtpReceivers.
20526
+ */
20527
+ class EncryptionManager extends TypedEventEmitter {
20528
+ algorithm;
20529
+ transform;
20530
+ disposed = false;
20531
+ piped;
20532
+ userId;
20533
+ worker;
20534
+ workerUrl;
20535
+ constructor(userId, worker, workerUrl, algorithm, transform) {
20536
+ super('EncryptionManager');
20537
+ this.userId = userId;
20538
+ this.worker = worker;
20539
+ this.workerUrl = workerUrl;
20540
+ this.algorithm = algorithm;
20541
+ this.transform = transform;
20542
+ this.worker.addEventListener('message', this.handleWorkerMessage);
20543
+ this.worker.addEventListener('error', this.handleWorkerError);
20544
+ }
20545
+ /**
20546
+ * Whether this browser has WebRTC Encoded Transforms, which E2EE requires.
20547
+ * Use it to guard UI, or to avoid calling {@link create} where it would throw.
20548
+ */
20549
+ static isSupported = () => {
20550
+ return preferredTransform() !== undefined;
20551
+ };
20552
+ /**
20553
+ * Create an EncryptionManager instance and initialize the E2EE Web Worker.
20554
+ *
20555
+ * @param userId - The local user's ID, normally `call.currentUserId`.
20556
+ * @param options - the create options.
20557
+ * @throws {Error} If the browser lacks Encoded Transforms.
20558
+ *
20559
+ * @example
20560
+ * ```ts
20561
+ * if (EncryptionManager.isSupported()) {
20562
+ * const e2ee = await EncryptionManager.create(call.currentUserId);
20563
+ * call.setE2EEManager(e2ee);
20564
+ * e2ee.setSharedKey(0, keyBytes);
20565
+ * }
20566
+ */
20567
+ static create = async (userId, options) => {
20568
+ const transform = preferredTransform();
20569
+ if (!transform) {
20570
+ throw new Error(`E2EE is not supported in this browser`);
20571
+ }
20572
+ const { e2eeWorker } = await import('./e2ee-worker.browser.es.js');
20573
+ const blob = new Blob([`(${e2eeWorker.toString()})()`], {
20574
+ type: 'application/javascript',
20575
+ });
20576
+ const url = URL.createObjectURL(blob);
20577
+ let worker;
20578
+ try {
20579
+ worker = new Worker(url, { name: 'stream-video-e2ee' });
20580
+ }
20581
+ catch (err) {
20582
+ // e.g. a CSP `worker-src` without `blob:`. Don't leak the object URL.
20583
+ URL.revokeObjectURL(url);
20584
+ throw err;
20585
+ }
20586
+ const algorithm = options?.algorithm ?? 'AES-128-GCM';
20587
+ return new EncryptionManager(userId, worker, url, algorithm, transform);
20588
+ };
20589
+ /**
20590
+ * {@link dispose} terminates the worker, so `postMessage` becomes a silent
20591
+ * no-op and an attached transform points at a dead worker: frames would stall
20592
+ * forever with no error and no event. Throwing is also fail-closed, since a
20593
+ * caller that swallows it still publishes nothing rather than cleartext.
20594
+ */
20595
+ assertUsable = () => {
20596
+ if (this.disposed)
20597
+ throw new Error(`EncryptionManager is disposed`);
20598
+ };
20599
+ /**
20600
+ * Terminate the worker and release all resources.
20601
+ *
20602
+ * The manager is unusable afterwards and every other method throws. Call
20603
+ * {@link create} for a new one. Safe to call more than once.
20604
+ */
20605
+ dispose = () => {
20606
+ if (this.disposed)
20607
+ return;
20608
+ this.disposed = true;
20609
+ this.piped = undefined;
20610
+ this.worker.removeEventListener('message', this.handleWorkerMessage);
20611
+ this.worker.removeEventListener('error', this.handleWorkerError);
20612
+ this.worker.terminate();
20613
+ URL.revokeObjectURL(this.workerUrl);
20614
+ this.removeAllListeners();
20615
+ };
20616
+ /**
20617
+ * Set a per-user AES-GCM encryption key in the worker's key store.
20618
+ *
20619
+ * Use it when each participant has their own key from a central authority.
20620
+ * The receiver picks the right one by the `keyIndex` in the frame trailer.
20621
+ *
20622
+ * @param userId - The key owner.
20623
+ * @param keyIndex - Increases with each rotation.
20624
+ * @param rawKey - 16 bytes for AES-128-GCM, 32 for AES-256-GCM.
20625
+ */
20626
+ setKey = (userId, keyIndex, rawKey) => {
20627
+ this.assertUsable();
20628
+ this.validateKeyIndex(keyIndex);
20629
+ this.validateKeyLength(rawKey);
20630
+ this.worker.postMessage({ type: 'cmd.set_key', userId, keyIndex, rawKey });
20631
+ };
20632
+ /**
20633
+ * Fallback key for any user without a per-user key. The simplest E2EE mode:
20634
+ * one key for everyone, usually passphrase-derived, no distribution needed.
20635
+ * Setting an epoch makes it active for encryption while older epochs remain
20636
+ * available to decrypt in-flight frames until {@link removeSharedKey}.
20637
+ *
20638
+ * @param keyIndex - An integer 0-255, since one trailer byte carries it.
20639
+ * @param rawKey - 16 bytes for AES-128-GCM, 32 for AES-256-GCM.
20640
+ */
20641
+ setSharedKey = (keyIndex, rawKey) => {
20642
+ this.assertUsable();
20643
+ this.validateKeyIndex(keyIndex);
20644
+ this.validateKeyLength(rawKey);
20645
+ this.worker.postMessage({ type: 'cmd.set_shared_key', keyIndex, rawKey });
20646
+ };
20647
+ /**
20648
+ * Remove one shared-key epoch from the worker's receive key ring.
20649
+ *
20650
+ * If this is the active epoch, shared-key encryption stops until
20651
+ * {@link setSharedKey} succeeds again. An older epoch is not reactivated.
20652
+ *
20653
+ * @param keyIndex - The exact shared-key epoch to remove.
20654
+ */
20655
+ removeSharedKey = (keyIndex) => {
20656
+ this.assertUsable();
20657
+ this.validateKeyIndex(keyIndex);
20658
+ this.worker.postMessage({ type: 'cmd.remove_shared_key', keyIndex });
20659
+ };
20660
+ /**
20661
+ * Retire one of a user's key epochs, leaving their other epochs usable.
20662
+ *
20663
+ * @param userId - The key owner.
20664
+ * @param keyIndex - The exact epoch to remove. Absent epochs are a no-op.
20665
+ */
20666
+ removeKey = (userId, keyIndex) => {
20667
+ this.assertUsable();
20668
+ this.validateKeyIndex(keyIndex);
20669
+ this.worker.postMessage({ type: 'cmd.remove_key', userId, keyIndex });
20670
+ };
20671
+ /**
20672
+ * Drop every key a user holds, revoking their ability to decrypt later
20673
+ * frames. Call it when a participant leaves.
20674
+ *
20675
+ * To retire one rotated epoch instead, use {@link removeKey}.
20676
+ */
20677
+ removeAllKeys = (userId) => {
20678
+ this.assertUsable();
20679
+ this.worker.postMessage({ type: 'cmd.remove_all_keys', userId });
20680
+ };
20681
+ /**
20682
+ * Called by the Publisher when it adds a transceiver.
20683
+ *
20684
+ * @param sender - The sender to encrypt.
20685
+ * @param codec - Codec name, e.g. 'vp8', selecting the clear-byte rules.
20686
+ * @param trackType - Optional label; only groups perf stats.
20687
+ * @internal
20688
+ */
20689
+ encrypt = (sender, codec, trackType) => {
20690
+ this.assertUsable();
20691
+ this.pipe(sender, {
20692
+ operation: 'encode',
20693
+ userId: this.userId,
20694
+ codec,
20695
+ trackType,
20696
+ });
20697
+ };
20698
+ /**
20699
+ * Called by the Subscriber when a remote track arrives.
20700
+ *
20701
+ * @param receiver - The receiver to decrypt.
20702
+ * @param userId - The remote user, for key lookup in the worker.
20703
+ * @param trackType - Optional label; only groups perf stats.
20704
+ * @internal
20705
+ */
20706
+ decrypt = (receiver, userId, trackType) => {
20707
+ this.assertUsable();
20708
+ this.pipe(receiver, { operation: 'decode', userId, trackType });
20709
+ };
20710
+ /** Pipe through the worker's transform, tracking targets to avoid double-piping. */
20711
+ pipe = (target, options) => {
20712
+ if (this.transform === 'script') {
20713
+ target.transform = new RTCRtpScriptTransform(this.worker, options);
20714
+ return;
20715
+ }
20716
+ if ((this.piped ??= new WeakSet()).has(target))
20717
+ return;
20718
+ this.piped.add(target);
20719
+ // @ts-expect-error createEncodedStreams is not in the standard typedefs
20720
+ const { readable, writable } = target.createEncodedStreams();
20721
+ this.worker.postMessage({ type: 'cmd.setup_transform', ...options, readable, writable }, [readable, writable]);
20722
+ };
20723
+ /**
20724
+ * Toggle periodic performance reporting from the E2EE worker.
20725
+ *
20726
+ * While on, the worker emits `e2ee.perf_report` once per second with per-track
20727
+ * FPS and crypto timings. Useful for debugging throughput.
20728
+ */
20729
+ enablePerformanceReporting = (enabled) => {
20730
+ this.assertUsable();
20731
+ this.worker.postMessage({
20732
+ type: 'cmd.enable_performance_reporting',
20733
+ enabled,
20734
+ });
20735
+ };
20736
+ /**
20737
+ * Request a snapshot of the worker's keys. It arrives later as the
20738
+ * `e2ee.key_state` event, listing fingerprints only, never key material.
20739
+ */
20740
+ requestKeyState = () => {
20741
+ this.assertUsable();
20742
+ this.worker.postMessage({ type: 'cmd.request_key_state' });
20743
+ };
20744
+ handleWorkerMessage = (e) => {
20745
+ const { type, ...payload } = e.data ?? {};
20746
+ if (type === 'e2ee.error') {
20747
+ this.logger.error(e.data.message);
20748
+ return;
20749
+ }
20750
+ const event = type;
20751
+ this.logger.debug('Dispatching', event, payload);
20752
+ this.emit(event, payload);
20753
+ };
20754
+ handleWorkerError = (e) => {
20755
+ this.logger.error('Unhandled worker error:', e.message);
20756
+ };
20757
+ validateKeyLength = (rawKey) => {
20758
+ const is256 = this.algorithm === 'AES-256-GCM';
20759
+ const expected = is256 ? 32 : 16;
20760
+ if (rawKey.byteLength !== expected) {
20761
+ throw new Error(`Key must be exactly ${expected} bytes (${is256 ? 'AES-256' : 'AES-128'})`);
20762
+ }
20763
+ };
20764
+ /**
20765
+ * One trailer byte carries the keyIndex. A larger value would truncate to
20766
+ * `keyIndex & 0xFF`, so the receiver would look up the wrong key and fail
20767
+ * every decrypt. Reject it rather than ship a silently broken key epoch.
20768
+ */
20769
+ validateKeyIndex = (keyIndex) => {
20770
+ if (!Number.isInteger(keyIndex) || keyIndex < 0 || keyIndex > 255) {
20771
+ throw new Error(`keyIndex must be an integer between 0 and 255, got ${keyIndex}`);
20772
+ }
20773
+ };
20774
+ }
20775
+
20776
+ export { AudioSettingsRequestDefaultDeviceEnum, AudioSettingsResponseDefaultDeviceEnum, browsers as Browsers, Call, CallRecordingFailedEventRecordingTypeEnum, CallRecordingReadyEventRecordingTypeEnum, CallRecordingStartedEventRecordingTypeEnum, CallRecordingStoppedEventRecordingTypeEnum, CallState, CallType, CallTypes, CallingState, CameraManager, CameraManagerState, CreateDeviceRequestPushProviderEnum, DEFAULT_LOOPBACK_RECORDING_DURATION_MS, DebounceType, DeviceManager, DeviceManagerState, DynascaleManager, EncryptionManager, EncryptionSettingsRequestModeEnum, EncryptionSettingsResponseModeEnum, ErrorFromResponse, FrameRecordingSettingsRequestModeEnum, FrameRecordingSettingsRequestQualityEnum, FrameRecordingSettingsResponseModeEnum, HLSSettingsRequestQualityTracksEnum, IndividualRecordingSettingsRequestModeEnum, IndividualRecordingSettingsRequestOutputTypesEnum, IndividualRecordingSettingsResponseModeEnum, IngressAudioEncodingOptionsRequestChannelsEnum, IngressSourceRequestFpsEnum, IngressVideoLayerRequestCodecEnum, LOOPBACK_STREAMS_WAIT_TIMEOUT_MS, LayoutSettingsRequestNameEnum, LoopbackStreamsTimeoutError, MAX_LOOPBACK_RECORDING_DURATION_MS, MIN_LOOPBACK_RECORDING_DURATION_MS, MediaStreamRecorder, MicrophoneManager, MicrophoneManagerState, NoiseCancellationSettingsModeEnum, OwnCapability, RTMPBroadcastRequestQualityEnum, RTMPSettingsRequestQualityEnum, RawRecordingSettingsRequestModeEnum, RawRecordingSettingsResponseModeEnum, RecordSettingsRequestModeEnum, RecordSettingsRequestQualityEnum, RequestPermissionRequestPermissionsEnum, rxUtils as RxUtils, ScreenShareManager, ScreenShareState, events as SfuEvents, SfuJoinError, models as SfuModels, SfuTimeoutError, SpeakerManager, SpeakerState, StartClosedCaptionsRequestLanguageEnum, StartTranscriptionRequestLanguageEnum, StreamSfuClient, StreamVideoClient, StreamVideoReadOnlyStateStore, StreamVideoWriteableStateStore, TranscriptionSettingsRequestClosedCaptionModeEnum, TranscriptionSettingsRequestLanguageEnum, TranscriptionSettingsRequestModeEnum, TranscriptionSettingsResponseClosedCaptionModeEnum, TranscriptionSettingsResponseLanguageEnum, TranscriptionSettingsResponseModeEnum, TranslationSettingsLanguagesEnum, UpdateUserPermissionsRequestGrantPermissionsEnum, UpdateUserPermissionsRequestRevokePermissionsEnum, VideoSettingsRequestCameraFacingEnum, VideoSettingsResponseCameraFacingEnum, ViewportTracker, VisibilityState, WSAuthMessageProductsEnum, checkIfAudioOutputChangeSupported, clampLoopbackRecordingDuration, combineComparators, conditional, createSoundDetector, defaultSortPreset, descending, deviceIds$, disposeOfMediaStream, dominantSpeaker, enableLoopbackAudio, getAudioBrowserPermission, getAudioDevices, getAudioOutputDevices, getAudioStream, getCallMediaEngineProvider, getClientDetails, getDeviceState, getLoopbackStreams, getLoopbackTracks, getScreenShareStream, getSdkInfo, getVideoBrowserPermission, getVideoDevices, getVideoStream, getWebRTCInfo, hasAudio$1 as hasAudio, hasInterruptedTrack, hasPausedTrack, hasScreenShare, hasScreenShareAudio, hasVideo, humanize, isPinned, livestreamOrAudioRoomSortPreset, logToConsole, name, noopComparator, paginatedLayoutSortPreset, pinned, publishingAudio, publishingVideo, reactionType, resolveDeviceId, role, screenSharing, setCallMediaEngineProvider, setDeviceInfo, setOSInfo, setPowerState, setSdkInfo, setThermalState, setWebRTCInfo, speakerLayoutSortPreset, speaking, videoLoggerSystem, waitForLoopbackStreams, withLoopbackAudioEnabled, withParticipantSource };
19753
20777
  //# sourceMappingURL=index.browser.es.js.map