@stream-io/video-client 1.57.0 → 1.59.0

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