@encarnage7/local-meet 0.0.0-stage → 1.0.1

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