- 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>
209 lines
6.4 KiB
JavaScript
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();
|
|
}
|
|
}
|