@stream-io/video-client 1.44.4 → 1.44.6-beta.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.
@@ -0,0 +1,325 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+
5
+ import '../../rtc/__tests__/mocks/webrtc.mocks';
6
+
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import { AudioBindingsWatchdog } from '../AudioBindingsWatchdog';
9
+ import { Call } from '../../Call';
10
+ import { StreamClient } from '../../coordinator/connection/client';
11
+ import { CallingState, StreamVideoWriteableStateStore } from '../../store';
12
+ import { noopComparator } from '../../sorting';
13
+ import { fromPartial } from '@total-typescript/shoehorn';
14
+
15
+ describe('AudioBindingsWatchdog', () => {
16
+ let watchdog: AudioBindingsWatchdog;
17
+ let call: Call;
18
+
19
+ beforeEach(() => {
20
+ vi.useFakeTimers();
21
+ call = new Call({
22
+ id: 'id',
23
+ type: 'default',
24
+ streamClient: new StreamClient('api-key', {
25
+ devicePersistence: { enabled: false },
26
+ }),
27
+ clientStore: new StreamVideoWriteableStateStore(),
28
+ });
29
+ call.setSortParticipantsBy(noopComparator());
30
+ watchdog = new AudioBindingsWatchdog(call.state, call.tracer);
31
+ });
32
+
33
+ afterEach(() => {
34
+ watchdog.dispose();
35
+ call.leave();
36
+ vi.useRealTimers();
37
+ });
38
+
39
+ const addRemoteParticipant = (
40
+ sessionId: string,
41
+ userId: string,
42
+ streams?: {
43
+ audioStream?: MediaStream;
44
+ screenShareAudioStream?: MediaStream;
45
+ },
46
+ ) => {
47
+ call.state.updateOrAddParticipant(
48
+ sessionId,
49
+ fromPartial({
50
+ userId,
51
+ sessionId,
52
+ publishedTracks: [],
53
+ ...streams,
54
+ }),
55
+ );
56
+ };
57
+
58
+ it('should warn about dangling audio streams when active', () => {
59
+ // @ts-expect-error private property
60
+ const warnSpy = vi.spyOn(watchdog.logger, 'warn');
61
+
62
+ addRemoteParticipant('session-1', 'user-1', {
63
+ audioStream: new MediaStream(),
64
+ });
65
+
66
+ call.state.setCallingState(CallingState.JOINED);
67
+ vi.advanceTimersByTime(3000);
68
+
69
+ expect(warnSpy).toHaveBeenCalledWith(
70
+ expect.stringContaining('Dangling audio bindings detected'),
71
+ );
72
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('user-1'));
73
+ });
74
+
75
+ it('should not warn when all audio elements are bound', () => {
76
+ // @ts-expect-error private property
77
+ const warnSpy = vi.spyOn(watchdog.logger, 'warn');
78
+
79
+ addRemoteParticipant('session-1', 'user-1', {
80
+ audioStream: new MediaStream(),
81
+ });
82
+ watchdog.register(
83
+ document.createElement('audio'),
84
+ 'session-1',
85
+ 'audioTrack',
86
+ );
87
+
88
+ call.state.setCallingState(CallingState.JOINED);
89
+ vi.advanceTimersByTime(3000);
90
+
91
+ expect(warnSpy).not.toHaveBeenCalled();
92
+ });
93
+
94
+ it('should skip local participant', () => {
95
+ // @ts-expect-error private property
96
+ const warnSpy = vi.spyOn(watchdog.logger, 'warn');
97
+
98
+ // @ts-expect-error incomplete data
99
+ call.state.updateOrAddParticipant('local-session', {
100
+ userId: 'local-user',
101
+ sessionId: 'local-session',
102
+ isLocalParticipant: true,
103
+ publishedTracks: [],
104
+ audioStream: new MediaStream(),
105
+ });
106
+
107
+ call.state.setCallingState(CallingState.JOINED);
108
+ vi.advanceTimersByTime(3000);
109
+
110
+ expect(warnSpy).not.toHaveBeenCalled();
111
+ });
112
+
113
+ it('should start on JOINED and stop on non-JOINED state', () => {
114
+ // @ts-expect-error private property
115
+ const warnSpy = vi.spyOn(watchdog.logger, 'warn');
116
+
117
+ addRemoteParticipant('session-1', 'user-1', {
118
+ audioStream: new MediaStream(),
119
+ });
120
+
121
+ call.state.setCallingState(CallingState.JOINED);
122
+ vi.advanceTimersByTime(3000);
123
+ expect(warnSpy).toHaveBeenCalled();
124
+
125
+ warnSpy.mockClear();
126
+
127
+ call.state.setCallingState(CallingState.IDLE);
128
+ vi.advanceTimersByTime(6000);
129
+
130
+ expect(warnSpy).not.toHaveBeenCalled();
131
+ });
132
+
133
+ it('should be disableable via setEnabled', () => {
134
+ // @ts-expect-error private property
135
+ const warnSpy = vi.spyOn(watchdog.logger, 'warn');
136
+
137
+ addRemoteParticipant('session-1', 'user-1', {
138
+ audioStream: new MediaStream(),
139
+ });
140
+
141
+ watchdog.setEnabled(false);
142
+
143
+ call.state.setCallingState(CallingState.JOINED);
144
+ vi.advanceTimersByTime(6000);
145
+
146
+ expect(warnSpy).not.toHaveBeenCalled();
147
+ });
148
+
149
+ it('should re-enable after disabling', () => {
150
+ // @ts-expect-error private property
151
+ const warnSpy = vi.spyOn(watchdog.logger, 'warn');
152
+
153
+ addRemoteParticipant('session-1', 'user-1', {
154
+ audioStream: new MediaStream(),
155
+ });
156
+
157
+ watchdog.setEnabled(false);
158
+ watchdog.setEnabled(true);
159
+
160
+ vi.advanceTimersByTime(3000);
161
+
162
+ expect(warnSpy).toHaveBeenCalledWith(
163
+ expect.stringContaining('Dangling audio bindings detected'),
164
+ );
165
+ });
166
+
167
+ it('should warn when binding a different element to the same key', () => {
168
+ // @ts-expect-error private property
169
+ const warnSpy = vi.spyOn(watchdog.logger, 'warn');
170
+
171
+ const audioElement1 = document.createElement('audio');
172
+ const audioElement2 = document.createElement('audio');
173
+
174
+ watchdog.register(audioElement1, 'session-1', 'audioTrack');
175
+ watchdog.register(audioElement2, 'session-1', 'audioTrack');
176
+
177
+ expect(warnSpy).toHaveBeenCalledWith(
178
+ expect.stringContaining('Audio element already bound'),
179
+ );
180
+ });
181
+
182
+ it('should not warn when re-binding the same element', () => {
183
+ // @ts-expect-error private property
184
+ const warnSpy = vi.spyOn(watchdog.logger, 'warn');
185
+
186
+ const audioElement = document.createElement('audio');
187
+
188
+ watchdog.register(audioElement, 'session-1', 'audioTrack');
189
+ watchdog.register(audioElement, 'session-1', 'audioTrack');
190
+
191
+ expect(warnSpy).not.toHaveBeenCalled();
192
+ });
193
+
194
+ it('unregisterBinding should remove the binding', () => {
195
+ // @ts-expect-error private property
196
+ const warnSpy = vi.spyOn(watchdog.logger, 'warn');
197
+
198
+ addRemoteParticipant('session-1', 'user-1', {
199
+ audioStream: new MediaStream(),
200
+ });
201
+ watchdog.register(
202
+ document.createElement('audio'),
203
+ 'session-1',
204
+ 'audioTrack',
205
+ );
206
+
207
+ call.state.setCallingState(CallingState.JOINED);
208
+ vi.advanceTimersByTime(3000);
209
+ expect(warnSpy).not.toHaveBeenCalled();
210
+
211
+ watchdog.unregister('session-1', 'audioTrack');
212
+ vi.advanceTimersByTime(3000);
213
+
214
+ expect(warnSpy).toHaveBeenCalledWith(
215
+ expect.stringContaining('Dangling audio bindings detected'),
216
+ );
217
+ });
218
+
219
+ it('should warn about dangling screenShareAudioStream', () => {
220
+ // @ts-expect-error private property
221
+ const warnSpy = vi.spyOn(watchdog.logger, 'warn');
222
+
223
+ addRemoteParticipant('session-1', 'user-1', {
224
+ screenShareAudioStream: new MediaStream(),
225
+ });
226
+
227
+ call.state.setCallingState(CallingState.JOINED);
228
+ vi.advanceTimersByTime(3000);
229
+
230
+ expect(warnSpy).toHaveBeenCalledWith(
231
+ expect.stringContaining('Dangling audio bindings detected'),
232
+ );
233
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('user-1'));
234
+ });
235
+
236
+ it('should not warn when screenShareAudio element is bound', () => {
237
+ // @ts-expect-error private property
238
+ const warnSpy = vi.spyOn(watchdog.logger, 'warn');
239
+
240
+ addRemoteParticipant('session-1', 'user-1', {
241
+ screenShareAudioStream: new MediaStream(),
242
+ });
243
+ watchdog.register(
244
+ document.createElement('audio'),
245
+ 'session-1',
246
+ 'screenShareAudioTrack',
247
+ );
248
+
249
+ call.state.setCallingState(CallingState.JOINED);
250
+ vi.advanceTimersByTime(3000);
251
+
252
+ expect(warnSpy).not.toHaveBeenCalled();
253
+ });
254
+
255
+ it('should warn only about the unbound track type', () => {
256
+ // @ts-expect-error private property
257
+ const warnSpy = vi.spyOn(watchdog.logger, 'warn');
258
+
259
+ addRemoteParticipant('session-1', 'user-1', {
260
+ audioStream: new MediaStream(),
261
+ screenShareAudioStream: new MediaStream(),
262
+ });
263
+
264
+ // bind only the regular audio track
265
+ watchdog.register(
266
+ document.createElement('audio'),
267
+ 'session-1',
268
+ 'audioTrack',
269
+ );
270
+
271
+ call.state.setCallingState(CallingState.JOINED);
272
+ vi.advanceTimersByTime(3000);
273
+
274
+ // should still warn because screenShareAudio is unbound
275
+ expect(warnSpy).toHaveBeenCalledWith(
276
+ expect.stringContaining('Dangling audio bindings detected'),
277
+ );
278
+ });
279
+
280
+ it('should not warn when both audio and screenShareAudio are bound', () => {
281
+ // @ts-expect-error private property
282
+ const warnSpy = vi.spyOn(watchdog.logger, 'warn');
283
+
284
+ addRemoteParticipant('session-1', 'user-1', {
285
+ audioStream: new MediaStream(),
286
+ screenShareAudioStream: new MediaStream(),
287
+ });
288
+
289
+ watchdog.register(
290
+ document.createElement('audio'),
291
+ 'session-1',
292
+ 'audioTrack',
293
+ );
294
+ watchdog.register(
295
+ document.createElement('audio'),
296
+ 'session-1',
297
+ 'screenShareAudioTrack',
298
+ );
299
+
300
+ call.state.setCallingState(CallingState.JOINED);
301
+ vi.advanceTimersByTime(3000);
302
+
303
+ expect(warnSpy).not.toHaveBeenCalled();
304
+ });
305
+
306
+ it('dispose should stop the watchdog', () => {
307
+ // @ts-expect-error private property
308
+ const warnSpy = vi.spyOn(watchdog.logger, 'warn');
309
+
310
+ addRemoteParticipant('session-1', 'user-1', {
311
+ audioStream: new MediaStream(),
312
+ });
313
+
314
+ call.state.setCallingState(CallingState.JOINED);
315
+ vi.advanceTimersByTime(3000);
316
+ expect(warnSpy).toHaveBeenCalled();
317
+
318
+ warnSpy.mockClear();
319
+
320
+ watchdog.dispose();
321
+ vi.advanceTimersByTime(6000);
322
+
323
+ expect(warnSpy).not.toHaveBeenCalled();
324
+ });
325
+ });
@@ -618,6 +618,70 @@ describe('DynascaleManager', () => {
618
618
  });
619
619
  });
620
620
 
621
+ it('audio: should register and unregister watchdog binding', () => {
622
+ const watchdog = dynascaleManager.audioBindingsWatchdog!;
623
+ const registerSpy = vi.spyOn(watchdog, 'register');
624
+ const unregisterSpy = vi.spyOn(watchdog, 'unregister');
625
+
626
+ // @ts-expect-error incomplete data
627
+ call.state.updateOrAddParticipant('session-id', {
628
+ userId: 'user-id',
629
+ sessionId: 'session-id',
630
+ publishedTracks: [],
631
+ });
632
+
633
+ const cleanup = dynascaleManager.bindAudioElement(
634
+ document.createElement('audio'),
635
+ 'session-id',
636
+ 'audioTrack',
637
+ );
638
+
639
+ expect(registerSpy).toHaveBeenCalledWith(
640
+ expect.any(HTMLAudioElement),
641
+ 'session-id',
642
+ 'audioTrack',
643
+ );
644
+
645
+ cleanup?.();
646
+
647
+ expect(unregisterSpy).toHaveBeenCalledWith('session-id', 'audioTrack');
648
+ });
649
+
650
+ it('audio: should warn when binding an already-bound session', () => {
651
+ const watchdog = dynascaleManager.audioBindingsWatchdog!;
652
+ // @ts-expect-error private property
653
+ const warnSpy = vi.spyOn(watchdog.logger, 'warn');
654
+
655
+ // @ts-expect-error incomplete data
656
+ call.state.updateOrAddParticipant('session-id', {
657
+ userId: 'user-id',
658
+ sessionId: 'session-id',
659
+ publishedTracks: [],
660
+ });
661
+
662
+ const audioElement1 = document.createElement('audio');
663
+ const audioElement2 = document.createElement('audio');
664
+
665
+ const cleanup1 = dynascaleManager.bindAudioElement(
666
+ audioElement1,
667
+ 'session-id',
668
+ 'audioTrack',
669
+ );
670
+
671
+ const cleanup2 = dynascaleManager.bindAudioElement(
672
+ audioElement2,
673
+ 'session-id',
674
+ 'audioTrack',
675
+ );
676
+
677
+ expect(warnSpy).toHaveBeenCalledWith(
678
+ expect.stringContaining('Audio element already bound'),
679
+ );
680
+
681
+ cleanup1?.();
682
+ cleanup2?.();
683
+ });
684
+
621
685
  it('video: should unsubscribe when element dimensions are zero', () => {
622
686
  // @ts-expect-error incomplete data
623
687
  call.state.updateOrAddParticipant('session-id', {
@@ -42,7 +42,7 @@ export class StreamVideoWriteableStateStore {
42
42
  * The currently connected user.
43
43
  */
44
44
  get connectedUser(): OwnUserResponse | undefined {
45
- return RxUtils.getCurrentValue(this.connectedUserSubject);
45
+ return this.connectedUserSubject.getValue();
46
46
  }
47
47
 
48
48
  /**
package/src/types.ts CHANGED
@@ -23,6 +23,7 @@ import type {
23
23
  import type { Comparator } from './sorting';
24
24
  import type { StreamVideoWriteableStateStore } from './store';
25
25
  import { AxiosError } from 'axios';
26
+ import type { Call } from './Call';
26
27
 
27
28
  export type StreamReaction = Pick<
28
29
  ReactionResponse,
@@ -392,26 +393,68 @@ export type StartCallRecordingFnType = {
392
393
  ): Promise<StartRecordingResponse>;
393
394
  };
394
395
 
396
+ type StreamRNVideoSDKCallManagerRingingParams = {
397
+ isRingingTypeCall: boolean;
398
+ };
399
+
400
+ type StreamRNVideoSDKCallManagerSetupParams =
401
+ StreamRNVideoSDKCallManagerRingingParams & {
402
+ defaultDevice: AudioSettingsRequestDefaultDeviceEnum;
403
+ };
404
+
405
+ type StreamRNVideoSDKEndCallReason =
406
+ /** Call ended by the local user (e.g., hanging up). */
407
+ | 'local'
408
+ /** Call ended by the remote party, or outgoing call was not answered. */
409
+ | 'remote'
410
+ /** Call was rejected/declined by the user. */
411
+ | 'rejected'
412
+ /** Remote party was busy. */
413
+ | 'busy'
414
+ /** Call was answered on another device. */
415
+ | 'answeredElsewhere'
416
+ /** No response to an incoming call. */
417
+ | 'missed'
418
+ /** Call failed due to an error (e.g., network issue). */
419
+ | 'error'
420
+ /** Call was canceled before the remote party could answer. */
421
+ | 'canceled'
422
+ /** Call restricted (e.g., airplane mode, dialing restrictions). */
423
+ | 'restricted'
424
+ /** Unknown or unspecified disconnect reason. */
425
+ | 'unknown';
426
+
427
+ type StreamRNVideoSDKCallingX = {
428
+ joinCall: (call: Call, activeCalls: Call[]) => Promise<void>;
429
+ endCall: (
430
+ call: Call,
431
+ reason?: StreamRNVideoSDKEndCallReason,
432
+ ) => Promise<void>;
433
+ registerOutgoingCall: (call: Call) => Promise<void>;
434
+ };
435
+
395
436
  export type StreamRNVideoSDKGlobals = {
437
+ callingX: StreamRNVideoSDKCallingX;
396
438
  callManager: {
397
439
  /**
398
440
  * Sets up the in call manager.
399
441
  */
400
442
  setup({
401
443
  defaultDevice,
402
- }: {
403
- defaultDevice: AudioSettingsRequestDefaultDeviceEnum;
404
- }): void;
444
+ isRingingTypeCall,
445
+ }: StreamRNVideoSDKCallManagerSetupParams): void;
405
446
 
406
447
  /**
407
448
  * Starts the in call manager.
408
449
  */
409
- start(): void;
450
+ start({
451
+ isRingingTypeCall,
452
+ }: StreamRNVideoSDKCallManagerRingingParams): void;
410
453
 
411
454
  /**
412
455
  * Stops the in call manager.
413
456
  */
414
- stop(): void;
457
+ stop({ isRingingTypeCall }: StreamRNVideoSDKCallManagerRingingParams): void;
415
458
  };
416
459
  permissions: {
417
460
  /**