@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.
- package/CHANGELOG.md +23 -0
- package/dist/e2ee-worker.browser.es.js +1342 -0
- package/dist/e2ee-worker.browser.es.js.map +1 -0
- package/dist/index.browser.es.js +867 -71
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +911 -70
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.es.js +900 -71
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +52 -6
- package/dist/src/coordinator/connection/connection.d.ts +1 -1
- package/dist/src/devices/CameraManager.d.ts +6 -0
- package/dist/src/devices/DeviceManager.d.ts +1 -0
- package/dist/src/devices/MicrophoneManager.d.ts +6 -0
- package/dist/src/devices/SpeakerManager.d.ts +2 -1
- package/dist/src/events/participant.d.ts +10 -3
- package/dist/src/gen/coordinator/index.d.ts +1363 -243
- package/dist/src/gen/video/sfu/models/models.d.ts +8 -0
- package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +4 -0
- package/dist/src/helpers/TypedEventEmitter.d.ts +23 -0
- package/dist/src/helpers/ensureExhausted.d.ts +1 -1
- package/dist/src/permissions/PermissionsContext.d.ts +2 -2
- package/dist/src/rtc/BasePeerConnection.d.ts +3 -1
- package/dist/src/rtc/e2ee/E2EEManager.d.ts +12 -0
- package/dist/src/rtc/e2ee/EncryptionManager.d.ts +148 -0
- package/dist/src/rtc/e2ee/e2ee-worker.d.ts +25 -0
- package/dist/src/rtc/e2ee/events.d.ts +163 -0
- package/dist/src/rtc/e2ee/transformSupport.d.ts +21 -0
- package/dist/src/rtc/helpers/rtcConfiguration.d.ts +2 -2
- package/dist/src/rtc/index.d.ts +1 -0
- package/dist/src/rtc/mediaEngine.d.ts +23 -0
- package/dist/src/rtc/types.d.ts +2 -0
- package/dist/src/store/CallState.d.ts +21 -0
- package/dist/src/types.d.ts +15 -6
- package/index.ts +3 -0
- package/package.json +11 -11
- package/src/Call.ts +160 -26
- package/src/__tests__/Call.test.ts +15 -0
- package/src/coordinator/connection/connection.ts +6 -3
- package/src/devices/AudioDeviceManager.ts +6 -0
- package/src/devices/CameraManager.ts +51 -4
- package/src/devices/DeviceManager.ts +17 -0
- package/src/devices/MicrophoneManager.ts +55 -5
- package/src/devices/ScreenShareManager.ts +3 -0
- package/src/devices/SpeakerManager.ts +42 -12
- package/src/devices/__tests__/MicrophoneManagerRN.test.ts +2 -22
- package/src/devices/__tests__/SpeakerManager.test.ts +138 -42
- package/src/devices/__tests__/mocks.ts +36 -3
- package/src/devices/devicePersistence.ts +1 -2
- package/src/events/__tests__/participant.test.ts +134 -1
- package/src/events/callEventHandlers.ts +9 -3
- package/src/events/participant.ts +42 -7
- package/src/gen/coordinator/index.ts +1384 -244
- package/src/gen/video/sfu/models/models.ts +15 -0
- package/src/helpers/MediaPlaybackWatchdog.ts +35 -16
- package/src/helpers/TypedEventEmitter.ts +71 -0
- package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +137 -8
- package/src/helpers/__tests__/TypedEventEmitter.test.ts +133 -0
- package/src/helpers/__tests__/ViewportTracker.test.ts +1 -2
- package/src/helpers/ensureExhausted.ts +4 -2
- package/src/permissions/PermissionsContext.ts +10 -5
- package/src/reporting/ClientEventReporter.ts +2 -8
- package/src/rtc/BasePeerConnection.ts +12 -1
- package/src/rtc/Publisher.ts +8 -0
- package/src/rtc/Subscriber.ts +5 -0
- package/src/rtc/__tests__/Publisher.test.ts +101 -0
- package/src/rtc/__tests__/Subscriber.test.ts +89 -0
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +31 -0
- package/src/rtc/e2ee/E2EEManager.ts +12 -0
- package/src/rtc/e2ee/EncryptionManager.ts +336 -0
- package/src/rtc/e2ee/SPEC.md +586 -0
- package/src/rtc/e2ee/__tests__/E2EEManager.contract.test.ts +39 -0
- package/src/rtc/e2ee/__tests__/EncryptionManager.test.ts +490 -0
- package/src/rtc/e2ee/__tests__/codec.test.ts +247 -0
- package/src/rtc/e2ee/__tests__/conformance.test.ts +184 -0
- package/src/rtc/e2ee/__tests__/failureTracker.test.ts +45 -0
- package/src/rtc/e2ee/__tests__/frameCounter.test.ts +90 -0
- package/src/rtc/e2ee/__tests__/keyStore.test.ts +221 -0
- package/src/rtc/e2ee/__tests__/notifications.test.ts +166 -0
- package/src/rtc/e2ee/__tests__/perf.test.ts +139 -0
- package/src/rtc/e2ee/__tests__/queue.test.ts +61 -0
- package/src/rtc/e2ee/__tests__/replayWindow.test.ts +133 -0
- package/src/rtc/e2ee/__tests__/trailer.test.ts +143 -0
- package/src/rtc/e2ee/__tests__/transform-pipeline.test.ts +839 -0
- package/src/rtc/e2ee/__tests__/transformSupport.test.ts +65 -0
- package/src/rtc/e2ee/e2ee-worker/codec.ts +220 -0
- package/src/rtc/e2ee/e2ee-worker/constants.ts +47 -0
- package/src/rtc/e2ee/e2ee-worker/decode.ts +159 -0
- package/src/rtc/e2ee/e2ee-worker/e2ee-worker-impl.ts +137 -0
- package/src/rtc/e2ee/e2ee-worker/encode.ts +147 -0
- package/src/rtc/e2ee/e2ee-worker/failureTracker.ts +26 -0
- package/src/rtc/e2ee/e2ee-worker/frameCounter.ts +37 -0
- package/src/rtc/e2ee/e2ee-worker/keyStore.ts +211 -0
- package/src/rtc/e2ee/e2ee-worker/notifications.ts +190 -0
- package/src/rtc/e2ee/e2ee-worker/perf.ts +129 -0
- package/src/rtc/e2ee/e2ee-worker/queue.ts +12 -0
- package/src/rtc/e2ee/e2ee-worker/replayWindow.ts +131 -0
- package/src/rtc/e2ee/e2ee-worker/trailer.ts +155 -0
- package/src/rtc/e2ee/e2ee-worker/tsconfig.json +8 -0
- package/src/rtc/e2ee/e2ee-worker/types.ts +29 -0
- package/src/rtc/e2ee/e2ee-worker.ts +27 -0
- package/src/rtc/e2ee/events.ts +179 -0
- package/src/rtc/e2ee/transformSupport.ts +35 -0
- package/src/rtc/helpers/rtcConfiguration.ts +4 -2
- package/src/rtc/index.ts +1 -0
- package/src/rtc/mediaEngine.ts +44 -0
- package/src/rtc/types.ts +3 -2
- package/src/stats/rtc/types.ts +8 -2
- package/src/store/CallState.ts +34 -3
- package/src/store/__tests__/CallState.test.ts +82 -1
- 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
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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 (
|
|
60
|
-
this.
|
|
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([
|
|
105
|
-
|
|
127
|
+
combineLatest([
|
|
128
|
+
deviceIds$!.pipe(pairwise()),
|
|
129
|
+
this.state.selectedDevice$,
|
|
130
|
+
]),
|
|
131
|
+
([[prevDevices, currentDevices], deviceId]) => {
|
|
106
132
|
if (!deviceId) return;
|
|
107
|
-
const
|
|
108
|
-
(
|
|
109
|
-
|
|
110
|
-
if (
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
+
const deviceId = mockAudioOutputDevices[0].deviceId;
|
|
128
|
+
emitDeviceIds([...mockAudioDevices, ...mockAudioOutputDevices]);
|
|
127
129
|
manager.select(deviceId);
|
|
128
130
|
|
|
129
|
-
emitDeviceIds(mockAudioDevices.slice(
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
242
|
+
persist('speaker-1', 'Speaker 1');
|
|
215
243
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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,
|
|
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:
|
|
288
|
+
let deviceIds: ReplaySubject<MediaDeviceInfo[]>;
|
|
256
289
|
export const mockDeviceIds$ = () => {
|
|
257
|
-
deviceIds = new
|
|
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';
|