@stream-io/video-client 0.4.5 → 0.4.7

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.
@@ -16,6 +16,8 @@ export declare abstract class InputMediaDeviceManager<T extends InputMediaDevice
16
16
  */
17
17
  disablePromise?: Promise<void>;
18
18
  logger: Logger;
19
+ private subscriptions;
20
+ private isTrackStoppedDueToTrackEnd;
19
21
  protected constructor(call: Call, state: T, trackType: TrackType);
20
22
  /**
21
23
  * Lists the available audio/video devices
@@ -49,13 +51,13 @@ export declare abstract class InputMediaDeviceManager<T extends InputMediaDevice
49
51
  */
50
52
  setDefaultConstraints(constraints: C): void;
51
53
  /**
52
- * Select device
54
+ * Selects a device.
53
55
  *
54
56
  * Note: this method is not supported in React Native
55
- *
56
- * @param deviceId
57
+ * @param deviceId the device id to select.
57
58
  */
58
59
  select(deviceId: string | undefined): Promise<void>;
60
+ removeSubscriptions: () => void;
59
61
  protected applySettingsToStream(): Promise<void>;
60
62
  protected abstract getDevices(): Observable<MediaDeviceInfo[] | undefined>;
61
63
  protected abstract getStream(constraints: C): Promise<MediaStream>;
@@ -68,4 +70,7 @@ export declare abstract class InputMediaDeviceManager<T extends InputMediaDevice
68
70
  private stopTracks;
69
71
  private muteLocalStream;
70
72
  protected unmuteStream(): Promise<void>;
73
+ private get mediaDeviceKind();
74
+ private handleDisconnectedOrReplacedDevices;
75
+ private findDeviceInList;
71
76
  }
@@ -1,8 +1,9 @@
1
- import { BehaviorSubject } from 'rxjs';
1
+ import { BehaviorSubject, Observable } from 'rxjs';
2
2
  import { RxUtils } from '../store';
3
3
  export type InputDeviceStatus = 'enabled' | 'disabled' | undefined;
4
4
  export declare abstract class InputMediaDeviceManagerState<C = MediaTrackConstraints> {
5
5
  readonly disableMode: 'stop-tracks' | 'disable-tracks';
6
+ private readonly permissionName;
6
7
  protected statusSubject: BehaviorSubject<InputDeviceStatus>;
7
8
  protected mediaStreamSubject: BehaviorSubject<MediaStream | undefined>;
8
9
  protected selectedDeviceSubject: BehaviorSubject<string | undefined>;
@@ -15,20 +16,32 @@ export declare abstract class InputMediaDeviceManagerState<C = MediaTrackConstra
15
16
  * An Observable that emits the current media stream, or `undefined` if the device is currently disabled.
16
17
  *
17
18
  */
18
- mediaStream$: import("rxjs").Observable<MediaStream | undefined>;
19
+ mediaStream$: Observable<MediaStream | undefined>;
19
20
  /**
20
21
  * An Observable that emits the currently selected device
21
22
  */
22
- selectedDevice$: import("rxjs").Observable<string | undefined>;
23
+ selectedDevice$: Observable<string | undefined>;
23
24
  /**
24
25
  * An Observable that emits the device status
25
26
  */
26
- status$: import("rxjs").Observable<InputDeviceStatus>;
27
+ status$: Observable<InputDeviceStatus>;
27
28
  /**
28
29
  * The default constraints for the device.
29
30
  */
30
- defaultConstraints$: import("rxjs").Observable<C | undefined>;
31
- constructor(disableMode?: 'stop-tracks' | 'disable-tracks');
31
+ defaultConstraints$: Observable<C | undefined>;
32
+ /**
33
+ * An observable that will emit `true` if browser/system permission
34
+ * is granted, `false` otherwise.
35
+ */
36
+ hasBrowserPermission$: Observable<boolean>;
37
+ /**
38
+ * Constructs new InputMediaDeviceManagerState instance.
39
+ *
40
+ * @param disableMode the disable mode to use.
41
+ * @param permissionName the permission name to use for querying.
42
+ * `undefined` means no permission is required.
43
+ */
44
+ constructor(disableMode?: 'stop-tracks' | 'disable-tracks', permissionName?: PermissionName | undefined);
32
45
  /**
33
46
  * The device status
34
47
  */
@@ -47,7 +60,7 @@ export declare abstract class InputMediaDeviceManagerState<C = MediaTrackConstra
47
60
  *
48
61
  * @param observable$ the observable to get the value from.
49
62
  */
50
- getCurrentValue: <T>(observable$: import("rxjs").Observable<T>) => T;
63
+ getCurrentValue: <T>(observable$: Observable<T>) => T;
51
64
  /**
52
65
  * @internal
53
66
  * @param status
@@ -1,6 +1,8 @@
1
1
  import { SpeakerState } from './SpeakerState';
2
2
  export declare class SpeakerManager {
3
3
  readonly state: SpeakerState;
4
+ private subscriptions;
5
+ constructor();
4
6
  /**
5
7
  * Lists the available audio output devices
6
8
  *
@@ -17,6 +19,7 @@ export declare class SpeakerManager {
17
19
  * @param deviceId empty string means the system default
18
20
  */
19
21
  select(deviceId: string): void;
22
+ removeSubscriptions: () => void;
20
23
  /**
21
24
  * Set the volume of the audio elements
22
25
  * @param volume a number between 0 and 1
@@ -2,8 +2,7 @@ import { Observable } from 'rxjs';
2
2
  /**
3
3
  * [Tells if the browser supports audio output change on 'audio' elements](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId).
4
4
  *
5
- * @angular It's recommended to use the [`DeviceManagerService`](./DeviceManagerService.md) for a higher level API, use this low-level method only if the `DeviceManagerService` doesn't suit your requirements.
6
- */
5
+ * */
7
6
  export declare const checkIfAudioOutputChangeSupported: () => boolean;
8
7
  /**
9
8
  * Prompts the user for a permission to use audio devices (if not already granted) and lists the available 'audioinput' devices, if devices are added/removed the list is updated.
@@ -46,45 +45,7 @@ export declare const getVideoStream: (trackConstraints?: MediaTrackConstraints)
46
45
  * @param options any additional options to pass to the [`getDisplayMedia`](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getDisplayMedia) API.
47
46
  */
48
47
  export declare const getScreenShareStream: (options?: DisplayMediaStreamOptions) => Promise<MediaStream>;
49
- /**
50
- * Notifies the subscriber if a given 'audioinput' device is disconnected
51
- *
52
- * @angular It's recommended to use the [`DeviceManagerService`](./DeviceManagerService.md) for a higher level API, use this low-level method only if the `DeviceManagerService` doesn't suit your requirements.
53
- * @param deviceId$ an Observable that specifies which device to watch for
54
- * @returns
55
- */
56
- export declare const watchForDisconnectedAudioDevice: (deviceId$: Observable<string | undefined>) => Observable<boolean>;
57
- /**
58
- * Notifies the subscriber if a given 'videoinput' device is disconnected
59
- *
60
- * @angular It's recommended to use the [`DeviceManagerService`](./DeviceManagerService.md) for a higher level API, use this low-level method only if the `DeviceManagerService` doesn't suit your requirements.
61
- * @param deviceId$ an Observable that specifies which device to watch for
62
- * @returns
63
- */
64
- export declare const watchForDisconnectedVideoDevice: (deviceId$: Observable<string | undefined>) => Observable<boolean>;
65
- /**
66
- * Notifies the subscriber if a given 'audiooutput' device is disconnected
67
- *
68
- * @angular It's recommended to use the [`DeviceManagerService`](./DeviceManagerService.md) for a higher level API, use this low-level method only if the `DeviceManagerService` doesn't suit your requirements.
69
- * @param deviceId$ an Observable that specifies which device to watch for
70
- * @returns
71
- */
72
- export declare const watchForDisconnectedAudioOutputDevice: (deviceId$: Observable<string | undefined>) => Observable<boolean>;
73
- /**
74
- * Notifies the subscriber about newly added default audio input device.
75
- * @returns Observable<boolean>
76
- */
77
- export declare const watchForAddedDefaultAudioDevice: () => Observable<boolean>;
78
- /**
79
- * Notifies the subscriber about newly added default audio output device.
80
- * @returns Observable<boolean>
81
- */
82
- export declare const watchForAddedDefaultAudioOutputDevice: () => Observable<boolean>;
83
- /**
84
- * Notifies the subscriber about newly added default video input device.
85
- * @returns Observable<boolean>
86
- */
87
- export declare const watchForAddedDefaultVideoDevice: () => Observable<boolean>;
48
+ export declare const deviceIds$: Observable<MediaDeviceInfo[]> | undefined;
88
49
  /**
89
50
  * Deactivates MediaStream (stops and removes tracks) to be later garbage collected
90
51
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-client",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "packageManager": "yarn@3.2.4",
5
5
  "main": "dist/index.cjs.js",
6
6
  "module": "dist/index.es.js",
package/src/Call.ts CHANGED
@@ -507,6 +507,11 @@ export class Call {
507
507
 
508
508
  this.clientStore.unregisterCall(this);
509
509
  this.state.setCallingState(CallingState.LEFT);
510
+
511
+ this.camera.removeSubscriptions();
512
+ this.microphone.removeSubscriptions();
513
+ this.screenShare.removeSubscriptions();
514
+ this.speaker.removeSubscriptions();
510
515
  };
511
516
 
512
517
  /**
@@ -1004,7 +1009,11 @@ export class Call {
1004
1009
  await this.initCamera({ setStatus: true });
1005
1010
  await this.initMic({ setStatus: true });
1006
1011
  } catch (error) {
1007
- this.logger('warn', 'Camera and/or mic init failed during join call');
1012
+ this.logger(
1013
+ 'warn',
1014
+ 'Camera and/or mic init failed during join call',
1015
+ error,
1016
+ );
1008
1017
  }
1009
1018
 
1010
1019
  // 3. once we have the "joinResponse", and possibly reconciled the local state
@@ -1,4 +1,4 @@
1
- import { BehaviorSubject, Observable, distinctUntilChanged } from 'rxjs';
1
+ import { BehaviorSubject, distinctUntilChanged, Observable } from 'rxjs';
2
2
  import { InputMediaDeviceManagerState } from './InputMediaDeviceManagerState';
3
3
  import { isReactNative } from '../helpers/platforms';
4
4
 
@@ -15,7 +15,12 @@ export class CameraManagerState extends InputMediaDeviceManagerState {
15
15
  direction$: Observable<CameraDirection>;
16
16
 
17
17
  constructor() {
18
- super('stop-tracks');
18
+ super(
19
+ 'stop-tracks',
20
+ // `camera` is not in the W3C standard yet,
21
+ // but it's supported by Chrome and Safari.
22
+ 'camera' as PermissionName,
23
+ );
19
24
  this.direction$ = this.directionSubject
20
25
  .asObservable()
21
26
  .pipe(distinctUntilChanged());
@@ -1,4 +1,4 @@
1
- import { Observable } from 'rxjs';
1
+ import { Observable, Subscription, combineLatest, pairwise } from 'rxjs';
2
2
  import { Call } from '../Call';
3
3
  import { CallingState } from '../store';
4
4
  import { InputMediaDeviceManagerState } from './InputMediaDeviceManagerState';
@@ -6,6 +6,7 @@ import { isReactNative } from '../helpers/platforms';
6
6
  import { Logger } from '../coordinator/connection/types';
7
7
  import { getLogger } from '../logger';
8
8
  import { TrackType } from '../gen/video/sfu/models/models';
9
+ import { deviceIds$ } from './devices';
9
10
 
10
11
  export abstract class InputMediaDeviceManager<
11
12
  T extends InputMediaDeviceManagerState<C>,
@@ -20,6 +21,8 @@ export abstract class InputMediaDeviceManager<
20
21
  */
21
22
  disablePromise?: Promise<void>;
22
23
  logger: Logger;
24
+ private subscriptions: Subscription[] = [];
25
+ private isTrackStoppedDueToTrackEnd = false;
23
26
 
24
27
  protected constructor(
25
28
  protected readonly call: Call,
@@ -27,6 +30,13 @@ export abstract class InputMediaDeviceManager<
27
30
  protected readonly trackType: TrackType,
28
31
  ) {
29
32
  this.logger = getLogger([`${TrackType[trackType].toLowerCase()} manager`]);
33
+ if (
34
+ deviceIds$ &&
35
+ !isReactNative() &&
36
+ (this.trackType === TrackType.AUDIO || this.trackType === TrackType.VIDEO)
37
+ ) {
38
+ this.handleDisconnectedOrReplacedDevices();
39
+ }
30
40
  }
31
41
 
32
42
  /**
@@ -108,11 +118,10 @@ export abstract class InputMediaDeviceManager<
108
118
  }
109
119
 
110
120
  /**
111
- * Select device
121
+ * Selects a device.
112
122
  *
113
123
  * Note: this method is not supported in React Native
114
- *
115
- * @param deviceId
124
+ * @param deviceId the device id to select.
116
125
  */
117
126
  async select(deviceId: string | undefined) {
118
127
  if (isReactNative()) {
@@ -125,6 +134,10 @@ export abstract class InputMediaDeviceManager<
125
134
  await this.applySettingsToStream();
126
135
  }
127
136
 
137
+ removeSubscriptions = () => {
138
+ this.subscriptions.forEach((s) => s.unsubscribe());
139
+ };
140
+
128
141
  protected async applySettingsToStream() {
129
142
  if (this.state.status === 'enabled') {
130
143
  await this.muteStream();
@@ -204,9 +217,6 @@ export abstract class InputMediaDeviceManager<
204
217
  stream = this.state.mediaStream;
205
218
  this.unmuteTracks();
206
219
  } else {
207
- if (this.state.mediaStream) {
208
- this.stopTracks();
209
- }
210
220
  const defaultConstraints = this.state.defaultConstraints;
211
221
  const constraints: MediaTrackConstraints = {
212
222
  ...defaultConstraints,
@@ -219,6 +229,89 @@ export abstract class InputMediaDeviceManager<
219
229
  }
220
230
  if (this.state.mediaStream !== stream) {
221
231
  this.state.setMediaStream(stream);
232
+ this.getTracks().forEach((track) => {
233
+ track.addEventListener('ended', async () => {
234
+ if (this.enablePromise) {
235
+ await this.enablePromise;
236
+ }
237
+ if (this.disablePromise) {
238
+ await this.disablePromise;
239
+ }
240
+ if (this.state.status === 'enabled') {
241
+ this.isTrackStoppedDueToTrackEnd = true;
242
+ setTimeout(() => {
243
+ this.isTrackStoppedDueToTrackEnd = false;
244
+ }, 2000);
245
+ await this.disable();
246
+ }
247
+ });
248
+ });
249
+ }
250
+ }
251
+
252
+ private get mediaDeviceKind() {
253
+ if (this.trackType === TrackType.AUDIO) {
254
+ return 'audioinput';
255
+ }
256
+ if (this.trackType === TrackType.VIDEO) {
257
+ return 'videoinput';
222
258
  }
259
+ return '';
260
+ }
261
+
262
+ private handleDisconnectedOrReplacedDevices() {
263
+ this.subscriptions.push(
264
+ combineLatest([
265
+ deviceIds$!.pipe(pairwise()),
266
+ this.state.selectedDevice$,
267
+ ]).subscribe(async ([[prevDevices, currentDevices], deviceId]) => {
268
+ if (!deviceId) {
269
+ return;
270
+ }
271
+ if (this.enablePromise) {
272
+ await this.enablePromise;
273
+ }
274
+ if (this.disablePromise) {
275
+ await this.disablePromise;
276
+ }
277
+
278
+ let isDeviceDisconnected = false;
279
+ let isDeviceReplaced = false;
280
+ const currentDevice = this.findDeviceInList(currentDevices, deviceId);
281
+ const prevDevice = this.findDeviceInList(prevDevices, deviceId);
282
+ if (!currentDevice && prevDevice) {
283
+ isDeviceDisconnected = true;
284
+ } else if (
285
+ currentDevice &&
286
+ prevDevice &&
287
+ currentDevice.deviceId === prevDevice.deviceId &&
288
+ currentDevice.groupId !== prevDevice.groupId
289
+ ) {
290
+ isDeviceReplaced = true;
291
+ }
292
+
293
+ if (isDeviceDisconnected) {
294
+ await this.disable();
295
+ this.select(undefined);
296
+ }
297
+ if (isDeviceReplaced) {
298
+ if (
299
+ this.isTrackStoppedDueToTrackEnd &&
300
+ this.state.status === 'disabled'
301
+ ) {
302
+ await this.enable();
303
+ this.isTrackStoppedDueToTrackEnd = false;
304
+ } else {
305
+ await this.applySettingsToStream();
306
+ }
307
+ }
308
+ }),
309
+ );
310
+ }
311
+
312
+ private findDeviceInList(devices: MediaDeviceInfo[], deviceId: string) {
313
+ return devices.find(
314
+ (d) => d.deviceId === deviceId && d.kind === this.mediaDeviceKind,
315
+ );
223
316
  }
224
317
  }
@@ -1,4 +1,10 @@
1
- import { BehaviorSubject, distinctUntilChanged } from 'rxjs';
1
+ import {
2
+ BehaviorSubject,
3
+ distinctUntilChanged,
4
+ Observable,
5
+ shareReplay,
6
+ } from 'rxjs';
7
+ import { isReactNative } from '../helpers/platforms';
2
8
  import { RxUtils } from '../store';
3
9
 
4
10
  export type InputDeviceStatus = 'enabled' | 'disabled' | undefined;
@@ -43,10 +49,47 @@ export abstract class InputMediaDeviceManagerState<C = MediaTrackConstraints> {
43
49
  */
44
50
  defaultConstraints$ = this.defaultConstraintsSubject.asObservable();
45
51
 
52
+ /**
53
+ * An observable that will emit `true` if browser/system permission
54
+ * is granted, `false` otherwise.
55
+ */
56
+ hasBrowserPermission$ = new Observable<boolean>((subscriber) => {
57
+ const notifyGranted = () => subscriber.next(true);
58
+ if (isReactNative() || !this.permissionName) return notifyGranted();
59
+
60
+ let permissionState: PermissionStatus;
61
+ const notify = () => subscriber.next(permissionState.state === 'granted');
62
+ navigator.permissions
63
+ .query({ name: this.permissionName })
64
+ .then((permissionStatus) => {
65
+ permissionState = permissionStatus;
66
+ permissionState.addEventListener('change', notify);
67
+ notify();
68
+ })
69
+ .catch(() => {
70
+ // permission doesn't exist or can't be queried -> assume it's granted
71
+ // an example would be Firefox,
72
+ // where neither camera microphone permission can be queried
73
+ notifyGranted();
74
+ });
75
+
76
+ return () => {
77
+ permissionState?.removeEventListener('change', notify);
78
+ };
79
+ }).pipe(shareReplay(1));
80
+
81
+ /**
82
+ * Constructs new InputMediaDeviceManagerState instance.
83
+ *
84
+ * @param disableMode the disable mode to use.
85
+ * @param permissionName the permission name to use for querying.
86
+ * `undefined` means no permission is required.
87
+ */
46
88
  constructor(
47
89
  public readonly disableMode:
48
90
  | 'stop-tracks'
49
91
  | 'disable-tracks' = 'stop-tracks',
92
+ private readonly permissionName: PermissionName | undefined = undefined,
50
93
  ) {}
51
94
 
52
95
  /**
@@ -1,4 +1,4 @@
1
- import { BehaviorSubject, Observable, distinctUntilChanged } from 'rxjs';
1
+ import { BehaviorSubject, distinctUntilChanged, Observable } from 'rxjs';
2
2
  import { InputMediaDeviceManagerState } from './InputMediaDeviceManagerState';
3
3
 
4
4
  export class MicrophoneManagerState extends InputMediaDeviceManagerState {
@@ -12,7 +12,12 @@ export class MicrophoneManagerState extends InputMediaDeviceManagerState {
12
12
  speakingWhileMuted$: Observable<boolean>;
13
13
 
14
14
  constructor() {
15
- super('disable-tracks');
15
+ super(
16
+ 'disable-tracks',
17
+ // `microphone` is not in the W3C standard yet,
18
+ // but it's supported by Chrome and Safari.
19
+ 'microphone' as PermissionName,
20
+ );
16
21
 
17
22
  this.speakingWhileMuted$ = this.speakingWhileMutedSubject
18
23
  .asObservable()
@@ -1,9 +1,31 @@
1
+ import { Subscription, combineLatest } from 'rxjs';
1
2
  import { isReactNative } from '../helpers/platforms';
2
3
  import { SpeakerState } from './SpeakerState';
3
- import { getAudioOutputDevices } from './devices';
4
+ import { deviceIds$, getAudioOutputDevices } from './devices';
4
5
 
5
6
  export class SpeakerManager {
6
7
  public readonly state = new SpeakerState();
8
+ private subscriptions: Subscription[] = [];
9
+
10
+ constructor() {
11
+ if (deviceIds$ && !isReactNative()) {
12
+ this.subscriptions.push(
13
+ combineLatest([deviceIds$!, this.state.selectedDevice$]).subscribe(
14
+ ([devices, deviceId]) => {
15
+ if (!deviceId) {
16
+ return;
17
+ }
18
+ const device = devices.find(
19
+ (d) => d.deviceId === deviceId && d.kind === 'audiooutput',
20
+ );
21
+ if (!device) {
22
+ this.select('');
23
+ }
24
+ },
25
+ ),
26
+ );
27
+ }
28
+ }
7
29
 
8
30
  /**
9
31
  * Lists the available audio output devices
@@ -30,6 +52,10 @@ export class SpeakerManager {
30
52
  this.state.setDevice(deviceId);
31
53
  }
32
54
 
55
+ removeSubscriptions = () => {
56
+ this.subscriptions.forEach((s) => s.unsubscribe());
57
+ };
58
+
33
59
  /**
34
60
  * Set the volume of the audio elements
35
61
  * @param volume a number between 0 and 1
@@ -3,7 +3,12 @@ import { StreamClient } from '../../coordinator/connection/client';
3
3
  import { CallingState, StreamVideoWriteableStateStore } from '../../store';
4
4
 
5
5
  import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
6
- import { mockCall, mockVideoDevices, mockVideoStream } from './mocks';
6
+ import {
7
+ mockCall,
8
+ mockDeviceIds$,
9
+ mockVideoDevices,
10
+ mockVideoStream,
11
+ } from './mocks';
7
12
  import { getVideoStream } from '../devices';
8
13
  import { TrackType } from '../../gen/video/sfu/models/models';
9
14
  import { CameraManager } from '../CameraManager';
@@ -17,6 +22,7 @@ vi.mock('../devices.ts', () => {
17
22
  return of(mockVideoDevices);
18
23
  }),
19
24
  getVideoStream: vi.fn(() => Promise.resolve(mockVideoStream())),
25
+ deviceIds$: mockDeviceIds$(),
20
26
  };
21
27
  });
22
28
 
@@ -3,7 +3,14 @@ import { StreamClient } from '../../coordinator/connection/client';
3
3
  import { CallingState, StreamVideoWriteableStateStore } from '../../store';
4
4
 
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
- import { mockCall, mockVideoDevices, mockVideoStream } from './mocks';
6
+ import {
7
+ MockTrack,
8
+ emitDeviceIds,
9
+ mockCall,
10
+ mockDeviceIds$,
11
+ mockVideoDevices,
12
+ mockVideoStream,
13
+ } from './mocks';
7
14
  import { InputMediaDeviceManager } from '../InputMediaDeviceManager';
8
15
  import { InputMediaDeviceManagerState } from '../InputMediaDeviceManagerState';
9
16
  import { of } from 'rxjs';
@@ -16,8 +23,17 @@ vi.mock('../../Call.ts', () => {
16
23
  };
17
24
  });
18
25
 
26
+ vi.mock('../devices.ts', () => {
27
+ console.log('MOCKING devices API');
28
+ return {
29
+ deviceIds$: mockDeviceIds$(),
30
+ };
31
+ });
32
+
19
33
  class TestInputMediaDeviceManagerState extends InputMediaDeviceManagerState {
20
- public getDeviceIdFromStream = vi.fn(() => 'mock-device-id');
34
+ public getDeviceIdFromStream = vi.fn(
35
+ (stream) => stream.getVideoTracks()[0].getSettings().deviceId,
36
+ );
21
37
  }
22
38
 
23
39
  class TestInputMediaDeviceManager extends InputMediaDeviceManager<TestInputMediaDeviceManagerState> {
@@ -28,7 +44,14 @@ class TestInputMediaDeviceManager extends InputMediaDeviceManager<TestInputMedia
28
44
  public getTracks = () => this.state.mediaStream?.getTracks() ?? [];
29
45
 
30
46
  constructor(call: Call) {
31
- super(call, new TestInputMediaDeviceManagerState(), TrackType.VIDEO);
47
+ super(
48
+ call,
49
+ new TestInputMediaDeviceManagerState(
50
+ 'stop-tracks',
51
+ 'camera' as PermissionName,
52
+ ),
53
+ TrackType.VIDEO,
54
+ );
32
55
  }
33
56
  }
34
57
 
@@ -217,6 +240,93 @@ describe('InputMediaDeviceManager.test', () => {
217
240
  });
218
241
  });
219
242
 
243
+ it('should set status to disabled if track ends', async () => {
244
+ vi.useFakeTimers();
245
+
246
+ await manager.enable();
247
+
248
+ vi.spyOn(manager, 'enable');
249
+ vi.spyOn(manager, 'listDevices').mockImplementationOnce(() =>
250
+ of(mockVideoDevices.slice(1)),
251
+ );
252
+ await (
253
+ (manager.state.mediaStream?.getTracks()[0] as MockTrack).eventHandlers[
254
+ 'ended'
255
+ ] as Function
256
+ )();
257
+ await vi.runAllTimersAsync();
258
+
259
+ expect(manager.state.status).toBe('disabled');
260
+ expect(manager.enable).not.toHaveBeenCalled();
261
+
262
+ vi.useRealTimers();
263
+ });
264
+
265
+ it('should restart track if the default device is replaced and status is enabled', async () => {
266
+ vi.useFakeTimers();
267
+ emitDeviceIds(mockVideoDevices);
268
+
269
+ await manager.enable();
270
+ const device = mockVideoDevices[0];
271
+ await manager.select(device.deviceId);
272
+
273
+ //@ts-expect-error
274
+ vi.spyOn(manager, 'applySettingsToStream');
275
+
276
+ emitDeviceIds([
277
+ { ...device, groupId: device.groupId + 'new' },
278
+ ...mockVideoDevices.slice(1),
279
+ ]);
280
+
281
+ await vi.runAllTimersAsync();
282
+
283
+ expect(manager['applySettingsToStream']).toHaveBeenCalledOnce();
284
+ expect(manager.state.status).toBe('enabled');
285
+
286
+ vi.useRealTimers();
287
+ });
288
+
289
+ it('should do nothing if default device is replaced and status is disabled', async () => {
290
+ vi.useFakeTimers();
291
+ emitDeviceIds(mockVideoDevices);
292
+
293
+ const device = mockVideoDevices[0];
294
+ await manager.select(device.deviceId);
295
+ await manager.disable();
296
+
297
+ emitDeviceIds([
298
+ { ...device, groupId: device.groupId + 'new' },
299
+ ...mockVideoDevices.slice(1),
300
+ ]);
301
+
302
+ await vi.runAllTimersAsync();
303
+
304
+ expect(manager.state.status).toBe('disabled');
305
+ expect(manager.disablePromise).toBeUndefined();
306
+ expect(manager.enablePromise).toBeUndefined();
307
+ expect(manager.state.selectedDevice).toBe(device.deviceId);
308
+
309
+ vi.useRealTimers();
310
+ });
311
+
312
+ it('should disable stream and deselect device if selected device is disconnected', async () => {
313
+ vi.useFakeTimers();
314
+ emitDeviceIds(mockVideoDevices);
315
+
316
+ await manager.enable();
317
+ const device = mockVideoDevices[0];
318
+ await manager.select(device.deviceId);
319
+
320
+ emitDeviceIds(mockVideoDevices.slice(1));
321
+
322
+ await vi.runAllTimersAsync();
323
+
324
+ expect(manager.state.selectedDevice).toBe(undefined);
325
+ expect(manager.state.status).toBe('disabled');
326
+
327
+ vi.useRealTimers();
328
+ });
329
+
220
330
  afterEach(() => {
221
331
  vi.clearAllMocks();
222
332
  vi.resetModules();