@stream-io/video-client 1.61.1 → 1.62.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.
- package/CHANGELOG.md +21 -0
- package/dist/index.browser.es.js +525 -178
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +525 -178
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +525 -178
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +21 -1
- package/dist/src/coordinator/connection/types.d.ts +1 -1
- package/dist/src/devices/DeviceManager.d.ts +6 -0
- package/dist/src/devices/DeviceManagerState.d.ts +17 -0
- package/dist/src/devices/MicrophoneManager.d.ts +11 -0
- package/dist/src/devices/devices.d.ts +5 -0
- package/dist/src/gen/video/sfu/signal_rpc/signal.d.ts +0 -27
- package/dist/src/helpers/AudioBindingsWatchdog.d.ts +7 -2
- package/dist/src/helpers/DynascaleManager.d.ts +15 -0
- package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +18 -0
- package/dist/src/helpers/mediaInterruptions.d.ts +14 -0
- package/dist/src/reporting/ClientEventReporter.d.ts +1 -1
- package/dist/src/ringing/reconcileRingState.d.ts +8 -7
- package/dist/src/rtc/BasePeerConnection.d.ts +13 -1
- package/dist/src/rtc/types.d.ts +7 -0
- package/dist/src/stats/SfuStatsReporter.d.ts +1 -13
- package/dist/src/stats/rtc/StatsTracer.d.ts +6 -6
- package/dist/src/stats/rtc/types.d.ts +0 -11
- package/package.json +1 -1
- package/src/Call.ts +110 -9
- package/src/StreamVideoClient.ts +8 -0
- package/src/__tests__/StreamVideoClient.rewatch.test.ts +136 -0
- package/src/coordinator/connection/types.ts +1 -1
- package/src/devices/DeviceManager.ts +26 -1
- package/src/devices/DeviceManagerState.ts +24 -0
- package/src/devices/MicrophoneManager.ts +79 -17
- package/src/devices/SpeakerManager.ts +1 -1
- package/src/devices/__tests__/CameraManager.test.ts +50 -1
- package/src/devices/__tests__/DeviceManager.test.ts +29 -0
- package/src/devices/__tests__/MicrophoneManager.test.ts +235 -0
- package/src/devices/__tests__/SpeakerManager.test.ts +27 -0
- package/src/devices/__tests__/devices.test.ts +10 -14
- package/src/devices/__tests__/mediaStreamTestHelpers.ts +11 -6
- package/src/devices/__tests__/mocks.ts +1 -0
- package/src/devices/devices.ts +7 -1
- package/src/gen/video/sfu/signal_rpc/signal.ts +0 -53
- package/src/helpers/AudioBindingsWatchdog.ts +91 -35
- package/src/helpers/DynascaleManager.ts +86 -11
- package/src/helpers/MediaPlaybackWatchdog.ts +55 -6
- package/src/helpers/__tests__/DynascaleManager.test.ts +253 -13
- package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +140 -0
- package/src/helpers/__tests__/mediaInterruptions.test.ts +131 -0
- package/src/helpers/mediaInterruptions.ts +37 -0
- package/src/reporting/ClientEventReporter.ts +1 -1
- package/src/ringing/RingStatePoller.ts +1 -1
- package/src/ringing/__tests__/RingStatePoller.test.ts +1 -1
- package/src/ringing/__tests__/reconcileRingState.test.ts +33 -0
- package/src/ringing/reconcileRingState.ts +18 -10
- package/src/rtc/BasePeerConnection.ts +47 -9
- package/src/rtc/__tests__/Call.reconnect.test.ts +474 -0
- package/src/rtc/types.ts +8 -0
- package/src/stats/SfuStatsReporter.ts +19 -93
- package/src/stats/__tests__/SfuStatsReporter.test.ts +2 -18
- package/src/stats/rtc/StatsTracer.ts +6 -6
- package/src/stats/rtc/types.ts +0 -13
|
@@ -202,6 +202,39 @@ describe('reconcileRingState', () => {
|
|
|
202
202
|
message: 'ring: call ended',
|
|
203
203
|
});
|
|
204
204
|
});
|
|
205
|
+
|
|
206
|
+
describe('as the callee', () => {
|
|
207
|
+
const ended = { reason: 'ended', message: 'ring: call ended' };
|
|
208
|
+
|
|
209
|
+
it('leaves when the session has ended', async () => {
|
|
210
|
+
const call = ringingCall({ currentUserId: 'm2', createdById: 'm1' });
|
|
211
|
+
setSession(call, { ended_at: timestamp() });
|
|
212
|
+
|
|
213
|
+
expect(await reconcile(call)).toBe(true);
|
|
214
|
+
expect(call.leave).toHaveBeenCalledWith(ended);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('leaves when the call itself has ended', async () => {
|
|
218
|
+
const call = ringingCall({ currentUserId: 'm2', createdById: 'm1' });
|
|
219
|
+
setSession(call, {});
|
|
220
|
+
call.state.setEndedAt(new Date());
|
|
221
|
+
|
|
222
|
+
expect(await reconcile(call)).toBe(true);
|
|
223
|
+
expect(call.leave).toHaveBeenCalledWith(ended);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('leaves once with the ended message when the creator also rejected', async () => {
|
|
227
|
+
const call = ringingCall({ currentUserId: 'm2', createdById: 'm1' });
|
|
228
|
+
setSession(call, {
|
|
229
|
+
ended_at: timestamp(),
|
|
230
|
+
rejected_by: { m1: timestamp() },
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
expect(await reconcile(call)).toBe(true);
|
|
234
|
+
expect(call.leave).toHaveBeenCalledTimes(1);
|
|
235
|
+
expect(call.leave).toHaveBeenCalledWith(ended);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
205
238
|
});
|
|
206
239
|
|
|
207
240
|
it('is terminal once the call is no longer ringing', async () => {
|
|
@@ -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
|
|
10
|
-
*
|
|
11
|
-
* handlers rely on `CallState.updateFromEvent`, which runs
|
|
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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
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
|
|
85
|
-
|
|
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, {
|
|
@@ -19,6 +19,7 @@ import { toJSON } from './helpers/iceCandiates';
|
|
|
19
19
|
import {
|
|
20
20
|
BasePeerConnectionOpts,
|
|
21
21
|
OnIceConnected,
|
|
22
|
+
OnNoLocalCandidates,
|
|
22
23
|
OnPeerConnectionStateChange,
|
|
23
24
|
OnReconnectionNeeded,
|
|
24
25
|
OnRemoteTrackUnmute,
|
|
@@ -43,10 +44,12 @@ export abstract class BasePeerConnection {
|
|
|
43
44
|
|
|
44
45
|
protected onReconnectionNeeded?: OnReconnectionNeeded;
|
|
45
46
|
private onIceConnected?: OnIceConnected;
|
|
47
|
+
private onNoLocalCandidates?: OnNoLocalCandidates;
|
|
46
48
|
private onPeerConnectionStateChange?: OnPeerConnectionStateChange;
|
|
47
49
|
protected onRemoteTrackUnmute?: OnRemoteTrackUnmute;
|
|
48
50
|
private readonly iceRestartDelay: number;
|
|
49
51
|
private iceHasEverConnected = false;
|
|
52
|
+
private localCandidatesGathered = 0;
|
|
50
53
|
private iceRestartTimeout?: NodeJS.Timeout;
|
|
51
54
|
private preConnectStuckTimeout?: NodeJS.Timeout;
|
|
52
55
|
protected isIceRestarting = false;
|
|
@@ -73,6 +76,7 @@ export abstract class BasePeerConnection {
|
|
|
73
76
|
dispatcher,
|
|
74
77
|
onReconnectionNeeded,
|
|
75
78
|
onIceConnected,
|
|
79
|
+
onNoLocalCandidates,
|
|
76
80
|
onPeerConnectionStateChange,
|
|
77
81
|
onRemoteTrackUnmute,
|
|
78
82
|
tag,
|
|
@@ -93,6 +97,7 @@ export abstract class BasePeerConnection {
|
|
|
93
97
|
this.tag = tag;
|
|
94
98
|
this.onReconnectionNeeded = onReconnectionNeeded;
|
|
95
99
|
this.onIceConnected = onIceConnected;
|
|
100
|
+
this.onNoLocalCandidates = onNoLocalCandidates;
|
|
96
101
|
this.onPeerConnectionStateChange = onPeerConnectionStateChange;
|
|
97
102
|
this.onRemoteTrackUnmute = onRemoteTrackUnmute;
|
|
98
103
|
this.logger = videoLoggerSystem.getLogger(
|
|
@@ -148,6 +153,7 @@ export abstract class BasePeerConnection {
|
|
|
148
153
|
this.preConnectStuckTimeout = undefined;
|
|
149
154
|
this.onReconnectionNeeded = undefined;
|
|
150
155
|
this.onIceConnected = undefined;
|
|
156
|
+
this.onNoLocalCandidates = undefined;
|
|
151
157
|
this.onPeerConnectionStateChange = undefined;
|
|
152
158
|
this.onRemoteTrackUnmute = undefined;
|
|
153
159
|
this.isDisposed = true;
|
|
@@ -285,6 +291,20 @@ export abstract class BasePeerConnection {
|
|
|
285
291
|
return this.trackIdToTrackType.get(trackId);
|
|
286
292
|
};
|
|
287
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Returns `true` once ICE has reached `connected`/`completed` at least
|
|
296
|
+
* once during the lifetime of this peer connection.
|
|
297
|
+
*/
|
|
298
|
+
hasIceEverConnected = () => this.iceHasEverConnected;
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Returns `true` when the latest ICE gathering round completed without
|
|
302
|
+
* producing a single local candidate.
|
|
303
|
+
*/
|
|
304
|
+
hasNoLocalCandidates = () =>
|
|
305
|
+
this.pc.iceGatheringState === 'complete' &&
|
|
306
|
+
this.localCandidatesGathered === 0;
|
|
307
|
+
|
|
288
308
|
/**
|
|
289
309
|
* Checks if the `RTCPeerConnection` is healthy.
|
|
290
310
|
* It checks the ICE connection state and the peer connection state.
|
|
@@ -311,6 +331,7 @@ export abstract class BasePeerConnection {
|
|
|
311
331
|
this.logger.debug('null ice candidate');
|
|
312
332
|
return;
|
|
313
333
|
}
|
|
334
|
+
if (candidate.candidate) this.localCandidatesGathered++;
|
|
314
335
|
|
|
315
336
|
const iceCandidate = toJSON(candidate);
|
|
316
337
|
this.sfuClient
|
|
@@ -389,6 +410,20 @@ export abstract class BasePeerConnection {
|
|
|
389
410
|
private handleConnectionStateUpdate = (
|
|
390
411
|
state: RTCIceConnectionState | RTCPeerConnectionState,
|
|
391
412
|
) => {
|
|
413
|
+
// Fire `onIceConnected` exactly once per peer-connection lifetime, the
|
|
414
|
+
// first time ICE reaches `connected`/`completed` end-to-end. Recorded
|
|
415
|
+
// ahead of the guards below so a connection established while offline,
|
|
416
|
+
// reconnecting or restarting ICE is not missed. Used by `Call` to reset
|
|
417
|
+
// the unsupported-network failure counter only after WebRTC has actually
|
|
418
|
+
// recovered, not merely on SFU join.
|
|
419
|
+
if (
|
|
420
|
+
(state === 'connected' || state === 'completed') &&
|
|
421
|
+
!this.iceHasEverConnected
|
|
422
|
+
) {
|
|
423
|
+
this.iceHasEverConnected = true;
|
|
424
|
+
this.onIceConnected?.(this.peerType);
|
|
425
|
+
}
|
|
426
|
+
|
|
392
427
|
const { callingState } = this.state;
|
|
393
428
|
if (callingState === CallingState.OFFLINE) return;
|
|
394
429
|
if (callingState === CallingState.RECONNECTING) return;
|
|
@@ -466,14 +501,6 @@ export abstract class BasePeerConnection {
|
|
|
466
501
|
|
|
467
502
|
case 'connected':
|
|
468
503
|
case 'completed':
|
|
469
|
-
// Fire `onIceConnected` exactly once per peer-connection lifetime —
|
|
470
|
-
// the first time ICE reaches `connected`/`completed` end-to-end.
|
|
471
|
-
// Used by `Call` to reset the unsupported-network failure counter
|
|
472
|
-
// only after WebRTC has actually recovered, not merely on SFU join.
|
|
473
|
-
if (!this.iceHasEverConnected) {
|
|
474
|
-
this.iceHasEverConnected = true;
|
|
475
|
-
this.onIceConnected?.(this.peerType);
|
|
476
|
-
}
|
|
477
504
|
// clear any scheduled restartICE since the connection is healthy
|
|
478
505
|
if (this.iceRestartTimeout) {
|
|
479
506
|
this.logger.info('connected connection, canceling restartICE');
|
|
@@ -502,7 +529,18 @@ export abstract class BasePeerConnection {
|
|
|
502
529
|
* Handles the ICE gathering state change event.
|
|
503
530
|
*/
|
|
504
531
|
private onIceGatherChange = () => {
|
|
505
|
-
|
|
532
|
+
const state = this.pc.iceGatheringState;
|
|
533
|
+
this.logger.debug(`ICE Gathering State`, state);
|
|
534
|
+
if (state === 'gathering') {
|
|
535
|
+
this.localCandidatesGathered = 0;
|
|
536
|
+
} else if (
|
|
537
|
+
state === 'complete' &&
|
|
538
|
+
this.localCandidatesGathered === 0 &&
|
|
539
|
+
!this.iceHasEverConnected
|
|
540
|
+
) {
|
|
541
|
+
this.logger.info('ICE gathering completed without local candidates');
|
|
542
|
+
this.onNoLocalCandidates?.(this.peerType);
|
|
543
|
+
}
|
|
506
544
|
};
|
|
507
545
|
|
|
508
546
|
/**
|