@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/index.browser.es.js +206 -59
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +205 -58
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +206 -59
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/StreamVideoClient.d.ts +2 -8
  9. package/dist/src/coordinator/connection/types.d.ts +5 -0
  10. package/dist/src/devices/CameraManager.d.ts +7 -2
  11. package/dist/src/devices/DeviceManager.d.ts +7 -15
  12. package/dist/src/devices/MicrophoneManager.d.ts +2 -1
  13. package/dist/src/devices/SpeakerManager.d.ts +6 -1
  14. package/dist/src/devices/devicePersistence.d.ts +27 -0
  15. package/dist/src/helpers/clientUtils.d.ts +1 -1
  16. package/dist/src/permissions/PermissionsContext.d.ts +1 -1
  17. package/dist/src/types.d.ts +38 -2
  18. package/package.json +1 -1
  19. package/src/Call.ts +5 -3
  20. package/src/StreamVideoClient.ts +1 -9
  21. package/src/coordinator/connection/types.ts +6 -0
  22. package/src/devices/CameraManager.ts +31 -11
  23. package/src/devices/DeviceManager.ts +113 -31
  24. package/src/devices/MicrophoneManager.ts +26 -8
  25. package/src/devices/ScreenShareManager.ts +7 -1
  26. package/src/devices/SpeakerManager.ts +62 -18
  27. package/src/devices/__tests__/CameraManager.test.ts +184 -21
  28. package/src/devices/__tests__/DeviceManager.test.ts +184 -2
  29. package/src/devices/__tests__/DeviceManagerFilters.test.ts +2 -0
  30. package/src/devices/__tests__/MicrophoneManager.test.ts +146 -2
  31. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +2 -0
  32. package/src/devices/__tests__/ScreenShareManager.test.ts +2 -0
  33. package/src/devices/__tests__/SpeakerManager.test.ts +90 -0
  34. package/src/devices/__tests__/devicePersistence.test.ts +142 -0
  35. package/src/devices/__tests__/devices.test.ts +390 -0
  36. package/src/devices/__tests__/mediaStreamTestHelpers.ts +58 -0
  37. package/src/devices/__tests__/mocks.ts +35 -0
  38. package/src/devices/devicePersistence.ts +106 -0
  39. package/src/devices/devices.ts +3 -3
  40. package/src/helpers/__tests__/DynascaleManager.test.ts +3 -1
  41. package/src/helpers/clientUtils.ts +1 -1
  42. package/src/permissions/PermissionsContext.ts +1 -0
  43. package/src/sorting/presets.ts +1 -1
  44. package/src/store/CallState.ts +1 -1
  45. 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(call: Call, disableMode: TrackDisableMode = 'stop-tracks') {
48
- super(call, new MicrophoneManagerState(disableMode), TrackType.AUDIO);
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 = getCurrentValue(this.listDevices());
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 server defaults are not deferred to application code
341
- if (this.state.status === undefined && !this.deferServerDefaults) {
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(call, new ScreenShareState(), TrackType.SCREEN_SHARE);
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, Subscription } from 'rxjs';
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: Subscription[] = [];
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(call: Call) {
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
- if (!isReactNative()) {
26
- return;
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
- combineLatest([deviceIds$!, this.state.selectedDevice$]).subscribe(
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((s) => s.unsubscribe());
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
- vi.fn(() => Promise.resolve(mockVideoStream())),
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
- // @ts-expect-error - partial settings
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
- // @ts-expect-error - partial settings
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 not turn on the camera when publish is false', async () => {
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
- // @ts-expect-error - partial settings
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(undefined);
367
+ expect(manager.state.status).toBe('enabled');
295
368
  expect(manager['targetResolution']).toEqual({ width: 640, height: 480 });
296
- expect(manager.enable).not.toHaveBeenCalled();
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
- // @ts-expect-error - partial settings
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
- // @ts-expect-error - partial settings
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();