@agatx/serenada-core 0.6.10
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/dist/ConsoleLogger.d.ts +6 -0
- package/dist/ConsoleLogger.d.ts.map +1 -0
- package/dist/ConsoleLogger.js +21 -0
- package/dist/ConsoleLogger.js.map +1 -0
- package/dist/RoomWatcher.d.ts +34 -0
- package/dist/RoomWatcher.d.ts.map +1 -0
- package/dist/RoomWatcher.js +103 -0
- package/dist/RoomWatcher.js.map +1 -0
- package/dist/SerenadaCore.d.ts +47 -0
- package/dist/SerenadaCore.d.ts.map +1 -0
- package/dist/SerenadaCore.js +141 -0
- package/dist/SerenadaCore.js.map +1 -0
- package/dist/SerenadaDiagnostics.d.ts +49 -0
- package/dist/SerenadaDiagnostics.d.ts.map +1 -0
- package/dist/SerenadaDiagnostics.js +421 -0
- package/dist/SerenadaDiagnostics.js.map +1 -0
- package/dist/SerenadaServerProvider.d.ts +48 -0
- package/dist/SerenadaServerProvider.d.ts.map +1 -0
- package/dist/SerenadaServerProvider.js +296 -0
- package/dist/SerenadaServerProvider.js.map +1 -0
- package/dist/SerenadaSession.d.ts +180 -0
- package/dist/SerenadaSession.d.ts.map +1 -0
- package/dist/SerenadaSession.js +1082 -0
- package/dist/SerenadaSession.js.map +1 -0
- package/dist/SignalingProvider.d.ts +132 -0
- package/dist/SignalingProvider.d.ts.map +1 -0
- package/dist/SignalingProvider.js +50 -0
- package/dist/SignalingProvider.js.map +1 -0
- package/dist/api/roomApi.d.ts +2 -0
- package/dist/api/roomApi.d.ts.map +1 -0
- package/dist/api/roomApi.js +14 -0
- package/dist/api/roomApi.js.map +1 -0
- package/dist/cameraModes.d.ts +13 -0
- package/dist/cameraModes.d.ts.map +1 -0
- package/dist/cameraModes.js +35 -0
- package/dist/cameraModes.js.map +1 -0
- package/dist/configValidation.d.ts +10 -0
- package/dist/configValidation.d.ts.map +1 -0
- package/dist/configValidation.js +24 -0
- package/dist/configValidation.js.map +1 -0
- package/dist/constants.d.ts +33 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +65 -0
- package/dist/constants.js.map +1 -0
- package/dist/formatError.d.ts +3 -0
- package/dist/formatError.d.ts.map +1 -0
- package/dist/formatError.js +7 -0
- package/dist/formatError.js.map +1 -0
- package/dist/iceServers.d.ts +2 -0
- package/dist/iceServers.d.ts.map +1 -0
- package/dist/iceServers.js +21 -0
- package/dist/iceServers.js.map +1 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +44 -0
- package/dist/index.js.map +1 -0
- package/dist/layout/computeLayout.d.ts +81 -0
- package/dist/layout/computeLayout.d.ts.map +1 -0
- package/dist/layout/computeLayout.js +380 -0
- package/dist/layout/computeLayout.js.map +1 -0
- package/dist/media/AudioLevelMonitor.d.ts +51 -0
- package/dist/media/AudioLevelMonitor.d.ts.map +1 -0
- package/dist/media/AudioLevelMonitor.js +179 -0
- package/dist/media/AudioLevelMonitor.js.map +1 -0
- package/dist/media/MediaEngine.d.ts +137 -0
- package/dist/media/MediaEngine.d.ts.map +1 -0
- package/dist/media/MediaEngine.js +1224 -0
- package/dist/media/MediaEngine.js.map +1 -0
- package/dist/media/callStats.d.ts +16 -0
- package/dist/media/callStats.d.ts.map +1 -0
- package/dist/media/callStats.js +214 -0
- package/dist/media/callStats.js.map +1 -0
- package/dist/media/localVideoRecovery.d.ts +16 -0
- package/dist/media/localVideoRecovery.d.ts.map +1 -0
- package/dist/media/localVideoRecovery.js +14 -0
- package/dist/media/localVideoRecovery.js.map +1 -0
- package/dist/recoveryStorage.d.ts +33 -0
- package/dist/recoveryStorage.d.ts.map +1 -0
- package/dist/recoveryStorage.js +88 -0
- package/dist/recoveryStorage.js.map +1 -0
- package/dist/serverUrls.d.ts +8 -0
- package/dist/serverUrls.d.ts.map +1 -0
- package/dist/serverUrls.js +65 -0
- package/dist/serverUrls.js.map +1 -0
- package/dist/signaling/SignalingEngine.d.ts +126 -0
- package/dist/signaling/SignalingEngine.d.ts.map +1 -0
- package/dist/signaling/SignalingEngine.js +720 -0
- package/dist/signaling/SignalingEngine.js.map +1 -0
- package/dist/signaling/payloads.d.ts +76 -0
- package/dist/signaling/payloads.d.ts.map +1 -0
- package/dist/signaling/payloads.js +160 -0
- package/dist/signaling/payloads.js.map +1 -0
- package/dist/signaling/roomStatuses.d.ts +9 -0
- package/dist/signaling/roomStatuses.d.ts.map +1 -0
- package/dist/signaling/roomStatuses.js +71 -0
- package/dist/signaling/roomStatuses.js.map +1 -0
- package/dist/signaling/transportConfig.d.ts +3 -0
- package/dist/signaling/transportConfig.d.ts.map +1 -0
- package/dist/signaling/transportConfig.js +27 -0
- package/dist/signaling/transportConfig.js.map +1 -0
- package/dist/signaling/transports/index.d.ts +13 -0
- package/dist/signaling/transports/index.d.ts.map +1 -0
- package/dist/signaling/transports/index.js +11 -0
- package/dist/signaling/transports/index.js.map +1 -0
- package/dist/signaling/transports/sse.d.ts +26 -0
- package/dist/signaling/transports/sse.d.ts.map +1 -0
- package/dist/signaling/transports/sse.js +131 -0
- package/dist/signaling/transports/sse.js.map +1 -0
- package/dist/signaling/transports/types.d.ts +17 -0
- package/dist/signaling/transports/types.d.ts.map +1 -0
- package/dist/signaling/transports/types.js +2 -0
- package/dist/signaling/transports/types.js.map +1 -0
- package/dist/signaling/transports/ws.d.ts +21 -0
- package/dist/signaling/transports/ws.d.ts.map +1 -0
- package/dist/signaling/transports/ws.js +93 -0
- package/dist/signaling/transports/ws.js.map +1 -0
- package/dist/signaling/types.d.ts +53 -0
- package/dist/signaling/types.d.ts.map +1 -0
- package/dist/signaling/types.js +2 -0
- package/dist/signaling/types.js.map +1 -0
- package/dist/types.d.ts +279 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +43 -0
- package/src/ConsoleLogger.ts +14 -0
- package/src/RoomWatcher.ts +127 -0
- package/src/SerenadaCore.ts +163 -0
- package/src/SerenadaDiagnostics.ts +485 -0
- package/src/SerenadaServerProvider.ts +362 -0
- package/src/SerenadaSession.ts +1258 -0
- package/src/SignalingProvider.ts +207 -0
- package/src/api/roomApi.ts +16 -0
- package/src/cameraModes.ts +34 -0
- package/src/configValidation.ts +35 -0
- package/src/constants.ts +77 -0
- package/src/formatError.ts +5 -0
- package/src/iceServers.ts +20 -0
- package/src/index.ts +155 -0
- package/src/layout/computeLayout.ts +639 -0
- package/src/media/AudioLevelMonitor.ts +190 -0
- package/src/media/MediaEngine.ts +1183 -0
- package/src/media/callStats.ts +260 -0
- package/src/media/localVideoRecovery.ts +39 -0
- package/src/recoveryStorage.ts +101 -0
- package/src/serverUrls.ts +69 -0
- package/src/signaling/SignalingEngine.ts +762 -0
- package/src/signaling/payloads.ts +215 -0
- package/src/signaling/roomStatuses.ts +89 -0
- package/src/signaling/transportConfig.ts +30 -0
- package/src/signaling/transports/index.ts +26 -0
- package/src/signaling/transports/sse.ts +146 -0
- package/src/signaling/transports/types.ts +19 -0
- package/src/signaling/transports/ws.ts +108 -0
- package/src/signaling/types.ts +68 -0
- package/src/types.ts +299 -0
|
@@ -0,0 +1,1224 @@
|
|
|
1
|
+
import { parseOfferPayload, parseAnswerPayload, parseIceCandidatePayload } from '../signaling/payloads.js';
|
|
2
|
+
import { formatError } from '../formatError.js';
|
|
3
|
+
import { normalizeIceServers } from '../iceServers.js';
|
|
4
|
+
import { OFFER_TIMEOUT_MS, ICE_RESTART_COOLDOWN_MS, NON_HOST_FALLBACK_DELAY_MS, NON_HOST_FALLBACK_MAX_ATTEMPTS, ICE_CANDIDATE_BUFFER_MAX, CONNECTION_RETRYING_DELAY_MS, LOCAL_VIDEO_HEARTBEAT_INTERVAL_MS, } from '../constants.js';
|
|
5
|
+
import { shouldForceLocalVideoRefresh, shouldRecoverLocalVideo } from './localVideoRecovery.js';
|
|
6
|
+
const DEFAULT_RTC_CONFIG = {
|
|
7
|
+
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
|
8
|
+
};
|
|
9
|
+
const ICE_STATE_PRIORITY = ['failed', 'disconnected', 'checking', 'new', 'connected', 'completed', 'closed'];
|
|
10
|
+
const CONN_STATE_PRIORITY = ['failed', 'disconnected', 'connecting', 'new', 'connected', 'closed'];
|
|
11
|
+
const SIG_STATE_PRIORITY = ['closed', 'have-local-offer', 'have-remote-offer', 'have-local-pranswer', 'have-remote-pranswer', 'stable'];
|
|
12
|
+
export class MediaEngine {
|
|
13
|
+
localStream = null;
|
|
14
|
+
remoteStreams = new Map();
|
|
15
|
+
isScreenSharing = false;
|
|
16
|
+
canScreenShare = !!navigator.mediaDevices?.getDisplayMedia;
|
|
17
|
+
facingMode = 'user';
|
|
18
|
+
hasMultipleCameras = false;
|
|
19
|
+
videoCaptureSupported;
|
|
20
|
+
iceConnectionState = 'new';
|
|
21
|
+
connectionState = 'new';
|
|
22
|
+
signalingState = 'stable';
|
|
23
|
+
connectionStatus = 'connected';
|
|
24
|
+
peers = new Map();
|
|
25
|
+
initialVideoEnabled;
|
|
26
|
+
// Per-remote-CID tally of cumulative `inbound-rtp.bytesReceived`. Sampled
|
|
27
|
+
// on every `getInboundFlowingCids()` call; a CID is "flowing" when its
|
|
28
|
+
// current sample exceeds the previous one. Drives #3's `media_liveness`
|
|
29
|
+
// emission (see SerenadaSession.startMediaLivenessTimer).
|
|
30
|
+
lastInboundBytesByCid = new Map();
|
|
31
|
+
rtcConfig = DEFAULT_RTC_CONFIG;
|
|
32
|
+
screenShareTrack = null;
|
|
33
|
+
requestingMedia = false;
|
|
34
|
+
destroyed = false;
|
|
35
|
+
cameraRecoveryInFlight = false;
|
|
36
|
+
mediaRequestId = 0;
|
|
37
|
+
retryingTimer = null;
|
|
38
|
+
localVideoHeartbeatAt = Date.now();
|
|
39
|
+
localVideoHiddenAt = typeof document !== 'undefined' && document.hidden ? Date.now() : null;
|
|
40
|
+
visibilityHandler = null;
|
|
41
|
+
pageShowHandler = null;
|
|
42
|
+
heartbeatInterval = null;
|
|
43
|
+
onlineHandler = null;
|
|
44
|
+
networkChangeHandler = null;
|
|
45
|
+
deviceChangeHandler = null;
|
|
46
|
+
turnsOnly;
|
|
47
|
+
logger;
|
|
48
|
+
// Injected dependencies
|
|
49
|
+
sendSignalingMessage;
|
|
50
|
+
roomState = null;
|
|
51
|
+
clientId = null;
|
|
52
|
+
isSignalingConnected = false;
|
|
53
|
+
onChange = null;
|
|
54
|
+
constructor(config, sendMessage) {
|
|
55
|
+
this.turnsOnly = config.turnsOnly ?? false;
|
|
56
|
+
this.logger = config.logger;
|
|
57
|
+
this.facingMode = config.initialFacingMode ?? 'user';
|
|
58
|
+
this.initialVideoEnabled = config.initialVideoEnabled !== false;
|
|
59
|
+
this.videoCaptureSupported = config.videoCaptureSupported !== false;
|
|
60
|
+
this.sendSignalingMessage = sendMessage;
|
|
61
|
+
this.setupEventListeners();
|
|
62
|
+
}
|
|
63
|
+
setOnChange(cb) { this.onChange = cb; }
|
|
64
|
+
updateRoomState(state, clientId) {
|
|
65
|
+
this.roomState = state;
|
|
66
|
+
this.clientId = clientId;
|
|
67
|
+
this.syncPeers();
|
|
68
|
+
}
|
|
69
|
+
updateSignalingConnected(connected) {
|
|
70
|
+
this.isSignalingConnected = connected;
|
|
71
|
+
this.updateConnectionStatusValue();
|
|
72
|
+
if (connected) {
|
|
73
|
+
for (const [cid, peer] of this.peers) {
|
|
74
|
+
if (peer.pendingIceRestart && this.shouldIOffer(cid) && peer.pc.signalingState === 'stable') {
|
|
75
|
+
peer.pendingIceRestart = false;
|
|
76
|
+
peer.lastIceRestartAt = Date.now();
|
|
77
|
+
void this.createOfferTo(cid, { iceRestart: true });
|
|
78
|
+
}
|
|
79
|
+
if (peer.pendingLocalTrackNegotiation) {
|
|
80
|
+
this.scheduleLocalTrackNegotiation(cid, peer);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
setIceServers(iceServers) {
|
|
86
|
+
const nextServers = this.normalizeIceServers(iceServers);
|
|
87
|
+
const nextConfig = {
|
|
88
|
+
iceServers: nextServers.length > 0 ? nextServers : DEFAULT_RTC_CONFIG.iceServers,
|
|
89
|
+
};
|
|
90
|
+
if (this.turnsOnly) {
|
|
91
|
+
nextConfig.iceTransportPolicy = 'relay';
|
|
92
|
+
}
|
|
93
|
+
this.rtcConfig = nextConfig;
|
|
94
|
+
for (const [, peer] of this.peers) {
|
|
95
|
+
try {
|
|
96
|
+
peer.pc.setConfiguration(nextConfig);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
this.logger?.log('warning', 'WebRTC', `Failed to update ICE config: ${formatError(error)}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
handleSignalingReconnect() {
|
|
104
|
+
if (!this.isSignalingConnected) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
for (const [remoteCid] of this.peers) {
|
|
108
|
+
if (this.shouldIOffer(remoteCid)) {
|
|
109
|
+
this.scheduleIceRestart(remoteCid, 'signaling-reconnect', 0);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
this.scheduleNonHostFallback(remoteCid);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Schedule glare-safe ICE restart for a specific peer because the server
|
|
118
|
+
* told us the pair is dirty after the peer reattached (#1).
|
|
119
|
+
*/
|
|
120
|
+
scheduleDirtyPairRestart(remoteCid) {
|
|
121
|
+
if (!this.peers.has(remoteCid)) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (this.shouldIOffer(remoteCid)) {
|
|
125
|
+
this.scheduleIceRestart(remoteCid, 'negotiation-dirty', 0);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
this.scheduleNonHostFallback(remoteCid);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
processSignalingMessage(msg) {
|
|
132
|
+
const { type, payload } = msg;
|
|
133
|
+
if (!payload)
|
|
134
|
+
return;
|
|
135
|
+
try {
|
|
136
|
+
switch (type) {
|
|
137
|
+
case 'offer': {
|
|
138
|
+
const offer = parseOfferPayload(payload);
|
|
139
|
+
if (offer)
|
|
140
|
+
void this.handleOfferFrom(offer.from, offer.sdp);
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
case 'answer': {
|
|
144
|
+
const answer = parseAnswerPayload(payload);
|
|
145
|
+
if (answer)
|
|
146
|
+
void this.handleAnswerFrom(answer.from, answer.sdp);
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
case 'ice': {
|
|
150
|
+
const ice = parseIceCandidatePayload(payload);
|
|
151
|
+
if (ice)
|
|
152
|
+
void this.handleIceFrom(ice.from, ice.candidate);
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
this.logger?.log('error', 'WebRTC', `Error processing message ${type}: ${formatError(err)}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async startLocalMedia() {
|
|
162
|
+
const requestId = this.mediaRequestId + 1;
|
|
163
|
+
this.mediaRequestId = requestId;
|
|
164
|
+
if (this.localStream)
|
|
165
|
+
return this.localStream;
|
|
166
|
+
this.requestingMedia = true;
|
|
167
|
+
try {
|
|
168
|
+
if (!navigator.mediaDevices?.getUserMedia) {
|
|
169
|
+
this.requestingMedia = false;
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
const audioConstraints = {
|
|
173
|
+
echoCancellation: { ideal: true },
|
|
174
|
+
noiseSuppression: { ideal: true },
|
|
175
|
+
autoGainControl: { ideal: true },
|
|
176
|
+
channelCount: { ideal: 1 },
|
|
177
|
+
sampleRate: { ideal: 48000 }
|
|
178
|
+
};
|
|
179
|
+
let stream;
|
|
180
|
+
if (!this.videoCaptureSupported || !this.initialVideoEnabled) {
|
|
181
|
+
stream = await navigator.mediaDevices.getUserMedia({ video: false, audio: audioConstraints });
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
try {
|
|
185
|
+
stream = await navigator.mediaDevices.getUserMedia({
|
|
186
|
+
video: { facingMode: this.facingMode },
|
|
187
|
+
audio: audioConstraints
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (this.destroyed || this.mediaRequestId !== requestId) {
|
|
195
|
+
stream.getTracks().forEach(t => t.stop());
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
this.applySpeechTrackHints(stream);
|
|
199
|
+
this.localStream = stream;
|
|
200
|
+
await this.detectCameras();
|
|
201
|
+
this.requestingMedia = false;
|
|
202
|
+
for (const [remoteCid, peer] of this.peers) {
|
|
203
|
+
await this.attachLocalTracksToPeer(remoteCid, peer, stream);
|
|
204
|
+
void this.applyAudioSenderParameters(peer.pc);
|
|
205
|
+
if (this.shouldIOffer(remoteCid) && !peer.pc.remoteDescription) {
|
|
206
|
+
if (peer.pc.signalingState === 'stable') {
|
|
207
|
+
void this.createOfferTo(remoteCid);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
peer.pendingLocalTrackNegotiation = true;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
else if (!this.shouldIOffer(remoteCid) && peer.pc.remoteDescription) {
|
|
214
|
+
if (peer.pc.signalingState === 'stable') {
|
|
215
|
+
void this.createOfferTo(remoteCid);
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
peer.pendingLocalTrackNegotiation = true;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
this.notifyChange();
|
|
223
|
+
return stream;
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
this.logger?.log('error', 'WebRTC', `Error accessing media: ${formatError(err)}`);
|
|
227
|
+
this.requestingMedia = false;
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
stopLocalMedia() {
|
|
232
|
+
this.mediaRequestId += 1;
|
|
233
|
+
if (this.screenShareTrack) {
|
|
234
|
+
this.screenShareTrack.onended = null;
|
|
235
|
+
this.screenShareTrack = null;
|
|
236
|
+
}
|
|
237
|
+
if (this.localStream) {
|
|
238
|
+
this.localStream.getTracks().forEach(t => t.stop());
|
|
239
|
+
this.localStream = null;
|
|
240
|
+
}
|
|
241
|
+
this.isScreenSharing = false;
|
|
242
|
+
this.requestingMedia = false;
|
|
243
|
+
this.notifyChange();
|
|
244
|
+
}
|
|
245
|
+
async releaseVideoTrack() {
|
|
246
|
+
if (this.isScreenSharing)
|
|
247
|
+
return;
|
|
248
|
+
const currentTrack = this.localStream?.getVideoTracks()[0] ?? null;
|
|
249
|
+
if (!currentTrack)
|
|
250
|
+
return;
|
|
251
|
+
await this.swapLocalVideoTrack(null, currentTrack);
|
|
252
|
+
}
|
|
253
|
+
async reacquireVideoTrack() {
|
|
254
|
+
if (!this.videoCaptureSupported)
|
|
255
|
+
return;
|
|
256
|
+
if (this.isScreenSharing)
|
|
257
|
+
return;
|
|
258
|
+
if (this.localStream?.getVideoTracks()[0])
|
|
259
|
+
return;
|
|
260
|
+
if (this.cameraRecoveryInFlight || this.requestingMedia)
|
|
261
|
+
return;
|
|
262
|
+
this.cameraRecoveryInFlight = true;
|
|
263
|
+
try {
|
|
264
|
+
const track = await this.acquireCameraTrack(this.facingMode, true);
|
|
265
|
+
await this.swapLocalVideoTrack(track, null);
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
this.logger?.log('error', 'Camera', `Failed to reacquire camera: ${formatError(err)}`);
|
|
269
|
+
}
|
|
270
|
+
finally {
|
|
271
|
+
this.cameraRecoveryInFlight = false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
async startScreenShare() {
|
|
275
|
+
if (this.isScreenSharing || !this.canScreenShare)
|
|
276
|
+
return;
|
|
277
|
+
if (!this.localStream)
|
|
278
|
+
return;
|
|
279
|
+
try {
|
|
280
|
+
const displayStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
|
|
281
|
+
const displayTrack = displayStream.getVideoTracks()[0];
|
|
282
|
+
if (!displayTrack) {
|
|
283
|
+
displayStream.getTracks().forEach(track => track.stop());
|
|
284
|
+
throw new Error('No display track returned');
|
|
285
|
+
}
|
|
286
|
+
const previousVideoTrack = this.localStream.getVideoTracks()[0];
|
|
287
|
+
const wasVideoEnabled = previousVideoTrack ? previousVideoTrack.enabled : true;
|
|
288
|
+
displayTrack.enabled = wasVideoEnabled;
|
|
289
|
+
if ('contentHint' in displayTrack) {
|
|
290
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- contentHint is a valid but untyped browser API
|
|
291
|
+
try {
|
|
292
|
+
displayTrack.contentHint = 'detail';
|
|
293
|
+
}
|
|
294
|
+
catch { /* ignore */ }
|
|
295
|
+
}
|
|
296
|
+
if (this.screenShareTrack)
|
|
297
|
+
this.screenShareTrack.onended = null;
|
|
298
|
+
this.screenShareTrack = displayTrack;
|
|
299
|
+
displayTrack.onended = () => { void this.stopScreenShare(); };
|
|
300
|
+
await this.swapLocalVideoTrack(displayTrack, previousVideoTrack);
|
|
301
|
+
this.isScreenSharing = true;
|
|
302
|
+
this.sendSignalingMessage('content_state', { active: true, contentType: 'screenShare' });
|
|
303
|
+
this.notifyChange();
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
this.logger?.log('error', 'ScreenShare', `Failed to start screen share: ${formatError(err)}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
async stopScreenShare() {
|
|
310
|
+
if (!this.isScreenSharing)
|
|
311
|
+
return;
|
|
312
|
+
if (!this.localStream) {
|
|
313
|
+
this.isScreenSharing = false;
|
|
314
|
+
this.sendSignalingMessage('content_state', { active: false });
|
|
315
|
+
this.notifyChange();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (this.screenShareTrack) {
|
|
319
|
+
this.screenShareTrack.onended = null;
|
|
320
|
+
this.screenShareTrack = null;
|
|
321
|
+
}
|
|
322
|
+
const previousVideoTrack = this.localStream.getVideoTracks()[0];
|
|
323
|
+
const wasVideoEnabled = previousVideoTrack ? previousVideoTrack.enabled : true;
|
|
324
|
+
try {
|
|
325
|
+
const cameraTrack = await this.acquireCameraTrack(this.facingMode, wasVideoEnabled);
|
|
326
|
+
await this.swapLocalVideoTrack(cameraTrack, previousVideoTrack);
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
this.logger?.log('error', 'ScreenShare', `Failed to stop screen share and restore camera: ${formatError(err)}`);
|
|
330
|
+
await this.swapLocalVideoTrack(null, previousVideoTrack);
|
|
331
|
+
}
|
|
332
|
+
finally {
|
|
333
|
+
this.isScreenSharing = false;
|
|
334
|
+
this.sendSignalingMessage('content_state', { active: false });
|
|
335
|
+
this.notifyChange();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
async flipCamera() {
|
|
339
|
+
if (this.isScreenSharing)
|
|
340
|
+
return;
|
|
341
|
+
if (!this.hasMultipleCameras)
|
|
342
|
+
return;
|
|
343
|
+
const newMode = this.facingMode === 'user' ? 'environment' : 'user';
|
|
344
|
+
this.facingMode = newMode;
|
|
345
|
+
if (!this.localStream) {
|
|
346
|
+
this.notifyChange();
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
const oldVideoTrack = this.localStream.getVideoTracks()[0];
|
|
351
|
+
const newVideoTrack = await this.acquireCameraTrack(newMode, oldVideoTrack?.enabled ?? true);
|
|
352
|
+
await this.swapLocalVideoTrack(newVideoTrack, oldVideoTrack);
|
|
353
|
+
this.notifyChange();
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
this.logger?.log('error', 'Camera', `Failed to flip camera: ${formatError(err)}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
getPeerConnections() {
|
|
360
|
+
return Array.from(this.peers.values()).map(ps => ps.pc);
|
|
361
|
+
}
|
|
362
|
+
getPeerConnectionsMap() {
|
|
363
|
+
const map = new Map();
|
|
364
|
+
for (const [cid, ps] of this.peers)
|
|
365
|
+
map.set(cid, ps.pc);
|
|
366
|
+
return map;
|
|
367
|
+
}
|
|
368
|
+
cleanupAllPeers() {
|
|
369
|
+
for (const [, peer] of this.peers) {
|
|
370
|
+
this.clearPeerTimers(peer);
|
|
371
|
+
peer.pc.close();
|
|
372
|
+
}
|
|
373
|
+
this.peers.clear();
|
|
374
|
+
this.remoteStreams = new Map();
|
|
375
|
+
this.lastInboundBytesByCid.clear();
|
|
376
|
+
if (this.retryingTimer) {
|
|
377
|
+
window.clearTimeout(this.retryingTimer);
|
|
378
|
+
this.retryingTimer = null;
|
|
379
|
+
}
|
|
380
|
+
this.iceConnectionState = 'closed';
|
|
381
|
+
this.connectionState = 'closed';
|
|
382
|
+
this.signalingState = 'closed';
|
|
383
|
+
this.connectionStatus = 'connected';
|
|
384
|
+
this.notifyChange();
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Sample inbound RTP `bytesReceived` per remote peer and return the CIDs
|
|
388
|
+
* whose totals advanced since the previous sample. Drives #3's
|
|
389
|
+
* `media_liveness{cids}` emission so the server can defer hard-eviction
|
|
390
|
+
* of suspended peers whose media is still being received locally.
|
|
391
|
+
*
|
|
392
|
+
* Conservative on first call (no baseline → empty result). Cleans up
|
|
393
|
+
* tracking for peers that have left.
|
|
394
|
+
*/
|
|
395
|
+
async getInboundFlowingCids() {
|
|
396
|
+
const flowing = [];
|
|
397
|
+
const seen = new Set();
|
|
398
|
+
for (const [cid, peer] of this.peers) {
|
|
399
|
+
seen.add(cid);
|
|
400
|
+
let bytes = 0;
|
|
401
|
+
try {
|
|
402
|
+
const report = await peer.pc.getStats();
|
|
403
|
+
report.forEach((stat) => {
|
|
404
|
+
if (stat.type !== 'inbound-rtp')
|
|
405
|
+
return;
|
|
406
|
+
const value = stat['bytesReceived'];
|
|
407
|
+
if (typeof value === 'number')
|
|
408
|
+
bytes += value;
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
const previous = this.lastInboundBytesByCid.get(cid);
|
|
415
|
+
if (previous !== undefined && bytes > previous) {
|
|
416
|
+
flowing.push(cid);
|
|
417
|
+
}
|
|
418
|
+
this.lastInboundBytesByCid.set(cid, bytes);
|
|
419
|
+
}
|
|
420
|
+
for (const cid of [...this.lastInboundBytesByCid.keys()]) {
|
|
421
|
+
if (!seen.has(cid))
|
|
422
|
+
this.lastInboundBytesByCid.delete(cid);
|
|
423
|
+
}
|
|
424
|
+
return flowing;
|
|
425
|
+
}
|
|
426
|
+
destroy() {
|
|
427
|
+
this.destroyed = true;
|
|
428
|
+
this.cleanupAllPeers();
|
|
429
|
+
this.stopLocalMedia();
|
|
430
|
+
this.removeEventListeners();
|
|
431
|
+
if (this.retryingTimer) {
|
|
432
|
+
window.clearTimeout(this.retryingTimer);
|
|
433
|
+
this.retryingTimer = null;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Inspects each peer connection's currently-selected ICE candidate pair
|
|
438
|
+
* and returns true only when at least one peer exists and every peer's
|
|
439
|
+
* local candidate is direct (host / srflx / prflx). Returns false when
|
|
440
|
+
* any peer is relaying through TURN, when any stats query fails, or when
|
|
441
|
+
* there are no peers (TURN may be needed for a future join).
|
|
442
|
+
*
|
|
443
|
+
* We identify the active pair via `RTCTransportStats.selectedCandidatePairId`,
|
|
444
|
+
* with a fallback to the nominated+succeeded pair. We do NOT accept any
|
|
445
|
+
* arbitrary succeeded pair: after an ICE failover the old pair stays
|
|
446
|
+
* present as "succeeded" for a while, so reading it would lie about the
|
|
447
|
+
* current active path and wrongly suppress TURN refresh while media is
|
|
448
|
+
* actually relaying.
|
|
449
|
+
*
|
|
450
|
+
* Used by the TURN refresh gate: if all active media flows are direct,
|
|
451
|
+
* refreshing TURN credentials over signaling is unnecessary upkeep. This
|
|
452
|
+
* lets a P2P call survive indefinite signaling outages.
|
|
453
|
+
*/
|
|
454
|
+
async arePeerPathsAllDirect() {
|
|
455
|
+
const activePeers = Array.from(this.peers.values())
|
|
456
|
+
.filter((peer) => peer.pc.connectionState !== 'closed' && peer.pc.connectionState !== 'failed');
|
|
457
|
+
if (activePeers.length === 0)
|
|
458
|
+
return false;
|
|
459
|
+
const results = await Promise.all(activePeers.map(async (peer) => {
|
|
460
|
+
try {
|
|
461
|
+
return this.isPeerOnDirectPath(await peer.pc.getStats());
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
}));
|
|
467
|
+
return results.every(Boolean);
|
|
468
|
+
}
|
|
469
|
+
isPeerOnDirectPath(stats) {
|
|
470
|
+
// Preferred: resolve the active pair through the transport stat.
|
|
471
|
+
let selectedPairId = null;
|
|
472
|
+
for (const report of stats.values()) {
|
|
473
|
+
if (report.type !== 'transport')
|
|
474
|
+
continue;
|
|
475
|
+
const id = report.selectedCandidatePairId;
|
|
476
|
+
if (typeof id === 'string' && id !== '') {
|
|
477
|
+
selectedPairId = id;
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
let activePair = null;
|
|
482
|
+
if (selectedPairId) {
|
|
483
|
+
const pair = stats.get(selectedPairId);
|
|
484
|
+
if (pair && pair.type === 'candidate-pair') {
|
|
485
|
+
activePair = pair;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
// Fallback for browsers that don't populate selectedCandidatePairId:
|
|
489
|
+
// the nominated + succeeded pair is authoritative once ICE settles.
|
|
490
|
+
if (!activePair) {
|
|
491
|
+
for (const report of stats.values()) {
|
|
492
|
+
if (report.type !== 'candidate-pair')
|
|
493
|
+
continue;
|
|
494
|
+
const pair = report;
|
|
495
|
+
if (pair.state !== 'succeeded')
|
|
496
|
+
continue;
|
|
497
|
+
if (!pair.nominated)
|
|
498
|
+
continue;
|
|
499
|
+
activePair = pair;
|
|
500
|
+
break;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (!activePair)
|
|
504
|
+
return false;
|
|
505
|
+
const localId = activePair.localCandidateId;
|
|
506
|
+
if (!localId)
|
|
507
|
+
return false;
|
|
508
|
+
const local = stats.get(localId);
|
|
509
|
+
if (!local || local.type !== 'local-candidate')
|
|
510
|
+
return false;
|
|
511
|
+
const candType = (local.candidateType ?? '').toString();
|
|
512
|
+
return candType !== '' && candType !== 'relay';
|
|
513
|
+
}
|
|
514
|
+
// --- Private methods ---
|
|
515
|
+
syncPeers() {
|
|
516
|
+
const myId = this.clientId;
|
|
517
|
+
if (!this.roomState || !myId) {
|
|
518
|
+
if (this.peers.size > 0) {
|
|
519
|
+
this.logger?.log('debug', 'WebRTC', 'Room state cleared, cleaning up all peers');
|
|
520
|
+
this.cleanupAllPeers();
|
|
521
|
+
}
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
const remotePeers = this.roomState.participants?.filter(p => p.cid !== myId) ?? [];
|
|
525
|
+
const remoteCids = new Set(remotePeers.map(p => p.cid));
|
|
526
|
+
for (const [cid] of this.peers) {
|
|
527
|
+
if (!remoteCids.has(cid)) {
|
|
528
|
+
this.logger?.log('debug', 'WebRTC', `Participant ${cid} left, cleaning up peer`);
|
|
529
|
+
this.cleanupPeer(cid);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
for (const peer of remotePeers) {
|
|
533
|
+
if (!this.peers.has(peer.cid)) {
|
|
534
|
+
this.getOrCreatePeer(peer.cid);
|
|
535
|
+
if (this.shouldIOffer(peer.cid)) {
|
|
536
|
+
const peerState = this.peers.get(peer.cid);
|
|
537
|
+
if (peerState &&
|
|
538
|
+
this.localStream &&
|
|
539
|
+
peerState.pc.signalingState === 'stable' &&
|
|
540
|
+
!peerState.pc.remoteDescription) {
|
|
541
|
+
void this.createOfferTo(peer.cid);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
else {
|
|
545
|
+
this.scheduleNonHostFallback(peer.cid);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
this.notifyChange();
|
|
550
|
+
}
|
|
551
|
+
getOrCreatePeer(remoteCid) {
|
|
552
|
+
const existing = this.peers.get(remoteCid);
|
|
553
|
+
if (existing)
|
|
554
|
+
return existing;
|
|
555
|
+
const pc = new RTCPeerConnection(this.rtcConfig);
|
|
556
|
+
const peerState = {
|
|
557
|
+
pc, remoteStream: null, iceBuffer: [],
|
|
558
|
+
isMakingOffer: false, offerTimeout: null, iceRestartTimer: null,
|
|
559
|
+
lastIceRestartAt: 0, pendingIceRestart: false,
|
|
560
|
+
pendingLocalTrackNegotiation: false,
|
|
561
|
+
nonHostFallbackTimer: null, nonHostFallbackAttempts: 0,
|
|
562
|
+
};
|
|
563
|
+
if (this.localStream) {
|
|
564
|
+
this.localStream.getTracks().forEach(track => pc.addTrack(track, this.localStream));
|
|
565
|
+
void this.applyAudioSenderParameters(pc);
|
|
566
|
+
}
|
|
567
|
+
this.ensureMediaTransceivers(pc);
|
|
568
|
+
pc.ontrack = (event) => {
|
|
569
|
+
this.logger?.log('debug', 'WebRTC', `[${remoteCid}] Remote track received`);
|
|
570
|
+
let remoteStream;
|
|
571
|
+
if (event.streams?.[0]) {
|
|
572
|
+
remoteStream = event.streams[0];
|
|
573
|
+
}
|
|
574
|
+
else {
|
|
575
|
+
remoteStream = peerState.remoteStream || new MediaStream();
|
|
576
|
+
if (!remoteStream.getTracks().some(t => t.id === event.track.id)) {
|
|
577
|
+
remoteStream.addTrack(event.track);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
peerState.remoteStream = remoteStream;
|
|
581
|
+
this.remoteStreams = new Map(this.remoteStreams).set(remoteCid, remoteStream);
|
|
582
|
+
this.notifyChange();
|
|
583
|
+
};
|
|
584
|
+
pc.oniceconnectionstatechange = () => {
|
|
585
|
+
this.logger?.log('debug', 'WebRTC', `[${remoteCid}] ICE: ${pc.iceConnectionState}`);
|
|
586
|
+
this.updateAggregateState();
|
|
587
|
+
if (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') {
|
|
588
|
+
if (peerState.iceRestartTimer) {
|
|
589
|
+
window.clearTimeout(peerState.iceRestartTimer);
|
|
590
|
+
peerState.iceRestartTimer = null;
|
|
591
|
+
}
|
|
592
|
+
peerState.pendingIceRestart = false;
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
if (pc.iceConnectionState === 'disconnected') {
|
|
596
|
+
this.scheduleIceRestart(remoteCid, 'ice-disconnected', 2000);
|
|
597
|
+
}
|
|
598
|
+
else if (pc.iceConnectionState === 'failed') {
|
|
599
|
+
this.scheduleIceRestart(remoteCid, 'ice-failed', 0);
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
pc.onconnectionstatechange = () => {
|
|
603
|
+
this.logger?.log('debug', 'WebRTC', `[${remoteCid}] Connection: ${pc.connectionState}`);
|
|
604
|
+
this.updateAggregateState();
|
|
605
|
+
if (pc.connectionState === 'connected') {
|
|
606
|
+
if (peerState.iceRestartTimer) {
|
|
607
|
+
window.clearTimeout(peerState.iceRestartTimer);
|
|
608
|
+
peerState.iceRestartTimer = null;
|
|
609
|
+
}
|
|
610
|
+
peerState.pendingIceRestart = false;
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
if (pc.connectionState === 'disconnected') {
|
|
614
|
+
this.scheduleIceRestart(remoteCid, 'conn-disconnected', 2000);
|
|
615
|
+
}
|
|
616
|
+
else if (pc.connectionState === 'failed') {
|
|
617
|
+
this.scheduleIceRestart(remoteCid, 'conn-failed', 0);
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
pc.onsignalingstatechange = () => {
|
|
621
|
+
this.updateAggregateState();
|
|
622
|
+
if (pc.signalingState === 'stable') {
|
|
623
|
+
if (peerState.offerTimeout) {
|
|
624
|
+
window.clearTimeout(peerState.offerTimeout);
|
|
625
|
+
peerState.offerTimeout = null;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
if (pc.signalingState === 'stable' && peerState.pendingLocalTrackNegotiation) {
|
|
629
|
+
this.scheduleLocalTrackNegotiation(remoteCid, peerState);
|
|
630
|
+
}
|
|
631
|
+
if (pc.signalingState === 'stable' && peerState.pendingIceRestart) {
|
|
632
|
+
if (peerState.offerTimeout) {
|
|
633
|
+
window.clearTimeout(peerState.offerTimeout);
|
|
634
|
+
peerState.offerTimeout = null;
|
|
635
|
+
}
|
|
636
|
+
if (!this.isSignalingConnected || !this.shouldIOffer(remoteCid))
|
|
637
|
+
return;
|
|
638
|
+
peerState.pendingIceRestart = false;
|
|
639
|
+
peerState.lastIceRestartAt = Date.now();
|
|
640
|
+
void this.createOfferTo(remoteCid, { iceRestart: true });
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
pc.onicecandidate = (event) => {
|
|
644
|
+
if (event.candidate) {
|
|
645
|
+
const candidate = event.candidate.toJSON();
|
|
646
|
+
if (!candidate.sdpMid) {
|
|
647
|
+
candidate.sdpMid = String(candidate.sdpMLineIndex ?? 0);
|
|
648
|
+
}
|
|
649
|
+
this.sendSignalingMessage('ice', { candidate }, remoteCid);
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
pc.onnegotiationneeded = async () => {
|
|
653
|
+
const peer = this.peers.get(remoteCid);
|
|
654
|
+
if (!peer)
|
|
655
|
+
return;
|
|
656
|
+
if (!this.shouldIOffer(remoteCid))
|
|
657
|
+
return;
|
|
658
|
+
if (!this.localStream) {
|
|
659
|
+
peer.pendingLocalTrackNegotiation = true;
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
await this.createOfferTo(remoteCid);
|
|
663
|
+
};
|
|
664
|
+
this.peers.set(remoteCid, peerState);
|
|
665
|
+
return peerState;
|
|
666
|
+
}
|
|
667
|
+
cleanupPeer(remoteCid) {
|
|
668
|
+
const peer = this.peers.get(remoteCid);
|
|
669
|
+
if (!peer)
|
|
670
|
+
return;
|
|
671
|
+
this.clearPeerTimers(peer);
|
|
672
|
+
peer.pc.close();
|
|
673
|
+
this.peers.delete(remoteCid);
|
|
674
|
+
const next = new Map(this.remoteStreams);
|
|
675
|
+
next.delete(remoteCid);
|
|
676
|
+
this.remoteStreams = next;
|
|
677
|
+
this.updateAggregateState();
|
|
678
|
+
}
|
|
679
|
+
clearPeerTimers(peer) {
|
|
680
|
+
if (peer.offerTimeout) {
|
|
681
|
+
window.clearTimeout(peer.offerTimeout);
|
|
682
|
+
peer.offerTimeout = null;
|
|
683
|
+
}
|
|
684
|
+
if (peer.iceRestartTimer) {
|
|
685
|
+
window.clearTimeout(peer.iceRestartTimer);
|
|
686
|
+
peer.iceRestartTimer = null;
|
|
687
|
+
}
|
|
688
|
+
if (peer.nonHostFallbackTimer) {
|
|
689
|
+
window.clearTimeout(peer.nonHostFallbackTimer);
|
|
690
|
+
peer.nonHostFallbackTimer = null;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
shouldIOffer(remoteCid) {
|
|
694
|
+
const myId = this.clientId;
|
|
695
|
+
return typeof myId === 'string' && myId.length > 0 && myId < remoteCid;
|
|
696
|
+
}
|
|
697
|
+
async createOfferTo(remoteCid, options) {
|
|
698
|
+
const peer = this.peers.get(remoteCid);
|
|
699
|
+
if (!peer)
|
|
700
|
+
return;
|
|
701
|
+
if (peer.isMakingOffer) {
|
|
702
|
+
if (options?.iceRestart)
|
|
703
|
+
peer.pendingIceRestart = true;
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
try {
|
|
707
|
+
if (peer.pc.signalingState !== 'stable') {
|
|
708
|
+
if (options?.iceRestart)
|
|
709
|
+
peer.pendingIceRestart = true;
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
peer.isMakingOffer = true;
|
|
713
|
+
const offer = await peer.pc.createOffer(options);
|
|
714
|
+
await peer.pc.setLocalDescription(offer);
|
|
715
|
+
this.sendSignalingMessage('offer', { sdp: offer.sdp }, remoteCid);
|
|
716
|
+
if (peer.offerTimeout)
|
|
717
|
+
window.clearTimeout(peer.offerTimeout);
|
|
718
|
+
peer.offerTimeout = window.setTimeout(() => {
|
|
719
|
+
peer.offerTimeout = null;
|
|
720
|
+
const currentPeer = this.peers.get(remoteCid);
|
|
721
|
+
if (!currentPeer)
|
|
722
|
+
return;
|
|
723
|
+
this.logger?.log('warning', 'WebRTC', `[${remoteCid}] Offer timeout`);
|
|
724
|
+
currentPeer.pendingIceRestart = true;
|
|
725
|
+
if (currentPeer.pc.signalingState === 'have-local-offer') {
|
|
726
|
+
currentPeer.pc.setLocalDescription({ type: 'rollback' })
|
|
727
|
+
.catch(err => this.logger?.log('warning', 'WebRTC', `[${remoteCid}] Rollback failed: ${formatError(err)}`))
|
|
728
|
+
.finally(() => this.scheduleIceRestart(remoteCid, 'offer-timeout', 0));
|
|
729
|
+
}
|
|
730
|
+
else {
|
|
731
|
+
this.scheduleIceRestart(remoteCid, 'offer-timeout-unexpected-state', 0);
|
|
732
|
+
}
|
|
733
|
+
}, OFFER_TIMEOUT_MS);
|
|
734
|
+
}
|
|
735
|
+
catch (err) {
|
|
736
|
+
this.logger?.log('error', 'WebRTC', `[${remoteCid}] Error creating offer: ${formatError(err)}`);
|
|
737
|
+
}
|
|
738
|
+
finally {
|
|
739
|
+
peer.isMakingOffer = false;
|
|
740
|
+
if (peer.pendingIceRestart) {
|
|
741
|
+
peer.pendingIceRestart = false;
|
|
742
|
+
this.scheduleIceRestart(remoteCid, 'pending-retry', 500);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
scheduleIceRestart(remoteCid, reason, delayMs) {
|
|
747
|
+
const peer = this.peers.get(remoteCid);
|
|
748
|
+
if (!peer)
|
|
749
|
+
return;
|
|
750
|
+
if (!this.isSignalingConnected) {
|
|
751
|
+
peer.pendingIceRestart = true;
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
if (peer.iceRestartTimer)
|
|
755
|
+
return;
|
|
756
|
+
if (Date.now() - peer.lastIceRestartAt < ICE_RESTART_COOLDOWN_MS)
|
|
757
|
+
return;
|
|
758
|
+
peer.iceRestartTimer = window.setTimeout(() => {
|
|
759
|
+
peer.iceRestartTimer = null;
|
|
760
|
+
void this.triggerIceRestart(remoteCid, reason);
|
|
761
|
+
}, delayMs);
|
|
762
|
+
}
|
|
763
|
+
async triggerIceRestart(remoteCid, reason) {
|
|
764
|
+
const peer = this.peers.get(remoteCid);
|
|
765
|
+
if (!peer)
|
|
766
|
+
return;
|
|
767
|
+
if (!this.isSignalingConnected) {
|
|
768
|
+
peer.pendingIceRestart = true;
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
if (!this.shouldIOffer(remoteCid))
|
|
772
|
+
return;
|
|
773
|
+
if (peer.isMakingOffer) {
|
|
774
|
+
peer.pendingIceRestart = true;
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
peer.lastIceRestartAt = Date.now();
|
|
778
|
+
peer.pendingIceRestart = false;
|
|
779
|
+
this.logger?.log('warning', 'WebRTC', `ICE restart triggered for ${remoteCid} (${reason})`);
|
|
780
|
+
await this.createOfferTo(remoteCid, { iceRestart: true });
|
|
781
|
+
}
|
|
782
|
+
scheduleNonHostFallback(remoteCid) {
|
|
783
|
+
if (this.shouldIOffer(remoteCid))
|
|
784
|
+
return;
|
|
785
|
+
const peer = this.peers.get(remoteCid);
|
|
786
|
+
if (!peer || peer.nonHostFallbackTimer)
|
|
787
|
+
return;
|
|
788
|
+
if (peer.nonHostFallbackAttempts >= NON_HOST_FALLBACK_MAX_ATTEMPTS)
|
|
789
|
+
return;
|
|
790
|
+
peer.nonHostFallbackTimer = window.setTimeout(async () => {
|
|
791
|
+
peer.nonHostFallbackTimer = null;
|
|
792
|
+
const currentPeer = this.peers.get(remoteCid);
|
|
793
|
+
if (!currentPeer)
|
|
794
|
+
return;
|
|
795
|
+
if (this.shouldIOffer(remoteCid))
|
|
796
|
+
return;
|
|
797
|
+
if (currentPeer.pc.remoteDescription)
|
|
798
|
+
return;
|
|
799
|
+
if (currentPeer.pc.signalingState !== 'stable')
|
|
800
|
+
return;
|
|
801
|
+
if (!this.isSignalingConnected)
|
|
802
|
+
return;
|
|
803
|
+
currentPeer.nonHostFallbackAttempts++;
|
|
804
|
+
this.logger?.log('warning', 'WebRTC', `[${remoteCid}] Non-host fallback offer (attempt ${currentPeer.nonHostFallbackAttempts})`);
|
|
805
|
+
try {
|
|
806
|
+
const offer = await currentPeer.pc.createOffer();
|
|
807
|
+
await currentPeer.pc.setLocalDescription(offer);
|
|
808
|
+
this.sendSignalingMessage('offer', { sdp: offer.sdp }, remoteCid);
|
|
809
|
+
if (currentPeer.offerTimeout)
|
|
810
|
+
window.clearTimeout(currentPeer.offerTimeout);
|
|
811
|
+
currentPeer.offerTimeout = window.setTimeout(async () => {
|
|
812
|
+
currentPeer.offerTimeout = null;
|
|
813
|
+
const p = this.peers.get(remoteCid);
|
|
814
|
+
if (!p)
|
|
815
|
+
return;
|
|
816
|
+
if (p.pc.signalingState === 'have-local-offer') {
|
|
817
|
+
try {
|
|
818
|
+
await p.pc.setLocalDescription({ type: 'rollback' });
|
|
819
|
+
}
|
|
820
|
+
catch (err) {
|
|
821
|
+
this.logger?.log('warning', 'WebRTC', `[${remoteCid}] Non-host rollback failed: ${formatError(err)}`);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
this.scheduleNonHostFallback(remoteCid);
|
|
825
|
+
}, OFFER_TIMEOUT_MS);
|
|
826
|
+
}
|
|
827
|
+
catch (err) {
|
|
828
|
+
this.logger?.log('error', 'WebRTC', `[${remoteCid}] Non-host fallback offer failed: ${formatError(err)}`);
|
|
829
|
+
this.scheduleNonHostFallback(remoteCid);
|
|
830
|
+
}
|
|
831
|
+
}, NON_HOST_FALLBACK_DELAY_MS);
|
|
832
|
+
}
|
|
833
|
+
async handleOfferFrom(fromCid, sdp) {
|
|
834
|
+
try {
|
|
835
|
+
const peer = this.getOrCreatePeer(fromCid);
|
|
836
|
+
if (peer.nonHostFallbackTimer) {
|
|
837
|
+
window.clearTimeout(peer.nonHostFallbackTimer);
|
|
838
|
+
peer.nonHostFallbackTimer = null;
|
|
839
|
+
}
|
|
840
|
+
await peer.pc.setRemoteDescription(new RTCSessionDescription({ type: 'offer', sdp }));
|
|
841
|
+
if (peer.offerTimeout) {
|
|
842
|
+
window.clearTimeout(peer.offerTimeout);
|
|
843
|
+
peer.offerTimeout = null;
|
|
844
|
+
}
|
|
845
|
+
while (peer.iceBuffer.length > 0) {
|
|
846
|
+
const c = peer.iceBuffer.shift();
|
|
847
|
+
if (c)
|
|
848
|
+
await peer.pc.addIceCandidate(c);
|
|
849
|
+
}
|
|
850
|
+
const answer = await peer.pc.createAnswer();
|
|
851
|
+
await peer.pc.setLocalDescription(answer);
|
|
852
|
+
this.sendSignalingMessage('answer', { sdp: answer.sdp }, fromCid);
|
|
853
|
+
}
|
|
854
|
+
catch (err) {
|
|
855
|
+
this.logger?.log('error', 'WebRTC', `[${fromCid}] Error handling offer: ${formatError(err)}`);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
async handleAnswerFrom(fromCid, sdp) {
|
|
859
|
+
try {
|
|
860
|
+
const peer = this.peers.get(fromCid);
|
|
861
|
+
if (!peer)
|
|
862
|
+
return;
|
|
863
|
+
if (peer.nonHostFallbackTimer) {
|
|
864
|
+
window.clearTimeout(peer.nonHostFallbackTimer);
|
|
865
|
+
peer.nonHostFallbackTimer = null;
|
|
866
|
+
}
|
|
867
|
+
await peer.pc.setRemoteDescription(new RTCSessionDescription({ type: 'answer', sdp }));
|
|
868
|
+
if (peer.offerTimeout) {
|
|
869
|
+
window.clearTimeout(peer.offerTimeout);
|
|
870
|
+
peer.offerTimeout = null;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
catch (err) {
|
|
874
|
+
this.logger?.log('error', 'WebRTC', `[${fromCid}] Error handling answer: ${formatError(err)}`);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
async handleIceFrom(fromCid, candidate) {
|
|
878
|
+
try {
|
|
879
|
+
const peer = this.getOrCreatePeer(fromCid);
|
|
880
|
+
if (peer.pc.remoteDescription) {
|
|
881
|
+
await peer.pc.addIceCandidate(candidate);
|
|
882
|
+
}
|
|
883
|
+
else {
|
|
884
|
+
if (peer.iceBuffer.length >= ICE_CANDIDATE_BUFFER_MAX)
|
|
885
|
+
peer.iceBuffer.shift();
|
|
886
|
+
peer.iceBuffer.push(candidate);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
catch (err) {
|
|
890
|
+
this.logger?.log('error', 'WebRTC', `[${fromCid}] Error handling ICE candidate: ${formatError(err)}`);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
updateAggregateState() {
|
|
894
|
+
const peers = this.peers;
|
|
895
|
+
let worstIce = peers.size === 0 ? 'new' : 'completed';
|
|
896
|
+
let worstConn = peers.size === 0 ? 'new' : 'connected';
|
|
897
|
+
let worstSig = peers.size === 0 ? 'stable' : 'stable';
|
|
898
|
+
if (peers.size > 0) {
|
|
899
|
+
for (const [, peer] of peers) {
|
|
900
|
+
const ice = peer.pc.iceConnectionState;
|
|
901
|
+
const conn = peer.pc.connectionState;
|
|
902
|
+
const sig = peer.pc.signalingState;
|
|
903
|
+
if (ICE_STATE_PRIORITY.indexOf(ice) < ICE_STATE_PRIORITY.indexOf(worstIce))
|
|
904
|
+
worstIce = ice;
|
|
905
|
+
if (CONN_STATE_PRIORITY.indexOf(conn) < CONN_STATE_PRIORITY.indexOf(worstConn))
|
|
906
|
+
worstConn = conn;
|
|
907
|
+
if (SIG_STATE_PRIORITY.indexOf(sig) < SIG_STATE_PRIORITY.indexOf(worstSig))
|
|
908
|
+
worstSig = sig;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
this.iceConnectionState = worstIce;
|
|
912
|
+
this.connectionState = worstConn;
|
|
913
|
+
this.signalingState = worstSig;
|
|
914
|
+
this.updateConnectionStatusValue();
|
|
915
|
+
this.notifyChange();
|
|
916
|
+
}
|
|
917
|
+
updateConnectionStatusValue() {
|
|
918
|
+
const isActive = !!this.roomState && (this.roomState.participants?.length ?? 0) > 1;
|
|
919
|
+
if (!isActive) {
|
|
920
|
+
this.resetConnectionStatusMachine();
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
const isDegraded = !this.isSignalingConnected ||
|
|
924
|
+
this.iceConnectionState === 'disconnected' || this.iceConnectionState === 'failed' ||
|
|
925
|
+
this.connectionState === 'disconnected' || this.connectionState === 'failed';
|
|
926
|
+
if (isDegraded) {
|
|
927
|
+
this.setConnectionRecovering();
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
this.resetConnectionStatusMachine();
|
|
931
|
+
}
|
|
932
|
+
resetConnectionStatusMachine() {
|
|
933
|
+
if (this.retryingTimer) {
|
|
934
|
+
window.clearTimeout(this.retryingTimer);
|
|
935
|
+
this.retryingTimer = null;
|
|
936
|
+
}
|
|
937
|
+
this.connectionStatus = 'connected';
|
|
938
|
+
}
|
|
939
|
+
setConnectionRecovering() {
|
|
940
|
+
if (this.connectionStatus === 'connected')
|
|
941
|
+
this.connectionStatus = 'recovering';
|
|
942
|
+
if (this.connectionStatus !== 'retrying')
|
|
943
|
+
this.scheduleRetryingTransition();
|
|
944
|
+
}
|
|
945
|
+
scheduleRetryingTransition() {
|
|
946
|
+
if (this.retryingTimer)
|
|
947
|
+
return;
|
|
948
|
+
this.retryingTimer = window.setTimeout(() => {
|
|
949
|
+
this.retryingTimer = null;
|
|
950
|
+
if (this.connectionStatus === 'recovering')
|
|
951
|
+
this.connectionStatus = 'retrying';
|
|
952
|
+
this.notifyChange();
|
|
953
|
+
}, CONNECTION_RETRYING_DELAY_MS);
|
|
954
|
+
}
|
|
955
|
+
normalizeIceServers(iceServers) {
|
|
956
|
+
return normalizeIceServers(iceServers, this.turnsOnly);
|
|
957
|
+
}
|
|
958
|
+
applySpeechTrackHints(stream) {
|
|
959
|
+
const audioTrack = stream.getAudioTracks()[0];
|
|
960
|
+
if (!audioTrack)
|
|
961
|
+
return;
|
|
962
|
+
if ('contentHint' in audioTrack) {
|
|
963
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- contentHint is a valid but untyped browser API
|
|
964
|
+
try {
|
|
965
|
+
audioTrack.contentHint = 'speech';
|
|
966
|
+
}
|
|
967
|
+
catch { /* ignore */ }
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
async applyAudioSenderParameters(pc) {
|
|
971
|
+
const sender = pc.getSenders().find(s => s.track?.kind === 'audio');
|
|
972
|
+
if (!sender?.getParameters || !sender?.setParameters)
|
|
973
|
+
return;
|
|
974
|
+
try {
|
|
975
|
+
const params = sender.getParameters();
|
|
976
|
+
if (!params.encodings || params.encodings.length === 0)
|
|
977
|
+
return;
|
|
978
|
+
const firstEncoding = params.encodings[0];
|
|
979
|
+
if (!firstEncoding || firstEncoding.maxBitrate === 32000)
|
|
980
|
+
return;
|
|
981
|
+
const nextParams = {
|
|
982
|
+
...params,
|
|
983
|
+
encodings: params.encodings.map((encoding, index) => (index === 0 ? { ...encoding, maxBitrate: 32000 } : encoding)),
|
|
984
|
+
};
|
|
985
|
+
await sender.setParameters(nextParams);
|
|
986
|
+
}
|
|
987
|
+
catch (err) {
|
|
988
|
+
this.logger?.log('warning', 'WebRTC', `Failed to apply audio sender parameters: ${formatError(err)}`);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
async acquireCameraTrack(targetFacingMode, enabled) {
|
|
992
|
+
let cameraStream;
|
|
993
|
+
try {
|
|
994
|
+
cameraStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: targetFacingMode }, audio: false });
|
|
995
|
+
}
|
|
996
|
+
catch {
|
|
997
|
+
cameraStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
|
|
998
|
+
}
|
|
999
|
+
const cameraTrack = cameraStream.getVideoTracks()[0];
|
|
1000
|
+
if (!cameraTrack) {
|
|
1001
|
+
cameraStream.getTracks().forEach(track => track.stop());
|
|
1002
|
+
throw new Error('No camera track returned');
|
|
1003
|
+
}
|
|
1004
|
+
cameraTrack.enabled = enabled;
|
|
1005
|
+
return cameraTrack;
|
|
1006
|
+
}
|
|
1007
|
+
async replaceVideoTrackOnAllPeers(newTrack, stream) {
|
|
1008
|
+
await Promise.all(Array.from(this.peers.entries()).map(async ([remoteCid, peer]) => {
|
|
1009
|
+
const videoTransceiver = this.findTransceiver(peer.pc, 'video');
|
|
1010
|
+
if (videoTransceiver) {
|
|
1011
|
+
try {
|
|
1012
|
+
await videoTransceiver.sender.replaceTrack(newTrack);
|
|
1013
|
+
if (videoTransceiver.direction !== 'sendrecv' && videoTransceiver.direction !== 'stopped' && this.videoCaptureSupported) {
|
|
1014
|
+
videoTransceiver.direction = 'sendrecv';
|
|
1015
|
+
this.scheduleLocalTrackNegotiation(remoteCid, peer);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
catch (err) {
|
|
1019
|
+
this.logger?.log('warning', 'WebRTC', `Failed to replace video track on peer: ${formatError(err)}`);
|
|
1020
|
+
}
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
if (newTrack && stream) {
|
|
1024
|
+
try {
|
|
1025
|
+
peer.pc.addTrack(newTrack, stream);
|
|
1026
|
+
this.scheduleLocalTrackNegotiation(remoteCid, peer);
|
|
1027
|
+
}
|
|
1028
|
+
catch (err) {
|
|
1029
|
+
this.logger?.log('warning', 'WebRTC', `Failed to add video track on peer: ${formatError(err)}`);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}));
|
|
1033
|
+
}
|
|
1034
|
+
async swapLocalVideoTrack(nextTrack, previousTrack) {
|
|
1035
|
+
if (!this.localStream) {
|
|
1036
|
+
if (previousTrack && previousTrack !== nextTrack)
|
|
1037
|
+
previousTrack.stop();
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
const nextStream = new MediaStream();
|
|
1041
|
+
let replacedVideo = false;
|
|
1042
|
+
for (const track of this.localStream.getTracks()) {
|
|
1043
|
+
if (track.kind !== 'video') {
|
|
1044
|
+
nextStream.addTrack(track);
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
if (!replacedVideo && nextTrack) {
|
|
1048
|
+
nextStream.addTrack(nextTrack);
|
|
1049
|
+
replacedVideo = true;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
if (nextTrack && !replacedVideo) {
|
|
1053
|
+
nextStream.addTrack(nextTrack);
|
|
1054
|
+
}
|
|
1055
|
+
this.localStream = nextStream;
|
|
1056
|
+
await this.replaceVideoTrackOnAllPeers(nextTrack, nextStream);
|
|
1057
|
+
if (previousTrack && previousTrack !== nextTrack)
|
|
1058
|
+
previousTrack.stop();
|
|
1059
|
+
this.notifyChange();
|
|
1060
|
+
}
|
|
1061
|
+
ensureMediaTransceivers(pc) {
|
|
1062
|
+
if (!this.findTransceiver(pc, 'audio') && !pc.getSenders().some(sender => sender.track?.kind === 'audio')) {
|
|
1063
|
+
pc.addTransceiver('audio', { direction: 'recvonly' });
|
|
1064
|
+
}
|
|
1065
|
+
if (!this.findTransceiver(pc, 'video') && !pc.getSenders().some(sender => sender.track?.kind === 'video')) {
|
|
1066
|
+
pc.addTransceiver('video', { direction: this.videoCaptureSupported ? 'sendrecv' : 'recvonly' });
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
findTransceiver(pc, kind) {
|
|
1070
|
+
return pc.getTransceivers().find(transceiver => (transceiver.receiver.track?.kind === kind || transceiver.sender.track?.kind === kind));
|
|
1071
|
+
}
|
|
1072
|
+
async attachLocalTracksToPeer(remoteCid, peer, stream) {
|
|
1073
|
+
let negotiationNeeded = false;
|
|
1074
|
+
for (const track of stream.getTracks()) {
|
|
1075
|
+
negotiationNeeded = await this.attachLocalTrackToPeer(peer, track, stream) || negotiationNeeded;
|
|
1076
|
+
}
|
|
1077
|
+
if (negotiationNeeded) {
|
|
1078
|
+
this.scheduleLocalTrackNegotiation(remoteCid, peer);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
async attachLocalTrackToPeer(peer, track, stream) {
|
|
1082
|
+
if (peer.pc.getSenders().some(sender => sender.track?.kind === track.kind)) {
|
|
1083
|
+
return false;
|
|
1084
|
+
}
|
|
1085
|
+
const transceiver = track.kind === 'audio' || track.kind === 'video'
|
|
1086
|
+
? this.findTransceiver(peer.pc, track.kind)
|
|
1087
|
+
: undefined;
|
|
1088
|
+
if (transceiver) {
|
|
1089
|
+
try {
|
|
1090
|
+
await transceiver.sender.replaceTrack(track);
|
|
1091
|
+
if (transceiver.direction !== 'sendrecv' && transceiver.direction !== 'stopped') {
|
|
1092
|
+
transceiver.direction = 'sendrecv';
|
|
1093
|
+
return true;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
catch (err) {
|
|
1097
|
+
this.logger?.log('warning', 'WebRTC', `Failed to attach ${track.kind} track to peer: ${formatError(err)}`);
|
|
1098
|
+
}
|
|
1099
|
+
return false;
|
|
1100
|
+
}
|
|
1101
|
+
peer.pc.addTrack(track, stream);
|
|
1102
|
+
return true;
|
|
1103
|
+
}
|
|
1104
|
+
scheduleLocalTrackNegotiation(remoteCid, peer) {
|
|
1105
|
+
if (!this.isSignalingConnected) {
|
|
1106
|
+
peer.pendingLocalTrackNegotiation = true;
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
if (!this.shouldIOffer(remoteCid) && !peer.pc.remoteDescription) {
|
|
1110
|
+
peer.pendingLocalTrackNegotiation = true;
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
if (peer.pc.signalingState !== 'stable') {
|
|
1114
|
+
peer.pendingLocalTrackNegotiation = true;
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
peer.pendingLocalTrackNegotiation = false;
|
|
1118
|
+
void this.createOfferTo(remoteCid);
|
|
1119
|
+
}
|
|
1120
|
+
async refreshLocalVideoTrack(reason, forceRefresh = false) {
|
|
1121
|
+
const currentVideoTrack = this.localStream?.getVideoTracks()[0] ?? null;
|
|
1122
|
+
const shouldRecover = shouldRecoverLocalVideo({
|
|
1123
|
+
hasVideoTrack: !!currentVideoTrack,
|
|
1124
|
+
isScreenSharing: this.isScreenSharing,
|
|
1125
|
+
videoTrackReadyState: currentVideoTrack?.readyState ?? null,
|
|
1126
|
+
videoTrackMuted: currentVideoTrack?.muted ?? false,
|
|
1127
|
+
forceRefresh
|
|
1128
|
+
});
|
|
1129
|
+
if (!shouldRecover || this.cameraRecoveryInFlight || this.requestingMedia)
|
|
1130
|
+
return false;
|
|
1131
|
+
this.cameraRecoveryInFlight = true;
|
|
1132
|
+
try {
|
|
1133
|
+
const nextTrack = await this.acquireCameraTrack(this.facingMode, currentVideoTrack?.enabled ?? true);
|
|
1134
|
+
await this.swapLocalVideoTrack(nextTrack, currentVideoTrack);
|
|
1135
|
+
this.logger?.log('info', 'WebRTC', `Refreshed local video track (${reason})`);
|
|
1136
|
+
return true;
|
|
1137
|
+
}
|
|
1138
|
+
catch (err) {
|
|
1139
|
+
this.logger?.log('error', 'WebRTC', `Failed to refresh local video track (${reason}): ${formatError(err)}`);
|
|
1140
|
+
return false;
|
|
1141
|
+
}
|
|
1142
|
+
finally {
|
|
1143
|
+
this.cameraRecoveryInFlight = false;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
async detectCameras() {
|
|
1147
|
+
if (!navigator.mediaDevices?.enumerateDevices)
|
|
1148
|
+
return;
|
|
1149
|
+
try {
|
|
1150
|
+
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
1151
|
+
this.hasMultipleCameras = devices.filter(d => d.kind === 'videoinput').length > 1;
|
|
1152
|
+
}
|
|
1153
|
+
catch { /* ignore */ }
|
|
1154
|
+
}
|
|
1155
|
+
setupEventListeners() {
|
|
1156
|
+
this.onlineHandler = () => {
|
|
1157
|
+
for (const [cid] of this.peers)
|
|
1158
|
+
this.scheduleIceRestart(cid, 'network-online', 0);
|
|
1159
|
+
};
|
|
1160
|
+
window.addEventListener('online', this.onlineHandler);
|
|
1161
|
+
this.networkChangeHandler = () => {
|
|
1162
|
+
for (const [cid] of this.peers)
|
|
1163
|
+
this.scheduleIceRestart(cid, 'network-change', 0);
|
|
1164
|
+
};
|
|
1165
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Network Information API is untyped
|
|
1166
|
+
const conn = navigator.connection;
|
|
1167
|
+
conn?.addEventListener?.('change', this.networkChangeHandler);
|
|
1168
|
+
this.deviceChangeHandler = () => { void this.detectCameras(); };
|
|
1169
|
+
navigator.mediaDevices?.addEventListener?.('devicechange', this.deviceChangeHandler);
|
|
1170
|
+
void this.detectCameras();
|
|
1171
|
+
// Local video recovery
|
|
1172
|
+
const consumeHiddenDuration = () => {
|
|
1173
|
+
const now = Date.now();
|
|
1174
|
+
const hiddenDurationMs = this.localVideoHiddenAt ? now - this.localVideoHiddenAt : null;
|
|
1175
|
+
this.localVideoHiddenAt = null;
|
|
1176
|
+
this.localVideoHeartbeatAt = now;
|
|
1177
|
+
return hiddenDurationMs;
|
|
1178
|
+
};
|
|
1179
|
+
this.visibilityHandler = () => {
|
|
1180
|
+
if (document.hidden) {
|
|
1181
|
+
const now = Date.now();
|
|
1182
|
+
this.localVideoHiddenAt = now;
|
|
1183
|
+
this.localVideoHeartbeatAt = now;
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
const hiddenDurationMs = consumeHiddenDuration();
|
|
1187
|
+
const forceRefresh = shouldForceLocalVideoRefresh({ hiddenDurationMs });
|
|
1188
|
+
void this.refreshLocalVideoTrack('visibility-resume', forceRefresh);
|
|
1189
|
+
};
|
|
1190
|
+
document.addEventListener('visibilitychange', this.visibilityHandler);
|
|
1191
|
+
this.pageShowHandler = (event) => {
|
|
1192
|
+
const hiddenDurationMs = consumeHiddenDuration();
|
|
1193
|
+
const forceRefresh = event.persisted || shouldForceLocalVideoRefresh({ hiddenDurationMs });
|
|
1194
|
+
void this.refreshLocalVideoTrack('pageshow-resume', forceRefresh);
|
|
1195
|
+
};
|
|
1196
|
+
window.addEventListener('pageshow', this.pageShowHandler);
|
|
1197
|
+
this.heartbeatInterval = window.setInterval(() => {
|
|
1198
|
+
const now = Date.now();
|
|
1199
|
+
const sleepGapMs = now - this.localVideoHeartbeatAt;
|
|
1200
|
+
this.localVideoHeartbeatAt = now;
|
|
1201
|
+
if (document.hidden || !shouldForceLocalVideoRefresh({ sleepGapMs }))
|
|
1202
|
+
return;
|
|
1203
|
+
void this.refreshLocalVideoTrack('sleep-resume', true);
|
|
1204
|
+
}, LOCAL_VIDEO_HEARTBEAT_INTERVAL_MS);
|
|
1205
|
+
}
|
|
1206
|
+
removeEventListeners() {
|
|
1207
|
+
if (this.onlineHandler)
|
|
1208
|
+
window.removeEventListener('online', this.onlineHandler);
|
|
1209
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Network Information API is untyped
|
|
1210
|
+
const conn = navigator.connection;
|
|
1211
|
+
if (this.networkChangeHandler)
|
|
1212
|
+
conn?.removeEventListener?.('change', this.networkChangeHandler);
|
|
1213
|
+
if (this.deviceChangeHandler)
|
|
1214
|
+
navigator.mediaDevices?.removeEventListener?.('devicechange', this.deviceChangeHandler);
|
|
1215
|
+
if (this.visibilityHandler)
|
|
1216
|
+
document.removeEventListener('visibilitychange', this.visibilityHandler);
|
|
1217
|
+
if (this.pageShowHandler)
|
|
1218
|
+
window.removeEventListener('pageshow', this.pageShowHandler);
|
|
1219
|
+
if (this.heartbeatInterval !== null)
|
|
1220
|
+
window.clearInterval(this.heartbeatInterval);
|
|
1221
|
+
}
|
|
1222
|
+
notifyChange() { this.onChange?.(); }
|
|
1223
|
+
}
|
|
1224
|
+
//# sourceMappingURL=MediaEngine.js.map
|