@agentuity/frontend 1.0.0 → 1.0.2
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/AGENTS.md +53 -53
- package/README.md +31 -0
- package/dist/beacon-script.js +1 -1
- package/dist/beacon.js +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/webrtc-manager.d.ts +420 -0
- package/dist/webrtc-manager.d.ts.map +1 -0
- package/dist/webrtc-manager.js +1221 -0
- package/dist/webrtc-manager.js.map +1 -0
- package/package.json +3 -3
- package/src/client/index.ts +11 -11
- package/src/index.ts +23 -0
- package/src/webrtc-manager.ts +1631 -0
|
@@ -0,0 +1,1631 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SignalMessage,
|
|
3
|
+
WebRTCConnectionState,
|
|
4
|
+
WebRTCDisconnectReason,
|
|
5
|
+
DataChannelConfig,
|
|
6
|
+
DataChannelState,
|
|
7
|
+
ConnectionQualitySummary,
|
|
8
|
+
RecordingOptions,
|
|
9
|
+
RecordingHandle,
|
|
10
|
+
RecordingState,
|
|
11
|
+
TrackSource as CoreTrackSource,
|
|
12
|
+
} from '@agentuity/core';
|
|
13
|
+
import { createReconnectManager, type ReconnectManager } from './reconnect';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Track source interface extended for browser environment.
|
|
17
|
+
*/
|
|
18
|
+
export interface TrackSource extends Omit<CoreTrackSource, 'getStream'> {
|
|
19
|
+
getStream(): Promise<MediaStream>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// =============================================================================
|
|
23
|
+
// Track Sources
|
|
24
|
+
// =============================================================================
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* User media (camera/microphone) track source.
|
|
28
|
+
*/
|
|
29
|
+
export class UserMediaSource implements TrackSource {
|
|
30
|
+
readonly type = 'user-media' as const;
|
|
31
|
+
private stream: MediaStream | null = null;
|
|
32
|
+
|
|
33
|
+
constructor(private constraints: MediaStreamConstraints = { video: true, audio: true }) {}
|
|
34
|
+
|
|
35
|
+
async getStream(): Promise<MediaStream> {
|
|
36
|
+
this.stream = await navigator.mediaDevices.getUserMedia(this.constraints);
|
|
37
|
+
return this.stream;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
stop(): void {
|
|
41
|
+
if (this.stream) {
|
|
42
|
+
for (const track of this.stream.getTracks()) {
|
|
43
|
+
track.stop();
|
|
44
|
+
}
|
|
45
|
+
this.stream = null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Display media (screen share) track source.
|
|
52
|
+
*/
|
|
53
|
+
export class DisplayMediaSource implements TrackSource {
|
|
54
|
+
readonly type = 'display-media' as const;
|
|
55
|
+
private stream: MediaStream | null = null;
|
|
56
|
+
|
|
57
|
+
constructor(private constraints: DisplayMediaStreamOptions = { video: true, audio: false }) {}
|
|
58
|
+
|
|
59
|
+
async getStream(): Promise<MediaStream> {
|
|
60
|
+
this.stream = await navigator.mediaDevices.getDisplayMedia(this.constraints);
|
|
61
|
+
return this.stream;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
stop(): void {
|
|
65
|
+
if (this.stream) {
|
|
66
|
+
for (const track of this.stream.getTracks()) {
|
|
67
|
+
track.stop();
|
|
68
|
+
}
|
|
69
|
+
this.stream = null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Custom stream track source - wraps a user-provided MediaStream.
|
|
76
|
+
*/
|
|
77
|
+
export class CustomStreamSource implements TrackSource {
|
|
78
|
+
readonly type = 'custom' as const;
|
|
79
|
+
|
|
80
|
+
constructor(private stream: MediaStream) {}
|
|
81
|
+
|
|
82
|
+
async getStream(): Promise<MediaStream> {
|
|
83
|
+
return this.stream;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
stop(): void {
|
|
87
|
+
for (const track of this.stream.getTracks()) {
|
|
88
|
+
track.stop();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// =============================================================================
|
|
94
|
+
// Per-Peer Session
|
|
95
|
+
// =============================================================================
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Represents a connection to a single remote peer.
|
|
99
|
+
*/
|
|
100
|
+
interface PeerSession {
|
|
101
|
+
peerId: string;
|
|
102
|
+
pc: RTCPeerConnection;
|
|
103
|
+
remoteStream: MediaStream | null;
|
|
104
|
+
dataChannels: Map<string, RTCDataChannel>;
|
|
105
|
+
makingOffer: boolean;
|
|
106
|
+
ignoreOffer: boolean;
|
|
107
|
+
hasRemoteDescription: boolean;
|
|
108
|
+
pendingCandidates: RTCIceCandidateInit[];
|
|
109
|
+
isOfferer: boolean;
|
|
110
|
+
negotiationStarted: boolean;
|
|
111
|
+
lastStats?: RTCStatsReport;
|
|
112
|
+
lastStatsTime?: number;
|
|
113
|
+
hasIceCandidate?: boolean;
|
|
114
|
+
iceGatheringTimer?: ReturnType<typeof setTimeout> | null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// =============================================================================
|
|
118
|
+
// Callbacks
|
|
119
|
+
// =============================================================================
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Callbacks for WebRTC client state changes and events.
|
|
123
|
+
* All callbacks are optional - only subscribe to events you care about.
|
|
124
|
+
*/
|
|
125
|
+
export interface WebRTCClientCallbacks {
|
|
126
|
+
/**
|
|
127
|
+
* Called on every state transition.
|
|
128
|
+
*/
|
|
129
|
+
onStateChange?: (
|
|
130
|
+
from: WebRTCConnectionState,
|
|
131
|
+
to: WebRTCConnectionState,
|
|
132
|
+
reason?: string
|
|
133
|
+
) => void;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Called when connected to at least one peer.
|
|
137
|
+
*/
|
|
138
|
+
onConnect?: () => void;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Called when disconnected from all peers.
|
|
142
|
+
*/
|
|
143
|
+
onDisconnect?: (reason: WebRTCDisconnectReason) => void;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Called when local media stream is acquired.
|
|
147
|
+
*/
|
|
148
|
+
onLocalStream?: (stream: MediaStream) => void;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Called when a remote media stream is received.
|
|
152
|
+
*/
|
|
153
|
+
onRemoteStream?: (peerId: string, stream: MediaStream) => void;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Called when a new track is added to a stream.
|
|
157
|
+
*/
|
|
158
|
+
onTrackAdded?: (peerId: string, track: MediaStreamTrack, stream: MediaStream) => void;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Called when a track is removed from a stream.
|
|
162
|
+
*/
|
|
163
|
+
onTrackRemoved?: (peerId: string, track: MediaStreamTrack) => void;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Called when a peer joins the room.
|
|
167
|
+
*/
|
|
168
|
+
onPeerJoined?: (peerId: string) => void;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Called when a peer leaves the room.
|
|
172
|
+
*/
|
|
173
|
+
onPeerLeft?: (peerId: string) => void;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Called when negotiation starts with a peer.
|
|
177
|
+
*/
|
|
178
|
+
onNegotiationStart?: (peerId: string) => void;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Called when negotiation completes with a peer.
|
|
182
|
+
*/
|
|
183
|
+
onNegotiationComplete?: (peerId: string) => void;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Called for each ICE candidate generated.
|
|
187
|
+
*/
|
|
188
|
+
onIceCandidate?: (peerId: string, candidate: RTCIceCandidateInit) => void;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Called when ICE connection state changes for a peer.
|
|
192
|
+
*/
|
|
193
|
+
onIceStateChange?: (peerId: string, state: string) => void;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Called when an error occurs.
|
|
197
|
+
*/
|
|
198
|
+
onError?: (error: Error, state: WebRTCConnectionState) => void;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Called when a data channel is opened.
|
|
202
|
+
*/
|
|
203
|
+
onDataChannelOpen?: (peerId: string, label: string) => void;
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Called when a data channel is closed.
|
|
207
|
+
*/
|
|
208
|
+
onDataChannelClose?: (peerId: string, label: string) => void;
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Called when a message is received on a data channel.
|
|
212
|
+
*
|
|
213
|
+
* **Note:** String messages are automatically parsed as JSON if valid.
|
|
214
|
+
* - If the message is valid JSON, `data` will be the parsed object/array/value
|
|
215
|
+
* - If the message is not valid JSON, `data` will be the raw string
|
|
216
|
+
* - Binary messages (ArrayBuffer) are passed through unchanged
|
|
217
|
+
*
|
|
218
|
+
* To distinguish between parsed JSON and raw strings, check the type:
|
|
219
|
+
* ```ts
|
|
220
|
+
* onDataChannelMessage: (peerId, label, data) => {
|
|
221
|
+
* if (typeof data === 'string') {
|
|
222
|
+
* // Raw string (failed JSON parse)
|
|
223
|
+
* } else if (data instanceof ArrayBuffer) {
|
|
224
|
+
* // Binary data
|
|
225
|
+
* } else {
|
|
226
|
+
* // Parsed JSON object/array/primitive
|
|
227
|
+
* }
|
|
228
|
+
* }
|
|
229
|
+
* ```
|
|
230
|
+
*/
|
|
231
|
+
onDataChannelMessage?: (
|
|
232
|
+
peerId: string,
|
|
233
|
+
label: string,
|
|
234
|
+
data: string | ArrayBuffer | unknown
|
|
235
|
+
) => void;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Called when a data channel error occurs.
|
|
239
|
+
*/
|
|
240
|
+
onDataChannelError?: (peerId: string, label: string, error: Error) => void;
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Called when screen sharing starts.
|
|
244
|
+
*/
|
|
245
|
+
onScreenShareStart?: () => void;
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Called when screen sharing stops.
|
|
249
|
+
*/
|
|
250
|
+
onScreenShareStop?: () => void;
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Called when a reconnect attempt is scheduled.
|
|
254
|
+
*/
|
|
255
|
+
onReconnecting?: (attempt: number) => void;
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Called after a successful reconnection.
|
|
259
|
+
*/
|
|
260
|
+
onReconnected?: () => void;
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Called when reconnect attempts are exhausted.
|
|
264
|
+
*/
|
|
265
|
+
onReconnectFailed?: () => void;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// =============================================================================
|
|
269
|
+
// Options and State
|
|
270
|
+
// =============================================================================
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Options for WebRTCManager
|
|
274
|
+
*/
|
|
275
|
+
export interface WebRTCManagerOptions {
|
|
276
|
+
/** WebSocket signaling URL */
|
|
277
|
+
signalUrl: string;
|
|
278
|
+
/** Room ID to join */
|
|
279
|
+
roomId: string;
|
|
280
|
+
/** Whether this peer is "polite" in perfect negotiation (default: auto-determined) */
|
|
281
|
+
polite?: boolean;
|
|
282
|
+
/** ICE servers configuration */
|
|
283
|
+
iceServers?: RTCIceServer[];
|
|
284
|
+
/**
|
|
285
|
+
* Media source configuration.
|
|
286
|
+
* - `false`: Data-only mode (no media)
|
|
287
|
+
* - `MediaStreamConstraints`: Use getUserMedia with these constraints
|
|
288
|
+
* - `TrackSource`: Use a custom track source
|
|
289
|
+
* Default: { video: true, audio: true }
|
|
290
|
+
*/
|
|
291
|
+
media?: MediaStreamConstraints | TrackSource | false;
|
|
292
|
+
/**
|
|
293
|
+
* Data channels to create when connection is established.
|
|
294
|
+
* Only the offerer (late joiner) creates channels; the answerer receives them.
|
|
295
|
+
*/
|
|
296
|
+
dataChannels?: DataChannelConfig[];
|
|
297
|
+
/**
|
|
298
|
+
* Callbacks for state changes and events.
|
|
299
|
+
*/
|
|
300
|
+
callbacks?: WebRTCClientCallbacks;
|
|
301
|
+
/**
|
|
302
|
+
* Whether to auto-reconnect on WebSocket/ICE failures (default: true)
|
|
303
|
+
*/
|
|
304
|
+
autoReconnect?: boolean;
|
|
305
|
+
/**
|
|
306
|
+
* Maximum reconnection attempts before giving up (default: 5)
|
|
307
|
+
*/
|
|
308
|
+
maxReconnectAttempts?: number;
|
|
309
|
+
/**
|
|
310
|
+
* Connection timeout in ms for connecting/negotiating (default: 30000)
|
|
311
|
+
*/
|
|
312
|
+
connectionTimeout?: number;
|
|
313
|
+
/**
|
|
314
|
+
* ICE gathering timeout in ms (default: 10000)
|
|
315
|
+
*/
|
|
316
|
+
iceGatheringTimeout?: number;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* WebRTC manager state
|
|
321
|
+
*/
|
|
322
|
+
export interface WebRTCManagerState {
|
|
323
|
+
state: WebRTCConnectionState;
|
|
324
|
+
peerId: string | null;
|
|
325
|
+
remotePeerIds: string[];
|
|
326
|
+
isAudioMuted: boolean;
|
|
327
|
+
isVideoMuted: boolean;
|
|
328
|
+
isScreenSharing: boolean;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Default ICE servers (public STUN servers)
|
|
333
|
+
*/
|
|
334
|
+
const DEFAULT_ICE_SERVERS: RTCIceServer[] = [
|
|
335
|
+
{ urls: 'stun:stun.l.google.com:19302' },
|
|
336
|
+
{ urls: 'stun:stun1.l.google.com:19302' },
|
|
337
|
+
];
|
|
338
|
+
|
|
339
|
+
// =============================================================================
|
|
340
|
+
// WebRTCManager
|
|
341
|
+
// =============================================================================
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Framework-agnostic WebRTC connection manager with multi-peer mesh networking,
|
|
345
|
+
* perfect negotiation, media/data channel handling, and screen sharing.
|
|
346
|
+
*
|
|
347
|
+
* Uses an explicit state machine for connection lifecycle:
|
|
348
|
+
* - idle: No resources allocated, ready to connect
|
|
349
|
+
* - connecting: Acquiring media + opening WebSocket
|
|
350
|
+
* - signaling: In room, waiting for peer(s)
|
|
351
|
+
* - negotiating: SDP/ICE exchange in progress with at least one peer
|
|
352
|
+
* - connected: At least one peer is connected
|
|
353
|
+
*
|
|
354
|
+
* @example
|
|
355
|
+
* ```ts
|
|
356
|
+
* const manager = new WebRTCManager({
|
|
357
|
+
* signalUrl: 'wss://example.com/call/signal',
|
|
358
|
+
* roomId: 'my-room',
|
|
359
|
+
* callbacks: {
|
|
360
|
+
* onStateChange: (from, to, reason) => console.log(`${from} → ${to}`, reason),
|
|
361
|
+
* onConnect: () => console.log('Connected!'),
|
|
362
|
+
* onRemoteStream: (peerId, stream) => { remoteVideos[peerId].srcObject = stream; },
|
|
363
|
+
* },
|
|
364
|
+
* });
|
|
365
|
+
*
|
|
366
|
+
* await manager.connect();
|
|
367
|
+
* ```
|
|
368
|
+
*/
|
|
369
|
+
export class WebRTCManager {
|
|
370
|
+
private ws: WebSocket | null = null;
|
|
371
|
+
private localStream: MediaStream | null = null;
|
|
372
|
+
private trackSource: TrackSource | null = null;
|
|
373
|
+
private previousVideoTrack: MediaStreamTrack | null = null;
|
|
374
|
+
|
|
375
|
+
private peerId: string | null = null;
|
|
376
|
+
private peers = new Map<string, PeerSession>();
|
|
377
|
+
private isAudioMuted = false;
|
|
378
|
+
private isVideoMuted = false;
|
|
379
|
+
private isScreenSharing = false;
|
|
380
|
+
|
|
381
|
+
private _state: WebRTCConnectionState = 'idle';
|
|
382
|
+
private isConnecting = false;
|
|
383
|
+
private basePolite: boolean | undefined;
|
|
384
|
+
|
|
385
|
+
private options: WebRTCManagerOptions;
|
|
386
|
+
private callbacks: WebRTCClientCallbacks;
|
|
387
|
+
private reconnectManager: ReconnectManager;
|
|
388
|
+
private isReconnecting = false;
|
|
389
|
+
private intentionalClose = false;
|
|
390
|
+
private connectionTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
391
|
+
|
|
392
|
+
private recordings = new Map<string, { recorder: MediaRecorder; chunks: Blob[] }>();
|
|
393
|
+
|
|
394
|
+
constructor(options: WebRTCManagerOptions) {
|
|
395
|
+
this.options = {
|
|
396
|
+
...options,
|
|
397
|
+
autoReconnect: options.autoReconnect ?? true,
|
|
398
|
+
maxReconnectAttempts: options.maxReconnectAttempts ?? 5,
|
|
399
|
+
connectionTimeout: options.connectionTimeout ?? 30000,
|
|
400
|
+
iceGatheringTimeout: options.iceGatheringTimeout ?? 10000,
|
|
401
|
+
};
|
|
402
|
+
this.basePolite = options.polite;
|
|
403
|
+
this.callbacks = options.callbacks ?? {};
|
|
404
|
+
this.reconnectManager = createReconnectManager({
|
|
405
|
+
onReconnect: () => {
|
|
406
|
+
void this.reconnect();
|
|
407
|
+
},
|
|
408
|
+
baseDelay: 1000,
|
|
409
|
+
factor: 2,
|
|
410
|
+
maxDelay: 30000,
|
|
411
|
+
jitter: 0,
|
|
412
|
+
enabled: () => this.shouldAutoReconnect(),
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Current connection state
|
|
418
|
+
*/
|
|
419
|
+
get state(): WebRTCConnectionState {
|
|
420
|
+
return this._state;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Get current manager state
|
|
425
|
+
*/
|
|
426
|
+
getState(): WebRTCManagerState {
|
|
427
|
+
return {
|
|
428
|
+
state: this._state,
|
|
429
|
+
peerId: this.peerId,
|
|
430
|
+
remotePeerIds: Array.from(this.peers.keys()),
|
|
431
|
+
isAudioMuted: this.isAudioMuted,
|
|
432
|
+
isVideoMuted: this.isVideoMuted,
|
|
433
|
+
isScreenSharing: this.isScreenSharing,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Get local media stream
|
|
439
|
+
*/
|
|
440
|
+
getLocalStream(): MediaStream | null {
|
|
441
|
+
return this.localStream;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Get remote media streams keyed by peer ID
|
|
446
|
+
*/
|
|
447
|
+
getRemoteStreams(): Map<string, MediaStream> {
|
|
448
|
+
const streams = new Map<string, MediaStream>();
|
|
449
|
+
for (const [peerId, session] of this.peers) {
|
|
450
|
+
if (session.remoteStream) {
|
|
451
|
+
streams.set(peerId, session.remoteStream);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return streams;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Get a specific peer's remote stream
|
|
459
|
+
*/
|
|
460
|
+
getRemoteStream(peerId: string): MediaStream | null {
|
|
461
|
+
return this.peers.get(peerId)?.remoteStream ?? null;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Whether this manager is in data-only mode (no media streams).
|
|
466
|
+
*/
|
|
467
|
+
get isDataOnly(): boolean {
|
|
468
|
+
return this.options.media === false;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Get connected peer count
|
|
473
|
+
*/
|
|
474
|
+
get peerCount(): number {
|
|
475
|
+
return this.peers.size;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// =========================================================================
|
|
479
|
+
// State Machine
|
|
480
|
+
// =========================================================================
|
|
481
|
+
|
|
482
|
+
private setState(newState: WebRTCConnectionState, reason?: string): void {
|
|
483
|
+
const prevState = this._state;
|
|
484
|
+
if (prevState === newState) return;
|
|
485
|
+
|
|
486
|
+
this._state = newState;
|
|
487
|
+
this.handleStateTimeouts(newState);
|
|
488
|
+
this.callbacks.onStateChange?.(prevState, newState, reason);
|
|
489
|
+
|
|
490
|
+
if (newState === 'connected' && prevState !== 'connected') {
|
|
491
|
+
this.callbacks.onConnect?.();
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (newState === 'idle' && prevState !== 'idle') {
|
|
495
|
+
const disconnectReason = this.mapToDisconnectReason(reason);
|
|
496
|
+
this.callbacks.onDisconnect?.(disconnectReason);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private mapToDisconnectReason(reason?: string): WebRTCDisconnectReason {
|
|
501
|
+
if (reason === 'hangup') return 'hangup';
|
|
502
|
+
if (reason === 'peer-left') return 'peer-left';
|
|
503
|
+
if (reason?.includes('timeout')) return 'timeout';
|
|
504
|
+
return 'error';
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
private handleStateTimeouts(state: WebRTCConnectionState): void {
|
|
508
|
+
if (state === 'connecting' || state === 'negotiating') {
|
|
509
|
+
this.startConnectionTimeout();
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
this.clearConnectionTimeout();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
private startConnectionTimeout(): void {
|
|
516
|
+
this.clearConnectionTimeout();
|
|
517
|
+
const timeoutMs = this.options.connectionTimeout ?? 30000;
|
|
518
|
+
this.connectionTimeoutId = setTimeout(() => {
|
|
519
|
+
if (this._state === 'connecting' || this._state === 'negotiating') {
|
|
520
|
+
const error = new Error('WebRTC connection timed out');
|
|
521
|
+
this.callbacks.onError?.(error, this._state);
|
|
522
|
+
this.handleTimeout('connection-timeout');
|
|
523
|
+
}
|
|
524
|
+
}, timeoutMs);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private clearConnectionTimeout(): void {
|
|
528
|
+
if (this.connectionTimeoutId) {
|
|
529
|
+
clearTimeout(this.connectionTimeoutId);
|
|
530
|
+
this.connectionTimeoutId = null;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private handleTimeout(reason: string): void {
|
|
535
|
+
this.intentionalClose = true;
|
|
536
|
+
this.cleanupPeerSessions();
|
|
537
|
+
if (this.ws) {
|
|
538
|
+
this.ws.close();
|
|
539
|
+
this.ws = null;
|
|
540
|
+
}
|
|
541
|
+
this.peerId = null;
|
|
542
|
+
this.setState('idle', reason);
|
|
543
|
+
this.intentionalClose = false;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
private shouldAutoReconnect(): boolean {
|
|
547
|
+
return (this.options.autoReconnect ?? true) && !this.intentionalClose;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private updateConnectionState(): void {
|
|
551
|
+
const connectedPeers = Array.from(this.peers.values()).filter(
|
|
552
|
+
(p) => p.pc.iceConnectionState === 'connected' || p.pc.iceConnectionState === 'completed'
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
if (connectedPeers.length > 0) {
|
|
556
|
+
if (this._state !== 'connected') {
|
|
557
|
+
this.setState('connected', 'peer connected');
|
|
558
|
+
}
|
|
559
|
+
} else if (this.peers.size > 0) {
|
|
560
|
+
if (this._state === 'connected') {
|
|
561
|
+
this.setState('negotiating', 'no connected peers');
|
|
562
|
+
}
|
|
563
|
+
} else if (this._state === 'connected' || this._state === 'negotiating') {
|
|
564
|
+
this.setState('signaling', 'all peers left');
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
private send(msg: SignalMessage): void {
|
|
569
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
570
|
+
this.ws.send(JSON.stringify(msg));
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// =========================================================================
|
|
575
|
+
// Connection
|
|
576
|
+
// =========================================================================
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Connect to the signaling server and start the call
|
|
580
|
+
*/
|
|
581
|
+
async connect(): Promise<void> {
|
|
582
|
+
if (this._state !== 'idle' || this.isConnecting) return;
|
|
583
|
+
this.isConnecting = true;
|
|
584
|
+
this.intentionalClose = false;
|
|
585
|
+
this.reconnectManager.reset();
|
|
586
|
+
|
|
587
|
+
this.setState('connecting', 'connect() called');
|
|
588
|
+
|
|
589
|
+
try {
|
|
590
|
+
await this.ensureLocalStream();
|
|
591
|
+
this.openWebSocket();
|
|
592
|
+
} catch (err) {
|
|
593
|
+
// Clean up local media on failure
|
|
594
|
+
if (this.localStream) {
|
|
595
|
+
for (const track of this.localStream.getTracks()) {
|
|
596
|
+
track.stop();
|
|
597
|
+
}
|
|
598
|
+
this.localStream = null;
|
|
599
|
+
}
|
|
600
|
+
if (this.trackSource) {
|
|
601
|
+
this.trackSource.stop();
|
|
602
|
+
this.trackSource = null;
|
|
603
|
+
}
|
|
604
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
605
|
+
this.callbacks.onError?.(error, this._state);
|
|
606
|
+
this.isConnecting = false;
|
|
607
|
+
this.setState('idle', 'error');
|
|
608
|
+
} finally {
|
|
609
|
+
this.isConnecting = false;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
private async ensureLocalStream(): Promise<void> {
|
|
614
|
+
if (this.options.media === false || this.localStream) return;
|
|
615
|
+
if (this.options.media && typeof this.options.media === 'object' && 'getStream' in this.options.media) {
|
|
616
|
+
this.trackSource = this.options.media;
|
|
617
|
+
} else {
|
|
618
|
+
const constraints = (this.options.media as MediaStreamConstraints) ?? {
|
|
619
|
+
video: true,
|
|
620
|
+
audio: true,
|
|
621
|
+
};
|
|
622
|
+
this.trackSource = new UserMediaSource(constraints);
|
|
623
|
+
}
|
|
624
|
+
this.localStream = await this.trackSource.getStream();
|
|
625
|
+
this.callbacks.onLocalStream?.(this.localStream);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
private openWebSocket(): void {
|
|
629
|
+
if (this.ws) {
|
|
630
|
+
const previous = this.ws;
|
|
631
|
+
this.ws = null;
|
|
632
|
+
previous.onclose = null;
|
|
633
|
+
previous.onerror = null;
|
|
634
|
+
previous.onmessage = null;
|
|
635
|
+
previous.onopen = null;
|
|
636
|
+
previous.close();
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
this.ws = new WebSocket(this.options.signalUrl);
|
|
640
|
+
|
|
641
|
+
this.ws.onopen = () => {
|
|
642
|
+
this.setState('signaling', 'WebSocket opened');
|
|
643
|
+
this.send({ t: 'join', roomId: this.options.roomId });
|
|
644
|
+
if (this.isReconnecting) {
|
|
645
|
+
this.isReconnecting = false;
|
|
646
|
+
this.reconnectManager.recordSuccess();
|
|
647
|
+
this.callbacks.onReconnected?.();
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
this.ws.onmessage = (event) => {
|
|
652
|
+
try {
|
|
653
|
+
const msg = JSON.parse(event.data) as SignalMessage;
|
|
654
|
+
void this.handleSignalingMessage(msg).catch((err) => {
|
|
655
|
+
this.callbacks.onError?.(
|
|
656
|
+
err instanceof Error ? err : new Error(String(err)),
|
|
657
|
+
this._state
|
|
658
|
+
);
|
|
659
|
+
});
|
|
660
|
+
} catch (_err) {
|
|
661
|
+
this.callbacks.onError?.(new Error('Invalid signaling message'), this._state);
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
this.ws.onerror = () => {
|
|
666
|
+
const error = new Error('WebSocket connection error');
|
|
667
|
+
this.callbacks.onError?.(error, this._state);
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
this.ws.onclose = () => {
|
|
671
|
+
if (this._state === 'idle') return;
|
|
672
|
+
if (this.intentionalClose) {
|
|
673
|
+
this.setState('idle', 'WebSocket closed');
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
this.handleConnectionLoss('WebSocket closed');
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
private handleConnectionLoss(reason: string): void {
|
|
681
|
+
this.cleanupPeerSessions();
|
|
682
|
+
this.peerId = null;
|
|
683
|
+
if (this.shouldAutoReconnect()) {
|
|
684
|
+
this.scheduleReconnect(reason);
|
|
685
|
+
} else {
|
|
686
|
+
this.setState('idle', reason);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
private scheduleReconnect(reason: string): void {
|
|
691
|
+
const nextAttempt = this.reconnectManager.getAttempts() + 1;
|
|
692
|
+
const maxAttempts = this.options.maxReconnectAttempts ?? 5;
|
|
693
|
+
if (nextAttempt > maxAttempts) {
|
|
694
|
+
this.callbacks.onReconnectFailed?.();
|
|
695
|
+
this.setState('idle', 'reconnect-failed');
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
this.isReconnecting = true;
|
|
700
|
+
this.callbacks.onReconnecting?.(nextAttempt);
|
|
701
|
+
this.setState('connecting', `reconnecting:${reason}`);
|
|
702
|
+
this.reconnectManager.recordFailure();
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
private async reconnect(): Promise<void> {
|
|
706
|
+
if (!this.shouldAutoReconnect()) return;
|
|
707
|
+
this.cleanupPeerSessions();
|
|
708
|
+
this.peerId = null;
|
|
709
|
+
try {
|
|
710
|
+
await this.ensureLocalStream();
|
|
711
|
+
this.openWebSocket();
|
|
712
|
+
} catch (err) {
|
|
713
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
714
|
+
this.callbacks.onError?.(error, this._state);
|
|
715
|
+
this.scheduleReconnect('reconnect-error');
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
private async handleSignalingMessage(msg: SignalMessage): Promise<void> {
|
|
720
|
+
switch (msg.t) {
|
|
721
|
+
case 'joined':
|
|
722
|
+
this.peerId = msg.peerId;
|
|
723
|
+
for (const existingPeerId of msg.peers) {
|
|
724
|
+
await this.createPeerSession(existingPeerId, true);
|
|
725
|
+
}
|
|
726
|
+
break;
|
|
727
|
+
|
|
728
|
+
case 'peer-joined':
|
|
729
|
+
this.callbacks.onPeerJoined?.(msg.peerId);
|
|
730
|
+
await this.createPeerSession(msg.peerId, false);
|
|
731
|
+
break;
|
|
732
|
+
|
|
733
|
+
case 'peer-left':
|
|
734
|
+
this.callbacks.onPeerLeft?.(msg.peerId);
|
|
735
|
+
this.closePeerSession(msg.peerId);
|
|
736
|
+
this.updateConnectionState();
|
|
737
|
+
break;
|
|
738
|
+
|
|
739
|
+
case 'sdp':
|
|
740
|
+
await this.handleRemoteSDP(msg.from, msg.description);
|
|
741
|
+
break;
|
|
742
|
+
|
|
743
|
+
case 'ice':
|
|
744
|
+
await this.handleRemoteICE(msg.from, msg.candidate);
|
|
745
|
+
break;
|
|
746
|
+
|
|
747
|
+
case 'error': {
|
|
748
|
+
const error = new Error(msg.message);
|
|
749
|
+
this.callbacks.onError?.(error, this._state);
|
|
750
|
+
break;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// =========================================================================
|
|
756
|
+
// Peer Session Management
|
|
757
|
+
// =========================================================================
|
|
758
|
+
|
|
759
|
+
private async createPeerSession(remotePeerId: string, isOfferer: boolean): Promise<PeerSession> {
|
|
760
|
+
if (this.peers.has(remotePeerId)) {
|
|
761
|
+
return this.peers.get(remotePeerId)!;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const iceServers = this.options.iceServers ?? DEFAULT_ICE_SERVERS;
|
|
765
|
+
const pc = new RTCPeerConnection({ iceServers });
|
|
766
|
+
|
|
767
|
+
const session: PeerSession = {
|
|
768
|
+
peerId: remotePeerId,
|
|
769
|
+
pc,
|
|
770
|
+
remoteStream: null,
|
|
771
|
+
dataChannels: new Map(),
|
|
772
|
+
makingOffer: false,
|
|
773
|
+
ignoreOffer: false,
|
|
774
|
+
hasRemoteDescription: false,
|
|
775
|
+
pendingCandidates: [],
|
|
776
|
+
isOfferer,
|
|
777
|
+
negotiationStarted: false,
|
|
778
|
+
hasIceCandidate: false,
|
|
779
|
+
iceGatheringTimer: null,
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
this.peers.set(remotePeerId, session);
|
|
783
|
+
|
|
784
|
+
if (this.localStream) {
|
|
785
|
+
for (const track of this.localStream.getTracks()) {
|
|
786
|
+
pc.addTrack(track, this.localStream);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
pc.ontrack = (event) => {
|
|
791
|
+
if (event.streams?.[0]) {
|
|
792
|
+
if (session.remoteStream !== event.streams[0]) {
|
|
793
|
+
session.remoteStream = event.streams[0];
|
|
794
|
+
this.callbacks.onRemoteStream?.(remotePeerId, session.remoteStream);
|
|
795
|
+
}
|
|
796
|
+
} else {
|
|
797
|
+
if (!session.remoteStream) {
|
|
798
|
+
session.remoteStream = new MediaStream([event.track]);
|
|
799
|
+
this.callbacks.onRemoteStream?.(remotePeerId, session.remoteStream);
|
|
800
|
+
} else {
|
|
801
|
+
session.remoteStream.addTrack(event.track);
|
|
802
|
+
this.callbacks.onRemoteStream?.(remotePeerId, session.remoteStream);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
this.callbacks.onTrackAdded?.(remotePeerId, event.track, session.remoteStream!);
|
|
807
|
+
this.updateConnectionState();
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
pc.ondatachannel = (event) => {
|
|
811
|
+
this.setupDataChannel(session, event.channel);
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
pc.onicecandidate = (event) => {
|
|
815
|
+
if (event.candidate) {
|
|
816
|
+
session.hasIceCandidate = true;
|
|
817
|
+
if (session.iceGatheringTimer) {
|
|
818
|
+
clearTimeout(session.iceGatheringTimer);
|
|
819
|
+
session.iceGatheringTimer = null;
|
|
820
|
+
}
|
|
821
|
+
this.callbacks.onIceCandidate?.(remotePeerId, event.candidate.toJSON());
|
|
822
|
+
this.send({
|
|
823
|
+
t: 'ice',
|
|
824
|
+
from: this.peerId!,
|
|
825
|
+
to: remotePeerId,
|
|
826
|
+
candidate: event.candidate.toJSON(),
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
this.scheduleIceGatheringTimeout(session);
|
|
832
|
+
|
|
833
|
+
pc.onnegotiationneeded = async () => {
|
|
834
|
+
// If we're not the offerer and haven't received a remote description yet,
|
|
835
|
+
// don't send an offer - wait for the other peer's offer
|
|
836
|
+
if (!session.isOfferer && !session.hasRemoteDescription && !session.negotiationStarted) {
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
try {
|
|
841
|
+
session.makingOffer = true;
|
|
842
|
+
await pc.setLocalDescription();
|
|
843
|
+
this.send({
|
|
844
|
+
t: 'sdp',
|
|
845
|
+
from: this.peerId!,
|
|
846
|
+
to: remotePeerId,
|
|
847
|
+
description: pc.localDescription!,
|
|
848
|
+
});
|
|
849
|
+
} catch (err) {
|
|
850
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
851
|
+
this.callbacks.onError?.(error, this._state);
|
|
852
|
+
} finally {
|
|
853
|
+
session.makingOffer = false;
|
|
854
|
+
}
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
pc.oniceconnectionstatechange = () => {
|
|
858
|
+
const iceState = pc.iceConnectionState;
|
|
859
|
+
this.callbacks.onIceStateChange?.(remotePeerId, iceState);
|
|
860
|
+
this.updateConnectionState();
|
|
861
|
+
|
|
862
|
+
if (iceState === 'failed') {
|
|
863
|
+
const error = new Error(`ICE connection failed for peer ${remotePeerId}`);
|
|
864
|
+
this.callbacks.onError?.(error, this._state);
|
|
865
|
+
this.handleConnectionLoss('ice-failed');
|
|
866
|
+
}
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
if (isOfferer) {
|
|
870
|
+
if (this.options.dataChannels) {
|
|
871
|
+
for (const config of this.options.dataChannels) {
|
|
872
|
+
const channel = pc.createDataChannel(config.label, {
|
|
873
|
+
ordered: config.ordered ?? true,
|
|
874
|
+
maxPacketLifeTime: config.maxPacketLifeTime,
|
|
875
|
+
maxRetransmits: config.maxRetransmits,
|
|
876
|
+
protocol: config.protocol,
|
|
877
|
+
});
|
|
878
|
+
this.setupDataChannel(session, channel);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
this.setState('negotiating', 'creating offer');
|
|
883
|
+
this.callbacks.onNegotiationStart?.(remotePeerId);
|
|
884
|
+
await this.createOffer(session);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
return session;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
private async createOffer(session: PeerSession): Promise<void> {
|
|
891
|
+
try {
|
|
892
|
+
session.makingOffer = true;
|
|
893
|
+
session.negotiationStarted = true;
|
|
894
|
+
const offer = await session.pc.createOffer();
|
|
895
|
+
await session.pc.setLocalDescription(offer);
|
|
896
|
+
|
|
897
|
+
this.send({
|
|
898
|
+
t: 'sdp',
|
|
899
|
+
from: this.peerId!,
|
|
900
|
+
to: session.peerId,
|
|
901
|
+
description: session.pc.localDescription!,
|
|
902
|
+
});
|
|
903
|
+
} finally {
|
|
904
|
+
session.makingOffer = false;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
private async handleRemoteSDP(
|
|
909
|
+
fromPeerId: string,
|
|
910
|
+
description: RTCSessionDescriptionInit
|
|
911
|
+
): Promise<void> {
|
|
912
|
+
let session = this.peers.get(fromPeerId);
|
|
913
|
+
if (!session) {
|
|
914
|
+
session = await this.createPeerSession(fromPeerId, false);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const pc = session.pc;
|
|
918
|
+
const isOffer = description.type === 'offer';
|
|
919
|
+
const polite = this.basePolite ?? !this.isOffererFor(fromPeerId);
|
|
920
|
+
const offerCollision = isOffer && (session.makingOffer || pc.signalingState !== 'stable');
|
|
921
|
+
|
|
922
|
+
session.ignoreOffer = !polite && offerCollision;
|
|
923
|
+
if (session.ignoreOffer) return;
|
|
924
|
+
|
|
925
|
+
if (this._state === 'signaling') {
|
|
926
|
+
this.setState('negotiating', 'received SDP');
|
|
927
|
+
this.callbacks.onNegotiationStart?.(fromPeerId);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
await pc.setRemoteDescription(description);
|
|
931
|
+
session.hasRemoteDescription = true;
|
|
932
|
+
|
|
933
|
+
for (const candidate of session.pendingCandidates) {
|
|
934
|
+
try {
|
|
935
|
+
await pc.addIceCandidate(candidate);
|
|
936
|
+
} catch (err) {
|
|
937
|
+
if (!session.ignoreOffer) {
|
|
938
|
+
console.warn('Failed to add buffered ICE candidate:', err);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
session.pendingCandidates = [];
|
|
943
|
+
|
|
944
|
+
if (isOffer) {
|
|
945
|
+
session.negotiationStarted = true;
|
|
946
|
+
await pc.setLocalDescription();
|
|
947
|
+
this.send({
|
|
948
|
+
t: 'sdp',
|
|
949
|
+
from: this.peerId!,
|
|
950
|
+
to: fromPeerId,
|
|
951
|
+
description: pc.localDescription!,
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
this.callbacks.onNegotiationComplete?.(fromPeerId);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
private isOffererFor(remotePeerId: string): boolean {
|
|
959
|
+
return this.peerId! > remotePeerId;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
private async handleRemoteICE(
|
|
963
|
+
fromPeerId: string,
|
|
964
|
+
candidate: RTCIceCandidateInit
|
|
965
|
+
): Promise<void> {
|
|
966
|
+
const session = this.peers.get(fromPeerId);
|
|
967
|
+
if (!session || !session.hasRemoteDescription) {
|
|
968
|
+
if (session) {
|
|
969
|
+
session.pendingCandidates.push(candidate);
|
|
970
|
+
}
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
try {
|
|
975
|
+
await session.pc.addIceCandidate(candidate);
|
|
976
|
+
} catch (err) {
|
|
977
|
+
if (!session.ignoreOffer) {
|
|
978
|
+
console.warn('Failed to add ICE candidate:', err);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
private closePeerSession(peerId: string): void {
|
|
984
|
+
const session = this.peers.get(peerId);
|
|
985
|
+
if (!session) return;
|
|
986
|
+
|
|
987
|
+
// Clear ICE gathering timer if exists
|
|
988
|
+
if (session.iceGatheringTimer) {
|
|
989
|
+
clearTimeout(session.iceGatheringTimer);
|
|
990
|
+
session.iceGatheringTimer = null;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// Close data channels
|
|
994
|
+
for (const channel of session.dataChannels.values()) {
|
|
995
|
+
channel.close();
|
|
996
|
+
}
|
|
997
|
+
session.dataChannels.clear();
|
|
998
|
+
|
|
999
|
+
// Clear all event handlers before closing to prevent memory leaks
|
|
1000
|
+
const pc = session.pc;
|
|
1001
|
+
pc.ontrack = null;
|
|
1002
|
+
pc.ondatachannel = null;
|
|
1003
|
+
pc.onicecandidate = null;
|
|
1004
|
+
pc.onnegotiationneeded = null;
|
|
1005
|
+
pc.oniceconnectionstatechange = null;
|
|
1006
|
+
|
|
1007
|
+
pc.close();
|
|
1008
|
+
this.peers.delete(peerId);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
private cleanupPeerSessions(): void {
|
|
1012
|
+
for (const peerId of this.peers.keys()) {
|
|
1013
|
+
this.closePeerSession(peerId);
|
|
1014
|
+
}
|
|
1015
|
+
this.peers.clear();
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
private scheduleIceGatheringTimeout(session: PeerSession): void {
|
|
1019
|
+
const timeoutMs = this.options.iceGatheringTimeout ?? 10000;
|
|
1020
|
+
if (timeoutMs <= 0) return;
|
|
1021
|
+
if (session.iceGatheringTimer) {
|
|
1022
|
+
clearTimeout(session.iceGatheringTimer);
|
|
1023
|
+
}
|
|
1024
|
+
session.iceGatheringTimer = setTimeout(() => {
|
|
1025
|
+
if (!session.hasIceCandidate) {
|
|
1026
|
+
console.warn(`ICE gathering timeout for peer ${session.peerId}`);
|
|
1027
|
+
}
|
|
1028
|
+
}, timeoutMs);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// =========================================================================
|
|
1032
|
+
// Data Channel
|
|
1033
|
+
// =========================================================================
|
|
1034
|
+
|
|
1035
|
+
private setupDataChannel(session: PeerSession, channel: RTCDataChannel): void {
|
|
1036
|
+
const label = channel.label;
|
|
1037
|
+
const peerId = session.peerId;
|
|
1038
|
+
session.dataChannels.set(label, channel);
|
|
1039
|
+
|
|
1040
|
+
channel.onopen = () => {
|
|
1041
|
+
this.callbacks.onDataChannelOpen?.(peerId, label);
|
|
1042
|
+
if (this.isDataOnly && this._state !== 'connected') {
|
|
1043
|
+
this.updateConnectionState();
|
|
1044
|
+
}
|
|
1045
|
+
};
|
|
1046
|
+
|
|
1047
|
+
channel.onclose = () => {
|
|
1048
|
+
session.dataChannels.delete(label);
|
|
1049
|
+
this.callbacks.onDataChannelClose?.(peerId, label);
|
|
1050
|
+
};
|
|
1051
|
+
|
|
1052
|
+
channel.onmessage = (event) => {
|
|
1053
|
+
const data = event.data;
|
|
1054
|
+
if (typeof data === 'string') {
|
|
1055
|
+
try {
|
|
1056
|
+
const parsed = JSON.parse(data);
|
|
1057
|
+
this.callbacks.onDataChannelMessage?.(peerId, label, parsed);
|
|
1058
|
+
} catch {
|
|
1059
|
+
this.callbacks.onDataChannelMessage?.(peerId, label, data);
|
|
1060
|
+
}
|
|
1061
|
+
} else {
|
|
1062
|
+
this.callbacks.onDataChannelMessage?.(peerId, label, data);
|
|
1063
|
+
}
|
|
1064
|
+
};
|
|
1065
|
+
|
|
1066
|
+
channel.onerror = (event) => {
|
|
1067
|
+
const error =
|
|
1068
|
+
event instanceof ErrorEvent
|
|
1069
|
+
? new Error(event.message)
|
|
1070
|
+
: new Error('Data channel error');
|
|
1071
|
+
this.callbacks.onDataChannelError?.(peerId, label, error);
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* Create a new data channel to all connected peers.
|
|
1077
|
+
*/
|
|
1078
|
+
createDataChannel(config: DataChannelConfig): Map<string, RTCDataChannel> {
|
|
1079
|
+
const channels = new Map<string, RTCDataChannel>();
|
|
1080
|
+
for (const [peerId, session] of this.peers) {
|
|
1081
|
+
const channel = session.pc.createDataChannel(config.label, {
|
|
1082
|
+
ordered: config.ordered ?? true,
|
|
1083
|
+
maxPacketLifeTime: config.maxPacketLifeTime,
|
|
1084
|
+
maxRetransmits: config.maxRetransmits,
|
|
1085
|
+
protocol: config.protocol,
|
|
1086
|
+
});
|
|
1087
|
+
this.setupDataChannel(session, channel);
|
|
1088
|
+
channels.set(peerId, channel);
|
|
1089
|
+
}
|
|
1090
|
+
return channels;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Get a data channel by label from a specific peer.
|
|
1095
|
+
*/
|
|
1096
|
+
getDataChannel(peerId: string, label: string): RTCDataChannel | undefined {
|
|
1097
|
+
return this.peers.get(peerId)?.dataChannels.get(label);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* Get all open data channel labels.
|
|
1102
|
+
*/
|
|
1103
|
+
getDataChannelLabels(): string[] {
|
|
1104
|
+
const labels = new Set<string>();
|
|
1105
|
+
for (const session of this.peers.values()) {
|
|
1106
|
+
for (const label of session.dataChannels.keys()) {
|
|
1107
|
+
labels.add(label);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
return Array.from(labels);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Get the state of a data channel for a specific peer.
|
|
1115
|
+
*/
|
|
1116
|
+
getDataChannelState(peerId: string, label: string): DataChannelState | null {
|
|
1117
|
+
const channel = this.peers.get(peerId)?.dataChannels.get(label);
|
|
1118
|
+
return channel ? (channel.readyState as DataChannelState) : null;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* Send a string message to all peers on a data channel.
|
|
1123
|
+
*/
|
|
1124
|
+
sendString(label: string, data: string): boolean {
|
|
1125
|
+
let sent = false;
|
|
1126
|
+
for (const session of this.peers.values()) {
|
|
1127
|
+
const channel = session.dataChannels.get(label);
|
|
1128
|
+
if (channel?.readyState === 'open') {
|
|
1129
|
+
channel.send(data);
|
|
1130
|
+
sent = true;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
return sent;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* Send a string message to a specific peer.
|
|
1138
|
+
*/
|
|
1139
|
+
sendStringTo(peerId: string, label: string, data: string): boolean {
|
|
1140
|
+
const channel = this.peers.get(peerId)?.dataChannels.get(label);
|
|
1141
|
+
if (!channel || channel.readyState !== 'open') return false;
|
|
1142
|
+
channel.send(data);
|
|
1143
|
+
return true;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* Send binary data to all peers on a data channel.
|
|
1148
|
+
*/
|
|
1149
|
+
sendBinary(label: string, data: ArrayBuffer | Uint8Array): boolean {
|
|
1150
|
+
let sent = false;
|
|
1151
|
+
const buffer =
|
|
1152
|
+
data instanceof ArrayBuffer
|
|
1153
|
+
? data
|
|
1154
|
+
: (() => {
|
|
1155
|
+
const buf = new ArrayBuffer(data.byteLength);
|
|
1156
|
+
new Uint8Array(buf).set(data);
|
|
1157
|
+
return buf;
|
|
1158
|
+
})();
|
|
1159
|
+
|
|
1160
|
+
for (const session of this.peers.values()) {
|
|
1161
|
+
const channel = session.dataChannels.get(label);
|
|
1162
|
+
if (channel?.readyState === 'open') {
|
|
1163
|
+
channel.send(buffer);
|
|
1164
|
+
sent = true;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
return sent;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* Send binary data to a specific peer.
|
|
1172
|
+
*/
|
|
1173
|
+
sendBinaryTo(peerId: string, label: string, data: ArrayBuffer | Uint8Array): boolean {
|
|
1174
|
+
const channel = this.peers.get(peerId)?.dataChannels.get(label);
|
|
1175
|
+
if (!channel || channel.readyState !== 'open') return false;
|
|
1176
|
+
|
|
1177
|
+
if (data instanceof ArrayBuffer) {
|
|
1178
|
+
channel.send(data);
|
|
1179
|
+
} else {
|
|
1180
|
+
const buffer = new ArrayBuffer(data.byteLength);
|
|
1181
|
+
new Uint8Array(buffer).set(data);
|
|
1182
|
+
channel.send(buffer);
|
|
1183
|
+
}
|
|
1184
|
+
return true;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* Send JSON data to all peers on a data channel.
|
|
1189
|
+
*/
|
|
1190
|
+
sendJSON(label: string, data: unknown): boolean {
|
|
1191
|
+
return this.sendString(label, JSON.stringify(data));
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* Send JSON data to a specific peer.
|
|
1196
|
+
*/
|
|
1197
|
+
sendJSONTo(peerId: string, label: string, data: unknown): boolean {
|
|
1198
|
+
return this.sendStringTo(peerId, label, JSON.stringify(data));
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* Close a specific data channel on all peers.
|
|
1203
|
+
*/
|
|
1204
|
+
closeDataChannel(label: string): boolean {
|
|
1205
|
+
let closed = false;
|
|
1206
|
+
for (const session of this.peers.values()) {
|
|
1207
|
+
const channel = session.dataChannels.get(label);
|
|
1208
|
+
if (channel) {
|
|
1209
|
+
channel.close();
|
|
1210
|
+
session.dataChannels.delete(label);
|
|
1211
|
+
closed = true;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
return closed;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// =========================================================================
|
|
1218
|
+
// Media Controls
|
|
1219
|
+
// =========================================================================
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* Mute or unmute audio
|
|
1223
|
+
*/
|
|
1224
|
+
muteAudio(muted: boolean): void {
|
|
1225
|
+
if (this.localStream) {
|
|
1226
|
+
for (const track of this.localStream.getAudioTracks()) {
|
|
1227
|
+
track.enabled = !muted;
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
this.isAudioMuted = muted;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* Mute or unmute video
|
|
1235
|
+
*/
|
|
1236
|
+
muteVideo(muted: boolean): void {
|
|
1237
|
+
if (this.localStream) {
|
|
1238
|
+
for (const track of this.localStream.getVideoTracks()) {
|
|
1239
|
+
track.enabled = !muted;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
this.isVideoMuted = muted;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
// =========================================================================
|
|
1246
|
+
// Screen Sharing
|
|
1247
|
+
// =========================================================================
|
|
1248
|
+
|
|
1249
|
+
/**
|
|
1250
|
+
* Start screen sharing, replacing the current video track.
|
|
1251
|
+
* @param options - Display media constraints
|
|
1252
|
+
*/
|
|
1253
|
+
async startScreenShare(
|
|
1254
|
+
options: DisplayMediaStreamOptions = { video: true, audio: false }
|
|
1255
|
+
): Promise<void> {
|
|
1256
|
+
if (this.isScreenSharing || this.isDataOnly) return;
|
|
1257
|
+
|
|
1258
|
+
const screenStream = await navigator.mediaDevices.getDisplayMedia(options);
|
|
1259
|
+
const screenTrack = screenStream.getVideoTracks()[0];
|
|
1260
|
+
|
|
1261
|
+
if (!screenTrack) {
|
|
1262
|
+
throw new Error('Failed to get screen video track');
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
if (this.localStream) {
|
|
1266
|
+
const currentVideoTrack = this.localStream.getVideoTracks()[0];
|
|
1267
|
+
if (currentVideoTrack) {
|
|
1268
|
+
this.previousVideoTrack = currentVideoTrack;
|
|
1269
|
+
this.localStream.removeTrack(currentVideoTrack);
|
|
1270
|
+
}
|
|
1271
|
+
this.localStream.addTrack(screenTrack);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
for (const session of this.peers.values()) {
|
|
1275
|
+
const sender = session.pc.getSenders().find((s) => s.track?.kind === 'video');
|
|
1276
|
+
if (sender) {
|
|
1277
|
+
await sender.replaceTrack(screenTrack);
|
|
1278
|
+
} else {
|
|
1279
|
+
session.pc.addTrack(screenTrack, this.localStream!);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
screenTrack.onended = () => {
|
|
1284
|
+
this.stopScreenShare();
|
|
1285
|
+
};
|
|
1286
|
+
|
|
1287
|
+
this.isScreenSharing = true;
|
|
1288
|
+
this.callbacks.onScreenShareStart?.();
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/**
|
|
1292
|
+
* Stop screen sharing and restore the previous video track.
|
|
1293
|
+
*/
|
|
1294
|
+
async stopScreenShare(): Promise<void> {
|
|
1295
|
+
if (!this.isScreenSharing) return;
|
|
1296
|
+
|
|
1297
|
+
const screenTrack = this.localStream?.getVideoTracks()[0];
|
|
1298
|
+
if (screenTrack) {
|
|
1299
|
+
screenTrack.stop();
|
|
1300
|
+
this.localStream?.removeTrack(screenTrack);
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
if (this.previousVideoTrack && this.localStream) {
|
|
1304
|
+
this.localStream.addTrack(this.previousVideoTrack);
|
|
1305
|
+
|
|
1306
|
+
for (const session of this.peers.values()) {
|
|
1307
|
+
const sender = session.pc.getSenders().find((s) => s.track?.kind === 'video');
|
|
1308
|
+
if (sender) {
|
|
1309
|
+
await sender.replaceTrack(this.previousVideoTrack);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
this.previousVideoTrack = null;
|
|
1315
|
+
this.isScreenSharing = false;
|
|
1316
|
+
this.callbacks.onScreenShareStop?.();
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// =========================================================================
|
|
1320
|
+
// Connection Stats
|
|
1321
|
+
// =========================================================================
|
|
1322
|
+
|
|
1323
|
+
/**
|
|
1324
|
+
* Get raw stats for a peer connection.
|
|
1325
|
+
*/
|
|
1326
|
+
async getRawStats(peerId: string): Promise<RTCStatsReport | null> {
|
|
1327
|
+
const session = this.peers.get(peerId);
|
|
1328
|
+
if (!session) return null;
|
|
1329
|
+
return session.pc.getStats();
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* Get raw stats for all peer connections.
|
|
1334
|
+
*/
|
|
1335
|
+
async getAllRawStats(): Promise<Map<string, RTCStatsReport>> {
|
|
1336
|
+
const stats = new Map<string, RTCStatsReport>();
|
|
1337
|
+
for (const [peerId, session] of this.peers) {
|
|
1338
|
+
stats.set(peerId, await session.pc.getStats());
|
|
1339
|
+
}
|
|
1340
|
+
return stats;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/**
|
|
1344
|
+
* Get a normalized quality summary for a peer connection.
|
|
1345
|
+
*/
|
|
1346
|
+
async getQualitySummary(peerId: string): Promise<ConnectionQualitySummary | null> {
|
|
1347
|
+
const session = this.peers.get(peerId);
|
|
1348
|
+
if (!session) return null;
|
|
1349
|
+
|
|
1350
|
+
const stats = await session.pc.getStats();
|
|
1351
|
+
return this.parseStatsToSummary(stats, session);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/**
|
|
1355
|
+
* Get quality summaries for all peer connections.
|
|
1356
|
+
*/
|
|
1357
|
+
async getAllQualitySummaries(): Promise<Map<string, ConnectionQualitySummary>> {
|
|
1358
|
+
const summaries = new Map<string, ConnectionQualitySummary>();
|
|
1359
|
+
for (const [peerId, session] of this.peers) {
|
|
1360
|
+
const stats = await session.pc.getStats();
|
|
1361
|
+
summaries.set(peerId, this.parseStatsToSummary(stats, session));
|
|
1362
|
+
}
|
|
1363
|
+
return summaries;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
private parseStatsToSummary(
|
|
1367
|
+
stats: RTCStatsReport,
|
|
1368
|
+
session: PeerSession
|
|
1369
|
+
): ConnectionQualitySummary {
|
|
1370
|
+
const summary: ConnectionQualitySummary = { timestamp: Date.now() };
|
|
1371
|
+
const now = Date.now();
|
|
1372
|
+
const prevStats = session.lastStats;
|
|
1373
|
+
const prevTime = session.lastStatsTime ?? now;
|
|
1374
|
+
const timeDelta = (now - prevTime) / 1000;
|
|
1375
|
+
|
|
1376
|
+
const bitrate: ConnectionQualitySummary['bitrate'] = {};
|
|
1377
|
+
|
|
1378
|
+
stats.forEach((report) => {
|
|
1379
|
+
if (report.type === 'candidate-pair' && report.state === 'succeeded') {
|
|
1380
|
+
summary.rtt = report.currentRoundTripTime
|
|
1381
|
+
? report.currentRoundTripTime * 1000
|
|
1382
|
+
: undefined;
|
|
1383
|
+
|
|
1384
|
+
const localCandidateId = report.localCandidateId;
|
|
1385
|
+
const remoteCandidateId = report.remoteCandidateId;
|
|
1386
|
+
const localCandidate = this.getStatReport(stats, localCandidateId);
|
|
1387
|
+
const remoteCandidate = this.getStatReport(stats, remoteCandidateId);
|
|
1388
|
+
|
|
1389
|
+
summary.candidatePair = {
|
|
1390
|
+
localType: localCandidate?.candidateType,
|
|
1391
|
+
remoteType: remoteCandidate?.candidateType,
|
|
1392
|
+
protocol: localCandidate?.protocol,
|
|
1393
|
+
usingRelay:
|
|
1394
|
+
localCandidate?.candidateType === 'relay' ||
|
|
1395
|
+
remoteCandidate?.candidateType === 'relay',
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
if (report.type === 'inbound-rtp' && report.kind === 'audio') {
|
|
1400
|
+
summary.jitter = report.jitter ? report.jitter * 1000 : undefined;
|
|
1401
|
+
if (report.packetsLost !== undefined && report.packetsReceived !== undefined) {
|
|
1402
|
+
const total = report.packetsLost + report.packetsReceived;
|
|
1403
|
+
if (total > 0) {
|
|
1404
|
+
summary.packetLossPercent = (report.packetsLost / total) * 100;
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
if (prevStats && timeDelta > 0) {
|
|
1409
|
+
const prev = this.findMatchingReport(prevStats, report.id);
|
|
1410
|
+
if (prev?.bytesReceived !== undefined && report.bytesReceived !== undefined) {
|
|
1411
|
+
bitrate.audio = bitrate.audio ?? {};
|
|
1412
|
+
bitrate.audio.inbound =
|
|
1413
|
+
((report.bytesReceived - prev.bytesReceived) * 8) / timeDelta;
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
if (report.type === 'inbound-rtp' && report.kind === 'video') {
|
|
1419
|
+
summary.video = {
|
|
1420
|
+
framesPerSecond: report.framesPerSecond,
|
|
1421
|
+
framesDropped: report.framesDropped,
|
|
1422
|
+
frameWidth: report.frameWidth,
|
|
1423
|
+
frameHeight: report.frameHeight,
|
|
1424
|
+
};
|
|
1425
|
+
|
|
1426
|
+
if (prevStats && timeDelta > 0) {
|
|
1427
|
+
const prev = this.findMatchingReport(prevStats, report.id);
|
|
1428
|
+
if (prev?.bytesReceived !== undefined && report.bytesReceived !== undefined) {
|
|
1429
|
+
bitrate.video = bitrate.video ?? {};
|
|
1430
|
+
bitrate.video.inbound =
|
|
1431
|
+
((report.bytesReceived - prev.bytesReceived) * 8) / timeDelta;
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
if (report.type === 'outbound-rtp' && prevStats && timeDelta > 0) {
|
|
1437
|
+
const prev = this.findMatchingReport(prevStats, report.id);
|
|
1438
|
+
if (prev?.bytesSent !== undefined && report.bytesSent !== undefined) {
|
|
1439
|
+
const bps = ((report.bytesSent - prev.bytesSent) * 8) / timeDelta;
|
|
1440
|
+
if (report.kind === 'audio') {
|
|
1441
|
+
bitrate.audio = bitrate.audio ?? {};
|
|
1442
|
+
bitrate.audio.outbound = bps;
|
|
1443
|
+
} else if (report.kind === 'video') {
|
|
1444
|
+
bitrate.video = bitrate.video ?? {};
|
|
1445
|
+
bitrate.video.outbound = bps;
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
if (Object.keys(bitrate).length > 0) {
|
|
1452
|
+
summary.bitrate = bitrate;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
session.lastStats = stats;
|
|
1456
|
+
session.lastStatsTime = now;
|
|
1457
|
+
|
|
1458
|
+
return summary;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1462
|
+
private findMatchingReport(stats: RTCStatsReport, id: string): any {
|
|
1463
|
+
return this.getStatReport(stats, id);
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
// RTCStatsReport extends Map but bun-types may not expose .get() properly
|
|
1467
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1468
|
+
private getStatReport(stats: RTCStatsReport, id: string): any {
|
|
1469
|
+
// Use Map.prototype.get via cast
|
|
1470
|
+
const mapLike = stats as unknown as Map<string, unknown>;
|
|
1471
|
+
return mapLike.get(id);
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// =========================================================================
|
|
1475
|
+
// Recording
|
|
1476
|
+
// =========================================================================
|
|
1477
|
+
|
|
1478
|
+
/**
|
|
1479
|
+
* Start recording a stream.
|
|
1480
|
+
* @param streamId - 'local' for local stream, or a peer ID for remote stream
|
|
1481
|
+
* @param options - Recording options
|
|
1482
|
+
*/
|
|
1483
|
+
startRecording(streamId: string, options?: RecordingOptions): RecordingHandle | null {
|
|
1484
|
+
const stream = streamId === 'local' ? this.localStream : this.getRemoteStream(streamId);
|
|
1485
|
+
if (!stream) return null;
|
|
1486
|
+
|
|
1487
|
+
const mimeType = this.selectMimeType(stream, options?.mimeType);
|
|
1488
|
+
if (!mimeType) return null;
|
|
1489
|
+
|
|
1490
|
+
const recorder = new MediaRecorder(stream, {
|
|
1491
|
+
mimeType,
|
|
1492
|
+
audioBitsPerSecond: options?.audioBitsPerSecond,
|
|
1493
|
+
videoBitsPerSecond: options?.videoBitsPerSecond,
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
const chunks: Blob[] = [];
|
|
1497
|
+
recorder.ondataavailable = (event) => {
|
|
1498
|
+
if (event.data.size > 0) {
|
|
1499
|
+
chunks.push(event.data);
|
|
1500
|
+
}
|
|
1501
|
+
};
|
|
1502
|
+
|
|
1503
|
+
this.recordings.set(streamId, { recorder, chunks });
|
|
1504
|
+
recorder.start(1000);
|
|
1505
|
+
|
|
1506
|
+
return {
|
|
1507
|
+
stop: () =>
|
|
1508
|
+
new Promise<Blob>((resolve) => {
|
|
1509
|
+
recorder.onstop = () => {
|
|
1510
|
+
this.recordings.delete(streamId);
|
|
1511
|
+
resolve(new Blob(chunks, { type: mimeType }));
|
|
1512
|
+
};
|
|
1513
|
+
recorder.stop();
|
|
1514
|
+
}),
|
|
1515
|
+
pause: () => recorder.pause(),
|
|
1516
|
+
resume: () => recorder.resume(),
|
|
1517
|
+
get state(): RecordingState {
|
|
1518
|
+
return recorder.state as RecordingState;
|
|
1519
|
+
},
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
/**
|
|
1524
|
+
* Check if a stream is being recorded.
|
|
1525
|
+
*/
|
|
1526
|
+
isRecording(streamId: string): boolean {
|
|
1527
|
+
const recording = this.recordings.get(streamId);
|
|
1528
|
+
return recording?.recorder.state === 'recording';
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/**
|
|
1532
|
+
* Stop all recordings and return the blobs.
|
|
1533
|
+
*/
|
|
1534
|
+
async stopAllRecordings(): Promise<Map<string, Blob>> {
|
|
1535
|
+
const blobs = new Map<string, Blob>();
|
|
1536
|
+
const promises: Promise<void>[] = [];
|
|
1537
|
+
|
|
1538
|
+
for (const [streamId, { recorder, chunks }] of this.recordings) {
|
|
1539
|
+
const mimeType = recorder.mimeType;
|
|
1540
|
+
promises.push(
|
|
1541
|
+
new Promise<void>((resolve) => {
|
|
1542
|
+
const timeout = setTimeout(() => {
|
|
1543
|
+
console.warn(`Recording stop timeout for stream ${streamId}`);
|
|
1544
|
+
resolve(); // Don't block other recordings
|
|
1545
|
+
}, 5000);
|
|
1546
|
+
|
|
1547
|
+
recorder.onstop = () => {
|
|
1548
|
+
clearTimeout(timeout);
|
|
1549
|
+
blobs.set(streamId, new Blob(chunks, { type: mimeType }));
|
|
1550
|
+
resolve();
|
|
1551
|
+
};
|
|
1552
|
+
recorder.stop();
|
|
1553
|
+
})
|
|
1554
|
+
);
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
await Promise.all(promises);
|
|
1558
|
+
this.recordings.clear();
|
|
1559
|
+
return blobs;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
private selectMimeType(stream: MediaStream, preferred?: string): string | null {
|
|
1563
|
+
if (preferred && MediaRecorder.isTypeSupported(preferred)) {
|
|
1564
|
+
return preferred;
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
const hasVideo = stream.getVideoTracks().length > 0;
|
|
1568
|
+
const hasAudio = stream.getAudioTracks().length > 0;
|
|
1569
|
+
|
|
1570
|
+
const videoTypes = [
|
|
1571
|
+
'video/webm;codecs=vp9,opus',
|
|
1572
|
+
'video/webm;codecs=vp8,opus',
|
|
1573
|
+
'video/webm',
|
|
1574
|
+
'video/mp4',
|
|
1575
|
+
];
|
|
1576
|
+
const audioTypes = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg'];
|
|
1577
|
+
|
|
1578
|
+
const candidates = hasVideo ? videoTypes : hasAudio ? audioTypes : [];
|
|
1579
|
+
for (const type of candidates) {
|
|
1580
|
+
if (MediaRecorder.isTypeSupported(type)) {
|
|
1581
|
+
return type;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
return null;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
// =========================================================================
|
|
1588
|
+
// Cleanup
|
|
1589
|
+
// =========================================================================
|
|
1590
|
+
|
|
1591
|
+
/**
|
|
1592
|
+
* End the call and disconnect from all peers
|
|
1593
|
+
*/
|
|
1594
|
+
hangup(): void {
|
|
1595
|
+
this.intentionalClose = true;
|
|
1596
|
+
this.reconnectManager.cancel();
|
|
1597
|
+
this.clearConnectionTimeout();
|
|
1598
|
+
this.cleanupPeerSessions();
|
|
1599
|
+
|
|
1600
|
+
if (this.isScreenSharing) {
|
|
1601
|
+
const screenTrack = this.localStream?.getVideoTracks()[0];
|
|
1602
|
+
screenTrack?.stop();
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
if (this.trackSource) {
|
|
1606
|
+
this.trackSource.stop();
|
|
1607
|
+
this.trackSource = null;
|
|
1608
|
+
}
|
|
1609
|
+
this.localStream = null;
|
|
1610
|
+
this.previousVideoTrack = null;
|
|
1611
|
+
|
|
1612
|
+
if (this.ws) {
|
|
1613
|
+
this.ws.close();
|
|
1614
|
+
this.ws = null;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
this.peerId = null;
|
|
1618
|
+
this.isScreenSharing = false;
|
|
1619
|
+
this.setState('idle', 'hangup');
|
|
1620
|
+
this.intentionalClose = false;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
/**
|
|
1624
|
+
* Clean up all resources
|
|
1625
|
+
*/
|
|
1626
|
+
dispose(): void {
|
|
1627
|
+
this.stopAllRecordings();
|
|
1628
|
+
this.hangup();
|
|
1629
|
+
this.reconnectManager.dispose();
|
|
1630
|
+
}
|
|
1631
|
+
}
|