@stream-io/video-client 0.3.5 → 0.3.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.
@@ -3,6 +3,7 @@ import { Call } from '../Call';
3
3
  import { CameraDirection, CameraManagerState } from './CameraManagerState';
4
4
  import { InputMediaDeviceManager } from './InputMediaDeviceManager';
5
5
  export declare class CameraManager extends InputMediaDeviceManager<CameraManagerState> {
6
+ private targetResolution;
6
7
  constructor(call: Call);
7
8
  /**
8
9
  * Select the camera direaction
@@ -16,6 +17,13 @@ export declare class CameraManager extends InputMediaDeviceManager<CameraManager
16
17
  * @returns
17
18
  */
18
19
  flip(): Promise<void>;
20
+ /**
21
+ * @internal
22
+ */
23
+ selectTargetResolution(resolution: {
24
+ width: number;
25
+ height: number;
26
+ }): Promise<void>;
19
27
  protected getDevices(): Observable<MediaDeviceInfo[]>;
20
28
  protected getStream(constraints: MediaTrackConstraints): Promise<MediaStream>;
21
29
  protected publishStream(stream: MediaStream): Promise<void>;
@@ -4,6 +4,14 @@ import { InputMediaDeviceManagerState } from './InputMediaDeviceManagerState';
4
4
  export declare abstract class InputMediaDeviceManager<T extends InputMediaDeviceManagerState> {
5
5
  protected readonly call: Call;
6
6
  readonly state: T;
7
+ /**
8
+ * @internal
9
+ */
10
+ enablePromise?: Promise<void>;
11
+ /**
12
+ * @internal
13
+ */
14
+ disablePromise?: Promise<void>;
7
15
  constructor(call: Call, state: T);
8
16
  /**
9
17
  * Lists the available audio/video devices
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const version = "0.3.5";
1
+ export declare const version = "0.3.7";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-client",
3
- "version": "0.3.5",
3
+ "version": "0.3.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
@@ -926,8 +926,12 @@ export class Call {
926
926
 
927
927
  // React uses a different device management for now
928
928
  if (getSdkInfo()?.type !== SdkType.REACT) {
929
- this.initCamera();
930
- this.initMic();
929
+ try {
930
+ await this.initCamera();
931
+ await this.initMic();
932
+ } catch (error) {
933
+ this.logger('warn', 'Camera and/or mic init failed during join call');
934
+ }
931
935
  }
932
936
 
933
937
  // 3. once we have the "joinResponse", and possibly reconciled the local state
@@ -1713,7 +1717,15 @@ export class Call {
1713
1717
  );
1714
1718
  };
1715
1719
 
1716
- private initCamera() {
1720
+ private async initCamera() {
1721
+ // Wait for any in progress camera operation
1722
+ if (this.camera.enablePromise) {
1723
+ await this.camera.enablePromise;
1724
+ }
1725
+ if (this.camera.disablePromise) {
1726
+ await this.camera.disablePromise;
1727
+ }
1728
+
1717
1729
  if (
1718
1730
  this.state.localParticipant?.videoStream ||
1719
1731
  !this.permissionsContext.hasPermission('send-video')
@@ -1722,36 +1734,48 @@ export class Call {
1722
1734
  }
1723
1735
 
1724
1736
  // Set camera direction if it's not yet set
1725
- // This will also start publishing if camera is enabled
1726
1737
  if (!this.camera.state.direction && !this.camera.state.selectedDevice) {
1727
1738
  let defaultDirection: CameraDirection = 'front';
1728
1739
  const backendSetting = this.state.settings?.video.camera_facing;
1729
1740
  if (backendSetting) {
1730
1741
  defaultDirection = backendSetting === 'front' ? 'front' : 'back';
1731
1742
  }
1732
- this.camera.selectDirection(defaultDirection);
1733
- } else if (this.camera.state.status === 'enabled') {
1734
- // Publish already started media streams (this is the case if there is a lobby screen before join)
1735
- // Wait for media stream
1736
- this.camera.state.mediaStream$
1737
- .pipe(takeWhile((s) => s === undefined, true))
1738
- .subscribe((stream) => {
1739
- if (!this.state.localParticipant?.videoStream) {
1740
- this.publishVideoStream(stream!);
1741
- }
1742
- });
1743
+ this.camera.state.setDirection(defaultDirection);
1744
+ }
1745
+
1746
+ // Set target resolution
1747
+ const targetResolution = this.state.settings?.video.target_resolution;
1748
+ if (targetResolution) {
1749
+ await this.camera.selectTargetResolution(targetResolution);
1743
1750
  }
1744
1751
 
1745
- // Apply backend config (this is the case if there is no lobby screen before join)
1752
+ // Publish already that was set before we joined
1753
+ if (
1754
+ this.camera.state.status === 'enabled' &&
1755
+ this.camera.state.mediaStream &&
1756
+ !this.publisher?.isPublishing(TrackType.VIDEO)
1757
+ ) {
1758
+ await this.publishVideoStream(this.camera.state.mediaStream);
1759
+ }
1760
+
1761
+ // Start camera if backend config speicifies, and there is no local setting
1746
1762
  if (
1747
1763
  this.camera.state.status === undefined &&
1748
1764
  this.state.settings?.video.camera_default_on
1749
1765
  ) {
1750
- void this.camera.enable();
1766
+ await this.camera.enable();
1751
1767
  }
1752
1768
  }
1753
1769
 
1754
- private initMic() {
1770
+ private async initMic() {
1771
+ // Wait for any in progress mic operation
1772
+ if (this.microphone.enablePromise) {
1773
+ await this.microphone.enablePromise;
1774
+ }
1775
+ if (this.microphone.disablePromise) {
1776
+ await this.microphone.disablePromise;
1777
+ }
1778
+
1755
1779
  if (
1756
1780
  this.state.localParticipant?.audioStream ||
1757
1781
  !this.permissionsContext.hasPermission('send-audio')
@@ -1759,24 +1783,21 @@ export class Call {
1759
1783
  return;
1760
1784
  }
1761
1785
 
1762
- // Publish already started media streams (this is the case if there is a lobby screen before join)
1763
- if (this.microphone.state.status === 'enabled') {
1764
- // Wait for media stream
1765
- this.microphone.state.mediaStream$
1766
- .pipe(takeWhile((s) => s === undefined, true))
1767
- .subscribe((stream) => {
1768
- if (!this.state.localParticipant?.audioStream) {
1769
- this.publishAudioStream(stream!);
1770
- }
1771
- });
1786
+ // Publish media stream that was set before we joined
1787
+ if (
1788
+ this.microphone.state.status === 'enabled' &&
1789
+ this.microphone.state.mediaStream &&
1790
+ !this.publisher?.isPublishing(TrackType.AUDIO)
1791
+ ) {
1792
+ this.publishAudioStream(this.microphone.state.mediaStream);
1772
1793
  }
1773
1794
 
1774
- // Apply backend config (this is the case if there is no lobby screen before join)
1795
+ // Start mic if backend config speicifies, and there is no local setting
1775
1796
  if (
1776
1797
  this.microphone.state.status === undefined &&
1777
1798
  this.state.settings?.audio.mic_default_on
1778
1799
  ) {
1779
- void this.microphone.enable();
1800
+ await this.microphone.enable();
1780
1801
  }
1781
1802
  }
1782
1803
  }
@@ -6,6 +6,11 @@ import { getVideoDevices, getVideoStream } from './devices';
6
6
  import { TrackType } from '../gen/video/sfu/models/models';
7
7
 
8
8
  export class CameraManager extends InputMediaDeviceManager<CameraManagerState> {
9
+ private targetResolution = {
10
+ width: 1280,
11
+ height: 720,
12
+ };
13
+
9
14
  constructor(call: Call) {
10
15
  super(call, new CameraManagerState());
11
16
  }
@@ -32,12 +37,39 @@ export class CameraManager extends InputMediaDeviceManager<CameraManagerState> {
32
37
  this.selectDirection(newDirection);
33
38
  }
34
39
 
40
+ /**
41
+ * @internal
42
+ */
43
+ async selectTargetResolution(resolution: { width: number; height: number }) {
44
+ this.targetResolution.height = resolution.height;
45
+ this.targetResolution.width = resolution.width;
46
+ if (this.enablePromise) {
47
+ try {
48
+ await this.enablePromise;
49
+ } catch (error) {
50
+ // couldn't enable device, target resolution will be applied the next time user attempts to start the device
51
+ }
52
+ }
53
+ if (this.state.status === 'enabled') {
54
+ const { width, height } = this.state
55
+ .mediaStream!.getVideoTracks()[0]
56
+ ?.getSettings();
57
+ if (
58
+ width !== this.targetResolution.width ||
59
+ height !== this.targetResolution.height
60
+ )
61
+ await this.applySettingsToStream();
62
+ }
63
+ }
64
+
35
65
  protected getDevices(): Observable<MediaDeviceInfo[]> {
36
66
  return getVideoDevices();
37
67
  }
38
68
  protected getStream(
39
69
  constraints: MediaTrackConstraints,
40
70
  ): Promise<MediaStream> {
71
+ constraints.width = this.targetResolution.width;
72
+ constraints.height = this.targetResolution.height;
41
73
  // We can't set both device id and facing mode
42
74
  // Device id has higher priority
43
75
  if (!constraints.deviceId && this.state.direction) {
@@ -8,6 +8,14 @@ import { isReactNative } from '../helpers/platforms';
8
8
  export abstract class InputMediaDeviceManager<
9
9
  T extends InputMediaDeviceManagerState,
10
10
  > {
11
+ /**
12
+ * @internal
13
+ */
14
+ enablePromise?: Promise<void>;
15
+ /**
16
+ * @internal
17
+ */
18
+ disablePromise?: Promise<void>;
11
19
  constructor(protected readonly call: Call, public readonly state: T) {}
12
20
 
13
21
  /**
@@ -28,8 +36,14 @@ export abstract class InputMediaDeviceManager<
28
36
  if (this.state.status === 'enabled') {
29
37
  return;
30
38
  }
31
- await this.unmuteStream();
32
- this.state.setStatus('enabled');
39
+ this.enablePromise = this.unmuteStream();
40
+ try {
41
+ await this.enablePromise;
42
+ this.state.setStatus('enabled');
43
+ } catch (error) {
44
+ this.enablePromise = undefined;
45
+ throw error;
46
+ }
33
47
  }
34
48
 
35
49
  /**
@@ -38,12 +52,21 @@ export abstract class InputMediaDeviceManager<
38
52
  * @returns
39
53
  */
40
54
  async disable() {
55
+ this.state.prevStatus = this.state.status;
41
56
  if (this.state.status === 'disabled') {
42
57
  return;
43
58
  }
44
- this.state.prevStatus = this.state.status;
45
- await this.muteStream(this.state.disableMode === 'stop-tracks');
46
- this.state.setStatus('disabled');
59
+ this.disablePromise = this.muteStream(
60
+ this.state.disableMode === 'stop-tracks',
61
+ );
62
+ try {
63
+ await this.disablePromise;
64
+ this.state.setStatus('disabled');
65
+ this.disablePromise = undefined;
66
+ } catch (error) {
67
+ this.disablePromise = undefined;
68
+ throw error;
69
+ }
47
70
  }
48
71
 
49
72
  /**
@@ -53,6 +53,8 @@ describe('CameraManager', () => {
53
53
 
54
54
  expect(getVideoStream).toHaveBeenCalledWith({
55
55
  deviceId: undefined,
56
+ width: 1280,
57
+ height: 720,
56
58
  });
57
59
  });
58
60
 
@@ -101,12 +103,18 @@ describe('CameraManager', () => {
101
103
 
102
104
  await manager.enable();
103
105
 
104
- expect(getVideoStream).toHaveBeenCalledWith({ deviceId: undefined });
106
+ expect(getVideoStream).toHaveBeenCalledWith({
107
+ deviceId: undefined,
108
+ width: 1280,
109
+ height: 720,
110
+ });
105
111
 
106
112
  await manager.selectDirection('front');
107
113
 
108
114
  expect(getVideoStream).toHaveBeenCalledWith({
109
115
  deviceId: undefined,
116
+ width: 1280,
117
+ height: 720,
110
118
  facingMode: 'user',
111
119
  });
112
120
 
@@ -115,6 +123,8 @@ describe('CameraManager', () => {
115
123
  expect(getVideoStream).toHaveBeenCalledWith({
116
124
  deviceId: undefined,
117
125
  facingMode: 'environment',
126
+ width: 1280,
127
+ height: 720,
118
128
  });
119
129
  });
120
130
 
@@ -123,16 +133,57 @@ describe('CameraManager', () => {
123
133
 
124
134
  await manager.flip();
125
135
 
126
- expect(getVideoStream).toHaveBeenCalledWith({ facingMode: 'environment' });
136
+ expect(getVideoStream).toHaveBeenCalledWith({
137
+ facingMode: 'environment',
138
+ width: 1280,
139
+ height: 720,
140
+ });
127
141
 
128
142
  const deviceId = mockVideoDevices[1].deviceId;
129
143
  await manager.select(deviceId);
130
144
 
131
145
  expect((getVideoStream as Mock).mock.lastCall[0]).toEqual({
132
146
  deviceId,
147
+ width: 1280,
148
+ height: 720,
149
+ });
150
+ });
151
+
152
+ it(`should set target resolution, but shouldn't change device status`, async () => {
153
+ manager['targetResolution'] = { width: 640, height: 480 };
154
+
155
+ expect(manager.state.status).toBe(undefined);
156
+
157
+ await manager.selectTargetResolution({ width: 1280, height: 720 });
158
+
159
+ const targetResolution = manager['targetResolution'];
160
+
161
+ expect(targetResolution.width).toBe(1280);
162
+ expect(targetResolution.height).toBe(720);
163
+ expect(manager.state.status).toBe(undefined);
164
+ });
165
+
166
+ it('should apply target resolution to existing media stream track', async () => {
167
+ await manager.enable();
168
+ await manager.selectTargetResolution({ width: 640, height: 480 });
169
+
170
+ expect((getVideoStream as Mock).mock.lastCall[0]).toEqual({
171
+ deviceId: mockVideoDevices[0].deviceId,
172
+ width: 640,
173
+ height: 480,
133
174
  });
134
175
  });
135
176
 
177
+ it(`should do nothing if existing track has the correct resolution`, async () => {
178
+ await manager.enable();
179
+
180
+ expect(getVideoStream).toHaveBeenCalledOnce();
181
+
182
+ await manager.selectTargetResolution({ width: 1280, height: 720 });
183
+
184
+ expect(getVideoStream).toHaveBeenCalledOnce();
185
+ });
186
+
136
187
  afterEach(() => {
137
188
  vi.clearAllMocks();
138
189
  vi.resetModules();
@@ -195,6 +195,23 @@ describe('InputMediaDeviceManager.test', () => {
195
195
  expect(manager.enable).not.toHaveBeenCalled();
196
196
  });
197
197
 
198
+ it(`shouldn't resume if it were disabled while in pause`, async () => {
199
+ vi.spyOn(manager, 'enable');
200
+
201
+ await manager.enable();
202
+
203
+ expect(manager.enable).toHaveBeenCalledOnce();
204
+
205
+ // first call is pause
206
+ await manager.disable();
207
+ // second call is for example mute from call admin
208
+ await manager.disable();
209
+
210
+ await manager.resume();
211
+
212
+ expect(manager.enable).toHaveBeenCalledOnce();
213
+ });
214
+
198
215
  afterEach(() => {
199
216
  vi.clearAllMocks();
200
217
  vi.resetModules();
@@ -89,6 +89,8 @@ export const mockVideoStream = () => {
89
89
  const track = {
90
90
  getSettings: () => ({
91
91
  deviceId: mockVideoDevices[0].deviceId,
92
+ width: 1280,
93
+ height: 720,
92
94
  }),
93
95
  enabled: true,
94
96
  };