@stream-io/video-client 1.47.0 → 1.49.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 (42) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/index.browser.es.js +383 -238
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +382 -238
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.d.ts +0 -1
  7. package/dist/index.es.js +383 -238
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/src/Call.d.ts +35 -1
  10. package/dist/src/StreamSfuClient.d.ts +8 -1
  11. package/dist/src/devices/DeviceManagerState.d.ts +13 -0
  12. package/dist/src/devices/MicrophoneManager.d.ts +0 -1
  13. package/dist/src/helpers/SlidingWindowRateLimiter.d.ts +28 -0
  14. package/dist/src/rtc/BasePeerConnection.d.ts +11 -2
  15. package/dist/src/rtc/index.d.ts +1 -0
  16. package/dist/src/rtc/types.d.ts +33 -1
  17. package/dist/src/types.d.ts +11 -0
  18. package/index.ts +0 -1
  19. package/package.json +1 -1
  20. package/src/Call.ts +179 -18
  21. package/src/StreamSfuClient.ts +75 -12
  22. package/src/__tests__/Call.publishing.test.ts +103 -0
  23. package/src/__tests__/StreamSfuClient.test.ts +275 -0
  24. package/src/devices/DeviceManagerState.ts +20 -0
  25. package/src/devices/MicrophoneManager.ts +9 -5
  26. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +28 -29
  27. package/src/devices/__tests__/ScreenShareManager.test.ts +0 -2
  28. package/src/devices/devices.ts +2 -1
  29. package/src/helpers/SlidingWindowRateLimiter.ts +49 -0
  30. package/src/helpers/__tests__/SlidingWindowRateLimiter.test.ts +43 -0
  31. package/src/rpc/retryable.ts +0 -1
  32. package/src/rtc/BasePeerConnection.ts +96 -6
  33. package/src/rtc/Publisher.ts +2 -1
  34. package/src/rtc/__tests__/Call.reconnect.test.ts +761 -0
  35. package/src/rtc/__tests__/Publisher.test.ts +210 -0
  36. package/src/rtc/__tests__/Subscriber.test.ts +56 -0
  37. package/src/rtc/index.ts +1 -0
  38. package/src/rtc/types.ts +38 -1
  39. package/src/types.ts +9 -0
  40. package/dist/src/helpers/RNSpeechDetector.d.ts +0 -23
  41. package/src/helpers/RNSpeechDetector.ts +0 -224
  42. package/src/helpers/__tests__/RNSpeechDetector.test.ts +0 -52
@@ -28,7 +28,7 @@ import {
28
28
  } from './gen/video/sfu/signal_rpc/signal';
29
29
  import { ICETrickle } from './gen/video/sfu/models/models';
30
30
  import { StreamClient } from './coordinator/connection/client';
31
- import { generateUUIDv4 } from './coordinator/connection/utils';
31
+ import { generateUUIDv4, sleep } from './coordinator/connection/utils';
32
32
  import { Credentials } from './gen/coordinator';
33
33
  import { ScopedLogger, videoLoggerSystem } from './logger';
34
34
  import {
@@ -104,6 +104,21 @@ type SfuWebSocketParams = {
104
104
  cid: string;
105
105
  };
106
106
 
107
+ /**
108
+ * Creates a fresh `joinResponseTask` with a no-op rejection handler attached
109
+ * to the underlying promise. The handler marks the rejection path as handled
110
+ * so a teardown-time reject (e.g., from `close()` during disposal) does not
111
+ * surface as an `UnhandledPromiseRejection`. Explicit awaiters of
112
+ * `StreamSfuClient.joinTask` still observe the rejection through their own
113
+ * `then`/`catch` chain. `.catch()` returns a new promise; the original is
114
+ * unchanged.
115
+ */
116
+ const makeJoinResponseTask = (): PromiseWithResolvers<JoinResponse> => {
117
+ const task = promiseWithResolvers<JoinResponse>();
118
+ task.promise.catch(() => {}); // see the comment above
119
+ return task;
120
+ };
121
+
107
122
  /**
108
123
  * The client used for exchanging information with the SFU.
109
124
  */
@@ -171,9 +186,10 @@ export class StreamSfuClient {
171
186
  private networkAvailableTask: PromiseWithResolvers<void> | undefined;
172
187
  /**
173
188
  * Promise that resolves when the JoinResponse is received.
174
- * Rejects after a certain threshold if the response is not received.
189
+ * Rejects after a certain threshold if the response is not received,
190
+ * or when the SFU client is disposed before a join completes.
175
191
  */
176
- private joinResponseTask = promiseWithResolvers<JoinResponse>();
192
+ private joinResponseTask = makeJoinResponseTask();
177
193
 
178
194
  /**
179
195
  * Promise that resolves when the migration is complete.
@@ -207,6 +223,12 @@ export class StreamSfuClient {
207
223
  * The close code used when the client fails to join the call (on the SFU).
208
224
  */
209
225
  static JOIN_FAILED = 4101;
226
+ /**
227
+ * Best-effort grace period in `leaveAndClose` for an in-flight join to
228
+ * complete before we give up and close without sending `leaveCallRequest`.
229
+ * Bounded so a stuck join can never hang the leave path.
230
+ */
231
+ static LEAVE_NOTIFY_GRACE_MS = 1000;
210
232
 
211
233
  /**
212
234
  * Constructs a new SFU client.
@@ -358,15 +380,24 @@ export class StreamSfuClient {
358
380
 
359
381
  close = (code: number = StreamSfuClient.NORMAL_CLOSURE, reason?: string) => {
360
382
  this.isClosingClean = code !== StreamSfuClient.ERROR_CONNECTION_UNHEALTHY;
361
- if (this.signalWs.readyState === WebSocket.OPEN) {
383
+ // Close the WebSocket whether it has fully opened (`OPEN`) or is still
384
+ // mid-handshake (`CONNECTING`). The WebSocket spec aborts the handshake
385
+ // when `close()` is called on a CONNECTING socket. Without this, an
386
+ // SFU socket that opens just after teardown would dispatch events into
387
+ // a Call instance that has already moved on.
388
+ const ws = this.signalWs;
389
+ if (
390
+ ws.readyState === WebSocket.OPEN ||
391
+ ws.readyState === WebSocket.CONNECTING
392
+ ) {
362
393
  this.logger.debug(`Closing SFU WS connection: ${code} - ${reason}`);
363
- this.signalWs.close(code, `js-client: ${reason}`);
364
- this.signalWs.removeEventListener('close', this.handleWebSocketClose);
394
+ ws.close(code, `js-client: ${reason}`);
395
+ ws.removeEventListener('close', this.handleWebSocketClose);
365
396
  }
366
- this.dispose();
397
+ this.dispose(reason);
367
398
  };
368
399
 
369
- private dispose = () => {
400
+ private dispose = (reason?: string) => {
370
401
  this.logger.debug('Disposing SFU client');
371
402
  this.unsubscribeIceTrickle();
372
403
  this.unsubscribeNetworkChanged();
@@ -375,6 +406,19 @@ export class StreamSfuClient {
375
406
  clearTimeout(this.migrateAwayTimeout);
376
407
  this.abortController.abort();
377
408
  this.migrationTask?.resolve();
409
+ // Settle a pending `joinResponseTask` so `leaveAndClose`, `join()`, and
410
+ // any other awaiters (`await this.joinTask`) don't hang indefinitely
411
+ // when the SFU client is torn down before the SFU sent a JoinResponse.
412
+ if (
413
+ !this.joinResponseTask.isResolved() &&
414
+ !this.joinResponseTask.isRejected()
415
+ ) {
416
+ this.joinResponseTask.reject(
417
+ new Error(
418
+ `SFU client disposed before join completed${reason ? `: ${reason}` : ''}`,
419
+ ),
420
+ );
421
+ }
378
422
  this.iceTrickleBuffer.dispose();
379
423
  };
380
424
 
@@ -385,8 +429,27 @@ export class StreamSfuClient {
385
429
  leaveAndClose = async (reason: string) => {
386
430
  try {
387
431
  this.isLeaving = true;
388
- await this.joinTask;
389
- await this.notifyLeave(reason);
432
+ // Best-effort: give an in-flight join a short grace period to complete
433
+ // so we can send a graceful `leaveCallRequest`. Bounded so we never hang
434
+ // here if the SFU is unresponsive. If the task settles either way during
435
+ // the wait, the re-check below decides whether to notify.
436
+ if (
437
+ !this.joinResponseTask.isResolved() &&
438
+ !this.joinResponseTask.isRejected()
439
+ ) {
440
+ await Promise.race([
441
+ // swallow rejection — we re-check `isResolved()` below to decide
442
+ this.joinResponseTask.promise.catch(() => {}),
443
+ sleep(StreamSfuClient.LEAVE_NOTIFY_GRACE_MS),
444
+ ]);
445
+ }
446
+ if (this.joinResponseTask.isResolved()) {
447
+ await this.notifyLeave(reason);
448
+ } else {
449
+ this.logger.debug(
450
+ '[leaveAndClose] join not completed within grace period, skipping notifyLeave',
451
+ );
452
+ }
390
453
  } catch (err) {
391
454
  this.logger.debug('Error notifying SFU about leaving call', err);
392
455
  }
@@ -535,9 +598,9 @@ export class StreamSfuClient {
535
598
  ) {
536
599
  // we need to lock the RPC requests until we receive a JoinResponse.
537
600
  // that's why we have this primitive lock mechanism.
538
- // the client starts with already initialized joinResponseTask,
601
+ // the client starts with an already initialized joinResponseTask,
539
602
  // and this code creates a new one for the next join request.
540
- this.joinResponseTask = promiseWithResolvers<JoinResponse>();
603
+ this.joinResponseTask = makeJoinResponseTask();
541
604
  }
542
605
 
543
606
  // capture a reference to the current joinResponseTask as it might
@@ -290,6 +290,109 @@ describe('Publishing and Unpublishing tracks', () => {
290
290
  expect(participant!.screenShareStream).toBeUndefined();
291
291
  expect(participant!.screenShareAudioStream).toBeUndefined();
292
292
  });
293
+
294
+ it('does not throw if sfuClient is cleared while the mute-state RPC is in flight', async () => {
295
+ let releaseMuteUpdate!: () => void;
296
+ let signalMuteUpdateEntered!: () => void;
297
+ const muteUpdateEntered = new Promise<void>(
298
+ (resolve) => (signalMuteUpdateEntered = resolve),
299
+ );
300
+ sfuClient.updateMuteStates = vi.fn().mockImplementation(() => {
301
+ signalMuteUpdateEntered();
302
+ return new Promise<void>((resolve) => (releaseMuteUpdate = resolve));
303
+ });
304
+
305
+ const track = new MediaStreamTrack() as MediaStreamAudioTrack;
306
+ const mediaStream = new MediaStream();
307
+ vi.spyOn(mediaStream, 'getAudioTracks').mockReturnValue([track]);
308
+
309
+ const inflight = call.publish(mediaStream, TrackType.AUDIO);
310
+
311
+ await muteUpdateEntered;
312
+
313
+ call['sfuClient'] = undefined;
314
+ releaseMuteUpdate();
315
+
316
+ await inflight;
317
+ });
318
+
319
+ it('updates local stream state when sfuClient is replaced with the same session id', async () => {
320
+ let releaseMuteUpdate!: () => void;
321
+ let signalMuteUpdateEntered!: () => void;
322
+ const muteUpdateEntered = new Promise<void>(
323
+ (resolve) => (signalMuteUpdateEntered = resolve),
324
+ );
325
+ sfuClient.updateMuteStates = vi.fn().mockImplementation(() => {
326
+ signalMuteUpdateEntered();
327
+ return new Promise<void>((resolve) => (releaseMuteUpdate = resolve));
328
+ });
329
+
330
+ const track = new MediaStreamTrack() as MediaStreamAudioTrack;
331
+ const mediaStream = new MediaStream();
332
+ vi.spyOn(mediaStream, 'getAudioTracks').mockReturnValue([track]);
333
+
334
+ const inflight = call.publish(mediaStream, TrackType.AUDIO);
335
+
336
+ await muteUpdateEntered;
337
+
338
+ const replacementSfuClient = vi.fn() as unknown as StreamSfuClient;
339
+ // @ts-expect-error sessionId is readonly
340
+ replacementSfuClient['sessionId'] = sessionId;
341
+ replacementSfuClient.updateMuteStates = vi.fn();
342
+ call['sfuClient'] = replacementSfuClient;
343
+ releaseMuteUpdate();
344
+
345
+ await inflight;
346
+
347
+ const participant = call.state.findParticipantBySessionId(sessionId);
348
+ expect(participant?.publishedTracks).toEqual([TrackType.AUDIO]);
349
+ expect(participant?.audioStream).toBe(mediaStream);
350
+ });
351
+
352
+ it('skips local stream state update when sfuClient is replaced with a new session id', async () => {
353
+ let releaseMuteUpdate!: () => void;
354
+ let signalMuteUpdateEntered!: () => void;
355
+ const muteUpdateEntered = new Promise<void>(
356
+ (resolve) => (signalMuteUpdateEntered = resolve),
357
+ );
358
+ sfuClient.updateMuteStates = vi.fn().mockImplementation(() => {
359
+ signalMuteUpdateEntered();
360
+ return new Promise<void>((resolve) => (releaseMuteUpdate = resolve));
361
+ });
362
+
363
+ const track = new MediaStreamTrack() as MediaStreamAudioTrack;
364
+ const mediaStream = new MediaStream();
365
+ vi.spyOn(mediaStream, 'getAudioTracks').mockReturnValue([track]);
366
+
367
+ const inflight = call.publish(mediaStream, TrackType.AUDIO);
368
+
369
+ await muteUpdateEntered;
370
+
371
+ const replacementSessionId = 'replacement-session-id';
372
+ // @ts-expect-error partial data
373
+ call.state.updateOrAddParticipant(replacementSessionId, {
374
+ sessionId: replacementSessionId,
375
+ publishedTracks: [],
376
+ });
377
+
378
+ const replacementSfuClient = vi.fn() as unknown as StreamSfuClient;
379
+ // @ts-expect-error sessionId is readonly
380
+ replacementSfuClient['sessionId'] = replacementSessionId;
381
+ replacementSfuClient.updateMuteStates = vi.fn();
382
+ call['sfuClient'] = replacementSfuClient;
383
+ releaseMuteUpdate();
384
+
385
+ await inflight;
386
+
387
+ const originalParticipant =
388
+ call.state.findParticipantBySessionId(sessionId);
389
+ const replacementParticipant =
390
+ call.state.findParticipantBySessionId(replacementSessionId);
391
+ expect(originalParticipant?.publishedTracks).toEqual([]);
392
+ expect(originalParticipant?.audioStream).toBeUndefined();
393
+ expect(replacementParticipant?.publishedTracks).toEqual([]);
394
+ expect(replacementParticipant?.audioStream).toBeUndefined();
395
+ });
293
396
  });
294
397
 
295
398
  describe('Deprecated methods', () => {
@@ -0,0 +1,275 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { StreamSfuClient } from '../StreamSfuClient';
3
+ import { Dispatcher } from '../rtc';
4
+ import { StreamClient } from '../coordinator/connection/client';
5
+
6
+ /**
7
+ * Minimal `WebSocket` stub used to drive `StreamSfuClient.close()` while the
8
+ * underlying connection is still in `CONNECTING` state. The constructor
9
+ * leaves `readyState = CONNECTING`; `close()` records the call and flips
10
+ * to `CLOSED` so subsequent assertions can see what happened.
11
+ */
12
+ class CapturingWebSocket {
13
+ static CONNECTING = 0;
14
+ static OPEN = 1;
15
+ static CLOSING = 2;
16
+ static CLOSED = 3;
17
+ static instances: CapturingWebSocket[] = [];
18
+
19
+ readyState = CapturingWebSocket.CONNECTING;
20
+ url: string;
21
+ binaryType = 'blob';
22
+ closeArgs: { code?: number; reason?: string } | undefined;
23
+ private listeners = new Map<string, Set<(e: unknown) => void>>();
24
+
25
+ constructor(url: string | URL) {
26
+ this.url = typeof url === 'string' ? url : url.toString();
27
+ CapturingWebSocket.instances.push(this);
28
+ }
29
+
30
+ addEventListener(event: string, listener: (e: unknown) => void) {
31
+ if (!this.listeners.has(event)) this.listeners.set(event, new Set());
32
+ this.listeners.get(event)!.add(listener);
33
+ }
34
+ removeEventListener(event: string, listener: (e: unknown) => void) {
35
+ this.listeners.get(event)?.delete(listener);
36
+ }
37
+ close(code?: number, reason?: string) {
38
+ this.closeArgs = { code, reason };
39
+ this.readyState = CapturingWebSocket.CLOSED;
40
+ }
41
+ }
42
+
43
+ const buildSfuClient = () => {
44
+ const dispatcher = new Dispatcher();
45
+ const streamClient = new StreamClient('test-key');
46
+ return new StreamSfuClient({
47
+ dispatcher,
48
+ sessionId: 'session-id-test',
49
+ streamClient,
50
+ cid: 'default:test',
51
+ credentials: {
52
+ server: {
53
+ url: 'https://test.invalid',
54
+ ws_endpoint: 'wss://test.invalid/ws',
55
+ edge_name: 'sfu-test',
56
+ },
57
+ token: 'token',
58
+ ice_servers: [],
59
+ },
60
+ tag: 'test',
61
+ enableTracing: false,
62
+ });
63
+ };
64
+
65
+ describe('StreamSfuClient.close()', () => {
66
+ beforeEach(() => {
67
+ CapturingWebSocket.instances = [];
68
+ vi.stubGlobal('WebSocket', CapturingWebSocket);
69
+ });
70
+
71
+ afterEach(() => {
72
+ vi.unstubAllGlobals();
73
+ vi.clearAllMocks();
74
+ });
75
+
76
+ it('closes the WebSocket even when it is still in CONNECTING state', () => {
77
+ const sfuClient = buildSfuClient();
78
+ const ws = CapturingWebSocket.instances.at(-1)!;
79
+ expect(ws.readyState).toBe(CapturingWebSocket.CONNECTING);
80
+
81
+ sfuClient.close(1000, 'tearing down');
82
+
83
+ expect(ws.closeArgs).toBeDefined();
84
+ expect(ws.closeArgs?.code).toBe(1000);
85
+ expect(ws.readyState).toBe(CapturingWebSocket.CLOSED);
86
+ });
87
+
88
+ it('rejects a pending joinResponseTask on close so awaiters do not hang', async () => {
89
+ const sfuClient = buildSfuClient();
90
+ const joinTask = sfuClient.joinTask;
91
+
92
+ sfuClient.close(1000, 'aborting');
93
+
94
+ await expect(joinTask).rejects.toThrow(/SFU client disposed/);
95
+ });
96
+
97
+ it('does not blow up when the WebSocket is already CLOSED', () => {
98
+ const sfuClient = buildSfuClient();
99
+ const ws = CapturingWebSocket.instances.at(-1)!;
100
+ ws.readyState = CapturingWebSocket.CLOSED;
101
+
102
+ expect(() => sfuClient.close(1000, 'noop')).not.toThrow();
103
+ // close() should not be called twice
104
+ expect(ws.closeArgs).toBeUndefined();
105
+ });
106
+
107
+ it('leaveAndClose returns within ~grace period when the SFU is silent (no hang)', async () => {
108
+ const sfuClient = buildSfuClient();
109
+ vi.spyOn(
110
+ sfuClient as unknown as {
111
+ notifyLeave: (reason: string) => Promise<void>;
112
+ },
113
+ 'notifyLeave',
114
+ ).mockResolvedValue(undefined);
115
+
116
+ // joinResponseTask stays pending forever — verify leaveAndClose still returns.
117
+ const start = Date.now();
118
+ await Promise.race([
119
+ sfuClient.leaveAndClose('silent-sfu'),
120
+ new Promise((_, reject) =>
121
+ setTimeout(
122
+ () => reject(new Error('leaveAndClose hung past 2x grace')),
123
+ StreamSfuClient.LEAVE_NOTIFY_GRACE_MS * 2,
124
+ ),
125
+ ),
126
+ ]);
127
+ const elapsed = Date.now() - start;
128
+ expect(elapsed).toBeLessThan(StreamSfuClient.LEAVE_NOTIFY_GRACE_MS * 2);
129
+ });
130
+
131
+ it('close() does NOT produce an unhandled rejection when nobody awaits joinTask', async () => {
132
+ // Capture unhandledrejection events that fire during this test. Without
133
+ // the safe-catch attached to `joinResponseTask.promise`, dispose-time
134
+ // reject would surface here.
135
+ const unhandled: PromiseRejectionEvent[] = [];
136
+ const onUnhandled = (e: PromiseRejectionEvent) => {
137
+ unhandled.push(e);
138
+ // mark as handled so it doesn't crash the test runner
139
+ e.preventDefault?.();
140
+ };
141
+ if (typeof window !== 'undefined') {
142
+ window.addEventListener('unhandledrejection', onUnhandled);
143
+ }
144
+ // Node-side fallback so the test passes regardless of test environment.
145
+ const onProcessUnhandled = (reason: unknown) => {
146
+ unhandled.push({ reason } as unknown as PromiseRejectionEvent);
147
+ };
148
+ process.on('unhandledRejection', onProcessUnhandled);
149
+
150
+ try {
151
+ const sfuClient = buildSfuClient();
152
+ // Intentionally do NOT touch joinTask anywhere — no .catch, no await.
153
+ sfuClient.close(1000, 'aborting before any join');
154
+
155
+ // give microtasks + a tick for any unhandledrejection event to fire
156
+ await new Promise((r) => setTimeout(r, 50));
157
+ expect(unhandled).toHaveLength(0);
158
+ } finally {
159
+ if (typeof window !== 'undefined') {
160
+ window.removeEventListener('unhandledrejection', onUnhandled);
161
+ }
162
+ process.off('unhandledRejection', onProcessUnhandled);
163
+ }
164
+ });
165
+ });
166
+
167
+ describe('StreamSfuClient.leaveAndClose()', () => {
168
+ beforeEach(() => {
169
+ CapturingWebSocket.instances = [];
170
+ vi.stubGlobal('WebSocket', CapturingWebSocket);
171
+ });
172
+
173
+ afterEach(() => {
174
+ vi.useRealTimers();
175
+ vi.unstubAllGlobals();
176
+ vi.clearAllMocks();
177
+ });
178
+
179
+ type JoinResponseTaskHandle = {
180
+ joinResponseTask: {
181
+ resolve: (v: unknown) => void;
182
+ reject: (err: unknown) => void;
183
+ };
184
+ };
185
+
186
+ it('notifies the SFU when joinResponseTask is already resolved', async () => {
187
+ const sfuClient = buildSfuClient();
188
+ (sfuClient as unknown as JoinResponseTaskHandle).joinResponseTask.resolve(
189
+ {},
190
+ );
191
+ const notifyLeaveSpy = vi
192
+ .spyOn(
193
+ sfuClient as unknown as {
194
+ notifyLeave: (reason: string) => Promise<void>;
195
+ },
196
+ 'notifyLeave',
197
+ )
198
+ .mockResolvedValue(undefined);
199
+
200
+ await sfuClient.leaveAndClose('user-leaving');
201
+
202
+ expect(notifyLeaveSpy).toHaveBeenCalledWith('user-leaving');
203
+ });
204
+
205
+ it('waits for an in-flight join and notifies the SFU when it resolves within the grace period', async () => {
206
+ const sfuClient = buildSfuClient();
207
+ const notifyLeaveSpy = vi
208
+ .spyOn(
209
+ sfuClient as unknown as {
210
+ notifyLeave: (reason: string) => Promise<void>;
211
+ },
212
+ 'notifyLeave',
213
+ )
214
+ .mockResolvedValue(undefined);
215
+
216
+ vi.useFakeTimers();
217
+ const leavePromise = sfuClient.leaveAndClose('user-leaving');
218
+
219
+ // simulate the SFU sending JoinResponse 50 ms in (well within the grace window)
220
+ await vi.advanceTimersByTimeAsync(50);
221
+ (sfuClient as unknown as JoinResponseTaskHandle).joinResponseTask.resolve(
222
+ {},
223
+ );
224
+ // flush remaining timers (the losing race branch and any microtasks)
225
+ await vi.runAllTimersAsync();
226
+ await leavePromise;
227
+
228
+ expect(notifyLeaveSpy).toHaveBeenCalledWith('user-leaving');
229
+ });
230
+
231
+ it('skips notifyLeave when the join does not complete within the grace period', async () => {
232
+ const sfuClient = buildSfuClient();
233
+ const notifyLeaveSpy = vi
234
+ .spyOn(
235
+ sfuClient as unknown as {
236
+ notifyLeave: (reason: string) => Promise<void>;
237
+ },
238
+ 'notifyLeave',
239
+ )
240
+ .mockResolvedValue(undefined);
241
+
242
+ vi.useFakeTimers();
243
+ const leavePromise = sfuClient.leaveAndClose('silent-sfu');
244
+ // run past the grace window — the task is never resolved
245
+ await vi.advanceTimersByTimeAsync(
246
+ StreamSfuClient.LEAVE_NOTIFY_GRACE_MS + 50,
247
+ );
248
+ await leavePromise;
249
+
250
+ expect(notifyLeaveSpy).not.toHaveBeenCalled();
251
+ });
252
+
253
+ it('skips notifyLeave when joinResponseTask rejects within the grace period', async () => {
254
+ const sfuClient = buildSfuClient();
255
+ const notifyLeaveSpy = vi
256
+ .spyOn(
257
+ sfuClient as unknown as {
258
+ notifyLeave: (reason: string) => Promise<void>;
259
+ },
260
+ 'notifyLeave',
261
+ )
262
+ .mockResolvedValue(undefined);
263
+
264
+ vi.useFakeTimers();
265
+ const leavePromise = sfuClient.leaveAndClose('rejected');
266
+ await vi.advanceTimersByTimeAsync(20);
267
+ (sfuClient as unknown as JoinResponseTaskHandle).joinResponseTask.reject(
268
+ new Error('SFU went away'),
269
+ );
270
+ await vi.runAllTimersAsync();
271
+ await leavePromise;
272
+
273
+ expect(notifyLeaveSpy).not.toHaveBeenCalled();
274
+ });
275
+ });
@@ -19,6 +19,9 @@ export abstract class DeviceManagerState<C = MediaTrackConstraints> {
19
19
  protected mediaStreamSubject = new BehaviorSubject<MediaStream | undefined>(
20
20
  undefined,
21
21
  );
22
+ protected rootMediaStreamSubject = new BehaviorSubject<
23
+ MediaStream | undefined
24
+ >(undefined);
22
25
  protected selectedDeviceSubject = new BehaviorSubject<string | undefined>(
23
26
  undefined,
24
27
  );
@@ -37,6 +40,13 @@ export abstract class DeviceManagerState<C = MediaTrackConstraints> {
37
40
  */
38
41
  mediaStream$ = this.mediaStreamSubject.asObservable();
39
42
 
43
+ /**
44
+ * An Observable that emits the raw device media stream (before any filters are applied),
45
+ * or `undefined` if the device is currently disabled. When no filters are active, this
46
+ * emits the same stream as `mediaStream$`.
47
+ */
48
+ rootMediaStream$ = this.rootMediaStreamSubject.asObservable();
49
+
40
50
  /**
41
51
  * An Observable that emits the currently selected device
42
52
  */
@@ -134,6 +144,15 @@ export abstract class DeviceManagerState<C = MediaTrackConstraints> {
134
144
  return RxUtils.getCurrentValue(this.mediaStream$);
135
145
  }
136
146
 
147
+ /**
148
+ * The raw device media stream (before any filters are applied), or `undefined`
149
+ * if the device is currently disabled. When no filters are active, this is the
150
+ * same as `mediaStream`.
151
+ */
152
+ get rootMediaStream() {
153
+ return RxUtils.getCurrentValue(this.rootMediaStream$);
154
+ }
155
+
137
156
  /**
138
157
  * @internal
139
158
  * @param status
@@ -163,6 +182,7 @@ export abstract class DeviceManagerState<C = MediaTrackConstraints> {
163
182
  rootStream: MediaStream | undefined,
164
183
  ) {
165
184
  RxUtils.setCurrentValue(this.mediaStreamSubject, stream);
185
+ RxUtils.setCurrentValue(this.rootMediaStreamSubject, rootStream);
166
186
  if (rootStream) {
167
187
  this.setDevice(this.getDeviceIdFromStream(rootStream));
168
188
  }
@@ -24,7 +24,6 @@ import {
24
24
  createSafeAsyncSubscription,
25
25
  createSubscription,
26
26
  } from '../store/rxUtils';
27
- import { RNSpeechDetector } from '../helpers/RNSpeechDetector';
28
27
  import { withoutConcurrency } from '../helpers/concurrency';
29
28
  import { disposeOfMediaStream } from './utils';
30
29
  import { promiseWithResolvers } from '../helpers/promise';
@@ -36,7 +35,6 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
36
35
  private soundDetectorCleanup?: () => Promise<void>;
37
36
  private soundDetectorDeviceId?: string;
38
37
  private noAudioDetectorCleanup?: () => Promise<void>;
39
- private rnSpeechDetector: RNSpeechDetector | undefined;
40
38
  private noiseCancellation: INoiseCancellation | undefined;
41
39
  private noiseCancellationChangeUnsubscribe: (() => void) | undefined;
42
40
  private noiseCancellationRegistration?: Promise<void>;
@@ -422,13 +420,19 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
422
420
  await this.teardownSpeakingWhileMutedDetection();
423
421
 
424
422
  if (isReactNative()) {
425
- this.rnSpeechDetector = new RNSpeechDetector();
426
- const unsubscribe = await this.rnSpeechDetector.start((event) => {
423
+ const speechActivity =
424
+ globalThis.streamRNVideoSDK?.nativeEvents?.speechActivity;
425
+ if (!speechActivity) {
426
+ this.logger.warn(
427
+ 'Native speech activity not available, make sure the "@stream-io/react-native-webrtc" peer dependency version is satisfied',
428
+ );
429
+ return;
430
+ }
431
+ const unsubscribe = speechActivity.subscribe((event) => {
427
432
  this.state.setSpeakingWhileMuted(event.isSoundDetected);
428
433
  });
429
434
  this.soundDetectorCleanup = async () => {
430
435
  unsubscribe();
431
- this.rnSpeechDetector = undefined;
432
436
  };
433
437
  } else {
434
438
  // Need to start a new stream that's not connected to publisher