@stream-io/video-client 1.42.2 → 1.43.0-beta.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 +6 -0
- package/dist/index.browser.es.js +118 -57
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +119 -58
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +118 -57
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +1 -0
- package/dist/src/devices/MicrophoneManager.d.ts +2 -0
- package/dist/src/helpers/no-audio-detector.d.ts +1 -7
- package/dist/src/types.d.ts +36 -5
- package/package.json +3 -3
- package/src/Call.ts +92 -48
- package/src/__tests__/Call.test.ts +22 -15
- package/src/devices/MicrophoneManager.ts +21 -5
- package/src/devices/SpeakerManager.ts +1 -0
- package/src/devices/__tests__/MicrophoneManager.test.ts +52 -0
- package/src/devices/__tests__/MicrophoneManagerRN.test.ts +26 -1
- package/src/devices/__tests__/web-audio.mocks.ts +6 -2
- package/src/events/call.ts +3 -0
- package/src/helpers/__tests__/no-audio-detector.test.ts +54 -28
- package/src/helpers/no-audio-detector.ts +25 -20
- package/src/types.ts +47 -5
package/dist/index.cjs.js
CHANGED
|
@@ -6251,7 +6251,7 @@ const getSdkVersion = (sdk) => {
|
|
|
6251
6251
|
return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
|
|
6252
6252
|
};
|
|
6253
6253
|
|
|
6254
|
-
const version = "1.
|
|
6254
|
+
const version = "1.43.0-beta.0";
|
|
6255
6255
|
const [major, minor, patch] = version.split('.');
|
|
6256
6256
|
let sdkInfo = {
|
|
6257
6257
|
type: SdkType.PLAIN_JAVASCRIPT,
|
|
@@ -8944,6 +8944,7 @@ const watchCallRejected = (call) => {
|
|
|
8944
8944
|
else {
|
|
8945
8945
|
if (rejectedBy[eventCall.created_by.id]) {
|
|
8946
8946
|
call.logger.info('call creator rejected, leaving call');
|
|
8947
|
+
globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
|
|
8947
8948
|
await call.leave({ message: 'ring: creator rejected' });
|
|
8948
8949
|
}
|
|
8949
8950
|
}
|
|
@@ -8957,6 +8958,7 @@ const watchCallEnded = (call) => {
|
|
|
8957
8958
|
const { callingState } = call.state;
|
|
8958
8959
|
if (callingState !== exports.CallingState.IDLE &&
|
|
8959
8960
|
callingState !== exports.CallingState.LEFT) {
|
|
8961
|
+
globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
|
|
8960
8962
|
call
|
|
8961
8963
|
.leave({ message: 'call.ended event received', reject: false })
|
|
8962
8964
|
.catch((err) => {
|
|
@@ -8985,6 +8987,7 @@ const watchSfuCallEnded = (call) => {
|
|
|
8985
8987
|
// update the call state to reflect the call has ended.
|
|
8986
8988
|
call.state.setEndedAt(new Date());
|
|
8987
8989
|
const reason = CallEndedReason[e.reason];
|
|
8990
|
+
globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
|
|
8988
8991
|
await call.leave({ message: `callEnded received: ${reason}` });
|
|
8989
8992
|
}
|
|
8990
8993
|
catch (err) {
|
|
@@ -11500,12 +11503,21 @@ const createSoundDetector = (audioStream, onSoundDetectedStateChanged, options =
|
|
|
11500
11503
|
};
|
|
11501
11504
|
|
|
11502
11505
|
/**
|
|
11503
|
-
* Analyzes
|
|
11504
|
-
|
|
11505
|
-
|
|
11506
|
-
|
|
11507
|
-
analyser.
|
|
11508
|
-
|
|
11506
|
+
* Analyzes time-domain waveform data to determine if audio is being captured.
|
|
11507
|
+
* Uses the waveform RMS around the 128 midpoint for robust silence detection.
|
|
11508
|
+
*/
|
|
11509
|
+
const hasAudio = (analyser) => {
|
|
11510
|
+
const data = new Uint8Array(analyser.fftSize);
|
|
11511
|
+
analyser.getByteTimeDomainData(data);
|
|
11512
|
+
let squareSum = 0;
|
|
11513
|
+
for (const sample of data) {
|
|
11514
|
+
const centered = sample - 128;
|
|
11515
|
+
// Ignore tiny quantization/jitter around midpoint (e.g. 127/128 samples).
|
|
11516
|
+
const signal = Math.abs(centered) <= 1 ? 0 : centered;
|
|
11517
|
+
squareSum += signal * signal;
|
|
11518
|
+
}
|
|
11519
|
+
const rms = Math.sqrt(squareSum / data.length);
|
|
11520
|
+
return rms > 0;
|
|
11509
11521
|
};
|
|
11510
11522
|
/** Helper for "no event" transitions */
|
|
11511
11523
|
const noEmit = (nextState) => ({
|
|
@@ -11519,9 +11531,9 @@ const emit = (capturesAudio, nextState) => ({ shouldEmit: true, nextState, captu
|
|
|
11519
11531
|
*/
|
|
11520
11532
|
const transitionState = (state, audioDetected, options) => {
|
|
11521
11533
|
if (audioDetected) {
|
|
11522
|
-
|
|
11523
|
-
|
|
11524
|
-
|
|
11534
|
+
// Any observed audio means the microphone is capturing.
|
|
11535
|
+
// Emit recovery/success and let the caller stop the detector.
|
|
11536
|
+
return emit(true, { kind: 'IDLE' });
|
|
11525
11537
|
}
|
|
11526
11538
|
const { noAudioThresholdMs, emitIntervalMs } = options;
|
|
11527
11539
|
const now = Date.now();
|
|
@@ -11562,16 +11574,17 @@ const createAudioAnalyzer = (audioStream, fftSize) => {
|
|
|
11562
11574
|
* @returns a cleanup function which once invoked stops the no-audio detector.
|
|
11563
11575
|
*/
|
|
11564
11576
|
const createNoAudioDetector = (audioStream, options) => {
|
|
11565
|
-
const { detectionFrequencyInMs = 350,
|
|
11577
|
+
const { detectionFrequencyInMs = 350, fftSize = 512, onCaptureStatusChange, } = options;
|
|
11566
11578
|
let state = { kind: 'IDLE' };
|
|
11567
11579
|
const { audioContext, analyser } = createAudioAnalyzer(audioStream, fftSize);
|
|
11568
11580
|
const detectionIntervalId = setInterval(() => {
|
|
11569
|
-
const [
|
|
11570
|
-
if (
|
|
11581
|
+
const [track] = audioStream.getAudioTracks();
|
|
11582
|
+
if (track && !track.enabled) {
|
|
11571
11583
|
state = { kind: 'IDLE' };
|
|
11572
11584
|
return;
|
|
11573
11585
|
}
|
|
11574
|
-
|
|
11586
|
+
// Missing or ended track is treated as no-audio to surface abrupt capture loss.
|
|
11587
|
+
const audioDetected = track?.readyState === 'live' && hasAudio(analyser);
|
|
11575
11588
|
const transition = transitionState(state, audioDetected, options);
|
|
11576
11589
|
state = transition.nextState;
|
|
11577
11590
|
if (!transition.shouldEmit)
|
|
@@ -12044,6 +12057,9 @@ class MicrophoneManager extends AudioDeviceManager {
|
|
|
12044
12057
|
}
|
|
12045
12058
|
async startSpeakingWhileMutedDetection(deviceId) {
|
|
12046
12059
|
await withoutConcurrency(this.soundDetectorConcurrencyTag, async () => {
|
|
12060
|
+
if (this.soundDetectorCleanup && this.soundDetectorDeviceId === deviceId)
|
|
12061
|
+
return;
|
|
12062
|
+
await this.teardownSpeakingWhileMutedDetection();
|
|
12047
12063
|
if (isReactNative()) {
|
|
12048
12064
|
this.rnSpeechDetector = new RNSpeechDetector();
|
|
12049
12065
|
const unsubscribe = await this.rnSpeechDetector.start((event) => {
|
|
@@ -12064,16 +12080,23 @@ class MicrophoneManager extends AudioDeviceManager {
|
|
|
12064
12080
|
this.state.setSpeakingWhileMuted(event.isSoundDetected);
|
|
12065
12081
|
});
|
|
12066
12082
|
}
|
|
12083
|
+
this.soundDetectorDeviceId = deviceId;
|
|
12067
12084
|
});
|
|
12068
12085
|
}
|
|
12069
12086
|
async stopSpeakingWhileMutedDetection() {
|
|
12070
12087
|
await withoutConcurrency(this.soundDetectorConcurrencyTag, async () => {
|
|
12071
|
-
|
|
12072
|
-
|
|
12073
|
-
|
|
12074
|
-
|
|
12075
|
-
|
|
12076
|
-
|
|
12088
|
+
return this.teardownSpeakingWhileMutedDetection();
|
|
12089
|
+
});
|
|
12090
|
+
}
|
|
12091
|
+
async teardownSpeakingWhileMutedDetection() {
|
|
12092
|
+
const soundDetectorCleanup = this.soundDetectorCleanup;
|
|
12093
|
+
this.soundDetectorCleanup = undefined;
|
|
12094
|
+
this.soundDetectorDeviceId = undefined;
|
|
12095
|
+
this.state.setSpeakingWhileMuted(false);
|
|
12096
|
+
if (!soundDetectorCleanup)
|
|
12097
|
+
return;
|
|
12098
|
+
await soundDetectorCleanup().catch((err) => {
|
|
12099
|
+
this.logger.warn('Failed to stop speaking while muted detector', err);
|
|
12077
12100
|
});
|
|
12078
12101
|
}
|
|
12079
12102
|
async hasPermission(permissionState) {
|
|
@@ -12342,6 +12365,7 @@ class SpeakerManager {
|
|
|
12342
12365
|
this.defaultDevice = defaultDevice;
|
|
12343
12366
|
globalThis.streamRNVideoSDK?.callManager.setup({
|
|
12344
12367
|
defaultDevice,
|
|
12368
|
+
isRingingTypeCall: this.call.ringing,
|
|
12345
12369
|
});
|
|
12346
12370
|
}
|
|
12347
12371
|
}
|
|
@@ -12466,6 +12490,7 @@ class Call {
|
|
|
12466
12490
|
this.hasJoinedOnce = false;
|
|
12467
12491
|
this.deviceSettingsAppliedOnce = false;
|
|
12468
12492
|
this.initialized = false;
|
|
12493
|
+
this.acceptRejectConcurrencyTag = Symbol('acceptRejectTag');
|
|
12469
12494
|
this.joinLeaveConcurrencyTag = Symbol('joinLeaveConcurrencyTag');
|
|
12470
12495
|
/**
|
|
12471
12496
|
* A list hooks/functions to invoke when the call is left.
|
|
@@ -12531,6 +12556,7 @@ class Call {
|
|
|
12531
12556
|
const currentUserId = this.currentUserId;
|
|
12532
12557
|
if (currentUserId && blockedUserIds.includes(currentUserId)) {
|
|
12533
12558
|
this.logger.info('Leaving call because of being blocked');
|
|
12559
|
+
globalThis.streamRNVideoSDK?.callingX?.endCall(this, 'restricted');
|
|
12534
12560
|
await this.leave({ message: 'user blocked' }).catch((err) => {
|
|
12535
12561
|
this.logger.error('Error leaving call after being blocked', err);
|
|
12536
12562
|
});
|
|
@@ -12567,6 +12593,7 @@ class Call {
|
|
|
12567
12593
|
const isAcceptedElsewhere = isAcceptedByMe && this.state.callingState === exports.CallingState.RINGING;
|
|
12568
12594
|
if ((isAcceptedElsewhere || isRejectedByMe) &&
|
|
12569
12595
|
!hasPending(this.joinLeaveConcurrencyTag)) {
|
|
12596
|
+
globalThis.streamRNVideoSDK?.callingX?.endCall(this, isAcceptedElsewhere ? 'answeredElsewhere' : 'rejected');
|
|
12570
12597
|
this.leave().catch(() => {
|
|
12571
12598
|
this.logger.error('Could not leave a call that was accepted or rejected elsewhere');
|
|
12572
12599
|
});
|
|
@@ -12716,17 +12743,28 @@ class Call {
|
|
|
12716
12743
|
}
|
|
12717
12744
|
if (callingState === exports.CallingState.RINGING && reject !== false) {
|
|
12718
12745
|
if (reject) {
|
|
12719
|
-
|
|
12746
|
+
const reasonToEndCallReason = {
|
|
12747
|
+
timeout: 'missed',
|
|
12748
|
+
cancel: 'canceled',
|
|
12749
|
+
busy: 'busy',
|
|
12750
|
+
decline: 'rejected',
|
|
12751
|
+
};
|
|
12752
|
+
const rejectReason = reason ?? 'decline';
|
|
12753
|
+
const endCallReason = reasonToEndCallReason[rejectReason] ?? 'rejected';
|
|
12754
|
+
globalThis.streamRNVideoSDK?.callingX?.endCall(this, endCallReason);
|
|
12755
|
+
await this.reject(rejectReason);
|
|
12720
12756
|
}
|
|
12721
12757
|
else {
|
|
12722
12758
|
// if reject was undefined, we still have to cancel the call automatically
|
|
12723
12759
|
// when I am the creator and everyone else left the call
|
|
12724
12760
|
const hasOtherParticipants = this.state.remoteParticipants.length > 0;
|
|
12725
12761
|
if (this.isCreatedByMe && !hasOtherParticipants) {
|
|
12762
|
+
globalThis.streamRNVideoSDK?.callingX?.endCall(this, 'canceled');
|
|
12726
12763
|
await this.reject('cancel');
|
|
12727
12764
|
}
|
|
12728
12765
|
}
|
|
12729
12766
|
}
|
|
12767
|
+
globalThis.streamRNVideoSDK?.callingX?.endCall(this);
|
|
12730
12768
|
this.statsReporter?.stop();
|
|
12731
12769
|
this.statsReporter = undefined;
|
|
12732
12770
|
const leaveReason = message ?? reason ?? 'user is leaving the call';
|
|
@@ -12753,7 +12791,9 @@ class Call {
|
|
|
12753
12791
|
this.ringingSubject.next(false);
|
|
12754
12792
|
this.cancelAutoDrop();
|
|
12755
12793
|
this.clientStore.unregisterCall(this);
|
|
12756
|
-
globalThis.streamRNVideoSDK?.callManager.stop(
|
|
12794
|
+
globalThis.streamRNVideoSDK?.callManager.stop({
|
|
12795
|
+
isRingingTypeCall: this.ringing,
|
|
12796
|
+
});
|
|
12757
12797
|
this.camera.dispose();
|
|
12758
12798
|
this.microphone.dispose();
|
|
12759
12799
|
this.screenShare.dispose();
|
|
@@ -12880,8 +12920,10 @@ class Call {
|
|
|
12880
12920
|
* Unless you are implementing a custom "ringing" flow, you should not use this method.
|
|
12881
12921
|
*/
|
|
12882
12922
|
this.accept = async () => {
|
|
12883
|
-
this.
|
|
12884
|
-
|
|
12923
|
+
return withoutConcurrency(this.acceptRejectConcurrencyTag, () => {
|
|
12924
|
+
this.tracer.trace('call.accept', '');
|
|
12925
|
+
return this.streamClient.post(`${this.streamClientBasePath}/accept`);
|
|
12926
|
+
});
|
|
12885
12927
|
};
|
|
12886
12928
|
/**
|
|
12887
12929
|
* Marks the incoming call as rejected.
|
|
@@ -12893,8 +12935,10 @@ class Call {
|
|
|
12893
12935
|
* @param reason the reason for rejecting the call.
|
|
12894
12936
|
*/
|
|
12895
12937
|
this.reject = async (reason = 'decline') => {
|
|
12896
|
-
this.
|
|
12897
|
-
|
|
12938
|
+
return withoutConcurrency(this.acceptRejectConcurrencyTag, () => {
|
|
12939
|
+
this.tracer.trace('call.reject', reason);
|
|
12940
|
+
return this.streamClient.post(`${this.streamClientBasePath}/reject`, { reason });
|
|
12941
|
+
});
|
|
12898
12942
|
};
|
|
12899
12943
|
/**
|
|
12900
12944
|
* Will start to watch for call related WebSocket events and initiate a call session with the server.
|
|
@@ -12902,11 +12946,19 @@ class Call {
|
|
|
12902
12946
|
* @returns a promise which resolves once the call join-flow has finished.
|
|
12903
12947
|
*/
|
|
12904
12948
|
this.join = async ({ maxJoinRetries = 3, joinResponseTimeout, rpcRequestTimeout, ...data } = {}) => {
|
|
12905
|
-
await this.setup();
|
|
12906
12949
|
const callingState = this.state.callingState;
|
|
12907
12950
|
if ([exports.CallingState.JOINED, exports.CallingState.JOINING].includes(callingState)) {
|
|
12908
12951
|
throw new Error(`Illegal State: call.join() shall be called only once`);
|
|
12909
12952
|
}
|
|
12953
|
+
if (data?.ring) {
|
|
12954
|
+
this.ringingSubject.next(true);
|
|
12955
|
+
}
|
|
12956
|
+
const callingX = globalThis.streamRNVideoSDK?.callingX;
|
|
12957
|
+
if (callingX) {
|
|
12958
|
+
// for Android/iOS, we need to start the call in the callingx library as soon as possible
|
|
12959
|
+
await callingX.startCall(this);
|
|
12960
|
+
}
|
|
12961
|
+
await this.setup();
|
|
12910
12962
|
this.joinResponseTimeout = joinResponseTimeout;
|
|
12911
12963
|
this.rpcRequestTimeout = rpcRequestTimeout;
|
|
12912
12964
|
// we will count the number of join failures per SFU.
|
|
@@ -12915,38 +12967,44 @@ class Call {
|
|
|
12915
12967
|
const sfuJoinFailures = new Map();
|
|
12916
12968
|
const joinData = data;
|
|
12917
12969
|
maxJoinRetries = Math.max(maxJoinRetries, 1);
|
|
12918
|
-
|
|
12919
|
-
|
|
12920
|
-
|
|
12921
|
-
|
|
12922
|
-
|
|
12923
|
-
|
|
12924
|
-
|
|
12925
|
-
|
|
12926
|
-
catch (err) {
|
|
12927
|
-
this.logger.warn(`Failed to join call (${attempt})`, this.cid);
|
|
12928
|
-
if ((err instanceof ErrorFromResponse && err.unrecoverable) ||
|
|
12929
|
-
(err instanceof SfuJoinError && err.unrecoverable)) {
|
|
12930
|
-
// if the error is unrecoverable, we should not retry as that signals
|
|
12931
|
-
// that connectivity is good, but the coordinator doesn't allow the user
|
|
12932
|
-
// to join the call due to some reason (e.g., ended call, expired token...)
|
|
12933
|
-
throw err;
|
|
12934
|
-
}
|
|
12935
|
-
// immediately switch to a different SFU in case of recoverable join error
|
|
12936
|
-
const switchSfu = err instanceof SfuJoinError &&
|
|
12937
|
-
SfuJoinError.isJoinErrorCode(err.errorEvent);
|
|
12938
|
-
const sfuId = this.credentials?.server.edge_name || '';
|
|
12939
|
-
const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
|
|
12940
|
-
sfuJoinFailures.set(sfuId, failures);
|
|
12941
|
-
if (switchSfu || failures >= 2) {
|
|
12942
|
-
joinData.migrating_from = sfuId;
|
|
12943
|
-
joinData.migrating_from_list = Array.from(sfuJoinFailures.keys());
|
|
12970
|
+
try {
|
|
12971
|
+
for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
|
|
12972
|
+
try {
|
|
12973
|
+
this.logger.trace(`Joining call (${attempt})`, this.cid);
|
|
12974
|
+
await this.doJoin(data);
|
|
12975
|
+
delete joinData.migrating_from;
|
|
12976
|
+
delete joinData.migrating_from_list;
|
|
12977
|
+
break;
|
|
12944
12978
|
}
|
|
12945
|
-
|
|
12946
|
-
|
|
12979
|
+
catch (err) {
|
|
12980
|
+
this.logger.warn(`Failed to join call (${attempt})`, this.cid);
|
|
12981
|
+
if ((err instanceof ErrorFromResponse && err.unrecoverable) ||
|
|
12982
|
+
(err instanceof SfuJoinError && err.unrecoverable)) {
|
|
12983
|
+
// if the error is unrecoverable, we should not retry as that signals
|
|
12984
|
+
// that connectivity is good, but the coordinator doesn't allow the user
|
|
12985
|
+
// to join the call due to some reason (e.g., ended call, expired token...)
|
|
12986
|
+
throw err;
|
|
12987
|
+
}
|
|
12988
|
+
// immediately switch to a different SFU in case of recoverable join error
|
|
12989
|
+
const switchSfu = err instanceof SfuJoinError &&
|
|
12990
|
+
SfuJoinError.isJoinErrorCode(err.errorEvent);
|
|
12991
|
+
const sfuId = this.credentials?.server.edge_name || '';
|
|
12992
|
+
const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
|
|
12993
|
+
sfuJoinFailures.set(sfuId, failures);
|
|
12994
|
+
if (switchSfu || failures >= 2) {
|
|
12995
|
+
joinData.migrating_from = sfuId;
|
|
12996
|
+
joinData.migrating_from_list = Array.from(sfuJoinFailures.keys());
|
|
12997
|
+
}
|
|
12998
|
+
if (attempt === maxJoinRetries - 1) {
|
|
12999
|
+
throw err;
|
|
13000
|
+
}
|
|
12947
13001
|
}
|
|
13002
|
+
await sleep(retryInterval(attempt));
|
|
12948
13003
|
}
|
|
12949
|
-
|
|
13004
|
+
}
|
|
13005
|
+
catch (error) {
|
|
13006
|
+
callingX?.endCall(this, 'error');
|
|
13007
|
+
throw error;
|
|
12950
13008
|
}
|
|
12951
13009
|
};
|
|
12952
13010
|
/**
|
|
@@ -13093,7 +13151,9 @@ class Call {
|
|
|
13093
13151
|
// re-apply them on later reconnections or server-side data fetches
|
|
13094
13152
|
if (!this.deviceSettingsAppliedOnce && this.state.settings) {
|
|
13095
13153
|
await this.applyDeviceConfig(this.state.settings, true);
|
|
13096
|
-
globalThis.streamRNVideoSDK?.callManager.start(
|
|
13154
|
+
globalThis.streamRNVideoSDK?.callManager.start({
|
|
13155
|
+
isRingingTypeCall: this.ringing,
|
|
13156
|
+
});
|
|
13097
13157
|
this.deviceSettingsAppliedOnce = true;
|
|
13098
13158
|
}
|
|
13099
13159
|
// We shouldn't persist the `ring` and `notify` state after joining the call
|
|
@@ -13521,6 +13581,7 @@ class Call {
|
|
|
13521
13581
|
if (strategy === WebsocketReconnectStrategy.UNSPECIFIED)
|
|
13522
13582
|
return;
|
|
13523
13583
|
if (strategy === WebsocketReconnectStrategy.DISCONNECT) {
|
|
13584
|
+
globalThis.streamRNVideoSDK?.callingX?.endCall(this, 'error');
|
|
13524
13585
|
this.leave({ message: 'SFU instructed to disconnect' }).catch((err) => {
|
|
13525
13586
|
this.logger.warn(`Can't leave call after disconnect request`, err);
|
|
13526
13587
|
});
|
|
@@ -15614,7 +15675,7 @@ class StreamClient {
|
|
|
15614
15675
|
this.getUserAgent = () => {
|
|
15615
15676
|
if (!this.cachedUserAgent) {
|
|
15616
15677
|
const { clientAppIdentifier = {} } = this.options;
|
|
15617
|
-
const { sdkName = 'js', sdkVersion = "1.
|
|
15678
|
+
const { sdkName = 'js', sdkVersion = "1.43.0-beta.0", ...extras } = clientAppIdentifier;
|
|
15618
15679
|
this.cachedUserAgent = [
|
|
15619
15680
|
`stream-video-${sdkName}-v${sdkVersion}`,
|
|
15620
15681
|
...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
|