@stream-io/video-client 1.58.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 (47) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/index.browser.es.js +319 -71
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +329 -69
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.es.js +319 -71
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/src/Call.d.ts +15 -4
  10. package/dist/src/StreamVideoClient.d.ts +9 -1
  11. package/dist/src/devices/CameraManager.d.ts +2 -1
  12. package/dist/src/devices/DeviceManager.d.ts +1 -1
  13. package/dist/src/devices/MicrophoneManager.d.ts +2 -1
  14. package/dist/src/gen/coordinator/index.d.ts +26 -0
  15. package/dist/src/helpers/MediaStreamRecorder.d.ts +26 -0
  16. package/dist/src/helpers/concurrency.d.ts +8 -0
  17. package/dist/src/helpers/loopback.d.ts +55 -0
  18. package/dist/src/reporting/ClientEventReporter.d.ts +0 -2
  19. package/dist/src/rtc/Subscriber.d.ts +5 -0
  20. package/index.ts +2 -0
  21. package/package.json +1 -1
  22. package/src/Call.ts +156 -134
  23. package/src/StreamVideoClient.ts +35 -8
  24. package/src/__tests__/Call.lifecycle.test.ts +33 -2
  25. package/src/__tests__/Call.publishing.test.ts +42 -1
  26. package/src/__tests__/StreamVideoClient.api.test.ts +33 -0
  27. package/src/__tests__/StreamVideoClient.rewatch.test.ts +213 -0
  28. package/src/__tests__/StreamVideoClient.ringing.test.ts +11 -4
  29. package/src/__tests__/StreamVideoClient.test.ts +14 -12
  30. package/src/devices/CameraManager.ts +16 -4
  31. package/src/devices/DeviceManager.ts +7 -4
  32. package/src/devices/MicrophoneManager.ts +11 -3
  33. package/src/devices/__tests__/CameraManager.test.ts +27 -1
  34. package/src/devices/__tests__/DeviceManager.test.ts +49 -1
  35. package/src/devices/__tests__/MicrophoneManager.test.ts +23 -1
  36. package/src/events/call.ts +6 -3
  37. package/src/gen/coordinator/index.ts +26 -0
  38. package/src/helpers/MediaStreamRecorder.ts +104 -0
  39. package/src/helpers/__tests__/MediaStreamRecorder.test.ts +324 -0
  40. package/src/helpers/__tests__/concurrency.test.ts +74 -0
  41. package/src/helpers/__tests__/loopback.test.ts +367 -0
  42. package/src/helpers/concurrency.ts +24 -0
  43. package/src/helpers/loopback.ts +200 -0
  44. package/src/reporting/ClientEventReporter.ts +15 -28
  45. package/src/reporting/__tests__/ClientEventReporter.test.ts +3 -7
  46. package/src/rtc/Subscriber.ts +8 -0
  47. package/src/rtc/__tests__/Subscriber.test.ts +101 -0
@@ -0,0 +1,213 @@
1
+ import '../rtc/__tests__/mocks/webrtc.mocks';
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { StreamVideoClient } from '../StreamVideoClient';
5
+ import { Call } from '../Call';
6
+ import { CallRingPayload } from './data';
7
+ import { settled, withoutConcurrency } from '../helpers/concurrency';
8
+ import { getCallInitConcurrencyTag } from '../helpers/clientUtils';
9
+ import { CallingState } from '../store';
10
+ import type { StreamVideoEvent } from '../coordinator/connection/types';
11
+ import type {
12
+ CallResponse,
13
+ ConnectedEvent,
14
+ GetCallResponse,
15
+ QueryCallsResponse,
16
+ } from '../gen/coordinator';
17
+
18
+ const apiKey = 'mock-api-key';
19
+ // the receiver of the CallRingPayload fixture
20
+ const userId = 'marcelo';
21
+
22
+ const queryCallsResponse = (call: CallResponse): QueryCallsResponse => ({
23
+ duration: '1ms',
24
+ calls: [{ call, members: CallRingPayload.members, own_capabilities: [] }],
25
+ });
26
+
27
+ const countListeners = (client: StreamVideoClient) =>
28
+ Object.values(client.streamClient.listeners).reduce(
29
+ (count, listeners) => count + (listeners?.length ?? 0),
30
+ 0,
31
+ );
32
+
33
+ describe('StreamVideoClient re-watching calls on reconnect', () => {
34
+ let client: StreamVideoClient;
35
+
36
+ beforeEach(async () => {
37
+ client = new StreamVideoClient(apiKey, {
38
+ // tests run in node, so we have to fake being in browser env
39
+ browser: true,
40
+ });
41
+ client.streamClient.connectUser = vi.fn().mockResolvedValue({
42
+ me: { id: userId },
43
+ } as ConnectedEvent);
44
+ await client.connectUser({ id: userId }, 'mock-token');
45
+ });
46
+
47
+ afterEach(() => {
48
+ for (const call of client.state.calls) {
49
+ call['cancelAutoDrop']();
50
+ }
51
+ vi.restoreAllMocks();
52
+ });
53
+
54
+ const setupRingingCall = async () => {
55
+ vi.spyOn(client.streamClient, 'get').mockResolvedValue({
56
+ duration: '1ms',
57
+ call: CallRingPayload.call,
58
+ members: CallRingPayload.members,
59
+ own_capabilities: [],
60
+ } as GetCallResponse);
61
+
62
+ client.streamClient.dispatchEvent(CallRingPayload as StreamVideoEvent);
63
+ await settled(getCallInitConcurrencyTag(CallRingPayload.call_cid));
64
+
65
+ const [call] = client.state.calls;
66
+ expect(call).toBeDefined();
67
+ expect(call.watching).toBe(true);
68
+ expect(call.state.callingState).toBe(CallingState.RINGING);
69
+ return call;
70
+ };
71
+
72
+ const reconnect = () => {
73
+ client.streamClient.dispatchEvent({
74
+ type: 'connection.changed',
75
+ online: true,
76
+ });
77
+ };
78
+
79
+ it('reuses the registered instance and refreshes its state', async () => {
80
+ const call = await setupRingingCall();
81
+ const listenerCountBeforeReconnect = countListeners(client);
82
+
83
+ const post = vi
84
+ .spyOn(client.streamClient, 'post')
85
+ .mockResolvedValue(
86
+ queryCallsResponse({ ...CallRingPayload.call, recording: true }),
87
+ );
88
+
89
+ reconnect();
90
+
91
+ await vi.waitFor(() => expect(post).toHaveBeenCalled());
92
+ expect(post).toHaveBeenCalledWith('/calls', {
93
+ watch: true,
94
+ filter_conditions: { cid: { $in: [call.cid] } },
95
+ });
96
+
97
+ // the refreshed state must reach the instance the integrator holds
98
+ await vi.waitFor(() => expect(call.state.recording).toBe(true));
99
+ expect(client.state.calls).toHaveLength(1);
100
+ expect(client.state.calls[0]).toBe(call);
101
+ expect(call.watching).toBe(true);
102
+
103
+ // re-watching must not leak event handlers (one orphaned Call
104
+ // used to register its handlers on every reconnect)
105
+ expect(countListeners(client)).toBe(listenerCountBeforeReconnect);
106
+ });
107
+
108
+ it('drops a ringing call accepted elsewhere while the socket was down', async () => {
109
+ const call = await setupRingingCall();
110
+ const leave = vi.spyOn(call, 'leave').mockResolvedValue(undefined);
111
+
112
+ const session = CallRingPayload.call.session!;
113
+ vi.spyOn(client.streamClient, 'post').mockResolvedValue(
114
+ queryCallsResponse({
115
+ ...CallRingPayload.call,
116
+ session: {
117
+ ...session,
118
+ accepted_by: { [userId]: '2025-08-14T14:49:00Z' },
119
+ },
120
+ }),
121
+ );
122
+
123
+ reconnect();
124
+
125
+ // accepted on another device -> this device should stop ringing
126
+ await vi.waitFor(() => expect(leave).toHaveBeenCalled());
127
+ });
128
+
129
+ it('queryCalls returns an independent instance for registered calls', async () => {
130
+ // e.g. being on a call while watching a dashboard of calls:
131
+ // leaving the joined instance must not silence the dashboard instance
132
+ const joinedCall = await setupRingingCall();
133
+ vi.spyOn(client.streamClient, 'post').mockResolvedValue(
134
+ queryCallsResponse(CallRingPayload.call),
135
+ );
136
+
137
+ const result = await client.queryCalls({ watch: true });
138
+ const [dashboardCall] = result.calls;
139
+ expect(dashboardCall).not.toBe(joinedCall);
140
+ expect(client.state.calls[0]).toBe(joinedCall);
141
+
142
+ await joinedCall.leave({ reject: false });
143
+
144
+ client.streamClient.dispatchEvent({
145
+ type: 'call.updated',
146
+ call_cid: joinedCall.cid,
147
+ created_at: '2025-08-14T14:50:00Z',
148
+ call: { ...CallRingPayload.call, recording: true },
149
+ } as StreamVideoEvent);
150
+
151
+ await vi.waitFor(() => expect(dashboardCall.state.recording).toBe(true));
152
+ });
153
+
154
+ it('creates and registers a new instance for unknown cids', async () => {
155
+ vi.spyOn(client.streamClient, 'post').mockResolvedValue(
156
+ queryCallsResponse(CallRingPayload.call),
157
+ );
158
+
159
+ const result = await client.queryCalls({ watch: true });
160
+
161
+ expect(result.calls).toHaveLength(1);
162
+ const [call] = result.calls;
163
+ expect(call).toBeInstanceOf(Call);
164
+ expect(call.watching).toBe(true);
165
+ expect(client.state.calls[0]).toBe(call);
166
+ });
167
+
168
+ it('does not resurrect a call that leaves during a re-watch', async () => {
169
+ const listenersBeforeRing = countListeners(client);
170
+ const call = await setupRingingCall();
171
+ const post = vi
172
+ .spyOn(client.streamClient, 'post')
173
+ .mockResolvedValue(queryCallsResponse(CallRingPayload.call));
174
+
175
+ // hold the call's join/leave queue so leave() is still in flight
176
+ // while the re-watch response is being processed
177
+ let releaseGate!: () => void;
178
+ const gate = new Promise<void>((resolve) => (releaseGate = resolve));
179
+ withoutConcurrency(call['joinLeaveConcurrencyTag'], () => gate);
180
+ const leavePromise = call.leave({ reject: false });
181
+
182
+ reconnect();
183
+ await vi.waitFor(() => expect(post).toHaveBeenCalled());
184
+ await Promise.resolve();
185
+
186
+ releaseGate();
187
+ await leavePromise;
188
+
189
+ // the left call must stay dead: not re-registered, not re-initialized,
190
+ // and all of its event handlers released
191
+ expect(client.state.calls).toHaveLength(0);
192
+ expect(call.state.callingState).toBe(CallingState.LEFT);
193
+ expect(countListeners(client)).toBe(listenersBeforeRing);
194
+ });
195
+
196
+ it('creates a fresh instance when the previous one has left', async () => {
197
+ const call = await setupRingingCall();
198
+ const post = vi
199
+ .spyOn(client.streamClient, 'post')
200
+ .mockResolvedValue(queryCallsResponse(CallRingPayload.call));
201
+
202
+ // leaving unregisters the call from the client store
203
+ await call.leave({ reject: false });
204
+ expect(client.state.calls).toHaveLength(0);
205
+
206
+ const result = await client.queryCalls({ watch: true });
207
+ expect(post).toHaveBeenCalled();
208
+ expect(result.calls).toHaveLength(1);
209
+ expect(result.calls[0]).not.toBe(call);
210
+ expect(client.state.calls[0]).toBe(result.calls[0]);
211
+ expect(result.calls[0].watching).toBe(true);
212
+ });
213
+ });
@@ -45,12 +45,21 @@ describe('StreamVideoClient Ringing', () => {
45
45
  ]);
46
46
  };
47
47
 
48
+ const leaveAllCalls = async () => {
49
+ await Promise.all(
50
+ [oliverClient, sachaClient, marceloClient].flatMap((client) =>
51
+ client.state.calls.map((call) => call.leave().catch(() => {})),
52
+ ),
53
+ );
54
+ };
55
+
48
56
  beforeEach(async () => {
49
57
  [oliverClient, sachaClient, marceloClient] =
50
58
  await createClients(testUserIds);
51
59
  });
52
60
 
53
61
  afterEach(async () => {
62
+ await leaveAllCalls();
54
63
  await disconnectClients();
55
64
  });
56
65
 
@@ -275,6 +284,7 @@ describe('StreamVideoClient Ringing', () => {
275
284
  };
276
285
 
277
286
  it('should reject the call when the caller is busy', async () => {
287
+ await disconnectClients();
278
288
  [oliverClient, sachaClient, marceloClient] = await createClients(
279
289
  testUserIds,
280
290
  {
@@ -301,11 +311,10 @@ describe('StreamVideoClient Ringing', () => {
301
311
  marceloCall.cid,
302
312
  );
303
313
  expect(oliverClient.state.calls.length).toBe(1);
304
-
305
- await disconnectClients();
306
314
  });
307
315
 
308
316
  it('should reject the call when the callee is busy', async () => {
317
+ await disconnectClients();
309
318
  [oliverClient, sachaClient, marceloClient] = await createClients(
310
319
  testUserIds,
311
320
  {
@@ -332,8 +341,6 @@ describe('StreamVideoClient Ringing', () => {
332
341
  marceloCall.cid,
333
342
  );
334
343
  expect(sachaClient.state.calls.length).toBe(1);
335
-
336
- await disconnectClients();
337
344
  });
338
345
 
339
346
  it('should allow multiple simultaneous calls to the same caller when rejectCallWhenBusy is false', async () => {
@@ -277,7 +277,7 @@ describe('StreamVideoClient.connectUser retries', () => {
277
277
  });
278
278
 
279
279
  describe('StreamVideoClient.watchCalls retries', () => {
280
- it('should retry queryCalls up to 3 times after reconnecting', async () => {
280
+ it('should retry the re-watch request up to 3 times after reconnecting', async () => {
281
281
  vi.useFakeTimers();
282
282
  const client = new StreamVideoClient(apiKey, {
283
283
  browser: true,
@@ -290,8 +290,8 @@ describe('StreamVideoClient.watchCalls retries', () => {
290
290
  const call = client.call('default', generateUUIDv4());
291
291
  await call.getOrCreate();
292
292
 
293
- const queryCallsSpy = vi
294
- .spyOn(client, 'queryCalls')
293
+ const doQueryCalls = vi
294
+ .spyOn(client as any, 'doQueryCalls')
295
295
  .mockRejectedValueOnce(new Error('fail 1'))
296
296
  .mockRejectedValueOnce(new Error('fail 2'))
297
297
  .mockResolvedValue({
@@ -306,7 +306,7 @@ describe('StreamVideoClient.watchCalls retries', () => {
306
306
 
307
307
  await vi.runAllTimersAsync();
308
308
 
309
- expect(queryCallsSpy).toHaveBeenCalledTimes(3);
309
+ expect(doQueryCalls).toHaveBeenCalledTimes(3);
310
310
 
311
311
  await client.disconnectUser();
312
312
 
@@ -327,8 +327,8 @@ describe('StreamVideoClient.watchCalls retries', () => {
327
327
  const call = client.call('default', generateUUIDv4());
328
328
  await call.getOrCreate();
329
329
 
330
- const queryCallsSpy = vi
331
- .spyOn(client, 'queryCalls')
330
+ const doQueryCalls = vi
331
+ .spyOn(client as any, 'doQueryCalls')
332
332
  .mockRejectedValueOnce(new Error('fail 1'))
333
333
  .mockRejectedValueOnce(new Error('fail 2'))
334
334
  .mockRejectedValueOnce(new Error('fail 3'))
@@ -344,7 +344,7 @@ describe('StreamVideoClient.watchCalls retries', () => {
344
344
 
345
345
  await vi.runAllTimersAsync();
346
346
 
347
- expect(queryCallsSpy).toHaveBeenCalledTimes(3);
347
+ expect(doQueryCalls).toHaveBeenCalledTimes(3);
348
348
 
349
349
  await client.disconnectUser();
350
350
  vi.useRealTimers();
@@ -363,10 +363,12 @@ describe('StreamVideoClient.watchCalls retries', () => {
363
363
  const call = client.call('default', generateUUIDv4());
364
364
  await call.getOrCreate();
365
365
 
366
- const queryCallsSpy = vi.spyOn(client, 'queryCalls').mockResolvedValue({
367
- calls: [],
368
- duration: '0ms',
369
- });
366
+ const doQueryCalls = vi
367
+ .spyOn(client as any, 'doQueryCalls')
368
+ .mockResolvedValue({
369
+ calls: [],
370
+ duration: '0ms',
371
+ });
370
372
 
371
373
  client.streamClient.dispatchEvent({
372
374
  type: 'connection.changed',
@@ -375,7 +377,7 @@ describe('StreamVideoClient.watchCalls retries', () => {
375
377
 
376
378
  await vi.runAllTimersAsync();
377
379
 
378
- expect(queryCallsSpy).toHaveBeenCalledTimes(1);
380
+ expect(doQueryCalls).toHaveBeenCalledTimes(1);
379
381
 
380
382
  await client.disconnectUser();
381
383
  vi.useRealTimers();
@@ -167,8 +167,13 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
167
167
  *
168
168
  * @param settings the video settings to apply.
169
169
  * @param publish whether to publish the stream after applying the settings.
170
+ * @param forceDisabled whether to keep the camera disabled, regardless of server-side or local preferences.
170
171
  */
171
- async apply(settings: VideoSettingsResponse, publish: boolean) {
172
+ async apply(
173
+ settings: VideoSettingsResponse,
174
+ publish: boolean,
175
+ forceDisabled: boolean = false,
176
+ ): Promise<void> {
172
177
  // Wait for any in progress camera operation
173
178
  await this.statusChangeSettled();
174
179
  await this.selectTargetResolution(settings.target_resolution);
@@ -186,8 +191,10 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
186
191
  this.devicePersistence.enabled &&
187
192
  permissionState === 'granted'
188
193
  ) {
189
- persistedPreferencesApplied =
190
- await this.applyPersistedPreferences(enabledInCallType);
194
+ persistedPreferencesApplied = await this.applyPersistedPreferences(
195
+ enabledInCallType,
196
+ forceDisabled,
197
+ );
191
198
  }
192
199
 
193
200
  // apply a direction and enable the camera only if in "pristine" state,
@@ -199,7 +206,12 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
199
206
  await this.selectDirection(direction, { enableCamera: false });
200
207
  }
201
208
 
202
- if (canPublish && settings.camera_default_on && enabledInCallType) {
209
+ if (
210
+ !forceDisabled &&
211
+ canPublish &&
212
+ settings.camera_default_on &&
213
+ enabledInCallType
214
+ ) {
203
215
  await this.enable();
204
216
  }
205
217
  }
@@ -842,7 +842,10 @@ export abstract class DeviceManager<
842
842
  writePreferences(currentDevice, deviceKey, muted, storageKey);
843
843
  }
844
844
 
845
- protected async applyPersistedPreferences(enabledInCallType: boolean) {
845
+ protected async applyPersistedPreferences(
846
+ enabledInCallType: boolean,
847
+ forceDisabled = false,
848
+ ) {
846
849
  const deviceKey: DevicePreferenceKey =
847
850
  this.trackType === TrackType.AUDIO ? 'microphone' : 'camera';
848
851
  const preferences = readPreferences(this.devicePersistence.storageKey);
@@ -875,15 +878,15 @@ export abstract class DeviceManager<
875
878
 
876
879
  const canPublish = this.call.permissionsContext.canPublish(this.trackType);
877
880
  if (typeof muted === 'boolean' && enabledInCallType && canPublish) {
878
- await this.applyMutedState(muted);
881
+ await this.applyMutedState(muted, forceDisabled);
879
882
  appliedMute = true;
880
883
  }
881
884
 
882
885
  return appliedDevice || appliedMute;
883
886
  }
884
887
 
885
- private async applyMutedState(muted: boolean) {
886
- if (this.state.status !== undefined) return;
888
+ private async applyMutedState(muted: boolean, forceDisabled: boolean) {
889
+ if (forceDisabled || this.state.status !== undefined) return;
887
890
  if (muted) {
888
891
  await this.disable();
889
892
  } else {
@@ -388,8 +388,13 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
388
388
  * Applies the audio settings to the microphone.
389
389
  * @param settings the audio settings to apply.
390
390
  * @param publish whether to publish the stream after applying the settings.
391
+ * @param forceDisabled whether to keep the mic disabled, regardless of server-side or local preferences.
391
392
  */
392
- async apply(settings: AudioSettingsResponse, publish: boolean) {
393
+ async apply(
394
+ settings: AudioSettingsResponse,
395
+ publish: boolean,
396
+ forceDisabled: boolean = false,
397
+ ) {
393
398
  // Wait for any in progress mic operation
394
399
  await this.statusChangeSettled();
395
400
 
@@ -407,12 +412,15 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
407
412
  this.devicePersistence.enabled &&
408
413
  permissionState === 'granted'
409
414
  ) {
410
- persistedPreferencesApplied = await this.applyPersistedPreferences(true);
415
+ persistedPreferencesApplied = await this.applyPersistedPreferences(
416
+ true,
417
+ forceDisabled,
418
+ );
411
419
  }
412
420
 
413
421
  const canPublish = this.call.permissionsContext.canPublish(this.trackType);
414
422
  if (shouldApplyDefaults && !persistedPreferencesApplied) {
415
- if (canPublish && settings.mic_default_on) {
423
+ if (!forceDisabled && canPublish && settings.mic_default_on) {
416
424
  await this.enable();
417
425
  }
418
426
  }
@@ -357,11 +357,37 @@ describe('CameraManager', () => {
357
357
  true,
358
358
  );
359
359
 
360
- expect(applySpy).toHaveBeenCalledWith(true);
360
+ expect(applySpy).toHaveBeenCalledWith(true, false);
361
361
  expect(selectDirectionSpy).not.toHaveBeenCalled();
362
362
  expect(enableSpy).not.toHaveBeenCalled();
363
363
  });
364
364
 
365
+ it('should apply persisted device preferences without enabling when forced disabled', async () => {
366
+ vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
367
+ of('granted'),
368
+ );
369
+ const devicePersistence = { enabled: true, storageKey: '' };
370
+ const persistedManager = new CameraManager(call, devicePersistence);
371
+ const applySpy = vi
372
+ .spyOn(persistedManager as never, 'applyPersistedPreferences')
373
+ .mockResolvedValue(true);
374
+ const enableSpy = vi.spyOn(persistedManager, 'enable');
375
+
376
+ await persistedManager.apply(
377
+ fromPartial({
378
+ enabled: true,
379
+ target_resolution: { width: 640, height: 480 },
380
+ camera_facing: 'front',
381
+ camera_default_on: true,
382
+ }),
383
+ false,
384
+ true,
385
+ );
386
+
387
+ expect(applySpy).toHaveBeenCalledWith(true, true);
388
+ expect(enableSpy).not.toHaveBeenCalled();
389
+ });
390
+
365
391
  it('should skip persisted preferences when permission is not granted', async () => {
366
392
  vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
367
393
  of('prompt'),
@@ -21,7 +21,7 @@ import { DeviceManagerState } from '../DeviceManagerState';
21
21
  import { firstValueFrom, of } from 'rxjs';
22
22
  import { TrackType } from '../../gen/video/sfu/models/models';
23
23
  import { PermissionsContext } from '../../permissions';
24
- import { readPreferences } from '../devicePersistence';
24
+ import { defaultDeviceId, readPreferences } from '../devicePersistence';
25
25
 
26
26
  vi.mock('../../reporting/ClientEventReporter', () => ({
27
27
  ClientEventReporter: vi.fn(function () {
@@ -1010,6 +1010,54 @@ describe('Device Manager', () => {
1010
1010
  expect(selectSpy).toHaveBeenCalledWith(mockVideoDevices[1].deviceId);
1011
1011
  });
1012
1012
 
1013
+ it('selects the device without unmuting when forced disabled', async () => {
1014
+ localStorageMock.setItem(
1015
+ storageKey,
1016
+ JSON.stringify({
1017
+ camera: [
1018
+ {
1019
+ selectedDeviceId: mockVideoDevices[0].deviceId,
1020
+ selectedDeviceLabel: mockVideoDevices[0].label,
1021
+ muted: false,
1022
+ },
1023
+ ],
1024
+ }),
1025
+ );
1026
+
1027
+ const selectSpy = vi.spyOn(manager, 'select');
1028
+ const enableSpy = vi.spyOn(manager, 'enable');
1029
+
1030
+ // @ts-expect-error - private API
1031
+ const result = await manager.applyPersistedPreferences(true, true);
1032
+
1033
+ expect(result).toBe(true);
1034
+ expect(selectSpy).toHaveBeenCalledWith(mockVideoDevices[0].deviceId);
1035
+ expect(enableSpy).not.toHaveBeenCalled();
1036
+ });
1037
+
1038
+ it('retains a default-device mute preference when forced disabled', async () => {
1039
+ localStorageMock.setItem(
1040
+ storageKey,
1041
+ JSON.stringify({
1042
+ camera: [
1043
+ {
1044
+ selectedDeviceId: defaultDeviceId,
1045
+ selectedDeviceLabel: '',
1046
+ muted: false,
1047
+ },
1048
+ ],
1049
+ }),
1050
+ );
1051
+
1052
+ const enableSpy = vi.spyOn(manager, 'enable');
1053
+
1054
+ // @ts-expect-error - private API
1055
+ const result = await manager.applyPersistedPreferences(true, true);
1056
+
1057
+ expect(result).toBe(true);
1058
+ expect(enableSpy).not.toHaveBeenCalled();
1059
+ });
1060
+
1013
1061
  it('applies muted state without selecting when default device is stored', async () => {
1014
1062
  localStorageMock.setItem(
1015
1063
  storageKey,
@@ -486,7 +486,29 @@ describe('MicrophoneManager', () => {
486
486
  // @ts-expect-error - partial data
487
487
  await persistedManager.apply({ mic_default_on: true }, true);
488
488
 
489
- expect(applySpy).toHaveBeenCalledWith(true);
489
+ expect(applySpy).toHaveBeenCalledWith(true, false);
490
+ expect(enableSpy).not.toHaveBeenCalled();
491
+ });
492
+
493
+ it('should apply persisted device preferences without enabling when forced disabled', async () => {
494
+ vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
495
+ of('granted'),
496
+ );
497
+ const devicePersistence = { enabled: true, storageKey: '' };
498
+ const persistedManager = new MicrophoneManager(
499
+ call,
500
+ devicePersistence,
501
+ 'disable-tracks',
502
+ );
503
+ const applySpy = vi
504
+ .spyOn(persistedManager as never, 'applyPersistedPreferences')
505
+ .mockResolvedValue(true);
506
+ const enableSpy = vi.spyOn(persistedManager, 'enable');
507
+
508
+ // @ts-expect-error - partial data
509
+ await persistedManager.apply({ mic_default_on: true }, false, true);
510
+
511
+ expect(applySpy).toHaveBeenCalledWith(true, true);
490
512
  expect(enableSpy).not.toHaveBeenCalled();
491
513
  });
492
514
 
@@ -81,7 +81,6 @@ export const watchCallRejected = (call: Call) => {
81
81
  */
82
82
  export const watchCallEnded = (call: Call) => {
83
83
  return function onCallEnded() {
84
- globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
85
84
  const { callingState } = call.state;
86
85
  if (
87
86
  callingState !== CallingState.IDLE &&
@@ -92,9 +91,13 @@ export const watchCallEnded = (call: Call) => {
92
91
  reason: 'call.ended event received',
93
92
  });
94
93
  call
95
- .leave({ message: 'call.ended event received', reject: false })
94
+ .leave({
95
+ message: 'call.ended event received',
96
+ reject: false,
97
+ reason: 'ended',
98
+ })
96
99
  .catch((err) => {
97
- call.logger.error('Failed to leave call after call.ended ', err);
100
+ call.logger.error('Failed to leave call after call.ended', err);
98
101
  });
99
102
  }
100
103
  };
@@ -8468,6 +8468,32 @@ export interface ReportByHistogramBucket {
8468
8468
  */
8469
8469
  upper_bound?: Bound;
8470
8470
  }
8471
+ /**
8472
+ * Reports a batch of client-side telemetry events. Each event is validated and processed independently; one invalid event does not block the rest of the batch.
8473
+ * @export
8474
+ * @interface ReportClientEventRequest
8475
+ */
8476
+ export interface ReportClientEventRequest {
8477
+ /**
8478
+ * Client-side events to report (1-100 per request)
8479
+ * @type {Array<ClientEvent>}
8480
+ * @memberof ReportClientEventRequest
8481
+ */
8482
+ events: Array<ClientEvent>;
8483
+ }
8484
+ /**
8485
+ * Response for reporting client-side telemetry events
8486
+ * @export
8487
+ * @interface ReportClientEventResponse
8488
+ */
8489
+ export interface ReportClientEventResponse {
8490
+ /**
8491
+ * Duration of the request in milliseconds
8492
+ * @type {string}
8493
+ * @memberof ReportClientEventResponse
8494
+ */
8495
+ duration: string;
8496
+ }
8471
8497
  /**
8472
8498
  *
8473
8499
  * @export