diff --git a/src/frontend/package.json b/src/frontend/package.json index eefc67b5..786f8acd 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -24,6 +24,7 @@ "react-dom": "^18.3.1", "react-icons": "^5.4.0", "react-router-dom": "^7.1.5", + "socket.io-client": "^4.8.1", "styled-components": "^6.1.14", "vite-plugin-mkcert": "^1.17.6", "websocket": "^1.0.35", diff --git a/src/frontend/src/components/guild/GuildCategoriesList/index.tsx b/src/frontend/src/components/guild/GuildCategoriesList/index.tsx index 6988d7ec..eb670672 100644 --- a/src/frontend/src/components/guild/GuildCategoriesList/index.tsx +++ b/src/frontend/src/components/guild/GuildCategoriesList/index.tsx @@ -2,6 +2,7 @@ import { BiHash } from 'react-icons/bi'; import { BsFillMicFill } from 'react-icons/bs'; import { TbPlus } from 'react-icons/tb'; +import { useChannelActionStore } from '@/stores/channelAction'; import { GuildChannelInfo, useChannelInfoStore } from '@/stores/channelInfo'; import { useGuildInfoStore } from '@/stores/guildInfo'; import useModalStore from '@/stores/modalStore'; @@ -20,6 +21,7 @@ const GuildCategoriesList = ({ categories, channels }: CategoriesListProps) => { const { openModal } = useModalStore(); const { guildId } = useGuildInfoStore(); const { selectedChannel, setSelectedChannel } = useChannelInfoStore(); + const { isInVoiceChannel, setIsInVoiceChannel } = useChannelActionStore(); const handleOpenModal = (categoryId: string, guildId: string) => { openModal('withFooter', ); @@ -27,6 +29,10 @@ const GuildCategoriesList = ({ categories, channels }: CategoriesListProps) => { const handleChannelClick = (channelInfo: GuildChannelInfo) => { setSelectedChannel({ id: channelInfo.id, name: channelInfo.name, type: channelInfo.type }); + + if (channelInfo.type === 'VOICE') { + if (!isInVoiceChannel) setIsInVoiceChannel(true); + } }; return ( diff --git a/src/frontend/src/components/guild/GuildCategory/index.tsx b/src/frontend/src/components/guild/GuildCategory/index.tsx index daae97ba..ef43dbac 100644 --- a/src/frontend/src/components/guild/GuildCategory/index.tsx +++ b/src/frontend/src/components/guild/GuildCategory/index.tsx @@ -25,6 +25,7 @@ const GuildCategory = () => { const { data } = useQuery({ queryKey: ['guildInfo', guildId], queryFn: () => getGuild(guildId), + enabled: !!guildId, }); const dropdownItems: DropdownItem[] = [ diff --git a/src/frontend/src/components/guild/GuildList/index.tsx b/src/frontend/src/components/guild/GuildList/index.tsx index babcc074..1b580aff 100644 --- a/src/frontend/src/components/guild/GuildList/index.tsx +++ b/src/frontend/src/components/guild/GuildList/index.tsx @@ -11,7 +11,7 @@ import * as S from './styles'; const GuildList = () => { const { openModal } = useModalStore(); - const { setGuildId } = useGuildInfoStore(); + const { setGuildId, setGuildName } = useGuildInfoStore(); const { data } = useQuery({ queryKey: ['guildList'], queryFn: getGuilds }); @@ -19,6 +19,11 @@ const GuildList = () => { openModal('basic', ); }; + const handleStoreGuildInfo = (guild: GuildResponse) => { + setGuildId(guild.guildId); + setGuildName(guild.name); + }; + return ( setGuildId('')}> @@ -29,7 +34,7 @@ const GuildList = () => { key={guild.guildId} data-tooltip={guild.name} $imageUrl={guild.profileImageUrl} - onClick={() => setGuildId(guild.guildId)} + onClick={() => handleStoreGuildInfo(guild)} /> ))} diff --git a/src/frontend/src/components/guild/VoiceChannelActions/index.tsx b/src/frontend/src/components/guild/VoiceChannelActions/index.tsx new file mode 100644 index 00000000..765dc776 --- /dev/null +++ b/src/frontend/src/components/guild/VoiceChannelActions/index.tsx @@ -0,0 +1,41 @@ +import { motion } from 'framer-motion'; +import { BiSolidVideo, BiSolidVideoOff } from 'react-icons/bi'; +import { LuScreenShare } from 'react-icons/lu'; +import { TbConfetti, TbTriangleSquareCircle } from 'react-icons/tb'; + +import { useChannelActionStore } from '@/stores/channelAction'; + +import * as S from './styles'; + +const VoiceChannelActions = () => { + const { isInVoiceChannel } = useChannelActionStore(); + + const actions = { + video: isInVoiceChannel ? : , + screenSharing: , + startActions: , + soundBoard: , + }; + + const bounceAnimation = { + y: [0, -5, 0], + transition: { + duration: 0.6, + repeat: 3, + repeatType: 'reverse' as const, + ease: 'easeInOut', + }, + }; + + return ( + + {Object.entries(actions).map(([key, value]) => ( + + {value} + + ))} + + ); +}; + +export default VoiceChannelActions; diff --git a/src/frontend/src/components/guild/VoiceChannelActions/styles.ts b/src/frontend/src/components/guild/VoiceChannelActions/styles.ts new file mode 100644 index 00000000..456a6ca4 --- /dev/null +++ b/src/frontend/src/components/guild/VoiceChannelActions/styles.ts @@ -0,0 +1,28 @@ +import styled from 'styled-components'; + +export const VoiceChannelActions = styled.div` + display: flex; + gap: 0.5rem; + align-items: center; + justify-content: space-evenly; + + margin-top: 1rem; + + svg { + color: ${({ theme }) => theme.colors.white}; + } +`; + +export const Action = styled.div` + cursor: pointer; + + display: flex; + align-items: center; + justify-content: center; + + width: 5rem; + height: 3rem; + border-radius: 0.8rem; + + background-color: ${({ theme }) => theme.colors.dark[500]}; +`; diff --git a/src/frontend/src/components/guild/VoiceChannelController/index.tsx b/src/frontend/src/components/guild/VoiceChannelController/index.tsx new file mode 100644 index 00000000..ba8e425e --- /dev/null +++ b/src/frontend/src/components/guild/VoiceChannelController/index.tsx @@ -0,0 +1,57 @@ +import { BsFillTelephoneXFill } from 'react-icons/bs'; + +import { useChannelActionStore } from '@/stores/channelAction'; +import { useChannelInfoStore } from '@/stores/channelInfo'; +import { useGuildInfoStore } from '@/stores/guildInfo'; +import { useWebRTCStore } from '@/stores/webRTCStore'; +import { tokenAxios } from '@/utils/axios'; + +import VoiceChannelActions from '../VoiceChannelActions'; + +import * as S from './styles'; + +const VoiceChannelController = () => { + const { selectedChannel } = useChannelInfoStore(); + const { setIsInVoiceChannel } = useChannelActionStore(); + const { guildName } = useGuildInfoStore(); + const { setIsStompConnected, disconnectStomp } = useWebRTCStore(); + + const roomId = useChannelInfoStore((state) => state.selectedChannel?.name); + + const handleLeaveRoom = async () => { + setIsInVoiceChannel(false); + + if (!roomId) { + alert('방 ID를 입력해주세요!'); + return; + } + + try { + const response = await tokenAxios.delete(`https://api.jungeunjipi.com/room/${roomId}/leave`); + console.log('방 나가기 성공: ', response); + + setIsStompConnected(false); + + disconnectStomp(); + } catch (error) { + console.error('🚨 방 나가기 오류:', error); + } + }; + + return ( + + + + 음성 연결됨 + + {selectedChannel?.name} / {guildName} + + + + + + + ); +}; + +export default VoiceChannelController; diff --git a/src/frontend/src/components/guild/VoiceChannelController/styles.ts b/src/frontend/src/components/guild/VoiceChannelController/styles.ts new file mode 100644 index 00000000..6a4289c8 --- /dev/null +++ b/src/frontend/src/components/guild/VoiceChannelController/styles.ts @@ -0,0 +1,38 @@ +import styled from 'styled-components'; + +import { ChipText, SmallText } from '@/styles/Typography'; + +export const VoiceChannelController = styled.div` + display: flex; + flex-direction: column; + + padding: 1rem; + border-bottom: 1px solid ${({ theme }) => theme.colors.dark[450]}; + + background-color: ${({ theme }) => theme.colors.dark[750]}; +`; + +export const InfoText = styled.div` + display: flex; + flex-direction: column; +`; + +export const ConnectStatusText = styled(ChipText)` + font-size: 1.5rem; + color: ${({ theme }) => theme.colors.lightGreen}; +`; + +export const ChannelInfoText = styled(SmallText)` + color: ${({ theme }) => theme.colors.dark[350]}; +`; + +export const ConnectStatusWrapper = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + + svg { + cursor: pointer; + color: ${({ theme }) => theme.colors.white}; + } +`; diff --git a/src/frontend/src/constants/endPoint.ts b/src/frontend/src/constants/endPoint.ts index aa98c4a0..3afb1809 100644 --- a/src/frontend/src/constants/endPoint.ts +++ b/src/frontend/src/constants/endPoint.ts @@ -11,6 +11,7 @@ export const endPoint = { POST_AUTHENTICATION_CODE: '/users/validation/authentication-code', POST_SIGN_UP: '/users/sign-up', POST_SIGN_IN: '/users/sign-in', + GET_USER_ID: '/users/user/id', }, friends: { diff --git a/src/frontend/src/pages/ChannelPage/components/VideoCard/index.tsx b/src/frontend/src/pages/ChannelPage/components/VideoCard/index.tsx new file mode 100644 index 00000000..ddf5d844 --- /dev/null +++ b/src/frontend/src/pages/ChannelPage/components/VideoCard/index.tsx @@ -0,0 +1,30 @@ +import * as S from './styles'; + +interface VideoCardProps { + userId: string; + stream?: MediaStream; + localRef?: React.MutableRefObject; +} + +const VideoCard = ({ userId, stream, localRef }: VideoCardProps) => { + return ( + + {userId} + { + if (localRef) { + localRef.current = videoElement; + } + + if (stream && videoElement && videoElement.srcObject !== stream) { + videoElement.srcObject = stream; + } + }} + /> + + ); +}; + +export default VideoCard; diff --git a/src/frontend/src/pages/ChannelPage/components/VideoCard/styles.ts b/src/frontend/src/pages/ChannelPage/components/VideoCard/styles.ts new file mode 100644 index 00000000..2167a043 --- /dev/null +++ b/src/frontend/src/pages/ChannelPage/components/VideoCard/styles.ts @@ -0,0 +1,24 @@ +import styled from 'styled-components'; + +import { BodyMediumText } from '@/styles/Typography'; + +export const VideoCard = styled.div` + position: relative; + width: fit-content; +`; + +export const Video = styled.video` + min-width: 32rem; + max-width: 48rem; + border-radius: 1rem; +`; + +export const UserName = styled(BodyMediumText)` + position: absolute; + bottom: 5%; + left: 3%; + + width: fit-content; + + color: ${({ theme }) => theme.colors.white}; +`; diff --git a/src/frontend/src/pages/ChannelPage/index.tsx b/src/frontend/src/pages/ChannelPage/index.tsx new file mode 100644 index 00000000..3a32ecb4 --- /dev/null +++ b/src/frontend/src/pages/ChannelPage/index.tsx @@ -0,0 +1,428 @@ +import { Client } from '@stomp/stompjs'; +import { useRef, useState } from 'react'; + +import { useChannelActionStore } from '@/stores/channelAction'; +import { useChannelInfoStore } from '@/stores/channelInfo'; +import { useUserInfoStore } from '@/stores/userInfo'; +import { useWebRTCStore } from '@/stores/webRTCStore'; +import { tokenAxios } from '@/utils/axios'; + +import VideoCard from './components/VideoCard'; + +const SERVER_URL = import.meta.env.VITE_SIGNALING; + +// user interface +interface UserInRoom { + id: string; + nickname: string; + profile_image: string; + is_mic_enabled: boolean; + is_camera_enabled: boolean; + is_screen_sharing_enabled: boolean; +} + +const WebRTC = () => { + const stompClient = useRef(null); + const [offerSent, setOfferSent] = useState(false); + const localVideoRef = useRef(null); + const remoteVideoRef = useRef(null); + const peerConnection = useRef(null); + + // 구독된 publisher id들을 저장 + const [subscribedPublishers, setSubscribedPublishers] = useState([]); + // 아직 publisher id와 매핑되지 않은 미디어 스트림을 저장 + const [pendingStreams, setPendingStreams] = useState([]); + // 최종적으로 매핑된 remote stream을 저장 (키: publisher id) + const [remoteStreams, setRemoteStreams] = useState<{ [userId: string]: MediaStream }>({}); + + // 최초 입장인지 확인 + const [firstEnter, setFirstEnter] = useState(true); + + // 유저 리스트 + const [userInRoomList, setUserInRoomList] = useState([]); + const { isSharingScreen, isVideoOn, isMicOn, setIsInVoiceChannel, setIsSharingScreen, setIsVideoOn, setIsMicOn } = + useChannelActionStore(); + + const { isStompConnected, setIsStompConnected } = useWebRTCStore(); + + const token = localStorage.getItem('access_token'); + const roomId = useChannelInfoStore((state) => state.selectedChannel?.name); + + const { userInfo } = useUserInfoStore(); + const userId = userInfo?.userId || ''; + + if (!roomId) return; + + // ✅ STOMP WebSocket 연결 함수 + const connectStomp = async () => { + if (!roomId) { + alert('방 ID를 입력해주세요!'); + return; + } + + console.log('🟢 WebSocket 연결 시도 중...'); + if (!token) return null; + const client = new Client({ + webSocketFactory: () => new WebSocket(SERVER_URL, ['v10.stomp', token]), + connectHeaders: { + Authorization: `Bearer ${token}`, + }, + reconnectDelay: 5000, + heartbeatIncoming: 10000, + heartbeatOutgoing: 10000, + onConnect: () => { + console.log(`✅ STOMP WebSocket 연결 성공 (Room: ${roomId})`); + setIsStompConnected(true); + + client.subscribe(`/topic/users/${roomId}`, (message) => { + const users = JSON.parse(message.body); + console.log('users을 수신 하였습니다. : ', users); + handleUsers(users); + }); + + // ✅ STOMP WebSocket이 연결된 후 Answer 메시지 Subscribe 실행 + client.subscribe(`/topic/answer/${roomId}/${userId}`, (message) => { + const answer = JSON.parse(message.body); + console.log('answer을 수신 하였습니다. : ', answer); + handleAnswer(answer.message); + }); + + client.subscribe(`/topic/candidate/${roomId}/${userId}`, (message) => { + const candidate = JSON.parse(message.body); + console.log('candidate을 수신 하였습니다. : ', candidate); + handleIceCandidate(candidate.candidate); + }); + + client.subscribe(`/topic/publisher/${roomId}`, (message) => { + const publisherId = JSON.parse(message.body).message; + console.log('publisher 수신:', publisherId); + + // publisher id가 자신의 userId와 같으면 아무 작업도 하지 않음 + if (publisherId === userId) { + console.log('자신의 publisher id는 무시합니다:', publisherId); + return; + } + + handlePublish(publisherId); + + // 이미 ontrack 이벤트에서 pending stream이 있다면 즉시 매핑 + setPendingStreams((prevPending) => { + if (prevPending.length > 0) { + const stream = prevPending[0]; + setRemoteStreams((prevStreams) => ({ + ...prevStreams, + [publisherId]: stream, + })); + return prevPending.slice(1); + } else { + // 아직 ontrack 이벤트가 도착하지 않았다면, subscribedPublishers에 publisher id를 저장 + setSubscribedPublishers((prev) => [...prev, publisherId]); + return prevPending; + } + }); + }); + + client.subscribe(`/topic/removed/${roomId}`, (message) => { + const recentUsers = JSON.parse(message.body); + console.log('recentUsers', recentUsers); + }); + + console.log(`✅ 구독 성공 하였습니다.`); + }, + onDisconnect: () => { + alert('🔌 STOMP WebSocket 연결 해제됨'); + console.log('🔌 STOMP WebSocket 연결 해제됨'); + setIsStompConnected(false); + }, + onWebSocketError: (error) => { + alert(`🚨 WebSocket 오류 발생: ${error}`); + console.error('🚨 WebSocket 오류 발생:', error); + }, + onStompError: (frame) => { + alert(`🚨 STOMP 오류 발생: ${frame}`); + console.error('🚨 STOMP 오류 발생:', frame); + }, + }); + + client.activate(); + stompClient.current = client; + }; + + // ✅ WebRTC Offer 전송 (버튼 클릭 시 실행) + const sendOffer = async () => { + if (!stompClient.current || !isStompConnected) { + alert('offer STOMP WebSocket이 연결되지 않았습니다.'); + return; + } + + try { + const localStream = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: true, + }); + + if (localVideoRef.current) { + localVideoRef.current.srcObject = localStream; + } + + peerConnection.current = new RTCPeerConnection({ + iceServers: [ + { urls: 'stun:stun.l.google.com:19302' }, + { + urls: 'turn:asyncturn.store', + username: 'asyncgate5', + credential: 'smilegate5', + }, + ], + }); + + localStream.getTracks().forEach((track) => { + peerConnection.current?.addTrack(track, localStream); + }); + + // ontrack 이벤트: remote 미디어 스트림 수신 + // ontrack 이벤트: 원격 미디어 스트림 수신 시 호출 + peerConnection.current.ontrack = (event) => { + console.log('ontrack 이벤트 수신:', event); + const stream = event.streams[0]; + console.log('수신된 stream:', stream, '비디오 트랙:', stream.getVideoTracks()); + + // 이미 signaling에서 publisher id를 받은 경우 pending 없이 바로 매핑 + setSubscribedPublishers((prevPublishers) => { + if (prevPublishers.length > 0) { + const [publisherId, ...rest] = prevPublishers; + setRemoteStreams((prevStreams) => ({ + ...prevStreams, + [publisherId]: stream, + })); + return rest; + } else { + // 아직 publisher id가 도착하지 않았다면 pending queue에 저장 + setPendingStreams((prev) => [...prev, stream]); + return prevPublishers; + } + }); + }; + + const offer = await peerConnection.current.createOffer(); + await peerConnection.current.setLocalDescription(offer); + + // 🔥 STOMP를 사용해 WebRTC Offer 전송 + stompClient.current.publish({ + destination: '/offer', + body: JSON.stringify({ + data: { + // ✅ data 내부에 room_id 포함 + room_id: roomId, + sdp_offer: offer.sdp, + }, + }), + }); + + console.log('📤 WebRTC Offer 전송:', offer.sdp); + } catch (error) { + console.error('❌ Offer 전송 실패:', error); + } + }; + + // ✅ kurento ice 수집 요청 + const sendGatherIceCandidate = async () => { + if (!stompClient.current) { + alert('gather STOMP WebSocket이 연결되지 않았습니다.'); + return; + } + + try { + // 🔥 STOMP를 사용해 WebRTC Offer 전송 + stompClient.current.publish({ + destination: '/gather/candidate', + body: JSON.stringify({ + data: { + room_id: roomId, + }, + }), + }); + + sendIceCandidates(); // 🔥 SDP Answer 수신 후 ICE Candidate 전송 + } catch (error) { + console.error('gather 요청 실패:', error); + } + }; + + // ✅ WebRTC Answer 처리 + const handleAnswer = async (sdpAnswer: string) => { + if (!peerConnection.current) return; + + try { + await peerConnection.current.setRemoteDescription( + new RTCSessionDescription({ + type: 'answer', + sdp: sdpAnswer, + }), + ); + } catch (error) { + console.error('Answer 요청 실패:', error); + } finally { + sendGatherIceCandidate(); + } + }; + + // ✅ WebRTC Users 처리 + const handleUsers = async (users: UserInRoom[]) => { + if (!peerConnection.current) return; + + setUserInRoomList(users); + + if (firstEnter) { + for (const user of users) { + console.log('subscribe 합니다. ~'); + console.log(user); + await handlePublish(user.id); + } + setFirstEnter(false); + } + }; + + // ✅ WebRTC Candidate 처리 + const handleIceCandidate = async (candidate: RTCIceCandidateInit) => { + if (!peerConnection.current) return; + + console.log('📥 ICE Candidate 수신:', candidate); + + try { + await peerConnection.current.addIceCandidate(new RTCIceCandidate(candidate)); + console.log('✅ ICE Candidate 추가 성공'); + } catch (error) { + console.error('❌ ICE Candidate 추가 실패:', error); + } + }; + + // ✅ WebRTC Candidate 처리 + const handlePublish = async (publisher_id: string): Promise => { + if (!peerConnection.current || !stompClient.current) return; + + stompClient.current.publish({ + destination: '/subscribe', + body: JSON.stringify({ + data: { + room_id: roomId, + publisher_id: publisher_id, + }, + }), + }); + }; + + // ✅ ICE Candidate 전송 (SDP Answer를 받은 후 실행) + const sendIceCandidates = () => { + if (!peerConnection.current || !stompClient.current) return; + + console.log('접근 완료 !!'); + peerConnection.current.onicecandidate = (event) => { + if (event.candidate) { + if (event.candidate.candidate.includes('typ host')) { + console.log('typ host'); + return; // host 후보는 버림 + } + + console.log('전송 ice candidate : ', event.candidate); + + if (stompClient.current) { + stompClient.current.publish({ + destination: '/candidate', + body: JSON.stringify({ + data: { + room_id: roomId, + candidate: { + candidate: event.candidate.candidate, + sdpMid: event.candidate.sdpMid, + sdpMLineIndex: event.candidate.sdpMLineIndex, + }, + }, + }), + }); + } + console.log('📤 ICE Candidate 전송:', event.candidate); + } + }; + + peerConnection.current.onicegatheringstatechange = () => { + console.log('[pc] ICE 수집 상태:', peerConnection.current?.iceGatheringState); + + if (peerConnection.current?.iceGatheringState === 'complete') { + console.log('[pc] ICE 후보 수집 완료'); + } + }; + + peerConnection.current.oniceconnectionstatechange = () => { + const state = peerConnection.current?.iceConnectionState; + console.log('[pc] ICE 연결 상태 변경:', state); + }; + }; + + const joinRoom = async (roomId: string) => { + if (!roomId) { + alert('방 ID를 입력해주세요!'); + return; + } + + try { + const response = await tokenAxios.post(`https://api.jungeunjipi.com/room/${roomId}/join`, { + audio_enabled: isMicOn, + media_enabled: isVideoOn, + data_enabled: isSharingScreen, + }); + + if (response) { + console.log('joinroom에서 얻은 sdp_answer', response.data.sdp_answer); + handleSdpAnswer(response.data.sdp_answer); + setIsInVoiceChannel(true); + } else { + console.error('참여 실패:', response); + } + } catch (error) { + console.error('API 요청 오류:', error); + } + }; + + const handleSdpAnswer = async (sdpAnswer: string) => { + if (peerConnection.current) { + await peerConnection.current.setRemoteDescription( + new RTCSessionDescription({ + type: 'answer', + sdp: sdpAnswer, + }), + ); + console.log('✅ SDP Answer 설정 완료'); + } + }; + + return ( +
+

Kurento SFU WebRTC

+ + + + + + + +
+

📹 내 화면

+ +
+ +
+

🔗 원격 사용자 화면

+ {Object.entries(remoteStreams).map(([userId, stream]) => ( + + ))} +
+
+ ); +}; + +export default WebRTC; diff --git a/src/frontend/src/pages/FriendsPage/components/CategorySection/index.tsx b/src/frontend/src/pages/FriendsPage/components/CategorySection/index.tsx index d6f109ec..81fff1b9 100644 --- a/src/frontend/src/pages/FriendsPage/components/CategorySection/index.tsx +++ b/src/frontend/src/pages/FriendsPage/components/CategorySection/index.tsx @@ -1,17 +1,29 @@ import DirectMessageCategory from '@/components/friend/DirectMessageCategory'; import GuildCategory from '@/components/guild/GuildCategory'; +import VoiceChannelController from '@/components/guild/VoiceChannelController'; import UserProfile from '@/pages/FriendsPage/components/UserProfile'; +import { useChannelActionStore } from '@/stores/channelAction'; import { useGuildInfoStore } from '@/stores/guildInfo'; import * as S from './styles'; const CategorySection = () => { const { guildId } = useGuildInfoStore(); + const { isInVoiceChannel } = useChannelActionStore(); return ( {guildId ? : } - {}} handleHeadsetToggle={() => {}} /> + {isInVoiceChannel && } + {}} + handleHeadsetToggle={() => {}} + /> ); }; diff --git a/src/frontend/src/pages/FriendsPage/index.tsx b/src/frontend/src/pages/FriendsPage/index.tsx index c237dd45..40e1b1cb 100644 --- a/src/frontend/src/pages/FriendsPage/index.tsx +++ b/src/frontend/src/pages/FriendsPage/index.tsx @@ -1,17 +1,33 @@ import CategorySection from '@/pages/FriendsPage/components/CategorySection'; +import { useChannelInfoStore } from '@/stores/channelInfo'; +import { useGuildInfoStore } from '@/stores/guildInfo'; import GuildList from '../../components/guild/GuildList'; +import VideoPage from '../VideoPage'; import ChattingSection from './components/ChattingSection'; import * as S from './styles'; const FriendsPage = () => { + const { guildId } = useGuildInfoStore(); + const { selectedChannel } = useChannelInfoStore(); + + const renderCategoryComponent = () => { + if (!guildId) return ; + + if (!selectedChannel) return ; + + if (selectedChannel.type === 'VOICE') return ; + + return ; + }; + return ( - + {renderCategoryComponent()} ); diff --git a/src/frontend/src/pages/LoginPage/index.tsx b/src/frontend/src/pages/LoginPage/index.tsx index d3907460..5fe26743 100644 --- a/src/frontend/src/pages/LoginPage/index.tsx +++ b/src/frontend/src/pages/LoginPage/index.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { postLogin } from '@/api/users'; import AuthInput from '@/components/common/AuthInput'; +import { useUserInfoStore } from '@/stores/userInfo'; import { formDropVarients } from '@/styles/motions'; import useLogin from './hooks/useLogin'; @@ -12,6 +13,7 @@ const LoginPage = () => { const navigate = useNavigate(); const [errorMessage, setErrorMessage] = useState(''); const { email, password, handleEmailChange, handlePasswordChange } = useLogin(); + const { setUserInfo } = useUserInfoStore(); const handleRegisterButtonClick = () => { navigate('/register'); @@ -22,6 +24,7 @@ const LoginPage = () => { const response = await postLogin({ email, password }); if (response.httpStatus === 200) { localStorage.setItem('access_token', response.result.access_token); + setUserInfo({ userId: response.result.user_id }); return navigate('/friends', { replace: true }); } else if (response.httpStatus === 404) { return setErrorMessage('이메일이나 비밀번호를 확인해주세요.'); diff --git a/src/frontend/src/pages/VideoPage/index.tsx b/src/frontend/src/pages/VideoPage/index.tsx new file mode 100644 index 00000000..b989b0ba --- /dev/null +++ b/src/frontend/src/pages/VideoPage/index.tsx @@ -0,0 +1,25 @@ +import { useChannelActionStore } from '@/stores/channelAction'; +import { useChannelInfoStore } from '@/stores/channelInfo'; +import { BodyRegularText, TitleText1 } from '@/styles/Typography'; + +import * as S from './styles'; + +const VideoPage = () => { + const { isInVoiceChannel } = useChannelActionStore(); + const { selectedChannel } = useChannelInfoStore(); + + return ( + + {isInVoiceChannel ? ( + <>참여시 비디오들 + ) : ( + + {selectedChannel?.name} + 현재 음성 채널에 아무도 없어요 + + )} + + ); +}; + +export default VideoPage; diff --git a/src/frontend/src/pages/VideoPage/styles.ts b/src/frontend/src/pages/VideoPage/styles.ts new file mode 100644 index 00000000..01f197ce --- /dev/null +++ b/src/frontend/src/pages/VideoPage/styles.ts @@ -0,0 +1,18 @@ +import styled from 'styled-components'; + +export const VideoPage = styled.div` + display: flex; + width: 100%; + color: white; +`; + +export const EmptyParticipant = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + width: 100%; + + background-color: ${({ theme }) => theme.colors.black}; +`; diff --git a/src/frontend/src/router.tsx b/src/frontend/src/router.tsx index f2ab3a58..3669916a 100644 --- a/src/frontend/src/router.tsx +++ b/src/frontend/src/router.tsx @@ -4,6 +4,8 @@ import ModalRenderer from './components/common/ModalRender'; import AuthFullLayout from './components/layout/AuthFullLayout'; import FullLayout from './components/layout/FullLayout'; import PublicOnlyLayout from './components/layout/PublicOnlyLayout'; +import WebRTC from './pages/ChannelPage'; +import VideoTest from './pages/ChannelPage/test'; import FriendsPage from './pages/FriendsPage'; import LandingPage from './pages/LandingPage'; import LoginPage from './pages/LoginPage'; @@ -47,6 +49,10 @@ const router = createBrowserRouter([ path: '/friends', element: , }, + { + path: '/video', + element: , + }, ], }, ], diff --git a/src/frontend/src/stores/channelAction.ts b/src/frontend/src/stores/channelAction.ts new file mode 100644 index 00000000..ecaad3da --- /dev/null +++ b/src/frontend/src/stores/channelAction.ts @@ -0,0 +1,34 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +interface ChannelActionState { + isInVoiceChannel: boolean; + isSharingScreen: boolean; + isVideoOn: boolean; + isMicOn: boolean; + setIsInVoiceChannel: (value: boolean) => void; + setIsSharingScreen: (value: boolean) => void; + setIsVideoOn: (value: boolean) => void; + setIsMicOn: (value: boolean) => void; +} + +export const useChannelActionStore = create()( + persist( + (set) => ({ + isInVoiceChannel: false, + isSharingScreen: false, + isVideoOn: false, + isMicOn: false, + setIsInVoiceChannel: (value?: boolean) => + set((state) => ({ + isInVoiceChannel: value !== undefined ? value : !state.isInVoiceChannel, + })), + setIsSharingScreen: () => set((state) => ({ isSharingScreen: !state.isSharingScreen })), + setIsVideoOn: () => set((state) => ({ isVideoOn: !state.isVideoOn })), + setIsMicOn: () => set((state) => ({ isMicOn: !state.isMicOn })), + }), + { + name: 'channelAction', + }, + ), +); diff --git a/src/frontend/src/stores/guildInfo.ts b/src/frontend/src/stores/guildInfo.ts index aaca414f..26fd51dc 100644 --- a/src/frontend/src/stores/guildInfo.ts +++ b/src/frontend/src/stores/guildInfo.ts @@ -3,14 +3,18 @@ import { persist } from 'zustand/middleware'; type GuildState = { guildId: string; + guildName: string; setGuildId: (guildId: string) => void; + setGuildName: (guildName: string) => void; }; export const useGuildInfoStore = create()( persist( (set) => ({ guildId: '', + guildName: '', setGuildId: (guildId) => set({ guildId }), + setGuildName: (guildName) => set({ guildName }), }), { name: 'guildInfo', diff --git a/src/frontend/src/stores/webRTCStore.ts b/src/frontend/src/stores/webRTCStore.ts new file mode 100644 index 00000000..2f1f2e89 --- /dev/null +++ b/src/frontend/src/stores/webRTCStore.ts @@ -0,0 +1,37 @@ +import { Client } from '@stomp/stompjs'; +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +interface WebRTCState { + stompClient: Client | null; + isStompConnected: boolean; + setStompClient: (client: Client | null) => void; + setIsStompConnected: (value: boolean) => void; + disconnectStomp: () => void; +} + +export const useWebRTCStore = create()( + persist( + (set, get) => ({ + stompClient: null, + isStompConnected: false, + + setStompClient: (client: Client | null) => set({ stompClient: client }), + setIsStompConnected: (isStompConnected) => set({ isStompConnected }), + disconnectStomp: () => { + const { stompClient } = get(); + if (stompClient) { + stompClient.deactivate(); + set({ stompClient: null, isStompConnected: false }); + console.log('🔌 STOMP WebSocket 연결 해제 시도'); + } + }, + }), + { + name: 'webRTCInfo', + partialize: (state) => ({ + isStompConnected: state.isStompConnected, + }), + }, + ), +); diff --git a/src/frontend/src/styles/theme.ts b/src/frontend/src/styles/theme.ts index 7d62e7ef..c3e8e9b1 100644 --- a/src/frontend/src/styles/theme.ts +++ b/src/frontend/src/styles/theme.ts @@ -17,6 +17,7 @@ const colors = { red: '#FF595E', green: '#248045', online: '#23A55A', + lightGreen: '#28B964', blue: '#5765F2', link: '#069BE3', }; diff --git a/src/frontend/src/types/users.ts b/src/frontend/src/types/users.ts index 93b91279..113f9296 100644 --- a/src/frontend/src/types/users.ts +++ b/src/frontend/src/types/users.ts @@ -19,6 +19,7 @@ export interface PostLoginResponse { time: Date; result: { access_token: string; + user_id: string; }; } diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index fee30537..48e6ebc5 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -1700,6 +1700,10 @@ version "7.0.0" resolved "https://registry.yarnpkg.com/@stomp/stompjs/-/stompjs-7.0.0.tgz#46b5c454a9dc8262e0b20f3b3dbacaa113993077" integrity sha512-fGdq4wPDnSV/KyOsjq4P+zLc8MFWC3lMmP5FBgLWKPJTYcuCbAIrnRGjB7q2jHZdYCOD5vxLuFoKIYLy5/u8Pw== +"@socket.io/component-emitter@~3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2" + integrity sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA== "@tanstack/query-core@5.66.0": version "5.66.0" @@ -2259,6 +2263,7 @@ axios@^1.7.4: proxy-from-env "^1.1.0" axios@^1.7.9: +axios@^1.7.4, axios@^1.7.9: version "1.7.9" resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.9.tgz#d7d071380c132a24accda1b2cfc1535b79ec650a" integrity sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw== @@ -2755,6 +2760,13 @@ debug@^3.2.7: dependencies: ms "^2.1.1" +debug@~4.3.1, debug@~4.3.2: + 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" + decimal.js@^10.4.2: version "10.5.0" resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.5.0.tgz#0f371c7cf6c4898ce0afb09836db73cd82010f22" @@ -2880,6 +2892,22 @@ emoji-regex@^8.0.0: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== +engine.io-client@~6.6.1: + version "6.6.3" + resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-6.6.3.tgz#815393fa24f30b8e6afa8f77ccca2f28146be6de" + integrity sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w== + dependencies: + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.1" + engine.io-parser "~5.2.1" + ws "~8.17.1" + xmlhttprequest-ssl "~2.1.1" + +engine.io-parser@~5.2.1: + version "5.2.3" + resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.2.3.tgz#00dc5b97b1f233a23c9398d0209504cf5f94d92f" + integrity sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q== + entities@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" @@ -5657,6 +5685,24 @@ slice-ansi@^4.0.0: astral-regex "^2.0.0" is-fullwidth-code-point "^3.0.0" +socket.io-client@^4.8.1: + version "4.8.1" + resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.8.1.tgz#1941eca135a5490b94281d0323fe2a35f6f291cb" + integrity sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ== + dependencies: + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.2" + engine.io-client "~6.6.1" + socket.io-parser "~4.2.4" + +socket.io-parser@~4.2.4: + version "4.2.4" + resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.4.tgz#c806966cf7270601e47469ddeec30fbdfda44c83" + integrity sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew== + dependencies: + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.1" + source-map-js@^1.0.1, source-map-js@^1.2.0, source-map-js@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" @@ -6376,6 +6422,11 @@ ws@^8.11.0: resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== +ws@~8.17.1: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" + integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== + xml-name-validator@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz#79a006e2e63149a8600f15430f0a4725d1524835" @@ -6386,6 +6437,11 @@ xmlchars@^2.2.0: resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== +xmlhttprequest-ssl@~2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz#e9e8023b3f29ef34b97a859f584c5e6c61418e23" + integrity sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ== + y18n@^5.0.5: version "5.0.8" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"