@stream-io/video-client 1.52.0 → 1.53.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 +10 -0
- package/dist/index.browser.es.js +796 -51
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +796 -50
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +796 -51
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +5 -1
- package/dist/src/StreamVideoClient.d.ts +2 -0
- package/dist/src/coordinator/connection/client.d.ts +1 -0
- package/dist/src/errors/SfuTimeoutError.d.ts +8 -0
- package/dist/src/errors/index.d.ts +1 -0
- package/dist/src/helpers/firstVideoFrame.d.ts +7 -0
- package/dist/src/reporting/ClientEventReporter.d.ts +84 -0
- package/dist/src/reporting/index.d.ts +1 -0
- package/dist/src/rtc/BasePeerConnection.d.ts +5 -2
- package/dist/src/rtc/types.d.ts +24 -1
- package/dist/src/types.d.ts +5 -0
- package/package.json +1 -1
- package/src/Call.ts +184 -60
- package/src/StreamSfuClient.ts +3 -3
- package/src/StreamVideoClient.ts +18 -3
- package/src/__tests__/Call.autodrop.test.ts +4 -1
- package/src/__tests__/Call.lifecycle.test.ts +4 -1
- package/src/__tests__/Call.publishing.test.ts +4 -1
- package/src/__tests__/Call.test.ts +23 -0
- package/src/coordinator/connection/client.ts +5 -0
- package/src/devices/__tests__/CameraManager.test.ts +10 -1
- package/src/devices/__tests__/DeviceManager.test.ts +10 -1
- package/src/devices/__tests__/DeviceManagerFilters.test.ts +4 -1
- package/src/devices/__tests__/MicrophoneManager.test.ts +10 -1
- package/src/devices/__tests__/MicrophoneManagerRN.test.ts +4 -1
- package/src/devices/__tests__/ScreenShareManager.test.ts +4 -1
- package/src/devices/__tests__/SpeakerManager.test.ts +13 -4
- package/src/errors/SfuTimeoutError.ts +7 -0
- package/src/errors/index.ts +1 -0
- package/src/events/__tests__/call.test.ts +2 -0
- package/src/events/__tests__/mutes.test.ts +4 -1
- package/src/events/call.ts +8 -0
- package/src/helpers/__tests__/AudioBindingsWatchdog.test.ts +6 -3
- package/src/helpers/__tests__/DynascaleManager.test.ts +6 -3
- package/src/helpers/__tests__/ViewportTracker.test.ts +6 -3
- package/src/helpers/__tests__/firstVideoFrame.test.ts +91 -0
- package/src/helpers/firstVideoFrame.ts +38 -0
- package/src/reporting/ClientEventReporter.ts +859 -0
- package/src/reporting/__tests__/ClientEventReporter.test.ts +620 -0
- package/src/reporting/index.ts +1 -0
- package/src/rtc/BasePeerConnection.ts +30 -0
- package/src/rtc/Subscriber.ts +1 -0
- package/src/rtc/__tests__/Call.reconnect.test.ts +3 -0
- package/src/rtc/types.ts +34 -0
- package/src/types.ts +6 -0
package/src/Call.ts
CHANGED
|
@@ -130,6 +130,7 @@ import {
|
|
|
130
130
|
ClientCapability,
|
|
131
131
|
ClientDetails,
|
|
132
132
|
Codec,
|
|
133
|
+
ErrorCode,
|
|
133
134
|
ParticipantSource,
|
|
134
135
|
PeerType,
|
|
135
136
|
PublishOption,
|
|
@@ -145,10 +146,12 @@ import {
|
|
|
145
146
|
StatsReporter,
|
|
146
147
|
Tracer,
|
|
147
148
|
} from './stats';
|
|
149
|
+
import type { ClientEventReporter, JoinReason } from './reporting';
|
|
148
150
|
import { AudioBindingsWatchdog } from './helpers/AudioBindingsWatchdog';
|
|
149
151
|
import { BlockedAudioTracker } from './helpers/BlockedAudioTracker';
|
|
150
152
|
import { TrackSubscriptionManager } from './helpers/TrackSubscriptionManager';
|
|
151
153
|
import { DynascaleManager } from './helpers/DynascaleManager';
|
|
154
|
+
import { createFirstVideoFrameDetector } from './helpers/firstVideoFrame';
|
|
152
155
|
import { ViewportTracker } from './helpers/ViewportTracker';
|
|
153
156
|
import { PermissionsContext } from './permissions';
|
|
154
157
|
import { CallTypes } from './CallType';
|
|
@@ -292,6 +295,7 @@ export class Call {
|
|
|
292
295
|
|
|
293
296
|
private readonly clientStore: StreamVideoWriteableStateStore;
|
|
294
297
|
public readonly streamClient: StreamClient;
|
|
298
|
+
public readonly clientEventReporter: ClientEventReporter;
|
|
295
299
|
private sfuClient?: StreamSfuClient;
|
|
296
300
|
private sfuClientTag = 0;
|
|
297
301
|
private unifiedSessionId?: string;
|
|
@@ -357,6 +361,7 @@ export class Call {
|
|
|
357
361
|
type,
|
|
358
362
|
id,
|
|
359
363
|
streamClient,
|
|
364
|
+
clientEventReporter,
|
|
360
365
|
members,
|
|
361
366
|
ownCapabilities,
|
|
362
367
|
sortParticipantsBy,
|
|
@@ -370,6 +375,7 @@ export class Call {
|
|
|
370
375
|
this.ringingSubject = new BehaviorSubject(ringing);
|
|
371
376
|
this.watching = watching;
|
|
372
377
|
this.streamClient = streamClient;
|
|
378
|
+
this.clientEventReporter = clientEventReporter;
|
|
373
379
|
this.clientStore = clientStore;
|
|
374
380
|
this.streamClientBasePath = `/call/${this.type}/${this.id}`;
|
|
375
381
|
this.logger = videoLoggerSystem.getLogger('Call');
|
|
@@ -740,11 +746,18 @@ export class Call {
|
|
|
740
746
|
this.lastStatsOptions = undefined;
|
|
741
747
|
|
|
742
748
|
await this.subscriber?.dispose();
|
|
749
|
+
this.clientEventReporter.abort(this.cid, {
|
|
750
|
+
code: 'CLIENT_ABORTED',
|
|
751
|
+
reason: leaveReason,
|
|
752
|
+
});
|
|
753
|
+
|
|
743
754
|
this.subscriber = undefined;
|
|
744
755
|
|
|
745
756
|
await this.publisher?.dispose();
|
|
746
757
|
this.publisher = undefined;
|
|
747
758
|
|
|
759
|
+
this.clientEventReporter.unregisterCall(this.cid);
|
|
760
|
+
|
|
748
761
|
await this.sfuClient?.leaveAndClose(leaveReason);
|
|
749
762
|
this.sfuClient = undefined;
|
|
750
763
|
this.trackSubscriptionManager.setSfuClient(undefined);
|
|
@@ -1052,6 +1065,14 @@ export class Call {
|
|
|
1052
1065
|
|
|
1053
1066
|
await this.setup();
|
|
1054
1067
|
|
|
1068
|
+
this.clientEventReporter.registerCall(this.cid, {
|
|
1069
|
+
callType: this.type,
|
|
1070
|
+
callId: this.id,
|
|
1071
|
+
getCallSessionId: () => this.state.session?.id ?? '',
|
|
1072
|
+
getSfuId: () => this.credentials?.server.edge_name ?? '',
|
|
1073
|
+
getUserSessionId: () => this.sfuClient?.sessionId ?? '',
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1055
1076
|
this.joinResponseTimeout = joinResponseTimeout;
|
|
1056
1077
|
this.rpcRequestTimeout = rpcRequestTimeout;
|
|
1057
1078
|
// we will count the number of join failures per SFU.
|
|
@@ -1061,44 +1082,56 @@ export class Call {
|
|
|
1061
1082
|
const joinData: JoinCallData = data;
|
|
1062
1083
|
maxJoinRetries = Math.max(maxJoinRetries, 1);
|
|
1063
1084
|
try {
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1085
|
+
await this.clientEventReporter.withJoinLifecycle(
|
|
1086
|
+
this.cid,
|
|
1087
|
+
'first-attempt',
|
|
1088
|
+
async () => {
|
|
1089
|
+
for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
|
|
1090
|
+
try {
|
|
1091
|
+
this.logger.trace(`Joining call (${attempt})`, this.cid);
|
|
1092
|
+
await this.doJoin(data);
|
|
1093
|
+
delete joinData.migrating_from;
|
|
1094
|
+
delete joinData.migrating_from_list;
|
|
1095
|
+
return;
|
|
1096
|
+
} catch (err) {
|
|
1097
|
+
this.logger.warn(`Failed to join call (${attempt})`, this.cid);
|
|
1098
|
+
if (
|
|
1099
|
+
(err instanceof ErrorFromResponse && err.unrecoverable) ||
|
|
1100
|
+
(err instanceof SfuJoinError && err.unrecoverable)
|
|
1101
|
+
) {
|
|
1102
|
+
throw err;
|
|
1103
|
+
}
|
|
1082
1104
|
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1105
|
+
const switchSfu =
|
|
1106
|
+
err instanceof SfuJoinError &&
|
|
1107
|
+
SfuJoinError.isJoinErrorCode(err.errorEvent);
|
|
1108
|
+
|
|
1109
|
+
const sfuId = this.credentials?.server.edge_name;
|
|
1110
|
+
if (sfuId) {
|
|
1111
|
+
const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
|
|
1112
|
+
sfuJoinFailures.set(sfuId, failures);
|
|
1113
|
+
if (switchSfu || failures >= 2) {
|
|
1114
|
+
joinData.migrating_from = sfuId;
|
|
1115
|
+
joinData.migrating_from_list = Array.from(
|
|
1116
|
+
sfuJoinFailures.keys(),
|
|
1117
|
+
);
|
|
1118
|
+
if (attempt < maxJoinRetries - 1) {
|
|
1119
|
+
this.clientEventReporter.startCorrelation(
|
|
1120
|
+
this.cid,
|
|
1121
|
+
'first-attempt',
|
|
1122
|
+
);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1095
1126
|
|
|
1096
|
-
|
|
1097
|
-
|
|
1127
|
+
if (attempt === maxJoinRetries - 1) {
|
|
1128
|
+
throw err;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
await sleep(retryInterval(attempt));
|
|
1098
1132
|
}
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
|
-
}
|
|
1133
|
+
},
|
|
1134
|
+
);
|
|
1102
1135
|
} catch (error) {
|
|
1103
1136
|
callingX?.endCall(this, 'error');
|
|
1104
1137
|
throw error;
|
|
@@ -1136,7 +1169,11 @@ export class Call {
|
|
|
1136
1169
|
data?.migrating_from
|
|
1137
1170
|
) {
|
|
1138
1171
|
try {
|
|
1139
|
-
const joinResponse = await this.
|
|
1172
|
+
const joinResponse = await this.clientEventReporter.track(
|
|
1173
|
+
this.cid,
|
|
1174
|
+
'CoordinatorJoin',
|
|
1175
|
+
() => this.doJoinRequest(data),
|
|
1176
|
+
);
|
|
1140
1177
|
this.credentials = joinResponse.credentials;
|
|
1141
1178
|
statsOptions = joinResponse.stats_options;
|
|
1142
1179
|
this.lastStatsOptions = statsOptions;
|
|
@@ -1202,20 +1239,24 @@ export class Call {
|
|
|
1202
1239
|
? this.getPreferredSubscribeOptions()
|
|
1203
1240
|
: [];
|
|
1204
1241
|
|
|
1242
|
+
const unifiedSessionId = this.unifiedSessionId;
|
|
1243
|
+
const capabilities = Array.from(this.clientCapabilities);
|
|
1205
1244
|
try {
|
|
1206
1245
|
const { callState, fastReconnectDeadlineSeconds, publishOptions } =
|
|
1207
|
-
await
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1246
|
+
await this.clientEventReporter.track(this.cid, 'WSJoin', () =>
|
|
1247
|
+
sfuClient.join({
|
|
1248
|
+
unifiedSessionId,
|
|
1249
|
+
subscriberSdp,
|
|
1250
|
+
publisherSdp,
|
|
1251
|
+
clientDetails,
|
|
1252
|
+
fastReconnect: performingFastReconnect,
|
|
1253
|
+
reconnectDetails,
|
|
1254
|
+
preferredPublishOptions,
|
|
1255
|
+
preferredSubscribeOptions,
|
|
1256
|
+
capabilities,
|
|
1257
|
+
source: ParticipantSource.WEBRTC_UNSPECIFIED,
|
|
1258
|
+
}),
|
|
1259
|
+
);
|
|
1219
1260
|
|
|
1220
1261
|
this.currentPublishOptions = publishOptions;
|
|
1221
1262
|
this.fastReconnectDeadlineSeconds = fastReconnectDeadlineSeconds;
|
|
@@ -1486,6 +1527,18 @@ export class Call {
|
|
|
1486
1527
|
// "ICE never connected" failure budget can be cleared.
|
|
1487
1528
|
this.iceFailuresWithoutConnect = 0;
|
|
1488
1529
|
},
|
|
1530
|
+
onPeerConnectionStateChange: (event) => {
|
|
1531
|
+
this.clientEventReporter.onPeerConnectionStateChange(this.cid, event);
|
|
1532
|
+
},
|
|
1533
|
+
onRemoteTrackUnmute: (trackType, trackId) => {
|
|
1534
|
+
const reportable =
|
|
1535
|
+
trackType === TrackType.AUDIO ||
|
|
1536
|
+
(isReactNative() && trackType === TrackType.VIDEO);
|
|
1537
|
+
|
|
1538
|
+
if (!reportable) return;
|
|
1539
|
+
|
|
1540
|
+
this.clientEventReporter.reportFirstFrame(this.cid, trackType, trackId);
|
|
1541
|
+
},
|
|
1489
1542
|
};
|
|
1490
1543
|
|
|
1491
1544
|
this.subscriber = new Subscriber(basePeerConnectionOptions);
|
|
@@ -1544,6 +1597,7 @@ export class Call {
|
|
|
1544
1597
|
JoinCallResponse,
|
|
1545
1598
|
JoinCallRequest
|
|
1546
1599
|
>(`${this.streamClientBasePath}/join`, request);
|
|
1600
|
+
|
|
1547
1601
|
this.state.updateFromCallResponse(joinResponse.call);
|
|
1548
1602
|
this.state.setMembers(joinResponse.members);
|
|
1549
1603
|
this.state.setOwnCapabilities(joinResponse.own_capabilities);
|
|
@@ -1717,7 +1771,9 @@ export class Call {
|
|
|
1717
1771
|
await this.networkAvailableTask?.promise;
|
|
1718
1772
|
|
|
1719
1773
|
this.logger.info(
|
|
1720
|
-
`[Reconnect] Reconnecting with strategy ${
|
|
1774
|
+
`[Reconnect] Reconnecting with strategy ${
|
|
1775
|
+
WebsocketReconnectStrategy[this.reconnectStrategy]
|
|
1776
|
+
}`,
|
|
1721
1777
|
);
|
|
1722
1778
|
|
|
1723
1779
|
switch (this.reconnectStrategy) {
|
|
@@ -1838,7 +1894,13 @@ export class Call {
|
|
|
1838
1894
|
const reconnectStartTime = Date.now();
|
|
1839
1895
|
this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN;
|
|
1840
1896
|
this.state.setCallingState(CallingState.RECONNECTING);
|
|
1841
|
-
|
|
1897
|
+
const joinReason: JoinReason =
|
|
1898
|
+
this.reconnectReason === ReconnectReason.NETWORK_BACK_ONLINE
|
|
1899
|
+
? 'network-available'
|
|
1900
|
+
: 'full-rejoin';
|
|
1901
|
+
await this.clientEventReporter.withJoinLifecycle(this.cid, joinReason, () =>
|
|
1902
|
+
this.doJoin(this.joinCallData),
|
|
1903
|
+
);
|
|
1842
1904
|
await this.restorePublishedTracks();
|
|
1843
1905
|
this.restoreSubscribedTracks();
|
|
1844
1906
|
this.sfuStatsReporter?.sendReconnectionTime(
|
|
@@ -1870,11 +1932,16 @@ export class Call {
|
|
|
1870
1932
|
|
|
1871
1933
|
try {
|
|
1872
1934
|
const currentSfu = currentSfuClient.edgeName;
|
|
1873
|
-
await this.
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1935
|
+
await this.clientEventReporter.withJoinLifecycle(
|
|
1936
|
+
this.cid,
|
|
1937
|
+
'migration',
|
|
1938
|
+
() =>
|
|
1939
|
+
this.doJoin({
|
|
1940
|
+
...this.joinCallData,
|
|
1941
|
+
migrating_from: currentSfu,
|
|
1942
|
+
migrating_from_list: [currentSfu],
|
|
1943
|
+
}),
|
|
1944
|
+
);
|
|
1878
1945
|
} finally {
|
|
1879
1946
|
// cleanup the migration_from field after the migration is complete or failed
|
|
1880
1947
|
// as we don't want to keep dirty data in the join call data
|
|
@@ -1916,6 +1983,10 @@ export class Call {
|
|
|
1916
1983
|
private registerReconnectHandlers = () => {
|
|
1917
1984
|
// handles the legacy "goAway" event
|
|
1918
1985
|
const unregisterGoAway = this.on('goAway', () => {
|
|
1986
|
+
this.clientEventReporter.captureWsError(this.cid, {
|
|
1987
|
+
code: 'SFU_GO_AWAY',
|
|
1988
|
+
reason: 'SFU goAway received during WS join',
|
|
1989
|
+
});
|
|
1919
1990
|
this.reconnect(
|
|
1920
1991
|
WebsocketReconnectStrategy.MIGRATE,
|
|
1921
1992
|
ReconnectReason.GO_AWAY,
|
|
@@ -1925,6 +1996,13 @@ export class Call {
|
|
|
1925
1996
|
// handles the "error" event, through which the SFU can request a reconnect
|
|
1926
1997
|
const unregisterOnError = this.on('error', (e) => {
|
|
1927
1998
|
const { reconnectStrategy: strategy, error } = e;
|
|
1999
|
+
if (!SfuJoinError.isJoinErrorCode(e)) {
|
|
2000
|
+
const code = error?.code ? ErrorCode[error.code] : undefined;
|
|
2001
|
+
this.clientEventReporter.captureWsError(this.cid, {
|
|
2002
|
+
code: code ?? 'SFU_ERROR',
|
|
2003
|
+
reason: error?.message || 'SFU error during WS join',
|
|
2004
|
+
});
|
|
2005
|
+
}
|
|
1928
2006
|
// SFU_FULL is a join error, and when emitted, although it specifies a
|
|
1929
2007
|
// `migrate` strategy, we should actually perform a REJOIN to a new SFU.
|
|
1930
2008
|
// This is now handled separately in the `call.join()` method.
|
|
@@ -2819,7 +2897,11 @@ export class Call {
|
|
|
2819
2897
|
this.leave({
|
|
2820
2898
|
reject: true,
|
|
2821
2899
|
reason: 'timeout',
|
|
2822
|
-
message: `ringing timeout - ${
|
|
2900
|
+
message: `ringing timeout - ${
|
|
2901
|
+
this.isCreatedByMe
|
|
2902
|
+
? 'no one accepted'
|
|
2903
|
+
: `user didn't interact with incoming call screen`
|
|
2904
|
+
}`,
|
|
2823
2905
|
}).catch((err) => {
|
|
2824
2906
|
this.logger.error('Failed to drop call', err);
|
|
2825
2907
|
});
|
|
@@ -2880,7 +2962,9 @@ export class Call {
|
|
|
2880
2962
|
filename: string,
|
|
2881
2963
|
): Promise<DeleteRecordingResponse> => {
|
|
2882
2964
|
return this.streamClient.delete<DeleteRecordingResponse>(
|
|
2883
|
-
`${this.streamClientBasePath}/${encodeURIComponent(
|
|
2965
|
+
`${this.streamClientBasePath}/${encodeURIComponent(
|
|
2966
|
+
callSessionId,
|
|
2967
|
+
)}/recordings/${encodeURIComponent(filename)}`,
|
|
2884
2968
|
);
|
|
2885
2969
|
};
|
|
2886
2970
|
|
|
@@ -2895,7 +2979,9 @@ export class Call {
|
|
|
2895
2979
|
filename: string,
|
|
2896
2980
|
): Promise<DeleteTranscriptionResponse> => {
|
|
2897
2981
|
return this.streamClient.delete<DeleteTranscriptionResponse>(
|
|
2898
|
-
`${this.streamClientBasePath}/${encodeURIComponent(
|
|
2982
|
+
`${this.streamClientBasePath}/${encodeURIComponent(
|
|
2983
|
+
callSessionId,
|
|
2984
|
+
)}/transcriptions/${encodeURIComponent(filename)}`,
|
|
2899
2985
|
);
|
|
2900
2986
|
};
|
|
2901
2987
|
|
|
@@ -3121,13 +3207,25 @@ export class Call {
|
|
|
3121
3207
|
sessionId: string,
|
|
3122
3208
|
trackType: VideoTrackType,
|
|
3123
3209
|
) => {
|
|
3124
|
-
const
|
|
3210
|
+
const unbindDynascale = this.dynascaleManager?.bindVideoElement(
|
|
3125
3211
|
videoElement,
|
|
3126
3212
|
sessionId,
|
|
3127
3213
|
trackType,
|
|
3128
3214
|
);
|
|
3129
3215
|
|
|
3130
|
-
|
|
3216
|
+
const stopFirstFrameDetector = this.bindFirstVideoFrameDetector(
|
|
3217
|
+
videoElement,
|
|
3218
|
+
sessionId,
|
|
3219
|
+
trackType,
|
|
3220
|
+
);
|
|
3221
|
+
|
|
3222
|
+
if (!unbindDynascale && !stopFirstFrameDetector) return;
|
|
3223
|
+
|
|
3224
|
+
const unbind = () => {
|
|
3225
|
+
stopFirstFrameDetector?.();
|
|
3226
|
+
unbindDynascale?.();
|
|
3227
|
+
};
|
|
3228
|
+
|
|
3131
3229
|
this.leaveCallHooks.add(unbind);
|
|
3132
3230
|
return () => {
|
|
3133
3231
|
this.leaveCallHooks.delete(unbind);
|
|
@@ -3135,6 +3233,32 @@ export class Call {
|
|
|
3135
3233
|
};
|
|
3136
3234
|
};
|
|
3137
3235
|
|
|
3236
|
+
private bindFirstVideoFrameDetector = (
|
|
3237
|
+
videoElement: HTMLVideoElement,
|
|
3238
|
+
sessionId: string,
|
|
3239
|
+
trackType: VideoTrackType,
|
|
3240
|
+
) => {
|
|
3241
|
+
if (trackType !== 'videoTrack') return;
|
|
3242
|
+
|
|
3243
|
+
return createFirstVideoFrameDetector(videoElement, () => {
|
|
3244
|
+
this.reportFirstRenderedVideoFrame(sessionId);
|
|
3245
|
+
});
|
|
3246
|
+
};
|
|
3247
|
+
|
|
3248
|
+
private reportFirstRenderedVideoFrame = (sessionId: string) => {
|
|
3249
|
+
const participant = this.state.findParticipantBySessionId(sessionId);
|
|
3250
|
+
if (participant?.isLocalParticipant) return;
|
|
3251
|
+
|
|
3252
|
+
const trackId = participant?.videoStream?.getVideoTracks()[0]?.id;
|
|
3253
|
+
if (!trackId) return;
|
|
3254
|
+
|
|
3255
|
+
this.clientEventReporter.reportFirstFrame(
|
|
3256
|
+
this.cid,
|
|
3257
|
+
TrackType.VIDEO,
|
|
3258
|
+
trackId,
|
|
3259
|
+
);
|
|
3260
|
+
};
|
|
3261
|
+
|
|
3138
3262
|
/**
|
|
3139
3263
|
* Binds a DOM <audio> element to the given session id.
|
|
3140
3264
|
*
|
package/src/StreamSfuClient.ts
CHANGED
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
import { withoutConcurrency } from './helpers/concurrency';
|
|
41
41
|
import { getTimers } from './timers';
|
|
42
42
|
import { Tracer, TraceSlice } from './stats';
|
|
43
|
-
import { SfuJoinError } from './errors';
|
|
43
|
+
import { SfuJoinError, SfuTimeoutError } from './errors';
|
|
44
44
|
|
|
45
45
|
export type StreamSfuClientConstructor = {
|
|
46
46
|
/**
|
|
@@ -353,7 +353,7 @@ export class StreamSfuClient {
|
|
|
353
353
|
timeoutId = setTimeout(() => {
|
|
354
354
|
const message = `SFU WS connection failed to open after ${this.joinResponseTimeout}ms`;
|
|
355
355
|
this.tracer?.trace('signal.timeout', message);
|
|
356
|
-
reject(new
|
|
356
|
+
reject(new SfuTimeoutError(message));
|
|
357
357
|
}, this.joinResponseTimeout);
|
|
358
358
|
}),
|
|
359
359
|
]),
|
|
@@ -644,7 +644,7 @@ export class StreamSfuClient {
|
|
|
644
644
|
cleanupJoinSubscriptions();
|
|
645
645
|
const message = `Waiting for "joinResponse" has timed out after ${this.joinResponseTimeout}ms`;
|
|
646
646
|
this.tracer?.trace('joinRequestTimeout', message);
|
|
647
|
-
current.reject(new
|
|
647
|
+
current.reject(new SfuTimeoutError(message));
|
|
648
648
|
}, this.joinResponseTimeout);
|
|
649
649
|
|
|
650
650
|
const joinRequest = SfuRequest.create({
|
package/src/StreamVideoClient.ts
CHANGED
|
@@ -42,6 +42,7 @@ import { logToConsole, ScopedLogger, videoLoggerSystem } from './logger';
|
|
|
42
42
|
import { isReactNative } from './helpers/platforms';
|
|
43
43
|
import { withoutConcurrency } from './helpers/concurrency';
|
|
44
44
|
import { enableTimerWorker } from './timers';
|
|
45
|
+
import { ClientEventReporter } from './reporting';
|
|
45
46
|
|
|
46
47
|
/**
|
|
47
48
|
* A `StreamVideoClient` instance lets you communicate with our API, and authenticate users.
|
|
@@ -60,6 +61,7 @@ export class StreamVideoClient {
|
|
|
60
61
|
|
|
61
62
|
protected readonly writeableStateStore: StreamVideoWriteableStateStore;
|
|
62
63
|
streamClient: StreamClient;
|
|
64
|
+
readonly clientEventReporter: ClientEventReporter;
|
|
63
65
|
|
|
64
66
|
private effectsRegistered = false;
|
|
65
67
|
private eventHandlersToUnregister: Array<() => void> = [];
|
|
@@ -97,6 +99,9 @@ export class StreamVideoClient {
|
|
|
97
99
|
this.rejectCallWhenBusy = clientOptions?.rejectCallWhenBusy ?? false;
|
|
98
100
|
|
|
99
101
|
this.streamClient = createCoordinatorClient(apiKey, clientOptions);
|
|
102
|
+
this.clientEventReporter = new ClientEventReporter({
|
|
103
|
+
streamClient: this.streamClient,
|
|
104
|
+
});
|
|
100
105
|
|
|
101
106
|
this.writeableStateStore = new StreamVideoWriteableStateStore();
|
|
102
107
|
this.readOnlyStateStore = new StreamVideoReadOnlyStateStore(
|
|
@@ -214,6 +219,7 @@ export class StreamVideoClient {
|
|
|
214
219
|
|
|
215
220
|
call = new Call({
|
|
216
221
|
streamClient: this.streamClient,
|
|
222
|
+
clientEventReporter: this.clientEventReporter,
|
|
217
223
|
type: e.call.type,
|
|
218
224
|
id: e.call.id,
|
|
219
225
|
members: e.members,
|
|
@@ -296,6 +302,9 @@ export class StreamVideoClient {
|
|
|
296
302
|
return this.connectAnonymousUser(user as UserWithId, tokenOrProvider);
|
|
297
303
|
}
|
|
298
304
|
|
|
305
|
+
const reporter = this.clientEventReporter;
|
|
306
|
+
reporter.startCoordinatorConnection(user.id);
|
|
307
|
+
|
|
299
308
|
const connectUserResponse = await withoutConcurrency(
|
|
300
309
|
this.connectionConcurrencyTag,
|
|
301
310
|
async () => {
|
|
@@ -309,13 +318,16 @@ export class StreamVideoClient {
|
|
|
309
318
|
for (let attempt = 0; attempt < maxConnectUserRetries; attempt++) {
|
|
310
319
|
try {
|
|
311
320
|
this.logger.trace(`Connecting user (${attempt})`, user);
|
|
312
|
-
return
|
|
313
|
-
|
|
314
|
-
|
|
321
|
+
return await reporter.trackCoordinatorWs(() =>
|
|
322
|
+
user.type === 'guest'
|
|
323
|
+
? client.connectGuestUser(user)
|
|
324
|
+
: client.connectUser(user, tokenOrProvider),
|
|
325
|
+
);
|
|
315
326
|
} catch (err) {
|
|
316
327
|
this.logger.warn(`Failed to connect a user (${attempt})`, err);
|
|
317
328
|
errorQueue.push(err as Error);
|
|
318
329
|
if (attempt === maxConnectUserRetries - 1) {
|
|
330
|
+
reporter.closeCoordinatorWs();
|
|
319
331
|
onConnectUserError?.(err as Error, errorQueue);
|
|
320
332
|
throw err;
|
|
321
333
|
}
|
|
@@ -415,6 +427,7 @@ export class StreamVideoClient {
|
|
|
415
427
|
call ??
|
|
416
428
|
new Call({
|
|
417
429
|
streamClient: this.streamClient,
|
|
430
|
+
clientEventReporter: this.clientEventReporter,
|
|
418
431
|
id: id,
|
|
419
432
|
type: type,
|
|
420
433
|
clientStore: this.writeableStateStore,
|
|
@@ -448,6 +461,7 @@ export class StreamVideoClient {
|
|
|
448
461
|
for (const c of response.calls) {
|
|
449
462
|
const call = new Call({
|
|
450
463
|
streamClient: this.streamClient,
|
|
464
|
+
clientEventReporter: this.clientEventReporter,
|
|
451
465
|
id: c.call.id,
|
|
452
466
|
type: c.call.type,
|
|
453
467
|
members: c.members,
|
|
@@ -591,6 +605,7 @@ export class StreamVideoClient {
|
|
|
591
605
|
const [callType, callId] = call_cid.split(':');
|
|
592
606
|
call = new Call({
|
|
593
607
|
streamClient: this.streamClient,
|
|
608
|
+
clientEventReporter: this.clientEventReporter,
|
|
594
609
|
type: callType,
|
|
595
610
|
id: callId,
|
|
596
611
|
clientStore: this.writeableStateStore,
|
|
@@ -3,6 +3,7 @@ import '../rtc/__tests__/mocks/webrtc.mocks';
|
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
4
|
import { Call } from '../Call';
|
|
5
5
|
import { StreamClient } from '../coordinator/connection/client';
|
|
6
|
+
import { ClientEventReporter } from '../reporting';
|
|
6
7
|
import { generateUUIDv4 } from '../coordinator/connection/utils';
|
|
7
8
|
import { CallingState, StreamVideoWriteableStateStore } from '../store';
|
|
8
9
|
|
|
@@ -14,10 +15,12 @@ describe('Auto drop ringing calls', () => {
|
|
|
14
15
|
vi.useFakeTimers();
|
|
15
16
|
|
|
16
17
|
const clientStore = new StreamVideoWriteableStateStore();
|
|
18
|
+
const streamClient = new StreamClient('abc');
|
|
17
19
|
call = new Call({
|
|
18
20
|
type: 'test',
|
|
19
21
|
id: generateUUIDv4(),
|
|
20
|
-
streamClient
|
|
22
|
+
streamClient,
|
|
23
|
+
clientEventReporter: new ClientEventReporter({ streamClient }),
|
|
21
24
|
clientStore: clientStore,
|
|
22
25
|
});
|
|
23
26
|
|
|
@@ -7,6 +7,7 @@ import '../rtc/__tests__/mocks/webrtc.mocks';
|
|
|
7
7
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
8
8
|
import { Call } from '../Call';
|
|
9
9
|
import { StreamClient } from '../coordinator/connection/client';
|
|
10
|
+
import { ClientEventReporter } from '../reporting';
|
|
10
11
|
import { generateUUIDv4 } from '../coordinator/connection/utils';
|
|
11
12
|
import { StreamVideoWriteableStateStore } from '../store';
|
|
12
13
|
|
|
@@ -14,10 +15,12 @@ describe('Call lifecycle wiring', () => {
|
|
|
14
15
|
let call: Call;
|
|
15
16
|
|
|
16
17
|
beforeEach(() => {
|
|
18
|
+
const streamClient = new StreamClient('abc');
|
|
17
19
|
call = new Call({
|
|
18
20
|
type: 'test',
|
|
19
21
|
id: generateUUIDv4(),
|
|
20
|
-
streamClient
|
|
22
|
+
streamClient,
|
|
23
|
+
clientEventReporter: new ClientEventReporter({ streamClient }),
|
|
21
24
|
clientStore: new StreamVideoWriteableStateStore(),
|
|
22
25
|
});
|
|
23
26
|
});
|
|
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
4
4
|
import { Call } from '../Call';
|
|
5
5
|
import { Publisher } from '../rtc';
|
|
6
6
|
import { StreamClient } from '../coordinator/connection/client';
|
|
7
|
+
import { ClientEventReporter } from '../reporting';
|
|
7
8
|
import { generateUUIDv4 } from '../coordinator/connection/utils';
|
|
8
9
|
import { PermissionsContext } from '../permissions';
|
|
9
10
|
import { OwnCapability } from '../gen/coordinator';
|
|
@@ -15,10 +16,12 @@ describe('Publishing and Unpublishing tracks', () => {
|
|
|
15
16
|
let call: Call;
|
|
16
17
|
|
|
17
18
|
beforeEach(async () => {
|
|
19
|
+
const streamClient = new StreamClient('abc');
|
|
18
20
|
call = new Call({
|
|
19
21
|
type: 'test',
|
|
20
22
|
id: generateUUIDv4(),
|
|
21
|
-
streamClient
|
|
23
|
+
streamClient,
|
|
24
|
+
clientEventReporter: new ClientEventReporter({ streamClient }),
|
|
22
25
|
clientStore: new StreamVideoWriteableStateStore(),
|
|
23
26
|
});
|
|
24
27
|
|
|
@@ -300,6 +300,29 @@ describe('muting logic', () => {
|
|
|
300
300
|
});
|
|
301
301
|
});
|
|
302
302
|
|
|
303
|
+
describe('client event reporting', () => {
|
|
304
|
+
afterEach(() => {
|
|
305
|
+
vi.restoreAllMocks();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('reports a client-aborted event and unregisters the call on leave', async () => {
|
|
309
|
+
const call = client.call('default', generateUUIDv4());
|
|
310
|
+
await call.getOrCreate();
|
|
311
|
+
|
|
312
|
+
const reporter = call.clientEventReporter;
|
|
313
|
+
const abortSpy = vi.spyOn(reporter, 'abort');
|
|
314
|
+
const unregisterSpy = vi.spyOn(reporter, 'unregisterCall');
|
|
315
|
+
|
|
316
|
+
await call.leave();
|
|
317
|
+
|
|
318
|
+
expect(abortSpy).toHaveBeenCalledWith(call.cid, {
|
|
319
|
+
code: 'CLIENT_ABORTED',
|
|
320
|
+
reason: expect.any(String),
|
|
321
|
+
});
|
|
322
|
+
expect(unregisterSpy).toHaveBeenCalledWith(call.cid);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
303
326
|
afterEach(() => {
|
|
304
327
|
client.disconnectUser();
|
|
305
328
|
});
|
|
@@ -617,6 +617,11 @@ export class StreamClient {
|
|
|
617
617
|
return await this.wsConnection.connect(this.defaultWSTimeout);
|
|
618
618
|
};
|
|
619
619
|
|
|
620
|
+
getSdkVersion = (): string =>
|
|
621
|
+
this.options.clientAppIdentifier?.sdkVersion ||
|
|
622
|
+
process.env.PKG_VERSION ||
|
|
623
|
+
'0.0.0';
|
|
624
|
+
|
|
620
625
|
getUserAgent = (): string => {
|
|
621
626
|
if (!this.cachedUserAgent) {
|
|
622
627
|
const { clientAppIdentifier = {} } = this.options;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Call } from '../../Call';
|
|
2
2
|
import { StreamClient } from '../../coordinator/connection/client';
|
|
3
|
+
import { ClientEventReporter } from '../../reporting';
|
|
3
4
|
import { CallingState, StreamVideoWriteableStateStore } from '../../store';
|
|
4
5
|
|
|
5
6
|
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
|
|
@@ -56,6 +57,12 @@ vi.mock('../../Call.ts', () => {
|
|
|
56
57
|
};
|
|
57
58
|
});
|
|
58
59
|
|
|
60
|
+
vi.mock('../../reporting/ClientEventReporter', () => ({
|
|
61
|
+
ClientEventReporter: vi.fn(function () {
|
|
62
|
+
return {};
|
|
63
|
+
}),
|
|
64
|
+
}));
|
|
65
|
+
|
|
59
66
|
vi.mock('../../helpers/compatibility.ts', () => {
|
|
60
67
|
console.log('MOCKING mobile device');
|
|
61
68
|
return {
|
|
@@ -76,10 +83,12 @@ describe('CameraManager', () => {
|
|
|
76
83
|
|
|
77
84
|
beforeEach(() => {
|
|
78
85
|
const devicePersistence = { enabled: false, storageKey: '' };
|
|
86
|
+
const streamClient = new StreamClient('abc123', { devicePersistence });
|
|
79
87
|
call = new Call({
|
|
80
88
|
id: '',
|
|
81
89
|
type: '',
|
|
82
|
-
streamClient
|
|
90
|
+
streamClient,
|
|
91
|
+
clientEventReporter: new ClientEventReporter({ streamClient }),
|
|
83
92
|
clientStore: new StreamVideoWriteableStateStore(),
|
|
84
93
|
});
|
|
85
94
|
manager = new CameraManager(call, devicePersistence);
|