@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 CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver).
4
4
 
5
+ ## [1.42.3](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-client-1.42.2...@stream-io/video-client-1.42.3) (2026-02-16)
6
+
7
+ ### Bug Fixes
8
+
9
+ - guard from parallel accept/reject invocations ([#2127](https://github.com/GetStream/stream-video-js/issues/2127)) ([621218f](https://github.com/GetStream/stream-video-js/commit/621218f4ab6b4623370fd66f1b02b8cb7cb1baad))
10
+
5
11
  ## [1.42.2](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-client-1.42.1...@stream-io/video-client-1.42.2) (2026-02-13)
6
12
 
7
13
  ### Bug Fixes
@@ -6231,7 +6231,7 @@ const getSdkVersion = (sdk) => {
6231
6231
  return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
6232
6232
  };
6233
6233
 
6234
- const version = "1.42.2";
6234
+ const version = "1.43.0-beta.0";
6235
6235
  const [major, minor, patch] = version.split('.');
6236
6236
  let sdkInfo = {
6237
6237
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -8924,6 +8924,7 @@ const watchCallRejected = (call) => {
8924
8924
  else {
8925
8925
  if (rejectedBy[eventCall.created_by.id]) {
8926
8926
  call.logger.info('call creator rejected, leaving call');
8927
+ globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
8927
8928
  await call.leave({ message: 'ring: creator rejected' });
8928
8929
  }
8929
8930
  }
@@ -8937,6 +8938,7 @@ const watchCallEnded = (call) => {
8937
8938
  const { callingState } = call.state;
8938
8939
  if (callingState !== CallingState.IDLE &&
8939
8940
  callingState !== CallingState.LEFT) {
8941
+ globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
8940
8942
  call
8941
8943
  .leave({ message: 'call.ended event received', reject: false })
8942
8944
  .catch((err) => {
@@ -8965,6 +8967,7 @@ const watchSfuCallEnded = (call) => {
8965
8967
  // update the call state to reflect the call has ended.
8966
8968
  call.state.setEndedAt(new Date());
8967
8969
  const reason = CallEndedReason[e.reason];
8970
+ globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
8968
8971
  await call.leave({ message: `callEnded received: ${reason}` });
8969
8972
  }
8970
8973
  catch (err) {
@@ -11480,12 +11483,21 @@ const createSoundDetector = (audioStream, onSoundDetectedStateChanged, options =
11480
11483
  };
11481
11484
 
11482
11485
  /**
11483
- * Analyzes frequency data to determine if audio is being captured.
11486
+ * Analyzes time-domain waveform data to determine if audio is being captured.
11487
+ * Uses the waveform RMS around the 128 midpoint for robust silence detection.
11484
11488
  */
11485
- const hasAudio = (analyser, threshold) => {
11486
- const data = new Uint8Array(analyser.frequencyBinCount);
11487
- analyser.getByteFrequencyData(data);
11488
- return data.some((value) => value > threshold);
11489
+ const hasAudio = (analyser) => {
11490
+ const data = new Uint8Array(analyser.fftSize);
11491
+ analyser.getByteTimeDomainData(data);
11492
+ let squareSum = 0;
11493
+ for (const sample of data) {
11494
+ const centered = sample - 128;
11495
+ // Ignore tiny quantization/jitter around midpoint (e.g. 127/128 samples).
11496
+ const signal = Math.abs(centered) <= 1 ? 0 : centered;
11497
+ squareSum += signal * signal;
11498
+ }
11499
+ const rms = Math.sqrt(squareSum / data.length);
11500
+ return rms > 0;
11489
11501
  };
11490
11502
  /** Helper for "no event" transitions */
11491
11503
  const noEmit = (nextState) => ({
@@ -11499,9 +11511,9 @@ const emit = (capturesAudio, nextState) => ({ shouldEmit: true, nextState, captu
11499
11511
  */
11500
11512
  const transitionState = (state, audioDetected, options) => {
11501
11513
  if (audioDetected) {
11502
- return state.kind === 'IDLE' || state.kind === 'EMITTING'
11503
- ? emit(true, state)
11504
- : noEmit(state);
11514
+ // Any observed audio means the microphone is capturing.
11515
+ // Emit recovery/success and let the caller stop the detector.
11516
+ return emit(true, { kind: 'IDLE' });
11505
11517
  }
11506
11518
  const { noAudioThresholdMs, emitIntervalMs } = options;
11507
11519
  const now = Date.now();
@@ -11542,16 +11554,17 @@ const createAudioAnalyzer = (audioStream, fftSize) => {
11542
11554
  * @returns a cleanup function which once invoked stops the no-audio detector.
11543
11555
  */
11544
11556
  const createNoAudioDetector = (audioStream, options) => {
11545
- const { detectionFrequencyInMs = 350, audioLevelThreshold = 0, fftSize = 256, onCaptureStatusChange, } = options;
11557
+ const { detectionFrequencyInMs = 350, fftSize = 512, onCaptureStatusChange, } = options;
11546
11558
  let state = { kind: 'IDLE' };
11547
11559
  const { audioContext, analyser } = createAudioAnalyzer(audioStream, fftSize);
11548
11560
  const detectionIntervalId = setInterval(() => {
11549
- const [audioTrack] = audioStream.getAudioTracks();
11550
- if (!audioTrack?.enabled || audioTrack.readyState === 'ended') {
11561
+ const [track] = audioStream.getAudioTracks();
11562
+ if (track && !track.enabled) {
11551
11563
  state = { kind: 'IDLE' };
11552
11564
  return;
11553
11565
  }
11554
- const audioDetected = hasAudio(analyser, audioLevelThreshold);
11566
+ // Missing or ended track is treated as no-audio to surface abrupt capture loss.
11567
+ const audioDetected = track?.readyState === 'live' && hasAudio(analyser);
11555
11568
  const transition = transitionState(state, audioDetected, options);
11556
11569
  state = transition.nextState;
11557
11570
  if (!transition.shouldEmit)
@@ -12024,6 +12037,9 @@ class MicrophoneManager extends AudioDeviceManager {
12024
12037
  }
12025
12038
  async startSpeakingWhileMutedDetection(deviceId) {
12026
12039
  await withoutConcurrency(this.soundDetectorConcurrencyTag, async () => {
12040
+ if (this.soundDetectorCleanup && this.soundDetectorDeviceId === deviceId)
12041
+ return;
12042
+ await this.teardownSpeakingWhileMutedDetection();
12027
12043
  if (isReactNative()) {
12028
12044
  this.rnSpeechDetector = new RNSpeechDetector();
12029
12045
  const unsubscribe = await this.rnSpeechDetector.start((event) => {
@@ -12044,16 +12060,23 @@ class MicrophoneManager extends AudioDeviceManager {
12044
12060
  this.state.setSpeakingWhileMuted(event.isSoundDetected);
12045
12061
  });
12046
12062
  }
12063
+ this.soundDetectorDeviceId = deviceId;
12047
12064
  });
12048
12065
  }
12049
12066
  async stopSpeakingWhileMutedDetection() {
12050
12067
  await withoutConcurrency(this.soundDetectorConcurrencyTag, async () => {
12051
- if (!this.soundDetectorCleanup)
12052
- return;
12053
- const soundDetectorCleanup = this.soundDetectorCleanup;
12054
- this.soundDetectorCleanup = undefined;
12055
- this.state.setSpeakingWhileMuted(false);
12056
- await soundDetectorCleanup();
12068
+ return this.teardownSpeakingWhileMutedDetection();
12069
+ });
12070
+ }
12071
+ async teardownSpeakingWhileMutedDetection() {
12072
+ const soundDetectorCleanup = this.soundDetectorCleanup;
12073
+ this.soundDetectorCleanup = undefined;
12074
+ this.soundDetectorDeviceId = undefined;
12075
+ this.state.setSpeakingWhileMuted(false);
12076
+ if (!soundDetectorCleanup)
12077
+ return;
12078
+ await soundDetectorCleanup().catch((err) => {
12079
+ this.logger.warn('Failed to stop speaking while muted detector', err);
12057
12080
  });
12058
12081
  }
12059
12082
  async hasPermission(permissionState) {
@@ -12322,6 +12345,7 @@ class SpeakerManager {
12322
12345
  this.defaultDevice = defaultDevice;
12323
12346
  globalThis.streamRNVideoSDK?.callManager.setup({
12324
12347
  defaultDevice,
12348
+ isRingingTypeCall: this.call.ringing,
12325
12349
  });
12326
12350
  }
12327
12351
  }
@@ -12446,6 +12470,7 @@ class Call {
12446
12470
  this.hasJoinedOnce = false;
12447
12471
  this.deviceSettingsAppliedOnce = false;
12448
12472
  this.initialized = false;
12473
+ this.acceptRejectConcurrencyTag = Symbol('acceptRejectTag');
12449
12474
  this.joinLeaveConcurrencyTag = Symbol('joinLeaveConcurrencyTag');
12450
12475
  /**
12451
12476
  * A list hooks/functions to invoke when the call is left.
@@ -12511,6 +12536,7 @@ class Call {
12511
12536
  const currentUserId = this.currentUserId;
12512
12537
  if (currentUserId && blockedUserIds.includes(currentUserId)) {
12513
12538
  this.logger.info('Leaving call because of being blocked');
12539
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this, 'restricted');
12514
12540
  await this.leave({ message: 'user blocked' }).catch((err) => {
12515
12541
  this.logger.error('Error leaving call after being blocked', err);
12516
12542
  });
@@ -12547,6 +12573,7 @@ class Call {
12547
12573
  const isAcceptedElsewhere = isAcceptedByMe && this.state.callingState === CallingState.RINGING;
12548
12574
  if ((isAcceptedElsewhere || isRejectedByMe) &&
12549
12575
  !hasPending(this.joinLeaveConcurrencyTag)) {
12576
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this, isAcceptedElsewhere ? 'answeredElsewhere' : 'rejected');
12550
12577
  this.leave().catch(() => {
12551
12578
  this.logger.error('Could not leave a call that was accepted or rejected elsewhere');
12552
12579
  });
@@ -12696,17 +12723,28 @@ class Call {
12696
12723
  }
12697
12724
  if (callingState === CallingState.RINGING && reject !== false) {
12698
12725
  if (reject) {
12699
- await this.reject(reason ?? 'decline');
12726
+ const reasonToEndCallReason = {
12727
+ timeout: 'missed',
12728
+ cancel: 'canceled',
12729
+ busy: 'busy',
12730
+ decline: 'rejected',
12731
+ };
12732
+ const rejectReason = reason ?? 'decline';
12733
+ const endCallReason = reasonToEndCallReason[rejectReason] ?? 'rejected';
12734
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this, endCallReason);
12735
+ await this.reject(rejectReason);
12700
12736
  }
12701
12737
  else {
12702
12738
  // if reject was undefined, we still have to cancel the call automatically
12703
12739
  // when I am the creator and everyone else left the call
12704
12740
  const hasOtherParticipants = this.state.remoteParticipants.length > 0;
12705
12741
  if (this.isCreatedByMe && !hasOtherParticipants) {
12742
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this, 'canceled');
12706
12743
  await this.reject('cancel');
12707
12744
  }
12708
12745
  }
12709
12746
  }
12747
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this);
12710
12748
  this.statsReporter?.stop();
12711
12749
  this.statsReporter = undefined;
12712
12750
  const leaveReason = message ?? reason ?? 'user is leaving the call';
@@ -12733,7 +12771,9 @@ class Call {
12733
12771
  this.ringingSubject.next(false);
12734
12772
  this.cancelAutoDrop();
12735
12773
  this.clientStore.unregisterCall(this);
12736
- globalThis.streamRNVideoSDK?.callManager.stop();
12774
+ globalThis.streamRNVideoSDK?.callManager.stop({
12775
+ isRingingTypeCall: this.ringing,
12776
+ });
12737
12777
  this.camera.dispose();
12738
12778
  this.microphone.dispose();
12739
12779
  this.screenShare.dispose();
@@ -12860,8 +12900,10 @@ class Call {
12860
12900
  * Unless you are implementing a custom "ringing" flow, you should not use this method.
12861
12901
  */
12862
12902
  this.accept = async () => {
12863
- this.tracer.trace('call.accept', '');
12864
- return this.streamClient.post(`${this.streamClientBasePath}/accept`);
12903
+ return withoutConcurrency(this.acceptRejectConcurrencyTag, () => {
12904
+ this.tracer.trace('call.accept', '');
12905
+ return this.streamClient.post(`${this.streamClientBasePath}/accept`);
12906
+ });
12865
12907
  };
12866
12908
  /**
12867
12909
  * Marks the incoming call as rejected.
@@ -12873,8 +12915,10 @@ class Call {
12873
12915
  * @param reason the reason for rejecting the call.
12874
12916
  */
12875
12917
  this.reject = async (reason = 'decline') => {
12876
- this.tracer.trace('call.reject', reason);
12877
- return this.streamClient.post(`${this.streamClientBasePath}/reject`, { reason: reason });
12918
+ return withoutConcurrency(this.acceptRejectConcurrencyTag, () => {
12919
+ this.tracer.trace('call.reject', reason);
12920
+ return this.streamClient.post(`${this.streamClientBasePath}/reject`, { reason });
12921
+ });
12878
12922
  };
12879
12923
  /**
12880
12924
  * Will start to watch for call related WebSocket events and initiate a call session with the server.
@@ -12882,11 +12926,19 @@ class Call {
12882
12926
  * @returns a promise which resolves once the call join-flow has finished.
12883
12927
  */
12884
12928
  this.join = async ({ maxJoinRetries = 3, joinResponseTimeout, rpcRequestTimeout, ...data } = {}) => {
12885
- await this.setup();
12886
12929
  const callingState = this.state.callingState;
12887
12930
  if ([CallingState.JOINED, CallingState.JOINING].includes(callingState)) {
12888
12931
  throw new Error(`Illegal State: call.join() shall be called only once`);
12889
12932
  }
12933
+ if (data?.ring) {
12934
+ this.ringingSubject.next(true);
12935
+ }
12936
+ const callingX = globalThis.streamRNVideoSDK?.callingX;
12937
+ if (callingX) {
12938
+ // for Android/iOS, we need to start the call in the callingx library as soon as possible
12939
+ await callingX.startCall(this);
12940
+ }
12941
+ await this.setup();
12890
12942
  this.joinResponseTimeout = joinResponseTimeout;
12891
12943
  this.rpcRequestTimeout = rpcRequestTimeout;
12892
12944
  // we will count the number of join failures per SFU.
@@ -12895,38 +12947,44 @@ class Call {
12895
12947
  const sfuJoinFailures = new Map();
12896
12948
  const joinData = data;
12897
12949
  maxJoinRetries = Math.max(maxJoinRetries, 1);
12898
- for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
12899
- try {
12900
- this.logger.trace(`Joining call (${attempt})`, this.cid);
12901
- await this.doJoin(data);
12902
- delete joinData.migrating_from;
12903
- delete joinData.migrating_from_list;
12904
- break;
12905
- }
12906
- catch (err) {
12907
- this.logger.warn(`Failed to join call (${attempt})`, this.cid);
12908
- if ((err instanceof ErrorFromResponse && err.unrecoverable) ||
12909
- (err instanceof SfuJoinError && err.unrecoverable)) {
12910
- // if the error is unrecoverable, we should not retry as that signals
12911
- // that connectivity is good, but the coordinator doesn't allow the user
12912
- // to join the call due to some reason (e.g., ended call, expired token...)
12913
- throw err;
12914
- }
12915
- // immediately switch to a different SFU in case of recoverable join error
12916
- const switchSfu = err instanceof SfuJoinError &&
12917
- SfuJoinError.isJoinErrorCode(err.errorEvent);
12918
- const sfuId = this.credentials?.server.edge_name || '';
12919
- const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
12920
- sfuJoinFailures.set(sfuId, failures);
12921
- if (switchSfu || failures >= 2) {
12922
- joinData.migrating_from = sfuId;
12923
- joinData.migrating_from_list = Array.from(sfuJoinFailures.keys());
12950
+ try {
12951
+ for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
12952
+ try {
12953
+ this.logger.trace(`Joining call (${attempt})`, this.cid);
12954
+ await this.doJoin(data);
12955
+ delete joinData.migrating_from;
12956
+ delete joinData.migrating_from_list;
12957
+ break;
12924
12958
  }
12925
- if (attempt === maxJoinRetries - 1) {
12926
- throw err;
12959
+ catch (err) {
12960
+ this.logger.warn(`Failed to join call (${attempt})`, this.cid);
12961
+ if ((err instanceof ErrorFromResponse && err.unrecoverable) ||
12962
+ (err instanceof SfuJoinError && err.unrecoverable)) {
12963
+ // if the error is unrecoverable, we should not retry as that signals
12964
+ // that connectivity is good, but the coordinator doesn't allow the user
12965
+ // to join the call due to some reason (e.g., ended call, expired token...)
12966
+ throw err;
12967
+ }
12968
+ // immediately switch to a different SFU in case of recoverable join error
12969
+ const switchSfu = err instanceof SfuJoinError &&
12970
+ SfuJoinError.isJoinErrorCode(err.errorEvent);
12971
+ const sfuId = this.credentials?.server.edge_name || '';
12972
+ const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
12973
+ sfuJoinFailures.set(sfuId, failures);
12974
+ if (switchSfu || failures >= 2) {
12975
+ joinData.migrating_from = sfuId;
12976
+ joinData.migrating_from_list = Array.from(sfuJoinFailures.keys());
12977
+ }
12978
+ if (attempt === maxJoinRetries - 1) {
12979
+ throw err;
12980
+ }
12927
12981
  }
12982
+ await sleep(retryInterval(attempt));
12928
12983
  }
12929
- await sleep(retryInterval(attempt));
12984
+ }
12985
+ catch (error) {
12986
+ callingX?.endCall(this, 'error');
12987
+ throw error;
12930
12988
  }
12931
12989
  };
12932
12990
  /**
@@ -13073,7 +13131,9 @@ class Call {
13073
13131
  // re-apply them on later reconnections or server-side data fetches
13074
13132
  if (!this.deviceSettingsAppliedOnce && this.state.settings) {
13075
13133
  await this.applyDeviceConfig(this.state.settings, true);
13076
- globalThis.streamRNVideoSDK?.callManager.start();
13134
+ globalThis.streamRNVideoSDK?.callManager.start({
13135
+ isRingingTypeCall: this.ringing,
13136
+ });
13077
13137
  this.deviceSettingsAppliedOnce = true;
13078
13138
  }
13079
13139
  // We shouldn't persist the `ring` and `notify` state after joining the call
@@ -13501,6 +13561,7 @@ class Call {
13501
13561
  if (strategy === WebsocketReconnectStrategy.UNSPECIFIED)
13502
13562
  return;
13503
13563
  if (strategy === WebsocketReconnectStrategy.DISCONNECT) {
13564
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this, 'error');
13504
13565
  this.leave({ message: 'SFU instructed to disconnect' }).catch((err) => {
13505
13566
  this.logger.warn(`Can't leave call after disconnect request`, err);
13506
13567
  });
@@ -15596,7 +15657,7 @@ class StreamClient {
15596
15657
  this.getUserAgent = () => {
15597
15658
  if (!this.cachedUserAgent) {
15598
15659
  const { clientAppIdentifier = {} } = this.options;
15599
- const { sdkName = 'js', sdkVersion = "1.42.2", ...extras } = clientAppIdentifier;
15660
+ const { sdkName = 'js', sdkVersion = "1.43.0-beta.0", ...extras } = clientAppIdentifier;
15600
15661
  this.cachedUserAgent = [
15601
15662
  `stream-video-${sdkName}-v${sdkVersion}`,
15602
15663
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),