.....
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user