diff --git a/src/frontend/package.json b/src/frontend/package.json index 6aeb771c..c4c32531 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -23,6 +23,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^7.1.3", + "socket.io-client": "^4.8.1", "sockjs-client": "1.5.1", "stompjs": "^2.3.3", "storybook": "^8.4.7", @@ -45,6 +46,7 @@ "@testing-library/react": "^16.1.0", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", + "@types/socket.io-client": "^3.0.0", "@types/styled-components": "^5.1.34", "@types/youtube": "^0.1.0", "@vitejs/plugin-react": "^4.3.4", diff --git a/src/frontend/pnpm-lock.yaml b/src/frontend/pnpm-lock.yaml index af1aa8b1..e08b6103 100644 --- a/src/frontend/pnpm-lock.yaml +++ b/src/frontend/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: react-router-dom: specifier: ^7.1.3 version: 7.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + socket.io-client: + specifier: ^4.8.1 + version: 4.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) sockjs-client: specifier: 1.5.1 version: 1.5.1 @@ -102,6 +105,9 @@ importers: '@types/react-dom': specifier: ^18.3.5 version: 18.3.5(@types/react@18.3.18) + '@types/socket.io-client': + specifier: ^3.0.0 + version: 3.0.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) '@types/styled-components': specifier: ^5.1.34 version: 5.1.34 @@ -880,6 +886,9 @@ packages: resolution: {integrity: sha512-IVBoAzZmpoX9+mnmIMq2ndxlFPoWMuYSE5Mek5zOWpYh+GbPxvkrxvM+vg0HeLH4r5v9Tm0FWcEZDgDIZqtoSg==} engines: {node: '>= 14'} + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@storybook/addon-actions@8.4.7': resolution: {integrity: sha512-mjtD5JxcPuW74T6h7nqMxWTvDneFtokg88p6kQ5OnC1M259iAXb//yiSZgu/quunMHPCXSiqn4FNOSgASTSbsA==} peerDependencies: @@ -1196,6 +1205,10 @@ packages: '@types/serve-static@1.15.7': resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==} + '@types/socket.io-client@3.0.0': + resolution: {integrity: sha512-s+IPvFoEIjKA3RdJz/Z2dGR4gLgysKi8owcnrVwNjgvc01Lk68LJDDsG2GRqegFITcxmvCMYM7bhMpwEMlHmDg==} + deprecated: This is a stub types definition. socket.io-client provides its own type definitions, so you do not need this installed. + '@types/sockjs-client@1.5.4': resolution: {integrity: sha512-zk+uFZeWyvJ5ZFkLIwoGA/DfJ+pYzcZ8eH4H/EILCm2OBZyHH6Hkdna1/UWL/CFruh5wj6ES7g75SvUB0VsH5w==} @@ -1538,6 +1551,15 @@ packages: supports-color: optional: true + debug@4.3.7: + resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.0: resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} engines: {node: '>=6.0'} @@ -1597,6 +1619,13 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + engine.io-client@6.6.3: + resolution: {integrity: sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==} + + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -2502,6 +2531,14 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + socket.io-client@4.8.1: + resolution: {integrity: sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==} + engines: {node: '>=10.0.0'} + + socket.io-parser@4.2.4: + resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==} + engines: {node: '>=10.0.0'} + sockjs-client@1.5.1: resolution: {integrity: sha512-VnVAb663fosipI/m6pqRXakEOw7nvd7TUgdr3PlR/8V2I95QIdwT8L4nMxhyU8SmDBHYXU1TOElaKOmKLfYzeQ==} @@ -2900,6 +2937,18 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + ws@8.17.1: + resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.18.0: resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} engines: {node: '>=10.0.0'} @@ -2919,6 +2968,10 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xmlhttprequest-ssl@2.1.2: + resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} + engines: {node: '>=0.4.0'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -3544,6 +3597,8 @@ snapshots: - encoding - supports-color + '@socket.io/component-emitter@3.1.2': {} + '@storybook/addon-actions@8.4.7(storybook@8.4.7(bufferutil@4.0.9)(prettier@3.4.2)(utf-8-validate@5.0.10))': dependencies: '@storybook/global': 5.0.0 @@ -4002,6 +4057,14 @@ snapshots: '@types/node': 22.10.6 '@types/send': 0.17.4 + '@types/socket.io-client@3.0.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)': + dependencies: + socket.io-client: 4.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@types/sockjs-client@1.5.4': {} '@types/stompjs@2.3.9': @@ -4393,6 +4456,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.3.7: + dependencies: + ms: 2.1.3 + debug@4.4.0: dependencies: ms: 2.1.3 @@ -4435,6 +4502,20 @@ snapshots: emoji-regex@8.0.0: {} + engine.io-client@6.6.3(bufferutil@4.0.9)(utf-8-validate@5.0.10): + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.3.7 + engine.io-parser: 5.2.3 + ws: 8.17.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + xmlhttprequest-ssl: 2.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + engine.io-parser@5.2.3: {} + entities@4.5.0: {} es-define-property@1.0.1: {} @@ -5381,6 +5462,24 @@ snapshots: siginfo@2.0.0: {} + socket.io-client@4.8.1(bufferutil@4.0.9)(utf-8-validate@5.0.10): + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.3.7 + engine.io-client: 6.6.3(bufferutil@4.0.9)(utf-8-validate@5.0.10) + socket.io-parser: 4.2.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.4: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.3.7 + transitivePeerDependencies: + - supports-color + sockjs-client@1.5.1: dependencies: debug: 3.2.7 @@ -5753,6 +5852,11 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + ws@8.17.1(bufferutil@4.0.9)(utf-8-validate@5.0.10): + optionalDependencies: + bufferutil: 4.0.9 + utf-8-validate: 5.0.10 + ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10): optionalDependencies: bufferutil: 4.0.9 @@ -5762,6 +5866,8 @@ snapshots: xmlchars@2.2.0: {} + xmlhttprequest-ssl@2.1.2: {} + y18n@5.0.8: {} yaeti@0.0.6: diff --git a/src/frontend/src/components/Sidebar/VoiceChat/index.css.ts b/src/frontend/src/components/Sidebar/VoiceChat/index.css.ts index 39336fb9..7ec6a184 100644 --- a/src/frontend/src/components/Sidebar/VoiceChat/index.css.ts +++ b/src/frontend/src/components/Sidebar/VoiceChat/index.css.ts @@ -1,13 +1,77 @@ import styled from 'styled-components'; +import { + VoiceChatFooter as OriginalFooter, + ActionButton as OriginalActionButton, + JoinButton as OriginalJoinButton, +} from '@/components/Sidebar/UserList/index.css.ts'; + export const Container = styled.div` height: 100%; display: flex; flex-direction: column; - align-items: center; justify-content: space-between; `; +export const ParticipantsPreview = styled.div` + flex: 0 0 auto; + border-bottom: 1px solid #ddd; + overflow-y: auto; +`; + +export const VideoSection = styled.div` + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + justify-content: space-between; +`; + +export const MyVideoSection = styled.p` + display: flex; + background-color: #eeeeee; + border-radius: 10px; + + padding: 10px; + align-items: center; +`; + +export const RemoteVideoGrid = styled.div` + display: grid; + grid-template-columns: repeat(2, 1fr); + margin-top: 10px; + overflow-y: auto; + overflow-x: hidden; + height: 100%; +`; + +export const FooterBar = styled(OriginalFooter)` + margin-top: auto; + position: static; + padding: 10px; + border-top: 1px solid #ddd; + display: flex; + justify-content: center; +`; + +export const ActionButton = styled(OriginalActionButton)` + margin: 0 5px; +`; + +export const JoinButton = styled(OriginalJoinButton)` + margin-left: 10px; +`; + +// + +// export const Container = styled.div` +// height: 100%; +// display: flex; +// flex-direction: column; +// align-items: center; +// justify-content: space-between; +// `; + export const UserList = styled.div` display: flex; flex-direction: column; @@ -31,24 +95,24 @@ export const VoiceChatFooter = styled.div` border-top: 1px solid var(--palette-line-solid-neutral); `; -export const ActionButton = styled.button` - border: none; - padding: 10px; - border-radius: 50%; - background-color: #f4f4f4; - cursor: pointer; -`; +// export const ActionButton = styled.button` +// border: none; +// padding: 10px; +// border-radius: 50%; +// background-color: #f4f4f4; +// cursor: pointer; +// `; -export const JoinButton = styled.button` - width: 194px; - border: none; - background-color: #ff9100; - padding: 10px 20px; - color: white; - font-weight: bold; - border-radius: 5px; - cursor: pointer; -`; +// export const JoinButton = styled.button` +// width: 194px; +// border: none; +// background-color: #ff9100; +// padding: 10px 20px; +// color: white; +// font-weight: bold; +// border-radius: 5px; +// cursor: pointer; +// `; export const ProfileWrapper = styled.div` position: relative; diff --git a/src/frontend/src/components/Sidebar/VoiceChat/index.tsx b/src/frontend/src/components/Sidebar/VoiceChat/index.tsx index 547edb2d..b6bc9195 100644 --- a/src/frontend/src/components/Sidebar/VoiceChat/index.tsx +++ b/src/frontend/src/components/Sidebar/VoiceChat/index.tsx @@ -1,63 +1,377 @@ -import { useState } from 'react'; -import { SmallProfile } from '@/components/common/SmallProfile'; -import { UserListFooter } from '@/components/Sidebar/UserList/UserListFooter'; +import { useEffect, useRef, useState } from 'react'; +import { io, Socket } from 'socket.io-client'; +import axios from 'axios'; + +import MicrophoneOn from '@/assets/img/MicrophoneOn.svg'; +import HeadphoneOn from '@/assets/img/HeadphoneOn.svg'; +import MicrophoneOffRed from '@/assets/img/MicrophoneOffRed.svg'; +import HeadphoneOffRed from '@/assets/img/HeadphoneOffRed.svg'; + +import { useCurrentRoomStore } from '@/stores/useCurrentRoomStore'; +import { useUserStore } from '@/stores/useUserStore'; import { SidebarType } from '@/types/enums/SidebarType'; import { ProfileType } from '@/types/enums/ProfileType'; -import { Container, UserList, ProfileWrapper } from './index.css'; -import { CurrentRoomUserDto } from '@/api/endpoints/room/room.interface'; import { RoomProfileModal } from '@/components/Modal/RoomProfileModal'; -import { useCurrentRoomStore } from '@/stores/useCurrentRoomStore'; +import { SmallProfile } from '@/components/common/SmallProfile'; +import { RoleNickname } from '@/components/common/RoleNickname'; + +import { + Container, + ParticipantsPreview, + VideoSection, + MyVideoSection, + RemoteVideoGrid, + FooterBar, + ActionButton, + JoinButton, + ProfileWrapper, +} from './index.css'; + export const VoiceChat = () => { - const [activeProfile, setActiveProfile] = useState(null); + const roomId = useCurrentRoomStore(state => state.roomId); + const userId = useUserStore(state => state.user?.userId); + const userNickname = useUserStore(state => state.user?.nickname); + const userList = useCurrentRoomStore(state => state.currentRoom?.roomDetails.userList || []); + + const [socket, setSocket] = useState(null); + const [joined, setJoined] = useState(false); + const [, setMuted] = useState(false); + const [, setCameraOff] = useState(false); + const [micOn, setMicOn] = useState(true); + const [soundOn, setSoundOn] = useState(true); + const [, setCameras] = useState([]); + const [participants, setParticipants] = useState([]); + + const myFaceRef = useRef(null); + const callRef = useRef(null); + const myStreamRef = useRef(null); + const peersRef = useRef<{ [socketId: string]: RTCPeerConnection }>({}); + const userMapRef = useRef<{ [socketId: string]: string }>({}); + + useEffect(() => { + if (!socket) { + const newSocket = io('http://localhost:8105', { transports: ['websocket'] }); + console.log('소켓연결'); + setSocket(newSocket); + } + return () => { + socket?.disconnect(); + }; + }, [socket]); + + useEffect(() => { + const fetchUserList = async () => { + try { + if (!roomId) return; + const response = await axios.get(`http://localhost:8105/api/signal/participants/${roomId}`); + if (response.data?.users) { + setParticipants(response.data.users); + console.log('API participants:', response.data.users); + } + } catch (error) { + console.error('fetchUserList error:', error); + } + }; + fetchUserList(); + }, [roomId]); + + useEffect(() => { + if (socket && roomId) { + socket.emit('request_participants', roomId); + } + }, [socket, roomId]); + + useEffect(() => { + if (!socket) return; + + const handleUpdateUserList = (users: string[]) => { + setParticipants(users); + console.log('소켓 participants:', users); + }; + socket.on('update_user_list', handleUpdateUserList); + + const handleUserLeft = (socketId: string) => { + const videoContainer = document.getElementById(`container_${socketId}`); + if (videoContainer && callRef.current) { + callRef.current.removeChild(videoContainer); + } + if (peersRef.current[socketId]) { + peersRef.current[socketId].close(); + delete peersRef.current[socketId]; + } + if (userMapRef.current[socketId]) { + delete userMapRef.current[socketId]; + } + }; + socket.on('user_left', handleUserLeft); + + const handleWelcome = async (newSocketId: string, newUserId: string) => { + userMapRef.current[newSocketId] = newUserId; + makeConnection(newSocketId); + + const pc = peersRef.current[newSocketId]; + if (!pc) return; + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + socket.emit('offer', offer, newSocketId); + }; + + const handleOffer = async ( + offer: RTCSessionDescriptionInit, + remoteId: string, + remoteUserId: string, + ) => { + userMapRef.current[remoteId] = remoteUserId; + if (!myStreamRef.current) { + await getMedia(); + } + makeConnection(remoteId); + + const pc = peersRef.current[remoteId]; + if (!pc) return; + await pc.setRemoteDescription(offer); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + socket.emit('answer', answer, remoteId); + }; + const handleAnswer = ( + answer: RTCSessionDescriptionInit, + remoteId: string, + remoteUserId: string, + ) => { + userMapRef.current[remoteId] = remoteUserId; + const pc = peersRef.current[remoteId]; + pc?.setRemoteDescription(answer); + }; + + const handleIce = (ice: RTCIceCandidate, remoteId: string, remoteUserId: string) => { + userMapRef.current[remoteId] = remoteUserId; + peersRef.current[remoteId]?.addIceCandidate(ice); + }; + + socket.on('welcome', handleWelcome); + socket.on('offer', handleOffer); + socket.on('answer', handleAnswer); + socket.on('ice', handleIce); + + return () => { + socket.off('update_user_list', handleUpdateUserList); + socket.off('user_left', handleUserLeft); + socket.off('welcome', handleWelcome); + socket.off('offer', handleOffer); + socket.off('answer', handleAnswer); + socket.off('ice', handleIce); + }; + }, [socket]); + + useEffect(() => { + if (joined && socket && roomId && userId) { + getMedia(); + socket.emit('join_room', { roomId, userId }); + } + }, [joined, socket, roomId, userId]); + + async function getMedia(deviceId?: string) { + const constraints = deviceId + ? { audio: true, video: { deviceId: { exact: deviceId } } } + : { audio: true, video: { facingMode: 'user' } }; + try { + const stream = await navigator.mediaDevices.getUserMedia(constraints); + myStreamRef.current = stream; + if (myFaceRef.current) { + myFaceRef.current.srcObject = stream; + myFaceRef.current.muted = true; + } + if (!deviceId) { + await getCameras(); + } + } catch (err) { + console.error('getMedia error:', err); + } + } + async function getCameras() { + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + const videoInputs = devices.filter(d => d.kind === 'videoinput'); + setCameras(videoInputs); + } catch (err) { + console.error('getCameras error:', err); + } + } + + function makeConnection(socketId: string) { + if (peersRef.current[socketId]) return; + const pc = new RTCPeerConnection({ + iceServers: [{ urls: ['stun:stun.l.google.com:19302'] }], + }); + + pc.addEventListener('icecandidate', e => { + if (e.candidate) { + socket?.emit('ice', e.candidate, socketId); + } + }); + + pc.addEventListener('track', e => { + const remoteUserId = userMapRef.current[socketId] || 'Unknown'; + const containerId = `container_${socketId}`; + let videoContainer = document.getElementById(containerId) as HTMLDivElement | null; + if (!videoContainer) { + videoContainer = document.createElement('div'); + videoContainer.id = containerId; + videoContainer.style.display = 'flex'; + videoContainer.style.flexDirection = 'column'; + videoContainer.style.alignItems = 'center'; + videoContainer.style.height = '80%'; + } + let peerVideo = document.getElementById(socketId) as HTMLVideoElement | null; + if (!peerVideo) { + peerVideo = document.createElement('video'); + peerVideo.id = socketId; + peerVideo.autoplay = true; + peerVideo.playsInline = true; + peerVideo.width = 150; + peerVideo.height = 100; + peerVideo.style.marginBottom = '10px'; + } + peerVideo.srcObject = e.streams[0]; + + let label = document.getElementById(`label_${socketId}`) as HTMLDivElement | null; + if (!label) { + label = document.createElement('div'); + label.id = `label_${socketId}`; + label.style.fontWeight = 'bold'; + } + const found = userList.find(u => String(u.userId) == remoteUserId); + + const userNickname = found?.nickname ?? 0; + label.innerText = `${userNickname}`; + + videoContainer.appendChild(peerVideo); + videoContainer.appendChild(label); + + if (callRef.current && !document.getElementById(containerId)) { + callRef.current.appendChild(videoContainer); + } + }); + + if (myStreamRef.current) { + myStreamRef.current.getTracks().forEach(track => pc.addTrack(track, myStreamRef.current!)); + } + + peersRef.current[socketId] = pc; + } + + function handleJoinClick() { + if (!roomId || !userId) { + alert('Room ID와 User ID가 필요합니다!'); + return; + } + setJoined(true); + } + + function handleLeaveClick() { + if (myStreamRef.current) { + myStreamRef.current.getTracks().forEach(track => track.stop()); + } + socket?.disconnect(); + setJoined(false); + + if (myFaceRef.current) { + myFaceRef.current.srcObject = null; + } + Object.values(peersRef.current).forEach(pc => pc.close()); + peersRef.current = {}; + userMapRef.current = {}; + setParticipants([]); + } + + // 마이크/카메라 버튼 + const handleMicrophone = () => { + setMicOn(!micOn); + if (myStreamRef.current) { + myStreamRef.current.getAudioTracks().forEach(track => (track.enabled = !track.enabled)); + } + setMuted(prev => !prev); + }; + + const handleSound = () => { + setSoundOn(!soundOn); + if (myStreamRef.current) { + myStreamRef.current.getVideoTracks().forEach(track => (track.enabled = !track.enabled)); + } + setCameraOff(prev => !prev); + }; + + const [activeProfile, setActiveProfile] = useState(null); const currentRoom = useCurrentRoomStore(state => state.currentRoom); - const roomId = useCurrentRoomStore(state => state.roomId); const handleProfileClick = (id: number) => { setActiveProfile(prevId => (prevId === id ? null : id)); }; - const memberList: CurrentRoomUserDto[] = []; - - const sortedUsers = memberList.sort((a, b) => { - if (a.role !== b.role) { - return a.role - b.role; - } - return a.nickname.localeCompare(b.nickname, 'ko'); - }); - return ( - - {sortedUsers.map(member => ( - -
handleProfileClick(member.userId)}> - -
- {roomId && activeProfile === member.userId ? ( - setActiveProfile(null)} - /> - ) : ( - '' - )} -
- ))} -
- + + {!joined && + participants.map(member => { + const found = userList.find(u => String(u.userId) === member); + const displayId = found?.userId ?? 0; + const displayName = found?.nickname ?? 'nickname없음'; + const displayRole = found?.role ?? 2; + const displayImg = found?.profileImageUrl ?? 'profileImageUrl없음'; + + return ( + +
handleProfileClick(displayId)}> + +
+ {roomId && activeProfile === displayId && ( + setActiveProfile(null)} + /> + )} +
+ ); + })} +
+ + {joined && ( + + + + + + )} + + + + mic-toggle + + + sound-toggle + + {!joined ? ( + 입장 + ) : ( + 퇴장 + )} +
); };