@stream-io/video-client 1.56.0 → 1.58.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 (112) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/e2ee-worker.browser.es.js +1342 -0
  3. package/dist/e2ee-worker.browser.es.js.map +1 -0
  4. package/dist/index.browser.es.js +867 -71
  5. package/dist/index.browser.es.js.map +1 -1
  6. package/dist/index.cjs.js +911 -70
  7. package/dist/index.cjs.js.map +1 -1
  8. package/dist/index.d.ts +3 -0
  9. package/dist/index.es.js +900 -71
  10. package/dist/index.es.js.map +1 -1
  11. package/dist/src/Call.d.ts +52 -6
  12. package/dist/src/coordinator/connection/connection.d.ts +1 -1
  13. package/dist/src/devices/CameraManager.d.ts +6 -0
  14. package/dist/src/devices/DeviceManager.d.ts +1 -0
  15. package/dist/src/devices/MicrophoneManager.d.ts +6 -0
  16. package/dist/src/devices/SpeakerManager.d.ts +2 -1
  17. package/dist/src/events/participant.d.ts +10 -3
  18. package/dist/src/gen/coordinator/index.d.ts +1363 -243
  19. package/dist/src/gen/video/sfu/models/models.d.ts +8 -0
  20. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +4 -0
  21. package/dist/src/helpers/TypedEventEmitter.d.ts +23 -0
  22. package/dist/src/helpers/ensureExhausted.d.ts +1 -1
  23. package/dist/src/permissions/PermissionsContext.d.ts +2 -2
  24. package/dist/src/rtc/BasePeerConnection.d.ts +3 -1
  25. package/dist/src/rtc/e2ee/E2EEManager.d.ts +12 -0
  26. package/dist/src/rtc/e2ee/EncryptionManager.d.ts +148 -0
  27. package/dist/src/rtc/e2ee/e2ee-worker.d.ts +25 -0
  28. package/dist/src/rtc/e2ee/events.d.ts +163 -0
  29. package/dist/src/rtc/e2ee/transformSupport.d.ts +21 -0
  30. package/dist/src/rtc/helpers/rtcConfiguration.d.ts +2 -2
  31. package/dist/src/rtc/index.d.ts +1 -0
  32. package/dist/src/rtc/mediaEngine.d.ts +23 -0
  33. package/dist/src/rtc/types.d.ts +2 -0
  34. package/dist/src/store/CallState.d.ts +21 -0
  35. package/dist/src/types.d.ts +15 -6
  36. package/index.ts +3 -0
  37. package/package.json +11 -11
  38. package/src/Call.ts +160 -26
  39. package/src/__tests__/Call.test.ts +15 -0
  40. package/src/coordinator/connection/connection.ts +6 -3
  41. package/src/devices/AudioDeviceManager.ts +6 -0
  42. package/src/devices/CameraManager.ts +51 -4
  43. package/src/devices/DeviceManager.ts +17 -0
  44. package/src/devices/MicrophoneManager.ts +55 -5
  45. package/src/devices/ScreenShareManager.ts +3 -0
  46. package/src/devices/SpeakerManager.ts +42 -12
  47. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +2 -22
  48. package/src/devices/__tests__/SpeakerManager.test.ts +138 -42
  49. package/src/devices/__tests__/mocks.ts +36 -3
  50. package/src/devices/devicePersistence.ts +1 -2
  51. package/src/events/__tests__/participant.test.ts +134 -1
  52. package/src/events/callEventHandlers.ts +9 -3
  53. package/src/events/participant.ts +42 -7
  54. package/src/gen/coordinator/index.ts +1384 -244
  55. package/src/gen/video/sfu/models/models.ts +15 -0
  56. package/src/helpers/MediaPlaybackWatchdog.ts +35 -16
  57. package/src/helpers/TypedEventEmitter.ts +71 -0
  58. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +137 -8
  59. package/src/helpers/__tests__/TypedEventEmitter.test.ts +133 -0
  60. package/src/helpers/__tests__/ViewportTracker.test.ts +1 -2
  61. package/src/helpers/ensureExhausted.ts +4 -2
  62. package/src/permissions/PermissionsContext.ts +10 -5
  63. package/src/reporting/ClientEventReporter.ts +2 -8
  64. package/src/rtc/BasePeerConnection.ts +12 -1
  65. package/src/rtc/Publisher.ts +8 -0
  66. package/src/rtc/Subscriber.ts +5 -0
  67. package/src/rtc/__tests__/Publisher.test.ts +101 -0
  68. package/src/rtc/__tests__/Subscriber.test.ts +89 -0
  69. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +31 -0
  70. package/src/rtc/e2ee/E2EEManager.ts +12 -0
  71. package/src/rtc/e2ee/EncryptionManager.ts +336 -0
  72. package/src/rtc/e2ee/SPEC.md +586 -0
  73. package/src/rtc/e2ee/__tests__/E2EEManager.contract.test.ts +39 -0
  74. package/src/rtc/e2ee/__tests__/EncryptionManager.test.ts +490 -0
  75. package/src/rtc/e2ee/__tests__/codec.test.ts +247 -0
  76. package/src/rtc/e2ee/__tests__/conformance.test.ts +184 -0
  77. package/src/rtc/e2ee/__tests__/failureTracker.test.ts +45 -0
  78. package/src/rtc/e2ee/__tests__/frameCounter.test.ts +90 -0
  79. package/src/rtc/e2ee/__tests__/keyStore.test.ts +221 -0
  80. package/src/rtc/e2ee/__tests__/notifications.test.ts +166 -0
  81. package/src/rtc/e2ee/__tests__/perf.test.ts +139 -0
  82. package/src/rtc/e2ee/__tests__/queue.test.ts +61 -0
  83. package/src/rtc/e2ee/__tests__/replayWindow.test.ts +133 -0
  84. package/src/rtc/e2ee/__tests__/trailer.test.ts +143 -0
  85. package/src/rtc/e2ee/__tests__/transform-pipeline.test.ts +839 -0
  86. package/src/rtc/e2ee/__tests__/transformSupport.test.ts +65 -0
  87. package/src/rtc/e2ee/e2ee-worker/codec.ts +220 -0
  88. package/src/rtc/e2ee/e2ee-worker/constants.ts +47 -0
  89. package/src/rtc/e2ee/e2ee-worker/decode.ts +159 -0
  90. package/src/rtc/e2ee/e2ee-worker/e2ee-worker-impl.ts +137 -0
  91. package/src/rtc/e2ee/e2ee-worker/encode.ts +147 -0
  92. package/src/rtc/e2ee/e2ee-worker/failureTracker.ts +26 -0
  93. package/src/rtc/e2ee/e2ee-worker/frameCounter.ts +37 -0
  94. package/src/rtc/e2ee/e2ee-worker/keyStore.ts +211 -0
  95. package/src/rtc/e2ee/e2ee-worker/notifications.ts +190 -0
  96. package/src/rtc/e2ee/e2ee-worker/perf.ts +129 -0
  97. package/src/rtc/e2ee/e2ee-worker/queue.ts +12 -0
  98. package/src/rtc/e2ee/e2ee-worker/replayWindow.ts +131 -0
  99. package/src/rtc/e2ee/e2ee-worker/trailer.ts +155 -0
  100. package/src/rtc/e2ee/e2ee-worker/tsconfig.json +8 -0
  101. package/src/rtc/e2ee/e2ee-worker/types.ts +29 -0
  102. package/src/rtc/e2ee/e2ee-worker.ts +27 -0
  103. package/src/rtc/e2ee/events.ts +179 -0
  104. package/src/rtc/e2ee/transformSupport.ts +35 -0
  105. package/src/rtc/helpers/rtcConfiguration.ts +4 -2
  106. package/src/rtc/index.ts +1 -0
  107. package/src/rtc/mediaEngine.ts +44 -0
  108. package/src/rtc/types.ts +3 -2
  109. package/src/stats/rtc/types.ts +8 -2
  110. package/src/store/CallState.ts +34 -3
  111. package/src/store/__tests__/CallState.test.ts +82 -1
  112. package/src/types.ts +24 -9
@@ -76,7 +76,10 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
76
76
  ]) => {
77
77
  try {
78
78
  if (callingState === CallingState.LEFT) {
79
- this.setMutedRecordingPrepared(false);
79
+ // The muted-recording-prepared mode is reset in `callManager.stop()`
80
+ // (during leave, while the call factory is still alive), not here —
81
+ // this subscription fires asynchronously and could land after the
82
+ // factory is disposed, forcing a default-ADM rebuild.
80
83
  await this.stopSpeakingWhileMutedDetection();
81
84
  }
82
85
  if (callingState !== CallingState.JOINED) return;
@@ -187,6 +190,44 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
187
190
  }
188
191
  }
189
192
 
193
+ override enable(): Promise<void> {
194
+ if (isReactNative() && !this.call.hasMediaEngine) {
195
+ this.state.setPendingStatus('enabled');
196
+ return Promise.resolve();
197
+ }
198
+
199
+ return super.enable();
200
+ }
201
+
202
+ override disable(options: { forceStop?: boolean }): Promise<void>;
203
+ override disable(forceStop?: boolean): Promise<void>;
204
+ override async disable(
205
+ forceStopOrOptions?: boolean | { forceStop?: boolean },
206
+ ): Promise<void> {
207
+ if (isReactNative() && !this.call.hasMediaEngine) {
208
+ this.state.setPendingStatus('disabled');
209
+ return;
210
+ }
211
+
212
+ // forward verbatim to the base, narrowing so the right overload is selected
213
+ if (forceStopOrOptions === undefined) return super.disable();
214
+ if (typeof forceStopOrOptions === 'boolean') {
215
+ return super.disable(forceStopOrOptions);
216
+ }
217
+ return super.disable(forceStopOrOptions);
218
+ }
219
+
220
+ override toggle(): Promise<void> {
221
+ if (isReactNative() && !this.call.hasMediaEngine) {
222
+ this.state.setPendingStatus(
223
+ this.state.optimisticStatus === 'enabled' ? 'disabled' : 'enabled',
224
+ );
225
+ return Promise.resolve();
226
+ }
227
+
228
+ return super.toggle();
229
+ }
230
+
190
231
  /**
191
232
  * Enables noise cancellation for the microphone.
192
233
  *
@@ -376,9 +417,15 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
376
417
  }
377
418
  }
378
419
 
379
- const { mediaStream } = this.state;
380
- if (canPublish && publish && this.enabled && mediaStream) {
381
- await this.publishStream(mediaStream);
420
+ if (isReactNative() && publish && canPublish) {
421
+ // On RN the microphone is enabled/disabled optimistically before JOINED. Reconcile now
422
+ // acquires the track and publishes it, so it fully owns the publish.
423
+ await this.reconcileOptimisticStatus();
424
+ } else {
425
+ const { mediaStream } = this.state;
426
+ if (canPublish && publish && this.enabled && mediaStream) {
427
+ await this.publishStream(mediaStream);
428
+ }
382
429
  }
383
430
  }
384
431
 
@@ -386,9 +433,12 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
386
433
  return getAudioDevices(this.call.tracer);
387
434
  }
388
435
 
389
- protected override getStream(
436
+ protected override async getStream(
390
437
  constraints: MediaTrackConstraints,
391
438
  ): Promise<MediaStream> {
439
+ // Ensure the call's media factory exists before capture so the resulting
440
+ // track is owned by it (the WebRTC globals resolve to the live factory).
441
+ await this.call.ensureMediaFactory();
392
442
  return getAudioStream(constraints, this.call.tracer);
393
443
  }
394
444
 
@@ -90,6 +90,9 @@ export class ScreenShareManager extends AudioDeviceManager<
90
90
  if (!this.state.audioEnabled) {
91
91
  constraints.audio = false;
92
92
  }
93
+ // Ensure the call's media factory exists before capture so the resulting
94
+ // track is owned by it (the WebRTC globals resolve to the live factory).
95
+ await this.call.ensureMediaFactory();
93
96
  const stream = await getScreenShareStream(constraints, this.call.tracer);
94
97
  const [track] = stream.getVideoTracks();
95
98
  const { contentHint } = this.state.settings || {};
@@ -1,4 +1,4 @@
1
- import { combineLatest } from 'rxjs';
1
+ import { combineLatest, firstValueFrom, pairwise } from 'rxjs';
2
2
  import { Call } from '../Call';
3
3
  import { isReactNative } from '../helpers/platforms';
4
4
  import { SpeakerState } from './SpeakerState';
@@ -39,11 +39,15 @@ export class SpeakerManager {
39
39
  this.setup();
40
40
  }
41
41
 
42
- apply(settings: CallSettingsResponse) {
43
- return isReactNative() ? this.applyRN(settings) : this.applyWeb();
42
+ async apply(settings: CallSettingsResponse): Promise<void> {
43
+ if (isReactNative()) {
44
+ this.applyRN(settings);
45
+ return;
46
+ }
47
+ await this.applyWeb();
44
48
  }
45
49
 
46
- private applyWeb() {
50
+ private async applyWeb() {
47
51
  const { enabled, storageKey } = this.devicePersistence;
48
52
  if (!enabled) return;
49
53
 
@@ -56,8 +60,26 @@ export class SpeakerManager {
56
60
  preference.selectedDeviceId === defaultDeviceId
57
61
  ? ''
58
62
  : preference.selectedDeviceId;
59
- if (this.state.selectedDevice !== nextDeviceId) {
60
- this.select(nextDeviceId);
63
+ if (!nextDeviceId) {
64
+ if (this.state.selectedDevice !== nextDeviceId) {
65
+ this.select(nextDeviceId);
66
+ }
67
+ return;
68
+ }
69
+
70
+ const permissionState = await firstValueFrom(
71
+ getAudioBrowserPermission(this.call.tracer).asStateObservable(),
72
+ );
73
+ if (permissionState !== 'granted') return;
74
+
75
+ const devices = await firstValueFrom(this.listDevices());
76
+ const device =
77
+ this.findDevice(devices, nextDeviceId) ??
78
+ (preference.selectedDeviceLabel
79
+ ? devices.find((d) => d.label === preference.selectedDeviceLabel)
80
+ : undefined);
81
+ if (device && this.state.selectedDevice !== device.deviceId) {
82
+ this.select(device.deviceId);
61
83
  }
62
84
  }
63
85
 
@@ -90,6 +112,7 @@ export class SpeakerManager {
90
112
  globalThis.streamRNVideoSDK?.callManager.setup({
91
113
  defaultDevice,
92
114
  isRingingTypeCall: this.call.ringing,
115
+ cid: this.call.cid,
93
116
  });
94
117
  }
95
118
  }
@@ -101,13 +124,16 @@ export class SpeakerManager {
101
124
  if (deviceIds$ && !isReactNative()) {
102
125
  this.subscriptions.push(
103
126
  createSubscription(
104
- combineLatest([deviceIds$, this.state.selectedDevice$]),
105
- ([devices, deviceId]) => {
127
+ combineLatest([
128
+ deviceIds$!.pipe(pairwise()),
129
+ this.state.selectedDevice$,
130
+ ]),
131
+ ([[prevDevices, currentDevices], deviceId]) => {
106
132
  if (!deviceId) return;
107
- const device = devices.find(
108
- (d) => d.deviceId === deviceId && d.kind === 'audiooutput',
109
- );
110
- if (!device) this.select('');
133
+ const isDisconnected =
134
+ this.findDevice(prevDevices, deviceId) &&
135
+ !this.findDevice(currentDevices, deviceId);
136
+ if (isDisconnected) this.select('');
111
137
  },
112
138
  ),
113
139
  );
@@ -164,6 +190,7 @@ export class SpeakerManager {
164
190
  this.subscriptions.forEach((unsubscribe) => unsubscribe());
165
191
  this.subscriptions = [];
166
192
  this.areSubscriptionsSetUp = false;
193
+ this.defaultDevice = undefined;
167
194
  };
168
195
 
169
196
  /**
@@ -201,6 +228,9 @@ export class SpeakerManager {
201
228
  });
202
229
  }
203
230
 
231
+ private findDevice = (devices: MediaDeviceInfo[], deviceId: string) =>
232
+ devices.find((d) => d.deviceId === deviceId && d.kind === 'audiooutput');
233
+
204
234
  private persistSpeakerDevicePreference(selectedDevice: string) {
205
235
  const { storageKey } = this.devicePersistence;
206
236
  const devices = getCurrentValue(this.listDevices()) || [];
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import { MicrophoneManager } from '../MicrophoneManager';
3
3
  import { Call } from '../../Call';
4
4
  import { StreamClient } from '../../coordinator/connection/client';
5
- import { CallingState, StreamVideoWriteableStateStore } from '../../store';
5
+ import { StreamVideoWriteableStateStore } from '../../store';
6
6
  import {
7
7
  mockAudioDevices,
8
8
  mockAudioStream,
@@ -16,8 +16,7 @@ import { settled, withoutConcurrency } from '../../helpers/concurrency';
16
16
  import { ClientEventReporter } from '../../reporting';
17
17
 
18
18
  let speechActivityCallback:
19
- | ((state: { isSoundDetected: boolean }) => void)
20
- | null = null;
19
+ ((state: { isSoundDetected: boolean }) => void) | null = null;
21
20
  let unsubscribeMocks: ReturnType<typeof vi.fn>[] = [];
22
21
 
23
22
  vi.mock('../../helpers/platforms.ts', () => {
@@ -264,25 +263,6 @@ describe('MicrophoneManager React Native', () => {
264
263
  expect(setMutedRecordingPreparedMock).not.toHaveBeenCalledWith(true);
265
264
  });
266
265
 
267
- it('should release prepared muted recording when the call is left', async () => {
268
- await manager.disable();
269
- await vi.waitUntil(
270
- () =>
271
- setMutedRecordingPreparedMock.mock.calls.some(([arg]) => arg === true),
272
- { timeout: 100 },
273
- );
274
- setMutedRecordingPreparedMock.mockClear();
275
-
276
- manager['call'].state.setCallingState(CallingState.LEFT);
277
-
278
- await vi.waitUntil(
279
- () =>
280
- setMutedRecordingPreparedMock.mock.calls.some(([arg]) => arg === false),
281
- { timeout: 100 },
282
- );
283
- expect(setMutedRecordingPreparedMock).toHaveBeenCalledWith(false);
284
- });
285
-
286
266
  afterEach(() => {
287
267
  globalThis.streamRNVideoSDK = undefined;
288
268
  vi.clearAllMocks();
@@ -6,8 +6,10 @@ import {
6
6
  emitDeviceIds,
7
7
  LocalStorageMock,
8
8
  mockAudioDevices,
9
+ mockAudioOutputDevices,
9
10
  mockBrowserPermission,
10
11
  mockDeviceIds$,
12
+ mockDevicesWithoutAudioPermission,
11
13
  } from './mocks';
12
14
  import { of } from 'rxjs';
13
15
  import { SpeakerManager } from '../SpeakerManager';
@@ -122,15 +124,36 @@ describe('SpeakerManager.test', () => {
122
124
  });
123
125
 
124
126
  it('should disable device if selected device is disconnected', () => {
125
- emitDeviceIds(mockAudioDevices);
126
- const deviceId = mockAudioDevices[1].deviceId;
127
+ const deviceId = mockAudioOutputDevices[0].deviceId;
128
+ emitDeviceIds([...mockAudioDevices, ...mockAudioOutputDevices]);
127
129
  manager.select(deviceId);
128
130
 
129
- emitDeviceIds(mockAudioDevices.slice(2));
131
+ emitDeviceIds([...mockAudioDevices, ...mockAudioOutputDevices.slice(1)]);
130
132
 
131
133
  expect(manager.state.selectedDevice).toBe('');
132
134
  });
133
135
 
136
+ it('should keep the selection when the device list never exposed audio ids', () => {
137
+ const deviceId = mockAudioOutputDevices[0].deviceId;
138
+ emitDeviceIds(mockDevicesWithoutAudioPermission);
139
+
140
+ manager.select(deviceId);
141
+ expect(manager.state.selectedDevice).toBe(deviceId);
142
+
143
+ emitDeviceIds(mockDevicesWithoutAudioPermission);
144
+ expect(manager.state.selectedDevice).toBe(deviceId);
145
+ });
146
+
147
+ it('should keep the selection when the device appears in a later enumeration', () => {
148
+ const deviceId = mockAudioOutputDevices[0].deviceId;
149
+ emitDeviceIds(mockDevicesWithoutAudioPermission);
150
+ manager.select(deviceId);
151
+
152
+ emitDeviceIds([...mockAudioDevices, ...mockAudioOutputDevices]);
153
+
154
+ expect(manager.state.selectedDevice).toBe(deviceId);
155
+ });
156
+
134
157
  it('persists speaker selection when permission is granted', async () => {
135
158
  const streamClient = new StreamClient('abc123');
136
159
  const persistedManager = new SpeakerManager(
@@ -159,16 +182,9 @@ describe('SpeakerManager.test', () => {
159
182
  });
160
183
 
161
184
  describe('apply (web)', () => {
162
- it('does nothing when persistence is disabled', () => {
163
- const selectSpy = vi.spyOn(manager, 'select');
164
- // @ts-expect-error - partial data
165
- manager.apply({});
166
- expect(selectSpy).not.toHaveBeenCalled();
167
- });
168
-
169
- it('selects the persisted speaker device', () => {
185
+ const createPersistedManager = () => {
170
186
  const streamClient = new StreamClient('abc123');
171
- const persistedManager = new SpeakerManager(
187
+ return new SpeakerManager(
172
188
  new Call({
173
189
  id: '',
174
190
  type: '',
@@ -178,56 +194,136 @@ describe('SpeakerManager.test', () => {
178
194
  }),
179
195
  { enabled: true, storageKey },
180
196
  );
197
+ };
181
198
 
199
+ const persist = (selectedDeviceId: string, selectedDeviceLabel: string) => {
182
200
  localStorageMock.setItem(
183
201
  storageKey,
184
202
  JSON.stringify({
185
- speaker: [
186
- {
187
- selectedDeviceId: 'speaker-1',
188
- selectedDeviceLabel: 'Speaker 1',
189
- },
190
- ],
203
+ speaker: [{ selectedDeviceId, selectedDeviceLabel }],
191
204
  }),
192
205
  );
206
+ };
207
+
208
+ it('does nothing when persistence is disabled', async () => {
209
+ const selectSpy = vi.spyOn(manager, 'select');
210
+ // @ts-expect-error - partial data
211
+ await manager.apply({});
212
+ expect(selectSpy).not.toHaveBeenCalled();
213
+ });
214
+
215
+ it('selects the persisted speaker device', async () => {
216
+ const persistedManager = createPersistedManager();
217
+ vi.spyOn(persistedManager, 'listDevices').mockReturnValue(
218
+ of([
219
+ {
220
+ deviceId: 'speaker-1',
221
+ kind: 'audiooutput',
222
+ label: 'Speaker 1',
223
+ groupId: 'speaker-group',
224
+ } as MediaDeviceInfo,
225
+ ]),
226
+ );
227
+ persist('speaker-1', 'Speaker 1');
193
228
 
194
229
  const selectSpy = vi.spyOn(persistedManager, 'select');
195
230
  // @ts-expect-error - partial data
196
- persistedManager.apply({});
231
+ await persistedManager.apply({});
197
232
 
198
233
  expect(selectSpy).toHaveBeenCalledWith('speaker-1');
199
234
  expect(persistedManager.state.selectedDevice).toBe('speaker-1');
200
235
  });
201
236
 
202
- it('selects system default when persisted device is default', () => {
203
- const streamClient = new StreamClient('abc123');
204
- const persistedManager = new SpeakerManager(
205
- new Call({
206
- id: '',
207
- type: '',
208
- streamClient,
209
- clientEventReporter: new ClientEventReporter({ streamClient }),
210
- clientStore: new StreamVideoWriteableStateStore(),
211
- }),
212
- { enabled: true, storageKey },
237
+ it('does not select a missing persisted speaker device', async () => {
238
+ const persistedManager = createPersistedManager();
239
+ vi.spyOn(persistedManager, 'listDevices').mockReturnValue(
240
+ of(mockAudioOutputDevices),
213
241
  );
214
- persistedManager.select('previous-device');
242
+ persist('speaker-1', 'Speaker 1');
215
243
 
216
- localStorageMock.setItem(
217
- storageKey,
218
- JSON.stringify({
219
- speaker: [
220
- {
221
- selectedDeviceId: defaultDeviceId,
222
- selectedDeviceLabel: '',
223
- },
224
- ],
225
- }),
244
+ const selectSpy = vi.spyOn(persistedManager, 'select');
245
+ // @ts-expect-error - partial data
246
+ await persistedManager.apply({});
247
+
248
+ expect(selectSpy).not.toHaveBeenCalled();
249
+ expect(persistedManager.state.selectedDevice).toBe('');
250
+ });
251
+
252
+ it('does not restore a speaker preference when audio permission is not granted', async () => {
253
+ const persistedManager = createPersistedManager();
254
+ vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
255
+ of('prompt'),
256
+ );
257
+ const listDevicesSpy = vi.spyOn(persistedManager, 'listDevices');
258
+ persist('speaker-1', 'Speaker 1');
259
+
260
+ const selectSpy = vi.spyOn(persistedManager, 'select');
261
+ // @ts-expect-error - partial data
262
+ await persistedManager.apply({});
263
+
264
+ expect(listDevicesSpy).not.toHaveBeenCalled();
265
+ expect(selectSpy).not.toHaveBeenCalled();
266
+ expect(persistedManager.state.selectedDevice).toBe('');
267
+ });
268
+
269
+ it('does not restore a speaker preference from a non-output device with the same label', async () => {
270
+ const persistedManager = createPersistedManager();
271
+ const videoDevice = {
272
+ deviceId: 'video-device-id',
273
+ kind: 'videoinput',
274
+ label: 'RODECaster Video (19f7:006b)',
275
+ groupId: 'video-group',
276
+ } as MediaDeviceInfo;
277
+ const outputDevice = {
278
+ deviceId: 'speaker-device-id',
279
+ kind: 'audiooutput',
280
+ label: 'RODECaster Video (19f7:006b)',
281
+ groupId: 'speaker-group',
282
+ } as MediaDeviceInfo;
283
+ vi.spyOn(persistedManager, 'listDevices').mockReturnValue(
284
+ of([outputDevice]),
226
285
  );
286
+ persist('missing-speaker-device-id', outputDevice.label);
287
+
288
+ const selectSpy = vi.spyOn(persistedManager, 'select');
289
+ // @ts-expect-error - partial data
290
+ await persistedManager.apply({});
291
+
292
+ expect(selectSpy).toHaveBeenCalledWith(outputDevice.deviceId);
293
+ expect(selectSpy).not.toHaveBeenCalledWith(videoDevice.deviceId);
294
+ expect(persistedManager.state.selectedDevice).toBe(outputDevice.deviceId);
295
+ });
296
+
297
+ it('does not restore a speaker preference from an empty label placeholder', async () => {
298
+ const persistedManager = createPersistedManager();
299
+ vi.spyOn(persistedManager, 'listDevices').mockReturnValue(
300
+ of([
301
+ {
302
+ deviceId: '',
303
+ kind: 'audiooutput',
304
+ label: '',
305
+ groupId: '',
306
+ } as MediaDeviceInfo,
307
+ ]),
308
+ );
309
+ persist('missing-speaker-device-id', '');
310
+
311
+ const selectSpy = vi.spyOn(persistedManager, 'select');
312
+ // @ts-expect-error - partial data
313
+ await persistedManager.apply({});
314
+
315
+ expect(selectSpy).not.toHaveBeenCalled();
316
+ expect(persistedManager.state.selectedDevice).toBe('');
317
+ });
318
+
319
+ it('selects system default when persisted device is default', async () => {
320
+ const persistedManager = createPersistedManager();
321
+ persistedManager.select('previous-device');
322
+ persist(defaultDeviceId, '');
227
323
 
228
324
  const selectSpy = vi.spyOn(persistedManager, 'select');
229
325
  // @ts-expect-error - partial data
230
- persistedManager.apply({});
326
+ await persistedManager.apply({});
231
327
 
232
328
  expect(selectSpy).toHaveBeenCalledWith('');
233
329
  expect(persistedManager.state.selectedDevice).toBe('');
@@ -6,7 +6,7 @@ import {
6
6
  OwnCapability,
7
7
  } from '../../gen/coordinator';
8
8
  import { Call } from '../../Call';
9
- import { of, Subject } from 'rxjs';
9
+ import { of, ReplaySubject } from 'rxjs';
10
10
  import { BrowserPermission } from '../BrowserPermission';
11
11
  import { Tracer } from '../../stats';
12
12
 
@@ -71,6 +71,35 @@ export const mockAudioDevices = [
71
71
  },
72
72
  ] as MediaDeviceInfo[];
73
73
 
74
+ export const mockAudioOutputDevices = [
75
+ {
76
+ deviceId:
77
+ 'f1602a62d9d363c0711de07182751b646b87cfc0edd9e89d2849289e49304158',
78
+ kind: 'audiooutput',
79
+ label: 'RODECaster Video (19f7:006b)',
80
+ groupId: '27f43ef815ab224429532d6e8ebbeb00d7a895e0cbb0c2e4fcbe5b370f234259',
81
+ },
82
+ {
83
+ deviceId:
84
+ 'd542885e6a47d7a38837aee0323bd2c50e825971690f47295cb338e1f1061283',
85
+ kind: 'audiooutput',
86
+ label: 'MacBook Pro Speakers (Built-in)',
87
+ groupId: 'a8708545cb3969c0b7e3712d2ffdbc381992aeb5577b06cd71ba7a94ee20cf3e',
88
+ },
89
+ ] as MediaDeviceInfo[];
90
+
91
+ export const mockDevicesWithoutAudioPermission = [
92
+ { deviceId: '', kind: 'audioinput', label: '', groupId: '' },
93
+ {
94
+ deviceId:
95
+ 'f10561a0bbd3effbd25645e7f06fc2e261255afeb05f6bc4fdaba3ed7bd9c2b7',
96
+ kind: 'videoinput',
97
+ label: 'RODECaster Video (19f7:006b)',
98
+ groupId: '27f43ef815ab224429532d6e8ebbeb00d7a895e0cbb0c2e4fcbe5b370f234259',
99
+ },
100
+ { deviceId: '', kind: 'audiooutput', label: '', groupId: '' },
101
+ ] as MediaDeviceInfo[];
102
+
74
103
  export const mockCall = (): Partial<Call> => {
75
104
  const callState = new CallState();
76
105
  callState.setCallingState(CallingState.JOINED);
@@ -97,6 +126,7 @@ export const mockCall = (): Partial<Call> => {
97
126
  return {
98
127
  cid: 'default:test-call',
99
128
  state: callState,
129
+ hasMediaEngine: true,
100
130
  publish: vi.fn(),
101
131
  stopPublish: vi.fn(),
102
132
  streamClient: fromPartial({
@@ -106,6 +136,9 @@ export const mockCall = (): Partial<Call> => {
106
136
  notifyNoiseCancellationStopped: vi.fn().mockResolvedValue(undefined),
107
137
  notifyTrackMuteState: vi.fn().mockResolvedValue(undefined),
108
138
  refreshPublishedTrack: vi.fn().mockResolvedValue(undefined),
139
+ ensureMediaFactory: vi.fn().mockResolvedValue({
140
+ dispose: vi.fn().mockResolvedValue(undefined),
141
+ }),
109
142
  tracer: new Tracer('tests'),
110
143
  };
111
144
  };
@@ -252,9 +285,9 @@ export const createLocalStorageMock = (): LocalStorageMock => {
252
285
  };
253
286
  };
254
287
 
255
- let deviceIds: Subject<MediaDeviceInfo[]>;
288
+ let deviceIds: ReplaySubject<MediaDeviceInfo[]>;
256
289
  export const mockDeviceIds$ = () => {
257
- deviceIds = new Subject();
290
+ deviceIds = new ReplaySubject(1);
258
291
  return deviceIds;
259
292
  };
260
293
 
@@ -24,8 +24,7 @@ export type LocalDevicePreference = {
24
24
 
25
25
  export type LocalDevicePreferences = {
26
26
  [type in DevicePreferenceKey]?:
27
- | LocalDevicePreference
28
- | LocalDevicePreference[];
27
+ LocalDevicePreference | LocalDevicePreference[];
29
28
  };
30
29
 
31
30
  export const defaultDeviceId = 'default';