@stream-io/video-client 1.49.0 → 1.51.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 (85) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/index.browser.es.js +1404 -682
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1404 -682
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1404 -682
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +43 -3
  9. package/dist/src/coordinator/connection/client.d.ts +1 -1
  10. package/dist/src/coordinator/connection/connection.d.ts +31 -25
  11. package/dist/src/coordinator/connection/types.d.ts +14 -0
  12. package/dist/src/coordinator/connection/utils.d.ts +1 -0
  13. package/dist/src/devices/CameraManager.d.ts +1 -0
  14. package/dist/src/devices/DeviceManager.d.ts +23 -0
  15. package/dist/src/devices/DeviceManagerState.d.ts +0 -1
  16. package/dist/src/devices/VirtualDevice.d.ts +59 -0
  17. package/dist/src/devices/devicePersistence.d.ts +1 -1
  18. package/dist/src/devices/index.d.ts +1 -0
  19. package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
  20. package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
  21. package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
  22. package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
  23. package/dist/src/helpers/DynascaleManager.d.ts +8 -86
  24. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
  25. package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
  26. package/dist/src/helpers/ViewportTracker.d.ts +11 -17
  27. package/dist/src/helpers/browsers.d.ts +13 -0
  28. package/dist/src/helpers/concurrency.d.ts +6 -4
  29. package/dist/src/rtc/BasePeerConnection.d.ts +7 -2
  30. package/dist/src/rtc/Publisher.d.ts +38 -3
  31. package/dist/src/rtc/Subscriber.d.ts +1 -0
  32. package/dist/src/rtc/TransceiverCache.d.ts +5 -1
  33. package/dist/src/rtc/helpers/degradationPreference.d.ts +3 -0
  34. package/dist/src/rtc/types.d.ts +2 -0
  35. package/dist/src/stats/rtc/types.d.ts +1 -1
  36. package/dist/src/store/rxUtils.d.ts +9 -0
  37. package/dist/src/types.d.ts +18 -0
  38. package/package.json +2 -2
  39. package/src/Call.ts +111 -33
  40. package/src/__tests__/Call.lifecycle.test.ts +67 -0
  41. package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
  42. package/src/coordinator/connection/client.ts +1 -1
  43. package/src/coordinator/connection/connection.ts +149 -96
  44. package/src/coordinator/connection/types.ts +15 -0
  45. package/src/coordinator/connection/utils.ts +15 -0
  46. package/src/devices/CameraManager.ts +9 -2
  47. package/src/devices/DeviceManager.ts +239 -39
  48. package/src/devices/DeviceManagerState.ts +4 -2
  49. package/src/devices/VirtualDevice.ts +69 -0
  50. package/src/devices/__tests__/CameraManager.test.ts +19 -0
  51. package/src/devices/__tests__/DeviceManager.test.ts +404 -1
  52. package/src/devices/__tests__/mocks.ts +2 -0
  53. package/src/devices/devicePersistence.ts +2 -1
  54. package/src/devices/index.ts +1 -0
  55. package/src/gen/video/sfu/event/events.ts +15 -0
  56. package/src/gen/video/sfu/models/models.ts +44 -0
  57. package/src/helpers/AudioBindingsWatchdog.ts +10 -7
  58. package/src/helpers/BlockedAudioTracker.ts +74 -0
  59. package/src/helpers/DynascaleManager.ts +46 -337
  60. package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
  61. package/src/helpers/TrackSubscriptionManager.ts +243 -0
  62. package/src/helpers/ViewportTracker.ts +74 -19
  63. package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
  64. package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
  65. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -0
  66. package/src/helpers/__tests__/TrackSubscriptionManager.test.ts +310 -0
  67. package/src/helpers/__tests__/ViewportTracker.test.ts +83 -0
  68. package/src/helpers/__tests__/browsers.test.ts +85 -1
  69. package/src/helpers/browsers.ts +24 -0
  70. package/src/helpers/concurrency.ts +9 -10
  71. package/src/rtc/BasePeerConnection.ts +15 -3
  72. package/src/rtc/Publisher.ts +185 -40
  73. package/src/rtc/Subscriber.ts +42 -14
  74. package/src/rtc/TransceiverCache.ts +10 -3
  75. package/src/rtc/__tests__/Call.reconnect.test.ts +121 -2
  76. package/src/rtc/__tests__/Publisher.test.ts +747 -88
  77. package/src/rtc/__tests__/Subscriber.test.ts +148 -3
  78. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
  79. package/src/rtc/helpers/__tests__/degradationPreference.test.ts +55 -0
  80. package/src/rtc/helpers/degradationPreference.ts +40 -0
  81. package/src/rtc/types.ts +2 -0
  82. package/src/stats/rtc/types.ts +1 -0
  83. package/src/store/__tests__/rxUtils.test.ts +276 -0
  84. package/src/store/rxUtils.ts +19 -0
  85. package/src/types.ts +19 -0
@@ -28,10 +28,11 @@ vi.mock('../../StreamSfuClient', () => {
28
28
  describe('Subscriber', () => {
29
29
  let sfuClient: StreamSfuClient;
30
30
  let subscriber: Subscriber;
31
- const state = new CallState();
31
+ let state: CallState;
32
32
  let dispatcher: Dispatcher;
33
33
 
34
34
  beforeEach(() => {
35
+ state = new CallState();
35
36
  dispatcher = new Dispatcher();
36
37
  sfuClient = new StreamSfuClient({
37
38
  dispatcher,
@@ -63,11 +64,11 @@ describe('Subscriber', () => {
63
64
  });
64
65
  });
65
66
 
66
- afterEach(() => {
67
+ afterEach(async () => {
67
68
  vi.useRealTimers();
68
69
  vi.clearAllMocks();
69
70
  vi.resetModules();
70
- subscriber.dispose();
71
+ await subscriber.dispose();
71
72
  });
72
73
 
73
74
  describe('Subscriber ICE restart', () => {
@@ -272,6 +273,150 @@ describe('Subscriber', () => {
272
273
  });
273
274
  });
274
275
 
276
+ describe('interruptedTracks', () => {
277
+ const setup = ({ muted = false }: { muted?: boolean } = {}) => {
278
+ const mediaStream = new MediaStream();
279
+ const track = new MediaStreamTrack();
280
+ // @ts-expect-error - mock
281
+ mediaStream.id = 'lookup:TRACK_TYPE_AUDIO';
282
+ // @ts-expect-error - mock
283
+ track.kind = 'audio';
284
+ Object.defineProperty(track, 'muted', {
285
+ configurable: true,
286
+ get: () => muted,
287
+ });
288
+ // @ts-expect-error - incomplete mock
289
+ state.updateOrAddParticipant('session-id', {
290
+ sessionId: 'session-id',
291
+ trackLookupPrefix: 'lookup',
292
+ });
293
+
294
+ const onTrack = subscriber['handleOnTrack'];
295
+ // @ts-expect-error - incomplete mock
296
+ onTrack({ streams: [mediaStream], track });
297
+
298
+ const calls = (track.addEventListener as ReturnType<typeof vi.fn>).mock
299
+ .calls;
300
+ const handlers: Record<string, () => void> = {};
301
+ for (const [event, handler] of calls) {
302
+ handlers[event] = handler as () => void;
303
+ }
304
+ return { track, handlers };
305
+ };
306
+
307
+ const interruptedFor = (sessionId: string) =>
308
+ state.participants.find((p) => p.sessionId === sessionId)
309
+ ?.interruptedTracks ?? [];
310
+
311
+ it('adds the track type when the mute handler fires', () => {
312
+ const { handlers } = setup();
313
+ expect(interruptedFor('session-id')).toEqual([]);
314
+
315
+ handlers['mute']();
316
+
317
+ expect(interruptedFor('session-id')).toEqual([TrackType.AUDIO]);
318
+ });
319
+
320
+ it('removes the track type when the unmute handler fires', () => {
321
+ const { handlers } = setup();
322
+ handlers['mute']();
323
+ expect(interruptedFor('session-id')).toEqual([TrackType.AUDIO]);
324
+
325
+ handlers['unmute']();
326
+
327
+ expect(interruptedFor('session-id')).toEqual([]);
328
+ });
329
+
330
+ it('seeds the track type when the track arrives already muted', () => {
331
+ setup({ muted: true });
332
+
333
+ expect(interruptedFor('session-id')).toEqual([TrackType.AUDIO]);
334
+ });
335
+
336
+ it('clears the track type when the track ends', () => {
337
+ const { handlers } = setup();
338
+ handlers['mute']();
339
+ expect(interruptedFor('session-id')).toEqual([TrackType.AUDIO]);
340
+
341
+ handlers['ended']();
342
+
343
+ expect(interruptedFor('session-id')).toEqual([]);
344
+ });
345
+
346
+ it('ignores non-audio remote tracks to avoid Dynascale false positives', () => {
347
+ // Remote video track.muted is dominated by viewport-driven
348
+ // SFU unsubscriptions, so we deliberately only track audio
349
+ // interruption on remote participants.
350
+ const mediaStream = new MediaStream();
351
+ const track = new MediaStreamTrack();
352
+ // @ts-expect-error - mock
353
+ mediaStream.id = 'video-lookup:TRACK_TYPE_VIDEO';
354
+ // @ts-expect-error - mock
355
+ track.kind = 'video';
356
+ Object.defineProperty(track, 'muted', {
357
+ configurable: true,
358
+ get: () => true,
359
+ });
360
+ // @ts-expect-error - incomplete mock
361
+ state.updateOrAddParticipant('video-session', {
362
+ sessionId: 'video-session',
363
+ trackLookupPrefix: 'video-lookup',
364
+ });
365
+
366
+ const onTrack = subscriber['handleOnTrack'];
367
+ // @ts-expect-error - incomplete mock
368
+ onTrack({ streams: [mediaStream], track });
369
+
370
+ // Seeded muted track is ignored.
371
+ expect(interruptedFor('video-session')).toEqual([]);
372
+
373
+ // Subsequent mute / unmute events are ignored too.
374
+ const calls = (track.addEventListener as ReturnType<typeof vi.fn>).mock
375
+ .calls;
376
+ const handlers: Record<string, () => void> = {};
377
+ for (const [event, handler] of calls) {
378
+ handlers[event] = handler as () => void;
379
+ }
380
+ handlers['mute']();
381
+ handlers['unmute']();
382
+ expect(interruptedFor('video-session')).toEqual([]);
383
+ });
384
+
385
+ it('does not mutate state for orphaned tracks until associated', () => {
386
+ const mediaStream = new MediaStream();
387
+ const track = new MediaStreamTrack();
388
+ // @ts-expect-error - mock
389
+ mediaStream.id = 'orphan:TRACK_TYPE_AUDIO';
390
+ // @ts-expect-error - mock
391
+ track.kind = 'audio';
392
+
393
+ const onTrack = subscriber['handleOnTrack'];
394
+ // @ts-expect-error - incomplete mock
395
+ onTrack({ streams: [mediaStream], track });
396
+
397
+ const calls = (track.addEventListener as ReturnType<typeof vi.fn>).mock
398
+ .calls;
399
+ const handlers: Record<string, () => void> = {};
400
+ for (const [event, handler] of calls) {
401
+ handlers[event] = handler as () => void;
402
+ }
403
+
404
+ // Orphan: handler fires before the participant exists.
405
+ handlers['mute']();
406
+ expect(state.participants).toEqual([]);
407
+
408
+ // Once the participant is registered, the next event lands.
409
+ // @ts-expect-error - incomplete mock
410
+ state.updateOrAddParticipant('orphan-session', {
411
+ sessionId: 'orphan-session',
412
+ trackLookupPrefix: 'orphan',
413
+ });
414
+ handlers['mute']();
415
+
416
+ expect(interruptedFor('orphan-session')).toEqual([TrackType.AUDIO]);
417
+ });
418
+ });
419
+
275
420
  describe('Negotiation', () => {
276
421
  it('negotiates with the SFU', async () => {
277
422
  sfuClient.sendAnswer = vi.fn();
@@ -16,8 +16,8 @@ const RTCPeerConnectionMock = vi.fn((): Partial<RTCPeerConnection> => {
16
16
  close: vi.fn(),
17
17
  connectionState: 'connected',
18
18
  signalingState: 'stable',
19
- getReceivers: vi.fn(),
20
- getSenders: vi.fn(),
19
+ getReceivers: vi.fn().mockReturnValue([]),
20
+ getSenders: vi.fn().mockReturnValue([]),
21
21
  removeTrack: vi.fn(),
22
22
  };
23
23
  });
@@ -109,6 +109,16 @@ const AudioContextMock = vi.fn((): Partial<AudioContext> => {
109
109
  gain: { value: 1 },
110
110
  } as unknown as GainNode;
111
111
  }),
112
+ // Silent keep-alive node used by DynascaleManager's probe AudioContext.
113
+ createConstantSource: vi.fn(() => {
114
+ return {
115
+ offset: { value: 0 },
116
+ connect: vi.fn((v) => v),
117
+ disconnect: vi.fn(),
118
+ start: vi.fn(),
119
+ stop: vi.fn(),
120
+ } as unknown as ConstantSourceNode;
121
+ }),
112
122
  close: vi.fn(async function () {
113
123
  this.state = 'closed';
114
124
  }),
@@ -119,6 +129,7 @@ const AudioContextMock = vi.fn((): Partial<AudioContext> => {
119
129
  this.sinkId = sinkId;
120
130
  }),
121
131
  addEventListener: vi.fn(),
132
+ removeEventListener: vi.fn(),
122
133
  };
123
134
  });
124
135
  vi.stubGlobal('AudioContext', AudioContextMock);
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { DegradationPreference } from '../../../gen/video/sfu/models/models';
3
+ import {
4
+ fromRTCDegradationPreference,
5
+ toRTCDegradationPreference,
6
+ } from '../degradationPreference';
7
+
8
+ describe('toRTCDegradationPreference', () => {
9
+ it.each([
10
+ [DegradationPreference.BALANCED, 'balanced'],
11
+ [DegradationPreference.MAINTAIN_FRAMERATE, 'maintain-framerate'],
12
+ [DegradationPreference.MAINTAIN_RESOLUTION, 'maintain-resolution'],
13
+ [
14
+ DegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION,
15
+ 'maintain-framerate-and-resolution',
16
+ ],
17
+ ])('maps %s to "%s"', (preference, expected) => {
18
+ expect(toRTCDegradationPreference(preference)).toBe(expected);
19
+ });
20
+
21
+ it('returns undefined for UNSPECIFIED', () => {
22
+ expect(
23
+ toRTCDegradationPreference(DegradationPreference.UNSPECIFIED),
24
+ ).toBeUndefined();
25
+ });
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
+ });
@@ -0,0 +1,40 @@
1
+ import { DegradationPreference } from '../../gen/video/sfu/models/models';
2
+ import { ensureExhausted } from '../../helpers/ensureExhausted';
3
+
4
+ export const toRTCDegradationPreference = (
5
+ preference: DegradationPreference,
6
+ ): RTCDegradationPreference | undefined => {
7
+ switch (preference) {
8
+ case DegradationPreference.BALANCED:
9
+ return 'balanced';
10
+ case DegradationPreference.MAINTAIN_FRAMERATE:
11
+ return 'maintain-framerate';
12
+ case DegradationPreference.MAINTAIN_RESOLUTION:
13
+ return 'maintain-resolution';
14
+ case DegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION:
15
+ // @ts-expect-error not in the typedefs yet
16
+ return 'maintain-framerate-and-resolution';
17
+ case DegradationPreference.UNSPECIFIED:
18
+ return undefined;
19
+ default:
20
+ ensureExhausted(preference, 'Unknown degradation preference');
21
+ }
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
@@ -73,6 +74,7 @@ export type PublishBundle = {
73
74
  publishOption: PublishOption;
74
75
  transceiver: RTCRtpTransceiver;
75
76
  options: TrackPublishOptions;
77
+ videoSender?: VideoSender;
76
78
  };
77
79
 
78
80
  export type TrackLayersCache = {
@@ -16,6 +16,7 @@ export type RTCStatsDataType =
16
16
  | RTCSessionDescriptionInit
17
17
  | (RTCIceCandidateInit | RTCIceCandidate) // addIceCandidate
18
18
  | object
19
+ | number
19
20
  | null
20
21
  | undefined;
21
22
 
@@ -0,0 +1,276 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { BehaviorSubject, Subject, throwError } from 'rxjs';
3
+ import { promiseWithResolvers } from '../../helpers/promise';
4
+ import {
5
+ createSafeAsyncSubscription,
6
+ createSubscription,
7
+ getCurrentValue,
8
+ setCurrentValue,
9
+ setCurrentValueAsync,
10
+ updateValue,
11
+ } from '../rxUtils';
12
+
13
+ describe('getCurrentValue', () => {
14
+ it('returns the current value of a BehaviorSubject', () => {
15
+ const subject = new BehaviorSubject(42);
16
+ expect(getCurrentValue(subject)).toBe(42);
17
+ });
18
+
19
+ it('reflects subsequent emissions', () => {
20
+ const subject = new BehaviorSubject('a');
21
+ subject.next('b');
22
+ expect(getCurrentValue(subject)).toBe('b');
23
+ });
24
+
25
+ it('rethrows errors emitted by the observable', () => {
26
+ const err = new Error('observable failed');
27
+ expect(() => getCurrentValue(throwError(() => err))).toThrow(err);
28
+ });
29
+ });
30
+
31
+ describe('setCurrentValue', () => {
32
+ it('sets a plain value and returns it', () => {
33
+ const subject = new BehaviorSubject(1);
34
+ const result = setCurrentValue(subject, 5);
35
+ expect(result).toBe(5);
36
+ expect(subject.getValue()).toBe(5);
37
+ });
38
+
39
+ it('applies a function patch using the current value', () => {
40
+ const subject = new BehaviorSubject(10);
41
+ const result = setCurrentValue(subject, (n) => n * 2);
42
+ expect(result).toBe(20);
43
+ expect(subject.getValue()).toBe(20);
44
+ });
45
+
46
+ it('emits the new value to subscribers', () => {
47
+ const subject = new BehaviorSubject(0);
48
+ const seen: number[] = [];
49
+ const sub = subject.subscribe((v) => seen.push(v));
50
+ setCurrentValue(subject, 1);
51
+ setCurrentValue(subject, (n) => n + 1);
52
+ sub.unsubscribe();
53
+ expect(seen).toEqual([0, 1, 2]);
54
+ });
55
+ });
56
+
57
+ describe('setCurrentValueAsync', () => {
58
+ it('passes the current value to the update fn and emits the resolved value', async () => {
59
+ const subject = new BehaviorSubject(1);
60
+ const update = vi.fn(async (n: number) => n + 1);
61
+
62
+ const result = await setCurrentValueAsync(subject, update);
63
+
64
+ expect(update).toHaveBeenCalledWith(1);
65
+ expect(result).toBe(2);
66
+ expect(subject.getValue()).toBe(2);
67
+ });
68
+
69
+ it('serializes concurrent calls on the same subject so each sees the previous result', async () => {
70
+ const subject = new BehaviorSubject(0);
71
+ const observed: number[] = [];
72
+
73
+ const append = (delay: number) =>
74
+ setCurrentValueAsync(subject, async (n) => {
75
+ observed.push(n);
76
+ await new Promise((r) => setTimeout(r, delay));
77
+ return n + 1;
78
+ });
79
+
80
+ const [a, b, c] = await Promise.all([append(10), append(0), append(0)]);
81
+
82
+ expect(observed).toEqual([0, 1, 2]);
83
+ expect([a, b, c]).toEqual([1, 2, 3]);
84
+ expect(subject.getValue()).toBe(3);
85
+ });
86
+
87
+ it('does not block updates on a different subject', async () => {
88
+ const a = new BehaviorSubject('a-0');
89
+ const b = new BehaviorSubject('b-0');
90
+
91
+ const gate = promiseWithResolvers();
92
+
93
+ const aPending = setCurrentValueAsync(a, async (v) => {
94
+ await gate.promise;
95
+ return `${v}-done`;
96
+ });
97
+ const bDone = await setCurrentValueAsync(b, async (v) => `${v}-done`);
98
+
99
+ expect(bDone).toBe('b-0-done');
100
+ expect(b.getValue()).toBe('b-0-done');
101
+
102
+ gate.resolve();
103
+ await expect(aPending).resolves.toBe('a-0-done');
104
+ expect(a.getValue()).toBe('a-0-done');
105
+ });
106
+
107
+ it('propagates rejections without emitting and keeps the prior value', async () => {
108
+ const subject = new BehaviorSubject(7);
109
+ const emitted: number[] = [];
110
+ const sub = subject.subscribe((v) => emitted.push(v));
111
+
112
+ const boom = new Error('boom');
113
+ await expect(
114
+ setCurrentValueAsync(subject, async () => {
115
+ throw boom;
116
+ }),
117
+ ).rejects.toBe(boom);
118
+
119
+ expect(subject.getValue()).toBe(7);
120
+ // Only the initial replay from the BehaviorSubject, no second emission.
121
+ expect(emitted).toEqual([7]);
122
+ sub.unsubscribe();
123
+ });
124
+
125
+ it('continues to process queued updates after a rejection', async () => {
126
+ const subject = new BehaviorSubject(0);
127
+
128
+ const failing = setCurrentValueAsync(subject, async () => {
129
+ throw new Error('nope');
130
+ });
131
+ const succeeding = setCurrentValueAsync(subject, async (n) => n + 5);
132
+
133
+ await expect(failing).rejects.toThrow('nope');
134
+ await expect(succeeding).resolves.toBe(5);
135
+ expect(subject.getValue()).toBe(5);
136
+ });
137
+ });
138
+
139
+ describe('updateValue', () => {
140
+ it('returns the previous and new values', () => {
141
+ const subject = new BehaviorSubject(1);
142
+ const { lastValue, value } = updateValue(subject, 2);
143
+ expect(lastValue).toBe(1);
144
+ expect(value).toBe(2);
145
+ expect(subject.getValue()).toBe(2);
146
+ });
147
+
148
+ it('rollback restores the previous value', () => {
149
+ const subject = new BehaviorSubject({ count: 3 });
150
+ const prior = subject.getValue();
151
+
152
+ const { rollback } = updateValue(subject, { count: 99 });
153
+ expect(subject.getValue()).toEqual({ count: 99 });
154
+
155
+ rollback();
156
+ expect(subject.getValue()).toBe(prior);
157
+ });
158
+
159
+ it('accepts a function patch', () => {
160
+ const subject = new BehaviorSubject(10);
161
+ const { value } = updateValue(subject, (n) => n + 5);
162
+ expect(value).toBe(15);
163
+ expect(subject.getValue()).toBe(15);
164
+ });
165
+ });
166
+
167
+ describe('createSubscription', () => {
168
+ it('invokes the handler with every emitted value', () => {
169
+ const subject = new Subject<number>();
170
+ const handler = vi.fn();
171
+ const unsubscribe = createSubscription(subject, handler);
172
+
173
+ subject.next(1);
174
+ subject.next(2);
175
+ unsubscribe();
176
+
177
+ expect(handler).toHaveBeenCalledTimes(2);
178
+ expect(handler).toHaveBeenNthCalledWith(1, 1);
179
+ expect(handler).toHaveBeenNthCalledWith(2, 2);
180
+ });
181
+
182
+ it('stops receiving values after unsubscribe is called', () => {
183
+ const subject = new Subject<number>();
184
+ const handler = vi.fn();
185
+ const unsubscribe = createSubscription(subject, handler);
186
+
187
+ subject.next(1);
188
+ unsubscribe();
189
+ subject.next(2);
190
+
191
+ expect(handler).toHaveBeenCalledTimes(1);
192
+ expect(handler).toHaveBeenCalledWith(1);
193
+ });
194
+
195
+ it('routes errors to the provided onError handler', () => {
196
+ const err = new Error('observable failed');
197
+ const onError = vi.fn();
198
+ createSubscription(
199
+ throwError(() => err),
200
+ vi.fn(),
201
+ onError,
202
+ );
203
+ expect(onError).toHaveBeenCalledWith(err);
204
+ });
205
+
206
+ it('swallows errors via the default onError when none is provided', () => {
207
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
208
+
209
+ expect(() =>
210
+ createSubscription(
211
+ throwError(() => new Error('boom')),
212
+ vi.fn(),
213
+ ),
214
+ ).not.toThrow();
215
+ expect(warn).toHaveBeenCalled();
216
+
217
+ warn.mockRestore();
218
+ });
219
+ });
220
+
221
+ describe('createSafeAsyncSubscription', () => {
222
+ it('runs the async handler for each emission', async () => {
223
+ const subject = new Subject<number>();
224
+ const handler = vi.fn(async () => {});
225
+ const unsubscribe = createSafeAsyncSubscription(subject, handler);
226
+
227
+ subject.next(1);
228
+ subject.next(2);
229
+ await new Promise((r) => setTimeout(r, 0));
230
+
231
+ expect(handler).toHaveBeenCalledTimes(2);
232
+ expect(handler).toHaveBeenNthCalledWith(1, 1);
233
+ expect(handler).toHaveBeenNthCalledWith(2, 2);
234
+ unsubscribe();
235
+ });
236
+
237
+ it('serializes handlers so a slow one blocks the next', async () => {
238
+ const subject = new Subject<number>();
239
+ const events: string[] = [];
240
+ const gate = promiseWithResolvers();
241
+
242
+ const unsubscribe = createSafeAsyncSubscription(subject, async (v) => {
243
+ events.push(`start:${v}`);
244
+ if (v === 1) await gate.promise;
245
+ events.push(`end:${v}`);
246
+ });
247
+
248
+ subject.next(1);
249
+ subject.next(2);
250
+ await new Promise((r) => setTimeout(r, 0));
251
+
252
+ // Second handler hasn't started yet because the first is still in-flight.
253
+ expect(events).toEqual(['start:1']);
254
+
255
+ gate.resolve();
256
+ await new Promise((r) => setTimeout(r, 0));
257
+ await new Promise((r) => setTimeout(r, 0));
258
+
259
+ expect(events).toEqual(['start:1', 'end:1', 'start:2', 'end:2']);
260
+ unsubscribe();
261
+ });
262
+
263
+ it('stops invoking the handler after unsubscribe', async () => {
264
+ const subject = new Subject<number>();
265
+ const handler = vi.fn(async () => {});
266
+ const unsubscribe = createSafeAsyncSubscription(subject, handler);
267
+
268
+ subject.next(1);
269
+ unsubscribe();
270
+ subject.next(2);
271
+ await new Promise((r) => setTimeout(r, 0));
272
+
273
+ expect(handler).toHaveBeenCalledTimes(1);
274
+ expect(handler).toHaveBeenCalledWith(1);
275
+ });
276
+ });
@@ -3,6 +3,7 @@ import { withoutConcurrency } from '../helpers/concurrency';
3
3
  import { videoLoggerSystem } from '../logger';
4
4
 
5
5
  type FunctionPatch<T> = (currentValue: T) => T;
6
+ type AsyncFunctionPatch<T> = (currentValue: T) => Promise<T>;
6
7
 
7
8
  /**
8
9
  * A value or a function which takes the current value and returns a new value.
@@ -59,6 +60,24 @@ export const setCurrentValue = <T>(subject: Subject<T>, update: Patch<T>) => {
59
60
  return next;
60
61
  };
61
62
 
63
+ /**
64
+ * Updates the value of the provided Subject asynchronously.
65
+ * Locks the subject to prevent concurrent updates.
66
+ *
67
+ * @param subject the subject to update.
68
+ * @param update the update to apply to the subject.
69
+ */
70
+ export const setCurrentValueAsync = async <T>(
71
+ subject: Subject<T>,
72
+ update: AsyncFunctionPatch<T>,
73
+ ) => {
74
+ return withoutConcurrency(subject, async () => {
75
+ const next = await update(getCurrentValue(subject));
76
+ subject.next(next);
77
+ return next;
78
+ });
79
+ };
80
+
62
81
  /**
63
82
  * Updates the value of the provided Subject and returns the previous value
64
83
  * and a function to roll back the update.
package/src/types.ts CHANGED
@@ -90,6 +90,25 @@ export interface StreamVideoParticipant extends Participant {
90
90
  */
91
91
  pausedTracks?: TrackType[];
92
92
 
93
+ /**
94
+ * The list of tracks that are currently not producing media.
95
+ *
96
+ * For remote participants this is currently surfaced for `TrackType.AUDIO`
97
+ * only and reflects the receiver-side `RTCRtpReceiver` track `mute`/`unmute`
98
+ * state, so it covers system mute on the sender (OS audio session
99
+ * interruption, etc.), the sender pausing its track, sustained RTP stalls,
100
+ * and SFU drops. Remote video and screen-share interruption is not tracked.
101
+ *
102
+ * For the local participant it reflects the local track `mute`/`unmute`
103
+ * events surfaced by the browser (e.g. bluetooth disconnect, OS-level
104
+ * mic/camera kill switch, iOS audio session interruption).
105
+ *
106
+ * Orthogonal to `publishedTracks`: a track can be in `publishedTracks`
107
+ * AND in `interruptedTracks` (the participant intends to publish, but
108
+ * no media is flowing right now).
109
+ */
110
+ interruptedTracks?: TrackType[];
111
+
93
112
  /**
94
113
  * True if the participant is the local participant.
95
114
  */