@stream-io/video-client 1.4.8 → 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 +231 -0
  2. package/dist/index.browser.es.js +1976 -1476
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1974 -1473
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1976 -1476
  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 +109 -2
  75. package/src/store/__tests__/CallState.test.ts +48 -37
  76. package/dist/src/rtc/flows/join.d.ts +0 -20
  77. package/src/rtc/flows/join.ts +0 -65
@@ -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
+ };