@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/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.42.2";
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 frequency data to determine if audio is being captured.
11504
- */
11505
- const hasAudio = (analyser, threshold) => {
11506
- const data = new Uint8Array(analyser.frequencyBinCount);
11507
- analyser.getByteFrequencyData(data);
11508
- return data.some((value) => value > threshold);
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
- return state.kind === 'IDLE' || state.kind === 'EMITTING'
11523
- ? emit(true, state)
11524
- : noEmit(state);
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, audioLevelThreshold = 0, fftSize = 256, onCaptureStatusChange, } = options;
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 [audioTrack] = audioStream.getAudioTracks();
11570
- if (!audioTrack?.enabled || audioTrack.readyState === 'ended') {
11581
+ const [track] = audioStream.getAudioTracks();
11582
+ if (track && !track.enabled) {
11571
11583
  state = { kind: 'IDLE' };
11572
11584
  return;
11573
11585
  }
11574
- const audioDetected = hasAudio(analyser, audioLevelThreshold);
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
- if (!this.soundDetectorCleanup)
12072
- return;
12073
- const soundDetectorCleanup = this.soundDetectorCleanup;
12074
- this.soundDetectorCleanup = undefined;
12075
- this.state.setSpeakingWhileMuted(false);
12076
- await soundDetectorCleanup();
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
- await this.reject(reason ?? 'decline');
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.tracer.trace('call.accept', '');
12884
- return this.streamClient.post(`${this.streamClientBasePath}/accept`);
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.tracer.trace('call.reject', reason);
12897
- return this.streamClient.post(`${this.streamClientBasePath}/reject`, { reason: reason });
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
- for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
12919
- try {
12920
- this.logger.trace(`Joining call (${attempt})`, this.cid);
12921
- await this.doJoin(data);
12922
- delete joinData.migrating_from;
12923
- delete joinData.migrating_from_list;
12924
- break;
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
- if (attempt === maxJoinRetries - 1) {
12946
- throw err;
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
- await sleep(retryInterval(attempt));
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.42.2", ...extras } = clientAppIdentifier;
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}`),