import { SignalType } from "@/types"; import { Client } from "@stomp/stompjs"; import { MediaStream, RTCIceCandidate, RTCPeerConnection, RTCSessionDescription } from "react-native-webrtc"; type StreamHandler = (stream: MediaStream | null) => void; type BoolHandler = (v: boolean) => void; export interface WebRTCServiceOptions { stompClient: Client; onRemoteStream?: StreamHandler; onConnectingChange?: BoolHandler; onConnectedChange?: BoolHandler; } export class WebRTCService { private pc: RTCPeerConnection | null = null; private localStream: MediaStream | null = null; private remoteStream: MediaStream | null = null; private stompClient: Client; private subscription: any | null = null; private remoteId: string | null = null; private pendingCandidates: any[] = []; private ICE_SERVERS = [{ urls: 'stun:stun.l.google.com:19302' }]; private opts: Required< Omit > & { iceServers: RTCIceServer[] }; constructor(options: WebRTCServiceOptions) { const { stompClient, onRemoteStream, onConnectingChange, onConnectedChange, } = options; this.stompClient = stompClient; this.opts = { iceServers: this.ICE_SERVERS, onRemoteStream: onRemoteStream ?? (() => {}), onConnectingChange: onConnectingChange ?? (() => {}), onConnectedChange: onConnectedChange ?? (() => {}), }; } initialize(localStream: MediaStream) { this.localStream = localStream; this.cleanupPeer(); console.log('Initializing WebRTC with local stream:', localStream); this.pc = new RTCPeerConnection({ iceServers: this.opts.iceServers, iceTransportPolicy: "all", bundlePolicy: "max-bundle", rtcpMuxPolicy: "require", } as any); // add local tracks this.localStream .getTracks() .forEach((t: any) => this.pc?.addTrack(t, this.localStream!)); // ICE connection state (this.pc as any).oniceconnectionstatechange = () => { const st = this.pc?.iceConnectionState as | RTCIceConnectionState | undefined; if (!st) return; if (st === "checking") this.opts.onConnectingChange(true); if (st === "connected" || st === "completed") { this.opts.onConnectingChange(false); this.opts.onConnectedChange(true); } if (st === "disconnected" || st === "failed" || st === "closed") { this.opts.onConnectingChange(false); this.opts.onConnectedChange(false); } }; // ICE gathering -> send candidates (this.pc as any).onicecandidate = ({ candidate }: any) => { if (!candidate) return; if (this.remoteId) { this.sendSignal(SignalType.CANDIDATE, candidate); } else { (this as any).pendingCandidates ??= []; (this as any).pendingCandidates.push(candidate); } }; // remote track (this.pc as any).ontrack = ({ streams }: any) => { const stream = streams?.[0] ?? null; this.remoteStream = stream; this.opts.onRemoteStream(stream); }; // subscribe signaling this.subscribeSignaling(); } async startCall() { if (!this.pc) throw new Error("PeerConnection not initialized"); if (!this.stompClient?.connected) throw new Error("STOMP not connected"); this.opts.onConnectingChange(true); const offer = await this.pc.createOffer(); await this.pc.setLocalDescription(offer); this.sendSignal(SignalType.OFFER, offer); } endCall() { this.opts.onConnectingChange(false); this.opts.onConnectedChange(false); if (this.remoteStream) { (this.remoteStream.getTracks() as any[]).forEach((t: any) => t.stop() ); this.remoteStream = null; this.opts.onRemoteStream(null); } this.cleanupPeer(); this.remoteId = null; } dispose() { try { if (this.subscription?.unsubscribe) this.subscription.unsubscribe(); } catch {} this.subscription = null; this.endCall(); } private cleanupPeer() { if (this.pc) { try { this.pc.close(); } catch {} } this.pc = null; } private subscribeSignaling() { console.log("Subscribing to signaling channel"); if (!this.stompClient?.connected) return; // clean existing if (this.subscription?.unsubscribe) { try { this.subscription.unsubscribe(); } catch {} } this.subscription = this.stompClient.subscribe( "/user/queue/signal", (message: any) => this.onSignal(message) ); } private sendSignal(type: SignalType, data: any) { if (!this.stompClient?.connected) return; this.stompClient.publish({ destination: "/app/signal", body: JSON.stringify({ receiverId: this.remoteId, type, data, }), headers: { "content-type": "application/json" }, }); } private async onSignal(message: any) { if (!this.pc) return; try { const { senderId, type, data } = JSON.parse(message.body); const prevRemoteId = this.remoteId; if (senderId && senderId !== this.remoteId) { this.remoteId = senderId; // 초기화 이후 최초로 remoteId가 생겼다면 보류 후보를 전송 if (!prevRemoteId && (this as any).pendingCandidates?.length) { for (const c of (this as any).pendingCandidates) { this.sendSignal(SignalType.CANDIDATE, c); } (this as any).pendingCandidates = []; } } switch (type as SignalType) { case SignalType.OFFER: { console.log("Received OFFER from:", senderId); await this.pc.setRemoteDescription( new RTCSessionDescription(data) ); const answer = await this.pc.createAnswer(); await this.pc.setLocalDescription(answer); this.sendSignal(SignalType.ANSWER, answer); break; } case SignalType.ANSWER: { console.log("Received ANSWER from:", senderId); await this.pc.setRemoteDescription( new RTCSessionDescription(data) ); break; } case SignalType.CANDIDATE: { console.log("Received ICE CANDIDATE from:", senderId); if (this.pc.remoteDescription) { await this.pc.addIceCandidate( new RTCIceCandidate(data) ); } break; } default: break; } } catch (err) { console.error("시그널 처리 중 오류:", err); } } }