[0.9.1] FORK_IA_UX v7: Karvan calls Fase 1 — video/mic UI + media over the existing WebRTC
Adds a call panel to the Karvan room (local video + remote gallery + call/mic/cam/hang-up buttons) and grafts getUserMedia + addTrack + ontrack onto the peer connections we already use for the text data-channel (perfect negotiation handles the renegotiation). No backend, no new !important. Verified: getUserMedia path attaches audio+video tracks to the local video and activates the panel (chromium fake device). getUserMedia will fail gracefully on the current wrapper (no camera/mic permission) with a clear message — the chat keeps working. Fases 2 (SSB cross-device signaling) and 3 (wrapper permissions) pending.
This commit is contained in:
parent
423e973f02
commit
e2598ab747
3 changed files with 134 additions and 3 deletions
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
// autodestrucción) y, si conectan pares, por data-channel WebRTC (instantáneo). El <script> solo
|
||||
// se carga en la página de sala; el WebRTC usa data-channel (sin cámara/mic => sin permisos extra).
|
||||
|
||||
const { div, section, h2, h3, p, a, form, input, button, span, ul, li, script, label } =
|
||||
const { div, section, h2, h3, p, a, form, input, button, span, ul, li, script, label, video } =
|
||||
require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n } = require("./main_views");
|
||||
|
||||
|
|
@ -80,6 +80,22 @@ exports.karvanRoomView = (room, messages, params = {}) =>
|
|||
span({ class: "kl-dot" }),
|
||||
span({ class: "kl-txt", id: "karvan-live-txt" }, i18n.karvanRelay || "server relay")
|
||||
),
|
||||
// Fase 1: panel de videollamada (media P2P sobre el mismo WebRTC del chat). La galería
|
||||
// aparece al iniciar; los botones cablean cámara/micro. En el móvil actual getUserMedia
|
||||
// fallará hasta que el wrapper conceda permisos (Fases 2/3) → se avisa en #kc-status.
|
||||
div({ class: "karvan-call", id: "karvan-call" },
|
||||
div({ class: "karvan-videos", id: "karvan-videos" },
|
||||
video({ id: "karvan-local", class: "kc-video kc-local", muted: "muted", autoplay: "autoplay", playsinline: "playsinline" }),
|
||||
div({ class: "kc-remote", id: "karvan-remote" })
|
||||
),
|
||||
div({ class: "karvan-call-bar" },
|
||||
button({ type: "button", class: "kc-btn kc-btn-start", id: "kc-start" }, "📞 " + (i18n.karvanCall || "Call")),
|
||||
button({ type: "button", class: "kc-btn", id: "kc-mic", "aria-label": "mic" }, "🎤"),
|
||||
button({ type: "button", class: "kc-btn", id: "kc-cam", "aria-label": "camera" }, "🎥"),
|
||||
button({ type: "button", class: "kc-btn kc-btn-stop", id: "kc-stop", "aria-label": "hang up" }, "⛔")
|
||||
),
|
||||
div({ class: "kc-status", id: "kc-status" }, "")
|
||||
),
|
||||
ul({ class: "karvan-msgs", id: "karvan-msgs" }, ...(messages || []).map(renderMsg)),
|
||||
form({ class: "karvan-compose", method: "POST", action: "/karvan/" + room.id + "/msg", id: "karvan-form" },
|
||||
input({ type: "hidden", name: "from", value: params.selfId || "" }),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue