// 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(); } }