@stream-io/video-client 1.4.7 → 1.5.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 (77) hide show
  1. package/CHANGELOG.md +238 -0
  2. package/dist/index.browser.es.js +1977 -1477
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1975 -1474
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1977 -1477
  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 +72 -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 +5 -5
  34. package/src/Call.ts +618 -563
  35. package/src/StreamSfuClient.ts +277 -246
  36. package/src/StreamVideoClient.ts +15 -16
  37. package/src/__tests__/Call.test.ts +145 -2
  38. package/src/__tests__/StreamVideoClient.api.test.ts +168 -0
  39. package/src/coordinator/connection/client.ts +25 -8
  40. package/src/coordinator/connection/connection.ts +2 -1
  41. package/src/coordinator/connection/types.ts +6 -0
  42. package/src/devices/BrowserPermission.ts +1 -1
  43. package/src/devices/CameraManager.ts +1 -1
  44. package/src/devices/InputMediaDeviceManager.ts +12 -3
  45. package/src/devices/MicrophoneManager.ts +3 -3
  46. package/src/devices/devices.ts +1 -1
  47. package/src/events/__tests__/mutes.test.ts +10 -13
  48. package/src/events/__tests__/participant.test.ts +75 -0
  49. package/src/events/callEventHandlers.ts +4 -7
  50. package/src/events/internal.ts +20 -3
  51. package/src/events/mutes.ts +5 -3
  52. package/src/events/participant.ts +48 -15
  53. package/src/gen/video/sfu/event/events.ts +451 -8
  54. package/src/gen/video/sfu/models/models.ts +211 -204
  55. package/src/helpers/ensureExhausted.ts +5 -0
  56. package/src/helpers/withResolvers.ts +43 -0
  57. package/src/logger.ts +3 -1
  58. package/src/rpc/__tests__/retryable.test.ts +72 -0
  59. package/src/rpc/createClient.ts +21 -0
  60. package/src/rpc/index.ts +1 -0
  61. package/src/rpc/retryable.ts +57 -0
  62. package/src/rtc/Dispatcher.ts +6 -2
  63. package/src/rtc/IceTrickleBuffer.ts +2 -2
  64. package/src/rtc/Publisher.ts +127 -163
  65. package/src/rtc/Subscriber.ts +94 -155
  66. package/src/rtc/__tests__/Publisher.test.ts +18 -95
  67. package/src/rtc/__tests__/Subscriber.test.ts +63 -99
  68. package/src/rtc/__tests__/videoLayers.test.ts +2 -2
  69. package/src/rtc/helpers/rtcConfiguration.ts +11 -0
  70. package/src/rtc/helpers/tracks.ts +27 -7
  71. package/src/rtc/signal.ts +3 -3
  72. package/src/rtc/videoLayers.ts +1 -10
  73. package/src/stats/SfuStatsReporter.ts +1 -0
  74. package/src/store/CallState.ts +111 -3
  75. package/src/store/__tests__/CallState.test.ts +58 -37
  76. package/dist/src/rtc/flows/join.d.ts +0 -20
  77. package/src/rtc/flows/join.ts +0 -65
@@ -1,12 +1,13 @@
1
1
  import './mocks/webrtc.mocks';
2
2
 
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
- import { Dispatcher } from '../Dispatcher';
4
+ import { DispatchableMessage, Dispatcher } from '../Dispatcher';
5
5
  import { StreamSfuClient } from '../../StreamSfuClient';
6
6
  import { Subscriber } from '../Subscriber';
7
7
  import { CallState } from '../../store';
8
8
  import { SfuEvent } from '../../gen/video/sfu/event/events';
9
- import { PeerType } from '../../gen/video/sfu/models/models';
9
+ import { PeerType, TrackType } from '../../gen/video/sfu/models/models';
10
+ import { IceTrickleBuffer } from '../IceTrickleBuffer';
10
11
 
11
12
  vi.mock('../../StreamSfuClient', () => {
12
13
  console.log('MOCKING StreamSfuClient');
@@ -25,20 +26,27 @@ describe('Subscriber', () => {
25
26
  dispatcher = new Dispatcher();
26
27
  sfuClient = new StreamSfuClient({
27
28
  dispatcher,
28
- sfuServer: {
29
- url: 'https://getstream.io/',
30
- ws_endpoint: 'https://getstream.io/ws',
31
- edge_name: 'sfu-1',
29
+ sessionId: 'sessionId',
30
+ logTag: 'logTag',
31
+ credentials: {
32
+ server: {
33
+ url: 'https://getstream.io/',
34
+ ws_endpoint: 'https://getstream.io/ws',
35
+ edge_name: 'sfu-1',
36
+ },
37
+ token: 'token',
38
+ ice_servers: [],
32
39
  },
33
- token: 'token',
34
40
  });
41
+ // @ts-expect-error readonly field
42
+ sfuClient.iceTrickleBuffer = new IceTrickleBuffer();
35
43
 
36
44
  subscriber = new Subscriber({
37
45
  sfuClient,
38
46
  dispatcher,
39
47
  state,
40
48
  connectionConfig: { iceServers: [] },
41
- iceRestartDelay: 100,
49
+ logTag: 'test',
42
50
  });
43
51
  });
44
52
 
@@ -48,82 +56,6 @@ describe('Subscriber', () => {
48
56
  dispatcher.offAll();
49
57
  });
50
58
 
51
- describe('Subscriber migration', () => {
52
- it('should update the sfuClient and create a new peer connection', async () => {
53
- const newSfuClient = new StreamSfuClient({
54
- dispatcher: new Dispatcher(),
55
- sfuServer: {
56
- url: 'https://getstream.io/',
57
- ws_endpoint: 'https://getstream.io/ws',
58
- edge_name: 'sfu-1',
59
- },
60
- token: 'token',
61
- });
62
- const newConnectionConfig = { iceServers: [] };
63
-
64
- const oldPeerConnection = subscriber['pc'];
65
- vi.spyOn(oldPeerConnection, 'getReceivers').mockReturnValue([]);
66
-
67
- await subscriber.migrateTo(newSfuClient, newConnectionConfig);
68
- const newPeerConnection = subscriber['pc'];
69
-
70
- expect(subscriber['sfuClient']).toBe(newSfuClient);
71
- expect(newPeerConnection).not.toBe(oldPeerConnection);
72
- });
73
-
74
- it('should close the old peer connection once the new one connects', async () => {
75
- let onConnectionStateChange: () => void = () => {};
76
- let onTrack: (e: RTCTrackEvent) => void = () => {};
77
- // @ts-ignore
78
- vi.spyOn(subscriber, 'createPeerConnection').mockImplementation(() => {
79
- const pc = new RTCPeerConnection();
80
- vi.spyOn(pc, 'addEventListener').mockImplementation((event, cb) => {
81
- if (event === 'connectionstatechange') {
82
- // @ts-ignore
83
- onConnectionStateChange = cb;
84
- } else if (event === 'track') {
85
- // @ts-ignore
86
- onTrack = cb;
87
- }
88
- });
89
- return pc;
90
- });
91
-
92
- const oldPeerConnection = subscriber['pc'];
93
- vi.spyOn(oldPeerConnection, 'getReceivers').mockReturnValue([]);
94
- vi.spyOn(oldPeerConnection, 'close');
95
-
96
- await subscriber.migrateTo(sfuClient, { iceServers: [] });
97
-
98
- const newPeerConnection = subscriber['pc'];
99
- vi.spyOn(newPeerConnection, 'removeEventListener');
100
- // @ts-ignore
101
- newPeerConnection.connectionState = 'connected';
102
-
103
- expect(onConnectionStateChange).toBeDefined();
104
- expect(oldPeerConnection.close).not.toHaveBeenCalled();
105
- onConnectionStateChange();
106
-
107
- // @ts-ignore
108
- onTrack(
109
- // @ts-ignore
110
- new RTCTrackEvent('video', {
111
- track: new MediaStreamTrack(),
112
- }),
113
- );
114
-
115
- expect(newPeerConnection.removeEventListener).toHaveBeenCalledWith(
116
- 'connectionstatechange',
117
- onConnectionStateChange,
118
- );
119
- expect(newPeerConnection.removeEventListener).toHaveBeenCalledWith(
120
- 'track',
121
- onTrack,
122
- );
123
- expect(oldPeerConnection.close).toHaveBeenCalled();
124
- });
125
- });
126
-
127
59
  describe('Subscriber ICE restart', () => {
128
60
  it('should perform ICE restart when iceRestart event is received', () => {
129
61
  sfuClient.iceRestart = vi.fn();
@@ -135,7 +67,7 @@ describe('Subscriber', () => {
135
67
  peerType: PeerType.SUBSCRIBER,
136
68
  },
137
69
  },
138
- }),
70
+ }) as DispatchableMessage<'iceRestart'>,
139
71
  );
140
72
 
141
73
  expect(sfuClient.iceRestart).toHaveBeenCalledWith({
@@ -153,7 +85,7 @@ describe('Subscriber', () => {
153
85
  peerType: PeerType.PUBLISHER_UNSPECIFIED,
154
86
  },
155
87
  },
156
- }),
88
+ }) as DispatchableMessage<'iceRestart'>,
157
89
  );
158
90
 
159
91
  expect(sfuClient.iceRestart).not.toHaveBeenCalled();
@@ -187,27 +119,59 @@ describe('Subscriber', () => {
187
119
 
188
120
  it(`should perform ICE restart when connection state changes to 'disconnected'`, () => {
189
121
  vi.spyOn(subscriber, 'restartIce').mockResolvedValue();
190
- vi.useFakeTimers();
191
-
192
122
  // @ts-ignore
193
123
  subscriber['pc'].iceConnectionState = 'disconnected';
194
124
  subscriber['onIceConnectionStateChange']();
195
- vi.runAllTimers();
196
125
  expect(subscriber.restartIce).toHaveBeenCalled();
197
126
  });
127
+ });
198
128
 
199
- it(`should bail-out from ICE restart once connection recovers before timeout`, () => {
200
- vi.spyOn(subscriber, 'restartIce').mockResolvedValue();
201
- vi.useFakeTimers();
129
+ describe('OnTrack', () => {
130
+ it('should add unknown tracks to the to the call state', () => {
131
+ const mediaStream = new MediaStream();
132
+ const mediaStreamTrack = new MediaStreamTrack();
133
+ // @ts-ignore - mock
134
+ mediaStream.id = '123:TRACK_TYPE_VIDEO';
135
+
136
+ const registerOrphanedTrackSpy = vi.spyOn(state, 'registerOrphanedTrack');
137
+ const updateParticipantSpy = vi.spyOn(state, 'updateParticipant');
138
+
139
+ const onTrack = subscriber['handleOnTrack'];
140
+ // @ts-expect-error - incomplete mock
141
+ onTrack({ streams: [mediaStream], track: mediaStreamTrack });
142
+
143
+ expect(registerOrphanedTrackSpy).toHaveBeenCalledWith({
144
+ id: mediaStream.id,
145
+ trackLookupPrefix: '123',
146
+ track: mediaStream,
147
+ trackType: TrackType.VIDEO,
148
+ });
149
+ expect(updateParticipantSpy).not.toHaveBeenCalled();
150
+ });
202
151
 
203
- // @ts-ignore
204
- subscriber['pc'].iceConnectionState = 'disconnected';
205
- subscriber['onIceConnectionStateChange']();
206
- // @ts-ignore
207
- subscriber['pc'].iceConnectionState = 'connected';
152
+ it('should assign known tracks to the participant', () => {
153
+ const mediaStream = new MediaStream();
154
+ const mediaStreamTrack = new MediaStreamTrack();
155
+ // @ts-ignore - mock
156
+ mediaStream.id = '123:TRACK_TYPE_VIDEO';
208
157
 
209
- vi.runAllTimers();
210
- expect(subscriber.restartIce).not.toHaveBeenCalled();
158
+ const registerOrphanedTrackSpy = vi.spyOn(state, 'registerOrphanedTrack');
159
+ const updateParticipantSpy = vi.spyOn(state, 'updateParticipant');
160
+
161
+ // @ts-expect-error - incomplete mock
162
+ state.updateOrAddParticipant('session-id', {
163
+ sessionId: 'session-id',
164
+ trackLookupPrefix: '123',
165
+ });
166
+
167
+ const onTrack = subscriber['handleOnTrack'];
168
+ // @ts-expect-error - incomplete mock
169
+ onTrack({ streams: [mediaStream], track: mediaStreamTrack });
170
+
171
+ expect(registerOrphanedTrackSpy).not.toHaveBeenCalled();
172
+ expect(updateParticipantSpy).toHaveBeenCalledWith('session-id', {
173
+ videoStream: mediaStream,
174
+ });
211
175
  });
212
176
  });
213
177
  });
@@ -59,7 +59,7 @@ describe('videoLayers', () => {
59
59
  height: height / 4,
60
60
  maxBitrate: targetBitrate / 4,
61
61
  scaleResolutionDownBy: 4,
62
- maxFramerate: 20,
62
+ maxFramerate: 30,
63
63
  },
64
64
  {
65
65
  active: true,
@@ -68,7 +68,7 @@ describe('videoLayers', () => {
68
68
  height: height / 2,
69
69
  maxBitrate: targetBitrate / 2,
70
70
  scaleResolutionDownBy: 2,
71
- maxFramerate: 25,
71
+ maxFramerate: 30,
72
72
  },
73
73
  {
74
74
  active: true,
@@ -0,0 +1,11 @@
1
+ import { ICEServer } from '../../gen/coordinator';
2
+
3
+ export const toRtcConfiguration = (config: ICEServer[]): RTCConfiguration => {
4
+ return {
5
+ iceServers: config.map((ice) => ({
6
+ urls: ice.urls,
7
+ username: ice.username,
8
+ credential: ice.password,
9
+ })),
10
+ };
11
+ };
@@ -1,10 +1,15 @@
1
1
  import { TrackType } from '../../gen/video/sfu/models/models';
2
- import type { StreamVideoParticipant } from '../../types';
3
2
  import { TrackMuteType } from '../../types';
3
+ import { ensureExhausted } from '../../helpers/ensureExhausted';
4
4
 
5
5
  export const trackTypeToParticipantStreamKey = (
6
6
  trackType: TrackType,
7
- ): keyof StreamVideoParticipant => {
7
+ ):
8
+ | 'audioStream'
9
+ | 'videoStream'
10
+ | 'screenShareStream'
11
+ | 'screenShareAudioStream'
12
+ | undefined => {
8
13
  switch (trackType) {
9
14
  case TrackType.SCREEN_SHARE:
10
15
  return 'screenShareStream';
@@ -17,12 +22,13 @@ export const trackTypeToParticipantStreamKey = (
17
22
  case TrackType.UNSPECIFIED:
18
23
  throw new Error('Track type is unspecified');
19
24
  default:
20
- const exhaustiveTrackTypeCheck: never = trackType;
21
- throw new Error(`Unknown track type: ${exhaustiveTrackTypeCheck}`);
25
+ ensureExhausted(trackType, 'Unknown track type');
22
26
  }
23
27
  };
24
28
 
25
- export const muteTypeToTrackType = (muteType: TrackMuteType): TrackType => {
29
+ export const muteTypeToTrackType = (
30
+ muteType: TrackMuteType,
31
+ ): TrackType | undefined => {
26
32
  switch (muteType) {
27
33
  case 'audio':
28
34
  return TrackType.AUDIO;
@@ -33,7 +39,21 @@ export const muteTypeToTrackType = (muteType: TrackMuteType): TrackType => {
33
39
  case 'screenshare_audio':
34
40
  return TrackType.SCREEN_SHARE_AUDIO;
35
41
  default:
36
- const exhaustiveMuteTypeCheck: never = muteType;
37
- throw new Error(`Unknown mute type: ${exhaustiveMuteTypeCheck}`);
42
+ ensureExhausted(muteType, 'Unknown mute type');
43
+ }
44
+ };
45
+
46
+ export const toTrackType = (trackType: string): TrackType | undefined => {
47
+ switch (trackType) {
48
+ case 'TRACK_TYPE_AUDIO':
49
+ return TrackType.AUDIO;
50
+ case 'TRACK_TYPE_VIDEO':
51
+ return TrackType.VIDEO;
52
+ case 'TRACK_TYPE_SCREEN_SHARE':
53
+ return TrackType.SCREEN_SHARE;
54
+ case 'TRACK_TYPE_SCREEN_SHARE_AUDIO':
55
+ return TrackType.SCREEN_SHARE_AUDIO;
56
+ default:
57
+ return undefined;
38
58
  }
39
59
  };
package/src/rtc/signal.ts CHANGED
@@ -1,4 +1,3 @@
1
- import WebSocket from 'isomorphic-ws';
2
1
  import { SfuEvent } from '../gen/video/sfu/event/events';
3
2
  import { getLogger } from '../logger';
4
3
  import { DispatchableMessage, SfuEventKinds } from './Dispatcher';
@@ -6,9 +5,10 @@ import { DispatchableMessage, SfuEventKinds } from './Dispatcher';
6
5
  export const createWebSocketSignalChannel = (opts: {
7
6
  endpoint: string;
8
7
  onMessage: <K extends SfuEventKinds>(message: DispatchableMessage<K>) => void;
8
+ logTag: string;
9
9
  }) => {
10
- const logger = getLogger(['sfu-client']);
11
- const { endpoint, onMessage } = opts;
10
+ const { endpoint, onMessage, logTag } = opts;
11
+ const logger = getLogger(['sfu-client-ws', logTag]);
12
12
  const ws = new WebSocket(endpoint);
13
13
  ws.binaryType = 'arraybuffer'; // do we need this?
14
14
 
@@ -1,7 +1,5 @@
1
- import { getOSInfo } from '../client-details';
2
1
  import { ScreenShareSettings } from '../types';
3
2
  import { TargetResolutionResponse } from '../gen/shims';
4
- import { isReactNative } from '../helpers/platforms';
5
3
 
6
4
  export type OptimalVideoLayer = RTCRtpEncodingParameters & {
7
5
  width: number;
@@ -36,8 +34,6 @@ export const findOptimalVideoLayers = (
36
34
  const settings = videoTrack.getSettings();
37
35
  const { width: w = 0, height: h = 0 } = settings;
38
36
 
39
- const isRNIos = isReactNative() && getOSInfo()?.name.toLowerCase() === 'ios';
40
-
41
37
  const maxBitrate = getComputedMaxBitrate(targetResolution, w, h);
42
38
  let downscaleFactor = 1;
43
39
  ['f', 'h', 'q'].forEach((rid) => {
@@ -52,12 +48,7 @@ export const findOptimalVideoLayers = (
52
48
  maxBitrate:
53
49
  Math.round(maxBitrate / downscaleFactor) || defaultBitratePerRid[rid],
54
50
  scaleResolutionDownBy: downscaleFactor,
55
- // Simulcast on iOS React-Native requires all encodings to share the same framerate
56
- maxFramerate: {
57
- f: 30,
58
- h: isRNIos ? 30 : 25,
59
- q: isRNIos ? 30 : 20,
60
- }[rid],
51
+ maxFramerate: 30,
61
52
  });
62
53
  downscaleFactor *= 2;
63
54
  });
@@ -65,6 +65,7 @@ export class SfuStatsReporter {
65
65
 
66
66
  start = () => {
67
67
  if (this.options.reporting_interval_ms <= 0) return;
68
+ clearInterval(this.intervalId);
68
69
  this.intervalId = setInterval(() => {
69
70
  this.run().catch((err) => {
70
71
  this.logger('warn', 'Failed to report stats', err);
@@ -11,6 +11,7 @@ import {
11
11
  StreamVideoParticipant,
12
12
  StreamVideoParticipantPatch,
13
13
  StreamVideoParticipantPatches,
14
+ VisibilityState,
14
15
  } from '../types';
15
16
  import { CallStatsReport } from '../stats';
16
17
  import {
@@ -36,7 +37,13 @@ import {
36
37
  UserResponse,
37
38
  WSEvent,
38
39
  } from '../gen/coordinator';
39
- import { Pin } from '../gen/video/sfu/models/models';
40
+ import { Timestamp } from '../gen/google/protobuf/timestamp';
41
+ import { ReconnectDetails } from '../gen/video/sfu/event/events';
42
+ import {
43
+ CallState as SfuCallState,
44
+ Pin,
45
+ TrackType,
46
+ } from '../gen/video/sfu/models/models';
40
47
  import { Comparator, defaultSortPreset } from '../sorting';
41
48
  import { getLogger } from '../logger';
42
49
  import { hasScreenShare } from '../helpers/participantUtils';
@@ -105,6 +112,13 @@ const defaultEgress: EgressResponse = {
105
112
  rtmps: [],
106
113
  };
107
114
 
115
+ type OrphanedTrack = {
116
+ id: string;
117
+ trackLookupPrefix: string;
118
+ trackType: TrackType;
119
+ track: MediaStream;
120
+ };
121
+
108
122
  /**
109
123
  * Holds the state of the current call.
110
124
  * @react You don't have to use this class directly, as we are exposing the state through Hooks.
@@ -155,6 +169,12 @@ export class CallState {
155
169
  CallStatsReport | undefined
156
170
  >(undefined);
157
171
 
172
+ // These are tracks that were delivered to the Subscriber's onTrack event
173
+ // that we couldn't associate with a participant yet.
174
+ // This happens when the participantJoined event hasn't been received yet.
175
+ // We keep these tracks around until we can associate them with a participant.
176
+ private orphanedTracks: OrphanedTrack[] = [];
177
+
158
178
  // Derived state
159
179
 
160
180
  /**
@@ -427,7 +447,6 @@ export class CallState {
427
447
  'call.closed_caption': undefined,
428
448
  'call.deleted': undefined,
429
449
  'call.permission_request': undefined,
430
- 'call.recording_failed': undefined,
431
450
  'call.recording_ready': undefined,
432
451
  'call.transcription_ready': undefined,
433
452
  'call.user_muted': undefined,
@@ -470,6 +489,8 @@ export class CallState {
470
489
  this.setCurrentValue(this.recordingSubject, true),
471
490
  'call.recording_stopped': () =>
472
491
  this.setCurrentValue(this.recordingSubject, false),
492
+ 'call.recording_failed': () =>
493
+ this.setCurrentValue(this.recordingSubject, false),
473
494
  'call.rejected': (e) => this.updateFromCallResponse(e.call),
474
495
  'call.ring': (e) => this.updateFromCallResponse(e.call),
475
496
  'call.missed': (e) => this.updateFromCallResponse(e.call),
@@ -910,7 +931,7 @@ export class CallState {
910
931
  * @returns all participants, with all patch applied.
911
932
  */
912
933
  updateParticipants = (patch: StreamVideoParticipantPatches) => {
913
- if (Object.keys(patch).length === 0) return;
934
+ if (Object.keys(patch).length === 0) return this.participants;
914
935
  return this.setParticipants((participants) =>
915
936
  participants.map((p) => {
916
937
  const thePatch = patch[p.sessionId];
@@ -980,6 +1001,48 @@ export class CallState {
980
1001
  );
981
1002
  };
982
1003
 
1004
+ /**
1005
+ * Adds an orphaned track to the call state.
1006
+ *
1007
+ * @internal
1008
+ *
1009
+ * @param orphanedTrack the orphaned track to add.
1010
+ */
1011
+ registerOrphanedTrack = (orphanedTrack: OrphanedTrack) => {
1012
+ this.orphanedTracks.push(orphanedTrack);
1013
+ };
1014
+
1015
+ /**
1016
+ * Removes an orphaned track from the call state.
1017
+ *
1018
+ * @internal
1019
+ *
1020
+ * @param id the ID of the orphaned track to remove.
1021
+ */
1022
+ removeOrphanedTrack = (id: string) => {
1023
+ this.orphanedTracks = this.orphanedTracks.filter((o) => o.id !== id);
1024
+ };
1025
+
1026
+ /**
1027
+ * Takes all orphaned tracks with the given track lookup prefix.
1028
+ * All orphaned tracks with the given track lookup prefix are removed from the call state.
1029
+ *
1030
+ * @internal
1031
+ *
1032
+ * @param trackLookupPrefix the track lookup prefix to match the orphaned tracks by.
1033
+ */
1034
+ takeOrphanedTracks = (trackLookupPrefix: string): OrphanedTrack[] => {
1035
+ const orphans = this.orphanedTracks.filter(
1036
+ (orphan) => orphan.trackLookupPrefix === trackLookupPrefix,
1037
+ );
1038
+ if (orphans.length > 0) {
1039
+ this.orphanedTracks = this.orphanedTracks.filter(
1040
+ (orphan) => orphan.trackLookupPrefix !== trackLookupPrefix,
1041
+ );
1042
+ }
1043
+ return orphans;
1044
+ };
1045
+
983
1046
  /**
984
1047
  * Updates the call state with the data received from the server.
985
1048
  *
@@ -1011,6 +1074,51 @@ export class CallState {
1011
1074
  this.setCurrentValue(this.thumbnailsSubject, call.thumbnails);
1012
1075
  };
1013
1076
 
1077
+ /**
1078
+ * Updates the call state with the data received from the SFU server.
1079
+ *
1080
+ * @internal
1081
+ *
1082
+ * @param callState the call state from the SFU server.
1083
+ * @param currentSessionId the session ID of the current user.
1084
+ * @param reconnectDetails optional reconnect details.
1085
+ */
1086
+ updateFromSfuCallState = (
1087
+ callState: SfuCallState,
1088
+ currentSessionId: string,
1089
+ reconnectDetails?: ReconnectDetails,
1090
+ ) => {
1091
+ const { participants, participantCount, startedAt, pins } = callState;
1092
+ const localPublishedTracks =
1093
+ reconnectDetails?.announcedTracks.map((t) => t.trackType) ?? [];
1094
+ this.setParticipants(() => {
1095
+ const participantLookup = this.getParticipantLookupBySessionId();
1096
+ return participants.map<StreamVideoParticipant>((p) => {
1097
+ // We need to preserve the local state of the participant
1098
+ // (e.g. videoDimension, visibilityState, pinnedAt, etc.)
1099
+ // as it doesn't exist on the server.
1100
+ const existingParticipant = participantLookup[p.sessionId];
1101
+ const isLocalParticipant = p.sessionId === currentSessionId;
1102
+ return Object.assign({}, existingParticipant, p, {
1103
+ isLocalParticipant,
1104
+ publishedTracks: isLocalParticipant
1105
+ ? localPublishedTracks
1106
+ : p.publishedTracks,
1107
+ viewportVisibilityState:
1108
+ existingParticipant?.viewportVisibilityState ?? {
1109
+ videoTrack: VisibilityState.UNKNOWN,
1110
+ screenShareTrack: VisibilityState.UNKNOWN,
1111
+ },
1112
+ } satisfies Partial<StreamVideoParticipant>);
1113
+ });
1114
+ });
1115
+
1116
+ this.setParticipantCount(participantCount?.total || 0);
1117
+ this.setAnonymousParticipantCount(participantCount?.anonymous || 0);
1118
+ this.setStartedAt(startedAt ? Timestamp.toDate(startedAt) : new Date());
1119
+ this.setServerSidePins(pins);
1120
+ };
1121
+
1014
1122
  private updateFromMemberRemoved = (event: CallMemberRemovedEvent) => {
1015
1123
  this.updateFromCallResponse(event.call);
1016
1124
  this.setCurrentValue(this.membersSubject, (members) =>
@@ -1,8 +1,9 @@
1
+ import '../../rtc/__tests__/mocks/webrtc.mocks';
1
2
  import { describe, expect, it, vi } from 'vitest';
2
3
  import { anyNumber } from 'vitest-mock-extended';
3
4
  import { StreamVideoParticipant, VisibilityState } from '../../types';
4
5
  import { CallingState, CallState } from '../CallState';
5
- import { ConnectionQuality } from '../../gen/video/sfu/models/models';
6
+ import { TrackType } from '../../gen/video/sfu/models/models';
6
7
  import {
7
8
  combineComparators,
8
9
  conditional,
@@ -463,40 +464,17 @@ describe('CallState', () => {
463
464
  describe('Call Permission Events', () => {
464
465
  it('handles call.permissions_updated', () => {
465
466
  const state = new CallState();
466
- state.setParticipants([
467
- {
468
- userId: 'test',
469
- name: 'test',
470
- sessionId: 'test',
471
- isDominantSpeaker: false,
472
- isSpeaking: false,
473
- audioLevel: 0,
474
- image: '',
475
- publishedTracks: [],
476
- connectionQuality: ConnectionQuality.EXCELLENT,
477
- roles: [],
478
- trackLookupPrefix: '',
479
- isLocalParticipant: true,
480
- },
481
- ]);
467
+ // @ts-expect-error incomplete data
468
+ state.setParticipants([{ userId: 'test', isLocalParticipant: true }]);
482
469
 
483
470
  state.updateFromEvent({
484
471
  type: 'call.permissions_updated',
485
- created_at: '',
486
- call_cid: 'development:12345',
487
472
  own_capabilities: [
488
473
  OwnCapability.SEND_AUDIO,
489
474
  OwnCapability.SEND_VIDEO,
490
475
  ],
491
- user: {
492
- id: 'test',
493
- created_at: '',
494
- role: '',
495
- updated_at: '',
496
- custom: {},
497
- teams: [],
498
- language: 'en',
499
- },
476
+ // @ts-expect-error incomplete data
477
+ user: { id: 'test' },
500
478
  });
501
479
 
502
480
  expect(state.ownCapabilities).toEqual([
@@ -509,15 +487,8 @@ describe('CallState', () => {
509
487
  created_at: '',
510
488
  call_cid: 'development:12345',
511
489
  own_capabilities: [OwnCapability.SEND_VIDEO],
512
- user: {
513
- id: 'test',
514
- created_at: '',
515
- role: '',
516
- updated_at: '',
517
- custom: {},
518
- teams: [],
519
- language: 'en',
520
- },
490
+ // @ts-expect-error incomplete data
491
+ user: { id: 'test' },
521
492
  });
522
493
  expect(state.ownCapabilities).toEqual([OwnCapability.SEND_VIDEO]);
523
494
  });
@@ -679,6 +650,16 @@ describe('CallState', () => {
679
650
  expect(state.recording).toBe(false);
680
651
  });
681
652
 
653
+ it('handles call.recording_failed events', () => {
654
+ const state = new CallState();
655
+ // @ts-expect-error incomplete data
656
+ state.updateFromEvent({ type: 'call.recording_started' });
657
+ expect(state.recording).toBe(true);
658
+ // @ts-expect-error incomplete data
659
+ state.updateFromEvent({ type: 'call.recording_failed' });
660
+ expect(state.recording).toBe(false);
661
+ });
662
+
682
663
  it('handles call.hls_broadcasting_started events', () => {
683
664
  const state = new CallState();
684
665
  state.updateFromCallResponse({
@@ -871,4 +852,44 @@ describe('CallState', () => {
871
852
  });
872
853
  });
873
854
  });
855
+
856
+ describe('orphaned tracks', () => {
857
+ it('registers orphaned tracks', () => {
858
+ const state = new CallState();
859
+ state.registerOrphanedTrack({
860
+ id: '123:TRACK_TYPE_VIDEO',
861
+ track: new MediaStream(),
862
+ trackLookupPrefix: '123',
863
+ trackType: TrackType.AUDIO,
864
+ });
865
+ expect(state['orphanedTracks'].length).toBe(1);
866
+ });
867
+
868
+ it('removes orphaned tracks once assigned', () => {
869
+ const state = new CallState();
870
+ state.registerOrphanedTrack({
871
+ id: '123:TRACK_TYPE_VIDEO',
872
+ track: new MediaStream(),
873
+ trackLookupPrefix: '123',
874
+ trackType: TrackType.VIDEO,
875
+ });
876
+ const orphans = state.takeOrphanedTracks('123');
877
+ expect(orphans.length).toBe(1);
878
+ expect(state['orphanedTracks'].length).toBe(0);
879
+ });
880
+
881
+ it('removes orphaned tracks', () => {
882
+ const state = new CallState();
883
+ const id = '123:TRACK_TYPE_VIDEO';
884
+ state.registerOrphanedTrack({
885
+ id,
886
+ track: new MediaStream(),
887
+ trackLookupPrefix: '123',
888
+ trackType: TrackType.VIDEO,
889
+ });
890
+ expect(state['orphanedTracks'].length).toBe(1);
891
+ state.removeOrphanedTrack(id);
892
+ expect(state['orphanedTracks'].length).toBe(0);
893
+ });
894
+ });
874
895
  });