@stream-io/video-client 1.61.1 → 1.61.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/index.browser.es.js +281 -152
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +281 -152
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +281 -152
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/coordinator/connection/types.d.ts +1 -1
  9. package/dist/src/devices/DeviceManager.d.ts +6 -0
  10. package/dist/src/gen/video/sfu/signal_rpc/signal.d.ts +0 -27
  11. package/dist/src/helpers/AudioBindingsWatchdog.d.ts +7 -2
  12. package/dist/src/helpers/DynascaleManager.d.ts +15 -0
  13. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +18 -0
  14. package/dist/src/helpers/mediaInterruptions.d.ts +14 -0
  15. package/dist/src/reporting/ClientEventReporter.d.ts +1 -1
  16. package/dist/src/ringing/reconcileRingState.d.ts +8 -7
  17. package/dist/src/stats/SfuStatsReporter.d.ts +1 -13
  18. package/dist/src/stats/rtc/StatsTracer.d.ts +6 -6
  19. package/dist/src/stats/rtc/types.d.ts +0 -11
  20. package/package.json +1 -1
  21. package/src/Call.ts +0 -3
  22. package/src/StreamVideoClient.ts +8 -0
  23. package/src/__tests__/StreamVideoClient.rewatch.test.ts +136 -0
  24. package/src/coordinator/connection/types.ts +1 -1
  25. package/src/devices/DeviceManager.ts +20 -1
  26. package/src/devices/__tests__/CameraManager.test.ts +50 -1
  27. package/src/devices/__tests__/mediaStreamTestHelpers.ts +10 -6
  28. package/src/gen/video/sfu/signal_rpc/signal.ts +0 -53
  29. package/src/helpers/AudioBindingsWatchdog.ts +91 -35
  30. package/src/helpers/DynascaleManager.ts +84 -9
  31. package/src/helpers/MediaPlaybackWatchdog.ts +55 -6
  32. package/src/helpers/__tests__/DynascaleManager.test.ts +247 -9
  33. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +140 -0
  34. package/src/helpers/__tests__/mediaInterruptions.test.ts +131 -0
  35. package/src/helpers/mediaInterruptions.ts +37 -0
  36. package/src/reporting/ClientEventReporter.ts +1 -1
  37. package/src/ringing/RingStatePoller.ts +1 -1
  38. package/src/ringing/__tests__/RingStatePoller.test.ts +1 -1
  39. package/src/ringing/__tests__/reconcileRingState.test.ts +33 -0
  40. package/src/ringing/reconcileRingState.ts +18 -10
  41. package/src/stats/SfuStatsReporter.ts +19 -93
  42. package/src/stats/__tests__/SfuStatsReporter.test.ts +2 -18
  43. package/src/stats/rtc/StatsTracer.ts +6 -6
  44. package/src/stats/rtc/types.ts +0 -13
@@ -6,15 +6,16 @@ import type { CallLeaveOptions } from '../types';
6
6
  /**
7
7
  * Decides what a ringing call should do next, based on the current call state.
8
8
  *
9
- * The `call.accepted`, `call.rejected` and `call.missed` handlers and the ring
10
- * state poller both run this. They differ only in how the state got there: the
11
- * handlers rely on `CallState.updateFromEvent`, which runs before them, and the
12
- * poller applies the polled ring state itself.
9
+ * The `call.accepted` and `call.rejected` handlers, the ring state poller and
10
+ * the rewatch after a WS reconnect all run this. They differ only in how the
11
+ * state got there: the handlers rely on `CallState.updateFromEvent`, which runs
12
+ * before them, while the poller applies the polled ring state itself and the
13
+ * rewatch applies the queried call state.
13
14
  *
14
15
  * @param call the call to reconcile.
15
- * @param joinSource which of the two triggered this run, reported on the
16
- * caller's join: `ring-ws` for the event handlers, `ring-poll-api` for the
17
- * poller.
16
+ * @param joinSource what triggered this run, reported on the caller's join:
17
+ * `ring-ws` for the event handlers, `ring-poll-api` for the poller and the
18
+ * rewatch (both come from an API response).
18
19
  * @returns whether the ring reached a terminal state. A failed join is not
19
20
  * terminal: the caller should keep trying while the ring is open.
20
21
  */
@@ -79,10 +80,17 @@ const reconcileAsCaller = async (
79
80
  };
80
81
 
81
82
  // the current user's own accept or reject, on this or another device, is
82
- // handled by `resolveOwnRingOutcome`, and `call.ended` by `watchCallEnded`.
83
+ // handled by `resolveOwnRingOutcome`, and a live `call.ended` by
84
+ // `watchCallEnded`. An end missed while offline is handled here.
83
85
  const reconcileAsCallee = async (call: Call): Promise<boolean> => {
84
- const createdById = call.state.createdBy?.id;
85
- const rejectedBy = call.state.session?.rejected_by ?? {};
86
+ const { session, endedAt, createdBy } = call.state;
87
+ if (endedAt || session?.ended_at) {
88
+ call.logger.info('ring: the call has ended, leaving');
89
+ return leave(call, { reason: 'ended', message: 'ring: call ended' });
90
+ }
91
+
92
+ const createdById = createdBy?.id;
93
+ const rejectedBy = session?.rejected_by ?? {};
86
94
  if (createdById && rejectedBy[createdById]) {
87
95
  call.logger.info('ring: the caller cancelled, leaving');
88
96
  return leave(call, {
@@ -1,20 +1,16 @@
1
- import { combineLatest } from 'rxjs';
2
1
  import { StreamSfuClient } from '../StreamSfuClient';
3
- import { OwnCapability, StatsOptions } from '../gen/coordinator';
2
+ import { StatsOptions } from '../gen/coordinator';
4
3
  import { Publisher, Subscriber } from '../rtc';
5
- import { ComputedStats, PendingDelta, Tracer, TraceRecord } from './rtc';
6
- import { flatten, getSdkName, getSdkVersion } from './utils';
4
+ import { PendingDelta, Tracer, TraceRecord } from './rtc';
5
+ import { getSdkName, getSdkVersion } from './utils';
7
6
  import { getDeviceState, getWebRTCInfo } from '../helpers/client-details';
8
7
  import { hasPending, withoutConcurrency } from '../helpers/concurrency';
9
8
  import { timeboxed } from '../coordinator/connection/utils';
10
9
  import {
11
10
  ClientDetails,
12
- InputDevices,
11
+ PerformanceStats,
13
12
  WebsocketReconnectStrategy,
14
13
  } from '../gen/video/sfu/models/models';
15
- import { CameraManager, MicrophoneManager } from '../devices';
16
- import { createSubscription } from '../store/rxUtils';
17
- import { CallState } from '../store';
18
14
  import { Telemetry } from '../gen/video/sfu/signal_rpc/signal';
19
15
  import { videoLoggerSystem } from '../logger';
20
16
 
@@ -23,9 +19,6 @@ export type SfuStatsReporterOptions = {
23
19
  clientDetails: ClientDetails;
24
20
  subscriber: Subscriber;
25
21
  publisher?: Publisher;
26
- microphone: MicrophoneManager;
27
- camera: CameraManager;
28
- state: CallState;
29
22
  tracer: Tracer;
30
23
  unifiedSessionId: string;
31
24
  };
@@ -38,21 +31,15 @@ export class SfuStatsReporter {
38
31
  private readonly sfuClient: StreamSfuClient;
39
32
  private readonly subscriber: Subscriber;
40
33
  private readonly publisher?: Publisher;
41
- private readonly microphone: MicrophoneManager;
42
- private readonly camera: CameraManager;
43
- private readonly state: CallState;
44
34
  private readonly tracer: Tracer;
45
35
  private readonly unifiedSessionId: string;
46
36
 
47
37
  private intervalId: NodeJS.Timeout | undefined;
48
38
  private timeoutId: NodeJS.Timeout | undefined;
49
39
  private reportCount: number = 0;
50
- private unsubscribeDevicePermissionsSubscription?: () => void;
51
- private unsubscribeListDevicesSubscription?: () => void;
52
40
  private readonly sdkName: string;
53
41
  private readonly sdkVersion: string;
54
42
  private readonly webRTCVersion: string;
55
- private readonly inputDevices = new Map<'mic' | 'camera', InputDevices>();
56
43
  private readonly statsConcurrencyTag = Symbol('sfuStatsReporter');
57
44
  private isStopped = false;
58
45
 
@@ -63,9 +50,6 @@ export class SfuStatsReporter {
63
50
  clientDetails,
64
51
  subscriber,
65
52
  publisher,
66
- microphone,
67
- camera,
68
- state,
69
53
  tracer,
70
54
  unifiedSessionId,
71
55
  }: SfuStatsReporterOptions,
@@ -74,9 +58,6 @@ export class SfuStatsReporter {
74
58
  this.options = options;
75
59
  this.subscriber = subscriber;
76
60
  this.publisher = publisher;
77
- this.microphone = microphone;
78
- this.camera = camera;
79
- this.state = state;
80
61
  this.tracer = tracer;
81
62
  this.unifiedSessionId = unifiedSessionId;
82
63
 
@@ -93,46 +74,6 @@ export class SfuStatsReporter {
93
74
  'N/A';
94
75
  }
95
76
 
96
- private observeDevice = (
97
- device: CameraManager | MicrophoneManager,
98
- kind: 'mic' | 'camera',
99
- ) => {
100
- const { browserPermissionState$ } = device.state;
101
- this.unsubscribeDevicePermissionsSubscription?.();
102
- this.unsubscribeDevicePermissionsSubscription = createSubscription(
103
- combineLatest([browserPermissionState$, this.state.ownCapabilities$]),
104
- ([browserPermissionState, ownCapabilities]) => {
105
- // cleanup the previous listDevices() subscription in case
106
- // permissions or capabilities have changed.
107
- // we will subscribe again if everything is in order.
108
- this.unsubscribeListDevicesSubscription?.();
109
- const hasCapability =
110
- kind === 'mic'
111
- ? ownCapabilities.includes(OwnCapability.SEND_AUDIO)
112
- : ownCapabilities.includes(OwnCapability.SEND_VIDEO);
113
- if (browserPermissionState !== 'granted' || !hasCapability) {
114
- this.inputDevices.set(kind, {
115
- currentDevice: '',
116
- availableDevices: [],
117
- isPermitted: false,
118
- });
119
- return;
120
- }
121
- this.unsubscribeListDevicesSubscription = createSubscription(
122
- combineLatest([device.listDevices(), device.state.selectedDevice$]),
123
- ([devices, deviceId]) => {
124
- const selected = devices.find((d) => d.deviceId === deviceId);
125
- this.inputDevices.set(kind, {
126
- currentDevice: selected?.label || deviceId || '',
127
- availableDevices: devices.map((d) => d.label),
128
- isPermitted: true,
129
- });
130
- },
131
- );
132
- },
133
- );
134
- };
135
-
136
77
  sendConnectionTime = (connectionTimeSeconds: number) => {
137
78
  this.sendTelemetryData({
138
79
  data: {
@@ -169,15 +110,17 @@ export class SfuStatsReporter {
169
110
  * explicit flush can capture the sample from live peer connections before
170
111
  * they are disposed, without waiting for an in-flight send.
171
112
  */
172
- private sample = (): Promise<[ComputedStats, ComputedStats | undefined]> =>
113
+ private sample = (): Promise<
114
+ [PerformanceStats[], PerformanceStats[] | undefined]
115
+ > =>
173
116
  Promise.all([
174
117
  this.subscriber.stats.takeSample(),
175
118
  this.publisher?.stats.takeSample(),
176
119
  ]);
177
120
 
178
121
  private send = (
179
- subscriberStats: ComputedStats,
180
- publisherStats: ComputedStats | undefined,
122
+ subscriberPerfStats: PerformanceStats[],
123
+ publisherPerfStats: PerformanceStats[] | undefined,
181
124
  telemetry?: Telemetry,
182
125
  ) => {
183
126
  // serialize sends so overlapping ones can't race on the trace buffers or
@@ -196,8 +139,8 @@ export class SfuStatsReporter {
196
139
  this.subscriber.stats.clearPendingDeltas();
197
140
  }
198
141
  let pubPending: PendingDelta[] = [];
199
- if (pubTracer && publisherStats) {
200
- pubPending = this.publisher?.stats.getPendingDeltas() ?? [];
142
+ if (pubTracer && this.publisher) {
143
+ pubPending = this.publisher.stats.getPendingDeltas();
201
144
  } else {
202
145
  this.publisher?.stats.clearPendingDeltas();
203
146
  }
@@ -220,17 +163,9 @@ export class SfuStatsReporter {
220
163
  sdk: this.sdkName,
221
164
  sdkVersion: this.sdkVersion,
222
165
  webrtcVersion: this.webRTCVersion,
223
- subscriberStats: JSON.stringify(flatten(subscriberStats.stats)),
224
- publisherStats: publisherStats
225
- ? JSON.stringify(flatten(publisherStats.stats))
226
- : '[]',
227
- subscriberRtcStats: '',
228
- publisherRtcStats: '',
229
166
  rtcStats: JSON.stringify(traces),
230
- encodeStats: publisherStats?.performanceStats ?? [],
231
- decodeStats: subscriberStats.performanceStats,
232
- audioDevices: this.inputDevices.get('mic'),
233
- videoDevices: this.inputDevices.get('camera'),
167
+ encodeStats: publisherPerfStats ?? [],
168
+ decodeStats: subscriberPerfStats,
234
169
  unifiedSessionId: this.unifiedSessionId,
235
170
  deviceState: getDeviceState(),
236
171
  telemetry,
@@ -243,8 +178,8 @@ export class SfuStatsReporter {
243
178
  }
244
179
  // delivery confirmed: advance the delivery baseline for each chain.
245
180
  if (subTracer) this.subscriber.stats.commitDeltas(subPending);
246
- if (pubTracer && publisherStats) {
247
- this.publisher?.stats.commitDeltas(pubPending);
181
+ if (pubTracer && this.publisher) {
182
+ this.publisher.stats.commitDeltas(pubPending);
248
183
  }
249
184
  } catch (err) {
250
185
  // keep the delta chains (re-sent next interval); only the append-only
@@ -265,8 +200,8 @@ export class SfuStatsReporter {
265
200
  */
266
201
  private run = async (telemetry?: Telemetry) => {
267
202
  if (this.isStopped) return;
268
- const [subscriberStats, publisherStats] = await this.sample();
269
- await this.send(subscriberStats, publisherStats, telemetry);
203
+ const [subscriberPerfStats, publisherPerfStats] = await this.sample();
204
+ await this.send(subscriberPerfStats, publisherPerfStats, telemetry);
270
205
  };
271
206
 
272
207
  private scheduleNextReport = () => {
@@ -288,9 +223,6 @@ export class SfuStatsReporter {
288
223
  start = () => {
289
224
  if (this.options.reporting_interval_ms <= 0) return;
290
225
 
291
- this.observeDevice(this.microphone, 'mic');
292
- this.observeDevice(this.camera, 'camera');
293
-
294
226
  this.isStopped = false;
295
227
  this.reportCount = 0;
296
228
  clearInterval(this.intervalId);
@@ -301,12 +233,6 @@ export class SfuStatsReporter {
301
233
 
302
234
  stop = () => {
303
235
  this.isStopped = true;
304
- this.unsubscribeDevicePermissionsSubscription?.();
305
- this.unsubscribeDevicePermissionsSubscription = undefined;
306
- this.unsubscribeListDevicesSubscription?.();
307
- this.unsubscribeListDevicesSubscription = undefined;
308
-
309
- this.inputDevices.clear();
310
236
  clearInterval(this.intervalId);
311
237
  this.intervalId = undefined;
312
238
  clearTimeout(this.timeoutId);
@@ -327,8 +253,8 @@ export class SfuStatsReporter {
327
253
  if (this.isStopped) return;
328
254
  try {
329
255
  const [sample] = await timeboxed([this.sample()], 2000);
330
- const [subscriberStats, publisherStats] = sample;
331
- this.send(subscriberStats, publisherStats).catch((err) => {
256
+ const [subscriberPerfStats, publisherPerfStats] = sample;
257
+ this.send(subscriberPerfStats, publisherPerfStats).catch((err) => {
332
258
  this.logger.warn('Failed to flush report stats', err);
333
259
  });
334
260
  } catch (err) {
@@ -1,5 +1,4 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
- import { BehaviorSubject } from 'rxjs';
3
2
  import { SfuStatsReporter } from '../SfuStatsReporter';
4
3
  import { promiseWithResolvers } from '../../helpers/promise';
5
4
 
@@ -9,10 +8,7 @@ const okResponse = { response: {} };
9
8
  const makeSlice = () => ({ snapshot: [] as unknown[], rollback: vi.fn() });
10
9
 
11
10
  const makeStats = (pending: Array<{ delta: object; ts: number }>) => ({
12
- takeSample: vi.fn().mockResolvedValue({
13
- performanceStats: [],
14
- stats: new Map(),
15
- }),
11
+ takeSample: vi.fn().mockResolvedValue([]),
16
12
  getPendingDeltas: vi.fn(() => pending),
17
13
  commitDeltas: vi.fn(),
18
14
  clearPendingDeltas: vi.fn(),
@@ -49,23 +45,11 @@ const build = (
49
45
  const sfuClient = { sendStats, getTrace: vi.fn(() => undefined) };
50
46
  const callTracer = { take: vi.fn(makeSlice), setEnabled: vi.fn() };
51
47
 
52
- // device permission is 'denied' so observeDevice() doesn't reach listDevices()
53
- const microphone = {
54
- state: { browserPermissionState$: new BehaviorSubject('denied') },
55
- };
56
- const camera = {
57
- state: { browserPermissionState$: new BehaviorSubject('denied') },
58
- };
59
- const state = { ownCapabilities$: new BehaviorSubject([]) };
60
-
61
48
  const reporter = new SfuStatsReporter(sfuClient as never, {
62
49
  options: { reporting_interval_ms: 1000, enable_rtc_stats: true } as never,
63
50
  clientDetails: { sdk: undefined, browser: undefined } as never,
64
51
  subscriber: subscriber as never,
65
52
  publisher: publisher as never,
66
- microphone: microphone as never,
67
- camera: camera as never,
68
- state: state as never,
69
53
  tracer: callTracer as never,
70
54
  unifiedSessionId: 'unified',
71
55
  });
@@ -143,7 +127,7 @@ describe('SfuStatsReporter delta delivery', () => {
143
127
  await Promise.resolve();
144
128
  expect(resolved).toBe(false); // still sampling -> flush not resolved
145
129
 
146
- dGet.resolve({ delta: {}, performanceStats: [], stats: new Map() });
130
+ dGet.resolve([]);
147
131
  await flushed;
148
132
  expect(resolved).toBe(true); // resolves after the sample, not the send
149
133
  });
@@ -5,7 +5,7 @@ import {
5
5
  TrackType,
6
6
  } from '../../gen/video/sfu/models/models';
7
7
  import type { RTCCodecStats, RTCMediaSourceStats } from '../types';
8
- import type { ComputedStats, PendingDelta } from './types';
8
+ import type { PendingDelta } from './types';
9
9
  import { withoutConcurrency } from '../../helpers/concurrency';
10
10
 
11
11
  /**
@@ -60,13 +60,13 @@ export class StatsTracer {
60
60
  }
61
61
 
62
62
  /**
63
- * Samples the RTCPeerConnection: returns the current stats report and the
64
- * derived performance stats, and appends the delta-compressed sample to the
65
- * un-acked delivery chain (retrieved via `getPendingDeltas`).
63
+ * Samples the RTCPeerConnection: returns the derived performance stats and
64
+ * appends the delta-compressed sample to the un-acked delivery chain
65
+ * (retrieved via `getPendingDeltas`).
66
66
  *
67
67
  * @internal
68
68
  */
69
- takeSample = (): Promise<ComputedStats> => {
69
+ takeSample = (): Promise<PerformanceStats[]> => {
70
70
  return withoutConcurrency(this.sampleTag, async () => {
71
71
  const stats = await this.pc.getStats();
72
72
  const now = Date.now();
@@ -98,7 +98,7 @@ export class StatsTracer {
98
98
  this.frameTimeHistory = this.frameTimeHistory.slice(-2);
99
99
  this.fpsHistory = this.fpsHistory.slice(-2);
100
100
 
101
- return { performanceStats, stats };
101
+ return performanceStats;
102
102
  });
103
103
  };
104
104
 
@@ -1,5 +1,3 @@
1
- import type { PerformanceStats } from '../../gen/video/sfu/models/models';
2
-
3
1
  export type RTCStatsDataType =
4
2
  | RTCConfiguration
5
3
  | RTCIceCandidate
@@ -41,17 +39,6 @@ export type TraceSlice = {
41
39
  rollback: () => void;
42
40
  };
43
41
 
44
- export type ComputedStats = {
45
- /**
46
- * Current stats from the RTCPeerConnection.
47
- */
48
- stats: RTCStatsReport;
49
- /**
50
- * The current iteration of the stats.
51
- */
52
- performanceStats: PerformanceStats[];
53
- };
54
-
55
42
  /**
56
43
  * A single, not-yet-delivered delta-compressed stats sample.
57
44
  * The `delta` is computed against the immediately preceding sample, so a