@stream-io/video-client 0.1.3 → 0.1.4
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 +12 -0
- package/dist/index.browser.es.js +366 -52
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +366 -52
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +366 -52
- package/dist/index.es.js.map +1 -1
- package/dist/src/StreamSfuClient.d.ts +2 -1
- package/dist/src/gen/video/sfu/event/events.d.ts +45 -2
- package/dist/src/gen/video/sfu/models/models.d.ts +12 -0
- package/dist/src/gen/video/sfu/signal_rpc/signal.client.d.ts +9 -1
- package/dist/src/gen/video/sfu/signal_rpc/signal.d.ts +42 -0
- package/dist/src/rtc/Publisher.d.ts +10 -2
- package/dist/src/rtc/Subscriber.d.ts +8 -3
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
- package/src/Call.ts +2 -0
- package/src/StreamSfuClient.ts +16 -5
- package/src/gen/google/protobuf/struct.ts +1 -2
- package/src/gen/google/protobuf/timestamp.ts +1 -1
- package/src/gen/video/sfu/event/events.ts +165 -5
- package/src/gen/video/sfu/models/models.ts +13 -1
- package/src/gen/video/sfu/signal_rpc/signal.client.ts +27 -1
- package/src/gen/video/sfu/signal_rpc/signal.ts +194 -1
- package/src/rtc/Dispatcher.ts +1 -0
- package/src/rtc/Publisher.ts +69 -34
- package/src/rtc/Subscriber.ts +74 -7
- package/src/rtc/__tests__/Publisher.test.ts +82 -2
- package/src/rtc/__tests__/Subscriber.test.ts +84 -1
package/src/rtc/Publisher.ts
CHANGED
|
@@ -28,14 +28,19 @@ import {
|
|
|
28
28
|
} from '../helpers/sdp-munging';
|
|
29
29
|
import { Logger } from '../coordinator/connection/types';
|
|
30
30
|
import { getLogger } from '../logger';
|
|
31
|
+
import { Dispatcher } from './Dispatcher';
|
|
32
|
+
|
|
33
|
+
const logger: Logger = getLogger(['Publisher']);
|
|
31
34
|
|
|
32
35
|
export type PublisherOpts = {
|
|
33
36
|
sfuClient: StreamSfuClient;
|
|
34
37
|
state: CallState;
|
|
38
|
+
dispatcher: Dispatcher;
|
|
35
39
|
connectionConfig?: RTCConfiguration;
|
|
36
40
|
isDtxEnabled: boolean;
|
|
37
41
|
isRedEnabled: boolean;
|
|
38
42
|
preferredVideoCodec?: string;
|
|
43
|
+
iceRestartDelay?: number;
|
|
39
44
|
};
|
|
40
45
|
|
|
41
46
|
/**
|
|
@@ -45,6 +50,7 @@ export type PublisherOpts = {
|
|
|
45
50
|
export class Publisher {
|
|
46
51
|
private pc: RTCPeerConnection;
|
|
47
52
|
private readonly state: CallState;
|
|
53
|
+
private readonly dispatcher: Dispatcher;
|
|
48
54
|
|
|
49
55
|
private readonly transceiverRegistry: {
|
|
50
56
|
[key in TrackType]: RTCRtpTransceiver | undefined;
|
|
@@ -87,7 +93,11 @@ export class Publisher {
|
|
|
87
93
|
private readonly isDtxEnabled: boolean;
|
|
88
94
|
private readonly isRedEnabled: boolean;
|
|
89
95
|
private readonly preferredVideoCodec?: string;
|
|
90
|
-
|
|
96
|
+
|
|
97
|
+
private readonly unsubscribeOnIceRestart: () => void;
|
|
98
|
+
|
|
99
|
+
private readonly iceRestartDelay: number;
|
|
100
|
+
private isIceRestarting = false;
|
|
91
101
|
|
|
92
102
|
/**
|
|
93
103
|
* The SFU client instance to use for publishing and signaling.
|
|
@@ -100,24 +110,40 @@ export class Publisher {
|
|
|
100
110
|
* @param connectionConfig the connection configuration to use.
|
|
101
111
|
* @param sfuClient the SFU client to use.
|
|
102
112
|
* @param state the call state to use.
|
|
113
|
+
* @param dispatcher the dispatcher to use.
|
|
103
114
|
* @param isDtxEnabled whether DTX is enabled.
|
|
104
115
|
* @param isRedEnabled whether RED is enabled.
|
|
105
116
|
* @param preferredVideoCodec the preferred video codec.
|
|
117
|
+
* @param iceRestartDelay the delay in milliseconds to wait before restarting ICE once connection goes to `disconnected` state.
|
|
106
118
|
*/
|
|
107
119
|
constructor({
|
|
108
120
|
connectionConfig,
|
|
109
121
|
sfuClient,
|
|
122
|
+
dispatcher,
|
|
110
123
|
state,
|
|
111
124
|
isDtxEnabled,
|
|
112
125
|
isRedEnabled,
|
|
113
126
|
preferredVideoCodec,
|
|
127
|
+
iceRestartDelay = 2500,
|
|
114
128
|
}: PublisherOpts) {
|
|
115
129
|
this.pc = this.createPeerConnection(connectionConfig);
|
|
116
130
|
this.sfuClient = sfuClient;
|
|
117
131
|
this.state = state;
|
|
132
|
+
this.dispatcher = dispatcher;
|
|
118
133
|
this.isDtxEnabled = isDtxEnabled;
|
|
119
134
|
this.isRedEnabled = isRedEnabled;
|
|
120
135
|
this.preferredVideoCodec = preferredVideoCodec;
|
|
136
|
+
this.iceRestartDelay = iceRestartDelay;
|
|
137
|
+
|
|
138
|
+
this.unsubscribeOnIceRestart = dispatcher.on(
|
|
139
|
+
'iceRestart',
|
|
140
|
+
async (message) => {
|
|
141
|
+
if (message.eventPayload.oneofKind !== 'iceRestart') return;
|
|
142
|
+
const { iceRestart } = message.eventPayload;
|
|
143
|
+
if (iceRestart.peerType !== PeerType.PUBLISHER_UNSPECIFIED) return;
|
|
144
|
+
await this.restartIce();
|
|
145
|
+
},
|
|
146
|
+
);
|
|
121
147
|
}
|
|
122
148
|
|
|
123
149
|
private createPeerConnection = (connectionConfig?: RTCConfiguration) => {
|
|
@@ -154,6 +180,7 @@ export class Publisher {
|
|
|
154
180
|
});
|
|
155
181
|
}
|
|
156
182
|
|
|
183
|
+
this.unsubscribeOnIceRestart();
|
|
157
184
|
this.pc.removeEventListener('negotiationneeded', this.onNegotiationNeeded);
|
|
158
185
|
this.pc.close();
|
|
159
186
|
};
|
|
@@ -192,7 +219,7 @@ export class Publisher {
|
|
|
192
219
|
* Once the track has ended, it will notify the SFU and update the state.
|
|
193
220
|
*/
|
|
194
221
|
const handleTrackEnded = async () => {
|
|
195
|
-
|
|
222
|
+
logger(
|
|
196
223
|
'info',
|
|
197
224
|
`Track ${TrackType[trackType]} has ended, notifying the SFU`,
|
|
198
225
|
);
|
|
@@ -233,12 +260,12 @@ export class Publisher {
|
|
|
233
260
|
sendEncodings: videoEncodings,
|
|
234
261
|
});
|
|
235
262
|
|
|
236
|
-
|
|
263
|
+
logger('debug', `Added ${TrackType[trackType]} transceiver`);
|
|
237
264
|
this.transceiverInitOrder.push(trackType);
|
|
238
265
|
this.transceiverRegistry[trackType] = transceiver;
|
|
239
266
|
|
|
240
267
|
if ('setCodecPreferences' in transceiver && codecPreferences) {
|
|
241
|
-
|
|
268
|
+
logger(
|
|
242
269
|
'info',
|
|
243
270
|
`Setting ${TrackType[trackType]} codec preferences`,
|
|
244
271
|
codecPreferences,
|
|
@@ -336,7 +363,7 @@ export class Publisher {
|
|
|
336
363
|
* Stops publishing all tracks and stop all tracks.
|
|
337
364
|
*/
|
|
338
365
|
stopPublishing = () => {
|
|
339
|
-
|
|
366
|
+
logger('debug', 'Stopping publishing all tracks');
|
|
340
367
|
this.pc.getSenders().forEach((s) => {
|
|
341
368
|
s.track?.stop();
|
|
342
369
|
if (this.pc.signalingState !== 'closed') {
|
|
@@ -346,7 +373,7 @@ export class Publisher {
|
|
|
346
373
|
};
|
|
347
374
|
|
|
348
375
|
updateVideoPublishQuality = async (enabledRids: string[]) => {
|
|
349
|
-
|
|
376
|
+
logger(
|
|
350
377
|
'info',
|
|
351
378
|
'Update publish quality, requested rids by SFU:',
|
|
352
379
|
enabledRids,
|
|
@@ -354,13 +381,13 @@ export class Publisher {
|
|
|
354
381
|
|
|
355
382
|
const videoSender = this.transceiverRegistry[TrackType.VIDEO]?.sender;
|
|
356
383
|
if (!videoSender) {
|
|
357
|
-
|
|
384
|
+
logger('warn', 'Update publish quality, no video sender found.');
|
|
358
385
|
return;
|
|
359
386
|
}
|
|
360
387
|
|
|
361
388
|
const params = videoSender.getParameters();
|
|
362
389
|
if (params.encodings.length === 0) {
|
|
363
|
-
|
|
390
|
+
logger(
|
|
364
391
|
'warn',
|
|
365
392
|
'Update publish quality, No suitable video encoding quality found',
|
|
366
393
|
);
|
|
@@ -383,12 +410,9 @@ export class Publisher {
|
|
|
383
410
|
.join(', ');
|
|
384
411
|
if (changed) {
|
|
385
412
|
await videoSender.setParameters(params);
|
|
386
|
-
|
|
387
|
-
'info',
|
|
388
|
-
`Update publish quality, enabled rids: ${activeRids}`,
|
|
389
|
-
);
|
|
413
|
+
logger('info', `Update publish quality, enabled rids: ${activeRids}`);
|
|
390
414
|
} else {
|
|
391
|
-
|
|
415
|
+
logger('info', `Update publish quality, no change: ${activeRids}`);
|
|
392
416
|
}
|
|
393
417
|
};
|
|
394
418
|
|
|
@@ -422,7 +446,7 @@ export class Publisher {
|
|
|
422
446
|
private onIceCandidate = async (e: RTCPeerConnectionIceEvent) => {
|
|
423
447
|
const { candidate } = e;
|
|
424
448
|
if (!candidate) {
|
|
425
|
-
|
|
449
|
+
logger('debug', 'null ice candidate');
|
|
426
450
|
return;
|
|
427
451
|
}
|
|
428
452
|
await this.sfuClient.iceTrickle({
|
|
@@ -457,7 +481,12 @@ export class Publisher {
|
|
|
457
481
|
* Restarts the ICE connection and renegotiates with the SFU.
|
|
458
482
|
*/
|
|
459
483
|
restartIce = async () => {
|
|
460
|
-
|
|
484
|
+
logger('debug', 'Restarting ICE connection');
|
|
485
|
+
const signalingState = this.pc.signalingState;
|
|
486
|
+
if (this.isIceRestarting || signalingState === 'have-local-offer') {
|
|
487
|
+
logger('debug', 'ICE restart is already in progress');
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
461
490
|
await this.negotiate({ iceRestart: true });
|
|
462
491
|
};
|
|
463
492
|
|
|
@@ -471,6 +500,8 @@ export class Publisher {
|
|
|
471
500
|
* @param options the optional offer options to use.
|
|
472
501
|
*/
|
|
473
502
|
private negotiate = async (options?: RTCOfferOptions) => {
|
|
503
|
+
this.isIceRestarting = options?.iceRestart ?? false;
|
|
504
|
+
|
|
474
505
|
const offer = await this.pc.createOffer(options);
|
|
475
506
|
offer.sdp = this.mungeCodecs(offer.sdp);
|
|
476
507
|
|
|
@@ -494,22 +525,21 @@ export class Publisher {
|
|
|
494
525
|
sdp: response.sdp,
|
|
495
526
|
});
|
|
496
527
|
} catch (e) {
|
|
497
|
-
|
|
528
|
+
logger('error', `setRemoteDescription error`, {
|
|
498
529
|
sdp: response.sdp,
|
|
499
530
|
error: e,
|
|
500
531
|
});
|
|
501
532
|
}
|
|
502
533
|
|
|
534
|
+
this.isIceRestarting = false;
|
|
535
|
+
|
|
503
536
|
this.sfuClient.iceTrickleBuffer.publisherCandidates.subscribe(
|
|
504
537
|
async (candidate) => {
|
|
505
538
|
try {
|
|
506
539
|
const iceCandidate = JSON.parse(candidate.iceCandidate);
|
|
507
540
|
await this.pc.addIceCandidate(iceCandidate);
|
|
508
541
|
} catch (e) {
|
|
509
|
-
|
|
510
|
-
error: e,
|
|
511
|
-
candidate,
|
|
512
|
-
});
|
|
542
|
+
logger('warn', `ICE candidate error`, [e, candidate]);
|
|
513
543
|
}
|
|
514
544
|
},
|
|
515
545
|
);
|
|
@@ -544,11 +574,11 @@ export class Publisher {
|
|
|
544
574
|
): string => {
|
|
545
575
|
if (defaultMid) return defaultMid;
|
|
546
576
|
if (!sdp) {
|
|
547
|
-
|
|
577
|
+
logger('warn', 'No SDP found. Returning empty mid');
|
|
548
578
|
return '';
|
|
549
579
|
}
|
|
550
580
|
|
|
551
|
-
|
|
581
|
+
logger(
|
|
552
582
|
'debug',
|
|
553
583
|
`No 'mid' found for track. Trying to find it from the Offer SDP`,
|
|
554
584
|
);
|
|
@@ -562,7 +592,7 @@ export class Publisher {
|
|
|
562
592
|
);
|
|
563
593
|
});
|
|
564
594
|
if (typeof media?.mid === 'undefined') {
|
|
565
|
-
|
|
595
|
+
logger(
|
|
566
596
|
'debug',
|
|
567
597
|
`No mid found in SDP for track type ${track.kind} and id ${track.id}. Attempting to find a heuristic mid`,
|
|
568
598
|
);
|
|
@@ -572,7 +602,7 @@ export class Publisher {
|
|
|
572
602
|
return String(heuristicMid);
|
|
573
603
|
}
|
|
574
604
|
|
|
575
|
-
|
|
605
|
+
logger('debug', 'No heuristic mid found. Returning empty mid');
|
|
576
606
|
return '';
|
|
577
607
|
}
|
|
578
608
|
return String(media.mid);
|
|
@@ -603,7 +633,7 @@ export class Publisher {
|
|
|
603
633
|
} else {
|
|
604
634
|
// we report the last known optimal layers for ended tracks
|
|
605
635
|
optimalLayers = this.trackLayersCache[trackType] || [];
|
|
606
|
-
|
|
636
|
+
logger(
|
|
607
637
|
'debug',
|
|
608
638
|
`Track ${TrackType[trackType]} is ended. Announcing last known optimal layers`,
|
|
609
639
|
optimalLayers,
|
|
@@ -639,22 +669,22 @@ export class Publisher {
|
|
|
639
669
|
const errorMessage =
|
|
640
670
|
e instanceof RTCPeerConnectionIceErrorEvent &&
|
|
641
671
|
`${e.errorCode}: ${e.errorText}`;
|
|
642
|
-
|
|
672
|
+
logger('error', `ICE Candidate error`, errorMessage);
|
|
643
673
|
};
|
|
644
674
|
|
|
645
675
|
private onIceConnectionStateChange = () => {
|
|
646
676
|
const state = this.pc.iceConnectionState;
|
|
647
|
-
|
|
677
|
+
logger('debug', `ICE Connection state changed to`, state);
|
|
648
678
|
|
|
649
679
|
if (state === 'failed') {
|
|
650
|
-
|
|
680
|
+
logger('warn', `Attempting to restart ICE`);
|
|
651
681
|
this.restartIce().catch((e) => {
|
|
652
|
-
|
|
682
|
+
logger('error', `ICE restart error`, e);
|
|
653
683
|
});
|
|
654
684
|
} else if (state === 'disconnected') {
|
|
655
685
|
// when in `disconnected` state, the browser may recover automatically,
|
|
656
686
|
// hence, we delay the ICE restart
|
|
657
|
-
|
|
687
|
+
logger('warn', `Scheduling ICE restart in ${this.iceRestartDelay} ms.`);
|
|
658
688
|
setTimeout(() => {
|
|
659
689
|
// check if the state is still `disconnected` or `failed`
|
|
660
690
|
// as the connection may have recovered (or failed) in the meantime
|
|
@@ -663,19 +693,24 @@ export class Publisher {
|
|
|
663
693
|
this.pc.iceConnectionState === 'failed'
|
|
664
694
|
) {
|
|
665
695
|
this.restartIce().catch((e) => {
|
|
666
|
-
|
|
696
|
+
logger('error', `ICE restart error`, e);
|
|
667
697
|
});
|
|
698
|
+
} else {
|
|
699
|
+
logger(
|
|
700
|
+
'debug',
|
|
701
|
+
`Scheduled ICE restart: connection recovered, canceled.`,
|
|
702
|
+
);
|
|
668
703
|
}
|
|
669
|
-
},
|
|
704
|
+
}, this.iceRestartDelay);
|
|
670
705
|
}
|
|
671
706
|
};
|
|
672
707
|
|
|
673
708
|
private onIceGatheringStateChange = () => {
|
|
674
|
-
|
|
709
|
+
logger('debug', `ICE Gathering State`, this.pc.iceGatheringState);
|
|
675
710
|
};
|
|
676
711
|
|
|
677
712
|
private onSignalingStateChange = () => {
|
|
678
|
-
|
|
713
|
+
logger('debug', `Signaling state changed`, this.pc.signalingState);
|
|
679
714
|
};
|
|
680
715
|
|
|
681
716
|
private ridToVideoQuality = (rid: string): VideoQuality => {
|
package/src/rtc/Subscriber.ts
CHANGED
|
@@ -11,6 +11,7 @@ export type SubscriberOpts = {
|
|
|
11
11
|
dispatcher: Dispatcher;
|
|
12
12
|
state: CallState;
|
|
13
13
|
connectionConfig?: RTCConfiguration;
|
|
14
|
+
iceRestartDelay?: number;
|
|
14
15
|
};
|
|
15
16
|
|
|
16
17
|
const logger = getLogger(['Subscriber']);
|
|
@@ -21,11 +22,16 @@ const logger = getLogger(['Subscriber']);
|
|
|
21
22
|
*/
|
|
22
23
|
export class Subscriber {
|
|
23
24
|
private pc: RTCPeerConnection;
|
|
24
|
-
private readonly unregisterOnSubscriberOffer: () => void;
|
|
25
25
|
private sfuClient: StreamSfuClient;
|
|
26
26
|
private dispatcher: Dispatcher;
|
|
27
27
|
private state: CallState;
|
|
28
28
|
|
|
29
|
+
private readonly unregisterOnSubscriberOffer: () => void;
|
|
30
|
+
private readonly unregisterOnIceRestart: () => void;
|
|
31
|
+
|
|
32
|
+
private readonly iceRestartDelay: number;
|
|
33
|
+
private isIceRestarting = false;
|
|
34
|
+
|
|
29
35
|
/**
|
|
30
36
|
* Constructs a new `Subscriber` instance.
|
|
31
37
|
*
|
|
@@ -33,16 +39,19 @@ export class Subscriber {
|
|
|
33
39
|
* @param dispatcher the dispatcher to use.
|
|
34
40
|
* @param state the state of the call.
|
|
35
41
|
* @param connectionConfig the connection configuration to use.
|
|
42
|
+
* @param iceRestartDelay the delay in milliseconds to wait before restarting ICE when connection goes to `disconnected` state.
|
|
36
43
|
*/
|
|
37
44
|
constructor({
|
|
38
45
|
sfuClient,
|
|
39
46
|
dispatcher,
|
|
40
47
|
state,
|
|
41
48
|
connectionConfig,
|
|
49
|
+
iceRestartDelay = 2500,
|
|
42
50
|
}: SubscriberOpts) {
|
|
43
51
|
this.sfuClient = sfuClient;
|
|
44
52
|
this.dispatcher = dispatcher;
|
|
45
53
|
this.state = state;
|
|
54
|
+
this.iceRestartDelay = iceRestartDelay;
|
|
46
55
|
|
|
47
56
|
this.pc = this.createPeerConnection(connectionConfig);
|
|
48
57
|
|
|
@@ -54,6 +63,16 @@ export class Subscriber {
|
|
|
54
63
|
await this.negotiate(subscriberOffer);
|
|
55
64
|
},
|
|
56
65
|
);
|
|
66
|
+
|
|
67
|
+
this.unregisterOnIceRestart = dispatcher.on(
|
|
68
|
+
'iceRestart',
|
|
69
|
+
async (message) => {
|
|
70
|
+
if (message.eventPayload.oneofKind !== 'iceRestart') return;
|
|
71
|
+
const { iceRestart } = message.eventPayload;
|
|
72
|
+
if (iceRestart.peerType !== PeerType.SUBSCRIBER) return;
|
|
73
|
+
await this.restartIce();
|
|
74
|
+
},
|
|
75
|
+
);
|
|
57
76
|
}
|
|
58
77
|
|
|
59
78
|
/**
|
|
@@ -84,6 +103,7 @@ export class Subscriber {
|
|
|
84
103
|
*/
|
|
85
104
|
close = () => {
|
|
86
105
|
this.unregisterOnSubscriberOffer();
|
|
106
|
+
this.unregisterOnIceRestart();
|
|
87
107
|
this.pc.close();
|
|
88
108
|
};
|
|
89
109
|
|
|
@@ -177,9 +197,23 @@ export class Subscriber {
|
|
|
177
197
|
/**
|
|
178
198
|
* Restarts the ICE connection and renegotiates with the SFU.
|
|
179
199
|
*/
|
|
180
|
-
restartIce = () => {
|
|
200
|
+
restartIce = async () => {
|
|
181
201
|
logger('debug', 'Restarting ICE connection');
|
|
182
|
-
this.pc.
|
|
202
|
+
if (this.pc.signalingState === 'have-remote-offer') {
|
|
203
|
+
logger('debug', 'ICE restart is already in progress');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const previousIsIceRestarting = this.isIceRestarting;
|
|
207
|
+
try {
|
|
208
|
+
this.isIceRestarting = true;
|
|
209
|
+
await this.sfuClient.iceRestart({
|
|
210
|
+
peerType: PeerType.SUBSCRIBER,
|
|
211
|
+
});
|
|
212
|
+
} catch (e) {
|
|
213
|
+
// restore the previous state, as our intent for restarting ICE failed
|
|
214
|
+
this.isIceRestarting = previousIsIceRestarting;
|
|
215
|
+
throw e;
|
|
216
|
+
}
|
|
183
217
|
};
|
|
184
218
|
|
|
185
219
|
private handleOnTrack = (e: RTCTrackEvent) => {
|
|
@@ -280,12 +314,11 @@ export class Subscriber {
|
|
|
280
314
|
const iceCandidate = JSON.parse(candidate.iceCandidate);
|
|
281
315
|
await this.pc.addIceCandidate(iceCandidate);
|
|
282
316
|
} catch (e) {
|
|
283
|
-
logger('
|
|
317
|
+
logger('warn', `ICE candidate error`, [e, candidate]);
|
|
284
318
|
}
|
|
285
319
|
},
|
|
286
320
|
);
|
|
287
321
|
|
|
288
|
-
// apply ice candidates
|
|
289
322
|
const answer = await this.pc.createAnswer();
|
|
290
323
|
await this.pc.setLocalDescription(answer);
|
|
291
324
|
|
|
@@ -293,14 +326,48 @@ export class Subscriber {
|
|
|
293
326
|
peerType: PeerType.SUBSCRIBER,
|
|
294
327
|
sdp: answer.sdp || '',
|
|
295
328
|
});
|
|
329
|
+
|
|
330
|
+
this.isIceRestarting = false;
|
|
296
331
|
};
|
|
297
332
|
|
|
298
333
|
private onIceConnectionStateChange = () => {
|
|
299
|
-
|
|
334
|
+
const state = this.pc.iceConnectionState;
|
|
335
|
+
logger('debug', `ICE connection state changed`, state);
|
|
336
|
+
|
|
337
|
+
// do nothing when ICE is restarting
|
|
338
|
+
if (this.isIceRestarting) return;
|
|
339
|
+
|
|
340
|
+
if (state === 'failed') {
|
|
341
|
+
logger('warn', `Attempting to restart ICE`);
|
|
342
|
+
this.restartIce().catch((e) => {
|
|
343
|
+
logger('error', `ICE restart failed`, e);
|
|
344
|
+
});
|
|
345
|
+
} else if (state === 'disconnected') {
|
|
346
|
+
// when in `disconnected` state, the browser may recover automatically,
|
|
347
|
+
// hence, we delay the ICE restart
|
|
348
|
+
logger('warn', `Scheduling ICE restart in ${this.iceRestartDelay} ms.`);
|
|
349
|
+
setTimeout(() => {
|
|
350
|
+
// check if the state is still `disconnected` or `failed`
|
|
351
|
+
// as the connection may have recovered (or failed) in the meantime
|
|
352
|
+
if (
|
|
353
|
+
this.pc.iceConnectionState === 'disconnected' ||
|
|
354
|
+
this.pc.iceConnectionState === 'failed'
|
|
355
|
+
) {
|
|
356
|
+
this.restartIce().catch((e) => {
|
|
357
|
+
logger('error', `ICE restart failed`, e);
|
|
358
|
+
});
|
|
359
|
+
} else {
|
|
360
|
+
logger(
|
|
361
|
+
'debug',
|
|
362
|
+
`Scheduled ICE restart: connection recovered, canceled.`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
}, 5000);
|
|
366
|
+
}
|
|
300
367
|
};
|
|
301
368
|
|
|
302
369
|
private onIceGatheringStateChange = () => {
|
|
303
|
-
logger('
|
|
370
|
+
logger('debug', `ICE gathering state changed`, this.pc.iceGatheringState);
|
|
304
371
|
};
|
|
305
372
|
|
|
306
373
|
private onIceCandidateError = (e: Event) => {
|
|
@@ -5,8 +5,9 @@ import { Publisher } from '../Publisher';
|
|
|
5
5
|
import { CallState } from '../../store';
|
|
6
6
|
import { StreamSfuClient } from '../../StreamSfuClient';
|
|
7
7
|
import { Dispatcher } from '../Dispatcher';
|
|
8
|
-
import { TrackType } from '../../gen/video/sfu/models/models';
|
|
8
|
+
import { PeerType, TrackType } from '../../gen/video/sfu/models/models';
|
|
9
9
|
import { IceTrickleBuffer } from '../IceTrickleBuffer';
|
|
10
|
+
import { SfuEvent } from '../../gen/video/sfu/event/events';
|
|
10
11
|
|
|
11
12
|
vi.mock('../../StreamSfuClient', () => {
|
|
12
13
|
console.log('MOCKING StreamSfuClient');
|
|
@@ -33,9 +34,10 @@ describe('Publisher', () => {
|
|
|
33
34
|
let publisher: Publisher;
|
|
34
35
|
let sfuClient: StreamSfuClient;
|
|
35
36
|
let state: CallState;
|
|
37
|
+
let dispatcher: Dispatcher;
|
|
36
38
|
|
|
37
39
|
beforeEach(() => {
|
|
38
|
-
|
|
40
|
+
dispatcher = new Dispatcher();
|
|
39
41
|
sfuClient = new StreamSfuClient({
|
|
40
42
|
dispatcher,
|
|
41
43
|
sfuServer: {
|
|
@@ -52,15 +54,18 @@ describe('Publisher', () => {
|
|
|
52
54
|
state = new CallState();
|
|
53
55
|
publisher = new Publisher({
|
|
54
56
|
sfuClient,
|
|
57
|
+
dispatcher,
|
|
55
58
|
state,
|
|
56
59
|
isDtxEnabled: true,
|
|
57
60
|
isRedEnabled: true,
|
|
61
|
+
iceRestartDelay: 100,
|
|
58
62
|
});
|
|
59
63
|
});
|
|
60
64
|
|
|
61
65
|
afterEach(() => {
|
|
62
66
|
vi.clearAllMocks();
|
|
63
67
|
vi.resetModules();
|
|
68
|
+
dispatcher.offAll();
|
|
64
69
|
});
|
|
65
70
|
|
|
66
71
|
it('can publish, re-publish and un-publish a stream', async () => {
|
|
@@ -209,4 +214,79 @@ describe('Publisher', () => {
|
|
|
209
214
|
expect(sfuClient.setPublisher).toHaveBeenCalled();
|
|
210
215
|
});
|
|
211
216
|
});
|
|
217
|
+
|
|
218
|
+
describe('Publisher ICE Restart', () => {
|
|
219
|
+
it('should perform ICE restart when iceRestart event is received', () => {
|
|
220
|
+
vi.spyOn(publisher, 'restartIce').mockResolvedValue();
|
|
221
|
+
dispatcher.dispatch(
|
|
222
|
+
SfuEvent.create({
|
|
223
|
+
eventPayload: {
|
|
224
|
+
oneofKind: 'iceRestart',
|
|
225
|
+
iceRestart: {
|
|
226
|
+
peerType: PeerType.PUBLISHER_UNSPECIFIED,
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
expect(publisher.restartIce).toHaveBeenCalled();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('should not perform ICE restart when iceRestart event is received for a different peer type', () => {
|
|
235
|
+
vi.spyOn(publisher, 'restartIce').mockResolvedValue();
|
|
236
|
+
dispatcher.dispatch(
|
|
237
|
+
SfuEvent.create({
|
|
238
|
+
eventPayload: {
|
|
239
|
+
oneofKind: 'iceRestart',
|
|
240
|
+
iceRestart: {
|
|
241
|
+
peerType: PeerType.SUBSCRIBER,
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
}),
|
|
245
|
+
);
|
|
246
|
+
expect(publisher.restartIce).not.toHaveBeenCalled();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it(`should drop consequent ICE restart requests`, async () => {
|
|
250
|
+
// @ts-ignore
|
|
251
|
+
publisher['pc'].signalingState = 'have-local-offer';
|
|
252
|
+
// @ts-ignore
|
|
253
|
+
vi.spyOn(publisher, 'negotiate').mockResolvedValue();
|
|
254
|
+
|
|
255
|
+
await publisher.restartIce();
|
|
256
|
+
expect(publisher['negotiate']).not.toHaveBeenCalled();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it(`should perform ICE restart when connection state changes to 'failed'`, () => {
|
|
260
|
+
vi.spyOn(publisher, 'restartIce').mockResolvedValue();
|
|
261
|
+
// @ts-ignore
|
|
262
|
+
publisher['pc'].iceConnectionState = 'failed';
|
|
263
|
+
publisher['onIceConnectionStateChange']();
|
|
264
|
+
expect(publisher.restartIce).toHaveBeenCalled();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it(`should perform ICE restart when connection state changes to 'disconnected'`, () => {
|
|
268
|
+
vi.spyOn(publisher, 'restartIce').mockResolvedValue();
|
|
269
|
+
vi.useFakeTimers();
|
|
270
|
+
|
|
271
|
+
// @ts-ignore
|
|
272
|
+
publisher['pc'].iceConnectionState = 'disconnected';
|
|
273
|
+
publisher['onIceConnectionStateChange']();
|
|
274
|
+
vi.runAllTimers();
|
|
275
|
+
expect(publisher.restartIce).toHaveBeenCalled();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it(`should bail-out from ICE restart once connection recovers before timeout`, () => {
|
|
279
|
+
vi.spyOn(publisher, 'restartIce').mockResolvedValue();
|
|
280
|
+
vi.useFakeTimers();
|
|
281
|
+
|
|
282
|
+
// @ts-ignore
|
|
283
|
+
publisher['pc'].iceConnectionState = 'disconnected';
|
|
284
|
+
publisher['onIceConnectionStateChange']();
|
|
285
|
+
// @ts-ignore
|
|
286
|
+
publisher['pc'].iceConnectionState = 'connected';
|
|
287
|
+
|
|
288
|
+
vi.runAllTimers();
|
|
289
|
+
expect(publisher.restartIce).not.toHaveBeenCalled();
|
|
290
|
+
});
|
|
291
|
+
});
|
|
212
292
|
});
|