@stream-io/video-client 1.42.3 → 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.
@@ -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.3";
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
  }
@@ -12512,6 +12536,7 @@ class Call {
12512
12536
  const currentUserId = this.currentUserId;
12513
12537
  if (currentUserId && blockedUserIds.includes(currentUserId)) {
12514
12538
  this.logger.info('Leaving call because of being blocked');
12539
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this, 'restricted');
12515
12540
  await this.leave({ message: 'user blocked' }).catch((err) => {
12516
12541
  this.logger.error('Error leaving call after being blocked', err);
12517
12542
  });
@@ -12548,6 +12573,7 @@ class Call {
12548
12573
  const isAcceptedElsewhere = isAcceptedByMe && this.state.callingState === CallingState.RINGING;
12549
12574
  if ((isAcceptedElsewhere || isRejectedByMe) &&
12550
12575
  !hasPending(this.joinLeaveConcurrencyTag)) {
12576
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this, isAcceptedElsewhere ? 'answeredElsewhere' : 'rejected');
12551
12577
  this.leave().catch(() => {
12552
12578
  this.logger.error('Could not leave a call that was accepted or rejected elsewhere');
12553
12579
  });
@@ -12697,17 +12723,28 @@ class Call {
12697
12723
  }
12698
12724
  if (callingState === CallingState.RINGING && reject !== false) {
12699
12725
  if (reject) {
12700
- 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);
12701
12736
  }
12702
12737
  else {
12703
12738
  // if reject was undefined, we still have to cancel the call automatically
12704
12739
  // when I am the creator and everyone else left the call
12705
12740
  const hasOtherParticipants = this.state.remoteParticipants.length > 0;
12706
12741
  if (this.isCreatedByMe && !hasOtherParticipants) {
12742
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this, 'canceled');
12707
12743
  await this.reject('cancel');
12708
12744
  }
12709
12745
  }
12710
12746
  }
12747
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this);
12711
12748
  this.statsReporter?.stop();
12712
12749
  this.statsReporter = undefined;
12713
12750
  const leaveReason = message ?? reason ?? 'user is leaving the call';
@@ -12734,7 +12771,9 @@ class Call {
12734
12771
  this.ringingSubject.next(false);
12735
12772
  this.cancelAutoDrop();
12736
12773
  this.clientStore.unregisterCall(this);
12737
- globalThis.streamRNVideoSDK?.callManager.stop();
12774
+ globalThis.streamRNVideoSDK?.callManager.stop({
12775
+ isRingingTypeCall: this.ringing,
12776
+ });
12738
12777
  this.camera.dispose();
12739
12778
  this.microphone.dispose();
12740
12779
  this.screenShare.dispose();
@@ -12887,11 +12926,19 @@ class Call {
12887
12926
  * @returns a promise which resolves once the call join-flow has finished.
12888
12927
  */
12889
12928
  this.join = async ({ maxJoinRetries = 3, joinResponseTimeout, rpcRequestTimeout, ...data } = {}) => {
12890
- await this.setup();
12891
12929
  const callingState = this.state.callingState;
12892
12930
  if ([CallingState.JOINED, CallingState.JOINING].includes(callingState)) {
12893
12931
  throw new Error(`Illegal State: call.join() shall be called only once`);
12894
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();
12895
12942
  this.joinResponseTimeout = joinResponseTimeout;
12896
12943
  this.rpcRequestTimeout = rpcRequestTimeout;
12897
12944
  // we will count the number of join failures per SFU.
@@ -12900,38 +12947,44 @@ class Call {
12900
12947
  const sfuJoinFailures = new Map();
12901
12948
  const joinData = data;
12902
12949
  maxJoinRetries = Math.max(maxJoinRetries, 1);
12903
- for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
12904
- try {
12905
- this.logger.trace(`Joining call (${attempt})`, this.cid);
12906
- await this.doJoin(data);
12907
- delete joinData.migrating_from;
12908
- delete joinData.migrating_from_list;
12909
- break;
12910
- }
12911
- catch (err) {
12912
- this.logger.warn(`Failed to join call (${attempt})`, this.cid);
12913
- if ((err instanceof ErrorFromResponse && err.unrecoverable) ||
12914
- (err instanceof SfuJoinError && err.unrecoverable)) {
12915
- // if the error is unrecoverable, we should not retry as that signals
12916
- // that connectivity is good, but the coordinator doesn't allow the user
12917
- // to join the call due to some reason (e.g., ended call, expired token...)
12918
- throw err;
12919
- }
12920
- // immediately switch to a different SFU in case of recoverable join error
12921
- const switchSfu = err instanceof SfuJoinError &&
12922
- SfuJoinError.isJoinErrorCode(err.errorEvent);
12923
- const sfuId = this.credentials?.server.edge_name || '';
12924
- const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
12925
- sfuJoinFailures.set(sfuId, failures);
12926
- if (switchSfu || failures >= 2) {
12927
- joinData.migrating_from = sfuId;
12928
- 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;
12929
12958
  }
12930
- if (attempt === maxJoinRetries - 1) {
12931
- 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
+ }
12932
12981
  }
12982
+ await sleep(retryInterval(attempt));
12933
12983
  }
12934
- await sleep(retryInterval(attempt));
12984
+ }
12985
+ catch (error) {
12986
+ callingX?.endCall(this, 'error');
12987
+ throw error;
12935
12988
  }
12936
12989
  };
12937
12990
  /**
@@ -13078,7 +13131,9 @@ class Call {
13078
13131
  // re-apply them on later reconnections or server-side data fetches
13079
13132
  if (!this.deviceSettingsAppliedOnce && this.state.settings) {
13080
13133
  await this.applyDeviceConfig(this.state.settings, true);
13081
- globalThis.streamRNVideoSDK?.callManager.start();
13134
+ globalThis.streamRNVideoSDK?.callManager.start({
13135
+ isRingingTypeCall: this.ringing,
13136
+ });
13082
13137
  this.deviceSettingsAppliedOnce = true;
13083
13138
  }
13084
13139
  // We shouldn't persist the `ring` and `notify` state after joining the call
@@ -13506,6 +13561,7 @@ class Call {
13506
13561
  if (strategy === WebsocketReconnectStrategy.UNSPECIFIED)
13507
13562
  return;
13508
13563
  if (strategy === WebsocketReconnectStrategy.DISCONNECT) {
13564
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this, 'error');
13509
13565
  this.leave({ message: 'SFU instructed to disconnect' }).catch((err) => {
13510
13566
  this.logger.warn(`Can't leave call after disconnect request`, err);
13511
13567
  });
@@ -15601,7 +15657,7 @@ class StreamClient {
15601
15657
  this.getUserAgent = () => {
15602
15658
  if (!this.cachedUserAgent) {
15603
15659
  const { clientAppIdentifier = {} } = this.options;
15604
- const { sdkName = 'js', sdkVersion = "1.42.3", ...extras } = clientAppIdentifier;
15660
+ const { sdkName = 'js', sdkVersion = "1.43.0-beta.0", ...extras } = clientAppIdentifier;
15605
15661
  this.cachedUserAgent = [
15606
15662
  `stream-video-${sdkName}-v${sdkVersion}`,
15607
15663
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),