import { ServiceDependencies } from "@/services"; import { MatchInfo } from "@/services/matching"; import { Destination, SignalMessage, SignalType, Topic } from "@/services/signaling"; import { SignalingRole } from "@/services/webrtc"; import { fromCallback, fromPromise } from "xstate"; import { CallContext } from "./types"; const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); // ===== listeners ===== /** * 미디어 스트림 초기화 및 이벤트 리스닝 */ export const listenToMedia = fromCallback(({ sendBack, input }) => { const { mediaManager } = input as ServiceDependencies; const handleStreamReady = (stream: MediaStream) => { sendBack({ type: "media.ready", stream}); }; const handleStreamError = (error: Error) => { sendBack({ type: "media.error", error}) }; // 리스너 등록 mediaManager.on("stream:ready", handleStreamReady); mediaManager.on("stream:error", handleStreamError); // 미디어 초기화 mediaManager.initialize(); // cleanup return () => { mediaManager.off("stream:ready", handleStreamReady); mediaManager.off("stream:error", handleStreamError); }; }); /** * WebSocket 연결 관리 */ export const listenToSignaling = fromCallback(({ sendBack, input }) => { const { signalingClient } = input as ServiceDependencies; const handleConnected = () => { sendBack({ type: "signaling.connected" }); }; const handleDisconnected = () => { sendBack({ type: 'signaling.disconnected' }); }; const handleError = (error: Error) => { sendBack({ type: 'signaling.error', error }); }; // 리스너 등록 signalingClient.on('signaling:connected', handleConnected); signalingClient.on('signaling:disconnected', handleDisconnected); signalingClient.on('signaling:error', handleError); // cleanup return () => { signalingClient.off('signaling:connected', handleConnected); signalingClient.off('signaling:disconnected', handleDisconnected); signalingClient.off('signaling:error', handleError); }; }); /** * 매칭 요청 및 이벤트 리스닝 */ export const listenToMatchResult = fromCallback(({ sendBack, input }) => { const { services, matchRequest } = input as CallContext; const handleMatchFound = (result: MatchInfo) => { sendBack({ type: "match.found", result }); }; const handleMatchFailed = (error: Error | string) => { sendBack({ type: "match.failed" }); }; const handleMatchTimeout = (error: Error | string) => { sendBack({ type: "match.timeout", error }); }; const handleMatchError = (error: Error) => { sendBack({ type: "match.error", error }); }; // 리스너 등록 services.matchingService.on("matching:found", handleMatchFound); services.matchingService.on("matching:failed", handleMatchFailed); services.matchingService.on("matching:timeout", handleMatchTimeout); services.matchingService.on("matching:error", handleMatchError); // 매칭 요청 services.matchingService.requestMatch(matchRequest!); // cleanup return () => { services.matchingService.off("matching:found", handleMatchFound); services.matchingService.off("matching:failed", handleMatchFailed); services.matchingService.off("matching:timeout", handleMatchTimeout); services.matchingService.off("matching:error", handleMatchError); }; }); /** * Offer, Answer, IceCandidate 신호 교환 */ export const listenToRTCSignal = fromCallback(({ sendBack, input }) => { const { services, sessionInfo } = input as CallContext; const signalingClient = services.signalingClient; const rtcConnectionManager = services.rtcConnectionManager; const handleSignalMassage = async (channel: Topic, payload: any) => { if (channel !== Topic.SIGNALING) return; const response = payload as SignalMessage; try { switch (response.type) { case SignalType.OFFER: await rtcConnectionManager.setRemoteDescription(response.data); const answer = await rtcConnectionManager.createAnswer(); signalingClient.send(Destination.SIGNALING, { receiverId: sessionInfo?.otherUserId, type: SignalType.ANSWER, data: answer }); break; case SignalType.ANSWER: await rtcConnectionManager.setRemoteDescription(response.data); break; case SignalType.CANDIDATE: await rtcConnectionManager.addIceCandidate(response.data); break; } } catch (error) { console.error("SignalMessage 처리 중 오류:", error); sendBack({ type: "connection.failed", error: error instanceof Error ? error : new Error(String(error)) }); } }; const handleIceCandidate = (candidate: RTCIceCandidate) => { signalingClient.send(Destination.SIGNALING, { receiverId: sessionInfo?.otherUserId, type: SignalType.CANDIDATE, data: candidate }); }; // 구독 const signalSubscription = signalingClient.subscribe(Topic.SIGNALING); // 리스너 등록 signalingClient.on('message', handleSignalMassage); rtcConnectionManager.on("rtc:ice-candidate", handleIceCandidate); // cleanup return () => { signalSubscription.unsubscribe(); signalingClient.off('message', handleSignalMassage); rtcConnectionManager.off("rtc:ice-candidate", handleIceCandidate); }; }); /** * RTCPeerConnection 이벤트 리스닝 */ export const listenToRTCConnection = fromCallback(({ sendBack, input }) => { const { rtcConnectionManager } = input as ServiceDependencies; const handleTrackAdded = (stream: MediaStream) => { sendBack({ type: "remote.stream-ready", stream }); }; const handleConnectionStateChange = (state: RTCPeerConnectionState) => { sendBack({ type: "connection.state-changed", state }); }; const handleIceConnectionStateChange = (state: RTCIceConnectionState) => { switch(state) { case "connected": case "completed": sendBack({ type: "connection.ice-connected" }); break; case "disconnected": sendBack({ type: "connection.ice-disconnected" }); break; case "failed": sendBack({ type: "connection.ice-failed" }); break; } }; const handleError = (error: Error) => { sendBack({ type: "connection.failed", error }); }; // 리스너 등록 rtcConnectionManager.on("rtc:track-added", handleTrackAdded); rtcConnectionManager.on("rtc:connection-state-change", handleConnectionStateChange); rtcConnectionManager.on("rtc:ice-connection-state-change", handleIceConnectionStateChange); rtcConnectionManager.on("rtc:error", handleError); // cleanup return () => { rtcConnectionManager.off("rtc:track-added", handleTrackAdded); rtcConnectionManager.off("rtc:connection-state-change", handleConnectionStateChange); rtcConnectionManager.off("rtc:ice-connection-state-change", handleIceConnectionStateChange); rtcConnectionManager.off("rtc:error", handleError); }; }); // ===== actions ===== const createOfferBase = async ( services: ServiceDependencies, sessionInfo: MatchInfo, isRestart: boolean = false ) => { if (sessionInfo.role === SignalingRole.ANSWER_USER) { return {} as RTCSessionDescriptionInit; } await delay(1000); // 임시 딜레이 try { const offer = isRestart ? await services.rtcConnectionManager.restartIce() : await services.rtcConnectionManager.createOffer(); services.signalingClient.send(Destination.SIGNALING, { receiverId: sessionInfo.otherUserId, type: SignalType.OFFER, data: offer }); } catch (error) { console.error("offer 생성 과정 에러: ", error); throw error; } }; /** * Offer 생성 */ export const createOffer = fromPromise(async ({ input }) => { const { services, sessionInfo } = input as CallContext; return createOfferBase(services, sessionInfo!, false); }); /** * Restart Offer 생성 */ export const restartIce = fromPromise(async ({ input }) => { const { services, sessionInfo } = input as CallContext; return createOfferBase(services, sessionInfo!, true); });