From 59832d9177f372382fe98d26fbb4a6f76c32c037 Mon Sep 17 00:00:00 2001 From: PARK NA HYUN <116629752+studioOwol@users.noreply.github.com> Date: Mon, 11 Nov 2024 17:03:44 +0900 Subject: [PATCH] =?UTF-8?q?[FE][hotfix]=20WebRTC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81=20(#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: JS 접근 제어자로 변경 * refactor: WebRTC 코드 리팩터링 및 렌더링 문제 일부 해결 * docs: README.md 업데이트 --- fe/README.md | 9 +- fe/src/hooks/useGameRoom.ts | 74 ++++++ fe/src/hooks/useRoomActions.ts | 83 ++++++ .../pages/GamePage/PlayerList/VolumeBar.tsx | 2 +- fe/src/pages/GamePage/index.tsx | 21 +- fe/src/services/gameSocket.ts | 125 +++++---- fe/src/services/signalingSocket.ts | 248 ++++++------------ 7 files changed, 351 insertions(+), 211 deletions(-) create mode 100644 fe/src/hooks/useGameRoom.ts create mode 100644 fe/src/hooks/useRoomActions.ts diff --git a/fe/README.md b/fe/README.md index 26e73f8..f4dbb49 100644 --- a/fe/README.md +++ b/fe/README.md @@ -2,4 +2,11 @@ ### 새로운 게임방이 추가될 때 순서대로 추가되지 않음 -### 게임 페이지에서 새로고침하면 렌더링이 되지 않음 +### 게임 페이지에서 새로고침하면 렌더링이 완전히 되지 않음 + +- VolumeBar `defaultValue` state 업데이트가 연속적으로 발생할 수 있는 구조 +- Controlled Component? Uncontrolled Component? 혼용하면 무한 상태 업데이트 루프 발생? + +## 게임 페이지에서 새로고침하면 players 방장을 제외하고 사라짐, 마이크도 안 됨 + +- 새로고침이 아주 문제다! diff --git a/fe/src/hooks/useGameRoom.ts b/fe/src/hooks/useGameRoom.ts new file mode 100644 index 0000000..d852990 --- /dev/null +++ b/fe/src/hooks/useGameRoom.ts @@ -0,0 +1,74 @@ +import { gameSocket } from '@/services/gameSocket'; +import { getRoomsQuery } from '@/stores/queries/getRoomsQuery'; +import useRoomStore from '@/stores/zustand/useRoomStore'; +import { useCallback, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; + +export const useGameRoom = (roomId: string | undefined) => { + const navigate = useNavigate(); + const setCurrentRoom = useRoomStore((state) => state.setCurrentRoom); + const { data: rooms, isLoading } = getRoomsQuery(); + + // 초기 설정 및 소켓 연결 관리 + useEffect(() => { + // 로딩 중이면 리턴 + if (isLoading) return; + + // roomId나 rooms가 없으면 홈으로 + if (!roomId || !rooms) { + navigate('/', { replace: true }); + return; + } + + const room = rooms.find((r) => r.roomId === roomId); + if (!room) { + navigate('/', { replace: true }); + return; + } + + // 소켓 연결 및 초기 설정 + const initializeRoom = async () => { + try { + // 1. 소켓 연결 + if (!gameSocket.socket?.connected) { + gameSocket.connect(); + } + + // 2. 방 설정 + setCurrentRoom(room); + + // 3. 소켓 재연결 (새로고침 대응) + await gameSocket.joinRoom( + roomId, + room.players[room.players.length - 1] + ); + } catch (error) { + console.error('Failed to initialize room:', error); + navigate('/', { replace: true }); + } + }; + + initializeRoom(); + + // Cleanup + return () => { + if (gameSocket.socket?.connected) { + gameSocket.disconnect(); + } + setCurrentRoom(null); + }; + }, [roomId, rooms, isLoading]); + + // 방 나가기 + const leaveRoom = useCallback(() => { + if (gameSocket.socket?.connected) { + gameSocket.disconnect(); + } + setCurrentRoom(null); + navigate('/', { replace: true }); + }, [navigate, setCurrentRoom]); + + return { + leaveRoom, + }; +}; diff --git a/fe/src/hooks/useRoomActions.ts b/fe/src/hooks/useRoomActions.ts new file mode 100644 index 0000000..2e51de9 --- /dev/null +++ b/fe/src/hooks/useRoomActions.ts @@ -0,0 +1,83 @@ +import { useCallback } from 'react'; +import { gameSocket } from '@/services/gameSocket'; +import { signalingSocket } from '@/services/signalingSocket'; + +export const useRoomActions = () => { + const createRoom = useCallback( + async (roomName: string, hostNickname: string) => { + try { + // 1. 게임 소켓 연결 + gameSocket.connect(); + + // 2. 오디오 스트림 설정 및 방 생성 + const stream = await gameSocket.createRoom(roomName, hostNickname); + + // 3. 시그널링 소켓 설정 + signalingSocket.connect(); + signalingSocket.setLocalStream(stream); + + // 4. 방 생성 성공 시 시그널링 룸 참여 + const onRoomCreated = (room) => { + signalingSocket.joinSignalingRoom(room.roomId, hostNickname); + gameSocket.socket?.off('roomCreated', onRoomCreated); + }; + + gameSocket.socket?.on('roomCreated', onRoomCreated); + } catch (error) { + console.error('방 생성 실패:', error); + throw error; + } + }, + [] + ); + + const joinRoom = useCallback( + async (roomId: string, playerNickname: string) => { + try { + // 1. 게임 소켓 연결 + gameSocket.connect(); + + // 2. 오디오 스트림 설정 및 방 참여 + const stream = await gameSocket.joinRoom(roomId, playerNickname); + + // 3. 시그널링 소켓 설정 + signalingSocket.connect(); + signalingSocket.setLocalStream(stream); + + // 4. 방 참여 성공 시 시그널링 룸 참여 + const onUpdateUsers = (players) => { + if (players.includes(playerNickname)) { + signalingSocket.joinSignalingRoom(roomId, playerNickname); + gameSocket.socket?.off('updateUsers', onUpdateUsers); + } + }; + + gameSocket.socket?.on('updateUsers', onUpdateUsers); + } catch (error) { + console.error('방 참여 실패:', error); + throw error; + } + }, + [] + ); + + const leaveRoom = useCallback(() => { + signalingSocket.disconnect(); + gameSocket.disconnect(); + }, []); + + const toggleAudio = useCallback((enabled: boolean) => { + if (gameSocket['#audioStream']) { + gameSocket['#audioStream'].getAudioTracks().forEach((track) => { + track.enabled = enabled; + }); + } + }, []); + + return { + createRoom, + joinRoom, + leaveRoom, + toggleAudio, + }; +}; diff --git a/fe/src/pages/GamePage/PlayerList/VolumeBar.tsx b/fe/src/pages/GamePage/PlayerList/VolumeBar.tsx index 11cad76..390ec5d 100644 --- a/fe/src/pages/GamePage/PlayerList/VolumeBar.tsx +++ b/fe/src/pages/GamePage/PlayerList/VolumeBar.tsx @@ -17,7 +17,7 @@ const VolumeBar = ({ isOn }: AudioControlProps) => { )} { const [isAudioOn, setIsAudioOn] = useState(true); const { roomId } = useParams(); const { data: rooms } = getRoomsQuery(); + const { leaveRoom } = useRoomActions(); const { currentRoom, setCurrentRoom } = useRoomStore(); + const navigate = useNavigate(); + // 방 정보 업데이트 및 WebRTC 연결 설정 useEffect(() => { - if (rooms && roomId) { + if (rooms && roomId && (!currentRoom || currentRoom.roomId !== roomId)) { const room = rooms.find((r) => r.roomId === roomId); if (room) { setCurrentRoom(room); + } else { + // 방을 찾을 수 없는 경우 + navigate('/'); } } - }, [rooms, roomId]); + }, [rooms, roomId, currentRoom]); + + // 페이지 나가기 전에 연결 정리 + useEffect(() => { + return () => { + console.log('Cleaning up room connections'); + leaveRoom(); + }; + }, [leaveRoom]); if (!currentRoom) return null; diff --git a/fe/src/services/gameSocket.ts b/fe/src/services/gameSocket.ts index 5431feb..9c2ae34 100644 --- a/fe/src/services/gameSocket.ts +++ b/fe/src/services/gameSocket.ts @@ -5,18 +5,73 @@ import { } from '@/types/socketTypes'; import { Room } from '@/types/roomTypes'; import { SocketService } from './SocketService'; -import useRoomStore from '@/stores/zustand/useRoomStore'; import { cleanupAudioStream, requestAudioStream } from './audioRequest'; +import useRoomStore from '@/stores/zustand/useRoomStore'; const GAME_SOCKET_URL = 'wss://game.clovapatra.com/rooms'; +interface AudioSetup { + audioContext: AudioContext; + source: MediaStreamAudioSourceNode; + gainNode: GainNode; +} + class GameSocket extends SocketService { #audioStream: MediaStream | null = null; + #audioSetup: AudioSetup | null = null; constructor() { super(); } + async setupAudioStream(): Promise { + try { + if (this.#audioStream) { + return this.#audioStream; + } + + const stream = await requestAudioStream(); + this.#audioStream = stream; + + // 오디오 설정 + this.#audioSetup = await this.#setupAudio(stream); + + return stream; + } catch (error) { + console.error('Failed to setup audio stream:', error); + throw error; + } + } + + async #setupAudio(stream: MediaStream): Promise { + try { + const audioContext = new AudioContext(); + const source = audioContext.createMediaStreamSource(stream); + const gainNode = audioContext.createGain(); + + source.connect(gainNode); + gainNode.connect(audioContext.destination); + + // 기본 볼륨 설정 + gainNode.gain.value = 0.5; + + return { + audioContext, + source, + gainNode, + }; + } catch (error) { + console.error('Error setting up audio:', error); + throw error; + } + } + + setAudioVolume(volume: number) { + if (this.#audioSetup) { + this.#audioSetup.gainNode.gain.value = volume; + } + } + connect() { if (this.socket?.connected) return; @@ -26,20 +81,30 @@ class GameSocket extends SocketService { }) as Socket; this.setSocket(socket); - this.setupEventListeners(); + this.#setupEventListeners(); + } + + async createRoom(roomName: string, hostNickname: string) { + this.validateSocket(); + const stream = await this.setupAudioStream(); + this.socket?.emit('createRoom', { roomName, hostNickname }); + return stream; + } + + async joinRoom(roomId: string, playerNickname: string) { + this.validateSocket(); + const stream = await this.setupAudioStream(); + this.socket?.emit('joinRoom', { roomId, playerNickname }); + return stream; } - private setupEventListeners() { + #setupEventListeners() { if (!this.socket) return; this.socket.on('connect', () => { console.log('Game socket connected'); }); - this.socket.on('connect_error', (error) => { - console.error('Game socket connection error:', error); - }); - this.socket.on('roomCreated', async (room: Room) => { try { const store = useRoomStore.getState(); @@ -50,12 +115,13 @@ class GameSocket extends SocketService { } }); - this.socket.on('updateUsers', async (players: string[]) => { + this.socket.on('updateUsers', (players: string[]) => { try { - const { currentRoom, setCurrentRoom } = useRoomStore.getState(); + const store = useRoomStore.getState(); + const { currentRoom } = store; if (currentRoom) { - setCurrentRoom({ + store.setCurrentRoom({ ...currentRoom, players, hostNickname: players[0], @@ -65,50 +131,19 @@ class GameSocket extends SocketService { console.error('Failed to update users:', error); } }); - - this.socket.on('error', (error) => { - console.error('Socket error:', error); - window.location.href = '/'; - }); - } - - createRoom(roomName: string, hostNickname: string) { - this.validateSocket(); - - // 마이크 권한 요청 후 방 생성 - requestAudioStream() - .then((stream) => { - this.#audioStream = stream; // stream 저장 - this.socket?.emit('createRoom', { roomName, hostNickname }); - }) - .catch((error) => { - console.error('Failed to access microphone:', error); - throw error; - }); } - joinRoom(roomId: string, playerNickname: string) { - this.validateSocket(); - requestAudioStream() - .then((stream) => { - this.#audioStream = stream; // stream 저장 - this.socket?.emit('joinRoom', { roomId, playerNickname }); - }) - .catch((error) => { - console.error('Failed to access microphone:', error); - throw error; - }); - } - - // audio stream 정리를 위한 메서드 cleanupAudio() { if (this.#audioStream) { cleanupAudioStream(this.#audioStream); this.#audioStream = null; } + if (this.#audioSetup) { + this.#audioSetup.audioContext.close(); + this.#audioSetup = null; + } } - // disconnect 시 audio도 정리 override disconnect() { this.cleanupAudio(); super.disconnect(); diff --git a/fe/src/services/signalingSocket.ts b/fe/src/services/signalingSocket.ts index 388bf66..afe5562 100644 --- a/fe/src/services/signalingSocket.ts +++ b/fe/src/services/signalingSocket.ts @@ -8,13 +8,17 @@ import { SocketService } from './SocketService'; const SIGNALING_URL = 'https://signaling.clovapatra.com'; class SignalingSocket extends SocketService { - #peerConnections: Record = {}; - #iceCandidateQueue = new Map(); + #peerConnections = new Map(); + #localStream: MediaStream | null = null; constructor() { super(); } + setLocalStream(stream: MediaStream) { + this.#localStream = stream; + } + connect() { if (this.socket?.connected) return; @@ -24,155 +28,46 @@ class SignalingSocket extends SocketService { }) as Socket; this.setSocket(socket); - this.setupEventListeners(); + this.#setupEventListeners(); } - private setupEventListeners() { - if (!this.socket) return; - - this.socket.on('connect', () => { - console.log('Signaling socket connected'); - }); - - this.socket.on('connect_error', (error) => { - console.error('Signaling socket connection error:', error); - }); - - this.socket.on('joinSuccess', () => { - console.log('Successfully joined signaling room'); - }); - - this.socket.on('peer-joined', async ({ peerId, userId }) => { - try { - const pc = await this.createPeerConnection(peerId); - const offer = await pc.createOffer({ - offerToReceiveAudio: true, - offerToReceiveVideo: false, - }); - await pc.setLocalDescription(offer); - - this.socket?.emit('offer', { - targetId: peerId, - sdp: pc.localDescription!, - }); - } catch (error) { - console.error('Error handling peer joined:', error); - } - }); - - this.socket.on('offer', async ({ targetId, sdp }) => { - try { - let pc = this.#peerConnections[targetId]; - - if (!pc) { - pc = await this.createPeerConnection(targetId); - } - - await pc.setRemoteDescription(new RTCSessionDescription(sdp)); - const answer = await pc.createAnswer(); - await pc.setLocalDescription(answer); - - this.socket?.emit('answer', { - targetId: targetId, - sdp: pc.localDescription!, - }); - - // Process queued ICE candidates - if (this.#iceCandidateQueue.has(targetId)) { - const candidates = this.#iceCandidateQueue.get(targetId)!; - for (const candidate of candidates) { - await pc.addIceCandidate(candidate); - } - this.#iceCandidateQueue.delete(targetId); - } - } catch (error) { - console.error('Error handling offer:', error); - } - }); - - this.socket.on('answer', async ({ targetId, sdp }) => { - try { - const pc = this.#peerConnections[targetId]; - if (!pc) return; - - await pc.setRemoteDescription(new RTCSessionDescription(sdp)); - - // Process queued ICE candidates - if (this.#iceCandidateQueue.has(targetId)) { - const candidates = this.#iceCandidateQueue.get(targetId)!; - for (const candidate of candidates) { - await pc.addIceCandidate(candidate); - } - this.#iceCandidateQueue.delete(targetId); - } - } catch (error) { - console.error('Error handling answer:', error); - } - }); - - this.socket.on('ice-candidate', async ({ targetId, candidate }) => { - try { - const pc = this.#peerConnections[targetId]; - if (pc && pc.remoteDescription) { - await pc.addIceCandidate(new RTCIceCandidate(candidate)); - } else { - if (!this.#iceCandidateQueue.has(targetId)) { - this.#iceCandidateQueue.set(targetId, []); - } - this.#iceCandidateQueue - .get(targetId) - ?.push(new RTCIceCandidate(candidate)); - } - } catch (error) { - console.error('Error handling ICE candidate:', error); - } - }); - - this.socket.on('peer-left', ({ peerId }) => { - this.cleanupPeerConnection(peerId); - }); + joinSignalingRoom(roomId: string, userId: string) { + this.validateSocket(); + console.log('Joining signaling room:', roomId, userId); + this.socket?.emit('join', { roomId, userId }); } - private async createPeerConnection( - peerId: string - ): Promise { - const configuration: RTCConfiguration = { + async #createPeerConnection(peerId: string) { + const config = { iceServers: [ - { - urls: [ - 'stun:stun.l.google.com:19302', - 'stun:stun1.l.google.com:19302', - ], - }, + { urls: 'stun:stun.l.google.com:19302' }, { urls: 'turn:turn.anyfirewall.com:443?transport=tcp', credential: 'webrtc', username: 'webrtc', }, ], - iceCandidatePoolSize: 0, - bundlePolicy: 'max-bundle', - rtcpMuxPolicy: 'require', - iceTransportPolicy: 'all', }; - const pc = new RTCPeerConnection(configuration); + const pc = new RTCPeerConnection(config); - pc.oniceconnectionstatechange = () => { - console.log(`ICE connection state: ${pc.iceConnectionState}`); - if (pc.iceConnectionState === 'failed') { - pc.restartIce(); - } - }; - - pc.onconnectionstatechange = () => { - console.log(`Connection state: ${pc.connectionState}`); - }; + // 로컬 스트림 추가 + if (this.#localStream) { + this.#localStream.getTracks().forEach((track) => { + pc.addTrack(track, this.#localStream!); + }); + } - pc.onsignalingstatechange = () => { - console.log(`Signaling state: ${pc.signalingState}`); + // 원격 스트림 처리 + pc.ontrack = (event) => { + console.log('Received remote track'); + const audio = new Audio(); + audio.srcObject = event.streams[0]; + audio.autoplay = true; + document.body.appendChild(audio); }; + // ICE 후보 전송 pc.onicecandidate = (event) => { if (event.candidate) { this.socket?.emit('ice-candidate', { @@ -182,40 +77,71 @@ class SignalingSocket extends SocketService { } }; - pc.ontrack = (event) => { - try { - const audioElement = new Audio(); - audioElement.autoplay = true; - audioElement.srcObject = event.streams[0]; - document.body.appendChild(audioElement); - - console.log('Audio element created and playing'); - } catch (error) { - console.error('Error setting up audio element:', error); - } - }; - - this.#peerConnections[peerId] = pc; + this.#peerConnections.set(peerId, pc); return pc; } - joinSignalingRoom(roomId: string, userId: string) { - this.validateSocket(); - this.socket?.emit('join', { roomId, userId }); - } + #setupEventListeners() { + if (!this.socket) return; - private cleanupPeerConnection(peerId: string) { - const pc = this.#peerConnections[peerId]; - if (pc) { - pc.close(); - delete this.#peerConnections[peerId]; - } + // 새로운 피어 참여 + this.socket.on('peer-joined', async ({ peerId }) => { + console.log('New peer joined:', peerId); + const pc = await this.#createPeerConnection(peerId); + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + + this.socket?.emit('offer', { + targetId: peerId, + sdp: pc.localDescription, + }); + }); + + // Offer 처리 + this.socket.on('offer', async ({ targetId, sdp }) => { + console.log('Received offer from:', targetId); + const pc = await this.#createPeerConnection(targetId); + await pc.setRemoteDescription(new RTCSessionDescription(sdp)); + + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + + this.socket?.emit('answer', { + targetId, + sdp: pc.localDescription, + }); + }); + + // Answer 처리 + this.socket.on('answer', async ({ targetId, sdp }) => { + console.log('Received answer from:', targetId); + const pc = this.#peerConnections.get(targetId); + if (pc) { + await pc.setRemoteDescription(new RTCSessionDescription(sdp)); + } + }); + + // ICE 후보 처리 + this.socket.on('ice-candidate', async ({ targetId, candidate }) => { + const pc = this.#peerConnections.get(targetId); + if (pc) { + await pc.addIceCandidate(new RTCIceCandidate(candidate)); + } + }); + + // 피어 연결 해제 + this.socket.on('peer-left', ({ peerId }) => { + const pc = this.#peerConnections.get(peerId); + if (pc) { + pc.close(); + this.#peerConnections.delete(peerId); + } + }); } override disconnect() { - Object.keys(this.#peerConnections).forEach( - this.cleanupPeerConnection.bind(this) - ); + this.#peerConnections.forEach((pc) => pc.close()); + this.#peerConnections.clear(); super.disconnect(); } }