@encarnage7/local-meet 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2574 @@
1
+ /**
2
+ * Local Meet SDK v1.0.0 (Browser UMD / CDN)
3
+ * Zero-trust WebRTC P2P Mesh & Cloudflare Calls SFU client library
4
+ * License: MIT
5
+ */
6
+ (function (root, factory) {
7
+ if (typeof define === 'function' && define.amd) {
8
+ define([], factory);
9
+ } else if (typeof module === 'object' && module.exports) {
10
+ module.exports = factory();
11
+ } else {
12
+ var lib = factory();
13
+ root.LocalMeet = lib.LocalMeet || lib;
14
+ root.LocalMeetRoom = lib.LocalMeetRoom;
15
+ root.RoomEvents = lib.RoomEvents;
16
+ root.TrackSources = lib.TrackSources;
17
+ root.TrackKinds = lib.TrackKinds;
18
+ root.EngineModes = lib.EngineModes;
19
+ }
20
+ }(typeof self !== 'undefined' ? self : this, function () {
21
+
22
+ /**
23
+ * Eventos emitidos por LocalMeetRoom
24
+ */
25
+ const RoomEvents = Object.freeze({
26
+ ParticipantConnected: "participantConnected",
27
+ ParticipantDisconnected: "participantDisconnected",
28
+ ParticipantMetadataChanged: "participantMetadataChanged",
29
+ ParticipantPermissionsChanged: "participantPermissionsChanged",
30
+ DataReceived: "dataReceived",
31
+ TrackSubscribed: "trackSubscribed",
32
+ TrackUnsubscribed: "trackUnsubscribed",
33
+ TrackUnpublished: "trackUnpublished",
34
+ LocalTrackPublished: "localTrackPublished",
35
+ LocalTrackUnpublished: "localTrackUnpublished",
36
+ TrackMuted: "trackMuted",
37
+ TrackUnmuted: "trackUnmuted",
38
+ ActiveSpeakersChanged: "activeSpeakersChanged",
39
+ Disconnected: "disconnected",
40
+ Error: "error",
41
+ });
42
+
43
+ const TrackSources = Object.freeze({
44
+ Camera: "camera",
45
+ Microphone: "microphone",
46
+ ScreenShare: "screen_share",
47
+ ScreenShareAudio: "screen_share_audio",
48
+ });
49
+
50
+ const TrackKinds = Object.freeze({
51
+ Audio: "audio",
52
+ Video: "video",
53
+ });
54
+
55
+ const EngineModes = Object.freeze({
56
+ CloudflareCalls: "cf-calls",
57
+ P2PMesh: "p2p",
58
+ LiveKit: "livekit",
59
+ });
60
+
61
+
62
+ /**
63
+ * Cifrado de paquetes de datos de sala con AES-GCM-256 usando Web Crypto API.
64
+ */
65
+
66
+ const PBKDF2_ITERATIONS = 100000;
67
+ const cachedAesKeys = new Map();
68
+
69
+ function bytesToBase64(bytes) {
70
+ let binary = "";
71
+ for (let i = 0; i < bytes.byteLength; i++) {
72
+ binary += String.fromCharCode(bytes[i]);
73
+ }
74
+ return btoa(binary);
75
+ }
76
+
77
+ function base64ToBytes(base64) {
78
+ const binary = atob(base64);
79
+ const out = new Uint8Array(binary.length);
80
+ for (let i = 0; i < binary.length; i++) {
81
+ out[i] = binary.charCodeAt(i);
82
+ }
83
+ return out;
84
+ }
85
+
86
+ async function deriveRoomAesKey(roomHash, extraSecret = "") {
87
+ const cleanHash = (roomHash || "").toLowerCase().trim();
88
+ const cacheKey = `${cleanHash}::${extraSecret}`;
89
+ if (cachedAesKeys.has(cacheKey)) {
90
+ return cachedAesKeys.get(cacheKey);
91
+ }
92
+
93
+ const salt = new TextEncoder().encode(`local-meet-salt-v1:${cleanHash}`);
94
+ const keyMaterial = await crypto.subtle.importKey(
95
+ "raw",
96
+ new TextEncoder().encode(`${cleanHash}:${extraSecret}`),
97
+ "PBKDF2",
98
+ false,
99
+ ["deriveKey"]
100
+ );
101
+
102
+ const aesKey = await crypto.subtle.deriveKey(
103
+ {
104
+ name: "PBKDF2",
105
+ salt,
106
+ iterations: PBKDF2_ITERATIONS,
107
+ hash: "SHA-256",
108
+ },
109
+ keyMaterial,
110
+ { name: "AES-GCM", length: 256 },
111
+ false,
112
+ ["encrypt", "decrypt"]
113
+ );
114
+
115
+ cachedAesKeys.set(cacheKey, aesKey);
116
+ return aesKey;
117
+ }
118
+
119
+ async function encryptDataPacket(roomHash, plaintext, extraSecret = "") {
120
+ const aesKey = await deriveRoomAesKey(roomHash, extraSecret);
121
+ const iv = new Uint8Array(12);
122
+ crypto.getRandomValues(iv);
123
+ const encoded = new TextEncoder().encode(plaintext);
124
+ const ciphertext = await crypto.subtle.encrypt(
125
+ { name: "AES-GCM", iv },
126
+ aesKey,
127
+ encoded
128
+ );
129
+ return {
130
+ e2ee: "aes-gcm-256",
131
+ iv: bytesToBase64(iv),
132
+ ct: bytesToBase64(new Uint8Array(ciphertext)),
133
+ };
134
+ }
135
+
136
+ async function decryptDataPacket(roomHash, packet, extraSecret = "") {
137
+ if (!packet || packet.e2ee !== "aes-gcm-256" || !packet.iv || !packet.ct) {
138
+ return null;
139
+ }
140
+ try {
141
+ const aesKey = await deriveRoomAesKey(roomHash, extraSecret);
142
+ const iv = base64ToBytes(packet.iv);
143
+ const ct = base64ToBytes(packet.ct);
144
+ const decryptedBuffer = await crypto.subtle.decrypt(
145
+ { name: "AES-GCM", iv },
146
+ aesKey,
147
+ ct
148
+ );
149
+ return new TextDecoder().decode(decryptedBuffer);
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
154
+
155
+
156
+
157
+
158
+ const DEFAULT_ICE_SERVERS = [
159
+ { urls: "stun:stun.cloudflare.com:3478" },
160
+ { urls: "stun:stun.l.google.com:19302" },
161
+ { urls: "stun:stun1.l.google.com:19302" },
162
+ {
163
+ urls: [
164
+ "turn:openrelay.metered.ca:80",
165
+ "turn:openrelay.metered.ca:443",
166
+ "turn:openrelay.metered.ca:443?transport=tcp",
167
+ "turns:openrelay.metered.ca:443?transport=tcp",
168
+ ],
169
+ username: "openrelayproject",
170
+ credential: "openrelayproject",
171
+ },
172
+ ];
173
+
174
+ class HybridTrack {
175
+ constructor(mediaStreamTrack, kind, source) {
176
+ this.mediaStreamTrack = mediaStreamTrack;
177
+ this.kind = kind;
178
+ this.source = source;
179
+ this.mediaStream = mediaStreamTrack ? new MediaStream([mediaStreamTrack]) : null;
180
+ this.primaryElement = null;
181
+ this.attachedElements = new Set();
182
+ }
183
+
184
+ attach(existingElement = null) {
185
+ if (this.kind === TrackKinds.Video && this.primaryElement) {
186
+ const el = this.primaryElement;
187
+ el.autoplay = true;
188
+ el.playsInline = true;
189
+ el.muted = true;
190
+ if (this.mediaStream && el.srcObject !== this.mediaStream) {
191
+ el.srcObject = this.mediaStream;
192
+ }
193
+ this.attachedElements.add(el);
194
+ return el;
195
+ }
196
+
197
+ const el =
198
+ existingElement ||
199
+ document.createElement(this.kind === TrackKinds.Audio ? "audio" : "video");
200
+ el.autoplay = true;
201
+ el.playsInline = true;
202
+ if (this.kind === TrackKinds.Video) {
203
+ el.muted = true;
204
+ el.setAttribute("muted", "");
205
+ el.setAttribute("playsinline", "");
206
+ el.setAttribute("autoplay", "");
207
+ }
208
+ if (this.mediaStream) {
209
+ el.srcObject = this.mediaStream;
210
+ }
211
+ const ensurePlay = () => {
212
+ el.play?.().catch(() => {});
213
+ };
214
+ el.addEventListener("loadedmetadata", ensurePlay);
215
+ el.addEventListener("loadeddata", ensurePlay);
216
+ if (this.mediaStreamTrack) {
217
+ this.mediaStreamTrack.addEventListener("unmute", ensurePlay);
218
+ }
219
+ if (this.kind === TrackKinds.Video) {
220
+ this.primaryElement = el;
221
+ }
222
+ this.attachedElements.add(el);
223
+ return el;
224
+ }
225
+
226
+ detach() {
227
+ const list = Array.from(this.attachedElements);
228
+ for (const el of list) {
229
+ try {
230
+ el.srcObject = null;
231
+ } catch {
232
+ // Ignorar
233
+ }
234
+ }
235
+ this.primaryElement = null;
236
+ this.attachedElements.clear();
237
+ return list;
238
+ }
239
+
240
+ stop() {
241
+ try {
242
+ this.mediaStreamTrack?.stop();
243
+ } catch {
244
+ // Ignorar
245
+ }
246
+ }
247
+ }
248
+
249
+ class HybridPublication {
250
+ constructor({ trackSid, source, track = null, isMuted = false }) {
251
+ this.trackSid = trackSid;
252
+ this.source = source;
253
+ this.track = track;
254
+ this.isMuted = isMuted;
255
+ }
256
+ }
257
+
258
+ class HybridParticipant {
259
+ constructor({ identity, name, metadata = "{}", permissions = {}, isLocal = false, room = null }) {
260
+ this.identity = identity;
261
+ this.name = name;
262
+ this.metadata = metadata;
263
+ this.permissions = {
264
+ canPublish: Boolean(permissions.canPublish),
265
+ canSubscribe: Boolean(permissions.canSubscribe),
266
+ canPublishData: Boolean(permissions.canPublishData),
267
+ };
268
+ this.isLocal = isLocal;
269
+ this.room = room;
270
+ this.trackPublications = new Map();
271
+ this.videoTrackPublications = new Map();
272
+ this.peerId = "";
273
+ this.cfSessionId = "";
274
+ this.cfPublishedTracks = [];
275
+ }
276
+
277
+ getTrackPublication(source) {
278
+ return this.trackPublications.get(source) || null;
279
+ }
280
+
281
+ setPublication(source, publication) {
282
+ if (!publication) {
283
+ this.trackPublications.delete(source);
284
+ this.videoTrackPublications.delete(source);
285
+ return;
286
+ }
287
+ this.trackPublications.set(source, publication);
288
+ if (source === TrackSources.Camera || source === TrackSources.ScreenShare) {
289
+ this.videoTrackPublications.set(source, publication);
290
+ }
291
+ }
292
+
293
+ async enableCameraAndMicrophone() {
294
+ if (!this.isLocal || !this.room) return;
295
+ await this.room.enableCameraAndMicrophone();
296
+ }
297
+
298
+ async setMicrophoneEnabled(enabled) {
299
+ if (!this.isLocal || !this.room) return;
300
+ await this.room.setMicrophoneEnabled(Boolean(enabled));
301
+ }
302
+
303
+ async setCameraEnabled(enabled) {
304
+ if (!this.isLocal || !this.room) return;
305
+ await this.room.setCameraEnabled(Boolean(enabled));
306
+ }
307
+
308
+ async setScreenShareEnabled(enabled) {
309
+ if (!this.isLocal || !this.room) return null;
310
+ return this.room.setScreenShareEnabled(Boolean(enabled));
311
+ }
312
+
313
+ async publishData(payloadUint8, options = {}) {
314
+ if (!this.isLocal || !this.room) return;
315
+ await this.room.sendData(payloadUint8, options);
316
+ }
317
+ }
318
+
319
+ class LocalMeetRoom {
320
+ constructor(options = {}) {
321
+ const {
322
+ serverUrl = typeof window !== "undefined" ? window.location.origin : "",
323
+ roomHash = "",
324
+ title = "",
325
+ identity = "",
326
+ displayName = "Participante",
327
+ isHost = false,
328
+ hostSecret = "",
329
+ engine = EngineModes.CloudflareCalls,
330
+ cfCallsReady = false,
331
+ startInWaitingRoom = false,
332
+ iceServers = [],
333
+ debug = false,
334
+ } = options;
335
+
336
+ this.serverUrl = (serverUrl || "").replace(/\/+$/, "");
337
+ this.roomHash = (roomHash || "").toLowerCase().trim();
338
+ this.roomTitle = title || `Sala ${this.roomHash}`;
339
+ this.isHost = Boolean(isHost);
340
+ this.hostSecret = hostSecret || "";
341
+ this.engine = engine;
342
+ this.cfCallsReady = Boolean(cfCallsReady && engine === EngineModes.CloudflareCalls);
343
+ this.debug = Boolean(debug);
344
+
345
+ this.iceServers =
346
+ Array.isArray(iceServers) && iceServers.length > 0 ? iceServers : DEFAULT_ICE_SERVERS;
347
+
348
+ this.listeners = new Map();
349
+ this.remoteParticipants = new Map();
350
+
351
+ const localId = identity || `usr_${Math.random().toString(36).slice(2, 10)}`;
352
+ this.localParticipant = new HybridParticipant({
353
+ identity: localId,
354
+ name: displayName,
355
+ metadata: JSON.stringify({
356
+ role: this.isHost ? "host" : "guest",
357
+ status: startInWaitingRoom ? "waiting" : "approved",
358
+ room_title: this.roomTitle,
359
+ }),
360
+ permissions: {
361
+ canPublish: !startInWaitingRoom,
362
+ canSubscribe: !startInWaitingRoom,
363
+ canPublishData: !startInWaitingRoom,
364
+ },
365
+ isLocal: true,
366
+ room: this,
367
+ });
368
+
369
+ this.peer = null;
370
+ this.hostConn = null;
371
+ this.knownHostPeerId = this.getHostPeerId();
372
+ this.guestConns = new Map();
373
+ this.activeMediaCalls = new Map();
374
+ this.directScreenPcs = new Map();
375
+ this.incomingScreenPcs = new Map();
376
+ this.pendingScreenIce = new Map();
377
+ this.rtcSignalQueues = new Map();
378
+
379
+ this.joinedAt = Date.now();
380
+ this.outgoingHttpSignals = [];
381
+ this.processedSignalIds = new Set();
382
+ this.syncTimer = null;
383
+ this.peerKeepAliveTimer = null;
384
+ this.syncInFlight = false;
385
+
386
+ // Estado Cloudflare Calls SFU
387
+ this.cfSessionId = "";
388
+ this.cfPc = null;
389
+ this.cfInitPromise = null;
390
+ this.cfTransceivers = new Map();
391
+ this.cfPulledRemoteKeys = new Set();
392
+ this.cfPullRetryCounts = new Map();
393
+ this.cfMidToRemoteMeta = new Map();
394
+ this.cfNegotiationChain = Promise.resolve();
395
+ this.currentScreenTrackName = "screen";
396
+ this.isDisconnected = false;
397
+ }
398
+
399
+ log(...args) {
400
+ if (this.debug) {
401
+ console.log("[LocalMeet]", ...args);
402
+ }
403
+ }
404
+
405
+ async apiPost(endpoint, payload) {
406
+ const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
407
+ const url = `${this.serverUrl}${cleanEndpoint}`;
408
+
409
+ const res = await fetch(url, {
410
+ method: "POST",
411
+ headers: { "Content-Type": "application/json" },
412
+ body: JSON.stringify(payload),
413
+ });
414
+
415
+ const rawText = await res.text();
416
+ let data = null;
417
+ if (rawText) {
418
+ try {
419
+ data = JSON.parse(rawText);
420
+ } catch {
421
+ data = null;
422
+ }
423
+ }
424
+
425
+ if (!res.ok) {
426
+ throw new Error(
427
+ data?.detail || `Error en servidor (${res.status}): ${rawText.slice(0, 120)}`
428
+ );
429
+ }
430
+ return data;
431
+ }
432
+
433
+ on(event, callback) {
434
+ if (!this.listeners.has(event)) {
435
+ this.listeners.set(event, new Set());
436
+ }
437
+ this.listeners.get(event).add(callback);
438
+ return this;
439
+ }
440
+
441
+ off(event, callback) {
442
+ const set = this.listeners.get(event);
443
+ if (set) {
444
+ set.delete(callback);
445
+ }
446
+ return this;
447
+ }
448
+
449
+ emit(event, ...args) {
450
+ const set = this.listeners.get(event);
451
+ if (!set) return;
452
+ for (const cb of set) {
453
+ try {
454
+ cb(...args);
455
+ } catch (err) {
456
+ if (this.debug) console.error(`[LocalMeet] Error en listener de '${event}':`, err);
457
+ }
458
+ }
459
+ }
460
+
461
+ getHostPeerId() {
462
+ const cleanHash = this.roomHash.replace(/[^a-z0-9]/g, "");
463
+ return `lm-host-${cleanHash}`;
464
+ }
465
+
466
+ getMyPeerId() {
467
+ if (this.isHost) {
468
+ return this.getHostPeerId();
469
+ }
470
+ const cleanId = this.localParticipant.identity.replace(/[^a-z0-9]/g, "");
471
+ return `lm-${cleanId}`;
472
+ }
473
+
474
+ async connect() {
475
+ const myPeerId = this.getMyPeerId();
476
+ this.localParticipant.peerId = myPeerId;
477
+
478
+ if (typeof window !== "undefined" && typeof window.Peer === "function") {
479
+ await this.initPeerWithRetry(myPeerId, 3).catch(() => {});
480
+ }
481
+
482
+ this.startWorkerSyncLoop();
483
+
484
+ if (this.isHost) {
485
+ if (this.cfCallsReady) {
486
+ await this.initCloudflareCallsSession().catch(() => {
487
+ this.cfCallsReady = false;
488
+ });
489
+ }
490
+ await this.flushWorkerSync();
491
+ } else {
492
+ await this.connectGuestToHost();
493
+ }
494
+ return this;
495
+ }
496
+
497
+ initPeerWithRetry(peerId, attemptsLeft) {
498
+ return new Promise((resolve) => {
499
+ let currentTryId = peerId;
500
+ const peer = new window.Peer(currentTryId, {
501
+ debug: 0,
502
+ pingInterval: 5000,
503
+ config: {
504
+ iceServers: this.iceServers,
505
+ },
506
+ });
507
+
508
+ let settled = false;
509
+ const finish = () => {
510
+ if (!settled) {
511
+ settled = true;
512
+ resolve();
513
+ }
514
+ };
515
+
516
+ const openTimeout = setTimeout(finish, 4500);
517
+
518
+ peer.on("open", (id) => {
519
+ clearTimeout(openTimeout);
520
+ this.peer = peer;
521
+ this.localParticipant.peerId = id || currentTryId;
522
+ this.bindPeerEvents(peer);
523
+ finish();
524
+ });
525
+
526
+ peer.on("error", (err) => {
527
+ if (!this.peer) {
528
+ clearTimeout(openTimeout);
529
+ try {
530
+ peer.destroy();
531
+ } catch {}
532
+ if (err?.type === "unavailable-id" && attemptsLeft > 1) {
533
+ const nextId =
534
+ attemptsLeft === 2 && this.isHost
535
+ ? `${peerId}-${Math.random().toString(36).slice(2, 6)}`
536
+ : peerId;
537
+ setTimeout(() => {
538
+ this.initPeerWithRetry(nextId, attemptsLeft - 1).then(finish);
539
+ }, 600);
540
+ return;
541
+ }
542
+ finish();
543
+ }
544
+ });
545
+ });
546
+ }
547
+
548
+ bindPeerEvents(peer) {
549
+ peer.on("connection", (conn) => {
550
+ this.handleIncomingDataConnection(conn);
551
+ });
552
+
553
+ peer.on("call", (call) => {
554
+ this.handleIncomingMediaCall(call);
555
+ });
556
+
557
+ peer.on("disconnected", () => {
558
+ if (!this.isDisconnected && !peer.destroyed) {
559
+ try {
560
+ peer.reconnect();
561
+ } catch {}
562
+ }
563
+ });
564
+
565
+ if (this.peerKeepAliveTimer) clearInterval(this.peerKeepAliveTimer);
566
+ this.peerKeepAliveTimer = setInterval(() => {
567
+ if (this.isDisconnected) return;
568
+ if (this.peer && this.peer.disconnected && !this.peer.destroyed) {
569
+ try {
570
+ this.peer.reconnect();
571
+ } catch {}
572
+ }
573
+ if (!this.isHost && (!this.hostConn || !this.hostConn.open) && this.peer?.open) {
574
+ this.tryOpenPeerDataChannelToHost();
575
+ }
576
+ }, 5000);
577
+ }
578
+
579
+ enqueueHttpSignal(targetId, packet) {
580
+ const msgId =
581
+ packet?._msgId || `m_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
582
+ const enriched = { ...packet, _msgId: msgId, _sentAt: packet?._sentAt || Date.now() };
583
+ this.processedSignalIds.add(msgId);
584
+ this.outgoingHttpSignals.push({
585
+ id: msgId,
586
+ targetId: targetId || "all",
587
+ packet: enriched,
588
+ });
589
+ this.flushWorkerSync();
590
+ return enriched;
591
+ }
592
+
593
+ startWorkerSyncLoop() {
594
+ if (this.syncTimer) clearInterval(this.syncTimer);
595
+ this.syncTimer = setInterval(() => {
596
+ if (!this.isDisconnected) {
597
+ this.flushWorkerSync();
598
+ }
599
+ }, 1300);
600
+ }
601
+
602
+ async flushWorkerSync() {
603
+ if (this.isDisconnected || this.syncInFlight) return;
604
+ this.syncInFlight = true;
605
+
606
+ const batch = [];
607
+ let batchBytes = 0;
608
+ while (this.outgoingHttpSignals.length > 0 && batch.length < 12) {
609
+ const next = this.outgoingHttpSignals[0];
610
+ const size = JSON.stringify(next).length;
611
+ if (batch.length > 0 && batchBytes + size > 48000) break;
612
+ batch.push(this.outgoingHttpSignals.shift());
613
+ batchBytes += size;
614
+ }
615
+
616
+ try {
617
+ const res = await this.apiPost("/api/room-sync", {
618
+ room_hash: this.roomHash,
619
+ identity: this.localParticipant.identity,
620
+ peer_id: this.localParticipant.peerId || "",
621
+ is_host: this.isHost,
622
+ joined_at: this.joinedAt,
623
+ outgoing: batch,
624
+ });
625
+
626
+ if (res?.host_peer_id && res.host_peer_id !== this.knownHostPeerId) {
627
+ this.knownHostPeerId = res.host_peer_id;
628
+ if (!this.isHost && (!this.hostConn || !this.hostConn.open)) {
629
+ this.tryOpenPeerDataChannelToHost();
630
+ }
631
+ }
632
+
633
+ if (Array.isArray(res?.messages)) {
634
+ for (const item of res.messages) {
635
+ if (!item?.id || this.processedSignalIds.has(item.id)) continue;
636
+ this.processedSignalIds.add(item.id);
637
+ const pkt = item.packet;
638
+ if (!pkt || typeof pkt !== "object") continue;
639
+
640
+ if (this.isHost) {
641
+ this.handleSignalingMessageAtHost(null, {
642
+ ...pkt,
643
+ senderId: pkt.senderId || item.senderId,
644
+ });
645
+ } else {
646
+ await this.handleSignalingMessageFromHost(pkt);
647
+ }
648
+ }
649
+ }
650
+ } catch {
651
+ if (batch.length > 0) {
652
+ this.outgoingHttpSignals.unshift(...batch);
653
+ }
654
+ } finally {
655
+ this.syncInFlight = false;
656
+ if (this.outgoingHttpSignals.length > 0 && !this.isDisconnected) {
657
+ setTimeout(() => this.flushWorkerSync(), 120);
658
+ }
659
+ }
660
+ }
661
+
662
+ tryOpenPeerDataChannelToHost() {
663
+ if (this.isDisconnected || this.isHost || !this.peer?.open) return;
664
+ const targetPeerId = this.knownHostPeerId || this.getHostPeerId();
665
+ if (!targetPeerId) return;
666
+
667
+ try {
668
+ const conn = this.peer.connect(targetPeerId, {
669
+ reliable: true,
670
+ serialization: "json",
671
+ });
672
+ if (!conn) return;
673
+
674
+ conn.on("open", () => {
675
+ this.hostConn = conn;
676
+ const helloPkt = {
677
+ type: "hello_guest",
678
+ identity: this.localParticipant.identity,
679
+ name: this.localParticipant.name,
680
+ peerId: this.localParticipant.peerId,
681
+ };
682
+ conn.send(helloPkt);
683
+ });
684
+
685
+ conn.on("data", (msg) => {
686
+ if (msg?._msgId) {
687
+ if (this.processedSignalIds.has(msg._msgId)) return;
688
+ this.processedSignalIds.add(msg._msgId);
689
+ }
690
+ this.handleSignalingMessageFromHost(msg);
691
+ });
692
+
693
+ conn.on("close", () => {
694
+ if (this.hostConn === conn) {
695
+ this.hostConn = null;
696
+ }
697
+ });
698
+ } catch {}
699
+ }
700
+
701
+ async connectGuestToHost() {
702
+ const helloPkt = {
703
+ type: "hello_guest",
704
+ identity: this.localParticipant.identity,
705
+ name: this.localParticipant.name,
706
+ peerId: this.localParticipant.peerId,
707
+ };
708
+
709
+ this.enqueueHttpSignal("host", helloPkt);
710
+ await this.flushWorkerSync();
711
+
712
+ this.tryOpenPeerDataChannelToHost();
713
+
714
+ const helloInterval = setInterval(() => {
715
+ if (this.isDisconnected || this.localParticipant.permissions?.canSubscribe) {
716
+ clearInterval(helloInterval);
717
+ return;
718
+ }
719
+ this.enqueueHttpSignal("host", {
720
+ ...helloPkt,
721
+ peerId: this.localParticipant.peerId,
722
+ });
723
+ if (this.hostConn?.open) {
724
+ try {
725
+ this.hostConn.send({
726
+ ...helloPkt,
727
+ peerId: this.localParticipant.peerId,
728
+ });
729
+ } catch {}
730
+ }
731
+ }, 3200);
732
+ }
733
+
734
+ handleIncomingDataConnection(conn) {
735
+ conn.on("data", (msg) => {
736
+ if (!msg || typeof msg !== "object") return;
737
+ if (msg._msgId) {
738
+ if (this.processedSignalIds.has(msg._msgId)) return;
739
+ this.processedSignalIds.add(msg._msgId);
740
+ }
741
+
742
+ if (this.isHost) {
743
+ this.handleSignalingMessageAtHost(conn, msg);
744
+ } else {
745
+ this.handleSignalingMessageFromHost(msg);
746
+ }
747
+ });
748
+
749
+ conn.on("close", () => {
750
+ if (this.isHost && conn._guestIdentity) {
751
+ this.guestConns.delete(conn._guestIdentity);
752
+ }
753
+ });
754
+ }
755
+
756
+ handleSignalingMessageAtHost(conn, msg) {
757
+ if (msg.type === "hello_guest") {
758
+ const guestId = msg.identity;
759
+ if (!guestId) return;
760
+ if (conn) {
761
+ conn._guestIdentity = guestId;
762
+ this.guestConns.set(guestId, conn);
763
+ }
764
+
765
+ const existing = this.remoteParticipants.get(guestId);
766
+ if (existing) {
767
+ if (msg.peerId) existing.peerId = msg.peerId;
768
+ if (msg.name) existing.name = msg.name;
769
+ if (existing.permissions?.canSubscribe) {
770
+ const roster = this.buildApprovedRosterList();
771
+ const approvePkt = this.enqueueHttpSignal(guestId, {
772
+ type: "decision",
773
+ action: "approve",
774
+ roomTitle: this.roomTitle,
775
+ roster,
776
+ });
777
+ if (conn?.open) conn.send(approvePkt);
778
+ }
779
+ return;
780
+ }
781
+
782
+ const guestParticipant = new HybridParticipant({
783
+ identity: guestId,
784
+ name: msg.name || "Invitado",
785
+ metadata: JSON.stringify({ role: "guest", status: "waiting" }),
786
+ permissions: {
787
+ canPublish: false,
788
+ canSubscribe: false,
789
+ canPublishData: false,
790
+ },
791
+ isLocal: false,
792
+ room: this,
793
+ });
794
+ guestParticipant.peerId = msg.peerId || "";
795
+
796
+ this.remoteParticipants.set(guestId, guestParticipant);
797
+ this.emit(RoomEvents.ParticipantConnected, guestParticipant);
798
+ return;
799
+ }
800
+
801
+ const senderId = conn?._guestIdentity || msg.identity || msg.senderId;
802
+ const senderP = this.remoteParticipants.get(senderId);
803
+ if (!senderP) return;
804
+
805
+ if (msg.type === "media_state") {
806
+ this.applyRemoteMediaState(senderP, msg);
807
+ this.broadcastRoster();
808
+ return;
809
+ }
810
+
811
+ if (msg.type === "rtc_signal") {
812
+ const targetId = msg.targetId;
813
+ if (!targetId || targetId === this.localParticipant.identity) {
814
+ this.handleRtcSignal(senderId, senderP.name || msg.senderName, msg);
815
+ } else {
816
+ const targetConn = this.guestConns.get(targetId);
817
+ const targetP = this.remoteParticipants.get(targetId);
818
+ const forwarded = {
819
+ ...msg,
820
+ senderId,
821
+ senderName: senderP.name || msg.senderName,
822
+ };
823
+ this.enqueueHttpSignal(targetId, forwarded);
824
+ if (targetConn?.open && targetP?.permissions?.canSubscribe) {
825
+ targetConn.send(forwarded);
826
+ }
827
+ }
828
+ return;
829
+ }
830
+
831
+ if (msg.type === "app_data") {
832
+ this.deliverAppData(msg.senderId, msg.senderName, msg);
833
+ this.enqueueHttpSignal("all", msg);
834
+ for (const [id, gConn] of this.guestConns.entries()) {
835
+ if (id !== senderId && gConn.open) {
836
+ const gp = this.remoteParticipants.get(id);
837
+ if (gp?.permissions?.canSubscribe) {
838
+ gConn.send(msg);
839
+ }
840
+ }
841
+ }
842
+ }
843
+ }
844
+
845
+ async handleSignalingMessageFromHost(msg) {
846
+ if (!msg || typeof msg !== "object") return;
847
+
848
+ if (msg.type === "decision") {
849
+ if (msg.action === "reject") {
850
+ this.disconnect();
851
+ this.emit(RoomEvents.Disconnected);
852
+ return;
853
+ }
854
+
855
+ if (msg.action === "approve") {
856
+ if (msg.roomTitle) {
857
+ this.roomTitle = msg.roomTitle;
858
+ }
859
+ this.localParticipant.metadata = JSON.stringify({
860
+ role: "guest",
861
+ status: "approved",
862
+ room_title: this.roomTitle,
863
+ });
864
+ this.localParticipant.permissions = {
865
+ canPublish: true,
866
+ canSubscribe: true,
867
+ canPublishData: true,
868
+ };
869
+
870
+ if (this.cfCallsReady) {
871
+ await this.initCloudflareCallsSession().catch(() => {
872
+ this.cfCallsReady = false;
873
+ });
874
+ }
875
+
876
+ if (Array.isArray(msg.roster)) {
877
+ await this.syncRosterFromHost(msg.roster);
878
+ }
879
+
880
+ this.emit(RoomEvents.ParticipantMetadataChanged, this.localParticipant);
881
+ this.emit(RoomEvents.ParticipantPermissionsChanged, this.localParticipant);
882
+ }
883
+ return;
884
+ }
885
+
886
+ if (msg.type === "roster") {
887
+ if (Array.isArray(msg.roster)) {
888
+ await this.syncRosterFromHost(msg.roster);
889
+ }
890
+ return;
891
+ }
892
+
893
+ if (msg.type === "rtc_signal") {
894
+ this.handleRtcSignal(msg.senderId, msg.senderName, msg);
895
+ return;
896
+ }
897
+
898
+ if (msg.type === "app_data") {
899
+ this.deliverAppData(msg.senderId, msg.senderName, msg);
900
+ return;
901
+ }
902
+
903
+ if (msg.type === "kicked" || msg.type === "room_ended") {
904
+ if (msg._sentAt && msg._sentAt < this.joinedAt - 3000) return;
905
+ this.disconnect();
906
+ this.emit(RoomEvents.Disconnected);
907
+ }
908
+ }
909
+
910
+ async deliverAppData(senderId, senderName, msgPacket) {
911
+ if (!msgPacket || senderId === this.localParticipant.identity) return;
912
+ let plaintext = "";
913
+ if (msgPacket.cipher) {
914
+ try {
915
+ plaintext = await decryptDataPacket(this.roomHash, msgPacket.cipher);
916
+ } catch {
917
+ return;
918
+ }
919
+ } else if (typeof msgPacket.text === "string") {
920
+ plaintext = msgPacket.text;
921
+ }
922
+ if (!plaintext) return;
923
+
924
+ const participant = this.remoteParticipants.get(senderId) || {
925
+ identity: senderId,
926
+ name: senderName || "Participante",
927
+ };
928
+ const payload = new TextEncoder().encode(plaintext);
929
+ this.emit(RoomEvents.DataReceived, payload, plaintext, participant);
930
+ }
931
+
932
+ async admitGuest(identity) {
933
+ if (!this.isHost) return;
934
+ try {
935
+ await this.apiPost("/api/decide", {
936
+ room_hash: this.roomHash,
937
+ host_secret: this.hostSecret,
938
+ identity,
939
+ action: "approve",
940
+ });
941
+ } catch {}
942
+
943
+ const conn = this.guestConns.get(identity);
944
+ const guestP = this.remoteParticipants.get(identity);
945
+ if (!guestP) return;
946
+
947
+ guestP.metadata = JSON.stringify({
948
+ role: "guest",
949
+ status: "approved",
950
+ room_title: this.roomTitle,
951
+ });
952
+ guestP.permissions = {
953
+ canPublish: true,
954
+ canSubscribe: true,
955
+ canPublishData: true,
956
+ };
957
+
958
+ const roster = this.buildApprovedRosterList();
959
+ const approvePkt = this.enqueueHttpSignal(identity, {
960
+ type: "decision",
961
+ action: "approve",
962
+ roomTitle: this.roomTitle,
963
+ roster,
964
+ });
965
+ if (conn?.open) {
966
+ conn.send(approvePkt);
967
+ }
968
+
969
+ this.emit(RoomEvents.ParticipantMetadataChanged, guestP);
970
+ this.broadcastRoster();
971
+
972
+ if (!this.cfCallsReady) {
973
+ this.startDirectUserMediaToPeer(guestP.identity);
974
+ if (guestP.peerId) {
975
+ this.callPeerWithLocalTracks(guestP.peerId, guestP.identity);
976
+ }
977
+ } else if (this.getLocalScreenMediaStream()) {
978
+ this.startDirectScreenShareToPeer(guestP.identity);
979
+ if (guestP.peerId) {
980
+ this.callPeerScreenShare(guestP.peerId, guestP.identity);
981
+ }
982
+ }
983
+ }
984
+
985
+ async rejectGuest(identity) {
986
+ if (!this.isHost) return;
987
+ try {
988
+ await this.apiPost("/api/decide", {
989
+ room_hash: this.roomHash,
990
+ host_secret: this.hostSecret,
991
+ identity,
992
+ action: "reject",
993
+ });
994
+ } catch {}
995
+
996
+ const conn = this.guestConns.get(identity);
997
+ const guestP = this.remoteParticipants.get(identity);
998
+ if (!guestP) return;
999
+
1000
+ const rejectPkt = this.enqueueHttpSignal(identity, {
1001
+ type: "decision",
1002
+ action: "reject",
1003
+ });
1004
+ if (conn?.open) {
1005
+ conn.send(rejectPkt);
1006
+ setTimeout(() => conn.close(), 200);
1007
+ }
1008
+ this.guestConns.delete(identity);
1009
+ this.remoteParticipants.delete(identity);
1010
+ this.emit(RoomEvents.ParticipantDisconnected, guestP);
1011
+ }
1012
+
1013
+ buildParticipantSummary(p) {
1014
+ const micPub = p.getTrackPublication(TrackSources.Microphone);
1015
+ const camPub = p.getTrackPublication(TrackSources.Camera);
1016
+ const scrPub = p.getTrackPublication(TrackSources.ScreenShare);
1017
+ return {
1018
+ identity: p.identity,
1019
+ name: p.name,
1020
+ peerId: p.peerId,
1021
+ metadata: p.metadata,
1022
+ micMuted: !micPub || micPub.isMuted,
1023
+ camMuted: !camPub || camPub.isMuted,
1024
+ hasScreen: Boolean(scrPub && !scrPub.isMuted),
1025
+ cfSessionId: p.cfSessionId || "",
1026
+ cfPublishedTracks: p.cfPublishedTracks || [],
1027
+ };
1028
+ }
1029
+
1030
+ buildApprovedRosterList() {
1031
+ const list = [this.buildParticipantSummary(this.localParticipant)];
1032
+ for (const p of this.remoteParticipants.values()) {
1033
+ if (p.permissions?.canSubscribe) {
1034
+ list.push(this.buildParticipantSummary(p));
1035
+ }
1036
+ }
1037
+ return list;
1038
+ }
1039
+
1040
+ broadcastRoster() {
1041
+ if (!this.isHost) return;
1042
+ const roster = this.buildApprovedRosterList();
1043
+ const rosterPkt = this.enqueueHttpSignal("all", { type: "roster", roster });
1044
+ for (const [id, conn] of this.guestConns.entries()) {
1045
+ const gp = this.remoteParticipants.get(id);
1046
+ if (conn.open && gp?.permissions?.canSubscribe) {
1047
+ conn.send(rosterPkt);
1048
+ }
1049
+ }
1050
+ if (this.cfCallsReady) {
1051
+ for (const p of this.remoteParticipants.values()) {
1052
+ if (p.permissions?.canSubscribe && p.cfSessionId) {
1053
+ this.pullRemoteTracksFromCloudflare(p);
1054
+ }
1055
+ }
1056
+ }
1057
+ }
1058
+
1059
+ async syncRosterFromHost(roster) {
1060
+ const incomingIds = new Set();
1061
+ let changed = false;
1062
+
1063
+ for (const item of roster) {
1064
+ if (item.identity === this.localParticipant.identity) continue;
1065
+ incomingIds.add(item.identity);
1066
+
1067
+ let p = this.remoteParticipants.get(item.identity);
1068
+ const isNew = !p;
1069
+ if (!p) {
1070
+ p = new HybridParticipant({
1071
+ identity: item.identity,
1072
+ name: item.name,
1073
+ metadata: item.metadata || JSON.stringify({ role: "guest", status: "approved" }),
1074
+ permissions: {
1075
+ canPublish: true,
1076
+ canSubscribe: true,
1077
+ canPublishData: true,
1078
+ },
1079
+ isLocal: false,
1080
+ room: this,
1081
+ });
1082
+ this.remoteParticipants.set(item.identity, p);
1083
+ changed = true;
1084
+ }
1085
+
1086
+ if (item.name && p.name !== item.name) {
1087
+ p.name = item.name;
1088
+ changed = true;
1089
+ }
1090
+ if (item.peerId && p.peerId !== item.peerId) {
1091
+ p.peerId = item.peerId;
1092
+ }
1093
+ if (item.metadata && p.metadata !== item.metadata) {
1094
+ p.metadata = item.metadata;
1095
+ changed = true;
1096
+ }
1097
+ const mediaChanged = this.applyRemoteMediaState(p, item, false);
1098
+ if (mediaChanged) changed = true;
1099
+
1100
+ if (isNew && !this.cfCallsReady) {
1101
+ this.startDirectUserMediaToPeer(p.identity);
1102
+ if (p.peerId) {
1103
+ this.callPeerWithLocalTracks(p.peerId, p.identity);
1104
+ }
1105
+ }
1106
+
1107
+ if (isNew && this.getLocalScreenMediaStream()) {
1108
+ this.startDirectScreenShareToPeer(p.identity);
1109
+ }
1110
+
1111
+ if (this.cfCallsReady && p.cfSessionId) {
1112
+ await this.pullRemoteTracksFromCloudflare(p);
1113
+ }
1114
+ }
1115
+
1116
+ for (const [id, p] of this.remoteParticipants.entries()) {
1117
+ if (!incomingIds.has(id)) {
1118
+ this.cleanupRemoteParticipantTracks(p);
1119
+ this.remoteParticipants.delete(id);
1120
+ changed = true;
1121
+ }
1122
+ }
1123
+
1124
+ if (changed) {
1125
+ this.emit(RoomEvents.ParticipantConnected);
1126
+ }
1127
+ }
1128
+
1129
+ applyRemoteMediaState(participant, state, shouldEmit = true) {
1130
+ let changed = false;
1131
+ if (typeof state.cfSessionId === "string" && participant.cfSessionId !== state.cfSessionId) {
1132
+ participant.cfSessionId = state.cfSessionId;
1133
+ changed = true;
1134
+ }
1135
+ if (Array.isArray(state.cfPublishedTracks)) {
1136
+ participant.cfPublishedTracks = state.cfPublishedTracks;
1137
+ }
1138
+
1139
+ const micPub = participant.getTrackPublication(TrackSources.Microphone);
1140
+ if (micPub && typeof state.micMuted === "boolean" && micPub.isMuted !== state.micMuted) {
1141
+ micPub.isMuted = state.micMuted;
1142
+ changed = true;
1143
+ }
1144
+
1145
+ const camPub = participant.getTrackPublication(TrackSources.Camera);
1146
+ if (camPub && typeof state.camMuted === "boolean" && camPub.isMuted !== state.camMuted) {
1147
+ camPub.isMuted = state.camMuted;
1148
+ changed = true;
1149
+ }
1150
+
1151
+ if (state.hasScreen === false) {
1152
+ const scrPub = participant.getTrackPublication(TrackSources.ScreenShare);
1153
+ if (scrPub) {
1154
+ participant.setPublication(TrackSources.ScreenShare, null);
1155
+ this.emit(RoomEvents.TrackUnpublished, scrPub, participant);
1156
+ if (scrPub.track) {
1157
+ this.emit(RoomEvents.TrackUnsubscribed, scrPub.track, scrPub, participant);
1158
+ }
1159
+ changed = true;
1160
+ }
1161
+ }
1162
+
1163
+ if (this.isHost && this.cfCallsReady && participant.cfSessionId) {
1164
+ this.pullRemoteTracksFromCloudflare(participant);
1165
+ }
1166
+ if (shouldEmit && changed) {
1167
+ this.emit(RoomEvents.TrackMuted);
1168
+ }
1169
+ return changed;
1170
+ }
1171
+
1172
+ sendLocalMediaState() {
1173
+ const summary = this.buildParticipantSummary(this.localParticipant);
1174
+ if (this.isHost) {
1175
+ this.broadcastRoster();
1176
+ } else {
1177
+ const pkt = this.enqueueHttpSignal("host", {
1178
+ type: "media_state",
1179
+ ...summary,
1180
+ });
1181
+ if (this.hostConn?.open) {
1182
+ this.hostConn.send(pkt);
1183
+ }
1184
+ }
1185
+ }
1186
+
1187
+ async enableCameraAndMicrophone() {
1188
+ const stream = await navigator.mediaDevices.getUserMedia({
1189
+ video: { width: { ideal: 1280 }, height: { ideal: 720 } },
1190
+ audio: {
1191
+ echoCancellation: true,
1192
+ noiseSuppression: true,
1193
+ autoGainControl: true,
1194
+ },
1195
+ });
1196
+
1197
+ const audioTrack = stream.getAudioTracks()[0];
1198
+ const videoTrack = stream.getVideoTracks()[0];
1199
+
1200
+ if (audioTrack) {
1201
+ this.setLocalPublicationTrack(TrackSources.Microphone, audioTrack, TrackKinds.Audio);
1202
+ }
1203
+ if (videoTrack) {
1204
+ this.setLocalPublicationTrack(TrackSources.Camera, videoTrack, TrackKinds.Video);
1205
+ }
1206
+
1207
+ await this.syncOutgoingMedia();
1208
+ }
1209
+
1210
+ async setMicrophoneEnabled(enabled) {
1211
+ let micPub = this.localParticipant.getTrackPublication(TrackSources.Microphone);
1212
+ if (enabled) {
1213
+ if (!micPub?.track?.mediaStreamTrack || micPub.track.mediaStreamTrack.readyState === "ended") {
1214
+ const stream = await navigator.mediaDevices.getUserMedia({
1215
+ audio: {
1216
+ echoCancellation: true,
1217
+ noiseSuppression: true,
1218
+ autoGainControl: true,
1219
+ },
1220
+ });
1221
+ const audioTrack = stream.getAudioTracks()[0];
1222
+ this.setLocalPublicationTrack(TrackSources.Microphone, audioTrack, TrackKinds.Audio);
1223
+ } else {
1224
+ micPub.track.mediaStreamTrack.enabled = true;
1225
+ micPub.isMuted = false;
1226
+ }
1227
+ } else if (micPub) {
1228
+ if (micPub.track?.mediaStreamTrack) {
1229
+ micPub.track.mediaStreamTrack.enabled = false;
1230
+ }
1231
+ micPub.isMuted = true;
1232
+ }
1233
+
1234
+ await this.syncOutgoingMedia();
1235
+ this.emit(enabled ? RoomEvents.TrackUnmuted : RoomEvents.TrackMuted, micPub);
1236
+ }
1237
+
1238
+ async setCameraEnabled(enabled) {
1239
+ let camPub = this.localParticipant.getTrackPublication(TrackSources.Camera);
1240
+ if (enabled) {
1241
+ if (!camPub?.track?.mediaStreamTrack || camPub.track.mediaStreamTrack.readyState === "ended") {
1242
+ const stream = await navigator.mediaDevices.getUserMedia({
1243
+ video: { width: { ideal: 1280 }, height: { ideal: 720 } },
1244
+ });
1245
+ const videoTrack = stream.getVideoTracks()[0];
1246
+ this.setLocalPublicationTrack(TrackSources.Camera, videoTrack, TrackKinds.Video);
1247
+ } else {
1248
+ camPub.track.mediaStreamTrack.enabled = true;
1249
+ camPub.isMuted = false;
1250
+ }
1251
+ } else if (camPub) {
1252
+ if (camPub.track?.mediaStreamTrack) {
1253
+ camPub.track.mediaStreamTrack.stop();
1254
+ }
1255
+ camPub.isMuted = true;
1256
+ }
1257
+
1258
+ await this.syncOutgoingMedia();
1259
+ this.emit(enabled ? RoomEvents.TrackUnmuted : RoomEvents.TrackMuted, camPub);
1260
+ }
1261
+
1262
+ async setScreenShareEnabled(enabled, stopTracks = true) {
1263
+ const existingPub = this.localParticipant.getTrackPublication(TrackSources.ScreenShare);
1264
+ if (!enabled) {
1265
+ if (existingPub) {
1266
+ if (stopTracks && existingPub.track) {
1267
+ existingPub.track.stop();
1268
+ }
1269
+ this.localParticipant.setPublication(TrackSources.ScreenShare, null);
1270
+ this.closeOutgoingScreenCalls();
1271
+ if (this.cfCallsReady) {
1272
+ const prevTr = this.cfTransceivers.get(this.currentScreenTrackName);
1273
+ if (prevTr?.sender) {
1274
+ prevTr.sender.replaceTrack(null).catch(() => {});
1275
+ }
1276
+ this.localParticipant.cfPublishedTracks = (
1277
+ this.localParticipant.cfPublishedTracks || []
1278
+ ).filter((t) => t !== "screen" && !t.startsWith("screen_"));
1279
+ }
1280
+ this.sendLocalMediaState();
1281
+ this.emit(RoomEvents.LocalTrackUnpublished, existingPub);
1282
+ }
1283
+ return null;
1284
+ }
1285
+
1286
+ const stream = await navigator.mediaDevices.getDisplayMedia({
1287
+ video: { frameRate: { ideal: 30, max: 30 } },
1288
+ audio: false,
1289
+ });
1290
+ const screenVideoTrack = stream.getVideoTracks()[0];
1291
+ if (!screenVideoTrack) return null;
1292
+
1293
+ this.currentScreenTrackName = `screen_${Date.now().toString(36).slice(-4)}`;
1294
+
1295
+ const pub = this.setLocalPublicationTrack(
1296
+ TrackSources.ScreenShare,
1297
+ screenVideoTrack,
1298
+ TrackKinds.Video
1299
+ );
1300
+
1301
+ await this.syncOutgoingMedia(true);
1302
+ this.emit(RoomEvents.LocalTrackPublished, pub);
1303
+ return pub;
1304
+ }
1305
+
1306
+ async startScreenShare() {
1307
+ return this.setScreenShareEnabled(true);
1308
+ }
1309
+
1310
+ async stopScreenShare() {
1311
+ return this.setScreenShareEnabled(false);
1312
+ }
1313
+
1314
+ setLocalPublicationTrack(source, mediaStreamTrack, kind) {
1315
+ const hTrack = new HybridTrack(mediaStreamTrack, kind, source);
1316
+ const pub = new HybridPublication({
1317
+ trackSid: `${source}_${this.localParticipant.identity}`,
1318
+ source,
1319
+ track: hTrack,
1320
+ isMuted: false,
1321
+ });
1322
+ this.localParticipant.setPublication(source, pub);
1323
+ return pub;
1324
+ }
1325
+
1326
+ async syncOutgoingMedia(includeScreen = false) {
1327
+ if (includeScreen) {
1328
+ this.callAllPeersScreenShare();
1329
+ }
1330
+
1331
+ if (this.cfCallsReady) {
1332
+ try {
1333
+ await this.pushLocalTracksToCloudflare();
1334
+ this.sendLocalMediaState();
1335
+ if (includeScreen) {
1336
+ setTimeout(() => {
1337
+ if (!this.isDisconnected) this.sendLocalMediaState();
1338
+ }, 650);
1339
+ }
1340
+ return;
1341
+ } catch {
1342
+ this.cfCallsReady = false;
1343
+ }
1344
+ }
1345
+
1346
+ if (!includeScreen) {
1347
+ for (const p of this.remoteParticipants.values()) {
1348
+ if (p.permissions?.canSubscribe) {
1349
+ this.startDirectUserMediaToPeer(p.identity);
1350
+ if (p.peerId) {
1351
+ this.callPeerWithLocalTracks(p.peerId, p.identity, false);
1352
+ }
1353
+ }
1354
+ }
1355
+ }
1356
+ this.sendLocalMediaState();
1357
+ }
1358
+
1359
+ sendSignalingToParticipant(targetIdentity, packet) {
1360
+ const payload = {
1361
+ ...packet,
1362
+ targetId: targetIdentity,
1363
+ senderId: this.localParticipant.identity,
1364
+ senderName: this.localParticipant.name,
1365
+ };
1366
+
1367
+ const enriched = this.enqueueHttpSignal(targetIdentity, payload);
1368
+ if (this.isHost) {
1369
+ const conn = this.guestConns.get(targetIdentity);
1370
+ if (conn?.open) {
1371
+ conn.send(enriched);
1372
+ }
1373
+ } else if (this.hostConn?.open) {
1374
+ this.hostConn.send(enriched);
1375
+ }
1376
+ }
1377
+
1378
+ async startDirectUserMediaToPeer(remoteIdentity) {
1379
+ if (!remoteIdentity || this.cfCallsReady) return;
1380
+ const userStream = this.getLocalUserMediaStream();
1381
+ if (!userStream) return;
1382
+
1383
+ const pcKey = `${remoteIdentity}:user`;
1384
+ const existingPc = this.directScreenPcs.get(pcKey);
1385
+ const isAlive = Boolean(
1386
+ existingPc &&
1387
+ existingPc.connectionState !== "closed" &&
1388
+ existingPc.connectionState !== "failed" &&
1389
+ existingPc.iceConnectionState !== "closed" &&
1390
+ existingPc.iceConnectionState !== "failed"
1391
+ );
1392
+
1393
+ if (isAlive && typeof existingPc.getSenders === "function") {
1394
+ const senders = existingPc.getSenders();
1395
+ const newAudio = userStream.getAudioTracks()[0] || null;
1396
+ const newVideo = userStream.getVideoTracks()[0] || null;
1397
+ const audioSender = senders.find((s) => s.track?.kind === "audio");
1398
+ const videoSender = senders.find((s) => s.track?.kind === "video");
1399
+ const canReplaceAudio = !newAudio || Boolean(audioSender);
1400
+ const canReplaceVideo = !newVideo || Boolean(videoSender);
1401
+ if (canReplaceAudio && canReplaceVideo) {
1402
+ if (audioSender && newAudio) audioSender.replaceTrack(newAudio).catch(() => {});
1403
+ if (videoSender) videoSender.replaceTrack(newVideo).catch(() => {});
1404
+ return;
1405
+ }
1406
+ }
1407
+
1408
+ if (existingPc) {
1409
+ try {
1410
+ existingPc.close();
1411
+ } catch {}
1412
+ this.directScreenPcs.delete(pcKey);
1413
+ }
1414
+ this.pendingScreenIce.delete(`${pcKey}:caller`);
1415
+
1416
+ const pc = new RTCPeerConnection({
1417
+ iceServers: this.iceServers,
1418
+ });
1419
+ this.directScreenPcs.set(pcKey, pc);
1420
+
1421
+ pc.onicecandidate = (ev) => {
1422
+ if (ev.candidate && this.directScreenPcs.get(pcKey) === pc) {
1423
+ this.sendSignalingToParticipant(remoteIdentity, {
1424
+ type: "rtc_signal",
1425
+ streamKind: "user",
1426
+ signalType: "candidate",
1427
+ role: "caller",
1428
+ candidate: ev.candidate.toJSON ? ev.candidate.toJSON() : ev.candidate,
1429
+ });
1430
+ }
1431
+ };
1432
+
1433
+ for (const track of userStream.getTracks()) {
1434
+ track.enabled = true;
1435
+ pc.addTrack(track, userStream);
1436
+ }
1437
+
1438
+ try {
1439
+ const offer = await pc.createOffer();
1440
+ await pc.setLocalDescription(offer);
1441
+ this.sendSignalingToParticipant(remoteIdentity, {
1442
+ type: "rtc_signal",
1443
+ streamKind: "user",
1444
+ signalType: "offer",
1445
+ sdp: {
1446
+ type: offer.type,
1447
+ sdp: offer.sdp,
1448
+ },
1449
+ });
1450
+ } catch {}
1451
+ }
1452
+
1453
+ async startDirectScreenShareToPeer(remoteIdentity) {
1454
+ if (!remoteIdentity) return;
1455
+ const screenStream = this.getLocalScreenMediaStream();
1456
+ const screenTrack = screenStream?.getVideoTracks()?.[0];
1457
+ if (!screenStream || !screenTrack) return;
1458
+
1459
+ screenTrack.enabled = true;
1460
+ const pcKey = `${remoteIdentity}:screen`;
1461
+ const prevPc = this.directScreenPcs.get(pcKey);
1462
+ if (prevPc) {
1463
+ try {
1464
+ prevPc.close();
1465
+ } catch {}
1466
+ this.directScreenPcs.delete(pcKey);
1467
+ }
1468
+ this.pendingScreenIce.delete(`${pcKey}:caller`);
1469
+
1470
+ const pc = new RTCPeerConnection({
1471
+ iceServers: this.iceServers,
1472
+ });
1473
+ this.directScreenPcs.set(pcKey, pc);
1474
+
1475
+ pc.onicecandidate = (ev) => {
1476
+ if (ev.candidate && this.directScreenPcs.get(pcKey) === pc) {
1477
+ this.sendSignalingToParticipant(remoteIdentity, {
1478
+ type: "rtc_signal",
1479
+ streamKind: "screen",
1480
+ signalType: "candidate",
1481
+ role: "caller",
1482
+ candidate: ev.candidate.toJSON ? ev.candidate.toJSON() : ev.candidate,
1483
+ });
1484
+ }
1485
+ };
1486
+
1487
+ pc.addTrack(screenTrack, screenStream);
1488
+
1489
+ try {
1490
+ const offer = await pc.createOffer();
1491
+ await pc.setLocalDescription(offer);
1492
+ this.sendSignalingToParticipant(remoteIdentity, {
1493
+ type: "rtc_signal",
1494
+ streamKind: "screen",
1495
+ signalType: "offer",
1496
+ sdp: {
1497
+ type: offer.type,
1498
+ sdp: offer.sdp,
1499
+ },
1500
+ });
1501
+ } catch {}
1502
+ }
1503
+
1504
+ handleRtcSignal(senderId, senderName, msg) {
1505
+ if (!senderId || !msg) return Promise.resolve();
1506
+ const streamKind = msg.streamKind === "user" ? "user" : "screen";
1507
+ const pcKey = `${senderId}:${streamKind}`;
1508
+ const prevChain = this.rtcSignalQueues.get(pcKey) || Promise.resolve();
1509
+ const nextChain = prevChain
1510
+ .then(() => this._processRtcSignalSequential(senderId, senderName, msg, streamKind, pcKey))
1511
+ .catch(() => {});
1512
+ this.rtcSignalQueues.set(pcKey, nextChain);
1513
+ return nextChain;
1514
+ }
1515
+
1516
+ async _processRtcSignalSequential(senderId, senderName, msg, streamKind, pcKey) {
1517
+ let participant = this.remoteParticipants.get(senderId);
1518
+ if (!participant) {
1519
+ participant = new HybridParticipant({
1520
+ identity: senderId,
1521
+ name: senderName || "Participante",
1522
+ metadata: JSON.stringify({ role: "guest", status: "approved" }),
1523
+ permissions: { canPublish: true, canSubscribe: true, canPublishData: true },
1524
+ isLocal: false,
1525
+ room: this,
1526
+ });
1527
+ this.remoteParticipants.set(senderId, participant);
1528
+ }
1529
+
1530
+ const attachIncomingTrack = (track) => {
1531
+ if (!track) return;
1532
+ if (streamKind === "screen") {
1533
+ if (track.kind === "video") {
1534
+ this.attachRemoteTrackToParticipant(
1535
+ participant,
1536
+ track,
1537
+ TrackKinds.Video,
1538
+ TrackSources.ScreenShare
1539
+ );
1540
+ }
1541
+ } else if (track.kind === "audio") {
1542
+ this.attachRemoteTrackToParticipant(
1543
+ participant,
1544
+ track,
1545
+ TrackKinds.Audio,
1546
+ TrackSources.Microphone
1547
+ );
1548
+ } else if (track.kind === "video") {
1549
+ this.attachRemoteTrackToParticipant(
1550
+ participant,
1551
+ track,
1552
+ TrackKinds.Video,
1553
+ TrackSources.Camera
1554
+ );
1555
+ }
1556
+ };
1557
+
1558
+ if (msg.signalType === "offer" && msg.sdp) {
1559
+ const prevPc = this.incomingScreenPcs.get(pcKey);
1560
+ if (prevPc) {
1561
+ const prevRemoteSdp =
1562
+ prevPc.currentRemoteDescription?.sdp || prevPc.remoteDescription?.sdp || "";
1563
+ if (
1564
+ prevRemoteSdp &&
1565
+ prevRemoteSdp === msg.sdp.sdp &&
1566
+ prevPc.connectionState !== "closed" &&
1567
+ prevPc.connectionState !== "failed"
1568
+ ) {
1569
+ return;
1570
+ }
1571
+ try {
1572
+ prevPc.close();
1573
+ } catch {}
1574
+ }
1575
+
1576
+ const pc = new RTCPeerConnection({
1577
+ iceServers: this.iceServers,
1578
+ });
1579
+ this.incomingScreenPcs.set(pcKey, pc);
1580
+
1581
+ pc.ontrack = (ev) => {
1582
+ if (ev.track) {
1583
+ attachIncomingTrack(ev.track);
1584
+ } else if (ev.streams?.[0]) {
1585
+ for (const t of ev.streams[0].getTracks()) {
1586
+ attachIncomingTrack(t);
1587
+ }
1588
+ }
1589
+ };
1590
+
1591
+ pc.onicecandidate = (ev) => {
1592
+ if (ev.candidate && this.incomingScreenPcs.get(pcKey) === pc) {
1593
+ this.sendSignalingToParticipant(senderId, {
1594
+ type: "rtc_signal",
1595
+ streamKind,
1596
+ signalType: "candidate",
1597
+ role: "callee",
1598
+ candidate: ev.candidate.toJSON ? ev.candidate.toJSON() : ev.candidate,
1599
+ });
1600
+ }
1601
+ };
1602
+
1603
+ try {
1604
+ await pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
1605
+
1606
+ for (const tr of pc.getTransceivers()) {
1607
+ if (tr.direction === "inactive" || tr.direction === "sendonly") {
1608
+ try {
1609
+ tr.direction = "recvonly";
1610
+ } catch {}
1611
+ }
1612
+ if (tr.receiver?.track) {
1613
+ attachIncomingTrack(tr.receiver.track);
1614
+ }
1615
+ }
1616
+
1617
+ const queuedKey = `${pcKey}:callee`;
1618
+ const queued = this.pendingScreenIce.get(queuedKey) || [];
1619
+ this.pendingScreenIce.delete(queuedKey);
1620
+ for (const cand of queued) {
1621
+ await pc.addIceCandidate(new RTCIceCandidate(cand)).catch(() => {});
1622
+ }
1623
+
1624
+ const answer = await pc.createAnswer();
1625
+ await pc.setLocalDescription(answer);
1626
+ this.sendSignalingToParticipant(senderId, {
1627
+ type: "rtc_signal",
1628
+ streamKind,
1629
+ signalType: "answer",
1630
+ sdp: {
1631
+ type: answer.type,
1632
+ sdp: answer.sdp,
1633
+ },
1634
+ });
1635
+ } catch {}
1636
+ return;
1637
+ }
1638
+
1639
+ if (msg.signalType === "answer" && msg.sdp) {
1640
+ const pc = this.directScreenPcs.get(pcKey);
1641
+ if (!pc || pc.signalingState === "stable") return;
1642
+ try {
1643
+ await pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
1644
+ const queuedKey = `${pcKey}:caller`;
1645
+ const queued = this.pendingScreenIce.get(queuedKey) || [];
1646
+ this.pendingScreenIce.delete(queuedKey);
1647
+ for (const cand of queued) {
1648
+ await pc.addIceCandidate(new RTCIceCandidate(cand)).catch(() => {});
1649
+ }
1650
+ } catch {}
1651
+ return;
1652
+ }
1653
+
1654
+ if (msg.signalType === "candidate" && msg.candidate) {
1655
+ const isFromCaller = msg.role === "caller";
1656
+ const targetPc = isFromCaller
1657
+ ? this.incomingScreenPcs.get(pcKey)
1658
+ : this.directScreenPcs.get(pcKey);
1659
+ const queueKey = `${pcKey}:${isFromCaller ? "callee" : "caller"}`;
1660
+
1661
+ if (targetPc && targetPc.remoteDescription) {
1662
+ await targetPc.addIceCandidate(new RTCIceCandidate(msg.candidate)).catch(() => {});
1663
+ } else {
1664
+ if (!this.pendingScreenIce.has(queueKey)) {
1665
+ this.pendingScreenIce.set(queueKey, []);
1666
+ }
1667
+ this.pendingScreenIce.get(queueKey).push(msg.candidate);
1668
+ }
1669
+ }
1670
+ }
1671
+
1672
+ getLocalUserMediaStream() {
1673
+ const tracks = [];
1674
+ const micPub = this.localParticipant.getTrackPublication(TrackSources.Microphone);
1675
+ const camPub = this.localParticipant.getTrackPublication(TrackSources.Camera);
1676
+
1677
+ if (micPub?.track?.mediaStreamTrack && micPub.track.mediaStreamTrack.readyState === "live") {
1678
+ tracks.push(micPub.track.mediaStreamTrack);
1679
+ }
1680
+ if (
1681
+ camPub?.track?.mediaStreamTrack &&
1682
+ !camPub.isMuted &&
1683
+ camPub.track.mediaStreamTrack.readyState === "live"
1684
+ ) {
1685
+ tracks.push(camPub.track.mediaStreamTrack);
1686
+ }
1687
+ return tracks.length > 0 ? new MediaStream(tracks) : null;
1688
+ }
1689
+
1690
+ getLocalScreenMediaStream() {
1691
+ const scrPub = this.localParticipant.getTrackPublication(TrackSources.ScreenShare);
1692
+ if (
1693
+ scrPub?.track?.mediaStreamTrack &&
1694
+ !scrPub.isMuted &&
1695
+ scrPub.track.mediaStreamTrack.readyState === "live"
1696
+ ) {
1697
+ return new MediaStream([scrPub.track.mediaStreamTrack]);
1698
+ }
1699
+ return null;
1700
+ }
1701
+
1702
+ patchMediaConnectionNegotiator(call) {
1703
+ const neg = call?._negotiator;
1704
+ if (!neg || neg._lmPatched) return;
1705
+ neg._lmPatched = true;
1706
+
1707
+ let sdpChain = Promise.resolve();
1708
+ const origHandleSDP = neg.handleSDP.bind(neg);
1709
+ const origHandleCandidate = neg.handleCandidate.bind(neg);
1710
+
1711
+ neg.handleSDP = (type, sdp) => {
1712
+ sdpChain = sdpChain
1713
+ .then(() => origHandleSDP(type, sdp))
1714
+ .catch(() => {});
1715
+ return sdpChain;
1716
+ };
1717
+
1718
+ neg.handleCandidate = (candidate) => {
1719
+ sdpChain = sdpChain
1720
+ .then(async () => {
1721
+ const pc = call.peerConnection;
1722
+ if (pc && !pc.remoteDescription) {
1723
+ for (let i = 0; i < 20 && !pc.remoteDescription; i++) {
1724
+ await new Promise((r) => setTimeout(r, 25));
1725
+ }
1726
+ }
1727
+ if (pc && pc.signalingState !== "closed") {
1728
+ return origHandleCandidate(candidate);
1729
+ }
1730
+ })
1731
+ .catch(() => {});
1732
+ return sdpChain;
1733
+ };
1734
+ }
1735
+
1736
+ callAllPeersScreenShare() {
1737
+ const screenStream = this.getLocalScreenMediaStream();
1738
+ if (!screenStream) return;
1739
+
1740
+ const dialedIdentities = new Set();
1741
+ for (const p of this.remoteParticipants.values()) {
1742
+ if (p.permissions?.canSubscribe && p.identity && !dialedIdentities.has(p.identity)) {
1743
+ dialedIdentities.add(p.identity);
1744
+ this.startDirectScreenShareToPeer(p.identity);
1745
+ if (p.peerId) {
1746
+ const peerIdCopy = p.peerId;
1747
+ const idCopy = p.identity;
1748
+ setTimeout(() => {
1749
+ if (this.isDisconnected || !this.getLocalScreenMediaStream()) return;
1750
+ const pc = this.directScreenPcs.get(`${idCopy}:screen`);
1751
+ const connected =
1752
+ pc &&
1753
+ (pc.connectionState === "connected" ||
1754
+ pc.iceConnectionState === "connected" ||
1755
+ pc.iceConnectionState === "completed");
1756
+ if (!connected) {
1757
+ this.callPeerScreenShare(peerIdCopy, idCopy);
1758
+ }
1759
+ }, 3200);
1760
+ }
1761
+ }
1762
+ }
1763
+
1764
+ if (!this.isHost && dialedIdentities.size === 0) {
1765
+ this.startDirectScreenShareToPeer("host");
1766
+ }
1767
+ }
1768
+
1769
+ callPeerScreenShare(remotePeerId, remoteIdentity) {
1770
+ if (!this.peer || !remotePeerId) return;
1771
+ const screenStream = this.getLocalScreenMediaStream();
1772
+ if (!screenStream) return;
1773
+
1774
+ const scrKey = `${remoteIdentity}:screen`;
1775
+ const prevCall = this.activeMediaCalls.get(scrKey);
1776
+ if (prevCall) {
1777
+ try {
1778
+ prevCall.close();
1779
+ } catch {}
1780
+ this.activeMediaCalls.delete(scrKey);
1781
+ }
1782
+
1783
+ const scrCall = this.peer.call(remotePeerId, screenStream, {
1784
+ metadata: {
1785
+ identity: this.localParticipant.identity,
1786
+ name: this.localParticipant.name,
1787
+ streamKind: "screen",
1788
+ },
1789
+ });
1790
+ if (scrCall) {
1791
+ this.patchMediaConnectionNegotiator(scrCall);
1792
+ this.bindMediaCallEvents(scrCall, remoteIdentity, "screen");
1793
+ this.activeMediaCalls.set(scrKey, scrCall);
1794
+ }
1795
+ }
1796
+
1797
+ callPeerWithLocalTracks(remotePeerId, remoteIdentity, forceScreen = false) {
1798
+ if (!this.peer || !remotePeerId) return;
1799
+
1800
+ const userStream = this.getLocalUserMediaStream();
1801
+ if (userStream) {
1802
+ const callKey = `${remoteIdentity}:user`;
1803
+ const existingUserCall = this.activeMediaCalls.get(callKey);
1804
+ const pc = existingUserCall?.peerConnection;
1805
+ const isCallAlive = Boolean(
1806
+ existingUserCall?.open &&
1807
+ pc &&
1808
+ pc.connectionState !== "closed" &&
1809
+ pc.connectionState !== "failed" &&
1810
+ pc.iceConnectionState !== "closed" &&
1811
+ pc.iceConnectionState !== "failed"
1812
+ );
1813
+
1814
+ let replacedInPlace = false;
1815
+ if (isCallAlive && typeof pc.getSenders === "function") {
1816
+ const senders = pc.getSenders();
1817
+ const newAudio = userStream.getAudioTracks()[0] || null;
1818
+ const newVideo = userStream.getVideoTracks()[0] || null;
1819
+ const audioSender = senders.find((s) => s.track?.kind === "audio");
1820
+ const videoSender = senders.find((s) => s.track?.kind === "video");
1821
+
1822
+ const canReplaceAudio = !newAudio || Boolean(audioSender);
1823
+ const canReplaceVideo = !newVideo || Boolean(videoSender);
1824
+ if (canReplaceAudio && canReplaceVideo) {
1825
+ if (audioSender && newAudio) audioSender.replaceTrack(newAudio).catch(() => {});
1826
+ if (videoSender) videoSender.replaceTrack(newVideo).catch(() => {});
1827
+ replacedInPlace = true;
1828
+ }
1829
+ }
1830
+
1831
+ if (!replacedInPlace) {
1832
+ if (existingUserCall) {
1833
+ try {
1834
+ existingUserCall.close();
1835
+ } catch {}
1836
+ }
1837
+ const call = this.peer.call(remotePeerId, userStream, {
1838
+ metadata: {
1839
+ identity: this.localParticipant.identity,
1840
+ name: this.localParticipant.name,
1841
+ streamKind: "user",
1842
+ },
1843
+ });
1844
+ if (call) {
1845
+ this.patchMediaConnectionNegotiator(call);
1846
+ this.bindMediaCallEvents(call, remoteIdentity, "user");
1847
+ this.activeMediaCalls.set(callKey, call);
1848
+ }
1849
+ }
1850
+ }
1851
+
1852
+ if (forceScreen && this.getLocalScreenMediaStream()) {
1853
+ this.startDirectScreenShareToPeer(remoteIdentity);
1854
+ }
1855
+ }
1856
+
1857
+ closeOutgoingScreenCalls() {
1858
+ for (const [key, call] of this.activeMediaCalls.entries()) {
1859
+ if (key.endsWith(":screen")) {
1860
+ try {
1861
+ call.close();
1862
+ } catch {}
1863
+ this.activeMediaCalls.delete(key);
1864
+ }
1865
+ }
1866
+ for (const [key, pc] of this.directScreenPcs.entries()) {
1867
+ if (key.endsWith(":screen") || !key.includes(":")) {
1868
+ try {
1869
+ pc.close();
1870
+ } catch {}
1871
+ this.directScreenPcs.delete(key);
1872
+ }
1873
+ }
1874
+ }
1875
+
1876
+ handleIncomingMediaCall(call) {
1877
+ const meta = call.metadata || {};
1878
+ const remoteIdentity = meta.identity || "";
1879
+ const streamKind = meta.streamKind || "user";
1880
+
1881
+ this.patchMediaConnectionNegotiator(call);
1882
+ this.bindMediaCallEvents(call, remoteIdentity, streamKind);
1883
+
1884
+ const answerStream = streamKind === "user" ? this.getLocalUserMediaStream() : undefined;
1885
+ try {
1886
+ call.answer(answerStream || undefined);
1887
+ } catch {}
1888
+
1889
+ if (call.remoteStream) {
1890
+ this.processIncomingRemoteStream(call.remoteStream, remoteIdentity, streamKind, meta.name);
1891
+ }
1892
+ }
1893
+
1894
+ processIncomingRemoteStream(remoteStream, remoteIdentity, streamKind, fallbackName) {
1895
+ if (!remoteIdentity || !remoteStream) return;
1896
+ let participant = this.remoteParticipants.get(remoteIdentity);
1897
+ if (!participant) {
1898
+ participant = new HybridParticipant({
1899
+ identity: remoteIdentity,
1900
+ name: fallbackName || "Participante",
1901
+ metadata: JSON.stringify({ role: "guest", status: "approved" }),
1902
+ permissions: { canPublish: true, canSubscribe: true, canPublishData: true },
1903
+ isLocal: false,
1904
+ room: this,
1905
+ });
1906
+ this.remoteParticipants.set(remoteIdentity, participant);
1907
+ }
1908
+
1909
+ if (streamKind === "screen") {
1910
+ const vTrack = remoteStream.getVideoTracks()[0];
1911
+ if (vTrack) {
1912
+ this.attachRemoteTrackToParticipant(
1913
+ participant,
1914
+ vTrack,
1915
+ TrackKinds.Video,
1916
+ TrackSources.ScreenShare
1917
+ );
1918
+ }
1919
+ return;
1920
+ }
1921
+
1922
+ for (const aTrack of remoteStream.getAudioTracks()) {
1923
+ this.attachRemoteTrackToParticipant(
1924
+ participant,
1925
+ aTrack,
1926
+ TrackKinds.Audio,
1927
+ TrackSources.Microphone
1928
+ );
1929
+ }
1930
+ for (const vTrack of remoteStream.getVideoTracks()) {
1931
+ this.attachRemoteTrackToParticipant(
1932
+ participant,
1933
+ vTrack,
1934
+ TrackKinds.Video,
1935
+ TrackSources.Camera
1936
+ );
1937
+ }
1938
+ }
1939
+
1940
+ bindMediaCallEvents(call, remoteIdentity, streamKind) {
1941
+ const fallbackName = call.metadata?.name || "Participante";
1942
+ call.on("stream", (remoteStream) => {
1943
+ this.processIncomingRemoteStream(remoteStream, remoteIdentity, streamKind, fallbackName);
1944
+ });
1945
+ }
1946
+
1947
+ attachRemoteTrackToParticipant(participant, mediaStreamTrack, kind, source) {
1948
+ if (!participant || !mediaStreamTrack) return;
1949
+
1950
+ if (!mediaStreamTrack._lmUnmuteBound) {
1951
+ mediaStreamTrack._lmUnmuteBound = true;
1952
+ mediaStreamTrack.addEventListener("unmute", () => {
1953
+ const curPub = participant.getTrackPublication(source);
1954
+ const curTrack = curPub?.track?.mediaStreamTrack;
1955
+ if (!curTrack || curTrack.readyState === "ended" || curTrack.muted) {
1956
+ if (curTrack?.id !== mediaStreamTrack.id) {
1957
+ this.attachRemoteTrackToParticipant(participant, mediaStreamTrack, kind, source);
1958
+ return;
1959
+ }
1960
+ }
1961
+ if (curPub && curTrack?.id === mediaStreamTrack.id) {
1962
+ curPub.isMuted = false;
1963
+ for (const el of curPub.track.attachedElements) {
1964
+ el.play?.().catch(() => {});
1965
+ }
1966
+ this.emit(RoomEvents.TrackUnmuted, curPub, participant);
1967
+ }
1968
+ });
1969
+ }
1970
+
1971
+ const existingPub = participant.getTrackPublication(source);
1972
+ if (existingPub?.track?.mediaStreamTrack?.id === mediaStreamTrack.id) {
1973
+ return;
1974
+ }
1975
+
1976
+ if (
1977
+ existingPub?.track?.mediaStreamTrack &&
1978
+ existingPub.track.mediaStreamTrack.readyState === "live" &&
1979
+ mediaStreamTrack.muted
1980
+ ) {
1981
+ return;
1982
+ }
1983
+
1984
+ if (existingPub?.track) {
1985
+ existingPub.track.detach().forEach((el) => el.remove());
1986
+ }
1987
+
1988
+ const hTrack = new HybridTrack(mediaStreamTrack, kind, source);
1989
+ const pub = new HybridPublication({
1990
+ trackSid: `${source}_${participant.identity}`,
1991
+ source,
1992
+ track: hTrack,
1993
+ isMuted: false,
1994
+ });
1995
+ participant.setPublication(source, pub);
1996
+
1997
+ this.emit(RoomEvents.TrackSubscribed, hTrack, pub, participant);
1998
+ }
1999
+
2000
+ // ============================================================================
2001
+ // TRANSPORTE CLOUDFLARE CALLS SFU
2002
+ // ============================================================================
2003
+ async initCloudflareCallsSession() {
2004
+ if (this.cfSessionId && this.cfPc) return;
2005
+ if (this.cfInitPromise) return this.cfInitPromise;
2006
+
2007
+ this.cfInitPromise = (async () => {
2008
+ const res = await this.apiPost("/api/cf-calls/session", {
2009
+ room_hash: this.roomHash,
2010
+ });
2011
+ if (!res?.sessionId) {
2012
+ throw new Error("No se recibió sessionId de Cloudflare Calls.");
2013
+ }
2014
+ this.cfSessionId = res.sessionId;
2015
+ this.localParticipant.cfSessionId = res.sessionId;
2016
+
2017
+ this.cfPc = new RTCPeerConnection({
2018
+ iceServers: this.iceServers,
2019
+ bundlePolicy: "max-bundle",
2020
+ });
2021
+
2022
+ this.cfPc.ontrack = (event) => {
2023
+ const mid = event.transceiver?.mid;
2024
+ const meta =
2025
+ mid !== null && mid !== undefined ? this.cfMidToRemoteMeta.get(String(mid)) : null;
2026
+ if (!meta) return;
2027
+ this.attachCloudflareRemoteTrack(meta, event.track);
2028
+ };
2029
+ })();
2030
+
2031
+ try {
2032
+ await this.cfInitPromise;
2033
+ } catch (err) {
2034
+ this.cfInitPromise = null;
2035
+ throw err;
2036
+ }
2037
+ }
2038
+
2039
+ attachCloudflareRemoteTrack(meta, mediaStreamTrack) {
2040
+ if (!meta || !mediaStreamTrack) return;
2041
+ const participant = this.remoteParticipants.get(meta.identity);
2042
+ if (!participant) return;
2043
+
2044
+ if (meta.trackName === "mic") {
2045
+ this.attachRemoteTrackToParticipant(
2046
+ participant,
2047
+ mediaStreamTrack,
2048
+ TrackKinds.Audio,
2049
+ TrackSources.Microphone
2050
+ );
2051
+ } else if (meta.trackName === "cam") {
2052
+ this.attachRemoteTrackToParticipant(
2053
+ participant,
2054
+ mediaStreamTrack,
2055
+ TrackKinds.Video,
2056
+ TrackSources.Camera
2057
+ );
2058
+ } else if (meta.trackName === "screen" || meta.trackName.startsWith("screen_")) {
2059
+ this.attachRemoteTrackToParticipant(
2060
+ participant,
2061
+ mediaStreamTrack,
2062
+ TrackKinds.Video,
2063
+ TrackSources.ScreenShare
2064
+ );
2065
+ }
2066
+ }
2067
+
2068
+ enqueueCfNegotiation(taskFn) {
2069
+ this.cfNegotiationChain = this.cfNegotiationChain.then(taskFn, taskFn);
2070
+ return this.cfNegotiationChain;
2071
+ }
2072
+
2073
+ async pushLocalTracksToCloudflare() {
2074
+ await this.initCloudflareCallsSession();
2075
+ return this.enqueueCfNegotiation(async () => {
2076
+ if (!this.cfPc || !this.cfSessionId) return;
2077
+
2078
+ const screenKey = this.currentScreenTrackName || "screen";
2079
+ const sourceMap = [
2080
+ { key: "mic", source: TrackSources.Microphone },
2081
+ { key: "cam", source: TrackSources.Camera },
2082
+ { key: screenKey, source: TrackSources.ScreenShare },
2083
+ ];
2084
+
2085
+ const newTracksToPush = [];
2086
+ const activeNames = [];
2087
+
2088
+ for (const { key, source } of sourceMap) {
2089
+ const pub = this.localParticipant.getTrackPublication(source);
2090
+ const mediaTrack = pub?.track?.mediaStreamTrack;
2091
+ const isLive = Boolean(mediaTrack && !pub.isMuted && mediaTrack.readyState === "live");
2092
+
2093
+ if (isLive) {
2094
+ activeNames.push(key);
2095
+ const existingTransceiver = this.cfTransceivers.get(key);
2096
+ if (existingTransceiver) {
2097
+ await existingTransceiver.sender.replaceTrack(mediaTrack);
2098
+ } else {
2099
+ const tr = this.cfPc.addTransceiver(mediaTrack, { direction: "sendonly" });
2100
+ this.cfTransceivers.set(key, tr);
2101
+ newTracksToPush.push({ key, tr });
2102
+ }
2103
+ }
2104
+ }
2105
+
2106
+ if (newTracksToPush.length > 0) {
2107
+ const offer = await this.cfPc.createOffer();
2108
+ await this.cfPc.setLocalDescription(offer);
2109
+
2110
+ const tracksPayload = newTracksToPush.map(({ key, tr }) => ({
2111
+ location: "local",
2112
+ mid: tr.mid,
2113
+ trackName: key,
2114
+ }));
2115
+
2116
+ const data = await this.apiPost("/api/cf-calls/tracks-new", {
2117
+ room_hash: this.roomHash,
2118
+ sessionId: this.cfSessionId,
2119
+ sessionDescription: {
2120
+ sdp: offer.sdp,
2121
+ type: "offer",
2122
+ },
2123
+ tracks: tracksPayload,
2124
+ });
2125
+
2126
+ if (data?.sessionDescription) {
2127
+ await this.cfPc.setRemoteDescription(
2128
+ new RTCSessionDescription(data.sessionDescription)
2129
+ );
2130
+ }
2131
+ }
2132
+
2133
+ this.localParticipant.cfPublishedTracks = activeNames;
2134
+ });
2135
+ }
2136
+
2137
+ async pullRemoteTracksFromCloudflare(remoteParticipant) {
2138
+ if (!this.cfCallsReady || !remoteParticipant?.cfSessionId) return;
2139
+ const remoteSessionId = remoteParticipant.cfSessionId;
2140
+ const published = Array.isArray(remoteParticipant.cfPublishedTracks)
2141
+ ? remoteParticipant.cfPublishedTracks
2142
+ : [];
2143
+
2144
+ const tracksToPull = [];
2145
+ for (const trackName of published) {
2146
+ const pullKey = `${remoteSessionId}:${trackName}`;
2147
+ if (!this.cfPulledRemoteKeys.has(pullKey)) {
2148
+ this.cfPulledRemoteKeys.add(pullKey);
2149
+ tracksToPull.push({
2150
+ location: "remote",
2151
+ sessionId: remoteSessionId,
2152
+ trackName,
2153
+ });
2154
+ }
2155
+ }
2156
+
2157
+ if (tracksToPull.length === 0) return;
2158
+
2159
+ await this.initCloudflareCallsSession();
2160
+ return this.enqueueCfNegotiation(async () => {
2161
+ if (!this.cfPc || !this.cfSessionId) return;
2162
+ try {
2163
+ const res = await this.apiPost("/api/cf-calls/tracks-new", {
2164
+ room_hash: this.roomHash,
2165
+ sessionId: this.cfSessionId,
2166
+ tracks: tracksToPull,
2167
+ });
2168
+
2169
+ let needsRetry = false;
2170
+ if (Array.isArray(res?.tracks)) {
2171
+ for (let i = 0; i < res.tracks.length; i++) {
2172
+ const t = res.tracks[i];
2173
+ const tName = t?.trackName || tracksToPull[i]?.trackName;
2174
+ if (!tName) continue;
2175
+ const pullKey = `${remoteSessionId}:${tName}`;
2176
+
2177
+ if (t && t.mid !== undefined && !t.errorCode) {
2178
+ this.cfMidToRemoteMeta.set(String(t.mid), {
2179
+ identity: remoteParticipant.identity,
2180
+ trackName: tName,
2181
+ });
2182
+ this.cfPullRetryCounts.delete(pullKey);
2183
+ } else {
2184
+ this.cfPulledRemoteKeys.delete(pullKey);
2185
+ const retries = (this.cfPullRetryCounts.get(pullKey) || 0) + 1;
2186
+ if (retries <= 8) {
2187
+ this.cfPullRetryCounts.set(pullKey, retries);
2188
+ needsRetry = true;
2189
+ }
2190
+ }
2191
+ }
2192
+ }
2193
+
2194
+ if (res?.requiresImmediateRenegotiation && res?.sessionDescription) {
2195
+ await this.cfPc.setRemoteDescription(
2196
+ new RTCSessionDescription(res.sessionDescription)
2197
+ );
2198
+ const answer = await this.cfPc.createAnswer();
2199
+ await this.cfPc.setLocalDescription(answer);
2200
+
2201
+ await this.apiPost("/api/cf-calls/renegotiate", {
2202
+ room_hash: this.roomHash,
2203
+ sessionId: this.cfSessionId,
2204
+ sessionDescription: {
2205
+ sdp: answer.sdp,
2206
+ type: "answer",
2207
+ },
2208
+ });
2209
+ }
2210
+
2211
+ for (const tr of this.cfPc.getTransceivers()) {
2212
+ if (tr.mid !== null && tr.mid !== undefined && tr.receiver?.track) {
2213
+ const meta = this.cfMidToRemoteMeta.get(String(tr.mid));
2214
+ if (meta) {
2215
+ this.attachCloudflareRemoteTrack(meta, tr.receiver.track);
2216
+ }
2217
+ }
2218
+ }
2219
+
2220
+ if (needsRetry && !this.isDisconnected) {
2221
+ setTimeout(() => {
2222
+ if (!this.isDisconnected) {
2223
+ this.pullRemoteTracksFromCloudflare(remoteParticipant);
2224
+ }
2225
+ }, 700);
2226
+ }
2227
+ } catch {
2228
+ for (const t of tracksToPull) {
2229
+ this.cfPulledRemoteKeys.delete(`${t.sessionId}:${t.trackName}`);
2230
+ }
2231
+ }
2232
+ });
2233
+ }
2234
+
2235
+ // ============================================================================
2236
+ // ENVÍO DE DATOS CIFRADOS (E2EE)
2237
+ // ============================================================================
2238
+ async sendData(payload, options = {}) {
2239
+ let text = "";
2240
+ if (typeof payload === "string") {
2241
+ text = payload;
2242
+ } else if (payload instanceof Uint8Array || payload instanceof ArrayBuffer) {
2243
+ text = new TextDecoder().decode(payload);
2244
+ } else {
2245
+ text = JSON.stringify(payload);
2246
+ }
2247
+
2248
+ let cipher = null;
2249
+ try {
2250
+ cipher = await encryptDataPacket(this.roomHash, text);
2251
+ } catch {
2252
+ cipher = null;
2253
+ }
2254
+
2255
+ const packet = {
2256
+ type: "app_data",
2257
+ senderId: this.localParticipant.identity,
2258
+ senderName: this.localParticipant.name,
2259
+ ...(cipher ? { cipher } : { text }),
2260
+ };
2261
+
2262
+ if (this.isHost) {
2263
+ for (const p of this.remoteParticipants.values()) {
2264
+ if (p.permissions?.canSubscribe) {
2265
+ this.enqueueHttpSignal(p.identity, packet);
2266
+ }
2267
+ }
2268
+ for (const [id, conn] of this.guestConns.entries()) {
2269
+ const gp = this.remoteParticipants.get(id);
2270
+ if (conn.open && gp?.permissions?.canSubscribe) {
2271
+ conn.send(packet);
2272
+ }
2273
+ }
2274
+ } else {
2275
+ this.enqueueHttpSignal("host", packet);
2276
+ if (this.hostConn?.open) {
2277
+ this.hostConn.send(packet);
2278
+ }
2279
+ }
2280
+ }
2281
+
2282
+ async kickParticipant(identity) {
2283
+ if (!this.isHost) return;
2284
+ try {
2285
+ await this.apiPost("/api/kick", {
2286
+ room_hash: this.roomHash,
2287
+ host_secret: this.hostSecret,
2288
+ identity,
2289
+ });
2290
+ } catch {}
2291
+
2292
+ this.enqueueHttpSignal(identity, { type: "kicked" });
2293
+ const conn = this.guestConns.get(identity);
2294
+ const p = this.remoteParticipants.get(identity);
2295
+ if (conn?.open) {
2296
+ conn.send({ type: "kicked" });
2297
+ setTimeout(() => conn.close(), 150);
2298
+ }
2299
+ this.guestConns.delete(identity);
2300
+ if (p) {
2301
+ this.cleanupRemoteParticipantTracks(p);
2302
+ this.remoteParticipants.delete(identity);
2303
+ this.emit(RoomEvents.ParticipantDisconnected, p);
2304
+ this.broadcastRoster();
2305
+ }
2306
+ }
2307
+
2308
+ async muteParticipant(identity, trackSid = "") {
2309
+ if (!this.isHost) return;
2310
+ try {
2311
+ if (trackSid) {
2312
+ await this.apiPost("/api/mute", {
2313
+ room_hash: this.roomHash,
2314
+ host_secret: this.hostSecret,
2315
+ identity,
2316
+ track_sid: trackSid,
2317
+ });
2318
+ }
2319
+ } catch {}
2320
+
2321
+ const payload = JSON.stringify({
2322
+ type: "remote_mute",
2323
+ targetIdentity: identity,
2324
+ senderName: this.localParticipant.name,
2325
+ });
2326
+ await this.sendData(payload);
2327
+ }
2328
+
2329
+ async endRoom() {
2330
+ if (!this.isHost) return;
2331
+ try {
2332
+ this.enqueueHttpSignal("all", { type: "room_ended" });
2333
+ for (const conn of this.guestConns.values()) {
2334
+ if (conn.open) {
2335
+ conn.send({ type: "room_ended" });
2336
+ }
2337
+ }
2338
+ await this.apiPost("/api/end-room", {
2339
+ room_hash: this.roomHash,
2340
+ host_secret: this.hostSecret,
2341
+ });
2342
+ } catch {}
2343
+ this.disconnect();
2344
+ }
2345
+
2346
+ cleanupRemoteParticipantTracks(participant) {
2347
+ for (const pub of participant.trackPublications.values()) {
2348
+ if (pub.track) {
2349
+ pub.track.detach().forEach((el) => el.remove());
2350
+ this.emit(RoomEvents.TrackUnsubscribed, pub.track, pub, participant);
2351
+ }
2352
+ }
2353
+ participant.trackPublications.clear();
2354
+ participant.videoTrackPublications.clear();
2355
+ }
2356
+
2357
+ disconnect() {
2358
+ if (this.isDisconnected) return;
2359
+ this.isDisconnected = true;
2360
+
2361
+ if (this.syncTimer) {
2362
+ clearInterval(this.syncTimer);
2363
+ this.syncTimer = null;
2364
+ }
2365
+ if (this.peerKeepAliveTimer) {
2366
+ clearInterval(this.peerKeepAliveTimer);
2367
+ this.peerKeepAliveTimer = null;
2368
+ }
2369
+
2370
+ for (const pub of this.localParticipant.trackPublications.values()) {
2371
+ pub.track?.stop();
2372
+ pub.track?.detach().forEach((el) => el.remove());
2373
+ }
2374
+ this.localParticipant.trackPublications.clear();
2375
+ this.localParticipant.videoTrackPublications.clear();
2376
+
2377
+ for (const p of this.remoteParticipants.values()) {
2378
+ this.cleanupRemoteParticipantTracks(p);
2379
+ }
2380
+ this.remoteParticipants.clear();
2381
+
2382
+ for (const call of this.activeMediaCalls.values()) {
2383
+ try {
2384
+ call.close();
2385
+ } catch {}
2386
+ }
2387
+ this.activeMediaCalls.clear();
2388
+
2389
+ for (const pc of this.directScreenPcs.values()) {
2390
+ try {
2391
+ pc.close();
2392
+ } catch {}
2393
+ }
2394
+ this.directScreenPcs.clear();
2395
+
2396
+ for (const pc of this.incomingScreenPcs.values()) {
2397
+ try {
2398
+ pc.close();
2399
+ } catch {}
2400
+ }
2401
+ this.incomingScreenPcs.clear();
2402
+ this.pendingScreenIce.clear();
2403
+
2404
+ try {
2405
+ this.cfPc?.close();
2406
+ } catch {}
2407
+ this.cfPc = null;
2408
+
2409
+ try {
2410
+ this.hostConn?.close();
2411
+ } catch {}
2412
+ for (const conn of this.guestConns.values()) {
2413
+ try {
2414
+ conn.close();
2415
+ } catch {}
2416
+ }
2417
+ this.guestConns.clear();
2418
+
2419
+ try {
2420
+ this.peer?.destroy();
2421
+ } catch {}
2422
+ this.peer = null;
2423
+
2424
+ this.emit(RoomEvents.Disconnected);
2425
+ }
2426
+ }
2427
+
2428
+
2429
+
2430
+
2431
+
2432
+ /**
2433
+ * Cliente principal estilo Axios para inicializar llamadas con 1 sola línea de código.
2434
+ */
2435
+ const LocalMeet = {
2436
+ RoomEvents,
2437
+ TrackSources,
2438
+ TrackKinds,
2439
+ EngineModes,
2440
+ LocalMeetRoom,
2441
+ HybridTrack,
2442
+ HybridPublication,
2443
+ HybridParticipant,
2444
+
2445
+ /**
2446
+ * Crea una sala nueva como anfitrión en el backend y devuelve una instancia lista de LocalMeetRoom.
2447
+ *
2448
+ * @param {Object} options
2449
+ * @param {string} options.serverUrl - URL del Worker o servidor de señalización (ej: "https://mi-meet.workers.dev")
2450
+ * @param {string} [options.hostName="Anfitrión"] - Nombre a mostrar del anfitrión
2451
+ * @param {string} [options.title=""] - Título de la reunión
2452
+ * @param {string} [options.engine="cf-calls"] - "cf-calls" | "p2p" | "livekit"
2453
+ * @param {string} [options.accessKey=""] - Clave HOST_ACCESS_KEY si el servidor la requiere
2454
+ * @param {string} [options.customHash=""] - Hash de sala a reutilizar si ya eres el anfitrión
2455
+ * @param {string} [options.hostSecret=""] - Secreto de anfitrión previo
2456
+ * @returns {Promise<LocalMeetRoom>}
2457
+ */
2458
+ async createRoom(options = {}) {
2459
+ const {
2460
+ serverUrl = typeof window !== "undefined" ? window.location.origin : "",
2461
+ hostName = "Anfitrión",
2462
+ title = "",
2463
+ engine = EngineModes.CloudflareCalls,
2464
+ accessKey = "",
2465
+ customHash = "",
2466
+ hostSecret = "",
2467
+ debug = false,
2468
+ } = options;
2469
+
2470
+ const cleanServer = (serverUrl || "").replace(/\/+$/, "");
2471
+ const res = await fetch(`${cleanServer}/api/host-join`, {
2472
+ method: "POST",
2473
+ headers: { "Content-Type": "application/json" },
2474
+ body: JSON.stringify({
2475
+ host_name: hostName,
2476
+ title,
2477
+ engine,
2478
+ access_key: accessKey,
2479
+ room_hash: customHash,
2480
+ host_secret: hostSecret,
2481
+ }),
2482
+ });
2483
+
2484
+ const data = await res.json();
2485
+ if (!res.ok) {
2486
+ throw new Error(data?.detail || `Error al crear sala (${res.status})`);
2487
+ }
2488
+
2489
+ const room = new LocalMeetRoom({
2490
+ serverUrl: cleanServer,
2491
+ roomHash: data.room_hash,
2492
+ title: data.title || title,
2493
+ identity: data.identity,
2494
+ displayName: data.host_name || hostName,
2495
+ isHost: true,
2496
+ hostSecret: data.host_secret,
2497
+ engine: data.engine || engine,
2498
+ cfCallsReady: Boolean(data.cf_calls_ready),
2499
+ iceServers: data.ice_servers,
2500
+ startInWaitingRoom: false,
2501
+ debug,
2502
+ });
2503
+
2504
+ return room;
2505
+ },
2506
+
2507
+ /**
2508
+ * Se une a una sala existente como invitado.
2509
+ *
2510
+ * @param {Object} options
2511
+ * @param {string} options.serverUrl - URL del Worker o servidor
2512
+ * @param {string} options.roomHash - Hash de la sala (ej: "c4f2-9k81-m3n2")
2513
+ * @param {string} [options.guestName="Invitado"] - Nombre del participante
2514
+ * @returns {Promise<LocalMeetRoom>}
2515
+ */
2516
+ async join(options = {}) {
2517
+ const {
2518
+ serverUrl = typeof window !== "undefined" ? window.location.origin : "",
2519
+ roomHash = "",
2520
+ guestName = "Invitado",
2521
+ debug = false,
2522
+ } = options;
2523
+
2524
+ const cleanServer = (serverUrl || "").replace(/\/+$/, "");
2525
+ const cleanHash = (roomHash || "").toLowerCase().trim();
2526
+
2527
+ const res = await fetch(`${cleanServer}/api/guest-join`, {
2528
+ method: "POST",
2529
+ headers: { "Content-Type": "application/json" },
2530
+ body: JSON.stringify({
2531
+ room_hash: cleanHash,
2532
+ guest_name: guestName,
2533
+ }),
2534
+ });
2535
+
2536
+ const data = await res.json();
2537
+ if (!res.ok) {
2538
+ throw new Error(data?.detail || `Error al unirse a sala (${res.status})`);
2539
+ }
2540
+
2541
+ const room = new LocalMeetRoom({
2542
+ serverUrl: cleanServer,
2543
+ roomHash: data.room_hash,
2544
+ title: `Sala ${data.room_hash}`,
2545
+ identity: data.identity,
2546
+ displayName: data.guest_name || guestName,
2547
+ isHost: false,
2548
+ engine: data.engine || EngineModes.CloudflareCalls,
2549
+ cfCallsReady: Boolean(data.cf_calls_ready),
2550
+ iceServers: data.ice_servers,
2551
+ startInWaitingRoom: true,
2552
+ debug,
2553
+ });
2554
+
2555
+ return room;
2556
+ },
2557
+ };
2558
+
2559
+
2560
+
2561
+ return {
2562
+ LocalMeet: LocalMeet,
2563
+ LocalMeetRoom: LocalMeetRoom,
2564
+ HybridTrack: HybridTrack,
2565
+ HybridPublication: HybridPublication,
2566
+ HybridParticipant: HybridParticipant,
2567
+ RoomEvents: RoomEvents,
2568
+ TrackSources: TrackSources,
2569
+ TrackKinds: TrackKinds,
2570
+ EngineModes: EngineModes,
2571
+ encryptDataPacket: encryptDataPacket,
2572
+ decryptDataPacket: decryptDataPacket
2573
+ };
2574
+ }));