Skip to content

Commit 2519744

Browse files
authored
Merge pull request #111 from sgdevcamp2025/fe/feat/74-WebRTC
[FE] feat: 미디어 컨트롤러 및 WebRTC 구현
2 parents f9e2056 + d98b1bf commit 2519744

24 files changed

Lines changed: 877 additions & 4 deletions

File tree

src/frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"react-dom": "^18.3.1",
2525
"react-icons": "^5.4.0",
2626
"react-router-dom": "^7.1.5",
27+
"socket.io-client": "^4.8.1",
2728
"styled-components": "^6.1.14",
2829
"vite-plugin-mkcert": "^1.17.6",
2930
"websocket": "^1.0.35",

src/frontend/src/components/guild/GuildCategoriesList/index.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { BiHash } from 'react-icons/bi';
22
import { BsFillMicFill } from 'react-icons/bs';
33
import { TbPlus } from 'react-icons/tb';
44

5+
import { useChannelActionStore } from '@/stores/channelAction';
56
import { GuildChannelInfo, useChannelInfoStore } from '@/stores/channelInfo';
67
import { useGuildInfoStore } from '@/stores/guildInfo';
78
import useModalStore from '@/stores/modalStore';
@@ -20,13 +21,18 @@ const GuildCategoriesList = ({ categories, channels }: CategoriesListProps) => {
2021
const { openModal } = useModalStore();
2122
const { guildId } = useGuildInfoStore();
2223
const { selectedChannel, setSelectedChannel } = useChannelInfoStore();
24+
const { isInVoiceChannel, setIsInVoiceChannel } = useChannelActionStore();
2325

2426
const handleOpenModal = (categoryId: string, guildId: string) => {
2527
openModal('withFooter', <CreateChannelModal categoryId={categoryId} guildId={guildId} />);
2628
};
2729

2830
const handleChannelClick = (channelInfo: GuildChannelInfo) => {
2931
setSelectedChannel({ id: channelInfo.id, name: channelInfo.name, type: channelInfo.type });
32+
33+
if (channelInfo.type === 'VOICE') {
34+
if (!isInVoiceChannel) setIsInVoiceChannel(true);
35+
}
3036
};
3137

3238
return (

src/frontend/src/components/guild/GuildCategory/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const GuildCategory = () => {
2525
const { data } = useQuery<GuildResultData>({
2626
queryKey: ['guildInfo', guildId],
2727
queryFn: () => getGuild(guildId),
28+
enabled: !!guildId,
2829
});
2930

3031
const dropdownItems: DropdownItem[] = [

src/frontend/src/components/guild/GuildList/index.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,19 @@ import * as S from './styles';
1111

1212
const GuildList = () => {
1313
const { openModal } = useModalStore();
14-
const { setGuildId } = useGuildInfoStore();
14+
const { setGuildId, setGuildName } = useGuildInfoStore();
1515

1616
const { data } = useQuery<GuildResponse[]>({ queryKey: ['guildList'], queryFn: getGuilds });
1717

1818
const handleChangeModal = () => {
1919
openModal('basic', <CreateGuildModalContent />);
2020
};
2121

22+
const handleStoreGuildInfo = (guild: GuildResponse) => {
23+
setGuildId(guild.guildId);
24+
setGuildName(guild.name);
25+
};
26+
2227
return (
2328
<S.GuildList>
2429
<S.DMButton onClick={() => setGuildId('')}>
@@ -29,7 +34,7 @@ const GuildList = () => {
2934
key={guild.guildId}
3035
data-tooltip={guild.name}
3136
$imageUrl={guild.profileImageUrl}
32-
onClick={() => setGuildId(guild.guildId)}
37+
onClick={() => handleStoreGuildInfo(guild)}
3338
/>
3439
))}
3540
<S.AddGuildButton onClick={handleChangeModal}>
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { motion } from 'framer-motion';
2+
import { BiSolidVideo, BiSolidVideoOff } from 'react-icons/bi';
3+
import { LuScreenShare } from 'react-icons/lu';
4+
import { TbConfetti, TbTriangleSquareCircle } from 'react-icons/tb';
5+
6+
import { useChannelActionStore } from '@/stores/channelAction';
7+
8+
import * as S from './styles';
9+
10+
const VoiceChannelActions = () => {
11+
const { isInVoiceChannel } = useChannelActionStore();
12+
13+
const actions = {
14+
video: isInVoiceChannel ? <BiSolidVideo size={24} /> : <BiSolidVideoOff size={24} />,
15+
screenSharing: <LuScreenShare size={24} />,
16+
startActions: <TbTriangleSquareCircle size={24} />,
17+
soundBoard: <TbConfetti size={24} />,
18+
};
19+
20+
const bounceAnimation = {
21+
y: [0, -5, 0],
22+
transition: {
23+
duration: 0.6,
24+
repeat: 3,
25+
repeatType: 'reverse' as const,
26+
ease: 'easeInOut',
27+
},
28+
};
29+
30+
return (
31+
<S.VoiceChannelActions>
32+
{Object.entries(actions).map(([key, value]) => (
33+
<motion.div key={key} initial={{ y: 0 }} whileHover={bounceAnimation}>
34+
<S.Action key={key}>{value}</S.Action>
35+
</motion.div>
36+
))}
37+
</S.VoiceChannelActions>
38+
);
39+
};
40+
41+
export default VoiceChannelActions;
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import styled from 'styled-components';
2+
3+
export const VoiceChannelActions = styled.div`
4+
display: flex;
5+
gap: 0.5rem;
6+
align-items: center;
7+
justify-content: space-evenly;
8+
9+
margin-top: 1rem;
10+
11+
svg {
12+
color: ${({ theme }) => theme.colors.white};
13+
}
14+
`;
15+
16+
export const Action = styled.div`
17+
cursor: pointer;
18+
19+
display: flex;
20+
align-items: center;
21+
justify-content: center;
22+
23+
width: 5rem;
24+
height: 3rem;
25+
border-radius: 0.8rem;
26+
27+
background-color: ${({ theme }) => theme.colors.dark[500]};
28+
`;
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { BsFillTelephoneXFill } from 'react-icons/bs';
2+
3+
import { useChannelActionStore } from '@/stores/channelAction';
4+
import { useChannelInfoStore } from '@/stores/channelInfo';
5+
import { useGuildInfoStore } from '@/stores/guildInfo';
6+
import { useWebRTCStore } from '@/stores/webRTCStore';
7+
import { tokenAxios } from '@/utils/axios';
8+
9+
import VoiceChannelActions from '../VoiceChannelActions';
10+
11+
import * as S from './styles';
12+
13+
const VoiceChannelController = () => {
14+
const { selectedChannel } = useChannelInfoStore();
15+
const { setIsInVoiceChannel } = useChannelActionStore();
16+
const { guildName } = useGuildInfoStore();
17+
const { setIsStompConnected, disconnectStomp } = useWebRTCStore();
18+
19+
const roomId = useChannelInfoStore((state) => state.selectedChannel?.name);
20+
21+
const handleLeaveRoom = async () => {
22+
setIsInVoiceChannel(false);
23+
24+
if (!roomId) {
25+
alert('방 ID를 입력해주세요!');
26+
return;
27+
}
28+
29+
try {
30+
const response = await tokenAxios.delete(`https://api.jungeunjipi.com/room/${roomId}/leave`);
31+
console.log('방 나가기 성공: ', response);
32+
33+
setIsStompConnected(false);
34+
35+
disconnectStomp();
36+
} catch (error) {
37+
console.error('🚨 방 나가기 오류:', error);
38+
}
39+
};
40+
41+
return (
42+
<S.VoiceChannelController>
43+
<S.ConnectStatusWrapper>
44+
<S.InfoText>
45+
<S.ConnectStatusText>음성 연결됨</S.ConnectStatusText>
46+
<S.ChannelInfoText>
47+
{selectedChannel?.name} / {guildName}
48+
</S.ChannelInfoText>
49+
</S.InfoText>
50+
<BsFillTelephoneXFill size={20} onClick={handleLeaveRoom} />
51+
</S.ConnectStatusWrapper>
52+
<VoiceChannelActions />
53+
</S.VoiceChannelController>
54+
);
55+
};
56+
57+
export default VoiceChannelController;
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import styled from 'styled-components';
2+
3+
import { ChipText, SmallText } from '@/styles/Typography';
4+
5+
export const VoiceChannelController = styled.div`
6+
display: flex;
7+
flex-direction: column;
8+
9+
padding: 1rem;
10+
border-bottom: 1px solid ${({ theme }) => theme.colors.dark[450]};
11+
12+
background-color: ${({ theme }) => theme.colors.dark[750]};
13+
`;
14+
15+
export const InfoText = styled.div`
16+
display: flex;
17+
flex-direction: column;
18+
`;
19+
20+
export const ConnectStatusText = styled(ChipText)`
21+
font-size: 1.5rem;
22+
color: ${({ theme }) => theme.colors.lightGreen};
23+
`;
24+
25+
export const ChannelInfoText = styled(SmallText)`
26+
color: ${({ theme }) => theme.colors.dark[350]};
27+
`;
28+
29+
export const ConnectStatusWrapper = styled.div`
30+
display: flex;
31+
align-items: center;
32+
justify-content: space-between;
33+
34+
svg {
35+
cursor: pointer;
36+
color: ${({ theme }) => theme.colors.white};
37+
}
38+
`;

src/frontend/src/constants/endPoint.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const endPoint = {
1111
POST_AUTHENTICATION_CODE: '/users/validation/authentication-code',
1212
POST_SIGN_UP: '/users/sign-up',
1313
POST_SIGN_IN: '/users/sign-in',
14+
GET_USER_ID: '/users/user/id',
1415
},
1516

1617
friends: {
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import * as S from './styles';
2+
3+
interface VideoCardProps {
4+
userId: string;
5+
stream?: MediaStream;
6+
localRef?: React.MutableRefObject<HTMLVideoElement | null>;
7+
}
8+
9+
const VideoCard = ({ userId, stream, localRef }: VideoCardProps) => {
10+
return (
11+
<S.VideoCard>
12+
<S.UserName>{userId}</S.UserName>
13+
<S.Video
14+
autoPlay
15+
playsInline
16+
ref={(videoElement) => {
17+
if (localRef) {
18+
localRef.current = videoElement;
19+
}
20+
21+
if (stream && videoElement && videoElement.srcObject !== stream) {
22+
videoElement.srcObject = stream;
23+
}
24+
}}
25+
/>
26+
</S.VideoCard>
27+
);
28+
};
29+
30+
export default VideoCard;

0 commit comments

Comments
 (0)