@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,10 +1,13 @@
1
+ /* @vitest-environment happy-dom */
1
2
  import { Call } from '../../Call';
2
3
  import { StreamClient } from '../../coordinator/connection/client';
3
4
  import { CallingState, StreamVideoWriteableStateStore } from '../../store';
4
5
 
5
6
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
7
  import {
8
+ createLocalStorageMock,
7
9
  emitDeviceIds,
10
+ LocalStorageMock,
8
11
  mockBrowserPermission,
9
12
  mockCall,
10
13
  mockDeviceIds$,
@@ -16,6 +19,8 @@ import { DeviceManager } from '../DeviceManager';
16
19
  import { DeviceManagerState } from '../DeviceManagerState';
17
20
  import { of } from 'rxjs';
18
21
  import { TrackType } from '../../gen/video/sfu/models/models';
22
+ import { PermissionsContext } from '../../permissions';
23
+ import { readPreferences } from '../devicePersistence';
19
24
 
20
25
  vi.mock('../../Call.ts', () => {
21
26
  console.log('MOCKING Call');
@@ -47,7 +52,10 @@ class TestInputMediaDeviceManager extends DeviceManager<TestInputMediaDeviceMana
47
52
  public stopPublishStream = vi.fn();
48
53
  public getTracks = () => this.state.mediaStream?.getTracks() ?? [];
49
54
 
50
- constructor(call: Call) {
55
+ constructor(
56
+ call: Call,
57
+ devicePersistence = { enabled: false, storageKey: '' },
58
+ ) {
51
59
  super(
52
60
  call,
53
61
  new TestInputMediaDeviceManagerState(
@@ -55,14 +63,23 @@ class TestInputMediaDeviceManager extends DeviceManager<TestInputMediaDeviceMana
55
63
  mockBrowserPermission,
56
64
  ),
57
65
  TrackType.VIDEO,
66
+ devicePersistence,
58
67
  );
59
68
  }
60
69
  }
61
70
 
62
- describe('InputMediaDeviceManager.test', () => {
71
+ describe('Device Manager', () => {
63
72
  let manager: TestInputMediaDeviceManager;
73
+ let localStorageMock: LocalStorageMock;
74
+ let storageKey: string;
64
75
 
65
76
  beforeEach(() => {
77
+ storageKey = '@test/device-preferences';
78
+ localStorageMock = createLocalStorageMock();
79
+ Object.defineProperty(window, 'localStorage', {
80
+ configurable: true,
81
+ value: localStorageMock,
82
+ });
66
83
  manager = new TestInputMediaDeviceManager(
67
84
  new Call({
68
85
  id: '',
@@ -70,6 +87,7 @@ describe('InputMediaDeviceManager.test', () => {
70
87
  streamClient: new StreamClient('abc123'),
71
88
  clientStore: new StreamVideoWriteableStateStore(),
72
89
  }),
90
+ { enabled: false, storageKey },
73
91
  );
74
92
  });
75
93
 
@@ -366,8 +384,172 @@ describe('InputMediaDeviceManager.test', () => {
366
384
  vi.useRealTimers();
367
385
  });
368
386
 
387
+ describe('persistPreference', () => {
388
+ it('stores selected device and muted state', () => {
389
+ const persistenceEnabledManager = new TestInputMediaDeviceManager(
390
+ manager['call'],
391
+ { enabled: true, storageKey },
392
+ );
393
+ persistenceEnabledManager.state.setDevice(mockVideoDevices[1].deviceId);
394
+ persistenceEnabledManager.state.setStatus('enabled');
395
+
396
+ const preferences = readPreferences(storageKey);
397
+ expect(preferences.camera).toEqual([
398
+ {
399
+ selectedDeviceId: mockVideoDevices[1].deviceId,
400
+ selectedDeviceLabel: mockVideoDevices[1].label,
401
+ muted: false,
402
+ },
403
+ ]);
404
+ });
405
+
406
+ it('stores default device when selection is cleared', () => {
407
+ const persistenceEnabledManager = new TestInputMediaDeviceManager(
408
+ manager['call'],
409
+ { enabled: true, storageKey },
410
+ );
411
+ persistenceEnabledManager.state.setDevice(undefined);
412
+ persistenceEnabledManager.state.setStatus('disabled');
413
+
414
+ const preferences = readPreferences(storageKey);
415
+ expect(preferences.camera).toEqual([
416
+ {
417
+ selectedDeviceId: 'default',
418
+ selectedDeviceLabel: '',
419
+ muted: true,
420
+ },
421
+ ]);
422
+ });
423
+
424
+ it('persists device history when selection changes', () => {
425
+ const persistenceEnabledManager = new TestInputMediaDeviceManager(
426
+ manager['call'],
427
+ { enabled: true, storageKey },
428
+ );
429
+
430
+ persistenceEnabledManager.state.setDevice(mockVideoDevices[0].deviceId);
431
+ persistenceEnabledManager.state.setStatus('enabled');
432
+
433
+ persistenceEnabledManager.state.setDevice(mockVideoDevices[1].deviceId);
434
+ persistenceEnabledManager.state.setStatus('enabled');
435
+
436
+ const preferences = readPreferences(storageKey);
437
+ expect(preferences.camera).toEqual([
438
+ {
439
+ selectedDeviceId: mockVideoDevices[1].deviceId,
440
+ selectedDeviceLabel: mockVideoDevices[1].label,
441
+ muted: false,
442
+ },
443
+ {
444
+ selectedDeviceId: mockVideoDevices[0].deviceId,
445
+ selectedDeviceLabel: mockVideoDevices[0].label,
446
+ muted: false,
447
+ },
448
+ ]);
449
+ });
450
+ });
451
+
452
+ describe('applyPersistedPreferences', () => {
453
+ beforeEach(() => {
454
+ manager.dispose();
455
+ manager = new TestInputMediaDeviceManager(manager['call'], {
456
+ enabled: true,
457
+ storageKey,
458
+ });
459
+ // @ts-expect-error - read only property
460
+ manager['call'].permissionsContext = new PermissionsContext();
461
+ manager['call'].permissionsContext.canPublish = vi
462
+ .fn()
463
+ .mockReturnValue(true);
464
+ });
465
+
466
+ it('returns false when no preferences exist', async () => {
467
+ // @ts-expect-error - private api
468
+ const result = await manager.applyPersistedPreferences(true);
469
+ expect(result).toBe(false);
470
+ });
471
+
472
+ it('selects device by id and applies muted state', async () => {
473
+ localStorageMock.setItem(
474
+ storageKey,
475
+ JSON.stringify({
476
+ camera: [
477
+ {
478
+ selectedDeviceId: mockVideoDevices[0].deviceId,
479
+ selectedDeviceLabel: mockVideoDevices[0].label,
480
+ muted: true,
481
+ },
482
+ ],
483
+ }),
484
+ );
485
+
486
+ const selectSpy = vi.spyOn(manager, 'select');
487
+ const disableSpy = vi.spyOn(manager, 'disable');
488
+
489
+ // @ts-expect-error - private API
490
+ const result = await manager.applyPersistedPreferences(true);
491
+
492
+ expect(result).toBe(true);
493
+ expect(selectSpy).toHaveBeenCalledWith(mockVideoDevices[0].deviceId);
494
+ expect(disableSpy).toHaveBeenCalled();
495
+ });
496
+
497
+ it('selects device by label when device id is not found', async () => {
498
+ localStorageMock.setItem(
499
+ storageKey,
500
+ JSON.stringify({
501
+ camera: [
502
+ {
503
+ selectedDeviceId: 'missing-device',
504
+ selectedDeviceLabel: mockVideoDevices[1].label,
505
+ muted: false,
506
+ },
507
+ ],
508
+ }),
509
+ );
510
+
511
+ const selectSpy = vi.spyOn(manager, 'select');
512
+
513
+ // @ts-expect-error private api
514
+ const result = await manager.applyPersistedPreferences(true);
515
+
516
+ expect(result).toBe(true);
517
+ expect(selectSpy).toHaveBeenCalledWith(mockVideoDevices[1].deviceId);
518
+ });
519
+
520
+ it('applies muted state without selecting when default device is stored', async () => {
521
+ localStorageMock.setItem(
522
+ storageKey,
523
+ JSON.stringify({
524
+ camera: [
525
+ {
526
+ selectedDeviceId: 'default',
527
+ selectedDeviceLabel: '',
528
+ muted: true,
529
+ },
530
+ ],
531
+ }),
532
+ );
533
+
534
+ const selectSpy = vi.spyOn(manager, 'select');
535
+ const disableSpy = vi.spyOn(manager, 'disable');
536
+
537
+ // @ts-expect-error private api
538
+ const result = await manager.applyPersistedPreferences(true);
539
+
540
+ expect(result).toBe(true);
541
+ expect(selectSpy).not.toHaveBeenCalled();
542
+ expect(disableSpy).toHaveBeenCalled();
543
+ });
544
+ });
545
+
369
546
  afterEach(() => {
547
+ manager.dispose();
370
548
  vi.clearAllMocks();
371
549
  vi.resetModules();
550
+ Object.defineProperty(window, 'localStorage', {
551
+ configurable: true,
552
+ value: undefined,
553
+ });
372
554
  });
373
555
  });
@@ -28,6 +28,7 @@ class TestInputMediaDeviceManager extends DeviceManager<TestInputMediaDeviceMana
28
28
  public getTracks = () => this.state.mediaStream?.getTracks() ?? [];
29
29
 
30
30
  constructor(call: Call) {
31
+ const devicePersistence = { enabled: false, storageKey: '' };
31
32
  super(
32
33
  call,
33
34
  new TestInputMediaDeviceManagerState(
@@ -35,6 +36,7 @@ class TestInputMediaDeviceManager extends DeviceManager<TestInputMediaDeviceMana
35
36
  mockBrowserPermission,
36
37
  ),
37
38
  TrackType.VIDEO,
39
+ devicePersistence,
38
40
  );
39
41
  }
40
42
  }
@@ -14,12 +14,15 @@ import {
14
14
  } from '../../gen/video/sfu/models/models';
15
15
  import { CallingState, StreamVideoWriteableStateStore } from '../../store';
16
16
  import {
17
+ createLocalStorageMock,
18
+ emitDeviceIds,
17
19
  mockAudioDevices,
18
20
  mockAudioStream,
19
21
  mockBrowserPermission,
20
22
  mockCall,
21
23
  mockDeviceIds$,
22
24
  } from './mocks';
25
+ import { createAudioStreamForDevice } from './mediaStreamTestHelpers';
23
26
  import { setupAudioContextMock } from './web-audio.mocks';
24
27
  import { getAudioStream } from '../devices';
25
28
  import { MicrophoneManager } from '../MicrophoneManager';
@@ -35,6 +38,11 @@ import {
35
38
  import { PermissionsContext } from '../../permissions';
36
39
  import { Tracer } from '../../stats';
37
40
  import { settled, withoutConcurrency } from '../../helpers/concurrency';
41
+ import {
42
+ defaultDeviceId,
43
+ readPreferences,
44
+ toPreferenceList,
45
+ } from '../devicePersistence';
38
46
 
39
47
  vi.mock('../devices.ts', () => {
40
48
  console.log('MOCKING devices API');
@@ -88,7 +96,8 @@ describe('MicrophoneManager', () => {
88
96
  streamClient: new StreamClient('abc123'),
89
97
  clientStore: new StreamVideoWriteableStateStore(),
90
98
  });
91
- manager = new MicrophoneManager(call, 'disable-tracks');
99
+ const devicePersistence = { enabled: false, storageKey: '' };
100
+ manager = new MicrophoneManager(call, devicePersistence, 'disable-tracks');
92
101
  });
93
102
  it('list devices', () => {
94
103
  const spy = vi.fn();
@@ -174,7 +183,12 @@ describe('MicrophoneManager', () => {
174
183
  vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
175
184
  of('denied'),
176
185
  );
177
- const innerManager = new MicrophoneManager(call, 'disable-tracks');
186
+ const devicePersistence = { enabled: false, storageKey: '' };
187
+ const innerManager = new MicrophoneManager(
188
+ call,
189
+ devicePersistence,
190
+ 'disable-tracks',
191
+ );
178
192
  // @ts-expect-error private api
179
193
  const fn = vi.spyOn(innerManager, 'startSpeakingWhileMutedDetection');
180
194
 
@@ -408,6 +422,13 @@ describe('MicrophoneManager', () => {
408
422
  call.permissionsContext.canPublish = vi.fn().mockReturnValue(true);
409
423
  });
410
424
 
425
+ it('should apply defaults when mic_default_on is true and enabled is pristine', async () => {
426
+ const enable = vi.spyOn(manager, 'enable');
427
+ // @ts-expect-error - partial data
428
+ await manager.apply({ mic_default_on: true }, true);
429
+ expect(enable).toHaveBeenCalled();
430
+ });
431
+
411
432
  it('should turn the mic on when set on dashboard', async () => {
412
433
  const enable = vi.spyOn(manager, 'enable');
413
434
  // @ts-expect-error - partial data
@@ -438,6 +459,37 @@ describe('MicrophoneManager', () => {
438
459
  await manager.apply({ mic_default_on: true }, true);
439
460
  expect(manager['publishStream']).toHaveBeenCalled();
440
461
  });
462
+
463
+ it('should skip defaults when preferences are applied', async () => {
464
+ const devicePersistence = { enabled: true, storageKey: '' };
465
+ const persistedManager = new MicrophoneManager(
466
+ call,
467
+ devicePersistence,
468
+ 'disable-tracks',
469
+ );
470
+ const applySpy = vi
471
+ .spyOn(persistedManager as never, 'applyPersistedPreferences')
472
+ .mockResolvedValue(true);
473
+ const enableSpy = vi.spyOn(persistedManager, 'enable');
474
+
475
+ // @ts-expect-error - partial data
476
+ await persistedManager.apply({ mic_default_on: true }, true);
477
+
478
+ expect(applySpy).toHaveBeenCalledWith(true);
479
+ expect(enableSpy).not.toHaveBeenCalled();
480
+ });
481
+
482
+ it('should not apply defaults when mic is not pristine', async () => {
483
+ manager.state.setStatus('enabled');
484
+ const applySpy = vi.spyOn(manager as never, 'applyPersistedPreferences');
485
+ const enableSpy = vi.spyOn(manager, 'enable');
486
+
487
+ // @ts-expect-error - partial data
488
+ await manager.apply({ mic_default_on: true }, true);
489
+
490
+ expect(applySpy).not.toHaveBeenCalled();
491
+ expect(enableSpy).not.toHaveBeenCalled();
492
+ });
441
493
  });
442
494
 
443
495
  describe('Hi-Fi Audio', () => {
@@ -582,6 +634,98 @@ describe('MicrophoneManager', () => {
582
634
  });
583
635
  });
584
636
 
637
+ describe('Device Persistence Stress', () => {
638
+ it('persists the final microphone and muted state after rapid toggles, switches, and unplug', async () => {
639
+ const storageKey = '@test/device-preferences-microphone-stress';
640
+ const localStorageMock = createLocalStorageMock();
641
+ const originalWindow = globalThis.window;
642
+ Object.defineProperty(globalThis, 'window', {
643
+ configurable: true,
644
+ value: { localStorage: localStorageMock },
645
+ });
646
+
647
+ const getAudioStreamMock = vi.mocked(getAudioStream);
648
+ getAudioStreamMock.mockImplementation((constraints) => {
649
+ const requestedDeviceId = (constraints?.deviceId as { exact?: string })
650
+ ?.exact;
651
+ const selectedDevice =
652
+ mockAudioDevices.find((d) => d.deviceId === requestedDeviceId) ??
653
+ mockAudioDevices[0];
654
+ return Promise.resolve(
655
+ createAudioStreamForDevice(
656
+ selectedDevice.deviceId,
657
+ selectedDevice.label,
658
+ ),
659
+ );
660
+ });
661
+
662
+ const stressManager = new MicrophoneManager(
663
+ call,
664
+ { enabled: true, storageKey },
665
+ 'disable-tracks',
666
+ );
667
+
668
+ try {
669
+ const finalDevice = mockAudioDevices[2];
670
+ emitDeviceIds(mockAudioDevices);
671
+
672
+ await Promise.allSettled([
673
+ stressManager.enable(),
674
+ stressManager.select(mockAudioDevices[1].deviceId),
675
+ stressManager.toggle(),
676
+ stressManager.select(finalDevice.deviceId),
677
+ stressManager.toggle(),
678
+ stressManager.enable(),
679
+ ]);
680
+ await stressManager.statusChangeSettled();
681
+ await stressManager.select(finalDevice.deviceId);
682
+ await stressManager.enable();
683
+ await stressManager.statusChangeSettled();
684
+
685
+ expect(stressManager.state.selectedDevice).toBe(finalDevice.deviceId);
686
+ expect(stressManager.state.status).toBe('enabled');
687
+
688
+ const persistedBeforeUnplug = toPreferenceList(
689
+ readPreferences(storageKey).microphone,
690
+ );
691
+ expect(persistedBeforeUnplug[0]).toEqual({
692
+ selectedDeviceId: finalDevice.deviceId,
693
+ selectedDeviceLabel: finalDevice.label,
694
+ muted: false,
695
+ });
696
+
697
+ emitDeviceIds(
698
+ mockAudioDevices.filter((d) => d.deviceId !== finalDevice.deviceId),
699
+ );
700
+
701
+ await vi.waitFor(() => {
702
+ expect(stressManager.state.selectedDevice).toBe(undefined);
703
+ expect(stressManager.state.status).toBe('disabled');
704
+ });
705
+
706
+ const persistedAfterUnplug = toPreferenceList(
707
+ readPreferences(storageKey).microphone,
708
+ );
709
+ expect(persistedAfterUnplug[0]).toEqual({
710
+ selectedDeviceId: defaultDeviceId,
711
+ selectedDeviceLabel: '',
712
+ muted: true,
713
+ });
714
+ expect(persistedAfterUnplug).toContainEqual({
715
+ selectedDeviceId: finalDevice.deviceId,
716
+ selectedDeviceLabel: finalDevice.label,
717
+ muted: true,
718
+ });
719
+ } finally {
720
+ stressManager.dispose();
721
+ Object.defineProperty(globalThis, 'window', {
722
+ configurable: true,
723
+ value: originalWindow,
724
+ });
725
+ }
726
+ });
727
+ });
728
+
585
729
  afterEach(() => {
586
730
  vi.restoreAllMocks();
587
731
  vi.clearAllMocks();
@@ -80,6 +80,7 @@ describe('MicrophoneManager React Native', () => {
80
80
  },
81
81
  };
82
82
 
83
+ const devicePersistence = { enabled: false, storageKey: '' };
83
84
  manager = new MicrophoneManager(
84
85
  new Call({
85
86
  id: '',
@@ -87,6 +88,7 @@ describe('MicrophoneManager React Native', () => {
87
88
  streamClient: new StreamClient('abc123'),
88
89
  clientStore: new StreamVideoWriteableStateStore(),
89
90
  }),
91
+ devicePersistence,
90
92
  );
91
93
  });
92
94
 
@@ -34,6 +34,7 @@ describe('ScreenShareManager', () => {
34
34
  let manager: ScreenShareManager;
35
35
 
36
36
  beforeEach(() => {
37
+ const devicePersistence = { enabled: false, storageKey: '' };
37
38
  manager = new ScreenShareManager(
38
39
  new Call({
39
40
  id: '',
@@ -41,6 +42,7 @@ describe('ScreenShareManager', () => {
41
42
  streamClient: new StreamClient('abc123'),
42
43
  clientStore: new StreamVideoWriteableStateStore(),
43
44
  }),
45
+ devicePersistence,
44
46
  );
45
47
  });
46
48
 
@@ -1,7 +1,10 @@
1
+ /* @vitest-environment happy-dom */
1
2
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
3
  import { fromPartial } from '@total-typescript/shoehorn';
3
4
  import {
5
+ createLocalStorageMock,
4
6
  emitDeviceIds,
7
+ LocalStorageMock,
5
8
  mockAudioDevices,
6
9
  mockBrowserPermission,
7
10
  mockDeviceIds$,
@@ -12,6 +15,7 @@ import { checkIfAudioOutputChangeSupported } from '../devices';
12
15
  import { Call } from '../../Call';
13
16
  import { StreamClient } from '../../coordinator/connection/client';
14
17
  import { StreamVideoWriteableStateStore } from '../../store';
18
+ import { defaultDeviceId } from '../devicePersistence';
15
19
 
16
20
  vi.mock('../devices.ts', () => {
17
21
  console.log('MOCKING devices');
@@ -27,8 +31,17 @@ vi.mock('../devices.ts', () => {
27
31
 
28
32
  describe('SpeakerManager.test', () => {
29
33
  let manager: SpeakerManager;
34
+ let storageKey: string;
35
+ let localStorageMock: LocalStorageMock;
30
36
 
31
37
  beforeEach(() => {
38
+ storageKey = '@test/speaker-preferences';
39
+ localStorageMock = createLocalStorageMock();
40
+ Object.defineProperty(window, 'localStorage', {
41
+ configurable: true,
42
+ value: localStorageMock,
43
+ });
44
+ const devicePersistence = { enabled: false, storageKey };
32
45
  manager = new SpeakerManager(
33
46
  new Call({
34
47
  id: '',
@@ -36,6 +49,7 @@ describe('SpeakerManager.test', () => {
36
49
  streamClient: new StreamClient('abc123'),
37
50
  clientStore: new StreamVideoWriteableStateStore(),
38
51
  }),
52
+ devicePersistence,
39
53
  );
40
54
  });
41
55
 
@@ -111,8 +125,84 @@ describe('SpeakerManager.test', () => {
111
125
  expect(manager.state.selectedDevice).toBe('');
112
126
  });
113
127
 
128
+ describe('apply (web)', () => {
129
+ it('does nothing when persistence is disabled', () => {
130
+ const selectSpy = vi.spyOn(manager, 'select');
131
+ // @ts-expect-error - partial data
132
+ manager.apply({});
133
+ expect(selectSpy).not.toHaveBeenCalled();
134
+ });
135
+
136
+ it('selects the persisted speaker device', () => {
137
+ const persistedManager = new SpeakerManager(
138
+ new Call({
139
+ id: '',
140
+ type: '',
141
+ streamClient: new StreamClient('abc123'),
142
+ clientStore: new StreamVideoWriteableStateStore(),
143
+ }),
144
+ { enabled: true, storageKey },
145
+ );
146
+
147
+ localStorageMock.setItem(
148
+ storageKey,
149
+ JSON.stringify({
150
+ speaker: [
151
+ {
152
+ selectedDeviceId: 'speaker-1',
153
+ selectedDeviceLabel: 'Speaker 1',
154
+ },
155
+ ],
156
+ }),
157
+ );
158
+
159
+ const selectSpy = vi.spyOn(persistedManager, 'select');
160
+ // @ts-expect-error - partial data
161
+ persistedManager.apply({});
162
+
163
+ expect(selectSpy).toHaveBeenCalledWith('speaker-1');
164
+ expect(persistedManager.state.selectedDevice).toBe('speaker-1');
165
+ });
166
+
167
+ it('selects system default when persisted device is default', () => {
168
+ const persistedManager = new SpeakerManager(
169
+ new Call({
170
+ id: '',
171
+ type: '',
172
+ streamClient: new StreamClient('abc123'),
173
+ clientStore: new StreamVideoWriteableStateStore(),
174
+ }),
175
+ { enabled: true, storageKey },
176
+ );
177
+ persistedManager.select('previous-device');
178
+
179
+ localStorageMock.setItem(
180
+ storageKey,
181
+ JSON.stringify({
182
+ speaker: [
183
+ {
184
+ selectedDeviceId: defaultDeviceId,
185
+ selectedDeviceLabel: '',
186
+ },
187
+ ],
188
+ }),
189
+ );
190
+
191
+ const selectSpy = vi.spyOn(persistedManager, 'select');
192
+ // @ts-expect-error - partial data
193
+ persistedManager.apply({});
194
+
195
+ expect(selectSpy).toHaveBeenCalledWith('');
196
+ expect(persistedManager.state.selectedDevice).toBe('');
197
+ });
198
+ });
199
+
114
200
  afterEach(() => {
115
201
  vi.clearAllMocks();
116
202
  vi.resetModules();
203
+ Object.defineProperty(window, 'localStorage', {
204
+ configurable: true,
205
+ value: undefined,
206
+ });
117
207
  });
118
208
  });