@stream-io/video-client 1.43.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 (45) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/index.browser.es.js +206 -59
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +205 -58
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +206 -59
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/StreamVideoClient.d.ts +2 -8
  9. package/dist/src/coordinator/connection/types.d.ts +5 -0
  10. package/dist/src/devices/CameraManager.d.ts +7 -2
  11. package/dist/src/devices/DeviceManager.d.ts +7 -15
  12. package/dist/src/devices/MicrophoneManager.d.ts +2 -1
  13. package/dist/src/devices/SpeakerManager.d.ts +6 -1
  14. package/dist/src/devices/devicePersistence.d.ts +27 -0
  15. package/dist/src/helpers/clientUtils.d.ts +1 -1
  16. package/dist/src/permissions/PermissionsContext.d.ts +1 -1
  17. package/dist/src/types.d.ts +38 -2
  18. package/package.json +1 -1
  19. package/src/Call.ts +5 -3
  20. package/src/StreamVideoClient.ts +1 -9
  21. package/src/coordinator/connection/types.ts +6 -0
  22. package/src/devices/CameraManager.ts +31 -11
  23. package/src/devices/DeviceManager.ts +113 -31
  24. package/src/devices/MicrophoneManager.ts +26 -8
  25. package/src/devices/ScreenShareManager.ts +7 -1
  26. package/src/devices/SpeakerManager.ts +62 -18
  27. package/src/devices/__tests__/CameraManager.test.ts +184 -21
  28. package/src/devices/__tests__/DeviceManager.test.ts +184 -2
  29. package/src/devices/__tests__/DeviceManagerFilters.test.ts +2 -0
  30. package/src/devices/__tests__/MicrophoneManager.test.ts +146 -2
  31. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +2 -0
  32. package/src/devices/__tests__/ScreenShareManager.test.ts +2 -0
  33. package/src/devices/__tests__/SpeakerManager.test.ts +90 -0
  34. package/src/devices/__tests__/devicePersistence.test.ts +142 -0
  35. package/src/devices/__tests__/devices.test.ts +390 -0
  36. package/src/devices/__tests__/mediaStreamTestHelpers.ts +58 -0
  37. package/src/devices/__tests__/mocks.ts +35 -0
  38. package/src/devices/devicePersistence.ts +106 -0
  39. package/src/devices/devices.ts +3 -3
  40. package/src/helpers/__tests__/DynascaleManager.test.ts +3 -1
  41. package/src/helpers/clientUtils.ts +1 -1
  42. package/src/permissions/PermissionsContext.ts +1 -0
  43. package/src/sorting/presets.ts +1 -1
  44. package/src/store/CallState.ts +1 -1
  45. package/src/types.ts +49 -2
@@ -2,15 +2,9 @@ import { Call } from './Call';
2
2
  import { StreamClient } from './coordinator/connection/client';
3
3
  import { StreamVideoReadOnlyStateStore, StreamVideoWriteableStateStore } from './store';
4
4
  import type { ConnectedEvent, CreateDeviceRequest, CreateGuestRequest, CreateGuestResponse, GetEdgesResponse, ListDevicesResponse, QueryAggregateCallStatsRequest, QueryAggregateCallStatsResponse, QueryCallsRequest, QueryCallStatsRequest, QueryCallStatsResponse } from './gen/coordinator';
5
- import { AllClientEvents, ClientEventListener, StreamClientOptions, TokenOrProvider, TokenProvider, User } from './coordinator/connection/types';
5
+ import { AllClientEvents, ClientEventListener, StreamClientOptions, TokenOrProvider, User } from './coordinator/connection/types';
6
+ import type { StreamVideoClientOptions } from './types';
6
7
  import { ScopedLogger } from './logger';
7
- export type StreamVideoClientOptions = {
8
- apiKey: string;
9
- options?: StreamClientOptions;
10
- user?: User;
11
- token?: string;
12
- tokenProvider?: TokenProvider;
13
- };
14
8
  /**
15
9
  * A `StreamVideoClient` instance lets you communicate with our API, and authenticate users.
16
10
  */
@@ -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
  export type UR = Record<string, unknown>;
6
7
  export type User = (Omit<UserRequest, 'role'> & {
7
8
  type?: 'authenticated';
@@ -185,6 +186,10 @@ export type StreamClientOptions = Partial<AxiosRequestConfig> & {
185
186
  * When set to true, the incoming calls are rejected when the user is busy in an another call.
186
187
  */
187
188
  rejectCallWhenBusy?: boolean;
189
+ /**
190
+ * Device persistence preference options (web only).
191
+ */
192
+ devicePersistence?: DevicePersistenceOptions;
188
193
  };
189
194
  export type ClientAppIdentifier = {
190
195
  sdkName?: 'react' | 'react-native' | 'plain-javascript' | (string & {});
@@ -3,21 +3,26 @@ import { Call } from '../Call';
3
3
  import { CameraDirection, CameraManagerState } from './CameraManagerState';
4
4
  import { DeviceManager } from './DeviceManager';
5
5
  import { VideoSettingsResponse } from '../gen/coordinator';
6
+ import { DevicePersistenceOptions } from './devicePersistence';
6
7
  export declare class CameraManager extends DeviceManager<CameraManagerState> {
7
8
  private targetResolution;
8
9
  /**
9
10
  * Constructs a new CameraManager.
10
11
  *
11
12
  * @param call the call instance.
13
+ * @param devicePersistence the device persistence preferences to use.
12
14
  */
13
- constructor(call: Call);
15
+ constructor(call: Call, devicePersistence: Required<DevicePersistenceOptions>);
14
16
  private isDirectionSupportedByDevice;
15
17
  /**
16
18
  * Select the camera direction.
17
19
  *
18
20
  * @param direction the direction of the camera to select.
21
+ * @param options additional direction selection options.
19
22
  */
20
- selectDirection(direction: Exclude<CameraDirection, undefined>): Promise<void>;
23
+ selectDirection(direction: Exclude<CameraDirection, undefined>, options?: {
24
+ enableCamera?: boolean;
25
+ }): Promise<void>;
21
26
  /**
22
27
  * Flips the camera direction: if it's front it will change to back, if it's back, it will change to front.
23
28
  *
@@ -5,6 +5,7 @@ import { DeviceManagerState } from './DeviceManagerState';
5
5
  import { ScopedLogger } from '../logger';
6
6
  import { TrackType } from '../gen/video/sfu/models/models';
7
7
  import { MediaStreamFilter, MediaStreamFilterRegistrationResult } from './filters';
8
+ import { DevicePersistenceOptions } from './devicePersistence';
8
9
  export declare abstract class DeviceManager<S extends DeviceManagerState<C>, C = MediaTrackConstraints> {
9
10
  /**
10
11
  * if true, stops the media stream when call is left
@@ -12,28 +13,16 @@ export declare abstract class DeviceManager<S extends DeviceManagerState<C>, C =
12
13
  stopOnLeave: boolean;
13
14
  logger: ScopedLogger;
14
15
  state: S;
15
- /**
16
- * When `true`, the `apply()` method will skip automatically enabling/disabling
17
- * the device based on server defaults (`mic_default_on`, `camera_default_on`).
18
- *
19
- * This is useful when application code wants to handle device preferences
20
- * (e.g., persisted user preferences) and prevent server defaults from
21
- * overriding them.
22
- *
23
- * @default false
24
- *
25
- * @internal
26
- */
27
- deferServerDefaults: boolean;
28
16
  protected readonly call: Call;
29
17
  protected readonly trackType: TrackType;
30
- protected subscriptions: Function[];
18
+ protected subscriptions: (() => void)[];
19
+ protected devicePersistence: Required<DevicePersistenceOptions>;
31
20
  protected areSubscriptionsSetUp: boolean;
32
21
  private isTrackStoppedDueToTrackEnd;
33
22
  private filters;
34
23
  private statusChangeConcurrencyTag;
35
24
  private filterRegistrationConcurrencyTag;
36
- protected constructor(call: Call, state: S, trackType: TrackType);
25
+ protected constructor(call: Call, state: S, trackType: TrackType, devicePersistence: Required<DevicePersistenceOptions>);
37
26
  setup(): void;
38
27
  /**
39
28
  * Lists the available audio/video devices
@@ -115,4 +104,7 @@ export declare abstract class DeviceManager<S extends DeviceManagerState<C>, C =
115
104
  private get mediaDeviceKind();
116
105
  private handleDisconnectedOrReplacedDevices;
117
106
  protected findDevice(devices: MediaDeviceInfo[], deviceId: string): MediaDeviceInfo | undefined;
107
+ private persistPreference;
108
+ protected applyPersistedPreferences(enabledInCallType: boolean): Promise<boolean>;
109
+ private applyMutedState;
118
110
  }
@@ -6,6 +6,7 @@ import { MicrophoneManagerState } from './MicrophoneManagerState';
6
6
  import { TrackDisableMode } from './DeviceManagerState';
7
7
  import { AudioBitrateProfile } from '../gen/video/sfu/models/models';
8
8
  import { AudioSettingsResponse } from '../gen/coordinator';
9
+ import { DevicePersistenceOptions } from './devicePersistence';
9
10
  export declare class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState> {
10
11
  private speakingWhileMutedNotificationEnabled;
11
12
  private soundDetectorConcurrencyTag;
@@ -18,7 +19,7 @@ export declare class MicrophoneManager extends AudioDeviceManager<MicrophoneMana
18
19
  private noiseCancellationRegistration?;
19
20
  private unregisterNoiseCancellation?;
20
21
  private silenceThresholdMs;
21
- constructor(call: Call, disableMode?: TrackDisableMode);
22
+ constructor(call: Call, devicePersistence: Required<DevicePersistenceOptions>, disableMode?: TrackDisableMode);
22
23
  setup(): void;
23
24
  /**
24
25
  * Enables noise cancellation for the microphone.
@@ -1,14 +1,18 @@
1
1
  import { Call } from '../Call';
2
2
  import { SpeakerState } from './SpeakerState';
3
3
  import { CallSettingsResponse } from '../gen/coordinator';
4
+ import { DevicePersistenceOptions } from './devicePersistence';
4
5
  export declare class SpeakerManager {
5
6
  readonly state: SpeakerState;
6
7
  private subscriptions;
7
8
  private areSubscriptionsSetUp;
8
9
  private readonly call;
9
10
  private defaultDevice?;
10
- constructor(call: Call);
11
+ private readonly devicePersistence;
12
+ constructor(call: Call, devicePreferences: Required<DevicePersistenceOptions>);
11
13
  apply(settings: CallSettingsResponse): void;
14
+ private applyWeb;
15
+ private applyRN;
12
16
  setup(): void;
13
17
  /**
14
18
  * Lists the available audio output devices
@@ -47,4 +51,5 @@ export declare class SpeakerManager {
47
51
  * @param volume a number between 0 and 1. Set it to `undefined` to use the default volume.
48
52
  */
49
53
  setParticipantVolume(sessionId: string, volume: number | undefined): void;
54
+ private persistSpeakerDevicePreference;
50
55
  }
@@ -0,0 +1,27 @@
1
+ export type DevicePersistenceOptions = {
2
+ /**
3
+ * Enables device preference persistence on web.
4
+ * @default true
5
+ */
6
+ enabled?: boolean;
7
+ /**
8
+ * Storage key for persisted preferences.
9
+ * @default '@stream-io/device-preferences'
10
+ */
11
+ storageKey?: string;
12
+ };
13
+ export type DevicePreferenceKey = 'microphone' | 'camera' | 'speaker';
14
+ export type LocalDevicePreference = {
15
+ selectedDeviceId: string;
16
+ selectedDeviceLabel: string;
17
+ muted?: boolean;
18
+ };
19
+ export type LocalDevicePreferences = {
20
+ [type in DevicePreferenceKey]?: LocalDevicePreference | LocalDevicePreference[];
21
+ };
22
+ export declare const defaultDeviceId = "default";
23
+ export declare const normalize: (options: DevicePersistenceOptions | undefined) => Required<DevicePersistenceOptions>;
24
+ export declare const createSyntheticDevice: (deviceId: string, kind: MediaDeviceKind) => MediaDeviceInfo;
25
+ export declare const readPreferences: (storageKey: string) => LocalDevicePreferences;
26
+ export declare const writePreferences: (currentDevice: MediaDeviceInfo | undefined, deviceKey: DevicePreferenceKey, muted: boolean | undefined, storageKey: string) => void;
27
+ export declare const toPreferenceList: (preference?: LocalDevicePreference | LocalDevicePreference[]) => LocalDevicePreference[];
@@ -1,6 +1,6 @@
1
1
  import type { StreamClientOptions, TokenOrProvider, User } from '../coordinator/connection/types';
2
2
  import { StreamClient } from '../coordinator/connection/client';
3
- import type { StreamVideoClientOptions } from '../StreamVideoClient';
3
+ import type { StreamVideoClientOptions } from '../types';
4
4
  /**
5
5
  * Utility function to get the instance key.
6
6
  */
@@ -33,7 +33,7 @@ export declare class PermissionsContext {
33
33
  * Helper method that checks whether the current user has the permission
34
34
  * to publish the given track type.
35
35
  */
36
- canPublish: (trackType: TrackType) => boolean | undefined;
36
+ canPublish: (trackType: TrackType) => boolean;
37
37
  /**
38
38
  * Checks if the current user can request a specific permission
39
39
  * within the call.
@@ -1,10 +1,10 @@
1
1
  import type { Participant, TrackType, VideoDimension } from './gen/video/sfu/models/models';
2
- import type { CallRecordingStartedEventRecordingTypeEnum, JoinCallRequest, MemberResponse, OwnCapability, ReactionResponse, AudioSettingsRequestDefaultDeviceEnum, StartRecordingRequest, StartRecordingResponse } from './gen/coordinator';
2
+ import type { AudioSettingsRequestDefaultDeviceEnum, CallRecordingStartedEventRecordingTypeEnum, JoinCallRequest, MemberResponse, OwnCapability, ReactionResponse, StartRecordingRequest, StartRecordingResponse } from './gen/coordinator';
3
3
  import type { StreamClient } from './coordinator/connection/client';
4
+ import type { RejectReason, StreamClientOptions, TokenProvider, User } from './coordinator/connection/types';
4
5
  import type { Comparator } from './sorting';
5
6
  import type { StreamVideoWriteableStateStore } from './store';
6
7
  import { AxiosError } from 'axios';
7
- import { RejectReason } from './coordinator/connection/types';
8
8
  export type StreamReaction = Pick<ReactionResponse, 'type' | 'emoji_code' | 'custom'>;
9
9
  export declare enum VisibilityState {
10
10
  UNKNOWN = "UNKNOWN",
@@ -273,6 +273,42 @@ export type CallConstructor = {
273
273
  */
274
274
  clientStore: StreamVideoWriteableStateStore;
275
275
  };
276
+ type StreamVideoClientBaseOptions = {
277
+ apiKey: string;
278
+ options?: StreamClientOptions;
279
+ };
280
+ type StreamVideoClientOptionsWithoutUser = StreamVideoClientBaseOptions & {
281
+ user?: undefined;
282
+ token?: never;
283
+ tokenProvider?: never;
284
+ };
285
+ type GuestUser = Extract<User, {
286
+ type: 'guest';
287
+ }>;
288
+ type AnonymousUser = Extract<User, {
289
+ type: 'anonymous';
290
+ }>;
291
+ type AuthenticatedUser = Exclude<User, GuestUser | AnonymousUser>;
292
+ type StreamVideoClientOptionsWithGuestUser = StreamVideoClientBaseOptions & {
293
+ user: GuestUser;
294
+ token?: never;
295
+ tokenProvider?: never;
296
+ };
297
+ type StreamVideoClientOptionsWithAnonymousUser = StreamVideoClientBaseOptions & {
298
+ user: AnonymousUser;
299
+ token?: string;
300
+ tokenProvider?: TokenProvider;
301
+ };
302
+ type StreamVideoClientOptionsWithAuthenticatedUser = StreamVideoClientBaseOptions & {
303
+ user: AuthenticatedUser;
304
+ } & ({
305
+ token: string;
306
+ tokenProvider?: TokenProvider;
307
+ } | {
308
+ token?: string;
309
+ tokenProvider: TokenProvider;
310
+ });
311
+ export type StreamVideoClientOptions = StreamVideoClientOptionsWithoutUser | StreamVideoClientOptionsWithGuestUser | StreamVideoClientOptionsWithAnonymousUser | StreamVideoClientOptionsWithAuthenticatedUser;
276
312
  export type CallRecordingType = CallRecordingStartedEventRecordingTypeEnum;
277
313
  export type StartCallRecordingFnType = {
278
314
  (): Promise<StartRecordingResponse>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-client",
3
- "version": "1.43.0",
3
+ "version": "1.44.0",
4
4
  "main": "dist/index.cjs.js",
5
5
  "module": "dist/index.es.js",
6
6
  "browser": "dist/index.browser.es.js",
package/src/Call.ts CHANGED
@@ -162,6 +162,7 @@ import {
162
162
  ScreenShareManager,
163
163
  SpeakerManager,
164
164
  } from './devices';
165
+ import { normalize } from './devices/devicePersistence';
165
166
  import { hasPending, withoutConcurrency } from './helpers/concurrency';
166
167
  import { ensureExhausted } from './helpers/ensureExhausted';
167
168
  import { pushToIfMissing } from './helpers/array';
@@ -341,9 +342,10 @@ export class Call {
341
342
  ringing ? CallingState.RINGING : CallingState.IDLE,
342
343
  );
343
344
 
344
- this.camera = new CameraManager(this);
345
- this.microphone = new MicrophoneManager(this);
346
- 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);
347
349
  this.screenShare = new ScreenShareManager(this);
348
350
  this.dynascaleManager = new DynascaleManager(
349
351
  this.state,
@@ -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
  }