From 41e4f157aeca67532fb57877c5bfa7b1503e041e Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Tue, 25 Feb 2025 22:13:58 +0900 Subject: [PATCH 01/40] =?UTF-8?q?[FE]=20setting:=20socket.io=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/package.json | 1 + src/frontend/yarn.lock | 55 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) 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/yarn.lock b/src/frontend/yarn.lock index fee30537..975330f5 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" @@ -2755,6 +2759,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 +2891,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 +5684,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 +6421,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 +6436,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" From 77cfcf119d912d51d8f6c262272bfcadf45bdfe2 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Wed, 26 Feb 2025 20:15:29 +0900 Subject: [PATCH 02/40] =?UTF-8?q?[FE]=20setting:=20mkcert=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/yarn.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 975330f5..48e6ebc5 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -2263,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== From a6e89c721f9d4a82b5aef22e10fc319f303f79a5 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Fri, 28 Feb 2025 18:10:22 +0900 Subject: [PATCH 03/40] =?UTF-8?q?[FE]=20fix:=20guildId=EA=B0=80=20?= =?UTF-8?q?=EC=A1=B4=EC=9E=AC=ED=95=98=EB=8A=94=20=EA=B2=BD=EC=9A=B0?= =?UTF-8?q?=EB=A7=8C=20=EC=BF=BC=EB=A6=AC=20=EC=8B=A4=ED=96=89=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EC=B6=94=EA=B0=80=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/components/guild/GuildCategory/index.tsx | 1 + 1 file changed, 1 insertion(+) 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[] = [ From cf149c6b158a4fdc4fb3a2abb8303387c698f9db Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Fri, 28 Feb 2025 21:48:39 +0900 Subject: [PATCH 04/40] =?UTF-8?q?[FE]=20feat:=20=EC=B1=84=EB=84=90?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=B1=84=ED=8C=85=20=ED=98=B9?= =?UTF-8?q?=EC=9D=80=20=EB=AF=B8=EB=94=94=EC=96=B4=EC=B0=BD=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=EB=A7=81=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/pages/FriendsPage/index.tsx | 21 +++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/frontend/src/pages/FriendsPage/index.tsx b/src/frontend/src/pages/FriendsPage/index.tsx index c237dd45..3698067d 100644 --- a/src/frontend/src/pages/FriendsPage/index.tsx +++ b/src/frontend/src/pages/FriendsPage/index.tsx @@ -1,17 +1,36 @@ import CategorySection from '@/pages/FriendsPage/components/CategorySection'; +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 renderCategoryComponent = () => { + const channelInfoStr = localStorage.getItem('channelInfo'); + + if (!guildId) return ; + + if (channelInfoStr) { + const channelData = JSON.parse(channelInfoStr); + const channelType = channelData.state.selectedChannel.type; + + if (channelType === 'VOICE') return ; + + // 텍스트 채널시 채팅창 주가 예정 + } + }; + return ( - + {renderCategoryComponent()} ); From c0bfdcd8f6722b846042056419eb3ee1e01fda25 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Fri, 28 Feb 2025 21:54:33 +0900 Subject: [PATCH 05/40] =?UTF-8?q?[FE]=20fix:=20=EC=B1=84=EB=84=90=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=EB=A5=BC=20=EC=A0=84=EC=97=AD=20=EA=B0=92?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=8C=80=EC=B2=B4=20=EB=B0=8F=20=EC=8B=A4?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=ED=99=94=EB=A9=B4=20=EA=B0=B1=EC=8B=A0=20?= =?UTF-8?q?(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/pages/FriendsPage/index.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/frontend/src/pages/FriendsPage/index.tsx b/src/frontend/src/pages/FriendsPage/index.tsx index 3698067d..40e1b1cb 100644 --- a/src/frontend/src/pages/FriendsPage/index.tsx +++ b/src/frontend/src/pages/FriendsPage/index.tsx @@ -1,4 +1,5 @@ import CategorySection from '@/pages/FriendsPage/components/CategorySection'; +import { useChannelInfoStore } from '@/stores/channelInfo'; import { useGuildInfoStore } from '@/stores/guildInfo'; import GuildList from '../../components/guild/GuildList'; @@ -9,20 +10,16 @@ import * as S from './styles'; const FriendsPage = () => { const { guildId } = useGuildInfoStore(); + const { selectedChannel } = useChannelInfoStore(); const renderCategoryComponent = () => { - const channelInfoStr = localStorage.getItem('channelInfo'); - if (!guildId) return ; - if (channelInfoStr) { - const channelData = JSON.parse(channelInfoStr); - const channelType = channelData.state.selectedChannel.type; + if (!selectedChannel) return ; - if (channelType === 'VOICE') return ; + if (selectedChannel.type === 'VOICE') return ; - // 텍스트 채널시 채팅창 주가 예정 - } + return ; }; return ( From 19e0302bd7779034bd8a98ded9b7eb8baccea53e Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Fri, 28 Feb 2025 22:51:59 +0900 Subject: [PATCH 06/40] =?UTF-8?q?[FE]=20feat:=20=EC=B0=B8=EC=97=AC?= =?UTF-8?q?=EC=9E=90=EA=B0=80=20=EC=97=86=EB=8A=94=20=EB=B9=84=EB=94=94?= =?UTF-8?q?=EC=98=A4=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EA=B5=AC=ED=98=84=20(#7?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/pages/VideoPage/index.tsx | 24 ++++++++++++++++++++++ src/frontend/src/pages/VideoPage/styles.ts | 16 +++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 src/frontend/src/pages/VideoPage/index.tsx create mode 100644 src/frontend/src/pages/VideoPage/styles.ts diff --git a/src/frontend/src/pages/VideoPage/index.tsx b/src/frontend/src/pages/VideoPage/index.tsx new file mode 100644 index 00000000..cb7a22f7 --- /dev/null +++ b/src/frontend/src/pages/VideoPage/index.tsx @@ -0,0 +1,24 @@ +import { useState } from 'react'; + +import { BodyRegularText, TitleText1 } from '@/styles/Typography'; + +import * as S from './styles'; + +const VideoPage = () => { + const [isAttend, setIsAttend] = useState(false); + + return ( + + {isAttend ? ( + <>참여시 비디오들 + ) : ( + + 채널 이름 + 현재 음성 채널에 아무도 없어요 + + )} + + ); +}; + +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..36bf10e0 --- /dev/null +++ b/src/frontend/src/pages/VideoPage/styles.ts @@ -0,0 +1,16 @@ +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%; +`; From b8866f804ddc0fb92c7d6d834cf88c5e9aeda523 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Fri, 28 Feb 2025 23:19:36 +0900 Subject: [PATCH 07/40] =?UTF-8?q?[FE]=20feat:=20theme=20color=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/styles/theme.ts | 1 + 1 file changed, 1 insertion(+) 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', }; From 4cf2273ac6728efbcfb25bfc87a0df7492093e2e Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Fri, 28 Feb 2025 23:20:01 +0900 Subject: [PATCH 08/40] =?UTF-8?q?feat:=20=EB=B9=88=20=EC=B1=84=EB=84=90=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=EC=97=90=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=EC=A4=91=EC=9D=B8=20=EC=B1=84=EB=84=90=EB=AA=85=20?= =?UTF-8?q?=EC=B6=9C=EB=A0=A5=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/pages/VideoPage/index.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/frontend/src/pages/VideoPage/index.tsx b/src/frontend/src/pages/VideoPage/index.tsx index cb7a22f7..d8ebdf48 100644 --- a/src/frontend/src/pages/VideoPage/index.tsx +++ b/src/frontend/src/pages/VideoPage/index.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; +import { useChannelInfoStore } from '@/stores/channelInfo'; import { BodyRegularText, TitleText1 } from '@/styles/Typography'; import * as S from './styles'; @@ -7,13 +8,15 @@ import * as S from './styles'; const VideoPage = () => { const [isAttend, setIsAttend] = useState(false); + const { selectedChannel } = useChannelInfoStore(); + return ( {isAttend ? ( <>참여시 비디오들 ) : ( - 채널 이름 + {selectedChannel?.name} 현재 음성 채널에 아무도 없어요 )} From 2351bcd20f7ea5fef2a94b610c17c11f7dfe8696 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Fri, 28 Feb 2025 23:21:21 +0900 Subject: [PATCH 09/40] =?UTF-8?q?[FE]=20feat:=20CategorySection=EC=97=90?= =?UTF-8?q?=20VoiceChannelController=20=EB=B0=B0=EC=B9=98=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/CategorySection/index.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/frontend/src/pages/FriendsPage/components/CategorySection/index.tsx b/src/frontend/src/pages/FriendsPage/components/CategorySection/index.tsx index d6f109ec..04e8cc6e 100644 --- a/src/frontend/src/pages/FriendsPage/components/CategorySection/index.tsx +++ b/src/frontend/src/pages/FriendsPage/components/CategorySection/index.tsx @@ -3,6 +3,8 @@ import GuildCategory from '@/components/guild/GuildCategory'; import UserProfile from '@/pages/FriendsPage/components/UserProfile'; import { useGuildInfoStore } from '@/stores/guildInfo'; +import VoiceChannelController from '../VoiceChannelController'; + import * as S from './styles'; const CategorySection = () => { @@ -11,7 +13,16 @@ const CategorySection = () => { return ( {guildId ? : } - {}} handleHeadsetToggle={() => {}} /> + + {}} + handleHeadsetToggle={() => {}} + /> ); }; From cdff6a3e1989e6fbfc909534479734c4c2f03600 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Fri, 28 Feb 2025 23:40:13 +0900 Subject: [PATCH 10/40] =?UTF-8?q?[FE]=20feat:=20guild=EC=9D=98=20name?= =?UTF-8?q?=EC=9D=84=20=EC=A0=84=EC=97=AD=EC=83=81=ED=83=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=97=90=20=EC=B6=94=EA=B0=80=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/stores/guildInfo.ts | 4 ++++ 1 file changed, 4 insertions(+) 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', From aa66dee7a11548090b2f32050c716008440cd6a4 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Fri, 28 Feb 2025 23:40:45 +0900 Subject: [PATCH 11/40] =?UTF-8?q?[FE]=20feat:=20=EA=B8=B8=EB=93=9C=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EC=8B=9C=20=EC=A0=80=EC=9E=A5=EB=90=98?= =?UTF-8?q?=EB=8A=94=20guild=20=EC=A0=95=EB=B3=B4=EB=A5=BC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/components/guild/GuildList/index.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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)} /> ))} From 969cd7467bc2c737f7810a278dca26b7e4db9066 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Fri, 28 Feb 2025 23:54:45 +0900 Subject: [PATCH 12/40] =?UTF-8?q?[FE]=20refactor:=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/FriendsPage/components/CategorySection/index.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/frontend/src/pages/FriendsPage/components/CategorySection/index.tsx b/src/frontend/src/pages/FriendsPage/components/CategorySection/index.tsx index 04e8cc6e..3a8042b7 100644 --- a/src/frontend/src/pages/FriendsPage/components/CategorySection/index.tsx +++ b/src/frontend/src/pages/FriendsPage/components/CategorySection/index.tsx @@ -1,10 +1,9 @@ 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 { useGuildInfoStore } from '@/stores/guildInfo'; -import VoiceChannelController from '../VoiceChannelController'; - import * as S from './styles'; const CategorySection = () => { From 3924686b00cf306bf8aef6b0dbf534aae7987888 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Fri, 28 Feb 2025 23:55:26 +0900 Subject: [PATCH 13/40] =?UTF-8?q?[FE]=20feat:=20=EC=9D=8C=EC=84=B1=20?= =?UTF-8?q?=EC=B1=84=EB=84=90=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=83=81=EB=8B=A8=20UI?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../guild/VoiceChannelController/index.tsx | 27 ++++++++++++++ .../guild/VoiceChannelController/styles.ts | 37 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 src/frontend/src/components/guild/VoiceChannelController/index.tsx create mode 100644 src/frontend/src/components/guild/VoiceChannelController/styles.ts 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..e59451de --- /dev/null +++ b/src/frontend/src/components/guild/VoiceChannelController/index.tsx @@ -0,0 +1,27 @@ +import { BsFillTelephoneXFill } from 'react-icons/bs'; + +import { useChannelInfoStore } from '@/stores/channelInfo'; +import { useGuildInfoStore } from '@/stores/guildInfo'; + +import * as S from './styles'; + +const VoiceChannelController = () => { + const { selectedChannel } = useChannelInfoStore(); + const { guildName } = useGuildInfoStore(); + + 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..163bcbcf --- /dev/null +++ b/src/frontend/src/components/guild/VoiceChannelController/styles.ts @@ -0,0 +1,37 @@ +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 { + color: ${({ theme }) => theme.colors.white}; + } +`; From 39321ec12cf0b9d48543990f46d7d393e2bf0929 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Sat, 1 Mar 2025 03:07:03 +0900 Subject: [PATCH 14/40] =?UTF-8?q?[FE]=20feat:=20voice=20channel=20action?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=ED=95=9C=20=EC=A0=84=EC=97=AD=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=B6=94=EA=B0=80=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/stores/channelAction.ts | 31 ++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/frontend/src/stores/channelAction.ts diff --git a/src/frontend/src/stores/channelAction.ts b/src/frontend/src/stores/channelAction.ts new file mode 100644 index 00000000..8e2dbfb3 --- /dev/null +++ b/src/frontend/src/stores/channelAction.ts @@ -0,0 +1,31 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +interface ChannelActionState { + isInVoiceChannel: boolean; + isSharingScreen: boolean; + isVideoOn: boolean; + isMicOn: boolean; + setIsInVoiceChannel: () => void; + setIsSharingScreen: () => void; + setIsVideoOn: () => void; + setIsMicOn: () => void; +} + +export const useChannelActionStore = create()( + persist( + (set) => ({ + isInVoiceChannel: false, + isSharingScreen: false, + isVideoOn: false, + isMicOn: false, + setIsInVoiceChannel: () => set((state) => ({ isInVoiceChannel: !state.isInVoiceChannel })), + setIsSharingScreen: () => set((state) => ({ isSharingScreen: !state.isSharingScreen })), + setIsVideoOn: () => set((state) => ({ isVideoOn: !state.isVideoOn })), + setIsMicOn: () => set((state) => ({ isMicOn: !state.isMicOn })), + }), + { + name: 'channelAction', + }, + ), +); From 5b5749d95d3a3179874173cb3f6c95deaf594bd6 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Sat, 1 Mar 2025 03:13:52 +0900 Subject: [PATCH 15/40] =?UTF-8?q?[FE]=20feat:=20voice=20channel=20controll?= =?UTF-8?q?er=20actions=20UI=20=EA=B5=AC=ED=98=84=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../guild/VoiceChannelActions/index.tsx | 28 +++++++++++++++++++ .../guild/VoiceChannelActions/styles.ts | 26 +++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 src/frontend/src/components/guild/VoiceChannelActions/index.tsx create mode 100644 src/frontend/src/components/guild/VoiceChannelActions/styles.ts 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..d6365bf2 --- /dev/null +++ b/src/frontend/src/components/guild/VoiceChannelActions/index.tsx @@ -0,0 +1,28 @@ +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: , + }; + + 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..3ffc6834 --- /dev/null +++ b/src/frontend/src/components/guild/VoiceChannelActions/styles.ts @@ -0,0 +1,26 @@ +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` + display: flex; + align-items: center; + justify-content: center; + + width: 5rem; + height: 3rem; + border-radius: 0.8rem; + + background-color: ${({ theme }) => theme.colors.dark[500]}; +`; From 5a2c029fd2b6e5ba027db99a41c086ac3b61ad60 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Sat, 1 Mar 2025 03:24:03 +0900 Subject: [PATCH 16/40] =?UTF-8?q?[FE]=20feat:=20voice=20type=20=EC=B1=84?= =?UTF-8?q?=EB=84=90=20=EC=84=A0=ED=83=9D=EC=8B=9C=20=EC=A0=84=EC=97=AD?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20(#7?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/guild/GuildCategoriesList/index.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/frontend/src/components/guild/GuildCategoriesList/index.tsx b/src/frontend/src/components/guild/GuildCategoriesList/index.tsx index 6988d7ec..b03a3e59 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(); + } }; return ( From f51baa1002a9cb263bfd0c3c3c84c1d0aab135e5 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Sat, 1 Mar 2025 03:27:18 +0900 Subject: [PATCH 17/40] =?UTF-8?q?[FE]=20fix:=20=EC=B1=84=EB=84=90=20?= =?UTF-8?q?=ED=81=B4=EB=A6=AD=EB=A7=88=EB=8B=A4=20=EC=A0=84=EC=97=AD?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EA=B0=92=EC=9D=B4=20=EB=B0=94=EB=80=8C?= =?UTF-8?q?=EB=8A=94=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/components/guild/GuildCategoriesList/index.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/frontend/src/components/guild/GuildCategoriesList/index.tsx b/src/frontend/src/components/guild/GuildCategoriesList/index.tsx index b03a3e59..919fbaa0 100644 --- a/src/frontend/src/components/guild/GuildCategoriesList/index.tsx +++ b/src/frontend/src/components/guild/GuildCategoriesList/index.tsx @@ -32,6 +32,8 @@ const GuildCategoriesList = ({ categories, channels }: CategoriesListProps) => { if (channelInfo.type === 'VOICE') { if (!isInVoiceChannel) setIsInVoiceChannel(); + } else if (isInVoiceChannel) { + setIsInVoiceChannel(); } }; From 17ad368e61aa40d8f37e3f20e019159f512604d1 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Sat, 1 Mar 2025 03:27:46 +0900 Subject: [PATCH 18/40] =?UTF-8?q?[FE]=20feat:=20=EC=9D=8C=EC=84=B1=20?= =?UTF-8?q?=EC=B1=84=EB=84=90=20=EC=B0=B8=EC=97=AC=EC=8B=9C=EC=97=90?= =?UTF-8?q?=EB=A7=8C=20VoiceChannelController=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EB=85=B8=EC=B6=9C=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/FriendsPage/components/CategorySection/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/frontend/src/pages/FriendsPage/components/CategorySection/index.tsx b/src/frontend/src/pages/FriendsPage/components/CategorySection/index.tsx index 3a8042b7..81fff1b9 100644 --- a/src/frontend/src/pages/FriendsPage/components/CategorySection/index.tsx +++ b/src/frontend/src/pages/FriendsPage/components/CategorySection/index.tsx @@ -2,17 +2,19 @@ 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 ? : } - + {isInVoiceChannel && } Date: Sat, 1 Mar 2025 03:28:43 +0900 Subject: [PATCH 19/40] =?UTF-8?q?[FE]=20feat:=20VoiceChannelController?= =?UTF-8?q?=EC=97=90=20actions=20=EA=B4=80=EB=A0=A8=ED=95=9C=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/guild/VoiceChannelController/index.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/frontend/src/components/guild/VoiceChannelController/index.tsx b/src/frontend/src/components/guild/VoiceChannelController/index.tsx index e59451de..cd6ea6dd 100644 --- a/src/frontend/src/components/guild/VoiceChannelController/index.tsx +++ b/src/frontend/src/components/guild/VoiceChannelController/index.tsx @@ -3,6 +3,8 @@ import { BsFillTelephoneXFill } from 'react-icons/bs'; import { useChannelInfoStore } from '@/stores/channelInfo'; import { useGuildInfoStore } from '@/stores/guildInfo'; +import VoiceChannelActions from '../VoiceChannelActions'; + import * as S from './styles'; const VoiceChannelController = () => { @@ -20,6 +22,7 @@ const VoiceChannelController = () => { + ); }; From 39dfab6ec5f7f2e1cba42dde29be9711b36ab8fc Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Sat, 1 Mar 2025 14:53:47 +0900 Subject: [PATCH 20/40] =?UTF-8?q?[FE]=20refactor:=20channelAction=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=ED=95=A8=EC=88=98?= =?UTF-8?q?=EC=97=90=20=EB=A7=A4=EA=B0=9C=EB=B3=80=EC=88=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/stores/channelAction.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/frontend/src/stores/channelAction.ts b/src/frontend/src/stores/channelAction.ts index 8e2dbfb3..05f7c70b 100644 --- a/src/frontend/src/stores/channelAction.ts +++ b/src/frontend/src/stores/channelAction.ts @@ -6,10 +6,10 @@ interface ChannelActionState { isSharingScreen: boolean; isVideoOn: boolean; isMicOn: boolean; - setIsInVoiceChannel: () => void; - setIsSharingScreen: () => void; - setIsVideoOn: () => void; - setIsMicOn: () => void; + setIsInVoiceChannel: (value: boolean) => void; + setIsSharingScreen: (value: boolean) => void; + setIsVideoOn: (value: boolean) => void; + setIsMicOn: (value: boolean) => void; } export const useChannelActionStore = create()( From 113437c20275291a2083cbe41937533dcac65534 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Sat, 1 Mar 2025 16:27:57 +0900 Subject: [PATCH 21/40] =?UTF-8?q?[FE]=20feat:=20Actions=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98=EB=93=A4=EC=97=90=20=EC=95=A0=EB=8B=88?= =?UTF-8?q?=EB=A9=94=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../guild/VoiceChannelActions/index.tsx | 15 ++++++++++++++- src/frontend/src/pages/VideoPage/styles.ts | 2 ++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/frontend/src/components/guild/VoiceChannelActions/index.tsx b/src/frontend/src/components/guild/VoiceChannelActions/index.tsx index d6365bf2..765dc776 100644 --- a/src/frontend/src/components/guild/VoiceChannelActions/index.tsx +++ b/src/frontend/src/components/guild/VoiceChannelActions/index.tsx @@ -1,3 +1,4 @@ +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'; @@ -16,10 +17,22 @@ const VoiceChannelActions = () => { 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} + + {value} + ))} ); diff --git a/src/frontend/src/pages/VideoPage/styles.ts b/src/frontend/src/pages/VideoPage/styles.ts index 36bf10e0..01f197ce 100644 --- a/src/frontend/src/pages/VideoPage/styles.ts +++ b/src/frontend/src/pages/VideoPage/styles.ts @@ -13,4 +13,6 @@ export const EmptyParticipant = styled.div` justify-content: center; width: 100%; + + background-color: ${({ theme }) => theme.colors.black}; `; From 96ad67536b25049e498f7928a9c32fe5a56260cf Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Sat, 1 Mar 2025 16:29:15 +0900 Subject: [PATCH 22/40] =?UTF-8?q?[FE]=20feat:=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98=20hover=EC=8B=9C=20=EC=BB=A4=EC=84=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/components/guild/VoiceChannelActions/styles.ts | 2 ++ .../src/components/guild/VoiceChannelController/styles.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/src/frontend/src/components/guild/VoiceChannelActions/styles.ts b/src/frontend/src/components/guild/VoiceChannelActions/styles.ts index 3ffc6834..456a6ca4 100644 --- a/src/frontend/src/components/guild/VoiceChannelActions/styles.ts +++ b/src/frontend/src/components/guild/VoiceChannelActions/styles.ts @@ -14,6 +14,8 @@ export const VoiceChannelActions = styled.div` `; export const Action = styled.div` + cursor: pointer; + display: flex; align-items: center; justify-content: center; diff --git a/src/frontend/src/components/guild/VoiceChannelController/styles.ts b/src/frontend/src/components/guild/VoiceChannelController/styles.ts index 163bcbcf..6a4289c8 100644 --- a/src/frontend/src/components/guild/VoiceChannelController/styles.ts +++ b/src/frontend/src/components/guild/VoiceChannelController/styles.ts @@ -32,6 +32,7 @@ export const ConnectStatusWrapper = styled.div` justify-content: space-between; svg { + cursor: pointer; color: ${({ theme }) => theme.colors.white}; } `; From 89643705f44bb01a48fcab27a1cc954ece7c6acd Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Mon, 3 Mar 2025 23:59:53 +0900 Subject: [PATCH 23/40] =?UTF-8?q?[FE]=20feat:=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EC=95=84=EC=9D=B4=EC=BD=98=20=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=EC=8B=9C=20=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/guild/VoiceChannelController/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/frontend/src/components/guild/VoiceChannelController/index.tsx b/src/frontend/src/components/guild/VoiceChannelController/index.tsx index cd6ea6dd..8191632f 100644 --- a/src/frontend/src/components/guild/VoiceChannelController/index.tsx +++ b/src/frontend/src/components/guild/VoiceChannelController/index.tsx @@ -1,5 +1,6 @@ import { BsFillTelephoneXFill } from 'react-icons/bs'; +import { useChannelActionStore } from '@/stores/channelAction'; import { useChannelInfoStore } from '@/stores/channelInfo'; import { useGuildInfoStore } from '@/stores/guildInfo'; @@ -9,6 +10,7 @@ import * as S from './styles'; const VoiceChannelController = () => { const { selectedChannel } = useChannelInfoStore(); + const { setIsInVoiceChannel } = useChannelActionStore(); const { guildName } = useGuildInfoStore(); return ( @@ -20,7 +22,7 @@ const VoiceChannelController = () => { {selectedChannel?.name} / {guildName} - + setIsInVoiceChannel(false)} /> From 0041a68d5cb56ca71c58e8a9e73df5bda6a347f1 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Wed, 5 Mar 2025 18:21:43 +0900 Subject: [PATCH 24/40] =?UTF-8?q?[FE]=20feat:=20Socket=EC=9D=84=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=9C=20WebRTC=20=EC=9E=84=EC=8B=9C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/pages/ChannelPage/test.tsx | 855 ++++++++++++++++++++ 1 file changed, 855 insertions(+) create mode 100644 src/frontend/src/pages/ChannelPage/test.tsx diff --git a/src/frontend/src/pages/ChannelPage/test.tsx b/src/frontend/src/pages/ChannelPage/test.tsx new file mode 100644 index 00000000..f1ff5885 --- /dev/null +++ b/src/frontend/src/pages/ChannelPage/test.tsx @@ -0,0 +1,855 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useRef, useState, useEffect, useCallback } from 'react'; + +import { useChannelActionStore } from '@/stores/channelAction'; +import { tokenAxios } from '@/utils/axios'; + +const SERVER_URL = import.meta.env.VITE_SIGNALING; +enum MessageType { + JOIN = 'join', + USER_JOINED = 'user-joined', + OFFER = 'offer', + ANSWER = 'answer', + CANDIDATE = 'candidate', + EXIT = 'exit', + AUDIO = 'AUDIO', + MEDIA = 'MEDIA', + DATA = 'DATA', +} + +// 메시지 인터페이스 +interface WebSocketMessage { + type: MessageType; + data: any; + token: string; +} + +const VideoTest = () => { + const localVideoRef = useRef(null); + const remoteVideoRef = useRef(null); + const screenShareRef = useRef(null); + + const pcRef = useRef(null); + const wsRef = useRef(null); + const localStreamRef = useRef(null); + const screenStreamRef = useRef(null); + const screenTrackRef = useRef(null); + + const [roomId, setRoomId] = useState(''); + const [joined, setJoined] = useState(false); + const [statusMessage, setStatusMessage] = useState(''); + + const { isSharingScreen, isVideoOn, isMicOn, setIsInVoiceChannel, setIsSharingScreen, setIsVideoOn, setIsMicOn } = + useChannelActionStore(); + + const token = localStorage.getItem('access_token'); + + useEffect(() => { + const connectWebSocket = () => { + if (token) { + try { + const ws = new WebSocket(SERVER_URL); + wsRef.current = ws; + + ws.onopen = () => { + console.log('[ws] 연결됨'); + setStatusMessage('시그널링 서버에 연결됨'); + }; + + ws.onmessage = (event) => { + try { + const message: WebSocketMessage = JSON.parse(event.data); + handleWebSocketMessage(message); + } catch (error) { + console.error('[ws] 메시지 파싱 오류:', error); + } + }; + + ws.onerror = (error) => { + console.error('[ws] 에러 발생:', error); + setStatusMessage('WebSocket 오류가 발생했습니다'); + }; + + ws.onclose = () => { + console.log('[ws] 연결 종료됨'); + setStatusMessage('서버 연결이 종료되었습니다'); + + setTimeout(() => { + connectWebSocket(); + }, 2000); + }; + } catch (error) { + console.error('[ws] 연결 생성 중 오류:', error); + setStatusMessage(`서버 연결 오류: ${error instanceof Error ? error.message : String(error)}`); + } + } + }; + + connectWebSocket(); + + return () => { + // 미디어 스트림, PeerConnection, WebSocket 종료 + if (localStreamRef.current) { + localStreamRef.current.getTracks().forEach((track) => track.stop()); + } + + if (screenStreamRef.current) { + screenStreamRef.current.getTracks().forEach((track) => track.stop()); + } + + if (pcRef.current) { + pcRef.current.close(); + } + + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.close(); + } + }; + }, []); + + const pendingCandidates = useRef([]); + + // WebSocket 메시지 처리 함수 + const handleWebSocketMessage = async (message: any) => { + console.log('수신된 메시지', message); + + if (message.type === 'candidate' && message.candidate) { + console.log('[handleWebSocketMessage] Candidate 메시지:', message.candidate); + try { + if (pcRef.current && pcRef.current.remoteDescription) { + await pcRef.current.addIceCandidate(new RTCIceCandidate(message.candidate)); + console.log('ICE Candidate 추가됨'); + } else { + // remoteDescription이 아직 설정되지 않았으면 후보를 대기열에 추가 + console.log('원격 설명이 설정되지 않음, 후보 대기열에 추가'); + pendingCandidates.current.push(message.candidate); + } + } catch (err) { + console.error('ICE Candidate 추가 중 오류:', err); + } + return; + } + + if (message.type === 'response' && message.users && message.users.length > 0) { + const user = message.users[0]; + + console.log('메시지정보', message); + console.log('사용자정보', message.users); + + if (user.sdpOffer) { + // 사용자가 sdpOffer를 보냈는지 확인 + setStatusMessage('Offer 수신되었습니다. 응답 중...'); + + if (!pcRef.current) { + await createPeerConnection(); + } + + if (pcRef.current) { + try { + await pcRef.current.setRemoteDescription( + new RTCSessionDescription({ + type: 'offer', + sdp: user.sdpOffer, + }), + ); + + // 이거 type이 response에서 하는게 맞나? + if (pendingCandidates.current.length > 0) { + console.log(`${pendingCandidates.current.length}개의 대기 중인 후보 처리 중`); + + for (const candidate of pendingCandidates.current) { + try { + await pcRef.current.addIceCandidate(new RTCIceCandidate(candidate)); + console.log('대기 중이던 ICE candidate 추가됨'); + } catch (err) { + console.error('대기 중이던 ICE candidate 추가 중 오류:', err); + } + } + pendingCandidates.current = []; + } + + const answer = await pcRef.current.createAnswer(); + await pcRef.current.setLocalDescription(answer); + + if (token) { + sendWebSocketMessage( + MessageType.ANSWER, + { + sdpAnswer: answer.sdp, + roomId, + }, + token, + ); + } + + setStatusMessage('응답을 보냈습니다. 연결 중...'); + } catch (err) { + console.error('Offer 처리 중 오류:', err); + setStatusMessage(`Offer 처리 오류: ${err instanceof Error ? err.message : String(err)}`); + } + } + } + + // 사용자가 sdpAnswer를 보냈는지 확인 + if (user.sdpAnswer) { + setStatusMessage('응답을 받았습니다. 연결 중...'); + + try { + if (pcRef.current) { + await pcRef.current.setRemoteDescription( + new RTCSessionDescription({ + type: 'answer', + sdp: user.sdpAnswer, + }), + ); + } + + if (pendingCandidates.current.length > 0) { + console.log(`${pendingCandidates.current.length}개의 대기 중인 후보 처리 중`); + + for (const candidate of pendingCandidates.current) { + try { + if (pcRef.current) { + await pcRef.current.addIceCandidate(new RTCIceCandidate(candidate)); + console.log('대기 중이던 ICE candidate 추가됨'); + } + } catch (err) { + console.error('대기 중이던 ICE candidate 추가 중 오류:', err); + } + } + pendingCandidates.current = []; // 처리 후 배열 비우기 + } + } catch (err) { + setStatusMessage(`Answer 처리 오류: ${err instanceof Error ? err.message : String(err)}`); + } + } + + // 새 사용자 참여 여부 확인 (audio, video가 false인 경우 새로 참여한 것으로 가정) + if (user.audio === false && user.video === false && !joined) { + setStatusMessage(`사용자가 방에 참여했습니다`); + + if (!joined) { + await joinRoom(); + } + } + + if (user.candidate) { + console.log('ICE Candidate 수신:', user.candidate); + try { + if (pcRef.current) { + await pcRef.current.addIceCandidate(new RTCIceCandidate(user.candidate)); + console.log('ICE Candidate 추가됨'); + } + } catch (err) { + console.error('ICE Candidate 추가 중 오류:', err); + } + } + } + }; + + // WebSocket 메시지 전송 헬퍼 함수 + const sendWebSocketMessage = (type: MessageType, data: any, token: string) => { + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + const message: WebSocketMessage = { type, data, token }; + wsRef.current.send(JSON.stringify(message)); + } else { + console.error('[ws] WebSocket이 열려있지 않아 메시지를 보낼 수 없습니다'); + setStatusMessage('서버에 연결되어 있지 않습니다'); + } + }; + + const createPeerConnection = useCallback(async () => { + try { + pcRef.current = new RTCPeerConnection({ + iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], + }); + + console.log('[pc] PeerConnection 구성됨:', pcRef.current); + + // ICE 후보 수집 상태 모니터링 + pcRef.current.onicegatheringstatechange = () => { + console.log('[pc] ICE 수집 상태:', pcRef.current?.iceGatheringState); + + // 수집 완료 시 로그 + if (pcRef.current?.iceGatheringState === 'complete') { + console.log('[pc] ICE 후보 수집 완료'); + } + }; + + pcRef.current.oniceconnectionstatechange = () => { + const state = pcRef.current?.iceConnectionState; + console.log('[pc] ICE 연결 상태 변경:', state); + + // ICE 연결 실패 시 처리 + if (state === 'failed' || state === 'disconnected') { + console.log('[pc] ICE 연결 문제 발생, 재연결 시도...'); + + // 연결이 끊어진 경우 재연결 시도 + if (pcRef.current) { + // ICE 재시작 오퍼 생성 + pcRef.current + .createOffer({ + iceRestart: true, + offerToReceiveAudio: true, + offerToReceiveVideo: true, + }) + .then((offer) => { + return pcRef.current?.setLocalDescription(offer); + }) + .then(() => { + // 새 오퍼를 서버로 전송 + if (token && pcRef.current?.localDescription?.sdp) { + sendWebSocketMessage( + MessageType.OFFER, + { + sdpOffer: pcRef.current.localDescription.sdp, + roomId, + iceRestart: true, + }, + token, + ); + console.log('[pc] ICE 재시작 오퍼 전송됨'); + } + }) + .catch((err) => { + console.error('[pc] ICE 재시작 실패:', err); + }); + } + } + + // ICE 연결 성공 시 + if (state === 'connected' || state === 'completed') { + console.log('[pc] ICE 연결 성공!'); + setStatusMessage('ICE 연결 성공! 화상 통화 진행 중...'); + } + }; + + // onicecandidate 이벤트: 수집된 ICE 후보를 시그널링 서버로 전송 + pcRef.current.onicecandidate = (event) => { + if (event.candidate && token) { + console.log('[pc] 생성된 ICE Candidate:', event.candidate); + + sendWebSocketMessage( + MessageType.CANDIDATE, + { + candidate: event.candidate, + roomId, + }, + token, + ); + } else { + console.log('[pc] ICE Candidate 수집 완료'); + } + }; + + // 연결 상태 변경 이벤트 + pcRef.current.onconnectionstatechange = () => { + console.log('[pc] onconnectionstatechange fired:', pcRef.current?.connectionState); + console.log('[pc] ICE 연결 상태 변경:', pcRef.current?.iceConnectionState); + setStatusMessage(`연결 상태: ${pcRef.current?.connectionState}`); + + if (pcRef.current?.connectionState === 'connected') { + setIsInVoiceChannel(true); + setStatusMessage('연결 성공! 화상 통화 중...'); + } + }; + + // 원격 트랙(상대방 미디어) 수신 시 비디오 태그에 설정 + pcRef.current.ontrack = (event) => { + console.log('[pc] 원격 트랙 수신됨:', event); + + if (event.streams && event.streams.length > 0) { + const remoteStream = event.streams[0]; + console.log('[pc] 원격 스트림:', remoteStream); + console.log('[pc] 원격 스트림 트랙:', remoteStream.getTracks()); + + // 추가 + remoteStream.getTracks().forEach((track) => { + console.log( + `[pc] 트랙 ID ${track.id}: 종류=${track.kind}, 활성화=${track.enabled}, 준비=${track.readyState}`, + ); + + // 트랙의 상태 변경 감지 + track.onended = () => console.log(`[pc] 트랙 ${track.id} 종료됨`); + track.onmute = () => console.log(`[pc] 트랙 ${track.id} 음소거됨`); + track.onunmute = () => console.log(`[pc] 트랙 ${track.id} 음소거 해제됨`); + }); + + if (remoteVideoRef.current) { + console.log('[pc] 원격 비디오 요소에 스트림 설정'); + + if (remoteVideoRef.current.srcObject) { + console.log('[pc] 이전 스트림 정리'); + remoteVideoRef.current.srcObject = null; + } + + remoteVideoRef.current.srcObject = remoteStream; + remoteVideoRef.current.muted = false; + + console.log('[pc] 비디오 요소 준비 상태:', { + videoWidth: remoteVideoRef.current.videoWidth, + videoHeight: remoteVideoRef.current.videoHeight, + readyState: remoteVideoRef.current.readyState, + paused: remoteVideoRef.current.paused, + }); + + // 명시적으로 재생 시도 (비동기/동기 모두 시도) + try { + remoteVideoRef.current.play(); + console.log('[pc] 동기 재생 시도'); + } catch (e) { + console.error('[pc] 동기 재생 실패:', e); + } + + remoteVideoRef.current + .play() + .then(() => console.log('[pc] 비동기 재생 성공')) + .catch((e) => { + console.error('[pc] 비동기 재생 실패:', e); + + setStatusMessage('비디오 자동 재생 실패. 화면을 클릭하여 재생하세요.'); + }); + + // 비디오 로딩 및 재생 확인을 위한 이벤트 리스너 추가 + remoteVideoRef.current.onloadedmetadata = () => { + console.log('[pc] 원격 비디오 메타데이터 로드됨'); + remoteVideoRef.current + ?.play() + .then(() => console.log('[pc] 원격 비디오 재생 시작')) + .catch((e) => console.error('[pc] 원격 비디오 재생 실패:', e)); + }; + + // 추가 디버깅을 위한 이벤트 리스너 + remoteVideoRef.current.oncanplay = () => { + console.log('[pc] 원격 비디오 재생 가능 상태'); + + remoteVideoRef.current?.play().catch((e) => console.error('[pc] 재생 가능 상태에서 재생 실패:', e)); + }; + + remoteVideoRef.current.onerror = (e) => { + console.error('[pc] 원격 비디오 오류:', e); + }; + } else { + console.warn('[pc] 원격 비디오 요소가 없습니다'); + } + } else { + console.warn('[pc] 원격 스트림이 없습니다', event); + } + }; + + const localStream = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: true, + }); + + localStreamRef.current = localStream; + + // 내 비디오 태그에 출력 + if (localVideoRef.current) { + localVideoRef.current.srcObject = localStream; + } + + setIsVideoOn(true); + + localStream.getTracks().forEach((track) => { + pcRef.current?.addTrack(track, localStream); + }); + + console.log('[pc] RTCPeerConnection 및 로컬 미디어 설정 완료'); + return true; + } catch (err) { + console.error('PeerConnection 생성 또는 미디어 접근 중 오류:', err); + setStatusMessage(`오류: ${err instanceof Error ? err.message : String(err)}`); + return false; + } + }, [setIsInVoiceChannel, roomId, setIsVideoOn, token]); + + const playAllVideos = () => { + console.log('모든 비디오 재생 시도'); + + // 로컬 비디오 재생 + if (localVideoRef.current) { + localVideoRef.current + .play() + .then(() => console.log('로컬 비디오 재생 성공')) + .catch((e) => console.error('로컬 비디오 재생 실패:', e)); + } + + // 원격 비디오 재생 + if (remoteVideoRef.current) { + remoteVideoRef.current + .play() + .then(() => console.log('원격 비디오 재생 성공')) + .catch((e) => console.error('원격 비디오 재생 실패:', e)); + } + + // 화면 공유 비디오 재생 + if (isSharingScreen && screenShareRef.current) { + screenShareRef.current + .play() + .then(() => console.log('화면 공유 비디오 재생 성공')) + .catch((e) => console.error('화면 공유 비디오 재생 실패:', e)); + } + }; + + const joinRoom = async () => { + setStatusMessage('방 참여 중...'); + + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + if (!pcRef.current) { + const success = await createPeerConnection(); + if (!success) { + setStatusMessage('미디어 장치 접근 실패'); + return; + } + } + + if (roomId) { + const response = await tokenAxios.post(`https://api.jungeunjipi.com/room/${roomId}/join`, { + audio_enabled: isMicOn, + media_enabled: isVideoOn, + data_enabled: isSharingScreen, + }); + + if (response) { + if (pcRef.current) { + // 타임아웃을 설정하여 ICE 후보 수집에 충분한 시간 부여 + setTimeout(async () => { + try { + const offer = await pcRef.current!.createOffer({ + offerToReceiveAudio: true, + offerToReceiveVideo: true, + }); + + await pcRef.current!.setLocalDescription(offer); + + // SDP 제안을 보내기 전에 잠시 대기하여 ICE 후보 수집이 일부 완료되도록 함 + setTimeout(() => { + if (token && pcRef.current?.localDescription) { + sendWebSocketMessage( + MessageType.OFFER, + { + sdpOffer: pcRef.current.localDescription.sdp, + roomId, + }, + token, + ); + } + + setJoined(true); + setStatusMessage(`방 ${roomId}에 참여함. 응답 대기 중...`); + }, 1000); + } catch (error) { + console.error('Offer 생성 중 오류:', error); + setStatusMessage(`Offer 생성 오류: ${error instanceof Error ? error.message : String(error)}`); + } + }, 500); + } + } else { + alert('방 참가 실패'); + } + } + } else { + setStatusMessage('소켓 연결이 없습니다. 페이지를 새로고침하세요.'); + } + }; + + // 화면 공유 시작 + const startScreenShare = async () => { + try { + const screenStream = await navigator.mediaDevices.getDisplayMedia({ + video: true, + audio: false, + }); + + screenStreamRef.current = screenStream; + + // 화면 공유 비디오 요소에 스트림 설정 + if (screenShareRef.current) { + // 먼저 이전 스트림 정리 + if (screenShareRef.current.srcObject) { + screenShareRef.current.srcObject = null; + } + + // 새 스트림 설정 + screenShareRef.current.srcObject = screenStream; + + // 수동으로 재생 시도 + screenShareRef.current.play().catch((err) => { + console.error('화면 공유 비디오 재생 실패:', err); + }); + } else { + console.error('화면 공유 비디오 요소를 찾을 수 없음'); + } + + // 화면 공유 종료 이벤트 리스너 + screenStream.getVideoTracks()[0].onended = () => { + stopScreenShare(); + }; + + // WebRTC 연결이 존재하는 경우 트랙 교체 (p2p 연결용) + if (pcRef.current) { + const screenVideoTrack = screenStream.getVideoTracks()[0]; + screenTrackRef.current = screenVideoTrack; + + const sender = pcRef.current.getSenders().find((s) => s.track?.kind === 'video'); + + if (sender) { + try { + await sender.replaceTrack(screenVideoTrack); + } catch (err) { + console.error('트랙 교체 실패:', err); + } + } else { + try { + pcRef.current.addTrack(screenVideoTrack, screenStream); + } catch (err) { + console.error('트랙 추가 실패:', err); + } + } + } + + if (token && wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + sendWebSocketMessage( + MessageType.DATA, + { + roomId, + enabled: true, + }, + token, + ); + } + + setIsSharingScreen(true); + setStatusMessage('화면 공유 중...'); + } catch (error) { + console.error('화면 공유 시작 중 오류:', error); + setStatusMessage(`화면 공유 시작 중 오류: ${error instanceof Error ? error.message : String(error)}`); + } + }; + + // 화면 공유 중지 + const stopScreenShare = async () => { + if (screenStreamRef.current) { + // 모든 화면 공유 트랙 중지 + screenStreamRef.current.getTracks().forEach((track) => track.stop()); + + // 화면 공유 비디오 초기화 + if (screenShareRef.current) { + screenShareRef.current.srcObject = null; + } + + // 다시 로컬 카메라 비디오로 돌아가기 + if (pcRef.current && localStreamRef.current) { + const videoTrack = localStreamRef.current.getVideoTracks()[0]; + const sender = pcRef.current.getSenders().find((s) => s.track?.kind === 'video'); + + if (sender && videoTrack) { + sender.replaceTrack(videoTrack); + } + + // 오디오 트랙도 원래대로 복구 + const audioTrack = localStreamRef.current.getAudioTracks()[0]; + if (audioTrack) { + const audioSender = pcRef.current.getSenders().find((s) => s.track?.kind === 'audio'); + if (audioSender) { + audioSender.replaceTrack(audioTrack); + } + } + } + + if (token && wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + sendWebSocketMessage( + MessageType.DATA, + { + roomId, + enabled: false, + }, + token, + ); + } + + screenStreamRef.current = null; + screenTrackRef.current = null; + setIsSharingScreen(false); + setStatusMessage('화면 공유 종료됨'); + } + }; + + // 마이크 음소거/해제 + const toggleAudio = () => { + if (localStreamRef.current) { + const audioTracks = localStreamRef.current.getAudioTracks(); + const newAudioState = !isMicOn; + + audioTracks.forEach((track) => { + track.enabled = newAudioState; + }); + + if (token && wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + sendWebSocketMessage( + MessageType.AUDIO, + { + roomId, + enabled: newAudioState, + }, + token, + ); + } + + setIsMicOn(newAudioState); + setStatusMessage(`마이크 ${newAudioState ? '활성화됨' : '음소거됨'}`); + } + }; + + // 비디오 켜기/끄기 + const toggleVideo = () => { + if (localStreamRef.current) { + const videoTracks = localStreamRef.current.getVideoTracks(); + const newVideoState = !isVideoOn; + + videoTracks.forEach((track) => { + track.enabled = newVideoState; + }); + + if (token && wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + sendWebSocketMessage( + MessageType.MEDIA, + { + roomId, + enabled: newVideoState, + }, + token, + ); + } + + setIsVideoOn(newVideoState); + setStatusMessage(`비디오 ${!isVideoOn ? '활성화됨' : '비활성화됨'}`); + } + }; + + // 통화 종료 + const hangUp = () => { + setStatusMessage('통화 종료 중...'); + + if (token && wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + sendWebSocketMessage( + MessageType.EXIT, + { + roomId, + }, + token, + ); + } + + if (pcRef.current) { + pcRef.current.close(); + pcRef.current = null; + } + + // 로컬 비디오 스트림 정리 + if (localStreamRef.current) { + localStreamRef.current.getTracks().forEach((track) => track.stop()); + localStreamRef.current = null; + } + + // 비디오 요소 초기화 + if (localVideoRef.current?.srcObject) { + localVideoRef.current.srcObject = null; + } + if (remoteVideoRef.current?.srcObject) { + remoteVideoRef.current.srcObject = null; + } + + setJoined(false); + setIsInVoiceChannel(false); + + setStatusMessage('통화가 종료되었습니다.'); + }; + + useEffect(() => { + if (isSharingScreen && screenShareRef.current && screenStreamRef.current) { + screenShareRef.current.srcObject = screenStreamRef.current; + + screenShareRef.current.play().catch((err) => console.error('useEffect에서 비디오 재생 오류:', err)); + } + }, [isSharingScreen]); + + return ( +
+

테스트 페이지

+
+ setRoomId(e.target.value)} placeholder="Enter Room ID" /> + + + + + +
+ +
+

+ 상태: {statusMessage} +

+
+ +
+
+

내 비디오

+
+
+

상대방 비디오

+
+ {isSharingScreen && ( +
+

화면 공유

+
+ )} +
+
+ ); +}; + +export default VideoTest; From 3520a4e27ce390cb8392813eb4a4807fd551b530 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Wed, 5 Mar 2025 19:53:42 +0900 Subject: [PATCH 25/40] =?UTF-8?q?[FE]=20feat:=20stomp=20=EB=B0=A9=EC=8B=9D?= =?UTF-8?q?=EC=9D=84=20=EC=82=AC=EC=9A=A9=ED=95=98=EC=97=AC=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=9A=A9=20WebRTC=20=EA=B5=AC=ED=98=84=20(#7?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/pages/ChannelPage/test.tsx | 288 +++++++++----------- 1 file changed, 130 insertions(+), 158 deletions(-) diff --git a/src/frontend/src/pages/ChannelPage/test.tsx b/src/frontend/src/pages/ChannelPage/test.tsx index f1ff5885..3b5e056c 100644 --- a/src/frontend/src/pages/ChannelPage/test.tsx +++ b/src/frontend/src/pages/ChannelPage/test.tsx @@ -4,7 +4,8 @@ import { useRef, useState, useEffect, useCallback } from 'react'; import { useChannelActionStore } from '@/stores/channelAction'; import { tokenAxios } from '@/utils/axios'; -const SERVER_URL = import.meta.env.VITE_SIGNALING; +import useStompWebRTC from './hooks/useStompWebRTC'; + enum MessageType { JOIN = 'join', USER_JOINED = 'user-joined', @@ -14,14 +15,11 @@ enum MessageType { EXIT = 'exit', AUDIO = 'AUDIO', MEDIA = 'MEDIA', - DATA = 'DATA', } -// 메시지 인터페이스 -interface WebSocketMessage { - type: MessageType; - data: any; - token: string; +interface AnswerMessage { + type: string; + message: string; } const VideoTest = () => { @@ -37,57 +35,56 @@ const VideoTest = () => { const [roomId, setRoomId] = useState(''); const [joined, setJoined] = useState(false); + const [answers, setAnswers] = useState([]); const [statusMessage, setStatusMessage] = useState(''); const { isSharingScreen, isVideoOn, isMicOn, setIsInVoiceChannel, setIsSharingScreen, setIsVideoOn, setIsMicOn } = useChannelActionStore(); + const { client, isConnected } = useStompWebRTC({ roomId }); + const token = localStorage.getItem('access_token'); useEffect(() => { - const connectWebSocket = () => { - if (token) { - try { - const ws = new WebSocket(SERVER_URL); - wsRef.current = ws; + if (!client || !isConnected) return; - ws.onopen = () => { - console.log('[ws] 연결됨'); - setStatusMessage('시그널링 서버에 연결됨'); - }; + setStatusMessage('STOMP 서버에 연결됨'); - ws.onmessage = (event) => { - try { - const message: WebSocketMessage = JSON.parse(event.data); - handleWebSocketMessage(message); - } catch (error) { - console.error('[ws] 메시지 파싱 오류:', error); - } - }; - - ws.onerror = (error) => { - console.error('[ws] 에러 발생:', error); - setStatusMessage('WebSocket 오류가 발생했습니다'); - }; - - ws.onclose = () => { - console.log('[ws] 연결 종료됨'); - setStatusMessage('서버 연결이 종료되었습니다'); - - setTimeout(() => { - connectWebSocket(); - }, 2000); - }; - } catch (error) { - console.error('[ws] 연결 생성 중 오류:', error); - setStatusMessage(`서버 연결 오류: ${error instanceof Error ? error.message : String(error)}`); + const userSubscription = client.subscribe(`/topic/users/${roomId}`, (message) => { + try { + const response = JSON.parse(message.body); + handleStompMessage(response); + } catch (error) { + console.error('[stomp] 메시지 파싱 오류:', error); + } + }); + + const candidateSubscription = client.subscribe(`/topic/candidate/${roomId}`, (message) => { + try { + const candidateData = JSON.parse(message.body); + if (candidateData.candidate) { + handleIceCandidate(candidateData.candidate); } + } catch (error) { + console.error('[stomp] candidate 파싱 오류:', error); } - }; + }); - connectWebSocket(); + const answerSubscription = client.subscribe(`/topic/answer/${roomId}`, (message) => { + try { + const parsedAnswer: AnswerMessage = JSON.parse(message.body); + setAnswers((prev) => [...prev, parsedAnswer]); + console.log(parsedAnswer); + } catch (error) { + console.error('[stomp] answer 파싱 오류:', error); + } + }); return () => { + userSubscription.unsubscribe(); + candidateSubscription.unsubscribe(); + answerSubscription.unsubscribe(); + // 미디어 스트림, PeerConnection, WebSocket 종료 if (localStreamRef.current) { localStreamRef.current.getTracks().forEach((track) => track.stop()); @@ -100,36 +97,15 @@ const VideoTest = () => { if (pcRef.current) { pcRef.current.close(); } - - if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { - wsRef.current.close(); - } }; - }, []); + }, [client, isConnected, roomId]); const pendingCandidates = useRef([]); // WebSocket 메시지 처리 함수 - const handleWebSocketMessage = async (message: any) => { + const handleStompMessage = async (message: any) => { console.log('수신된 메시지', message); - if (message.type === 'candidate' && message.candidate) { - console.log('[handleWebSocketMessage] Candidate 메시지:', message.candidate); - try { - if (pcRef.current && pcRef.current.remoteDescription) { - await pcRef.current.addIceCandidate(new RTCIceCandidate(message.candidate)); - console.log('ICE Candidate 추가됨'); - } else { - // remoteDescription이 아직 설정되지 않았으면 후보를 대기열에 추가 - console.log('원격 설명이 설정되지 않음, 후보 대기열에 추가'); - pendingCandidates.current.push(message.candidate); - } - } catch (err) { - console.error('ICE Candidate 추가 중 오류:', err); - } - return; - } - if (message.type === 'response' && message.users && message.users.length > 0) { const user = message.users[0]; @@ -168,18 +144,21 @@ const VideoTest = () => { pendingCandidates.current = []; } + // 응답 보내기 -> answer 아닌 것 같아서 확인은 해야함 const answer = await pcRef.current.createAnswer(); await pcRef.current.setLocalDescription(answer); - if (token) { - sendWebSocketMessage( - MessageType.ANSWER, - { - sdpAnswer: answer.sdp, - roomId, - }, - token, - ); + if (client && token) { + client.publish({ + destination: '/answer', + body: JSON.stringify({ + type: MessageType.ANSWER, + data: { + roomId, + sdp_answer: answer.sdp, + }, + }), + }); } setStatusMessage('응답을 보냈습니다. 연결 중...'); @@ -217,7 +196,7 @@ const VideoTest = () => { console.error('대기 중이던 ICE candidate 추가 중 오류:', err); } } - pendingCandidates.current = []; // 처리 후 배열 비우기 + pendingCandidates.current = []; } } catch (err) { setStatusMessage(`Answer 처리 오류: ${err instanceof Error ? err.message : String(err)}`); @@ -247,14 +226,18 @@ const VideoTest = () => { } }; - // WebSocket 메시지 전송 헬퍼 함수 - const sendWebSocketMessage = (type: MessageType, data: any, token: string) => { - if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { - const message: WebSocketMessage = { type, data, token }; - wsRef.current.send(JSON.stringify(message)); - } else { - console.error('[ws] WebSocket이 열려있지 않아 메시지를 보낼 수 없습니다'); - setStatusMessage('서버에 연결되어 있지 않습니다'); + const handleIceCandidate = async (candidate: RTCIceCandidateInit) => { + console.log('[handleIceCandidate] Candidate 메시지:', candidate); + try { + if (pcRef.current && pcRef.current.remoteDescription) { + await pcRef.current.addIceCandidate(new RTCIceCandidate(candidate)); + console.log('ICE Candidate 추가됨'); + } else { + console.log('원격 설명이 설정되지 않음, 후보 대기열에 추가'); + pendingCandidates.current.push(candidate); + } + } catch (err) { + console.error('ICE Candidate 추가 중 오류:', err); } }; @@ -299,15 +282,16 @@ const VideoTest = () => { .then(() => { // 새 오퍼를 서버로 전송 if (token && pcRef.current?.localDescription?.sdp) { - sendWebSocketMessage( - MessageType.OFFER, - { - sdpOffer: pcRef.current.localDescription.sdp, - roomId, - iceRestart: true, - }, - token, - ); + client?.publish({ + destination: '/offer', + body: JSON.stringify({ + type: MessageType.OFFER, + data: { + roomId, + sdp_offer: pcRef.current.localDescription.sdp, + }, + }), + }); console.log('[pc] ICE 재시작 오퍼 전송됨'); } }) @@ -326,17 +310,19 @@ const VideoTest = () => { // onicecandidate 이벤트: 수집된 ICE 후보를 시그널링 서버로 전송 pcRef.current.onicecandidate = (event) => { - if (event.candidate && token) { + if (event.candidate && token && client) { console.log('[pc] 생성된 ICE Candidate:', event.candidate); - sendWebSocketMessage( - MessageType.CANDIDATE, - { - candidate: event.candidate, - roomId, - }, - token, - ); + client.publish({ + destination: '/candidate', + body: JSON.stringify({ + type: MessageType.CANDIDATE, + data: { + roomId, + candidate: event.candidate, + }, + }), + }); } else { console.log('[pc] ICE Candidate 수집 완료'); } @@ -495,7 +481,7 @@ const VideoTest = () => { const joinRoom = async () => { setStatusMessage('방 참여 중...'); - if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + if (token && isConnected && client) { if (!pcRef.current) { const success = await createPeerConnection(); if (!success) { @@ -526,14 +512,16 @@ const VideoTest = () => { // SDP 제안을 보내기 전에 잠시 대기하여 ICE 후보 수집이 일부 완료되도록 함 setTimeout(() => { if (token && pcRef.current?.localDescription) { - sendWebSocketMessage( - MessageType.OFFER, - { - sdpOffer: pcRef.current.localDescription.sdp, - roomId, - }, - token, - ); + client?.publish({ + destination: '/offer', + body: JSON.stringify({ + type: MessageType.OFFER, + data: { + roomId, + sdp_offer: pcRef.current.localDescription.sdp, + }, + }), + }); } setJoined(true); @@ -609,17 +597,6 @@ const VideoTest = () => { } } - if (token && wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { - sendWebSocketMessage( - MessageType.DATA, - { - roomId, - enabled: true, - }, - token, - ); - } - setIsSharingScreen(true); setStatusMessage('화면 공유 중...'); } catch (error) { @@ -658,17 +635,6 @@ const VideoTest = () => { } } - if (token && wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { - sendWebSocketMessage( - MessageType.DATA, - { - roomId, - enabled: false, - }, - token, - ); - } - screenStreamRef.current = null; screenTrackRef.current = null; setIsSharingScreen(false); @@ -686,15 +652,17 @@ const VideoTest = () => { track.enabled = newAudioState; }); - if (token && wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { - sendWebSocketMessage( - MessageType.AUDIO, - { - roomId, - enabled: newAudioState, - }, - token, - ); + if (token && isConnected && client) { + client.publish({ + destination: '/toggle', + body: JSON.stringify({ + type: MessageType.AUDIO, + data: { + roomId, + enabled: newAudioState, + }, + }), + }); } setIsMicOn(newAudioState); @@ -712,15 +680,17 @@ const VideoTest = () => { track.enabled = newVideoState; }); - if (token && wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { - sendWebSocketMessage( - MessageType.MEDIA, - { - roomId, - enabled: newVideoState, - }, - token, - ); + if (token && isConnected && client) { + client.publish({ + destination: '/toggle', + body: JSON.stringify({ + type: MessageType.MEDIA, + data: { + roomId, + enabled: newVideoState, + }, + }), + }); } setIsVideoOn(newVideoState); @@ -733,13 +703,15 @@ const VideoTest = () => { setStatusMessage('통화 종료 중...'); if (token && wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { - sendWebSocketMessage( - MessageType.EXIT, - { - roomId, - }, - token, - ); + client?.publish({ + destination: '/exit', + body: JSON.stringify({ + type: MessageType.EXIT, + data: { + roomId, + }, + }), + }); } if (pcRef.current) { From ee80e4332165f183cbb21df80d5e1a74b7723c09 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Wed, 5 Mar 2025 22:12:07 +0900 Subject: [PATCH 26/40] =?UTF-8?q?[FE]=20feat:=20createAnswer=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=A0=9C=EA=B1=B0=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/pages/ChannelPage/test.tsx | 81 ++++----------------- 1 file changed, 16 insertions(+), 65 deletions(-) diff --git a/src/frontend/src/pages/ChannelPage/test.tsx b/src/frontend/src/pages/ChannelPage/test.tsx index 3b5e056c..a82c332d 100644 --- a/src/frontend/src/pages/ChannelPage/test.tsx +++ b/src/frontend/src/pages/ChannelPage/test.tsx @@ -28,14 +28,13 @@ const VideoTest = () => { const screenShareRef = useRef(null); const pcRef = useRef(null); - const wsRef = useRef(null); const localStreamRef = useRef(null); const screenStreamRef = useRef(null); const screenTrackRef = useRef(null); const [roomId, setRoomId] = useState(''); const [joined, setJoined] = useState(false); - const [answers, setAnswers] = useState([]); + const [answers, setAnswers] = useState(); const [statusMessage, setStatusMessage] = useState(''); const { isSharingScreen, isVideoOn, isMicOn, setIsInVoiceChannel, setIsSharingScreen, setIsVideoOn, setIsMicOn } = @@ -73,8 +72,8 @@ const VideoTest = () => { const answerSubscription = client.subscribe(`/topic/answer/${roomId}`, (message) => { try { const parsedAnswer: AnswerMessage = JSON.parse(message.body); - setAnswers((prev) => [...prev, parsedAnswer]); - console.log(parsedAnswer); + setAnswers(parsedAnswer); + console.log('answer', parsedAnswer); } catch (error) { console.error('[stomp] answer 파싱 오류:', error); } @@ -107,10 +106,10 @@ const VideoTest = () => { console.log('수신된 메시지', message); if (message.type === 'response' && message.users && message.users.length > 0) { - const user = message.users[0]; + const user = message.users.filter((user: any) => user.is_me === false); console.log('메시지정보', message); - console.log('사용자정보', message.users); + console.log('사용자정보', message.user); if (user.sdpOffer) { // 사용자가 sdpOffer를 보냈는지 확인 @@ -119,54 +118,6 @@ const VideoTest = () => { if (!pcRef.current) { await createPeerConnection(); } - - if (pcRef.current) { - try { - await pcRef.current.setRemoteDescription( - new RTCSessionDescription({ - type: 'offer', - sdp: user.sdpOffer, - }), - ); - - // 이거 type이 response에서 하는게 맞나? - if (pendingCandidates.current.length > 0) { - console.log(`${pendingCandidates.current.length}개의 대기 중인 후보 처리 중`); - - for (const candidate of pendingCandidates.current) { - try { - await pcRef.current.addIceCandidate(new RTCIceCandidate(candidate)); - console.log('대기 중이던 ICE candidate 추가됨'); - } catch (err) { - console.error('대기 중이던 ICE candidate 추가 중 오류:', err); - } - } - pendingCandidates.current = []; - } - - // 응답 보내기 -> answer 아닌 것 같아서 확인은 해야함 - const answer = await pcRef.current.createAnswer(); - await pcRef.current.setLocalDescription(answer); - - if (client && token) { - client.publish({ - destination: '/answer', - body: JSON.stringify({ - type: MessageType.ANSWER, - data: { - roomId, - sdp_answer: answer.sdp, - }, - }), - }); - } - - setStatusMessage('응답을 보냈습니다. 연결 중...'); - } catch (err) { - console.error('Offer 처리 중 오류:', err); - setStatusMessage(`Offer 처리 오류: ${err instanceof Error ? err.message : String(err)}`); - } - } } // 사용자가 sdpAnswer를 보냈는지 확인 @@ -178,7 +129,7 @@ const VideoTest = () => { await pcRef.current.setRemoteDescription( new RTCSessionDescription({ type: 'answer', - sdp: user.sdpAnswer, + sdp: answers?.message, }), ); } @@ -287,7 +238,7 @@ const VideoTest = () => { body: JSON.stringify({ type: MessageType.OFFER, data: { - roomId, + room_id: roomId, sdp_offer: pcRef.current.localDescription.sdp, }, }), @@ -311,15 +262,15 @@ const VideoTest = () => { // onicecandidate 이벤트: 수집된 ICE 후보를 시그널링 서버로 전송 pcRef.current.onicecandidate = (event) => { if (event.candidate && token && client) { - console.log('[pc] 생성된 ICE Candidate:', event.candidate); + console.log('[pc] 생성된 ICE Candidate:', event.candidate.candidate); client.publish({ destination: '/candidate', body: JSON.stringify({ type: MessageType.CANDIDATE, data: { - roomId, - candidate: event.candidate, + room_id: roomId, + candidate: event.candidate.candidate, }, }), }); @@ -517,7 +468,7 @@ const VideoTest = () => { body: JSON.stringify({ type: MessageType.OFFER, data: { - roomId, + room_id: roomId, sdp_offer: pcRef.current.localDescription.sdp, }, }), @@ -658,7 +609,7 @@ const VideoTest = () => { body: JSON.stringify({ type: MessageType.AUDIO, data: { - roomId, + room_id: roomId, enabled: newAudioState, }, }), @@ -686,7 +637,7 @@ const VideoTest = () => { body: JSON.stringify({ type: MessageType.MEDIA, data: { - roomId, + room_id: roomId, enabled: newVideoState, }, }), @@ -702,13 +653,13 @@ const VideoTest = () => { const hangUp = () => { setStatusMessage('통화 종료 중...'); - if (token && wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { - client?.publish({ + if (token && client) { + client.publish({ destination: '/exit', body: JSON.stringify({ type: MessageType.EXIT, data: { - roomId, + room_id: roomId, }, }), }); From f6eec5f6b2c4cc3c0b70db4f9265f0066dba9a8c Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Thu, 6 Mar 2025 19:26:52 +0900 Subject: [PATCH 27/40] =?UTF-8?q?[FE]=20feat;=20useStompWebRTC=20hook=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/ChannelPage/hooks/useStompWebRTC.ts | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 src/frontend/src/pages/ChannelPage/hooks/useStompWebRTC.ts diff --git a/src/frontend/src/pages/ChannelPage/hooks/useStompWebRTC.ts b/src/frontend/src/pages/ChannelPage/hooks/useStompWebRTC.ts new file mode 100644 index 00000000..bf0c66b5 --- /dev/null +++ b/src/frontend/src/pages/ChannelPage/hooks/useStompWebRTC.ts @@ -0,0 +1,69 @@ +import { Client, Frame } from '@stomp/stompjs'; +import { useEffect, useRef, useState } from 'react'; + +interface UseStompWebRTCProps { + roomId: string; +} + +const useStompWebRTC = ({ roomId }: UseStompWebRTCProps) => { + const [isConnected, setIsConnected] = useState(false); + const clientRef = useRef(null); + + const SERVER_URL = import.meta.env.VITE_SIGNALING; + + useEffect(() => { + const token = localStorage.getItem('access_token'); + let socketToken: string; + if (token) socketToken = token; + + const client = new Client({ + webSocketFactory: () => new WebSocket(SERVER_URL, ['v10.stomp', socketToken]), + connectHeaders: { Authorization: `Bearer ${token}` }, + // debug: (msg) => console.log('STOMP DEBUG:', msg), + reconnectDelay: 5000, + heartbeatIncoming: 10000, + heartbeatOutgoing: 10000, + + onConnect: (frame: Frame) => { + console.log('✅ STOMP 연결 성공!', frame); + + // 연결 성공 시 subscribe + client.subscribe(`/topic/users/${roomId}`, (message) => { + console.log('📩 users 받은 메시지:', message); + }); + + client.subscribe(`/topic/answer/${roomId}`, (message) => { + console.log('📩 answer 받은 메시지:', message); + }); + + client.subscribe(`/topic/candidate/${roomId}`, (message) => { + console.log('📩 candidate 받은 메시지:', message); + }); + + setIsConnected(true); + }, + + onWebSocketError: (error: Error) => { + console.log('WebSocket 에러', error); + }, + + onStompError: (frame) => { + console.error('❌ STOMP 오류 발생!', frame); + }, + }); + + client.activate(); + clientRef.current = client; + + return () => { + client.deactivate(); + clientRef.current = null; + setIsConnected(false); + console.log('✅ WebSocket 연결 해제됨'); + }; + }, [roomId]); + + return { client: clientRef.current, isConnected }; +}; + +export default useStompWebRTC; From 69453b64bd9b568e1ab81576e71721a19fa063d5 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Thu, 6 Mar 2025 19:28:05 +0900 Subject: [PATCH 28/40] =?UTF-8?q?[FE]=20feat:=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20offer=20=EC=9A=94=EC=B2=AD=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/pages/ChannelPage/test.tsx | 36 +-------------------- 1 file changed, 1 insertion(+), 35 deletions(-) diff --git a/src/frontend/src/pages/ChannelPage/test.tsx b/src/frontend/src/pages/ChannelPage/test.tsx index a82c332d..c6e4d3dc 100644 --- a/src/frontend/src/pages/ChannelPage/test.tsx +++ b/src/frontend/src/pages/ChannelPage/test.tsx @@ -217,39 +217,6 @@ const VideoTest = () => { // ICE 연결 실패 시 처리 if (state === 'failed' || state === 'disconnected') { console.log('[pc] ICE 연결 문제 발생, 재연결 시도...'); - - // 연결이 끊어진 경우 재연결 시도 - if (pcRef.current) { - // ICE 재시작 오퍼 생성 - pcRef.current - .createOffer({ - iceRestart: true, - offerToReceiveAudio: true, - offerToReceiveVideo: true, - }) - .then((offer) => { - return pcRef.current?.setLocalDescription(offer); - }) - .then(() => { - // 새 오퍼를 서버로 전송 - if (token && pcRef.current?.localDescription?.sdp) { - client?.publish({ - destination: '/offer', - body: JSON.stringify({ - type: MessageType.OFFER, - data: { - room_id: roomId, - sdp_offer: pcRef.current.localDescription.sdp, - }, - }), - }); - console.log('[pc] ICE 재시작 오퍼 전송됨'); - } - }) - .catch((err) => { - console.error('[pc] ICE 재시작 실패:', err); - }); - } } // ICE 연결 성공 시 @@ -441,7 +408,7 @@ const VideoTest = () => { } } - if (roomId) { + if (roomId && pcRef.current) { const response = await tokenAxios.post(`https://api.jungeunjipi.com/room/${roomId}/join`, { audio_enabled: isMicOn, media_enabled: isVideoOn, @@ -460,7 +427,6 @@ const VideoTest = () => { await pcRef.current!.setLocalDescription(offer); - // SDP 제안을 보내기 전에 잠시 대기하여 ICE 후보 수집이 일부 완료되도록 함 setTimeout(() => { if (token && pcRef.current?.localDescription) { client?.publish({ From 97c23cba58b8c8624d0c0b025ac8d87251849138 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Thu, 6 Mar 2025 20:55:06 +0900 Subject: [PATCH 29/40] =?UTF-8?q?[FE]=20refactor:=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EA=B5=AC=EB=8F=85=20=EB=B0=A9=EC=A7=80=20=EB=B0=8F=20token=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/ChannelPage/hooks/useStompWebRTC.ts | 56 ++++++++++++------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/src/frontend/src/pages/ChannelPage/hooks/useStompWebRTC.ts b/src/frontend/src/pages/ChannelPage/hooks/useStompWebRTC.ts index bf0c66b5..d8759092 100644 --- a/src/frontend/src/pages/ChannelPage/hooks/useStompWebRTC.ts +++ b/src/frontend/src/pages/ChannelPage/hooks/useStompWebRTC.ts @@ -8,39 +8,57 @@ interface UseStompWebRTCProps { const useStompWebRTC = ({ roomId }: UseStompWebRTCProps) => { const [isConnected, setIsConnected] = useState(false); const clientRef = useRef(null); + const connectionAttempts = useRef(0); const SERVER_URL = import.meta.env.VITE_SIGNALING; useEffect(() => { + if (!roomId) return; + const token = localStorage.getItem('access_token'); - let socketToken: string; - if (token) socketToken = token; + if (!token) return; + + if (clientRef.current) { + console.log('이전 STOMP 연결 정리 중...'); + clientRef.current.deactivate(); + clientRef.current = null; + } + + connectionAttempts.current = 0; const client = new Client({ - webSocketFactory: () => new WebSocket(SERVER_URL, ['v10.stomp', socketToken]), + webSocketFactory: () => new WebSocket(SERVER_URL, ['v10.stomp', token]), connectHeaders: { Authorization: `Bearer ${token}` }, - // debug: (msg) => console.log('STOMP DEBUG:', msg), + debug: (msg) => console.log('STOMP DEBUG:', msg), reconnectDelay: 5000, - heartbeatIncoming: 10000, - heartbeatOutgoing: 10000, + heartbeatIncoming: 4000, + heartbeatOutgoing: 4000, onConnect: (frame: Frame) => { console.log('✅ STOMP 연결 성공!', frame); + connectionAttempts.current = 0; + setIsConnected(true); - // 연결 성공 시 subscribe - client.subscribe(`/topic/users/${roomId}`, (message) => { - console.log('📩 users 받은 메시지:', message); - }); - - client.subscribe(`/topic/answer/${roomId}`, (message) => { - console.log('📩 answer 받은 메시지:', message); - }); - - client.subscribe(`/topic/candidate/${roomId}`, (message) => { - console.log('📩 candidate 받은 메시지:', message); - }); + const subscriptions = []; - setIsConnected(true); + // 연결 성공 시 subscribe + subscriptions.push( + client.subscribe(`/topic/users/${roomId}`, (message) => { + console.log('📩 users 받은 메시지:', message); + }), + ); + + subscriptions.push( + client.subscribe(`/topic/answer/${roomId}`, (message) => { + console.log('📩 answer 받은 메시지:', message); + }), + ); + + subscriptions.push( + client.subscribe(`/topic/candidate/${roomId}`, (message) => { + console.log('📩 candidate 받은 메시지:', message); + }), + ); }, onWebSocketError: (error: Error) => { From 6754f1f29826486a45f29105f4ee8ecfd5786bc6 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Thu, 6 Mar 2025 20:56:20 +0900 Subject: [PATCH 30/40] =?UTF-8?q?[FE]=20feat:=20candidate=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=EC=8B=9C=20=EC=A1=B0=EA=B1=B4=20=EB=B0=8F=20=EB=8C=80?= =?UTF-8?q?=EA=B8=B0=20=EC=A4=91=EC=9D=B8=20=ED=9B=84=EB=B3=B4=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=20=EC=8B=9C=EB=8F=84=20=EC=B6=94=EA=B0=80=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/pages/ChannelPage/test.tsx | 73 ++++++++++++++++++--- 1 file changed, 63 insertions(+), 10 deletions(-) diff --git a/src/frontend/src/pages/ChannelPage/test.tsx b/src/frontend/src/pages/ChannelPage/test.tsx index c6e4d3dc..a6e736b1 100644 --- a/src/frontend/src/pages/ChannelPage/test.tsx +++ b/src/frontend/src/pages/ChannelPage/test.tsx @@ -124,14 +124,18 @@ const VideoTest = () => { if (user.sdpAnswer) { setStatusMessage('응답을 받았습니다. 연결 중...'); + console.log(answers?.message); + try { if (pcRef.current) { + console.log('원격 설명 설정 시도'); await pcRef.current.setRemoteDescription( new RTCSessionDescription({ type: 'answer', sdp: answers?.message, }), ); + console.log('원격 설명 설정 완료'); } if (pendingCandidates.current.length > 0) { @@ -200,6 +204,10 @@ const VideoTest = () => { console.log('[pc] PeerConnection 구성됨:', pcRef.current); + pcRef.current.onsignalingstatechange = () => { + console.log('[pc] Signaling 상태 변경:', pcRef.current?.signalingState); + }; + // ICE 후보 수집 상태 모니터링 pcRef.current.onicegatheringstatechange = () => { console.log('[pc] ICE 수집 상태:', pcRef.current?.iceGatheringState); @@ -231,16 +239,27 @@ const VideoTest = () => { if (event.candidate && token && client) { console.log('[pc] 생성된 ICE Candidate:', event.candidate.candidate); - client.publish({ - destination: '/candidate', - body: JSON.stringify({ - type: MessageType.CANDIDATE, - data: { - room_id: roomId, - candidate: event.candidate.candidate, - }, - }), - }); + pendingCandidates.current.push(event.candidate); + + if (client && client.connected) { + try { + client.publish({ + destination: '/candidate', + body: JSON.stringify({ + type: MessageType.CANDIDATE, + data: { + room_id: roomId, + candidate: event.candidate.candidate, + }, + }), + }); + console.log('ICE candidate 전송 성공'); + } catch (err) { + console.error('ICE candidate 전송 오류:', err); + } + } else { + console.log('STOMP 연결이 없어 candidate를 큐에 저장합니다'); + } } else { console.log('[pc] ICE Candidate 수집 완료'); } @@ -397,6 +416,11 @@ const VideoTest = () => { }; const joinRoom = async () => { + if (!roomId.trim()) { + setStatusMessage('방 ID를 입력하세요'); + return; + } + setStatusMessage('방 참여 중...'); if (token && isConnected && client) { @@ -426,6 +450,7 @@ const VideoTest = () => { }); await pcRef.current!.setLocalDescription(offer); + console.log('로컬 설명 설정됨:', pcRef.current!.localDescription); setTimeout(() => { if (token && pcRef.current?.localDescription) { @@ -664,6 +689,34 @@ const VideoTest = () => { } }, [isSharingScreen]); + // STOMP 연결 상태 변경 감지 + useEffect(() => { + if (isConnected && client && pendingCandidates.current.length > 0 && roomId) { + console.log(`연결 복구 - ${pendingCandidates.current.length}개의 대기 중인 ICE candidate 전송 시도`); + + // 대기 중인 모든 후보 전송 시도 + for (const candidate of pendingCandidates.current) { + try { + client.publish({ + destination: '/candidate', + body: JSON.stringify({ + type: MessageType.CANDIDATE, + data: { + room_id: roomId, + candidate: candidate.candidate, + }, + }), + }); + console.log('대기 중인 ICE candidate 전송 성공'); + } catch (err) { + console.error('대기 중인 ICE candidate 전송 오류:', err); + } + } + + pendingCandidates.current = []; + } + }, [isConnected, client, roomId]); + return (

테스트 페이지

From 58439f3ca5c025e4cdcaa20118eb8b285f95cca2 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Sat, 8 Mar 2025 17:44:37 +0900 Subject: [PATCH 31/40] =?UTF-8?q?[FE]=20feat:=20userId=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=A5=BC=20=EC=9C=84=ED=95=9C=20api=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EA=B5=AC=ED=98=84=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/api/users.ts | 12 ++++++++++++ src/frontend/src/constants/endPoint.ts | 1 + 2 files changed, 13 insertions(+) diff --git a/src/frontend/src/api/users.ts b/src/frontend/src/api/users.ts index 7428f6dd..f91aab6c 100644 --- a/src/frontend/src/api/users.ts +++ b/src/frontend/src/api/users.ts @@ -37,6 +37,18 @@ export const postAuthCode = async (requestBody: PostAuthCodeRequest) => { return data; }; +interface GetUserIdResponse { + httpStatus: number; + message: string; + time: string; + result: string; +} + +export const getUserId = async () => { + const { data } = await tokenAxios.get(endPoint.users.GET_USER_ID); + return data.result; +}; + interface PostEmailDuplicateParams { email: string; } diff --git a/src/frontend/src/constants/endPoint.ts b/src/frontend/src/constants/endPoint.ts index aa98c4a0..0cff692c 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/id', }, friends: { From 50df3fec6eddc22e298da24a9030b0ec2e89c491 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Sat, 8 Mar 2025 18:05:37 +0900 Subject: [PATCH 32/40] =?UTF-8?q?[FE]=20refactor:=20userId=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/constants/endPoint.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/src/constants/endPoint.ts b/src/frontend/src/constants/endPoint.ts index 0cff692c..3afb1809 100644 --- a/src/frontend/src/constants/endPoint.ts +++ b/src/frontend/src/constants/endPoint.ts @@ -11,7 +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/id', + GET_USER_ID: '/users/user/id', }, friends: { From 55eb1e38cef1fb4a8fb52f27a773c69526ee5c65 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Sat, 8 Mar 2025 18:34:36 +0900 Subject: [PATCH 33/40] =?UTF-8?q?[FE]=20feat:=20N:M=20WebRTC=20=EC=9E=84?= =?UTF-8?q?=EC=8B=9C=20=EA=B5=AC=ED=98=84=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/VideoCard/index.tsx | 7 + .../components/VideoCard/styles.ts | 0 .../pages/ChannelPage/hooks/useStompWebRTC.ts | 153 +++--- src/frontend/src/pages/ChannelPage/index.tsx | 514 ++++++++++++++++++ src/frontend/src/pages/ChannelPage/test.tsx | 350 +++++------- src/frontend/src/pages/VideoPage/index.tsx | 8 +- src/frontend/src/router.tsx | 6 + 7 files changed, 755 insertions(+), 283 deletions(-) create mode 100644 src/frontend/src/pages/ChannelPage/components/VideoCard/index.tsx create mode 100644 src/frontend/src/pages/ChannelPage/components/VideoCard/styles.ts create mode 100644 src/frontend/src/pages/ChannelPage/index.tsx 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..b8a7a2f8 --- /dev/null +++ b/src/frontend/src/pages/ChannelPage/components/VideoCard/index.tsx @@ -0,0 +1,7 @@ +import * as S from './styles'; + +const VideoCard = () => { + return
; +}; + +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..e69de29b diff --git a/src/frontend/src/pages/ChannelPage/hooks/useStompWebRTC.ts b/src/frontend/src/pages/ChannelPage/hooks/useStompWebRTC.ts index d8759092..01a64294 100644 --- a/src/frontend/src/pages/ChannelPage/hooks/useStompWebRTC.ts +++ b/src/frontend/src/pages/ChannelPage/hooks/useStompWebRTC.ts @@ -1,87 +1,92 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { Client, Frame } from '@stomp/stompjs'; import { useEffect, useRef, useState } from 'react'; +import { getUserId } from '@/api/users'; + interface UseStompWebRTCProps { roomId: string; + handleUsers: (users: any) => void; + handleAnswer: (answer: any) => void; + handleIceCandidate: (candidate: any) => void; + handlePublish: (publisherId: any) => void; } -const useStompWebRTC = ({ roomId }: UseStompWebRTCProps) => { +const useStompWebRTC = ({ + roomId, + handleUsers, + handleAnswer, + handleIceCandidate, + handlePublish, +}: UseStompWebRTCProps) => { const [isConnected, setIsConnected] = useState(false); - const clientRef = useRef(null); - const connectionAttempts = useRef(0); + const stompClient = useRef(null); const SERVER_URL = import.meta.env.VITE_SIGNALING; - - useEffect(() => { - if (!roomId) return; - - const token = localStorage.getItem('access_token'); - if (!token) return; - - if (clientRef.current) { - console.log('이전 STOMP 연결 정리 중...'); - clientRef.current.deactivate(); - clientRef.current = null; - } - - connectionAttempts.current = 0; - - const client = new Client({ - webSocketFactory: () => new WebSocket(SERVER_URL, ['v10.stomp', token]), - connectHeaders: { Authorization: `Bearer ${token}` }, - debug: (msg) => console.log('STOMP DEBUG:', msg), - reconnectDelay: 5000, - heartbeatIncoming: 4000, - heartbeatOutgoing: 4000, - - onConnect: (frame: Frame) => { - console.log('✅ STOMP 연결 성공!', frame); - connectionAttempts.current = 0; - setIsConnected(true); - - const subscriptions = []; - - // 연결 성공 시 subscribe - subscriptions.push( - client.subscribe(`/topic/users/${roomId}`, (message) => { - console.log('📩 users 받은 메시지:', message); - }), - ); - - subscriptions.push( - client.subscribe(`/topic/answer/${roomId}`, (message) => { - console.log('📩 answer 받은 메시지:', message); - }), - ); - - subscriptions.push( - client.subscribe(`/topic/candidate/${roomId}`, (message) => { - console.log('📩 candidate 받은 메시지:', message); - }), - ); - }, - - onWebSocketError: (error: Error) => { - console.log('WebSocket 에러', error); - }, - - onStompError: (frame) => { - console.error('❌ STOMP 오류 발생!', frame); - }, - }); - - client.activate(); - clientRef.current = client; - - return () => { - client.deactivate(); - clientRef.current = null; - setIsConnected(false); - console.log('✅ WebSocket 연결 해제됨'); - }; - }, [roomId]); - - return { client: clientRef.current, isConnected }; + const token = localStorage.getItem('access_token'); + + if (!token) return; + const userId = getUserId(); + + if (!roomId) return; + + const client = new Client({ + webSocketFactory: () => new WebSocket(SERVER_URL, ['v10.stomp', token]), + connectHeaders: { Authorization: `Bearer ${token}` }, + reconnectDelay: 5000, + heartbeatIncoming: 10000, + heartbeatOutgoing: 10000, + + onConnect: (frame: Frame) => { + console.log('✅ STOMP 연결 성공!', frame); + setIsConnected(true); + + // 연결 성공 시 subscribe + client.subscribe(`/topic/users/${roomId}`, (message) => { + const users = JSON.parse(message.body); + console.log('📩 users 받은 메시지:', message); + handleUsers(users); + }); + + client.subscribe(`/topic/answer/${roomId}/${userId}`, (message) => { + const answer = JSON.parse(message.body); + console.log('📩 answer 받은 메시지:', message); + handleAnswer(answer.message); + }); + + client.subscribe(`/topic/candidate/${roomId}/${userId}`, (message) => { + const candidate = JSON.parse(message.body); + console.log('📩 candidate 받은 메시지:', message); + handleIceCandidate(candidate.candidate); + }); + + client.subscribe(`/topic/publisher/${roomId}`, (message) => { + const publisher_id = JSON.parse(message.body); + console.log('📩 publisher 받은 메시지:', message); + handlePublish(publisher_id); + }); + }, + + onWebSocketError: (error: Error) => { + console.log('WebSocket 에러', error); + }, + + onStompError: (frame) => { + console.error('❌ STOMP 오류 발생!', frame); + }, + }); + + client.activate(); + stompClient.current = client; + + return { + // client.deactivate(); + // clientRef.current = null; + // setIsConnected(false); + // console.log('✅ WebSocket 연결 해제됨'); + client: stompClient.current, + isConnected, + }; }; export default useStompWebRTC; diff --git a/src/frontend/src/pages/ChannelPage/index.tsx b/src/frontend/src/pages/ChannelPage/index.tsx new file mode 100644 index 00000000..5b388ca7 --- /dev/null +++ b/src/frontend/src/pages/ChannelPage/index.tsx @@ -0,0 +1,514 @@ +import { Client } from '@stomp/stompjs'; +import React, { useEffect, useRef, useState } from 'react'; + +import { getUserId } from '@/api/users'; +import { useChannelActionStore } from '@/stores/channelAction'; +import { tokenAxios } from '@/utils/axios'; + +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 [connected, setConnected] = useState(false); + const [roomId, setRoomId] = useState(''); + const [offerSent, setOfferSent] = useState(false); + const localVideoRef = useRef(null); + const remoteVideoRef = useRef(null); + const peerConnection = useRef(null); + const [userId, setUserId] = useState(''); + + // 구독된 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 { + isInVoiceChannel, + isSharingScreen, + isVideoOn, + isMicOn, + setIsInVoiceChannel, + setIsSharingScreen, + setIsVideoOn, + setIsMicOn, + } = useChannelActionStore(); + + const token = localStorage.getItem('access_token'); + + useEffect(() => { + const fetchUserId = async () => { + const token = localStorage.getItem('access_token'); + if (!token) { + return; + } + + try { + const id = await getUserId(); + setUserId(id); + console.log('사용자 ID 가져오기 성공:', id); + } catch (error) { + console.error('사용자 ID 가져오기 실패:', error); + } + }; + + fetchUserId(); + }, []); + + // ✅ 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})`); + setConnected(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; + } + }); + }); + + console.log(`✅ 구독 성공 하였습니다.`); + }, + onDisconnect: () => { + alert('🔌 STOMP WebSocket 연결 해제됨'); + console.log('🔌 STOMP WebSocket 연결 해제됨'); + setConnected(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 || !connected) { + 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 sendGetherIceCandidate = async () => { + if (!stompClient.current) { + alert('gether STOMP WebSocket이 연결되지 않았습니다.'); + return; + } + + try { + // 🔥 STOMP를 사용해 WebRTC Offer 전송 + stompClient.current.publish({ + destination: '/gather/candidate', + body: JSON.stringify({ + data: { + // ✅ data 내부에 room_id 포함 + room_id: roomId, + }, + }), + }); + + sendIceCandidates(); // 🔥 SDP Answer 수신 후 ICE Candidate 전송 + } catch (error) { + console.error('Gether 요청 실패:', 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 { + sendGetherIceCandidate(); + // sendIceCandidates(); + } + + // sendGetherIceCandidate(); + }; + + // 예시: 특정 사용자(userId)의 remote stream을 업데이트 + const handleRemoteStream = (userId: string, stream: MediaStream) => { + setRemoteStreams((prev) => ({ + ...prev, + [userId]: stream, + })); + }; + + // ✅ 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); + } + } + }; + + // ✅ 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); + }; + }; + + // ✅ STOMP 연결 해제 함수 + const disconnectStomp = () => { + if (stompClient.current) { + stompClient.current.deactivate(); + stompClient.current = null; + setConnected(false); + console.log('🔌 STOMP WebSocket 연결 해제 시도'); + } + }; + + 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 leaveRoom = async (roomId: string) => { + if (!roomId) { + alert('방 ID를 입력해주세요!'); + return; + } + + try { + const response = await tokenAxios.delete(`https://api.jungeunjipi.com/room/${roomId}/leave`); + console.log('방 나가기 성공: ', response); + // ✅ 상태 초기화 + setIsInVoiceChannel(false); + setConnected(false); + setRoomId(''); + + disconnectStomp(); + } catch (error) { + console.error('🚨 방 나가기 오류:', error); + } + }; + + // ✅ SDP Answer 처리 + 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

+ + setRoomId(e.target.value)} + disabled={connected} + style={{ marginRight: '10px', padding: '5px' }} + /> + + + + + + + + + + + +
+

📹 내 화면

+
+ +
+

🔗 원격 사용자 화면

+ {Object.entries(remoteStreams).map(([userId, stream]) => ( +
+

{userId}

+
+ ))} +
+
+ ); +}; + +export default WebRTC; diff --git a/src/frontend/src/pages/ChannelPage/test.tsx b/src/frontend/src/pages/ChannelPage/test.tsx index a6e736b1..acb717af 100644 --- a/src/frontend/src/pages/ChannelPage/test.tsx +++ b/src/frontend/src/pages/ChannelPage/test.tsx @@ -37,67 +37,108 @@ const VideoTest = () => { const [answers, setAnswers] = useState(); const [statusMessage, setStatusMessage] = useState(''); - const { isSharingScreen, isVideoOn, isMicOn, setIsInVoiceChannel, setIsSharingScreen, setIsVideoOn, setIsMicOn } = - useChannelActionStore(); - - const { client, isConnected } = useStompWebRTC({ roomId }); + const { + isInVoiceChannel, + isSharingScreen, + isVideoOn, + isMicOn, + setIsInVoiceChannel, + setIsSharingScreen, + setIsVideoOn, + setIsMicOn, + } = useChannelActionStore(); + + const { client, isConnected } = useStompWebRTC({ + roomId, + handleUsers, + handleAnswer, + handleIceCandidate, + handlePublish, + }); const token = localStorage.getItem('access_token'); - useEffect(() => { - if (!client || !isConnected) return; + const handleAnswer = async (sdpAnswer: string) => { + if (!pcRef.current) return; - setStatusMessage('STOMP 서버에 연결됨'); + try { + await pcRef.current.setRemoteDescription( + new RTCSessionDescription({ + type: 'answer', + sdp: sdpAnswer, + }), + ); + } catch (error) { + console.error('answer 요청 실패', error); + } finally { + sendGetherIceCandidate(); + } + }; - const userSubscription = client.subscribe(`/topic/users/${roomId}`, (message) => { - try { - const response = JSON.parse(message.body); - handleStompMessage(response); - } catch (error) { - console.error('[stomp] 메시지 파싱 오류:', error); - } - }); + const sendGetherIceCandidate = async () => { + if (!client) { + alert('gather STOMP WebSocket이 연결되지 않았습니다.'); + return; + } + + try { + client.publish({ + destination: '/gather/candidate', + body: JSON.stringify({ + data: { + room_id: roomId, + }, + }), + }); + + sendIceCandidates(); // SDP Answer 수신 후 ICE Candidate 전송 + } catch (error) { + console.error('gather 요청 실패:', error); + } + }; + + const sendIceCandidates = () => { + if (!pcRef.current || !client) return; - const candidateSubscription = client.subscribe(`/topic/candidate/${roomId}`, (message) => { - try { - const candidateData = JSON.parse(message.body); - if (candidateData.candidate) { - handleIceCandidate(candidateData.candidate); + console.log('접근 완료'); + pcRef.current.onicecandidate = (event) => { + if (event.candidate) { + if (event.candidate.candidate.includes('typ host')) { + return; // host 후보는 버립니다 } - } catch (error) { - console.error('[stomp] candidate 파싱 오류:', error); - } - }); - - const answerSubscription = client.subscribe(`/topic/answer/${roomId}`, (message) => { - try { - const parsedAnswer: AnswerMessage = JSON.parse(message.body); - setAnswers(parsedAnswer); - console.log('answer', parsedAnswer); - } catch (error) { - console.error('[stomp] answer 파싱 오류:', error); - } - }); - return () => { - userSubscription.unsubscribe(); - candidateSubscription.unsubscribe(); - answerSubscription.unsubscribe(); + console.log('전송 ice candidate: ', event.candidate); - // 미디어 스트림, PeerConnection, WebSocket 종료 - if (localStreamRef.current) { - localStreamRef.current.getTracks().forEach((track) => track.stop()); + client.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); } + }; - if (screenStreamRef.current) { - screenStreamRef.current.getTracks().forEach((track) => track.stop()); - } + pcRef.current.onicegatheringstatechange = () => { + console.log('[pc] ICE 수집 상태:', pcRef.current?.iceGatheringState); - if (pcRef.current) { - pcRef.current.close(); + if (pcRef.current?.iceGatheringState === 'complete') { + console.log('[pc] ICE 후보 수집 완료'); } }; - }, [client, isConnected, roomId]); + + pcRef.current.oniceconnectionstatechange = () => { + const state = pcRef.current?.iceConnectionState; + console.log('[pc] ICE 연결 상태 변경:', state); + }; + }; const pendingCandidates = useRef([]); @@ -196,44 +237,20 @@ const VideoTest = () => { } }; + const disconnectStomp = () => { + if (client) { + client.deactivate(); + setIsInVoiceChannel(false); + console.log('🔌 STOMP WebSocket 연결 해제 시도'); + } + }; + const createPeerConnection = useCallback(async () => { try { pcRef.current = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], }); - console.log('[pc] PeerConnection 구성됨:', pcRef.current); - - pcRef.current.onsignalingstatechange = () => { - console.log('[pc] Signaling 상태 변경:', pcRef.current?.signalingState); - }; - - // ICE 후보 수집 상태 모니터링 - pcRef.current.onicegatheringstatechange = () => { - console.log('[pc] ICE 수집 상태:', pcRef.current?.iceGatheringState); - - // 수집 완료 시 로그 - if (pcRef.current?.iceGatheringState === 'complete') { - console.log('[pc] ICE 후보 수집 완료'); - } - }; - - pcRef.current.oniceconnectionstatechange = () => { - const state = pcRef.current?.iceConnectionState; - console.log('[pc] ICE 연결 상태 변경:', state); - - // ICE 연결 실패 시 처리 - if (state === 'failed' || state === 'disconnected') { - console.log('[pc] ICE 연결 문제 발생, 재연결 시도...'); - } - - // ICE 연결 성공 시 - if (state === 'connected' || state === 'completed') { - console.log('[pc] ICE 연결 성공!'); - setStatusMessage('ICE 연결 성공! 화상 통화 진행 중...'); - } - }; - // onicecandidate 이벤트: 수집된 ICE 후보를 시그널링 서버로 전송 pcRef.current.onicecandidate = (event) => { if (event.candidate && token && client) { @@ -421,66 +438,34 @@ const VideoTest = () => { return; } - setStatusMessage('방 참여 중...'); + try { + const response = await tokenAxios.post(`https://api.jungeunjipi.com/room/${roomId}/join`, { + audio_enabled: isMicOn, + media_enabled: isVideoOn, + data_enabled: isSharingScreen, + }); - if (token && isConnected && client) { - if (!pcRef.current) { - const success = await createPeerConnection(); - if (!success) { - setStatusMessage('미디어 장치 접근 실패'); - return; - } + if (response) { + console.log(response); + // handleSdpAnswer(response.sdp_answer); + setIsInVoiceChannel(true); + } else { + console.error('참여 실패: ', response); } + } catch (error) { + console.error('API 요청 오류: ', error); + } + }; - if (roomId && pcRef.current) { - const response = await tokenAxios.post(`https://api.jungeunjipi.com/room/${roomId}/join`, { - audio_enabled: isMicOn, - media_enabled: isVideoOn, - data_enabled: isSharingScreen, - }); - - if (response) { - if (pcRef.current) { - // 타임아웃을 설정하여 ICE 후보 수집에 충분한 시간 부여 - setTimeout(async () => { - try { - const offer = await pcRef.current!.createOffer({ - offerToReceiveAudio: true, - offerToReceiveVideo: true, - }); - - await pcRef.current!.setLocalDescription(offer); - console.log('로컬 설명 설정됨:', pcRef.current!.localDescription); - - setTimeout(() => { - if (token && pcRef.current?.localDescription) { - client?.publish({ - destination: '/offer', - body: JSON.stringify({ - type: MessageType.OFFER, - data: { - room_id: roomId, - sdp_offer: pcRef.current.localDescription.sdp, - }, - }), - }); - } - - setJoined(true); - setStatusMessage(`방 ${roomId}에 참여함. 응답 대기 중...`); - }, 1000); - } catch (error) { - console.error('Offer 생성 중 오류:', error); - setStatusMessage(`Offer 생성 오류: ${error instanceof Error ? error.message : String(error)}`); - } - }, 500); - } - } else { - alert('방 참가 실패'); - } - } - } else { - setStatusMessage('소켓 연결이 없습니다. 페이지를 새로고침하세요.'); + const handleSdpAnswer = async (sdpAnswer: string) => { + if (pcRef.current) { + await pcRef.current.setRemoteDescription( + new RTCSessionDescription({ + type: 'answer', + sdp: sdpAnswer, + }), + ); + console.log('✅ SDP Answer 설정 완료'); } }; @@ -641,81 +626,38 @@ const VideoTest = () => { }; // 통화 종료 - const hangUp = () => { + const hangUp = async () => { setStatusMessage('통화 종료 중...'); - if (token && client) { - client.publish({ - destination: '/exit', - body: JSON.stringify({ - type: MessageType.EXIT, - data: { - room_id: roomId, - }, - }), - }); - } - - if (pcRef.current) { - pcRef.current.close(); - pcRef.current = null; - } - - // 로컬 비디오 스트림 정리 - if (localStreamRef.current) { - localStreamRef.current.getTracks().forEach((track) => track.stop()); - localStreamRef.current = null; - } - - // 비디오 요소 초기화 - if (localVideoRef.current?.srcObject) { - localVideoRef.current.srcObject = null; - } - if (remoteVideoRef.current?.srcObject) { - remoteVideoRef.current.srcObject = null; - } - - setJoined(false); - setIsInVoiceChannel(false); - - setStatusMessage('통화가 종료되었습니다.'); - }; - - useEffect(() => { - if (isSharingScreen && screenShareRef.current && screenStreamRef.current) { - screenShareRef.current.srcObject = screenStreamRef.current; - - screenShareRef.current.play().catch((err) => console.error('useEffect에서 비디오 재생 오류:', err)); - } - }, [isSharingScreen]); + try { + const response = await tokenAxios(`https://api.jungeunjipi.com/room/${roomId}/leave`); - // STOMP 연결 상태 변경 감지 - useEffect(() => { - if (isConnected && client && pendingCandidates.current.length > 0 && roomId) { - console.log(`연결 복구 - ${pendingCandidates.current.length}개의 대기 중인 ICE candidate 전송 시도`); + if (!response) { + console.error('방 나가기 실패: ', response); + return; + } + // 로컬 비디오 스트림 정리 + if (localStreamRef.current) { + localStreamRef.current.getTracks().forEach((track) => track.stop()); + localStreamRef.current = null; + } - // 대기 중인 모든 후보 전송 시도 - for (const candidate of pendingCandidates.current) { - try { - client.publish({ - destination: '/candidate', - body: JSON.stringify({ - type: MessageType.CANDIDATE, - data: { - room_id: roomId, - candidate: candidate.candidate, - }, - }), - }); - console.log('대기 중인 ICE candidate 전송 성공'); - } catch (err) { - console.error('대기 중인 ICE candidate 전송 오류:', err); - } + // 비디오 요소 초기화 + if (localVideoRef.current?.srcObject) { + localVideoRef.current.srcObject = null; + } + if (remoteVideoRef.current?.srcObject) { + remoteVideoRef.current.srcObject = null; } - pendingCandidates.current = []; + setJoined(false); + setIsInVoiceChannel(false); + + setStatusMessage('통화가 종료되었습니다.'); + } catch (error) { + console.error('API 요청 오류', error); } - }, [isConnected, client, roomId]); + }; return (
diff --git a/src/frontend/src/pages/VideoPage/index.tsx b/src/frontend/src/pages/VideoPage/index.tsx index d8ebdf48..b989b0ba 100644 --- a/src/frontend/src/pages/VideoPage/index.tsx +++ b/src/frontend/src/pages/VideoPage/index.tsx @@ -1,18 +1,16 @@ -import { useState } from 'react'; - +import { useChannelActionStore } from '@/stores/channelAction'; import { useChannelInfoStore } from '@/stores/channelInfo'; import { BodyRegularText, TitleText1 } from '@/styles/Typography'; import * as S from './styles'; const VideoPage = () => { - const [isAttend, setIsAttend] = useState(false); - + const { isInVoiceChannel } = useChannelActionStore(); const { selectedChannel } = useChannelInfoStore(); return ( - {isAttend ? ( + {isInVoiceChannel ? ( <>참여시 비디오들 ) : ( 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: , + }, ], }, ], From 39f4f8fc06352d26ce5fa0449765eee150525de8 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Sat, 8 Mar 2025 19:18:11 +0900 Subject: [PATCH 34/40] =?UTF-8?q?[FE]=20feat:=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=EC=8B=9C=20userId=EB=A5=BC=20=EC=A0=84=EC=97=AD=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EC=97=90=20=EC=A0=80=EC=9E=A5=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/pages/LoginPage/index.tsx | 3 +++ src/frontend/src/types/users.ts | 1 + 2 files changed, 4 insertions(+) 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/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; }; } From c6011e3f16107757d13284d49b12f325ff482068 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Sat, 8 Mar 2025 19:18:37 +0900 Subject: [PATCH 35/40] =?UTF-8?q?[FE]=20feat:=20WebRTC=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=8B=9C=20=ED=95=84=EC=9A=94=ED=95=9C=20userId?= =?UTF-8?q?=EB=A5=BC=20=EC=A0=84=EC=97=AD=20=EC=83=81=ED=83=9C=20=EA=B0=92?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=82=AC=EC=9A=A9=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/pages/ChannelPage/index.tsx | 23 ++++---------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/src/frontend/src/pages/ChannelPage/index.tsx b/src/frontend/src/pages/ChannelPage/index.tsx index 5b388ca7..e202d292 100644 --- a/src/frontend/src/pages/ChannelPage/index.tsx +++ b/src/frontend/src/pages/ChannelPage/index.tsx @@ -4,6 +4,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { getUserId } from '@/api/users'; import { useChannelActionStore } from '@/stores/channelAction'; import { tokenAxios } from '@/utils/axios'; +import { useUserInfoStore } from '@/stores/userInfo'; const SERVER_URL = import.meta.env.VITE_SIGNALING; @@ -25,7 +26,6 @@ const WebRTC = () => { const localVideoRef = useRef(null); const remoteVideoRef = useRef(null); const peerConnection = useRef(null); - const [userId, setUserId] = useState(''); // 구독된 publisher id들을 저장 const [subscribedPublishers, setSubscribedPublishers] = useState([]); @@ -52,24 +52,8 @@ const WebRTC = () => { const token = localStorage.getItem('access_token'); - useEffect(() => { - const fetchUserId = async () => { - const token = localStorage.getItem('access_token'); - if (!token) { - return; - } - - try { - const id = await getUserId(); - setUserId(id); - console.log('사용자 ID 가져오기 성공:', id); - } catch (error) { - console.error('사용자 ID 가져오기 실패:', error); - } - }; - - fetchUserId(); - }, []); + const { userInfo } = useUserInfoStore(); + const userId = userInfo?.userId || ''; // ✅ STOMP WebSocket 연결 함수 const connectStomp = async () => { @@ -304,6 +288,7 @@ const WebRTC = () => { console.log(user); await handlePublish(user.id); } + setFirstEnter(false); } }; From fae956e71ea39df82c6b903babc5721c65e7f736 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Sun, 9 Mar 2025 00:41:01 +0900 Subject: [PATCH 36/40] =?UTF-8?q?[FE]=20feat:=20=EB=82=98=EA=B0=84=20?= =?UTF-8?q?=EC=9C=A0=EC=A0=80=EC=97=90=20=EB=8C=80=ED=95=9C=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=EB=A5=BC=20subscribe=20=EB=B0=8F=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/ChannelPage/hooks/useStompWebRTC.ts | 92 --- src/frontend/src/pages/ChannelPage/index.tsx | 27 +- src/frontend/src/pages/ChannelPage/test.tsx | 739 ------------------ 3 files changed, 10 insertions(+), 848 deletions(-) delete mode 100644 src/frontend/src/pages/ChannelPage/hooks/useStompWebRTC.ts delete mode 100644 src/frontend/src/pages/ChannelPage/test.tsx diff --git a/src/frontend/src/pages/ChannelPage/hooks/useStompWebRTC.ts b/src/frontend/src/pages/ChannelPage/hooks/useStompWebRTC.ts deleted file mode 100644 index 01a64294..00000000 --- a/src/frontend/src/pages/ChannelPage/hooks/useStompWebRTC.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { Client, Frame } from '@stomp/stompjs'; -import { useEffect, useRef, useState } from 'react'; - -import { getUserId } from '@/api/users'; - -interface UseStompWebRTCProps { - roomId: string; - handleUsers: (users: any) => void; - handleAnswer: (answer: any) => void; - handleIceCandidate: (candidate: any) => void; - handlePublish: (publisherId: any) => void; -} - -const useStompWebRTC = ({ - roomId, - handleUsers, - handleAnswer, - handleIceCandidate, - handlePublish, -}: UseStompWebRTCProps) => { - const [isConnected, setIsConnected] = useState(false); - const stompClient = useRef(null); - - const SERVER_URL = import.meta.env.VITE_SIGNALING; - const token = localStorage.getItem('access_token'); - - if (!token) return; - const userId = getUserId(); - - if (!roomId) return; - - const client = new Client({ - webSocketFactory: () => new WebSocket(SERVER_URL, ['v10.stomp', token]), - connectHeaders: { Authorization: `Bearer ${token}` }, - reconnectDelay: 5000, - heartbeatIncoming: 10000, - heartbeatOutgoing: 10000, - - onConnect: (frame: Frame) => { - console.log('✅ STOMP 연결 성공!', frame); - setIsConnected(true); - - // 연결 성공 시 subscribe - client.subscribe(`/topic/users/${roomId}`, (message) => { - const users = JSON.parse(message.body); - console.log('📩 users 받은 메시지:', message); - handleUsers(users); - }); - - client.subscribe(`/topic/answer/${roomId}/${userId}`, (message) => { - const answer = JSON.parse(message.body); - console.log('📩 answer 받은 메시지:', message); - handleAnswer(answer.message); - }); - - client.subscribe(`/topic/candidate/${roomId}/${userId}`, (message) => { - const candidate = JSON.parse(message.body); - console.log('📩 candidate 받은 메시지:', message); - handleIceCandidate(candidate.candidate); - }); - - client.subscribe(`/topic/publisher/${roomId}`, (message) => { - const publisher_id = JSON.parse(message.body); - console.log('📩 publisher 받은 메시지:', message); - handlePublish(publisher_id); - }); - }, - - onWebSocketError: (error: Error) => { - console.log('WebSocket 에러', error); - }, - - onStompError: (frame) => { - console.error('❌ STOMP 오류 발생!', frame); - }, - }); - - client.activate(); - stompClient.current = client; - - return { - // client.deactivate(); - // clientRef.current = null; - // setIsConnected(false); - // console.log('✅ WebSocket 연결 해제됨'); - client: stompClient.current, - isConnected, - }; -}; - -export default useStompWebRTC; diff --git a/src/frontend/src/pages/ChannelPage/index.tsx b/src/frontend/src/pages/ChannelPage/index.tsx index e202d292..70d603df 100644 --- a/src/frontend/src/pages/ChannelPage/index.tsx +++ b/src/frontend/src/pages/ChannelPage/index.tsx @@ -3,8 +3,8 @@ import React, { useEffect, useRef, useState } from 'react'; import { getUserId } from '@/api/users'; import { useChannelActionStore } from '@/stores/channelAction'; -import { tokenAxios } from '@/utils/axios'; import { useUserInfoStore } from '@/stores/userInfo'; +import { tokenAxios } from '@/utils/axios'; const SERVER_URL = import.meta.env.VITE_SIGNALING; @@ -124,6 +124,11 @@ const WebRTC = () => { }); }); + client.subscribe(`/topic/removed/${roomId}`, (message) => { + const recentUsers = JSON.parse(message.body); + console.log('recentUsers', recentUsers); + }); + console.log(`✅ 구독 성공 하였습니다.`); }, onDisconnect: () => { @@ -223,9 +228,9 @@ const WebRTC = () => { }; // ✅ kurento ice 수집 요청 - const sendGetherIceCandidate = async () => { + const sendGatherIceCandidate = async () => { if (!stompClient.current) { - alert('gether STOMP WebSocket이 연결되지 않았습니다.'); + alert('gather STOMP WebSocket이 연결되지 않았습니다.'); return; } @@ -235,7 +240,6 @@ const WebRTC = () => { destination: '/gather/candidate', body: JSON.stringify({ data: { - // ✅ data 내부에 room_id 포함 room_id: roomId, }, }), @@ -243,7 +247,7 @@ const WebRTC = () => { sendIceCandidates(); // 🔥 SDP Answer 수신 후 ICE Candidate 전송 } catch (error) { - console.error('Gether 요청 실패:', error); + console.error('gather 요청 실패:', error); } }; @@ -261,19 +265,8 @@ const WebRTC = () => { } catch (error) { console.error('Answer 요청 실패:', error); } finally { - sendGetherIceCandidate(); - // sendIceCandidates(); + sendGatherIceCandidate(); } - - // sendGetherIceCandidate(); - }; - - // 예시: 특정 사용자(userId)의 remote stream을 업데이트 - const handleRemoteStream = (userId: string, stream: MediaStream) => { - setRemoteStreams((prev) => ({ - ...prev, - [userId]: stream, - })); }; // ✅ WebRTC Users 처리 diff --git a/src/frontend/src/pages/ChannelPage/test.tsx b/src/frontend/src/pages/ChannelPage/test.tsx deleted file mode 100644 index acb717af..00000000 --- a/src/frontend/src/pages/ChannelPage/test.tsx +++ /dev/null @@ -1,739 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { useRef, useState, useEffect, useCallback } from 'react'; - -import { useChannelActionStore } from '@/stores/channelAction'; -import { tokenAxios } from '@/utils/axios'; - -import useStompWebRTC from './hooks/useStompWebRTC'; - -enum MessageType { - JOIN = 'join', - USER_JOINED = 'user-joined', - OFFER = 'offer', - ANSWER = 'answer', - CANDIDATE = 'candidate', - EXIT = 'exit', - AUDIO = 'AUDIO', - MEDIA = 'MEDIA', -} - -interface AnswerMessage { - type: string; - message: string; -} - -const VideoTest = () => { - const localVideoRef = useRef(null); - const remoteVideoRef = useRef(null); - const screenShareRef = useRef(null); - - const pcRef = useRef(null); - const localStreamRef = useRef(null); - const screenStreamRef = useRef(null); - const screenTrackRef = useRef(null); - - const [roomId, setRoomId] = useState(''); - const [joined, setJoined] = useState(false); - const [answers, setAnswers] = useState(); - const [statusMessage, setStatusMessage] = useState(''); - - const { - isInVoiceChannel, - isSharingScreen, - isVideoOn, - isMicOn, - setIsInVoiceChannel, - setIsSharingScreen, - setIsVideoOn, - setIsMicOn, - } = useChannelActionStore(); - - const { client, isConnected } = useStompWebRTC({ - roomId, - handleUsers, - handleAnswer, - handleIceCandidate, - handlePublish, - }); - - const token = localStorage.getItem('access_token'); - - const handleAnswer = async (sdpAnswer: string) => { - if (!pcRef.current) return; - - try { - await pcRef.current.setRemoteDescription( - new RTCSessionDescription({ - type: 'answer', - sdp: sdpAnswer, - }), - ); - } catch (error) { - console.error('answer 요청 실패', error); - } finally { - sendGetherIceCandidate(); - } - }; - - const sendGetherIceCandidate = async () => { - if (!client) { - alert('gather STOMP WebSocket이 연결되지 않았습니다.'); - return; - } - - try { - client.publish({ - destination: '/gather/candidate', - body: JSON.stringify({ - data: { - room_id: roomId, - }, - }), - }); - - sendIceCandidates(); // SDP Answer 수신 후 ICE Candidate 전송 - } catch (error) { - console.error('gather 요청 실패:', error); - } - }; - - const sendIceCandidates = () => { - if (!pcRef.current || !client) return; - - console.log('접근 완료'); - pcRef.current.onicecandidate = (event) => { - if (event.candidate) { - if (event.candidate.candidate.includes('typ host')) { - return; // host 후보는 버립니다 - } - - console.log('전송 ice candidate: ', event.candidate); - - client.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); - } - }; - - pcRef.current.onicegatheringstatechange = () => { - console.log('[pc] ICE 수집 상태:', pcRef.current?.iceGatheringState); - - if (pcRef.current?.iceGatheringState === 'complete') { - console.log('[pc] ICE 후보 수집 완료'); - } - }; - - pcRef.current.oniceconnectionstatechange = () => { - const state = pcRef.current?.iceConnectionState; - console.log('[pc] ICE 연결 상태 변경:', state); - }; - }; - - const pendingCandidates = useRef([]); - - // WebSocket 메시지 처리 함수 - const handleStompMessage = async (message: any) => { - console.log('수신된 메시지', message); - - if (message.type === 'response' && message.users && message.users.length > 0) { - const user = message.users.filter((user: any) => user.is_me === false); - - console.log('메시지정보', message); - console.log('사용자정보', message.user); - - if (user.sdpOffer) { - // 사용자가 sdpOffer를 보냈는지 확인 - setStatusMessage('Offer 수신되었습니다. 응답 중...'); - - if (!pcRef.current) { - await createPeerConnection(); - } - } - - // 사용자가 sdpAnswer를 보냈는지 확인 - if (user.sdpAnswer) { - setStatusMessage('응답을 받았습니다. 연결 중...'); - - console.log(answers?.message); - - try { - if (pcRef.current) { - console.log('원격 설명 설정 시도'); - await pcRef.current.setRemoteDescription( - new RTCSessionDescription({ - type: 'answer', - sdp: answers?.message, - }), - ); - console.log('원격 설명 설정 완료'); - } - - if (pendingCandidates.current.length > 0) { - console.log(`${pendingCandidates.current.length}개의 대기 중인 후보 처리 중`); - - for (const candidate of pendingCandidates.current) { - try { - if (pcRef.current) { - await pcRef.current.addIceCandidate(new RTCIceCandidate(candidate)); - console.log('대기 중이던 ICE candidate 추가됨'); - } - } catch (err) { - console.error('대기 중이던 ICE candidate 추가 중 오류:', err); - } - } - pendingCandidates.current = []; - } - } catch (err) { - setStatusMessage(`Answer 처리 오류: ${err instanceof Error ? err.message : String(err)}`); - } - } - - // 새 사용자 참여 여부 확인 (audio, video가 false인 경우 새로 참여한 것으로 가정) - if (user.audio === false && user.video === false && !joined) { - setStatusMessage(`사용자가 방에 참여했습니다`); - - if (!joined) { - await joinRoom(); - } - } - - if (user.candidate) { - console.log('ICE Candidate 수신:', user.candidate); - try { - if (pcRef.current) { - await pcRef.current.addIceCandidate(new RTCIceCandidate(user.candidate)); - console.log('ICE Candidate 추가됨'); - } - } catch (err) { - console.error('ICE Candidate 추가 중 오류:', err); - } - } - } - }; - - const handleIceCandidate = async (candidate: RTCIceCandidateInit) => { - console.log('[handleIceCandidate] Candidate 메시지:', candidate); - try { - if (pcRef.current && pcRef.current.remoteDescription) { - await pcRef.current.addIceCandidate(new RTCIceCandidate(candidate)); - console.log('ICE Candidate 추가됨'); - } else { - console.log('원격 설명이 설정되지 않음, 후보 대기열에 추가'); - pendingCandidates.current.push(candidate); - } - } catch (err) { - console.error('ICE Candidate 추가 중 오류:', err); - } - }; - - const disconnectStomp = () => { - if (client) { - client.deactivate(); - setIsInVoiceChannel(false); - console.log('🔌 STOMP WebSocket 연결 해제 시도'); - } - }; - - const createPeerConnection = useCallback(async () => { - try { - pcRef.current = new RTCPeerConnection({ - iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], - }); - - // onicecandidate 이벤트: 수집된 ICE 후보를 시그널링 서버로 전송 - pcRef.current.onicecandidate = (event) => { - if (event.candidate && token && client) { - console.log('[pc] 생성된 ICE Candidate:', event.candidate.candidate); - - pendingCandidates.current.push(event.candidate); - - if (client && client.connected) { - try { - client.publish({ - destination: '/candidate', - body: JSON.stringify({ - type: MessageType.CANDIDATE, - data: { - room_id: roomId, - candidate: event.candidate.candidate, - }, - }), - }); - console.log('ICE candidate 전송 성공'); - } catch (err) { - console.error('ICE candidate 전송 오류:', err); - } - } else { - console.log('STOMP 연결이 없어 candidate를 큐에 저장합니다'); - } - } else { - console.log('[pc] ICE Candidate 수집 완료'); - } - }; - - // 연결 상태 변경 이벤트 - pcRef.current.onconnectionstatechange = () => { - console.log('[pc] onconnectionstatechange fired:', pcRef.current?.connectionState); - console.log('[pc] ICE 연결 상태 변경:', pcRef.current?.iceConnectionState); - setStatusMessage(`연결 상태: ${pcRef.current?.connectionState}`); - - if (pcRef.current?.connectionState === 'connected') { - setIsInVoiceChannel(true); - setStatusMessage('연결 성공! 화상 통화 중...'); - } - }; - - // 원격 트랙(상대방 미디어) 수신 시 비디오 태그에 설정 - pcRef.current.ontrack = (event) => { - console.log('[pc] 원격 트랙 수신됨:', event); - - if (event.streams && event.streams.length > 0) { - const remoteStream = event.streams[0]; - console.log('[pc] 원격 스트림:', remoteStream); - console.log('[pc] 원격 스트림 트랙:', remoteStream.getTracks()); - - // 추가 - remoteStream.getTracks().forEach((track) => { - console.log( - `[pc] 트랙 ID ${track.id}: 종류=${track.kind}, 활성화=${track.enabled}, 준비=${track.readyState}`, - ); - - // 트랙의 상태 변경 감지 - track.onended = () => console.log(`[pc] 트랙 ${track.id} 종료됨`); - track.onmute = () => console.log(`[pc] 트랙 ${track.id} 음소거됨`); - track.onunmute = () => console.log(`[pc] 트랙 ${track.id} 음소거 해제됨`); - }); - - if (remoteVideoRef.current) { - console.log('[pc] 원격 비디오 요소에 스트림 설정'); - - if (remoteVideoRef.current.srcObject) { - console.log('[pc] 이전 스트림 정리'); - remoteVideoRef.current.srcObject = null; - } - - remoteVideoRef.current.srcObject = remoteStream; - remoteVideoRef.current.muted = false; - - console.log('[pc] 비디오 요소 준비 상태:', { - videoWidth: remoteVideoRef.current.videoWidth, - videoHeight: remoteVideoRef.current.videoHeight, - readyState: remoteVideoRef.current.readyState, - paused: remoteVideoRef.current.paused, - }); - - // 명시적으로 재생 시도 (비동기/동기 모두 시도) - try { - remoteVideoRef.current.play(); - console.log('[pc] 동기 재생 시도'); - } catch (e) { - console.error('[pc] 동기 재생 실패:', e); - } - - remoteVideoRef.current - .play() - .then(() => console.log('[pc] 비동기 재생 성공')) - .catch((e) => { - console.error('[pc] 비동기 재생 실패:', e); - - setStatusMessage('비디오 자동 재생 실패. 화면을 클릭하여 재생하세요.'); - }); - - // 비디오 로딩 및 재생 확인을 위한 이벤트 리스너 추가 - remoteVideoRef.current.onloadedmetadata = () => { - console.log('[pc] 원격 비디오 메타데이터 로드됨'); - remoteVideoRef.current - ?.play() - .then(() => console.log('[pc] 원격 비디오 재생 시작')) - .catch((e) => console.error('[pc] 원격 비디오 재생 실패:', e)); - }; - - // 추가 디버깅을 위한 이벤트 리스너 - remoteVideoRef.current.oncanplay = () => { - console.log('[pc] 원격 비디오 재생 가능 상태'); - - remoteVideoRef.current?.play().catch((e) => console.error('[pc] 재생 가능 상태에서 재생 실패:', e)); - }; - - remoteVideoRef.current.onerror = (e) => { - console.error('[pc] 원격 비디오 오류:', e); - }; - } else { - console.warn('[pc] 원격 비디오 요소가 없습니다'); - } - } else { - console.warn('[pc] 원격 스트림이 없습니다', event); - } - }; - - const localStream = await navigator.mediaDevices.getUserMedia({ - video: true, - audio: true, - }); - - localStreamRef.current = localStream; - - // 내 비디오 태그에 출력 - if (localVideoRef.current) { - localVideoRef.current.srcObject = localStream; - } - - setIsVideoOn(true); - - localStream.getTracks().forEach((track) => { - pcRef.current?.addTrack(track, localStream); - }); - - console.log('[pc] RTCPeerConnection 및 로컬 미디어 설정 완료'); - return true; - } catch (err) { - console.error('PeerConnection 생성 또는 미디어 접근 중 오류:', err); - setStatusMessage(`오류: ${err instanceof Error ? err.message : String(err)}`); - return false; - } - }, [setIsInVoiceChannel, roomId, setIsVideoOn, token]); - - const playAllVideos = () => { - console.log('모든 비디오 재생 시도'); - - // 로컬 비디오 재생 - if (localVideoRef.current) { - localVideoRef.current - .play() - .then(() => console.log('로컬 비디오 재생 성공')) - .catch((e) => console.error('로컬 비디오 재생 실패:', e)); - } - - // 원격 비디오 재생 - if (remoteVideoRef.current) { - remoteVideoRef.current - .play() - .then(() => console.log('원격 비디오 재생 성공')) - .catch((e) => console.error('원격 비디오 재생 실패:', e)); - } - - // 화면 공유 비디오 재생 - if (isSharingScreen && screenShareRef.current) { - screenShareRef.current - .play() - .then(() => console.log('화면 공유 비디오 재생 성공')) - .catch((e) => console.error('화면 공유 비디오 재생 실패:', e)); - } - }; - - const joinRoom = async () => { - if (!roomId.trim()) { - setStatusMessage('방 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(response); - // handleSdpAnswer(response.sdp_answer); - setIsInVoiceChannel(true); - } else { - console.error('참여 실패: ', response); - } - } catch (error) { - console.error('API 요청 오류: ', error); - } - }; - - const handleSdpAnswer = async (sdpAnswer: string) => { - if (pcRef.current) { - await pcRef.current.setRemoteDescription( - new RTCSessionDescription({ - type: 'answer', - sdp: sdpAnswer, - }), - ); - console.log('✅ SDP Answer 설정 완료'); - } - }; - - // 화면 공유 시작 - const startScreenShare = async () => { - try { - const screenStream = await navigator.mediaDevices.getDisplayMedia({ - video: true, - audio: false, - }); - - screenStreamRef.current = screenStream; - - // 화면 공유 비디오 요소에 스트림 설정 - if (screenShareRef.current) { - // 먼저 이전 스트림 정리 - if (screenShareRef.current.srcObject) { - screenShareRef.current.srcObject = null; - } - - // 새 스트림 설정 - screenShareRef.current.srcObject = screenStream; - - // 수동으로 재생 시도 - screenShareRef.current.play().catch((err) => { - console.error('화면 공유 비디오 재생 실패:', err); - }); - } else { - console.error('화면 공유 비디오 요소를 찾을 수 없음'); - } - - // 화면 공유 종료 이벤트 리스너 - screenStream.getVideoTracks()[0].onended = () => { - stopScreenShare(); - }; - - // WebRTC 연결이 존재하는 경우 트랙 교체 (p2p 연결용) - if (pcRef.current) { - const screenVideoTrack = screenStream.getVideoTracks()[0]; - screenTrackRef.current = screenVideoTrack; - - const sender = pcRef.current.getSenders().find((s) => s.track?.kind === 'video'); - - if (sender) { - try { - await sender.replaceTrack(screenVideoTrack); - } catch (err) { - console.error('트랙 교체 실패:', err); - } - } else { - try { - pcRef.current.addTrack(screenVideoTrack, screenStream); - } catch (err) { - console.error('트랙 추가 실패:', err); - } - } - } - - setIsSharingScreen(true); - setStatusMessage('화면 공유 중...'); - } catch (error) { - console.error('화면 공유 시작 중 오류:', error); - setStatusMessage(`화면 공유 시작 중 오류: ${error instanceof Error ? error.message : String(error)}`); - } - }; - - // 화면 공유 중지 - const stopScreenShare = async () => { - if (screenStreamRef.current) { - // 모든 화면 공유 트랙 중지 - screenStreamRef.current.getTracks().forEach((track) => track.stop()); - - // 화면 공유 비디오 초기화 - if (screenShareRef.current) { - screenShareRef.current.srcObject = null; - } - - // 다시 로컬 카메라 비디오로 돌아가기 - if (pcRef.current && localStreamRef.current) { - const videoTrack = localStreamRef.current.getVideoTracks()[0]; - const sender = pcRef.current.getSenders().find((s) => s.track?.kind === 'video'); - - if (sender && videoTrack) { - sender.replaceTrack(videoTrack); - } - - // 오디오 트랙도 원래대로 복구 - const audioTrack = localStreamRef.current.getAudioTracks()[0]; - if (audioTrack) { - const audioSender = pcRef.current.getSenders().find((s) => s.track?.kind === 'audio'); - if (audioSender) { - audioSender.replaceTrack(audioTrack); - } - } - } - - screenStreamRef.current = null; - screenTrackRef.current = null; - setIsSharingScreen(false); - setStatusMessage('화면 공유 종료됨'); - } - }; - - // 마이크 음소거/해제 - const toggleAudio = () => { - if (localStreamRef.current) { - const audioTracks = localStreamRef.current.getAudioTracks(); - const newAudioState = !isMicOn; - - audioTracks.forEach((track) => { - track.enabled = newAudioState; - }); - - if (token && isConnected && client) { - client.publish({ - destination: '/toggle', - body: JSON.stringify({ - type: MessageType.AUDIO, - data: { - room_id: roomId, - enabled: newAudioState, - }, - }), - }); - } - - setIsMicOn(newAudioState); - setStatusMessage(`마이크 ${newAudioState ? '활성화됨' : '음소거됨'}`); - } - }; - - // 비디오 켜기/끄기 - const toggleVideo = () => { - if (localStreamRef.current) { - const videoTracks = localStreamRef.current.getVideoTracks(); - const newVideoState = !isVideoOn; - - videoTracks.forEach((track) => { - track.enabled = newVideoState; - }); - - if (token && isConnected && client) { - client.publish({ - destination: '/toggle', - body: JSON.stringify({ - type: MessageType.MEDIA, - data: { - room_id: roomId, - enabled: newVideoState, - }, - }), - }); - } - - setIsVideoOn(newVideoState); - setStatusMessage(`비디오 ${!isVideoOn ? '활성화됨' : '비활성화됨'}`); - } - }; - - // 통화 종료 - const hangUp = async () => { - setStatusMessage('통화 종료 중...'); - - try { - const response = await tokenAxios(`https://api.jungeunjipi.com/room/${roomId}/leave`); - - if (!response) { - console.error('방 나가기 실패: ', response); - return; - } - // 로컬 비디오 스트림 정리 - if (localStreamRef.current) { - localStreamRef.current.getTracks().forEach((track) => track.stop()); - localStreamRef.current = null; - } - - // 비디오 요소 초기화 - if (localVideoRef.current?.srcObject) { - localVideoRef.current.srcObject = null; - } - if (remoteVideoRef.current?.srcObject) { - remoteVideoRef.current.srcObject = null; - } - - setJoined(false); - setIsInVoiceChannel(false); - - setStatusMessage('통화가 종료되었습니다.'); - } catch (error) { - console.error('API 요청 오류', error); - } - }; - - return ( -
-

테스트 페이지

-
- setRoomId(e.target.value)} placeholder="Enter Room ID" /> - - - - - -
- -
-

- 상태: {statusMessage} -

-
- -
-
-

내 비디오

-
-
-

상대방 비디오

-
- {isSharingScreen && ( -
-

화면 공유

-
- )} -
-
- ); -}; - -export default VideoTest; From ca7859da25162f99074fdf87c975b73f4a5d5696 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Sun, 9 Mar 2025 18:21:53 +0900 Subject: [PATCH 37/40] =?UTF-8?q?[FE]=20feat:=20Video=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/api/users.ts | 12 --------- .../components/VideoCard/index.tsx | 27 +++++++++++++++++-- .../components/VideoCard/styles.ts | 24 +++++++++++++++++ src/frontend/src/pages/ChannelPage/index.tsx | 21 ++++----------- 4 files changed, 54 insertions(+), 30 deletions(-) diff --git a/src/frontend/src/api/users.ts b/src/frontend/src/api/users.ts index f91aab6c..7428f6dd 100644 --- a/src/frontend/src/api/users.ts +++ b/src/frontend/src/api/users.ts @@ -37,18 +37,6 @@ export const postAuthCode = async (requestBody: PostAuthCodeRequest) => { return data; }; -interface GetUserIdResponse { - httpStatus: number; - message: string; - time: string; - result: string; -} - -export const getUserId = async () => { - const { data } = await tokenAxios.get(endPoint.users.GET_USER_ID); - return data.result; -}; - interface PostEmailDuplicateParams { email: string; } diff --git a/src/frontend/src/pages/ChannelPage/components/VideoCard/index.tsx b/src/frontend/src/pages/ChannelPage/components/VideoCard/index.tsx index b8a7a2f8..ddf5d844 100644 --- a/src/frontend/src/pages/ChannelPage/components/VideoCard/index.tsx +++ b/src/frontend/src/pages/ChannelPage/components/VideoCard/index.tsx @@ -1,7 +1,30 @@ import * as S from './styles'; -const VideoCard = () => { - return
; +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 index e69de29b..2167a043 100644 --- a/src/frontend/src/pages/ChannelPage/components/VideoCard/styles.ts +++ 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 index 70d603df..d80251ca 100644 --- a/src/frontend/src/pages/ChannelPage/index.tsx +++ b/src/frontend/src/pages/ChannelPage/index.tsx @@ -1,11 +1,12 @@ import { Client } from '@stomp/stompjs'; -import React, { useEffect, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; -import { getUserId } from '@/api/users'; import { useChannelActionStore } from '@/stores/channelAction'; import { useUserInfoStore } from '@/stores/userInfo'; import { tokenAxios } from '@/utils/axios'; +import VideoCard from './components/VideoCard'; + const SERVER_URL = import.meta.env.VITE_SIGNALING; // user interface @@ -464,25 +465,13 @@ const WebRTC = () => {

📹 내 화면

-

🔗 원격 사용자 화면

{Object.entries(remoteStreams).map(([userId, stream]) => ( -
-

{userId}

-
+ ))}
From 92e56f1bae4685fede07543981dee85322c47cb9 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Mon, 10 Mar 2025 03:18:14 +0900 Subject: [PATCH 38/40] =?UTF-8?q?[FE]=20feat:=20webRTCStore=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/stores/webRTCStore.ts | 42 ++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/frontend/src/stores/webRTCStore.ts diff --git a/src/frontend/src/stores/webRTCStore.ts b/src/frontend/src/stores/webRTCStore.ts new file mode 100644 index 00000000..2931a5c2 --- /dev/null +++ b/src/frontend/src/stores/webRTCStore.ts @@ -0,0 +1,42 @@ +import { Client } from '@stomp/stompjs'; +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +interface WebRTCState { + stompClient: Client | null; + isStompConnected: boolean; + roomId: string; + setStompClient: (client: Client | null) => void; + setRoomId: (value: string) => void; + setIsStompConnected: (value: boolean) => void; + disconnectStomp: () => void; +} + +export const useWebRTCStore = create()( + persist( + (set, get) => ({ + stompClient: null, + isStompConnected: false, + roomId: '', + + setStompClient: (client: Client | null) => set({ stompClient: client }), + setRoomId: (roomId) => set({ roomId }), + 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) => ({ + roomId: state.roomId, + isStompConnected: state.isStompConnected, + }), + }, + ), +); From 57d59afe358bcb5cc5dd65c996f806f5326a3ac7 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Mon, 10 Mar 2025 03:54:04 +0900 Subject: [PATCH 39/40] =?UTF-8?q?[FE]=20feat:=20controller=20=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=20=EC=95=84=EC=9D=B4=EC=BD=98=20=ED=81=B4=EB=A6=AD?= =?UTF-8?q?=EC=8B=9C=20stomp=20=EC=97=B0=EA=B2=B0=20=EC=A2=85=EB=A3=8C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../guild/VoiceChannelController/index.tsx | 27 +++++- src/frontend/src/pages/ChannelPage/index.tsx | 85 ++++--------------- src/frontend/src/stores/channelAction.ts | 2 +- src/frontend/src/stores/webRTCStore.ts | 5 -- 4 files changed, 43 insertions(+), 76 deletions(-) diff --git a/src/frontend/src/components/guild/VoiceChannelController/index.tsx b/src/frontend/src/components/guild/VoiceChannelController/index.tsx index 8191632f..47c5d7f1 100644 --- a/src/frontend/src/components/guild/VoiceChannelController/index.tsx +++ b/src/frontend/src/components/guild/VoiceChannelController/index.tsx @@ -3,6 +3,8 @@ 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'; @@ -12,6 +14,29 @@ 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); + + setIsInVoiceChannel(false); + setIsStompConnected(false); + + disconnectStomp(); + } catch (error) { + console.error('🚨 방 나가기 오류:', error); + } + }; return ( @@ -22,7 +47,7 @@ const VoiceChannelController = () => { {selectedChannel?.name} / {guildName} - setIsInVoiceChannel(false)} /> + diff --git a/src/frontend/src/pages/ChannelPage/index.tsx b/src/frontend/src/pages/ChannelPage/index.tsx index d80251ca..3a32ecb4 100644 --- a/src/frontend/src/pages/ChannelPage/index.tsx +++ b/src/frontend/src/pages/ChannelPage/index.tsx @@ -2,7 +2,9 @@ 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'; @@ -21,8 +23,6 @@ interface UserInRoom { const WebRTC = () => { const stompClient = useRef(null); - const [connected, setConnected] = useState(false); - const [roomId, setRoomId] = useState(''); const [offerSent, setOfferSent] = useState(false); const localVideoRef = useRef(null); const remoteVideoRef = useRef(null); @@ -40,22 +40,19 @@ const WebRTC = () => { // 유저 리스트 const [userInRoomList, setUserInRoomList] = useState([]); - const { - isInVoiceChannel, - isSharingScreen, - isVideoOn, - isMicOn, - setIsInVoiceChannel, - setIsSharingScreen, - setIsVideoOn, - setIsMicOn, - } = useChannelActionStore(); + 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) { @@ -75,7 +72,7 @@ const WebRTC = () => { heartbeatOutgoing: 10000, onConnect: () => { console.log(`✅ STOMP WebSocket 연결 성공 (Room: ${roomId})`); - setConnected(true); + setIsStompConnected(true); client.subscribe(`/topic/users/${roomId}`, (message) => { const users = JSON.parse(message.body); @@ -135,7 +132,7 @@ const WebRTC = () => { onDisconnect: () => { alert('🔌 STOMP WebSocket 연결 해제됨'); console.log('🔌 STOMP WebSocket 연결 해제됨'); - setConnected(false); + setIsStompConnected(false); }, onWebSocketError: (error) => { alert(`🚨 WebSocket 오류 발생: ${error}`); @@ -153,7 +150,7 @@ const WebRTC = () => { // ✅ WebRTC Offer 전송 (버튼 클릭 시 실행) const sendOffer = async () => { - if (!stompClient.current || !connected) { + if (!stompClient.current || !isStompConnected) { alert('offer STOMP WebSocket이 연결되지 않았습니다.'); return; } @@ -362,16 +359,6 @@ const WebRTC = () => { }; }; - // ✅ STOMP 연결 해제 함수 - const disconnectStomp = () => { - if (stompClient.current) { - stompClient.current.deactivate(); - stompClient.current = null; - setConnected(false); - console.log('🔌 STOMP WebSocket 연결 해제 시도'); - } - }; - const joinRoom = async (roomId: string) => { if (!roomId) { alert('방 ID를 입력해주세요!'); @@ -397,27 +384,6 @@ const WebRTC = () => { } }; - const leaveRoom = async (roomId: string) => { - if (!roomId) { - alert('방 ID를 입력해주세요!'); - return; - } - - try { - const response = await tokenAxios.delete(`https://api.jungeunjipi.com/room/${roomId}/leave`); - console.log('방 나가기 성공: ', response); - // ✅ 상태 초기화 - setIsInVoiceChannel(false); - setConnected(false); - setRoomId(''); - - disconnectStomp(); - } catch (error) { - console.error('🚨 방 나가기 오류:', error); - } - }; - - // ✅ SDP Answer 처리 const handleSdpAnswer = async (sdpAnswer: string) => { if (peerConnection.current) { await peerConnection.current.setRemoteDescription( @@ -434,34 +400,15 @@ const WebRTC = () => {

Kurento SFU WebRTC

- setRoomId(e.target.value)} - disabled={connected} - style={{ marginRight: '10px', padding: '5px' }} - /> - - - - - - - - +

📹 내 화면

diff --git a/src/frontend/src/stores/channelAction.ts b/src/frontend/src/stores/channelAction.ts index 05f7c70b..0da1e5a5 100644 --- a/src/frontend/src/stores/channelAction.ts +++ b/src/frontend/src/stores/channelAction.ts @@ -6,7 +6,7 @@ interface ChannelActionState { isSharingScreen: boolean; isVideoOn: boolean; isMicOn: boolean; - setIsInVoiceChannel: (value: boolean) => void; + setIsInVoiceChannel: (value?: boolean) => void; setIsSharingScreen: (value: boolean) => void; setIsVideoOn: (value: boolean) => void; setIsMicOn: (value: boolean) => void; diff --git a/src/frontend/src/stores/webRTCStore.ts b/src/frontend/src/stores/webRTCStore.ts index 2931a5c2..2f1f2e89 100644 --- a/src/frontend/src/stores/webRTCStore.ts +++ b/src/frontend/src/stores/webRTCStore.ts @@ -5,9 +5,7 @@ import { persist } from 'zustand/middleware'; interface WebRTCState { stompClient: Client | null; isStompConnected: boolean; - roomId: string; setStompClient: (client: Client | null) => void; - setRoomId: (value: string) => void; setIsStompConnected: (value: boolean) => void; disconnectStomp: () => void; } @@ -17,10 +15,8 @@ export const useWebRTCStore = create()( (set, get) => ({ stompClient: null, isStompConnected: false, - roomId: '', setStompClient: (client: Client | null) => set({ stompClient: client }), - setRoomId: (roomId) => set({ roomId }), setIsStompConnected: (isStompConnected) => set({ isStompConnected }), disconnectStomp: () => { const { stompClient } = get(); @@ -34,7 +30,6 @@ export const useWebRTCStore = create()( { name: 'webRTCInfo', partialize: (state) => ({ - roomId: state.roomId, isStompConnected: state.isStompConnected, }), }, From d98b1bf264a9110241f715041d3813e06f237a45 Mon Sep 17 00:00:00 2001 From: zelkovaria Date: Mon, 10 Mar 2025 04:10:37 +0900 Subject: [PATCH 40/40] =?UTF-8?q?[FE]=20fix:=20=ED=86=B5=ED=99=94=20?= =?UTF-8?q?=EC=A2=85=EB=A3=8C=EC=8B=9C=EC=97=90=EB=8F=84=20VoiceChannelCon?= =?UTF-8?q?troller=EA=B0=80=20=EC=82=AC=EB=9D=BC=EC=A7=80=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8D=98=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/guild/GuildCategoriesList/index.tsx | 4 +--- .../src/components/guild/VoiceChannelController/index.tsx | 2 +- src/frontend/src/stores/channelAction.ts | 7 +++++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/frontend/src/components/guild/GuildCategoriesList/index.tsx b/src/frontend/src/components/guild/GuildCategoriesList/index.tsx index 919fbaa0..eb670672 100644 --- a/src/frontend/src/components/guild/GuildCategoriesList/index.tsx +++ b/src/frontend/src/components/guild/GuildCategoriesList/index.tsx @@ -31,9 +31,7 @@ const GuildCategoriesList = ({ categories, channels }: CategoriesListProps) => { setSelectedChannel({ id: channelInfo.id, name: channelInfo.name, type: channelInfo.type }); if (channelInfo.type === 'VOICE') { - if (!isInVoiceChannel) setIsInVoiceChannel(); - } else if (isInVoiceChannel) { - setIsInVoiceChannel(); + if (!isInVoiceChannel) setIsInVoiceChannel(true); } }; diff --git a/src/frontend/src/components/guild/VoiceChannelController/index.tsx b/src/frontend/src/components/guild/VoiceChannelController/index.tsx index 47c5d7f1..ba8e425e 100644 --- a/src/frontend/src/components/guild/VoiceChannelController/index.tsx +++ b/src/frontend/src/components/guild/VoiceChannelController/index.tsx @@ -20,6 +20,7 @@ const VoiceChannelController = () => { const handleLeaveRoom = async () => { setIsInVoiceChannel(false); + if (!roomId) { alert('방 ID를 입력해주세요!'); return; @@ -29,7 +30,6 @@ const VoiceChannelController = () => { const response = await tokenAxios.delete(`https://api.jungeunjipi.com/room/${roomId}/leave`); console.log('방 나가기 성공: ', response); - setIsInVoiceChannel(false); setIsStompConnected(false); disconnectStomp(); diff --git a/src/frontend/src/stores/channelAction.ts b/src/frontend/src/stores/channelAction.ts index 0da1e5a5..ecaad3da 100644 --- a/src/frontend/src/stores/channelAction.ts +++ b/src/frontend/src/stores/channelAction.ts @@ -6,7 +6,7 @@ interface ChannelActionState { isSharingScreen: boolean; isVideoOn: boolean; isMicOn: boolean; - setIsInVoiceChannel: (value?: boolean) => void; + setIsInVoiceChannel: (value: boolean) => void; setIsSharingScreen: (value: boolean) => void; setIsVideoOn: (value: boolean) => void; setIsMicOn: (value: boolean) => void; @@ -19,7 +19,10 @@ export const useChannelActionStore = create()( isSharingScreen: false, isVideoOn: false, isMicOn: false, - setIsInVoiceChannel: () => set((state) => ({ isInVoiceChannel: !state.isInVoiceChannel })), + 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 })),