From e3da9d303fb1ce6fc5b6a63bdb07ce39472447b8 Mon Sep 17 00:00:00 2001 From: YuMin Kim Date: Fri, 1 Nov 2024 11:28:27 +0900 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=A8=20refactor:=20Migrate=20video=20ch?= =?UTF-8?q?at=20from=20P2P=20to=20SFU=20architecture=20using=20MediaSoup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace P2P WebRTC implementation with MediaSoup-based SFU architecture - Restructure LiveClass component for scalable video streaming: - Add ConnectionStatus for improved connection state management - Extract ControlButtons for better media control handling - Create VideoBox for unified video stream rendering - Implement MediaSoup producer/consumer pattern - Enhance WebSocket signaling for SFU communication - Add robust error handling and reconnection logic - Improve stream management and cleanup Technical Details: - Switch from direct P2P connections to MediaSoup-based routing - Implement proper stream producer/consumer lifecycle - Add TypeScript interfaces for MediaSoup integration - Update WebSocket protocol for SFU requirements Dependencies: - Add mediasoup-client - Add @types/webrtc --- package.json | 2 + .../manageSubComponents/ConnectionStatus.tsx | 36 + .../manageSubComponents/ControlButtons.tsx | 92 ++ .../manageSubComponents/LiveClass.tsx | 827 ++++++++++++------ .../manageSubComponents/VideoBox.tsx | 95 ++ yarn.lock | 88 +- 6 files changed, 872 insertions(+), 268 deletions(-) create mode 100644 src/app/classes/[cId]/[mId]/components/manageSubComponents/ConnectionStatus.tsx create mode 100644 src/app/classes/[cId]/[mId]/components/manageSubComponents/ControlButtons.tsx create mode 100644 src/app/classes/[cId]/[mId]/components/manageSubComponents/VideoBox.tsx diff --git a/package.json b/package.json index 5e626df..5791c5e 100644 --- a/package.json +++ b/package.json @@ -26,11 +26,13 @@ "@tanstack/react-query": "^5.17.10", "@types/event-source-polyfill": "^1.0.5", "@types/js-cookie": "^3.0.6", + "@types/webrtc": "^0.0.44", "axios": "^1.6.5", "date-fns": "^3.6.0", "event-source-polyfill": "^1.0.31", "ion-sdk-js": "^1.8.2", "js-cookie": "^3.0.5", + "mediasoup-client": "^3.7.17", "moment": "^2.30.1", "next": "^14.2.7", "react": "^18", diff --git a/src/app/classes/[cId]/[mId]/components/manageSubComponents/ConnectionStatus.tsx b/src/app/classes/[cId]/[mId]/components/manageSubComponents/ConnectionStatus.tsx new file mode 100644 index 0000000..e9f3772 --- /dev/null +++ b/src/app/classes/[cId]/[mId]/components/manageSubComponents/ConnectionStatus.tsx @@ -0,0 +1,36 @@ +import React, {memo} from 'react'; + +interface ConnectionStatusProps { + state: 'connecting' | 'connected' | 'disconnected'; +} + +const ConnectionStatus: React.FC = memo(({state}) => { + const getStatusData = (): {color: string; text: string} => { + const statusMap = { + connected: {color: 'bg-green-500', text: '接続中'}, + connecting: {color: 'bg-yellow-500', text: '接続試行中...'}, + disconnected: {color: 'bg-red-500', text: '未接続'}, + }; + return statusMap[state]; + }; + + const {color, text} = getStatusData(); + + return ( +
+ + ); +}); + +ConnectionStatus.displayName = 'ConnectionStatus'; +export default ConnectionStatus; diff --git a/src/app/classes/[cId]/[mId]/components/manageSubComponents/ControlButtons.tsx b/src/app/classes/[cId]/[mId]/components/manageSubComponents/ControlButtons.tsx new file mode 100644 index 0000000..215f0e0 --- /dev/null +++ b/src/app/classes/[cId]/[mId]/components/manageSubComponents/ControlButtons.tsx @@ -0,0 +1,92 @@ +import React, {memo} from 'react'; + +interface MediaState { + video: boolean; + audio: boolean; +} + +interface ControlButtonsProps { + isTeacher: boolean; + isSharingScreen: boolean; + mediaState: MediaState; + isLoading?: boolean; + disabled?: boolean; + onEndClass: () => void; + onToggleScreen: () => void; + onToggleAudio: () => void; + onToggleVideo: () => void; +} + +const ControlButtons: React.FC = memo( + ({ + isTeacher, + isSharingScreen, + mediaState, + isLoading = false, + disabled = false, + onEndClass, + onToggleScreen, + onToggleAudio, + onToggleVideo, + }) => { + const buttonClass = (active: boolean, color: string) => + `${active ? `bg-${color}-500 hover:bg-${color}-600` : 'bg-gray-500'} + text-white font-bold py-2 px-4 rounded-md transition-colors + disabled:opacity-50 disabled:cursor-not-allowed`; + + return ( +
+ + + {isTeacher && ( + + )} + +
+ + +
+
+ ); + } +); + +ControlButtons.displayName = 'ControlButtons'; +export default ControlButtons; diff --git a/src/app/classes/[cId]/[mId]/components/manageSubComponents/LiveClass.tsx b/src/app/classes/[cId]/[mId]/components/manageSubComponents/LiveClass.tsx index 70174d1..e3492a6 100644 --- a/src/app/classes/[cId]/[mId]/components/manageSubComponents/LiveClass.tsx +++ b/src/app/classes/[cId]/[mId]/components/manageSubComponents/LiveClass.tsx @@ -1,353 +1,650 @@ /* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import React, {useEffect, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState, useCallback} from 'react'; +import {Device, types} from 'mediasoup-client'; import AttendanceCard from '../AttendanceCard'; +import ConnectionStatus from './ConnectionStatus'; +import ControlButtons from './ControlButtons'; +import VideoBox from './VideoBox'; + +interface StreamsState { + [streamId: string]: { + stream: MediaStream; + type: 'video' | 'screen'; + userId: number; + nickname?: string; + }; +} + +interface MediaState { + video: boolean; + audio: boolean; +} interface LiveClassProps { classId: number; userId: number; + isTeacher?: boolean; + nickname?: string; } -const LiveClass: React.FC = ({classId, userId}) => { +interface MediasoupTransport + extends Omit, 'on' | 'emit'> { + emit( + event: K, + ...args: types.TransportEvents[K] + ): boolean; + on( + event: K, + callback: (...args: types.TransportEvents[K]) => void + ): this; +} + +type Producer< + AppData extends Record = Record, +> = types.Producer; +type Consumer< + AppData extends Record = Record, +> = types.Consumer; +type AppData = { + mediaType?: string; + [key: string]: unknown; +}; + +const LiveClass: React.FC = ({ + classId, + userId, + isTeacher = false, + nickname = '', +}) => { + // State 관리 const [classStarted, setClassStarted] = useState(false); + const [connectionState, setConnectionState] = useState< + 'connecting' | 'connected' | 'disconnected' + >('disconnected'); + const [streams, setStreams] = useState({}); + const [mediaState, setMediaState] = useState({ + video: true, + audio: true, + }); const [isSharingScreen, setIsSharingScreen] = useState(false); - const [isMicOn, setIsMicOn] = useState(true); - const [isCameraOn, setIsCameraOn] = useState(true); - const localVideoRef = useRef(null); - const remoteVideoRef = useRef(null); - const pcRef = useRef(null); + const [reconnectAttempts, setReconnectAttempts] = useState(0); + + // Refs + const deviceRef = useRef(null); const wsRef = useRef(null); - const iceCandidatesRef = useRef([]); - const [localDescriptionSet, setLocalDescriptionSet] = useState(false); + const producerTransportRef = useRef | null>(null); + const consumerTransportRef = useRef | null>(null); + const producersRef = useRef>>(new Map()); + const consumersRef = useRef>>(new Map()); + const localStreamRef = useRef(null); const screenStreamRef = useRef(null); - const mediaStreamRef = useRef(null); + const handleReconnectRef = useRef<(() => void) | null>(null); + const connectWebSocketRef = useRef<(() => void) | null>(null); + const wsUrl = + process.env.NODE_ENV === 'production' + ? `wss://3.39.137.182:8080/?roomId=${classId}&userId=${userId}&nickname=${encodeURIComponent( + nickname + )}` + : `ws://localhost:8080/?roomId=${classId}&userId=${userId}&nickname=${encodeURIComponent( + nickname + )}`; - const startWebSocket = () => { - const ws = new WebSocket( - // `ws://localhost:8080/?classId=${classId}&userId=${userId}` - `ws://3.39.137.182:8080/?classId=${classId}&userId=${userId}` - ); - wsRef.current = ws; + const handleError = useCallback((error: Error) => { + console.error('Error:', error); + setConnectionState('disconnected'); + }, []); - ws.onopen = async () => { - console.log('WebSocket connected'); - iceCandidatesRef.current.forEach(candidate => { - ws.send(JSON.stringify({event: 'candidate', data: candidate})); + const handleMediaError = useCallback( + (error: Error) => { + handleError(error); + if (error.name === 'NotAllowedError') { + // Camera/Mic permission error handling + } else if (error.name === 'NotFoundError') { + // Device not found error handling + } + }, + [handleError] + ); + + // 로컬 스트림 시작 + const startLocalStream = useCallback(async () => { + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + const hasVideo = devices.some(device => device.kind === 'videoinput'); + const hasAudio = devices.some(device => device.kind === 'audioinput'); + + const stream = await navigator.mediaDevices.getUserMedia({ + video: hasVideo, + audio: hasAudio, }); - iceCandidatesRef.current = []; - 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; - } - const offer = await pcRef.current.createOffer(); - await pcRef.current.setLocalDescription(offer); - setLocalDescriptionSet(true); - ws.send( - JSON.stringify({ - event: 'offer', - data: pcRef.current.localDescription, - }) - ); - } catch (error) { - console.error('Failed to start media stream', error); + localStreamRef.current = stream; + + // Produce audio and video tracks + for (const track of stream.getTracks()) { + const producer = await producerTransportRef.current?.produce({ + track, + encodings: [], + codecOptions: {}, + appData: {mediaType: 'screen'} satisfies AppData, + stopTracks: true, + disableTrackOnPause: true, + }); + if (producer) { + producersRef.current.set(track.kind, producer); } } - }; - ws.onmessage = async event => { - const {event: evt, data} = JSON.parse(event.data); - console.log('Message received:', evt, data); - if (evt === 'answer') { - if (localDescriptionSet) { - await pcRef.current?.setRemoteDescription( - new RTCSessionDescription(data) - ); - } else { - console.error('Local description not set'); + setStreams(prev => ({ + ...prev, + [`${userId}-video`]: { + stream, + type: 'video', + userId, + nickname, + }, + })); + + // 초기 미디어 상태 설정 + stream.getTracks().forEach(track => { + if (track.kind === 'audio') { + track.enabled = mediaState.audio; + } else if (track.kind === 'video') { + track.enabled = mediaState.video; } - } else if (evt === 'candidate') { - if (localDescriptionSet) { - await pcRef.current?.addIceCandidate(new RTCIceCandidate(data)); - } else { - iceCandidatesRef.current.push(data); + }); + } catch (error) { + handleMediaError( + error instanceof Error ? error : new Error(String(error)) + ); + setConnectionState('disconnected'); + } + }, [userId, nickname, mediaState.audio, mediaState.video, handleMediaError]); + + const monitorTransportState = useCallback( + (transport: MediasoupTransport) => { + transport.on('connectionstatechange', (state: string) => { + if ( + state === 'failed' || + (state === 'closed' && handleReconnectRef.current) + ) { + handleReconnectRef.current?.(); } - } - }; + }); + }, + [] + ); - ws.onclose = () => { - console.log('WebSocket closed'); + // WebSocket 연결 설정 + const connectWebSocket = useCallback((): void => { + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + setConnectionState('connected'); + ws.send(JSON.stringify({event: 'getRouterRtpCapabilities'})); }; - }; - useEffect(() => { - if (classStarted) { - 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', - }, - ], - }); - //code for creating PeerConnection in each browser + ws.onmessage = async ({data}) => { + const {event, data: eventData} = JSON.parse(data); - pcRef.current = pc; + switch (event) { + case 'routerRtpCapabilities': { + try { + const device = new Device(); + await device.load({routerRtpCapabilities: eventData}); + deviceRef.current = device; + ws.send(JSON.stringify({event: 'createProducerTransport'})); + } catch (error) { + console.error('Failed to load device:', error); + } + break; + } - 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()); + case 'producerTransportCreated': { + const transport = deviceRef.current?.createSendTransport(eventData); + if (!transport) return; + if (producerTransportRef.current) { + monitorTransportState(producerTransportRef.current); } + + transport.on( + 'connect', + async ({dtlsParameters}, callback, errback) => { + try { + await ws.send( + JSON.stringify({ + event: 'connectProducerTransport', + data: {dtlsParameters}, + }) + ); + callback(); + } catch (error) { + errback( + error instanceof Error ? error : new Error('Unknown error') + ); + } + } + ); + + transport.on( + 'produce', + async ({kind, rtpParameters, appData}, callback, errback) => { + try { + ws.send( + JSON.stringify({ + event: 'produce', + data: {kind, rtpParameters, appData}, + }) + ); + callback({id: Date.now().toString()}); // id를 반환하도록 수정 + } catch (error) { + errback( + error instanceof Error ? error : new Error('Unknown error') + ); + } + } + ); + + await startLocalStream(); + break; } - }; - pc.ontrack = event => { - if (remoteVideoRef.current) { - remoteVideoRef.current.srcObject = event.streams[0]; + case 'consumerTransportCreated': { + const transport = deviceRef.current?.createRecvTransport(eventData); + if (!transport) return; + if (consumerTransportRef.current) { + monitorTransportState(consumerTransportRef.current); + } + + transport.on( + 'connect', + async ({dtlsParameters}, callback, errback) => { + try { + await ws.send( + JSON.stringify({ + event: 'connectProducerTransport', + data: {dtlsParameters}, + }) + ); + callback(); + } catch (error) { + errback( + error instanceof Error ? error : new Error('Unknown error') + ); + } + } + ); + break; } - }; - startWebSocket(); + case 'newProducer': { + const {producerId, userId: producerUserId} = eventData; + subscribeToTrack(producerId, producerUserId); + break; + } - return () => { - pcRef.current?.close(); - wsRef.current?.close(); - pcRef.current = null; - wsRef.current = null; - if (localVideoRef.current) { - localVideoRef.current.srcObject = null; + case 'consumed': { + const {id, producerId, kind, rtpParameters} = eventData; + const consumer = await consumerTransportRef.current?.consume({ + id, + producerId, + kind, + rtpParameters, + }); + + if (!consumer) return; + + consumersRef.current.set(id, consumer); + + const stream = new MediaStream([consumer.track]); + setStreams(prev => ({ + ...prev, + [producerId]: { + stream, + type: 'video', + userId: parseInt(producerId.split('-')[0]), + }, + })); + + ws.send( + JSON.stringify({ + event: 'resumeConsumer', + data: {consumerId: id}, + }) + ); + break; } - if (remoteVideoRef.current) { - remoteVideoRef.current.srcObject = null; + + case 'producerClosed': { + const {producerId} = eventData; + setStreams(prev => { + const newStreams = {...prev}; + delete newStreams[producerId]; + return newStreams; + }); + break; } - }; - } - }, [classStarted]); + } + }; - const handleStartClass = () => { - setClassStarted(true); - }; + ws.onclose = () => { + setConnectionState('disconnected'); + if (handleReconnectRef.current) { + handleReconnectRef.current(); + } + }; - const handleEndClass = () => { - wsRef.current?.close(); - pcRef.current?.close(); - pcRef.current = null; - wsRef.current = null; - setClassStarted(false); - if (localVideoRef.current) { - localVideoRef.current.srcObject = null; - } - if (remoteVideoRef.current) { - remoteVideoRef.current.srcObject = null; - } - }; + ws.onerror = error => { + console.error('WebSocket error:', error); + setConnectionState('disconnected'); + }; + }, [wsUrl, monitorTransportState, startLocalStream]); + + useEffect(() => { + connectWebSocketRef.current = connectWebSocket; + }, [connectWebSocket]); + // 화면 공유 시작 const startScreenShare = async () => { try { - const mediaStream = await navigator.mediaDevices.getDisplayMedia({ - video: true, - audio: true, + const stream = await navigator.mediaDevices.getDisplayMedia({ + video: { + displaySurface: 'monitor' as DisplayCaptureSurfaceType, + width: {ideal: 1920}, + height: {ideal: 1080}, + frameRate: {max: 30}, + }, + audio: { + autoGainControl: true, + echoCancellation: true, + noiseSuppression: true, + }, }); - screenStreamRef.current = mediaStream; - const videoTrack = mediaStream.getVideoTracks()[0]; - if (pcRef.current) { - const senders = pcRef.current.getSenders(); - const videoSender = senders.find( - sender => sender.track?.kind === 'video' - ); - if (videoSender) { - videoSender.replaceTrack(videoTrack); - } else { - mediaStream - .getTracks() - .forEach(track => pcRef.current?.addTrack(track, mediaStream)); - } + if (!stream) return; - if (localVideoRef.current) { - localVideoRef.current.srcObject = mediaStream; - } + screenStreamRef.current = stream; - setIsSharingScreen(true); - wsRef.current?.send(JSON.stringify({event: 'screenShare', data: true})); + // Produce screen share track + const videoTrack = stream.getVideoTracks()[0]; + const producer = await producerTransportRef.current?.produce({ + track: videoTrack, + encodings: [], // 필요한 경우 인코딩 설정 추가 + codecOptions: {}, // 필요한 경우 코덱 옵션 추가 + appData: {mediaType: 'screen'}, + }); + if (producer) { + producersRef.current.set('screen', producer); } + + setStreams(prev => ({ + ...prev, + [`${userId}-screen`]: { + stream, + type: 'screen', + userId, + nickname, + }, + })); + + setIsSharingScreen(true); + + videoTrack.onended = () => { + stopScreenShare(); + }; } catch (error) { - console.error('Screen sharing failed', error); + console.error('Failed to start screen share:', error); + setIsSharingScreen(false); } }; + // 화면 공유 중지 const stopScreenShare = async () => { + const producer = producersRef.current.get('screen'); + if (producer) { + producer.close(); + producersRef.current.delete('screen'); + } + if (screenStreamRef.current) { screenStreamRef.current.getTracks().forEach(track => track.stop()); screenStreamRef.current = null; + + setStreams(prev => { + const newStreams = {...prev}; + delete newStreams[`${userId}-screen`]; + return newStreams; + }); + + setIsSharingScreen(false); } - setIsSharingScreen(false); - resetLocalVideo(); - wsRef.current?.send(JSON.stringify({event: 'screenShare', data: false})); }; - const resetLocalVideo = async () => { - try { - const mediaStream = await navigator.mediaDevices.getUserMedia({ - video: true, - audio: true, - }); - mediaStreamRef.current = mediaStream; - const videoTrack = mediaStream.getVideoTracks()[0]; + // 수업 종료 + const handleEndClass = useCallback(() => { + // Close all producers + producersRef.current.forEach(producer => producer.close()); + producersRef.current.clear(); - if (pcRef.current) { - const senders = pcRef.current.getSenders(); - const videoSender = senders.find( - sender => sender.track?.kind === 'video' - ); - if (videoSender) { - videoSender.replaceTrack(videoTrack); - } else { - mediaStream - .getTracks() - .forEach(track => pcRef.current?.addTrack(track, mediaStream)); - } + // Close all consumers + consumersRef.current.forEach(consumer => consumer.close()); + consumersRef.current.clear(); - if (localVideoRef.current) { - localVideoRef.current.srcObject = mediaStream; - } - } + // Close transports + producerTransportRef.current?.close(); + consumerTransportRef.current?.close(); + + // Stop all tracks + if (localStreamRef.current) { + localStreamRef.current.getTracks().forEach(track => track.stop()); + } + if (screenStreamRef.current) { + 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 + ) => { + try { + const {rtpCapabilities} = deviceRef.current!; + + wsRef.current?.send( + JSON.stringify({ + event: 'consume', + data: { + producerId, + rtpCapabilities, + userId: producerUserId, + }, + }) + ); } catch (error) { - console.error('Failed to reset local video stream', error); + console.error('Failed to subscribe to track:', error); } }; - const toggleMic = () => { - if (mediaStreamRef.current) { - const audioTrack = mediaStreamRef.current.getAudioTracks()[0]; - if (audioTrack) { - audioTrack.enabled = !audioTrack.enabled; - setIsMicOn(audioTrack.enabled); + // 미디어 상태 업데이트 + const updateMediaState = useCallback( + (type: 'audio' | 'video', enabled: boolean) => { + if (localStreamRef.current) { + const tracks = + type === 'audio' + ? localStreamRef.current.getAudioTracks() + : localStreamRef.current.getVideoTracks(); + + tracks.forEach(track => { + track.enabled = enabled; + }); + + setMediaState(prev => ({ + ...prev, + [type]: enabled, + })); + + // Notify other participants about media state change + wsRef.current?.send( + JSON.stringify({ + event: 'mediaStateChange', + data: { + userId, + type, + enabled, + }, + }) + ); } + }, + [userId] + ); + + // 재연결 처리 + const handleReconnect = useCallback((): void => { + if (reconnectAttempts < 5) { + const timeout = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000); + setTimeout(() => { + setReconnectAttempts(prev => prev + 1); + connectWebSocketRef.current?.(); + }, timeout); + } else { + console.error('Maximum reconnection attempts reached'); + handleEndClass(); } - }; + }, [reconnectAttempts, handleEndClass]); - const toggleCamera = () => { - if (mediaStreamRef.current) { - const videoTrack = mediaStreamRef.current.getVideoTracks()[0]; - if (videoTrack) { - videoTrack.enabled = !videoTrack.enabled; - setIsCameraOn(videoTrack.enabled); - } + useEffect(() => { + handleReconnectRef.current = handleReconnect; + }, [handleReconnect]); + + handleReconnectRef.current = handleReconnect; + + useEffect(() => { + return () => { + handleEndClass(); + wsRef.current?.close(); + localStreamRef.current?.getTracks().forEach(track => track.stop()); + }; + }, [handleEndClass]); + + // 수업 시작 + const handleStartClass = useCallback(async () => { + try { + setClassStarted(true); + setConnectionState('connecting'); + connectWebSocketRef.current?.(); + } catch (error) { + console.error('Failed to start class:', error); + setClassStarted(false); + setConnectionState('disconnected'); } - }; + }, []); + + useEffect(() => { + const handleAutoReconnect = () => { + if (connectionState === 'disconnected' && classStarted) { + handleReconnectRef.current?.(); + } + }; + + const timer = setTimeout(handleAutoReconnect, 3000); + + return () => { + clearTimeout(timer); + }; + }, [connectionState, classStarted]); + + const cleanupStreams = useCallback(() => { + Object.values(streams).forEach(streamData => { + if (streamData.stream) { + streamData.stream.getTracks().forEach(track => { + track.stop(); + track.enabled = false; + }); + } + }); + setStreams({}); + }, [streams]); + + useEffect(() => { + return () => { + cleanupStreams(); + handleEndClass(); + wsRef.current?.close(); + localStreamRef.current?.getTracks().forEach(track => track.stop()); + }; + }, [cleanupStreams, handleEndClass]); + + // 비디오 레이아웃 계산 + const getVideoLayout = useCallback(() => { + const streamCount = Object.keys(streams).length; + return streamCount <= 1 + ? 'grid-cols-1' + : streamCount <= 4 + ? 'grid-cols-2' + : streamCount <= 9 + ? 'grid-cols-3' + : 'grid-cols-4'; + }, [streams]); return ( -
-
+
+ {/* 컨트롤 패널 */} +
+ {!classStarted ? ( ) : ( - <> - - {isSharingScreen ? ( - - ) : ( - - )} -
- - -
- + updateMediaState('audio', !mediaState.audio)} + onToggleVideo={() => updateMediaState('video', !mediaState.video)} + /> )}
- {classStarted ? ( -
+ {/* 출석부 */} + {classStarted && isTeacher && ( +
- ) : null} + )} + {/* 비디오 그리드 */} {classStarted && ( -
-
-
-
-
+ ))}
)}
diff --git a/src/app/classes/[cId]/[mId]/components/manageSubComponents/VideoBox.tsx b/src/app/classes/[cId]/[mId]/components/manageSubComponents/VideoBox.tsx new file mode 100644 index 0000000..b353d67 --- /dev/null +++ b/src/app/classes/[cId]/[mId]/components/manageSubComponents/VideoBox.tsx @@ -0,0 +1,95 @@ +import React, {useState, useEffect, useRef, memo} from 'react'; + +interface VideoBoxProps { + streamData: { + stream: MediaStream; + type: 'video' | 'screen'; + userId: number; + nickname?: string; + }; + isLocal: boolean; +} + +const VideoBox: React.FC = memo(({streamData, isLocal}) => { + const videoRef = useRef(null); + const [hasError, setHasError] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const videoElement = videoRef.current; + + if (videoElement && streamData.stream) { + try { + videoElement.srcObject = streamData.stream; + setHasError(false); + + const handleCanPlay = () => setIsLoading(false); + videoElement.addEventListener('canplay', handleCanPlay); + + return () => { + videoElement.removeEventListener('canplay', handleCanPlay); + }; + } catch (error) { + console.error('Error setting video stream:', error); + setHasError(true); + } + } + + // Cleanup srcObject when component unmounts or stream changes + return () => { + if (videoElement) { + videoElement.srcObject = null; + } + }; + }, [streamData.stream]); + + const handleError = () => { + setHasError(true); + setIsLoading(false); + }; + + const containerClass = + 'relative aspect-video rounded-lg overflow-hidden ' + + (hasError || isLoading ? 'bg-gray-800' : 'bg-black'); + + return ( +
+ {isLoading && !hasError && ( +
+
読み込み中...
+
+ )} + + {hasError ? ( +
+ ビデオの読み込みに失敗しました +
+ ) : ( + <> +
+ ); +}); + +VideoBox.displayName = 'VideoBox'; +export default VideoBox; diff --git a/yarn.lock b/yarn.lock index 3448c1a..445ebd5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1157,7 +1157,7 @@ dependencies: "@babel/types" "^7.20.7" -"@types/debug@^4.0.0": +"@types/debug@^4.0.0", "@types/debug@^4.1.12": version "4.1.12" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917" integrity sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ== @@ -1282,6 +1282,11 @@ resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz#56e2cc26c397c038fab0e3a917a12d5c5909e901" integrity sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA== +"@types/npm-events-package@npm:@types/events@^3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.3.tgz#a8ef894305af28d1fc6d2dfdfc98e899591ea529" + integrity sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g== + "@types/prop-types@*": version "15.7.11" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.11.tgz#2596fb352ee96a1379c657734d4b913a613ad563" @@ -1340,6 +1345,11 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.10.tgz#04ffa7f406ab628f7f7e97ca23e290cd8ab15efc" integrity sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA== +"@types/webrtc@^0.0.44": + version "0.0.44" + resolved "https://registry.yarnpkg.com/@types/webrtc/-/webrtc-0.0.44.tgz#62b61ff391b5ded124861eaaeefa9433fc6ae9b4" + integrity sha512-4BJZdzrApNFeuXgucyqs24k69f7oti3wUcGEbFbaV08QBh7yEe3tnRRuYXlyXJNXiumpZujiZqUZZ2/gMSeO0g== + "@types/yargs-parser@*": version "21.0.3" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" @@ -1850,6 +1860,13 @@ available-typed-arrays@^1.0.5: resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7" integrity sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw== +awaitqueue@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/awaitqueue/-/awaitqueue-3.0.2.tgz#a37a212b137b784dc6bd701d1ecfa4a07ec89625" + integrity sha512-AVAtRwmf0DNSesMdyanFKKejTrOnjdKtz5LIDQFu2OTUgXvB/CRTYMrkPAF/2GCF9XBtYVxSwxDORlD41S+RyQ== + dependencies: + debug "^4.3.4" + axe-core@=4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.7.0.tgz#34ba5a48a8b564f67e103f0aa5768d76e15bbbbf" @@ -2461,6 +2478,13 @@ debug@^3.2.7: dependencies: ms "^2.1.1" +debug@^4.3.7: + version "4.3.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" + integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== + dependencies: + ms "^2.1.3" + decamelize-keys@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/decamelize-keys/-/decamelize-keys-1.1.1.tgz#04a2d523b2f18d80d0158a43b895d56dff8d19d8" @@ -3194,6 +3218,11 @@ event-source-polyfill@^1.0.31: resolved "https://registry.yarnpkg.com/event-source-polyfill/-/event-source-polyfill-1.0.31.tgz#45fb0a6fc1375b2ba597361ba4287ffec5bf2e0c" integrity sha512-4IJSItgS/41IxN5UVAVuAyczwZF7ZIEsM1XAoUzIHA6A+xzusEZUutdXz2Nr+MQPLxfTiCvqE79/C8HT8fKFvA== +event-target-shim@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-6.0.2.tgz#ea5348c3618ee8b62ff1d344f01908ee2b8a2b71" + integrity sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA== + eventemitter3@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4" @@ -3264,6 +3293,14 @@ external-editor@^3.0.3: iconv-lite "^0.4.24" tmp "^0.0.33" +fake-mediastreamtrack@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/fake-mediastreamtrack/-/fake-mediastreamtrack-1.2.0.tgz#11e6e0c50d36d3bc988461c034beb81debee548b" + integrity sha512-AxHtlEmka1sqNoe3Ej1H1hJc9gjjO/6vCbCPm4D4QeEXvzhjYumA+iZ7wOi2WrmkAhGElHhBgWoNgJhFccectA== + dependencies: + event-target-shim "^6.0.2" + uuid "^9.0.0" + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -3664,6 +3701,14 @@ gts@^5.2.0: rimraf "3.0.2" write-file-atomic "^4.0.0" +h264-profile-level-id@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/h264-profile-level-id/-/h264-profile-level-id-2.0.0.tgz#b7ea45badbac8f5dbb9583d34b06db09764f2535" + integrity sha512-X4CLryVbVA0CtjTExS4G5U1gb2Z4wa32AF8ukVmFuLdw2JRq2aHisor7SY5SYTUUrUSqq0KdPIO18sql6IWIQw== + dependencies: + "@types/debug" "^4.1.12" + debug "^4.3.4" + hamt_plus@1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/hamt_plus/-/hamt_plus-1.0.2.tgz#e21c252968c7e33b20f6a1b094cd85787a265601" @@ -5160,6 +5205,23 @@ mdast-util-to-string@^4.0.0: dependencies: "@types/mdast" "^4.0.0" +mediasoup-client@^3.7.17: + version "3.7.17" + resolved "https://registry.yarnpkg.com/mediasoup-client/-/mediasoup-client-3.7.17.tgz#109a903e20b8d370f70c39f0ec5ca38e7cf50a9c" + integrity sha512-yAOYrA30W+8I8RWdMHy6ahAD8y/zeKQSe8HXnUiKxRvBFgor3q1lOLCf1zk96fvpv69ayX4Ti6Ve1pWSCU36dw== + dependencies: + "@types/debug" "^4.1.12" + "@types/npm-events-package" "npm:@types/events@^3.0.3" + awaitqueue "^3.0.2" + debug "^4.3.7" + fake-mediastreamtrack "^1.2.0" + h264-profile-level-id "^2.0.0" + npm-events-package "npm:events@^3.3.0" + queue-microtask "^1.2.3" + sdp-transform "^2.14.2" + supports-color "^9.4.0" + ua-parser-js "^1.0.39" + mem@^8.0.0: version "8.1.1" resolved "https://registry.yarnpkg.com/mem/-/mem-8.1.1.tgz#cf118b357c65ab7b7e0817bdf00c8062297c0122" @@ -5530,7 +5592,7 @@ ms@2.1.2: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@^2.1.1: +ms@^2.1.1, ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== @@ -5651,6 +5713,11 @@ normalize-range@^0.1.2: resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" integrity sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA== +"npm-events-package@npm:events@^3.3.0": + version "3.3.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== + npm-run-path@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" @@ -6543,7 +6610,7 @@ scheduler@^0.23.0: dependencies: loose-envify "^1.1.0" -sdp-transform@^2.14.0: +sdp-transform@^2.14.0, sdp-transform@^2.14.2: version "2.14.2" resolved "https://registry.yarnpkg.com/sdp-transform/-/sdp-transform-2.14.2.tgz#d2cee6a1f7abe44e6332ac6cbb94e8600f32d813" integrity sha512-icY6jVao7MfKCieyo1AyxFYm1baiM+fA00qW/KrNNVlkxHAd34riEKuEkUe4bBb3gJwLJZM+xT60Yj1QL8rHiA== @@ -6997,6 +7064,11 @@ supports-color@^8.0.0: dependencies: has-flag "^4.0.0" +supports-color@^9.4.0: + version "9.4.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-9.4.0.tgz#17bfcf686288f531db3dea3215510621ccb55954" + integrity sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw== + supports-preserve-symlinks-flag@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" @@ -7332,6 +7404,11 @@ typescript@5.1.6: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.1.6.tgz#02f8ac202b6dad2c0dd5e0913745b47a37998274" integrity sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA== +ua-parser-js@^1.0.39: + version "1.0.39" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.39.tgz#bfc07f361549bf249bd8f4589a4cccec18fd2018" + integrity sha512-k24RCVWlEcjkdOxYmVJgeD/0a1TiSpqLg+ZalVGV9lsnr4yqu0w7tX/x2xX6G4zpkgQnRf89lxuZ1wsbjXM8lw== + unbox-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e" @@ -7449,6 +7526,11 @@ uuid@^8.0.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== +uuid@^9.0.0: + version "9.0.1" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" + integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== + v8-compile-cache-lib@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf"