509 lines
15 KiB
TypeScript
509 lines
15 KiB
TypeScript
"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,
|
|
}
|
|
}
|