@stream-io/video-client 1.45.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.
- package/CHANGELOG.md +20 -0
- package/dist/index.browser.es.js +186 -49
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +186 -49
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +186 -49
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +5 -1
- package/dist/src/gen/video/sfu/event/events.d.ts +4 -0
- package/dist/src/helpers/DynascaleManager.d.ts +20 -0
- package/dist/src/helpers/RNSpeechDetector.d.ts +4 -2
- package/dist/src/types.d.ts +37 -5
- package/package.json +1 -1
- package/src/Call.ts +92 -40
- package/src/devices/MicrophoneManager.ts +7 -1
- package/src/devices/SpeakerManager.ts +1 -0
- package/src/events/__tests__/participant.test.ts +41 -0
- package/src/events/call.ts +3 -0
- package/src/events/participant.ts +1 -0
- package/src/gen/video/sfu/event/events.ts +5 -0
- package/src/helpers/DynascaleManager.ts +72 -1
- package/src/helpers/RNSpeechDetector.ts +52 -12
- package/src/helpers/__tests__/DynascaleManager.test.ts +120 -0
- package/src/helpers/__tests__/RNSpeechDetector.test.ts +52 -0
- package/src/store/stateStore.ts +1 -1
- package/src/types.ts +48 -5
|
@@ -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,27 +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
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
|
|
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) => {
|
|
33
38
|
e.streams[0].getTracks().forEach((track) => {
|
|
34
39
|
// In RN, the remote track is automatically added to the audio output device
|
|
35
40
|
// so we need to mute it to avoid hearing the audio back
|
|
36
41
|
// @ts-expect-error _setVolume is a private method in react-native-webrtc
|
|
37
42
|
track._setVolume(0);
|
|
38
43
|
});
|
|
39
|
-
}
|
|
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
|
+
};
|
|
40
54
|
|
|
41
55
|
audioStream
|
|
42
56
|
.getTracks()
|
|
@@ -47,14 +61,19 @@ export class RNSpeechDetector {
|
|
|
47
61
|
const answer = await this.pc2.createAnswer();
|
|
48
62
|
await this.pc1.setRemoteDescription(answer);
|
|
49
63
|
await this.pc2.setLocalDescription(answer);
|
|
50
|
-
|
|
64
|
+
unsubscribe = this.onSpeakingDetectedStateChange(
|
|
51
65
|
onSoundDetectedStateChanged,
|
|
52
66
|
);
|
|
53
67
|
return () => {
|
|
54
|
-
|
|
68
|
+
detachListeners?.();
|
|
69
|
+
unsubscribe?.();
|
|
55
70
|
this.stop();
|
|
56
71
|
};
|
|
57
72
|
} catch (error) {
|
|
73
|
+
detachListeners?.();
|
|
74
|
+
unsubscribe?.();
|
|
75
|
+
this.stop();
|
|
76
|
+
|
|
58
77
|
const logger = videoLoggerSystem.getLogger('RNSpeechDetector');
|
|
59
78
|
logger.error('error handling permissions: ', error);
|
|
60
79
|
return () => {};
|
|
@@ -65,6 +84,9 @@ export class RNSpeechDetector {
|
|
|
65
84
|
* Stops the speech detection and releases all allocated resources.
|
|
66
85
|
*/
|
|
67
86
|
private stop() {
|
|
87
|
+
if (this.isStopped) return;
|
|
88
|
+
this.isStopped = true;
|
|
89
|
+
|
|
68
90
|
this.pc1.close();
|
|
69
91
|
this.pc2.close();
|
|
70
92
|
|
|
@@ -181,4 +203,22 @@ export class RNSpeechDetector {
|
|
|
181
203
|
this.audioStream.release();
|
|
182
204
|
}
|
|
183
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
|
+
}
|
|
184
224
|
}
|
|
@@ -18,6 +18,7 @@ import { DynascaleManager } from '../DynascaleManager';
|
|
|
18
18
|
import { Call } from '../../Call';
|
|
19
19
|
import { StreamClient } from '../../coordinator/connection/client';
|
|
20
20
|
import { StreamVideoWriteableStateStore } from '../../store';
|
|
21
|
+
import { getCurrentValue } from '../../store/rxUtils';
|
|
21
22
|
import { VisibilityState } from '../../types';
|
|
22
23
|
import { noopComparator } from '../../sorting';
|
|
23
24
|
import { TrackType } from '../../gen/video/sfu/models/models';
|
|
@@ -647,6 +648,125 @@ describe('DynascaleManager', () => {
|
|
|
647
648
|
expect(unregisterSpy).toHaveBeenCalledWith('session-id', 'audioTrack');
|
|
648
649
|
});
|
|
649
650
|
|
|
651
|
+
it('audio: should track blocked audio elements on NotAllowedError', async () => {
|
|
652
|
+
vi.useFakeTimers();
|
|
653
|
+
const audioElement = document.createElement('audio');
|
|
654
|
+
Object.defineProperty(audioElement, 'srcObject', { writable: true });
|
|
655
|
+
const notAllowedError = new DOMException('', 'NotAllowedError');
|
|
656
|
+
vi.spyOn(audioElement, 'play').mockRejectedValue(notAllowedError);
|
|
657
|
+
|
|
658
|
+
// @ts-expect-error incomplete data
|
|
659
|
+
call.state.updateOrAddParticipant('session-id', {
|
|
660
|
+
userId: 'user-id',
|
|
661
|
+
sessionId: 'session-id',
|
|
662
|
+
publishedTracks: [],
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
const cleanup = dynascaleManager.bindAudioElement(
|
|
666
|
+
audioElement,
|
|
667
|
+
'session-id',
|
|
668
|
+
'audioTrack',
|
|
669
|
+
);
|
|
670
|
+
|
|
671
|
+
const mediaStream = new MediaStream();
|
|
672
|
+
call.state.updateParticipant('session-id', {
|
|
673
|
+
audioStream: mediaStream,
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
vi.runAllTimers();
|
|
677
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
678
|
+
|
|
679
|
+
expect(getCurrentValue(dynascaleManager.autoplayBlocked$)).toBe(true);
|
|
680
|
+
|
|
681
|
+
cleanup?.();
|
|
682
|
+
expect(getCurrentValue(dynascaleManager.autoplayBlocked$)).toBe(false);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it('audio: should unblock audio elements on explicit resumeAudio call', async () => {
|
|
686
|
+
vi.useFakeTimers();
|
|
687
|
+
const audioElement = document.createElement('audio');
|
|
688
|
+
Object.defineProperty(audioElement, 'srcObject', { writable: true });
|
|
689
|
+
const playSpy = vi
|
|
690
|
+
.spyOn(audioElement, 'play')
|
|
691
|
+
.mockRejectedValueOnce(new DOMException('', 'NotAllowedError'))
|
|
692
|
+
.mockResolvedValue(undefined);
|
|
693
|
+
|
|
694
|
+
// @ts-expect-error incomplete data
|
|
695
|
+
call.state.updateOrAddParticipant('session-id', {
|
|
696
|
+
userId: 'user-id',
|
|
697
|
+
sessionId: 'session-id',
|
|
698
|
+
publishedTracks: [],
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
const cleanup = dynascaleManager.bindAudioElement(
|
|
702
|
+
audioElement,
|
|
703
|
+
'session-id',
|
|
704
|
+
'audioTrack',
|
|
705
|
+
);
|
|
706
|
+
|
|
707
|
+
const mediaStream = new MediaStream();
|
|
708
|
+
call.state.updateParticipant('session-id', {
|
|
709
|
+
audioStream: mediaStream,
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
vi.runAllTimers();
|
|
713
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
714
|
+
|
|
715
|
+
expect(getCurrentValue(dynascaleManager.autoplayBlocked$)).toBe(true);
|
|
716
|
+
|
|
717
|
+
await dynascaleManager.resumeAudio();
|
|
718
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
719
|
+
|
|
720
|
+
expect(playSpy).toHaveBeenCalledTimes(2);
|
|
721
|
+
expect(getCurrentValue(dynascaleManager.autoplayBlocked$)).toBe(false);
|
|
722
|
+
|
|
723
|
+
cleanup?.();
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it('audio: should clear blocked state when the audio stream is removed', async () => {
|
|
727
|
+
vi.useFakeTimers();
|
|
728
|
+
const audioElement = document.createElement('audio');
|
|
729
|
+
Object.defineProperty(audioElement, 'srcObject', { writable: true });
|
|
730
|
+
vi.spyOn(audioElement, 'play').mockRejectedValue(
|
|
731
|
+
new DOMException('', 'NotAllowedError'),
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
// @ts-expect-error incomplete data
|
|
735
|
+
call.state.updateOrAddParticipant('session-id', {
|
|
736
|
+
userId: 'user-id',
|
|
737
|
+
sessionId: 'session-id',
|
|
738
|
+
publishedTracks: [],
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
const cleanup = dynascaleManager.bindAudioElement(
|
|
742
|
+
audioElement,
|
|
743
|
+
'session-id',
|
|
744
|
+
'audioTrack',
|
|
745
|
+
);
|
|
746
|
+
|
|
747
|
+
const mediaStream = new MediaStream();
|
|
748
|
+
call.state.updateParticipant('session-id', {
|
|
749
|
+
audioStream: mediaStream,
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
vi.runAllTimers();
|
|
753
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
754
|
+
|
|
755
|
+
expect(getCurrentValue(dynascaleManager.autoplayBlocked$)).toBe(true);
|
|
756
|
+
|
|
757
|
+
call.state.updateParticipant('session-id', {
|
|
758
|
+
audioStream: undefined,
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
vi.runAllTimers();
|
|
762
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
763
|
+
|
|
764
|
+
expect(audioElement.srcObject).toBeNull();
|
|
765
|
+
expect(getCurrentValue(dynascaleManager.autoplayBlocked$)).toBe(false);
|
|
766
|
+
|
|
767
|
+
cleanup?.();
|
|
768
|
+
});
|
|
769
|
+
|
|
650
770
|
it('audio: should warn when binding an already-bound session', () => {
|
|
651
771
|
const watchdog = dynascaleManager.audioBindingsWatchdog!;
|
|
652
772
|
// @ts-expect-error private property
|
|
@@ -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
|
+
});
|
package/src/store/stateStore.ts
CHANGED
|
@@ -42,7 +42,7 @@ export class StreamVideoWriteableStateStore {
|
|
|
42
42
|
* The currently connected user.
|
|
43
43
|
*/
|
|
44
44
|
get connectedUser(): OwnUserResponse | undefined {
|
|
45
|
-
return
|
|
45
|
+
return this.connectedUserSubject.getValue();
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/**
|
package/src/types.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
import type { Comparator } from './sorting';
|
|
24
24
|
import type { StreamVideoWriteableStateStore } from './store';
|
|
25
25
|
import { AxiosError } from 'axios';
|
|
26
|
+
import type { Call } from './Call';
|
|
26
27
|
|
|
27
28
|
export type StreamReaction = Pick<
|
|
28
29
|
ReactionResponse,
|
|
@@ -392,26 +393,68 @@ export type StartCallRecordingFnType = {
|
|
|
392
393
|
): Promise<StartRecordingResponse>;
|
|
393
394
|
};
|
|
394
395
|
|
|
396
|
+
type StreamRNVideoSDKCallManagerRingingParams = {
|
|
397
|
+
isRingingTypeCall: boolean;
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
type StreamRNVideoSDKCallManagerSetupParams =
|
|
401
|
+
StreamRNVideoSDKCallManagerRingingParams & {
|
|
402
|
+
defaultDevice: AudioSettingsRequestDefaultDeviceEnum;
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
type StreamRNVideoSDKEndCallReason =
|
|
406
|
+
/** Call ended by the local user (e.g., hanging up). */
|
|
407
|
+
| 'local'
|
|
408
|
+
/** Call ended by the remote party, or outgoing call was not answered. */
|
|
409
|
+
| 'remote'
|
|
410
|
+
/** Call was rejected/declined by the user. */
|
|
411
|
+
| 'rejected'
|
|
412
|
+
/** Remote party was busy. */
|
|
413
|
+
| 'busy'
|
|
414
|
+
/** Call was answered on another device. */
|
|
415
|
+
| 'answeredElsewhere'
|
|
416
|
+
/** No response to an incoming call. */
|
|
417
|
+
| 'missed'
|
|
418
|
+
/** Call failed due to an error (e.g., network issue). */
|
|
419
|
+
| 'error'
|
|
420
|
+
/** Call was canceled before the remote party could answer. */
|
|
421
|
+
| 'canceled'
|
|
422
|
+
/** Call restricted (e.g., airplane mode, dialing restrictions). */
|
|
423
|
+
| 'restricted'
|
|
424
|
+
/** Unknown or unspecified disconnect reason. */
|
|
425
|
+
| 'unknown';
|
|
426
|
+
|
|
427
|
+
type StreamRNVideoSDKCallingX = {
|
|
428
|
+
joinCall: (call: Call, activeCalls: Call[]) => Promise<void>;
|
|
429
|
+
endCall: (
|
|
430
|
+
call: Call,
|
|
431
|
+
reason?: StreamRNVideoSDKEndCallReason,
|
|
432
|
+
) => Promise<void>;
|
|
433
|
+
registerOutgoingCall: (call: Call) => Promise<void>;
|
|
434
|
+
};
|
|
435
|
+
|
|
395
436
|
export type StreamRNVideoSDKGlobals = {
|
|
437
|
+
callingX: StreamRNVideoSDKCallingX;
|
|
396
438
|
callManager: {
|
|
397
439
|
/**
|
|
398
440
|
* Sets up the in call manager.
|
|
399
441
|
*/
|
|
400
442
|
setup({
|
|
401
443
|
defaultDevice,
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
}): void;
|
|
444
|
+
isRingingTypeCall,
|
|
445
|
+
}: StreamRNVideoSDKCallManagerSetupParams): void;
|
|
405
446
|
|
|
406
447
|
/**
|
|
407
448
|
* Starts the in call manager.
|
|
408
449
|
*/
|
|
409
|
-
start(
|
|
450
|
+
start({
|
|
451
|
+
isRingingTypeCall,
|
|
452
|
+
}: StreamRNVideoSDKCallManagerRingingParams): void;
|
|
410
453
|
|
|
411
454
|
/**
|
|
412
455
|
* Stops the in call manager.
|
|
413
456
|
*/
|
|
414
|
-
stop(): void;
|
|
457
|
+
stop({ isRingingTypeCall }: StreamRNVideoSDKCallManagerRingingParams): void;
|
|
415
458
|
};
|
|
416
459
|
permissions: {
|
|
417
460
|
/**
|