@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
@@ -124,13 +124,56 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
124
124
  }
125
125
  }
126
126
 
127
+ override enable(): Promise<void> {
128
+ if (isReactNative() && !this.call.hasMediaEngine) {
129
+ this.state.setPendingStatus('enabled');
130
+ return Promise.resolve();
131
+ }
132
+
133
+ return super.enable();
134
+ }
135
+
136
+ override disable(options: { forceStop?: boolean }): Promise<void>;
137
+ override disable(forceStop?: boolean): Promise<void>;
138
+ override async disable(
139
+ forceStopOrOptions?: boolean | { forceStop?: boolean },
140
+ ): Promise<void> {
141
+ if (isReactNative() && !this.call.hasMediaEngine) {
142
+ this.state.setPendingStatus('disabled');
143
+ return;
144
+ }
145
+
146
+ // forward verbatim to the base, narrowing so the right overload is selected
147
+ if (forceStopOrOptions === undefined) return super.disable();
148
+ if (typeof forceStopOrOptions === 'boolean') {
149
+ return super.disable(forceStopOrOptions);
150
+ }
151
+ return super.disable(forceStopOrOptions);
152
+ }
153
+
154
+ override toggle(): Promise<void> {
155
+ if (isReactNative() && !this.call.hasMediaEngine) {
156
+ this.state.setPendingStatus(
157
+ this.state.optimisticStatus === 'enabled' ? 'disabled' : 'enabled',
158
+ );
159
+ return Promise.resolve();
160
+ }
161
+
162
+ return super.toggle();
163
+ }
164
+
127
165
  /**
128
166
  * Applies the video settings to the camera.
129
167
  *
130
168
  * @param settings the video settings to apply.
131
169
  * @param publish whether to publish the stream after applying the settings.
170
+ * @param forceDisabled whether to keep the camera disabled, regardless of server-side or local preferences.
132
171
  */
133
- async apply(settings: VideoSettingsResponse, publish: boolean) {
172
+ async apply(
173
+ settings: VideoSettingsResponse,
174
+ publish: boolean,
175
+ forceDisabled: boolean = false,
176
+ ): Promise<void> {
134
177
  // Wait for any in progress camera operation
135
178
  await this.statusChangeSettled();
136
179
  await this.selectTargetResolution(settings.target_resolution);
@@ -148,8 +191,10 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
148
191
  this.devicePersistence.enabled &&
149
192
  permissionState === 'granted'
150
193
  ) {
151
- persistedPreferencesApplied =
152
- await this.applyPersistedPreferences(enabledInCallType);
194
+ persistedPreferencesApplied = await this.applyPersistedPreferences(
195
+ enabledInCallType,
196
+ forceDisabled,
197
+ );
153
198
  }
154
199
 
155
200
  // apply a direction and enable the camera only if in "pristine" state,
@@ -161,14 +206,25 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
161
206
  await this.selectDirection(direction, { enableCamera: false });
162
207
  }
163
208
 
164
- if (canPublish && settings.camera_default_on && enabledInCallType) {
209
+ if (
210
+ !forceDisabled &&
211
+ canPublish &&
212
+ settings.camera_default_on &&
213
+ enabledInCallType
214
+ ) {
165
215
  await this.enable();
166
216
  }
167
217
  }
168
218
 
169
- const { mediaStream } = this.state;
170
- if (canPublish && publish && this.enabled && mediaStream) {
171
- await this.publishStream(mediaStream);
219
+ if (isReactNative() && publish && canPublish) {
220
+ // On RN the camera is enabled/disabled optimistically before JOINED. Reconcile now
221
+ // acquires the track and publishes it, so it fully owns the publish.
222
+ await this.reconcileOptimisticStatus();
223
+ } else {
224
+ const { mediaStream } = this.state;
225
+ if (canPublish && publish && this.enabled && mediaStream) {
226
+ await this.publishStream(mediaStream);
227
+ }
172
228
  }
173
229
  }
174
230
 
@@ -196,9 +252,12 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
196
252
  return constraints;
197
253
  }
198
254
 
199
- protected override getStream(
255
+ protected override async getStream(
200
256
  constraints: MediaTrackConstraints,
201
257
  ): Promise<MediaStream> {
258
+ // Ensure the call's media factory exists before capture so the resulting
259
+ // track is owned by it (the WebRTC globals resolve to the live factory).
260
+ await this.call.ensureMediaFactory();
202
261
  return getVideoStream(constraints, this.call.tracer);
203
262
  }
204
263
  }
@@ -509,6 +509,23 @@ export abstract class DeviceManager<
509
509
  }
510
510
  }
511
511
 
512
+ protected reconcileOptimisticStatus = async (): Promise<void> => {
513
+ const target = this.state.optimisticStatus;
514
+ await withCancellation(this.statusChangeConcurrencyTag, async (signal) => {
515
+ try {
516
+ if (target === 'enabled' && this.state.status !== 'enabled') {
517
+ await this.unmuteStream();
518
+ if (!signal.aborted) this.state.setStatus('enabled');
519
+ } else if (target === 'disabled' && this.state.status === 'enabled') {
520
+ // mirror whatever disable() does to stop/pause the track per disableMode
521
+ if (!signal.aborted) this.state.setStatus('disabled');
522
+ }
523
+ } finally {
524
+ if (!signal.aborted) this.state.setPendingStatus(this.state.status);
525
+ }
526
+ });
527
+ };
528
+
512
529
  private disableTracks() {
513
530
  this.getTracks().forEach((track) => {
514
531
  if (track.enabled) track.enabled = false;
@@ -825,7 +842,10 @@ export abstract class DeviceManager<
825
842
  writePreferences(currentDevice, deviceKey, muted, storageKey);
826
843
  }
827
844
 
828
- protected async applyPersistedPreferences(enabledInCallType: boolean) {
845
+ protected async applyPersistedPreferences(
846
+ enabledInCallType: boolean,
847
+ forceDisabled = false,
848
+ ) {
829
849
  const deviceKey: DevicePreferenceKey =
830
850
  this.trackType === TrackType.AUDIO ? 'microphone' : 'camera';
831
851
  const preferences = readPreferences(this.devicePersistence.storageKey);
@@ -858,15 +878,15 @@ export abstract class DeviceManager<
858
878
 
859
879
  const canPublish = this.call.permissionsContext.canPublish(this.trackType);
860
880
  if (typeof muted === 'boolean' && enabledInCallType && canPublish) {
861
- await this.applyMutedState(muted);
881
+ await this.applyMutedState(muted, forceDisabled);
862
882
  appliedMute = true;
863
883
  }
864
884
 
865
885
  return appliedDevice || appliedMute;
866
886
  }
867
887
 
868
- private async applyMutedState(muted: boolean) {
869
- if (this.state.status !== undefined) return;
888
+ private async applyMutedState(muted: boolean, forceDisabled: boolean) {
889
+ if (forceDisabled || this.state.status !== undefined) return;
870
890
  if (muted) {
871
891
  await this.disable();
872
892
  } else {
@@ -76,7 +76,10 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
76
76
  ]) => {
77
77
  try {
78
78
  if (callingState === CallingState.LEFT) {
79
- this.setMutedRecordingPrepared(false);
79
+ // The muted-recording-prepared mode is reset in `callManager.stop()`
80
+ // (during leave, while the call factory is still alive), not here —
81
+ // this subscription fires asynchronously and could land after the
82
+ // factory is disposed, forcing a default-ADM rebuild.
80
83
  await this.stopSpeakingWhileMutedDetection();
81
84
  }
82
85
  if (callingState !== CallingState.JOINED) return;
@@ -187,6 +190,44 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
187
190
  }
188
191
  }
189
192
 
193
+ override enable(): Promise<void> {
194
+ if (isReactNative() && !this.call.hasMediaEngine) {
195
+ this.state.setPendingStatus('enabled');
196
+ return Promise.resolve();
197
+ }
198
+
199
+ return super.enable();
200
+ }
201
+
202
+ override disable(options: { forceStop?: boolean }): Promise<void>;
203
+ override disable(forceStop?: boolean): Promise<void>;
204
+ override async disable(
205
+ forceStopOrOptions?: boolean | { forceStop?: boolean },
206
+ ): Promise<void> {
207
+ if (isReactNative() && !this.call.hasMediaEngine) {
208
+ this.state.setPendingStatus('disabled');
209
+ return;
210
+ }
211
+
212
+ // forward verbatim to the base, narrowing so the right overload is selected
213
+ if (forceStopOrOptions === undefined) return super.disable();
214
+ if (typeof forceStopOrOptions === 'boolean') {
215
+ return super.disable(forceStopOrOptions);
216
+ }
217
+ return super.disable(forceStopOrOptions);
218
+ }
219
+
220
+ override toggle(): Promise<void> {
221
+ if (isReactNative() && !this.call.hasMediaEngine) {
222
+ this.state.setPendingStatus(
223
+ this.state.optimisticStatus === 'enabled' ? 'disabled' : 'enabled',
224
+ );
225
+ return Promise.resolve();
226
+ }
227
+
228
+ return super.toggle();
229
+ }
230
+
190
231
  /**
191
232
  * Enables noise cancellation for the microphone.
192
233
  *
@@ -347,8 +388,13 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
347
388
  * Applies the audio settings to the microphone.
348
389
  * @param settings the audio settings to apply.
349
390
  * @param publish whether to publish the stream after applying the settings.
391
+ * @param forceDisabled whether to keep the mic disabled, regardless of server-side or local preferences.
350
392
  */
351
- async apply(settings: AudioSettingsResponse, publish: boolean) {
393
+ async apply(
394
+ settings: AudioSettingsResponse,
395
+ publish: boolean,
396
+ forceDisabled: boolean = false,
397
+ ) {
352
398
  // Wait for any in progress mic operation
353
399
  await this.statusChangeSettled();
354
400
 
@@ -366,19 +412,28 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
366
412
  this.devicePersistence.enabled &&
367
413
  permissionState === 'granted'
368
414
  ) {
369
- persistedPreferencesApplied = await this.applyPersistedPreferences(true);
415
+ persistedPreferencesApplied = await this.applyPersistedPreferences(
416
+ true,
417
+ forceDisabled,
418
+ );
370
419
  }
371
420
 
372
421
  const canPublish = this.call.permissionsContext.canPublish(this.trackType);
373
422
  if (shouldApplyDefaults && !persistedPreferencesApplied) {
374
- if (canPublish && settings.mic_default_on) {
423
+ if (!forceDisabled && canPublish && settings.mic_default_on) {
375
424
  await this.enable();
376
425
  }
377
426
  }
378
427
 
379
- const { mediaStream } = this.state;
380
- if (canPublish && publish && this.enabled && mediaStream) {
381
- await this.publishStream(mediaStream);
428
+ if (isReactNative() && publish && canPublish) {
429
+ // On RN the microphone is enabled/disabled optimistically before JOINED. Reconcile now
430
+ // acquires the track and publishes it, so it fully owns the publish.
431
+ await this.reconcileOptimisticStatus();
432
+ } else {
433
+ const { mediaStream } = this.state;
434
+ if (canPublish && publish && this.enabled && mediaStream) {
435
+ await this.publishStream(mediaStream);
436
+ }
382
437
  }
383
438
  }
384
439
 
@@ -386,9 +441,12 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
386
441
  return getAudioDevices(this.call.tracer);
387
442
  }
388
443
 
389
- protected override getStream(
444
+ protected override async getStream(
390
445
  constraints: MediaTrackConstraints,
391
446
  ): Promise<MediaStream> {
447
+ // Ensure the call's media factory exists before capture so the resulting
448
+ // track is owned by it (the WebRTC globals resolve to the live factory).
449
+ await this.call.ensureMediaFactory();
392
450
  return getAudioStream(constraints, this.call.tracer);
393
451
  }
394
452
 
@@ -90,6 +90,9 @@ export class ScreenShareManager extends AudioDeviceManager<
90
90
  if (!this.state.audioEnabled) {
91
91
  constraints.audio = false;
92
92
  }
93
+ // Ensure the call's media factory exists before capture so the resulting
94
+ // track is owned by it (the WebRTC globals resolve to the live factory).
95
+ await this.call.ensureMediaFactory();
93
96
  const stream = await getScreenShareStream(constraints, this.call.tracer);
94
97
  const [track] = stream.getVideoTracks();
95
98
  const { contentHint } = this.state.settings || {};
@@ -1,4 +1,4 @@
1
- import { combineLatest } from 'rxjs';
1
+ import { combineLatest, firstValueFrom, pairwise } from 'rxjs';
2
2
  import { Call } from '../Call';
3
3
  import { isReactNative } from '../helpers/platforms';
4
4
  import { SpeakerState } from './SpeakerState';
@@ -39,11 +39,15 @@ export class SpeakerManager {
39
39
  this.setup();
40
40
  }
41
41
 
42
- apply(settings: CallSettingsResponse) {
43
- return isReactNative() ? this.applyRN(settings) : this.applyWeb();
42
+ async apply(settings: CallSettingsResponse): Promise<void> {
43
+ if (isReactNative()) {
44
+ this.applyRN(settings);
45
+ return;
46
+ }
47
+ await this.applyWeb();
44
48
  }
45
49
 
46
- private applyWeb() {
50
+ private async applyWeb() {
47
51
  const { enabled, storageKey } = this.devicePersistence;
48
52
  if (!enabled) return;
49
53
 
@@ -56,8 +60,26 @@ export class SpeakerManager {
56
60
  preference.selectedDeviceId === defaultDeviceId
57
61
  ? ''
58
62
  : preference.selectedDeviceId;
59
- if (this.state.selectedDevice !== nextDeviceId) {
60
- this.select(nextDeviceId);
63
+ if (!nextDeviceId) {
64
+ if (this.state.selectedDevice !== nextDeviceId) {
65
+ this.select(nextDeviceId);
66
+ }
67
+ return;
68
+ }
69
+
70
+ const permissionState = await firstValueFrom(
71
+ getAudioBrowserPermission(this.call.tracer).asStateObservable(),
72
+ );
73
+ if (permissionState !== 'granted') return;
74
+
75
+ const devices = await firstValueFrom(this.listDevices());
76
+ const device =
77
+ this.findDevice(devices, nextDeviceId) ??
78
+ (preference.selectedDeviceLabel
79
+ ? devices.find((d) => d.label === preference.selectedDeviceLabel)
80
+ : undefined);
81
+ if (device && this.state.selectedDevice !== device.deviceId) {
82
+ this.select(device.deviceId);
61
83
  }
62
84
  }
63
85
 
@@ -90,6 +112,7 @@ export class SpeakerManager {
90
112
  globalThis.streamRNVideoSDK?.callManager.setup({
91
113
  defaultDevice,
92
114
  isRingingTypeCall: this.call.ringing,
115
+ cid: this.call.cid,
93
116
  });
94
117
  }
95
118
  }
@@ -101,13 +124,16 @@ export class SpeakerManager {
101
124
  if (deviceIds$ && !isReactNative()) {
102
125
  this.subscriptions.push(
103
126
  createSubscription(
104
- combineLatest([deviceIds$, this.state.selectedDevice$]),
105
- ([devices, deviceId]) => {
127
+ combineLatest([
128
+ deviceIds$!.pipe(pairwise()),
129
+ this.state.selectedDevice$,
130
+ ]),
131
+ ([[prevDevices, currentDevices], deviceId]) => {
106
132
  if (!deviceId) return;
107
- const device = devices.find(
108
- (d) => d.deviceId === deviceId && d.kind === 'audiooutput',
109
- );
110
- if (!device) this.select('');
133
+ const isDisconnected =
134
+ this.findDevice(prevDevices, deviceId) &&
135
+ !this.findDevice(currentDevices, deviceId);
136
+ if (isDisconnected) this.select('');
111
137
  },
112
138
  ),
113
139
  );
@@ -164,6 +190,7 @@ export class SpeakerManager {
164
190
  this.subscriptions.forEach((unsubscribe) => unsubscribe());
165
191
  this.subscriptions = [];
166
192
  this.areSubscriptionsSetUp = false;
193
+ this.defaultDevice = undefined;
167
194
  };
168
195
 
169
196
  /**
@@ -201,6 +228,9 @@ export class SpeakerManager {
201
228
  });
202
229
  }
203
230
 
231
+ private findDevice = (devices: MediaDeviceInfo[], deviceId: string) =>
232
+ devices.find((d) => d.deviceId === deviceId && d.kind === 'audiooutput');
233
+
204
234
  private persistSpeakerDevicePreference(selectedDevice: string) {
205
235
  const { storageKey } = this.devicePersistence;
206
236
  const devices = getCurrentValue(this.listDevices()) || [];
@@ -357,11 +357,37 @@ describe('CameraManager', () => {
357
357
  true,
358
358
  );
359
359
 
360
- expect(applySpy).toHaveBeenCalledWith(true);
360
+ expect(applySpy).toHaveBeenCalledWith(true, false);
361
361
  expect(selectDirectionSpy).not.toHaveBeenCalled();
362
362
  expect(enableSpy).not.toHaveBeenCalled();
363
363
  });
364
364
 
365
+ it('should apply persisted device preferences without enabling when forced disabled', async () => {
366
+ vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
367
+ of('granted'),
368
+ );
369
+ const devicePersistence = { enabled: true, storageKey: '' };
370
+ const persistedManager = new CameraManager(call, devicePersistence);
371
+ const applySpy = vi
372
+ .spyOn(persistedManager as never, 'applyPersistedPreferences')
373
+ .mockResolvedValue(true);
374
+ const enableSpy = vi.spyOn(persistedManager, 'enable');
375
+
376
+ await persistedManager.apply(
377
+ fromPartial({
378
+ enabled: true,
379
+ target_resolution: { width: 640, height: 480 },
380
+ camera_facing: 'front',
381
+ camera_default_on: true,
382
+ }),
383
+ false,
384
+ true,
385
+ );
386
+
387
+ expect(applySpy).toHaveBeenCalledWith(true, true);
388
+ expect(enableSpy).not.toHaveBeenCalled();
389
+ });
390
+
365
391
  it('should skip persisted preferences when permission is not granted', async () => {
366
392
  vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
367
393
  of('prompt'),
@@ -21,7 +21,7 @@ import { DeviceManagerState } from '../DeviceManagerState';
21
21
  import { firstValueFrom, of } from 'rxjs';
22
22
  import { TrackType } from '../../gen/video/sfu/models/models';
23
23
  import { PermissionsContext } from '../../permissions';
24
- import { readPreferences } from '../devicePersistence';
24
+ import { defaultDeviceId, readPreferences } from '../devicePersistence';
25
25
 
26
26
  vi.mock('../../reporting/ClientEventReporter', () => ({
27
27
  ClientEventReporter: vi.fn(function () {
@@ -1010,6 +1010,54 @@ describe('Device Manager', () => {
1010
1010
  expect(selectSpy).toHaveBeenCalledWith(mockVideoDevices[1].deviceId);
1011
1011
  });
1012
1012
 
1013
+ it('selects the device without unmuting when forced disabled', async () => {
1014
+ localStorageMock.setItem(
1015
+ storageKey,
1016
+ JSON.stringify({
1017
+ camera: [
1018
+ {
1019
+ selectedDeviceId: mockVideoDevices[0].deviceId,
1020
+ selectedDeviceLabel: mockVideoDevices[0].label,
1021
+ muted: false,
1022
+ },
1023
+ ],
1024
+ }),
1025
+ );
1026
+
1027
+ const selectSpy = vi.spyOn(manager, 'select');
1028
+ const enableSpy = vi.spyOn(manager, 'enable');
1029
+
1030
+ // @ts-expect-error - private API
1031
+ const result = await manager.applyPersistedPreferences(true, true);
1032
+
1033
+ expect(result).toBe(true);
1034
+ expect(selectSpy).toHaveBeenCalledWith(mockVideoDevices[0].deviceId);
1035
+ expect(enableSpy).not.toHaveBeenCalled();
1036
+ });
1037
+
1038
+ it('retains a default-device mute preference when forced disabled', async () => {
1039
+ localStorageMock.setItem(
1040
+ storageKey,
1041
+ JSON.stringify({
1042
+ camera: [
1043
+ {
1044
+ selectedDeviceId: defaultDeviceId,
1045
+ selectedDeviceLabel: '',
1046
+ muted: false,
1047
+ },
1048
+ ],
1049
+ }),
1050
+ );
1051
+
1052
+ const enableSpy = vi.spyOn(manager, 'enable');
1053
+
1054
+ // @ts-expect-error - private API
1055
+ const result = await manager.applyPersistedPreferences(true, true);
1056
+
1057
+ expect(result).toBe(true);
1058
+ expect(enableSpy).not.toHaveBeenCalled();
1059
+ });
1060
+
1013
1061
  it('applies muted state without selecting when default device is stored', async () => {
1014
1062
  localStorageMock.setItem(
1015
1063
  storageKey,
@@ -486,7 +486,29 @@ describe('MicrophoneManager', () => {
486
486
  // @ts-expect-error - partial data
487
487
  await persistedManager.apply({ mic_default_on: true }, true);
488
488
 
489
- expect(applySpy).toHaveBeenCalledWith(true);
489
+ expect(applySpy).toHaveBeenCalledWith(true, false);
490
+ expect(enableSpy).not.toHaveBeenCalled();
491
+ });
492
+
493
+ it('should apply persisted device preferences without enabling when forced disabled', async () => {
494
+ vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
495
+ of('granted'),
496
+ );
497
+ const devicePersistence = { enabled: true, storageKey: '' };
498
+ const persistedManager = new MicrophoneManager(
499
+ call,
500
+ devicePersistence,
501
+ 'disable-tracks',
502
+ );
503
+ const applySpy = vi
504
+ .spyOn(persistedManager as never, 'applyPersistedPreferences')
505
+ .mockResolvedValue(true);
506
+ const enableSpy = vi.spyOn(persistedManager, 'enable');
507
+
508
+ // @ts-expect-error - partial data
509
+ await persistedManager.apply({ mic_default_on: true }, false, true);
510
+
511
+ expect(applySpy).toHaveBeenCalledWith(true, true);
490
512
  expect(enableSpy).not.toHaveBeenCalled();
491
513
  });
492
514
 
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import { MicrophoneManager } from '../MicrophoneManager';
3
3
  import { Call } from '../../Call';
4
4
  import { StreamClient } from '../../coordinator/connection/client';
5
- import { CallingState, StreamVideoWriteableStateStore } from '../../store';
5
+ import { StreamVideoWriteableStateStore } from '../../store';
6
6
  import {
7
7
  mockAudioDevices,
8
8
  mockAudioStream,
@@ -263,25 +263,6 @@ describe('MicrophoneManager React Native', () => {
263
263
  expect(setMutedRecordingPreparedMock).not.toHaveBeenCalledWith(true);
264
264
  });
265
265
 
266
- it('should release prepared muted recording when the call is left', async () => {
267
- await manager.disable();
268
- await vi.waitUntil(
269
- () =>
270
- setMutedRecordingPreparedMock.mock.calls.some(([arg]) => arg === true),
271
- { timeout: 100 },
272
- );
273
- setMutedRecordingPreparedMock.mockClear();
274
-
275
- manager['call'].state.setCallingState(CallingState.LEFT);
276
-
277
- await vi.waitUntil(
278
- () =>
279
- setMutedRecordingPreparedMock.mock.calls.some(([arg]) => arg === false),
280
- { timeout: 100 },
281
- );
282
- expect(setMutedRecordingPreparedMock).toHaveBeenCalledWith(false);
283
- });
284
-
285
266
  afterEach(() => {
286
267
  globalThis.streamRNVideoSDK = undefined;
287
268
  vi.clearAllMocks();