@stream-io/video-client 1.42.0 → 1.42.2

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.
@@ -1,5 +1,5 @@
1
1
  import { Dispatcher, IceTrickleBuffer } from './rtc';
2
- import { Error as SfuErrorEvent, JoinRequest, JoinResponse } from './gen/video/sfu/event/events';
2
+ import { JoinRequest, JoinResponse } from './gen/video/sfu/event/events';
3
3
  import { ICERestartRequest, SendAnswerRequest, SendStatsRequest, SetPublisherRequest, TrackMuteState, TrackSubscriptionDetails } from './gen/video/sfu/signal_rpc/signal';
4
4
  import { ICETrickle } from './gen/video/sfu/models/models';
5
5
  import { StreamClient } from './coordinator/connection/client';
@@ -170,8 +170,3 @@ export declare class StreamSfuClient {
170
170
  private keepAlive;
171
171
  private scheduleConnectionCheck;
172
172
  }
173
- export declare class SfuJoinError extends Error {
174
- errorEvent: SfuErrorEvent;
175
- unrecoverable: boolean;
176
- constructor(event: SfuErrorEvent);
177
- }
@@ -69,4 +69,5 @@ export declare class MicrophoneManager extends AudioDeviceManager<MicrophoneMana
69
69
  protected doSetAudioBitrateProfile(profile: AudioBitrateProfile): void;
70
70
  private startSpeakingWhileMutedDetection;
71
71
  private stopSpeakingWhileMutedDetection;
72
+ private hasPermission;
72
73
  }
@@ -0,0 +1,7 @@
1
+ import { Error as SfuErrorEvent } from '../gen/video/sfu/event/events';
2
+ export declare class SfuJoinError extends Error {
3
+ errorEvent: SfuErrorEvent;
4
+ unrecoverable: boolean;
5
+ constructor(event: SfuErrorEvent);
6
+ static isJoinErrorCode(event: SfuErrorEvent): boolean;
7
+ }
@@ -0,0 +1 @@
1
+ export * from './SfuJoinError';
@@ -5358,6 +5358,12 @@ export interface JoinCallRequest {
5358
5358
  * @memberof JoinCallRequest
5359
5359
  */
5360
5360
  migrating_from?: string;
5361
+ /**
5362
+ * List of SFU IDs to exclude when picking a new SFU for the participant
5363
+ * @type {Array<string>}
5364
+ * @memberof JoinCallRequest
5365
+ */
5366
+ migrating_from_list?: Array<string>;
5361
5367
  /**
5362
5368
  *
5363
5369
  * @type {boolean}
@@ -18,6 +18,7 @@ export declare abstract class BasePeerConnection {
18
18
  protected readonly state: CallState;
19
19
  protected readonly dispatcher: Dispatcher;
20
20
  protected readonly clientPublishOptions?: ClientPublishOptions;
21
+ protected readonly tag: string;
21
22
  protected sfuClient: StreamSfuClient;
22
23
  private onReconnectionNeeded?;
23
24
  private readonly iceRestartDelay;
@@ -15,11 +15,45 @@ export type DispatchableMessage<K extends SfuEventKinds> = {
15
15
  [Key in K]: AllSfuEvents[Key];
16
16
  };
17
17
  };
18
+ /**
19
+ * Determines if a given event name belongs to the category of SFU events.
20
+ *
21
+ * @param eventName the name of the event to check.
22
+ * @returns true if the event name is an SFU event, otherwise false.
23
+ */
18
24
  export declare const isSfuEvent: (eventName: SfuEventKinds | EventTypes) => eventName is SfuEventKinds;
19
25
  export declare class Dispatcher {
20
26
  private readonly logger;
21
27
  private subscribers;
28
+ /**
29
+ * Dispatch an event to all subscribers.
30
+ *
31
+ * @param message the event payload to dispatch.
32
+ * @param tag for scoping events to a specific tag. Use `*` dispatch to every tag.
33
+ */
22
34
  dispatch: <K extends SfuEventKinds>(message: DispatchableMessage<K>, tag?: string) => void;
23
- on: <E extends keyof AllSfuEvents>(eventName: E, fn: CallEventListener<E>) => () => void;
24
- off: <E extends keyof AllSfuEvents>(eventName: E, fn: CallEventListener<E>) => void;
35
+ /**
36
+ * Emit an event to a list of listeners.
37
+ *
38
+ * @param payload the event payload to emit.
39
+ * @param listeners the list of listeners to emit the event to.
40
+ */
41
+ emit: (payload: any, listeners?: CallEventListener<any>[]) => void;
42
+ /**
43
+ * Subscribe to an event.
44
+ *
45
+ * @param eventName the name of the event to subscribe to.
46
+ * @param tag for scoping events to a specific tag. Use `*` dispatch to every tag.
47
+ * @param fn the callback function to invoke when the event is emitted.
48
+ * @returns a function that can be called to unsubscribe from the event.
49
+ */
50
+ on: <E extends keyof AllSfuEvents>(eventName: E, tag: string, fn: CallEventListener<E>) => () => void;
51
+ /**
52
+ * Unsubscribe from an event.
53
+ *
54
+ * @param eventName the name of the event to unsubscribe from.
55
+ * @param tag for scoping events to a specific tag. Use `*` dispatch to every tag.
56
+ * @param fn the callback function to remove from the event listeners.
57
+ */
58
+ off: <E extends keyof AllSfuEvents>(eventName: E, tag: string, fn: CallEventListener<E>) => void;
25
59
  }
@@ -297,6 +297,12 @@ export type StreamRNVideoSDKGlobals = {
297
297
  */
298
298
  stop(): void;
299
299
  };
300
+ permissions: {
301
+ /**
302
+ * Checks whether a native device permission has been granted.
303
+ */
304
+ check(permission: 'microphone' | 'camera'): Promise<boolean>;
305
+ };
300
306
  };
301
307
  declare global {
302
308
  var streamRNVideoSDK: StreamRNVideoSDKGlobals | undefined;
package/index.ts CHANGED
@@ -14,6 +14,7 @@ export * from './src/CallType';
14
14
  export * from './src/StreamVideoClient';
15
15
  export * from './src/StreamSfuClient';
16
16
  export * from './src/devices';
17
+ export * from './src/errors';
17
18
  export * from './src/store';
18
19
  export * from './src/sorting';
19
20
  export * from './src/helpers/client-details';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-client",
3
- "version": "1.42.0",
3
+ "version": "1.42.2",
4
4
  "main": "dist/index.cjs.js",
5
5
  "module": "dist/index.es.js",
6
6
  "browser": "dist/index.browser.es.js",
package/src/Call.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { SfuJoinError, StreamSfuClient } from './StreamSfuClient';
1
+ import { StreamSfuClient } from './StreamSfuClient';
2
+ import { SfuJoinError } from './errors';
2
3
  import {
3
4
  BasePeerConnectionOpts,
4
5
  Dispatcher,
@@ -561,7 +562,7 @@ export class Call {
561
562
  fn: CallEventListener<E>,
562
563
  ) => {
563
564
  if (isSfuEvent(eventName)) {
564
- return this.dispatcher.on(eventName, fn);
565
+ return this.dispatcher.on(eventName, '*', fn);
565
566
  }
566
567
 
567
568
  const offHandler = this.streamClient.on(eventName, (e) => {
@@ -589,7 +590,7 @@ export class Call {
589
590
  fn: CallEventListener<E>,
590
591
  ) => {
591
592
  if (isSfuEvent(eventName)) {
592
- return this.dispatcher.off(eventName, fn);
593
+ return this.dispatcher.off(eventName, '*', fn);
593
594
  }
594
595
 
595
596
  // unsubscribe from the stream client event by using the 'off' reference
@@ -918,6 +919,7 @@ export class Call {
918
919
  this.logger.trace(`Joining call (${attempt})`, this.cid);
919
920
  await this.doJoin(data);
920
921
  delete joinData.migrating_from;
922
+ delete joinData.migrating_from_list;
921
923
  break;
922
924
  } catch (err) {
923
925
  this.logger.warn(`Failed to join call (${attempt})`, this.cid);
@@ -931,11 +933,17 @@ export class Call {
931
933
  throw err;
932
934
  }
933
935
 
936
+ // immediately switch to a different SFU in case of recoverable join error
937
+ const switchSfu =
938
+ err instanceof SfuJoinError &&
939
+ SfuJoinError.isJoinErrorCode(err.errorEvent);
940
+
934
941
  const sfuId = this.credentials?.server.edge_name || '';
935
942
  const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
936
943
  sfuJoinFailures.set(sfuId, failures);
937
- if (failures >= 2) {
944
+ if (switchSfu || failures >= 2) {
938
945
  joinData.migrating_from = sfuId;
946
+ joinData.migrating_from_list = Array.from(sfuJoinFailures.keys());
939
947
  }
940
948
 
941
949
  if (attempt === maxJoinRetries - 1) {
@@ -1611,11 +1619,16 @@ export class Call {
1611
1619
 
1612
1620
  try {
1613
1621
  const currentSfu = currentSfuClient.edgeName;
1614
- await this.doJoin({ ...this.joinCallData, migrating_from: currentSfu });
1622
+ await this.doJoin({
1623
+ ...this.joinCallData,
1624
+ migrating_from: currentSfu,
1625
+ migrating_from_list: [currentSfu],
1626
+ });
1615
1627
  } finally {
1616
1628
  // cleanup the migration_from field after the migration is complete or failed
1617
1629
  // as we don't want to keep dirty data in the join call data
1618
1630
  delete this.joinCallData?.migrating_from;
1631
+ delete this.joinCallData?.migrating_from_list;
1619
1632
  }
1620
1633
 
1621
1634
  await this.restorePublishedTracks();
@@ -1660,6 +1673,10 @@ export class Call {
1660
1673
  // handles the "error" event, through which the SFU can request a reconnect
1661
1674
  const unregisterOnError = this.on('error', (e) => {
1662
1675
  const { reconnectStrategy: strategy, error } = e;
1676
+ // SFU_FULL is a join error, and when emitted, although it specifies a
1677
+ // `migrate` strategy, we should actually perform a REJOIN to a new SFU.
1678
+ // This is now handled separately in the `call.join()` method.
1679
+ if (SfuJoinError.isJoinErrorCode(e)) return;
1663
1680
  if (strategy === WebsocketReconnectStrategy.UNSPECIFIED) return;
1664
1681
  if (strategy === WebsocketReconnectStrategy.DISCONNECT) {
1665
1682
  this.leave({ message: 'SFU instructed to disconnect' }).catch((err) => {
@@ -14,7 +14,6 @@ import {
14
14
  SfuEventKinds,
15
15
  } from './rtc';
16
16
  import {
17
- Error as SfuErrorEvent,
18
17
  JoinRequest,
19
18
  JoinResponse,
20
19
  SfuRequest,
@@ -27,10 +26,7 @@ import {
27
26
  TrackMuteState,
28
27
  TrackSubscriptionDetails,
29
28
  } from './gen/video/sfu/signal_rpc/signal';
30
- import {
31
- ICETrickle,
32
- WebsocketReconnectStrategy,
33
- } from './gen/video/sfu/models/models';
29
+ import { ICETrickle } from './gen/video/sfu/models/models';
34
30
  import { StreamClient } from './coordinator/connection/client';
35
31
  import { generateUUIDv4 } from './coordinator/connection/utils';
36
32
  import { Credentials } from './gen/coordinator';
@@ -43,6 +39,7 @@ import {
43
39
  } from './helpers/promise';
44
40
  import { getTimers } from './timers';
45
41
  import { Tracer, TraceSlice } from './stats';
42
+ import { SfuJoinError } from './errors';
46
43
 
47
44
  export type StreamSfuClientConstructor = {
48
45
  /**
@@ -250,8 +247,8 @@ export class StreamSfuClient {
250
247
  // In that case, those events (ICE candidates) need to be buffered
251
248
  // and later added to the appropriate PeerConnection
252
249
  // once the remoteDescription is known and set.
253
- this.unsubscribeIceTrickle = dispatcher.on('iceTrickle', (iceTrickle) => {
254
- this.iceTrickleBuffer.push(iceTrickle);
250
+ this.unsubscribeIceTrickle = dispatcher.on('iceTrickle', tag, (t) => {
251
+ this.iceTrickleBuffer.push(t);
255
252
  });
256
253
 
257
254
  // listen to network changes to handle offline state
@@ -504,6 +501,7 @@ export class StreamSfuClient {
504
501
  const task = (this.migrationTask = promiseWithResolvers());
505
502
  const unsubscribe = this.dispatcher.on(
506
503
  'participantMigrationComplete',
504
+ this.tag,
507
505
  () => {
508
506
  unsubscribe();
509
507
  clearTimeout(this.migrateAwayTimeout);
@@ -541,27 +539,40 @@ export class StreamSfuClient {
541
539
  const current = this.joinResponseTask;
542
540
 
543
541
  let timeoutId: NodeJS.Timeout | undefined = undefined;
544
- const unsubscribeJoinErrorEvents = this.dispatcher.on('error', (event) => {
545
- const { error, reconnectStrategy } = event;
546
- if (!error) return;
547
- if (reconnectStrategy === WebsocketReconnectStrategy.DISCONNECT) {
548
- clearTimeout(timeoutId);
549
- unsubscribe?.();
550
- unsubscribeJoinErrorEvents();
551
- current.reject(new SfuJoinError(event));
552
- }
553
- });
554
- const unsubscribe = this.dispatcher.on('joinResponse', (joinResponse) => {
542
+ let unsubscribeJoinResponse: (() => void) | undefined = undefined;
543
+ let unsubscribeJoinErrorEvents: (() => void) | undefined = undefined;
544
+
545
+ const cleanupJoinSubscriptions = () => {
555
546
  clearTimeout(timeoutId);
556
- unsubscribe();
557
- unsubscribeJoinErrorEvents();
558
- this.keepAlive();
559
- current.resolve(joinResponse);
560
- });
547
+ timeoutId = undefined;
548
+ unsubscribeJoinErrorEvents?.();
549
+ unsubscribeJoinErrorEvents = undefined;
550
+ unsubscribeJoinResponse?.();
551
+ unsubscribeJoinResponse = undefined;
552
+ };
553
+
554
+ unsubscribeJoinErrorEvents = this.dispatcher.on(
555
+ 'error',
556
+ this.tag,
557
+ (event) => {
558
+ if (SfuJoinError.isJoinErrorCode(event)) {
559
+ cleanupJoinSubscriptions();
560
+ current.reject(new SfuJoinError(event));
561
+ }
562
+ },
563
+ );
564
+ unsubscribeJoinResponse = this.dispatcher.on(
565
+ 'joinResponse',
566
+ this.tag,
567
+ (joinResponse) => {
568
+ cleanupJoinSubscriptions();
569
+ this.keepAlive();
570
+ current.resolve(joinResponse);
571
+ },
572
+ );
561
573
 
562
574
  timeoutId = setTimeout(() => {
563
- unsubscribe();
564
- unsubscribeJoinErrorEvents();
575
+ cleanupJoinSubscriptions();
565
576
  const message = `Waiting for "joinResponse" has timed out after ${this.joinResponseTimeout}ms`;
566
577
  this.tracer?.trace('joinRequestTimeout', message);
567
578
  current.reject(new Error(message));
@@ -647,15 +658,3 @@ export class StreamSfuClient {
647
658
  }, this.unhealthyTimeoutInMs);
648
659
  };
649
660
  }
650
-
651
- export class SfuJoinError extends Error {
652
- errorEvent: SfuErrorEvent;
653
- unrecoverable: boolean;
654
-
655
- constructor(event: SfuErrorEvent) {
656
- super(event.error?.message || 'Join Error');
657
- this.errorEvent = event;
658
- this.unrecoverable =
659
- event.reconnectStrategy === WebsocketReconnectStrategy.DISCONNECT;
660
- }
661
- }
@@ -5,6 +5,7 @@ import {
5
5
  AudioDeviceManager,
6
6
  createAudioConstraints,
7
7
  } from './AudioDeviceManager';
8
+ import { type BrowserPermissionState } from './BrowserPermission';
8
9
  import { MicrophoneManagerState } from './MicrophoneManagerState';
9
10
  import { TrackDisableMode } from './DeviceManagerState';
10
11
  import { getAudioDevices, getAudioStream } from './devices';
@@ -56,8 +57,15 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
56
57
  this.call.state.ownCapabilities$,
57
58
  this.state.selectedDevice$,
58
59
  this.state.status$,
60
+ this.state.browserPermissionState$,
59
61
  ]),
60
- async ([callingState, ownCapabilities, deviceId, status]) => {
62
+ async ([
63
+ callingState,
64
+ ownCapabilities,
65
+ deviceId,
66
+ status,
67
+ permissionState,
68
+ ]) => {
61
69
  try {
62
70
  if (callingState === CallingState.LEFT) {
63
71
  await this.stopSpeakingWhileMutedDetection();
@@ -66,7 +74,8 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
66
74
  if (!this.speakingWhileMutedNotificationEnabled) return;
67
75
 
68
76
  if (ownCapabilities.includes(OwnCapability.SEND_AUDIO)) {
69
- if (status !== 'enabled') {
77
+ const hasPermission = await this.hasPermission(permissionState);
78
+ if (hasPermission && status !== 'enabled') {
70
79
  await this.startSpeakingWhileMutedDetection(deviceId);
71
80
  } else {
72
81
  await this.stopSpeakingWhileMutedDetection();
@@ -407,4 +416,20 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
407
416
  await soundDetectorCleanup();
408
417
  });
409
418
  }
419
+
420
+ private async hasPermission(
421
+ permissionState: BrowserPermissionState,
422
+ ): Promise<boolean> {
423
+ if (!isReactNative()) return permissionState === 'granted';
424
+
425
+ const nativePermissions = globalThis.streamRNVideoSDK?.permissions;
426
+ if (!nativePermissions) return true; // assume granted
427
+
428
+ try {
429
+ return await nativePermissions.check('microphone');
430
+ } catch (err) {
431
+ this.logger.warn('Failed to check permission', err);
432
+ return false;
433
+ }
434
+ }
410
435
  }
@@ -78,6 +78,9 @@ describe('MicrophoneManager', () => {
78
78
 
79
79
  beforeEach(() => {
80
80
  setupAudioContextMock();
81
+ vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
82
+ of('granted'),
83
+ );
81
84
 
82
85
  call = new Call({
83
86
  id: '',
@@ -167,6 +170,20 @@ describe('MicrophoneManager', () => {
167
170
  expect(fn).toHaveBeenCalled();
168
171
  });
169
172
 
173
+ it('should not start sound detection if browser mic permission is denied', async () => {
174
+ vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
175
+ of('denied'),
176
+ );
177
+ const innerManager = new MicrophoneManager(call, 'disable-tracks');
178
+ // @ts-expect-error private api
179
+ const fn = vi.spyOn(innerManager, 'startSpeakingWhileMutedDetection');
180
+
181
+ await innerManager.enable();
182
+
183
+ await sleep(25);
184
+ expect(fn).not.toHaveBeenCalled();
185
+ });
186
+
170
187
  it(`should stop sound detection if mic is enabled`, async () => {
171
188
  manager.state.setSpeakingWhileMuted(true);
172
189
  manager['soundDetectorCleanup'] = async () => {};
@@ -177,6 +194,7 @@ describe('MicrophoneManager', () => {
177
194
  await withoutConcurrency(syncTag, () => Promise.resolve());
178
195
  await settled(syncTag);
179
196
 
197
+ await sleep(25);
180
198
  expect(manager.state.speakingWhileMuted).toBe(false);
181
199
  });
182
200
 
@@ -513,6 +531,7 @@ describe('MicrophoneManager', () => {
513
531
  });
514
532
 
515
533
  afterEach(() => {
534
+ vi.restoreAllMocks();
516
535
  vi.clearAllMocks();
517
536
  vi.resetModules();
518
537
  });
@@ -61,7 +61,21 @@ vi.mock('../../helpers/RNSpeechDetector.ts', () => {
61
61
 
62
62
  describe('MicrophoneManager React Native', () => {
63
63
  let manager: MicrophoneManager;
64
+ let checkPermissionMock: ReturnType<typeof vi.fn>;
64
65
  beforeEach(() => {
66
+ checkPermissionMock = vi.fn(async () => true);
67
+
68
+ globalThis.streamRNVideoSDK = {
69
+ callManager: {
70
+ setup: vi.fn(),
71
+ start: vi.fn(),
72
+ stop: vi.fn(),
73
+ },
74
+ permissions: {
75
+ check: checkPermissionMock,
76
+ },
77
+ };
78
+
65
79
  manager = new MicrophoneManager(
66
80
  new Call({
67
81
  id: '',
@@ -83,6 +97,30 @@ describe('MicrophoneManager React Native', () => {
83
97
  expect(manager['rnSpeechDetector']?.start).toHaveBeenCalled();
84
98
  });
85
99
 
100
+ it('should check native microphone permission before starting detection', async () => {
101
+ await manager.enable();
102
+ await manager.disable();
103
+
104
+ await vi.waitUntil(() => checkPermissionMock.mock.calls.length > 0, {
105
+ timeout: 100,
106
+ });
107
+ expect(checkPermissionMock).toHaveBeenCalledWith('microphone');
108
+ });
109
+
110
+ it('should not start sound detection if native microphone permission is denied', async () => {
111
+ checkPermissionMock.mockResolvedValue(false);
112
+
113
+ await manager.enable();
114
+ // @ts-expect-error - private method
115
+ const fn = vi.spyOn(manager, 'startSpeakingWhileMutedDetection');
116
+ await manager.disable();
117
+
118
+ await vi.waitUntil(() => checkPermissionMock.mock.calls.length > 0, {
119
+ timeout: 100,
120
+ });
121
+ expect(fn).not.toHaveBeenCalled();
122
+ });
123
+
86
124
  it(`should stop sound detection if mic is enabled`, async () => {
87
125
  manager.state.setSpeakingWhileMuted(true);
88
126
  manager['soundDetectorCleanup'] = async () => {};
@@ -94,6 +132,9 @@ describe('MicrophoneManager React Native', () => {
94
132
  await withoutConcurrency(syncTag, () => Promise.resolve());
95
133
  await settled(syncTag);
96
134
 
135
+ await vi.waitUntil(() => manager.state.speakingWhileMuted === false, {
136
+ timeout: 100,
137
+ });
97
138
  expect(manager.state.speakingWhileMuted).toBe(false);
98
139
  });
99
140
 
@@ -139,6 +180,7 @@ describe('MicrophoneManager React Native', () => {
139
180
  });
140
181
 
141
182
  afterEach(() => {
183
+ globalThis.streamRNVideoSDK = undefined;
142
184
  vi.clearAllMocks();
143
185
  vi.resetModules();
144
186
  });
@@ -0,0 +1,26 @@
1
+ import { Error as SfuErrorEvent } from '../gen/video/sfu/event/events';
2
+ import {
3
+ ErrorCode,
4
+ WebsocketReconnectStrategy,
5
+ } from '../gen/video/sfu/models/models';
6
+
7
+ export class SfuJoinError extends Error {
8
+ errorEvent: SfuErrorEvent;
9
+ unrecoverable: boolean;
10
+
11
+ constructor(event: SfuErrorEvent) {
12
+ super(event.error?.message || 'Join Error');
13
+ this.errorEvent = event;
14
+ this.unrecoverable =
15
+ event.reconnectStrategy === WebsocketReconnectStrategy.DISCONNECT;
16
+ }
17
+
18
+ static isJoinErrorCode(event: SfuErrorEvent): boolean {
19
+ const code = event.error?.code;
20
+ return (
21
+ code === ErrorCode.SFU_FULL ||
22
+ code === ErrorCode.SFU_SHUTTING_DOWN ||
23
+ code === ErrorCode.CALL_PARTICIPANT_LIMIT_REACHED
24
+ );
25
+ }
26
+ }
@@ -0,0 +1 @@
1
+ export * from './SfuJoinError';
@@ -18,7 +18,7 @@ export const watchConnectionQualityChanged = (
18
18
  dispatcher: Dispatcher,
19
19
  state: CallState,
20
20
  ) => {
21
- return dispatcher.on('connectionQualityChanged', (e) => {
21
+ return dispatcher.on('connectionQualityChanged', '*', (e) => {
22
22
  const { connectionQualityUpdates } = e;
23
23
  if (!connectionQualityUpdates) return;
24
24
  state.updateParticipants(
@@ -44,7 +44,7 @@ export const watchParticipantCountChanged = (
44
44
  dispatcher: Dispatcher,
45
45
  state: CallState,
46
46
  ) => {
47
- return dispatcher.on('healthCheckResponse', (e) => {
47
+ return dispatcher.on('healthCheckResponse', '*', (e) => {
48
48
  const { participantCount } = e;
49
49
  if (participantCount) {
50
50
  state.setParticipantCount(participantCount.total);
@@ -54,7 +54,7 @@ export const watchParticipantCountChanged = (
54
54
  };
55
55
 
56
56
  export const watchLiveEnded = (dispatcher: Dispatcher, call: Call) => {
57
- return dispatcher.on('error', (e) => {
57
+ return dispatcher.on('error', '*', (e) => {
58
58
  if (e.error && e.error.code !== ErrorCode.LIVE_ENDED) return;
59
59
 
60
60
  call.state.setBackstage(true);
@@ -70,7 +70,7 @@ export const watchLiveEnded = (dispatcher: Dispatcher, call: Call) => {
70
70
  * Watches and logs the errors reported by the currently connected SFU.
71
71
  */
72
72
  export const watchSfuErrorReports = (dispatcher: Dispatcher) => {
73
- return dispatcher.on('error', (e) => {
73
+ return dispatcher.on('error', '*', (e) => {
74
74
  if (!e.error) return;
75
75
  const logger = videoLoggerSystem.getLogger('SfuClient');
76
76
  const { error, reconnectStrategy } = e;
@@ -9,7 +9,7 @@ export const watchDominantSpeakerChanged = (
9
9
  dispatcher: Dispatcher,
10
10
  state: CallState,
11
11
  ) => {
12
- return dispatcher.on('dominantSpeakerChanged', (e) => {
12
+ return dispatcher.on('dominantSpeakerChanged', '*', (e) => {
13
13
  const { sessionId } = e;
14
14
  if (sessionId === state.dominantSpeaker?.sessionId) return;
15
15
  state.setParticipants((participants) =>
@@ -41,7 +41,7 @@ export const watchAudioLevelChanged = (
41
41
  dispatcher: Dispatcher,
42
42
  state: CallState,
43
43
  ) => {
44
- return dispatcher.on('audioLevelChanged', (e) => {
44
+ return dispatcher.on('audioLevelChanged', '*', (e) => {
45
45
  const { audioLevels } = e;
46
46
  state.updateParticipants(
47
47
  audioLevels.reduce<StreamVideoParticipantPatches>((patches, current) => {
@@ -5362,6 +5362,12 @@ export interface JoinCallRequest {
5362
5362
  * @memberof JoinCallRequest
5363
5363
  */
5364
5364
  migrating_from?: string;
5365
+ /**
5366
+ * List of SFU IDs to exclude when picking a new SFU for the participant
5367
+ * @type {Array<string>}
5368
+ * @memberof JoinCallRequest
5369
+ */
5370
+ migrating_from_list?: Array<string>;
5365
5371
  /**
5366
5372
  *
5367
5373
  * @type {boolean}
@@ -27,6 +27,7 @@ export abstract class BasePeerConnection {
27
27
  protected readonly state: CallState;
28
28
  protected readonly dispatcher: Dispatcher;
29
29
  protected readonly clientPublishOptions?: ClientPublishOptions;
30
+ protected readonly tag: string;
30
31
  protected sfuClient: StreamSfuClient;
31
32
 
32
33
  private onReconnectionNeeded?: OnReconnectionNeeded;
@@ -67,6 +68,7 @@ export abstract class BasePeerConnection {
67
68
  this.dispatcher = dispatcher;
68
69
  this.iceRestartDelay = iceRestartDelay;
69
70
  this.clientPublishOptions = clientPublishOptions;
71
+ this.tag = tag;
70
72
  this.onReconnectionNeeded = onReconnectionNeeded;
71
73
  this.logger = videoLoggerSystem.getLogger(
72
74
  peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher',
@@ -162,7 +164,7 @@ export abstract class BasePeerConnection {
162
164
  fn: CallEventListener<E>,
163
165
  ): void => {
164
166
  this.subscriptions.push(
165
- this.dispatcher.on(event, (e) => {
167
+ this.dispatcher.on(event, this.tag, (e) => {
166
168
  const lockKey = `pc.${this.lock}.${event}`;
167
169
  withoutConcurrency(lockKey, async () => fn(e)).catch((err) => {
168
170
  if (this.isDisposed) return;