@stream-io/video-client 1.57.0 → 1.59.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 +28 -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 +1141 -117
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +1196 -115
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.es.js +1174 -117
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +66 -9
- package/dist/src/StreamVideoClient.d.ts +9 -1
- package/dist/src/coordinator/connection/connection.d.ts +1 -1
- package/dist/src/devices/CameraManager.d.ts +8 -1
- package/dist/src/devices/DeviceManager.d.ts +2 -1
- package/dist/src/devices/MicrophoneManager.d.ts +8 -1
- 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 +1386 -240
- package/dist/src/gen/video/sfu/models/models.d.ts +8 -0
- package/dist/src/helpers/MediaStreamRecorder.d.ts +26 -0
- package/dist/src/helpers/TypedEventEmitter.d.ts +23 -0
- package/dist/src/helpers/concurrency.d.ts +8 -0
- package/dist/src/helpers/ensureExhausted.d.ts +1 -1
- package/dist/src/helpers/loopback.d.ts +55 -0
- package/dist/src/permissions/PermissionsContext.d.ts +2 -2
- package/dist/src/reporting/ClientEventReporter.d.ts +0 -2
- package/dist/src/rtc/BasePeerConnection.d.ts +3 -1
- package/dist/src/rtc/Subscriber.d.ts +5 -0
- 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 +5 -0
- package/package.json +1 -1
- package/src/Call.ts +311 -155
- package/src/StreamVideoClient.ts +35 -8
- package/src/__tests__/Call.lifecycle.test.ts +33 -2
- package/src/__tests__/Call.publishing.test.ts +42 -1
- package/src/__tests__/Call.test.ts +15 -0
- package/src/__tests__/StreamVideoClient.api.test.ts +33 -0
- package/src/__tests__/StreamVideoClient.rewatch.test.ts +213 -0
- package/src/__tests__/StreamVideoClient.ringing.test.ts +11 -4
- package/src/__tests__/StreamVideoClient.test.ts +14 -12
- package/src/coordinator/connection/connection.ts +6 -3
- package/src/devices/AudioDeviceManager.ts +6 -0
- package/src/devices/CameraManager.ts +67 -8
- package/src/devices/DeviceManager.ts +24 -4
- package/src/devices/MicrophoneManager.ts +66 -8
- package/src/devices/ScreenShareManager.ts +3 -0
- package/src/devices/SpeakerManager.ts +42 -12
- package/src/devices/__tests__/CameraManager.test.ts +27 -1
- package/src/devices/__tests__/DeviceManager.test.ts +49 -1
- package/src/devices/__tests__/MicrophoneManager.test.ts +23 -1
- package/src/devices/__tests__/MicrophoneManagerRN.test.ts +1 -20
- package/src/devices/__tests__/SpeakerManager.test.ts +138 -42
- package/src/devices/__tests__/mocks.ts +36 -3
- package/src/events/__tests__/participant.test.ts +134 -1
- package/src/events/call.ts +6 -3
- package/src/events/callEventHandlers.ts +9 -3
- package/src/events/participant.ts +42 -7
- package/src/gen/coordinator/index.ts +1402 -236
- package/src/gen/video/sfu/models/models.ts +15 -0
- package/src/helpers/MediaStreamRecorder.ts +104 -0
- package/src/helpers/TypedEventEmitter.ts +71 -0
- package/src/helpers/__tests__/MediaStreamRecorder.test.ts +324 -0
- package/src/helpers/__tests__/TypedEventEmitter.test.ts +133 -0
- package/src/helpers/__tests__/concurrency.test.ts +74 -0
- package/src/helpers/__tests__/loopback.test.ts +367 -0
- package/src/helpers/concurrency.ts +24 -0
- package/src/helpers/ensureExhausted.ts +4 -2
- package/src/helpers/loopback.ts +200 -0
- package/src/permissions/PermissionsContext.ts +10 -5
- package/src/reporting/ClientEventReporter.ts +15 -28
- package/src/reporting/__tests__/ClientEventReporter.test.ts +3 -7
- package/src/rtc/BasePeerConnection.ts +12 -1
- package/src/rtc/Publisher.ts +8 -0
- package/src/rtc/Subscriber.ts +13 -0
- package/src/rtc/__tests__/Publisher.test.ts +101 -0
- package/src/rtc/__tests__/Subscriber.test.ts +190 -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 +2 -0
- package/src/store/CallState.ts +33 -1
- package/src/store/__tests__/CallState.test.ts +82 -1
- package/src/types.ts +23 -5
|
@@ -124,13 +124,56 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
|
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
override enable(): Promise<void> {
|
|
128
|
+
if (isReactNative() && !this.call.hasMediaEngine) {
|
|
129
|
+
this.state.setPendingStatus('enabled');
|
|
130
|
+
return Promise.resolve();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return super.enable();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
override disable(options: { forceStop?: boolean }): Promise<void>;
|
|
137
|
+
override disable(forceStop?: boolean): Promise<void>;
|
|
138
|
+
override async disable(
|
|
139
|
+
forceStopOrOptions?: boolean | { forceStop?: boolean },
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
if (isReactNative() && !this.call.hasMediaEngine) {
|
|
142
|
+
this.state.setPendingStatus('disabled');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// forward verbatim to the base, narrowing so the right overload is selected
|
|
147
|
+
if (forceStopOrOptions === undefined) return super.disable();
|
|
148
|
+
if (typeof forceStopOrOptions === 'boolean') {
|
|
149
|
+
return super.disable(forceStopOrOptions);
|
|
150
|
+
}
|
|
151
|
+
return super.disable(forceStopOrOptions);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
override toggle(): Promise<void> {
|
|
155
|
+
if (isReactNative() && !this.call.hasMediaEngine) {
|
|
156
|
+
this.state.setPendingStatus(
|
|
157
|
+
this.state.optimisticStatus === 'enabled' ? 'disabled' : 'enabled',
|
|
158
|
+
);
|
|
159
|
+
return Promise.resolve();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return super.toggle();
|
|
163
|
+
}
|
|
164
|
+
|
|
127
165
|
/**
|
|
128
166
|
* Applies the video settings to the camera.
|
|
129
167
|
*
|
|
130
168
|
* @param settings the video settings to apply.
|
|
131
169
|
* @param publish whether to publish the stream after applying the settings.
|
|
170
|
+
* @param forceDisabled whether to keep the camera disabled, regardless of server-side or local preferences.
|
|
132
171
|
*/
|
|
133
|
-
async apply(
|
|
172
|
+
async apply(
|
|
173
|
+
settings: VideoSettingsResponse,
|
|
174
|
+
publish: boolean,
|
|
175
|
+
forceDisabled: boolean = false,
|
|
176
|
+
): Promise<void> {
|
|
134
177
|
// Wait for any in progress camera operation
|
|
135
178
|
await this.statusChangeSettled();
|
|
136
179
|
await this.selectTargetResolution(settings.target_resolution);
|
|
@@ -148,8 +191,10 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
|
|
|
148
191
|
this.devicePersistence.enabled &&
|
|
149
192
|
permissionState === 'granted'
|
|
150
193
|
) {
|
|
151
|
-
persistedPreferencesApplied =
|
|
152
|
-
|
|
194
|
+
persistedPreferencesApplied = await this.applyPersistedPreferences(
|
|
195
|
+
enabledInCallType,
|
|
196
|
+
forceDisabled,
|
|
197
|
+
);
|
|
153
198
|
}
|
|
154
199
|
|
|
155
200
|
// apply a direction and enable the camera only if in "pristine" state,
|
|
@@ -161,14 +206,25 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
|
|
|
161
206
|
await this.selectDirection(direction, { enableCamera: false });
|
|
162
207
|
}
|
|
163
208
|
|
|
164
|
-
if (
|
|
209
|
+
if (
|
|
210
|
+
!forceDisabled &&
|
|
211
|
+
canPublish &&
|
|
212
|
+
settings.camera_default_on &&
|
|
213
|
+
enabledInCallType
|
|
214
|
+
) {
|
|
165
215
|
await this.enable();
|
|
166
216
|
}
|
|
167
217
|
}
|
|
168
218
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
219
|
+
if (isReactNative() && publish && canPublish) {
|
|
220
|
+
// On RN the camera is enabled/disabled optimistically before JOINED. Reconcile now
|
|
221
|
+
// acquires the track and publishes it, so it fully owns the publish.
|
|
222
|
+
await this.reconcileOptimisticStatus();
|
|
223
|
+
} else {
|
|
224
|
+
const { mediaStream } = this.state;
|
|
225
|
+
if (canPublish && publish && this.enabled && mediaStream) {
|
|
226
|
+
await this.publishStream(mediaStream);
|
|
227
|
+
}
|
|
172
228
|
}
|
|
173
229
|
}
|
|
174
230
|
|
|
@@ -196,9 +252,12 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
|
|
|
196
252
|
return constraints;
|
|
197
253
|
}
|
|
198
254
|
|
|
199
|
-
protected override getStream(
|
|
255
|
+
protected override async getStream(
|
|
200
256
|
constraints: MediaTrackConstraints,
|
|
201
257
|
): Promise<MediaStream> {
|
|
258
|
+
// Ensure the call's media factory exists before capture so the resulting
|
|
259
|
+
// track is owned by it (the WebRTC globals resolve to the live factory).
|
|
260
|
+
await this.call.ensureMediaFactory();
|
|
202
261
|
return getVideoStream(constraints, this.call.tracer);
|
|
203
262
|
}
|
|
204
263
|
}
|
|
@@ -509,6 +509,23 @@ export abstract class DeviceManager<
|
|
|
509
509
|
}
|
|
510
510
|
}
|
|
511
511
|
|
|
512
|
+
protected reconcileOptimisticStatus = async (): Promise<void> => {
|
|
513
|
+
const target = this.state.optimisticStatus;
|
|
514
|
+
await withCancellation(this.statusChangeConcurrencyTag, async (signal) => {
|
|
515
|
+
try {
|
|
516
|
+
if (target === 'enabled' && this.state.status !== 'enabled') {
|
|
517
|
+
await this.unmuteStream();
|
|
518
|
+
if (!signal.aborted) this.state.setStatus('enabled');
|
|
519
|
+
} else if (target === 'disabled' && this.state.status === 'enabled') {
|
|
520
|
+
// mirror whatever disable() does to stop/pause the track per disableMode
|
|
521
|
+
if (!signal.aborted) this.state.setStatus('disabled');
|
|
522
|
+
}
|
|
523
|
+
} finally {
|
|
524
|
+
if (!signal.aborted) this.state.setPendingStatus(this.state.status);
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
};
|
|
528
|
+
|
|
512
529
|
private disableTracks() {
|
|
513
530
|
this.getTracks().forEach((track) => {
|
|
514
531
|
if (track.enabled) track.enabled = false;
|
|
@@ -825,7 +842,10 @@ export abstract class DeviceManager<
|
|
|
825
842
|
writePreferences(currentDevice, deviceKey, muted, storageKey);
|
|
826
843
|
}
|
|
827
844
|
|
|
828
|
-
protected async applyPersistedPreferences(
|
|
845
|
+
protected async applyPersistedPreferences(
|
|
846
|
+
enabledInCallType: boolean,
|
|
847
|
+
forceDisabled = false,
|
|
848
|
+
) {
|
|
829
849
|
const deviceKey: DevicePreferenceKey =
|
|
830
850
|
this.trackType === TrackType.AUDIO ? 'microphone' : 'camera';
|
|
831
851
|
const preferences = readPreferences(this.devicePersistence.storageKey);
|
|
@@ -858,15 +878,15 @@ export abstract class DeviceManager<
|
|
|
858
878
|
|
|
859
879
|
const canPublish = this.call.permissionsContext.canPublish(this.trackType);
|
|
860
880
|
if (typeof muted === 'boolean' && enabledInCallType && canPublish) {
|
|
861
|
-
await this.applyMutedState(muted);
|
|
881
|
+
await this.applyMutedState(muted, forceDisabled);
|
|
862
882
|
appliedMute = true;
|
|
863
883
|
}
|
|
864
884
|
|
|
865
885
|
return appliedDevice || appliedMute;
|
|
866
886
|
}
|
|
867
887
|
|
|
868
|
-
private async applyMutedState(muted: boolean) {
|
|
869
|
-
if (this.state.status !== undefined) return;
|
|
888
|
+
private async applyMutedState(muted: boolean, forceDisabled: boolean) {
|
|
889
|
+
if (forceDisabled || this.state.status !== undefined) return;
|
|
870
890
|
if (muted) {
|
|
871
891
|
await this.disable();
|
|
872
892
|
} else {
|
|
@@ -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
|
*
|
|
@@ -347,8 +388,13 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
347
388
|
* Applies the audio settings to the microphone.
|
|
348
389
|
* @param settings the audio settings to apply.
|
|
349
390
|
* @param publish whether to publish the stream after applying the settings.
|
|
391
|
+
* @param forceDisabled whether to keep the mic disabled, regardless of server-side or local preferences.
|
|
350
392
|
*/
|
|
351
|
-
async apply(
|
|
393
|
+
async apply(
|
|
394
|
+
settings: AudioSettingsResponse,
|
|
395
|
+
publish: boolean,
|
|
396
|
+
forceDisabled: boolean = false,
|
|
397
|
+
) {
|
|
352
398
|
// Wait for any in progress mic operation
|
|
353
399
|
await this.statusChangeSettled();
|
|
354
400
|
|
|
@@ -366,19 +412,28 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
366
412
|
this.devicePersistence.enabled &&
|
|
367
413
|
permissionState === 'granted'
|
|
368
414
|
) {
|
|
369
|
-
persistedPreferencesApplied = await this.applyPersistedPreferences(
|
|
415
|
+
persistedPreferencesApplied = await this.applyPersistedPreferences(
|
|
416
|
+
true,
|
|
417
|
+
forceDisabled,
|
|
418
|
+
);
|
|
370
419
|
}
|
|
371
420
|
|
|
372
421
|
const canPublish = this.call.permissionsContext.canPublish(this.trackType);
|
|
373
422
|
if (shouldApplyDefaults && !persistedPreferencesApplied) {
|
|
374
|
-
if (canPublish && settings.mic_default_on) {
|
|
423
|
+
if (!forceDisabled && canPublish && settings.mic_default_on) {
|
|
375
424
|
await this.enable();
|
|
376
425
|
}
|
|
377
426
|
}
|
|
378
427
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
428
|
+
if (isReactNative() && publish && canPublish) {
|
|
429
|
+
// On RN the microphone is enabled/disabled optimistically before JOINED. Reconcile now
|
|
430
|
+
// acquires the track and publishes it, so it fully owns the publish.
|
|
431
|
+
await this.reconcileOptimisticStatus();
|
|
432
|
+
} else {
|
|
433
|
+
const { mediaStream } = this.state;
|
|
434
|
+
if (canPublish && publish && this.enabled && mediaStream) {
|
|
435
|
+
await this.publishStream(mediaStream);
|
|
436
|
+
}
|
|
382
437
|
}
|
|
383
438
|
}
|
|
384
439
|
|
|
@@ -386,9 +441,12 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
386
441
|
return getAudioDevices(this.call.tracer);
|
|
387
442
|
}
|
|
388
443
|
|
|
389
|
-
protected override getStream(
|
|
444
|
+
protected override async getStream(
|
|
390
445
|
constraints: MediaTrackConstraints,
|
|
391
446
|
): Promise<MediaStream> {
|
|
447
|
+
// Ensure the call's media factory exists before capture so the resulting
|
|
448
|
+
// track is owned by it (the WebRTC globals resolve to the live factory).
|
|
449
|
+
await this.call.ensureMediaFactory();
|
|
392
450
|
return getAudioStream(constraints, this.call.tracer);
|
|
393
451
|
}
|
|
394
452
|
|
|
@@ -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()) || [];
|
|
@@ -357,11 +357,37 @@ describe('CameraManager', () => {
|
|
|
357
357
|
true,
|
|
358
358
|
);
|
|
359
359
|
|
|
360
|
-
expect(applySpy).toHaveBeenCalledWith(true);
|
|
360
|
+
expect(applySpy).toHaveBeenCalledWith(true, false);
|
|
361
361
|
expect(selectDirectionSpy).not.toHaveBeenCalled();
|
|
362
362
|
expect(enableSpy).not.toHaveBeenCalled();
|
|
363
363
|
});
|
|
364
364
|
|
|
365
|
+
it('should apply persisted device preferences without enabling when forced disabled', async () => {
|
|
366
|
+
vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
|
|
367
|
+
of('granted'),
|
|
368
|
+
);
|
|
369
|
+
const devicePersistence = { enabled: true, storageKey: '' };
|
|
370
|
+
const persistedManager = new CameraManager(call, devicePersistence);
|
|
371
|
+
const applySpy = vi
|
|
372
|
+
.spyOn(persistedManager as never, 'applyPersistedPreferences')
|
|
373
|
+
.mockResolvedValue(true);
|
|
374
|
+
const enableSpy = vi.spyOn(persistedManager, 'enable');
|
|
375
|
+
|
|
376
|
+
await persistedManager.apply(
|
|
377
|
+
fromPartial({
|
|
378
|
+
enabled: true,
|
|
379
|
+
target_resolution: { width: 640, height: 480 },
|
|
380
|
+
camera_facing: 'front',
|
|
381
|
+
camera_default_on: true,
|
|
382
|
+
}),
|
|
383
|
+
false,
|
|
384
|
+
true,
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
expect(applySpy).toHaveBeenCalledWith(true, true);
|
|
388
|
+
expect(enableSpy).not.toHaveBeenCalled();
|
|
389
|
+
});
|
|
390
|
+
|
|
365
391
|
it('should skip persisted preferences when permission is not granted', async () => {
|
|
366
392
|
vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
|
|
367
393
|
of('prompt'),
|
|
@@ -21,7 +21,7 @@ import { DeviceManagerState } from '../DeviceManagerState';
|
|
|
21
21
|
import { firstValueFrom, of } from 'rxjs';
|
|
22
22
|
import { TrackType } from '../../gen/video/sfu/models/models';
|
|
23
23
|
import { PermissionsContext } from '../../permissions';
|
|
24
|
-
import { readPreferences } from '../devicePersistence';
|
|
24
|
+
import { defaultDeviceId, readPreferences } from '../devicePersistence';
|
|
25
25
|
|
|
26
26
|
vi.mock('../../reporting/ClientEventReporter', () => ({
|
|
27
27
|
ClientEventReporter: vi.fn(function () {
|
|
@@ -1010,6 +1010,54 @@ describe('Device Manager', () => {
|
|
|
1010
1010
|
expect(selectSpy).toHaveBeenCalledWith(mockVideoDevices[1].deviceId);
|
|
1011
1011
|
});
|
|
1012
1012
|
|
|
1013
|
+
it('selects the device without unmuting when forced disabled', async () => {
|
|
1014
|
+
localStorageMock.setItem(
|
|
1015
|
+
storageKey,
|
|
1016
|
+
JSON.stringify({
|
|
1017
|
+
camera: [
|
|
1018
|
+
{
|
|
1019
|
+
selectedDeviceId: mockVideoDevices[0].deviceId,
|
|
1020
|
+
selectedDeviceLabel: mockVideoDevices[0].label,
|
|
1021
|
+
muted: false,
|
|
1022
|
+
},
|
|
1023
|
+
],
|
|
1024
|
+
}),
|
|
1025
|
+
);
|
|
1026
|
+
|
|
1027
|
+
const selectSpy = vi.spyOn(manager, 'select');
|
|
1028
|
+
const enableSpy = vi.spyOn(manager, 'enable');
|
|
1029
|
+
|
|
1030
|
+
// @ts-expect-error - private API
|
|
1031
|
+
const result = await manager.applyPersistedPreferences(true, true);
|
|
1032
|
+
|
|
1033
|
+
expect(result).toBe(true);
|
|
1034
|
+
expect(selectSpy).toHaveBeenCalledWith(mockVideoDevices[0].deviceId);
|
|
1035
|
+
expect(enableSpy).not.toHaveBeenCalled();
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
it('retains a default-device mute preference when forced disabled', async () => {
|
|
1039
|
+
localStorageMock.setItem(
|
|
1040
|
+
storageKey,
|
|
1041
|
+
JSON.stringify({
|
|
1042
|
+
camera: [
|
|
1043
|
+
{
|
|
1044
|
+
selectedDeviceId: defaultDeviceId,
|
|
1045
|
+
selectedDeviceLabel: '',
|
|
1046
|
+
muted: false,
|
|
1047
|
+
},
|
|
1048
|
+
],
|
|
1049
|
+
}),
|
|
1050
|
+
);
|
|
1051
|
+
|
|
1052
|
+
const enableSpy = vi.spyOn(manager, 'enable');
|
|
1053
|
+
|
|
1054
|
+
// @ts-expect-error - private API
|
|
1055
|
+
const result = await manager.applyPersistedPreferences(true, true);
|
|
1056
|
+
|
|
1057
|
+
expect(result).toBe(true);
|
|
1058
|
+
expect(enableSpy).not.toHaveBeenCalled();
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1013
1061
|
it('applies muted state without selecting when default device is stored', async () => {
|
|
1014
1062
|
localStorageMock.setItem(
|
|
1015
1063
|
storageKey,
|
|
@@ -486,7 +486,29 @@ describe('MicrophoneManager', () => {
|
|
|
486
486
|
// @ts-expect-error - partial data
|
|
487
487
|
await persistedManager.apply({ mic_default_on: true }, true);
|
|
488
488
|
|
|
489
|
-
expect(applySpy).toHaveBeenCalledWith(true);
|
|
489
|
+
expect(applySpy).toHaveBeenCalledWith(true, false);
|
|
490
|
+
expect(enableSpy).not.toHaveBeenCalled();
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it('should apply persisted device preferences without enabling when forced disabled', async () => {
|
|
494
|
+
vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
|
|
495
|
+
of('granted'),
|
|
496
|
+
);
|
|
497
|
+
const devicePersistence = { enabled: true, storageKey: '' };
|
|
498
|
+
const persistedManager = new MicrophoneManager(
|
|
499
|
+
call,
|
|
500
|
+
devicePersistence,
|
|
501
|
+
'disable-tracks',
|
|
502
|
+
);
|
|
503
|
+
const applySpy = vi
|
|
504
|
+
.spyOn(persistedManager as never, 'applyPersistedPreferences')
|
|
505
|
+
.mockResolvedValue(true);
|
|
506
|
+
const enableSpy = vi.spyOn(persistedManager, 'enable');
|
|
507
|
+
|
|
508
|
+
// @ts-expect-error - partial data
|
|
509
|
+
await persistedManager.apply({ mic_default_on: true }, false, true);
|
|
510
|
+
|
|
511
|
+
expect(applySpy).toHaveBeenCalledWith(true, true);
|
|
490
512
|
expect(enableSpy).not.toHaveBeenCalled();
|
|
491
513
|
});
|
|
492
514
|
|
|
@@ -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,
|
|
@@ -263,25 +263,6 @@ describe('MicrophoneManager React Native', () => {
|
|
|
263
263
|
expect(setMutedRecordingPreparedMock).not.toHaveBeenCalledWith(true);
|
|
264
264
|
});
|
|
265
265
|
|
|
266
|
-
it('should release prepared muted recording when the call is left', async () => {
|
|
267
|
-
await manager.disable();
|
|
268
|
-
await vi.waitUntil(
|
|
269
|
-
() =>
|
|
270
|
-
setMutedRecordingPreparedMock.mock.calls.some(([arg]) => arg === true),
|
|
271
|
-
{ timeout: 100 },
|
|
272
|
-
);
|
|
273
|
-
setMutedRecordingPreparedMock.mockClear();
|
|
274
|
-
|
|
275
|
-
manager['call'].state.setCallingState(CallingState.LEFT);
|
|
276
|
-
|
|
277
|
-
await vi.waitUntil(
|
|
278
|
-
() =>
|
|
279
|
-
setMutedRecordingPreparedMock.mock.calls.some(([arg]) => arg === false),
|
|
280
|
-
{ timeout: 100 },
|
|
281
|
-
);
|
|
282
|
-
expect(setMutedRecordingPreparedMock).toHaveBeenCalledWith(false);
|
|
283
|
-
});
|
|
284
|
-
|
|
285
266
|
afterEach(() => {
|
|
286
267
|
globalThis.streamRNVideoSDK = undefined;
|
|
287
268
|
vi.clearAllMocks();
|