@stream-io/video-client 1.40.0 → 1.40.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/index.browser.es.js +72 -48
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +72 -47
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +72 -48
- package/dist/index.es.js.map +1 -1
- package/dist/src/StreamSfuClient.d.ts +6 -1
- package/dist/src/devices/DeviceManager.d.ts +13 -0
- package/package.json +1 -1
- package/src/Call.ts +5 -2
- package/src/StreamSfuClient.ts +29 -1
- package/src/devices/CameraManager.ts +26 -38
- package/src/devices/DeviceManager.ts +14 -0
- package/src/devices/MicrophoneManager.ts +10 -14
- package/src/devices/__tests__/CameraManager.test.ts +6 -6
- package/src/devices/__tests__/MicrophoneManager.test.ts +2 -9
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Dispatcher, IceTrickleBuffer } from './rtc';
|
|
2
|
-
import { JoinRequest, JoinResponse } from './gen/video/sfu/event/events';
|
|
2
|
+
import { Error as SfuErrorEvent, JoinRequest, JoinResponse } from './gen/video/sfu/event/events';
|
|
3
3
|
import { ICERestartRequest, SendAnswerRequest, SendStatsRequest, SetPublisherRequest, TrackMuteState, TrackSubscriptionDetails } from './gen/video/sfu/signal_rpc/signal';
|
|
4
4
|
import { ICETrickle } from './gen/video/sfu/models/models';
|
|
5
5
|
import { StreamClient } from './coordinator/connection/client';
|
|
@@ -170,3 +170,8 @@ export declare class StreamSfuClient {
|
|
|
170
170
|
private keepAlive;
|
|
171
171
|
private scheduleConnectionCheck;
|
|
172
172
|
}
|
|
173
|
+
export declare class SfuJoinError extends Error {
|
|
174
|
+
errorEvent: SfuErrorEvent;
|
|
175
|
+
unrecoverable: boolean;
|
|
176
|
+
constructor(event: SfuErrorEvent);
|
|
177
|
+
}
|
|
@@ -12,6 +12,19 @@ export declare abstract class DeviceManager<S extends DeviceManagerState<C>, C =
|
|
|
12
12
|
stopOnLeave: boolean;
|
|
13
13
|
logger: ScopedLogger;
|
|
14
14
|
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;
|
|
15
28
|
protected readonly call: Call;
|
|
16
29
|
protected readonly trackType: TrackType;
|
|
17
30
|
protected subscriptions: Function[];
|
package/package.json
CHANGED
package/src/Call.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { StreamSfuClient } from './StreamSfuClient';
|
|
1
|
+
import { SfuJoinError, StreamSfuClient } from './StreamSfuClient';
|
|
2
2
|
import {
|
|
3
3
|
BasePeerConnectionOpts,
|
|
4
4
|
Dispatcher,
|
|
@@ -908,7 +908,10 @@ export class Call {
|
|
|
908
908
|
break;
|
|
909
909
|
} catch (err) {
|
|
910
910
|
this.logger.warn(`Failed to join call (${attempt})`, this.cid);
|
|
911
|
-
if (
|
|
911
|
+
if (
|
|
912
|
+
(err instanceof ErrorFromResponse && err.unrecoverable) ||
|
|
913
|
+
(err instanceof SfuJoinError && err.unrecoverable)
|
|
914
|
+
) {
|
|
912
915
|
// if the error is unrecoverable, we should not retry as that signals
|
|
913
916
|
// that connectivity is good, but the coordinator doesn't allow the user
|
|
914
917
|
// to join the call due to some reason (e.g., ended call, expired token...)
|
package/src/StreamSfuClient.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
SfuEventKinds,
|
|
15
15
|
} from './rtc';
|
|
16
16
|
import {
|
|
17
|
+
Error as SfuErrorEvent,
|
|
17
18
|
JoinRequest,
|
|
18
19
|
JoinResponse,
|
|
19
20
|
SfuRequest,
|
|
@@ -26,7 +27,10 @@ import {
|
|
|
26
27
|
TrackMuteState,
|
|
27
28
|
TrackSubscriptionDetails,
|
|
28
29
|
} from './gen/video/sfu/signal_rpc/signal';
|
|
29
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
ICETrickle,
|
|
32
|
+
WebsocketReconnectStrategy,
|
|
33
|
+
} from './gen/video/sfu/models/models';
|
|
30
34
|
import { StreamClient } from './coordinator/connection/client';
|
|
31
35
|
import { generateUUIDv4 } from './coordinator/connection/utils';
|
|
32
36
|
import { Credentials } from './gen/coordinator';
|
|
@@ -537,15 +541,27 @@ export class StreamSfuClient {
|
|
|
537
541
|
const current = this.joinResponseTask;
|
|
538
542
|
|
|
539
543
|
let timeoutId: NodeJS.Timeout | undefined = undefined;
|
|
544
|
+
const unsubscribeJoinErrorEvents = this.dispatcher.on('error', (event) => {
|
|
545
|
+
const { error, reconnectStrategy } = event;
|
|
546
|
+
if (!error) return;
|
|
547
|
+
if (reconnectStrategy === WebsocketReconnectStrategy.DISCONNECT) {
|
|
548
|
+
clearTimeout(timeoutId);
|
|
549
|
+
unsubscribe?.();
|
|
550
|
+
unsubscribeJoinErrorEvents();
|
|
551
|
+
current.reject(new SfuJoinError(event));
|
|
552
|
+
}
|
|
553
|
+
});
|
|
540
554
|
const unsubscribe = this.dispatcher.on('joinResponse', (joinResponse) => {
|
|
541
555
|
clearTimeout(timeoutId);
|
|
542
556
|
unsubscribe();
|
|
557
|
+
unsubscribeJoinErrorEvents();
|
|
543
558
|
this.keepAlive();
|
|
544
559
|
current.resolve(joinResponse);
|
|
545
560
|
});
|
|
546
561
|
|
|
547
562
|
timeoutId = setTimeout(() => {
|
|
548
563
|
unsubscribe();
|
|
564
|
+
unsubscribeJoinErrorEvents();
|
|
549
565
|
const message = `Waiting for "joinResponse" has timed out after ${this.joinResponseTimeout}ms`;
|
|
550
566
|
this.tracer?.trace('joinRequestTimeout', message);
|
|
551
567
|
current.reject(new Error(message));
|
|
@@ -631,3 +647,15 @@ export class StreamSfuClient {
|
|
|
631
647
|
}, this.unhealthyTimeoutInMs);
|
|
632
648
|
};
|
|
633
649
|
}
|
|
650
|
+
|
|
651
|
+
export class SfuJoinError extends Error {
|
|
652
|
+
errorEvent: SfuErrorEvent;
|
|
653
|
+
unrecoverable: boolean;
|
|
654
|
+
|
|
655
|
+
constructor(event: SfuErrorEvent) {
|
|
656
|
+
super(event.error?.message || 'Join Error');
|
|
657
|
+
this.errorEvent = event;
|
|
658
|
+
this.unrecoverable =
|
|
659
|
+
event.reconnectStrategy === WebsocketReconnectStrategy.DISCONNECT;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
@@ -3,7 +3,7 @@ import { Call } from '../Call';
|
|
|
3
3
|
import { CameraDirection, CameraManagerState } from './CameraManagerState';
|
|
4
4
|
import { DeviceManager } from './DeviceManager';
|
|
5
5
|
import { getVideoDevices, getVideoStream } from './devices';
|
|
6
|
-
import {
|
|
6
|
+
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';
|
|
@@ -79,8 +79,15 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
|
|
|
79
79
|
* @internal
|
|
80
80
|
*/
|
|
81
81
|
async selectTargetResolution(resolution: { width: number; height: number }) {
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
// normalize target resolution to landscape format.
|
|
83
|
+
// on mobile devices, the device itself adjusts the resolution to portrait or landscape
|
|
84
|
+
// depending on the orientation of the device. using portrait resolution
|
|
85
|
+
// will result in falling back to the default resolution (640x480).
|
|
86
|
+
let { width, height } = resolution;
|
|
87
|
+
if (width < height) [width, height] = [height, width];
|
|
88
|
+
this.targetResolution.height = height;
|
|
89
|
+
this.targetResolution.width = width;
|
|
90
|
+
|
|
84
91
|
if (this.state.optimisticStatus === 'enabled') {
|
|
85
92
|
try {
|
|
86
93
|
await this.statusChangeSettled();
|
|
@@ -92,11 +99,8 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
|
|
|
92
99
|
if (this.enabled && this.state.mediaStream) {
|
|
93
100
|
const [videoTrack] = this.state.mediaStream.getVideoTracks();
|
|
94
101
|
if (!videoTrack) return;
|
|
95
|
-
const { width, height } = videoTrack.getSettings();
|
|
96
|
-
if (
|
|
97
|
-
width !== this.targetResolution.width ||
|
|
98
|
-
height !== this.targetResolution.height
|
|
99
|
-
) {
|
|
102
|
+
const { width: w, height: h } = videoTrack.getSettings();
|
|
103
|
+
if (w !== width || h !== height) {
|
|
100
104
|
await this.applySettingsToStream();
|
|
101
105
|
this.logger.debug(
|
|
102
106
|
`${width}x${height} target resolution applied to media stream`,
|
|
@@ -112,43 +116,27 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
|
|
|
112
116
|
* @param publish whether to publish the stream after applying the settings.
|
|
113
117
|
*/
|
|
114
118
|
async apply(settings: VideoSettingsResponse, publish: boolean) {
|
|
115
|
-
const hasPublishedVideo = !!this.call.state.localParticipant?.videoStream;
|
|
116
|
-
const hasPermission = this.call.permissionsContext.hasPermission(
|
|
117
|
-
OwnCapability.SEND_AUDIO,
|
|
118
|
-
);
|
|
119
|
-
if (hasPublishedVideo || !hasPermission) return;
|
|
120
|
-
|
|
121
119
|
// Wait for any in progress camera operation
|
|
122
120
|
await this.statusChangeSettled();
|
|
121
|
+
await this.selectTargetResolution(settings.target_resolution);
|
|
122
|
+
|
|
123
|
+
// apply a direction and enable the camera only if in "pristine" state
|
|
124
|
+
// and server defaults are not deferred to application code
|
|
125
|
+
const canPublish = this.call.permissionsContext.canPublish(this.trackType);
|
|
126
|
+
if (this.state.status === undefined && !this.deferServerDefaults) {
|
|
127
|
+
if (!this.state.direction && !this.state.selectedDevice) {
|
|
128
|
+
const direction = settings.camera_facing === 'front' ? 'front' : 'back';
|
|
129
|
+
await this.selectDirection(direction);
|
|
130
|
+
}
|
|
123
131
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
// on mobile devices, the device itself adjusts the resolution to portrait or landscape
|
|
128
|
-
// depending on the orientation of the device. using portrait resolution
|
|
129
|
-
// will result in falling back to the default resolution (640x480).
|
|
130
|
-
let { width, height } = target_resolution;
|
|
131
|
-
if (width < height) [width, height] = [height, width];
|
|
132
|
-
await this.selectTargetResolution({ width, height });
|
|
133
|
-
|
|
134
|
-
// Set camera direction if it's not yet set
|
|
135
|
-
if (!this.state.direction && !this.state.selectedDevice) {
|
|
136
|
-
this.state.setDirection(camera_facing === 'front' ? 'front' : 'back');
|
|
132
|
+
if (canPublish && settings.camera_default_on && settings.enabled) {
|
|
133
|
+
await this.enable();
|
|
134
|
+
}
|
|
137
135
|
}
|
|
138
136
|
|
|
139
|
-
if (!publish) return;
|
|
140
|
-
|
|
141
137
|
const { mediaStream } = this.state;
|
|
142
|
-
if (this.enabled && mediaStream) {
|
|
143
|
-
// The camera is already enabled (e.g. lobby screen). Publish the stream
|
|
138
|
+
if (canPublish && publish && this.enabled && mediaStream) {
|
|
144
139
|
await this.publishStream(mediaStream);
|
|
145
|
-
} else if (
|
|
146
|
-
this.state.status === undefined &&
|
|
147
|
-
camera_default_on &&
|
|
148
|
-
enabled
|
|
149
|
-
) {
|
|
150
|
-
// Start camera if backend config specifies, and there is no local setting
|
|
151
|
-
await this.enable();
|
|
152
140
|
}
|
|
153
141
|
}
|
|
154
142
|
|
|
@@ -32,6 +32,20 @@ export abstract class DeviceManager<
|
|
|
32
32
|
|
|
33
33
|
state: S;
|
|
34
34
|
|
|
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
|
+
|
|
35
49
|
protected readonly call: Call;
|
|
36
50
|
protected readonly trackType: TrackType;
|
|
37
51
|
protected subscriptions: Function[] = [];
|
|
@@ -230,25 +230,21 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
230
230
|
* @param publish whether to publish the stream after applying the settings.
|
|
231
231
|
*/
|
|
232
232
|
async apply(settings: AudioSettingsResponse, publish: boolean) {
|
|
233
|
-
if (!publish) return;
|
|
234
|
-
|
|
235
|
-
const hasPublishedAudio = !!this.call.state.localParticipant?.audioStream;
|
|
236
|
-
const hasPermission = this.call.permissionsContext.hasPermission(
|
|
237
|
-
OwnCapability.SEND_AUDIO,
|
|
238
|
-
);
|
|
239
|
-
if (hasPublishedAudio || !hasPermission) return;
|
|
240
|
-
|
|
241
233
|
// Wait for any in progress mic operation
|
|
242
234
|
await this.statusChangeSettled();
|
|
243
235
|
|
|
244
|
-
|
|
236
|
+
const canPublish = this.call.permissionsContext.canPublish(this.trackType);
|
|
237
|
+
// apply server-side settings only when the device state is pristine
|
|
238
|
+
// and server defaults are not deferred to application code
|
|
239
|
+
if (this.state.status === undefined && !this.deferServerDefaults) {
|
|
240
|
+
if (canPublish && settings.mic_default_on) {
|
|
241
|
+
await this.enable();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
245
|
const { mediaStream } = this.state;
|
|
246
|
-
if (this.enabled && mediaStream) {
|
|
247
|
-
// The mic is already enabled (e.g. lobby screen). Publish the stream
|
|
246
|
+
if (canPublish && publish && this.enabled && mediaStream) {
|
|
248
247
|
await this.publishStream(mediaStream);
|
|
249
|
-
} else if (this.state.status === undefined && settings.mic_default_on) {
|
|
250
|
-
// Start mic if backend config specifies, and there is no local setting
|
|
251
|
-
await this.enable();
|
|
252
248
|
}
|
|
253
249
|
}
|
|
254
250
|
|
|
@@ -3,6 +3,7 @@ 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 { fromPartial } from '@total-typescript/shoehorn';
|
|
6
7
|
import {
|
|
7
8
|
mockBrowserPermission,
|
|
8
9
|
mockCall,
|
|
@@ -296,21 +297,20 @@ describe('CameraManager', () => {
|
|
|
296
297
|
});
|
|
297
298
|
|
|
298
299
|
it('should not enable the camera when the user does not have permission', async () => {
|
|
299
|
-
call.permissionsContext.
|
|
300
|
+
call.permissionsContext.canPublish = vi.fn().mockReturnValue(false);
|
|
300
301
|
vi.spyOn(manager, 'enable');
|
|
301
302
|
await manager.apply(
|
|
302
|
-
|
|
303
|
-
{
|
|
303
|
+
fromPartial({
|
|
304
304
|
target_resolution: { width: 640, height: 480 },
|
|
305
305
|
camera_facing: 'front',
|
|
306
306
|
camera_default_on: true,
|
|
307
|
-
},
|
|
307
|
+
}),
|
|
308
308
|
true,
|
|
309
309
|
);
|
|
310
310
|
|
|
311
|
-
expect(manager.state.direction).toBe(
|
|
311
|
+
expect(manager.state.direction).toBe('front');
|
|
312
312
|
expect(manager.state.status).toBe(undefined);
|
|
313
|
-
expect(manager['targetResolution']).toEqual({ width:
|
|
313
|
+
expect(manager['targetResolution']).toEqual({ width: 640, height: 480 });
|
|
314
314
|
expect(manager.enable).not.toHaveBeenCalled();
|
|
315
315
|
});
|
|
316
316
|
|
|
@@ -329,7 +329,7 @@ describe('MicrophoneManager', () => {
|
|
|
329
329
|
beforeEach(() => {
|
|
330
330
|
// @ts-expect-error - read only property
|
|
331
331
|
call.permissionsContext = new PermissionsContext();
|
|
332
|
-
call.permissionsContext.
|
|
332
|
+
call.permissionsContext.canPublish = vi.fn().mockReturnValue(true);
|
|
333
333
|
});
|
|
334
334
|
|
|
335
335
|
it('should turn the mic on when set on dashboard', async () => {
|
|
@@ -346,15 +346,8 @@ describe('MicrophoneManager', () => {
|
|
|
346
346
|
expect(enable).not.toHaveBeenCalled();
|
|
347
347
|
});
|
|
348
348
|
|
|
349
|
-
it('should not turn on the mic when publish is false', async () => {
|
|
350
|
-
const enable = vi.spyOn(manager, 'enable');
|
|
351
|
-
// @ts-expect-error - partial data
|
|
352
|
-
await manager.apply({ mic_default_on: true }, false);
|
|
353
|
-
expect(enable).not.toHaveBeenCalled();
|
|
354
|
-
});
|
|
355
|
-
|
|
356
349
|
it('should not turn on the mic when permission is missing', async () => {
|
|
357
|
-
call.permissionsContext.
|
|
350
|
+
call.permissionsContext.canPublish = vi.fn().mockReturnValue(false);
|
|
358
351
|
const enable = vi.spyOn(manager, 'enable');
|
|
359
352
|
// @ts-expect-error - partial data
|
|
360
353
|
await manager.apply({ mic_default_on: true }, true);
|