diff --git a/src/client/assets/themes/OasisMobile.css b/src/client/assets/themes/OasisMobile.css index 5f76e03..21be6b7 100644 --- a/src/client/assets/themes/OasisMobile.css +++ b/src/client/assets/themes/OasisMobile.css @@ -1184,3 +1184,21 @@ body { padding-top: 84px; } .karvan-compose { display: flex; gap: 8px; margin-top: 10px; } .karvan-compose input[type="text"] { flex: 1 1 auto; min-width: 0; padding: 10px 12px; background: #161616; border: 1px solid #333; border-radius: 10px; color: #FFD700; } + +/* --- Karvan videollamada (Fase 1): media P2P sobre el mismo WebRTC del chat --- */ +.karvan-call, .karvan-videos, .karvan-call-bar, .kc-status { + background-color: transparent; box-shadow: none; border-radius: 0; padding: 0; margin: 0; /* anular div{#222} */ +} +.karvan-call { margin: 10px 0; } +.karvan-videos { display: none; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 6px; margin-bottom: 8px; } +.karvan-call.active .karvan-videos { display: grid; } +.kc-video { width: 100%; aspect-ratio: 4 / 3; background: #000; border-radius: 10px; border: 1px solid #333; object-fit: cover; display: block; } +.kc-local { transform: scaleX(-1); } /* espejo, como cámara frontal */ +.kc-remote { display: contents; } /* los vídeos remotos entran en la misma rejilla */ +.karvan-call-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.kc-btn { min-height: 44px; min-width: 44px; padding: 8px 12px; border-radius: 999px; + border: 1px solid #333; background: #161616; color: #FFD700; line-height: 1; cursor: pointer; } +.kc-btn.on { background: #FFD700; color: #121212; border-color: #FFD700; } +.kc-btn-start { font-weight: 600; } +.kc-btn-stop { background: #241414; border-color: #5a2a2a; } +.kc-status { font-size: 12px; color: #FFDD44; margin-top: 6px; min-height: 14px; } diff --git a/src/client/public/js/karvan.js b/src/client/public/js/karvan.js index dfe7281..a57c896 100644 --- a/src/client/public/js/karvan.js +++ b/src/client/public/js/karvan.js @@ -23,6 +23,19 @@ 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); } @@ -130,11 +143,14 @@ 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 }; + 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; @@ -146,11 +162,92 @@ }; pc.onconnectionstatechange = function () { var st = pc.connectionState; - if (st === "failed" || st === "closed") { try { pc.close(); } catch (e) {} delete peers[remoteId]; } + 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); diff --git a/src/views/karvan_view.js b/src/views/karvan_view.js index 3c0b2ea..c63e212 100644 --- a/src/views/karvan_view.js +++ b/src/views/karvan_view.js @@ -3,7 +3,7 @@ // autodestrucción) y, si conectan pares, por data-channel WebRTC (instantáneo). El