@stream-io/video-client 1.43.0 → 1.44.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 +206 -59
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +205 -58
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +206 -59
- package/dist/index.es.js.map +1 -1
- package/dist/src/StreamVideoClient.d.ts +2 -8
- package/dist/src/coordinator/connection/types.d.ts +5 -0
- package/dist/src/devices/CameraManager.d.ts +7 -2
- package/dist/src/devices/DeviceManager.d.ts +7 -15
- package/dist/src/devices/MicrophoneManager.d.ts +2 -1
- package/dist/src/devices/SpeakerManager.d.ts +6 -1
- package/dist/src/devices/devicePersistence.d.ts +27 -0
- package/dist/src/helpers/clientUtils.d.ts +1 -1
- package/dist/src/permissions/PermissionsContext.d.ts +1 -1
- package/dist/src/types.d.ts +38 -2
- package/package.json +1 -1
- package/src/Call.ts +5 -3
- package/src/StreamVideoClient.ts +1 -9
- package/src/coordinator/connection/types.ts +6 -0
- package/src/devices/CameraManager.ts +31 -11
- package/src/devices/DeviceManager.ts +113 -31
- package/src/devices/MicrophoneManager.ts +26 -8
- package/src/devices/ScreenShareManager.ts +7 -1
- package/src/devices/SpeakerManager.ts +62 -18
- package/src/devices/__tests__/CameraManager.test.ts +184 -21
- package/src/devices/__tests__/DeviceManager.test.ts +184 -2
- package/src/devices/__tests__/DeviceManagerFilters.test.ts +2 -0
- package/src/devices/__tests__/MicrophoneManager.test.ts +146 -2
- package/src/devices/__tests__/MicrophoneManagerRN.test.ts +2 -0
- package/src/devices/__tests__/ScreenShareManager.test.ts +2 -0
- package/src/devices/__tests__/SpeakerManager.test.ts +90 -0
- package/src/devices/__tests__/devicePersistence.test.ts +142 -0
- package/src/devices/__tests__/devices.test.ts +390 -0
- package/src/devices/__tests__/mediaStreamTestHelpers.ts +58 -0
- package/src/devices/__tests__/mocks.ts +35 -0
- package/src/devices/devicePersistence.ts +106 -0
- package/src/devices/devices.ts +3 -3
- package/src/helpers/__tests__/DynascaleManager.test.ts +3 -1
- package/src/helpers/clientUtils.ts +1 -1
- package/src/permissions/PermissionsContext.ts +1 -0
- package/src/sorting/presets.ts +1 -1
- package/src/store/CallState.ts +1 -1
- package/src/types.ts +49 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { combineLatest, Observable } from 'rxjs';
|
|
1
|
+
import { combineLatest, firstValueFrom, Observable } from 'rxjs';
|
|
2
2
|
import type { INoiseCancellation } from '@stream-io/audio-filters-web';
|
|
3
3
|
import { Call } from '../Call';
|
|
4
4
|
import {
|
|
@@ -23,12 +23,12 @@ import { CallingState } from '../store';
|
|
|
23
23
|
import {
|
|
24
24
|
createSafeAsyncSubscription,
|
|
25
25
|
createSubscription,
|
|
26
|
-
getCurrentValue,
|
|
27
26
|
} from '../store/rxUtils';
|
|
28
27
|
import { RNSpeechDetector } from '../helpers/RNSpeechDetector';
|
|
29
28
|
import { withoutConcurrency } from '../helpers/concurrency';
|
|
30
29
|
import { disposeOfMediaStream } from './utils';
|
|
31
30
|
import { promiseWithResolvers } from '../helpers/promise';
|
|
31
|
+
import { DevicePersistenceOptions } from './devicePersistence';
|
|
32
32
|
|
|
33
33
|
export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState> {
|
|
34
34
|
private speakingWhileMutedNotificationEnabled = true;
|
|
@@ -44,8 +44,17 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
44
44
|
|
|
45
45
|
private silenceThresholdMs = 5000;
|
|
46
46
|
|
|
47
|
-
constructor(
|
|
48
|
-
|
|
47
|
+
constructor(
|
|
48
|
+
call: Call,
|
|
49
|
+
devicePersistence: Required<DevicePersistenceOptions>,
|
|
50
|
+
disableMode: TrackDisableMode = 'stop-tracks',
|
|
51
|
+
) {
|
|
52
|
+
super(
|
|
53
|
+
call,
|
|
54
|
+
new MicrophoneManagerState(disableMode),
|
|
55
|
+
TrackType.AUDIO,
|
|
56
|
+
devicePersistence,
|
|
57
|
+
);
|
|
49
58
|
}
|
|
50
59
|
|
|
51
60
|
override setup(): void {
|
|
@@ -146,7 +155,7 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
146
155
|
if (this.silenceThresholdMs <= 0) return;
|
|
147
156
|
|
|
148
157
|
const deviceId = this.state.selectedDevice;
|
|
149
|
-
const devices =
|
|
158
|
+
const devices = await firstValueFrom(this.listDevices());
|
|
150
159
|
const label = devices.find((d) => d.deviceId === deviceId)?.label;
|
|
151
160
|
|
|
152
161
|
this.noAudioDetectorCleanup = createNoAudioDetector(mediaStream, {
|
|
@@ -160,6 +169,7 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
160
169
|
deviceId,
|
|
161
170
|
label,
|
|
162
171
|
};
|
|
172
|
+
console.log(event);
|
|
163
173
|
this.call.tracer.trace('mic.capture_report', event);
|
|
164
174
|
this.call.streamClient.dispatchEvent(event);
|
|
165
175
|
},
|
|
@@ -335,10 +345,18 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
335
345
|
// Wait for any in progress mic operation
|
|
336
346
|
await this.statusChangeSettled();
|
|
337
347
|
|
|
338
|
-
const canPublish = this.call.permissionsContext.canPublish(this.trackType);
|
|
339
348
|
// apply server-side settings only when the device state is pristine
|
|
340
|
-
// and
|
|
341
|
-
|
|
349
|
+
// and there are no persisted preferences
|
|
350
|
+
const shouldApplyDefaults =
|
|
351
|
+
this.state.status === undefined &&
|
|
352
|
+
this.state.optimisticStatus === undefined;
|
|
353
|
+
let persistedPreferencesApplied = false;
|
|
354
|
+
if (shouldApplyDefaults && this.devicePersistence.enabled) {
|
|
355
|
+
persistedPreferencesApplied = await this.applyPersistedPreferences(true);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const canPublish = this.call.permissionsContext.canPublish(this.trackType);
|
|
359
|
+
if (shouldApplyDefaults && !persistedPreferencesApplied) {
|
|
342
360
|
if (canPublish && settings.mic_default_on) {
|
|
343
361
|
await this.enable();
|
|
344
362
|
}
|
|
@@ -9,13 +9,19 @@ import { AudioBitrateProfile, TrackType } from '../gen/video/sfu/models/models';
|
|
|
9
9
|
import { getScreenShareStream } from './devices';
|
|
10
10
|
import { ScreenShareSettings } from '../types';
|
|
11
11
|
import { createSubscription } from '../store/rxUtils';
|
|
12
|
+
import { normalize } from './devicePersistence';
|
|
12
13
|
|
|
13
14
|
export class ScreenShareManager extends AudioDeviceManager<
|
|
14
15
|
ScreenShareState,
|
|
15
16
|
DisplayMediaStreamOptions
|
|
16
17
|
> {
|
|
17
18
|
constructor(call: Call) {
|
|
18
|
-
super(
|
|
19
|
+
super(
|
|
20
|
+
call,
|
|
21
|
+
new ScreenShareState(),
|
|
22
|
+
TrackType.SCREEN_SHARE,
|
|
23
|
+
normalize({ enabled: false }),
|
|
24
|
+
);
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
override setup(): void {
|
|
@@ -1,30 +1,63 @@
|
|
|
1
|
-
import { combineLatest
|
|
1
|
+
import { combineLatest } from 'rxjs';
|
|
2
2
|
import { Call } from '../Call';
|
|
3
3
|
import { isReactNative } from '../helpers/platforms';
|
|
4
4
|
import { SpeakerState } from './SpeakerState';
|
|
5
5
|
import { deviceIds$, getAudioOutputDevices } from './devices';
|
|
6
6
|
import {
|
|
7
|
-
CallSettingsResponse,
|
|
8
7
|
AudioSettingsRequestDefaultDeviceEnum,
|
|
8
|
+
CallSettingsResponse,
|
|
9
9
|
} from '../gen/coordinator';
|
|
10
|
+
import {
|
|
11
|
+
createSyntheticDevice,
|
|
12
|
+
defaultDeviceId,
|
|
13
|
+
DevicePersistenceOptions,
|
|
14
|
+
readPreferences,
|
|
15
|
+
toPreferenceList,
|
|
16
|
+
writePreferences,
|
|
17
|
+
} from './devicePersistence';
|
|
18
|
+
import { createSubscription, getCurrentValue } from '../store/rxUtils';
|
|
10
19
|
|
|
11
20
|
export class SpeakerManager {
|
|
12
21
|
readonly state: SpeakerState;
|
|
13
|
-
private subscriptions:
|
|
22
|
+
private subscriptions: (() => void)[] = [];
|
|
14
23
|
private areSubscriptionsSetUp = false;
|
|
15
24
|
private readonly call: Call;
|
|
16
25
|
private defaultDevice?: AudioSettingsRequestDefaultDeviceEnum;
|
|
26
|
+
private readonly devicePersistence: Required<DevicePersistenceOptions>;
|
|
17
27
|
|
|
18
|
-
constructor(
|
|
28
|
+
constructor(
|
|
29
|
+
call: Call,
|
|
30
|
+
devicePreferences: Required<DevicePersistenceOptions>,
|
|
31
|
+
) {
|
|
19
32
|
this.call = call;
|
|
20
33
|
this.state = new SpeakerState(call.tracer);
|
|
34
|
+
this.devicePersistence = devicePreferences;
|
|
21
35
|
this.setup();
|
|
22
36
|
}
|
|
23
37
|
|
|
24
38
|
apply(settings: CallSettingsResponse) {
|
|
25
|
-
|
|
26
|
-
|
|
39
|
+
return isReactNative() ? this.applyRN(settings) : this.applyWeb();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private applyWeb() {
|
|
43
|
+
const { enabled, storageKey } = this.devicePersistence;
|
|
44
|
+
if (!enabled) return;
|
|
45
|
+
|
|
46
|
+
const preferences = readPreferences(storageKey);
|
|
47
|
+
const preferenceList = toPreferenceList(preferences.speaker);
|
|
48
|
+
if (preferenceList.length === 0) return;
|
|
49
|
+
|
|
50
|
+
const preference = preferenceList[0];
|
|
51
|
+
const nextDeviceId =
|
|
52
|
+
preference.selectedDeviceId === defaultDeviceId
|
|
53
|
+
? ''
|
|
54
|
+
: preference.selectedDeviceId;
|
|
55
|
+
if (this.state.selectedDevice !== nextDeviceId) {
|
|
56
|
+
this.select(nextDeviceId);
|
|
27
57
|
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private applyRN(settings: CallSettingsResponse) {
|
|
28
61
|
/// Determines if the speaker should be enabled based on a priority hierarchy of
|
|
29
62
|
/// settings.
|
|
30
63
|
///
|
|
@@ -57,29 +90,31 @@ export class SpeakerManager {
|
|
|
57
90
|
}
|
|
58
91
|
|
|
59
92
|
setup() {
|
|
60
|
-
if (this.areSubscriptionsSetUp)
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
|
|
93
|
+
if (this.areSubscriptionsSetUp) return;
|
|
64
94
|
this.areSubscriptionsSetUp = true;
|
|
65
95
|
|
|
66
96
|
if (deviceIds$ && !isReactNative()) {
|
|
67
97
|
this.subscriptions.push(
|
|
68
|
-
|
|
98
|
+
createSubscription(
|
|
99
|
+
combineLatest([deviceIds$, this.state.selectedDevice$]),
|
|
69
100
|
([devices, deviceId]) => {
|
|
70
|
-
if (!deviceId)
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
101
|
+
if (!deviceId) return;
|
|
73
102
|
const device = devices.find(
|
|
74
103
|
(d) => d.deviceId === deviceId && d.kind === 'audiooutput',
|
|
75
104
|
);
|
|
76
|
-
if (!device)
|
|
77
|
-
this.select('');
|
|
78
|
-
}
|
|
105
|
+
if (!device) this.select('');
|
|
79
106
|
},
|
|
80
107
|
),
|
|
81
108
|
);
|
|
82
109
|
}
|
|
110
|
+
|
|
111
|
+
if (!isReactNative() && this.devicePersistence.enabled) {
|
|
112
|
+
this.subscriptions.push(
|
|
113
|
+
createSubscription(this.state.selectedDevice$, (selectedDevice) => {
|
|
114
|
+
this.persistSpeakerDevicePreference(selectedDevice);
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
83
118
|
}
|
|
84
119
|
|
|
85
120
|
/**
|
|
@@ -113,7 +148,7 @@ export class SpeakerManager {
|
|
|
113
148
|
* @internal
|
|
114
149
|
*/
|
|
115
150
|
dispose = () => {
|
|
116
|
-
this.subscriptions.forEach((
|
|
151
|
+
this.subscriptions.forEach((unsubscribe) => unsubscribe());
|
|
117
152
|
this.subscriptions = [];
|
|
118
153
|
this.areSubscriptionsSetUp = false;
|
|
119
154
|
};
|
|
@@ -152,6 +187,15 @@ export class SpeakerManager {
|
|
|
152
187
|
return { audioVolume: volume };
|
|
153
188
|
});
|
|
154
189
|
}
|
|
190
|
+
|
|
191
|
+
private persistSpeakerDevicePreference(selectedDevice: string) {
|
|
192
|
+
const { storageKey } = this.devicePersistence;
|
|
193
|
+
const devices = getCurrentValue(this.listDevices()) || [];
|
|
194
|
+
const currentDevice =
|
|
195
|
+
devices.find((d) => d.deviceId === selectedDevice) ??
|
|
196
|
+
createSyntheticDevice(selectedDevice, 'audiooutput');
|
|
197
|
+
writePreferences(currentDevice, 'speaker', undefined, storageKey);
|
|
198
|
+
}
|
|
155
199
|
}
|
|
156
200
|
|
|
157
201
|
const assertUnsupportedInReactNative = () => {
|
|
@@ -5,20 +5,31 @@ import { CallingState, StreamVideoWriteableStateStore } from '../../store';
|
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
|
|
6
6
|
import { fromPartial } from '@total-typescript/shoehorn';
|
|
7
7
|
import {
|
|
8
|
+
createLocalStorageMock,
|
|
9
|
+
emitDeviceIds,
|
|
8
10
|
mockBrowserPermission,
|
|
9
11
|
mockCall,
|
|
10
12
|
mockDeviceIds$,
|
|
11
13
|
mockVideoDevices,
|
|
12
14
|
mockVideoStream,
|
|
13
15
|
} from './mocks';
|
|
16
|
+
import { createVideoStreamForDevice } from './mediaStreamTestHelpers';
|
|
14
17
|
import { TrackType } from '../../gen/video/sfu/models/models';
|
|
15
18
|
import { CameraManager } from '../CameraManager';
|
|
16
19
|
import { of } from 'rxjs';
|
|
17
20
|
import { PermissionsContext } from '../../permissions';
|
|
18
21
|
import { Tracer } from '../../stats';
|
|
22
|
+
import {
|
|
23
|
+
defaultDeviceId,
|
|
24
|
+
readPreferences,
|
|
25
|
+
toPreferenceList,
|
|
26
|
+
} from '../devicePersistence';
|
|
19
27
|
|
|
20
28
|
const getVideoStream = vi.hoisted(() =>
|
|
21
|
-
|
|
29
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
30
|
+
vi.fn((_trackConstraints?: MediaTrackConstraints, _tracer?: Tracer) =>
|
|
31
|
+
Promise.resolve(mockVideoStream()),
|
|
32
|
+
),
|
|
22
33
|
);
|
|
23
34
|
|
|
24
35
|
vi.mock('../devices.ts', () => {
|
|
@@ -62,13 +73,14 @@ describe('CameraManager', () => {
|
|
|
62
73
|
let call: Call;
|
|
63
74
|
|
|
64
75
|
beforeEach(() => {
|
|
76
|
+
const devicePersistence = { enabled: false, storageKey: '' };
|
|
65
77
|
call = new Call({
|
|
66
78
|
id: '',
|
|
67
79
|
type: '',
|
|
68
|
-
streamClient: new StreamClient('abc123'),
|
|
80
|
+
streamClient: new StreamClient('abc123', { devicePersistence }),
|
|
69
81
|
clientStore: new StreamVideoWriteableStateStore(),
|
|
70
82
|
});
|
|
71
|
-
manager = new CameraManager(call);
|
|
83
|
+
manager = new CameraManager(call, devicePersistence);
|
|
72
84
|
});
|
|
73
85
|
|
|
74
86
|
it('list devices', () => {
|
|
@@ -243,13 +255,29 @@ describe('CameraManager', () => {
|
|
|
243
255
|
it('should enable the camera when set on the dashboard', async () => {
|
|
244
256
|
vi.spyOn(manager, 'enable');
|
|
245
257
|
await manager.apply(
|
|
246
|
-
|
|
247
|
-
{
|
|
258
|
+
fromPartial({
|
|
248
259
|
enabled: true,
|
|
249
260
|
target_resolution: { width: 640, height: 480 },
|
|
250
261
|
camera_facing: 'front',
|
|
251
262
|
camera_default_on: true,
|
|
252
|
-
},
|
|
263
|
+
}),
|
|
264
|
+
true,
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
expect(manager.state.direction).toBe('front');
|
|
268
|
+
expect(manager.state.status).toBe('enabled');
|
|
269
|
+
expect(manager['targetResolution']).toEqual({ width: 640, height: 480 });
|
|
270
|
+
expect(manager.enable).toHaveBeenCalled();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('should enable the camera when enabled is not provided', async () => {
|
|
274
|
+
vi.spyOn(manager, 'enable');
|
|
275
|
+
await manager.apply(
|
|
276
|
+
fromPartial({
|
|
277
|
+
target_resolution: { width: 640, height: 480 },
|
|
278
|
+
camera_facing: 'front',
|
|
279
|
+
camera_default_on: true,
|
|
280
|
+
}),
|
|
253
281
|
true,
|
|
254
282
|
);
|
|
255
283
|
|
|
@@ -262,13 +290,12 @@ describe('CameraManager', () => {
|
|
|
262
290
|
it('should not enable the camera when set on the dashboard', async () => {
|
|
263
291
|
vi.spyOn(manager, 'enable');
|
|
264
292
|
await manager.apply(
|
|
265
|
-
|
|
266
|
-
{
|
|
293
|
+
fromPartial({
|
|
267
294
|
enabled: true,
|
|
268
295
|
target_resolution: { width: 640, height: 480 },
|
|
269
296
|
camera_facing: 'front',
|
|
270
297
|
camera_default_on: false,
|
|
271
|
-
},
|
|
298
|
+
}),
|
|
272
299
|
true,
|
|
273
300
|
);
|
|
274
301
|
|
|
@@ -278,22 +305,70 @@ describe('CameraManager', () => {
|
|
|
278
305
|
expect(manager.enable).not.toHaveBeenCalled();
|
|
279
306
|
});
|
|
280
307
|
|
|
281
|
-
it('should
|
|
308
|
+
it('should skip defaults when preferences are applied', async () => {
|
|
309
|
+
const devicePersistence = { enabled: true, storageKey: '' };
|
|
310
|
+
const persistedManager = new CameraManager(call, devicePersistence);
|
|
311
|
+
const applySpy = vi
|
|
312
|
+
.spyOn(persistedManager as never, 'applyPersistedPreferences')
|
|
313
|
+
.mockResolvedValue(true);
|
|
314
|
+
const selectDirectionSpy = vi.spyOn(persistedManager, 'selectDirection');
|
|
315
|
+
const enableSpy = vi.spyOn(persistedManager, 'enable');
|
|
316
|
+
|
|
317
|
+
await persistedManager.apply(
|
|
318
|
+
fromPartial({
|
|
319
|
+
enabled: true,
|
|
320
|
+
target_resolution: { width: 640, height: 480 },
|
|
321
|
+
camera_facing: 'front',
|
|
322
|
+
camera_default_on: true,
|
|
323
|
+
}),
|
|
324
|
+
true,
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
expect(applySpy).toHaveBeenCalledWith(true);
|
|
328
|
+
expect(selectDirectionSpy).not.toHaveBeenCalled();
|
|
329
|
+
expect(enableSpy).not.toHaveBeenCalled();
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('should not apply defaults when device is not pristine', async () => {
|
|
333
|
+
manager.state.setStatus('enabled');
|
|
334
|
+
const selectDirectionSpy = vi.spyOn(manager, 'selectDirection');
|
|
335
|
+
const enableSpy = vi.spyOn(manager, 'enable');
|
|
336
|
+
|
|
337
|
+
await manager.apply(
|
|
338
|
+
fromPartial({
|
|
339
|
+
enabled: true,
|
|
340
|
+
target_resolution: { width: 640, height: 480 },
|
|
341
|
+
camera_facing: 'front',
|
|
342
|
+
camera_default_on: true,
|
|
343
|
+
}),
|
|
344
|
+
true,
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
expect(selectDirectionSpy).not.toHaveBeenCalled();
|
|
348
|
+
expect(enableSpy).not.toHaveBeenCalled();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('should on the camera but not publish when publish is false', async () => {
|
|
352
|
+
manager['call'].state.setCallingState(CallingState.IDLE);
|
|
282
353
|
vi.spyOn(manager, 'enable');
|
|
354
|
+
// @ts-expect-error - private api
|
|
355
|
+
vi.spyOn(manager, 'publishStream');
|
|
283
356
|
await manager.apply(
|
|
284
|
-
|
|
285
|
-
|
|
357
|
+
fromPartial({
|
|
358
|
+
enabled: true,
|
|
286
359
|
target_resolution: { width: 640, height: 480 },
|
|
287
360
|
camera_facing: 'front',
|
|
288
361
|
camera_default_on: true,
|
|
289
|
-
},
|
|
362
|
+
}),
|
|
290
363
|
false,
|
|
291
364
|
);
|
|
292
365
|
|
|
293
366
|
expect(manager.state.direction).toBe('front');
|
|
294
|
-
expect(manager.state.status).toBe(
|
|
367
|
+
expect(manager.state.status).toBe('enabled');
|
|
295
368
|
expect(manager['targetResolution']).toEqual({ width: 640, height: 480 });
|
|
296
|
-
expect(manager.enable).
|
|
369
|
+
expect(manager.enable).toHaveBeenCalled();
|
|
370
|
+
// @ts-expect-error - private api
|
|
371
|
+
expect(manager.publishStream).not.toHaveBeenCalled();
|
|
297
372
|
});
|
|
298
373
|
|
|
299
374
|
it('should not enable the camera when the user does not have permission', async () => {
|
|
@@ -304,6 +379,7 @@ describe('CameraManager', () => {
|
|
|
304
379
|
target_resolution: { width: 640, height: 480 },
|
|
305
380
|
camera_facing: 'front',
|
|
306
381
|
camera_default_on: true,
|
|
382
|
+
enabled: true,
|
|
307
383
|
}),
|
|
308
384
|
true,
|
|
309
385
|
);
|
|
@@ -319,12 +395,12 @@ describe('CameraManager', () => {
|
|
|
319
395
|
// @ts-expect-error - private api
|
|
320
396
|
vi.spyOn(manager, 'publishStream');
|
|
321
397
|
await manager.apply(
|
|
322
|
-
|
|
323
|
-
{
|
|
398
|
+
fromPartial({
|
|
324
399
|
target_resolution: { width: 640, height: 480 },
|
|
325
400
|
camera_facing: 'front',
|
|
326
401
|
camera_default_on: true,
|
|
327
|
-
|
|
402
|
+
enabled: true,
|
|
403
|
+
}),
|
|
328
404
|
true,
|
|
329
405
|
);
|
|
330
406
|
|
|
@@ -334,13 +410,12 @@ describe('CameraManager', () => {
|
|
|
334
410
|
it('should not turn on the camera when video is disabled', async () => {
|
|
335
411
|
vi.spyOn(manager, 'enable');
|
|
336
412
|
await manager.apply(
|
|
337
|
-
|
|
338
|
-
{
|
|
413
|
+
fromPartial({
|
|
339
414
|
enabled: false,
|
|
340
415
|
target_resolution: { width: 640, height: 480 },
|
|
341
416
|
camera_facing: 'front',
|
|
342
417
|
camera_default_on: true,
|
|
343
|
-
},
|
|
418
|
+
}),
|
|
344
419
|
false,
|
|
345
420
|
);
|
|
346
421
|
|
|
@@ -349,6 +424,94 @@ describe('CameraManager', () => {
|
|
|
349
424
|
});
|
|
350
425
|
});
|
|
351
426
|
|
|
427
|
+
describe('Device Persistence Stress', () => {
|
|
428
|
+
it('persists the final camera and muted state after rapid toggles, switches, and unplug', async () => {
|
|
429
|
+
const storageKey = '@test/device-preferences-camera-stress';
|
|
430
|
+
const localStorageMock = createLocalStorageMock();
|
|
431
|
+
const originalWindow = globalThis.window;
|
|
432
|
+
Object.defineProperty(globalThis, 'window', {
|
|
433
|
+
configurable: true,
|
|
434
|
+
value: { localStorage: localStorageMock },
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
const getVideoStreamMock = vi.mocked(getVideoStream);
|
|
438
|
+
getVideoStreamMock.mockImplementation((constraints) => {
|
|
439
|
+
const requestedDeviceId = (constraints?.deviceId as { exact?: string })
|
|
440
|
+
?.exact;
|
|
441
|
+
const selectedDevice =
|
|
442
|
+
mockVideoDevices.find((d) => d.deviceId === requestedDeviceId) ??
|
|
443
|
+
mockVideoDevices[0];
|
|
444
|
+
return Promise.resolve(
|
|
445
|
+
createVideoStreamForDevice(selectedDevice.deviceId),
|
|
446
|
+
);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
const stressManager = new CameraManager(call, {
|
|
450
|
+
enabled: true,
|
|
451
|
+
storageKey,
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
try {
|
|
455
|
+
const finalDevice = mockVideoDevices[2];
|
|
456
|
+
emitDeviceIds(mockVideoDevices);
|
|
457
|
+
|
|
458
|
+
await Promise.allSettled([
|
|
459
|
+
stressManager.enable(),
|
|
460
|
+
stressManager.select(mockVideoDevices[1].deviceId),
|
|
461
|
+
stressManager.toggle(),
|
|
462
|
+
stressManager.select(finalDevice.deviceId),
|
|
463
|
+
stressManager.toggle(),
|
|
464
|
+
stressManager.enable(),
|
|
465
|
+
]);
|
|
466
|
+
await stressManager.statusChangeSettled();
|
|
467
|
+
await stressManager.select(finalDevice.deviceId);
|
|
468
|
+
await stressManager.enable();
|
|
469
|
+
await stressManager.statusChangeSettled();
|
|
470
|
+
|
|
471
|
+
expect(stressManager.state.selectedDevice).toBe(finalDevice.deviceId);
|
|
472
|
+
expect(stressManager.state.status).toBe('enabled');
|
|
473
|
+
|
|
474
|
+
const persistedBeforeUnplug = toPreferenceList(
|
|
475
|
+
readPreferences(storageKey).camera,
|
|
476
|
+
);
|
|
477
|
+
expect(persistedBeforeUnplug[0]).toEqual({
|
|
478
|
+
selectedDeviceId: finalDevice.deviceId,
|
|
479
|
+
selectedDeviceLabel: finalDevice.label,
|
|
480
|
+
muted: false,
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
emitDeviceIds(
|
|
484
|
+
mockVideoDevices.filter((d) => d.deviceId !== finalDevice.deviceId),
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
await vi.waitFor(() => {
|
|
488
|
+
expect(stressManager.state.selectedDevice).toBe(undefined);
|
|
489
|
+
expect(stressManager.state.status).toBe('disabled');
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
const persistedAfterUnplug = toPreferenceList(
|
|
493
|
+
readPreferences(storageKey).camera,
|
|
494
|
+
);
|
|
495
|
+
expect(persistedAfterUnplug[0]).toEqual({
|
|
496
|
+
selectedDeviceId: defaultDeviceId,
|
|
497
|
+
selectedDeviceLabel: '',
|
|
498
|
+
muted: true,
|
|
499
|
+
});
|
|
500
|
+
expect(persistedAfterUnplug).toContainEqual({
|
|
501
|
+
selectedDeviceId: finalDevice.deviceId,
|
|
502
|
+
selectedDeviceLabel: finalDevice.label,
|
|
503
|
+
muted: true,
|
|
504
|
+
});
|
|
505
|
+
} finally {
|
|
506
|
+
stressManager.dispose();
|
|
507
|
+
Object.defineProperty(globalThis, 'window', {
|
|
508
|
+
configurable: true,
|
|
509
|
+
value: originalWindow,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
|
|
352
515
|
afterEach(() => {
|
|
353
516
|
vi.clearAllMocks();
|
|
354
517
|
vi.resetModules();
|