@stream-io/video-client 1.57.0 → 1.59.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 (130) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/e2ee-worker.browser.es.js +1342 -0
  3. package/dist/e2ee-worker.browser.es.js.map +1 -0
  4. package/dist/index.browser.es.js +1141 -117
  5. package/dist/index.browser.es.js.map +1 -1
  6. package/dist/index.cjs.js +1196 -115
  7. package/dist/index.cjs.js.map +1 -1
  8. package/dist/index.d.ts +5 -0
  9. package/dist/index.es.js +1174 -117
  10. package/dist/index.es.js.map +1 -1
  11. package/dist/src/Call.d.ts +66 -9
  12. package/dist/src/StreamVideoClient.d.ts +9 -1
  13. package/dist/src/coordinator/connection/connection.d.ts +1 -1
  14. package/dist/src/devices/CameraManager.d.ts +8 -1
  15. package/dist/src/devices/DeviceManager.d.ts +2 -1
  16. package/dist/src/devices/MicrophoneManager.d.ts +8 -1
  17. package/dist/src/devices/SpeakerManager.d.ts +2 -1
  18. package/dist/src/events/participant.d.ts +10 -3
  19. package/dist/src/gen/coordinator/index.d.ts +1386 -240
  20. package/dist/src/gen/video/sfu/models/models.d.ts +8 -0
  21. package/dist/src/helpers/MediaStreamRecorder.d.ts +26 -0
  22. package/dist/src/helpers/TypedEventEmitter.d.ts +23 -0
  23. package/dist/src/helpers/concurrency.d.ts +8 -0
  24. package/dist/src/helpers/ensureExhausted.d.ts +1 -1
  25. package/dist/src/helpers/loopback.d.ts +55 -0
  26. package/dist/src/permissions/PermissionsContext.d.ts +2 -2
  27. package/dist/src/reporting/ClientEventReporter.d.ts +0 -2
  28. package/dist/src/rtc/BasePeerConnection.d.ts +3 -1
  29. package/dist/src/rtc/Subscriber.d.ts +5 -0
  30. package/dist/src/rtc/e2ee/E2EEManager.d.ts +12 -0
  31. package/dist/src/rtc/e2ee/EncryptionManager.d.ts +148 -0
  32. package/dist/src/rtc/e2ee/e2ee-worker.d.ts +25 -0
  33. package/dist/src/rtc/e2ee/events.d.ts +163 -0
  34. package/dist/src/rtc/e2ee/transformSupport.d.ts +21 -0
  35. package/dist/src/rtc/helpers/rtcConfiguration.d.ts +2 -2
  36. package/dist/src/rtc/index.d.ts +1 -0
  37. package/dist/src/rtc/mediaEngine.d.ts +23 -0
  38. package/dist/src/rtc/types.d.ts +2 -0
  39. package/dist/src/store/CallState.d.ts +21 -0
  40. package/dist/src/types.d.ts +15 -6
  41. package/index.ts +5 -0
  42. package/package.json +1 -1
  43. package/src/Call.ts +311 -155
  44. package/src/StreamVideoClient.ts +35 -8
  45. package/src/__tests__/Call.lifecycle.test.ts +33 -2
  46. package/src/__tests__/Call.publishing.test.ts +42 -1
  47. package/src/__tests__/Call.test.ts +15 -0
  48. package/src/__tests__/StreamVideoClient.api.test.ts +33 -0
  49. package/src/__tests__/StreamVideoClient.rewatch.test.ts +213 -0
  50. package/src/__tests__/StreamVideoClient.ringing.test.ts +11 -4
  51. package/src/__tests__/StreamVideoClient.test.ts +14 -12
  52. package/src/coordinator/connection/connection.ts +6 -3
  53. package/src/devices/AudioDeviceManager.ts +6 -0
  54. package/src/devices/CameraManager.ts +67 -8
  55. package/src/devices/DeviceManager.ts +24 -4
  56. package/src/devices/MicrophoneManager.ts +66 -8
  57. package/src/devices/ScreenShareManager.ts +3 -0
  58. package/src/devices/SpeakerManager.ts +42 -12
  59. package/src/devices/__tests__/CameraManager.test.ts +27 -1
  60. package/src/devices/__tests__/DeviceManager.test.ts +49 -1
  61. package/src/devices/__tests__/MicrophoneManager.test.ts +23 -1
  62. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +1 -20
  63. package/src/devices/__tests__/SpeakerManager.test.ts +138 -42
  64. package/src/devices/__tests__/mocks.ts +36 -3
  65. package/src/events/__tests__/participant.test.ts +134 -1
  66. package/src/events/call.ts +6 -3
  67. package/src/events/callEventHandlers.ts +9 -3
  68. package/src/events/participant.ts +42 -7
  69. package/src/gen/coordinator/index.ts +1402 -236
  70. package/src/gen/video/sfu/models/models.ts +15 -0
  71. package/src/helpers/MediaStreamRecorder.ts +104 -0
  72. package/src/helpers/TypedEventEmitter.ts +71 -0
  73. package/src/helpers/__tests__/MediaStreamRecorder.test.ts +324 -0
  74. package/src/helpers/__tests__/TypedEventEmitter.test.ts +133 -0
  75. package/src/helpers/__tests__/concurrency.test.ts +74 -0
  76. package/src/helpers/__tests__/loopback.test.ts +367 -0
  77. package/src/helpers/concurrency.ts +24 -0
  78. package/src/helpers/ensureExhausted.ts +4 -2
  79. package/src/helpers/loopback.ts +200 -0
  80. package/src/permissions/PermissionsContext.ts +10 -5
  81. package/src/reporting/ClientEventReporter.ts +15 -28
  82. package/src/reporting/__tests__/ClientEventReporter.test.ts +3 -7
  83. package/src/rtc/BasePeerConnection.ts +12 -1
  84. package/src/rtc/Publisher.ts +8 -0
  85. package/src/rtc/Subscriber.ts +13 -0
  86. package/src/rtc/__tests__/Publisher.test.ts +101 -0
  87. package/src/rtc/__tests__/Subscriber.test.ts +190 -0
  88. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +31 -0
  89. package/src/rtc/e2ee/E2EEManager.ts +12 -0
  90. package/src/rtc/e2ee/EncryptionManager.ts +336 -0
  91. package/src/rtc/e2ee/SPEC.md +586 -0
  92. package/src/rtc/e2ee/__tests__/E2EEManager.contract.test.ts +39 -0
  93. package/src/rtc/e2ee/__tests__/EncryptionManager.test.ts +490 -0
  94. package/src/rtc/e2ee/__tests__/codec.test.ts +247 -0
  95. package/src/rtc/e2ee/__tests__/conformance.test.ts +184 -0
  96. package/src/rtc/e2ee/__tests__/failureTracker.test.ts +45 -0
  97. package/src/rtc/e2ee/__tests__/frameCounter.test.ts +90 -0
  98. package/src/rtc/e2ee/__tests__/keyStore.test.ts +221 -0
  99. package/src/rtc/e2ee/__tests__/notifications.test.ts +166 -0
  100. package/src/rtc/e2ee/__tests__/perf.test.ts +139 -0
  101. package/src/rtc/e2ee/__tests__/queue.test.ts +61 -0
  102. package/src/rtc/e2ee/__tests__/replayWindow.test.ts +133 -0
  103. package/src/rtc/e2ee/__tests__/trailer.test.ts +143 -0
  104. package/src/rtc/e2ee/__tests__/transform-pipeline.test.ts +839 -0
  105. package/src/rtc/e2ee/__tests__/transformSupport.test.ts +65 -0
  106. package/src/rtc/e2ee/e2ee-worker/codec.ts +220 -0
  107. package/src/rtc/e2ee/e2ee-worker/constants.ts +47 -0
  108. package/src/rtc/e2ee/e2ee-worker/decode.ts +159 -0
  109. package/src/rtc/e2ee/e2ee-worker/e2ee-worker-impl.ts +137 -0
  110. package/src/rtc/e2ee/e2ee-worker/encode.ts +147 -0
  111. package/src/rtc/e2ee/e2ee-worker/failureTracker.ts +26 -0
  112. package/src/rtc/e2ee/e2ee-worker/frameCounter.ts +37 -0
  113. package/src/rtc/e2ee/e2ee-worker/keyStore.ts +211 -0
  114. package/src/rtc/e2ee/e2ee-worker/notifications.ts +190 -0
  115. package/src/rtc/e2ee/e2ee-worker/perf.ts +129 -0
  116. package/src/rtc/e2ee/e2ee-worker/queue.ts +12 -0
  117. package/src/rtc/e2ee/e2ee-worker/replayWindow.ts +131 -0
  118. package/src/rtc/e2ee/e2ee-worker/trailer.ts +155 -0
  119. package/src/rtc/e2ee/e2ee-worker/tsconfig.json +8 -0
  120. package/src/rtc/e2ee/e2ee-worker/types.ts +29 -0
  121. package/src/rtc/e2ee/e2ee-worker.ts +27 -0
  122. package/src/rtc/e2ee/events.ts +179 -0
  123. package/src/rtc/e2ee/transformSupport.ts +35 -0
  124. package/src/rtc/helpers/rtcConfiguration.ts +4 -2
  125. package/src/rtc/index.ts +1 -0
  126. package/src/rtc/mediaEngine.ts +44 -0
  127. package/src/rtc/types.ts +2 -0
  128. package/src/store/CallState.ts +33 -1
  129. package/src/store/__tests__/CallState.test.ts +82 -1
  130. package/src/types.ts +23 -5
@@ -1,6 +1,7 @@
1
1
  import { beforeEach, describe, expect, it } from 'vitest';
2
2
  import {
3
3
  hasPending,
4
+ singleFlight,
4
5
  withCancellation,
5
6
  withoutConcurrency,
6
7
  } from '../concurrency';
@@ -168,6 +169,79 @@ it('keeps track of pending promises', async () => {
168
169
  expect(hasPending(tag)).toBeFalsy();
169
170
  });
170
171
 
172
+ describe('single flight', () => {
173
+ it('shares an in-flight promise for the wrapped function', async () => {
174
+ const [run1, resolve1] = mockAsyncFn('promise1');
175
+ const run = singleFlight(run1);
176
+
177
+ const ready1 = run();
178
+ const ready2 = run();
179
+
180
+ expect(ready2).toBe(ready1);
181
+ expect(log).toMatchObject(['promise1 start']);
182
+
183
+ resolve1();
184
+ await expect(Promise.all([ready1, ready2])).resolves.toEqual([
185
+ undefined,
186
+ undefined,
187
+ ]);
188
+ expect(log).toMatchObject(['promise1 start', 'promise1 end']);
189
+ });
190
+
191
+ it('allows a new execution after the in-flight promise settles', async () => {
192
+ const [run1, resolve1] = mockAsyncFn('promise1');
193
+ const run = singleFlight(run1);
194
+
195
+ const ready1 = run();
196
+ resolve1();
197
+ await ready1;
198
+
199
+ const ready2 = run();
200
+ expect(ready2).not.toBe(ready1);
201
+
202
+ resolve1();
203
+ await ready2;
204
+ expect(log).toMatchObject([
205
+ 'promise1 start',
206
+ 'promise1 end',
207
+ 'promise1 start',
208
+ 'promise1 end',
209
+ ]);
210
+ });
211
+
212
+ it('does not share promises across different wrapped functions', async () => {
213
+ const [runTom, resolveTom] = mockAsyncFn('tom');
214
+ const [runJerry, resolveJerry] = mockAsyncFn('jerry');
215
+ const tom = singleFlight(runTom);
216
+ const jerry = singleFlight(runJerry);
217
+
218
+ const readyTom = tom();
219
+ const readyJerry = jerry();
220
+
221
+ expect(readyJerry).not.toBe(readyTom);
222
+ expect(log).toMatchObject(['tom start', 'jerry start']);
223
+
224
+ resolveTom();
225
+ resolveJerry();
226
+ await Promise.all([readyTom, readyJerry]);
227
+ });
228
+
229
+ it('shares the first call arguments while a call is in flight', async () => {
230
+ const run = singleFlight(async (value: string) => {
231
+ log.push(`${value} start`);
232
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
233
+ log.push(`${value} end`);
234
+ return value;
235
+ });
236
+
237
+ await expect(Promise.all([run('first'), run('second')])).resolves.toEqual([
238
+ 'first',
239
+ 'first',
240
+ ]);
241
+ expect(log).toMatchObject(['first start', 'first end']);
242
+ });
243
+ });
244
+
171
245
  describe('cancelation', () => {
172
246
  it('cancels promises mid-action', async () => {
173
247
  const tag = Symbol();
@@ -0,0 +1,367 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { BehaviorSubject } from 'rxjs';
3
+ import { fromPartial } from '@total-typescript/shoehorn';
4
+ import {
5
+ DEFAULT_LOOPBACK_RECORDING_DURATION_MS,
6
+ LoopbackStreamsTimeoutError,
7
+ MAX_LOOPBACK_RECORDING_DURATION_MS,
8
+ MIN_LOOPBACK_RECORDING_DURATION_MS,
9
+ clampLoopbackRecordingDuration,
10
+ getLoopbackStreams,
11
+ getLoopbackTracks,
12
+ waitForLoopbackStreams,
13
+ withLoopbackAudioEnabled,
14
+ } from '../loopback';
15
+ import type { Call } from '../../Call';
16
+ import type { StreamVideoParticipant } from '../../types';
17
+
18
+ const track = (kind: 'audio' | 'video', id = `${kind}-track`) =>
19
+ fromPartial<MediaStreamTrack>({ id, kind, enabled: false });
20
+
21
+ const streamOf = (...tracks: MediaStreamTrack[]) =>
22
+ fromPartial<MediaStream>({
23
+ getAudioTracks: () => tracks.filter((t) => t.kind === 'audio'),
24
+ getVideoTracks: () => tracks.filter((t) => t.kind === 'video'),
25
+ });
26
+
27
+ /** Streams the fake subscriber reports as SFU echoes. */
28
+ const echoedStreams = new WeakSet<MediaStream>();
29
+
30
+ /** A stream as it would arrive over the subscriber connection. */
31
+ const echoOf = (...tracks: MediaStreamTrack[]) => {
32
+ const stream = streamOf(...tracks);
33
+ echoedStreams.add(stream);
34
+ return stream;
35
+ };
36
+
37
+ const participantWith = (streams: {
38
+ audioStream?: MediaStream;
39
+ videoStream?: MediaStream;
40
+ }) => fromPartial<StreamVideoParticipant>(streams);
41
+
42
+ const buildCall = (initial?: { participant?: StreamVideoParticipant }) => {
43
+ const participant$ = new BehaviorSubject<StreamVideoParticipant | undefined>(
44
+ initial?.participant,
45
+ );
46
+
47
+ const call = fromPartial<Call>({
48
+ state: {
49
+ get localParticipant() {
50
+ return participant$.getValue();
51
+ },
52
+ localParticipant$: participant$.asObservable(),
53
+ },
54
+ subscriber: {
55
+ isSelfSubscribedStream: (stream: MediaStream | undefined) =>
56
+ !!stream && echoedStreams.has(stream),
57
+ },
58
+ });
59
+
60
+ return { call, participant$ };
61
+ };
62
+
63
+ describe('getLoopbackStreams', () => {
64
+ const { call } = buildCall();
65
+
66
+ it('reads the streams the subscriber echoed back', () => {
67
+ const loopbackVideo = echoOf(track('video', 'loopback-video'));
68
+ const loopbackAudio = echoOf(track('audio', 'loopback-audio'));
69
+
70
+ const result = getLoopbackStreams(
71
+ call,
72
+ participantWith({
73
+ videoStream: loopbackVideo,
74
+ audioStream: loopbackAudio,
75
+ }),
76
+ );
77
+
78
+ expect(result.loopbackVideoStream).toBe(loopbackVideo);
79
+ expect(result.loopbackAudioStream).toBe(loopbackAudio);
80
+ });
81
+
82
+ it('ignores the capture streams the publishing path writes', () => {
83
+ const result = getLoopbackStreams(
84
+ call,
85
+ participantWith({
86
+ videoStream: streamOf(track('video')),
87
+ audioStream: streamOf(track('audio')),
88
+ }),
89
+ );
90
+
91
+ expect(result.loopbackVideoStream).toBeUndefined();
92
+ expect(result.loopbackAudioStream).toBeUndefined();
93
+ });
94
+
95
+ it('handles a missing participant', () => {
96
+ const result = getLoopbackStreams(call, undefined);
97
+ expect(result.loopbackVideoStream).toBeUndefined();
98
+ expect(result.loopbackAudioStream).toBeUndefined();
99
+ });
100
+
101
+ it('handles a call without a subscriber connection', () => {
102
+ const result = getLoopbackStreams(
103
+ fromPartial<Call>({}),
104
+ participantWith({ audioStream: echoOf(track('audio')) }),
105
+ );
106
+ expect(result.loopbackAudioStream).toBeUndefined();
107
+ });
108
+ });
109
+
110
+ describe('getLoopbackTracks', () => {
111
+ const { call } = buildCall();
112
+ const videoStream = echoOf(track('video', 'loopback-video'));
113
+ const audioStream = echoOf(track('audio', 'loopback-audio'));
114
+
115
+ it('resolves both tracks when present', () => {
116
+ const result = getLoopbackTracks(
117
+ call,
118
+ participantWith({ videoStream, audioStream }),
119
+ true,
120
+ );
121
+
122
+ expect(result?.audioTrack?.id).toBe('loopback-audio');
123
+ expect(result?.videoTrack?.id).toBe('loopback-video');
124
+ });
125
+
126
+ it('returns undefined while audio is missing', () => {
127
+ const result = getLoopbackTracks(
128
+ call,
129
+ participantWith({ videoStream }),
130
+ true,
131
+ );
132
+ expect(result).toBeUndefined();
133
+ });
134
+
135
+ it('returns undefined while video is missing and video is required', () => {
136
+ const result = getLoopbackTracks(
137
+ call,
138
+ participantWith({ audioStream }),
139
+ true,
140
+ );
141
+ expect(result).toBeUndefined();
142
+ });
143
+
144
+ it('returns undefined while only the capture streams are assigned', () => {
145
+ const result = getLoopbackTracks(
146
+ call,
147
+ participantWith({
148
+ audioStream: streamOf(track('audio')),
149
+ videoStream: streamOf(track('video')),
150
+ }),
151
+ true,
152
+ );
153
+ expect(result).toBeUndefined();
154
+ });
155
+
156
+ it('resolves on audio alone when includeVideo is false', () => {
157
+ const result = getLoopbackTracks(
158
+ call,
159
+ participantWith({ audioStream }),
160
+ false,
161
+ );
162
+
163
+ expect(result?.audioTrack?.id).toBe('loopback-audio');
164
+ expect(result?.videoTrack).toBeUndefined();
165
+ });
166
+ });
167
+
168
+ describe('waitForLoopbackStreams', () => {
169
+ afterEach(() => {
170
+ vi.useRealTimers();
171
+ });
172
+
173
+ const loopbackAudio = echoOf(track('audio', 'loopback-audio'));
174
+ const loopbackVideo = echoOf(track('video', 'loopback-video'));
175
+
176
+ it('resolves from the synchronous snapshot without waiting', async () => {
177
+ const { call } = buildCall({
178
+ participant: participantWith({
179
+ audioStream: loopbackAudio,
180
+ videoStream: loopbackVideo,
181
+ }),
182
+ });
183
+
184
+ const result = await waitForLoopbackStreams(call, {
185
+ includeVideo: true,
186
+ signal: new AbortController().signal,
187
+ });
188
+
189
+ expect(result?.audioTrack?.id).toBe('loopback-audio');
190
+ });
191
+
192
+ it('resolves once the echoed streams replace the capture ones', async () => {
193
+ const { call, participant$ } = buildCall({
194
+ participant: participantWith({
195
+ audioStream: streamOf(track('audio')),
196
+ videoStream: streamOf(track('video')),
197
+ }),
198
+ });
199
+
200
+ const pending = waitForLoopbackStreams(call, {
201
+ includeVideo: true,
202
+ signal: new AbortController().signal,
203
+ });
204
+
205
+ participant$.next(
206
+ participantWith({
207
+ audioStream: loopbackAudio,
208
+ videoStream: loopbackVideo,
209
+ }),
210
+ );
211
+
212
+ await expect(pending).resolves.toMatchObject({
213
+ audioTrack: { id: 'loopback-audio' },
214
+ videoTrack: { id: 'loopback-video' },
215
+ });
216
+ });
217
+
218
+ it('resolves null when the signal aborts', async () => {
219
+ const { call } = buildCall();
220
+ const controller = new AbortController();
221
+
222
+ const pending = waitForLoopbackStreams(call, {
223
+ includeVideo: true,
224
+ signal: controller.signal,
225
+ });
226
+ controller.abort();
227
+
228
+ await expect(pending).resolves.toBeNull();
229
+ });
230
+
231
+ it('resolves null when the signal is already aborted', async () => {
232
+ const { call } = buildCall();
233
+ const controller = new AbortController();
234
+ controller.abort();
235
+
236
+ await expect(
237
+ waitForLoopbackStreams(call, {
238
+ includeVideo: true,
239
+ signal: controller.signal,
240
+ }),
241
+ ).resolves.toBeNull();
242
+ });
243
+
244
+ it('rejects with a timeout error once timeoutMs elapses', async () => {
245
+ vi.useFakeTimers();
246
+ const { call } = buildCall();
247
+
248
+ const pending = waitForLoopbackStreams(call, {
249
+ includeVideo: true,
250
+ signal: new AbortController().signal,
251
+ timeoutMs: 1000,
252
+ });
253
+
254
+ vi.advanceTimersByTime(1000);
255
+
256
+ await expect(pending).rejects.toBeInstanceOf(LoopbackStreamsTimeoutError);
257
+ });
258
+
259
+ it('stops observing after resolving, and ignores later emissions', async () => {
260
+ vi.useFakeTimers();
261
+ const { call, participant$ } = buildCall({
262
+ participant: participantWith({
263
+ audioStream: streamOf(track('audio')),
264
+ videoStream: streamOf(track('video')),
265
+ }),
266
+ });
267
+
268
+ const pending = waitForLoopbackStreams(call, {
269
+ includeVideo: false,
270
+ signal: new AbortController().signal,
271
+ timeoutMs: 1000,
272
+ });
273
+
274
+ participant$.next(participantWith({ audioStream: loopbackAudio }));
275
+ await expect(pending).resolves.toMatchObject({
276
+ audioTrack: { id: 'loopback-audio' },
277
+ });
278
+
279
+ expect(participant$.observed).toBe(false);
280
+ expect(() => {
281
+ participant$.next(undefined);
282
+ vi.advanceTimersByTime(5000);
283
+ }).not.toThrow();
284
+ });
285
+
286
+ it('stops observing after aborting', async () => {
287
+ const { call, participant$ } = buildCall();
288
+ const controller = new AbortController();
289
+
290
+ const pending = waitForLoopbackStreams(call, {
291
+ includeVideo: true,
292
+ signal: controller.signal,
293
+ });
294
+ controller.abort();
295
+ await pending;
296
+
297
+ expect(participant$.observed).toBe(false);
298
+ });
299
+ });
300
+
301
+ describe('withLoopbackAudioEnabled', () => {
302
+ it('enables the audio track for the duration and restores it', async () => {
303
+ const audioTrack = track('audio');
304
+ let enabledDuringCall = false;
305
+
306
+ await withLoopbackAudioEnabled({ audioTrack }, async () => {
307
+ enabledDuringCall = audioTrack.enabled;
308
+ });
309
+
310
+ expect(enabledDuringCall).toBe(true);
311
+ expect(audioTrack.enabled).toBe(false);
312
+ });
313
+
314
+ it('restores the previous value when the callback throws', async () => {
315
+ const audioTrack = track('audio');
316
+
317
+ await expect(
318
+ withLoopbackAudioEnabled({ audioTrack }, async () => {
319
+ throw new Error('boom');
320
+ }),
321
+ ).rejects.toThrow('boom');
322
+
323
+ expect(audioTrack.enabled).toBe(false);
324
+ });
325
+
326
+ it('restores a previously-enabled track to enabled', async () => {
327
+ const audioTrack = fromPartial<MediaStreamTrack>({
328
+ id: 'audio-track',
329
+ kind: 'audio',
330
+ enabled: true,
331
+ });
332
+
333
+ await withLoopbackAudioEnabled({ audioTrack }, async () => {});
334
+
335
+ expect(audioTrack.enabled).toBe(true);
336
+ });
337
+
338
+ it('is a no-op without an audio track, and still returns the value', async () => {
339
+ await expect(
340
+ withLoopbackAudioEnabled({}, async () => 'result'),
341
+ ).resolves.toBe('result');
342
+ });
343
+ });
344
+
345
+ describe('clampLoopbackRecordingDuration', () => {
346
+ it('raises durations below the minimum', () => {
347
+ expect(clampLoopbackRecordingDuration(0)).toBe(
348
+ MIN_LOOPBACK_RECORDING_DURATION_MS,
349
+ );
350
+ });
351
+
352
+ it('lowers durations above the maximum', () => {
353
+ expect(clampLoopbackRecordingDuration(10 * 60 * 1000)).toBe(
354
+ MAX_LOOPBACK_RECORDING_DURATION_MS,
355
+ );
356
+ });
357
+
358
+ it('keeps in-range durations', () => {
359
+ expect(
360
+ clampLoopbackRecordingDuration(DEFAULT_LOOPBACK_RECORDING_DURATION_MS),
361
+ ).toBe(DEFAULT_LOOPBACK_RECORDING_DURATION_MS);
362
+ });
363
+
364
+ it('rounds fractional durations', () => {
365
+ expect(clampLoopbackRecordingDuration(10_000.6)).toBe(10_001);
366
+ });
367
+ });
@@ -45,6 +45,30 @@ export const withCancellation = createRunner(wrapWithCancellation);
45
45
 
46
46
  const pendingPromises = new Map<TagKey, PendingPromise>();
47
47
 
48
+ /**
49
+ * Wraps an async function so concurrent calls share the in-flight promise
50
+ * instead of queueing another execution.
51
+ *
52
+ * @param cb Async function to wrap.
53
+ * @returns Wrapped async function.
54
+ */
55
+ export function singleFlight<P extends unknown[], T>(
56
+ cb: (...args: P) => Promise<T>,
57
+ ): (...args: P) => Promise<T> {
58
+ let pending: Promise<T> | undefined;
59
+ return (...args: P): Promise<T> => {
60
+ if (pending) return pending;
61
+
62
+ const promise = cb(...args).finally(() => {
63
+ if (pending === promise) {
64
+ pending = undefined;
65
+ }
66
+ });
67
+ pending = promise;
68
+ return promise;
69
+ };
70
+ }
71
+
48
72
  export function hasPending(tag: TagKey) {
49
73
  return pendingPromises.has(tag);
50
74
  }
@@ -1,5 +1,7 @@
1
1
  import { videoLoggerSystem } from '../logger';
2
2
 
3
- export const ensureExhausted = (x: never, message: string) => {
4
- videoLoggerSystem.getLogger('helpers').warn(message, x);
3
+ export const ensureExhausted = (x: never, message?: string) => {
4
+ if (message) {
5
+ videoLoggerSystem.getLogger('helpers').warn(message, x);
6
+ }
5
7
  };
@@ -0,0 +1,200 @@
1
+ import { Subscription } from 'rxjs';
2
+ import type { Call } from '../Call';
3
+ import type { StreamVideoParticipant } from '../types';
4
+
5
+ /** Default loopback stream wait timeout. */
6
+ export const LOOPBACK_STREAMS_WAIT_TIMEOUT_MS = 10 * 1000;
7
+
8
+ /** Default loopback recording duration. */
9
+ export const DEFAULT_LOOPBACK_RECORDING_DURATION_MS = 10 * 1000;
10
+
11
+ /** Shortest permitted loopback recording duration. */
12
+ export const MIN_LOOPBACK_RECORDING_DURATION_MS = 5 * 1000;
13
+
14
+ /** Longest permitted loopback recording duration. */
15
+ export const MAX_LOOPBACK_RECORDING_DURATION_MS = 2 * 60 * 1000;
16
+
17
+ /** SFU-echoed streams on the local participant. */
18
+ export type LoopbackStreams = {
19
+ loopbackVideoStream?: MediaStream;
20
+ loopbackAudioStream?: MediaStream;
21
+ };
22
+
23
+ /** SFU-echoed tracks on the local participant. */
24
+ export type LoopbackTracks = {
25
+ audioTrack?: MediaStreamTrack;
26
+ videoTrack?: MediaStreamTrack;
27
+ };
28
+
29
+ /** Thrown when loopback tracks do not arrive in time. */
30
+ export class LoopbackStreamsTimeoutError extends Error {
31
+ constructor() {
32
+ super(
33
+ 'Timed out waiting for loopback streams. Ensure the call was joined ' +
34
+ 'with `allowOwnTracksLoopback: true` and that the SFU is configured ' +
35
+ 'to echo self-sub tracks.',
36
+ );
37
+ this.name = 'LoopbackStreamsTimeoutError';
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Returns the streams on the given participant that the SFU echoed back to us,
43
+ * ignoring the local capture streams the publishing path writes to the same
44
+ * fields.
45
+ */
46
+ export const getLoopbackStreams = (
47
+ call: Call | undefined,
48
+ participant: StreamVideoParticipant | undefined,
49
+ ): LoopbackStreams => {
50
+ const { subscriber } = call ?? {};
51
+ const { audioStream, videoStream } = participant ?? {};
52
+ return {
53
+ loopbackVideoStream: subscriber?.isSelfSubscribedStream(videoStream)
54
+ ? videoStream
55
+ : undefined,
56
+ loopbackAudioStream: subscriber?.isSelfSubscribedStream(audioStream)
57
+ ? audioStream
58
+ : undefined,
59
+ };
60
+ };
61
+
62
+ /**
63
+ * Returns loopback tracks when all required tracks are present.
64
+ *
65
+ * Prefer {@link waitForLoopbackStreams}, which observes call state until the
66
+ * tracks arrive. This is the synchronous snapshot it is built from.
67
+ *
68
+ * @internal
69
+ */
70
+ export const getLoopbackTracks = (
71
+ call: Call | undefined,
72
+ participant: StreamVideoParticipant | undefined,
73
+ includeVideo: boolean,
74
+ ): LoopbackTracks | undefined => {
75
+ if (!participant) return undefined;
76
+
77
+ const { loopbackAudioStream, loopbackVideoStream } = getLoopbackStreams(
78
+ call,
79
+ participant,
80
+ );
81
+
82
+ const audioTrack = loopbackAudioStream?.getAudioTracks()[0];
83
+ const videoTrack = includeVideo
84
+ ? loopbackVideoStream?.getVideoTracks()[0]
85
+ : undefined;
86
+
87
+ if (!audioTrack || (includeVideo && !videoTrack)) {
88
+ return undefined;
89
+ }
90
+
91
+ return { audioTrack, videoTrack };
92
+ };
93
+
94
+ export type WaitForLoopbackStreamsOptions = {
95
+ /** Whether video is required. Audio is always required. */
96
+ includeVideo: boolean;
97
+ /** Aborts the wait. */
98
+ signal: AbortSignal;
99
+ /** Defaults to {@link LOOPBACK_STREAMS_WAIT_TIMEOUT_MS}. */
100
+ timeoutMs?: number;
101
+ };
102
+
103
+ /** Waits for the requested loopback tracks. */
104
+ export const waitForLoopbackStreams = (
105
+ call: Call,
106
+ opts: WaitForLoopbackStreamsOptions,
107
+ ): Promise<LoopbackTracks | null> => {
108
+ const callState = call.state;
109
+ const {
110
+ includeVideo,
111
+ signal,
112
+ timeoutMs = LOOPBACK_STREAMS_WAIT_TIMEOUT_MS,
113
+ } = opts;
114
+
115
+ return new Promise((resolve, reject) => {
116
+ const initial = getLoopbackTracks(
117
+ call,
118
+ callState.localParticipant,
119
+ includeVideo,
120
+ );
121
+ if (initial) {
122
+ resolve(initial);
123
+ return;
124
+ }
125
+
126
+ if (signal.aborted) {
127
+ resolve(null);
128
+ return;
129
+ }
130
+
131
+ let settled = false;
132
+ const subscriptions = new Subscription();
133
+
134
+ const cleanup = () => {
135
+ if (settled) return;
136
+ settled = true;
137
+ clearTimeout(timeoutId);
138
+ signal.removeEventListener('abort', onAbort);
139
+ subscriptions.unsubscribe();
140
+ };
141
+
142
+ const onAbort = () => {
143
+ cleanup();
144
+ resolve(null);
145
+ };
146
+
147
+ const timeoutId = setTimeout(() => {
148
+ cleanup();
149
+ reject(new LoopbackStreamsTimeoutError());
150
+ }, timeoutMs);
151
+
152
+ signal.addEventListener('abort', onAbort);
153
+
154
+ subscriptions.add(
155
+ callState.localParticipant$.subscribe((participant) => {
156
+ const ready = getLoopbackTracks(call, participant, includeVideo);
157
+ if (ready) {
158
+ cleanup();
159
+ resolve(ready);
160
+ }
161
+ }),
162
+ );
163
+ });
164
+ };
165
+
166
+ /** Enables loopback audio while `fn` runs, then restores it. */
167
+ export const withLoopbackAudioEnabled = async <T>(
168
+ tracks: LoopbackTracks,
169
+ fn: () => Promise<T>,
170
+ ): Promise<T> => {
171
+ const restore = enableLoopbackAudio(tracks);
172
+ try {
173
+ return await fn();
174
+ } finally {
175
+ restore();
176
+ }
177
+ };
178
+
179
+ /** Enables loopback audio and returns a restore function. */
180
+ export const enableLoopbackAudio = (tracks: LoopbackTracks): (() => void) => {
181
+ const { audioTrack } = tracks;
182
+ const previous = audioTrack?.enabled ?? false;
183
+ if (audioTrack) {
184
+ audioTrack.enabled = true;
185
+ }
186
+ return () => {
187
+ if (audioTrack) {
188
+ audioTrack.enabled = previous;
189
+ }
190
+ };
191
+ };
192
+
193
+ /** Clamps and rounds a loopback recording duration. */
194
+ export const clampLoopbackRecordingDuration = (durationMs: number): number =>
195
+ Math.round(
196
+ Math.min(
197
+ MAX_LOOPBACK_RECORDING_DURATION_MS,
198
+ Math.max(MIN_LOOPBACK_RECORDING_DURATION_MS, durationMs),
199
+ ),
200
+ );