Ramble-FE / services / webrtcService.ts
webrtcService.ts
Raw
import { SignalType } from "@/types";
import { Client } from "@stomp/stompjs";

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.ICE_SERVERS,
            iceTransportPolicy: "all",
            bundlePolicy: "max-bundle",
            rtcpMuxPolicy: "require",
        });

        // add local tracks
        this.localStream
            .getTracks()
            .forEach((track) => this.pc?.addTrack(track, this.localStream!));

        // ICE connection state
        this.pc.oniceconnectionstatechange = () => {
            const state = this.pc?.iceConnectionState as
                | RTCIceConnectionState
                | undefined;
            console.log('ICE 연결 상태:', state);

            if (!state) return;

            if (state === "checking") this.opts.onConnectingChange(true);
            if (state === "connected" || state === "completed") {
                this.opts.onConnectingChange(false);
                this.opts.onConnectedChange(true);
            }
            if (state === "disconnected" || state === "failed" || state === "closed") {
                this.opts.onConnectingChange(false);
                this.opts.onConnectedChange(false);
            }
        };

        // ICE gathering -> send candidates
        this.pc.onicecandidate = ({ candidate }) => {
            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.ontrack = ({ streams }) => {
            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({
            offerToReceiveAudio: true,
            offerToReceiveVideo: true,
        });
        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().forEach((t) => 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({
                        offerToReceiveAudio: true,
                        offerToReceiveVideo: true,
                    });
                    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);
        }
    }
}