@stream-io/video-client 1.47.0 → 1.49.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 +20 -0
- package/dist/index.browser.es.js +383 -238
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +382 -238
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.es.js +383 -238
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +35 -1
- package/dist/src/StreamSfuClient.d.ts +8 -1
- package/dist/src/devices/DeviceManagerState.d.ts +13 -0
- package/dist/src/devices/MicrophoneManager.d.ts +0 -1
- package/dist/src/helpers/SlidingWindowRateLimiter.d.ts +28 -0
- package/dist/src/rtc/BasePeerConnection.d.ts +11 -2
- package/dist/src/rtc/index.d.ts +1 -0
- package/dist/src/rtc/types.d.ts +33 -1
- package/dist/src/types.d.ts +11 -0
- package/index.ts +0 -1
- package/package.json +1 -1
- package/src/Call.ts +179 -18
- package/src/StreamSfuClient.ts +75 -12
- package/src/__tests__/Call.publishing.test.ts +103 -0
- package/src/__tests__/StreamSfuClient.test.ts +275 -0
- package/src/devices/DeviceManagerState.ts +20 -0
- package/src/devices/MicrophoneManager.ts +9 -5
- package/src/devices/__tests__/MicrophoneManagerRN.test.ts +28 -29
- package/src/devices/__tests__/ScreenShareManager.test.ts +0 -2
- package/src/devices/devices.ts +2 -1
- package/src/helpers/SlidingWindowRateLimiter.ts +49 -0
- package/src/helpers/__tests__/SlidingWindowRateLimiter.test.ts +43 -0
- package/src/rpc/retryable.ts +0 -1
- package/src/rtc/BasePeerConnection.ts +96 -6
- package/src/rtc/Publisher.ts +2 -1
- package/src/rtc/__tests__/Call.reconnect.test.ts +761 -0
- package/src/rtc/__tests__/Publisher.test.ts +210 -0
- package/src/rtc/__tests__/Subscriber.test.ts +56 -0
- package/src/rtc/index.ts +1 -0
- package/src/rtc/types.ts +38 -1
- package/src/types.ts +9 -0
- package/dist/src/helpers/RNSpeechDetector.d.ts +0 -23
- package/src/helpers/RNSpeechDetector.ts +0 -224
- package/src/helpers/__tests__/RNSpeechDetector.test.ts +0 -52
|
@@ -12,11 +12,12 @@ import {
|
|
|
12
12
|
import { of } from 'rxjs';
|
|
13
13
|
import '../../rtc/__tests__/mocks/webrtc.mocks';
|
|
14
14
|
import { OwnCapability } from '../../gen/coordinator';
|
|
15
|
-
import { SoundStateChangeHandler } from '../../helpers/sound-detector';
|
|
16
15
|
import { settled, withoutConcurrency } from '../../helpers/concurrency';
|
|
17
16
|
|
|
18
|
-
let
|
|
19
|
-
|
|
17
|
+
let speechActivityCallback:
|
|
18
|
+
| ((state: { isSoundDetected: boolean }) => void)
|
|
19
|
+
| null = null;
|
|
20
|
+
let unsubscribeMocks: ReturnType<typeof vi.fn>[] = [];
|
|
20
21
|
|
|
21
22
|
vi.mock('../../helpers/platforms.ts', () => {
|
|
22
23
|
return {
|
|
@@ -46,28 +47,21 @@ vi.mock('../../Call.ts', () => {
|
|
|
46
47
|
};
|
|
47
48
|
});
|
|
48
49
|
|
|
49
|
-
vi.mock('../../helpers/RNSpeechDetector.ts', () => {
|
|
50
|
-
console.log('MOCKING RNSpeechDetector');
|
|
51
|
-
return {
|
|
52
|
-
RNSpeechDetector: vi.fn().mockImplementation(() => ({
|
|
53
|
-
start: vi.fn((callback) => {
|
|
54
|
-
handler = callback;
|
|
55
|
-
const unsubscribe = vi.fn();
|
|
56
|
-
unsubscribeHandlers.push(unsubscribe);
|
|
57
|
-
return unsubscribe;
|
|
58
|
-
}),
|
|
59
|
-
stop: vi.fn(),
|
|
60
|
-
onSpeakingDetectedStateChange: vi.fn(),
|
|
61
|
-
})),
|
|
62
|
-
};
|
|
63
|
-
});
|
|
64
|
-
|
|
65
50
|
describe('MicrophoneManager React Native', () => {
|
|
66
51
|
let manager: MicrophoneManager;
|
|
67
52
|
let checkPermissionMock: ReturnType<typeof vi.fn>;
|
|
53
|
+
let subscribeMock: ReturnType<typeof vi.fn>;
|
|
54
|
+
|
|
68
55
|
beforeEach(() => {
|
|
69
|
-
|
|
56
|
+
speechActivityCallback = null;
|
|
57
|
+
unsubscribeMocks = [];
|
|
70
58
|
checkPermissionMock = vi.fn(async () => true);
|
|
59
|
+
subscribeMock = vi.fn((cb) => {
|
|
60
|
+
speechActivityCallback = cb;
|
|
61
|
+
const unsub = vi.fn();
|
|
62
|
+
unsubscribeMocks.push(unsub);
|
|
63
|
+
return unsub;
|
|
64
|
+
});
|
|
71
65
|
|
|
72
66
|
globalThis.streamRNVideoSDK = {
|
|
73
67
|
callManager: {
|
|
@@ -78,6 +72,11 @@ describe('MicrophoneManager React Native', () => {
|
|
|
78
72
|
permissions: {
|
|
79
73
|
check: checkPermissionMock,
|
|
80
74
|
},
|
|
75
|
+
nativeEvents: {
|
|
76
|
+
speechActivity: {
|
|
77
|
+
subscribe: subscribeMock,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
81
80
|
};
|
|
82
81
|
|
|
83
82
|
const devicePersistence = { enabled: false, storageKey: '' };
|
|
@@ -100,7 +99,7 @@ describe('MicrophoneManager React Native', () => {
|
|
|
100
99
|
|
|
101
100
|
await vi.waitUntil(() => fn.mock.calls.length > 0, { timeout: 100 });
|
|
102
101
|
expect(fn).toHaveBeenCalled();
|
|
103
|
-
expect(
|
|
102
|
+
expect(subscribeMock).toHaveBeenCalled();
|
|
104
103
|
});
|
|
105
104
|
|
|
106
105
|
it('should check native microphone permission before starting detection', async () => {
|
|
@@ -146,15 +145,15 @@ describe('MicrophoneManager React Native', () => {
|
|
|
146
145
|
|
|
147
146
|
it('should update speaking while muted state', async () => {
|
|
148
147
|
await manager['startSpeakingWhileMutedDetection']();
|
|
149
|
-
expect(
|
|
148
|
+
expect(subscribeMock).toHaveBeenCalled();
|
|
150
149
|
|
|
151
150
|
expect(manager.state.speakingWhileMuted).toBe(false);
|
|
152
151
|
|
|
153
|
-
|
|
152
|
+
speechActivityCallback!({ isSoundDetected: true });
|
|
154
153
|
|
|
155
154
|
expect(manager.state.speakingWhileMuted).toBe(true);
|
|
156
155
|
|
|
157
|
-
|
|
156
|
+
speechActivityCallback!({ isSoundDetected: false });
|
|
158
157
|
|
|
159
158
|
expect(manager.state.speakingWhileMuted).toBe(false);
|
|
160
159
|
});
|
|
@@ -163,21 +162,21 @@ describe('MicrophoneManager React Native', () => {
|
|
|
163
162
|
await manager['startSpeakingWhileMutedDetection']('device-1');
|
|
164
163
|
await manager['startSpeakingWhileMutedDetection']('device-1');
|
|
165
164
|
|
|
166
|
-
expect(
|
|
165
|
+
expect(unsubscribeMocks).toHaveLength(1);
|
|
167
166
|
|
|
168
167
|
await manager['stopSpeakingWhileMutedDetection']();
|
|
169
|
-
expect(
|
|
168
|
+
expect(unsubscribeMocks[0]).toHaveBeenCalledTimes(1);
|
|
170
169
|
});
|
|
171
170
|
|
|
172
171
|
it('should cleanup previous speech detector before starting a new one', async () => {
|
|
173
172
|
await manager['startSpeakingWhileMutedDetection']('device-1');
|
|
174
173
|
await manager['startSpeakingWhileMutedDetection']('device-2');
|
|
175
174
|
|
|
176
|
-
expect(
|
|
177
|
-
expect(
|
|
175
|
+
expect(unsubscribeMocks).toHaveLength(2);
|
|
176
|
+
expect(unsubscribeMocks[0]).toHaveBeenCalledTimes(1);
|
|
178
177
|
|
|
179
178
|
await manager['stopSpeakingWhileMutedDetection']();
|
|
180
|
-
expect(
|
|
179
|
+
expect(unsubscribeMocks[1]).toHaveBeenCalledTimes(1);
|
|
181
180
|
});
|
|
182
181
|
|
|
183
182
|
it('should stop speaking while muted notifications if user loses permission to send audio', async () => {
|
|
@@ -34,7 +34,6 @@ describe('ScreenShareManager', () => {
|
|
|
34
34
|
let manager: ScreenShareManager;
|
|
35
35
|
|
|
36
36
|
beforeEach(() => {
|
|
37
|
-
const devicePersistence = { enabled: false, storageKey: '' };
|
|
38
37
|
manager = new ScreenShareManager(
|
|
39
38
|
new Call({
|
|
40
39
|
id: '',
|
|
@@ -42,7 +41,6 @@ describe('ScreenShareManager', () => {
|
|
|
42
41
|
streamClient: new StreamClient('abc123'),
|
|
43
42
|
clientStore: new StreamVideoWriteableStateStore(),
|
|
44
43
|
}),
|
|
45
|
-
devicePersistence,
|
|
46
44
|
);
|
|
47
45
|
});
|
|
48
46
|
|
package/src/devices/devices.ts
CHANGED
|
@@ -340,7 +340,6 @@ export const getScreenShareStream = async (
|
|
|
340
340
|
const tag = `navigator.mediaDevices.getDisplayMedia.${getDisplayMediaExecId++}.`;
|
|
341
341
|
try {
|
|
342
342
|
const constraints: DisplayMediaStreamOptions = {
|
|
343
|
-
// @ts-expect-error - not present in types yet
|
|
344
343
|
systemAudio: 'include',
|
|
345
344
|
...options,
|
|
346
345
|
video:
|
|
@@ -357,6 +356,8 @@ export const getScreenShareStream = async (
|
|
|
357
356
|
? options.audio
|
|
358
357
|
: {
|
|
359
358
|
channelCount: { ideal: 2 },
|
|
359
|
+
// @ts-expect-error not yet present in the types
|
|
360
|
+
restrictOwnAudio: true,
|
|
360
361
|
echoCancellation: false,
|
|
361
362
|
autoGainControl: false,
|
|
362
363
|
noiseSuppression: false,
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A generic sliding-window rate limiter.
|
|
3
|
+
*
|
|
4
|
+
* Allows at most `maxAttempts` registrations inside a rolling `windowMs`.
|
|
5
|
+
* Attempts spaced further apart than `windowMs` are always allowed.
|
|
6
|
+
*/
|
|
7
|
+
export class SlidingWindowRateLimiter {
|
|
8
|
+
private maxAttempts: number;
|
|
9
|
+
private windowMs: number;
|
|
10
|
+
private timestamps: number[] = [];
|
|
11
|
+
|
|
12
|
+
constructor(maxAttempts: number, windowMs: number) {
|
|
13
|
+
this.maxAttempts = maxAttempts;
|
|
14
|
+
this.windowMs = windowMs;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Attempts to register a new event at `now`. Returns `true` if the attempt
|
|
19
|
+
* fits inside the budget (and records it), or `false` if the budget is
|
|
20
|
+
* exhausted (in which case no timestamp is recorded).
|
|
21
|
+
*/
|
|
22
|
+
tryRegister = (now: number = Date.now()): boolean => {
|
|
23
|
+
this.prune(now);
|
|
24
|
+
if (this.timestamps.length >= this.maxAttempts) return false;
|
|
25
|
+
this.timestamps.push(now);
|
|
26
|
+
return true;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Clears the attempt history.
|
|
31
|
+
*/
|
|
32
|
+
reset = (): void => {
|
|
33
|
+
this.timestamps = [];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Updates the budget and window size. Existing timestamps are kept; they
|
|
38
|
+
* will be pruned by the next `tryRegister` call.
|
|
39
|
+
*/
|
|
40
|
+
setLimits = (maxAttempts: number, windowMs: number): void => {
|
|
41
|
+
this.maxAttempts = maxAttempts;
|
|
42
|
+
this.windowMs = windowMs;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
private prune = (now: number): void => {
|
|
46
|
+
const cutoff = now - this.windowMs;
|
|
47
|
+
this.timestamps = this.timestamps.filter((t) => t >= cutoff);
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { SlidingWindowRateLimiter } from '../SlidingWindowRateLimiter';
|
|
3
|
+
|
|
4
|
+
describe('SlidingWindowRateLimiter', () => {
|
|
5
|
+
it('allows up to the configured number of attempts inside the window', () => {
|
|
6
|
+
const limiter = new SlidingWindowRateLimiter(3, 1000);
|
|
7
|
+
expect(limiter.tryRegister(0)).toBe(true);
|
|
8
|
+
expect(limiter.tryRegister(10)).toBe(true);
|
|
9
|
+
expect(limiter.tryRegister(20)).toBe(true);
|
|
10
|
+
// fourth attempt inside the same window is denied
|
|
11
|
+
expect(limiter.tryRegister(30)).toBe(false);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('prunes timestamps outside the rolling window so attempts after it pass', () => {
|
|
15
|
+
const limiter = new SlidingWindowRateLimiter(2, 1000);
|
|
16
|
+
expect(limiter.tryRegister(0)).toBe(true);
|
|
17
|
+
expect(limiter.tryRegister(500)).toBe(true);
|
|
18
|
+
expect(limiter.tryRegister(900)).toBe(false);
|
|
19
|
+
// at t=1501 cutoff=501, so both 0 and 500 fall out of window
|
|
20
|
+
expect(limiter.tryRegister(1501)).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('reset() clears the attempt history', () => {
|
|
24
|
+
const limiter = new SlidingWindowRateLimiter(2, 1000);
|
|
25
|
+
limiter.tryRegister(0);
|
|
26
|
+
limiter.tryRegister(100);
|
|
27
|
+
expect(limiter.tryRegister(200)).toBe(false);
|
|
28
|
+
limiter.reset();
|
|
29
|
+
expect(limiter.tryRegister(300)).toBe(true);
|
|
30
|
+
expect(limiter.tryRegister(400)).toBe(true);
|
|
31
|
+
expect(limiter.tryRegister(500)).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('setLimits() updates the budget and window in place', () => {
|
|
35
|
+
const limiter = new SlidingWindowRateLimiter(2, 1000);
|
|
36
|
+
limiter.tryRegister(0);
|
|
37
|
+
limiter.tryRegister(100);
|
|
38
|
+
expect(limiter.tryRegister(200)).toBe(false);
|
|
39
|
+
// raising the limit lets the next attempt through without reset
|
|
40
|
+
limiter.setLimits(5, 1000);
|
|
41
|
+
expect(limiter.tryRegister(300)).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|
package/src/rpc/retryable.ts
CHANGED
|
@@ -44,7 +44,6 @@ export const retryable = async <
|
|
|
44
44
|
let result: FinishedUnaryCall<I, O> | undefined = undefined;
|
|
45
45
|
do {
|
|
46
46
|
if (attempt > 0) await sleep(retryInterval(attempt));
|
|
47
|
-
if (signal?.aborted) throw new Error(signal.reason);
|
|
48
47
|
|
|
49
48
|
try {
|
|
50
49
|
result = await rpc({ attempt });
|
|
@@ -13,7 +13,12 @@ import { StreamSfuClient } from '../StreamSfuClient';
|
|
|
13
13
|
import { AllSfuEvents, Dispatcher } from './Dispatcher';
|
|
14
14
|
import { withoutConcurrency } from '../helpers/concurrency';
|
|
15
15
|
import { StatsTracer, Tracer, traceRTCPeerConnection } from '../stats';
|
|
16
|
-
import
|
|
16
|
+
import {
|
|
17
|
+
BasePeerConnectionOpts,
|
|
18
|
+
OnIceConnected,
|
|
19
|
+
OnReconnectionNeeded,
|
|
20
|
+
ReconnectReason,
|
|
21
|
+
} from './types';
|
|
17
22
|
import type { ClientPublishOptions } from '../types';
|
|
18
23
|
|
|
19
24
|
/**
|
|
@@ -31,8 +36,11 @@ export abstract class BasePeerConnection {
|
|
|
31
36
|
protected sfuClient: StreamSfuClient;
|
|
32
37
|
|
|
33
38
|
private onReconnectionNeeded?: OnReconnectionNeeded;
|
|
39
|
+
private onIceConnected?: OnIceConnected;
|
|
34
40
|
private readonly iceRestartDelay: number;
|
|
41
|
+
private iceHasEverConnected = false;
|
|
35
42
|
private iceRestartTimeout?: NodeJS.Timeout;
|
|
43
|
+
private preConnectStuckTimeout?: NodeJS.Timeout;
|
|
36
44
|
protected isIceRestarting = false;
|
|
37
45
|
private isDisposed = false;
|
|
38
46
|
|
|
@@ -56,6 +64,7 @@ export abstract class BasePeerConnection {
|
|
|
56
64
|
state,
|
|
57
65
|
dispatcher,
|
|
58
66
|
onReconnectionNeeded,
|
|
67
|
+
onIceConnected,
|
|
59
68
|
tag,
|
|
60
69
|
enableTracing,
|
|
61
70
|
clientPublishOptions,
|
|
@@ -70,6 +79,7 @@ export abstract class BasePeerConnection {
|
|
|
70
79
|
this.clientPublishOptions = clientPublishOptions;
|
|
71
80
|
this.tag = tag;
|
|
72
81
|
this.onReconnectionNeeded = onReconnectionNeeded;
|
|
82
|
+
this.onIceConnected = onIceConnected;
|
|
73
83
|
this.logger = videoLoggerSystem.getLogger(
|
|
74
84
|
peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher',
|
|
75
85
|
{ tags: [tag] },
|
|
@@ -108,7 +118,10 @@ export abstract class BasePeerConnection {
|
|
|
108
118
|
dispose() {
|
|
109
119
|
clearTimeout(this.iceRestartTimeout);
|
|
110
120
|
this.iceRestartTimeout = undefined;
|
|
121
|
+
clearTimeout(this.preConnectStuckTimeout);
|
|
122
|
+
this.preConnectStuckTimeout = undefined;
|
|
111
123
|
this.onReconnectionNeeded = undefined;
|
|
124
|
+
this.onIceConnected = undefined;
|
|
112
125
|
this.isDisposed = true;
|
|
113
126
|
this.detachEventHandlers();
|
|
114
127
|
this.pc.close();
|
|
@@ -145,14 +158,17 @@ export abstract class BasePeerConnection {
|
|
|
145
158
|
*/
|
|
146
159
|
protected tryRestartIce = () => {
|
|
147
160
|
this.restartIce().catch((e) => {
|
|
148
|
-
|
|
149
|
-
this.logger.error(reason, e);
|
|
161
|
+
this.logger.error('restartICE() failed, initiating reconnect', e);
|
|
150
162
|
const strategy =
|
|
151
163
|
e instanceof NegotiationError &&
|
|
152
164
|
e.error.code === ErrorCode.PARTICIPANT_SIGNAL_LOST
|
|
153
165
|
? WebsocketReconnectStrategy.FAST
|
|
154
166
|
: WebsocketReconnectStrategy.REJOIN;
|
|
155
|
-
this.onReconnectionNeeded?.(
|
|
167
|
+
this.onReconnectionNeeded?.(
|
|
168
|
+
strategy,
|
|
169
|
+
ReconnectReason.RESTART_ICE_FAILED,
|
|
170
|
+
this.peerType,
|
|
171
|
+
);
|
|
156
172
|
});
|
|
157
173
|
};
|
|
158
174
|
|
|
@@ -239,6 +255,20 @@ export abstract class BasePeerConnection {
|
|
|
239
255
|
return !failedStates.has(iceState) && !failedStates.has(connectionState);
|
|
240
256
|
};
|
|
241
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Returns true only when the peer connection is currently fully established
|
|
260
|
+
* (ICE `connected`/`completed` AND connection state `connected`).
|
|
261
|
+
* Transient states like `disconnected`, `checking`, or `new` return false.
|
|
262
|
+
*/
|
|
263
|
+
isStable = () => {
|
|
264
|
+
const iceState = this.pc.iceConnectionState;
|
|
265
|
+
const connectionState = this.pc.connectionState;
|
|
266
|
+
return (
|
|
267
|
+
(iceState === 'connected' || iceState === 'completed') &&
|
|
268
|
+
connectionState === 'connected'
|
|
269
|
+
);
|
|
270
|
+
};
|
|
271
|
+
|
|
242
272
|
/**
|
|
243
273
|
* Handles the ICECandidate event and
|
|
244
274
|
* Initiates an ICE Trickle process with the SFU.
|
|
@@ -292,7 +322,7 @@ export abstract class BasePeerConnection {
|
|
|
292
322
|
if (state === 'failed') {
|
|
293
323
|
this.onReconnectionNeeded?.(
|
|
294
324
|
WebsocketReconnectStrategy.REJOIN,
|
|
295
|
-
|
|
325
|
+
ReconnectReason.CONNECTION_FAILED,
|
|
296
326
|
this.peerType,
|
|
297
327
|
);
|
|
298
328
|
return;
|
|
@@ -320,6 +350,54 @@ export abstract class BasePeerConnection {
|
|
|
320
350
|
// do nothing when ICE is restarting
|
|
321
351
|
if (this.isIceRestarting) return;
|
|
322
352
|
|
|
353
|
+
// Pre-connect handling: ICE has never reached `connected`/`completed`.
|
|
354
|
+
// Restart is futile here (the data plane was never established), but
|
|
355
|
+
// these two terminal-ish states need different treatment:
|
|
356
|
+
// - `failed` is terminal, escalate to REJOIN so a new SFU/credentials
|
|
357
|
+
// /PC configuration gets a chance, and let `Call.reconnect` count
|
|
358
|
+
// this toward the unsupported-network budget.
|
|
359
|
+
// - `disconnected` is transient, the browser may yet move back to
|
|
360
|
+
// `checking`/`connected`. Don't restart, don't escalate; wait it
|
|
361
|
+
// out. If it ultimately fails, ICE will transition to `failed` and
|
|
362
|
+
// the branch above will take over.
|
|
363
|
+
if (!this.iceHasEverConnected) {
|
|
364
|
+
if (state === 'failed') {
|
|
365
|
+
this.logger.info('ICE failed before connected, escalating to REJOIN');
|
|
366
|
+
clearTimeout(this.preConnectStuckTimeout);
|
|
367
|
+
this.preConnectStuckTimeout = undefined;
|
|
368
|
+
this.onReconnectionNeeded?.(
|
|
369
|
+
WebsocketReconnectStrategy.REJOIN,
|
|
370
|
+
ReconnectReason.ICE_NEVER_CONNECTED,
|
|
371
|
+
this.peerType,
|
|
372
|
+
);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (state === 'disconnected') {
|
|
376
|
+
this.logger.info('ICE disconnected before connected, wait to recover');
|
|
377
|
+
// Watchdog: if the browser stays in `disconnected` without ever
|
|
378
|
+
// reaching `connected` or transitioning to `failed`, escalate to
|
|
379
|
+
// REJOIN ourselves so we don't wait silently forever. Rare but
|
|
380
|
+
// observed on flaky mobile networks.
|
|
381
|
+
clearTimeout(this.preConnectStuckTimeout);
|
|
382
|
+
this.preConnectStuckTimeout = setTimeout(() => {
|
|
383
|
+
if (
|
|
384
|
+
!this.iceHasEverConnected &&
|
|
385
|
+
this.pc.iceConnectionState === 'disconnected'
|
|
386
|
+
) {
|
|
387
|
+
this.logger.info(
|
|
388
|
+
'ICE stuck in pre-connect disconnected, escalating to REJOIN',
|
|
389
|
+
);
|
|
390
|
+
this.onReconnectionNeeded?.(
|
|
391
|
+
WebsocketReconnectStrategy.REJOIN,
|
|
392
|
+
ReconnectReason.ICE_NEVER_CONNECTED,
|
|
393
|
+
this.peerType,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
}, this.iceRestartDelay * 2);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
323
401
|
switch (state) {
|
|
324
402
|
case 'failed':
|
|
325
403
|
// in the `failed` state, we try to restart ICE immediately
|
|
@@ -341,12 +419,24 @@ export abstract class BasePeerConnection {
|
|
|
341
419
|
break;
|
|
342
420
|
|
|
343
421
|
case 'connected':
|
|
344
|
-
|
|
422
|
+
case 'completed':
|
|
423
|
+
// Fire `onIceConnected` exactly once per peer-connection lifetime —
|
|
424
|
+
// the first time ICE reaches `connected`/`completed` end-to-end.
|
|
425
|
+
// Used by `Call` to reset the unsupported-network failure counter
|
|
426
|
+
// only after WebRTC has actually recovered, not merely on SFU join.
|
|
427
|
+
if (!this.iceHasEverConnected) {
|
|
428
|
+
this.iceHasEverConnected = true;
|
|
429
|
+
this.onIceConnected?.(this.peerType);
|
|
430
|
+
}
|
|
431
|
+
// clear any scheduled restartICE since the connection is healthy
|
|
345
432
|
if (this.iceRestartTimeout) {
|
|
346
433
|
this.logger.info('connected connection, canceling restartICE');
|
|
347
434
|
clearTimeout(this.iceRestartTimeout);
|
|
348
435
|
this.iceRestartTimeout = undefined;
|
|
349
436
|
}
|
|
437
|
+
// clear the pre-connect watchdog if it was armed
|
|
438
|
+
clearTimeout(this.preConnectStuckTimeout);
|
|
439
|
+
this.preConnectStuckTimeout = undefined;
|
|
350
440
|
break;
|
|
351
441
|
}
|
|
352
442
|
};
|
package/src/rtc/Publisher.ts
CHANGED
|
@@ -460,7 +460,8 @@ export class Publisher extends BasePeerConnection {
|
|
|
460
460
|
const trackInfos: TrackInfo[] = [];
|
|
461
461
|
for (const publishOption of this.publishOptions) {
|
|
462
462
|
const bundle = this.transceiverCache.get(publishOption);
|
|
463
|
-
|
|
463
|
+
const track = bundle?.transceiver.sender.track;
|
|
464
|
+
if (!bundle || !track || track.readyState !== 'live') continue;
|
|
464
465
|
trackInfos.push(this.toTrackInfo(bundle, sdp));
|
|
465
466
|
}
|
|
466
467
|
return trackInfos;
|