@stream-io/video-client 1.58.0 → 1.59.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 +22 -0
- package/dist/index.browser.es.js +367 -76
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +378 -74
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.es.js +367 -76
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +15 -4
- package/dist/src/StreamVideoClient.d.ts +9 -1
- package/dist/src/coordinator/connection/utils.d.ts +10 -0
- package/dist/src/devices/CameraManager.d.ts +2 -1
- package/dist/src/devices/DeviceManager.d.ts +1 -1
- package/dist/src/devices/MicrophoneManager.d.ts +2 -1
- package/dist/src/gen/coordinator/index.d.ts +26 -0
- package/dist/src/helpers/MediaStreamRecorder.d.ts +26 -0
- package/dist/src/helpers/concurrency.d.ts +8 -0
- package/dist/src/helpers/loopback.d.ts +55 -0
- package/dist/src/reporting/ClientEventReporter.d.ts +0 -2
- package/dist/src/rtc/Subscriber.d.ts +5 -0
- package/dist/src/sorting/participants.d.ts +13 -0
- package/index.ts +2 -0
- package/package.json +1 -1
- package/src/Call.ts +156 -134
- package/src/StreamVideoClient.ts +35 -8
- package/src/__tests__/Call.lifecycle.test.ts +33 -2
- package/src/__tests__/Call.publishing.test.ts +42 -1
- package/src/__tests__/StreamVideoClient.api.test.ts +33 -0
- package/src/__tests__/StreamVideoClient.rewatch.test.ts +213 -0
- package/src/__tests__/StreamVideoClient.ringing.test.ts +43 -24
- package/src/__tests__/StreamVideoClient.test.ts +14 -12
- package/src/coordinator/connection/__tests__/eventListenerIsolation.test.ts +115 -0
- package/src/coordinator/connection/client.ts +3 -2
- package/src/coordinator/connection/utils.ts +28 -0
- package/src/devices/CameraManager.ts +16 -4
- package/src/devices/DeviceManager.ts +7 -4
- package/src/devices/MicrophoneManager.ts +11 -3
- package/src/devices/__tests__/CameraManager.test.ts +27 -1
- package/src/devices/__tests__/DeviceManager.test.ts +49 -1
- package/src/devices/__tests__/MicrophoneManager.test.ts +23 -1
- package/src/events/call.ts +6 -3
- package/src/gen/coordinator/index.ts +26 -0
- package/src/helpers/MediaStreamRecorder.ts +104 -0
- package/src/helpers/__tests__/MediaStreamRecorder.test.ts +324 -0
- package/src/helpers/__tests__/concurrency.test.ts +74 -0
- package/src/helpers/__tests__/loopback.test.ts +367 -0
- package/src/helpers/concurrency.ts +24 -0
- package/src/helpers/loopback.ts +200 -0
- package/src/reporting/ClientEventReporter.ts +15 -28
- package/src/reporting/__tests__/ClientEventReporter.test.ts +3 -7
- package/src/rtc/Subscriber.ts +8 -0
- package/src/rtc/__tests__/Subscriber.test.ts +101 -0
- package/src/sorting/__tests__/presets.test.ts +188 -3
- package/src/sorting/__tests__/sorting.test.ts +22 -1
- package/src/sorting/participants.ts +20 -0
- package/src/sorting/presets.ts +4 -0
- package/src/store/__tests__/CallState.test.ts +2 -2
package/src/Call.ts
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
BlockUserResponse,
|
|
40
40
|
CallRingEvent,
|
|
41
41
|
CallSettingsResponse,
|
|
42
|
+
CallStateResponseFields,
|
|
42
43
|
CollectUserFeedbackRequest,
|
|
43
44
|
CollectUserFeedbackResponse,
|
|
44
45
|
Credentials,
|
|
@@ -178,7 +179,11 @@ import {
|
|
|
178
179
|
SpeakerManager,
|
|
179
180
|
} from './devices';
|
|
180
181
|
import { normalize } from './devices/devicePersistence';
|
|
181
|
-
import {
|
|
182
|
+
import {
|
|
183
|
+
hasPending,
|
|
184
|
+
singleFlight,
|
|
185
|
+
withoutConcurrency,
|
|
186
|
+
} from './helpers/concurrency';
|
|
182
187
|
import { ensureExhausted } from './helpers/ensureExhausted';
|
|
183
188
|
import { pushToIfMissing } from './helpers/array';
|
|
184
189
|
import {
|
|
@@ -753,7 +758,10 @@ export class Call {
|
|
|
753
758
|
}
|
|
754
759
|
}
|
|
755
760
|
}
|
|
756
|
-
globalThis.streamRNVideoSDK?.callingX?.endCall(
|
|
761
|
+
globalThis.streamRNVideoSDK?.callingX?.endCall(
|
|
762
|
+
this,
|
|
763
|
+
reason === 'ended' ? 'remote' : undefined,
|
|
764
|
+
);
|
|
757
765
|
|
|
758
766
|
this.statsReporter?.stop();
|
|
759
767
|
this.statsReporter = undefined;
|
|
@@ -936,8 +944,19 @@ export class Call {
|
|
|
936
944
|
// const calls = useCalls().filter((c) => c.ringing);
|
|
937
945
|
const calls = this.clientStore.calls.filter((c) => c.cid !== this.cid);
|
|
938
946
|
this.clientStore.setCalls([this, ...calls]);
|
|
939
|
-
|
|
940
|
-
|
|
947
|
+
await this.applyDeviceConfig(settings, { publish: false });
|
|
948
|
+
};
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Applies a backend-provided call state snapshot
|
|
952
|
+
* (call, members, own capabilities) to this instance.
|
|
953
|
+
*
|
|
954
|
+
* @internal an internal method and should not be used outside the SDK.
|
|
955
|
+
*/
|
|
956
|
+
updateFromCallStateResponse = (response: CallStateResponseFields) => {
|
|
957
|
+
this.state.updateFromCallResponse(response.call);
|
|
958
|
+
this.state.setMembers(response.members);
|
|
959
|
+
this.state.setOwnCapabilities(response.own_capabilities);
|
|
941
960
|
};
|
|
942
961
|
|
|
943
962
|
/**
|
|
@@ -961,9 +980,7 @@ export class Call {
|
|
|
961
980
|
params,
|
|
962
981
|
);
|
|
963
982
|
|
|
964
|
-
this.
|
|
965
|
-
this.state.setMembers(response.members);
|
|
966
|
-
this.state.setOwnCapabilities(response.own_capabilities);
|
|
983
|
+
this.updateFromCallStateResponse(response);
|
|
967
984
|
|
|
968
985
|
if (params?.ring) {
|
|
969
986
|
this.ringingSubject.next(true);
|
|
@@ -973,13 +990,7 @@ export class Call {
|
|
|
973
990
|
this.watching = true;
|
|
974
991
|
this.clientStore.registerOrUpdateCall(this);
|
|
975
992
|
}
|
|
976
|
-
|
|
977
|
-
const skipSpeakerApply = isReactNative();
|
|
978
|
-
await this.applyDeviceConfig(
|
|
979
|
-
response.call.settings,
|
|
980
|
-
false,
|
|
981
|
-
skipSpeakerApply,
|
|
982
|
-
);
|
|
993
|
+
await this.applyDeviceConfig(response.call.settings, { publish: false });
|
|
983
994
|
|
|
984
995
|
return response;
|
|
985
996
|
};
|
|
@@ -997,9 +1008,7 @@ export class Call {
|
|
|
997
1008
|
GetOrCreateCallRequest
|
|
998
1009
|
>(this.streamClientBasePath, data);
|
|
999
1010
|
|
|
1000
|
-
this.
|
|
1001
|
-
this.state.setMembers(response.members);
|
|
1002
|
-
this.state.setOwnCapabilities(response.own_capabilities);
|
|
1011
|
+
this.updateFromCallStateResponse(response);
|
|
1003
1012
|
|
|
1004
1013
|
if (data?.ring) {
|
|
1005
1014
|
this.ringingSubject.next(true);
|
|
@@ -1010,13 +1019,7 @@ export class Call {
|
|
|
1010
1019
|
this.clientStore.registerOrUpdateCall(this);
|
|
1011
1020
|
}
|
|
1012
1021
|
|
|
1013
|
-
|
|
1014
|
-
const skipSpeakerApply = isReactNative();
|
|
1015
|
-
await this.applyDeviceConfig(
|
|
1016
|
-
response.call.settings,
|
|
1017
|
-
false,
|
|
1018
|
-
skipSpeakerApply,
|
|
1019
|
-
);
|
|
1022
|
+
await this.applyDeviceConfig(response.call.settings, { publish: false });
|
|
1020
1023
|
|
|
1021
1024
|
return response;
|
|
1022
1025
|
};
|
|
@@ -1103,112 +1106,113 @@ export class Call {
|
|
|
1103
1106
|
*
|
|
1104
1107
|
* @returns a promise which resolves once the call join-flow has finished.
|
|
1105
1108
|
*/
|
|
1106
|
-
join =
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1109
|
+
join = singleFlight(
|
|
1110
|
+
async ({
|
|
1111
|
+
maxJoinRetries = 3,
|
|
1112
|
+
joinResponseTimeout,
|
|
1113
|
+
rpcRequestTimeout,
|
|
1114
|
+
allowOwnTracksLoopback = false,
|
|
1115
|
+
...data
|
|
1116
|
+
}: JoinCallData & {
|
|
1117
|
+
maxJoinRetries?: number;
|
|
1118
|
+
joinResponseTimeout?: number;
|
|
1119
|
+
rpcRequestTimeout?: number;
|
|
1120
|
+
allowOwnTracksLoopback?: boolean;
|
|
1121
|
+
} = {}): Promise<void> => {
|
|
1122
|
+
const callingState = this.state.callingState;
|
|
1119
1123
|
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1124
|
+
if ([CallingState.JOINED, CallingState.JOINING].includes(callingState)) {
|
|
1125
|
+
throw new Error(`Illegal State: call.join() shall be called only once`);
|
|
1126
|
+
}
|
|
1123
1127
|
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1128
|
+
// we need this to be set before the callingx.joinCall() is
|
|
1129
|
+
// called to avoid registering the test call in the CallKit/Telecom
|
|
1130
|
+
this.allowOwnTracksLoopback = allowOwnTracksLoopback;
|
|
1127
1131
|
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1132
|
+
if (data?.ring) {
|
|
1133
|
+
this.ringingSubject.next(true);
|
|
1134
|
+
}
|
|
1131
1135
|
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1136
|
+
const callingX = globalThis.streamRNVideoSDK?.callingX;
|
|
1137
|
+
if (callingX) {
|
|
1138
|
+
// for Android/iOS, we need to start the call in the callingx library as soon as possible
|
|
1139
|
+
await callingX.joinCall(this, this.clientStore.calls);
|
|
1140
|
+
}
|
|
1137
1141
|
|
|
1138
|
-
|
|
1142
|
+
await this.setup();
|
|
1139
1143
|
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
});
|
|
1144
|
+
this.clientEventReporter.registerCall(this.cid, {
|
|
1145
|
+
callType: this.type,
|
|
1146
|
+
callId: this.id,
|
|
1147
|
+
getCallSessionId: () => this.state.session?.id ?? '',
|
|
1148
|
+
getSfuId: () => this.credentials?.server.edge_name ?? '',
|
|
1149
|
+
});
|
|
1147
1150
|
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1151
|
+
this.joinResponseTimeout = joinResponseTimeout;
|
|
1152
|
+
this.rpcRequestTimeout = rpcRequestTimeout;
|
|
1153
|
+
// we will count the number of join failures per SFU.
|
|
1154
|
+
// once the number of failures reaches 2, we will piggyback on the `migrating_from`
|
|
1155
|
+
// field to force the coordinator to provide us another SFU
|
|
1156
|
+
const sfuJoinFailures = new Map<string, number>();
|
|
1157
|
+
const joinData: JoinCallData = data;
|
|
1158
|
+
maxJoinRetries = Math.max(maxJoinRetries, 1);
|
|
1159
|
+
try {
|
|
1160
|
+
await this.clientEventReporter.withJoinLifecycle(
|
|
1161
|
+
this.cid,
|
|
1162
|
+
'first-attempt',
|
|
1163
|
+
async () => {
|
|
1164
|
+
for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
|
|
1165
|
+
try {
|
|
1166
|
+
this.logger.trace(`Joining call (${attempt})`, this.cid);
|
|
1167
|
+
await this.doJoin(data);
|
|
1168
|
+
delete joinData.migrating_from;
|
|
1169
|
+
delete joinData.migrating_from_list;
|
|
1170
|
+
return;
|
|
1171
|
+
} catch (err) {
|
|
1172
|
+
this.logger.warn(`Failed to join call (${attempt})`, this.cid);
|
|
1173
|
+
if (
|
|
1174
|
+
(err instanceof ErrorFromResponse && err.unrecoverable) ||
|
|
1175
|
+
(err instanceof SfuJoinError && err.unrecoverable)
|
|
1176
|
+
) {
|
|
1177
|
+
throw err;
|
|
1178
|
+
}
|
|
1176
1179
|
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
);
|
|
1190
|
-
if (attempt < maxJoinRetries - 1) {
|
|
1191
|
-
this.clientEventReporter.startCorrelation(
|
|
1192
|
-
this.cid,
|
|
1193
|
-
'first-attempt',
|
|
1180
|
+
const switchSfu =
|
|
1181
|
+
err instanceof SfuJoinError &&
|
|
1182
|
+
SfuJoinError.isJoinErrorCode(err.errorEvent);
|
|
1183
|
+
|
|
1184
|
+
const sfuId = this.credentials?.server.edge_name;
|
|
1185
|
+
if (sfuId) {
|
|
1186
|
+
const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
|
|
1187
|
+
sfuJoinFailures.set(sfuId, failures);
|
|
1188
|
+
if (switchSfu || failures >= 2) {
|
|
1189
|
+
joinData.migrating_from = sfuId;
|
|
1190
|
+
joinData.migrating_from_list = Array.from(
|
|
1191
|
+
sfuJoinFailures.keys(),
|
|
1194
1192
|
);
|
|
1193
|
+
if (attempt < maxJoinRetries - 1) {
|
|
1194
|
+
this.clientEventReporter.startCorrelation(
|
|
1195
|
+
this.cid,
|
|
1196
|
+
'first-attempt',
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1195
1199
|
}
|
|
1196
1200
|
}
|
|
1197
|
-
}
|
|
1198
1201
|
|
|
1199
|
-
|
|
1200
|
-
|
|
1202
|
+
if (attempt === maxJoinRetries - 1) {
|
|
1203
|
+
throw err;
|
|
1204
|
+
}
|
|
1201
1205
|
}
|
|
1206
|
+
await sleep(retryInterval(attempt));
|
|
1202
1207
|
}
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
};
|
|
1208
|
+
},
|
|
1209
|
+
);
|
|
1210
|
+
} catch (error) {
|
|
1211
|
+
callingX?.endCall(this, 'error');
|
|
1212
|
+
throw error;
|
|
1213
|
+
}
|
|
1214
|
+
},
|
|
1215
|
+
);
|
|
1212
1216
|
|
|
1213
1217
|
/**
|
|
1214
1218
|
* Will make a single attempt to watch for call related WebSocket events
|
|
@@ -1431,7 +1435,10 @@ export class Call {
|
|
|
1431
1435
|
this.state.settings &&
|
|
1432
1436
|
!supersededByLeave()
|
|
1433
1437
|
) {
|
|
1434
|
-
await this.applyDeviceConfig(this.state.settings,
|
|
1438
|
+
await this.applyDeviceConfig(this.state.settings, {
|
|
1439
|
+
publish: true,
|
|
1440
|
+
skipSpeakerApply: false,
|
|
1441
|
+
});
|
|
1435
1442
|
this.deviceSettingsAppliedOnce = true;
|
|
1436
1443
|
}
|
|
1437
1444
|
|
|
@@ -2411,12 +2418,18 @@ export class Call {
|
|
|
2411
2418
|
const streamStateProp = trackTypeToParticipantStreamKey(trackType);
|
|
2412
2419
|
if (!streamStateProp) continue;
|
|
2413
2420
|
|
|
2414
|
-
this.state.updateParticipant(sessionId, (p) =>
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2421
|
+
this.state.updateParticipant(sessionId, (p) => {
|
|
2422
|
+
const currentStream = p[streamStateProp];
|
|
2423
|
+
const isEchoedStream =
|
|
2424
|
+
this.subscriber?.isSelfSubscribedStream(currentStream);
|
|
2425
|
+
|
|
2426
|
+
return {
|
|
2427
|
+
publishedTracks: mediaStream
|
|
2428
|
+
? pushToIfMissing([...p.publishedTracks], trackType)
|
|
2429
|
+
: p.publishedTracks.filter((t) => t !== trackType),
|
|
2430
|
+
[streamStateProp]: isEchoedStream ? currentStream : mediaStream,
|
|
2431
|
+
};
|
|
2432
|
+
});
|
|
2420
2433
|
}
|
|
2421
2434
|
};
|
|
2422
2435
|
|
|
@@ -3367,20 +3380,29 @@ export class Call {
|
|
|
3367
3380
|
*/
|
|
3368
3381
|
applyDeviceConfig = async (
|
|
3369
3382
|
settings: CallSettingsResponse,
|
|
3370
|
-
|
|
3371
|
-
|
|
3383
|
+
opts: {
|
|
3384
|
+
publish: boolean;
|
|
3385
|
+
skipSpeakerApply?: boolean;
|
|
3386
|
+
withDisabledDevices?: boolean;
|
|
3387
|
+
},
|
|
3372
3388
|
) => {
|
|
3389
|
+
const {
|
|
3390
|
+
publish,
|
|
3391
|
+
// Skip speaker setup on RN until joining (explicitly passed as false during join())
|
|
3392
|
+
skipSpeakerApply = isReactNative(),
|
|
3393
|
+
withDisabledDevices,
|
|
3394
|
+
} = opts;
|
|
3373
3395
|
if (!skipSpeakerApply) {
|
|
3374
|
-
await this.speaker
|
|
3375
|
-
|
|
3376
|
-
|
|
3396
|
+
await this.speaker
|
|
3397
|
+
.apply(settings)
|
|
3398
|
+
.catch((err) => this.logger.warn('Speaker init failed', err));
|
|
3377
3399
|
}
|
|
3378
|
-
await this.camera
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
await this.microphone
|
|
3382
|
-
|
|
3383
|
-
|
|
3400
|
+
await this.camera
|
|
3401
|
+
.apply(settings.video, publish, withDisabledDevices)
|
|
3402
|
+
.catch((err) => this.logger.warn('Camera init failed', err));
|
|
3403
|
+
await this.microphone
|
|
3404
|
+
.apply(settings.audio, publish, withDisabledDevices)
|
|
3405
|
+
.catch((err) => this.logger.warn('Mic init failed', err));
|
|
3384
3406
|
};
|
|
3385
3407
|
|
|
3386
3408
|
/**
|
package/src/StreamVideoClient.ts
CHANGED
|
@@ -39,7 +39,6 @@ import {
|
|
|
39
39
|
getInstanceKey,
|
|
40
40
|
} from './helpers/clientUtils';
|
|
41
41
|
import { logToConsole, ScopedLogger, videoLoggerSystem } from './logger';
|
|
42
|
-
import { isReactNative } from './helpers/platforms';
|
|
43
42
|
import { withoutConcurrency } from './helpers/concurrency';
|
|
44
43
|
import { enableTimerWorker } from './timers';
|
|
45
44
|
import { ClientEventReporter } from './reporting';
|
|
@@ -211,6 +210,7 @@ export class StreamVideoClient {
|
|
|
211
210
|
await call.reject('busy');
|
|
212
211
|
} else {
|
|
213
212
|
await call.updateFromRingingEvent(e as CallRingEvent);
|
|
213
|
+
await call.get();
|
|
214
214
|
}
|
|
215
215
|
} else {
|
|
216
216
|
call.state.updateFromCallResponse(e.call);
|
|
@@ -248,6 +248,17 @@ export class StreamVideoClient {
|
|
|
248
248
|
}
|
|
249
249
|
};
|
|
250
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Queries the API for calls matching the given filters.
|
|
253
|
+
* @param data the query data.
|
|
254
|
+
*/
|
|
255
|
+
private doQueryCalls = (data: QueryCallsRequest) => {
|
|
256
|
+
return this.streamClient.post<QueryCallsResponse, QueryCallsRequest>(
|
|
257
|
+
'/calls',
|
|
258
|
+
data,
|
|
259
|
+
);
|
|
260
|
+
};
|
|
261
|
+
|
|
251
262
|
/**
|
|
252
263
|
* Rewatches the given calls with retry logic.
|
|
253
264
|
* @param callsToReWatch array of call IDs to rewatch
|
|
@@ -261,11 +272,22 @@ export class StreamVideoClient {
|
|
|
261
272
|
`Rewatching calls ${callsToReWatch.join(', ')} attempt ${attempt + 1}`,
|
|
262
273
|
);
|
|
263
274
|
|
|
264
|
-
await this.
|
|
275
|
+
const response = await this.doQueryCalls({
|
|
265
276
|
watch: true,
|
|
266
277
|
filter_conditions: { cid: { $in: callsToReWatch } },
|
|
267
278
|
});
|
|
268
279
|
|
|
280
|
+
for (const c of response.calls) {
|
|
281
|
+
const call = this.writeableStateStore.findCall(
|
|
282
|
+
c.call.type,
|
|
283
|
+
c.call.id,
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
if (call) {
|
|
287
|
+
call.updateFromCallStateResponse(c);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
269
291
|
return;
|
|
270
292
|
} catch (err) {
|
|
271
293
|
if (err instanceof ErrorFromResponse && err.unrecoverable) {
|
|
@@ -452,12 +474,14 @@ export class StreamVideoClient {
|
|
|
452
474
|
* Will query the API for calls matching the given filters.
|
|
453
475
|
*
|
|
454
476
|
* @param data the query data.
|
|
477
|
+
* @param opts additional options, for tweaking the API behavior.
|
|
455
478
|
*/
|
|
456
|
-
queryCalls = async (
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
479
|
+
queryCalls = async (
|
|
480
|
+
data: QueryCallsRequest = {},
|
|
481
|
+
opts: { withDisabledDevices?: boolean } = {},
|
|
482
|
+
) => {
|
|
483
|
+
const { withDisabledDevices = true } = opts;
|
|
484
|
+
const response = await this.doQueryCalls(data);
|
|
461
485
|
const calls = [];
|
|
462
486
|
for (const c of response.calls) {
|
|
463
487
|
const call = new Call({
|
|
@@ -471,7 +495,10 @@ export class StreamVideoClient {
|
|
|
471
495
|
clientStore: this.writeableStateStore,
|
|
472
496
|
});
|
|
473
497
|
call.state.updateFromCallResponse(c.call);
|
|
474
|
-
await call.applyDeviceConfig(c.call.settings,
|
|
498
|
+
await call.applyDeviceConfig(c.call.settings, {
|
|
499
|
+
publish: false,
|
|
500
|
+
withDisabledDevices,
|
|
501
|
+
});
|
|
475
502
|
if (data.watch) {
|
|
476
503
|
await call.setup();
|
|
477
504
|
this.writeableStateStore.registerCall(call);
|
|
@@ -4,27 +4,38 @@
|
|
|
4
4
|
|
|
5
5
|
import '../rtc/__tests__/mocks/webrtc.mocks';
|
|
6
6
|
|
|
7
|
-
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
8
8
|
import { Call } from '../Call';
|
|
9
9
|
import { StreamClient } from '../coordinator/connection/client';
|
|
10
10
|
import { ClientEventReporter } from '../reporting';
|
|
11
11
|
import { generateUUIDv4 } from '../coordinator/connection/utils';
|
|
12
12
|
import { StreamVideoWriteableStateStore } from '../store';
|
|
13
|
+
import { promiseWithResolvers } from '../helpers/promise';
|
|
13
14
|
|
|
14
15
|
describe('Call lifecycle wiring', () => {
|
|
15
16
|
let call: Call;
|
|
16
17
|
|
|
17
18
|
beforeEach(() => {
|
|
19
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
|
20
|
+
headers: { get: () => 'AMS1-P2' },
|
|
21
|
+
} as Response);
|
|
18
22
|
const streamClient = new StreamClient('abc');
|
|
19
23
|
call = new Call({
|
|
20
24
|
type: 'test',
|
|
21
25
|
id: generateUUIDv4(),
|
|
22
26
|
streamClient,
|
|
23
|
-
clientEventReporter: new ClientEventReporter({
|
|
27
|
+
clientEventReporter: new ClientEventReporter({
|
|
28
|
+
streamClient,
|
|
29
|
+
enabled: false,
|
|
30
|
+
}),
|
|
24
31
|
clientStore: new StreamVideoWriteableStateStore(),
|
|
25
32
|
});
|
|
26
33
|
});
|
|
27
34
|
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
vi.restoreAllMocks();
|
|
37
|
+
});
|
|
38
|
+
|
|
28
39
|
// Regression guard for the Call-owned helper teardown chain. Each of
|
|
29
40
|
// these helpers holds a resource (timer, listener, AudioContext) that
|
|
30
41
|
// leaks across calls if teardown is dropped during a refactor.
|
|
@@ -67,4 +78,24 @@ describe('Call lifecycle wiring', () => {
|
|
|
67
78
|
expect(trackSubOrder).toBeLessThan(audioBindingsOrder);
|
|
68
79
|
expect(audioBindingsOrder).toBeLessThan(dynascaleOrder);
|
|
69
80
|
});
|
|
81
|
+
|
|
82
|
+
it('call.join() shares an in-flight join flow', async () => {
|
|
83
|
+
const joinTask = promiseWithResolvers<void>();
|
|
84
|
+
vi.spyOn(call, 'setup').mockResolvedValue(undefined);
|
|
85
|
+
const doJoin = vi
|
|
86
|
+
.spyOn(call as unknown as { doJoin: Call['join'] }, 'doJoin')
|
|
87
|
+
.mockReturnValue(joinTask.promise);
|
|
88
|
+
|
|
89
|
+
const firstJoin = call.join();
|
|
90
|
+
const secondJoin = call.join();
|
|
91
|
+
|
|
92
|
+
await Promise.resolve();
|
|
93
|
+
expect(doJoin).toHaveBeenCalledTimes(1);
|
|
94
|
+
|
|
95
|
+
joinTask.resolve();
|
|
96
|
+
await expect(Promise.all([firstJoin, secondJoin])).resolves.toEqual([
|
|
97
|
+
undefined,
|
|
98
|
+
undefined,
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
70
101
|
});
|
|
@@ -2,7 +2,7 @@ import '../rtc/__tests__/mocks/webrtc.mocks';
|
|
|
2
2
|
|
|
3
3
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
4
|
import { Call } from '../Call';
|
|
5
|
-
import { Publisher } from '../rtc';
|
|
5
|
+
import { Publisher, Subscriber } from '../rtc';
|
|
6
6
|
import { StreamClient } from '../coordinator/connection/client';
|
|
7
7
|
import { ClientEventReporter } from '../reporting';
|
|
8
8
|
import { generateUUIDv4 } from '../coordinator/connection/utils';
|
|
@@ -183,6 +183,47 @@ describe('Publishing and Unpublishing tracks', () => {
|
|
|
183
183
|
expect(participant!.audioStream).toEqual(mediaStream);
|
|
184
184
|
});
|
|
185
185
|
|
|
186
|
+
it('keeps a stream the SFU already echoed back instead of the capture one', async () => {
|
|
187
|
+
// `allowOwnTracksLoopback`: the echo can land on the participant before
|
|
188
|
+
// publishing gets to write the capture stream to the same field.
|
|
189
|
+
const echoedStream = new MediaStream();
|
|
190
|
+
call.subscriber = {
|
|
191
|
+
isSelfSubscribedStream: (stream: MediaStream | undefined) =>
|
|
192
|
+
stream === echoedStream,
|
|
193
|
+
} as unknown as Subscriber;
|
|
194
|
+
call.state.updateParticipant(sessionId, { audioStream: echoedStream });
|
|
195
|
+
|
|
196
|
+
const track = new MediaStreamTrack();
|
|
197
|
+
const captureStream = new MediaStream();
|
|
198
|
+
vi.spyOn(captureStream, 'getAudioTracks').mockReturnValue([track]);
|
|
199
|
+
|
|
200
|
+
await call.publish(captureStream, TrackType.AUDIO);
|
|
201
|
+
|
|
202
|
+
const participant = call.state.findParticipantBySessionId(sessionId);
|
|
203
|
+
expect(participant!.publishedTracks).toEqual([TrackType.AUDIO]);
|
|
204
|
+
expect(participant!.audioStream).toBe(echoedStream);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('keeps an echoed stream when the track is unpublished', async () => {
|
|
208
|
+
// a device switch unpublishes and republishes; the echoed track goes
|
|
209
|
+
// briefly silent but is never replaced, so the reference has to survive
|
|
210
|
+
const echoedStream = new MediaStream();
|
|
211
|
+
call.subscriber = {
|
|
212
|
+
isSelfSubscribedStream: (stream: MediaStream | undefined) =>
|
|
213
|
+
stream === echoedStream,
|
|
214
|
+
} as unknown as Subscriber;
|
|
215
|
+
call.state.updateParticipant(sessionId, {
|
|
216
|
+
audioStream: echoedStream,
|
|
217
|
+
publishedTracks: [TrackType.AUDIO],
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await call.stopPublish(TrackType.AUDIO);
|
|
221
|
+
|
|
222
|
+
const participant = call.state.findParticipantBySessionId(sessionId);
|
|
223
|
+
expect(participant!.publishedTracks).toEqual([]);
|
|
224
|
+
expect(participant!.audioStream).toBe(echoedStream);
|
|
225
|
+
});
|
|
226
|
+
|
|
186
227
|
it('publish screen share stream', async () => {
|
|
187
228
|
const track = new MediaStreamTrack();
|
|
188
229
|
const mediaStream = new MediaStream();
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
} from 'vitest';
|
|
10
10
|
import { StreamVideoClient } from '../StreamVideoClient';
|
|
11
11
|
import { Call } from '../Call';
|
|
12
|
+
import { CameraManager } from '../devices/CameraManager';
|
|
13
|
+
import { MicrophoneManager } from '../devices/MicrophoneManager';
|
|
12
14
|
import { CallCreatedPayload } from './data';
|
|
13
15
|
import { generateUUIDv4 } from '../coordinator/connection/utils';
|
|
14
16
|
import type { StreamClient } from '../coordinator/connection/client';
|
|
@@ -73,6 +75,37 @@ describe('StreamVideoClient - coordinator API', () => {
|
|
|
73
75
|
expect(call.cid).toBe(CallCreatedPayload.call.cid);
|
|
74
76
|
});
|
|
75
77
|
|
|
78
|
+
it('query calls keeps devices disabled by default', async () => {
|
|
79
|
+
const response: QueryCallsResponse = {
|
|
80
|
+
duration: '1ms',
|
|
81
|
+
calls: [
|
|
82
|
+
{
|
|
83
|
+
call: CallCreatedPayload.call,
|
|
84
|
+
members: CallCreatedPayload.members,
|
|
85
|
+
own_capabilities: [],
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
};
|
|
89
|
+
post.mockResolvedValue(response);
|
|
90
|
+
const applyCamera = vi
|
|
91
|
+
.spyOn(CameraManager.prototype, 'apply')
|
|
92
|
+
.mockResolvedValue();
|
|
93
|
+
const applyMicrophone = vi
|
|
94
|
+
.spyOn(MicrophoneManager.prototype, 'apply')
|
|
95
|
+
.mockResolvedValue();
|
|
96
|
+
|
|
97
|
+
await client.queryCalls();
|
|
98
|
+
await client.queryCalls({}, {});
|
|
99
|
+
await client.queryCalls({}, { withDisabledDevices: false });
|
|
100
|
+
|
|
101
|
+
expect(
|
|
102
|
+
applyCamera.mock.calls.map(([, , forceDisabled]) => forceDisabled),
|
|
103
|
+
).toEqual([true, true, false]);
|
|
104
|
+
expect(
|
|
105
|
+
applyMicrophone.mock.calls.map(([, , forceDisabled]) => forceDisabled),
|
|
106
|
+
).toEqual([true, true, false]);
|
|
107
|
+
});
|
|
108
|
+
|
|
76
109
|
it('query calls - ongoing', async () => {
|
|
77
110
|
post.mockResolvedValue({ duration: '1ms', calls: [] });
|
|
78
111
|
|