@stream-io/video-client 1.43.0-beta.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.
- package/CHANGELOG.md +28 -0
- package/dist/index.browser.es.js +288 -128
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +287 -127
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +288 -128
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +41 -1
- package/dist/src/StreamVideoClient.d.ts +2 -8
- package/dist/src/coordinator/connection/types.d.ts +5 -0
- package/dist/src/devices/CameraManager.d.ts +7 -2
- package/dist/src/devices/DeviceManager.d.ts +7 -15
- package/dist/src/devices/MicrophoneManager.d.ts +2 -1
- package/dist/src/devices/SpeakerManager.d.ts +6 -1
- package/dist/src/devices/devicePersistence.d.ts +27 -0
- package/dist/src/helpers/clientUtils.d.ts +1 -1
- package/dist/src/permissions/PermissionsContext.d.ts +1 -1
- package/dist/src/types.d.ts +43 -38
- package/package.json +3 -3
- package/src/Call.ts +120 -81
- package/src/StreamVideoClient.ts +1 -9
- package/src/coordinator/connection/types.ts +6 -0
- package/src/devices/CameraManager.ts +31 -11
- package/src/devices/DeviceManager.ts +113 -31
- package/src/devices/MicrophoneManager.ts +26 -8
- package/src/devices/ScreenShareManager.ts +7 -1
- package/src/devices/SpeakerManager.ts +62 -19
- package/src/devices/__tests__/CameraManager.test.ts +184 -21
- package/src/devices/__tests__/DeviceManager.test.ts +184 -2
- package/src/devices/__tests__/DeviceManagerFilters.test.ts +2 -0
- package/src/devices/__tests__/MicrophoneManager.test.ts +146 -2
- package/src/devices/__tests__/MicrophoneManagerRN.test.ts +2 -0
- package/src/devices/__tests__/ScreenShareManager.test.ts +2 -0
- package/src/devices/__tests__/SpeakerManager.test.ts +90 -0
- package/src/devices/__tests__/devicePersistence.test.ts +142 -0
- package/src/devices/__tests__/devices.test.ts +390 -0
- package/src/devices/__tests__/mediaStreamTestHelpers.ts +58 -0
- package/src/devices/__tests__/mocks.ts +35 -0
- package/src/devices/devicePersistence.ts +106 -0
- package/src/devices/devices.ts +3 -3
- package/src/events/call.ts +0 -3
- package/src/helpers/__tests__/DynascaleManager.test.ts +3 -1
- package/src/helpers/clientUtils.ts +1 -1
- package/src/permissions/PermissionsContext.ts +1 -0
- package/src/sorting/presets.ts +1 -1
- package/src/store/CallState.ts +1 -1
- package/src/types.ts +54 -49
package/src/Call.ts
CHANGED
|
@@ -39,6 +39,8 @@ import type {
|
|
|
39
39
|
Credentials,
|
|
40
40
|
DeleteCallRequest,
|
|
41
41
|
DeleteCallResponse,
|
|
42
|
+
DeleteRecordingResponse,
|
|
43
|
+
DeleteTranscriptionResponse,
|
|
42
44
|
EndCallResponse,
|
|
43
45
|
GetCallReportResponse,
|
|
44
46
|
GetCallResponse,
|
|
@@ -59,6 +61,8 @@ import type {
|
|
|
59
61
|
PinResponse,
|
|
60
62
|
QueryCallMembersRequest,
|
|
61
63
|
QueryCallMembersResponse,
|
|
64
|
+
QueryCallParticipantsRequest,
|
|
65
|
+
QueryCallParticipantsResponse,
|
|
62
66
|
QueryCallSessionParticipantStatsResponse,
|
|
63
67
|
QueryCallSessionParticipantStatsTimelineResponse,
|
|
64
68
|
QueryCallStatsMapResponse,
|
|
@@ -158,6 +162,7 @@ import {
|
|
|
158
162
|
ScreenShareManager,
|
|
159
163
|
SpeakerManager,
|
|
160
164
|
} from './devices';
|
|
165
|
+
import { normalize } from './devices/devicePersistence';
|
|
161
166
|
import { hasPending, withoutConcurrency } from './helpers/concurrency';
|
|
162
167
|
import { ensureExhausted } from './helpers/ensureExhausted';
|
|
163
168
|
import { pushToIfMissing } from './helpers/array';
|
|
@@ -337,9 +342,10 @@ export class Call {
|
|
|
337
342
|
ringing ? CallingState.RINGING : CallingState.IDLE,
|
|
338
343
|
);
|
|
339
344
|
|
|
340
|
-
|
|
341
|
-
this.
|
|
342
|
-
this.
|
|
345
|
+
const preferences = normalize(streamClient.options.devicePersistence);
|
|
346
|
+
this.camera = new CameraManager(this, preferences);
|
|
347
|
+
this.microphone = new MicrophoneManager(this, preferences);
|
|
348
|
+
this.speaker = new SpeakerManager(this, preferences);
|
|
343
349
|
this.screenShare = new ScreenShareManager(this);
|
|
344
350
|
this.dynascaleManager = new DynascaleManager(
|
|
345
351
|
this.state,
|
|
@@ -414,7 +420,6 @@ export class Call {
|
|
|
414
420
|
const currentUserId = this.currentUserId;
|
|
415
421
|
if (currentUserId && blockedUserIds.includes(currentUserId)) {
|
|
416
422
|
this.logger.info('Leaving call because of being blocked');
|
|
417
|
-
globalThis.streamRNVideoSDK?.callingX?.endCall(this, 'restricted');
|
|
418
423
|
await this.leave({ message: 'user blocked' }).catch((err) => {
|
|
419
424
|
this.logger.error('Error leaving call after being blocked', err);
|
|
420
425
|
});
|
|
@@ -459,10 +464,6 @@ export class Call {
|
|
|
459
464
|
(isAcceptedElsewhere || isRejectedByMe) &&
|
|
460
465
|
!hasPending(this.joinLeaveConcurrencyTag)
|
|
461
466
|
) {
|
|
462
|
-
globalThis.streamRNVideoSDK?.callingX?.endCall(
|
|
463
|
-
this,
|
|
464
|
-
isAcceptedElsewhere ? 'answeredElsewhere' : 'rejected',
|
|
465
|
-
);
|
|
466
467
|
this.leave().catch(() => {
|
|
467
468
|
this.logger.error(
|
|
468
469
|
'Could not leave a call that was accepted or rejected elsewhere',
|
|
@@ -634,30 +635,16 @@ export class Call {
|
|
|
634
635
|
|
|
635
636
|
if (callingState === CallingState.RINGING && reject !== false) {
|
|
636
637
|
if (reject) {
|
|
637
|
-
|
|
638
|
-
timeout: 'missed',
|
|
639
|
-
cancel: 'canceled',
|
|
640
|
-
busy: 'busy',
|
|
641
|
-
decline: 'rejected',
|
|
642
|
-
} as const;
|
|
643
|
-
const rejectReason = reason ?? 'decline';
|
|
644
|
-
const endCallReason =
|
|
645
|
-
reasonToEndCallReason[
|
|
646
|
-
rejectReason as keyof typeof reasonToEndCallReason
|
|
647
|
-
] ?? 'rejected';
|
|
648
|
-
globalThis.streamRNVideoSDK?.callingX?.endCall(this, endCallReason);
|
|
649
|
-
await this.reject(rejectReason);
|
|
638
|
+
await this.reject(reason ?? 'decline');
|
|
650
639
|
} else {
|
|
651
640
|
// if reject was undefined, we still have to cancel the call automatically
|
|
652
641
|
// when I am the creator and everyone else left the call
|
|
653
642
|
const hasOtherParticipants = this.state.remoteParticipants.length > 0;
|
|
654
643
|
if (this.isCreatedByMe && !hasOtherParticipants) {
|
|
655
|
-
globalThis.streamRNVideoSDK?.callingX?.endCall(this, 'canceled');
|
|
656
644
|
await this.reject('cancel');
|
|
657
645
|
}
|
|
658
646
|
}
|
|
659
647
|
}
|
|
660
|
-
globalThis.streamRNVideoSDK?.callingX?.endCall(this);
|
|
661
648
|
|
|
662
649
|
this.statsReporter?.stop();
|
|
663
650
|
this.statsReporter = undefined;
|
|
@@ -692,9 +679,7 @@ export class Call {
|
|
|
692
679
|
this.cancelAutoDrop();
|
|
693
680
|
this.clientStore.unregisterCall(this);
|
|
694
681
|
|
|
695
|
-
globalThis.streamRNVideoSDK?.callManager.stop(
|
|
696
|
-
isRingingTypeCall: this.ringing,
|
|
697
|
-
});
|
|
682
|
+
globalThis.streamRNVideoSDK?.callManager.stop();
|
|
698
683
|
|
|
699
684
|
this.camera.dispose();
|
|
700
685
|
this.microphone.dispose();
|
|
@@ -779,7 +764,6 @@ export class Call {
|
|
|
779
764
|
video?: boolean;
|
|
780
765
|
}): Promise<GetCallResponse> => {
|
|
781
766
|
await this.setup();
|
|
782
|
-
|
|
783
767
|
const response = await this.streamClient.get<GetCallResponse>(
|
|
784
768
|
this.streamClientBasePath,
|
|
785
769
|
params,
|
|
@@ -810,7 +794,6 @@ export class Call {
|
|
|
810
794
|
*/
|
|
811
795
|
getOrCreate = async (data?: GetOrCreateCallRequest) => {
|
|
812
796
|
await this.setup();
|
|
813
|
-
|
|
814
797
|
const response = await this.streamClient.post<
|
|
815
798
|
GetOrCreateCallResponse,
|
|
816
799
|
GetOrCreateCallRequest
|
|
@@ -926,73 +909,60 @@ export class Call {
|
|
|
926
909
|
joinResponseTimeout?: number;
|
|
927
910
|
rpcRequestTimeout?: number;
|
|
928
911
|
} = {}): Promise<void> => {
|
|
912
|
+
await this.setup();
|
|
929
913
|
const callingState = this.state.callingState;
|
|
930
914
|
|
|
931
915
|
if ([CallingState.JOINED, CallingState.JOINING].includes(callingState)) {
|
|
932
916
|
throw new Error(`Illegal State: call.join() shall be called only once`);
|
|
933
917
|
}
|
|
934
918
|
|
|
935
|
-
if (data?.ring) {
|
|
936
|
-
this.ringingSubject.next(true);
|
|
937
|
-
}
|
|
938
|
-
const callingX = globalThis.streamRNVideoSDK?.callingX;
|
|
939
|
-
if (callingX) {
|
|
940
|
-
// for Android/iOS, we need to start the call in the callingx library as soon as possible
|
|
941
|
-
await callingX.startCall(this);
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
await this.setup();
|
|
945
|
-
|
|
946
919
|
this.joinResponseTimeout = joinResponseTimeout;
|
|
947
920
|
this.rpcRequestTimeout = rpcRequestTimeout;
|
|
921
|
+
|
|
948
922
|
// we will count the number of join failures per SFU.
|
|
949
923
|
// once the number of failures reaches 2, we will piggyback on the `migrating_from`
|
|
950
924
|
// field to force the coordinator to provide us another SFU
|
|
951
925
|
const sfuJoinFailures = new Map<string, number>();
|
|
952
926
|
const joinData: JoinCallData = data;
|
|
953
927
|
maxJoinRetries = Math.max(maxJoinRetries, 1);
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
}
|
|
928
|
+
for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
|
|
929
|
+
try {
|
|
930
|
+
this.logger.trace(`Joining call (${attempt})`, this.cid);
|
|
931
|
+
await this.doJoin(data);
|
|
932
|
+
delete joinData.migrating_from;
|
|
933
|
+
delete joinData.migrating_from_list;
|
|
934
|
+
break;
|
|
935
|
+
} catch (err) {
|
|
936
|
+
this.logger.warn(`Failed to join call (${attempt})`, this.cid);
|
|
937
|
+
if (
|
|
938
|
+
(err instanceof ErrorFromResponse && err.unrecoverable) ||
|
|
939
|
+
(err instanceof SfuJoinError && err.unrecoverable)
|
|
940
|
+
) {
|
|
941
|
+
// if the error is unrecoverable, we should not retry as that signals
|
|
942
|
+
// that connectivity is good, but the coordinator doesn't allow the user
|
|
943
|
+
// to join the call due to some reason (e.g., ended call, expired token...)
|
|
944
|
+
throw err;
|
|
945
|
+
}
|
|
973
946
|
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
947
|
+
// immediately switch to a different SFU in case of recoverable join error
|
|
948
|
+
const switchSfu =
|
|
949
|
+
err instanceof SfuJoinError &&
|
|
950
|
+
SfuJoinError.isJoinErrorCode(err.errorEvent);
|
|
951
|
+
|
|
952
|
+
const sfuId = this.credentials?.server.edge_name || '';
|
|
953
|
+
const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
|
|
954
|
+
sfuJoinFailures.set(sfuId, failures);
|
|
955
|
+
if (switchSfu || failures >= 2) {
|
|
956
|
+
joinData.migrating_from = sfuId;
|
|
957
|
+
joinData.migrating_from_list = Array.from(sfuJoinFailures.keys());
|
|
958
|
+
}
|
|
986
959
|
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
}
|
|
960
|
+
if (attempt === maxJoinRetries - 1) {
|
|
961
|
+
throw err;
|
|
990
962
|
}
|
|
991
|
-
await sleep(retryInterval(attempt));
|
|
992
963
|
}
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
throw error;
|
|
964
|
+
|
|
965
|
+
await sleep(retryInterval(attempt));
|
|
996
966
|
}
|
|
997
967
|
};
|
|
998
968
|
|
|
@@ -1175,9 +1145,7 @@ export class Call {
|
|
|
1175
1145
|
// re-apply them on later reconnections or server-side data fetches
|
|
1176
1146
|
if (!this.deviceSettingsAppliedOnce && this.state.settings) {
|
|
1177
1147
|
await this.applyDeviceConfig(this.state.settings, true);
|
|
1178
|
-
globalThis.streamRNVideoSDK?.callManager.start(
|
|
1179
|
-
isRingingTypeCall: this.ringing,
|
|
1180
|
-
});
|
|
1148
|
+
globalThis.streamRNVideoSDK?.callManager.start();
|
|
1181
1149
|
this.deviceSettingsAppliedOnce = true;
|
|
1182
1150
|
}
|
|
1183
1151
|
|
|
@@ -1722,7 +1690,6 @@ export class Call {
|
|
|
1722
1690
|
if (SfuJoinError.isJoinErrorCode(e)) return;
|
|
1723
1691
|
if (strategy === WebsocketReconnectStrategy.UNSPECIFIED) return;
|
|
1724
1692
|
if (strategy === WebsocketReconnectStrategy.DISCONNECT) {
|
|
1725
|
-
globalThis.streamRNVideoSDK?.callingX?.endCall(this, 'error');
|
|
1726
1693
|
this.leave({ message: 'SFU instructed to disconnect' }).catch((err) => {
|
|
1727
1694
|
this.logger.warn(`Can't leave call after disconnect request`, err);
|
|
1728
1695
|
});
|
|
@@ -1958,6 +1925,7 @@ export class Call {
|
|
|
1958
1925
|
'Updating publish options after joining the call does not have an effect',
|
|
1959
1926
|
);
|
|
1960
1927
|
}
|
|
1928
|
+
this.tracer.trace('updatePublishOptions', options);
|
|
1961
1929
|
this.clientPublishOptions = { ...this.clientPublishOptions, ...options };
|
|
1962
1930
|
};
|
|
1963
1931
|
|
|
@@ -2533,6 +2501,22 @@ export class Call {
|
|
|
2533
2501
|
});
|
|
2534
2502
|
};
|
|
2535
2503
|
|
|
2504
|
+
/**
|
|
2505
|
+
* Query call participants with optional filters.
|
|
2506
|
+
*
|
|
2507
|
+
* @param data the request data.
|
|
2508
|
+
* @param params optional query parameters.
|
|
2509
|
+
*/
|
|
2510
|
+
queryParticipants = async (
|
|
2511
|
+
data: QueryCallParticipantsRequest = {},
|
|
2512
|
+
params: { limit?: number } = {},
|
|
2513
|
+
): Promise<QueryCallParticipantsResponse> => {
|
|
2514
|
+
return this.streamClient.post<
|
|
2515
|
+
QueryCallParticipantsResponse,
|
|
2516
|
+
QueryCallParticipantsRequest
|
|
2517
|
+
>(`${this.streamClientBasePath}/participants`, data, params);
|
|
2518
|
+
};
|
|
2519
|
+
|
|
2536
2520
|
/**
|
|
2537
2521
|
* Will update the call members.
|
|
2538
2522
|
*
|
|
@@ -2594,9 +2578,24 @@ export class Call {
|
|
|
2594
2578
|
* Otherwise, all recordings for the current call will be returned.
|
|
2595
2579
|
*
|
|
2596
2580
|
* @param callSessionId the call session id to retrieve recordings for.
|
|
2581
|
+
* @deprecated use {@link listRecordings} instead.
|
|
2597
2582
|
*/
|
|
2598
2583
|
queryRecordings = async (
|
|
2599
2584
|
callSessionId?: string,
|
|
2585
|
+
): Promise<ListRecordingsResponse> => {
|
|
2586
|
+
return this.listRecordings(callSessionId);
|
|
2587
|
+
};
|
|
2588
|
+
|
|
2589
|
+
/**
|
|
2590
|
+
* Retrieves the list of recordings for the current call or call session.
|
|
2591
|
+
*
|
|
2592
|
+
* If `callSessionId` is provided, it will return the recordings for that call session.
|
|
2593
|
+
* Otherwise, all recordings for the current call will be returned.
|
|
2594
|
+
*
|
|
2595
|
+
* @param callSessionId the call session id to retrieve recordings for.
|
|
2596
|
+
*/
|
|
2597
|
+
listRecordings = async (
|
|
2598
|
+
callSessionId?: string,
|
|
2600
2599
|
): Promise<ListRecordingsResponse> => {
|
|
2601
2600
|
let endpoint = this.streamClientBasePath;
|
|
2602
2601
|
if (callSessionId) {
|
|
@@ -2607,12 +2606,52 @@ export class Call {
|
|
|
2607
2606
|
);
|
|
2608
2607
|
};
|
|
2609
2608
|
|
|
2609
|
+
/**
|
|
2610
|
+
* Deletes a recording for the given call session.
|
|
2611
|
+
*
|
|
2612
|
+
* @param callSessionId the call session id that the recording belongs to.
|
|
2613
|
+
* @param filename the recording filename.
|
|
2614
|
+
*/
|
|
2615
|
+
deleteRecording = async (
|
|
2616
|
+
callSessionId: string,
|
|
2617
|
+
filename: string,
|
|
2618
|
+
): Promise<DeleteRecordingResponse> => {
|
|
2619
|
+
return this.streamClient.delete<DeleteRecordingResponse>(
|
|
2620
|
+
`${this.streamClientBasePath}/${encodeURIComponent(callSessionId)}/recordings/${encodeURIComponent(filename)}`,
|
|
2621
|
+
);
|
|
2622
|
+
};
|
|
2623
|
+
|
|
2624
|
+
/**
|
|
2625
|
+
* Deletes a transcription for the given call session.
|
|
2626
|
+
*
|
|
2627
|
+
* @param callSessionId the call session id that the transcription belongs to.
|
|
2628
|
+
* @param filename the transcription filename.
|
|
2629
|
+
*/
|
|
2630
|
+
deleteTranscription = async (
|
|
2631
|
+
callSessionId: string,
|
|
2632
|
+
filename: string,
|
|
2633
|
+
): Promise<DeleteTranscriptionResponse> => {
|
|
2634
|
+
return this.streamClient.delete<DeleteTranscriptionResponse>(
|
|
2635
|
+
`${this.streamClientBasePath}/${encodeURIComponent(callSessionId)}/transcriptions/${encodeURIComponent(filename)}`,
|
|
2636
|
+
);
|
|
2637
|
+
};
|
|
2638
|
+
|
|
2610
2639
|
/**
|
|
2611
2640
|
* Retrieves the list of transcriptions for the current call.
|
|
2612
2641
|
*
|
|
2613
2642
|
* @returns the list of transcriptions.
|
|
2643
|
+
* @deprecated use {@link listTranscriptions} instead.
|
|
2614
2644
|
*/
|
|
2615
2645
|
queryTranscriptions = async (): Promise<ListTranscriptionsResponse> => {
|
|
2646
|
+
return this.listTranscriptions();
|
|
2647
|
+
};
|
|
2648
|
+
|
|
2649
|
+
/**
|
|
2650
|
+
* Retrieves the list of transcriptions for the current call.
|
|
2651
|
+
*
|
|
2652
|
+
* @returns the list of transcriptions.
|
|
2653
|
+
*/
|
|
2654
|
+
listTranscriptions = async (): Promise<ListTranscriptionsResponse> => {
|
|
2616
2655
|
return this.streamClient.get<ListTranscriptionsResponse>(
|
|
2617
2656
|
`${this.streamClientBasePath}/transcriptions`,
|
|
2618
2657
|
);
|
package/src/StreamVideoClient.ts
CHANGED
|
@@ -27,10 +27,10 @@ import {
|
|
|
27
27
|
ErrorFromResponse,
|
|
28
28
|
StreamClientOptions,
|
|
29
29
|
TokenOrProvider,
|
|
30
|
-
TokenProvider,
|
|
31
30
|
User,
|
|
32
31
|
UserWithId,
|
|
33
32
|
} from './coordinator/connection/types';
|
|
33
|
+
import type { StreamVideoClientOptions } from './types';
|
|
34
34
|
import { retryInterval, sleep } from './coordinator/connection/utils';
|
|
35
35
|
import {
|
|
36
36
|
createCoordinatorClient,
|
|
@@ -42,14 +42,6 @@ import { logToConsole, ScopedLogger, videoLoggerSystem } from './logger';
|
|
|
42
42
|
import { withoutConcurrency } from './helpers/concurrency';
|
|
43
43
|
import { enableTimerWorker } from './timers';
|
|
44
44
|
|
|
45
|
-
export type StreamVideoClientOptions = {
|
|
46
|
-
apiKey: string;
|
|
47
|
-
options?: StreamClientOptions;
|
|
48
|
-
user?: User;
|
|
49
|
-
token?: string;
|
|
50
|
-
tokenProvider?: TokenProvider;
|
|
51
|
-
};
|
|
52
|
-
|
|
53
45
|
/**
|
|
54
46
|
* A `StreamVideoClient` instance lets you communicate with our API, and authenticate users.
|
|
55
47
|
*/
|
|
@@ -2,6 +2,7 @@ import { AxiosRequestConfig, AxiosResponse } from 'axios';
|
|
|
2
2
|
import { ConnectedEvent, UserRequest, VideoEvent } from '../../gen/coordinator';
|
|
3
3
|
import { AllSfuEvents } from '../../rtc';
|
|
4
4
|
import type { ConfigureLoggersOptions, LogLevel } from '@stream-io/logger';
|
|
5
|
+
import type { DevicePersistenceOptions } from '../../devices/devicePersistence';
|
|
5
6
|
|
|
6
7
|
export type UR = Record<string, unknown>;
|
|
7
8
|
|
|
@@ -258,6 +259,11 @@ export type StreamClientOptions = Partial<AxiosRequestConfig> & {
|
|
|
258
259
|
* When set to true, the incoming calls are rejected when the user is busy in an another call.
|
|
259
260
|
*/
|
|
260
261
|
rejectCallWhenBusy?: boolean;
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Device persistence preference options (web only).
|
|
265
|
+
*/
|
|
266
|
+
devicePersistence?: DevicePersistenceOptions;
|
|
261
267
|
};
|
|
262
268
|
|
|
263
269
|
export type ClientAppIdentifier = {
|
|
@@ -7,6 +7,7 @@ import { VideoSettingsResponse } from '../gen/coordinator';
|
|
|
7
7
|
import { TrackType } from '../gen/video/sfu/models/models';
|
|
8
8
|
import { isMobile } from '../helpers/compatibility';
|
|
9
9
|
import { isReactNative } from '../helpers/platforms';
|
|
10
|
+
import { DevicePersistenceOptions } from './devicePersistence';
|
|
10
11
|
|
|
11
12
|
export class CameraManager extends DeviceManager<CameraManagerState> {
|
|
12
13
|
private targetResolution = {
|
|
@@ -18,9 +19,13 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
|
|
|
18
19
|
* Constructs a new CameraManager.
|
|
19
20
|
*
|
|
20
21
|
* @param call the call instance.
|
|
22
|
+
* @param devicePersistence the device persistence preferences to use.
|
|
21
23
|
*/
|
|
22
|
-
constructor(
|
|
23
|
-
|
|
24
|
+
constructor(
|
|
25
|
+
call: Call,
|
|
26
|
+
devicePersistence: Required<DevicePersistenceOptions>,
|
|
27
|
+
) {
|
|
28
|
+
super(call, new CameraManagerState(), TrackType.VIDEO, devicePersistence);
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
private isDirectionSupportedByDevice() {
|
|
@@ -31,8 +36,12 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
|
|
|
31
36
|
* Select the camera direction.
|
|
32
37
|
*
|
|
33
38
|
* @param direction the direction of the camera to select.
|
|
39
|
+
* @param options additional direction selection options.
|
|
34
40
|
*/
|
|
35
|
-
async selectDirection(
|
|
41
|
+
async selectDirection(
|
|
42
|
+
direction: Exclude<CameraDirection, undefined>,
|
|
43
|
+
options: { enableCamera?: boolean } = {},
|
|
44
|
+
) {
|
|
36
45
|
if (!this.isDirectionSupportedByDevice()) {
|
|
37
46
|
this.logger.warn('Setting direction is not supported on this device');
|
|
38
47
|
return;
|
|
@@ -47,9 +56,10 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
|
|
|
47
56
|
// providing both device id and direction doesn't work, so we deselect the device
|
|
48
57
|
this.state.setDirection(direction);
|
|
49
58
|
this.state.setDevice(undefined);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
59
|
+
|
|
60
|
+
const { enableCamera = true } = options;
|
|
61
|
+
if (isReactNative() || !enableCamera) return;
|
|
62
|
+
|
|
53
63
|
this.getTracks().forEach((track) => track.stop());
|
|
54
64
|
try {
|
|
55
65
|
await this.unmuteStream();
|
|
@@ -120,16 +130,26 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
|
|
|
120
130
|
await this.statusChangeSettled();
|
|
121
131
|
await this.selectTargetResolution(settings.target_resolution);
|
|
122
132
|
|
|
123
|
-
|
|
124
|
-
|
|
133
|
+
const enabledInCallType = settings.enabled ?? true;
|
|
134
|
+
const shouldApplyDefaults =
|
|
135
|
+
this.state.status === undefined &&
|
|
136
|
+
this.state.optimisticStatus === undefined;
|
|
137
|
+
let persistedPreferencesApplied = false;
|
|
138
|
+
if (shouldApplyDefaults && this.devicePersistence.enabled) {
|
|
139
|
+
persistedPreferencesApplied =
|
|
140
|
+
await this.applyPersistedPreferences(enabledInCallType);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// apply a direction and enable the camera only if in "pristine" state,
|
|
144
|
+
// and there are no persisted preferences
|
|
125
145
|
const canPublish = this.call.permissionsContext.canPublish(this.trackType);
|
|
126
|
-
if (
|
|
146
|
+
if (shouldApplyDefaults && !persistedPreferencesApplied) {
|
|
127
147
|
if (!this.state.direction && !this.state.selectedDevice) {
|
|
128
148
|
const direction = settings.camera_facing === 'front' ? 'front' : 'back';
|
|
129
|
-
await this.selectDirection(direction);
|
|
149
|
+
await this.selectDirection(direction, { enableCamera: false });
|
|
130
150
|
}
|
|
131
151
|
|
|
132
|
-
if (canPublish && settings.camera_default_on &&
|
|
152
|
+
if (canPublish && settings.camera_default_on && enabledInCallType) {
|
|
133
153
|
await this.enable();
|
|
134
154
|
}
|
|
135
155
|
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { combineLatest, Observable, pairwise } from 'rxjs';
|
|
1
|
+
import { combineLatest, firstValueFrom, Observable, pairwise } from 'rxjs';
|
|
2
2
|
import { Call } from '../Call';
|
|
3
3
|
import { TrackPublishOptions } from '../rtc';
|
|
4
4
|
import { CallingState } from '../store';
|
|
5
|
-
import { createSubscription } from '../store/rxUtils';
|
|
6
|
-
import {
|
|
5
|
+
import { createSubscription, getCurrentValue } from '../store/rxUtils';
|
|
6
|
+
import {
|
|
7
|
+
DeviceManagerState,
|
|
8
|
+
type InputDeviceStatus,
|
|
9
|
+
} from './DeviceManagerState';
|
|
7
10
|
import { isMobile } from '../helpers/compatibility';
|
|
8
11
|
import { isReactNative } from '../helpers/platforms';
|
|
9
12
|
import { ScopedLogger, videoLoggerSystem } from '../logger';
|
|
@@ -19,6 +22,15 @@ import {
|
|
|
19
22
|
MediaStreamFilterEntry,
|
|
20
23
|
MediaStreamFilterRegistrationResult,
|
|
21
24
|
} from './filters';
|
|
25
|
+
import {
|
|
26
|
+
createSyntheticDevice,
|
|
27
|
+
defaultDeviceId,
|
|
28
|
+
DevicePersistenceOptions,
|
|
29
|
+
DevicePreferenceKey,
|
|
30
|
+
readPreferences,
|
|
31
|
+
toPreferenceList,
|
|
32
|
+
writePreferences,
|
|
33
|
+
} from './devicePersistence';
|
|
22
34
|
|
|
23
35
|
export abstract class DeviceManager<
|
|
24
36
|
S extends DeviceManagerState<C>,
|
|
@@ -32,23 +44,10 @@ export abstract class DeviceManager<
|
|
|
32
44
|
|
|
33
45
|
state: S;
|
|
34
46
|
|
|
35
|
-
/**
|
|
36
|
-
* When `true`, the `apply()` method will skip automatically enabling/disabling
|
|
37
|
-
* the device based on server defaults (`mic_default_on`, `camera_default_on`).
|
|
38
|
-
*
|
|
39
|
-
* This is useful when application code wants to handle device preferences
|
|
40
|
-
* (e.g., persisted user preferences) and prevent server defaults from
|
|
41
|
-
* overriding them.
|
|
42
|
-
*
|
|
43
|
-
* @default false
|
|
44
|
-
*
|
|
45
|
-
* @internal
|
|
46
|
-
*/
|
|
47
|
-
deferServerDefaults = false;
|
|
48
|
-
|
|
49
47
|
protected readonly call: Call;
|
|
50
48
|
protected readonly trackType: TrackType;
|
|
51
|
-
protected subscriptions:
|
|
49
|
+
protected subscriptions: (() => void)[] = [];
|
|
50
|
+
protected devicePersistence: Required<DevicePersistenceOptions>;
|
|
52
51
|
protected areSubscriptionsSetUp = false;
|
|
53
52
|
private isTrackStoppedDueToTrackEnd = false;
|
|
54
53
|
private filters: MediaStreamFilterEntry[] = [];
|
|
@@ -57,10 +56,16 @@ export abstract class DeviceManager<
|
|
|
57
56
|
'filterRegistrationConcurrencyTag',
|
|
58
57
|
);
|
|
59
58
|
|
|
60
|
-
protected constructor(
|
|
59
|
+
protected constructor(
|
|
60
|
+
call: Call,
|
|
61
|
+
state: S,
|
|
62
|
+
trackType: TrackType,
|
|
63
|
+
devicePersistence: Required<DevicePersistenceOptions>,
|
|
64
|
+
) {
|
|
61
65
|
this.call = call;
|
|
62
66
|
this.state = state;
|
|
63
67
|
this.trackType = trackType;
|
|
68
|
+
this.devicePersistence = devicePersistence;
|
|
64
69
|
this.logger = videoLoggerSystem.getLogger(
|
|
65
70
|
`${TrackType[trackType].toLowerCase()} manager`,
|
|
66
71
|
);
|
|
@@ -68,10 +73,7 @@ export abstract class DeviceManager<
|
|
|
68
73
|
}
|
|
69
74
|
|
|
70
75
|
setup() {
|
|
71
|
-
if (this.areSubscriptionsSetUp)
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
|
|
76
|
+
if (this.areSubscriptionsSetUp) return;
|
|
75
77
|
this.areSubscriptionsSetUp = true;
|
|
76
78
|
|
|
77
79
|
if (
|
|
@@ -81,6 +83,18 @@ export abstract class DeviceManager<
|
|
|
81
83
|
) {
|
|
82
84
|
this.handleDisconnectedOrReplacedDevices();
|
|
83
85
|
}
|
|
86
|
+
|
|
87
|
+
if (this.devicePersistence.enabled) {
|
|
88
|
+
this.subscriptions.push(
|
|
89
|
+
createSubscription(
|
|
90
|
+
combineLatest([this.state.selectedDevice$, this.state.status$]),
|
|
91
|
+
([selectedDevice, status]) => {
|
|
92
|
+
if (!status) return;
|
|
93
|
+
this.persistPreference(selectedDevice, status);
|
|
94
|
+
},
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
84
98
|
}
|
|
85
99
|
|
|
86
100
|
/**
|
|
@@ -495,14 +509,10 @@ export abstract class DeviceManager<
|
|
|
495
509
|
}
|
|
496
510
|
}
|
|
497
511
|
|
|
498
|
-
private get mediaDeviceKind() {
|
|
499
|
-
if (this.trackType === TrackType.AUDIO)
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
if (this.trackType === TrackType.VIDEO) {
|
|
503
|
-
return 'videoinput';
|
|
504
|
-
}
|
|
505
|
-
return '';
|
|
512
|
+
private get mediaDeviceKind(): MediaDeviceKind {
|
|
513
|
+
if (this.trackType === TrackType.AUDIO) return 'audioinput';
|
|
514
|
+
if (this.trackType === TrackType.VIDEO) return 'videoinput';
|
|
515
|
+
throw new Error('Invalid track type');
|
|
506
516
|
}
|
|
507
517
|
|
|
508
518
|
private handleDisconnectedOrReplacedDevices() {
|
|
@@ -562,4 +572,76 @@ export abstract class DeviceManager<
|
|
|
562
572
|
const kind = this.mediaDeviceKind;
|
|
563
573
|
return devices.find((d) => d.deviceId === deviceId && d.kind === kind);
|
|
564
574
|
}
|
|
575
|
+
|
|
576
|
+
private persistPreference(
|
|
577
|
+
selectedDevice: string | undefined,
|
|
578
|
+
status: InputDeviceStatus,
|
|
579
|
+
) {
|
|
580
|
+
const deviceKind = this.mediaDeviceKind;
|
|
581
|
+
const deviceKey = deviceKind === 'audioinput' ? 'microphone' : 'camera';
|
|
582
|
+
const muted =
|
|
583
|
+
status === 'disabled' ? true : status === 'enabled' ? false : undefined;
|
|
584
|
+
|
|
585
|
+
const { storageKey } = this.devicePersistence;
|
|
586
|
+
if (!selectedDevice) {
|
|
587
|
+
writePreferences(undefined, deviceKey, muted, storageKey);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const devices = getCurrentValue(this.listDevices()) || [];
|
|
592
|
+
const currentDevice =
|
|
593
|
+
this.findDevice(devices, selectedDevice) ??
|
|
594
|
+
createSyntheticDevice(selectedDevice, deviceKind);
|
|
595
|
+
|
|
596
|
+
writePreferences(currentDevice, deviceKey, muted, storageKey);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
protected async applyPersistedPreferences(enabledInCallType: boolean) {
|
|
600
|
+
const deviceKey: DevicePreferenceKey =
|
|
601
|
+
this.trackType === TrackType.AUDIO ? 'microphone' : 'camera';
|
|
602
|
+
const preferences = readPreferences(this.devicePersistence.storageKey);
|
|
603
|
+
const preferenceList = toPreferenceList(preferences[deviceKey]);
|
|
604
|
+
|
|
605
|
+
if (preferenceList.length === 0) return false;
|
|
606
|
+
|
|
607
|
+
let muted: boolean | undefined;
|
|
608
|
+
let appliedDevice = false;
|
|
609
|
+
let appliedMute = false;
|
|
610
|
+
|
|
611
|
+
const devices = await firstValueFrom(this.listDevices());
|
|
612
|
+
for (const preference of preferenceList) {
|
|
613
|
+
muted ??= preference.muted;
|
|
614
|
+
if (preference.selectedDeviceId === defaultDeviceId) break;
|
|
615
|
+
|
|
616
|
+
const device =
|
|
617
|
+
devices.find((d) => d.deviceId === preference.selectedDeviceId) ??
|
|
618
|
+
devices.find((d) => d.label === preference.selectedDeviceLabel);
|
|
619
|
+
|
|
620
|
+
if (device) {
|
|
621
|
+
appliedDevice = true;
|
|
622
|
+
if (!this.state.selectedDevice) {
|
|
623
|
+
await this.select(device.deviceId);
|
|
624
|
+
}
|
|
625
|
+
muted = preference.muted;
|
|
626
|
+
break;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const canPublish = this.call.permissionsContext.canPublish(this.trackType);
|
|
631
|
+
if (typeof muted === 'boolean' && enabledInCallType && canPublish) {
|
|
632
|
+
await this.applyMutedState(muted);
|
|
633
|
+
appliedMute = true;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return appliedDevice || appliedMute;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
private async applyMutedState(muted: boolean) {
|
|
640
|
+
if (this.state.status !== undefined) return;
|
|
641
|
+
if (muted) {
|
|
642
|
+
await this.disable();
|
|
643
|
+
} else {
|
|
644
|
+
await this.enable();
|
|
645
|
+
}
|
|
646
|
+
}
|
|
565
647
|
}
|