"use client" import { useState, useEffect, useRef, useCallback } from "react" import { io, Socket } from "socket.io-client" const STUN_SERVERS = { iceServers: [ { urls: "stun:stun.l.google.com:19302" }, { urls: "stun:stun1.l.google.com:19302" }, ], } const SIGNALING_URL = process.env.NEXT_PUBLIC_SIGNALING_URL || "http://localhost:3007" export type CallState = "idle" | "calling" | "ringing" | "connecting" | "connected" | "ended" | "missed" | "rejected" | "offline" | "busy" | "waiting" | "participant_joined" export interface PlatformUser { id: string username: string firstName: string lastName: string phone: string | null avatar: string | null online: boolean } export interface CallerInfo { id: string username: string firstName: string lastName: string avatar: string | null } export interface Participant { id: string label: string } export function useWebRTCCall(options?: { anonymous?: boolean }) { const isAnonymous = options?.anonymous ?? false const [callState, setCallState] = useState("idle") const [isMuted, setIsMuted] = useState(false) const [isSpeakerOn, setIsSpeakerOn] = useState(false) const [callDuration, setCallDuration] = useState(0) const [error, setError] = useState(null) const [isReady, setIsReady] = useState(false) const [remoteStream, setRemoteStream] = useState(null) const [callerInfo, setCallerInfo] = useState(null) const [calleeNumber, setCalleeNumber] = useState("") const [tokenLoading, setTokenLoading] = useState(true) const [participants, setParticipants] = useState([]) const socketRef = useRef(null) const pcRef = useRef(null) const localStreamRef = useRef(null) const audioRef = useRef(null) const timerRef = useRef(null) const pendingCallRef = useRef<{ to: string; sdp: any } | null>(null) const roomIdRef = useRef(null) const startTimer = useCallback(() => { setCallDuration(0) if (timerRef.current) clearInterval(timerRef.current) timerRef.current = setInterval(() => { setCallDuration((prev) => prev + 1) }, 1000) }, []) useEffect(() => { let socket: Socket | null = null let cancelled = false async function init() { try { let authToken: string | undefined if (!isAnonymous) { const res = await fetch("/api/auth/jwt") if (res.ok) { const data = await res.json() authToken = data.token } if (cancelled) return } socket = io(SIGNALING_URL, { auth: authToken ? { token: authToken } : undefined, transports: ["websocket", "polling"], }) socket.on("connect", () => setIsReady(true)) socket.on("disconnect", () => setIsReady(false)) if (!isAnonymous) { socket.on("call:incoming", ({ from, sdp, callerInfo: info }) => { setCallerInfo(info) setCallState("ringing") pendingCallRef.current = { to: from, sdp } }) socket.on("call:answered", async ({ from, sdp }) => { setCallState("connecting") if (pcRef.current && sdp) { try { await pcRef.current.setRemoteDescription(new RTCSessionDescription(sdp)) } catch {} } }) socket.on("call:ice-candidate", async ({ from, candidate }) => { if (pcRef.current && candidate) { try { await pcRef.current.addIceCandidate(new RTCIceCandidate(candidate)) } catch {} } }) socket.on("call:ended", ({ from }) => { setCallState("ended") cleanupCall() }) socket.on("call:rejected", ({ from }) => { setCallState("rejected") cleanupCall() }) socket.on("call:busy", ({ from }) => { setCallState("busy") cleanupCall() }) socket.on("call:user-offline", ({ userId }) => { setCallState("offline") cleanupCall() }) } socket.on("room:waiting", ({ roomId, participants: pList }) => { setParticipants(pList || []) setCallState("waiting") }) socket.on("room:participant-joined", ({ roomId, participants: pList }) => { setParticipants(pList || []) setCallState("participant_joined") }) socket.on("room:initiate-offer", async ({ roomId, participants: pList }) => { setParticipants(pList || []) setCallState("participant_joined") try { const pc = new RTCPeerConnection(STUN_SERVERS) pc.onicecandidate = (e) => { if (e.candidate && roomIdRef.current) { socket?.emit("room:ice-candidate", { roomId: roomIdRef.current, candidate: e.candidate.toJSON(), }) } } pc.ontrack = (e) => { setRemoteStream(e.streams[0]) const audio = new Audio() audio.srcObject = e.streams[0] audio.autoplay = true audioRef.current = audio audio.play().catch(() => {}) } pc.oniceconnectionstatechange = () => { if (pc.iceConnectionState === "disconnected" || pc.iceConnectionState === "failed") { setCallState("ended") cleanupCall() } } const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }) localStreamRef.current = stream stream.getTracks().forEach((track) => pc.addTrack(track, stream)) pcRef.current = pc const offer = await pc.createOffer() await pc.setLocalDescription(offer) setCallState("connecting") socket?.emit("room:offer", { roomId, sdp: pc.localDescription }) } catch (e) { setError("Failed to start call") cleanupCall() } }) socket.on("room:offer", async ({ sdp }) => { try { setCallState("connecting") const pc = new RTCPeerConnection(STUN_SERVERS) pc.onicecandidate = (e) => { if (e.candidate && roomIdRef.current) { socket?.emit("room:ice-candidate", { roomId: roomIdRef.current, candidate: e.candidate.toJSON(), }) } } pc.ontrack = (e) => { setRemoteStream(e.streams[0]) const audio = new Audio() audio.srcObject = e.streams[0] audio.autoplay = true audioRef.current = audio audio.play().catch(() => {}) } pc.oniceconnectionstatechange = () => { if (pc.iceConnectionState === "disconnected" || pc.iceConnectionState === "failed") { setCallState("ended") cleanupCall() } } const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }) localStreamRef.current = stream stream.getTracks().forEach((track) => pc.addTrack(track, stream)) pcRef.current = pc await pc.setRemoteDescription(new RTCSessionDescription(sdp)) const answer = await pc.createAnswer() await pc.setLocalDescription(answer) socket?.emit("room:answer", { roomId: roomIdRef.current, sdp: pc.localDescription }) setCallState("connected") startTimer() } catch (e) { setError("Failed to join call") cleanupCall() } }) socket.on("room:answer", async ({ sdp }) => { if (pcRef.current) { try { await pcRef.current.setRemoteDescription(new RTCSessionDescription(sdp)) setCallState("connected") startTimer() } catch {} } }) socket.on("room:ice-candidate", async ({ candidate }) => { if (pcRef.current) { try { await pcRef.current.addIceCandidate(new RTCIceCandidate(candidate)) } catch {} } }) socket.on("room:participant-left", ({ roomId, participants: pList }) => { setParticipants(pList || []) setCallState("ended") cleanupCall() }) socketRef.current = socket } finally { setTokenLoading(false) } } init() return () => { cancelled = true if (socket) socket.disconnect() cleanupCall() } }, [isAnonymous]) const cleanupCall = useCallback(() => { if (timerRef.current) { clearInterval(timerRef.current) timerRef.current = null } if (pcRef.current) { pcRef.current.close() pcRef.current = null } if (localStreamRef.current) { localStreamRef.current.getTracks().forEach((t) => t.stop()) localStreamRef.current = null } setRemoteStream(null) setIsMuted(false) setIsSpeakerOn(false) if (audioRef.current) { audioRef.current.pause() audioRef.current.srcObject = null audioRef.current = null } setCallDuration(0) pendingCallRef.current = null roomIdRef.current = null setParticipants([]) }, []) const createPeerConnection = useCallback(async () => { const pc = new RTCPeerConnection(STUN_SERVERS) pc.onicecandidate = (e) => { if (e.candidate && pendingCallRef.current) { socketRef.current?.emit("call:ice-candidate", { to: pendingCallRef.current.to, candidate: e.candidate.toJSON(), }) } } pc.ontrack = (e) => { setRemoteStream(e.streams[0]) const audio = new Audio() audio.srcObject = e.streams[0] audio.autoplay = true audioRef.current = audio audio.play().catch(() => {}) } pc.oniceconnectionstatechange = () => { if (pc.iceConnectionState === "disconnected" || pc.iceConnectionState === "failed") { setCallState("ended") cleanupCall() } } try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }) localStreamRef.current = stream stream.getTracks().forEach((track) => pc.addTrack(track, stream)) } catch { setError("Microphone access denied") } pcRef.current = pc return pc }, [cleanupCall]) const lookupUser = useCallback(async (phone: string): Promise => { return new Promise((resolve) => { socketRef.current?.emit("user:lookup", { phone }, (result: any) => { if (result?.found) { resolve(result.user) } else { resolve(null) } }) }) }, []) const makeCall = useCallback(async (phoneNumber: string) => { try { setError(null) setCalleeNumber(phoneNumber) const user = await lookupUser(phoneNumber) if (!user) { setCallState("missed") setError("Not on Platform") return } if (!user.online) { setCallState("offline") setError("User is offline") return } const pc = await createPeerConnection() setCallState("calling") const offer = await pc.createOffer() await pc.setLocalDescription(offer) socketRef.current?.emit("call:offer", { to: user.id, sdp: pc.localDescription, callerInfo: null, }) pendingCallRef.current = { to: user.id, sdp: null } setCallState("ringing") } catch { setError("Failed to start call") cleanupCall() } }, [createPeerConnection, lookupUser, cleanupCall]) const acceptCall = useCallback(async () => { if (!pendingCallRef.current) return setCallState("connecting") try { const pc = await createPeerConnection() await pc.setRemoteDescription(new RTCSessionDescription(pendingCallRef.current.sdp)) const answer = await pc.createAnswer() await pc.setLocalDescription(answer) socketRef.current?.emit("call:answer", { to: pendingCallRef.current.to, sdp: pc.localDescription, }) setCallState("connected") startTimer() } catch { setError("Failed to accept call") cleanupCall() } }, [createPeerConnection, cleanupCall, startTimer]) const rejectCall = useCallback(() => { if (pendingCallRef.current) { socketRef.current?.emit("call:reject", { to: pendingCallRef.current.to }) } setCallState("idle") cleanupCall() }, [cleanupCall]) const endCall = useCallback(() => { if (roomIdRef.current) { socketRef.current?.emit("room:leave", { roomId: roomIdRef.current }) } if (!isAnonymous) { if (callState === "ringing" && pendingCallRef.current) { socketRef.current?.emit("call:reject", { to: pendingCallRef.current.to }) } if (callState === "connected" || callState === "connecting" || callState === "calling") { const to = pendingCallRef.current?.to if (to) { socketRef.current?.emit("call:end", { to }) } } } setCallState("ended") cleanupCall() }, [callState, cleanupCall, isAnonymous]) const toggleSpeaker = useCallback(() => { setIsSpeakerOn((prev) => !prev) }, []) const toggleMute = useCallback(() => { if (localStreamRef.current) { localStreamRef.current.getAudioTracks().forEach((track) => { track.enabled = isMuted }) setIsMuted(!isMuted) } }, [isMuted]) const formatDuration = useCallback((seconds: number) => { const m = Math.floor(seconds / 60).toString().padStart(2, "0") const s = (seconds % 60).toString().padStart(2, "0") return `${m}:${s}` }, []) const createRoom = useCallback(() => { const roomId = crypto.randomUUID?.() || Math.random().toString(36).slice(2, 14) const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || (typeof window !== "undefined" ? window.location.origin : "http://localhost:3000") const link = `${baseUrl}/join/${roomId}` return { roomId, link } }, []) const joinRoom = useCallback((roomId: string, displayName?: string) => { roomIdRef.current = roomId setCallState("calling") socketRef.current?.emit("room:join", { roomId, displayName: displayName || "" }) }, []) const leaveRoom = useCallback(() => { if (roomIdRef.current) { socketRef.current?.emit("room:leave", { roomId: roomIdRef.current }) } setCallState("ended") cleanupCall() }, [cleanupCall]) return { callState, isCallActive: callState === "connected" || callState === "connecting" || callState === "ringing" || callState === "calling" || callState === "waiting" || callState === "participant_joined", isIncoming: callState === "ringing" && !!callerInfo, isMuted, isSpeakerOn, callDuration, error, isReady, remoteStream, callerInfo, calleeNumber, participants, createRoom, joinRoom, leaveRoom, makeCall, acceptCall, rejectCall, endCall, toggleMute, toggleSpeaker, lookupUser, formatDuration, tokenLoading, } }