diff --git a/src/app/classes/[cId]/[mId]/components/manageSubComponents/LiveClass.tsx b/src/app/classes/[cId]/[mId]/components/manageSubComponents/LiveClass.tsx index ae88b48..09965d2 100644 --- a/src/app/classes/[cId]/[mId]/components/manageSubComponents/LiveClass.tsx +++ b/src/app/classes/[cId]/[mId]/components/manageSubComponents/LiveClass.tsx @@ -58,7 +58,6 @@ const LiveClass: React.FC = ({ isTeacher = false, nickname = '', }) => { - // State 관리 const [classStarted, setClassStarted] = useState(false); const [connectionState, setConnectionState] = useState< 'connecting' | 'connected' | 'disconnected' @@ -71,7 +70,6 @@ const LiveClass: React.FC = ({ const [isSharingScreen, setIsSharingScreen] = useState(false); const [reconnectAttempts, setReconnectAttempts] = useState(0); - // Refs const deviceRef = useRef(null); const wsRef = useRef(null); const producerTransportRef = useRef | null>(null); @@ -89,10 +87,7 @@ const LiveClass: React.FC = ({ ? `${protocol}://${window.location.host}/ws` : 'ws://localhost:8080'; - // nickname이 없을 경우 기본값 설정 const safeNickname = nickname || `User_${userId}`; - - // URLSearchParams 사용하여 안전한 URL 생성 const params = new URLSearchParams({ roomId: classId.toString(), userId: userId.toString(), @@ -119,23 +114,19 @@ const LiveClass: React.FC = ({ [handleError] ); - // 최적의 인코딩 설정을 반환하는 함수 const getOptimalEncodings = (kind: string) => { if (kind === 'video') { return [ - {maxBitrate: 100000, scaleResolutionDownBy: 4, maxFramerate: 15}, // 낮은 품질 - {maxBitrate: 300000, scaleResolutionDownBy: 2, maxFramerate: 30}, // 중간 품질 - {maxBitrate: 900000, scaleResolutionDownBy: 1, maxFramerate: 60}, // 높은 품질 + {maxBitrate: 100000, scaleResolutionDownBy: 4, maxFramerate: 15}, + {maxBitrate: 300000, scaleResolutionDownBy: 2, maxFramerate: 30}, + {maxBitrate: 900000, scaleResolutionDownBy: 1, maxFramerate: 60}, ]; } else if (kind === 'audio') { - return [ - {maxBitrate: 64000}, // 음성 최적화 - ]; + return [{maxBitrate: 64000}]; } return []; }; - // 로컬 스트림 시작 const startLocalStream = useCallback(async () => { try { const devices = await navigator.mediaDevices.enumerateDevices(); @@ -149,12 +140,10 @@ const LiveClass: React.FC = ({ localStreamRef.current = stream; - // Producer 생성 전 상태 체크 추가 if (!producerTransportRef.current || !deviceRef.current) { throw new Error('Transport or device not initialized'); } - // Produce audio and video tracks for (const track of stream.getTracks()) { try { const producer = await producerTransportRef.current.produce({ @@ -186,7 +175,6 @@ const LiveClass: React.FC = ({ }, })); - // 초기 미디어 상태 설정 stream.getTracks().forEach(track => { if (track.kind === 'audio') { track.enabled = mediaState.audio; @@ -215,7 +203,6 @@ const LiveClass: React.FC = ({ [] ); - // 화면 공유 최적화 const getOptimalScreenShareConstraints = () => ({ video: { displaySurface: 'monitor' as DisplayCaptureSurfaceType, @@ -232,7 +219,6 @@ const LiveClass: React.FC = ({ }, }); - // WebSocket 연결 설정 const connectWebSocket = useCallback((): void => { if (wsRef.current?.readyState === WebSocket.CONNECTING) { console.log('WebSocket connection already in progress'); @@ -244,7 +230,6 @@ const LiveClass: React.FC = ({ const ws = new WebSocket(wsUrl); wsRef.current = ws; - // 연결 타임아웃 처리 const connectionTimeout = setTimeout(() => { if (ws.readyState === WebSocket.CONNECTING) { ws.close(); @@ -258,7 +243,6 @@ const LiveClass: React.FC = ({ setConnectionState('connected'); setReconnectAttempts(0); - // 즉시 capabilities 요청 ws.send(JSON.stringify({event: 'getRouterRtpCapabilities'})); }; @@ -276,7 +260,6 @@ const LiveClass: React.FC = ({ console.log('WebSocket closed:', event); setConnectionState('disconnected'); - // 정상적인 종료가 아닌 경우에만 재연결 if (!event.wasClean) { handleReconnect(); } @@ -334,7 +317,7 @@ const LiveClass: React.FC = ({ data: {kind, rtpParameters, appData}, }) ); - callback({id: Date.now().toString()}); // id를 반환하도록 수정 + callback({id: Date.now().toString()}); } catch (error) { errback( error instanceof Error ? error : new Error('Unknown error') @@ -433,7 +416,6 @@ const LiveClass: React.FC = ({ connectWebSocketRef.current = connectWebSocket; }, [connectWebSocket]); - // 화면 공유 시작 const startScreenShare = async () => { try { const constraints = getOptimalScreenShareConstraints(); @@ -443,7 +425,6 @@ const LiveClass: React.FC = ({ screenStreamRef.current = stream; - // Produce screen share track const videoTrack = stream.getVideoTracks()[0]; const producer = await producerTransportRef.current?.produce({ track: videoTrack, @@ -485,7 +466,6 @@ const LiveClass: React.FC = ({ } }; - // 화면 공유 중지 const stopScreenShare = async () => { const producer = producersRef.current.get('screen'); if (producer) { @@ -507,21 +487,16 @@ const LiveClass: React.FC = ({ } }; - // 수업 종료 const handleEndClass = useCallback(() => { - // Close all producers producersRef.current.forEach(producer => producer.close()); producersRef.current.clear(); - // Close all consumers consumersRef.current.forEach(consumer => consumer.close()); consumersRef.current.clear(); - // Close transports producerTransportRef.current?.close(); consumerTransportRef.current?.close(); - // Stop all tracks if (localStreamRef.current) { localStreamRef.current.getTracks().forEach(track => track.stop()); } @@ -529,17 +504,14 @@ const LiveClass: React.FC = ({ screenStreamRef.current.getTracks().forEach(track => track.stop()); } - // Close WebSocket wsRef.current?.close(); - // Reset states setClassStarted(false); setStreams({}); setConnectionState('disconnected'); setIsSharingScreen(false); }, []); - // 트랙 구독 const subscribeToTrack = async ( producerId: string, producerUserId: number @@ -562,7 +534,6 @@ const LiveClass: React.FC = ({ } }; - // 미디어 상태 업데이트 const updateMediaState = useCallback( (type: 'audio' | 'video', enabled: boolean) => { if (localStreamRef.current) { @@ -580,7 +551,6 @@ const LiveClass: React.FC = ({ [type]: enabled, })); - // Notify other participants about media state change wsRef.current?.send( JSON.stringify({ event: 'mediaStateChange', @@ -596,7 +566,6 @@ const LiveClass: React.FC = ({ [userId] ); - // 재연결 처리 const handleReconnect = useCallback((): void => { if (reconnectAttempts < 5) { const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000); @@ -626,7 +595,6 @@ const LiveClass: React.FC = ({ }; }, [handleEndClass]); - // 수업 시작 const handleStartClass = useCallback(async () => { try { setClassStarted(true); @@ -688,6 +656,37 @@ const LiveClass: React.FC = ({ : 'grid-cols-4'; }, [streams]); + useEffect(() => { + if (classStarted) { + connectWebSocketRef.current?.(); + } + + return () => { + console.log('Cleaning up WebSocket connection'); + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + cleanupStreams(); + }; + }, [classStarted, cleanupStreams]); + + useEffect(() => { + const handleNewStream = (event: MessageEvent) => { + const {event: eventType, data} = JSON.parse(event.data); + if (eventType === 'newProducer') { + const {producerId, userId: producerUserId} = data; + subscribeToTrack(producerId, producerUserId); + } + }; + + wsRef.current?.addEventListener('message', handleNewStream); + + return () => { + wsRef.current?.removeEventListener('message', handleNewStream); + }; + }, [subscribeToTrack]); + return (
{/* 컨트롤 패널 */} diff --git a/src/app/classes/[cId]/[mId]/components/subComponents/LiveClassViewer.tsx b/src/app/classes/[cId]/[mId]/components/subComponents/LiveClassViewer.tsx index 499ecfc..6d006ce 100644 --- a/src/app/classes/[cId]/[mId]/components/subComponents/LiveClassViewer.tsx +++ b/src/app/classes/[cId]/[mId]/components/subComponents/LiveClassViewer.tsx @@ -2,7 +2,12 @@ /* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import React, {useEffect, useRef, useState} from 'react'; +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable react-hooks/exhaustive-deps */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import React, {useEffect, useRef, useState, useCallback, useMemo} from 'react'; +import {Device, types} from 'mediasoup-client'; interface LiveClassViewerProps { classId: number; @@ -15,149 +20,134 @@ const LiveClassViewer: React.FC = ({classId, userId}) => { const [isCameraOn, setIsCameraOn] = useState(true); const localVideoRef = useRef(null); const remoteVideoRef = useRef(null); - const pcRef = useRef(null); + const deviceRef = useRef(null); const wsRef = useRef(null); - const iceCandidatesRef = useRef([]); - const mediaStreamRef = useRef(null); + const consumerTransportRef = useRef(null); + const consumersRef = useRef>(new Map()); - const startWebSocket = () => { - const ws = new WebSocket( - // `ws://localhost:8080/?classId=${classId}&userId=${userId}` - `ws://3.39.137.182:8080/?classId=${classId}&userId=${userId}` - ); + const wsUrl = useMemo(() => { + const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; + const baseUrl = + process.env.NODE_ENV === 'production' + ? `${protocol}://${window.location.host}/mediasoup` + : 'ws://localhost:8000/mediasoup'; + + return `${baseUrl}?classId=${classId}&userId=${userId}`; + }, [classId, userId]); + + const startWebSocket = useCallback(() => { + const ws = new WebSocket(wsUrl); wsRef.current = ws; - ws.onopen = async () => { + ws.onopen = () => { console.log('WebSocket connected'); - iceCandidatesRef.current.forEach(candidate => { - ws.send(JSON.stringify({event: 'candidate', data: candidate})); - }); - iceCandidatesRef.current = []; + ws.send(JSON.stringify({event: 'getRouterRtpCapabilities'})); + }; - if (pcRef.current) { - try { - const mediaStream = await navigator.mediaDevices.getUserMedia({ - video: true, - audio: true, - }); - mediaStreamRef.current = mediaStream; - mediaStream - .getTracks() - .forEach(track => pcRef.current?.addTrack(track, mediaStream)); - if (localVideoRef.current) { - localVideoRef.current.srcObject = mediaStream; + ws.onmessage = async event => { + const {event: evt, data} = JSON.parse(event.data); + console.log('Message received:', evt, data); + + switch (evt) { + case 'routerRtpCapabilities': { + if (!deviceRef.current) { + const device = new Device(); + await device.load({routerRtpCapabilities: data}); + deviceRef.current = device; + ws.send(JSON.stringify({event: 'createConsumerTransport'})); } - const offer = await pcRef.current.createOffer(); - await pcRef.current.setLocalDescription(offer); + break; + } + + case 'consumerTransportCreated': { + const transport = deviceRef.current?.createRecvTransport(data); + if (!transport) return; + consumerTransportRef.current = transport; + + transport.on( + 'connect', + async ({dtlsParameters}, callback, errback) => { + try { + ws.send( + JSON.stringify({ + event: 'connectConsumerTransport', + data: {dtlsParameters}, + }) + ); + callback(); + } catch (error) { + errback(error as Error); + } + } + ); + + ws.send(JSON.stringify({event: 'getProducers'})); + break; + } + + case 'newProducer': { + const {producerId} = data; ws.send( JSON.stringify({ - event: 'offer', - data: pcRef.current.localDescription, + event: 'consume', + data: { + producerId, + rtpCapabilities: deviceRef.current?.rtpCapabilities, + }, }) ); - } catch (error) { - console.error('Failed to start media stream', error); + break; } - } - }; - ws.onmessage = async event => { - const {event: evt, data} = JSON.parse(event.data); - console.log('Message received:', evt, data); - if (evt === 'offer' && pcRef.current) { - try { - console.log('Received offer:', data); - await pcRef.current.setRemoteDescription( - new RTCSessionDescription(data) + case 'consumed': { + const {id, producerId: remoteProducerId, kind, rtpParameters} = data; + const consumer = await consumerTransportRef.current?.consume({ + id, + producerId: remoteProducerId, + kind, + rtpParameters, + }); + + if (!consumer) return; + + consumersRef.current.set(id, consumer); + + const stream = new MediaStream([consumer.track]); + if (kind === 'video' && remoteVideoRef.current) { + remoteVideoRef.current.srcObject = stream; + } + + ws.send( + JSON.stringify({ + event: 'resumeConsumer', + data: {consumerId: id}, + }) ); - const answer = await pcRef.current.createAnswer(); - await pcRef.current.setLocalDescription(answer); - ws.send(JSON.stringify({event: 'answer', data: answer})); - } catch (error) { - console.error('Failed to handle offer:', error); - } - } else if (evt === 'candidate') { - try { - await pcRef.current?.addIceCandidate(new RTCIceCandidate(data)); - } catch (error) { - console.error('Failed to add ICE candidate', error); + break; } + + default: + break; } }; ws.onclose = () => { console.log('WebSocket closed'); }; - }; + }, [wsUrl]); useEffect(() => { if (inClass) { - const pc = new RTCPeerConnection({ - iceServers: [ - { - urls: [ - 'stun:stun.l.google.com:19302', - 'stun:stun1.l.google.com:19302', - 'stun:stun2.l.google.com:19302', - 'stun:stun3.l.google.com:19302', - 'stun:stun4.l.google.com:19302', - ], - }, - { - urls: 'turn:3.39.137.182:3478', - username: 'minori', - credential: 'minoriwebrtc', - }, - ], - }); - pc.addEventListener('icecandidate', event => { - if (event.candidate) { - wsRef.current?.send( - JSON.stringify({ - event: 'candidate', - data: event.candidate, - }) - ); - } - }); - pcRef.current = pc; - - pc.onicecandidate = event => { - if (event.candidate) { - const candidateData = JSON.stringify({ - event: 'candidate', - data: event.candidate.toJSON(), - }); - if (wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send(candidateData); - } else { - iceCandidatesRef.current.push(event.candidate.toJSON()); - } - } - }; - - pc.ontrack = event => { - if (remoteVideoRef.current) { - remoteVideoRef.current.srcObject = event.streams[0]; - } - }; - startWebSocket(); return () => { - pcRef.current?.close(); wsRef.current?.close(); - pcRef.current = null; - wsRef.current = null; - if (localVideoRef.current) { - localVideoRef.current.srcObject = null; - } - if (remoteVideoRef.current) { - remoteVideoRef.current.srcObject = null; - } + consumerTransportRef.current?.close(); + consumersRef.current.forEach(consumer => consumer.close()); + consumersRef.current.clear(); }; } - }, [inClass]); + }, [inClass, startWebSocket]); const handleJoinClass = () => { setInClass(true); @@ -165,36 +155,10 @@ const LiveClassViewer: React.FC = ({classId, userId}) => { const handleLeaveClass = () => { wsRef.current?.close(); - pcRef.current?.close(); - pcRef.current = null; - wsRef.current = null; + consumerTransportRef.current?.close(); + consumersRef.current.forEach(consumer => consumer.close()); + consumersRef.current.clear(); setInClass(false); - if (localVideoRef.current) { - localVideoRef.current.srcObject = null; - } - if (remoteVideoRef.current) { - remoteVideoRef.current.srcObject = null; - } - }; - - const toggleMicrophone = () => { - if (mediaStreamRef.current) { - const audioTrack = mediaStreamRef.current.getAudioTracks()[0]; - if (audioTrack) { - audioTrack.enabled = !audioTrack.enabled; - setIsMicMuted(!audioTrack.enabled); - } - } - }; - - const toggleCamera = () => { - if (mediaStreamRef.current) { - const videoTrack = mediaStreamRef.current.getVideoTracks()[0]; - if (videoTrack) { - videoTrack.enabled = !videoTrack.enabled; - setIsCameraOn(videoTrack.enabled); - } - } }; return ( @@ -217,7 +181,7 @@ const LiveClassViewer: React.FC = ({classId, userId}) => {