@stream-io/video-client 1.53.0 → 1.53.2

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.
@@ -71,5 +71,11 @@ export declare class MicrophoneManager extends AudioDeviceManager<MicrophoneMana
71
71
  private startSpeakingWhileMutedDetection;
72
72
  private stopSpeakingWhileMutedDetection;
73
73
  private teardownSpeakingWhileMutedDetection;
74
+ /**
75
+ * iOS-only: keep the mic-input chain prepared while muted
76
+ * so the `AVAudioEngine` stays full-duplex and remote audio renders on a
77
+ * muted join.
78
+ */
79
+ private setMutedRecordingPrepared;
74
80
  private hasPermission;
75
81
  }
@@ -387,6 +387,17 @@ export type StreamRNVideoSDKGlobals = {
387
387
  * Stops the in call manager.
388
388
  */
389
389
  stop({ isRingingTypeCall }: StreamRNVideoSDKCallManagerRingingParams): void;
390
+ /**
391
+ * iOS-only. Keeps the audio engine's microphone-input (voice-processing)
392
+ * chain prepared while the mic is muted, so the `AVAudioEngine` stays full-duplex
393
+ * meaning: mic going out and speaker coming in, simultaneously
394
+ *
395
+ * Without this, joining muted builds an output-only engine under the
396
+ * `PlayAndRecord`/`VoiceChat` (VPIO) session, which makes the remote audio
397
+ * not audible when joining muted. As echo cancellation expects the mic to be
398
+ * prepared.
399
+ */
400
+ setMutedRecordingPrepared?(enabled: boolean): void;
390
401
  };
391
402
  permissions: {
392
403
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-client",
3
- "version": "1.53.0",
3
+ "version": "1.53.2",
4
4
  "main": "dist/index.cjs.js",
5
5
  "module": "dist/index.es.js",
6
6
  "browser": "dist/index.browser.es.js",
@@ -76,6 +76,7 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
76
76
  ]) => {
77
77
  try {
78
78
  if (callingState === CallingState.LEFT) {
79
+ this.setMutedRecordingPrepared(false);
79
80
  await this.stopSpeakingWhileMutedDetection();
80
81
  }
81
82
  if (callingState !== CallingState.JOINED) return;
@@ -84,11 +85,14 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
84
85
  if (ownCapabilities.includes(OwnCapability.SEND_AUDIO)) {
85
86
  const hasPermission = await this.hasPermission(permissionState);
86
87
  if (hasPermission && status !== 'enabled') {
88
+ this.setMutedRecordingPrepared(true);
87
89
  await this.startSpeakingWhileMutedDetection(deviceId);
88
90
  } else {
91
+ this.setMutedRecordingPrepared(false);
89
92
  await this.stopSpeakingWhileMutedDetection();
90
93
  }
91
94
  } else {
95
+ this.setMutedRecordingPrepared(false);
92
96
  await this.stopSpeakingWhileMutedDetection();
93
97
  }
94
98
  } catch (err) {
@@ -467,6 +471,18 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
467
471
  });
468
472
  }
469
473
 
474
+ /**
475
+ * iOS-only: keep the mic-input chain prepared while muted
476
+ * so the `AVAudioEngine` stays full-duplex and remote audio renders on a
477
+ * muted join.
478
+ */
479
+ private setMutedRecordingPrepared(enabled: boolean): void {
480
+ if (!isReactNative()) return;
481
+ globalThis.streamRNVideoSDK?.callManager.setMutedRecordingPrepared?.(
482
+ enabled,
483
+ );
484
+ }
485
+
470
486
  private async hasPermission(
471
487
  permissionState: BrowserPermissionState,
472
488
  ): Promise<boolean> {
@@ -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 { StreamVideoWriteableStateStore } from '../../store';
5
+ import { CallingState, StreamVideoWriteableStateStore } from '../../store';
6
6
  import {
7
7
  mockAudioDevices,
8
8
  mockAudioStream,
@@ -54,11 +54,13 @@ describe('MicrophoneManager React Native', () => {
54
54
  let manager: MicrophoneManager;
55
55
  let checkPermissionMock: ReturnType<typeof vi.fn>;
56
56
  let subscribeMock: ReturnType<typeof vi.fn>;
57
+ let setMutedRecordingPreparedMock: ReturnType<typeof vi.fn>;
57
58
 
58
59
  beforeEach(() => {
59
60
  speechActivityCallback = null;
60
61
  unsubscribeMocks = [];
61
62
  checkPermissionMock = vi.fn(async () => true);
63
+ setMutedRecordingPreparedMock = vi.fn();
62
64
  subscribeMock = vi.fn((cb) => {
63
65
  speechActivityCallback = cb;
64
66
  const unsub = vi.fn();
@@ -71,6 +73,7 @@ describe('MicrophoneManager React Native', () => {
71
73
  setup: vi.fn(),
72
74
  start: vi.fn(),
73
75
  stop: vi.fn(),
76
+ setMutedRecordingPrepared: setMutedRecordingPreparedMock,
74
77
  },
75
78
  permissions: {
76
79
  check: checkPermissionMock,
@@ -210,6 +213,76 @@ describe('MicrophoneManager React Native', () => {
210
213
  expect(fn).toHaveBeenCalled();
211
214
  });
212
215
 
216
+ it('should prepare muted recording when mic is disabled and user can send audio', async () => {
217
+ await manager.enable();
218
+ await manager.disable();
219
+
220
+ await vi.waitUntil(
221
+ () =>
222
+ setMutedRecordingPreparedMock.mock.calls.some(([arg]) => arg === true),
223
+ { timeout: 100 },
224
+ );
225
+ expect(setMutedRecordingPreparedMock).toHaveBeenCalledWith(true);
226
+ });
227
+
228
+ it('should release prepared muted recording when mic is enabled', async () => {
229
+ await manager.disable();
230
+ setMutedRecordingPreparedMock.mockClear();
231
+ await manager.enable();
232
+
233
+ await vi.waitUntil(
234
+ () =>
235
+ setMutedRecordingPreparedMock.mock.calls.some(([arg]) => arg === false),
236
+ { timeout: 100 },
237
+ );
238
+ expect(setMutedRecordingPreparedMock).toHaveBeenCalledWith(false);
239
+ });
240
+
241
+ it('should not prepare muted recording when user cannot send audio', async () => {
242
+ await manager.disable();
243
+ manager['call'].state.setOwnCapabilities([]);
244
+ setMutedRecordingPreparedMock.mockClear();
245
+ // toggle status to re-run the reactive subscription with no send-audio cap
246
+ await manager.enable();
247
+ await manager.disable();
248
+
249
+ await vi.waitUntil(
250
+ () => setMutedRecordingPreparedMock.mock.calls.length > 0,
251
+ { timeout: 100 },
252
+ );
253
+ expect(setMutedRecordingPreparedMock).not.toHaveBeenCalledWith(true);
254
+ });
255
+
256
+ it('should not prepare muted recording if native microphone permission is denied', async () => {
257
+ checkPermissionMock.mockResolvedValue(false);
258
+ await manager.enable();
259
+ await manager.disable();
260
+
261
+ await vi.waitUntil(() => checkPermissionMock.mock.calls.length > 0, {
262
+ timeout: 100,
263
+ });
264
+ expect(setMutedRecordingPreparedMock).not.toHaveBeenCalledWith(true);
265
+ });
266
+
267
+ it('should release prepared muted recording when the call is left', async () => {
268
+ await manager.disable();
269
+ await vi.waitUntil(
270
+ () =>
271
+ setMutedRecordingPreparedMock.mock.calls.some(([arg]) => arg === true),
272
+ { timeout: 100 },
273
+ );
274
+ setMutedRecordingPreparedMock.mockClear();
275
+
276
+ manager['call'].state.setCallingState(CallingState.LEFT);
277
+
278
+ await vi.waitUntil(
279
+ () =>
280
+ setMutedRecordingPreparedMock.mock.calls.some(([arg]) => arg === false),
281
+ { timeout: 100 },
282
+ );
283
+ expect(setMutedRecordingPreparedMock).toHaveBeenCalledWith(false);
284
+ });
285
+
213
286
  afterEach(() => {
214
287
  globalThis.streamRNVideoSDK = undefined;
215
288
  vi.clearAllMocks();
@@ -390,8 +390,13 @@ export class ClientEventReporter {
390
390
  const joinAttemptId = this.joinAttemptIds.get(cid);
391
391
  if (!joinAttemptId) return;
392
392
  const coordinatorConnectId = this.coordinatorConnectId;
393
+ const ctx = this.callContexts.get(cid);
394
+
393
395
  this.send({
394
- user_id: this.streamClient.userID,
396
+ user_id: this.streamClient.userID || this.coordinatorConnectUserId,
397
+ type: ctx?.callType,
398
+ id: ctx?.callId,
399
+ call_cid: cid,
395
400
  stage: 'JoinInitiated',
396
401
  join_attempt_id: joinAttemptId,
397
402
  ...(coordinatorConnectId && {
@@ -707,7 +712,7 @@ export class ClientEventReporter {
707
712
  const ctx = this.callContexts.get(cid);
708
713
  const coordinatorConnectId = this.coordinatorConnectId;
709
714
  return {
710
- user_id: this.streamClient.userID,
715
+ user_id: this.streamClient.userID || this.coordinatorConnectUserId,
711
716
  type: ctx?.callType ?? '',
712
717
  id: ctx?.callId ?? '',
713
718
  call_cid: cid,
package/src/types.ts CHANGED
@@ -480,6 +480,18 @@ export type StreamRNVideoSDKGlobals = {
480
480
  * Stops the in call manager.
481
481
  */
482
482
  stop({ isRingingTypeCall }: StreamRNVideoSDKCallManagerRingingParams): void;
483
+
484
+ /**
485
+ * iOS-only. Keeps the audio engine's microphone-input (voice-processing)
486
+ * chain prepared while the mic is muted, so the `AVAudioEngine` stays full-duplex
487
+ * meaning: mic going out and speaker coming in, simultaneously
488
+ *
489
+ * Without this, joining muted builds an output-only engine under the
490
+ * `PlayAndRecord`/`VoiceChat` (VPIO) session, which makes the remote audio
491
+ * not audible when joining muted. As echo cancellation expects the mic to be
492
+ * prepared.
493
+ */
494
+ setMutedRecordingPrepared?(enabled: boolean): void;
483
495
  };
484
496
  permissions: {
485
497
  /**