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