@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.
- package/CHANGELOG.md +15 -0
- package/dist/index.browser.es.js +319 -71
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +329 -69
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.es.js +319 -71
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +15 -4
- package/dist/src/StreamVideoClient.d.ts +9 -1
- package/dist/src/devices/CameraManager.d.ts +2 -1
- package/dist/src/devices/DeviceManager.d.ts +1 -1
- package/dist/src/devices/MicrophoneManager.d.ts +2 -1
- package/dist/src/gen/coordinator/index.d.ts +26 -0
- package/dist/src/helpers/MediaStreamRecorder.d.ts +26 -0
- package/dist/src/helpers/concurrency.d.ts +8 -0
- package/dist/src/helpers/loopback.d.ts +55 -0
- package/dist/src/reporting/ClientEventReporter.d.ts +0 -2
- package/dist/src/rtc/Subscriber.d.ts +5 -0
- package/index.ts +2 -0
- package/package.json +1 -1
- package/src/Call.ts +156 -134
- package/src/StreamVideoClient.ts +35 -8
- package/src/__tests__/Call.lifecycle.test.ts +33 -2
- package/src/__tests__/Call.publishing.test.ts +42 -1
- package/src/__tests__/StreamVideoClient.api.test.ts +33 -0
- package/src/__tests__/StreamVideoClient.rewatch.test.ts +213 -0
- package/src/__tests__/StreamVideoClient.ringing.test.ts +11 -4
- package/src/__tests__/StreamVideoClient.test.ts +14 -12
- package/src/devices/CameraManager.ts +16 -4
- package/src/devices/DeviceManager.ts +7 -4
- package/src/devices/MicrophoneManager.ts +11 -3
- package/src/devices/__tests__/CameraManager.test.ts +27 -1
- package/src/devices/__tests__/DeviceManager.test.ts +49 -1
- package/src/devices/__tests__/MicrophoneManager.test.ts +23 -1
- package/src/events/call.ts +6 -3
- package/src/gen/coordinator/index.ts +26 -0
- package/src/helpers/MediaStreamRecorder.ts +104 -0
- package/src/helpers/__tests__/MediaStreamRecorder.test.ts +324 -0
- package/src/helpers/__tests__/concurrency.test.ts +74 -0
- package/src/helpers/__tests__/loopback.test.ts +367 -0
- package/src/helpers/concurrency.ts +24 -0
- package/src/helpers/loopback.ts +200 -0
- package/src/reporting/ClientEventReporter.ts +15 -28
- package/src/reporting/__tests__/ClientEventReporter.test.ts +3 -7
- package/src/rtc/Subscriber.ts +8 -0
- 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
|
|
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
|
|
294
|
-
.spyOn(client, '
|
|
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(
|
|
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
|
|
331
|
-
.spyOn(client, '
|
|
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(
|
|
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
|
|
367
|
-
|
|
368
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
package/src/events/call.ts
CHANGED
|
@@ -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({
|
|
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
|
|
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
|