/* KARVAN client — mensajes temporales + WebRTC (data-channel). * Capa fiable: relay por servidor (RAM, autodestrucción) con polling. * Capa turbo: data-channel WebRTC P2P (instantáneo) con "perfect negotiation". * Todo degrada: si no hay WebRTC o no conecta, el chat sigue por el relay. * Sin cámara/mic (solo data-channel) => no necesita permisos del wrapper. */ (function () { "use strict"; var root = document.querySelector(".karvan-room"); if (!root) return; var roomId = root.getAttribute("data-room"); var selfId = root.getAttribute("data-self") || ""; var peerId = "p" + Math.random().toString(16).slice(2, 12); var fromLabel = selfId || peerId; var listEl = document.getElementById("karvan-msgs"); var formEl = document.getElementById("karvan-form"); var textEl = document.getElementById("karvan-text"); var liveTxt = document.getElementById("karvan-live-txt"); var liveWrap = document.getElementById("karvan-live"); var seen = Object.create(null); var lastSeq = 0; var lastSig = 0; var peers = Object.create(null); // --- Fase 1: videollamada (media sobre el MISMO WebRTC del chat) --- var callRoot = document.getElementById("karvan-call"); var localVid = document.getElementById("karvan-local"); var remoteEl = document.getElementById("karvan-remote"); var statusEl = document.getElementById("kc-status"); var btnStart = document.getElementById("kc-start"); var btnMic = document.getElementById("kc-mic"); var btnCam = document.getElementById("kc-cam"); var btnStop = document.getElementById("kc-stop"); var localStream = (window.MediaStream ? new MediaStream() : null); if (localVid && localStream) localVid.srcObject = localStream; var micActive = false, camActive = false; function esc(s) { return String(s == null ? "" : s); } function shortId(id) { id = esc(id || "?"); return id.charAt(0) === "@" ? id.slice(1, 7) : id.slice(0, 6); } function showMsg(m, live) { var key = m.mid ? "m" + m.mid : "s" + (m.seq || Math.random()); if (seen[key]) return; seen[key] = 1; var li = document.createElement("li"); li.className = "karvan-msg" + (m.from === fromLabel ? " karvan-msg-self" : "") + (live ? " karvan-msg-live" : ""); var f = document.createElement("span"); f.className = "km-from"; f.textContent = shortId(m.from); var t = document.createElement("span"); t.className = "km-text"; t.textContent = esc(m.text); li.appendChild(f); li.appendChild(t); listEl.appendChild(li); listEl.scrollTop = listEl.scrollHeight; } // --- relay por servidor (siempre activo) --- function pollMsgs() { fetch("/karvan/" + roomId + "/msgs?since=" + lastSeq, { headers: { Accept: "application/json" } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { if (!d || !d.messages) return; d.messages.forEach(function (m) { if (m.seq > lastSeq) lastSeq = m.seq; showMsg(m, false); }); }) .catch(function () {}); } function sendMessage(text) { if (!text) return; var mid = peerId + "-" + Date.now().toString(36) + Math.random().toString(16).slice(2, 6); var m = { mid: mid, from: fromLabel, text: text }; showMsg(m, false); // optimista broadcastDC({ kind: "chat", mid: mid, from: fromLabel, text: text }); // instantáneo a los pares fetch("/karvan/" + roomId + "/msg", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ from: fromLabel, text: text, mid: mid }) }).catch(function () {}); } if (formEl) { formEl.addEventListener("submit", function (e) { e.preventDefault(); var v = (textEl.value || "").trim(); if (!v) return; textEl.value = ""; sendMessage(v); }); } // --- señalización (mailbox RAM del servidor) --- function sendSig(to, payload) { fetch("/karvan/" + roomId + "/signal", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ from: peerId, to: to || "", payload: payload }) }).catch(function () {}); } function announce() { sendSig("", { kind: "hello" }); } function pollSignals() { fetch("/karvan/" + roomId + "/signal?for=" + peerId + "&since=" + lastSig, { headers: { Accept: "application/json" } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { if (!d) return; (d.signals || []).forEach(function (s) { if (s.seq > lastSig) lastSig = s.seq; onSignal(s.from, s.payload); }); (d.members || []).forEach(function (mid2) { if (mid2 && mid2 !== peerId) ensurePeer(mid2); }); }) .catch(function () {}); } // --- WebRTC (data-channel, perfect negotiation) --- var RTC = window.RTCPeerConnection || window.webkitRTCPeerConnection; var rtcConfig = { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] }; function setLive(on) { if (!liveTxt) return; liveTxt.textContent = on ? "🔒 P2P (WebRTC)" : "server relay"; if (liveWrap) liveWrap.className = "karvan-live" + (on ? " on" : ""); } function broadcastDC(obj) { var s = JSON.stringify(obj); for (var k in peers) { var dc = peers[k] && peers[k].dc; if (dc && dc.readyState === "open") { try { dc.send(s); } catch (e) {} } } } function attachDC(peer, dc) { peer.dc = dc; dc.onopen = function () { setLive(true); }; dc.onmessage = function (e) { try { var m = JSON.parse(e.data); if (m && m.kind === "chat") showMsg(m, true); } catch (x) {} }; dc.onclose = function () { var any = false; for (var k in peers) if (peers[k].dc && peers[k].dc.readyState === "open") any = true; if (!any) setLive(false); }; } function ensurePeer(remoteId) { if (peers[remoteId] || !RTC) return peers[remoteId]; var polite = peerId > remoteId; // el id mayor es "polite" var pc = new RTC(rtcConfig); var peer = { pc: pc, dc: null, polite: polite, makingOffer: false, ignoreOffer: false, vid: null, remoteStream: null }; peers[remoteId] = peer; if (!polite) { attachDC(peer, pc.createDataChannel("chat")); } // el impolite inicia el canal pc.ondatachannel = function (e) { attachDC(peer, e.channel); }; // media: publicar los tracks locales que ya tengamos (si la llamada está activa) + recibir los del par if (localStream) { localStream.getTracks().forEach(function (t) { try { pc.addTrack(t, localStream); } catch (e) {} }); } pc.ontrack = function (e) { attachRemote(peer, remoteId, e); }; pc.onicecandidate = function (e) { if (e.candidate) sendSig(remoteId, { kind: "ice", candidate: e.candidate }); }; pc.onnegotiationneeded = function () { peer.makingOffer = true; Promise.resolve() .then(function () { return pc.setLocalDescription(); }) .then(function () { sendSig(remoteId, { kind: "desc", desc: pc.localDescription }); }) .catch(function () {}) .then(function () { peer.makingOffer = false; }); }; pc.onconnectionstatechange = function () { var st = pc.connectionState; if (st === "failed" || st === "closed") { try { pc.close(); } catch (e) {} removeRemoteVideo(peer); delete peers[remoteId]; } }; return peer; } // --- media / videollamada (Fase 1) --- function setCallActive(on) { if (callRoot) callRoot.className = "karvan-call" + (on ? " active" : ""); } function setStatus(msg) { if (statusEl) statusEl.textContent = msg || ""; } function statusText() { var p = []; if (micActive) p.push("mic ON"); if (camActive) p.push("cam ON"); return p.length ? p.join(" · ") : ""; } function updateBtns() { if (btnMic) btnMic.className = "kc-btn" + (micActive ? " on" : ""); if (btnCam) btnCam.className = "kc-btn" + (camActive ? " on" : ""); } function attachRemote(peer, remoteId, e) { if (!remoteEl) return; if (!peer.vid) { peer.vid = document.createElement("video"); peer.vid.autoplay = true; peer.vid.playsInline = true; peer.vid.className = "kc-video kc-remote-vid"; peer.vid.setAttribute("data-peer", remoteId); remoteEl.appendChild(peer.vid); } if (e.streams && e.streams[0]) peer.vid.srcObject = e.streams[0]; else { if (!peer.remoteStream) peer.remoteStream = new MediaStream(); peer.remoteStream.addTrack(e.track); peer.vid.srcObject = peer.remoteStream; } setCallActive(true); } function removeRemoteVideo(peer) { if (peer && peer.vid && peer.vid.parentNode) { try { peer.vid.parentNode.removeChild(peer.vid); } catch (_) {} peer.vid = null; } } function addLocalTrack(track) { if (!localStream) return; localStream.addTrack(track); for (var k in peers) { var pc = peers[k].pc, senders = pc.getSenders(), existing = null; for (var s = 0; s < senders.length; s++) if (senders[s].track && senders[s].track.kind === track.kind) { existing = senders[s]; break; } if (existing) { try { existing.replaceTrack(track); } catch (_) {} } // swap (cambio de cámara) — sin renegociar else { try { pc.addTrack(track, localStream); } catch (_) {} } // kind nuevo — dispara onnegotiationneeded } } function removeLocalKind(kind) { if (!localStream) return; localStream.getTracks().filter(function (t) { return t.kind === kind; }).forEach(function (t) { t.stop(); localStream.removeTrack(t); }); for (var k in peers) { var senders = peers[k].pc.getSenders(); for (var s = 0; s < senders.length; s++) if (senders[s].track && senders[s].track.kind === kind) { try { senders[s].replaceTrack(null); } catch (_) {} } } } function onMediaErr(kind, x) { var name = (x && x.name) || "", msg = (x && x.message) || String(x); if (name === "NotAllowedError" || /denied|permission/i.test(msg)) setStatus("⛔ Cámara/micro denegados (en el móvil, pendiente del wrapper). El chat sigue."); else if (name === "NotFoundError") setStatus("⚠ No hay " + (kind === "audio" ? "micrófono" : "cámara") + "."); else if (name === "NotReadableError") setStatus("⚠ Dispositivo ocupado."); else setStatus("⚠ " + (name || "error") + ": " + msg); } function getMedia(kind) { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { setStatus("⚠ Cámara/micro no disponibles aquí."); return; } var c = (kind === "audio") ? { audio: true, video: false } : { audio: false, video: true }; setStatus((kind === "audio" ? "🎤" : "🎥") + " pidiendo permiso…"); navigator.mediaDevices.getUserMedia(c).then(function (s) { s.getTracks().forEach(addLocalTrack); if (kind === "audio") micActive = true; else camActive = true; setCallActive(true); updateBtns(); setStatus(statusText()); }).catch(function (x) { onMediaErr(kind, x); }); } function startCall() { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { setStatus("⚠ Cámara/micro no disponibles aquí."); return; } setStatus("📞 pidiendo cámara y micro…"); navigator.mediaDevices.getUserMedia({ audio: true, video: true }).then(function (s) { s.getTracks().forEach(addLocalTrack); micActive = true; camActive = true; setCallActive(true); updateBtns(); setStatus(statusText()); }).catch(function (x) { if (x && x.name === "NotFoundError") { getMedia("audio"); return; } // sin cámara → intenta solo audio onMediaErr("call", x); }); } function hangUp() { if (localStream) localStream.getTracks().forEach(function (t) { t.stop(); localStream.removeTrack(t); }); for (var k in peers) { var sn = peers[k].pc.getSenders(); for (var s = 0; s < sn.length; s++) { try { sn[s].replaceTrack(null); } catch (_) {} } } micActive = false; camActive = false; setCallActive(false); updateBtns(); setStatus(""); } if (btnStart) btnStart.addEventListener("click", startCall); if (btnMic) btnMic.addEventListener("click", function () { if (micActive) { removeLocalKind("audio"); micActive = false; updateBtns(); setStatus(statusText()); } else getMedia("audio"); }); if (btnCam) btnCam.addEventListener("click", function () { if (camActive) { removeLocalKind("video"); camActive = false; updateBtns(); setStatus(statusText()); } else getMedia("video"); }); if (btnStop) btnStop.addEventListener("click", hangUp); function onSignal(fromRemote, payload) { if (!RTC || !payload) return; var peer = ensurePeer(fromRemote); if (!peer) return; var pc = peer.pc; if (payload.kind === "desc") { var desc = payload.desc; if (!desc) return; // señal malformada (sin desc) — no reventar el lote de sondeo var collision = desc.type === "offer" && (peer.makingOffer || pc.signalingState !== "stable"); peer.ignoreOffer = !peer.polite && collision; if (peer.ignoreOffer) return; Promise.resolve() .then(function () { return pc.setRemoteDescription(desc); }) .then(function () { if (desc.type === "offer") { return pc.setLocalDescription().then(function () { sendSig(fromRemote, { kind: "desc", desc: pc.localDescription }); }); } }) .catch(function () {}); } else if (payload.kind === "ice") { Promise.resolve().then(function () { return pc.addIceCandidate(payload.candidate); }).catch(function () {}); } } // --- arranque --- showMsgInitialSeq(); function showMsgInitialSeq() { // registrar los mensajes ya renderizados por el servidor para no duplicarlos var items = listEl ? listEl.querySelectorAll(".karvan-msg") : []; for (var i = 0; i < items.length; i++) { var seq = parseInt(items[i].getAttribute("data-seq"), 10) || 0; var mid = items[i].getAttribute("data-mid") || ""; if (seq > lastSeq) lastSeq = seq; seen[mid ? "m" + mid : "s" + seq] = 1; } } pollMsgs(); setInterval(pollMsgs, 2000); if (RTC) { setLive(false); announce(); setInterval(announce, 8000); pollSignals(); setInterval(pollSignals, 2000); } })();