// useWebRTC.ts
import { useState, useEffect, useRef, useCallback } from 'react'
export type CallState = 'idle' | 'connecting' | 'connected' | 'failed' | 'ended'
interface UseWebRTCOptions {
callId: string
wsUrl: string
onHangup?: () => void
}
interface UseWebRTCReturn {
callState: CallState
localStream: MediaStream | null
remoteStream: MediaStream | null
isMuted: boolean
isCameraOff: boolean
toggleMute: () => void
toggleCamera: () => void
hangup: () => void
errorMessage: string | null
}
const ICE_SERVERS: RTCIceServer[] = [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
{ urls: 'stun:stun2.l.google.com:19302' },
{ urls: 'stun:stun3.l.google.com:19302' },
{ urls: 'stun:stun4.l.google.com:19302' },
]
export function useWebRTC({ callId, wsUrl, onHangup }: UseWebRTCOptions): UseWebRTCReturn {
const [callState, setCallState] = useState<CallState>('idle')
const [localStream, setLocalStream] = useState<MediaStream | null>(null)
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null)
const [isMuted, setIsMuted] = useState(false)
const [isCameraOff, setIsCameraOff] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
// useRef でPCとWSを保持(非同期ハンドラ内でも最新値を参照できる)
const pcRef = useRef<RTCPeerConnection | null>(null)
const wsRef = useRef<WebSocket | null>(null)
const localStreamRef = useRef<MediaStream | null>(null)
// remoteDescriptionが設定される前に届いたICE候補をバッファリング
const pendingCandidatesRef = useRef<RTCIceCandidateInit[]>([])
const callStateRef = useRef<CallState>('idle')
const updateCallState = useCallback((state: CallState) => {
callStateRef.current = state
setCallState(state)
}, [])
const cleanup = useCallback(() => {
localStreamRef.current?.getTracks().forEach(track => track.stop())
localStreamRef.current = null
setLocalStream(null)
if (pcRef.current) {
pcRef.current.close()
pcRef.current = null
}
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.close()
}
wsRef.current = null
pendingCandidatesRef.current = []
}, [])
useEffect(() => {
if (!callId || !wsUrl) return
updateCallState('idle')
const socket = new WebSocket(wsUrl)
wsRef.current = socket
socket.onopen = () => {
console.log('WebSocket connected')
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
.then(stream => {
localStreamRef.current = stream
setLocalStream(stream)
const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS })
pcRef.current = pc
stream.getTracks().forEach(track => pc.addTrack(track, stream))
pc.ontrack = (event) => {
if (event.streams?.[0]) setRemoteStream(event.streams[0])
}
pc.oniceconnectionstatechange = () => {
console.log('ICE connection state:', pc.iceConnectionState)
if (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') {
updateCallState('connected')
} else if (pc.iceConnectionState === 'failed' || pc.iceConnectionState === 'disconnected') {
updateCallState('failed')
setErrorMessage('接続が切断されました')
}
}
// Trickle ICE: 候補が見つかる都度送信
pc.onicecandidate = (event) => {
if (socket.readyState !== WebSocket.OPEN) return
socket.send(JSON.stringify({
type: 'ice',
callId,
candidate: event.candidate ? event.candidate.toJSON() : null,
}))
}
pc.createOffer()
.then(offer => pc.setLocalDescription(offer))
.then(() => {
if (socket.readyState !== WebSocket.OPEN || !pc.localDescription) return
socket.send(JSON.stringify({ type: 'offer', callId, sdp: pc.localDescription.sdp }))
updateCallState('connecting')
})
.catch(err => {
console.error('SDP offer error:', err)
updateCallState('failed')
setErrorMessage('接続の開始に失敗しました')
})
})
.catch(err => {
console.error('getUserMedia error:', err)
updateCallState('failed')
setErrorMessage('カメラ・マイクへのアクセスが拒否されました')
})
}
socket.onmessage = async (event: MessageEvent) => {
let msg: { type: string; sdp?: string; candidate?: RTCIceCandidateInit | null; message?: string }
try { msg = JSON.parse(event.data) } catch { return }
console.log('WebSocket message:', msg.type)
const pc = pcRef.current
switch (msg.type) {
case 'answer':
if (!pc || !msg.sdp) break
await pc.setRemoteDescription({ type: 'answer', sdp: msg.sdp }).catch(console.error)
for (const c of pendingCandidatesRef.current) {
await pc.addIceCandidate(c).catch(console.error)
}
pendingCandidatesRef.current = []
break
case 'ice':
if (!pc || msg.candidate == null) break
if (pc.remoteDescription) {
await pc.addIceCandidate(msg.candidate).catch(console.error)
} else {
pendingCandidatesRef.current.push(msg.candidate)
}
break
case 'hangup':
cleanup()
updateCallState('ended')
onHangup?.()
break
case 'error':
updateCallState('failed')
setErrorMessage(msg.message ?? '接続エラーが発生しました')
break
}
}
socket.onerror = () => {
if (callStateRef.current !== 'ended') {
updateCallState('failed')
setErrorMessage('通信エラーが発生しました')
}
}
socket.onclose = () => {
if (callStateRef.current !== 'ended' && callStateRef.current !== 'failed') {
updateCallState('failed')
setErrorMessage('接続が切断されました')
}
}
return () => { cleanup() }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wsUrl, callId])
const hangup = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'hangup', callId }))
}
cleanup()
updateCallState('ended')
}, [callId, cleanup, updateCallState])
const toggleMute = useCallback(() => {
const track = localStreamRef.current?.getAudioTracks()[0]
if (!track) return
track.enabled = !track.enabled
setIsMuted(!track.enabled)
}, [])
const toggleCamera = useCallback(() => {
const track = localStreamRef.current?.getVideoTracks()[0]
if (!track) return
track.enabled = !track.enabled
setIsCameraOff(!track.enabled)
}, [])
return { callState, localStream, remoteStream, isMuted, isCameraOff, toggleMute, toggleCamera, hangup, errorMessage }
}