@stream-io/video-client 1.50.0 → 1.52.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 (61) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/index.browser.es.js +597 -70
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +597 -69
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +597 -70
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +1 -0
  9. package/dist/src/devices/CameraManager.d.ts +1 -0
  10. package/dist/src/devices/DeviceManager.d.ts +20 -0
  11. package/dist/src/devices/VirtualDevice.d.ts +59 -0
  12. package/dist/src/devices/devicePersistence.d.ts +1 -1
  13. package/dist/src/devices/index.d.ts +1 -0
  14. package/dist/src/gen/video/sfu/event/events.d.ts +4 -0
  15. package/dist/src/gen/video/sfu/models/models.d.ts +204 -2
  16. package/dist/src/gen/video/sfu/signal_rpc/signal.client.d.ts +9 -1
  17. package/dist/src/gen/video/sfu/signal_rpc/signal.d.ts +67 -1
  18. package/dist/src/helpers/participantUtils.d.ts +10 -0
  19. package/dist/src/rtc/BasePeerConnection.d.ts +8 -3
  20. package/dist/src/rtc/Publisher.d.ts +21 -3
  21. package/dist/src/rtc/TransceiverCache.d.ts +5 -1
  22. package/dist/src/rtc/helpers/degradationPreference.d.ts +1 -0
  23. package/dist/src/rtc/types.d.ts +3 -0
  24. package/dist/src/stats/rtc/StatsTracer.d.ts +2 -1
  25. package/dist/src/stats/utils.d.ts +1 -0
  26. package/package.json +14 -14
  27. package/src/Call.ts +27 -12
  28. package/src/devices/CameraManager.ts +9 -2
  29. package/src/devices/DeviceManager.ts +148 -8
  30. package/src/devices/DeviceManagerState.ts +4 -1
  31. package/src/devices/VirtualDevice.ts +69 -0
  32. package/src/devices/__tests__/CameraManager.test.ts +22 -1
  33. package/src/devices/__tests__/DeviceManager.test.ts +124 -2
  34. package/src/devices/__tests__/MicrophoneManager.test.ts +3 -1
  35. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +3 -1
  36. package/src/devices/__tests__/ScreenShareManager.test.ts +3 -1
  37. package/src/devices/__tests__/web-audio.mocks.ts +3 -1
  38. package/src/devices/devicePersistence.ts +2 -1
  39. package/src/devices/index.ts +1 -0
  40. package/src/gen/video/sfu/event/events.ts +10 -0
  41. package/src/gen/video/sfu/models/models.ts +338 -0
  42. package/src/gen/video/sfu/signal_rpc/signal.client.ts +28 -2
  43. package/src/gen/video/sfu/signal_rpc/signal.ts +121 -15
  44. package/src/helpers/__tests__/DynascaleManager.test.ts +8 -7
  45. package/src/helpers/__tests__/browsers.test.ts +4 -4
  46. package/src/helpers/__tests__/participantUtils.test.ts +47 -0
  47. package/src/helpers/client-details.ts +4 -1
  48. package/src/helpers/participantUtils.ts +15 -0
  49. package/src/rtc/BasePeerConnection.ts +22 -4
  50. package/src/rtc/Publisher.ts +140 -41
  51. package/src/rtc/Subscriber.ts +1 -0
  52. package/src/rtc/TransceiverCache.ts +10 -3
  53. package/src/rtc/__tests__/Call.reconnect.test.ts +121 -2
  54. package/src/rtc/__tests__/Publisher.test.ts +659 -112
  55. package/src/rtc/__tests__/Subscriber.test.ts +7 -3
  56. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +16 -15
  57. package/src/rtc/helpers/__tests__/degradationPreference.test.ts +33 -1
  58. package/src/rtc/helpers/degradationPreference.ts +18 -0
  59. package/src/rtc/types.ts +3 -0
  60. package/src/stats/rtc/StatsTracer.ts +25 -4
  61. package/src/stats/rtc/__tests__/StatsTracer.test.ts +155 -0
@@ -64,11 +64,11 @@ describe('Subscriber', () => {
64
64
  });
65
65
  });
66
66
 
67
- afterEach(() => {
67
+ afterEach(async () => {
68
68
  vi.useRealTimers();
69
69
  vi.clearAllMocks();
70
70
  vi.resetModules();
71
- subscriber.dispose();
71
+ await subscriber.dispose();
72
72
  });
73
73
 
74
74
  describe('Subscriber ICE restart', () => {
@@ -425,7 +425,10 @@ describe('Subscriber', () => {
425
425
  .mockResolvedValue({ sdp: 'answer-sdp' });
426
426
  vi.spyOn(subscriber['pc'], 'setRemoteDescription').mockResolvedValue();
427
427
 
428
- const offer = SubscriberOffer.create({ sdp: 'offer-sdp' });
428
+ const offer = SubscriberOffer.create({
429
+ sdp: 'offer-sdp',
430
+ negotiationId: 42,
431
+ });
429
432
  // @ts-expect-error - private method
430
433
  await subscriber.negotiate(offer);
431
434
  expect(subscriber['pc'].setRemoteDescription).toHaveBeenCalledWith({
@@ -437,6 +440,7 @@ describe('Subscriber', () => {
437
440
  expect(sfuClient.sendAnswer).toHaveBeenCalledWith({
438
441
  peerType: PeerType.SUBSCRIBER,
439
442
  sdp: 'answer-sdp',
443
+ negotiationId: 42,
440
444
  });
441
445
  });
442
446
  });
@@ -1,6 +1,6 @@
1
1
  import { vi } from 'vitest';
2
2
 
3
- const RTCPeerConnectionMock = vi.fn((): Partial<RTCPeerConnection> => {
3
+ const RTCPeerConnectionMock = vi.fn(function (): Partial<RTCPeerConnection> {
4
4
  return {
5
5
  addEventListener: vi.fn(),
6
6
  addIceCandidate: vi.fn(),
@@ -23,7 +23,7 @@ const RTCPeerConnectionMock = vi.fn((): Partial<RTCPeerConnection> => {
23
23
  });
24
24
  vi.stubGlobal('RTCPeerConnection', RTCPeerConnectionMock);
25
25
 
26
- const MediaStreamMock = vi.fn((): Partial<MediaStream> => {
26
+ const MediaStreamMock = vi.fn(function (): Partial<MediaStream> {
27
27
  return {
28
28
  getTracks: vi.fn().mockReturnValue([]),
29
29
  addTrack: vi.fn(),
@@ -34,7 +34,7 @@ const MediaStreamMock = vi.fn((): Partial<MediaStream> => {
34
34
  });
35
35
  vi.stubGlobal('MediaStream', MediaStreamMock);
36
36
 
37
- const MediaStreamTrackMock = vi.fn((): Partial<MediaStreamTrack> => {
37
+ const MediaStreamTrackMock = vi.fn(function (): Partial<MediaStreamTrack> {
38
38
  return {
39
39
  addEventListener: vi.fn(),
40
40
  removeEventListener: vi.fn(),
@@ -49,7 +49,7 @@ const MediaStreamTrackMock = vi.fn((): Partial<MediaStreamTrack> => {
49
49
  });
50
50
  vi.stubGlobal('MediaStreamTrack', MediaStreamTrackMock);
51
51
 
52
- const RTCRtpTransceiverMock = vi.fn((): Partial<RTCRtpTransceiver> => {
52
+ const RTCRtpTransceiverMock = vi.fn(function (): Partial<RTCRtpTransceiver> {
53
53
  return {
54
54
  // @ts-expect-error - incomplete mock
55
55
  sender: {
@@ -64,24 +64,25 @@ const RTCRtpTransceiverMock = vi.fn((): Partial<RTCRtpTransceiver> => {
64
64
  });
65
65
  vi.stubGlobal('RTCRtpTransceiver', RTCRtpTransceiverMock);
66
66
 
67
- const RTCTrackEvent = vi.fn(
68
- (type: string, eventInitDict: RTCTrackEventInit): Partial<RTCTrackEvent> => {
69
- return {
70
- type,
71
- ...eventInitDict,
72
- };
73
- },
74
- );
67
+ const RTCTrackEvent = vi.fn(function (
68
+ type: string,
69
+ eventInitDict: RTCTrackEventInit,
70
+ ): Partial<RTCTrackEvent> {
71
+ return {
72
+ type,
73
+ ...eventInitDict,
74
+ };
75
+ });
75
76
  vi.stubGlobal('RTCTrackEvent', RTCTrackEvent);
76
77
 
77
- const RTCRtpReceiverMock = vi.fn((): Partial<typeof RTCRtpReceiver> => {
78
+ const RTCRtpReceiverMock = vi.fn(function (): Partial<typeof RTCRtpReceiver> {
78
79
  return {
79
80
  getCapabilities: vi.fn(),
80
81
  };
81
82
  });
82
83
  vi.stubGlobal('RTCRtpReceiver', RTCRtpReceiverMock);
83
84
 
84
- const RTCRtpSenderMock = vi.fn((): Partial<typeof RTCRtpSender> => {
85
+ const RTCRtpSenderMock = vi.fn(function (): Partial<typeof RTCRtpSender> {
85
86
  return {
86
87
  getCapabilities: vi.fn(),
87
88
  // @ts-expect-error - incomplete mock
@@ -90,7 +91,7 @@ const RTCRtpSenderMock = vi.fn((): Partial<typeof RTCRtpSender> => {
90
91
  });
91
92
  vi.stubGlobal('RTCRtpSender', RTCRtpSenderMock);
92
93
 
93
- const AudioContextMock = vi.fn((): Partial<AudioContext> => {
94
+ const AudioContextMock = vi.fn(function (): Partial<AudioContext> {
94
95
  return {
95
96
  state: 'suspended',
96
97
  sinkId: '',
@@ -1,6 +1,9 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { DegradationPreference } from '../../../gen/video/sfu/models/models';
3
- import { toRTCDegradationPreference } from '../degradationPreference';
3
+ import {
4
+ fromRTCDegradationPreference,
5
+ toRTCDegradationPreference,
6
+ } from '../degradationPreference';
4
7
 
5
8
  describe('toRTCDegradationPreference', () => {
6
9
  it.each([
@@ -21,3 +24,32 @@ describe('toRTCDegradationPreference', () => {
21
24
  ).toBeUndefined();
22
25
  });
23
26
  });
27
+
28
+ describe('fromRTCDegradationPreference', () => {
29
+ it.each([
30
+ ['balanced', DegradationPreference.BALANCED],
31
+ ['maintain-framerate', DegradationPreference.MAINTAIN_FRAMERATE],
32
+ ['maintain-resolution', DegradationPreference.MAINTAIN_RESOLUTION],
33
+ [
34
+ 'maintain-framerate-and-resolution',
35
+ DegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION,
36
+ ],
37
+ ] as const)('maps "%s" to %s', (preference, expected) => {
38
+ // @ts-expect-error not in the lib types yet
39
+ expect(fromRTCDegradationPreference(preference)).toBe(expected);
40
+ });
41
+
42
+ it('returns UNSPECIFIED for undefined', () => {
43
+ expect(fromRTCDegradationPreference(undefined)).toBe(
44
+ DegradationPreference.UNSPECIFIED,
45
+ );
46
+ });
47
+
48
+ it('returns UNSPECIFIED for an unknown value', () => {
49
+ expect(
50
+ fromRTCDegradationPreference(
51
+ 'something-else' as unknown as RTCDegradationPreference,
52
+ ),
53
+ ).toBe(DegradationPreference.UNSPECIFIED);
54
+ });
55
+ });
@@ -20,3 +20,21 @@ export const toRTCDegradationPreference = (
20
20
  ensureExhausted(preference, 'Unknown degradation preference');
21
21
  }
22
22
  };
23
+
24
+ export const fromRTCDegradationPreference = (
25
+ preference: RTCDegradationPreference | undefined,
26
+ ): DegradationPreference => {
27
+ switch (preference) {
28
+ case 'balanced':
29
+ return DegradationPreference.BALANCED;
30
+ case 'maintain-framerate':
31
+ return DegradationPreference.MAINTAIN_FRAMERATE;
32
+ case 'maintain-resolution':
33
+ return DegradationPreference.MAINTAIN_RESOLUTION;
34
+ // @ts-expect-error not in the typedefs yet
35
+ case 'maintain-framerate-and-resolution':
36
+ return DegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION;
37
+ default:
38
+ return DegradationPreference.UNSPECIFIED;
39
+ }
40
+ };
package/src/rtc/types.ts CHANGED
@@ -9,6 +9,7 @@ import { CallState } from '../store';
9
9
  import { Dispatcher } from './Dispatcher';
10
10
  import type { OptimalVideoLayer } from './layers';
11
11
  import type { ClientPublishOptions } from '../types';
12
+ import type { VideoSender } from '../gen/video/sfu/event/events';
12
13
 
13
14
  /**
14
15
  * Canonical reasons the SDK uses to trigger a reconnection. Free-form strings
@@ -63,6 +64,7 @@ export type BasePeerConnectionOpts = {
63
64
  enableTracing: boolean;
64
65
  iceRestartDelay?: number;
65
66
  clientPublishOptions?: ClientPublishOptions;
67
+ statsTimestampDriftThresholdMs?: number;
66
68
  };
67
69
 
68
70
  export type TrackPublishOptions = {
@@ -73,6 +75,7 @@ export type PublishBundle = {
73
75
  publishOption: PublishOption;
74
76
  transceiver: RTCRtpTransceiver;
75
77
  options: TrackPublishOptions;
78
+ videoSender?: VideoSender;
76
79
  };
77
80
 
78
81
  export type TrackLayersCache = {
@@ -19,6 +19,7 @@ export class StatsTracer {
19
19
  private readonly pc: RTCPeerConnection;
20
20
  private readonly peerType: PeerType;
21
21
  private readonly trackIdToTrackType: Map<string, TrackType>;
22
+ private readonly driftThresholdMs: number;
22
23
 
23
24
  private costOverrides?: Map<TrackType, number>;
24
25
 
@@ -33,10 +34,12 @@ export class StatsTracer {
33
34
  pc: RTCPeerConnection,
34
35
  peerType: PeerType,
35
36
  trackIdToTrackType: Map<string, TrackType>,
37
+ statsTimestampDriftThresholdMs: number = 0,
36
38
  ) {
37
39
  this.pc = pc;
38
40
  this.peerType = peerType;
39
41
  this.trackIdToTrackType = trackIdToTrackType;
42
+ this.driftThresholdMs = statsTimestampDriftThresholdMs;
40
43
  }
41
44
 
42
45
  /**
@@ -49,7 +52,11 @@ export class StatsTracer {
49
52
  */
50
53
  get = async (): Promise<ComputedStats> => {
51
54
  const stats = await this.pc.getStats();
52
- const currentStats = toObject(stats);
55
+ const currentStats = toObjectWithCorrectedTimestamp(
56
+ stats,
57
+ Date.now(),
58
+ this.driftThresholdMs,
59
+ );
53
60
 
54
61
  const performanceStats = this.withOverrides(
55
62
  this.peerType === PeerType.SUBSCRIBER
@@ -213,14 +220,28 @@ export class StatsTracer {
213
220
  }
214
221
 
215
222
  /**
216
- * Convert the stat report to an object.
223
+ * Convert the stat report to an object, correcting clock drift along the way.
224
+ * Entries whose `timestamp` differs from `wallNow` by more than `thresholdMs`
225
+ * are replaced with a clone whose `timestamp` is set to `wallNow`. The platform
226
+ * clock backing `DOMHighResTimeStamp` can desynchronise from `Date.now()` after
227
+ * system sleep or clock-jump events (notably on Electron/Chromium), which
228
+ * corrupts the delta-compressed stats payload. A non-positive `thresholdMs`
229
+ * disables correction.
217
230
  *
218
231
  * @param report the stat report to convert.
232
+ * @param wallNow current wall-clock time used as the drift reference.
233
+ * @param thresholdMs maximum tolerated drift in milliseconds.
219
234
  */
220
- const toObject = (report: RTCStatsReport): Record<string, RTCStats> => {
235
+ const toObjectWithCorrectedTimestamp = (
236
+ report: RTCStatsReport,
237
+ wallNow: number,
238
+ thresholdMs: number,
239
+ ): Record<string, RTCStats> => {
221
240
  const obj: Record<string, RTCStats> = {};
241
+ const correct = thresholdMs > 0;
222
242
  report.forEach((v, k) => {
223
- obj[k] = v;
243
+ const drift = Math.abs(v.timestamp - wallNow);
244
+ obj[k] = correct && drift > thresholdMs ? { ...v, timestamp: wallNow } : v;
224
245
  });
225
246
  return obj;
226
247
  };
@@ -0,0 +1,155 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { StatsTracer } from '../StatsTracer';
3
+ import { PeerType } from '../../../gen/video/sfu/models/models';
4
+
5
+ const WALL_NOW = 1_700_000_000_000;
6
+ const THRESHOLD_MS = 5000;
7
+
8
+ const makeReport = (
9
+ entries: Array<{ id: string; timestamp: number }>,
10
+ ): RTCStatsReport => {
11
+ const map = new Map<string, RTCStats>();
12
+ for (const e of entries) {
13
+ map.set(e.id, { id: e.id, timestamp: e.timestamp, type: 'codec' });
14
+ }
15
+ return map as unknown as RTCStatsReport;
16
+ };
17
+
18
+ const makePc = (report: RTCStatsReport) => {
19
+ return {
20
+ getStats: vi.fn().mockResolvedValue(report),
21
+ } as unknown as RTCPeerConnection;
22
+ };
23
+
24
+ describe('StatsTracer timestamp drift correction', () => {
25
+ beforeEach(() => {
26
+ vi.useFakeTimers();
27
+ vi.setSystemTime(WALL_NOW);
28
+ });
29
+
30
+ afterEach(() => {
31
+ vi.useRealTimers();
32
+ });
33
+
34
+ it('keeps original timestamps when no entry drifts past the threshold', async () => {
35
+ const report = makeReport([
36
+ { id: 'a', timestamp: WALL_NOW - 1000 },
37
+ { id: 'b', timestamp: WALL_NOW + 2000 },
38
+ ]);
39
+ const tracer = new StatsTracer(
40
+ makePc(report),
41
+ PeerType.SUBSCRIBER,
42
+ new Map(),
43
+ THRESHOLD_MS,
44
+ );
45
+
46
+ const { delta } = await tracer.get();
47
+
48
+ expect(delta.timestamp).toBe(WALL_NOW + 2000);
49
+ });
50
+
51
+ it('replaces drifted timestamps with wall time', async () => {
52
+ const report = makeReport([
53
+ { id: 'a', timestamp: WALL_NOW },
54
+ { id: 'b', timestamp: WALL_NOW + 10_000 },
55
+ ]);
56
+ const tracer = new StatsTracer(
57
+ makePc(report),
58
+ PeerType.SUBSCRIBER,
59
+ new Map(),
60
+ THRESHOLD_MS,
61
+ );
62
+
63
+ const { delta } = await tracer.get();
64
+
65
+ expect(delta.timestamp).toBe(WALL_NOW);
66
+ });
67
+
68
+ it('clamps stale timestamps to wall time (past drift)', async () => {
69
+ const report = makeReport([
70
+ { id: 'a', timestamp: WALL_NOW + 2000 },
71
+ { id: 'b', timestamp: WALL_NOW - 10_000 },
72
+ ]);
73
+ const tracer = new StatsTracer(
74
+ makePc(report),
75
+ PeerType.SUBSCRIBER,
76
+ new Map(),
77
+ THRESHOLD_MS,
78
+ );
79
+
80
+ const { delta } = await tracer.get();
81
+
82
+ expect(delta['b'].timestamp).toBe(WALL_NOW);
83
+ expect(delta.timestamp).toBe(WALL_NOW + 2000);
84
+ });
85
+
86
+ it('keeps timestamps with drift exactly at the threshold (exclusive boundary)', async () => {
87
+ const report = makeReport([
88
+ { id: 'a', timestamp: WALL_NOW },
89
+ { id: 'b', timestamp: WALL_NOW + THRESHOLD_MS },
90
+ ]);
91
+ const tracer = new StatsTracer(
92
+ makePc(report),
93
+ PeerType.SUBSCRIBER,
94
+ new Map(),
95
+ THRESHOLD_MS,
96
+ );
97
+
98
+ const { delta } = await tracer.get();
99
+
100
+ expect(delta.timestamp).toBe(WALL_NOW + THRESHOLD_MS);
101
+ });
102
+
103
+ it('clamps remote-sourced stat types like any other stat (no special handling)', async () => {
104
+ const map = new Map<string, RTCStats>([
105
+ ['anchor', { id: 'anchor', timestamp: WALL_NOW + 2000, type: 'codec' }],
106
+ [
107
+ 'remote-in',
108
+ {
109
+ id: 'remote-in',
110
+ timestamp: WALL_NOW - 30_000,
111
+ type: 'remote-inbound-rtp',
112
+ },
113
+ ],
114
+ [
115
+ 'remote-out',
116
+ {
117
+ id: 'remote-out',
118
+ timestamp: WALL_NOW + 30_000,
119
+ type: 'remote-outbound-rtp',
120
+ },
121
+ ],
122
+ ]);
123
+ const tracer = new StatsTracer(
124
+ makePc(map as unknown as RTCStatsReport),
125
+ PeerType.SUBSCRIBER,
126
+ new Map(),
127
+ THRESHOLD_MS,
128
+ );
129
+
130
+ const { delta } = await tracer.get();
131
+
132
+ // both stale and future drift get clamped to wall time, while the
133
+ // within-threshold anchor stays the delta's top-level timestamp.
134
+ expect(delta['remote-in'].timestamp).toBe(WALL_NOW);
135
+ expect(delta['remote-out'].timestamp).toBe(WALL_NOW);
136
+ expect(delta.timestamp).toBe(WALL_NOW + 2000);
137
+ });
138
+
139
+ it('does not correct when the threshold is disabled (0)', async () => {
140
+ const report = makeReport([
141
+ { id: 'a', timestamp: WALL_NOW },
142
+ { id: 'b', timestamp: WALL_NOW + 99_999 },
143
+ ]);
144
+ const tracer = new StatsTracer(
145
+ makePc(report),
146
+ PeerType.SUBSCRIBER,
147
+ new Map(),
148
+ 0,
149
+ );
150
+
151
+ const { delta } = await tracer.get();
152
+
153
+ expect(delta.timestamp).toBe(WALL_NOW + 99_999);
154
+ });
155
+ });