From 173e323468a43908afb0bfb03dbe582139aad763 Mon Sep 17 00:00:00 2001 From: Regulus0811 Date: Wed, 12 Jun 2024 23:16:43 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20feat:=20Implement=20initial=20l?= =?UTF-8?q?ayout=20for=20the=20instructor=20side=20in=20video=20chat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Created the initial layout for the instructor side in the video chat functionality. Related issue: #150 --- .../manageSubComponents/LiveClass.tsx | 284 ++++++++++++------ 1 file changed, 193 insertions(+), 91 deletions(-) diff --git a/src/app/classes/[cId]/[mId]/components/manageSubComponents/LiveClass.tsx b/src/app/classes/[cId]/[mId]/components/manageSubComponents/LiveClass.tsx index 2556f98..d618058 100644 --- a/src/app/classes/[cId]/[mId]/components/manageSubComponents/LiveClass.tsx +++ b/src/app/classes/[cId]/[mId]/components/manageSubComponents/LiveClass.tsx @@ -117,6 +117,7 @@ // export default LiveClass; /* eslint-disable @typescript-eslint/no-explicit-any */ +// LiveClass.tsx import React, {useEffect, useRef, useState} from 'react'; interface LiveClassProps { @@ -127,10 +128,10 @@ interface LiveClassProps { const LiveClass: React.FC = ({classId, userId}) => { const [classStarted, setClassStarted] = useState(false); const localVideoRef = useRef(null); - const peerConnections = useRef<{[key: string]: RTCPeerConnection}>({}); - const remoteVideoRefs = useRef<{[key: string]: HTMLVideoElement | null}>({}); + const remoteVideoRef = useRef(null); + const pcRef = useRef(null); const wsRef = useRef(null); - const iceCandidatesRef = useRef<{[key: string]: any[]}>({}); + const iceCandidatesRef = useRef([]); const startWebSocket = () => { const ws = new WebSocket( @@ -140,33 +141,46 @@ const LiveClass: React.FC = ({classId, userId}) => { ws.onopen = async () => { console.log('WebSocket connected'); + iceCandidatesRef.current.forEach(candidate => { + ws.send(JSON.stringify({event: 'candidate', data: candidate})); + }); + iceCandidatesRef.current = []; + + if (pcRef.current) { + try { + const mediaStream = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: true, + }); + 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); + ws.send( + JSON.stringify({ + event: 'offer', + data: pcRef.current.localDescription, + }) + ); + } catch (error) { + console.error('Failed to start media stream', error); + } + } }; ws.onmessage = async event => { - const {event: evt, data, from} = JSON.parse(event.data); - console.log('Message received:', evt, data, from); - - if (evt === 'offer') { - const pc = createPeerConnection(from); - await pc.setRemoteDescription(new RTCSessionDescription(data)); - const answer = await pc.createAnswer(); - await pc.setLocalDescription(answer); - ws.send(JSON.stringify({event: 'answer', data: answer, to: from})); - } else if (evt === 'answer') { - const pc = peerConnections.current[from]; - if (pc) { - await pc.setRemoteDescription(new RTCSessionDescription(data)); - } + const {event: evt, data} = JSON.parse(event.data); + console.log('Message received:', evt, data); + if (evt === 'answer') { + await pcRef.current?.setRemoteDescription( + new RTCSessionDescription(data) + ); } else if (evt === 'candidate') { - const pc = peerConnections.current[from]; - if (pc) { - await pc.addIceCandidate(new RTCIceCandidate(data)); - } else { - if (!iceCandidatesRef.current[from]) { - iceCandidatesRef.current[from] = []; - } - iceCandidatesRef.current[from].push(data); - } + await pcRef.current?.addIceCandidate(new RTCIceCandidate(data)); } }; @@ -175,74 +189,62 @@ const LiveClass: React.FC = ({classId, userId}) => { }; }; - const createPeerConnection = (peerId: string) => { - const pc = new RTCPeerConnection({ - iceServers: [{urls: 'stun:stun.l.google.com:19302'}], - }); - peerConnections.current[peerId] = pc; + 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', + ], + }, + ], + }); + pcRef.current = pc; - pc.onicecandidate = event => { - if (event.candidate) { - wsRef.current?.send( - JSON.stringify({ + pc.onicecandidate = event => { + if (event.candidate) { + const candidateData = JSON.stringify({ event: 'candidate', - data: event.candidate, - to: peerId, - }) - ); - } - }; - - pc.ontrack = event => { - if (!remoteVideoRefs.current[peerId]) { - remoteVideoRefs.current[peerId] = document.createElement('video'); - remoteVideoRefs.current[peerId]!.autoplay = true; - remoteVideoRefs.current[peerId]!.playsInline = true; - document - .getElementById('remoteVideos') - ?.appendChild(remoteVideoRefs.current[peerId]!); - } - remoteVideoRefs.current[peerId]!.srcObject = event.streams[0]; - }; - - if (iceCandidatesRef.current[peerId]) { - iceCandidatesRef.current[peerId].forEach(candidate => { - pc.addIceCandidate(new RTCIceCandidate(candidate)); - }); - iceCandidatesRef.current[peerId] = []; - } + data: event.candidate.toJSON(), + }); + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(candidateData); + } else { + iceCandidatesRef.current.push(event.candidate.toJSON()); + } + } + }; - return pc; - }; + pc.ontrack = event => { + if (remoteVideoRef.current) { + remoteVideoRef.current.srcObject = event.streams[0]; + } + }; - useEffect(() => { - if (classStarted) { startWebSocket(); return () => { - // eslint-disable-next-line react-hooks/exhaustive-deps - Object.values(peerConnections.current).forEach(pc => pc.close()); - if (wsRef.current) wsRef.current.close(); - // eslint-disable-next-line react-hooks/exhaustive-deps - if (localVideoRef.current) localVideoRef.current.srcObject = null; - Object.values(remoteVideoRefs.current).forEach(video => { - if (video) video.srcObject = null; - }); - remoteVideoRefs.current = {}; + 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; + } }; } }, [classStarted]); - const handleStartClass = async () => { + const handleStartClass = () => { setClassStarted(true); - const mediaStream = await navigator.mediaDevices.getUserMedia({ - video: true, - audio: true, - }); - if (localVideoRef.current) { - localVideoRef.current.srcObject = mediaStream; - } - wsRef.current?.send(JSON.stringify({event: 'join', data: null})); }; const handleEndClass = () => { @@ -250,16 +252,116 @@ const LiveClass: React.FC = ({classId, userId}) => { }; return ( -
- {!classStarted ? ( - - ) : ( - - )} +
+
+ {!classStarted ? ( + + ) : ( + + )} +
+ {classStarted && ( -
-
From f3345ef78a10a2561d18793817f665751b0b6e40 Mon Sep 17 00:00:00 2001 From: Regulus0811 Date: Wed, 12 Jun 2024 23:17:14 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8=20feat:=20Implement=20initial=20l?= =?UTF-8?q?ayout=20for=20the=20student=20side=20in=20video=20chat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Created the initial layout for the student side in the video chat functionality. Related issue: #150 --- .../subComponents/LiveClassViewer.tsx | 293 ++++++++++++------ 1 file changed, 204 insertions(+), 89 deletions(-) diff --git a/src/app/classes/[cId]/[mId]/components/subComponents/LiveClassViewer.tsx b/src/app/classes/[cId]/[mId]/components/subComponents/LiveClassViewer.tsx index cb33b8c..1b50b73 100644 --- a/src/app/classes/[cId]/[mId]/components/subComponents/LiveClassViewer.tsx +++ b/src/app/classes/[cId]/[mId]/components/subComponents/LiveClassViewer.tsx @@ -95,6 +95,8 @@ // export default LiveClassViewer; +/* eslint-disable @typescript-eslint/no-explicit-any */ +// LiveClassViewer.tsx import React, {useEffect, useRef, useState} from 'react'; interface LiveClassViewerProps { @@ -105,11 +107,10 @@ interface LiveClassViewerProps { const LiveClassViewer: React.FC = ({classId, userId}) => { const [inClass, setInClass] = useState(false); const localVideoRef = useRef(null); - const peerConnections = useRef<{[key: string]: RTCPeerConnection}>({}); - const remoteVideoRefs = useRef<{[key: string]: HTMLVideoElement | null}>({}); + const remoteVideoRef = useRef(null); + const pcRef = useRef(null); const wsRef = useRef(null); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const iceCandidatesRef = useRef<{[key: string]: any[]}>({}); + const iceCandidatesRef = useRef([]); const startWebSocket = () => { const ws = new WebSocket( @@ -119,27 +120,50 @@ const LiveClassViewer: React.FC = ({classId, userId}) => { ws.onopen = async () => { console.log('WebSocket connected'); + iceCandidatesRef.current.forEach(candidate => { + ws.send(JSON.stringify({event: 'candidate', data: candidate})); + }); + iceCandidatesRef.current = []; + + if (pcRef.current) { + try { + const mediaStream = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: true, + }); + mediaStream + .getTracks() + .forEach(track => pcRef.current?.addTrack(track, mediaStream)); + if (localVideoRef.current) { + localVideoRef.current.srcObject = mediaStream; + } + } catch (error) { + console.error('Failed to start media stream', error); + } + } }; ws.onmessage = async event => { - const {event: evt, data, from} = JSON.parse(event.data); - console.log('Message received:', evt, data, from); - - if (evt === 'offer') { - const pc = createPeerConnection(from); - await pc.setRemoteDescription(new RTCSessionDescription(data)); - const answer = await pc.createAnswer(); - await pc.setLocalDescription(answer); - ws.send(JSON.stringify({event: 'answer', data: answer, to: from})); - } else if (evt === 'candidate') { - const pc = peerConnections.current[from]; - if (pc) { - await pc.addIceCandidate(new RTCIceCandidate(data)); - } else { - if (!iceCandidatesRef.current[from]) { - iceCandidatesRef.current[from] = []; - } - iceCandidatesRef.current[from].push(data); + 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) + ); + 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' && pcRef.current) { + try { + console.log('Received candidate:', data); + await pcRef.current.addIceCandidate(new RTCIceCandidate(data)); + } catch (error) { + console.error('Failed to add ICE candidate:', error); } } }; @@ -149,93 +173,184 @@ const LiveClassViewer: React.FC = ({classId, userId}) => { }; }; - const createPeerConnection = (peerId: string) => { - const pc = new RTCPeerConnection({ - iceServers: [{urls: 'stun:stun.l.google.com:19302'}], - }); - peerConnections.current[peerId] = pc; + 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', + ], + }, + ], + }); + pcRef.current = pc; - pc.onicecandidate = event => { - if (event.candidate) { - wsRef.current?.send( - JSON.stringify({ + pc.onicecandidate = event => { + if (event.candidate) { + const candidateData = JSON.stringify({ event: 'candidate', - data: event.candidate, - to: peerId, - }) - ); - } - }; - - pc.ontrack = event => { - if (!remoteVideoRefs.current[peerId]) { - remoteVideoRefs.current[peerId] = document.createElement('video'); - remoteVideoRefs.current[peerId]!.autoplay = true; - remoteVideoRefs.current[peerId]!.playsInline = true; - document - .getElementById('remoteVideos') - ?.appendChild(remoteVideoRefs.current[peerId]!); - } - remoteVideoRefs.current[peerId]!.srcObject = event.streams[0]; - }; - - if (iceCandidatesRef.current[peerId]) { - iceCandidatesRef.current[peerId].forEach(candidate => { - pc.addIceCandidate(new RTCIceCandidate(candidate)); - }); - iceCandidatesRef.current[peerId] = []; - } + data: event.candidate.toJSON(), + }); + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(candidateData); + } else { + iceCandidatesRef.current.push(event.candidate.toJSON()); + } + } + }; - return pc; - }; + pc.ontrack = event => { + if (remoteVideoRef.current) { + remoteVideoRef.current.srcObject = event.streams[0]; + } + }; - useEffect(() => { - if (inClass) { startWebSocket(); - - return () => { - Object.values(peerConnections.current).forEach(pc => pc.close()); - if (wsRef.current) wsRef.current.close(); - if (localVideoRef.current) localVideoRef.current.srcObject = null; - Object.values(remoteVideoRefs.current).forEach(video => { - if (video) video.srcObject = null; - }); - remoteVideoRefs.current = {}; - }; } + + return () => { + pcRef.current?.close(); + wsRef.current?.close(); + }; }, [inClass]); - const handleJoinClass = async () => { + const handleJoinClass = () => { setInClass(true); - const mediaStream = await navigator.mediaDevices.getUserMedia({ - video: true, - audio: true, - }); - if (localVideoRef.current) { - localVideoRef.current.srcObject = mediaStream; - } - wsRef.current?.send(JSON.stringify({event: 'join', data: null})); }; const handleLeaveClass = () => { + wsRef.current?.close(); + pcRef.current?.close(); + pcRef.current = null; + wsRef.current = null; setInClass(false); + if (localVideoRef.current) { + localVideoRef.current.srcObject = null; + } + if (remoteVideoRef.current) { + remoteVideoRef.current.srcObject = null; + } }; return ( -
- {!inClass ? ( - - ) : ( - - )} +
+
+ {!inClass ? ( + + ) : ( + + )} +
+ {inClass && ( -
-
); }; - export default LiveClassViewer;