@marianmeres/webrtc 0.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.
@@ -0,0 +1,726 @@
1
+ import { FSM } from "@marianmeres/fsm";
2
+ import { PubSub } from "@marianmeres/pubsub";
3
+ import { WebRtcState, WebRtcFsmEvent, } from "./types.js";
4
+ export class WebRtcManager {
5
+ // Event name constants
6
+ static EVENT_STATE_CHANGE = "state_change";
7
+ static EVENT_LOCAL_STREAM = "local_stream";
8
+ static EVENT_REMOTE_STREAM = "remote_stream";
9
+ static EVENT_DATA_CHANNEL_OPEN = "data_channel_open";
10
+ static EVENT_DATA_CHANNEL_MESSAGE = "data_channel_message";
11
+ static EVENT_DATA_CHANNEL_CLOSE = "data_channel_close";
12
+ static EVENT_ICE_CANDIDATE = "ice_candidate";
13
+ static EVENT_RECONNECTING = "reconnecting";
14
+ static EVENT_RECONNECT_FAILED = "reconnect_failed";
15
+ static EVENT_DEVICE_CHANGED = "device_changed";
16
+ static EVENT_MICROPHONE_FAILED = "microphone_failed";
17
+ static EVENT_ERROR = "error";
18
+ #fsm;
19
+ #pubsub;
20
+ #pc = null;
21
+ #factory;
22
+ #config;
23
+ #localStream = null;
24
+ #remoteStream = null;
25
+ #dataChannels = new Map();
26
+ #reconnectAttempts = 0;
27
+ #reconnectTimer = null;
28
+ #deviceChangeHandler = null;
29
+ constructor(factory, config = {}) {
30
+ this.#factory = factory;
31
+ this.#config = config;
32
+ this.#pubsub = new PubSub();
33
+ // Initialize FSM
34
+ this.#fsm = new FSM({
35
+ initial: WebRtcState.IDLE,
36
+ states: {
37
+ [WebRtcState.IDLE]: {
38
+ on: { [WebRtcFsmEvent.INIT]: WebRtcState.INITIALIZING },
39
+ },
40
+ [WebRtcState.INITIALIZING]: {
41
+ on: {
42
+ [WebRtcFsmEvent.CONNECT]: WebRtcState.CONNECTING,
43
+ [WebRtcFsmEvent.ERROR]: WebRtcState.ERROR,
44
+ },
45
+ },
46
+ [WebRtcState.CONNECTING]: {
47
+ on: {
48
+ [WebRtcFsmEvent.CONNECTED]: WebRtcState.CONNECTED,
49
+ [WebRtcFsmEvent.DISCONNECT]: WebRtcState.DISCONNECTED,
50
+ [WebRtcFsmEvent.ERROR]: WebRtcState.ERROR,
51
+ },
52
+ },
53
+ [WebRtcState.CONNECTED]: {
54
+ on: {
55
+ [WebRtcFsmEvent.DISCONNECT]: WebRtcState.DISCONNECTED,
56
+ [WebRtcFsmEvent.ERROR]: WebRtcState.ERROR,
57
+ },
58
+ },
59
+ [WebRtcState.RECONNECTING]: {
60
+ on: {
61
+ [WebRtcFsmEvent.CONNECT]: WebRtcState.CONNECTING,
62
+ [WebRtcFsmEvent.DISCONNECT]: WebRtcState.DISCONNECTED,
63
+ [WebRtcFsmEvent.RESET]: WebRtcState.IDLE,
64
+ },
65
+ },
66
+ [WebRtcState.DISCONNECTED]: {
67
+ on: {
68
+ [WebRtcFsmEvent.CONNECT]: WebRtcState.CONNECTING,
69
+ [WebRtcFsmEvent.RECONNECTING]: WebRtcState.RECONNECTING,
70
+ [WebRtcFsmEvent.RESET]: WebRtcState.IDLE,
71
+ },
72
+ },
73
+ [WebRtcState.ERROR]: {
74
+ on: { [WebRtcFsmEvent.RESET]: WebRtcState.IDLE },
75
+ },
76
+ },
77
+ });
78
+ }
79
+ // --- Public API ---
80
+ /** Returns the current state of the WebRTC connection. */
81
+ get state() {
82
+ return this.#fsm.state;
83
+ }
84
+ /** Returns a readonly map of all active data channels indexed by label. */
85
+ get dataChannels() {
86
+ return this.#dataChannels;
87
+ }
88
+ /** Returns the local media stream, or null if not initialized. */
89
+ get localStream() {
90
+ return this.#localStream;
91
+ }
92
+ /** Returns the remote media stream, or null if not connected. */
93
+ get remoteStream() {
94
+ return this.#remoteStream;
95
+ }
96
+ /** Returns the underlying RTCPeerConnection, or null if not initialized. */
97
+ get peerConnection() {
98
+ return this.#pc;
99
+ }
100
+ /** Returns a Mermaid diagram representation of the FSM state machine. */
101
+ toMermaid() {
102
+ return this.#fsm.toMermaid();
103
+ }
104
+ /**
105
+ * Subscribe to a specific WebRTC event.
106
+ * @returns Unsubscribe function to remove the event listener.
107
+ */
108
+ on(event, handler) {
109
+ return this.#pubsub.subscribe(event, handler);
110
+ }
111
+ /**
112
+ * Subscribe to the overall state of the WebRTC manager.
113
+ * Compatible with Svelte stores - immediately calls handler with current state,
114
+ * then notifies on any changes to state, streams, or data channels.
115
+ * @param handler - Callback that receives the overall state object
116
+ * @returns Unsubscribe function to remove the event listener.
117
+ */
118
+ subscribe(handler) {
119
+ // Helper to get current overall state
120
+ const getCurrentState = () => ({
121
+ state: this.state,
122
+ localStream: this.localStream,
123
+ remoteStream: this.remoteStream,
124
+ dataChannels: this.dataChannels,
125
+ peerConnection: this.peerConnection,
126
+ });
127
+ // Immediately call handler with current state (Svelte store compatibility)
128
+ handler(getCurrentState());
129
+ // Subscribe to relevant events that affect the overall state
130
+ const unsubscribers = [
131
+ this.#pubsub.subscribe(WebRtcManager.EVENT_STATE_CHANGE, () => handler(getCurrentState())),
132
+ this.#pubsub.subscribe(WebRtcManager.EVENT_LOCAL_STREAM, () => handler(getCurrentState())),
133
+ this.#pubsub.subscribe(WebRtcManager.EVENT_REMOTE_STREAM, () => handler(getCurrentState())),
134
+ this.#pubsub.subscribe(WebRtcManager.EVENT_DATA_CHANNEL_OPEN, () => handler(getCurrentState())),
135
+ this.#pubsub.subscribe(WebRtcManager.EVENT_DATA_CHANNEL_CLOSE, () => handler(getCurrentState())),
136
+ ];
137
+ // Return combined unsubscribe function
138
+ return () => {
139
+ unsubscribers.forEach((unsub) => unsub());
140
+ };
141
+ }
142
+ /**
143
+ * Retrieves all available audio input devices.
144
+ * @returns Array of audio input devices, or empty array on error.
145
+ */
146
+ async getAudioInputDevices() {
147
+ try {
148
+ const devices = await this.#factory.enumerateDevices();
149
+ return devices.filter((d) => d.kind === "audioinput");
150
+ }
151
+ catch (e) {
152
+ console.error("Failed to enumerate devices:", e);
153
+ return [];
154
+ }
155
+ }
156
+ /**
157
+ * Switches the active microphone to a different audio input device.
158
+ * @param deviceId - The device ID of the audio input to switch to.
159
+ * @returns True if the switch was successful, false otherwise.
160
+ */
161
+ async switchMicrophone(deviceId) {
162
+ if (!this.#pc || !this.#localStream) {
163
+ console.error("Cannot switch microphone: not initialized or no active stream");
164
+ return false;
165
+ }
166
+ try {
167
+ // Get new stream from the specified device
168
+ const newStream = await this.#factory.getUserMedia({
169
+ audio: { deviceId: { exact: deviceId } },
170
+ video: false,
171
+ });
172
+ const newTrack = newStream.getAudioTracks()[0];
173
+ if (!newTrack) {
174
+ throw new Error("No audio track in new stream");
175
+ }
176
+ // Find the sender for the audio track - check both senders and transceivers
177
+ let sender = this.#pc.getSenders().find((s) => s.track?.kind === "audio");
178
+ if (!sender) {
179
+ // Try to find via transceiver
180
+ const transceivers = this.#pc.getTransceivers();
181
+ const audioTransceiver = transceivers.find((t) => t.receiver.track.kind === "audio");
182
+ if (audioTransceiver) {
183
+ sender = audioTransceiver.sender;
184
+ }
185
+ }
186
+ if (!sender) {
187
+ throw new Error("No audio sender found - enable microphone first");
188
+ }
189
+ // Replace the track
190
+ await sender.replaceTrack(newTrack);
191
+ // Stop old tracks
192
+ this.#localStream.getAudioTracks().forEach((track) => track.stop());
193
+ // Update local stream reference
194
+ this.#localStream = newStream;
195
+ this.#pubsub.publish(WebRtcManager.EVENT_LOCAL_STREAM, newStream);
196
+ return true;
197
+ }
198
+ catch (e) {
199
+ console.error("Failed to switch microphone:", e);
200
+ this.#error(e);
201
+ return false;
202
+ }
203
+ }
204
+ /**
205
+ * Initializes the WebRTC peer connection and sets up media tracks.
206
+ * Must be called before creating offers or answers. Can only be called from IDLE state.
207
+ */
208
+ async initialize() {
209
+ if (this.state !== WebRtcState.IDLE)
210
+ return;
211
+ this.#dispatch(WebRtcFsmEvent.INIT);
212
+ try {
213
+ this.#pc = this.#factory.createPeerConnection(this.#config.peerConfig);
214
+ this.#setupPcListeners();
215
+ // Setup device change detection now that we have a connection
216
+ this.#setupDeviceChangeListener();
217
+ if (this.#config.enableMicrophone) {
218
+ const success = await this.enableMicrophone(true);
219
+ if (!success) {
220
+ this.#pubsub.publish(WebRtcManager.EVENT_MICROPHONE_FAILED, {
221
+ reason: "Failed to enable microphone during initialization",
222
+ });
223
+ }
224
+ }
225
+ else {
226
+ // Always setup to receive audio, even if we don't enable microphone
227
+ // This ensures the SDP includes audio media line
228
+ this.#pc.addTransceiver("audio", { direction: "recvonly" });
229
+ }
230
+ if (this.#config.dataChannelLabel) {
231
+ this.createDataChannel(this.#config.dataChannelLabel);
232
+ }
233
+ }
234
+ catch (e) {
235
+ this.#error(e);
236
+ }
237
+ }
238
+ /**
239
+ * Transitions to the CONNECTING state. Automatically initializes if needed.
240
+ * If disconnected, reinitializes the peer connection.
241
+ */
242
+ async connect() {
243
+ // Initialize if needed
244
+ if (this.state === WebRtcState.IDLE) {
245
+ await this.initialize();
246
+ }
247
+ // Reinitialize if disconnected (PeerConnection was closed)
248
+ if (this.state === WebRtcState.DISCONNECTED) {
249
+ // Clean up old connection
250
+ this.#cleanup();
251
+ // Reset to IDLE and reinitialize
252
+ this.#fsm.transition(WebRtcFsmEvent.RESET);
253
+ await this.initialize();
254
+ // Stay in INITIALIZING state - caller needs to create offer/answer
255
+ return;
256
+ }
257
+ if (this.state === WebRtcState.CONNECTED ||
258
+ this.state === WebRtcState.CONNECTING)
259
+ return;
260
+ this.#dispatch(WebRtcFsmEvent.CONNECT);
261
+ }
262
+ /**
263
+ * Enables or disables the microphone and adds/removes audio tracks to the peer connection.
264
+ * @param enable - True to enable microphone, false to disable.
265
+ * @returns True if successful, false if failed to get user media.
266
+ */
267
+ async enableMicrophone(enable) {
268
+ if (enable) {
269
+ if (this.#localStream)
270
+ return true; // Already enabled
271
+ try {
272
+ const stream = await this.#factory.getUserMedia({
273
+ audio: true,
274
+ video: false,
275
+ });
276
+ this.#localStream = stream;
277
+ this.#pubsub.publish(WebRtcManager.EVENT_LOCAL_STREAM, stream);
278
+ if (this.#pc) {
279
+ // Check if we have an existing audio transceiver
280
+ const transceivers = this.#pc.getTransceivers();
281
+ const audioTransceiver = transceivers.find((t) => t.receiver.track.kind === "audio");
282
+ if (audioTransceiver && audioTransceiver.sender) {
283
+ // Replace the track in existing transceiver
284
+ const track = stream.getAudioTracks()[0];
285
+ await audioTransceiver.sender.replaceTrack(track);
286
+ // Update direction to sendrecv
287
+ audioTransceiver.direction = "sendrecv";
288
+ }
289
+ else {
290
+ // Add track normally
291
+ stream.getTracks().forEach((track) => {
292
+ this.#pc.addTrack(track, stream);
293
+ });
294
+ }
295
+ }
296
+ return true;
297
+ }
298
+ catch (e) {
299
+ console.error("Failed to get user media", e);
300
+ this.#pubsub.publish(WebRtcManager.EVENT_MICROPHONE_FAILED, { error: e });
301
+ return false;
302
+ }
303
+ }
304
+ else {
305
+ if (!this.#localStream)
306
+ return true;
307
+ this.#localStream.getTracks().forEach((track) => {
308
+ track.stop();
309
+ // Remove from PC if needed, or just stop sending
310
+ if (this.#pc) {
311
+ const senders = this.#pc.getSenders();
312
+ const sender = senders.find((s) => s.track === track);
313
+ if (sender) {
314
+ this.#pc.removeTrack(sender);
315
+ }
316
+ }
317
+ });
318
+ this.#localStream = null;
319
+ this.#pubsub.publish(WebRtcManager.EVENT_LOCAL_STREAM, null);
320
+ return true;
321
+ }
322
+ }
323
+ /**
324
+ * Disconnects the peer connection and cleans up all resources.
325
+ * Transitions to DISCONNECTED state.
326
+ */
327
+ disconnect() {
328
+ this.#cleanup();
329
+ this.#dispatch(WebRtcFsmEvent.DISCONNECT);
330
+ }
331
+ /**
332
+ * Resets the manager to IDLE state from any state.
333
+ * Cleans up all resources and allows reinitialization.
334
+ */
335
+ reset() {
336
+ this.#cleanup();
337
+ // Reset from any non-IDLE state
338
+ if (this.state !== WebRtcState.IDLE) {
339
+ // Force transition to DISCONNECTED first if needed, then to IDLE
340
+ if (this.state === WebRtcState.ERROR ||
341
+ this.state === WebRtcState.DISCONNECTED ||
342
+ this.state === WebRtcState.RECONNECTING) {
343
+ this.#dispatch(WebRtcFsmEvent.RESET);
344
+ }
345
+ else {
346
+ // For other states, go through DISCONNECTED first
347
+ this.#dispatch(WebRtcFsmEvent.DISCONNECT);
348
+ this.#dispatch(WebRtcFsmEvent.RESET);
349
+ }
350
+ }
351
+ }
352
+ /**
353
+ * Creates a new data channel with the specified label.
354
+ * Returns existing channel if one with the same label already exists.
355
+ * @param label - The label for the data channel.
356
+ * @param options - Optional RTCDataChannelInit configuration.
357
+ * @returns The created data channel, or null if peer connection not initialized.
358
+ */
359
+ createDataChannel(label, options) {
360
+ if (!this.#pc)
361
+ return null;
362
+ if (this.#dataChannels.has(label))
363
+ return this.#dataChannels.get(label);
364
+ try {
365
+ const dc = this.#pc.createDataChannel(label, options);
366
+ this.#setupDataChannelListeners(dc);
367
+ this.#dataChannels.set(label, dc);
368
+ return dc;
369
+ }
370
+ catch (e) {
371
+ this.#error(e);
372
+ return null;
373
+ }
374
+ }
375
+ /**
376
+ * Retrieves an existing data channel by label.
377
+ * @param label - The label of the data channel to retrieve.
378
+ * @returns The data channel if found, undefined otherwise.
379
+ */
380
+ getDataChannel(label) {
381
+ return this.#dataChannels.get(label);
382
+ }
383
+ /**
384
+ * Sends data through a data channel identified by label.
385
+ * Checks that the channel exists and is in open state before sending.
386
+ * @param label - The label of the data channel to send through.
387
+ * @param data - The data to send (string, Blob, or ArrayBuffer).
388
+ * @returns True if data was sent successfully, false otherwise.
389
+ */
390
+ sendData(label, data) {
391
+ const channel = this.#dataChannels.get(label);
392
+ if (!channel) {
393
+ this.#debug(`Data channel '${label}' not found`);
394
+ return false;
395
+ }
396
+ if (channel.readyState !== "open") {
397
+ this.#debug(`Data channel '${label}' is not open (state: ${channel.readyState})`);
398
+ return false;
399
+ }
400
+ try {
401
+ channel.send(data);
402
+ return true;
403
+ }
404
+ catch (e) {
405
+ this.#error(e);
406
+ return false;
407
+ }
408
+ }
409
+ // --- Signaling methods ---
410
+ /**
411
+ * Creates an SDP offer for initiating a WebRTC connection.
412
+ * @param options - Optional offer configuration.
413
+ * @returns The offer SDP, or null if peer connection not initialized.
414
+ */
415
+ async createOffer(options) {
416
+ if (!this.#pc)
417
+ return null;
418
+ try {
419
+ const offer = await this.#pc.createOffer(options);
420
+ return offer;
421
+ }
422
+ catch (e) {
423
+ this.#error(e);
424
+ return null;
425
+ }
426
+ }
427
+ /**
428
+ * Creates an SDP answer in response to a received offer.
429
+ * @param options - Optional answer configuration.
430
+ * @returns The answer SDP, or null if peer connection not initialized.
431
+ */
432
+ async createAnswer(options) {
433
+ if (!this.#pc)
434
+ return null;
435
+ try {
436
+ const answer = await this.#pc.createAnswer(options);
437
+ return answer;
438
+ }
439
+ catch (e) {
440
+ this.#error(e);
441
+ return null;
442
+ }
443
+ }
444
+ /**
445
+ * Sets the local description for the peer connection.
446
+ * @param description - The SDP description (offer or answer).
447
+ * @returns True if successful, false otherwise.
448
+ */
449
+ async setLocalDescription(description) {
450
+ if (!this.#pc)
451
+ return false;
452
+ try {
453
+ await this.#pc.setLocalDescription(description);
454
+ return true;
455
+ }
456
+ catch (e) {
457
+ this.#error(e);
458
+ return false;
459
+ }
460
+ }
461
+ /**
462
+ * Sets the remote description received from the peer.
463
+ * @param description - The remote SDP description.
464
+ * @returns True if successful, false otherwise.
465
+ */
466
+ async setRemoteDescription(description) {
467
+ if (!this.#pc)
468
+ return false;
469
+ try {
470
+ await this.#pc.setRemoteDescription(description);
471
+ return true;
472
+ }
473
+ catch (e) {
474
+ this.#error(e);
475
+ return false;
476
+ }
477
+ }
478
+ /**
479
+ * Adds an ICE candidate received from the remote peer.
480
+ * @param candidate - The ICE candidate to add, or null for end-of-candidates.
481
+ * @returns True if successful, false otherwise.
482
+ */
483
+ async addIceCandidate(candidate) {
484
+ if (!this.#pc)
485
+ return false;
486
+ try {
487
+ if (candidate) {
488
+ await this.#pc.addIceCandidate(candidate);
489
+ }
490
+ return true;
491
+ }
492
+ catch (e) {
493
+ this.#error(e);
494
+ return false;
495
+ }
496
+ }
497
+ /**
498
+ * Performs an ICE restart to recover from connection issues.
499
+ * Creates a new offer with iceRestart flag and sets it as local description.
500
+ * @returns True if successful, false otherwise.
501
+ */
502
+ async iceRestart() {
503
+ if (!this.#pc)
504
+ return false;
505
+ try {
506
+ const offer = await this.#pc.createOffer({ iceRestart: true });
507
+ await this.#pc.setLocalDescription(offer);
508
+ return true;
509
+ }
510
+ catch (e) {
511
+ this.#error(e);
512
+ return false;
513
+ }
514
+ }
515
+ /**
516
+ * Returns the current local session description.
517
+ * @returns The local description, or null if not set.
518
+ */
519
+ getLocalDescription() {
520
+ return this.#pc?.localDescription ?? null;
521
+ }
522
+ /**
523
+ * Returns the current remote session description.
524
+ * @returns The remote description, or null if not set.
525
+ */
526
+ getRemoteDescription() {
527
+ return this.#pc?.remoteDescription ?? null;
528
+ }
529
+ /**
530
+ * Retrieves WebRTC statistics for the peer connection.
531
+ * @returns Stats report, or null if peer connection not initialized.
532
+ */
533
+ async getStats() {
534
+ if (!this.#pc)
535
+ return null;
536
+ try {
537
+ return await this.#pc.getStats();
538
+ }
539
+ catch (e) {
540
+ this.#error(e);
541
+ return null;
542
+ }
543
+ }
544
+ // --- Private ---
545
+ #dispatch(event) {
546
+ const oldState = this.#fsm.state;
547
+ this.#fsm.transition(event);
548
+ const newState = this.#fsm.state;
549
+ if (oldState !== newState) {
550
+ this.#pubsub.publish(WebRtcManager.EVENT_STATE_CHANGE, newState);
551
+ }
552
+ }
553
+ #debug(...args) {
554
+ if (this.#config.debug) {
555
+ console.debug("[WebRtcManager]", ...args);
556
+ }
557
+ }
558
+ #error(error) {
559
+ console.error(error);
560
+ this.#dispatch(WebRtcFsmEvent.ERROR);
561
+ this.#pubsub.publish(WebRtcManager.EVENT_ERROR, error);
562
+ }
563
+ #setupPcListeners() {
564
+ if (!this.#pc)
565
+ return;
566
+ this.#pc.onconnectionstatechange = () => {
567
+ const state = this.#pc.connectionState;
568
+ if (state === "connected") {
569
+ // Connection successful - reset reconnect attempts
570
+ this.#reconnectAttempts = 0;
571
+ this.#dispatch(WebRtcFsmEvent.CONNECTED);
572
+ }
573
+ else if (state === "failed") {
574
+ // Connection failed - attempt reconnection if enabled
575
+ this.#handleConnectionFailure();
576
+ }
577
+ else if (state === "disconnected" || state === "closed") {
578
+ this.#dispatch(WebRtcFsmEvent.DISCONNECT);
579
+ }
580
+ };
581
+ this.#pc.ontrack = (event) => {
582
+ if (event.streams && event.streams[0]) {
583
+ this.#remoteStream = event.streams[0];
584
+ this.#pubsub.publish(WebRtcManager.EVENT_REMOTE_STREAM, this.#remoteStream);
585
+ }
586
+ };
587
+ this.#pc.ondatachannel = (event) => {
588
+ const dc = event.channel;
589
+ this.#setupDataChannelListeners(dc);
590
+ this.#dataChannels.set(dc.label, dc);
591
+ };
592
+ this.#pc.onicecandidate = (event) => {
593
+ this.#pubsub.publish(WebRtcManager.EVENT_ICE_CANDIDATE, event.candidate);
594
+ };
595
+ }
596
+ #cleanup() {
597
+ // Clear any pending reconnect timers
598
+ if (this.#reconnectTimer !== null) {
599
+ clearTimeout(this.#reconnectTimer);
600
+ this.#reconnectTimer = null;
601
+ }
602
+ // Remove device change listener
603
+ if (this.#deviceChangeHandler) {
604
+ navigator.mediaDevices.removeEventListener("devicechange", this.#deviceChangeHandler);
605
+ this.#deviceChangeHandler = null;
606
+ }
607
+ // Close all data channels
608
+ this.#dataChannels.forEach((dc) => {
609
+ if (dc.readyState !== "closed") {
610
+ dc.close();
611
+ }
612
+ });
613
+ this.#dataChannels.clear();
614
+ // Stop local stream tracks
615
+ if (this.#localStream) {
616
+ this.#localStream.getTracks().forEach((track) => track.stop());
617
+ this.#localStream = null;
618
+ }
619
+ // Close peer connection
620
+ if (this.#pc) {
621
+ this.#pc.close();
622
+ this.#pc = null;
623
+ }
624
+ this.#remoteStream = null;
625
+ }
626
+ #handleConnectionFailure() {
627
+ this.#dispatch(WebRtcFsmEvent.DISCONNECT);
628
+ // Check if auto-reconnect is enabled
629
+ if (!this.#config.autoReconnect) {
630
+ return;
631
+ }
632
+ const maxAttempts = this.#config.maxReconnectAttempts ?? 5;
633
+ // Check if we've exceeded max attempts
634
+ if (this.#reconnectAttempts >= maxAttempts) {
635
+ this.#pubsub.publish(WebRtcManager.EVENT_RECONNECT_FAILED, {
636
+ attempts: this.#reconnectAttempts,
637
+ });
638
+ return;
639
+ }
640
+ // Transition to RECONNECTING state
641
+ this.#dispatch(WebRtcFsmEvent.RECONNECTING);
642
+ // Attempt reconnection with exponential backoff
643
+ this.#attemptReconnect();
644
+ }
645
+ #attemptReconnect() {
646
+ this.#reconnectAttempts++;
647
+ const baseDelay = this.#config.reconnectDelay ?? 1000;
648
+ const delay = baseDelay * Math.pow(2, this.#reconnectAttempts - 1);
649
+ // Try ICE restart first (attempts 1-2), then full reconnect
650
+ const strategy = this.#reconnectAttempts <= 2 ? "ice-restart" : "full";
651
+ this.#pubsub.publish(WebRtcManager.EVENT_RECONNECTING, {
652
+ attempt: this.#reconnectAttempts,
653
+ strategy,
654
+ });
655
+ this.#reconnectTimer = setTimeout(async () => {
656
+ this.#reconnectTimer = null;
657
+ if (strategy === "ice-restart" && this.#pc) {
658
+ // Try ICE restart - keep existing connection
659
+ const success = await this.iceRestart();
660
+ if (!success) {
661
+ // ICE restart failed, will try again or switch to full reconnect
662
+ this.#handleConnectionFailure();
663
+ }
664
+ // If successful, onconnectionstatechange will reset attempts
665
+ }
666
+ else {
667
+ // Full reconnection - create new connection
668
+ // IMPORTANT: This will only initialize the connection. Consumers MUST
669
+ // listen for the 'reconnecting' event with strategy='full' and manually
670
+ // perform the signaling handshake (create offer/answer exchange) to
671
+ // complete the reconnection.
672
+ try {
673
+ await this.connect();
674
+ // If successful, onconnectionstatechange will reset attempts
675
+ }
676
+ catch (e) {
677
+ console.error("Reconnection failed:", e);
678
+ this.#handleConnectionFailure();
679
+ }
680
+ }
681
+ }, delay);
682
+ }
683
+ #setupDeviceChangeListener() {
684
+ // Only setup in browser environment with navigator.mediaDevices
685
+ if (typeof navigator === "undefined" || !navigator.mediaDevices) {
686
+ return;
687
+ }
688
+ // Don't setup twice
689
+ if (this.#deviceChangeHandler) {
690
+ return;
691
+ }
692
+ this.#deviceChangeHandler = async () => {
693
+ try {
694
+ const devices = await this.getAudioInputDevices();
695
+ this.#pubsub.publish(WebRtcManager.EVENT_DEVICE_CHANGED, devices);
696
+ }
697
+ catch (e) {
698
+ console.error("Error handling device change:", e);
699
+ }
700
+ };
701
+ navigator.mediaDevices.addEventListener("devicechange", this.#deviceChangeHandler);
702
+ }
703
+ #setupDataChannelListeners(dc) {
704
+ dc.onopen = () => {
705
+ this.#pubsub.publish(WebRtcManager.EVENT_DATA_CHANNEL_OPEN, dc);
706
+ };
707
+ dc.onmessage = (event) => {
708
+ this.#pubsub.publish(WebRtcManager.EVENT_DATA_CHANNEL_MESSAGE, {
709
+ channel: dc,
710
+ data: event.data,
711
+ });
712
+ };
713
+ dc.onclose = () => {
714
+ this.#pubsub.publish(WebRtcManager.EVENT_DATA_CHANNEL_CLOSE, dc);
715
+ this.#dataChannels.delete(dc.label);
716
+ };
717
+ dc.onerror = (error) => {
718
+ // Ignore "User-Initiated Abort" errors which occur during intentional close()
719
+ const isUserAbort = error?.error?.message?.includes("User-Initiated Abort");
720
+ if (!isUserAbort) {
721
+ console.error("Data Channel Error:", error);
722
+ this.#pubsub.publish(WebRtcManager.EVENT_ERROR, error);
723
+ }
724
+ };
725
+ }
726
+ }