@stream-io/video-client 1.5.0 → 1.6.0-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.
Files changed (74) hide show
  1. package/CHANGELOG.md +175 -0
  2. package/dist/index.browser.es.js +1986 -1482
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1983 -1478
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1986 -1482
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +93 -9
  9. package/dist/src/StreamSfuClient.d.ts +73 -56
  10. package/dist/src/StreamVideoClient.d.ts +2 -2
  11. package/dist/src/coordinator/connection/client.d.ts +3 -4
  12. package/dist/src/coordinator/connection/types.d.ts +5 -1
  13. package/dist/src/devices/InputMediaDeviceManager.d.ts +4 -0
  14. package/dist/src/devices/MicrophoneManager.d.ts +1 -1
  15. package/dist/src/events/callEventHandlers.d.ts +1 -3
  16. package/dist/src/events/internal.d.ts +4 -0
  17. package/dist/src/gen/video/sfu/event/events.d.ts +106 -4
  18. package/dist/src/gen/video/sfu/models/models.d.ts +64 -65
  19. package/dist/src/helpers/ensureExhausted.d.ts +1 -0
  20. package/dist/src/helpers/withResolvers.d.ts +14 -0
  21. package/dist/src/logger.d.ts +1 -0
  22. package/dist/src/rpc/createClient.d.ts +2 -0
  23. package/dist/src/rpc/index.d.ts +1 -0
  24. package/dist/src/rpc/retryable.d.ts +23 -0
  25. package/dist/src/rtc/Dispatcher.d.ts +1 -1
  26. package/dist/src/rtc/IceTrickleBuffer.d.ts +0 -1
  27. package/dist/src/rtc/Publisher.d.ts +24 -25
  28. package/dist/src/rtc/Subscriber.d.ts +12 -11
  29. package/dist/src/rtc/helpers/rtcConfiguration.d.ts +2 -0
  30. package/dist/src/rtc/helpers/tracks.d.ts +3 -3
  31. package/dist/src/rtc/signal.d.ts +1 -1
  32. package/dist/src/store/CallState.d.ts +46 -2
  33. package/package.json +3 -3
  34. package/src/Call.ts +628 -566
  35. package/src/StreamSfuClient.ts +276 -246
  36. package/src/StreamVideoClient.ts +15 -16
  37. package/src/coordinator/connection/client.ts +25 -8
  38. package/src/coordinator/connection/connection.ts +1 -0
  39. package/src/coordinator/connection/types.ts +6 -0
  40. package/src/devices/CameraManager.ts +1 -1
  41. package/src/devices/InputMediaDeviceManager.ts +12 -3
  42. package/src/devices/MicrophoneManager.ts +3 -3
  43. package/src/devices/devices.ts +1 -1
  44. package/src/events/__tests__/mutes.test.ts +10 -13
  45. package/src/events/__tests__/participant.test.ts +75 -0
  46. package/src/events/callEventHandlers.ts +4 -7
  47. package/src/events/internal.ts +20 -3
  48. package/src/events/mutes.ts +5 -3
  49. package/src/events/participant.ts +48 -15
  50. package/src/gen/video/sfu/event/events.ts +451 -8
  51. package/src/gen/video/sfu/models/models.ts +211 -204
  52. package/src/helpers/ensureExhausted.ts +5 -0
  53. package/src/helpers/withResolvers.ts +43 -0
  54. package/src/logger.ts +3 -1
  55. package/src/rpc/__tests__/retryable.test.ts +72 -0
  56. package/src/rpc/createClient.ts +21 -0
  57. package/src/rpc/index.ts +1 -0
  58. package/src/rpc/retryable.ts +57 -0
  59. package/src/rtc/Dispatcher.ts +6 -2
  60. package/src/rtc/IceTrickleBuffer.ts +2 -2
  61. package/src/rtc/Publisher.ts +127 -163
  62. package/src/rtc/Subscriber.ts +92 -155
  63. package/src/rtc/__tests__/Publisher.test.ts +18 -95
  64. package/src/rtc/__tests__/Subscriber.test.ts +63 -99
  65. package/src/rtc/__tests__/videoLayers.test.ts +2 -2
  66. package/src/rtc/helpers/rtcConfiguration.ts +11 -0
  67. package/src/rtc/helpers/tracks.ts +27 -7
  68. package/src/rtc/signal.ts +3 -3
  69. package/src/rtc/videoLayers.ts +1 -10
  70. package/src/stats/SfuStatsReporter.ts +1 -0
  71. package/src/store/CallState.ts +109 -2
  72. package/src/store/__tests__/CallState.test.ts +48 -37
  73. package/dist/src/rtc/flows/join.d.ts +0 -20
  74. package/src/rtc/flows/join.ts +0 -65
@@ -86,29 +86,30 @@ export class StreamVideoClient {
86
86
 
87
87
  setLogger(logger, logLevel);
88
88
  this.logger = getLogger(['client']);
89
+ const coordinatorLogger = getLogger(['coordinator']);
89
90
 
90
91
  if (typeof apiKeyOrArgs === 'string') {
91
92
  this.streamClient = new StreamClient(apiKeyOrArgs, {
92
93
  persistUserOnConnectionFailure: true,
93
94
  ...opts,
94
95
  logLevel,
95
- logger: this.logger,
96
+ logger: coordinatorLogger,
96
97
  });
97
98
  } else {
98
99
  this.streamClient = new StreamClient(apiKeyOrArgs.apiKey, {
99
100
  persistUserOnConnectionFailure: true,
100
101
  ...apiKeyOrArgs.options,
101
102
  logLevel,
102
- logger: this.logger,
103
+ logger: coordinatorLogger,
103
104
  });
104
105
 
105
106
  const sdkInfo = getSdkInfo();
106
107
  if (sdkInfo) {
108
+ const sdkName = SdkType[sdkInfo.type].toLowerCase();
109
+ const sdkVersion = `${sdkInfo.major}.${sdkInfo.minor}.${sdkInfo.patch}`;
110
+ const userAgent = this.streamClient.getUserAgent();
107
111
  this.streamClient.setUserAgent(
108
- this.streamClient.getUserAgent() +
109
- `-video-${SdkType[sdkInfo.type].toLowerCase()}-sdk-${
110
- sdkInfo.major
111
- }.${sdkInfo.minor}.${sdkInfo.patch}`,
112
+ `${userAgent}-video-${sdkName}-sdk-${sdkVersion}`,
112
113
  );
113
114
  }
114
115
  }
@@ -187,10 +188,10 @@ export class StreamVideoClient {
187
188
  * @param user the user to connect.
188
189
  * @param token a token or a function that returns a token.
189
190
  */
190
- async connectUser(
191
+ connectUser = async (
191
192
  user: User,
192
193
  token?: TokenOrProvider,
193
- ): Promise<void | ConnectedEvent> {
194
+ ): Promise<void | ConnectedEvent> => {
194
195
  if (user.type === 'anonymous') {
195
196
  user.id = '!anon';
196
197
  return this.connectAnonymousUser(user as UserWithId, token);
@@ -299,7 +300,7 @@ export class StreamVideoClient {
299
300
  );
300
301
 
301
302
  return connectUserResponse;
302
- }
303
+ };
303
304
 
304
305
  /**
305
306
  * Disconnects the currently connected user from the client.
@@ -408,7 +409,7 @@ export class StreamVideoClient {
408
409
  clientStore: this.writeableStateStore,
409
410
  });
410
411
  call.state.updateFromCallResponse(c.call);
411
- await call.applyDeviceConfig();
412
+ await call.applyDeviceConfig(false);
412
413
  if (data.watch) {
413
414
  this.writeableStateStore.registerCall(call);
414
415
  }
@@ -473,12 +474,12 @@ export class StreamVideoClient {
473
474
  * @param {string} push_provider_name user provided push provider name
474
475
  * @param {string} [userID] the user id (defaults to current user)
475
476
  */
476
- async addVoipDevice(
477
+ addVoipDevice = async (
477
478
  id: string,
478
479
  push_provider: string,
479
480
  push_provider_name: string,
480
481
  userID?: string,
481
- ) {
482
+ ) => {
482
483
  return await this.addDevice(
483
484
  id,
484
485
  push_provider,
@@ -486,7 +487,7 @@ export class StreamVideoClient {
486
487
  userID,
487
488
  true,
488
489
  );
489
- }
490
+ };
490
491
 
491
492
  /**
492
493
  * getDevices - Returns the devices associated with a current user
@@ -520,9 +521,7 @@ export class StreamVideoClient {
520
521
  onRingingCall = async (call_cid: string) => {
521
522
  // if we find the call and is already ringing, we don't need to create a new call
522
523
  // as client would have received the call.ring state because the app had WS alive when receiving push notifications
523
- let call = this.readOnlyStateStore.calls.find(
524
- (c) => c.cid === call_cid && c.ringing,
525
- );
524
+ let call = this.state.calls.find((c) => c.cid === call_cid && c.ringing);
526
525
  if (!call) {
527
526
  // if not it means that WS is not alive when receiving the push notifications and we need to fetch the call
528
527
  const [callType, callId] = call_cid.split(':');
@@ -12,10 +12,12 @@ import { TokenManager } from './token_manager';
12
12
  import { WSConnectionFallback } from './connection_fallback';
13
13
  import { isErrorResponse, isWSFailure } from './errors';
14
14
  import {
15
+ addConnectionEventListeners,
15
16
  isFunction,
16
17
  isOnline,
17
18
  KnownCodes,
18
19
  randomId,
20
+ removeConnectionEventListeners,
19
21
  retryInterval,
20
22
  sleep,
21
23
  } from './utils';
@@ -131,11 +133,19 @@ export class StreamClient {
131
133
  this.options.baseURL || 'https://video.stream-io-api.com/video',
132
134
  );
133
135
 
134
- if (typeof process !== 'undefined' && process.env.STREAM_LOCAL_TEST_RUN) {
136
+ if (
137
+ typeof process !== 'undefined' &&
138
+ 'env' in process &&
139
+ process.env.STREAM_LOCAL_TEST_RUN
140
+ ) {
135
141
  this.setBaseURL('http://localhost:3030/video');
136
142
  }
137
143
 
138
- if (typeof process !== 'undefined' && process.env.STREAM_LOCAL_TEST_HOST) {
144
+ if (
145
+ typeof process !== 'undefined' &&
146
+ 'env' in process &&
147
+ process.env.STREAM_LOCAL_TEST_HOST
148
+ ) {
139
149
  this.setBaseURL(`http://${process.env.STREAM_LOCAL_TEST_HOST}/video`);
140
150
  }
141
151
 
@@ -265,6 +275,7 @@ export class StreamClient {
265
275
  );
266
276
 
267
277
  try {
278
+ addConnectionEventListeners(this.updateNetworkConnectionStatus);
268
279
  return await this.setUserPromise;
269
280
  } catch (err) {
270
281
  if (this.persistUserOnConnectionFailure) {
@@ -398,6 +409,7 @@ export class StreamClient {
398
409
  this.anonymous = false;
399
410
 
400
411
  await this.closeConnection(timeout);
412
+ removeConnectionEventListeners(this.updateNetworkConnectionStatus);
401
413
 
402
414
  this.tokenManager.reset();
403
415
 
@@ -436,6 +448,7 @@ export class StreamClient {
436
448
  user: UserWithId,
437
449
  tokenOrProvider: TokenOrProvider,
438
450
  ) => {
451
+ addConnectionEventListeners(this.updateNetworkConnectionStatus);
439
452
  this.connectionIdPromise = new Promise<string | undefined>(
440
453
  (resolve, reject) => {
441
454
  this.resolveConnectionId = resolve;
@@ -661,7 +674,6 @@ export class StreamClient {
661
674
  };
662
675
 
663
676
  dispatchEvent = (event: StreamVideoEvent) => {
664
- if (!event.received_at) event.received_at = new Date();
665
677
  this.logger('debug', `Dispatching event: ${event.type}`, event);
666
678
  if (!this.listeners) return;
667
679
 
@@ -855,10 +867,15 @@ export class StreamClient {
855
867
  });
856
868
  };
857
869
 
858
- /**
859
- * creates an abort controller that will be used by the next HTTP Request.
860
- */
861
- createAbortControllerForNextRequest = () => {
862
- return (this.nextRequestAbortController = new AbortController());
870
+ updateNetworkConnectionStatus = (
871
+ event: { type: 'online' | 'offline' } | Event,
872
+ ) => {
873
+ if (event.type === 'offline') {
874
+ this.logger('debug', 'device went offline');
875
+ this.dispatchEvent({ type: 'network.changed', online: false });
876
+ } else if (event.type === 'online') {
877
+ this.logger('debug', 'device went online');
878
+ this.dispatchEvent({ type: 'network.changed', online: true });
879
+ }
863
880
  };
864
881
  }
@@ -578,6 +578,7 @@ export class StableWSConnection {
578
578
  }
579
579
 
580
580
  if (data) {
581
+ data.received_at = new Date();
581
582
  this.client.dispatchEvent(data);
582
583
  }
583
584
  this.scheduleConnectionCheck();
@@ -52,6 +52,11 @@ export type ConnectionChangedEvent = {
52
52
  online: boolean;
53
53
  };
54
54
 
55
+ export type NetworkChangedEvent = {
56
+ type: 'network.changed';
57
+ online: boolean;
58
+ };
59
+
55
60
  export type TransportChangedEvent = {
56
61
  type: 'transport.changed';
57
62
  mode: 'longpoll';
@@ -63,6 +68,7 @@ export type ConnectionRecoveredEvent = {
63
68
 
64
69
  export type StreamVideoEvent = (
65
70
  | WSEvent
71
+ | NetworkChangedEvent
66
72
  | ConnectionChangedEvent
67
73
  | TransportChangedEvent
68
74
  | ConnectionRecoveredEvent
@@ -61,7 +61,7 @@ export class CameraManager extends InputMediaDeviceManager<CameraManagerState> {
61
61
  this.logger('warn', 'could not apply target resolution', error);
62
62
  }
63
63
  }
64
- if (this.state.status === 'enabled') {
64
+ if (this.enabled) {
65
65
  const { width, height } = this.state
66
66
  .mediaStream!.getVideoTracks()[0]
67
67
  ?.getSettings();
@@ -63,6 +63,13 @@ export abstract class InputMediaDeviceManager<
63
63
  return this.getDevices();
64
64
  }
65
65
 
66
+ /**
67
+ * Returns `true` when this device is in enabled state.
68
+ */
69
+ get enabled() {
70
+ return this.state.status === 'enabled';
71
+ }
72
+
66
73
  /**
67
74
  * Starts stream.
68
75
  */
@@ -216,7 +223,7 @@ export abstract class InputMediaDeviceManager<
216
223
  };
217
224
 
218
225
  protected async applySettingsToStream() {
219
- if (this.state.status === 'enabled') {
226
+ if (this.enabled) {
220
227
  await this.muteStream();
221
228
  await this.unmuteStream();
222
229
  }
@@ -374,7 +381,7 @@ export abstract class InputMediaDeviceManager<
374
381
  .then(chainWith(parent), (error) => {
375
382
  this.logger(
376
383
  'warn',
377
- 'Fitler failed to start and will be ignored',
384
+ 'Filter failed to start and will be ignored',
378
385
  error,
379
386
  );
380
387
  return parent;
@@ -384,13 +391,15 @@ export abstract class InputMediaDeviceManager<
384
391
  }
385
392
  if (this.call.state.callingState === CallingState.JOINED) {
386
393
  await this.publishStream(stream);
394
+ } else {
395
+ this.logger('debug', 'Stream is not published as the call is not joined');
387
396
  }
388
397
  if (this.state.mediaStream !== stream) {
389
398
  this.state.setMediaStream(stream, await rootStream);
390
399
  this.getTracks().forEach((track) => {
391
400
  track.addEventListener('ended', async () => {
392
401
  await this.statusChangeSettled();
393
- if (this.state.status === 'enabled') {
402
+ if (this.enabled) {
394
403
  this.isTrackStoppedDueToTrackEnd = true;
395
404
  setTimeout(() => {
396
405
  this.isTrackStoppedDueToTrackEnd = false;
@@ -25,7 +25,7 @@ export class MicrophoneManager extends InputMediaDeviceManager<MicrophoneManager
25
25
  private noiseCancellation: INoiseCancellation | undefined;
26
26
  private noiseCancellationChangeUnsubscribe: (() => void) | undefined;
27
27
  private noiseCancellationRegistration?: Promise<void>;
28
- private uregisterNoiseCancellation?: () => Promise<void>;
28
+ private unregisterNoiseCancellation?: () => Promise<void>;
29
29
 
30
30
  constructor(
31
31
  call: Call,
@@ -144,7 +144,7 @@ export class MicrophoneManager extends InputMediaDeviceManager<MicrophoneManager
144
144
  noiseCancellation.toFilter(),
145
145
  );
146
146
  this.noiseCancellationRegistration = registrationResult.registered;
147
- this.uregisterNoiseCancellation = registrationResult.unregister;
147
+ this.unregisterNoiseCancellation = registrationResult.unregister;
148
148
  await this.noiseCancellationRegistration;
149
149
 
150
150
  // handles an edge case where a noise cancellation is enabled after
@@ -173,7 +173,7 @@ export class MicrophoneManager extends InputMediaDeviceManager<MicrophoneManager
173
173
  if (isReactNative()) {
174
174
  throw new Error('Noise cancellation is not supported in React Native');
175
175
  }
176
- await (this.uregisterNoiseCancellation?.() ?? Promise.resolve())
176
+ await (this.unregisterNoiseCancellation?.() ?? Promise.resolve())
177
177
  .then(() => this.noiseCancellation?.disable())
178
178
  .then(() => this.noiseCancellationChangeUnsubscribe?.())
179
179
  .catch((err) => {
@@ -16,7 +16,7 @@ import { lazy } from '../helpers/lazy';
16
16
  * Returns an Observable that emits the list of available devices
17
17
  * that meet the given constraints.
18
18
  *
19
- * @param constraints the constraints to use when requesting the devices.
19
+ * @param permission a BrowserPermission instance.
20
20
  * @param kind the kind of devices to enumerate.
21
21
  */
22
22
  const getDevices = (permission: BrowserPermission, kind: MediaDeviceKind) => {
@@ -28,9 +28,9 @@ describe('mutes', () => {
28
28
  // @ts-expect-error partial data
29
29
  call.publisher.isPublishing = vi.fn().mockReturnValue(true);
30
30
 
31
- vi.spyOn(call, 'stopPublish').mockResolvedValue(undefined);
32
31
  vi.spyOn(call.camera, 'disable').mockResolvedValue(undefined);
33
32
  vi.spyOn(call.microphone, 'disable').mockResolvedValue(undefined);
33
+ vi.spyOn(call.screenShare, 'disable').mockResolvedValue(undefined);
34
34
 
35
35
  // @ts-ignore
36
36
  call.on = (event: string, h) => {
@@ -56,41 +56,38 @@ describe('mutes', () => {
56
56
  });
57
57
  });
58
58
 
59
- it('should automatically mute only when cause is moderation', async () => {
60
- await handler!({
59
+ it('should automatically mute only when cause is moderation', () => {
60
+ handler!({
61
61
  cause: TrackUnpublishReason.PERMISSION_REVOKED,
62
62
  type: TrackType.VIDEO,
63
63
  sessionId: 'session-id',
64
64
  userId: 'user-id',
65
65
  });
66
66
  expect(call.camera.disable).not.toHaveBeenCalled();
67
- expect(call.stopPublish).not.toHaveBeenCalledWith(TrackType.VIDEO);
68
67
  });
69
68
 
70
- it('should handle remote soft video mute', async () => {
71
- await handler!({
69
+ it('should handle remote soft video mute', () => {
70
+ handler!({
72
71
  cause: TrackUnpublishReason.MODERATION,
73
72
  type: TrackType.VIDEO,
74
73
  sessionId: 'session-id',
75
74
  userId: 'user-id',
76
75
  });
77
76
  expect(call.camera.disable).toHaveBeenCalled();
78
- expect(call.stopPublish).toHaveBeenCalledWith(TrackType.VIDEO);
79
77
  });
80
78
 
81
- it('should handle remote soft audio mute', async () => {
82
- await handler!({
79
+ it('should handle remote soft audio mute', () => {
80
+ handler!({
83
81
  cause: TrackUnpublishReason.MODERATION,
84
82
  type: TrackType.AUDIO,
85
83
  sessionId: 'session-id',
86
84
  userId: 'user-id',
87
85
  });
88
86
  expect(call.microphone.disable).toHaveBeenCalled();
89
- expect(call.stopPublish).toHaveBeenCalledWith(TrackType.AUDIO);
90
87
  });
91
88
 
92
- it('should handle remote soft screenshare mute', async () => {
93
- await handler!({
89
+ it('should handle remote soft screenshare mute', () => {
90
+ handler!({
94
91
  cause: TrackUnpublishReason.MODERATION,
95
92
  type: TrackType.SCREEN_SHARE,
96
93
  sessionId: 'session-id',
@@ -98,7 +95,7 @@ describe('mutes', () => {
98
95
  });
99
96
  expect(call.camera.disable).not.toHaveBeenCalled();
100
97
  expect(call.microphone.disable).not.toHaveBeenCalled();
101
- expect(call.stopPublish).toHaveBeenCalledWith(TrackType.SCREEN_SHARE);
98
+ expect(call.screenShare.disable).toHaveBeenCalled();
102
99
  });
103
100
  });
104
101
  });
@@ -1,3 +1,4 @@
1
+ import '../../rtc/__tests__/mocks/webrtc.mocks';
1
2
  import { describe, expect, it } from 'vitest';
2
3
  import { CallState } from '../../store';
3
4
  import { VisibilityState } from '../../types';
@@ -75,6 +76,80 @@ describe('Participant events', () => {
75
76
  });
76
77
  });
77
78
 
79
+ describe('orphaned tracks reconciliation', () => {
80
+ it('participantJoined should reconcile orphaned tracks if any', () => {
81
+ const state = new CallState();
82
+ const mediaStream = new MediaStream();
83
+ state.registerOrphanedTrack({
84
+ trackLookupPrefix: 'track-lookup-prefix',
85
+ trackType: TrackType.VIDEO,
86
+ track: mediaStream,
87
+ });
88
+ const onParticipantJoined = watchParticipantJoined(state);
89
+ onParticipantJoined({
90
+ // @ts-expect-error incomplete data
91
+ participant: {
92
+ userId: 'user-id',
93
+ sessionId: 'session-id',
94
+ trackLookupPrefix: 'track-lookup-prefix',
95
+ },
96
+ });
97
+
98
+ const p = state.findParticipantBySessionId('session-id');
99
+ expect(p).toBeDefined();
100
+ expect(p?.videoStream).toBe(mediaStream);
101
+ expect(state.takeOrphanedTracks('track-lookup-prefix')).toHaveLength(0);
102
+ });
103
+
104
+ it('trackPublished should reconcile orphaned tracks if any', () => {
105
+ const state = new CallState();
106
+ const mediaStream = new MediaStream();
107
+ state.registerOrphanedTrack({
108
+ trackLookupPrefix: 'track-lookup-prefix',
109
+ trackType: TrackType.AUDIO,
110
+ track: mediaStream,
111
+ });
112
+ const onTrackPublished = watchTrackPublished(state);
113
+ onTrackPublished({
114
+ // @ts-expect-error incomplete data
115
+ participant: {
116
+ userId: 'user-id',
117
+ sessionId: 'session-id',
118
+ trackLookupPrefix: 'track-lookup-prefix',
119
+ },
120
+ });
121
+
122
+ const p = state.findParticipantBySessionId('session-id');
123
+ expect(p).toBeDefined();
124
+ expect(p?.audioStream).toBe(mediaStream);
125
+ expect(state.takeOrphanedTracks('track-lookup-prefix')).toHaveLength(0);
126
+ });
127
+
128
+ it('trackUnpublished should reconcile orphaned tracks if any', () => {
129
+ const state = new CallState();
130
+ const mediaStream = new MediaStream();
131
+ state.registerOrphanedTrack({
132
+ trackLookupPrefix: 'track-lookup-prefix',
133
+ trackType: TrackType.SCREEN_SHARE,
134
+ track: mediaStream,
135
+ });
136
+ const onTrackUnPublished = watchTrackUnpublished(state);
137
+ onTrackUnPublished({
138
+ // @ts-expect-error incomplete data
139
+ participant: {
140
+ userId: 'user-id',
141
+ sessionId: 'session-id',
142
+ trackLookupPrefix: 'track-lookup-prefix',
143
+ },
144
+ });
145
+
146
+ const p = state.findParticipantBySessionId('session-id');
147
+ expect(p).toBeDefined();
148
+ expect(p?.screenShareStream).toBe(mediaStream);
149
+ expect(state.takeOrphanedTracks('track-lookup-prefix')).toHaveLength(0);
150
+ });
151
+ });
152
+
78
153
  describe('trackPublished', () => {
79
154
  it('updates the participant track list', () => {
80
155
  const state = new CallState();
@@ -1,6 +1,5 @@
1
1
  import { Call } from '../Call';
2
2
  import { Dispatcher } from '../rtc';
3
- import { CallState } from '../store';
4
3
  import {
5
4
  handleRemoteSoftMute,
6
5
  watchAudioLevelChanged,
@@ -17,6 +16,7 @@ import {
17
16
  watchParticipantLeft,
18
17
  watchParticipantUpdated,
19
18
  watchPinsUpdated,
19
+ watchSfuCallEnded,
20
20
  watchSfuErrorReports,
21
21
  watchTrackPublished,
22
22
  watchTrackUnpublished,
@@ -36,16 +36,13 @@ type RingCallEvents = Extract<
36
36
  * Registers the default event handlers for a call during its lifecycle.
37
37
  *
38
38
  * @param call the call to register event handlers for.
39
- * @param state the call state.
40
39
  * @param dispatcher the dispatcher.
41
40
  */
42
- export const registerEventHandlers = (
43
- call: Call,
44
- state: CallState,
45
- dispatcher: Dispatcher,
46
- ) => {
41
+ export const registerEventHandlers = (call: Call, dispatcher: Dispatcher) => {
42
+ const state = call.state;
47
43
  const eventHandlers = [
48
44
  call.on('call.ended', watchCallEnded(call)),
45
+ watchSfuCallEnded(call),
49
46
 
50
47
  watchLiveEnded(dispatcher, call),
51
48
  watchSfuErrorReports(dispatcher),
@@ -3,8 +3,12 @@ import { Call } from '../Call';
3
3
  import { CallState } from '../store';
4
4
  import { StreamVideoParticipantPatches } from '../types';
5
5
  import { getLogger } from '../logger';
6
- import type { PinsChanged } from '../gen/video/sfu/event/events';
7
- import { ErrorCode } from '../gen/video/sfu/models/models';
6
+ import type { CallEnded, PinsChanged } from '../gen/video/sfu/event/events';
7
+ import {
8
+ CallEndedReason,
9
+ ErrorCode,
10
+ WebsocketReconnectStrategy,
11
+ } from '../gen/video/sfu/models/models';
8
12
  import { OwnCapability } from '../gen/coordinator';
9
13
 
10
14
  const logger = getLogger(['events']);
@@ -82,9 +86,10 @@ export const watchLiveEnded = (dispatcher: Dispatcher, call: Call) => {
82
86
  export const watchSfuErrorReports = (dispatcher: Dispatcher) => {
83
87
  return dispatcher.on('error', (e) => {
84
88
  if (!e.error) return;
85
- const { error } = e;
89
+ const { error, reconnectStrategy } = e;
86
90
  logger('error', 'SFU reported error', {
87
91
  code: ErrorCode[error.code],
92
+ reconnectStrategy: WebsocketReconnectStrategy[reconnectStrategy],
88
93
  message: error.message,
89
94
  shouldRetry: error.shouldRetry,
90
95
  });
@@ -101,3 +106,15 @@ export const watchPinsUpdated = (state: CallState) => {
101
106
  state.setServerSidePins(pins);
102
107
  };
103
108
  };
109
+
110
+ /**
111
+ * Watches for `callEnded` events.
112
+ */
113
+ export const watchSfuCallEnded = (call: Call) => {
114
+ return call.on('callEnded', (e: CallEnded) => {
115
+ const reason = CallEndedReason[e.reason];
116
+ call.leave({ reason }).catch((err) => {
117
+ logger('error', 'Failed to leave call after call ended by the SFU', err);
118
+ });
119
+ });
120
+ };
@@ -27,6 +27,11 @@ export const handleRemoteSoftMute = (call: Call) => {
27
27
  await call.camera.disable();
28
28
  } else if (type === TrackType.AUDIO) {
29
29
  await call.microphone.disable();
30
+ } else if (
31
+ type === TrackType.SCREEN_SHARE ||
32
+ type === TrackType.SCREEN_SHARE_AUDIO
33
+ ) {
34
+ await call.screenShare.disable();
30
35
  } else {
31
36
  logger(
32
37
  'warn',
@@ -34,9 +39,6 @@ export const handleRemoteSoftMute = (call: Call) => {
34
39
  TrackType[type],
35
40
  );
36
41
  }
37
- if (call.publisher?.isPublishing(type)) {
38
- await call.stopPublish(type);
39
- }
40
42
  } catch (error) {
41
43
  logger('error', 'Failed to stop publishing', error);
42
44
  }
@@ -5,8 +5,14 @@ import type {
5
5
  TrackPublished,
6
6
  TrackUnpublished,
7
7
  } from '../gen/video/sfu/event/events';
8
- import { StreamVideoParticipant, VisibilityState } from '../types';
8
+ import type { Participant } from '../gen/video/sfu/models/models';
9
+ import {
10
+ StreamVideoParticipant,
11
+ StreamVideoParticipantPatch,
12
+ VisibilityState,
13
+ } from '../types';
9
14
  import { CallState } from '../store';
15
+ import { trackTypeToParticipantStreamKey } from '../rtc/helpers/tracks';
10
16
 
11
17
  /**
12
18
  * An event responder which handles the `participantJoined` event.
@@ -19,21 +25,23 @@ export const watchParticipantJoined = (state: CallState) => {
19
25
  // potential duplicate events from the SFU.
20
26
  //
21
27
  // Although the SFU should not send duplicate events, we have seen
22
- // some race conditions in the past during the `join-flow` where
23
- // the SFU would send participant info as part of the `join`
28
+ // some race conditions in the past during the `join-flow`.
29
+ // The SFU would send participant info as part of the `join`
24
30
  // response and then follow up with a `participantJoined` event for
25
31
  // already announced participants.
32
+ const orphanedTracks = reconcileOrphanedTracks(state, participant);
26
33
  state.updateOrAddParticipant(
27
34
  participant.sessionId,
28
- Object.assign<StreamVideoParticipant, Partial<StreamVideoParticipant>>(
29
- participant,
30
- {
31
- viewportVisibilityState: {
32
- videoTrack: VisibilityState.UNKNOWN,
33
- screenShareTrack: VisibilityState.UNKNOWN,
34
- },
35
+ Object.assign<
36
+ StreamVideoParticipant,
37
+ StreamVideoParticipantPatch | undefined,
38
+ Partial<StreamVideoParticipant>
39
+ >(participant, orphanedTracks, {
40
+ viewportVisibilityState: {
41
+ videoTrack: VisibilityState.UNKNOWN,
42
+ screenShareTrack: VisibilityState.UNKNOWN,
35
43
  },
36
- ),
44
+ }),
37
45
  );
38
46
  };
39
47
  };
@@ -69,12 +77,14 @@ export const watchParticipantUpdated = (state: CallState) => {
69
77
  */
70
78
  export const watchTrackPublished = (state: CallState) => {
71
79
  return function onTrackPublished(e: TrackPublished) {
72
- const { type, sessionId, participant } = e;
80
+ const { type, sessionId } = e;
73
81
  // An optimization for large calls.
74
82
  // After a certain threshold, the SFU would stop emitting `participantJoined`
75
83
  // events, and instead, it would only provide the participant's information
76
84
  // once they start publishing a track.
77
- if (participant) {
85
+ if (e.participant) {
86
+ const orphanedTracks = reconcileOrphanedTracks(state, e.participant);
87
+ const participant = Object.assign(e.participant, orphanedTracks);
78
88
  state.updateOrAddParticipant(sessionId, participant);
79
89
  } else {
80
90
  state.updateParticipant(sessionId, (p) => ({
@@ -90,9 +100,11 @@ export const watchTrackPublished = (state: CallState) => {
90
100
  */
91
101
  export const watchTrackUnpublished = (state: CallState) => {
92
102
  return function onTrackUnpublished(e: TrackUnpublished) {
93
- const { type, sessionId, participant } = e;
103
+ const { type, sessionId } = e;
94
104
  // An optimization for large calls. See `watchTrackPublished`.
95
- if (participant) {
105
+ if (e.participant) {
106
+ const orphanedTracks = reconcileOrphanedTracks(state, e.participant);
107
+ const participant = Object.assign(e.participant, orphanedTracks);
96
108
  state.updateOrAddParticipant(sessionId, participant);
97
109
  } else {
98
110
  state.updateParticipant(sessionId, (p) => ({
@@ -103,3 +115,24 @@ export const watchTrackUnpublished = (state: CallState) => {
103
115
  };
104
116
 
105
117
  const unique = <T>(v: T, i: number, arr: T[]) => arr.indexOf(v) === i;
118
+
119
+ /**
120
+ * Reconciles orphaned tracks (if any) for the given participant.
121
+ *
122
+ * @param state the call state.
123
+ * @param participant the participant.
124
+ */
125
+ const reconcileOrphanedTracks = (
126
+ state: CallState,
127
+ participant: Participant,
128
+ ): StreamVideoParticipantPatch | undefined => {
129
+ const orphanTracks = state.takeOrphanedTracks(participant.trackLookupPrefix);
130
+ if (!orphanTracks.length) return;
131
+ const reconciledTracks: StreamVideoParticipantPatch = {};
132
+ for (const orphan of orphanTracks) {
133
+ const key = trackTypeToParticipantStreamKey(orphan.trackType);
134
+ if (!key) continue;
135
+ reconciledTracks[key] = orphan.track;
136
+ }
137
+ return reconciledTracks;
138
+ };