@stream-io/video-client 0.6.3 → 0.6.5

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.
@@ -115,7 +115,7 @@ export declare class Call {
115
115
  /**
116
116
  * Leave the call and stop the media streams that were published by the call.
117
117
  */
118
- leave: ({ reject }?: CallLeaveOptions) => Promise<void>;
118
+ leave: ({ reject, reason, }?: CallLeaveOptions) => Promise<void>;
119
119
  /**
120
120
  * A flag indicating whether the call is "ringing" type of call.
121
121
  */
@@ -100,7 +100,7 @@ export declare class StreamSfuClient {
100
100
  * @param sessionId the `sessionId` of the currently connected participant.
101
101
  */
102
102
  constructor({ dispatcher, sfuServer, token, sessionId, }: StreamSfuClientConstructor);
103
- close: (code?: number, reason?: string) => void;
103
+ close: (code: number, reason: string) => void;
104
104
  updateSubscriptions: (subscriptions: TrackSubscriptionDetails[]) => Promise<FinishedUnaryCall<import("./gen/video/sfu/signal_rpc/signal").UpdateSubscriptionsRequest, import("./gen/video/sfu/signal_rpc/signal").UpdateSubscriptionsResponse>>;
105
105
  setPublisher: (data: Omit<SetPublisherRequest, 'sessionId'>) => Promise<FinishedUnaryCall<SetPublisherRequest, import("./gen/video/sfu/signal_rpc/signal").SetPublisherResponse>>;
106
106
  sendAnswer: (data: Omit<SendAnswerRequest, 'sessionId'>) => Promise<FinishedUnaryCall<SendAnswerRequest, import("./gen/video/sfu/signal_rpc/signal").SendAnswerResponse>>;
@@ -72,7 +72,7 @@ export declare class StreamVideoClient {
72
72
  * Creates a new call.
73
73
  *
74
74
  * @param type the type of the call.
75
- * @param id the id of the call, if not provided a unique random value is used
75
+ * @param id the id of the call.
76
76
  */
77
77
  call: (type: string, id: string) => Call;
78
78
  /**
@@ -58,7 +58,7 @@ export declare abstract class InputMediaDeviceManager<T extends InputMediaDevice
58
58
  /**
59
59
  * Selects a device.
60
60
  *
61
- * Note: this method is not supported in React Native
61
+ * Note: This method is not supported in React Native
62
62
  * @param deviceId the device id to select.
63
63
  */
64
64
  select(deviceId: string | undefined): Promise<void>;
@@ -9,6 +9,7 @@ export declare class SpeakerManager {
9
9
  * Lists the available audio output devices
10
10
  *
11
11
  * Note: It prompts the user for a permission to use devices (if not already granted)
12
+ * Note: This method is not supported in React Native
12
13
  *
13
14
  * @returns an Observable that will be updated if a device is connected or disconnected
14
15
  */
@@ -16,7 +17,7 @@ export declare class SpeakerManager {
16
17
  /**
17
18
  * Select a device.
18
19
  *
19
- * Note: this method is not supported in React Native
20
+ * Note: This method is not supported in React Native
20
21
  *
21
22
  * @param deviceId empty string means the system default
22
23
  */
@@ -26,13 +27,13 @@ export declare class SpeakerManager {
26
27
  * Set the volume of the audio elements
27
28
  * @param volume a number between 0 and 1.
28
29
  *
29
- * Note: this method is not supported in React Native
30
+ * Note: This method is not supported in React Native
30
31
  */
31
32
  setVolume(volume: number): void;
32
33
  /**
33
34
  * Set the volume of a participant.
34
35
  *
35
- * Note: this method is not supported in React Native.
36
+ * Note: This method is not supported in React Native.
36
37
  *
37
38
  * @param sessionId the participant's session id.
38
39
  * @param volume a number between 0 and 1. Set it to `undefined` to use the default volume.
@@ -133,6 +133,11 @@ export type CallLeaveOptions = {
133
133
  * @default `false`.
134
134
  */
135
135
  reject?: boolean;
136
+ /**
137
+ * The reason for leaving the call.
138
+ * This will be sent to the backend and will be visible in the logs.
139
+ */
140
+ reason?: string;
136
141
  };
137
142
  /**
138
143
  * The options to pass to {@link Call} constructor.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-client",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
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
@@ -372,7 +372,7 @@ export class Call {
372
372
  const currentUserId = this.currentUserId;
373
373
  if (currentUserId && blockedUserIds.includes(currentUserId)) {
374
374
  this.logger('info', 'Leaving call because of being blocked');
375
- await this.leave();
375
+ await this.leave({ reason: 'user blocked' });
376
376
  }
377
377
  }),
378
378
  );
@@ -459,7 +459,10 @@ export class Call {
459
459
  /**
460
460
  * Leave the call and stop the media streams that were published by the call.
461
461
  */
462
- leave = async ({ reject = false }: CallLeaveOptions = {}) => {
462
+ leave = async ({
463
+ reject = false,
464
+ reason = 'user is leaving the call',
465
+ }: CallLeaveOptions = {}) => {
463
466
  const callingState = this.state.callingState;
464
467
  if (callingState === CallingState.LEFT) {
465
468
  throw new Error('Cannot leave call that has already been left.');
@@ -494,7 +497,7 @@ export class Call {
494
497
  this.publisher?.close();
495
498
  this.publisher = undefined;
496
499
 
497
- this.sfuClient?.close();
500
+ this.sfuClient?.close(StreamSfuClient.NORMAL_CLOSURE, reason);
498
501
  this.sfuClient = undefined;
499
502
 
500
503
  this.dispatcher.offAll();
@@ -740,7 +743,8 @@ export class Call {
740
743
  * A closure which hides away the re-connection logic.
741
744
  */
742
745
  const reconnect = async (
743
- strategy: 'full' | 'fast' | 'migrate' = 'full',
746
+ strategy: 'full' | 'fast' | 'migrate',
747
+ reason: string,
744
748
  ): Promise<void> => {
745
749
  const currentState = this.state.callingState;
746
750
  if (
@@ -777,7 +781,7 @@ export class Call {
777
781
  if (strategy === 'fast') {
778
782
  sfuClient.close(
779
783
  StreamSfuClient.ERROR_CONNECTION_BROKEN,
780
- 'js-client: attempting fast reconnect',
784
+ `attempting fast reconnect: ${reason}`,
781
785
  );
782
786
  } else if (strategy === 'full') {
783
787
  // in migration or recovery scenarios, we don't want to
@@ -797,7 +801,7 @@ export class Call {
797
801
  // clean up current connection
798
802
  sfuClient.close(
799
803
  StreamSfuClient.NORMAL_CLOSURE,
800
- 'js-client: attempting full reconnect',
804
+ `attempting full reconnect: ${reason}`,
801
805
  );
802
806
  }
803
807
  await this.join({
@@ -807,10 +811,7 @@ export class Call {
807
811
 
808
812
  // clean up previous connection
809
813
  if (strategy === 'migrate') {
810
- sfuClient.close(
811
- StreamSfuClient.NORMAL_CLOSURE,
812
- 'js-client: attempting migration',
813
- );
814
+ sfuClient.close(StreamSfuClient.NORMAL_CLOSURE, 'attempting migration');
814
815
  }
815
816
 
816
817
  this.logger(
@@ -866,7 +867,7 @@ export class Call {
866
867
  'info',
867
868
  `[Migration]: Going away from SFU... Reason: ${GoAwayReason[reason]}`,
868
869
  );
869
- reconnect('migrate').catch((err) => {
870
+ reconnect('migrate', GoAwayReason[reason]).catch((err) => {
870
871
  this.logger(
871
872
  'warn',
872
873
  `[Migration]: Failed to migrate to another SFU.`,
@@ -901,14 +902,16 @@ export class Call {
901
902
  if (this.reconnectAttempts < this.maxReconnectAttempts) {
902
903
  sfuClient.isFastReconnecting = this.reconnectAttempts === 0;
903
904
  const strategy = sfuClient.isFastReconnecting ? 'fast' : 'full';
904
- reconnect(strategy).catch((err) => {
905
- this.logger(
906
- 'error',
907
- `[Rejoin]: ${strategy} rejoin failed for ${this.reconnectAttempts} times. Giving up.`,
908
- err,
909
- );
910
- this.state.setCallingState(CallingState.RECONNECTING_FAILED);
911
- });
905
+ reconnect(strategy, `SFU closed the WS with code: ${e.code}`).catch(
906
+ (err) => {
907
+ this.logger(
908
+ 'error',
909
+ `[Rejoin]: ${strategy} rejoin failed for ${this.reconnectAttempts} times. Giving up.`,
910
+ err,
911
+ );
912
+ this.state.setCallingState(CallingState.RECONNECTING_FAILED);
913
+ },
914
+ );
912
915
  } else {
913
916
  this.logger(
914
917
  'error',
@@ -936,7 +939,10 @@ export class Call {
936
939
  do {
937
940
  try {
938
941
  sfuClient.isFastReconnecting = isFirstReconnectAttempt;
939
- await reconnect(isFirstReconnectAttempt ? 'fast' : 'full');
942
+ await reconnect(
943
+ isFirstReconnectAttempt ? 'fast' : 'full',
944
+ 'Network: online',
945
+ );
940
946
  return; // break the loop if rejoin is successful
941
947
  } catch (err) {
942
948
  this.logger(
@@ -1057,7 +1063,7 @@ export class Call {
1057
1063
  await this.publisher.restartIce();
1058
1064
  } else if (previousSfuClient?.isFastReconnecting) {
1059
1065
  // reconnection wasn't possible, so we need to do a full rejoin
1060
- return await reconnect('full').catch((err) => {
1066
+ return await reconnect('full', 're-attempting').catch((err) => {
1061
1067
  this.logger(
1062
1068
  'error',
1063
1069
  `[Rejoin]: Rejoin failed forced full rejoin.`,
@@ -1125,7 +1131,7 @@ export class Call {
1125
1131
  `[Rejoin]: Rejoin ${this.reconnectAttempts} failed.`,
1126
1132
  err,
1127
1133
  );
1128
- await reconnect();
1134
+ await reconnect('full', 'previous attempt failed');
1129
1135
  this.logger(
1130
1136
  'info',
1131
1137
  `[Rejoin]: Rejoin ${this.reconnectAttempts} successful!`,
@@ -1825,7 +1831,7 @@ export class Call {
1825
1831
 
1826
1832
  clearTimeout(this.dropTimeout);
1827
1833
  this.dropTimeout = setTimeout(() => {
1828
- this.leave().catch((err) => {
1834
+ this.leave({ reason: 'ring: timeout' }).catch((err) => {
1829
1835
  this.logger('error', 'Failed to drop call', err);
1830
1836
  });
1831
1837
  }, timeoutInMs);
@@ -209,13 +209,10 @@ export class StreamSfuClient {
209
209
  });
210
210
  }
211
211
 
212
- close = (
213
- code: number = StreamSfuClient.NORMAL_CLOSURE,
214
- reason: string = 'js-client: requested signal connection close',
215
- ) => {
216
- this.logger('debug', 'Closing SFU WS connection', code, reason);
212
+ close = (code: number, reason: string) => {
213
+ this.logger('debug', `Closing SFU WS connection: ${code} - ${reason}`);
217
214
  if (this.signalWs.readyState !== this.signalWs.CLOSED) {
218
- this.signalWs.close(code, reason);
215
+ this.signalWs.close(code, `js-client: ${reason}`);
219
216
  }
220
217
 
221
218
  this.unsubscribeIceTrickle();
@@ -232,7 +232,7 @@ export class StreamVideoClient {
232
232
  // if `call.created` was received before `call.ring`.
233
233
  // In that case, we cleanup the already tracked call.
234
234
  const prevCall = this.writeableStateStore.findCall(call.type, call.id);
235
- await prevCall?.leave();
235
+ await prevCall?.leave({ reason: 'cleaning-up in call.ring' });
236
236
  // we create a new call
237
237
  const theCall = new Call({
238
238
  streamClient: this.streamClient,
@@ -310,7 +310,7 @@ export class StreamVideoClient {
310
310
  * Creates a new call.
311
311
  *
312
312
  * @param type the type of the call.
313
- * @param id the id of the call, if not provided a unique random value is used
313
+ * @param id the id of the call.
314
314
  */
315
315
  call = (type: string, id: string) => {
316
316
  return new Call({
@@ -125,12 +125,14 @@ export abstract class InputMediaDeviceManager<
125
125
  /**
126
126
  * Selects a device.
127
127
  *
128
- * Note: this method is not supported in React Native
128
+ * Note: This method is not supported in React Native
129
129
  * @param deviceId the device id to select.
130
130
  */
131
131
  async select(deviceId: string | undefined) {
132
132
  if (isReactNative()) {
133
- throw new Error('This method is not supported in React Native');
133
+ throw new Error(
134
+ 'This method is not supported in React Native. Please visit https://getstream.io/video/docs/reactnative/core/camera-and-microphone/#speaker-management for reference.',
135
+ );
134
136
  }
135
137
  if (deviceId === this.state.selectedDevice) {
136
138
  return;
@@ -7,7 +7,6 @@ import {
7
7
  import { isReactNative } from '../helpers/platforms';
8
8
  import { RxUtils } from '../store';
9
9
  import { getLogger } from '../logger';
10
- import { isSafari } from '../helpers/browsers';
11
10
 
12
11
  export type InputDeviceStatus = 'enabled' | 'disabled' | undefined;
13
12
 
@@ -67,15 +66,15 @@ export abstract class InputMediaDeviceManagerState<C = MediaTrackConstraints> {
67
66
  }
68
67
 
69
68
  let permissionState: PermissionStatus;
70
- const notify = () =>
69
+ const notify = () => {
71
70
  subscriber.next(
72
- // In Safari, the `change` event doesn't reliably emit and hence,
71
+ // In some browsers, the 'change' event doesn't reliably emit and hence,
73
72
  // permissionState stays in 'prompt' state forever.
73
+ // Typically, this happens when a user grants one-time permission.
74
74
  // Instead of checking if a permission is granted, we check if it isn't denied
75
- isSafari()
76
- ? permissionState.state !== 'denied'
77
- : permissionState.state === 'granted',
75
+ permissionState.state !== 'denied',
78
76
  );
77
+ };
79
78
  navigator.permissions
80
79
  .query({ name: this.permissionName })
81
80
  .then((permissionStatus) => {
@@ -34,23 +34,31 @@ export class SpeakerManager {
34
34
  * Lists the available audio output devices
35
35
  *
36
36
  * Note: It prompts the user for a permission to use devices (if not already granted)
37
+ * Note: This method is not supported in React Native
37
38
  *
38
39
  * @returns an Observable that will be updated if a device is connected or disconnected
39
40
  */
40
41
  listDevices() {
42
+ if (isReactNative()) {
43
+ throw new Error(
44
+ 'This feature is not supported in React Native. Please visit https://getstream.io/video/docs/reactnative/core/camera-and-microphone/#speaker-management for more details',
45
+ );
46
+ }
41
47
  return getAudioOutputDevices();
42
48
  }
43
49
 
44
50
  /**
45
51
  * Select a device.
46
52
  *
47
- * Note: this method is not supported in React Native
53
+ * Note: This method is not supported in React Native
48
54
  *
49
55
  * @param deviceId empty string means the system default
50
56
  */
51
57
  select(deviceId: string) {
52
58
  if (isReactNative()) {
53
- throw new Error('This feature is not supported in React Native');
59
+ throw new Error(
60
+ 'This feature is not supported in React Native. Please visit https://getstream.io/video/docs/reactnative/core/camera-and-microphone/#speaker-management for more details',
61
+ );
54
62
  }
55
63
  this.state.setDevice(deviceId);
56
64
  }
@@ -63,11 +71,13 @@ export class SpeakerManager {
63
71
  * Set the volume of the audio elements
64
72
  * @param volume a number between 0 and 1.
65
73
  *
66
- * Note: this method is not supported in React Native
74
+ * Note: This method is not supported in React Native
67
75
  */
68
76
  setVolume(volume: number) {
69
77
  if (isReactNative()) {
70
- throw new Error('This feature is not supported in React Native');
78
+ throw new Error(
79
+ 'This feature is not supported in React Native. Please visit https://getstream.io/video/docs/reactnative/core/camera-and-microphone/#speaker-management for more details',
80
+ );
71
81
  }
72
82
  if (volume && (volume < 0 || volume > 1)) {
73
83
  throw new Error('Volume must be between 0 and 1');
@@ -78,14 +88,16 @@ export class SpeakerManager {
78
88
  /**
79
89
  * Set the volume of a participant.
80
90
  *
81
- * Note: this method is not supported in React Native.
91
+ * Note: This method is not supported in React Native.
82
92
  *
83
93
  * @param sessionId the participant's session id.
84
94
  * @param volume a number between 0 and 1. Set it to `undefined` to use the default volume.
85
95
  */
86
96
  setParticipantVolume(sessionId: string, volume: number | undefined) {
87
97
  if (isReactNative()) {
88
- throw new Error('This feature is not supported in React Native');
98
+ throw new Error(
99
+ 'This feature is not supported in React Native. Please visit https://getstream.io/video/docs/reactnative/core/camera-and-microphone/#speaker-management for more details',
100
+ );
89
101
  }
90
102
  if (volume && (volume < 0 || volume > 1)) {
91
103
  throw new Error('Volume must be between 0 and 1, or undefined');
@@ -54,7 +54,7 @@ describe('InputMediaDeviceManagerState', () => {
54
54
  expect(permissionStatus.addEventListener).toHaveBeenCalled();
55
55
  });
56
56
 
57
- it('should emit false when prompt is needed', async () => {
57
+ it('should emit true when prompt is needed', async () => {
58
58
  const permissionStatus: Partial<PermissionStatus> = {
59
59
  state: 'prompt',
60
60
  addEventListener: vi.fn(),
@@ -64,30 +64,6 @@ describe('InputMediaDeviceManagerState', () => {
64
64
  // @ts-ignore - navigator is readonly, but we need to mock it
65
65
  globalThis.navigator.permissions = { query };
66
66
 
67
- const hasPermission = await new Promise((resolve) => {
68
- state.hasBrowserPermission$.subscribe((v) => resolve(v));
69
- });
70
- expect(hasPermission).toBe(false);
71
- expect(query).toHaveBeenCalledWith({ name: 'camera' });
72
- expect(permissionStatus.addEventListener).toHaveBeenCalled();
73
- });
74
-
75
- it('should emit true when prompt is needed in Safari', async () => {
76
- const permissionStatus: Partial<PermissionStatus> = {
77
- state: 'prompt',
78
- addEventListener: vi.fn(),
79
- };
80
- const query = vi.fn(() => Promise.resolve(permissionStatus));
81
- globalThis.navigator ??= { userAgent: 'safari' } as Navigator;
82
- // @ts-ignore - navigator is readonly, but we need to mock it
83
- globalThis.navigator.permissions = { query };
84
-
85
- Object.defineProperty(globalThis.navigator, 'userAgent', {
86
- get() {
87
- return 'safari';
88
- },
89
- });
90
-
91
67
  const hasPermission = await new Promise((resolve) => {
92
68
  state.hasBrowserPermission$.subscribe((v) => resolve(v));
93
69
  });
@@ -56,12 +56,12 @@ export const watchCallRejected = (call: Call) => {
56
56
  .every((m) => rejectedBy[m.user_id]);
57
57
  if (everyoneElseRejected) {
58
58
  call.logger('info', 'everyone rejected, leaving the call');
59
- await call.leave();
59
+ await call.leave({ reason: 'ring: everyone rejected' });
60
60
  }
61
61
  } else {
62
62
  if (rejectedBy[eventCall.created_by.id]) {
63
63
  call.logger('info', 'call creator rejected, leaving call');
64
- await call.leave();
64
+ await call.leave({ reason: 'ring: creator rejected' });
65
65
  }
66
66
  }
67
67
  };
@@ -78,7 +78,7 @@ export const watchCallEnded = (call: Call) => {
78
78
  callingState === CallingState.JOINED ||
79
79
  callingState === CallingState.JOINING
80
80
  ) {
81
- await call.leave();
81
+ await call.leave({ reason: 'call.ended event received' });
82
82
  }
83
83
  };
84
84
  };
@@ -69,7 +69,7 @@ export const watchLiveEnded = (dispatcher: Dispatcher, call: Call) => {
69
69
  if (e.error && e.error.code !== ErrorCode.LIVE_ENDED) return;
70
70
 
71
71
  if (!call.permissionsContext.hasPermission(OwnCapability.JOIN_BACKSTAGE)) {
72
- call.leave().catch((err) => {
72
+ call.leave({ reason: 'live ended' }).catch((err) => {
73
73
  logger('error', 'Failed to leave call after live ended', err);
74
74
  });
75
75
  }
@@ -97,6 +97,15 @@ export enum CallingState {
97
97
  OFFLINE = 'offline',
98
98
  }
99
99
 
100
+ /**
101
+ * Returns the default egress object - when no egress data is available.
102
+ */
103
+ const defaultEgress: EgressResponse = {
104
+ broadcasting: false,
105
+ hls: { playlist_url: '' },
106
+ rtmps: [],
107
+ };
108
+
100
109
  /**
101
110
  * Holds the state of the current call.
102
111
  * @react You don't have to use this class directly, as we are exposing the state through Hooks.
@@ -978,15 +987,15 @@ export class CallState {
978
987
  };
979
988
 
980
989
  private updateFromHLSBroadcastStopped = () => {
981
- this.setCurrentValue(this.egressSubject, (egress) => ({
982
- ...egress!,
990
+ this.setCurrentValue(this.egressSubject, (egress = defaultEgress) => ({
991
+ ...egress,
983
992
  broadcasting: false,
984
993
  }));
985
994
  };
986
995
 
987
996
  private updateFromHLSBroadcastingFailed = () => {
988
- this.setCurrentValue(this.egressSubject, (egress) => ({
989
- ...egress!,
997
+ this.setCurrentValue(this.egressSubject, (egress = defaultEgress) => ({
998
+ ...egress,
990
999
  broadcasting: false,
991
1000
  }));
992
1001
  };
@@ -994,11 +1003,11 @@ export class CallState {
994
1003
  private updateFromHLSBroadcastStarted = (
995
1004
  event: CallHLSBroadcastingStartedEvent,
996
1005
  ) => {
997
- this.setCurrentValue(this.egressSubject, (egress) => ({
998
- ...egress!,
1006
+ this.setCurrentValue(this.egressSubject, (egress = defaultEgress) => ({
1007
+ ...egress,
999
1008
  broadcasting: true,
1000
1009
  hls: {
1001
- ...egress!.hls,
1010
+ ...egress.hls,
1002
1011
  playlist_url: event.hls_playlist_url,
1003
1012
  },
1004
1013
  }));
@@ -28,9 +28,11 @@ export class StreamVideoWriteableStateStore {
28
28
  if (call.state.callingState === CallingState.LEFT) continue;
29
29
 
30
30
  logger('info', `User disconnected, leaving call: ${call.cid}`);
31
- await call.leave().catch((err) => {
32
- logger('error', `Error leaving call: ${call.cid}`, err);
33
- });
31
+ await call
32
+ .leave({ reason: 'client.disconnectUser() called' })
33
+ .catch((err) => {
34
+ logger('error', `Error leaving call: ${call.cid}`, err);
35
+ });
34
36
  }
35
37
  }
36
38
  });
package/src/types.ts CHANGED
@@ -173,6 +173,12 @@ export type CallLeaveOptions = {
173
173
  * @default `false`.
174
174
  */
175
175
  reject?: boolean;
176
+
177
+ /**
178
+ * The reason for leaving the call.
179
+ * This will be sent to the backend and will be visible in the logs.
180
+ */
181
+ reason?: string;
176
182
  };
177
183
 
178
184
  /**