@stream-io/video-client 1.46.0 → 1.46.1

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.
@@ -1,9 +1,10 @@
1
1
  import { SoundStateChangeHandler } from './sound-detector';
2
2
  export declare class RNSpeechDetector {
3
- private pc1;
4
- private pc2;
3
+ private readonly pc1;
4
+ private readonly pc2;
5
5
  private audioStream;
6
6
  private externalAudioStream;
7
+ private isStopped;
7
8
  constructor(externalAudioStream?: MediaStream);
8
9
  /**
9
10
  * Starts the speech detection.
@@ -18,4 +19,5 @@ export declare class RNSpeechDetector {
18
19
  */
19
20
  private onSpeakingDetectedStateChange;
20
21
  private cleanupAudioStream;
22
+ private forwardIceCandidate;
21
23
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-client",
3
- "version": "1.46.0",
3
+ "version": "1.46.1",
4
4
  "main": "dist/index.cjs.js",
5
5
  "module": "dist/index.es.js",
6
6
  "browser": "dist/index.browser.es.js",
@@ -3,10 +3,11 @@ import { SoundStateChangeHandler } from './sound-detector';
3
3
  import { videoLoggerSystem } from '../logger';
4
4
 
5
5
  export class RNSpeechDetector {
6
- private pc1 = new RTCPeerConnection({});
7
- private pc2 = new RTCPeerConnection({});
6
+ private readonly pc1 = new RTCPeerConnection({});
7
+ private readonly pc2 = new RTCPeerConnection({});
8
8
  private audioStream: MediaStream | undefined;
9
9
  private externalAudioStream: MediaStream | undefined;
10
+ private isStopped = false;
10
11
 
11
12
  constructor(externalAudioStream?: MediaStream) {
12
13
  this.externalAudioStream = externalAudioStream;
@@ -16,31 +17,40 @@ export class RNSpeechDetector {
16
17
  * Starts the speech detection.
17
18
  */
18
19
  public async start(onSoundDetectedStateChanged: SoundStateChangeHandler) {
20
+ let detachListeners: (() => void) | undefined;
21
+ let unsubscribe: (() => void) | undefined;
22
+
19
23
  try {
24
+ this.isStopped = false;
20
25
  const audioStream =
21
26
  this.externalAudioStream != null
22
27
  ? this.externalAudioStream
23
28
  : await navigator.mediaDevices.getUserMedia({ audio: true });
24
29
  this.audioStream = audioStream;
25
30
 
26
- this.pc1.addEventListener('icecandidate', (e) => {
27
- this.pc2.addIceCandidate(e.candidate).catch(() => {
28
- // do nothing
29
- });
30
- });
31
- this.pc2.addEventListener('icecandidate', async (e) => {
32
- this.pc1.addIceCandidate(e.candidate).catch(() => {
33
- // do nothing
34
- });
35
- });
36
- this.pc2.addEventListener('track', (e) => {
31
+ const onPc1IceCandidate = (e: RTCPeerConnectionIceEvent) => {
32
+ this.forwardIceCandidate(this.pc2, e.candidate);
33
+ };
34
+ const onPc2IceCandidate = (e: RTCPeerConnectionIceEvent) => {
35
+ this.forwardIceCandidate(this.pc1, e.candidate);
36
+ };
37
+ const onTrackPc2 = (e: RTCTrackEvent) => {
37
38
  e.streams[0].getTracks().forEach((track) => {
38
39
  // In RN, the remote track is automatically added to the audio output device
39
40
  // so we need to mute it to avoid hearing the audio back
40
41
  // @ts-expect-error _setVolume is a private method in react-native-webrtc
41
42
  track._setVolume(0);
42
43
  });
43
- });
44
+ };
45
+
46
+ this.pc1.addEventListener('icecandidate', onPc1IceCandidate);
47
+ this.pc2.addEventListener('icecandidate', onPc2IceCandidate);
48
+ this.pc2.addEventListener('track', onTrackPc2);
49
+ detachListeners = () => {
50
+ this.pc1.removeEventListener('icecandidate', onPc1IceCandidate);
51
+ this.pc2.removeEventListener('icecandidate', onPc2IceCandidate);
52
+ this.pc2.removeEventListener('track', onTrackPc2);
53
+ };
44
54
 
45
55
  audioStream
46
56
  .getTracks()
@@ -51,14 +61,19 @@ export class RNSpeechDetector {
51
61
  const answer = await this.pc2.createAnswer();
52
62
  await this.pc1.setRemoteDescription(answer);
53
63
  await this.pc2.setLocalDescription(answer);
54
- const unsubscribe = this.onSpeakingDetectedStateChange(
64
+ unsubscribe = this.onSpeakingDetectedStateChange(
55
65
  onSoundDetectedStateChanged,
56
66
  );
57
67
  return () => {
58
- unsubscribe();
68
+ detachListeners?.();
69
+ unsubscribe?.();
59
70
  this.stop();
60
71
  };
61
72
  } catch (error) {
73
+ detachListeners?.();
74
+ unsubscribe?.();
75
+ this.stop();
76
+
62
77
  const logger = videoLoggerSystem.getLogger('RNSpeechDetector');
63
78
  logger.error('error handling permissions: ', error);
64
79
  return () => {};
@@ -69,6 +84,9 @@ export class RNSpeechDetector {
69
84
  * Stops the speech detection and releases all allocated resources.
70
85
  */
71
86
  private stop() {
87
+ if (this.isStopped) return;
88
+ this.isStopped = true;
89
+
72
90
  this.pc1.close();
73
91
  this.pc2.close();
74
92
 
@@ -185,4 +203,22 @@ export class RNSpeechDetector {
185
203
  this.audioStream.release();
186
204
  }
187
205
  }
206
+
207
+ private forwardIceCandidate(
208
+ destination: RTCPeerConnection,
209
+ candidate: RTCIceCandidate | null,
210
+ ) {
211
+ if (
212
+ this.isStopped ||
213
+ !candidate ||
214
+ destination.signalingState === 'closed'
215
+ ) {
216
+ return;
217
+ }
218
+ destination.addIceCandidate(candidate).catch(() => {
219
+ // silently ignore the error
220
+ const logger = videoLoggerSystem.getLogger('RNSpeechDetector');
221
+ logger.info('cannot add ice candidate - ignoring');
222
+ });
223
+ }
188
224
  }
@@ -0,0 +1,52 @@
1
+ import '../../rtc/__tests__/mocks/webrtc.mocks';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { RNSpeechDetector } from '../RNSpeechDetector';
4
+
5
+ describe('RNSpeechDetector', () => {
6
+ // Shared test setup stubs RTCPeerConnection with a vi.fn constructor.
7
+ // We keep a typed handle to that constructor to inspect created instances.
8
+ let rtcPeerConnectionMockCtor: ReturnType<typeof vi.fn>;
9
+
10
+ beforeEach(() => {
11
+ rtcPeerConnectionMockCtor =
12
+ globalThis.RTCPeerConnection as unknown as ReturnType<typeof vi.fn>;
13
+ rtcPeerConnectionMockCtor.mockClear();
14
+ });
15
+
16
+ afterEach(() => {
17
+ vi.restoreAllMocks();
18
+ });
19
+
20
+ it('ignores late ICE candidates after cleanup', async () => {
21
+ const stream = {
22
+ getTracks: () => [],
23
+ } as unknown as MediaStream;
24
+ const detector = new RNSpeechDetector(stream);
25
+
26
+ const cleanup = await detector.start(() => {});
27
+ cleanup();
28
+
29
+ // start() creates two peer connections (pc1 and pc2). We pull them from
30
+ // constructor call results to inspect listener wiring and ICE forwarding.
31
+ const [pc1, pc2] = rtcPeerConnectionMockCtor.mock.results.map(
32
+ (result) => result.value,
33
+ );
34
+
35
+ // Find the registered ICE callback and invoke it manually after cleanup to
36
+ // simulate a late ICE event arriving during teardown.
37
+ const onIceCandidate = pc1.addEventListener.mock.calls.find(
38
+ ([eventName]: [string]) => eventName === 'icecandidate',
39
+ )?.[1] as ((e: RTCPeerConnectionIceEvent) => void) | undefined;
40
+
41
+ expect(onIceCandidate).toBeDefined();
42
+ onIceCandidate?.({
43
+ candidate: { candidate: 'candidate:1 1 UDP 0 127.0.0.1 11111 typ host' },
44
+ } as unknown as RTCPeerConnectionIceEvent);
45
+
46
+ expect(pc1.removeEventListener).toHaveBeenCalledWith(
47
+ 'icecandidate',
48
+ onIceCandidate,
49
+ );
50
+ expect(pc2.addIceCandidate).not.toHaveBeenCalled();
51
+ });
52
+ });