@stream-io/video-client 1.49.0 → 1.51.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 +22 -0
- package/dist/index.browser.es.js +1404 -682
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +1404 -682
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +1404 -682
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +43 -3
- package/dist/src/coordinator/connection/client.d.ts +1 -1
- package/dist/src/coordinator/connection/connection.d.ts +31 -25
- package/dist/src/coordinator/connection/types.d.ts +14 -0
- package/dist/src/coordinator/connection/utils.d.ts +1 -0
- package/dist/src/devices/CameraManager.d.ts +1 -0
- package/dist/src/devices/DeviceManager.d.ts +23 -0
- package/dist/src/devices/DeviceManagerState.d.ts +0 -1
- package/dist/src/devices/VirtualDevice.d.ts +59 -0
- package/dist/src/devices/devicePersistence.d.ts +1 -1
- package/dist/src/devices/index.d.ts +1 -0
- package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
- package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
- package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
- package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
- package/dist/src/helpers/DynascaleManager.d.ts +8 -86
- package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
- package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
- package/dist/src/helpers/ViewportTracker.d.ts +11 -17
- package/dist/src/helpers/browsers.d.ts +13 -0
- package/dist/src/helpers/concurrency.d.ts +6 -4
- package/dist/src/rtc/BasePeerConnection.d.ts +7 -2
- package/dist/src/rtc/Publisher.d.ts +38 -3
- package/dist/src/rtc/Subscriber.d.ts +1 -0
- package/dist/src/rtc/TransceiverCache.d.ts +5 -1
- package/dist/src/rtc/helpers/degradationPreference.d.ts +3 -0
- package/dist/src/rtc/types.d.ts +2 -0
- package/dist/src/stats/rtc/types.d.ts +1 -1
- package/dist/src/store/rxUtils.d.ts +9 -0
- package/dist/src/types.d.ts +18 -0
- package/package.json +2 -2
- package/src/Call.ts +111 -33
- package/src/__tests__/Call.lifecycle.test.ts +67 -0
- package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
- package/src/coordinator/connection/client.ts +1 -1
- package/src/coordinator/connection/connection.ts +149 -96
- package/src/coordinator/connection/types.ts +15 -0
- package/src/coordinator/connection/utils.ts +15 -0
- package/src/devices/CameraManager.ts +9 -2
- package/src/devices/DeviceManager.ts +239 -39
- package/src/devices/DeviceManagerState.ts +4 -2
- package/src/devices/VirtualDevice.ts +69 -0
- package/src/devices/__tests__/CameraManager.test.ts +19 -0
- package/src/devices/__tests__/DeviceManager.test.ts +404 -1
- package/src/devices/__tests__/mocks.ts +2 -0
- package/src/devices/devicePersistence.ts +2 -1
- package/src/devices/index.ts +1 -0
- package/src/gen/video/sfu/event/events.ts +15 -0
- package/src/gen/video/sfu/models/models.ts +44 -0
- package/src/helpers/AudioBindingsWatchdog.ts +10 -7
- package/src/helpers/BlockedAudioTracker.ts +74 -0
- package/src/helpers/DynascaleManager.ts +46 -337
- package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
- package/src/helpers/TrackSubscriptionManager.ts +243 -0
- package/src/helpers/ViewportTracker.ts +74 -19
- package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
- package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
- package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -0
- package/src/helpers/__tests__/TrackSubscriptionManager.test.ts +310 -0
- package/src/helpers/__tests__/ViewportTracker.test.ts +83 -0
- package/src/helpers/__tests__/browsers.test.ts +85 -1
- package/src/helpers/browsers.ts +24 -0
- package/src/helpers/concurrency.ts +9 -10
- package/src/rtc/BasePeerConnection.ts +15 -3
- package/src/rtc/Publisher.ts +185 -40
- package/src/rtc/Subscriber.ts +42 -14
- package/src/rtc/TransceiverCache.ts +10 -3
- package/src/rtc/__tests__/Call.reconnect.test.ts +121 -2
- package/src/rtc/__tests__/Publisher.test.ts +747 -88
- package/src/rtc/__tests__/Subscriber.test.ts +148 -3
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
- package/src/rtc/helpers/__tests__/degradationPreference.test.ts +55 -0
- package/src/rtc/helpers/degradationPreference.ts +40 -0
- package/src/rtc/types.ts +2 -0
- package/src/stats/rtc/types.ts +1 -0
- package/src/store/__tests__/rxUtils.test.ts +276 -0
- package/src/store/rxUtils.ts +19 -0
- package/src/types.ts +19 -0
package/src/rtc/Publisher.ts
CHANGED
|
@@ -20,10 +20,15 @@ import {
|
|
|
20
20
|
toVideoLayers,
|
|
21
21
|
} from './layers';
|
|
22
22
|
import { isSvcCodec } from './codecs';
|
|
23
|
+
import {
|
|
24
|
+
fromRTCDegradationPreference,
|
|
25
|
+
toRTCDegradationPreference,
|
|
26
|
+
} from './helpers/degradationPreference';
|
|
23
27
|
import { isAudioTrackType } from './helpers/tracks';
|
|
24
28
|
import { extractMid, removeCodecsExcept, setStartBitrate } from './helpers/sdp';
|
|
25
29
|
import { withoutConcurrency } from '../helpers/concurrency';
|
|
26
30
|
import { isReactNative } from '../helpers/platforms';
|
|
31
|
+
import { isFirefox } from '../helpers/browsers';
|
|
27
32
|
|
|
28
33
|
/**
|
|
29
34
|
* The `Publisher` is responsible for publishing/unpublishing media streams to/from the SFU
|
|
@@ -52,7 +57,16 @@ export class Publisher extends BasePeerConnection {
|
|
|
52
57
|
|
|
53
58
|
this.on('changePublishQuality', async (event) => {
|
|
54
59
|
for (const videoSender of event.videoSenders) {
|
|
55
|
-
|
|
60
|
+
// if not publishing, update the encodingConfigCache and don't modify the state.
|
|
61
|
+
// we'll apply this config on the next publish/unmute.
|
|
62
|
+
const { trackType, publishOptionId } = videoSender;
|
|
63
|
+
const bundle = this.transceiverCache.getBy(publishOptionId, trackType);
|
|
64
|
+
if (bundle) {
|
|
65
|
+
this.transceiverCache.update(bundle.publishOption, { videoSender });
|
|
66
|
+
}
|
|
67
|
+
if (isFirefox() && !this.isPublishing(trackType)) continue;
|
|
68
|
+
|
|
69
|
+
await this.changePublishQuality(videoSender, bundle);
|
|
56
70
|
}
|
|
57
71
|
});
|
|
58
72
|
|
|
@@ -65,9 +79,13 @@ export class Publisher extends BasePeerConnection {
|
|
|
65
79
|
/**
|
|
66
80
|
* Disposes this Publisher instance.
|
|
67
81
|
*/
|
|
68
|
-
dispose() {
|
|
69
|
-
super.dispose();
|
|
70
|
-
|
|
82
|
+
async dispose(): Promise<void> {
|
|
83
|
+
await super.dispose();
|
|
84
|
+
try {
|
|
85
|
+
await this.stopAllTracks();
|
|
86
|
+
} catch (err) {
|
|
87
|
+
this.logger.warn('Failed to stop tracks during dispose', err);
|
|
88
|
+
}
|
|
71
89
|
this.clonedTracks.clear();
|
|
72
90
|
}
|
|
73
91
|
|
|
@@ -97,17 +115,12 @@ export class Publisher extends BasePeerConnection {
|
|
|
97
115
|
// appear in the SDP in multiple transceivers
|
|
98
116
|
const trackToPublish = this.cloneTrack(track);
|
|
99
117
|
|
|
100
|
-
const
|
|
101
|
-
if (!
|
|
118
|
+
const bundle = this.transceiverCache.get(publishOption);
|
|
119
|
+
if (!bundle) {
|
|
102
120
|
await this.addTransceiver(trackToPublish, publishOption, options);
|
|
103
121
|
} else {
|
|
104
|
-
const previousTrack = transceiver.sender.track;
|
|
105
|
-
await this.updateTransceiver(
|
|
106
|
-
transceiver,
|
|
107
|
-
trackToPublish,
|
|
108
|
-
trackType,
|
|
109
|
-
options,
|
|
110
|
-
);
|
|
122
|
+
const previousTrack = bundle.transceiver.sender.track;
|
|
123
|
+
await this.updateTransceiver(bundle, trackToPublish, options);
|
|
111
124
|
if (!isReactNative()) {
|
|
112
125
|
this.stopTrack(previousTrack);
|
|
113
126
|
}
|
|
@@ -135,7 +148,9 @@ export class Publisher extends BasePeerConnection {
|
|
|
135
148
|
});
|
|
136
149
|
|
|
137
150
|
const params = transceiver.sender.getParameters();
|
|
138
|
-
params.degradationPreference =
|
|
151
|
+
params.degradationPreference =
|
|
152
|
+
toRTCDegradationPreference(publishOption.degradationPreference) ??
|
|
153
|
+
'maintain-framerate';
|
|
139
154
|
await transceiver.sender.setParameters(params);
|
|
140
155
|
|
|
141
156
|
const trackType = publishOption.trackType;
|
|
@@ -150,15 +165,22 @@ export class Publisher extends BasePeerConnection {
|
|
|
150
165
|
* Updates the transceiver with the given track and track type.
|
|
151
166
|
*/
|
|
152
167
|
private updateTransceiver = async (
|
|
153
|
-
|
|
168
|
+
bundle: PublishBundle,
|
|
154
169
|
track: MediaStreamTrack | null,
|
|
155
|
-
trackType: TrackType,
|
|
156
170
|
options: TrackPublishOptions = {},
|
|
157
171
|
) => {
|
|
172
|
+
const { transceiver, publishOption } = bundle;
|
|
173
|
+
const trackType = publishOption.trackType;
|
|
158
174
|
const sender = transceiver.sender;
|
|
159
175
|
if (sender.track) this.trackIdToTrackType.delete(sender.track.id);
|
|
160
176
|
await sender.replaceTrack(track);
|
|
161
|
-
if (track)
|
|
177
|
+
if (track) {
|
|
178
|
+
this.trackIdToTrackType.set(track.id, trackType);
|
|
179
|
+
if (isFirefox() && bundle.videoSender) {
|
|
180
|
+
// restore the encoding config from the cache, if any
|
|
181
|
+
await this.changePublishQuality(bundle.videoSender, bundle);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
162
184
|
if (isAudioTrackType(trackType)) {
|
|
163
185
|
await this.updateAudioPublishOptions(trackType, options);
|
|
164
186
|
}
|
|
@@ -227,7 +249,7 @@ export class Publisher extends BasePeerConnection {
|
|
|
227
249
|
if (hasPublishOption) continue;
|
|
228
250
|
// it is safe to stop the track here, it is a clone
|
|
229
251
|
this.stopTrack(transceiver.sender.track);
|
|
230
|
-
await this.updateTransceiver(
|
|
252
|
+
await this.updateTransceiver(item, null);
|
|
231
253
|
}
|
|
232
254
|
};
|
|
233
255
|
|
|
@@ -249,41 +271,84 @@ export class Publisher extends BasePeerConnection {
|
|
|
249
271
|
};
|
|
250
272
|
|
|
251
273
|
/**
|
|
252
|
-
*
|
|
274
|
+
* Re-arms the encoder for the given track type by detaching and
|
|
275
|
+
* reattaching the currently published track on each matching sender.
|
|
276
|
+
*
|
|
277
|
+
* Workaround for a WebKit / iOS Safari quirk: after a system audio
|
|
278
|
+
* session interruption (Siri, PSTN call), the `RTCRtpSender` encoder
|
|
279
|
+
* can stop producing RTP packets even though the underlying
|
|
280
|
+
* `MediaStreamTrack` is `live` and `track.muted === false`.
|
|
281
|
+
* `replaceTrack(null)` followed by `replaceTrack(track)` resets the
|
|
282
|
+
* sender's encoder pipeline without renegotiation, restoring packet
|
|
283
|
+
* flow with the same SSRC.
|
|
284
|
+
*
|
|
285
|
+
* No-op when nothing is published for the given track type.
|
|
286
|
+
*
|
|
287
|
+
* @param trackType the track type to refresh.
|
|
253
288
|
*/
|
|
254
|
-
|
|
289
|
+
refreshTrack = async (trackType: TrackType) => {
|
|
255
290
|
for (const item of this.transceiverCache.items()) {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
291
|
+
if (item.publishOption.trackType !== trackType) continue;
|
|
292
|
+
const { sender } = item.transceiver;
|
|
293
|
+
const track = sender.track;
|
|
294
|
+
if (!track || track.readyState !== 'live') continue;
|
|
295
|
+
try {
|
|
296
|
+
await sender.replaceTrack(null);
|
|
297
|
+
await sender.replaceTrack(track);
|
|
298
|
+
this.logger.debug(`Refreshed ${TrackType[trackType]} sender`);
|
|
299
|
+
} catch (err) {
|
|
300
|
+
this.logger.warn(`Failed to refresh ${TrackType[trackType]}`, err);
|
|
301
|
+
}
|
|
259
302
|
}
|
|
260
303
|
};
|
|
261
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Stops the cloned track that is being published to the SFU.
|
|
307
|
+
*/
|
|
308
|
+
stopTracks = async (...trackTypes: TrackType[]) => {
|
|
309
|
+
return withoutConcurrency(
|
|
310
|
+
this.eventLockKey('changePublishQuality'),
|
|
311
|
+
async () => {
|
|
312
|
+
for (const item of this.transceiverCache.items()) {
|
|
313
|
+
const { publishOption, transceiver } = item;
|
|
314
|
+
if (!trackTypes.includes(publishOption.trackType)) continue;
|
|
315
|
+
const track = transceiver.sender.track;
|
|
316
|
+
await this.silenceSenderOnFirefox(item);
|
|
317
|
+
this.stopTrack(track);
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
);
|
|
321
|
+
};
|
|
322
|
+
|
|
262
323
|
/**
|
|
263
324
|
* Stops all the cloned tracks that are being published to the SFU.
|
|
264
325
|
*/
|
|
265
|
-
stopAllTracks = () => {
|
|
266
|
-
|
|
267
|
-
this.
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
326
|
+
stopAllTracks = async () => {
|
|
327
|
+
return withoutConcurrency(
|
|
328
|
+
this.eventLockKey('changePublishQuality'),
|
|
329
|
+
async () => {
|
|
330
|
+
for (const item of this.transceiverCache.items()) {
|
|
331
|
+
const track = item.transceiver.sender.track;
|
|
332
|
+
await this.silenceSenderOnFirefox(item);
|
|
333
|
+
this.stopTrack(track);
|
|
334
|
+
}
|
|
335
|
+
for (const track of this.clonedTracks) {
|
|
336
|
+
this.stopTrack(track);
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
);
|
|
272
340
|
};
|
|
273
341
|
|
|
274
|
-
private changePublishQuality = async (
|
|
275
|
-
|
|
276
|
-
|
|
342
|
+
private changePublishQuality = async (
|
|
343
|
+
videoSender: VideoSender,
|
|
344
|
+
bundle: PublishBundle | undefined,
|
|
345
|
+
) => {
|
|
346
|
+
const enabledLayers = videoSender.layers.filter((l) => l.active);
|
|
277
347
|
|
|
278
348
|
const tag = 'Update publish quality:';
|
|
279
349
|
this.logger.info(`${tag} requested layers by SFU:`, enabledLayers);
|
|
280
350
|
|
|
281
|
-
const
|
|
282
|
-
(t) =>
|
|
283
|
-
t.publishOption.id === publishOptionId &&
|
|
284
|
-
t.publishOption.trackType === trackType,
|
|
285
|
-
);
|
|
286
|
-
const sender = transceiverId?.transceiver.sender;
|
|
351
|
+
const sender = bundle?.transceiver.sender;
|
|
287
352
|
if (!sender) {
|
|
288
353
|
return this.logger.warn(`${tag} no video sender found.`);
|
|
289
354
|
}
|
|
@@ -293,7 +358,7 @@ export class Publisher extends BasePeerConnection {
|
|
|
293
358
|
return this.logger.warn(`${tag} there are no encodings set.`);
|
|
294
359
|
}
|
|
295
360
|
|
|
296
|
-
const codecInUse =
|
|
361
|
+
const codecInUse = bundle?.publishOption.codec?.name;
|
|
297
362
|
const usesSvcCodec = codecInUse && isSvcCodec(codecInUse);
|
|
298
363
|
|
|
299
364
|
let changed = false;
|
|
@@ -344,6 +409,17 @@ export class Publisher extends BasePeerConnection {
|
|
|
344
409
|
}
|
|
345
410
|
}
|
|
346
411
|
|
|
412
|
+
const degradationPreference = toRTCDegradationPreference(
|
|
413
|
+
videoSender.degradationPreference,
|
|
414
|
+
);
|
|
415
|
+
if (
|
|
416
|
+
degradationPreference &&
|
|
417
|
+
params.degradationPreference !== degradationPreference
|
|
418
|
+
) {
|
|
419
|
+
params.degradationPreference = degradationPreference;
|
|
420
|
+
changed = true;
|
|
421
|
+
}
|
|
422
|
+
|
|
347
423
|
const activeEncoders = params.encodings.filter((e) => e.active);
|
|
348
424
|
if (!changed) {
|
|
349
425
|
return this.logger.info(`${tag} no change:`, activeEncoders);
|
|
@@ -514,4 +590,73 @@ export class Publisher extends BasePeerConnection {
|
|
|
514
590
|
track.stop();
|
|
515
591
|
this.clonedTracks.delete(track);
|
|
516
592
|
};
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Silences a Firefox sender on the wire during unpublish.
|
|
596
|
+
*
|
|
597
|
+
* Firefox keeps emitting RTP after track.stop(), but the right lever
|
|
598
|
+
* differs by track type:
|
|
599
|
+
* - audio: `replaceTrack(null)` is the only reliable silencer;
|
|
600
|
+
* `setParameters({encodings:[...active:false]})` does NOT stop
|
|
601
|
+
* the Opus encoder.
|
|
602
|
+
* - video: `setParameters({encodings:[...active:false]})` pauses
|
|
603
|
+
* the encoder; `replaceTrack(null)` does NOT reliably stop the
|
|
604
|
+
* video encoder. The prior active=true configuration is captured
|
|
605
|
+
* onto `bundle.videoSender` so `updateTransceiver` can restore
|
|
606
|
+
* it on the next publish.
|
|
607
|
+
*
|
|
608
|
+
* No-op on non-Firefox browsers and during teardown.
|
|
609
|
+
*/
|
|
610
|
+
private silenceSenderOnFirefox = async (bundle: PublishBundle) => {
|
|
611
|
+
if (this.isDisposed || !isFirefox()) return;
|
|
612
|
+
const { transceiver, publishOption } = bundle;
|
|
613
|
+
if (isAudioTrackType(publishOption.trackType)) {
|
|
614
|
+
await transceiver.sender.replaceTrack(null).catch((err) => {
|
|
615
|
+
this.logger.warn('Failed to clear audio sender track', err);
|
|
616
|
+
});
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
await this.disableAllEncodings(bundle);
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
private disableAllEncodings = async (bundle: PublishBundle) => {
|
|
623
|
+
const { transceiver, publishOption } = bundle;
|
|
624
|
+
const sender = transceiver.sender;
|
|
625
|
+
const params = sender.getParameters();
|
|
626
|
+
if (!params.encodings || params.encodings.length === 0) return;
|
|
627
|
+
|
|
628
|
+
if (!bundle.videoSender) {
|
|
629
|
+
this.transceiverCache.update(publishOption, {
|
|
630
|
+
videoSender: {
|
|
631
|
+
trackType: publishOption.trackType,
|
|
632
|
+
publishOptionId: publishOption.id,
|
|
633
|
+
codec: publishOption.codec,
|
|
634
|
+
degradationPreference: fromRTCDegradationPreference(
|
|
635
|
+
params.degradationPreference,
|
|
636
|
+
),
|
|
637
|
+
layers: params.encodings.map((e) => ({
|
|
638
|
+
name: e.rid ?? 'q',
|
|
639
|
+
active: e.active ?? true,
|
|
640
|
+
maxBitrate: e.maxBitrate ?? 0,
|
|
641
|
+
scaleResolutionDownBy: e.scaleResolutionDownBy ?? 0,
|
|
642
|
+
maxFramerate: e.maxFramerate ?? 0,
|
|
643
|
+
// @ts-expect-error scalabilityMode is not in the typedefs yet
|
|
644
|
+
scalabilityMode: e.scalabilityMode ?? '',
|
|
645
|
+
})),
|
|
646
|
+
},
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
let changed = false;
|
|
651
|
+
for (const encoding of params.encodings) {
|
|
652
|
+
if (encoding.active !== false) {
|
|
653
|
+
encoding.active = false;
|
|
654
|
+
changed = true;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
if (!changed) return;
|
|
658
|
+
await sender.setParameters(params).catch((err) => {
|
|
659
|
+
this.logger.error('Failed to disable video sender encodings:', err);
|
|
660
|
+
});
|
|
661
|
+
};
|
|
517
662
|
}
|
package/src/rtc/Subscriber.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { BasePeerConnection } from './BasePeerConnection';
|
|
2
2
|
import { BasePeerConnectionOpts } from './types';
|
|
3
3
|
import { NegotiationError } from './NegotiationError';
|
|
4
|
-
import { PeerType } from '../gen/video/sfu/models/models';
|
|
4
|
+
import { PeerType, TrackType } from '../gen/video/sfu/models/models';
|
|
5
5
|
import { SubscriberOffer } from '../gen/video/sfu/event/events';
|
|
6
6
|
import { toTrackType, trackTypeToParticipantStreamKey } from './helpers/tracks';
|
|
7
|
+
import { pushToIfMissing, removeFromIfPresent } from '../helpers/array';
|
|
7
8
|
import { enableStereo, removeCodecsExcept } from './helpers/sdp';
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -67,7 +68,8 @@ export class Subscriber extends BasePeerConnection {
|
|
|
67
68
|
};
|
|
68
69
|
|
|
69
70
|
private handleOnTrack = (e: RTCTrackEvent) => {
|
|
70
|
-
const
|
|
71
|
+
const { streams, track } = e;
|
|
72
|
+
const [primaryStream] = streams;
|
|
71
73
|
// example: `e3f6aaf8-b03d-4911-be36-83f47d37a76a:TRACK_TYPE_VIDEO`
|
|
72
74
|
const [trackId, rawTrackType] = primaryStream.id.split(':');
|
|
73
75
|
const participantToUpdate = this.state.participants.find(
|
|
@@ -75,30 +77,35 @@ export class Subscriber extends BasePeerConnection {
|
|
|
75
77
|
);
|
|
76
78
|
this.logger.debug(
|
|
77
79
|
`[onTrack]: Got remote ${rawTrackType} track for userId: ${participantToUpdate?.userId}`,
|
|
78
|
-
|
|
79
|
-
|
|
80
|
+
track.id,
|
|
81
|
+
track,
|
|
80
82
|
);
|
|
81
83
|
|
|
84
|
+
const trackType = toTrackType(rawTrackType);
|
|
85
|
+
if (!trackType) {
|
|
86
|
+
return this.logger.error(`Unknown track type: ${rawTrackType}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
82
89
|
const trackDebugInfo = `${participantToUpdate?.userId} ${rawTrackType}:${trackId}`;
|
|
83
|
-
|
|
90
|
+
track.addEventListener('mute', () => {
|
|
84
91
|
this.logger.info(`[onTrack]: Track muted: ${trackDebugInfo}`);
|
|
92
|
+
this.setRemoteTrackInterrupted(trackId, trackType, true);
|
|
85
93
|
});
|
|
86
|
-
|
|
87
|
-
e.track.addEventListener('unmute', () => {
|
|
94
|
+
track.addEventListener('unmute', () => {
|
|
88
95
|
this.logger.info(`[onTrack]: Track unmuted: ${trackDebugInfo}`);
|
|
96
|
+
this.setRemoteTrackInterrupted(trackId, trackType, false);
|
|
89
97
|
});
|
|
90
|
-
|
|
91
|
-
e.track.addEventListener('ended', () => {
|
|
98
|
+
track.addEventListener('ended', () => {
|
|
92
99
|
this.logger.info(`[onTrack]: Track ended: ${trackDebugInfo}`);
|
|
100
|
+
this.setRemoteTrackInterrupted(trackId, trackType, false);
|
|
93
101
|
this.state.removeOrphanedTrack(primaryStream.id);
|
|
94
102
|
});
|
|
95
103
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
return this.logger.error(`Unknown track type: ${rawTrackType}`);
|
|
104
|
+
if (track.muted) {
|
|
105
|
+
this.setRemoteTrackInterrupted(trackId, trackType, true);
|
|
99
106
|
}
|
|
100
107
|
|
|
101
|
-
this.trackIdToTrackType.set(
|
|
108
|
+
this.trackIdToTrackType.set(track.id, trackType);
|
|
102
109
|
|
|
103
110
|
if (!participantToUpdate) {
|
|
104
111
|
this.logger.warn(
|
|
@@ -133,7 +140,7 @@ export class Subscriber extends BasePeerConnection {
|
|
|
133
140
|
// now, dispose the previous stream if it exists
|
|
134
141
|
if (previousStream) {
|
|
135
142
|
this.logger.info(
|
|
136
|
-
`[onTrack]: Cleaning up previous remote ${
|
|
143
|
+
`[onTrack]: Cleaning up previous remote ${track.kind} tracks for userId: ${participantToUpdate.userId}`,
|
|
137
144
|
);
|
|
138
145
|
previousStream.getTracks().forEach((t) => {
|
|
139
146
|
t.stop();
|
|
@@ -142,6 +149,27 @@ export class Subscriber extends BasePeerConnection {
|
|
|
142
149
|
}
|
|
143
150
|
};
|
|
144
151
|
|
|
152
|
+
private setRemoteTrackInterrupted = (
|
|
153
|
+
trackId: string,
|
|
154
|
+
trackType: TrackType,
|
|
155
|
+
interrupted: boolean,
|
|
156
|
+
) => {
|
|
157
|
+
if (trackType !== TrackType.AUDIO) return;
|
|
158
|
+
const target = this.state.participants.find(
|
|
159
|
+
(p) => p.trackLookupPrefix === trackId,
|
|
160
|
+
);
|
|
161
|
+
if (!target) return;
|
|
162
|
+
this.state.updateParticipant(target.sessionId, (p) => {
|
|
163
|
+
const current = p.interruptedTracks ?? [];
|
|
164
|
+
const has = current.includes(trackType);
|
|
165
|
+
if (interrupted === has) return {};
|
|
166
|
+
const next = interrupted
|
|
167
|
+
? pushToIfMissing([...current], trackType)
|
|
168
|
+
: removeFromIfPresent([...current], trackType);
|
|
169
|
+
return { interruptedTracks: next };
|
|
170
|
+
});
|
|
171
|
+
};
|
|
172
|
+
|
|
145
173
|
private negotiate = async (subscriberOffer: SubscriberOffer) => {
|
|
146
174
|
await this.pc.setRemoteDescription({
|
|
147
175
|
type: 'offer',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PublishOption } from '../gen/video/sfu/models/models';
|
|
1
|
+
import { PublishOption, TrackType } from '../gen/video/sfu/models/models';
|
|
2
2
|
import type { OptimalVideoLayer } from './layers';
|
|
3
3
|
import type { PublishBundle, TrackLayersCache } from './types';
|
|
4
4
|
|
|
@@ -25,10 +25,17 @@ export class TransceiverCache {
|
|
|
25
25
|
* Gets the transceiver for the given publish option.
|
|
26
26
|
*/
|
|
27
27
|
get = (publishOption: PublishOption): PublishBundle | undefined => {
|
|
28
|
+
return this.getBy(publishOption.id, publishOption.trackType);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Gets the transceiver for the given publish option id and track type.
|
|
33
|
+
*/
|
|
34
|
+
getBy = (publishOptionId: number, trackType: TrackType) => {
|
|
28
35
|
return this.cache.find(
|
|
29
36
|
(bundle) =>
|
|
30
|
-
bundle.publishOption.id ===
|
|
31
|
-
bundle.publishOption.trackType ===
|
|
37
|
+
bundle.publishOption.id === publishOptionId &&
|
|
38
|
+
bundle.publishOption.trackType === trackType,
|
|
32
39
|
);
|
|
33
40
|
};
|
|
34
41
|
|
|
@@ -43,7 +43,7 @@ const makeCall = () => {
|
|
|
43
43
|
*/
|
|
44
44
|
const primeForReconnect = (call: Call) => {
|
|
45
45
|
// put the call in a non-terminal, non-JOINED state so the do-while iterates
|
|
46
|
-
call.state.setCallingState(CallingState.
|
|
46
|
+
call.state.setCallingState(CallingState.IDLE);
|
|
47
47
|
// force the strategy-decider in the catch block to always pick REJOIN,
|
|
48
48
|
// so tests that care about the rejoin rate limiter don't bounce to FAST
|
|
49
49
|
// based on wall-clock timing. Individual tests that want to exercise the
|
|
@@ -136,7 +136,7 @@ describe('Call reconnect stopping conditions', () => {
|
|
|
136
136
|
});
|
|
137
137
|
|
|
138
138
|
for (let i = 0; i < 5; i++) {
|
|
139
|
-
call.state.setCallingState(CallingState.
|
|
139
|
+
call.state.setCallingState(CallingState.IDLE);
|
|
140
140
|
await call['reconnect'](WebsocketReconnectStrategy.FAST, 'test');
|
|
141
141
|
}
|
|
142
142
|
|
|
@@ -404,6 +404,125 @@ describe('Call reconnect stopping conditions', () => {
|
|
|
404
404
|
});
|
|
405
405
|
});
|
|
406
406
|
|
|
407
|
+
/**
|
|
408
|
+
* Entry-condition bails. `reconnect()` must drop new triggers when:
|
|
409
|
+
* - A join/reconnect/migrate lifecycle is already in progress.
|
|
410
|
+
* - A reconnect is already queued via `hasPending(reconnectConcurrencyTag)`.
|
|
411
|
+
* - The terminal `RECONNECTING_FAILED` state has been reached.
|
|
412
|
+
*
|
|
413
|
+
* These are pure short-circuits — none of the strategy implementations
|
|
414
|
+
* should be invoked.
|
|
415
|
+
*/
|
|
416
|
+
describe('Call reconnect entry-condition bails', () => {
|
|
417
|
+
let call: Call;
|
|
418
|
+
|
|
419
|
+
beforeEach(() => {
|
|
420
|
+
call = makeCall();
|
|
421
|
+
vi.spyOn(connectionUtils, 'sleep').mockResolvedValue(undefined);
|
|
422
|
+
vi.spyOn(call, 'leave').mockResolvedValue(undefined);
|
|
423
|
+
vi.spyOn(call, 'get').mockResolvedValue({} as never);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
afterEach(() => {
|
|
427
|
+
vi.clearAllMocks();
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
const stubAllStrategies = () => ({
|
|
431
|
+
fast: vi
|
|
432
|
+
.spyOn(
|
|
433
|
+
call as unknown as { reconnectFast: () => Promise<void> },
|
|
434
|
+
'reconnectFast',
|
|
435
|
+
)
|
|
436
|
+
.mockResolvedValue(undefined),
|
|
437
|
+
rejoin: vi
|
|
438
|
+
.spyOn(
|
|
439
|
+
call as unknown as { reconnectRejoin: () => Promise<void> },
|
|
440
|
+
'reconnectRejoin',
|
|
441
|
+
)
|
|
442
|
+
.mockResolvedValue(undefined),
|
|
443
|
+
migrate: vi
|
|
444
|
+
.spyOn(
|
|
445
|
+
call as unknown as { reconnectMigrate: () => Promise<void> },
|
|
446
|
+
'reconnectMigrate',
|
|
447
|
+
)
|
|
448
|
+
.mockResolvedValue(undefined),
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('bails immediately when state is JOINING — Call.join owns recovery during the initial join window', async () => {
|
|
452
|
+
const strategies = stubAllStrategies();
|
|
453
|
+
call.state.setCallingState(CallingState.JOINING);
|
|
454
|
+
|
|
455
|
+
await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
|
|
456
|
+
|
|
457
|
+
expect(strategies.fast).not.toHaveBeenCalled();
|
|
458
|
+
expect(strategies.rejoin).not.toHaveBeenCalled();
|
|
459
|
+
expect(strategies.migrate).not.toHaveBeenCalled();
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('bails immediately when state is RECONNECTING — another reconnect is already running', async () => {
|
|
463
|
+
const strategies = stubAllStrategies();
|
|
464
|
+
call.state.setCallingState(CallingState.RECONNECTING);
|
|
465
|
+
|
|
466
|
+
await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
|
|
467
|
+
|
|
468
|
+
expect(strategies.fast).not.toHaveBeenCalled();
|
|
469
|
+
expect(strategies.rejoin).not.toHaveBeenCalled();
|
|
470
|
+
expect(strategies.migrate).not.toHaveBeenCalled();
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('bails immediately when state is MIGRATING — reconnectMigrate is in flight', async () => {
|
|
474
|
+
const strategies = stubAllStrategies();
|
|
475
|
+
call.state.setCallingState(CallingState.MIGRATING);
|
|
476
|
+
|
|
477
|
+
await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
|
|
478
|
+
|
|
479
|
+
expect(strategies.fast).not.toHaveBeenCalled();
|
|
480
|
+
expect(strategies.rejoin).not.toHaveBeenCalled();
|
|
481
|
+
expect(strategies.migrate).not.toHaveBeenCalled();
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it('bails immediately when state is RECONNECTING_FAILED — terminal, no further attempts', async () => {
|
|
485
|
+
const strategies = stubAllStrategies();
|
|
486
|
+
call.state.setCallingState(CallingState.RECONNECTING_FAILED);
|
|
487
|
+
|
|
488
|
+
await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
|
|
489
|
+
|
|
490
|
+
expect(strategies.fast).not.toHaveBeenCalled();
|
|
491
|
+
expect(strategies.rejoin).not.toHaveBeenCalled();
|
|
492
|
+
expect(strategies.migrate).not.toHaveBeenCalled();
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('drops duplicate reconnect calls while one is already pending', async () => {
|
|
496
|
+
let resolveFirst: () => void = () => {};
|
|
497
|
+
const firstStrategy = new Promise<void>((resolve) => {
|
|
498
|
+
resolveFirst = resolve;
|
|
499
|
+
});
|
|
500
|
+
const rejoinSpy = vi
|
|
501
|
+
.spyOn(
|
|
502
|
+
call as unknown as { reconnectRejoin: () => Promise<void> },
|
|
503
|
+
'reconnectRejoin',
|
|
504
|
+
)
|
|
505
|
+
.mockImplementationOnce(async () => {
|
|
506
|
+
await firstStrategy;
|
|
507
|
+
call.state.setCallingState(CallingState.JOINED);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
primeForReconnect(call);
|
|
511
|
+
|
|
512
|
+
const firstCall = call['reconnect'](
|
|
513
|
+
WebsocketReconnectStrategy.REJOIN,
|
|
514
|
+
'first',
|
|
515
|
+
);
|
|
516
|
+
await Promise.resolve();
|
|
517
|
+
call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'second');
|
|
518
|
+
|
|
519
|
+
expect(rejoinSpy).toHaveBeenCalledTimes(1);
|
|
520
|
+
|
|
521
|
+
resolveFirst();
|
|
522
|
+
await firstCall;
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
407
526
|
/**
|
|
408
527
|
* End-to-end-ish wiring tests: simulate failures at the peer-connection layer
|
|
409
528
|
* and verify they propagate through `onReconnectionNeeded` → `Call.reconnect` →
|