@stream-io/video-client 1.45.0 → 1.46.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,18 @@
2
2
 
3
3
  This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver).
4
4
 
5
+ ## [1.46.0](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-client-1.45.0...@stream-io/video-client-1.46.0) (2026-04-09)
6
+
7
+ ### Features
8
+
9
+ - callkit/telecom integration ([#2028](https://github.com/GetStream/stream-video-js/issues/2028)) ([d579acd](https://github.com/GetStream/stream-video-js/commit/d579acd1975fb4945e40452b27e372694c737628))
10
+ - **client:** expose blocked autoplay audio state and explicit resume API ([#2187](https://github.com/GetStream/stream-video-js/issues/2187)) ([adbec63](https://github.com/GetStream/stream-video-js/commit/adbec63a23d47cf7c1002897e242c3f2a6a7007c))
11
+
12
+ ### Bug Fixes
13
+
14
+ - **client:** deduplicate mic.capture_report trace emissions ([#2189](https://github.com/GetStream/stream-video-js/issues/2189)) ([152ae90](https://github.com/GetStream/stream-video-js/commit/152ae907910616e79bc20321bc56df4cfe0dcc4a))
15
+ - **client:** support server-side pinning on participant join ([#2190](https://github.com/GetStream/stream-video-js/issues/2190)) ([2c354a4](https://github.com/GetStream/stream-video-js/commit/2c354a4b05f688766663bd13e0da7da601c8971d))
16
+
5
17
  ## [1.45.0](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-client-1.44.5...@stream-io/video-client-1.45.0) (2026-04-02)
6
18
 
7
19
  ### Features
@@ -3265,6 +3265,7 @@ class ParticipantJoined$Type extends MessageType {
3265
3265
  super('stream.video.sfu.event.ParticipantJoined', [
3266
3266
  { no: 1, name: 'call_cid', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
3267
3267
  { no: 2, name: 'participant', kind: 'message', T: () => Participant },
3268
+ { no: 3, name: 'is_pinned', kind: 'scalar', T: 8 /*ScalarType.BOOL*/ },
3268
3269
  ]);
3269
3270
  }
3270
3271
  }
@@ -4784,7 +4785,7 @@ class StreamVideoWriteableStateStore {
4784
4785
  * The currently connected user.
4785
4786
  */
4786
4787
  get connectedUser() {
4787
- return getCurrentValue(this.connectedUserSubject);
4788
+ return this.connectedUserSubject.getValue();
4788
4789
  }
4789
4790
  /**
4790
4791
  * A list of {@link Call} objects created/tracked by this client.
@@ -6283,7 +6284,7 @@ const getSdkVersion = (sdk) => {
6283
6284
  return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
6284
6285
  };
6285
6286
 
6286
- const version = "1.45.0";
6287
+ const version = "1.46.0";
6287
6288
  const [major, minor, patch] = version.split('.');
6288
6289
  let sdkInfo = {
6289
6290
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -8982,6 +8983,7 @@ const watchCallRejected = (call) => {
8982
8983
  else {
8983
8984
  if (rejectedBy[eventCall.created_by.id]) {
8984
8985
  call.logger.info('call creator rejected, leaving call');
8986
+ globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
8985
8987
  await call.leave({ message: 'ring: creator rejected' });
8986
8988
  }
8987
8989
  }
@@ -8992,6 +8994,7 @@ const watchCallRejected = (call) => {
8992
8994
  */
8993
8995
  const watchCallEnded = (call) => {
8994
8996
  return function onCallEnded() {
8997
+ globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
8995
8998
  const { callingState } = call.state;
8996
8999
  if (callingState !== CallingState.IDLE &&
8997
9000
  callingState !== CallingState.LEFT) {
@@ -9023,6 +9026,7 @@ const watchSfuCallEnded = (call) => {
9023
9026
  // update the call state to reflect the call has ended.
9024
9027
  call.state.setEndedAt(new Date());
9025
9028
  const reason = CallEndedReason[e.reason];
9029
+ globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
9026
9030
  await call.leave({ message: `callEnded received: ${reason}` });
9027
9031
  }
9028
9032
  catch (err) {
@@ -9218,6 +9222,7 @@ const watchParticipantJoined = (state) => {
9218
9222
  // already announced participants.
9219
9223
  const orphanedTracks = reconcileOrphanedTracks(state, participant);
9220
9224
  state.updateOrAddParticipant(participant.sessionId, Object.assign(participant, orphanedTracks, {
9225
+ ...(e.isPinned && { pin: { isLocalPin: false, pinnedAt: Date.now() } }),
9221
9226
  viewportVisibilityState: {
9222
9227
  videoTrack: VisibilityState.UNKNOWN,
9223
9228
  screenShareTrack: VisibilityState.UNKNOWN,
@@ -9611,6 +9616,31 @@ class DynascaleManager {
9611
9616
  this.logger = videoLoggerSystem.getLogger('DynascaleManager');
9612
9617
  this.useWebAudio = false;
9613
9618
  this.pendingSubscriptionsUpdate = null;
9619
+ /**
9620
+ * Audio elements that were blocked by the browser's autoplay policy.
9621
+ * These can be retried by calling `resumeAudio()` from a user gesture.
9622
+ */
9623
+ this.blockedAudioElementsSubject = new BehaviorSubject(new Set());
9624
+ /**
9625
+ * Whether the browser's autoplay policy is blocking audio playback.
9626
+ * Will be `true` when the browser blocks autoplay (e.g., no prior user interaction).
9627
+ * Use `resumeAudio()` within a user gesture to unblock.
9628
+ */
9629
+ this.autoplayBlocked$ = this.blockedAudioElementsSubject.pipe(map((elements) => elements.size > 0), distinctUntilChanged());
9630
+ this.addBlockedAudioElement = (audioElement) => {
9631
+ setCurrentValue(this.blockedAudioElementsSubject, (elements) => {
9632
+ const next = new Set(elements);
9633
+ next.add(audioElement);
9634
+ return next;
9635
+ });
9636
+ };
9637
+ this.removeBlockedAudioElement = (audioElement) => {
9638
+ setCurrentValue(this.blockedAudioElementsSubject, (elements) => {
9639
+ const nextElements = new Set(elements);
9640
+ nextElements.delete(audioElement);
9641
+ return nextElements;
9642
+ });
9643
+ };
9614
9644
  this.videoTrackSubscriptionOverridesSubject = new BehaviorSubject({});
9615
9645
  this.videoTrackSubscriptionOverrides$ = this.videoTrackSubscriptionOverridesSubject.asObservable();
9616
9646
  this.incomingVideoSettings$ = this.videoTrackSubscriptionOverrides$.pipe(map((overrides) => {
@@ -9642,6 +9672,7 @@ class DynascaleManager {
9642
9672
  clearTimeout(this.pendingSubscriptionsUpdate);
9643
9673
  }
9644
9674
  this.audioBindingsWatchdog?.dispose();
9675
+ setCurrentValue(this.blockedAudioElementsSubject, new Set());
9645
9676
  const context = this.audioContext;
9646
9677
  if (context && context.state !== 'closed') {
9647
9678
  document.removeEventListener('click', this.resumeAudioContext);
@@ -9939,8 +9970,10 @@ class DynascaleManager {
9939
9970
  return;
9940
9971
  setTimeout(() => {
9941
9972
  audioElement.srcObject = source ?? null;
9942
- if (!source)
9973
+ if (!source) {
9974
+ this.removeBlockedAudioElement(audioElement);
9943
9975
  return;
9976
+ }
9944
9977
  // Safari has a special quirk that prevents playing audio until the user
9945
9978
  // interacts with the page or focuses on the tab where the call happens.
9946
9979
  // This is a workaround for the issue where:
@@ -9964,6 +9997,10 @@ class DynascaleManager {
9964
9997
  audioElement.muted = false;
9965
9998
  audioElement.play().catch((e) => {
9966
9999
  this.tracer.trace('audioPlaybackError', e.message);
10000
+ if (e.name === 'NotAllowedError') {
10001
+ this.tracer.trace('audioPlaybackBlocked', null);
10002
+ this.addBlockedAudioElement(audioElement);
10003
+ }
9967
10004
  this.logger.warn(`Failed to play audio stream`, e);
9968
10005
  });
9969
10006
  }
@@ -9990,6 +10027,7 @@ class DynascaleManager {
9990
10027
  audioElement.autoplay = true;
9991
10028
  return () => {
9992
10029
  this.audioBindingsWatchdog?.unregister(sessionId, trackType);
10030
+ this.removeBlockedAudioElement(audioElement);
9993
10031
  sinkIdSubscription?.unsubscribe();
9994
10032
  volumeSubscription.unsubscribe();
9995
10033
  updateMediaStreamSubscription.unsubscribe();
@@ -9998,6 +10036,28 @@ class DynascaleManager {
9998
10036
  gainNode?.disconnect();
9999
10037
  };
10000
10038
  };
10039
+ /**
10040
+ * Plays all audio elements blocked by the browser's autoplay policy.
10041
+ * Must be called from within a user gesture (e.g., click handler).
10042
+ *
10043
+ * @returns a promise that resolves when all blocked elements have been retried.
10044
+ */
10045
+ this.resumeAudio = async () => {
10046
+ this.tracer.trace('resumeAudio', null);
10047
+ const blocked = new Set();
10048
+ await Promise.all(Array.from(getCurrentValue(this.blockedAudioElementsSubject), async (el) => {
10049
+ try {
10050
+ if (el.srcObject) {
10051
+ await el.play();
10052
+ }
10053
+ }
10054
+ catch {
10055
+ this.logger.warn(`Can't resume audio for element: `, el);
10056
+ blocked.add(el);
10057
+ }
10058
+ }));
10059
+ setCurrentValue(this.blockedAudioElementsSubject, blocked);
10060
+ };
10001
10061
  this.getOrCreateAudioContext = () => {
10002
10062
  if (!this.useWebAudio)
10003
10063
  return;
@@ -11878,11 +11938,15 @@ class RNSpeechDetector {
11878
11938
  ? this.externalAudioStream
11879
11939
  : await navigator.mediaDevices.getUserMedia({ audio: true });
11880
11940
  this.audioStream = audioStream;
11881
- this.pc1.addEventListener('icecandidate', async (e) => {
11882
- await this.pc2.addIceCandidate(e.candidate);
11941
+ this.pc1.addEventListener('icecandidate', (e) => {
11942
+ this.pc2.addIceCandidate(e.candidate).catch(() => {
11943
+ // do nothing
11944
+ });
11883
11945
  });
11884
11946
  this.pc2.addEventListener('icecandidate', async (e) => {
11885
- await this.pc1.addIceCandidate(e.candidate);
11947
+ this.pc1.addIceCandidate(e.candidate).catch(() => {
11948
+ // do nothing
11949
+ });
11886
11950
  });
11887
11951
  this.pc2.addEventListener('track', (e) => {
11888
11952
  e.streams[0].getTracks().forEach((track) => {
@@ -12111,6 +12175,7 @@ class MicrophoneManager extends AudioDeviceManager {
12111
12175
  const deviceId = this.state.selectedDevice;
12112
12176
  const devices = await firstValueFrom(this.listDevices());
12113
12177
  const label = devices.find((d) => d.deviceId === deviceId)?.label;
12178
+ let lastCapturesAudio;
12114
12179
  this.noAudioDetectorCleanup = createNoAudioDetector(mediaStream, {
12115
12180
  noAudioThresholdMs: this.silenceThresholdMs,
12116
12181
  emitIntervalMs: this.silenceThresholdMs,
@@ -12122,7 +12187,10 @@ class MicrophoneManager extends AudioDeviceManager {
12122
12187
  deviceId,
12123
12188
  label,
12124
12189
  };
12125
- this.call.tracer.trace('mic.capture_report', event);
12190
+ if (capturesAudio !== lastCapturesAudio) {
12191
+ lastCapturesAudio = capturesAudio;
12192
+ this.call.tracer.trace('mic.capture_report', event);
12193
+ }
12126
12194
  this.call.streamClient.dispatchEvent(event);
12127
12195
  },
12128
12196
  });
@@ -12644,6 +12712,7 @@ class SpeakerManager {
12644
12712
  this.defaultDevice = defaultDevice;
12645
12713
  globalThis.streamRNVideoSDK?.callManager.setup({
12646
12714
  defaultDevice,
12715
+ isRingingTypeCall: this.call.ringing,
12647
12716
  });
12648
12717
  }
12649
12718
  }
@@ -12843,6 +12912,7 @@ class Call {
12843
12912
  const currentUserId = this.currentUserId;
12844
12913
  if (currentUserId && blockedUserIds.includes(currentUserId)) {
12845
12914
  this.logger.info('Leaving call because of being blocked');
12915
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this, 'restricted');
12846
12916
  await this.leave({ message: 'user blocked' }).catch((err) => {
12847
12917
  this.logger.error('Error leaving call after being blocked', err);
12848
12918
  });
@@ -12879,6 +12949,7 @@ class Call {
12879
12949
  const isAcceptedElsewhere = isAcceptedByMe && this.state.callingState === CallingState.RINGING;
12880
12950
  if ((isAcceptedElsewhere || isRejectedByMe) &&
12881
12951
  !hasPending(this.joinLeaveConcurrencyTag)) {
12952
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this, isAcceptedElsewhere ? 'answeredElsewhere' : 'rejected');
12882
12953
  this.leave().catch(() => {
12883
12954
  this.logger.error('Could not leave a call that was accepted or rejected elsewhere');
12884
12955
  });
@@ -12890,6 +12961,9 @@ class Call {
12890
12961
  const receiver_id = this.clientStore.connectedUser?.id;
12891
12962
  const ended_at = callSession?.ended_at;
12892
12963
  const created_by_id = this.state.createdBy?.id;
12964
+ if (this.currentUserId && created_by_id === this.currentUserId) {
12965
+ globalThis.streamRNVideoSDK?.callingX?.registerOutgoingCall(this);
12966
+ }
12893
12967
  const rejected_by = callSession?.rejected_by;
12894
12968
  const accepted_by = callSession?.accepted_by;
12895
12969
  let leaveCallIdle = false;
@@ -13028,7 +13102,16 @@ class Call {
13028
13102
  }
13029
13103
  if (callingState === CallingState.RINGING && reject !== false) {
13030
13104
  if (reject) {
13031
- await this.reject(reason ?? 'decline');
13105
+ const reasonToEndCallReason = {
13106
+ timeout: 'missed',
13107
+ cancel: 'canceled',
13108
+ busy: 'busy',
13109
+ decline: 'rejected',
13110
+ };
13111
+ const rejectReason = reason ?? 'decline';
13112
+ const endCallReason = reasonToEndCallReason[rejectReason] ?? 'rejected';
13113
+ await this.reject(rejectReason);
13114
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this, endCallReason);
13032
13115
  }
13033
13116
  else {
13034
13117
  // if reject was undefined, we still have to cancel the call automatically
@@ -13036,9 +13119,11 @@ class Call {
13036
13119
  const hasOtherParticipants = this.state.remoteParticipants.length > 0;
13037
13120
  if (this.isCreatedByMe && !hasOtherParticipants) {
13038
13121
  await this.reject('cancel');
13122
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this, 'canceled');
13039
13123
  }
13040
13124
  }
13041
13125
  }
13126
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this);
13042
13127
  this.statsReporter?.stop();
13043
13128
  this.statsReporter = undefined;
13044
13129
  const leaveReason = message ?? reason ?? 'user is leaving the call';
@@ -13065,7 +13150,9 @@ class Call {
13065
13150
  this.ringingSubject.next(false);
13066
13151
  this.cancelAutoDrop();
13067
13152
  this.clientStore.unregisterCall(this);
13068
- globalThis.streamRNVideoSDK?.callManager.stop();
13153
+ globalThis.streamRNVideoSDK?.callManager.stop({
13154
+ isRingingTypeCall: this.ringing,
13155
+ });
13069
13156
  this.camera.dispose();
13070
13157
  this.microphone.dispose();
13071
13158
  this.screenShare.dispose();
@@ -13231,11 +13318,19 @@ class Call {
13231
13318
  * @returns a promise which resolves once the call join-flow has finished.
13232
13319
  */
13233
13320
  this.join = async ({ maxJoinRetries = 3, joinResponseTimeout, rpcRequestTimeout, ...data } = {}) => {
13234
- await this.setup();
13235
13321
  const callingState = this.state.callingState;
13236
13322
  if ([CallingState.JOINED, CallingState.JOINING].includes(callingState)) {
13237
13323
  throw new Error(`Illegal State: call.join() shall be called only once`);
13238
13324
  }
13325
+ if (data?.ring) {
13326
+ this.ringingSubject.next(true);
13327
+ }
13328
+ const callingX = globalThis.streamRNVideoSDK?.callingX;
13329
+ if (callingX) {
13330
+ // for Android/iOS, we need to start the call in the callingx library as soon as possible
13331
+ await callingX.joinCall(this, this.clientStore.calls);
13332
+ }
13333
+ await this.setup();
13239
13334
  this.joinResponseTimeout = joinResponseTimeout;
13240
13335
  this.rpcRequestTimeout = rpcRequestTimeout;
13241
13336
  // we will count the number of join failures per SFU.
@@ -13244,38 +13339,44 @@ class Call {
13244
13339
  const sfuJoinFailures = new Map();
13245
13340
  const joinData = data;
13246
13341
  maxJoinRetries = Math.max(maxJoinRetries, 1);
13247
- for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
13248
- try {
13249
- this.logger.trace(`Joining call (${attempt})`, this.cid);
13250
- await this.doJoin(data);
13251
- delete joinData.migrating_from;
13252
- delete joinData.migrating_from_list;
13253
- break;
13254
- }
13255
- catch (err) {
13256
- this.logger.warn(`Failed to join call (${attempt})`, this.cid);
13257
- if ((err instanceof ErrorFromResponse && err.unrecoverable) ||
13258
- (err instanceof SfuJoinError && err.unrecoverable)) {
13259
- // if the error is unrecoverable, we should not retry as that signals
13260
- // that connectivity is good, but the coordinator doesn't allow the user
13261
- // to join the call due to some reason (e.g., ended call, expired token...)
13262
- throw err;
13263
- }
13264
- // immediately switch to a different SFU in case of recoverable join error
13265
- const switchSfu = err instanceof SfuJoinError &&
13266
- SfuJoinError.isJoinErrorCode(err.errorEvent);
13267
- const sfuId = this.credentials?.server.edge_name || '';
13268
- const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
13269
- sfuJoinFailures.set(sfuId, failures);
13270
- if (switchSfu || failures >= 2) {
13271
- joinData.migrating_from = sfuId;
13272
- joinData.migrating_from_list = Array.from(sfuJoinFailures.keys());
13342
+ try {
13343
+ for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
13344
+ try {
13345
+ this.logger.trace(`Joining call (${attempt})`, this.cid);
13346
+ await this.doJoin(data);
13347
+ delete joinData.migrating_from;
13348
+ delete joinData.migrating_from_list;
13349
+ break;
13273
13350
  }
13274
- if (attempt === maxJoinRetries - 1) {
13275
- throw err;
13351
+ catch (err) {
13352
+ this.logger.warn(`Failed to join call (${attempt})`, this.cid);
13353
+ if ((err instanceof ErrorFromResponse && err.unrecoverable) ||
13354
+ (err instanceof SfuJoinError && err.unrecoverable)) {
13355
+ // if the error is unrecoverable, we should not retry as that signals
13356
+ // that connectivity is good, but the coordinator doesn't allow the user
13357
+ // to join the call due to some reason (e.g., ended call, expired token...)
13358
+ throw err;
13359
+ }
13360
+ // immediately switch to a different SFU in case of recoverable join error
13361
+ const switchSfu = err instanceof SfuJoinError &&
13362
+ SfuJoinError.isJoinErrorCode(err.errorEvent);
13363
+ const sfuId = this.credentials?.server.edge_name || '';
13364
+ const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
13365
+ sfuJoinFailures.set(sfuId, failures);
13366
+ if (switchSfu || failures >= 2) {
13367
+ joinData.migrating_from = sfuId;
13368
+ joinData.migrating_from_list = Array.from(sfuJoinFailures.keys());
13369
+ }
13370
+ if (attempt === maxJoinRetries - 1) {
13371
+ throw err;
13372
+ }
13276
13373
  }
13374
+ await sleep(retryInterval(attempt));
13277
13375
  }
13278
- await sleep(retryInterval(attempt));
13376
+ }
13377
+ catch (error) {
13378
+ callingX?.endCall(this, 'error');
13379
+ throw error;
13279
13380
  }
13280
13381
  };
13281
13382
  /**
@@ -13422,7 +13523,9 @@ class Call {
13422
13523
  // re-apply them on later reconnections or server-side data fetches
13423
13524
  if (!this.deviceSettingsAppliedOnce && this.state.settings) {
13424
13525
  await this.applyDeviceConfig(this.state.settings, true, false);
13425
- globalThis.streamRNVideoSDK?.callManager.start();
13526
+ globalThis.streamRNVideoSDK?.callManager.start({
13527
+ isRingingTypeCall: this.ringing,
13528
+ });
13426
13529
  this.deviceSettingsAppliedOnce = true;
13427
13530
  }
13428
13531
  // We shouldn't persist the `ring` and `notify` state after joining the call
@@ -13850,6 +13953,7 @@ class Call {
13850
13953
  if (strategy === WebsocketReconnectStrategy.UNSPECIFIED)
13851
13954
  return;
13852
13955
  if (strategy === WebsocketReconnectStrategy.DISCONNECT) {
13956
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this, 'error');
13853
13957
  this.leave({ message: 'SFU instructed to disconnect' }).catch((err) => {
13854
13958
  this.logger.warn(`Can't leave call after disconnect request`, err);
13855
13959
  });
@@ -14754,6 +14858,12 @@ class Call {
14754
14858
  unbind();
14755
14859
  };
14756
14860
  };
14861
+ /**
14862
+ * Plays all audio elements blocked by the browser's autoplay policy.
14863
+ */
14864
+ this.resumeAudio = () => {
14865
+ return this.dynascaleManager.resumeAudio();
14866
+ };
14757
14867
  /**
14758
14868
  * Binds a DOM <img> element to this call's thumbnail (if enabled in settings).
14759
14869
  *
@@ -14871,7 +14981,7 @@ class Call {
14871
14981
  * A flag indicating whether the call was created by the current user.
14872
14982
  */
14873
14983
  get isCreatedByMe() {
14874
- return this.state.createdBy?.id === this.currentUserId;
14984
+ return (this.currentUserId && this.state.createdBy?.id === this.currentUserId);
14875
14985
  }
14876
14986
  }
14877
14987
 
@@ -15997,7 +16107,7 @@ class StreamClient {
15997
16107
  this.getUserAgent = () => {
15998
16108
  if (!this.cachedUserAgent) {
15999
16109
  const { clientAppIdentifier = {} } = this.options;
16000
- const { sdkName = 'js', sdkVersion = "1.45.0", ...extras } = clientAppIdentifier;
16110
+ const { sdkName = 'js', sdkVersion = "1.46.0", ...extras } = clientAppIdentifier;
16001
16111
  this.cachedUserAgent = [
16002
16112
  `stream-video-${sdkName}-v${sdkVersion}`,
16003
16113
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),