Skip to content

Commit 32394a0

Browse files
Merge pull request #331 from ShipFriend0516/feature/media-preview
[Feat] 세션 참가시 비디오 미리보기 모달창 구현
2 parents e253e13 + ba37234 commit 32394a0

File tree

19 files changed

+306
-72
lines changed

19 files changed

+306
-72
lines changed

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@
5858

5959
## 🧩 설계
6060

61-
<img width="1064" alt="architecture" src="https://github.com/user-attachments/assets/67ebaee3-9fe1-4743-ac5e-30b25d609a44">
61+
![image](https://github.com/user-attachments/assets/1725bf0f-8ff3-4dfe-b11e-e5b0c053514f)
62+
6263

6364
## 🛠 기술 스택
6465

@@ -75,6 +76,8 @@
7576

7677
## 🥊 기술적 경험
7778

79+
저희들의 휘발되기엔 아까운 우리 팀의 개발 경험입니다! 더 자세한 경험들은 [이곳](https://alpine-tiglon-9f0.notion.site/87b7f1ce19564eda8127eca29d567d0f?v=f2df7d634605464d876ccf43c9197db4&pvs=4) 에서 확인하실 수 있습니다.
80+
7881
### [BE] Coturn 설치 및 config 파일 수정
7982

8083
coturn은 WebRTC에서 NAT와 방화벽 문제를 해결하기 위해 사용되는 오픈소스 TURN/STUN 서버이다. 설치 후 `/etc/turnserver.conf` 파일에서 `listening-port`, `external-ip`, `min-port`, `verbose` 등을 설정하여 서버 동작을 최적화했다. 인증 방식(`lt-cred-mech`)과 사용자 계정을 설정해 보안을 강화하며, NAT 뒤에서의 통신을 지원하도록 `external-ip`를 공인 IP와 매핑했다.

backend/src/room/exceptions/join-room-exceptions.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,7 @@ export class FullRoomException extends Error {
55
export class InProgressException extends Error {
66
name: "InProgressException";
77
}
8+
9+
export class RoomNotFoundException extends Error {
10+
name: "RoomNotFoundException";
11+
}

backend/src/room/room.events.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const EMIT_EVENT = {
1515
CREATE: "server:room__create",
1616
QUIT: "server:room__quit",
1717
FULL: "server:room__full",
18+
NOT_FOUND: "server:room__not_found",
1819
JOIN: "server:room__join",
1920
IN_PROGRESS: "server:room__progress",
2021
FINISH: "server:room__finish",

backend/src/room/room.gateway.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ import { RoomRepository } from "@/room/room.repository";
2626

2727
import { gatewayConfig } from "@/infra/infra.config";
2828

29-
import { FullRoomException, InProgressException } from "@/room/exceptions/join-room-exceptions";
29+
import {
30+
FullRoomException,
31+
InProgressException,
32+
RoomNotFoundException,
33+
} from "@/room/exceptions/join-room-exceptions";
3034
import { createAdapter } from "@socket.io/redis-adapter";
3135

3236
@WebSocketGateway(gatewayConfig)
@@ -82,6 +86,7 @@ export class RoomGateway implements OnGatewayDisconnect, OnGatewayInit, OnGatewa
8286
} catch (e) {
8387
if (e instanceof InProgressException) client.emit(EMIT_EVENT.IN_PROGRESS, {});
8488
else if (e instanceof FullRoomException) client.emit(EMIT_EVENT.FULL, {});
89+
else if (e instanceof RoomNotFoundException) client.emit(EMIT_EVENT.NOT_FOUND, {});
8590
else throw e;
8691
}
8792
}

backend/src/room/room.service.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import { Room, RoomStatus } from "@/room/domain/room";
1010
import { EMIT_EVENT } from "@/room/room.events";
1111
import { createHash } from "node:crypto";
1212
import { QuestionRepository } from "@/question-list/repository/question.respository";
13-
import { FullRoomException, InProgressException } from "@/room/exceptions/join-room-exceptions";
13+
import {
14+
FullRoomException,
15+
InProgressException,
16+
RoomNotFoundException,
17+
} from "@/room/exceptions/join-room-exceptions";
1418
import { InfraService } from "@/infra/infra.service";
1519
import { QuestionListRepository } from "@/question-list/repository/question-list.repository";
1620

@@ -74,7 +78,7 @@ export class RoomService {
7478
const room = Room.fromEntity(await this.roomRepository.getRoom(roomId));
7579

7680
if (!socket) throw new Error("Invalid Socket");
77-
if (!room.entity) throw new Error("RoomEntity Not found");
81+
if (!room.entity) throw new RoomNotFoundException();
7882

7983
await this.infraService.joinRoom(socket, room.entity.id);
8084

frontend/src/components/common/Animate/NotFound.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,13 @@ const NotFound = ({ message, className, redirect }: NotFoundProps) => {
2020
style={{ width: 200 }}
2121
className={"dark:invert"}
2222
/>
23-
<p className={"text-medium-m text-gray-500 text-center flex flex-col gap-0.5"}>
24-
{message?.split("\n").map(text => (<p>{text}</p>))}
25-
</p>
23+
<div
24+
className={
25+
"text-medium-m text-gray-500 text-center flex flex-col gap-0.5"
26+
}
27+
>
28+
{message?.split("\n").map((text) => <p key={text}>{text}</p>)}
29+
</div>
2630
{redirect && (
2731
<Link
2832
className={

frontend/src/components/session/CommonTools.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ interface CommonToolsProps {
2828
videoLoading: boolean;
2929
isHost: boolean;
3030
roomId: string;
31+
setShouldBlock: (shouldBlock: boolean) => void;
3132
}
3233

3334
const CommonTools = ({
@@ -43,6 +44,7 @@ const CommonTools = ({
4344
videoLoading,
4445
isHost,
4546
roomId,
47+
setShouldBlock,
4648
}: CommonToolsProps) => {
4749
const navigate = useNavigate();
4850
const toast = useToast();
@@ -52,13 +54,15 @@ const CommonTools = ({
5254
const existHandler = () => {
5355
socket?.emit(SESSION_EMIT_EVENT.LEAVE, { roomId });
5456
toast.success("메인 화면으로 이동합니다.");
57+
setShouldBlock(false);
5558
navigate("/sessions");
5659
};
5760

5861
const destroyAndExitHandler = () => {
5962
socket?.off(SESSION_EMIT_EVENT.FINISH);
6063
socket?.emit(SESSION_EMIT_EVENT.FINISH, { roomId });
6164
toast.success("메인 화면으로 이동합니다.");
65+
setShouldBlock(false);
6266
navigate("/sessions");
6367
};
6468

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import VideoContainer from "@components/session/VideoContainer.tsx";
2+
import { useCallback, useEffect, useState } from "react";
3+
import useToast from "@hooks/useToast.ts";
4+
5+
interface MediaPreviewModalProps {
6+
modal: UseModalReturn;
7+
nickname?: string;
8+
setNickname: (nickname: string) => void;
9+
isVideoOn: boolean;
10+
setIsVideoOn: (value: boolean) => void;
11+
onConfirm: () => void;
12+
onReject: () => void;
13+
setReady: (ready: boolean) => void;
14+
getMediaStream: (type: "video" | "audio") => Promise<MediaStream | undefined>;
15+
}
16+
17+
interface UseModalReturn {
18+
dialogRef: React.RefObject<HTMLDialogElement>;
19+
isOpen: boolean;
20+
openModal: () => void;
21+
closeModal: () => void;
22+
}
23+
24+
const MediaPreviewModal = ({
25+
modal,
26+
nickname,
27+
setNickname,
28+
onConfirm,
29+
onReject,
30+
getMediaStream,
31+
isVideoOn,
32+
setIsVideoOn,
33+
setReady,
34+
}: MediaPreviewModalProps) => {
35+
const [preview, setPreview] = useState<MediaStream | undefined>();
36+
const toast = useToast();
37+
38+
const getMediaPreview = useCallback(async () => {
39+
const mediaStream = await getMediaStream("video");
40+
if (!mediaStream) {
41+
toast.error(
42+
"비디오 장치를 찾을 수 없습니다. 비디오 장치 없이 세션에 참가합니다."
43+
);
44+
}
45+
setPreview(mediaStream);
46+
}, []);
47+
48+
useEffect(() => {
49+
getMediaPreview();
50+
51+
return () => {
52+
preview?.getTracks().forEach((track) => track.stop());
53+
};
54+
}, []);
55+
56+
useEffect(() => {
57+
if (modal.dialogRef.current) {
58+
const dialog = modal.dialogRef.current;
59+
const handleEscape = (event: globalThis.KeyboardEvent) => {
60+
if (event.key === "Escape") {
61+
event.preventDefault();
62+
onReject();
63+
setReady(false);
64+
}
65+
};
66+
dialog.addEventListener("keydown", (event) => handleEscape(event));
67+
}
68+
}, [modal.dialogRef.current]);
69+
70+
return (
71+
modal.isOpen && (
72+
<dialog
73+
ref={modal.dialogRef}
74+
className={
75+
"flex flex-col items-center rounded-custom-l px-10 py-6 w-[640px] shadow-lg"
76+
}
77+
>
78+
<h3 className={"text-bold-m"}>비디오 미리보기</h3>
79+
<div className={"w-[400px] p-4"}>
80+
<VideoContainer
81+
nickname={nickname || ""}
82+
isMicOn={true}
83+
isVideoOn={isVideoOn}
84+
isLocal={true}
85+
isSpeaking={false}
86+
reaction={""}
87+
stream={isVideoOn ? preview : undefined}
88+
videoCount={1}
89+
/>
90+
</div>
91+
<div className={"text-medium-r mt-4 flex w-full justify-center gap-4"}>
92+
<label
93+
className={
94+
"inline-flex gap-2 items-center rounded-custom-m px-4 py-2 bg-transparent"
95+
}
96+
id={"checkbox"}
97+
>
98+
<input
99+
defaultChecked={!isVideoOn || false}
100+
className={"w-6 h-6"}
101+
type={"checkbox"}
102+
title={"dd"}
103+
onClick={() => setIsVideoOn(!isVideoOn)}
104+
/>
105+
<span>내 비디오 끄고 참가하기</span>
106+
</label>
107+
<input
108+
className={
109+
"rounded-custom-m px-4 py-2 bg-gray-50 hover:bg-gray-100"
110+
}
111+
type={"text"}
112+
defaultValue={nickname}
113+
placeholder={"닉네임 변경"}
114+
onChange={(e) => setNickname(e.target.value)}
115+
/>
116+
</div>
117+
<div
118+
className={"text-semibold-r mt-4 flex w-full justify-center gap-4"}
119+
>
120+
<button
121+
onClick={() => {
122+
onReject();
123+
setReady(false);
124+
preview?.getTracks().forEach((track) => track.stop());
125+
}}
126+
className={
127+
"rounded-custom-m px-16 py-4 bg-gray-50 hover:bg-gray-100"
128+
}
129+
>
130+
세션 나가기
131+
</button>
132+
<button
133+
onClick={() => {
134+
onConfirm();
135+
setReady(true);
136+
modal.closeModal();
137+
}}
138+
className={
139+
"rounded-custom-m px-16 py-4 bg-green-500 text-white hover:bg-green-600"
140+
}
141+
>
142+
세션 참가
143+
</button>
144+
</div>
145+
</dialog>
146+
)
147+
);
148+
};
149+
150+
export default MediaPreviewModal;

frontend/src/components/session/VideoContainer.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ interface VideoContainerProps {
1616
isLocal: boolean;
1717
isSpeaking: boolean;
1818
reaction: string;
19-
stream: MediaStream;
19+
stream: MediaStream | undefined;
2020
videoLoading?: boolean;
2121
videoCount: number;
2222
}
@@ -94,7 +94,7 @@ const VideoContainer = ({
9494
className={`relative ${getVideoLayoutClass(videoCount)} ${speakingEffect} rounded-custom-l aspect-[4/3]`}
9595
>
9696
<div className="absolute inset-0 bg-gray-black rounded-custom-l overflow-hidden z-10">
97-
<DisplayMediaStream mediaStream={stream} isLocal={isLocal} />
97+
<DisplayMediaStream mediaStream={stream!} isLocal={isLocal} />
9898
<div className="inline-flex gap-4 absolute bottom-2 w-full justify-between px-2">
9999
<p
100100
className={`bg-grayscale-500 ${localNickName} bg-opacity-70 text-white px-2 py-0.5 rounded`}

frontend/src/constants/WebSocket/SessionEvent.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ export const SESSION_LISTEN_EVENT = {
1414
FINISH: "server:room__finish",
1515
CHANGE_HOST: "server:room__change_host",
1616
REACTION: "server:room__reaction",
17+
NOT_FOUND: "server:room__not_found",
1718
} as const;

0 commit comments

Comments
 (0)