@stream-io/video-client 1.50.0 → 1.52.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 +24 -0
- package/dist/index.browser.es.js +597 -70
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +597 -69
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +597 -70
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +1 -0
- package/dist/src/devices/CameraManager.d.ts +1 -0
- package/dist/src/devices/DeviceManager.d.ts +20 -0
- 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 +4 -0
- package/dist/src/gen/video/sfu/models/models.d.ts +204 -2
- 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 +67 -1
- package/dist/src/helpers/participantUtils.d.ts +10 -0
- package/dist/src/rtc/BasePeerConnection.d.ts +8 -3
- package/dist/src/rtc/Publisher.d.ts +21 -3
- package/dist/src/rtc/TransceiverCache.d.ts +5 -1
- package/dist/src/rtc/helpers/degradationPreference.d.ts +1 -0
- package/dist/src/rtc/types.d.ts +3 -0
- package/dist/src/stats/rtc/StatsTracer.d.ts +2 -1
- package/dist/src/stats/utils.d.ts +1 -0
- package/package.json +14 -14
- package/src/Call.ts +27 -12
- package/src/devices/CameraManager.ts +9 -2
- package/src/devices/DeviceManager.ts +148 -8
- package/src/devices/DeviceManagerState.ts +4 -1
- package/src/devices/VirtualDevice.ts +69 -0
- package/src/devices/__tests__/CameraManager.test.ts +22 -1
- package/src/devices/__tests__/DeviceManager.test.ts +124 -2
- package/src/devices/__tests__/MicrophoneManager.test.ts +3 -1
- package/src/devices/__tests__/MicrophoneManagerRN.test.ts +3 -1
- package/src/devices/__tests__/ScreenShareManager.test.ts +3 -1
- package/src/devices/__tests__/web-audio.mocks.ts +3 -1
- package/src/devices/devicePersistence.ts +2 -1
- package/src/devices/index.ts +1 -0
- package/src/gen/video/sfu/event/events.ts +10 -0
- package/src/gen/video/sfu/models/models.ts +338 -0
- package/src/gen/video/sfu/signal_rpc/signal.client.ts +28 -2
- package/src/gen/video/sfu/signal_rpc/signal.ts +121 -15
- package/src/helpers/__tests__/DynascaleManager.test.ts +8 -7
- package/src/helpers/__tests__/browsers.test.ts +4 -4
- package/src/helpers/__tests__/participantUtils.test.ts +47 -0
- package/src/helpers/client-details.ts +4 -1
- package/src/helpers/participantUtils.ts +15 -0
- package/src/rtc/BasePeerConnection.ts +22 -4
- package/src/rtc/Publisher.ts +140 -41
- package/src/rtc/Subscriber.ts +1 -0
- package/src/rtc/TransceiverCache.ts +10 -3
- package/src/rtc/__tests__/Call.reconnect.test.ts +121 -2
- package/src/rtc/__tests__/Publisher.test.ts +659 -112
- package/src/rtc/__tests__/Subscriber.test.ts +7 -3
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +16 -15
- package/src/rtc/helpers/__tests__/degradationPreference.test.ts +33 -1
- package/src/rtc/helpers/degradationPreference.ts +18 -0
- package/src/rtc/types.ts +3 -0
- package/src/stats/rtc/StatsTracer.ts +25 -4
- package/src/stats/rtc/__tests__/StatsTracer.test.ts +155 -0
|
@@ -1,23 +1,24 @@
|
|
|
1
1
|
// @generated by protobuf-ts 2.10.0 with parameter long_type_string,client_generic,server_none,eslint_disable,optimize_code_size
|
|
2
2
|
// @generated from protobuf file "video/sfu/signal_rpc/signal.proto" (package "stream.video.sfu.signal", syntax proto3)
|
|
3
3
|
// tslint:disable
|
|
4
|
-
import {
|
|
5
|
-
AndroidState,
|
|
6
|
-
AppleState,
|
|
7
|
-
Error,
|
|
8
|
-
ICETrickle,
|
|
9
|
-
InputDevices,
|
|
10
|
-
PeerType,
|
|
11
|
-
PerformanceStats,
|
|
12
|
-
RTMPIngress,
|
|
13
|
-
TrackInfo,
|
|
14
|
-
TrackType,
|
|
15
|
-
VideoDimension,
|
|
16
|
-
WebsocketReconnectStrategy,
|
|
17
|
-
} from '../models/models';
|
|
4
|
+
import { ICETrickle } from '../models/models';
|
|
18
5
|
import { ServiceType } from '@protobuf-ts/runtime-rpc';
|
|
19
6
|
import { MessageType } from '@protobuf-ts/runtime';
|
|
20
|
-
|
|
7
|
+
import { TrackInfo } from '../models/models';
|
|
8
|
+
import { VideoDimension } from '../models/models';
|
|
9
|
+
import { TrackType } from '../models/models';
|
|
10
|
+
import { PeerType } from '../models/models';
|
|
11
|
+
import { PerformanceStats } from '../models/models';
|
|
12
|
+
import { RTMPIngress } from '../models/models';
|
|
13
|
+
import { AppleState } from '../models/models';
|
|
14
|
+
import { AndroidState } from '../models/models';
|
|
15
|
+
import { InputDevices } from '../models/models';
|
|
16
|
+
import { RemoteOutboundRtp } from '../models/models';
|
|
17
|
+
import { RemoteInboundRtp } from '../models/models';
|
|
18
|
+
import { OutboundRtp } from '../models/models';
|
|
19
|
+
import { InboundRtp } from '../models/models';
|
|
20
|
+
import { WebsocketReconnectStrategy } from '../models/models';
|
|
21
|
+
import { Error } from '../models/models';
|
|
21
22
|
/**
|
|
22
23
|
* @generated from protobuf message stream.video.sfu.signal.StartNoiseCancellationRequest
|
|
23
24
|
*/
|
|
@@ -93,6 +94,39 @@ export interface Telemetry {
|
|
|
93
94
|
oneofKind: undefined;
|
|
94
95
|
};
|
|
95
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* @generated from protobuf message stream.video.sfu.signal.SendMetricsRequest
|
|
99
|
+
*/
|
|
100
|
+
export interface SendMetricsRequest {
|
|
101
|
+
/**
|
|
102
|
+
* @generated from protobuf field: string session_id = 1;
|
|
103
|
+
*/
|
|
104
|
+
sessionId: string;
|
|
105
|
+
/**
|
|
106
|
+
* @generated from protobuf field: string unified_session_id = 2;
|
|
107
|
+
*/
|
|
108
|
+
unifiedSessionId: string;
|
|
109
|
+
/**
|
|
110
|
+
* @generated from protobuf field: repeated stream.video.sfu.models.InboundRtp inbounds = 3;
|
|
111
|
+
*/
|
|
112
|
+
inbounds: InboundRtp[];
|
|
113
|
+
/**
|
|
114
|
+
* @generated from protobuf field: repeated stream.video.sfu.models.OutboundRtp outbounds = 4;
|
|
115
|
+
*/
|
|
116
|
+
outbounds: OutboundRtp[];
|
|
117
|
+
/**
|
|
118
|
+
* @generated from protobuf field: repeated stream.video.sfu.models.RemoteInboundRtp remote_inbounds = 5;
|
|
119
|
+
*/
|
|
120
|
+
remoteInbounds: RemoteInboundRtp[];
|
|
121
|
+
/**
|
|
122
|
+
* @generated from protobuf field: repeated stream.video.sfu.models.RemoteOutboundRtp remote_outbounds = 6;
|
|
123
|
+
*/
|
|
124
|
+
remoteOutbounds: RemoteOutboundRtp[];
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* @generated from protobuf message stream.video.sfu.signal.SendMetricsResponse
|
|
128
|
+
*/
|
|
129
|
+
export interface SendMetricsResponse {}
|
|
96
130
|
/**
|
|
97
131
|
* @generated from protobuf message stream.video.sfu.signal.SendStatsRequest
|
|
98
132
|
*/
|
|
@@ -336,6 +370,10 @@ export interface SendAnswerRequest {
|
|
|
336
370
|
* @generated from protobuf field: string session_id = 3;
|
|
337
371
|
*/
|
|
338
372
|
sessionId: string;
|
|
373
|
+
/**
|
|
374
|
+
* @generated from protobuf field: uint32 negotiation_id = 4;
|
|
375
|
+
*/
|
|
376
|
+
negotiationId: number;
|
|
339
377
|
}
|
|
340
378
|
/**
|
|
341
379
|
* @generated from protobuf message stream.video.sfu.signal.SendAnswerResponse
|
|
@@ -502,6 +540,62 @@ class Telemetry$Type extends MessageType<Telemetry> {
|
|
|
502
540
|
*/
|
|
503
541
|
export const Telemetry = new Telemetry$Type();
|
|
504
542
|
// @generated message type with reflection information, may provide speed optimized methods
|
|
543
|
+
class SendMetricsRequest$Type extends MessageType<SendMetricsRequest> {
|
|
544
|
+
constructor() {
|
|
545
|
+
super('stream.video.sfu.signal.SendMetricsRequest', [
|
|
546
|
+
{ no: 1, name: 'session_id', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
|
|
547
|
+
{
|
|
548
|
+
no: 2,
|
|
549
|
+
name: 'unified_session_id',
|
|
550
|
+
kind: 'scalar',
|
|
551
|
+
T: 9 /*ScalarType.STRING*/,
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
no: 3,
|
|
555
|
+
name: 'inbounds',
|
|
556
|
+
kind: 'message',
|
|
557
|
+
repeat: 2 /*RepeatType.UNPACKED*/,
|
|
558
|
+
T: () => InboundRtp,
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
no: 4,
|
|
562
|
+
name: 'outbounds',
|
|
563
|
+
kind: 'message',
|
|
564
|
+
repeat: 2 /*RepeatType.UNPACKED*/,
|
|
565
|
+
T: () => OutboundRtp,
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
no: 5,
|
|
569
|
+
name: 'remote_inbounds',
|
|
570
|
+
kind: 'message',
|
|
571
|
+
repeat: 2 /*RepeatType.UNPACKED*/,
|
|
572
|
+
T: () => RemoteInboundRtp,
|
|
573
|
+
},
|
|
574
|
+
{
|
|
575
|
+
no: 6,
|
|
576
|
+
name: 'remote_outbounds',
|
|
577
|
+
kind: 'message',
|
|
578
|
+
repeat: 2 /*RepeatType.UNPACKED*/,
|
|
579
|
+
T: () => RemoteOutboundRtp,
|
|
580
|
+
},
|
|
581
|
+
]);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* @generated MessageType for protobuf message stream.video.sfu.signal.SendMetricsRequest
|
|
586
|
+
*/
|
|
587
|
+
export const SendMetricsRequest = new SendMetricsRequest$Type();
|
|
588
|
+
// @generated message type with reflection information, may provide speed optimized methods
|
|
589
|
+
class SendMetricsResponse$Type extends MessageType<SendMetricsResponse> {
|
|
590
|
+
constructor() {
|
|
591
|
+
super('stream.video.sfu.signal.SendMetricsResponse', []);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* @generated MessageType for protobuf message stream.video.sfu.signal.SendMetricsResponse
|
|
596
|
+
*/
|
|
597
|
+
export const SendMetricsResponse = new SendMetricsResponse$Type();
|
|
598
|
+
// @generated message type with reflection information, may provide speed optimized methods
|
|
505
599
|
class SendStatsRequest$Type extends MessageType<SendStatsRequest> {
|
|
506
600
|
constructor() {
|
|
507
601
|
super('stream.video.sfu.signal.SendStatsRequest', [
|
|
@@ -776,6 +870,12 @@ class SendAnswerRequest$Type extends MessageType<SendAnswerRequest> {
|
|
|
776
870
|
},
|
|
777
871
|
{ no: 2, name: 'sdp', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
|
|
778
872
|
{ no: 3, name: 'session_id', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
|
|
873
|
+
{
|
|
874
|
+
no: 4,
|
|
875
|
+
name: 'negotiation_id',
|
|
876
|
+
kind: 'scalar',
|
|
877
|
+
T: 13 /*ScalarType.UINT32*/,
|
|
878
|
+
},
|
|
779
879
|
]);
|
|
780
880
|
}
|
|
781
881
|
}
|
|
@@ -885,6 +985,12 @@ export const SignalServer = new ServiceType(
|
|
|
885
985
|
I: SendStatsRequest,
|
|
886
986
|
O: SendStatsResponse,
|
|
887
987
|
},
|
|
988
|
+
{
|
|
989
|
+
name: 'SendMetrics',
|
|
990
|
+
options: {},
|
|
991
|
+
I: SendMetricsRequest,
|
|
992
|
+
O: SendMetricsResponse,
|
|
993
|
+
},
|
|
888
994
|
{
|
|
889
995
|
name: 'StartNoiseCancellation',
|
|
890
996
|
options: {},
|
|
@@ -23,6 +23,14 @@ import { VisibilityState } from '../../types';
|
|
|
23
23
|
import { noopComparator } from '../../sorting';
|
|
24
24
|
import { TrackType } from '../../gen/video/sfu/models/models';
|
|
25
25
|
|
|
26
|
+
vi.mock(import('../browsers'), async (importOriginal) => {
|
|
27
|
+
const module = await importOriginal();
|
|
28
|
+
return {
|
|
29
|
+
...module,
|
|
30
|
+
isSafari: () => globalThis._isSafari ?? false,
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
|
|
26
34
|
describe('DynascaleManager', () => {
|
|
27
35
|
let dynascaleManager: DynascaleManager;
|
|
28
36
|
let call: Call;
|
|
@@ -50,13 +58,6 @@ describe('DynascaleManager', () => {
|
|
|
50
58
|
beforeEach(() => {
|
|
51
59
|
// Mock global isSafari to false for testing
|
|
52
60
|
globalThis._isSafari = false;
|
|
53
|
-
vi.mock(import('../browsers'), async (importOriginal) => {
|
|
54
|
-
const module = await importOriginal();
|
|
55
|
-
return {
|
|
56
|
-
...module,
|
|
57
|
-
isSafari: () => globalThis._isSafari ?? false,
|
|
58
|
-
};
|
|
59
|
-
});
|
|
60
61
|
|
|
61
62
|
dynascaleManager.setUseWebAudio(false);
|
|
62
63
|
|
|
@@ -9,6 +9,10 @@ import {
|
|
|
9
9
|
import { getClientDetails } from '../client-details';
|
|
10
10
|
import { ClientDetails } from '../../gen/video/sfu/models/models';
|
|
11
11
|
|
|
12
|
+
vi.mock('../client-details', () => ({
|
|
13
|
+
getClientDetails: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
12
16
|
describe('browsers', () => {
|
|
13
17
|
beforeEach(() => {
|
|
14
18
|
Object.defineProperty(globalThis, 'navigator', {
|
|
@@ -156,10 +160,6 @@ describe('browsers', () => {
|
|
|
156
160
|
});
|
|
157
161
|
|
|
158
162
|
describe('isSupportedBrowser', () => {
|
|
159
|
-
vi.mock('../client-details', () => ({
|
|
160
|
-
getClientDetails: vi.fn(),
|
|
161
|
-
}));
|
|
162
|
-
|
|
163
163
|
it('should return false if browser is undefined', async () => {
|
|
164
164
|
vi.mocked(getClientDetails).mockResolvedValue({
|
|
165
165
|
browser: undefined,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import {
|
|
3
3
|
hasAudio,
|
|
4
|
+
hasInterruptedTrack,
|
|
4
5
|
hasPausedTrack,
|
|
5
6
|
hasScreenShare,
|
|
6
7
|
hasScreenShareAudio,
|
|
@@ -120,6 +121,52 @@ describe('participantUtils', () => {
|
|
|
120
121
|
});
|
|
121
122
|
});
|
|
122
123
|
|
|
124
|
+
describe('hasInterruptedTrack', () => {
|
|
125
|
+
it('returns true when the track is both interrupted and published', () => {
|
|
126
|
+
const participant = createMockParticipant({
|
|
127
|
+
publishedTracks: [TrackType.AUDIO],
|
|
128
|
+
interruptedTracks: [TrackType.AUDIO],
|
|
129
|
+
});
|
|
130
|
+
expect(hasInterruptedTrack(participant, TrackType.AUDIO)).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('returns false when the track is interrupted but no longer published', () => {
|
|
134
|
+
const participant = createMockParticipant({
|
|
135
|
+
publishedTracks: [],
|
|
136
|
+
interruptedTracks: [TrackType.AUDIO],
|
|
137
|
+
});
|
|
138
|
+
expect(hasInterruptedTrack(participant, TrackType.AUDIO)).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('returns false when the track is published but not interrupted', () => {
|
|
142
|
+
const participant = createMockParticipant({
|
|
143
|
+
publishedTracks: [TrackType.AUDIO],
|
|
144
|
+
interruptedTracks: [],
|
|
145
|
+
});
|
|
146
|
+
expect(hasInterruptedTrack(participant, TrackType.AUDIO)).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('returns false when interruptedTracks is undefined', () => {
|
|
150
|
+
const participant = createMockParticipant({
|
|
151
|
+
publishedTracks: [TrackType.AUDIO],
|
|
152
|
+
interruptedTracks: undefined,
|
|
153
|
+
});
|
|
154
|
+
expect(hasInterruptedTrack(participant, TrackType.AUDIO)).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('checks each track type independently', () => {
|
|
158
|
+
const participant = createMockParticipant({
|
|
159
|
+
publishedTracks: [TrackType.AUDIO, TrackType.VIDEO],
|
|
160
|
+
interruptedTracks: [TrackType.VIDEO],
|
|
161
|
+
});
|
|
162
|
+
expect(hasInterruptedTrack(participant, TrackType.AUDIO)).toBe(false);
|
|
163
|
+
expect(hasInterruptedTrack(participant, TrackType.VIDEO)).toBe(true);
|
|
164
|
+
expect(hasInterruptedTrack(participant, TrackType.SCREEN_SHARE)).toBe(
|
|
165
|
+
false,
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
123
170
|
describe('hasPausedTrack', () => {
|
|
124
171
|
it('should return true when participant has paused VIDEO track', () => {
|
|
125
172
|
const participant = createMockParticipant({
|
|
@@ -137,6 +137,7 @@ export const getClientDetails = async (): Promise<ClientDetails> => {
|
|
|
137
137
|
sdk: sdkInfo,
|
|
138
138
|
os: osInfo,
|
|
139
139
|
device: deviceInfo,
|
|
140
|
+
webrtcVersion: webRtcInfo?.version || '',
|
|
140
141
|
};
|
|
141
142
|
}
|
|
142
143
|
|
|
@@ -173,11 +174,12 @@ export const getClientDetails = async (): Promise<ClientDetails> => {
|
|
|
173
174
|
const uaBrowser = userAgentData?.fullVersionList?.find(
|
|
174
175
|
(v) => !v.brand.includes('Chromium') && !v.brand.match(/[()\-./:;=?_]/g),
|
|
175
176
|
);
|
|
177
|
+
const browserVersion = uaBrowser?.version || browser.version || '';
|
|
176
178
|
return {
|
|
177
179
|
sdk: sdkInfo,
|
|
178
180
|
browser: {
|
|
179
181
|
name: uaBrowser?.brand || browser.name || navigator.userAgent,
|
|
180
|
-
version:
|
|
182
|
+
version: browserVersion,
|
|
181
183
|
},
|
|
182
184
|
os: {
|
|
183
185
|
name: userAgentData?.platform || os.name || '',
|
|
@@ -190,5 +192,6 @@ export const getClientDetails = async (): Promise<ClientDetails> => {
|
|
|
190
192
|
.join(' '),
|
|
191
193
|
version: '',
|
|
192
194
|
},
|
|
195
|
+
webrtcVersion: browserVersion,
|
|
193
196
|
};
|
|
194
197
|
};
|
|
@@ -41,6 +41,21 @@ export const hasScreenShareAudio = (p: StreamVideoParticipant): boolean =>
|
|
|
41
41
|
export const isPinned = (p: StreamVideoParticipant): boolean =>
|
|
42
42
|
!!p.pin && (p.pin.isLocalPin || p.pin.pinnedAt > 0);
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Check if a participant has a track that is currently interrupted: the
|
|
46
|
+
* participant intends to publish it (it is in `publishedTracks`) but no
|
|
47
|
+
* media is flowing right now (it is in `interruptedTracks`).
|
|
48
|
+
*
|
|
49
|
+
* @param p the participant to check.
|
|
50
|
+
* @param trackType the track type to check.
|
|
51
|
+
*/
|
|
52
|
+
export const hasInterruptedTrack = (
|
|
53
|
+
p: StreamVideoParticipant,
|
|
54
|
+
trackType: TrackType,
|
|
55
|
+
): boolean =>
|
|
56
|
+
!!p.interruptedTracks?.includes(trackType) &&
|
|
57
|
+
p.publishedTracks.includes(trackType);
|
|
58
|
+
|
|
44
59
|
/**
|
|
45
60
|
* Check if a participant has a paused track of the specified type.
|
|
46
61
|
*
|
|
@@ -42,7 +42,7 @@ export abstract class BasePeerConnection {
|
|
|
42
42
|
private iceRestartTimeout?: NodeJS.Timeout;
|
|
43
43
|
private preConnectStuckTimeout?: NodeJS.Timeout;
|
|
44
44
|
protected isIceRestarting = false;
|
|
45
|
-
|
|
45
|
+
protected isDisposed = false;
|
|
46
46
|
|
|
47
47
|
protected trackIdToTrackType = new Map<string, TrackType>();
|
|
48
48
|
|
|
@@ -69,6 +69,7 @@ export abstract class BasePeerConnection {
|
|
|
69
69
|
enableTracing,
|
|
70
70
|
clientPublishOptions,
|
|
71
71
|
iceRestartDelay = 2500,
|
|
72
|
+
statsTimestampDriftThresholdMs = 0,
|
|
72
73
|
}: BasePeerConnectionOpts,
|
|
73
74
|
) {
|
|
74
75
|
this.peerType = peerType;
|
|
@@ -85,7 +86,12 @@ export abstract class BasePeerConnection {
|
|
|
85
86
|
{ tags: [tag] },
|
|
86
87
|
);
|
|
87
88
|
this.pc = this.createPeerConnection(connectionConfig);
|
|
88
|
-
this.stats = new StatsTracer(
|
|
89
|
+
this.stats = new StatsTracer(
|
|
90
|
+
this.pc,
|
|
91
|
+
peerType,
|
|
92
|
+
this.trackIdToTrackType,
|
|
93
|
+
statsTimestampDriftThresholdMs,
|
|
94
|
+
);
|
|
89
95
|
if (enableTracing) {
|
|
90
96
|
this.tracer = new Tracer(
|
|
91
97
|
`${tag}-${peerType === PeerType.SUBSCRIBER ? 'sub' : 'pub'}`,
|
|
@@ -115,7 +121,7 @@ export abstract class BasePeerConnection {
|
|
|
115
121
|
/**
|
|
116
122
|
* Disposes the `RTCPeerConnection` instance.
|
|
117
123
|
*/
|
|
118
|
-
dispose() {
|
|
124
|
+
async dispose(): Promise<void> {
|
|
119
125
|
clearTimeout(this.iceRestartTimeout);
|
|
120
126
|
this.iceRestartTimeout = undefined;
|
|
121
127
|
clearTimeout(this.preConnectStuckTimeout);
|
|
@@ -141,6 +147,10 @@ export abstract class BasePeerConnection {
|
|
|
141
147
|
this.onIceConnectionStateChange,
|
|
142
148
|
);
|
|
143
149
|
pc.removeEventListener('icegatheringstatechange', this.onIceGatherChange);
|
|
150
|
+
pc.removeEventListener(
|
|
151
|
+
'connectionstatechange',
|
|
152
|
+
this.onConnectionStateChange,
|
|
153
|
+
);
|
|
144
154
|
this.unsubscribeIceTrickle?.();
|
|
145
155
|
this.subscriptions.forEach((unsubscribe) => unsubscribe());
|
|
146
156
|
this.subscriptions = [];
|
|
@@ -183,7 +193,7 @@ export abstract class BasePeerConnection {
|
|
|
183
193
|
const getTag = () => this.tag;
|
|
184
194
|
this.subscriptions.push(
|
|
185
195
|
this.dispatcher.on(event, getTag, (e) => {
|
|
186
|
-
const lockKey =
|
|
196
|
+
const lockKey = this.eventLockKey(event);
|
|
187
197
|
withoutConcurrency(lockKey, async () => fn(e)).catch((err) => {
|
|
188
198
|
if (this.isDisposed) return;
|
|
189
199
|
this.logger.warn(`Error handling ${event}`, err);
|
|
@@ -192,6 +202,14 @@ export abstract class BasePeerConnection {
|
|
|
192
202
|
);
|
|
193
203
|
};
|
|
194
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Returns the per-event `withoutConcurrency` tag used to serialize the
|
|
207
|
+
* dispatcher handler for `event` on this peer connection.
|
|
208
|
+
*/
|
|
209
|
+
protected eventLockKey = (event: keyof AllSfuEvents): string => {
|
|
210
|
+
return `pc.${this.lock}.${event}`;
|
|
211
|
+
};
|
|
212
|
+
|
|
195
213
|
/**
|
|
196
214
|
* Appends the trickled ICE candidates to the `RTCPeerConnection`.
|
|
197
215
|
*/
|
package/src/rtc/Publisher.ts
CHANGED
|
@@ -20,11 +20,15 @@ import {
|
|
|
20
20
|
toVideoLayers,
|
|
21
21
|
} from './layers';
|
|
22
22
|
import { isSvcCodec } from './codecs';
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
fromRTCDegradationPreference,
|
|
25
|
+
toRTCDegradationPreference,
|
|
26
|
+
} from './helpers/degradationPreference';
|
|
24
27
|
import { isAudioTrackType } from './helpers/tracks';
|
|
25
28
|
import { extractMid, removeCodecsExcept, setStartBitrate } from './helpers/sdp';
|
|
26
29
|
import { withoutConcurrency } from '../helpers/concurrency';
|
|
27
30
|
import { isReactNative } from '../helpers/platforms';
|
|
31
|
+
import { isFirefox } from '../helpers/browsers';
|
|
28
32
|
|
|
29
33
|
/**
|
|
30
34
|
* The `Publisher` is responsible for publishing/unpublishing media streams to/from the SFU
|
|
@@ -53,7 +57,16 @@ export class Publisher extends BasePeerConnection {
|
|
|
53
57
|
|
|
54
58
|
this.on('changePublishQuality', async (event) => {
|
|
55
59
|
for (const videoSender of event.videoSenders) {
|
|
56
|
-
|
|
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);
|
|
57
70
|
}
|
|
58
71
|
});
|
|
59
72
|
|
|
@@ -66,9 +79,13 @@ export class Publisher extends BasePeerConnection {
|
|
|
66
79
|
/**
|
|
67
80
|
* Disposes this Publisher instance.
|
|
68
81
|
*/
|
|
69
|
-
dispose() {
|
|
70
|
-
super.dispose();
|
|
71
|
-
|
|
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
|
+
}
|
|
72
89
|
this.clonedTracks.clear();
|
|
73
90
|
}
|
|
74
91
|
|
|
@@ -98,17 +115,12 @@ export class Publisher extends BasePeerConnection {
|
|
|
98
115
|
// appear in the SDP in multiple transceivers
|
|
99
116
|
const trackToPublish = this.cloneTrack(track);
|
|
100
117
|
|
|
101
|
-
const
|
|
102
|
-
if (!
|
|
118
|
+
const bundle = this.transceiverCache.get(publishOption);
|
|
119
|
+
if (!bundle) {
|
|
103
120
|
await this.addTransceiver(trackToPublish, publishOption, options);
|
|
104
121
|
} else {
|
|
105
|
-
const previousTrack = transceiver.sender.track;
|
|
106
|
-
await this.updateTransceiver(
|
|
107
|
-
transceiver,
|
|
108
|
-
trackToPublish,
|
|
109
|
-
trackType,
|
|
110
|
-
options,
|
|
111
|
-
);
|
|
122
|
+
const previousTrack = bundle.transceiver.sender.track;
|
|
123
|
+
await this.updateTransceiver(bundle, trackToPublish, options);
|
|
112
124
|
if (!isReactNative()) {
|
|
113
125
|
this.stopTrack(previousTrack);
|
|
114
126
|
}
|
|
@@ -153,15 +165,22 @@ export class Publisher extends BasePeerConnection {
|
|
|
153
165
|
* Updates the transceiver with the given track and track type.
|
|
154
166
|
*/
|
|
155
167
|
private updateTransceiver = async (
|
|
156
|
-
|
|
168
|
+
bundle: PublishBundle,
|
|
157
169
|
track: MediaStreamTrack | null,
|
|
158
|
-
trackType: TrackType,
|
|
159
170
|
options: TrackPublishOptions = {},
|
|
160
171
|
) => {
|
|
172
|
+
const { transceiver, publishOption } = bundle;
|
|
173
|
+
const trackType = publishOption.trackType;
|
|
161
174
|
const sender = transceiver.sender;
|
|
162
175
|
if (sender.track) this.trackIdToTrackType.delete(sender.track.id);
|
|
163
176
|
await sender.replaceTrack(track);
|
|
164
|
-
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
|
+
}
|
|
165
184
|
if (isAudioTrackType(trackType)) {
|
|
166
185
|
await this.updateAudioPublishOptions(trackType, options);
|
|
167
186
|
}
|
|
@@ -230,7 +249,7 @@ export class Publisher extends BasePeerConnection {
|
|
|
230
249
|
if (hasPublishOption) continue;
|
|
231
250
|
// it is safe to stop the track here, it is a clone
|
|
232
251
|
this.stopTrack(transceiver.sender.track);
|
|
233
|
-
await this.updateTransceiver(
|
|
252
|
+
await this.updateTransceiver(item, null);
|
|
234
253
|
}
|
|
235
254
|
};
|
|
236
255
|
|
|
@@ -286,39 +305,50 @@ export class Publisher extends BasePeerConnection {
|
|
|
286
305
|
/**
|
|
287
306
|
* Stops the cloned track that is being published to the SFU.
|
|
288
307
|
*/
|
|
289
|
-
stopTracks = (...trackTypes: TrackType[]) => {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
+
);
|
|
295
321
|
};
|
|
296
322
|
|
|
297
323
|
/**
|
|
298
324
|
* Stops all the cloned tracks that are being published to the SFU.
|
|
299
325
|
*/
|
|
300
|
-
stopAllTracks = () => {
|
|
301
|
-
|
|
302
|
-
this.
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
+
);
|
|
307
340
|
};
|
|
308
341
|
|
|
309
|
-
private changePublishQuality = async (
|
|
310
|
-
|
|
311
|
-
|
|
342
|
+
private changePublishQuality = async (
|
|
343
|
+
videoSender: VideoSender,
|
|
344
|
+
bundle: PublishBundle | undefined,
|
|
345
|
+
) => {
|
|
346
|
+
const enabledLayers = videoSender.layers.filter((l) => l.active);
|
|
312
347
|
|
|
313
348
|
const tag = 'Update publish quality:';
|
|
314
349
|
this.logger.info(`${tag} requested layers by SFU:`, enabledLayers);
|
|
315
350
|
|
|
316
|
-
const
|
|
317
|
-
(t) =>
|
|
318
|
-
t.publishOption.id === publishOptionId &&
|
|
319
|
-
t.publishOption.trackType === trackType,
|
|
320
|
-
);
|
|
321
|
-
const sender = transceiverId?.transceiver.sender;
|
|
351
|
+
const sender = bundle?.transceiver.sender;
|
|
322
352
|
if (!sender) {
|
|
323
353
|
return this.logger.warn(`${tag} no video sender found.`);
|
|
324
354
|
}
|
|
@@ -328,7 +358,7 @@ export class Publisher extends BasePeerConnection {
|
|
|
328
358
|
return this.logger.warn(`${tag} there are no encodings set.`);
|
|
329
359
|
}
|
|
330
360
|
|
|
331
|
-
const codecInUse =
|
|
361
|
+
const codecInUse = bundle?.publishOption.codec?.name;
|
|
332
362
|
const usesSvcCodec = codecInUse && isSvcCodec(codecInUse);
|
|
333
363
|
|
|
334
364
|
let changed = false;
|
|
@@ -560,4 +590,73 @@ export class Publisher extends BasePeerConnection {
|
|
|
560
590
|
track.stop();
|
|
561
591
|
this.clonedTracks.delete(track);
|
|
562
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
|
+
};
|
|
563
662
|
}
|
package/src/rtc/Subscriber.ts
CHANGED