@stream-io/video-client 1.42.0 → 1.42.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.
- package/CHANGELOG.md +12 -0
- package/dist/index.browser.es.js +134 -50
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +134 -50
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +134 -50
- package/dist/index.es.js.map +1 -1
- package/dist/src/StreamSfuClient.d.ts +1 -6
- package/dist/src/devices/MicrophoneManager.d.ts +1 -0
- package/dist/src/errors/SfuJoinError.d.ts +7 -0
- package/dist/src/errors/index.d.ts +1 -0
- package/dist/src/gen/coordinator/index.d.ts +6 -0
- package/dist/src/rtc/BasePeerConnection.d.ts +1 -0
- package/dist/src/rtc/Dispatcher.d.ts +36 -2
- package/dist/src/types.d.ts +6 -0
- package/index.ts +1 -0
- package/package.json +1 -1
- package/src/Call.ts +22 -5
- package/src/StreamSfuClient.ts +36 -37
- package/src/devices/MicrophoneManager.ts +27 -2
- package/src/devices/__tests__/MicrophoneManager.test.ts +19 -0
- package/src/devices/__tests__/MicrophoneManagerRN.test.ts +42 -0
- package/src/errors/SfuJoinError.ts +26 -0
- package/src/errors/index.ts +1 -0
- package/src/events/internal.ts +4 -4
- package/src/events/speaker.ts +2 -2
- package/src/gen/coordinator/index.ts +6 -0
- package/src/rtc/BasePeerConnection.ts +3 -1
- package/src/rtc/Dispatcher.ts +55 -13
- package/src/rtc/__tests__/Dispatcher.test.ts +28 -0
- package/src/rtc/__tests__/Publisher.test.ts +3 -0
- package/src/rtc/__tests__/Subscriber.test.ts +2 -1
- package/src/types.ts +6 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Dispatcher, IceTrickleBuffer } from './rtc';
|
|
2
|
-
import {
|
|
2
|
+
import { JoinRequest, JoinResponse } from './gen/video/sfu/event/events';
|
|
3
3
|
import { ICERestartRequest, SendAnswerRequest, SendStatsRequest, SetPublisherRequest, TrackMuteState, TrackSubscriptionDetails } from './gen/video/sfu/signal_rpc/signal';
|
|
4
4
|
import { ICETrickle } from './gen/video/sfu/models/models';
|
|
5
5
|
import { StreamClient } from './coordinator/connection/client';
|
|
@@ -170,8 +170,3 @@ export declare class StreamSfuClient {
|
|
|
170
170
|
private keepAlive;
|
|
171
171
|
private scheduleConnectionCheck;
|
|
172
172
|
}
|
|
173
|
-
export declare class SfuJoinError extends Error {
|
|
174
|
-
errorEvent: SfuErrorEvent;
|
|
175
|
-
unrecoverable: boolean;
|
|
176
|
-
constructor(event: SfuErrorEvent);
|
|
177
|
-
}
|
|
@@ -69,4 +69,5 @@ export declare class MicrophoneManager extends AudioDeviceManager<MicrophoneMana
|
|
|
69
69
|
protected doSetAudioBitrateProfile(profile: AudioBitrateProfile): void;
|
|
70
70
|
private startSpeakingWhileMutedDetection;
|
|
71
71
|
private stopSpeakingWhileMutedDetection;
|
|
72
|
+
private hasPermission;
|
|
72
73
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Error as SfuErrorEvent } from '../gen/video/sfu/event/events';
|
|
2
|
+
export declare class SfuJoinError extends Error {
|
|
3
|
+
errorEvent: SfuErrorEvent;
|
|
4
|
+
unrecoverable: boolean;
|
|
5
|
+
constructor(event: SfuErrorEvent);
|
|
6
|
+
static isJoinErrorCode(event: SfuErrorEvent): boolean;
|
|
7
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './SfuJoinError';
|
|
@@ -5358,6 +5358,12 @@ export interface JoinCallRequest {
|
|
|
5358
5358
|
* @memberof JoinCallRequest
|
|
5359
5359
|
*/
|
|
5360
5360
|
migrating_from?: string;
|
|
5361
|
+
/**
|
|
5362
|
+
* List of SFU IDs to exclude when picking a new SFU for the participant
|
|
5363
|
+
* @type {Array<string>}
|
|
5364
|
+
* @memberof JoinCallRequest
|
|
5365
|
+
*/
|
|
5366
|
+
migrating_from_list?: Array<string>;
|
|
5361
5367
|
/**
|
|
5362
5368
|
*
|
|
5363
5369
|
* @type {boolean}
|
|
@@ -18,6 +18,7 @@ export declare abstract class BasePeerConnection {
|
|
|
18
18
|
protected readonly state: CallState;
|
|
19
19
|
protected readonly dispatcher: Dispatcher;
|
|
20
20
|
protected readonly clientPublishOptions?: ClientPublishOptions;
|
|
21
|
+
protected readonly tag: string;
|
|
21
22
|
protected sfuClient: StreamSfuClient;
|
|
22
23
|
private onReconnectionNeeded?;
|
|
23
24
|
private readonly iceRestartDelay;
|
|
@@ -15,11 +15,45 @@ export type DispatchableMessage<K extends SfuEventKinds> = {
|
|
|
15
15
|
[Key in K]: AllSfuEvents[Key];
|
|
16
16
|
};
|
|
17
17
|
};
|
|
18
|
+
/**
|
|
19
|
+
* Determines if a given event name belongs to the category of SFU events.
|
|
20
|
+
*
|
|
21
|
+
* @param eventName the name of the event to check.
|
|
22
|
+
* @returns true if the event name is an SFU event, otherwise false.
|
|
23
|
+
*/
|
|
18
24
|
export declare const isSfuEvent: (eventName: SfuEventKinds | EventTypes) => eventName is SfuEventKinds;
|
|
19
25
|
export declare class Dispatcher {
|
|
20
26
|
private readonly logger;
|
|
21
27
|
private subscribers;
|
|
28
|
+
/**
|
|
29
|
+
* Dispatch an event to all subscribers.
|
|
30
|
+
*
|
|
31
|
+
* @param message the event payload to dispatch.
|
|
32
|
+
* @param tag for scoping events to a specific tag. Use `*` dispatch to every tag.
|
|
33
|
+
*/
|
|
22
34
|
dispatch: <K extends SfuEventKinds>(message: DispatchableMessage<K>, tag?: string) => void;
|
|
23
|
-
|
|
24
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Emit an event to a list of listeners.
|
|
37
|
+
*
|
|
38
|
+
* @param payload the event payload to emit.
|
|
39
|
+
* @param listeners the list of listeners to emit the event to.
|
|
40
|
+
*/
|
|
41
|
+
emit: (payload: any, listeners?: CallEventListener<any>[]) => void;
|
|
42
|
+
/**
|
|
43
|
+
* Subscribe to an event.
|
|
44
|
+
*
|
|
45
|
+
* @param eventName the name of the event to subscribe to.
|
|
46
|
+
* @param tag for scoping events to a specific tag. Use `*` dispatch to every tag.
|
|
47
|
+
* @param fn the callback function to invoke when the event is emitted.
|
|
48
|
+
* @returns a function that can be called to unsubscribe from the event.
|
|
49
|
+
*/
|
|
50
|
+
on: <E extends keyof AllSfuEvents>(eventName: E, tag: string, fn: CallEventListener<E>) => () => void;
|
|
51
|
+
/**
|
|
52
|
+
* Unsubscribe from an event.
|
|
53
|
+
*
|
|
54
|
+
* @param eventName the name of the event to unsubscribe from.
|
|
55
|
+
* @param tag for scoping events to a specific tag. Use `*` dispatch to every tag.
|
|
56
|
+
* @param fn the callback function to remove from the event listeners.
|
|
57
|
+
*/
|
|
58
|
+
off: <E extends keyof AllSfuEvents>(eventName: E, tag: string, fn: CallEventListener<E>) => void;
|
|
25
59
|
}
|
package/dist/src/types.d.ts
CHANGED
|
@@ -297,6 +297,12 @@ export type StreamRNVideoSDKGlobals = {
|
|
|
297
297
|
*/
|
|
298
298
|
stop(): void;
|
|
299
299
|
};
|
|
300
|
+
permissions: {
|
|
301
|
+
/**
|
|
302
|
+
* Checks whether a native device permission has been granted.
|
|
303
|
+
*/
|
|
304
|
+
check(permission: 'microphone' | 'camera'): Promise<boolean>;
|
|
305
|
+
};
|
|
300
306
|
};
|
|
301
307
|
declare global {
|
|
302
308
|
var streamRNVideoSDK: StreamRNVideoSDKGlobals | undefined;
|
package/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ export * from './src/CallType';
|
|
|
14
14
|
export * from './src/StreamVideoClient';
|
|
15
15
|
export * from './src/StreamSfuClient';
|
|
16
16
|
export * from './src/devices';
|
|
17
|
+
export * from './src/errors';
|
|
17
18
|
export * from './src/store';
|
|
18
19
|
export * from './src/sorting';
|
|
19
20
|
export * from './src/helpers/client-details';
|
package/package.json
CHANGED
package/src/Call.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { StreamSfuClient } from './StreamSfuClient';
|
|
2
|
+
import { SfuJoinError } from './errors';
|
|
2
3
|
import {
|
|
3
4
|
BasePeerConnectionOpts,
|
|
4
5
|
Dispatcher,
|
|
@@ -561,7 +562,7 @@ export class Call {
|
|
|
561
562
|
fn: CallEventListener<E>,
|
|
562
563
|
) => {
|
|
563
564
|
if (isSfuEvent(eventName)) {
|
|
564
|
-
return this.dispatcher.on(eventName, fn);
|
|
565
|
+
return this.dispatcher.on(eventName, '*', fn);
|
|
565
566
|
}
|
|
566
567
|
|
|
567
568
|
const offHandler = this.streamClient.on(eventName, (e) => {
|
|
@@ -589,7 +590,7 @@ export class Call {
|
|
|
589
590
|
fn: CallEventListener<E>,
|
|
590
591
|
) => {
|
|
591
592
|
if (isSfuEvent(eventName)) {
|
|
592
|
-
return this.dispatcher.off(eventName, fn);
|
|
593
|
+
return this.dispatcher.off(eventName, '*', fn);
|
|
593
594
|
}
|
|
594
595
|
|
|
595
596
|
// unsubscribe from the stream client event by using the 'off' reference
|
|
@@ -918,6 +919,7 @@ export class Call {
|
|
|
918
919
|
this.logger.trace(`Joining call (${attempt})`, this.cid);
|
|
919
920
|
await this.doJoin(data);
|
|
920
921
|
delete joinData.migrating_from;
|
|
922
|
+
delete joinData.migrating_from_list;
|
|
921
923
|
break;
|
|
922
924
|
} catch (err) {
|
|
923
925
|
this.logger.warn(`Failed to join call (${attempt})`, this.cid);
|
|
@@ -931,11 +933,17 @@ export class Call {
|
|
|
931
933
|
throw err;
|
|
932
934
|
}
|
|
933
935
|
|
|
936
|
+
// immediately switch to a different SFU in case of recoverable join error
|
|
937
|
+
const switchSfu =
|
|
938
|
+
err instanceof SfuJoinError &&
|
|
939
|
+
SfuJoinError.isJoinErrorCode(err.errorEvent);
|
|
940
|
+
|
|
934
941
|
const sfuId = this.credentials?.server.edge_name || '';
|
|
935
942
|
const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
|
|
936
943
|
sfuJoinFailures.set(sfuId, failures);
|
|
937
|
-
if (failures >= 2) {
|
|
944
|
+
if (switchSfu || failures >= 2) {
|
|
938
945
|
joinData.migrating_from = sfuId;
|
|
946
|
+
joinData.migrating_from_list = Array.from(sfuJoinFailures.keys());
|
|
939
947
|
}
|
|
940
948
|
|
|
941
949
|
if (attempt === maxJoinRetries - 1) {
|
|
@@ -1611,11 +1619,16 @@ export class Call {
|
|
|
1611
1619
|
|
|
1612
1620
|
try {
|
|
1613
1621
|
const currentSfu = currentSfuClient.edgeName;
|
|
1614
|
-
await this.doJoin({
|
|
1622
|
+
await this.doJoin({
|
|
1623
|
+
...this.joinCallData,
|
|
1624
|
+
migrating_from: currentSfu,
|
|
1625
|
+
migrating_from_list: [currentSfu],
|
|
1626
|
+
});
|
|
1615
1627
|
} finally {
|
|
1616
1628
|
// cleanup the migration_from field after the migration is complete or failed
|
|
1617
1629
|
// as we don't want to keep dirty data in the join call data
|
|
1618
1630
|
delete this.joinCallData?.migrating_from;
|
|
1631
|
+
delete this.joinCallData?.migrating_from_list;
|
|
1619
1632
|
}
|
|
1620
1633
|
|
|
1621
1634
|
await this.restorePublishedTracks();
|
|
@@ -1660,6 +1673,10 @@ export class Call {
|
|
|
1660
1673
|
// handles the "error" event, through which the SFU can request a reconnect
|
|
1661
1674
|
const unregisterOnError = this.on('error', (e) => {
|
|
1662
1675
|
const { reconnectStrategy: strategy, error } = e;
|
|
1676
|
+
// SFU_FULL is a join error, and when emitted, although it specifies a
|
|
1677
|
+
// `migrate` strategy, we should actually perform a REJOIN to a new SFU.
|
|
1678
|
+
// This is now handled separately in the `call.join()` method.
|
|
1679
|
+
if (SfuJoinError.isJoinErrorCode(e)) return;
|
|
1663
1680
|
if (strategy === WebsocketReconnectStrategy.UNSPECIFIED) return;
|
|
1664
1681
|
if (strategy === WebsocketReconnectStrategy.DISCONNECT) {
|
|
1665
1682
|
this.leave({ message: 'SFU instructed to disconnect' }).catch((err) => {
|
package/src/StreamSfuClient.ts
CHANGED
|
@@ -14,7 +14,6 @@ import {
|
|
|
14
14
|
SfuEventKinds,
|
|
15
15
|
} from './rtc';
|
|
16
16
|
import {
|
|
17
|
-
Error as SfuErrorEvent,
|
|
18
17
|
JoinRequest,
|
|
19
18
|
JoinResponse,
|
|
20
19
|
SfuRequest,
|
|
@@ -27,10 +26,7 @@ import {
|
|
|
27
26
|
TrackMuteState,
|
|
28
27
|
TrackSubscriptionDetails,
|
|
29
28
|
} from './gen/video/sfu/signal_rpc/signal';
|
|
30
|
-
import {
|
|
31
|
-
ICETrickle,
|
|
32
|
-
WebsocketReconnectStrategy,
|
|
33
|
-
} from './gen/video/sfu/models/models';
|
|
29
|
+
import { ICETrickle } from './gen/video/sfu/models/models';
|
|
34
30
|
import { StreamClient } from './coordinator/connection/client';
|
|
35
31
|
import { generateUUIDv4 } from './coordinator/connection/utils';
|
|
36
32
|
import { Credentials } from './gen/coordinator';
|
|
@@ -43,6 +39,7 @@ import {
|
|
|
43
39
|
} from './helpers/promise';
|
|
44
40
|
import { getTimers } from './timers';
|
|
45
41
|
import { Tracer, TraceSlice } from './stats';
|
|
42
|
+
import { SfuJoinError } from './errors';
|
|
46
43
|
|
|
47
44
|
export type StreamSfuClientConstructor = {
|
|
48
45
|
/**
|
|
@@ -250,8 +247,8 @@ export class StreamSfuClient {
|
|
|
250
247
|
// In that case, those events (ICE candidates) need to be buffered
|
|
251
248
|
// and later added to the appropriate PeerConnection
|
|
252
249
|
// once the remoteDescription is known and set.
|
|
253
|
-
this.unsubscribeIceTrickle = dispatcher.on('iceTrickle', (
|
|
254
|
-
this.iceTrickleBuffer.push(
|
|
250
|
+
this.unsubscribeIceTrickle = dispatcher.on('iceTrickle', tag, (t) => {
|
|
251
|
+
this.iceTrickleBuffer.push(t);
|
|
255
252
|
});
|
|
256
253
|
|
|
257
254
|
// listen to network changes to handle offline state
|
|
@@ -504,6 +501,7 @@ export class StreamSfuClient {
|
|
|
504
501
|
const task = (this.migrationTask = promiseWithResolvers());
|
|
505
502
|
const unsubscribe = this.dispatcher.on(
|
|
506
503
|
'participantMigrationComplete',
|
|
504
|
+
this.tag,
|
|
507
505
|
() => {
|
|
508
506
|
unsubscribe();
|
|
509
507
|
clearTimeout(this.migrateAwayTimeout);
|
|
@@ -541,27 +539,40 @@ export class StreamSfuClient {
|
|
|
541
539
|
const current = this.joinResponseTask;
|
|
542
540
|
|
|
543
541
|
let timeoutId: NodeJS.Timeout | undefined = undefined;
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
clearTimeout(timeoutId);
|
|
549
|
-
unsubscribe?.();
|
|
550
|
-
unsubscribeJoinErrorEvents();
|
|
551
|
-
current.reject(new SfuJoinError(event));
|
|
552
|
-
}
|
|
553
|
-
});
|
|
554
|
-
const unsubscribe = this.dispatcher.on('joinResponse', (joinResponse) => {
|
|
542
|
+
let unsubscribeJoinResponse: (() => void) | undefined = undefined;
|
|
543
|
+
let unsubscribeJoinErrorEvents: (() => void) | undefined = undefined;
|
|
544
|
+
|
|
545
|
+
const cleanupJoinSubscriptions = () => {
|
|
555
546
|
clearTimeout(timeoutId);
|
|
556
|
-
|
|
557
|
-
unsubscribeJoinErrorEvents();
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
547
|
+
timeoutId = undefined;
|
|
548
|
+
unsubscribeJoinErrorEvents?.();
|
|
549
|
+
unsubscribeJoinErrorEvents = undefined;
|
|
550
|
+
unsubscribeJoinResponse?.();
|
|
551
|
+
unsubscribeJoinResponse = undefined;
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
unsubscribeJoinErrorEvents = this.dispatcher.on(
|
|
555
|
+
'error',
|
|
556
|
+
this.tag,
|
|
557
|
+
(event) => {
|
|
558
|
+
if (SfuJoinError.isJoinErrorCode(event)) {
|
|
559
|
+
cleanupJoinSubscriptions();
|
|
560
|
+
current.reject(new SfuJoinError(event));
|
|
561
|
+
}
|
|
562
|
+
},
|
|
563
|
+
);
|
|
564
|
+
unsubscribeJoinResponse = this.dispatcher.on(
|
|
565
|
+
'joinResponse',
|
|
566
|
+
this.tag,
|
|
567
|
+
(joinResponse) => {
|
|
568
|
+
cleanupJoinSubscriptions();
|
|
569
|
+
this.keepAlive();
|
|
570
|
+
current.resolve(joinResponse);
|
|
571
|
+
},
|
|
572
|
+
);
|
|
561
573
|
|
|
562
574
|
timeoutId = setTimeout(() => {
|
|
563
|
-
|
|
564
|
-
unsubscribeJoinErrorEvents();
|
|
575
|
+
cleanupJoinSubscriptions();
|
|
565
576
|
const message = `Waiting for "joinResponse" has timed out after ${this.joinResponseTimeout}ms`;
|
|
566
577
|
this.tracer?.trace('joinRequestTimeout', message);
|
|
567
578
|
current.reject(new Error(message));
|
|
@@ -647,15 +658,3 @@ export class StreamSfuClient {
|
|
|
647
658
|
}, this.unhealthyTimeoutInMs);
|
|
648
659
|
};
|
|
649
660
|
}
|
|
650
|
-
|
|
651
|
-
export class SfuJoinError extends Error {
|
|
652
|
-
errorEvent: SfuErrorEvent;
|
|
653
|
-
unrecoverable: boolean;
|
|
654
|
-
|
|
655
|
-
constructor(event: SfuErrorEvent) {
|
|
656
|
-
super(event.error?.message || 'Join Error');
|
|
657
|
-
this.errorEvent = event;
|
|
658
|
-
this.unrecoverable =
|
|
659
|
-
event.reconnectStrategy === WebsocketReconnectStrategy.DISCONNECT;
|
|
660
|
-
}
|
|
661
|
-
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
AudioDeviceManager,
|
|
6
6
|
createAudioConstraints,
|
|
7
7
|
} from './AudioDeviceManager';
|
|
8
|
+
import { type BrowserPermissionState } from './BrowserPermission';
|
|
8
9
|
import { MicrophoneManagerState } from './MicrophoneManagerState';
|
|
9
10
|
import { TrackDisableMode } from './DeviceManagerState';
|
|
10
11
|
import { getAudioDevices, getAudioStream } from './devices';
|
|
@@ -56,8 +57,15 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
56
57
|
this.call.state.ownCapabilities$,
|
|
57
58
|
this.state.selectedDevice$,
|
|
58
59
|
this.state.status$,
|
|
60
|
+
this.state.browserPermissionState$,
|
|
59
61
|
]),
|
|
60
|
-
async ([
|
|
62
|
+
async ([
|
|
63
|
+
callingState,
|
|
64
|
+
ownCapabilities,
|
|
65
|
+
deviceId,
|
|
66
|
+
status,
|
|
67
|
+
permissionState,
|
|
68
|
+
]) => {
|
|
61
69
|
try {
|
|
62
70
|
if (callingState === CallingState.LEFT) {
|
|
63
71
|
await this.stopSpeakingWhileMutedDetection();
|
|
@@ -66,7 +74,8 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
66
74
|
if (!this.speakingWhileMutedNotificationEnabled) return;
|
|
67
75
|
|
|
68
76
|
if (ownCapabilities.includes(OwnCapability.SEND_AUDIO)) {
|
|
69
|
-
|
|
77
|
+
const hasPermission = await this.hasPermission(permissionState);
|
|
78
|
+
if (hasPermission && status !== 'enabled') {
|
|
70
79
|
await this.startSpeakingWhileMutedDetection(deviceId);
|
|
71
80
|
} else {
|
|
72
81
|
await this.stopSpeakingWhileMutedDetection();
|
|
@@ -407,4 +416,20 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
407
416
|
await soundDetectorCleanup();
|
|
408
417
|
});
|
|
409
418
|
}
|
|
419
|
+
|
|
420
|
+
private async hasPermission(
|
|
421
|
+
permissionState: BrowserPermissionState,
|
|
422
|
+
): Promise<boolean> {
|
|
423
|
+
if (!isReactNative()) return permissionState === 'granted';
|
|
424
|
+
|
|
425
|
+
const nativePermissions = globalThis.streamRNVideoSDK?.permissions;
|
|
426
|
+
if (!nativePermissions) return true; // assume granted
|
|
427
|
+
|
|
428
|
+
try {
|
|
429
|
+
return await nativePermissions.check('microphone');
|
|
430
|
+
} catch (err) {
|
|
431
|
+
this.logger.warn('Failed to check permission', err);
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
410
435
|
}
|
|
@@ -78,6 +78,9 @@ describe('MicrophoneManager', () => {
|
|
|
78
78
|
|
|
79
79
|
beforeEach(() => {
|
|
80
80
|
setupAudioContextMock();
|
|
81
|
+
vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
|
|
82
|
+
of('granted'),
|
|
83
|
+
);
|
|
81
84
|
|
|
82
85
|
call = new Call({
|
|
83
86
|
id: '',
|
|
@@ -167,6 +170,20 @@ describe('MicrophoneManager', () => {
|
|
|
167
170
|
expect(fn).toHaveBeenCalled();
|
|
168
171
|
});
|
|
169
172
|
|
|
173
|
+
it('should not start sound detection if browser mic permission is denied', async () => {
|
|
174
|
+
vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
|
|
175
|
+
of('denied'),
|
|
176
|
+
);
|
|
177
|
+
const innerManager = new MicrophoneManager(call, 'disable-tracks');
|
|
178
|
+
// @ts-expect-error private api
|
|
179
|
+
const fn = vi.spyOn(innerManager, 'startSpeakingWhileMutedDetection');
|
|
180
|
+
|
|
181
|
+
await innerManager.enable();
|
|
182
|
+
|
|
183
|
+
await sleep(25);
|
|
184
|
+
expect(fn).not.toHaveBeenCalled();
|
|
185
|
+
});
|
|
186
|
+
|
|
170
187
|
it(`should stop sound detection if mic is enabled`, async () => {
|
|
171
188
|
manager.state.setSpeakingWhileMuted(true);
|
|
172
189
|
manager['soundDetectorCleanup'] = async () => {};
|
|
@@ -177,6 +194,7 @@ describe('MicrophoneManager', () => {
|
|
|
177
194
|
await withoutConcurrency(syncTag, () => Promise.resolve());
|
|
178
195
|
await settled(syncTag);
|
|
179
196
|
|
|
197
|
+
await sleep(25);
|
|
180
198
|
expect(manager.state.speakingWhileMuted).toBe(false);
|
|
181
199
|
});
|
|
182
200
|
|
|
@@ -513,6 +531,7 @@ describe('MicrophoneManager', () => {
|
|
|
513
531
|
});
|
|
514
532
|
|
|
515
533
|
afterEach(() => {
|
|
534
|
+
vi.restoreAllMocks();
|
|
516
535
|
vi.clearAllMocks();
|
|
517
536
|
vi.resetModules();
|
|
518
537
|
});
|
|
@@ -61,7 +61,21 @@ vi.mock('../../helpers/RNSpeechDetector.ts', () => {
|
|
|
61
61
|
|
|
62
62
|
describe('MicrophoneManager React Native', () => {
|
|
63
63
|
let manager: MicrophoneManager;
|
|
64
|
+
let checkPermissionMock: ReturnType<typeof vi.fn>;
|
|
64
65
|
beforeEach(() => {
|
|
66
|
+
checkPermissionMock = vi.fn(async () => true);
|
|
67
|
+
|
|
68
|
+
globalThis.streamRNVideoSDK = {
|
|
69
|
+
callManager: {
|
|
70
|
+
setup: vi.fn(),
|
|
71
|
+
start: vi.fn(),
|
|
72
|
+
stop: vi.fn(),
|
|
73
|
+
},
|
|
74
|
+
permissions: {
|
|
75
|
+
check: checkPermissionMock,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
65
79
|
manager = new MicrophoneManager(
|
|
66
80
|
new Call({
|
|
67
81
|
id: '',
|
|
@@ -83,6 +97,30 @@ describe('MicrophoneManager React Native', () => {
|
|
|
83
97
|
expect(manager['rnSpeechDetector']?.start).toHaveBeenCalled();
|
|
84
98
|
});
|
|
85
99
|
|
|
100
|
+
it('should check native microphone permission before starting detection', async () => {
|
|
101
|
+
await manager.enable();
|
|
102
|
+
await manager.disable();
|
|
103
|
+
|
|
104
|
+
await vi.waitUntil(() => checkPermissionMock.mock.calls.length > 0, {
|
|
105
|
+
timeout: 100,
|
|
106
|
+
});
|
|
107
|
+
expect(checkPermissionMock).toHaveBeenCalledWith('microphone');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should not start sound detection if native microphone permission is denied', async () => {
|
|
111
|
+
checkPermissionMock.mockResolvedValue(false);
|
|
112
|
+
|
|
113
|
+
await manager.enable();
|
|
114
|
+
// @ts-expect-error - private method
|
|
115
|
+
const fn = vi.spyOn(manager, 'startSpeakingWhileMutedDetection');
|
|
116
|
+
await manager.disable();
|
|
117
|
+
|
|
118
|
+
await vi.waitUntil(() => checkPermissionMock.mock.calls.length > 0, {
|
|
119
|
+
timeout: 100,
|
|
120
|
+
});
|
|
121
|
+
expect(fn).not.toHaveBeenCalled();
|
|
122
|
+
});
|
|
123
|
+
|
|
86
124
|
it(`should stop sound detection if mic is enabled`, async () => {
|
|
87
125
|
manager.state.setSpeakingWhileMuted(true);
|
|
88
126
|
manager['soundDetectorCleanup'] = async () => {};
|
|
@@ -94,6 +132,9 @@ describe('MicrophoneManager React Native', () => {
|
|
|
94
132
|
await withoutConcurrency(syncTag, () => Promise.resolve());
|
|
95
133
|
await settled(syncTag);
|
|
96
134
|
|
|
135
|
+
await vi.waitUntil(() => manager.state.speakingWhileMuted === false, {
|
|
136
|
+
timeout: 100,
|
|
137
|
+
});
|
|
97
138
|
expect(manager.state.speakingWhileMuted).toBe(false);
|
|
98
139
|
});
|
|
99
140
|
|
|
@@ -139,6 +180,7 @@ describe('MicrophoneManager React Native', () => {
|
|
|
139
180
|
});
|
|
140
181
|
|
|
141
182
|
afterEach(() => {
|
|
183
|
+
globalThis.streamRNVideoSDK = undefined;
|
|
142
184
|
vi.clearAllMocks();
|
|
143
185
|
vi.resetModules();
|
|
144
186
|
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Error as SfuErrorEvent } from '../gen/video/sfu/event/events';
|
|
2
|
+
import {
|
|
3
|
+
ErrorCode,
|
|
4
|
+
WebsocketReconnectStrategy,
|
|
5
|
+
} from '../gen/video/sfu/models/models';
|
|
6
|
+
|
|
7
|
+
export class SfuJoinError extends Error {
|
|
8
|
+
errorEvent: SfuErrorEvent;
|
|
9
|
+
unrecoverable: boolean;
|
|
10
|
+
|
|
11
|
+
constructor(event: SfuErrorEvent) {
|
|
12
|
+
super(event.error?.message || 'Join Error');
|
|
13
|
+
this.errorEvent = event;
|
|
14
|
+
this.unrecoverable =
|
|
15
|
+
event.reconnectStrategy === WebsocketReconnectStrategy.DISCONNECT;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
static isJoinErrorCode(event: SfuErrorEvent): boolean {
|
|
19
|
+
const code = event.error?.code;
|
|
20
|
+
return (
|
|
21
|
+
code === ErrorCode.SFU_FULL ||
|
|
22
|
+
code === ErrorCode.SFU_SHUTTING_DOWN ||
|
|
23
|
+
code === ErrorCode.CALL_PARTICIPANT_LIMIT_REACHED
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './SfuJoinError';
|
package/src/events/internal.ts
CHANGED
|
@@ -18,7 +18,7 @@ export const watchConnectionQualityChanged = (
|
|
|
18
18
|
dispatcher: Dispatcher,
|
|
19
19
|
state: CallState,
|
|
20
20
|
) => {
|
|
21
|
-
return dispatcher.on('connectionQualityChanged', (e) => {
|
|
21
|
+
return dispatcher.on('connectionQualityChanged', '*', (e) => {
|
|
22
22
|
const { connectionQualityUpdates } = e;
|
|
23
23
|
if (!connectionQualityUpdates) return;
|
|
24
24
|
state.updateParticipants(
|
|
@@ -44,7 +44,7 @@ export const watchParticipantCountChanged = (
|
|
|
44
44
|
dispatcher: Dispatcher,
|
|
45
45
|
state: CallState,
|
|
46
46
|
) => {
|
|
47
|
-
return dispatcher.on('healthCheckResponse', (e) => {
|
|
47
|
+
return dispatcher.on('healthCheckResponse', '*', (e) => {
|
|
48
48
|
const { participantCount } = e;
|
|
49
49
|
if (participantCount) {
|
|
50
50
|
state.setParticipantCount(participantCount.total);
|
|
@@ -54,7 +54,7 @@ export const watchParticipantCountChanged = (
|
|
|
54
54
|
};
|
|
55
55
|
|
|
56
56
|
export const watchLiveEnded = (dispatcher: Dispatcher, call: Call) => {
|
|
57
|
-
return dispatcher.on('error', (e) => {
|
|
57
|
+
return dispatcher.on('error', '*', (e) => {
|
|
58
58
|
if (e.error && e.error.code !== ErrorCode.LIVE_ENDED) return;
|
|
59
59
|
|
|
60
60
|
call.state.setBackstage(true);
|
|
@@ -70,7 +70,7 @@ export const watchLiveEnded = (dispatcher: Dispatcher, call: Call) => {
|
|
|
70
70
|
* Watches and logs the errors reported by the currently connected SFU.
|
|
71
71
|
*/
|
|
72
72
|
export const watchSfuErrorReports = (dispatcher: Dispatcher) => {
|
|
73
|
-
return dispatcher.on('error', (e) => {
|
|
73
|
+
return dispatcher.on('error', '*', (e) => {
|
|
74
74
|
if (!e.error) return;
|
|
75
75
|
const logger = videoLoggerSystem.getLogger('SfuClient');
|
|
76
76
|
const { error, reconnectStrategy } = e;
|
package/src/events/speaker.ts
CHANGED
|
@@ -9,7 +9,7 @@ export const watchDominantSpeakerChanged = (
|
|
|
9
9
|
dispatcher: Dispatcher,
|
|
10
10
|
state: CallState,
|
|
11
11
|
) => {
|
|
12
|
-
return dispatcher.on('dominantSpeakerChanged', (e) => {
|
|
12
|
+
return dispatcher.on('dominantSpeakerChanged', '*', (e) => {
|
|
13
13
|
const { sessionId } = e;
|
|
14
14
|
if (sessionId === state.dominantSpeaker?.sessionId) return;
|
|
15
15
|
state.setParticipants((participants) =>
|
|
@@ -41,7 +41,7 @@ export const watchAudioLevelChanged = (
|
|
|
41
41
|
dispatcher: Dispatcher,
|
|
42
42
|
state: CallState,
|
|
43
43
|
) => {
|
|
44
|
-
return dispatcher.on('audioLevelChanged', (e) => {
|
|
44
|
+
return dispatcher.on('audioLevelChanged', '*', (e) => {
|
|
45
45
|
const { audioLevels } = e;
|
|
46
46
|
state.updateParticipants(
|
|
47
47
|
audioLevels.reduce<StreamVideoParticipantPatches>((patches, current) => {
|
|
@@ -5362,6 +5362,12 @@ export interface JoinCallRequest {
|
|
|
5362
5362
|
* @memberof JoinCallRequest
|
|
5363
5363
|
*/
|
|
5364
5364
|
migrating_from?: string;
|
|
5365
|
+
/**
|
|
5366
|
+
* List of SFU IDs to exclude when picking a new SFU for the participant
|
|
5367
|
+
* @type {Array<string>}
|
|
5368
|
+
* @memberof JoinCallRequest
|
|
5369
|
+
*/
|
|
5370
|
+
migrating_from_list?: Array<string>;
|
|
5365
5371
|
/**
|
|
5366
5372
|
*
|
|
5367
5373
|
* @type {boolean}
|
|
@@ -27,6 +27,7 @@ export abstract class BasePeerConnection {
|
|
|
27
27
|
protected readonly state: CallState;
|
|
28
28
|
protected readonly dispatcher: Dispatcher;
|
|
29
29
|
protected readonly clientPublishOptions?: ClientPublishOptions;
|
|
30
|
+
protected readonly tag: string;
|
|
30
31
|
protected sfuClient: StreamSfuClient;
|
|
31
32
|
|
|
32
33
|
private onReconnectionNeeded?: OnReconnectionNeeded;
|
|
@@ -67,6 +68,7 @@ export abstract class BasePeerConnection {
|
|
|
67
68
|
this.dispatcher = dispatcher;
|
|
68
69
|
this.iceRestartDelay = iceRestartDelay;
|
|
69
70
|
this.clientPublishOptions = clientPublishOptions;
|
|
71
|
+
this.tag = tag;
|
|
70
72
|
this.onReconnectionNeeded = onReconnectionNeeded;
|
|
71
73
|
this.logger = videoLoggerSystem.getLogger(
|
|
72
74
|
peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher',
|
|
@@ -162,7 +164,7 @@ export abstract class BasePeerConnection {
|
|
|
162
164
|
fn: CallEventListener<E>,
|
|
163
165
|
): void => {
|
|
164
166
|
this.subscriptions.push(
|
|
165
|
-
this.dispatcher.on(event, (e) => {
|
|
167
|
+
this.dispatcher.on(event, this.tag, (e) => {
|
|
166
168
|
const lockKey = `pc.${this.lock}.${event}`;
|
|
167
169
|
withoutConcurrency(lockKey, async () => fn(e)).catch((err) => {
|
|
168
170
|
if (this.isDisposed) return;
|