@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-client",
3
- "version": "1.40.0",
3
+ "version": "1.40.2",
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
@@ -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 (err instanceof ErrorFromResponse && err.unrecoverable) {
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...)
@@ -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 { ICETrickle } from './gen/video/sfu/models/models';
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 { OwnCapability, VideoSettingsResponse } from '../gen/coordinator';
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
- this.targetResolution.height = resolution.height;
83
- this.targetResolution.width = resolution.width;
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
- const { target_resolution, camera_facing, camera_default_on, enabled } =
125
- settings;
126
- // normalize target resolution to landscape format.
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
- // Publish media stream that was set before we joined
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.hasPermission = vi.fn().mockReturnValue(false);
300
+ call.permissionsContext.canPublish = vi.fn().mockReturnValue(false);
300
301
  vi.spyOn(manager, 'enable');
301
302
  await manager.apply(
302
- // @ts-expect-error - partial settings
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(undefined);
311
+ expect(manager.state.direction).toBe('front');
312
312
  expect(manager.state.status).toBe(undefined);
313
- expect(manager['targetResolution']).toEqual({ width: 1280, height: 720 });
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.hasPermission = vi.fn().mockReturnValue(true);
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.hasPermission = vi.fn().mockReturnValue(false);
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);