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<WebRTCServiceOptions, "iceServers" | "stompClient">
> & { 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);
}
}
}