@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/index.browser.es.js +288 -128
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +287 -127
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +288 -128
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +41 -1
  9. package/dist/src/StreamVideoClient.d.ts +2 -8
  10. package/dist/src/coordinator/connection/types.d.ts +5 -0
  11. package/dist/src/devices/CameraManager.d.ts +7 -2
  12. package/dist/src/devices/DeviceManager.d.ts +7 -15
  13. package/dist/src/devices/MicrophoneManager.d.ts +2 -1
  14. package/dist/src/devices/SpeakerManager.d.ts +6 -1
  15. package/dist/src/devices/devicePersistence.d.ts +27 -0
  16. package/dist/src/helpers/clientUtils.d.ts +1 -1
  17. package/dist/src/permissions/PermissionsContext.d.ts +1 -1
  18. package/dist/src/types.d.ts +43 -38
  19. package/package.json +3 -3
  20. package/src/Call.ts +120 -81
  21. package/src/StreamVideoClient.ts +1 -9
  22. package/src/coordinator/connection/types.ts +6 -0
  23. package/src/devices/CameraManager.ts +31 -11
  24. package/src/devices/DeviceManager.ts +113 -31
  25. package/src/devices/MicrophoneManager.ts +26 -8
  26. package/src/devices/ScreenShareManager.ts +7 -1
  27. package/src/devices/SpeakerManager.ts +62 -19
  28. package/src/devices/__tests__/CameraManager.test.ts +184 -21
  29. package/src/devices/__tests__/DeviceManager.test.ts +184 -2
  30. package/src/devices/__tests__/DeviceManagerFilters.test.ts +2 -0
  31. package/src/devices/__tests__/MicrophoneManager.test.ts +146 -2
  32. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +2 -0
  33. package/src/devices/__tests__/ScreenShareManager.test.ts +2 -0
  34. package/src/devices/__tests__/SpeakerManager.test.ts +90 -0
  35. package/src/devices/__tests__/devicePersistence.test.ts +142 -0
  36. package/src/devices/__tests__/devices.test.ts +390 -0
  37. package/src/devices/__tests__/mediaStreamTestHelpers.ts +58 -0
  38. package/src/devices/__tests__/mocks.ts +35 -0
  39. package/src/devices/devicePersistence.ts +106 -0
  40. package/src/devices/devices.ts +3 -3
  41. package/src/events/call.ts +0 -3
  42. package/src/helpers/__tests__/DynascaleManager.test.ts +3 -1
  43. package/src/helpers/clientUtils.ts +1 -1
  44. package/src/permissions/PermissionsContext.ts +1 -0
  45. package/src/sorting/presets.ts +1 -1
  46. package/src/store/CallState.ts +1 -1
  47. 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
- this.camera = new CameraManager(this);
341
- this.microphone = new MicrophoneManager(this);
342
- this.speaker = new SpeakerManager(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
- const reasonToEndCallReason = {
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
- try {
955
- for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
956
- try {
957
- this.logger.trace(`Joining call (${attempt})`, this.cid);
958
- await this.doJoin(data);
959
- delete joinData.migrating_from;
960
- delete joinData.migrating_from_list;
961
- break;
962
- } catch (err) {
963
- this.logger.warn(`Failed to join call (${attempt})`, this.cid);
964
- if (
965
- (err instanceof ErrorFromResponse && err.unrecoverable) ||
966
- (err instanceof SfuJoinError && err.unrecoverable)
967
- ) {
968
- // if the error is unrecoverable, we should not retry as that signals
969
- // that connectivity is good, but the coordinator doesn't allow the user
970
- // to join the call due to some reason (e.g., ended call, expired token...)
971
- throw err;
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
- // immediately switch to a different SFU in case of recoverable join error
975
- const switchSfu =
976
- err instanceof SfuJoinError &&
977
- SfuJoinError.isJoinErrorCode(err.errorEvent);
978
-
979
- const sfuId = this.credentials?.server.edge_name || '';
980
- const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
981
- sfuJoinFailures.set(sfuId, failures);
982
- if (switchSfu || failures >= 2) {
983
- joinData.migrating_from = sfuId;
984
- joinData.migrating_from_list = Array.from(sfuJoinFailures.keys());
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
- if (attempt === maxJoinRetries - 1) {
988
- throw err;
989
- }
960
+ if (attempt === maxJoinRetries - 1) {
961
+ throw err;
990
962
  }
991
- await sleep(retryInterval(attempt));
992
963
  }
993
- } catch (error) {
994
- callingX?.endCall(this, 'error');
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
  );
@@ -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(call: Call) {
23
- super(call, new CameraManagerState(), TrackType.VIDEO);
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(direction: Exclude<CameraDirection, undefined>) {
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
- if (isReactNative()) {
51
- return;
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
- // apply a direction and enable the camera only if in "pristine" state
124
- // and server defaults are not deferred to application code
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 (this.state.status === undefined && !this.deferServerDefaults) {
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 && settings.enabled) {
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 { DeviceManagerState } from './DeviceManagerState';
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: Function[] = [];
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(call: Call, state: S, trackType: TrackType) {
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
- return 'audioinput';
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
  }