@stream-io/video-client 1.5.0 → 1.6.0-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.
- package/CHANGELOG.md +175 -0
- package/dist/index.browser.es.js +1986 -1482
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +1983 -1478
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +1986 -1482
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +93 -9
- package/dist/src/StreamSfuClient.d.ts +73 -56
- package/dist/src/StreamVideoClient.d.ts +2 -2
- package/dist/src/coordinator/connection/client.d.ts +3 -4
- package/dist/src/coordinator/connection/types.d.ts +5 -1
- package/dist/src/devices/InputMediaDeviceManager.d.ts +4 -0
- package/dist/src/devices/MicrophoneManager.d.ts +1 -1
- package/dist/src/events/callEventHandlers.d.ts +1 -3
- package/dist/src/events/internal.d.ts +4 -0
- package/dist/src/gen/video/sfu/event/events.d.ts +106 -4
- package/dist/src/gen/video/sfu/models/models.d.ts +64 -65
- package/dist/src/helpers/ensureExhausted.d.ts +1 -0
- package/dist/src/helpers/withResolvers.d.ts +14 -0
- package/dist/src/logger.d.ts +1 -0
- package/dist/src/rpc/createClient.d.ts +2 -0
- package/dist/src/rpc/index.d.ts +1 -0
- package/dist/src/rpc/retryable.d.ts +23 -0
- package/dist/src/rtc/Dispatcher.d.ts +1 -1
- package/dist/src/rtc/IceTrickleBuffer.d.ts +0 -1
- package/dist/src/rtc/Publisher.d.ts +24 -25
- package/dist/src/rtc/Subscriber.d.ts +12 -11
- package/dist/src/rtc/helpers/rtcConfiguration.d.ts +2 -0
- package/dist/src/rtc/helpers/tracks.d.ts +3 -3
- package/dist/src/rtc/signal.d.ts +1 -1
- package/dist/src/store/CallState.d.ts +46 -2
- package/package.json +3 -3
- package/src/Call.ts +628 -566
- package/src/StreamSfuClient.ts +276 -246
- package/src/StreamVideoClient.ts +15 -16
- package/src/coordinator/connection/client.ts +25 -8
- package/src/coordinator/connection/connection.ts +1 -0
- package/src/coordinator/connection/types.ts +6 -0
- package/src/devices/CameraManager.ts +1 -1
- package/src/devices/InputMediaDeviceManager.ts +12 -3
- package/src/devices/MicrophoneManager.ts +3 -3
- package/src/devices/devices.ts +1 -1
- package/src/events/__tests__/mutes.test.ts +10 -13
- package/src/events/__tests__/participant.test.ts +75 -0
- package/src/events/callEventHandlers.ts +4 -7
- package/src/events/internal.ts +20 -3
- package/src/events/mutes.ts +5 -3
- package/src/events/participant.ts +48 -15
- package/src/gen/video/sfu/event/events.ts +451 -8
- package/src/gen/video/sfu/models/models.ts +211 -204
- package/src/helpers/ensureExhausted.ts +5 -0
- package/src/helpers/withResolvers.ts +43 -0
- package/src/logger.ts +3 -1
- package/src/rpc/__tests__/retryable.test.ts +72 -0
- package/src/rpc/createClient.ts +21 -0
- package/src/rpc/index.ts +1 -0
- package/src/rpc/retryable.ts +57 -0
- package/src/rtc/Dispatcher.ts +6 -2
- package/src/rtc/IceTrickleBuffer.ts +2 -2
- package/src/rtc/Publisher.ts +127 -163
- package/src/rtc/Subscriber.ts +92 -155
- package/src/rtc/__tests__/Publisher.test.ts +18 -95
- package/src/rtc/__tests__/Subscriber.test.ts +63 -99
- package/src/rtc/__tests__/videoLayers.test.ts +2 -2
- package/src/rtc/helpers/rtcConfiguration.ts +11 -0
- package/src/rtc/helpers/tracks.ts +27 -7
- package/src/rtc/signal.ts +3 -3
- package/src/rtc/videoLayers.ts +1 -10
- package/src/stats/SfuStatsReporter.ts +1 -0
- package/src/store/CallState.ts +109 -2
- package/src/store/__tests__/CallState.test.ts +48 -37
- package/dist/src/rtc/flows/join.d.ts +0 -20
- package/src/rtc/flows/join.ts +0 -65
package/src/StreamVideoClient.ts
CHANGED
|
@@ -86,29 +86,30 @@ export class StreamVideoClient {
|
|
|
86
86
|
|
|
87
87
|
setLogger(logger, logLevel);
|
|
88
88
|
this.logger = getLogger(['client']);
|
|
89
|
+
const coordinatorLogger = getLogger(['coordinator']);
|
|
89
90
|
|
|
90
91
|
if (typeof apiKeyOrArgs === 'string') {
|
|
91
92
|
this.streamClient = new StreamClient(apiKeyOrArgs, {
|
|
92
93
|
persistUserOnConnectionFailure: true,
|
|
93
94
|
...opts,
|
|
94
95
|
logLevel,
|
|
95
|
-
logger:
|
|
96
|
+
logger: coordinatorLogger,
|
|
96
97
|
});
|
|
97
98
|
} else {
|
|
98
99
|
this.streamClient = new StreamClient(apiKeyOrArgs.apiKey, {
|
|
99
100
|
persistUserOnConnectionFailure: true,
|
|
100
101
|
...apiKeyOrArgs.options,
|
|
101
102
|
logLevel,
|
|
102
|
-
logger:
|
|
103
|
+
logger: coordinatorLogger,
|
|
103
104
|
});
|
|
104
105
|
|
|
105
106
|
const sdkInfo = getSdkInfo();
|
|
106
107
|
if (sdkInfo) {
|
|
108
|
+
const sdkName = SdkType[sdkInfo.type].toLowerCase();
|
|
109
|
+
const sdkVersion = `${sdkInfo.major}.${sdkInfo.minor}.${sdkInfo.patch}`;
|
|
110
|
+
const userAgent = this.streamClient.getUserAgent();
|
|
107
111
|
this.streamClient.setUserAgent(
|
|
108
|
-
|
|
109
|
-
`-video-${SdkType[sdkInfo.type].toLowerCase()}-sdk-${
|
|
110
|
-
sdkInfo.major
|
|
111
|
-
}.${sdkInfo.minor}.${sdkInfo.patch}`,
|
|
112
|
+
`${userAgent}-video-${sdkName}-sdk-${sdkVersion}`,
|
|
112
113
|
);
|
|
113
114
|
}
|
|
114
115
|
}
|
|
@@ -187,10 +188,10 @@ export class StreamVideoClient {
|
|
|
187
188
|
* @param user the user to connect.
|
|
188
189
|
* @param token a token or a function that returns a token.
|
|
189
190
|
*/
|
|
190
|
-
async
|
|
191
|
+
connectUser = async (
|
|
191
192
|
user: User,
|
|
192
193
|
token?: TokenOrProvider,
|
|
193
|
-
): Promise<void | ConnectedEvent> {
|
|
194
|
+
): Promise<void | ConnectedEvent> => {
|
|
194
195
|
if (user.type === 'anonymous') {
|
|
195
196
|
user.id = '!anon';
|
|
196
197
|
return this.connectAnonymousUser(user as UserWithId, token);
|
|
@@ -299,7 +300,7 @@ export class StreamVideoClient {
|
|
|
299
300
|
);
|
|
300
301
|
|
|
301
302
|
return connectUserResponse;
|
|
302
|
-
}
|
|
303
|
+
};
|
|
303
304
|
|
|
304
305
|
/**
|
|
305
306
|
* Disconnects the currently connected user from the client.
|
|
@@ -408,7 +409,7 @@ export class StreamVideoClient {
|
|
|
408
409
|
clientStore: this.writeableStateStore,
|
|
409
410
|
});
|
|
410
411
|
call.state.updateFromCallResponse(c.call);
|
|
411
|
-
await call.applyDeviceConfig();
|
|
412
|
+
await call.applyDeviceConfig(false);
|
|
412
413
|
if (data.watch) {
|
|
413
414
|
this.writeableStateStore.registerCall(call);
|
|
414
415
|
}
|
|
@@ -473,12 +474,12 @@ export class StreamVideoClient {
|
|
|
473
474
|
* @param {string} push_provider_name user provided push provider name
|
|
474
475
|
* @param {string} [userID] the user id (defaults to current user)
|
|
475
476
|
*/
|
|
476
|
-
async
|
|
477
|
+
addVoipDevice = async (
|
|
477
478
|
id: string,
|
|
478
479
|
push_provider: string,
|
|
479
480
|
push_provider_name: string,
|
|
480
481
|
userID?: string,
|
|
481
|
-
) {
|
|
482
|
+
) => {
|
|
482
483
|
return await this.addDevice(
|
|
483
484
|
id,
|
|
484
485
|
push_provider,
|
|
@@ -486,7 +487,7 @@ export class StreamVideoClient {
|
|
|
486
487
|
userID,
|
|
487
488
|
true,
|
|
488
489
|
);
|
|
489
|
-
}
|
|
490
|
+
};
|
|
490
491
|
|
|
491
492
|
/**
|
|
492
493
|
* getDevices - Returns the devices associated with a current user
|
|
@@ -520,9 +521,7 @@ export class StreamVideoClient {
|
|
|
520
521
|
onRingingCall = async (call_cid: string) => {
|
|
521
522
|
// if we find the call and is already ringing, we don't need to create a new call
|
|
522
523
|
// as client would have received the call.ring state because the app had WS alive when receiving push notifications
|
|
523
|
-
let call = this.
|
|
524
|
-
(c) => c.cid === call_cid && c.ringing,
|
|
525
|
-
);
|
|
524
|
+
let call = this.state.calls.find((c) => c.cid === call_cid && c.ringing);
|
|
526
525
|
if (!call) {
|
|
527
526
|
// if not it means that WS is not alive when receiving the push notifications and we need to fetch the call
|
|
528
527
|
const [callType, callId] = call_cid.split(':');
|
|
@@ -12,10 +12,12 @@ import { TokenManager } from './token_manager';
|
|
|
12
12
|
import { WSConnectionFallback } from './connection_fallback';
|
|
13
13
|
import { isErrorResponse, isWSFailure } from './errors';
|
|
14
14
|
import {
|
|
15
|
+
addConnectionEventListeners,
|
|
15
16
|
isFunction,
|
|
16
17
|
isOnline,
|
|
17
18
|
KnownCodes,
|
|
18
19
|
randomId,
|
|
20
|
+
removeConnectionEventListeners,
|
|
19
21
|
retryInterval,
|
|
20
22
|
sleep,
|
|
21
23
|
} from './utils';
|
|
@@ -131,11 +133,19 @@ export class StreamClient {
|
|
|
131
133
|
this.options.baseURL || 'https://video.stream-io-api.com/video',
|
|
132
134
|
);
|
|
133
135
|
|
|
134
|
-
if (
|
|
136
|
+
if (
|
|
137
|
+
typeof process !== 'undefined' &&
|
|
138
|
+
'env' in process &&
|
|
139
|
+
process.env.STREAM_LOCAL_TEST_RUN
|
|
140
|
+
) {
|
|
135
141
|
this.setBaseURL('http://localhost:3030/video');
|
|
136
142
|
}
|
|
137
143
|
|
|
138
|
-
if (
|
|
144
|
+
if (
|
|
145
|
+
typeof process !== 'undefined' &&
|
|
146
|
+
'env' in process &&
|
|
147
|
+
process.env.STREAM_LOCAL_TEST_HOST
|
|
148
|
+
) {
|
|
139
149
|
this.setBaseURL(`http://${process.env.STREAM_LOCAL_TEST_HOST}/video`);
|
|
140
150
|
}
|
|
141
151
|
|
|
@@ -265,6 +275,7 @@ export class StreamClient {
|
|
|
265
275
|
);
|
|
266
276
|
|
|
267
277
|
try {
|
|
278
|
+
addConnectionEventListeners(this.updateNetworkConnectionStatus);
|
|
268
279
|
return await this.setUserPromise;
|
|
269
280
|
} catch (err) {
|
|
270
281
|
if (this.persistUserOnConnectionFailure) {
|
|
@@ -398,6 +409,7 @@ export class StreamClient {
|
|
|
398
409
|
this.anonymous = false;
|
|
399
410
|
|
|
400
411
|
await this.closeConnection(timeout);
|
|
412
|
+
removeConnectionEventListeners(this.updateNetworkConnectionStatus);
|
|
401
413
|
|
|
402
414
|
this.tokenManager.reset();
|
|
403
415
|
|
|
@@ -436,6 +448,7 @@ export class StreamClient {
|
|
|
436
448
|
user: UserWithId,
|
|
437
449
|
tokenOrProvider: TokenOrProvider,
|
|
438
450
|
) => {
|
|
451
|
+
addConnectionEventListeners(this.updateNetworkConnectionStatus);
|
|
439
452
|
this.connectionIdPromise = new Promise<string | undefined>(
|
|
440
453
|
(resolve, reject) => {
|
|
441
454
|
this.resolveConnectionId = resolve;
|
|
@@ -661,7 +674,6 @@ export class StreamClient {
|
|
|
661
674
|
};
|
|
662
675
|
|
|
663
676
|
dispatchEvent = (event: StreamVideoEvent) => {
|
|
664
|
-
if (!event.received_at) event.received_at = new Date();
|
|
665
677
|
this.logger('debug', `Dispatching event: ${event.type}`, event);
|
|
666
678
|
if (!this.listeners) return;
|
|
667
679
|
|
|
@@ -855,10 +867,15 @@ export class StreamClient {
|
|
|
855
867
|
});
|
|
856
868
|
};
|
|
857
869
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
870
|
+
updateNetworkConnectionStatus = (
|
|
871
|
+
event: { type: 'online' | 'offline' } | Event,
|
|
872
|
+
) => {
|
|
873
|
+
if (event.type === 'offline') {
|
|
874
|
+
this.logger('debug', 'device went offline');
|
|
875
|
+
this.dispatchEvent({ type: 'network.changed', online: false });
|
|
876
|
+
} else if (event.type === 'online') {
|
|
877
|
+
this.logger('debug', 'device went online');
|
|
878
|
+
this.dispatchEvent({ type: 'network.changed', online: true });
|
|
879
|
+
}
|
|
863
880
|
};
|
|
864
881
|
}
|
|
@@ -52,6 +52,11 @@ export type ConnectionChangedEvent = {
|
|
|
52
52
|
online: boolean;
|
|
53
53
|
};
|
|
54
54
|
|
|
55
|
+
export type NetworkChangedEvent = {
|
|
56
|
+
type: 'network.changed';
|
|
57
|
+
online: boolean;
|
|
58
|
+
};
|
|
59
|
+
|
|
55
60
|
export type TransportChangedEvent = {
|
|
56
61
|
type: 'transport.changed';
|
|
57
62
|
mode: 'longpoll';
|
|
@@ -63,6 +68,7 @@ export type ConnectionRecoveredEvent = {
|
|
|
63
68
|
|
|
64
69
|
export type StreamVideoEvent = (
|
|
65
70
|
| WSEvent
|
|
71
|
+
| NetworkChangedEvent
|
|
66
72
|
| ConnectionChangedEvent
|
|
67
73
|
| TransportChangedEvent
|
|
68
74
|
| ConnectionRecoveredEvent
|
|
@@ -61,7 +61,7 @@ export class CameraManager extends InputMediaDeviceManager<CameraManagerState> {
|
|
|
61
61
|
this.logger('warn', 'could not apply target resolution', error);
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
|
-
if (this.
|
|
64
|
+
if (this.enabled) {
|
|
65
65
|
const { width, height } = this.state
|
|
66
66
|
.mediaStream!.getVideoTracks()[0]
|
|
67
67
|
?.getSettings();
|
|
@@ -63,6 +63,13 @@ export abstract class InputMediaDeviceManager<
|
|
|
63
63
|
return this.getDevices();
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Returns `true` when this device is in enabled state.
|
|
68
|
+
*/
|
|
69
|
+
get enabled() {
|
|
70
|
+
return this.state.status === 'enabled';
|
|
71
|
+
}
|
|
72
|
+
|
|
66
73
|
/**
|
|
67
74
|
* Starts stream.
|
|
68
75
|
*/
|
|
@@ -216,7 +223,7 @@ export abstract class InputMediaDeviceManager<
|
|
|
216
223
|
};
|
|
217
224
|
|
|
218
225
|
protected async applySettingsToStream() {
|
|
219
|
-
if (this.
|
|
226
|
+
if (this.enabled) {
|
|
220
227
|
await this.muteStream();
|
|
221
228
|
await this.unmuteStream();
|
|
222
229
|
}
|
|
@@ -374,7 +381,7 @@ export abstract class InputMediaDeviceManager<
|
|
|
374
381
|
.then(chainWith(parent), (error) => {
|
|
375
382
|
this.logger(
|
|
376
383
|
'warn',
|
|
377
|
-
'
|
|
384
|
+
'Filter failed to start and will be ignored',
|
|
378
385
|
error,
|
|
379
386
|
);
|
|
380
387
|
return parent;
|
|
@@ -384,13 +391,15 @@ export abstract class InputMediaDeviceManager<
|
|
|
384
391
|
}
|
|
385
392
|
if (this.call.state.callingState === CallingState.JOINED) {
|
|
386
393
|
await this.publishStream(stream);
|
|
394
|
+
} else {
|
|
395
|
+
this.logger('debug', 'Stream is not published as the call is not joined');
|
|
387
396
|
}
|
|
388
397
|
if (this.state.mediaStream !== stream) {
|
|
389
398
|
this.state.setMediaStream(stream, await rootStream);
|
|
390
399
|
this.getTracks().forEach((track) => {
|
|
391
400
|
track.addEventListener('ended', async () => {
|
|
392
401
|
await this.statusChangeSettled();
|
|
393
|
-
if (this.
|
|
402
|
+
if (this.enabled) {
|
|
394
403
|
this.isTrackStoppedDueToTrackEnd = true;
|
|
395
404
|
setTimeout(() => {
|
|
396
405
|
this.isTrackStoppedDueToTrackEnd = false;
|
|
@@ -25,7 +25,7 @@ export class MicrophoneManager extends InputMediaDeviceManager<MicrophoneManager
|
|
|
25
25
|
private noiseCancellation: INoiseCancellation | undefined;
|
|
26
26
|
private noiseCancellationChangeUnsubscribe: (() => void) | undefined;
|
|
27
27
|
private noiseCancellationRegistration?: Promise<void>;
|
|
28
|
-
private
|
|
28
|
+
private unregisterNoiseCancellation?: () => Promise<void>;
|
|
29
29
|
|
|
30
30
|
constructor(
|
|
31
31
|
call: Call,
|
|
@@ -144,7 +144,7 @@ export class MicrophoneManager extends InputMediaDeviceManager<MicrophoneManager
|
|
|
144
144
|
noiseCancellation.toFilter(),
|
|
145
145
|
);
|
|
146
146
|
this.noiseCancellationRegistration = registrationResult.registered;
|
|
147
|
-
this.
|
|
147
|
+
this.unregisterNoiseCancellation = registrationResult.unregister;
|
|
148
148
|
await this.noiseCancellationRegistration;
|
|
149
149
|
|
|
150
150
|
// handles an edge case where a noise cancellation is enabled after
|
|
@@ -173,7 +173,7 @@ export class MicrophoneManager extends InputMediaDeviceManager<MicrophoneManager
|
|
|
173
173
|
if (isReactNative()) {
|
|
174
174
|
throw new Error('Noise cancellation is not supported in React Native');
|
|
175
175
|
}
|
|
176
|
-
await (this.
|
|
176
|
+
await (this.unregisterNoiseCancellation?.() ?? Promise.resolve())
|
|
177
177
|
.then(() => this.noiseCancellation?.disable())
|
|
178
178
|
.then(() => this.noiseCancellationChangeUnsubscribe?.())
|
|
179
179
|
.catch((err) => {
|
package/src/devices/devices.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { lazy } from '../helpers/lazy';
|
|
|
16
16
|
* Returns an Observable that emits the list of available devices
|
|
17
17
|
* that meet the given constraints.
|
|
18
18
|
*
|
|
19
|
-
* @param
|
|
19
|
+
* @param permission a BrowserPermission instance.
|
|
20
20
|
* @param kind the kind of devices to enumerate.
|
|
21
21
|
*/
|
|
22
22
|
const getDevices = (permission: BrowserPermission, kind: MediaDeviceKind) => {
|
|
@@ -28,9 +28,9 @@ describe('mutes', () => {
|
|
|
28
28
|
// @ts-expect-error partial data
|
|
29
29
|
call.publisher.isPublishing = vi.fn().mockReturnValue(true);
|
|
30
30
|
|
|
31
|
-
vi.spyOn(call, 'stopPublish').mockResolvedValue(undefined);
|
|
32
31
|
vi.spyOn(call.camera, 'disable').mockResolvedValue(undefined);
|
|
33
32
|
vi.spyOn(call.microphone, 'disable').mockResolvedValue(undefined);
|
|
33
|
+
vi.spyOn(call.screenShare, 'disable').mockResolvedValue(undefined);
|
|
34
34
|
|
|
35
35
|
// @ts-ignore
|
|
36
36
|
call.on = (event: string, h) => {
|
|
@@ -56,41 +56,38 @@ describe('mutes', () => {
|
|
|
56
56
|
});
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
-
it('should automatically mute only when cause is moderation',
|
|
60
|
-
|
|
59
|
+
it('should automatically mute only when cause is moderation', () => {
|
|
60
|
+
handler!({
|
|
61
61
|
cause: TrackUnpublishReason.PERMISSION_REVOKED,
|
|
62
62
|
type: TrackType.VIDEO,
|
|
63
63
|
sessionId: 'session-id',
|
|
64
64
|
userId: 'user-id',
|
|
65
65
|
});
|
|
66
66
|
expect(call.camera.disable).not.toHaveBeenCalled();
|
|
67
|
-
expect(call.stopPublish).not.toHaveBeenCalledWith(TrackType.VIDEO);
|
|
68
67
|
});
|
|
69
68
|
|
|
70
|
-
it('should handle remote soft video mute',
|
|
71
|
-
|
|
69
|
+
it('should handle remote soft video mute', () => {
|
|
70
|
+
handler!({
|
|
72
71
|
cause: TrackUnpublishReason.MODERATION,
|
|
73
72
|
type: TrackType.VIDEO,
|
|
74
73
|
sessionId: 'session-id',
|
|
75
74
|
userId: 'user-id',
|
|
76
75
|
});
|
|
77
76
|
expect(call.camera.disable).toHaveBeenCalled();
|
|
78
|
-
expect(call.stopPublish).toHaveBeenCalledWith(TrackType.VIDEO);
|
|
79
77
|
});
|
|
80
78
|
|
|
81
|
-
it('should handle remote soft audio mute',
|
|
82
|
-
|
|
79
|
+
it('should handle remote soft audio mute', () => {
|
|
80
|
+
handler!({
|
|
83
81
|
cause: TrackUnpublishReason.MODERATION,
|
|
84
82
|
type: TrackType.AUDIO,
|
|
85
83
|
sessionId: 'session-id',
|
|
86
84
|
userId: 'user-id',
|
|
87
85
|
});
|
|
88
86
|
expect(call.microphone.disable).toHaveBeenCalled();
|
|
89
|
-
expect(call.stopPublish).toHaveBeenCalledWith(TrackType.AUDIO);
|
|
90
87
|
});
|
|
91
88
|
|
|
92
|
-
it('should handle remote soft screenshare mute',
|
|
93
|
-
|
|
89
|
+
it('should handle remote soft screenshare mute', () => {
|
|
90
|
+
handler!({
|
|
94
91
|
cause: TrackUnpublishReason.MODERATION,
|
|
95
92
|
type: TrackType.SCREEN_SHARE,
|
|
96
93
|
sessionId: 'session-id',
|
|
@@ -98,7 +95,7 @@ describe('mutes', () => {
|
|
|
98
95
|
});
|
|
99
96
|
expect(call.camera.disable).not.toHaveBeenCalled();
|
|
100
97
|
expect(call.microphone.disable).not.toHaveBeenCalled();
|
|
101
|
-
expect(call.
|
|
98
|
+
expect(call.screenShare.disable).toHaveBeenCalled();
|
|
102
99
|
});
|
|
103
100
|
});
|
|
104
101
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import '../../rtc/__tests__/mocks/webrtc.mocks';
|
|
1
2
|
import { describe, expect, it } from 'vitest';
|
|
2
3
|
import { CallState } from '../../store';
|
|
3
4
|
import { VisibilityState } from '../../types';
|
|
@@ -75,6 +76,80 @@ describe('Participant events', () => {
|
|
|
75
76
|
});
|
|
76
77
|
});
|
|
77
78
|
|
|
79
|
+
describe('orphaned tracks reconciliation', () => {
|
|
80
|
+
it('participantJoined should reconcile orphaned tracks if any', () => {
|
|
81
|
+
const state = new CallState();
|
|
82
|
+
const mediaStream = new MediaStream();
|
|
83
|
+
state.registerOrphanedTrack({
|
|
84
|
+
trackLookupPrefix: 'track-lookup-prefix',
|
|
85
|
+
trackType: TrackType.VIDEO,
|
|
86
|
+
track: mediaStream,
|
|
87
|
+
});
|
|
88
|
+
const onParticipantJoined = watchParticipantJoined(state);
|
|
89
|
+
onParticipantJoined({
|
|
90
|
+
// @ts-expect-error incomplete data
|
|
91
|
+
participant: {
|
|
92
|
+
userId: 'user-id',
|
|
93
|
+
sessionId: 'session-id',
|
|
94
|
+
trackLookupPrefix: 'track-lookup-prefix',
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const p = state.findParticipantBySessionId('session-id');
|
|
99
|
+
expect(p).toBeDefined();
|
|
100
|
+
expect(p?.videoStream).toBe(mediaStream);
|
|
101
|
+
expect(state.takeOrphanedTracks('track-lookup-prefix')).toHaveLength(0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('trackPublished should reconcile orphaned tracks if any', () => {
|
|
105
|
+
const state = new CallState();
|
|
106
|
+
const mediaStream = new MediaStream();
|
|
107
|
+
state.registerOrphanedTrack({
|
|
108
|
+
trackLookupPrefix: 'track-lookup-prefix',
|
|
109
|
+
trackType: TrackType.AUDIO,
|
|
110
|
+
track: mediaStream,
|
|
111
|
+
});
|
|
112
|
+
const onTrackPublished = watchTrackPublished(state);
|
|
113
|
+
onTrackPublished({
|
|
114
|
+
// @ts-expect-error incomplete data
|
|
115
|
+
participant: {
|
|
116
|
+
userId: 'user-id',
|
|
117
|
+
sessionId: 'session-id',
|
|
118
|
+
trackLookupPrefix: 'track-lookup-prefix',
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const p = state.findParticipantBySessionId('session-id');
|
|
123
|
+
expect(p).toBeDefined();
|
|
124
|
+
expect(p?.audioStream).toBe(mediaStream);
|
|
125
|
+
expect(state.takeOrphanedTracks('track-lookup-prefix')).toHaveLength(0);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('trackUnpublished should reconcile orphaned tracks if any', () => {
|
|
129
|
+
const state = new CallState();
|
|
130
|
+
const mediaStream = new MediaStream();
|
|
131
|
+
state.registerOrphanedTrack({
|
|
132
|
+
trackLookupPrefix: 'track-lookup-prefix',
|
|
133
|
+
trackType: TrackType.SCREEN_SHARE,
|
|
134
|
+
track: mediaStream,
|
|
135
|
+
});
|
|
136
|
+
const onTrackUnPublished = watchTrackUnpublished(state);
|
|
137
|
+
onTrackUnPublished({
|
|
138
|
+
// @ts-expect-error incomplete data
|
|
139
|
+
participant: {
|
|
140
|
+
userId: 'user-id',
|
|
141
|
+
sessionId: 'session-id',
|
|
142
|
+
trackLookupPrefix: 'track-lookup-prefix',
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const p = state.findParticipantBySessionId('session-id');
|
|
147
|
+
expect(p).toBeDefined();
|
|
148
|
+
expect(p?.screenShareStream).toBe(mediaStream);
|
|
149
|
+
expect(state.takeOrphanedTracks('track-lookup-prefix')).toHaveLength(0);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
78
153
|
describe('trackPublished', () => {
|
|
79
154
|
it('updates the participant track list', () => {
|
|
80
155
|
const state = new CallState();
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { Call } from '../Call';
|
|
2
2
|
import { Dispatcher } from '../rtc';
|
|
3
|
-
import { CallState } from '../store';
|
|
4
3
|
import {
|
|
5
4
|
handleRemoteSoftMute,
|
|
6
5
|
watchAudioLevelChanged,
|
|
@@ -17,6 +16,7 @@ import {
|
|
|
17
16
|
watchParticipantLeft,
|
|
18
17
|
watchParticipantUpdated,
|
|
19
18
|
watchPinsUpdated,
|
|
19
|
+
watchSfuCallEnded,
|
|
20
20
|
watchSfuErrorReports,
|
|
21
21
|
watchTrackPublished,
|
|
22
22
|
watchTrackUnpublished,
|
|
@@ -36,16 +36,13 @@ type RingCallEvents = Extract<
|
|
|
36
36
|
* Registers the default event handlers for a call during its lifecycle.
|
|
37
37
|
*
|
|
38
38
|
* @param call the call to register event handlers for.
|
|
39
|
-
* @param state the call state.
|
|
40
39
|
* @param dispatcher the dispatcher.
|
|
41
40
|
*/
|
|
42
|
-
export const registerEventHandlers = (
|
|
43
|
-
call
|
|
44
|
-
state: CallState,
|
|
45
|
-
dispatcher: Dispatcher,
|
|
46
|
-
) => {
|
|
41
|
+
export const registerEventHandlers = (call: Call, dispatcher: Dispatcher) => {
|
|
42
|
+
const state = call.state;
|
|
47
43
|
const eventHandlers = [
|
|
48
44
|
call.on('call.ended', watchCallEnded(call)),
|
|
45
|
+
watchSfuCallEnded(call),
|
|
49
46
|
|
|
50
47
|
watchLiveEnded(dispatcher, call),
|
|
51
48
|
watchSfuErrorReports(dispatcher),
|
package/src/events/internal.ts
CHANGED
|
@@ -3,8 +3,12 @@ import { Call } from '../Call';
|
|
|
3
3
|
import { CallState } from '../store';
|
|
4
4
|
import { StreamVideoParticipantPatches } from '../types';
|
|
5
5
|
import { getLogger } from '../logger';
|
|
6
|
-
import type { PinsChanged } from '../gen/video/sfu/event/events';
|
|
7
|
-
import {
|
|
6
|
+
import type { CallEnded, PinsChanged } from '../gen/video/sfu/event/events';
|
|
7
|
+
import {
|
|
8
|
+
CallEndedReason,
|
|
9
|
+
ErrorCode,
|
|
10
|
+
WebsocketReconnectStrategy,
|
|
11
|
+
} from '../gen/video/sfu/models/models';
|
|
8
12
|
import { OwnCapability } from '../gen/coordinator';
|
|
9
13
|
|
|
10
14
|
const logger = getLogger(['events']);
|
|
@@ -82,9 +86,10 @@ export const watchLiveEnded = (dispatcher: Dispatcher, call: Call) => {
|
|
|
82
86
|
export const watchSfuErrorReports = (dispatcher: Dispatcher) => {
|
|
83
87
|
return dispatcher.on('error', (e) => {
|
|
84
88
|
if (!e.error) return;
|
|
85
|
-
const { error } = e;
|
|
89
|
+
const { error, reconnectStrategy } = e;
|
|
86
90
|
logger('error', 'SFU reported error', {
|
|
87
91
|
code: ErrorCode[error.code],
|
|
92
|
+
reconnectStrategy: WebsocketReconnectStrategy[reconnectStrategy],
|
|
88
93
|
message: error.message,
|
|
89
94
|
shouldRetry: error.shouldRetry,
|
|
90
95
|
});
|
|
@@ -101,3 +106,15 @@ export const watchPinsUpdated = (state: CallState) => {
|
|
|
101
106
|
state.setServerSidePins(pins);
|
|
102
107
|
};
|
|
103
108
|
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Watches for `callEnded` events.
|
|
112
|
+
*/
|
|
113
|
+
export const watchSfuCallEnded = (call: Call) => {
|
|
114
|
+
return call.on('callEnded', (e: CallEnded) => {
|
|
115
|
+
const reason = CallEndedReason[e.reason];
|
|
116
|
+
call.leave({ reason }).catch((err) => {
|
|
117
|
+
logger('error', 'Failed to leave call after call ended by the SFU', err);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
};
|
package/src/events/mutes.ts
CHANGED
|
@@ -27,6 +27,11 @@ export const handleRemoteSoftMute = (call: Call) => {
|
|
|
27
27
|
await call.camera.disable();
|
|
28
28
|
} else if (type === TrackType.AUDIO) {
|
|
29
29
|
await call.microphone.disable();
|
|
30
|
+
} else if (
|
|
31
|
+
type === TrackType.SCREEN_SHARE ||
|
|
32
|
+
type === TrackType.SCREEN_SHARE_AUDIO
|
|
33
|
+
) {
|
|
34
|
+
await call.screenShare.disable();
|
|
30
35
|
} else {
|
|
31
36
|
logger(
|
|
32
37
|
'warn',
|
|
@@ -34,9 +39,6 @@ export const handleRemoteSoftMute = (call: Call) => {
|
|
|
34
39
|
TrackType[type],
|
|
35
40
|
);
|
|
36
41
|
}
|
|
37
|
-
if (call.publisher?.isPublishing(type)) {
|
|
38
|
-
await call.stopPublish(type);
|
|
39
|
-
}
|
|
40
42
|
} catch (error) {
|
|
41
43
|
logger('error', 'Failed to stop publishing', error);
|
|
42
44
|
}
|
|
@@ -5,8 +5,14 @@ import type {
|
|
|
5
5
|
TrackPublished,
|
|
6
6
|
TrackUnpublished,
|
|
7
7
|
} from '../gen/video/sfu/event/events';
|
|
8
|
-
import {
|
|
8
|
+
import type { Participant } from '../gen/video/sfu/models/models';
|
|
9
|
+
import {
|
|
10
|
+
StreamVideoParticipant,
|
|
11
|
+
StreamVideoParticipantPatch,
|
|
12
|
+
VisibilityState,
|
|
13
|
+
} from '../types';
|
|
9
14
|
import { CallState } from '../store';
|
|
15
|
+
import { trackTypeToParticipantStreamKey } from '../rtc/helpers/tracks';
|
|
10
16
|
|
|
11
17
|
/**
|
|
12
18
|
* An event responder which handles the `participantJoined` event.
|
|
@@ -19,21 +25,23 @@ export const watchParticipantJoined = (state: CallState) => {
|
|
|
19
25
|
// potential duplicate events from the SFU.
|
|
20
26
|
//
|
|
21
27
|
// Although the SFU should not send duplicate events, we have seen
|
|
22
|
-
// some race conditions in the past during the `join-flow
|
|
23
|
-
//
|
|
28
|
+
// some race conditions in the past during the `join-flow`.
|
|
29
|
+
// The SFU would send participant info as part of the `join`
|
|
24
30
|
// response and then follow up with a `participantJoined` event for
|
|
25
31
|
// already announced participants.
|
|
32
|
+
const orphanedTracks = reconcileOrphanedTracks(state, participant);
|
|
26
33
|
state.updateOrAddParticipant(
|
|
27
34
|
participant.sessionId,
|
|
28
|
-
Object.assign<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
Object.assign<
|
|
36
|
+
StreamVideoParticipant,
|
|
37
|
+
StreamVideoParticipantPatch | undefined,
|
|
38
|
+
Partial<StreamVideoParticipant>
|
|
39
|
+
>(participant, orphanedTracks, {
|
|
40
|
+
viewportVisibilityState: {
|
|
41
|
+
videoTrack: VisibilityState.UNKNOWN,
|
|
42
|
+
screenShareTrack: VisibilityState.UNKNOWN,
|
|
35
43
|
},
|
|
36
|
-
),
|
|
44
|
+
}),
|
|
37
45
|
);
|
|
38
46
|
};
|
|
39
47
|
};
|
|
@@ -69,12 +77,14 @@ export const watchParticipantUpdated = (state: CallState) => {
|
|
|
69
77
|
*/
|
|
70
78
|
export const watchTrackPublished = (state: CallState) => {
|
|
71
79
|
return function onTrackPublished(e: TrackPublished) {
|
|
72
|
-
const { type, sessionId
|
|
80
|
+
const { type, sessionId } = e;
|
|
73
81
|
// An optimization for large calls.
|
|
74
82
|
// After a certain threshold, the SFU would stop emitting `participantJoined`
|
|
75
83
|
// events, and instead, it would only provide the participant's information
|
|
76
84
|
// once they start publishing a track.
|
|
77
|
-
if (participant) {
|
|
85
|
+
if (e.participant) {
|
|
86
|
+
const orphanedTracks = reconcileOrphanedTracks(state, e.participant);
|
|
87
|
+
const participant = Object.assign(e.participant, orphanedTracks);
|
|
78
88
|
state.updateOrAddParticipant(sessionId, participant);
|
|
79
89
|
} else {
|
|
80
90
|
state.updateParticipant(sessionId, (p) => ({
|
|
@@ -90,9 +100,11 @@ export const watchTrackPublished = (state: CallState) => {
|
|
|
90
100
|
*/
|
|
91
101
|
export const watchTrackUnpublished = (state: CallState) => {
|
|
92
102
|
return function onTrackUnpublished(e: TrackUnpublished) {
|
|
93
|
-
const { type, sessionId
|
|
103
|
+
const { type, sessionId } = e;
|
|
94
104
|
// An optimization for large calls. See `watchTrackPublished`.
|
|
95
|
-
if (participant) {
|
|
105
|
+
if (e.participant) {
|
|
106
|
+
const orphanedTracks = reconcileOrphanedTracks(state, e.participant);
|
|
107
|
+
const participant = Object.assign(e.participant, orphanedTracks);
|
|
96
108
|
state.updateOrAddParticipant(sessionId, participant);
|
|
97
109
|
} else {
|
|
98
110
|
state.updateParticipant(sessionId, (p) => ({
|
|
@@ -103,3 +115,24 @@ export const watchTrackUnpublished = (state: CallState) => {
|
|
|
103
115
|
};
|
|
104
116
|
|
|
105
117
|
const unique = <T>(v: T, i: number, arr: T[]) => arr.indexOf(v) === i;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Reconciles orphaned tracks (if any) for the given participant.
|
|
121
|
+
*
|
|
122
|
+
* @param state the call state.
|
|
123
|
+
* @param participant the participant.
|
|
124
|
+
*/
|
|
125
|
+
const reconcileOrphanedTracks = (
|
|
126
|
+
state: CallState,
|
|
127
|
+
participant: Participant,
|
|
128
|
+
): StreamVideoParticipantPatch | undefined => {
|
|
129
|
+
const orphanTracks = state.takeOrphanedTracks(participant.trackLookupPrefix);
|
|
130
|
+
if (!orphanTracks.length) return;
|
|
131
|
+
const reconciledTracks: StreamVideoParticipantPatch = {};
|
|
132
|
+
for (const orphan of orphanTracks) {
|
|
133
|
+
const key = trackTypeToParticipantStreamKey(orphan.trackType);
|
|
134
|
+
if (!key) continue;
|
|
135
|
+
reconciledTracks[key] = orphan.track;
|
|
136
|
+
}
|
|
137
|
+
return reconciledTracks;
|
|
138
|
+
};
|