This commit is contained in:
2026-06-25 15:15:35 +02:00
parent 906e37e845
commit 1465016b56
21 changed files with 1979 additions and 666 deletions
+508
View File
@@ -0,0 +1,508 @@
"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<CallState>("idle")
const [isMuted, setIsMuted] = useState(false)
const [isSpeakerOn, setIsSpeakerOn] = useState(false)
const [callDuration, setCallDuration] = useState(0)
const [error, setError] = useState<string | null>(null)
const [isReady, setIsReady] = useState(false)
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null)
const [callerInfo, setCallerInfo] = useState<CallerInfo | null>(null)
const [calleeNumber, setCalleeNumber] = useState<string>("")
const [tokenLoading, setTokenLoading] = useState(true)
const [participants, setParticipants] = useState<Participant[]>([])
const socketRef = useRef<Socket | null>(null)
const pcRef = useRef<RTCPeerConnection | null>(null)
const localStreamRef = useRef<MediaStream | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
const timerRef = useRef<NodeJS.Timeout | null>(null)
const pendingCallRef = useRef<{ to: string; sdp: any } | null>(null)
const roomIdRef = useRef<string | null>(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<PlatformUser | null> => {
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,
}
}