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);
});