@stream-io/video-client 1.21.0 → 1.22.0

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/index.browser.es.js +142 -133
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +142 -133
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.d.ts +0 -1
  7. package/dist/index.es.js +142 -133
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/src/Call.d.ts +2 -0
  10. package/dist/src/devices/InputMediaDeviceManager.d.ts +3 -3
  11. package/dist/src/devices/SpeakerState.d.ts +3 -1
  12. package/dist/src/devices/devices.d.ts +8 -8
  13. package/dist/src/events/call.d.ts +1 -1
  14. package/dist/src/rtc/BasePeerConnection.d.ts +1 -0
  15. package/dist/src/stats/SfuStatsReporter.d.ts +4 -1
  16. package/dist/src/stats/utils.d.ts +14 -0
  17. package/index.ts +0 -4
  18. package/package.json +10 -10
  19. package/src/Call.ts +5 -2
  20. package/src/devices/CameraManager.ts +27 -23
  21. package/src/devices/InputMediaDeviceManager.ts +8 -5
  22. package/src/devices/MicrophoneManager.ts +1 -1
  23. package/src/devices/ScreenShareManager.ts +2 -2
  24. package/src/devices/SpeakerManager.ts +2 -1
  25. package/src/devices/SpeakerState.ts +6 -3
  26. package/src/devices/__tests__/CameraManager.test.ts +43 -27
  27. package/src/devices/__tests__/MicrophoneManager.test.ts +5 -3
  28. package/src/devices/__tests__/ScreenShareManager.test.ts +5 -1
  29. package/src/devices/__tests__/mocks.ts +2 -3
  30. package/src/devices/devices.ts +38 -16
  31. package/src/events/__tests__/call.test.ts +23 -0
  32. package/src/events/call.ts +12 -1
  33. package/src/rtc/BasePeerConnection.ts +8 -3
  34. package/src/rtc/Publisher.ts +1 -1
  35. package/src/stats/SfuStatsReporter.ts +9 -5
  36. package/src/stats/utils.ts +15 -0
  37. package/dist/src/stats/rtc/mediaDevices.d.ts +0 -2
  38. package/src/stats/rtc/mediaDevices.ts +0 -43
@@ -12,6 +12,7 @@ import { getLogger } from '../logger';
12
12
  import { BrowserPermission } from './BrowserPermission';
13
13
  import { lazy } from '../helpers/lazy';
14
14
  import { isFirefox } from '../helpers/browsers';
15
+ import { dumpStream, Tracer } from '../stats';
15
16
 
16
17
  /**
17
18
  * Returns an Observable that emits the list of available devices
@@ -158,15 +159,27 @@ export const getAudioOutputDevices = lazy(() => {
158
159
  );
159
160
  });
160
161
 
161
- const getStream = async (constraints: MediaStreamConstraints) => {
162
- const stream = await navigator.mediaDevices.getUserMedia(constraints);
163
- if (isFirefox()) {
164
- // When enumerating devices, Firefox will hide device labels unless there's been
165
- // an active user media stream on the page. So we force device list updates after
166
- // every successful getUserMedia call.
167
- navigator.mediaDevices.dispatchEvent(new Event('devicechange'));
162
+ let getUserMediaExecId = 0;
163
+ const getStream = async (
164
+ constraints: MediaStreamConstraints,
165
+ tracer: Tracer | undefined,
166
+ ) => {
167
+ const tag = `navigator.mediaDevices.getUserMedia.${getUserMediaExecId++}.`;
168
+ try {
169
+ tracer?.trace(tag, constraints);
170
+ const stream = await navigator.mediaDevices.getUserMedia(constraints);
171
+ tracer?.trace(`${tag}OnSuccess`, dumpStream(stream));
172
+ if (isFirefox()) {
173
+ // When enumerating devices, Firefox will hide device labels unless there's been
174
+ // an active user media stream on the page. So we force device list updates after
175
+ // every successful getUserMedia call.
176
+ navigator.mediaDevices.dispatchEvent(new Event('devicechange'));
177
+ }
178
+ return stream;
179
+ } catch (error) {
180
+ tracer?.trace(`${tag}OnFailure`, (error as Error).name);
181
+ throw error;
168
182
  }
169
- return stream;
170
183
  };
171
184
 
172
185
  function isNotFoundOrOverconstrainedError(error: unknown) {
@@ -195,12 +208,13 @@ function isNotFoundOrOverconstrainedError(error: unknown) {
195
208
  * Returns an audio media stream that fulfills the given constraints.
196
209
  * If no constraints are provided, it uses the browser's default ones.
197
210
  *
198
- * @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.
199
211
  * @param trackConstraints the constraints to use when requesting the stream.
200
- * @returns the new `MediaStream` fulfilling the given constraints.
212
+ * @param tracer the tracer to use for tracing the stream creation.
213
+ * @returns a new `MediaStream` fulfilling the given constraints.
201
214
  */
202
215
  export const getAudioStream = async (
203
216
  trackConstraints?: MediaTrackConstraints,
217
+ tracer?: Tracer,
204
218
  ): Promise<MediaStream> => {
205
219
  const constraints: MediaStreamConstraints = {
206
220
  audio: {
@@ -214,7 +228,7 @@ export const getAudioStream = async (
214
228
  throwOnNotAllowed: true,
215
229
  forcePrompt: true,
216
230
  });
217
- return await getStream(constraints);
231
+ return await getStream(constraints, tracer);
218
232
  } catch (error) {
219
233
  if (isNotFoundOrOverconstrainedError(error) && trackConstraints?.deviceId) {
220
234
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -239,12 +253,13 @@ export const getAudioStream = async (
239
253
  * Returns a video media stream that fulfills the given constraints.
240
254
  * If no constraints are provided, it uses the browser's default ones.
241
255
  *
242
- * @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.
243
256
  * @param trackConstraints the constraints to use when requesting the stream.
257
+ * @param tracer the tracer to use for tracing the stream creation.
244
258
  * @returns a new `MediaStream` fulfilling the given constraints.
245
259
  */
246
260
  export const getVideoStream = async (
247
261
  trackConstraints?: MediaTrackConstraints,
262
+ tracer?: Tracer,
248
263
  ): Promise<MediaStream> => {
249
264
  const constraints: MediaStreamConstraints = {
250
265
  video: {
@@ -257,7 +272,7 @@ export const getVideoStream = async (
257
272
  throwOnNotAllowed: true,
258
273
  forcePrompt: true,
259
274
  });
260
- return await getStream(constraints);
275
+ return await getStream(constraints, tracer);
261
276
  } catch (error) {
262
277
  if (isNotFoundOrOverconstrainedError(error) && trackConstraints?.deviceId) {
263
278
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -278,21 +293,25 @@ export const getVideoStream = async (
278
293
  }
279
294
  };
280
295
 
296
+ let getDisplayMediaExecId = 0;
297
+
281
298
  /**
282
299
  * Prompts the user for a permission to share a screen.
283
300
  * If the user grants the permission, a screen sharing stream is returned. Throws otherwise.
284
301
  *
285
302
  * The callers of this API are responsible to handle the possible errors.
286
303
  *
287
- * @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.
288
- *
289
304
  * @param options any additional options to pass to the [`getDisplayMedia`](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getDisplayMedia) API.
305
+ * @param tracer the tracer to use for tracing the stream creation.
290
306
  */
291
307
  export const getScreenShareStream = async (
292
308
  options?: DisplayMediaStreamOptions,
309
+ tracer?: Tracer | undefined,
293
310
  ) => {
311
+ const tag = `navigator.mediaDevices.getDisplayMedia.${getDisplayMediaExecId++}.`;
294
312
  try {
295
- return await navigator.mediaDevices.getDisplayMedia({
313
+ tracer?.trace(tag, options);
314
+ const stream = await navigator.mediaDevices.getDisplayMedia({
296
315
  video: true,
297
316
  audio: {
298
317
  channelCount: {
@@ -306,7 +325,10 @@ export const getScreenShareStream = async (
306
325
  systemAudio: 'include',
307
326
  ...options,
308
327
  });
328
+ tracer?.trace(`${tag}OnSuccess`, dumpStream(stream));
329
+ return stream;
309
330
  } catch (e) {
331
+ tracer?.trace(`${tag}OnFailure`, (e as Error).name);
310
332
  getLogger(['devices'])('error', 'Failed to get screen share stream', e);
311
333
  throw e;
312
334
  }
@@ -10,6 +10,7 @@ import {
10
10
  CallAcceptedEvent,
11
11
  CallEndedEvent,
12
12
  CallResponse,
13
+ OwnCapability,
13
14
  RejectCallResponse,
14
15
  } from '../../gen/coordinator';
15
16
  import { Call } from '../../Call';
@@ -295,6 +296,28 @@ describe('Call ringing events', () => {
295
296
 
296
297
  expect(call.leave).not.toHaveBeenCalled();
297
298
  });
299
+
300
+ it('will stay in backstage if live ended and has permission', async () => {
301
+ const call = fakeCall();
302
+ call.state.setBackstage(false);
303
+ call.permissionsContext.setPermissions([OwnCapability.JOIN_BACKSTAGE]);
304
+ vi.spyOn(call, 'leave').mockImplementation(async () => {
305
+ console.log(`TEST: leave() called`);
306
+ });
307
+
308
+ watchSfuCallEnded(call);
309
+ const event: SfuEvent = {
310
+ eventPayload: {
311
+ oneofKind: 'callEnded',
312
+ callEnded: { reason: CallEndedReason.LIVE_ENDED },
313
+ },
314
+ };
315
+ // @ts-expect-error type issue
316
+ call['dispatcher'].dispatch(event);
317
+
318
+ expect(call.leave).not.toHaveBeenCalled();
319
+ expect(call.state.backstage).toBe(true);
320
+ });
298
321
  });
299
322
 
300
323
  describe('call.leave', () => {
@@ -1,6 +1,10 @@
1
1
  import { CallingState } from '../store';
2
2
  import { Call } from '../Call';
3
- import type { CallAcceptedEvent, CallRejectedEvent } from '../gen/coordinator';
3
+ import {
4
+ CallAcceptedEvent,
5
+ CallRejectedEvent,
6
+ OwnCapability,
7
+ } from '../gen/coordinator';
4
8
  import { CallEnded } from '../gen/video/sfu/event/events';
5
9
  import { CallEndedReason } from '../gen/video/sfu/models/models';
6
10
 
@@ -99,6 +103,13 @@ export const watchSfuCallEnded = (call: Call) => {
99
103
  return call.on('callEnded', async (e: CallEnded) => {
100
104
  if (call.state.callingState === CallingState.LEFT) return;
101
105
  try {
106
+ if (e.reason === CallEndedReason.LIVE_ENDED) {
107
+ call.state.setBackstage(true);
108
+
109
+ // don't leave the call if the user has permission to join backstage
110
+ const { hasPermission } = call.permissionsContext;
111
+ if (hasPermission(OwnCapability.JOIN_BACKSTAGE)) return;
112
+ }
102
113
  // `call.ended` event arrived after the call is already left
103
114
  // and all event handlers are detached. We need to manually
104
115
  // update the call state to reflect the call has ended.
@@ -43,6 +43,7 @@ export abstract class BasePeerConnection {
43
43
  readonly stats: StatsTracer;
44
44
  private readonly subscriptions: (() => void)[] = [];
45
45
  private unsubscribeIceTrickle?: () => void;
46
+ protected readonly lock = Math.random().toString(36).slice(2);
46
47
 
47
48
  /**
48
49
  * Constructs a new `BasePeerConnection` instance.
@@ -83,9 +84,12 @@ export abstract class BasePeerConnection {
83
84
  );
84
85
  this.stats = new StatsTracer(this.pc, peerType, this.trackIdToTrackType);
85
86
  if (enableTracing) {
86
- const tag = `${logTag}-${peerType === PeerType.SUBSCRIBER ? 'sub' : 'pub'}-${sfuClient.edgeName}`;
87
+ const tag = `${logTag}-${peerType === PeerType.SUBSCRIBER ? 'sub' : 'pub'}`;
87
88
  this.tracer = new Tracer(tag);
88
- this.tracer.trace('create', connectionConfig);
89
+ this.tracer.trace('create', {
90
+ url: sfuClient.edgeName,
91
+ ...connectionConfig,
92
+ });
89
93
  traceRTCPeerConnection(this.pc, this.tracer.trace);
90
94
  }
91
95
  }
@@ -135,7 +139,8 @@ export abstract class BasePeerConnection {
135
139
  ): void => {
136
140
  this.subscriptions.push(
137
141
  this.dispatcher.on(event, (e) => {
138
- withoutConcurrency(`pc.${event}`, async () => fn(e)).catch((err) => {
142
+ const lockKey = `pc.${this.lock}.${event}`;
143
+ withoutConcurrency(lockKey, async () => fn(e)).catch((err) => {
139
144
  if (this.isDisposed) return;
140
145
  this.logger('warn', `Error handling ${event}`, err);
141
146
  });
@@ -323,7 +323,7 @@ export class Publisher extends BasePeerConnection {
323
323
  * @param options the optional offer options to use.
324
324
  */
325
325
  private negotiate = async (options?: RTCOfferOptions): Promise<void> => {
326
- return withoutConcurrency('publisher.negotiate', async () => {
326
+ return withoutConcurrency(`publisher.negotiate.${this.lock}`, async () => {
327
327
  const offer = await this.pc.createOffer(options);
328
328
  const tracks = this.getAnnouncedTracks(offer.sdp);
329
329
  if (!tracks.length) throw new Error(`Can't negotiate without any tracks`);
@@ -3,7 +3,7 @@ import { StreamSfuClient } from '../StreamSfuClient';
3
3
  import { OwnCapability, StatsOptions } from '../gen/coordinator';
4
4
  import { getLogger } from '../logger';
5
5
  import { Publisher, Subscriber } from '../rtc';
6
- import { tracer as mediaStatsTracer } from './rtc/mediaDevices';
6
+ import { Tracer, TraceRecord } from './rtc';
7
7
  import { flatten, getSdkName, getSdkVersion } from './utils';
8
8
  import { getDeviceState, getWebRTCInfo } from '../helpers/client-details';
9
9
  import {
@@ -24,6 +24,7 @@ export type SfuStatsReporterOptions = {
24
24
  microphone: MicrophoneManager;
25
25
  camera: CameraManager;
26
26
  state: CallState;
27
+ tracer: Tracer;
27
28
  unifiedSessionId: string;
28
29
  };
29
30
 
@@ -38,6 +39,7 @@ export class SfuStatsReporter {
38
39
  private readonly microphone: MicrophoneManager;
39
40
  private readonly camera: CameraManager;
40
41
  private readonly state: CallState;
42
+ private readonly tracer: Tracer;
41
43
  private readonly unifiedSessionId: string;
42
44
 
43
45
  private intervalId: NodeJS.Timeout | undefined;
@@ -59,6 +61,7 @@ export class SfuStatsReporter {
59
61
  microphone,
60
62
  camera,
61
63
  state,
64
+ tracer,
62
65
  unifiedSessionId,
63
66
  }: SfuStatsReporterOptions,
64
67
  ) {
@@ -69,6 +72,7 @@ export class SfuStatsReporter {
69
72
  this.microphone = microphone;
70
73
  this.camera = camera;
71
74
  this.state = state;
75
+ this.tracer = tracer;
72
76
  this.unifiedSessionId = unifiedSessionId;
73
77
 
74
78
  const { sdk, browser } = clientDetails;
@@ -166,10 +170,10 @@ export class SfuStatsReporter {
166
170
 
167
171
  const subscriberTrace = this.subscriber.tracer?.take();
168
172
  const publisherTrace = this.publisher?.tracer?.take();
169
- const mediaTrace = mediaStatsTracer.take();
173
+ const tracer = this.tracer.take();
170
174
  const sfuTrace = this.sfuClient.getTrace();
171
- const traces = [
172
- ...mediaTrace.snapshot,
175
+ const traces: TraceRecord[] = [
176
+ ...tracer.snapshot,
173
177
  ...(sfuTrace?.snapshot ?? []),
174
178
  ...(publisherTrace?.snapshot ?? []),
175
179
  ...(subscriberTrace?.snapshot ?? []),
@@ -198,7 +202,7 @@ export class SfuStatsReporter {
198
202
  } catch (err) {
199
203
  publisherTrace?.rollback();
200
204
  subscriberTrace?.rollback();
201
- mediaTrace.rollback();
205
+ tracer.rollback();
202
206
  sfuTrace?.rollback();
203
207
  throw err;
204
208
  }
@@ -13,6 +13,21 @@ export const flatten = (report: RTCStatsReport) => {
13
13
  return stats;
14
14
  };
15
15
 
16
+ /**
17
+ * Dump the provided MediaStream into a JSON object.
18
+ */
19
+ export const dumpStream = (stream: MediaStream) => ({
20
+ id: stream.id,
21
+ tracks: stream.getTracks().map((track) => ({
22
+ id: track.id,
23
+ kind: track.kind,
24
+ label: track.label,
25
+ enabled: track.enabled,
26
+ muted: track.muted,
27
+ readyState: track.readyState,
28
+ })),
29
+ });
30
+
16
31
  export const getSdkSignature = (clientDetails: ClientDetails) => {
17
32
  const { sdk, ...platform } = clientDetails;
18
33
  const sdkName = getSdkName(sdk);
@@ -1,2 +0,0 @@
1
- import { Tracer } from './Tracer';
2
- export declare const tracer: Tracer;
@@ -1,43 +0,0 @@
1
- import { Tracer } from './Tracer';
2
-
3
- export const tracer = new Tracer(null);
4
-
5
- if (
6
- typeof navigator !== 'undefined' &&
7
- typeof navigator.mediaDevices !== 'undefined'
8
- ) {
9
- const dumpStream = (stream: MediaStream) => ({
10
- id: stream.id,
11
- tracks: stream.getTracks().map((track) => ({
12
- id: track.id,
13
- kind: track.kind,
14
- label: track.label,
15
- enabled: track.enabled,
16
- muted: track.muted,
17
- readyState: track.readyState,
18
- })),
19
- });
20
-
21
- const trace = tracer.trace;
22
- const target = navigator.mediaDevices;
23
- for (const method of ['getUserMedia', 'getDisplayMedia'] as const) {
24
- const original = target[method];
25
- if (!original) continue;
26
-
27
- let mark = 0;
28
- target[method] = async function tracedMethod(
29
- constraints: MediaStreamConstraints,
30
- ) {
31
- const tag = `navigator.mediaDevices.${method}.${mark++}`;
32
- trace(tag, constraints);
33
- try {
34
- const stream = await original.call(target, constraints);
35
- trace(`${tag}.OnSuccess`, dumpStream(stream));
36
- return stream;
37
- } catch (err) {
38
- trace(`${tag}.OnFailure`, (err as Error).name);
39
- throw err;
40
- }
41
- };
42
- }
43
- }