@stream-io/video-client 1.24.0 → 1.25.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 (40) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/index.browser.es.js +335 -127
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +334 -126
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +335 -127
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/StreamSfuClient.d.ts +12 -4
  9. package/dist/src/StreamVideoClient.d.ts +3 -1
  10. package/dist/src/coordinator/connection/errors.d.ts +1 -0
  11. package/dist/src/gen/video/sfu/models/models.d.ts +4 -0
  12. package/dist/src/rtc/BasePeerConnection.d.ts +23 -4
  13. package/dist/src/rtc/NegotiationError.d.ts +15 -0
  14. package/dist/src/rtc/Publisher.d.ts +2 -2
  15. package/dist/src/rtc/helpers/sdp.d.ts +7 -0
  16. package/dist/src/types.d.ts +11 -0
  17. package/package.json +1 -1
  18. package/src/Call.ts +66 -38
  19. package/src/StreamSfuClient.ts +17 -7
  20. package/src/StreamVideoClient.ts +17 -7
  21. package/src/coordinator/connection/connection.ts +2 -1
  22. package/src/coordinator/connection/errors.ts +31 -0
  23. package/src/devices/ScreenShareManager.ts +12 -2
  24. package/src/devices/devices.ts +23 -12
  25. package/src/events/__tests__/internal.test.ts +1 -0
  26. package/src/gen/google/protobuf/struct.ts +2 -2
  27. package/src/gen/google/protobuf/timestamp.ts +1 -1
  28. package/src/gen/video/sfu/event/events.ts +15 -15
  29. package/src/gen/video/sfu/models/models.ts +9 -5
  30. package/src/gen/video/sfu/signal_rpc/signal.client.ts +1 -1
  31. package/src/gen/video/sfu/signal_rpc/signal.ts +6 -6
  32. package/src/rtc/BasePeerConnection.ts +132 -46
  33. package/src/rtc/NegotiationError.ts +21 -0
  34. package/src/rtc/Publisher.ts +12 -9
  35. package/src/rtc/Subscriber.ts +8 -2
  36. package/src/rtc/__tests__/Publisher.test.ts +160 -17
  37. package/src/rtc/__tests__/Subscriber.test.ts +31 -14
  38. package/src/rtc/helpers/__tests__/sdp.stereo.test.ts +120 -0
  39. package/src/rtc/helpers/sdp.ts +43 -1
  40. package/src/types.ts +12 -0
@@ -1,21 +1,26 @@
1
1
  import './mocks/webrtc.mocks';
2
2
 
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { anyString } from 'vitest-mock-extended';
5
+ import { NegotiationError } from '../NegotiationError';
4
6
  import { Publisher } from '../Publisher';
5
7
  import { CallState } from '../../store';
6
8
  import { StreamSfuClient } from '../../StreamSfuClient';
7
9
  import { DispatchableMessage, Dispatcher } from '../Dispatcher';
8
10
  import {
11
+ ErrorCode,
9
12
  PeerType,
10
13
  PublishOption,
11
- SdkType,
12
14
  TrackInfo,
13
15
  TrackType,
16
+ WebsocketReconnectStrategy,
14
17
  } from '../../gen/video/sfu/models/models';
18
+ import { SetPublisherResponse } from '../../gen/video/sfu/signal_rpc/signal';
15
19
  import { SfuEvent } from '../../gen/video/sfu/event/events';
16
20
  import { IceTrickleBuffer } from '../IceTrickleBuffer';
17
21
  import { StreamClient } from '../../coordinator/connection/client';
18
22
  import { TransceiverCache } from '../TransceiverCache';
23
+ import { promiseWithResolvers } from '../../helpers/promise';
19
24
 
20
25
  vi.mock('../../StreamSfuClient', () => {
21
26
  console.log('MOCKING StreamSfuClient');
@@ -47,7 +52,7 @@ describe('Publisher', () => {
47
52
  ice_servers: [],
48
53
  },
49
54
  logTag: 'test',
50
- enableTracing: false,
55
+ enableTracing: true,
51
56
  });
52
57
 
53
58
  // @ts-expect-error readonly field
@@ -63,18 +68,6 @@ describe('Publisher', () => {
63
68
  state,
64
69
  logTag: 'test',
65
70
  enableTracing: false,
66
- clientDetails: {
67
- sdk: {
68
- type: SdkType.PLAIN_JAVASCRIPT,
69
- major: '1',
70
- minor: '0',
71
- patch: '0',
72
- },
73
- device: {
74
- name: 'test-device',
75
- version: '1.0.0',
76
- },
77
- },
78
71
  publishOptions: [
79
72
  {
80
73
  id: 1,
@@ -242,20 +235,169 @@ describe('Publisher', () => {
242
235
  expect(publisher['negotiate']).not.toHaveBeenCalled();
243
236
  });
244
237
 
238
+ it('should initiate new negotiation when ICE restart is requested', async () => {
239
+ // @ts-expect-error private method
240
+ vi.spyOn(publisher, 'negotiate').mockResolvedValue();
241
+
242
+ await publisher.restartIce();
243
+ expect(publisher['negotiate']).toHaveBeenCalled();
244
+ });
245
+
245
246
  it(`should perform ICE restart when connection state changes to 'failed'`, () => {
246
- publisher['onUnrecoverableError'] = vi.fn();
247
+ vi.spyOn(publisher, 'restartIce').mockResolvedValue();
247
248
  // @ts-expect-error private api
248
249
  publisher['pc'].iceConnectionState = 'failed';
249
250
  publisher['onIceConnectionStateChange']();
250
- expect(publisher['onUnrecoverableError']).toHaveBeenCalled();
251
+ expect(publisher.restartIce).toHaveBeenCalled();
251
252
  });
252
253
 
253
- it(`should perform ICE restart when connection state changes to 'disconnected'`, () => {
254
+ it(`should perform rejoin when ICE restart fails after connection state changes to 'failed'`, async () => {
255
+ const { promise: lock, resolve: unlock } = promiseWithResolvers<void>();
256
+ publisher['onReconnectionNeeded'] = vi
257
+ .fn()
258
+ .mockImplementation(() => unlock());
259
+ vi.spyOn(publisher, 'restartIce').mockRejectedValue('ICE restart failed');
260
+ // @ts-expect-error private api
261
+ publisher['pc'].iceConnectionState = 'failed';
262
+ publisher['onIceConnectionStateChange']();
263
+
264
+ await lock;
265
+ expect(publisher.restartIce).toHaveBeenCalled();
266
+ expect(publisher['onReconnectionNeeded']).toHaveBeenCalled();
267
+ });
268
+
269
+ it(`should perform fast reconnect when ICE restart fails with SIGNAL_LOST error`, async () => {
270
+ const { promise: lock, resolve: unlock } = promiseWithResolvers<void>();
271
+ publisher['onReconnectionNeeded'] = vi
272
+ .fn()
273
+ .mockImplementation(() => unlock());
274
+ publisher.getAnnouncedTracks = vi
275
+ .fn()
276
+ .mockReturnValue([
277
+ { trackId: '123', trackType: TrackType.VIDEO, mid: '0' },
278
+ ]);
279
+
280
+ // @ts-expect-error private api
281
+ vi.spyOn(publisher, 'negotiate');
282
+ vi.spyOn(publisher, 'restartIce');
283
+
284
+ const pc = publisher['pc'];
285
+
286
+ sfuClient.setPublisher = vi.fn().mockImplementation(() => {
287
+ // @ts-expect-error private api
288
+ pc.signalingState = 'have-local-offer';
289
+ return {
290
+ response: {
291
+ error: {
292
+ code: ErrorCode.PARTICIPANT_SIGNAL_LOST,
293
+ message: 'Signal lost',
294
+ shouldRetry: true,
295
+ },
296
+ } as SetPublisherResponse,
297
+ };
298
+ });
299
+
300
+ // @ts-expect-error private api
301
+ pc.iceConnectionState = 'failed';
302
+ publisher['onIceConnectionStateChange']();
303
+
304
+ await lock;
305
+ expect(publisher.restartIce).toHaveBeenCalled();
306
+ expect(publisher['negotiate']).toHaveBeenCalled();
307
+ expect(publisher['onReconnectionNeeded']).toHaveBeenCalledWith(
308
+ WebsocketReconnectStrategy.FAST,
309
+ anyString(),
310
+ );
311
+
312
+ expect(pc.setLocalDescription).toHaveBeenCalledTimes(2);
313
+ expect(pc.setLocalDescription).toHaveBeenLastCalledWith({
314
+ type: 'rollback',
315
+ });
316
+ expect(pc.setRemoteDescription).not.toHaveBeenCalled();
317
+ });
318
+
319
+ it(`should perform REJOIN reconnect when ICE restart fails with any other error code`, async () => {
320
+ const { promise: lock, resolve: unlock } = promiseWithResolvers<void>();
321
+ publisher['onReconnectionNeeded'] = vi
322
+ .fn()
323
+ .mockImplementation(() => unlock());
324
+ publisher.getAnnouncedTracks = vi
325
+ .fn()
326
+ .mockReturnValue([
327
+ { trackId: '123', trackType: TrackType.VIDEO, mid: '0' },
328
+ ]);
329
+
330
+ // @ts-expect-error private api
331
+ vi.spyOn(publisher, 'negotiate');
332
+ vi.spyOn(publisher, 'restartIce');
333
+
334
+ sfuClient.setPublisher = vi.fn().mockResolvedValue({
335
+ response: {
336
+ error: {
337
+ code: ErrorCode.PARTICIPANT_NOT_FOUND,
338
+ message: 'participant not found',
339
+ shouldRetry: true,
340
+ },
341
+ } as SetPublisherResponse,
342
+ });
343
+
344
+ // @ts-expect-error private api
345
+ publisher['pc'].iceConnectionState = 'failed';
346
+ publisher['onIceConnectionStateChange']();
347
+
348
+ await lock;
349
+ expect(publisher.restartIce).toHaveBeenCalled();
350
+ expect(publisher['negotiate']).toHaveBeenCalled();
351
+ await expect(publisher.restartIce).rejects.toThrowError(NegotiationError);
352
+ expect(publisher['onReconnectionNeeded']).toHaveBeenCalledWith(
353
+ WebsocketReconnectStrategy.REJOIN,
354
+ anyString(),
355
+ );
356
+ });
357
+
358
+ it(`should schedule ICE restart when connection state changes to 'disconnected'`, () => {
254
359
  vi.spyOn(publisher, 'restartIce').mockResolvedValue();
360
+ vi.useFakeTimers();
361
+ // @ts-expect-error private api
362
+ publisher['pc'].iceConnectionState = 'disconnected';
363
+ publisher['onIceConnectionStateChange']();
364
+
365
+ vi.runOnlyPendingTimers();
366
+ expect(publisher.restartIce).toHaveBeenCalled();
367
+ });
368
+
369
+ it(`should perform rejoin when scheduled ICE restart fails`, async () => {
370
+ vi.spyOn(publisher, 'restartIce').mockRejectedValue('ICE restart failed');
371
+ const { promise: lock, resolve } = promiseWithResolvers<void>();
372
+ publisher['onReconnectionNeeded'] = vi
373
+ .fn()
374
+ .mockImplementation(() => resolve());
375
+ vi.useFakeTimers();
255
376
  // @ts-expect-error private api
256
377
  publisher['pc'].iceConnectionState = 'disconnected';
257
378
  publisher['onIceConnectionStateChange']();
379
+
380
+ vi.runOnlyPendingTimers();
381
+
382
+ await lock;
258
383
  expect(publisher.restartIce).toHaveBeenCalled();
384
+ expect(publisher['onReconnectionNeeded']).toHaveBeenCalled();
385
+ });
386
+
387
+ it(`should schedule ICE restart but cancel it if connection recovers in the meantime`, () => {
388
+ vi.spyOn(publisher, 'restartIce').mockResolvedValue();
389
+ vi.useFakeTimers();
390
+ // @ts-expect-error private api
391
+ publisher['pc'].iceConnectionState = 'disconnected';
392
+ publisher['onIceConnectionStateChange']();
393
+
394
+ // @ts-expect-error private api
395
+ publisher['pc'].iceConnectionState = 'connected';
396
+ publisher['onIceConnectionStateChange']();
397
+
398
+ vi.runOnlyPendingTimers();
399
+ expect(publisher.restartIce).not.toHaveBeenCalled();
400
+ expect(publisher['iceRestartTimeout']).toBeUndefined();
259
401
  });
260
402
  });
261
403
 
@@ -721,6 +863,7 @@ describe('Publisher', () => {
721
863
  it('isPublishing should return true if there are active tracks', () => {
722
864
  expect(publisher.isPublishing(TrackType.VIDEO)).toBe(true);
723
865
  expect(publisher.isPublishing(TrackType.SCREEN_SHARE_AUDIO)).toBe(false);
866
+ expect(publisher.isPublishing()).toBe(true);
724
867
  });
725
868
 
726
869
  it('getTrackType should return the track type', () => {
@@ -6,11 +6,13 @@ import { StreamSfuClient } from '../../StreamSfuClient';
6
6
  import { Subscriber } from '../Subscriber';
7
7
  import { CallState } from '../../store';
8
8
  import { SfuEvent, SubscriberOffer } from '../../gen/video/sfu/event/events';
9
+ import { ICERestartResponse } from '../../gen/video/sfu/signal_rpc/signal';
9
10
  import {
11
+ ErrorCode,
10
12
  PeerType,
11
- SdkType,
12
13
  TrackType,
13
14
  } from '../../gen/video/sfu/models/models';
15
+ import { NegotiationError } from '../NegotiationError';
14
16
  import { IceTrickleBuffer } from '../IceTrickleBuffer';
15
17
  import { StreamClient } from '../../coordinator/connection/client';
16
18
 
@@ -43,7 +45,7 @@ describe('Subscriber', () => {
43
45
  token: 'token',
44
46
  ice_servers: [],
45
47
  },
46
- enableTracing: false,
48
+ enableTracing: true,
47
49
  });
48
50
  // @ts-expect-error readonly field
49
51
  sfuClient.iceTrickleBuffer = new IceTrickleBuffer();
@@ -54,15 +56,7 @@ describe('Subscriber', () => {
54
56
  state,
55
57
  connectionConfig: { iceServers: [] },
56
58
  logTag: 'test',
57
- enableTracing: false,
58
- clientDetails: {
59
- sdk: {
60
- type: SdkType.PLAIN_JAVASCRIPT,
61
- major: '1',
62
- minor: '0',
63
- patch: '1',
64
- },
65
- },
59
+ enableTracing: true,
66
60
  });
67
61
  });
68
62
 
@@ -92,7 +86,7 @@ describe('Subscriber', () => {
92
86
  });
93
87
 
94
88
  it('should ask the SFU for ICE restart', async () => {
95
- sfuClient.iceRestart = vi.fn();
89
+ sfuClient.iceRestart = vi.fn().mockResolvedValue({ response: {} });
96
90
  // @ts-expect-error - private field
97
91
  subscriber['pc'].connectionState = 'connected';
98
92
 
@@ -103,20 +97,43 @@ describe('Subscriber', () => {
103
97
  });
104
98
 
105
99
  it(`should perform ICE restart when connection state changes to 'failed'`, () => {
106
- subscriber['onUnrecoverableError'] = vi.fn();
100
+ vi.spyOn(subscriber, 'restartIce').mockResolvedValue();
107
101
  // @ts-expect-error - private field
108
102
  subscriber['pc'].iceConnectionState = 'failed';
109
103
  subscriber['onIceConnectionStateChange']();
110
- expect(subscriber['onUnrecoverableError']).toHaveBeenCalled();
104
+ expect(subscriber['restartIce']).toHaveBeenCalled();
111
105
  });
112
106
 
113
107
  it(`should perform ICE restart when connection state changes to 'disconnected'`, () => {
114
108
  vi.spyOn(subscriber, 'restartIce').mockResolvedValue();
109
+ vi.useFakeTimers();
115
110
  // @ts-expect-error - private field
116
111
  subscriber['pc'].iceConnectionState = 'disconnected';
117
112
  subscriber['onIceConnectionStateChange']();
113
+ vi.runOnlyPendingTimers();
118
114
  expect(subscriber.restartIce).toHaveBeenCalled();
119
115
  });
116
+
117
+ it(`should throw NegotiationError when SFU returns an error`, async () => {
118
+ sfuClient.iceRestart = vi.fn().mockResolvedValue({
119
+ response: {
120
+ error: {
121
+ code: ErrorCode.PARTICIPANT_SIGNAL_LOST,
122
+ message: 'Signal lost',
123
+ },
124
+ } as ICERestartResponse,
125
+ });
126
+
127
+ // @ts-expect-error - private field
128
+ subscriber['pc'].connectionState = 'connected';
129
+
130
+ await expect(subscriber.restartIce()).rejects.toThrowError(
131
+ NegotiationError,
132
+ );
133
+ expect(sfuClient.iceRestart).toHaveBeenCalledWith({
134
+ peerType: PeerType.SUBSCRIBER,
135
+ });
136
+ });
120
137
  });
121
138
 
122
139
  describe('OnTrack', () => {
@@ -0,0 +1,120 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { enableStereo } from '../sdp';
3
+
4
+ const offerSdp = `o=- 5087842825318906515 1750254551 IN IP4 127.0.0.1
5
+ s=-
6
+ t=0 0
7
+ a=group:BUNDLE 0 1
8
+ a=extmap-allow-mixed
9
+ a=msid-semantic: WMS a1e5f21f716affb7:TRACK_TYPE_AUDIO:eO a1e5f21f716affb7:TRACK_TYPE_VIDEO:Nk
10
+ a=ice-lite
11
+ m=audio 51808 UDP/TLS/RTP/SAVPF 111 63 (19 more lines) direction=sendrecv mid=0
12
+ c=IN IP4 18.119.157.125
13
+ a=rtcp:51808 IN IP4 18.119.157.125
14
+ a=candidate:965407073 1 udp 2130706431 18.119.157.125 51808 typ host generation 0
15
+ a=candidate:965407073 2 udp 2130706431 18.119.157.125 51808 typ host generation 0
16
+ a=ice-ufrag:AFJnYPvMfEaZeHdt
17
+ a=ice-pwd:mbwUwrcoSApXwOyGrQOAsipWsfFGHcww
18
+ a=fingerprint:sha-256 13:79:52:41:12:BB:A7:5D:39:F0:9B:1A:95:58:94:D6:F9:D3:1E:00:A4:9D:CA:12:26:AE:7C:2A:E1:FC:42:F4
19
+ a=setup:actpass
20
+ a=mid:0
21
+ a=sendrecv
22
+ a=msid:a1e5f21f716affb7:TRACK_TYPE_AUDIO:eO 5040549b-8458-4646-892d-ad08f4475568
23
+ a=rtcp-mux
24
+ a=rtcp-rsize
25
+ a=rtpmap:111 opus/48000/2
26
+ a=fmtp:111 maxaveragebitrate=510000;minptime=10;sprop-stereo=1;stereo=1;useinbandfec=1
27
+ a=rtpmap:63 red/48000/2
28
+ a=fmtp:63 111/111
29
+ a=ssrc:1826085709 cname:a1e5f21f716affb7:TRACK_TYPE_AUDIO:eO
30
+ a=ssrc:1826085709 msid:a1e5f21f716affb7:TRACK_TYPE_AUDIO:eO 5040549b-8458-4646-892d-ad08f4475568
31
+ m=video 9 UDP/TLS/RTP/SAVPF 96 97 direction=sendrecv mid=1
32
+ c=IN IP4 0.0.0.0
33
+ a=rtcp:9 IN IP4 0.0.0.0
34
+ a=ice-ufrag:AFJnYPvMfEaZeHdt
35
+ a=ice-pwd:mbwUwrcoSApXwOyGrQOAsipWsfFGHcww
36
+ a=fingerprint:sha-256 13:79:52:41:12:BB:A7:5D:39:F0:9B:1A:95:58:94:D6:F9:D3:1E:00:A4:9D:CA:12:26:AE:7C:2A:E1:FC:42:F4
37
+ a=setup:actpass
38
+ a=mid:1
39
+ a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01
40
+ a=extmap:1 https://aomediacodec.github.io/av1-rtp-spec/#dependency-descriptor-rtp-header-extension
41
+ a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
42
+ a=sendrecv
43
+ a=msid:a1e5f21f716affb7:TRACK_TYPE_VIDEO:Nk 40c4a11d-fa67-49b6-a4fa-1625b4bde4a5
44
+ a=rtcp-mux
45
+ a=rtcp-rsize
46
+ a=rtpmap:96 VP8/90000
47
+ a=rtcp-fb:96 ccm fir
48
+ a=rtcp-fb:96 nack
49
+ a=rtcp-fb:96 nack pli
50
+ a=rtcp-fb:96 goog-remb
51
+ a=rtcp-fb:96 transport-cc
52
+ a=rtpmap:97 rtx/90000
53
+ a=fmtp:97 apt=96
54
+ a=ssrc-group:FID 3718708632 2750164767
55
+ a=ssrc:3718708632 cname:a1e5f21f716affb7:TRACK_TYPE_VIDEO:Nk
56
+ a=ssrc:3718708632 msid:a1e5f21f716affb7:TRACK_TYPE_VIDEO:Nk 40c4a11d-fa67-49b6-a4fa-1625b4bde4a5
57
+ a=ssrc:2750164767 cname:a1e5f21f716affb7:TRACK_TYPE_VIDEO:Nk
58
+ a=ssrc:2750164767 msid:a1e5f21f716affb7:TRACK_TYPE_VIDEO:Nk 40c4a11d-fa67-49b6-a4fa-1625b4bde4a5
59
+ `;
60
+
61
+ const answerSdp = `v=0
62
+ o=- 6793916097087762106 2 IN IP4 127.0.0.1
63
+ s=-
64
+ t=0 0
65
+ a=group:BUNDLE 0 1
66
+ a=extmap-allow-mixed
67
+ a=msid-semantic: WMS
68
+ m=audio 9 UDP/TLS/RTP/SAVPF 111 63
69
+ c=IN IP4 0.0.0.0
70
+ a=rtcp:9 IN IP4 0.0.0.0
71
+ a=ice-ufrag:zcgT
72
+ a=ice-pwd:v559SXDwx4y9yAv7oeCvjsDR
73
+ a=ice-options:trickle
74
+ a=fingerprint:sha-256 F7:C8:B3:87:4A:AD:5A:86:48:1B:51:04:BE:CE:3B:D6:D3:7C:25:63:3E:9C:2B:F6:5B:8C:65:1F:72:8A:11:61
75
+ a=setup:active
76
+ a=mid:0
77
+ a=recvonly
78
+ a=rtcp-mux
79
+ a=rtcp-rsize
80
+ a=rtpmap:111 opus/48000/2
81
+ a=fmtp:111 minptime=10;useinbandfec=1
82
+ a=rtpmap:63 red/48000/2
83
+ a=fmtp:63 111/111
84
+ m=video 9 UDP/TLS/RTP/SAVPF 96 97
85
+ c=IN IP4 0.0.0.0
86
+ a=rtcp:9 IN IP4 0.0.0.0
87
+ a=ice-ufrag:zcgT
88
+ a=ice-pwd:v559SXDwx4y9yAv7oeCvjsDR
89
+ a=ice-options:trickle
90
+ a=fingerprint:sha-256 F7:C8:B3:87:4A:AD:5A:86:48:1B:51:04:BE:CE:3B:D6:D3:7C:25:63:3E:9C:2B:F6:5B:8C:65:1F:72:8A:11:61
91
+ a=setup:active
92
+ a=mid:1
93
+ a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
94
+ a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01
95
+ a=extmap:1 https://aomediacodec.github.io/av1-rtp-spec/#dependency-descriptor-rtp-header-extension
96
+ a=recvonly
97
+ a=rtcp-mux
98
+ a=rtcp-rsize
99
+ a=rtpmap:96 VP8/90000
100
+ a=rtcp-fb:96 goog-remb
101
+ a=rtcp-fb:96 transport-cc
102
+ a=rtcp-fb:96 ccm fir
103
+ a=rtcp-fb:96 nack
104
+ a=rtcp-fb:96 nack pli
105
+ a=rtpmap:97 rtx/90000
106
+ a=fmtp:97 apt=96
107
+ `;
108
+
109
+ describe('sdp - enableStereo', () => {
110
+ it('should enable stereo in the answer SDP based on the offered stereo in the offer SDP', () => {
111
+ const result = enableStereo(offerSdp, answerSdp);
112
+ expect(result).toContain('a=fmtp:111 minptime=10;useinbandfec=1;stereo=1');
113
+ });
114
+
115
+ it('should not modify the answer SDP if no stereo is offered', () => {
116
+ const modifiedOfferSdp = offerSdp.replace(/stereo=1/g, 'stereo=0');
117
+ const result = enableStereo(modifiedOfferSdp, answerSdp);
118
+ expect(result).toBe(answerSdp);
119
+ });
120
+ });
@@ -1,4 +1,4 @@
1
- import { parse } from 'sdp-transform';
1
+ import { parse, write } from 'sdp-transform';
2
2
 
3
3
  /**
4
4
  * Extracts the mid from the transceiver or the SDP.
@@ -28,3 +28,45 @@ export const extractMid = (
28
28
  if (transceiverInitIndex < 0) return '';
29
29
  return String(transceiverInitIndex);
30
30
  };
31
+
32
+ /**
33
+ * Enables stereo in the answer SDP based on the offered stereo in the offer SDP.
34
+ *
35
+ * @param offerSdp the offer SDP containing the stereo configuration.
36
+ * @param answerSdp the answer SDP to be modified.
37
+ */
38
+ export const enableStereo = (offerSdp: string, answerSdp: string): string => {
39
+ const offeredStereoMids = new Set<string>();
40
+ const parsedOfferSdp = parse(offerSdp);
41
+ for (const media of parsedOfferSdp.media) {
42
+ if (media.type !== 'audio') continue;
43
+
44
+ const opus = media.rtp.find((r) => r.codec === 'opus');
45
+ if (!opus) continue;
46
+
47
+ for (const fmtp of media.fmtp) {
48
+ if (fmtp.payload === opus.payload && fmtp.config.includes('stereo=1')) {
49
+ offeredStereoMids.add(media.mid!);
50
+ }
51
+ }
52
+ }
53
+
54
+ // No stereo offered, return the original answerSdp
55
+ if (offeredStereoMids.size === 0) return answerSdp;
56
+
57
+ const parsedAnswerSdp = parse(answerSdp);
58
+ for (const media of parsedAnswerSdp.media) {
59
+ if (media.type !== 'audio' || !offeredStereoMids.has(media.mid!)) continue;
60
+
61
+ const opus = media.rtp.find((r) => r.codec === 'opus');
62
+ if (!opus) continue;
63
+
64
+ for (const fmtp of media.fmtp) {
65
+ if (fmtp.payload === opus.payload && !fmtp.config.includes('stereo=1')) {
66
+ fmtp.config += ';stereo=1';
67
+ }
68
+ }
69
+ }
70
+
71
+ return write(parsedAnswerSdp);
72
+ };
package/src/types.ts CHANGED
@@ -215,6 +215,18 @@ export type ScreenShareSettings = {
215
215
  * Defaults to 3000000 (3Mbps).
216
216
  */
217
217
  maxBitrate?: number;
218
+
219
+ /**
220
+ * The content hint to use when publishing the screen share.
221
+ * This can be used to optimize the video quality for different types of content.
222
+ *
223
+ * Defaults to '' (no hint, browser's default behavior).
224
+ * Use 'motion' for video content, 'detail' for presentations or documents, and 'text' for text-heavy content.
225
+ *
226
+ * Please read the documentation for more information on content hints:
227
+ * - https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack/contentHint
228
+ */
229
+ contentHint?: '' | 'motion' | 'detail' | 'text';
218
230
  };
219
231
 
220
232
  export type CallLeaveOptions = {