cipher/public/js/call.js
maverick 67367d5e83 Add WebRTC E2E voice/video calling (1:1)
- call.js: CallManager — RTCPeerConnection lifecycle, offer/answer/ICE/bye state
  machine, mute/camera toggles, hangup. STUN (Google + Cloudflare) for NAT.
- Signaling (SDP + ICE) rides the existing E2E message relay as {k:"call"}
  payloads — never seen by the server in clear. Media is P2P, encrypted by
  WebRTC's mandatory DTLS-SRTP, so audio/video is end-to-end and never hits R2.
- app.js: call buttons (📞/🎥) in DM headers, incoming/outgoing/connected call
  overlay with video tiles + PiP, mute/camera/hangup, ring pulse, duration timer.
- Verified signaling state machine (mocked WebRTC): both sides reach 'connected'.
  Group calls not supported yet (needs SFU). SW cache v5->v6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 13:21:52 +10:00

209 lines
6.4 KiB
JavaScript

// 1:1 encrypted voice/video calls.
//
// Signaling (SDP offer/answer + ICE candidates) travels E2E-encrypted through
// the normal message relay as {k:"call"} payloads — the server never sees it in
// the clear. The media itself is peer-to-peer and encrypted by WebRTC's
// mandatory DTLS-SRTP, so audio/video is end-to-end too and never touches the
// server (STUN is only used to discover network paths).
const ICE_SERVERS = [
{ urls: "stun:stun.l.google.com:19302" },
{ urls: "stun:stun.cloudflare.com:3478" },
];
export class CallManager {
// sendSignal(peerUserId, obj) -> encrypts & relays a {k:"call"} payload.
// onState(state) -> drives the UI. randomId() -> string (no Math.random in some ctxs).
constructor({ sendSignal, onState }) {
this.sendSignal = sendSignal;
this.onState = onState;
this.reset();
}
reset() {
this.pc = null;
this.localStream = null;
this.remoteStream = null;
this.peerUserId = null;
this.peerName = null;
this.callId = null;
this.callType = null; // "audio" | "video"
this.role = null; // "caller" | "callee"
this.phase = "idle"; // idle | outgoing | incoming | connected | ended
this.pendingOffer = null;
this.pendingIce = [];
this.muted = false;
this.cameraOff = false;
this.startedAt = 0;
}
emit() {
this.onState({
phase: this.phase,
peerName: this.peerName,
callType: this.callType,
localStream: this.localStream,
remoteStream: this.remoteStream,
muted: this.muted,
cameraOff: this.cameraOff,
startedAt: this.startedAt,
role: this.role,
});
}
busy() {
return this.phase !== "idle" && this.phase !== "ended";
}
newPc() {
const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
pc.onicecandidate = (e) => {
if (e.candidate) {
this.sendSignal(this.peerUserId, { k: "call", callId: this.callId, sub: "ice", candidate: e.candidate });
}
};
pc.ontrack = (e) => {
this.remoteStream = e.streams[0];
this.emit();
};
pc.onconnectionstatechange = () => {
if (pc.connectionState === "connected" && this.phase !== "connected") {
this.phase = "connected";
if (!this.startedAt) this.startedAt = Date.now();
this.emit();
}
if (["failed", "disconnected", "closed"].includes(pc.connectionState) && this.phase === "connected") {
this.hangup(true);
}
};
return pc;
}
async getMedia(video) {
return navigator.mediaDevices.getUserMedia({
audio: true,
video: video ? { facingMode: "user" } : false,
});
}
// ---- outgoing ----
async startCall(peerUserId, peerName, video, callId) {
if (this.busy()) return;
this.peerUserId = peerUserId;
this.peerName = peerName;
this.callType = video ? "video" : "audio";
this.role = "caller";
this.callId = callId;
this.phase = "outgoing";
this.emit();
try {
this.localStream = await this.getMedia(video);
} catch {
this.phase = "ended";
this.emit();
throw new Error("Camera/microphone permission denied");
}
this.pc = this.newPc();
for (const t of this.localStream.getTracks()) this.pc.addTrack(t, this.localStream);
const offer = await this.pc.createOffer();
await this.pc.setLocalDescription(offer);
this.sendSignal(peerUserId, { k: "call", callId: this.callId, sub: "offer", callType: this.callType, sdp: offer, peerName: "caller" });
this.emit();
}
// ---- incoming signal handling ----
async onSignal(fromUserId, data) {
if (data.sub === "offer") {
if (this.busy()) {
// already on a call — auto-decline
this.sendSignal(fromUserId, { k: "call", callId: data.callId, sub: "bye", reason: "busy" });
return;
}
this.peerUserId = fromUserId;
this.callId = data.callId;
this.callType = data.callType || "audio";
this.role = "callee";
this.phase = "incoming";
this.pendingOffer = data.sdp;
this.emit();
return;
}
if (!this.callId || data.callId !== this.callId) return;
if (data.sub === "answer") {
if (this.pc && !this.pc.currentRemoteDescription) {
await this.pc.setRemoteDescription(data.sdp);
await this.flushIce();
}
} else if (data.sub === "ice") {
if (this.pc && this.pc.remoteDescription) {
try { await this.pc.addIceCandidate(data.candidate); } catch {}
} else {
this.pendingIce.push(data.candidate);
}
} else if (data.sub === "bye") {
this.hangup(true);
}
}
async flushIce() {
for (const c of this.pendingIce) {
try { await this.pc.addIceCandidate(c); } catch {}
}
this.pendingIce = [];
}
// ---- accept an incoming call ----
async accept() {
if (this.phase !== "incoming") return;
try {
this.localStream = await this.getMedia(this.callType === "video");
} catch {
this.decline();
throw new Error("Camera/microphone permission denied");
}
this.pc = this.newPc();
for (const t of this.localStream.getTracks()) this.pc.addTrack(t, this.localStream);
await this.pc.setRemoteDescription(this.pendingOffer);
await this.flushIce();
const answer = await this.pc.createAnswer();
await this.pc.setLocalDescription(answer);
this.sendSignal(this.peerUserId, { k: "call", callId: this.callId, sub: "answer", sdp: answer });
this.phase = "connected";
if (!this.startedAt) this.startedAt = Date.now();
this.emit();
}
decline() {
if (this.peerUserId) this.sendSignal(this.peerUserId, { k: "call", callId: this.callId, sub: "bye", reason: "declined" });
this.teardown();
}
hangup(silent) {
if (!silent && this.peerUserId) {
this.sendSignal(this.peerUserId, { k: "call", callId: this.callId, sub: "bye" });
}
this.teardown();
}
teardown() {
if (this.pc) { try { this.pc.close(); } catch {} }
if (this.localStream) this.localStream.getTracks().forEach((t) => t.stop());
this.phase = "ended";
this.emit();
this.reset();
this.emit();
}
toggleMute() {
this.muted = !this.muted;
if (this.localStream) this.localStream.getAudioTracks().forEach((t) => (t.enabled = !this.muted));
this.emit();
}
toggleCamera() {
this.cameraOff = !this.cameraOff;
if (this.localStream) this.localStream.getVideoTracks().forEach((t) => (t.enabled = !this.cameraOff));
this.emit();
}
}