@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.
- package/CHANGELOG.md +238 -0
- package/dist/index.browser.es.js +1977 -1477
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +1975 -1474
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +1977 -1477
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +93 -9
- package/dist/src/StreamSfuClient.d.ts +72 -56
- package/dist/src/StreamVideoClient.d.ts +2 -2
- package/dist/src/coordinator/connection/client.d.ts +3 -4
- package/dist/src/coordinator/connection/types.d.ts +5 -1
- package/dist/src/devices/InputMediaDeviceManager.d.ts +4 -0
- package/dist/src/devices/MicrophoneManager.d.ts +1 -1
- package/dist/src/events/callEventHandlers.d.ts +1 -3
- package/dist/src/events/internal.d.ts +4 -0
- package/dist/src/gen/video/sfu/event/events.d.ts +106 -4
- package/dist/src/gen/video/sfu/models/models.d.ts +64 -65
- package/dist/src/helpers/ensureExhausted.d.ts +1 -0
- package/dist/src/helpers/withResolvers.d.ts +14 -0
- package/dist/src/logger.d.ts +1 -0
- package/dist/src/rpc/createClient.d.ts +2 -0
- package/dist/src/rpc/index.d.ts +1 -0
- package/dist/src/rpc/retryable.d.ts +23 -0
- package/dist/src/rtc/Dispatcher.d.ts +1 -1
- package/dist/src/rtc/IceTrickleBuffer.d.ts +0 -1
- package/dist/src/rtc/Publisher.d.ts +24 -25
- package/dist/src/rtc/Subscriber.d.ts +12 -11
- package/dist/src/rtc/helpers/rtcConfiguration.d.ts +2 -0
- package/dist/src/rtc/helpers/tracks.d.ts +3 -3
- package/dist/src/rtc/signal.d.ts +1 -1
- package/dist/src/store/CallState.d.ts +46 -2
- package/package.json +5 -5
- package/src/Call.ts +618 -563
- package/src/StreamSfuClient.ts +277 -246
- package/src/StreamVideoClient.ts +15 -16
- package/src/__tests__/Call.test.ts +145 -2
- package/src/__tests__/StreamVideoClient.api.test.ts +168 -0
- package/src/coordinator/connection/client.ts +25 -8
- package/src/coordinator/connection/connection.ts +2 -1
- package/src/coordinator/connection/types.ts +6 -0
- package/src/devices/BrowserPermission.ts +1 -1
- package/src/devices/CameraManager.ts +1 -1
- package/src/devices/InputMediaDeviceManager.ts +12 -3
- package/src/devices/MicrophoneManager.ts +3 -3
- package/src/devices/devices.ts +1 -1
- package/src/events/__tests__/mutes.test.ts +10 -13
- package/src/events/__tests__/participant.test.ts +75 -0
- package/src/events/callEventHandlers.ts +4 -7
- package/src/events/internal.ts +20 -3
- package/src/events/mutes.ts +5 -3
- package/src/events/participant.ts +48 -15
- package/src/gen/video/sfu/event/events.ts +451 -8
- package/src/gen/video/sfu/models/models.ts +211 -204
- package/src/helpers/ensureExhausted.ts +5 -0
- package/src/helpers/withResolvers.ts +43 -0
- package/src/logger.ts +3 -1
- package/src/rpc/__tests__/retryable.test.ts +72 -0
- package/src/rpc/createClient.ts +21 -0
- package/src/rpc/index.ts +1 -0
- package/src/rpc/retryable.ts +57 -0
- package/src/rtc/Dispatcher.ts +6 -2
- package/src/rtc/IceTrickleBuffer.ts +2 -2
- package/src/rtc/Publisher.ts +127 -163
- package/src/rtc/Subscriber.ts +94 -155
- package/src/rtc/__tests__/Publisher.test.ts +18 -95
- package/src/rtc/__tests__/Subscriber.test.ts +63 -99
- package/src/rtc/__tests__/videoLayers.test.ts +2 -2
- package/src/rtc/helpers/rtcConfiguration.ts +11 -0
- package/src/rtc/helpers/tracks.ts +27 -7
- package/src/rtc/signal.ts +3 -3
- package/src/rtc/videoLayers.ts +1 -10
- package/src/stats/SfuStatsReporter.ts +1 -0
- package/src/store/CallState.ts +111 -3
- package/src/store/__tests__/CallState.test.ts +58 -37
- package/dist/src/rtc/flows/join.d.ts +0 -20
- 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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
// @ts-ignore
|
|
207
|
-
|
|
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.
|
|
210
|
-
|
|
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:
|
|
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:
|
|
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
|
-
):
|
|
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
|
-
|
|
21
|
-
throw new Error(`Unknown track type: ${exhaustiveTrackTypeCheck}`);
|
|
25
|
+
ensureExhausted(trackType, 'Unknown track type');
|
|
22
26
|
}
|
|
23
27
|
};
|
|
24
28
|
|
|
25
|
-
export const muteTypeToTrackType = (
|
|
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
|
-
|
|
37
|
-
|
|
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
|
|
11
|
-
const
|
|
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
|
|
package/src/rtc/videoLayers.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/src/store/CallState.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
492
|
-
|
|
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
|
-
|
|
513
|
-
|
|
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
|
});
|