From 88b6d7c65ac67c0e03d9a37747256a86c0306856 Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Sun, 3 Nov 2024 18:07:18 +0900 Subject: [PATCH 01/56] =?UTF-8?q?feat:=20React-router-dom=20=EC=84=A4?= =?UTF-8?q?=EC=B9=98=20=EB=B0=8F=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package.json | 6 ++++-- frontend/src/main.tsx | 20 ++++++++++++-------- frontend/src/routes.tsx | 7 +++++++ pnpm-lock.yaml | 38 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 59 insertions(+), 12 deletions(-) create mode 100644 frontend/src/routes.tsx diff --git a/frontend/package.json b/frontend/package.json index c597b581..cb895cf1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,11 +8,13 @@ "build": "tsc -b && vite build", "lint": "eslint .", "format": "prettier --write \"**/*.{ts,tsx}\"", - "preview": "vite preview" + "preview": "vite preview", + "test": "echo \"Error: no test specified\" && exit 1" }, "dependencies": { "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-router-dom": "^6.27.0" }, "devDependencies": { "@eslint/js": "^9.13.0", diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202a..b5345aac 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,14 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './App.tsx' +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "./index.css"; +import App from "./App.tsx"; +import { createBrowserRouter, RouterProvider } from "react-router-dom"; +import { routes } from "./routes.tsx"; -createRoot(document.getElementById('root')!).render( +const router = createBrowserRouter(routes); + +createRoot(document.getElementById("root")!).render( - - , -) + + +); diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx new file mode 100644 index 00000000..8e6e1647 --- /dev/null +++ b/frontend/src/routes.tsx @@ -0,0 +1,7 @@ +import App from "./App.tsx"; +export const routes = [ + { + element: , + path: "/", + }, +]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3fe540f2..02ef9d55 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,7 +44,7 @@ importers: version: 10.2.3(chokidar@3.6.0)(typescript@5.6.3) '@nestjs/testing': specifier: ^10.0.0 - version: 10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)(@nestjs/platform-express@10.4.6) + version: 10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)) '@types/express': specifier: ^5.0.0 version: 5.0.0 @@ -108,6 +108,9 @@ importers: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + react-router-dom: + specifier: ^6.27.0 + version: 6.27.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) devDependencies: '@eslint/js': specifier: ^9.13.0 @@ -850,6 +853,10 @@ packages: resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@remix-run/router@1.20.0': + resolution: {integrity: sha512-mUnk8rPJBI9loFDZ+YzPGdeniYK+FTmRD1TMCz7ev2SNIozyKKpnGgsxO34u6Z4z/t0ITuu7voi/AshfsGsgFg==} + engines: {node: '>=14.0.0'} + '@rollup/rollup-android-arm-eabi@4.24.3': resolution: {integrity: sha512-ufb2CH2KfBWPJok95frEZZ82LtDl0A6QKTa8MoM+cWwDZvVGl5/jNb79pIhRvAalUu+7LD91VYR0nwRD799HkQ==} cpu: [arm] @@ -2946,6 +2953,19 @@ packages: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} + react-router-dom@6.27.0: + resolution: {integrity: sha512-+bvtFWMC0DgAFrfKXKG9Fc+BcXWRUO1aJIihbB79xaeq0v5UzfvnM5houGUm1Y461WVRcgAQ+Clh5rdb1eCx4g==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + react-router@6.27.0: + resolution: {integrity: sha512-YA+HGZXz4jaAkVoYBE98VQl+nVzI+cVI2Oj/06F5ZM+0u3TgedN9Y9kmMRo2mnkSK2nCpNQn0DVob4HCsY/WLw==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -4397,7 +4417,7 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/testing@10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)(@nestjs/platform-express@10.4.6)': + '@nestjs/testing@10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6))': dependencies: '@nestjs/common': 10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/core': 10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -4430,6 +4450,8 @@ snapshots: '@pkgr/core@0.1.1': {} + '@remix-run/router@1.20.0': {} + '@rollup/rollup-android-arm-eabi@4.24.3': optional: true @@ -6833,6 +6855,18 @@ snapshots: react-refresh@0.14.2: {} + react-router-dom@6.27.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@remix-run/router': 1.20.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router: 6.27.0(react@18.3.1) + + react-router@6.27.0(react@18.3.1): + dependencies: + '@remix-run/router': 1.20.0 + react: 18.3.1 + react@18.3.1: dependencies: loose-envify: 1.4.0 From c1ad2b9120a683c0ef4430128be495e401a32b4f Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Sun, 3 Nov 2024 18:07:44 +0900 Subject: [PATCH 02/56] =?UTF-8?q?feat:=20=EA=B0=84=EB=8B=A8=ED=95=9C=20?= =?UTF-8?q?=EB=B9=84=EB=94=94=EC=98=A4=20=EC=BC=9C=EA=B8=B0=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 128 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 102 insertions(+), 26 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fba62e02..a53b22d4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,35 +1,111 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' -import './App.css' +import { useRef, useState } from "react"; +import reactLogo from "./assets/react.svg"; +import viteLogo from "/vite.svg"; +import "./App.css"; function App() { - const [count, setCount] = useState(0) + const [count, setCount] = useState(0); + const [callButtonActive, setCallButtonActive] = useState(false); + const [hangupButtonActive, setHangupButtonActive] = useState(false); + const [startButtonActive, setStartButtonActive] = useState(true); + const [status, setStatus] = useState("연결 대기중"); + + const localVideoRef = useRef(null); + const remoteVideoRef = useRef(null); + // WebRTC 설정 + const configuration = { + iceServers: [ + { + urls: "stun:stun.l.google.com:19302", + }, + ], + }; + + // 변수 선언 + + const [localStream, setLocalStream] = useState(null); + const [remoteStream, setRemoteStream] = useState(null); + const [peerConnection, setPeerConnection] = + useState(null); + + // 미디어 스트림 시작 + async function startCall() { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: true, + }); + + if (localVideoRef.current) { + localVideoRef.current!.srcObject = stream; + setLocalStream(stream); + } + + setCallButtonActive(true); + setStatus("비디오 켜는 중..."); + } catch (e) { + console.error("미디어 스트림 획득 실패:", e); + setStatus("카메라/마이크 접근 실패"); + } + } + + async function stopVideo() { + try { + if (localStream) { + localStream.getTracks().forEach((track) => { + track.stop(); + }); + setLocalStream(null); + } + } catch (e) { + console.error("비디오 중지 실패:", e); + } + } return ( - <> -
- - Vite logo - - - React logo - +
+
+ +
-

27팀 아자아자 화이팅

-
- -

- Edit src/App.tsx and save to test HMR -

-

- Click on the Vite and React logos to learn more -

- - ) +
{status}
+
+ ); } -export default App +export default App; From a4d37cdeb34607d431d71321e4672e51fde272be Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Tue, 5 Nov 2024 15:43:00 +0900 Subject: [PATCH 03/56] =?UTF-8?q?feat:=20=ED=98=91=EC=97=85=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20routes=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/routes.tsx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index 8e6e1647..658b4083 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -4,4 +4,24 @@ export const routes = [ element: , path: "/", }, + { + element: <>화상회의 페이지, + path: '/session/:sessionId' + }, + { + element: <>세션 리스트 페이지, + path: '/sessions' + }, + { + element: <>로그인 페이지, + path: '/login', + }, + { + element: <>세션 생성 페이지, + path: '/sessions/create' + }, + { + element: <>에러 페이지, + path: '/*' + } ]; From 9d815bd5dce403ccaf7a47eb5917977bfc4a0dd4 Mon Sep 17 00:00:00 2001 From: twalla26 Date: Tue, 5 Nov 2024 17:57:11 +0900 Subject: [PATCH 04/56] =?UTF-8?q?feat:=20Signaling=20server=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package.json | 5 +- backend/src/app.controller.spec.ts | 32 +++---- backend/src/app.controller.ts | 14 +-- backend/src/app.module.ts | 13 +-- backend/src/app.service.ts | 8 +- backend/src/main.ts | 8 +- backend/src/socket/socket.gateway.ts | 124 +++++++++++++++++++++++++++ backend/src/socket/socket.module.ts | 7 ++ 8 files changed, 173 insertions(+), 38 deletions(-) create mode 100644 backend/src/socket/socket.gateway.ts create mode 100644 backend/src/socket/socket.module.ts diff --git a/backend/package.json b/backend/package.json index ad5415a9..11b922ba 100644 --- a/backend/package.json +++ b/backend/package.json @@ -23,8 +23,11 @@ "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", "@nestjs/platform-express": "^10.0.0", + "@nestjs/platform-socket.io": "^10.4.6", + "@nestjs/websockets": "^10.4.6", "reflect-metadata": "^0.2.0", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "socket.io": "^4.8.1" }, "devDependencies": { "@nestjs/cli": "^10.0.0", diff --git a/backend/src/app.controller.spec.ts b/backend/src/app.controller.spec.ts index d22f3890..d9d81e1b 100644 --- a/backend/src/app.controller.spec.ts +++ b/backend/src/app.controller.spec.ts @@ -1,22 +1,22 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; +import { Test, TestingModule } from "@nestjs/testing"; +import { AppController } from "./app.controller"; +import { AppService } from "./app.service"; -describe('AppController', () => { - let appController: AppController; +describe("AppController", () => { + let appController: AppController; - beforeEach(async () => { - const app: TestingModule = await Test.createTestingModule({ - controllers: [AppController], - providers: [AppService], - }).compile(); + beforeEach(async () => { + const app: TestingModule = await Test.createTestingModule({ + controllers: [AppController], + providers: [AppService], + }).compile(); - appController = app.get(AppController); - }); + appController = app.get(AppController); + }); - describe('root', () => { - it('should return "Hello World!"', () => { - expect(appController.getHello()).toBe('Hello World!'); + describe("root", () => { + it('should return "Hello World!"', () => { + expect(appController.getHello()).toBe("Hello World!"); + }); }); - }); }); diff --git a/backend/src/app.controller.ts b/backend/src/app.controller.ts index cce879ee..00e6336b 100644 --- a/backend/src/app.controller.ts +++ b/backend/src/app.controller.ts @@ -1,12 +1,12 @@ -import { Controller, Get } from '@nestjs/common'; -import { AppService } from './app.service'; +import { Controller, Get } from "@nestjs/common"; +import { AppService } from "./app.service"; @Controller() export class AppController { - constructor(private readonly appService: AppService) {} + constructor(private readonly appService: AppService) {} - @Get() - getHello(): string { - return this.appService.getHello(); - } + @Get() + getHello(): string { + return this.appService.getHello(); + } } diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 86628031..f6e8a2e5 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,10 +1,11 @@ -import { Module } from '@nestjs/common'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; +import { Module } from "@nestjs/common"; +import { AppController } from "./app.controller"; +import { AppService } from "./app.service"; +import { SocketModule } from "./socket/socket.module"; @Module({ - imports: [], - controllers: [AppController], - providers: [AppService], + imports: [SocketModule], + controllers: [AppController], + providers: [AppService], }) export class AppModule {} diff --git a/backend/src/app.service.ts b/backend/src/app.service.ts index 927d7cca..07de6b45 100644 --- a/backend/src/app.service.ts +++ b/backend/src/app.service.ts @@ -1,8 +1,8 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable } from "@nestjs/common"; @Injectable() export class AppService { - getHello(): string { - return 'Hello World!'; - } + getHello(): string { + return "Hello World!"; + } } diff --git a/backend/src/main.ts b/backend/src/main.ts index f76bc8d9..b5c531a1 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,8 +1,8 @@ -import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module'; +import { NestFactory } from "@nestjs/core"; +import { AppModule } from "./app.module"; async function bootstrap() { - const app = await NestFactory.create(AppModule); - await app.listen(process.env.PORT ?? 3000); + const app = await NestFactory.create(AppModule); + await app.listen(process.env.PORT ?? 3000); } bootstrap(); diff --git a/backend/src/socket/socket.gateway.ts b/backend/src/socket/socket.gateway.ts new file mode 100644 index 00000000..7246aad3 --- /dev/null +++ b/backend/src/socket/socket.gateway.ts @@ -0,0 +1,124 @@ +import { + WebSocketGateway, + WebSocketServer, + OnGatewayConnection, + OnGatewayDisconnect, + SubscribeMessage, + MessageBody, +} from "@nestjs/websockets"; +import { Server } from "socket.io"; + +interface User { + id: string; + nickname: string; +} + +@WebSocketGateway({ + cors: { + origin: "*", // CORS 설정 + }, +}) +export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() + server: Server; + + private users: { [key: string]: User[] } = {}; + private socketToRoom: { [key: string]: string } = {}; + private maximum = 5; + + handleConnection(socket: any) { + console.log(`Client connected: ${socket.id}`); + } + + handleDisconnect(socket: any) { + console.log(`Client disconnected: ${socket.id}`); + const roomID = this.socketToRoom[socket.id]; + if (roomID) { + const room = this.users[roomID]; + if (room) { + this.users[roomID] = room.filter( + (user) => user.id !== socket.id + ); + if (this.users[roomID].length === 0) { + delete this.users[roomID]; + } else { + this.server.to(roomID).emit("user_exit", { id: socket.id }); + } + } + } + } + + @SubscribeMessage("join_room") + handleJoinRoom(socket: any, data: { room: string; nickname: string }) { + if (this.users[data.room]) { + if (this.users[data.room].length === this.maximum) { + socket.emit("room_full"); + return; + } + this.users[data.room].push({ + id: socket.id, + nickname: data.nickname, + }); + } else { + this.users[data.room] = [ + { id: socket.id, nickname: data.nickname }, + ]; + } + + this.socketToRoom[socket.id] = data.room; + socket.join(data.room); + console.log(`[${data.room}]: ${socket.id} enter`); + + const usersInThisRoom = this.users[data.room].filter( + (user) => user.id !== socket.id + ); + socket.emit("all_users", usersInThisRoom); + } + + @SubscribeMessage("offer") + handleOffer( + @MessageBody() + data: { + offerReceiveID: string; + sdp: any; + offerSendID: string; + offerSendNickname: string; + } + ) { + this.server.to(data.offerReceiveID).emit("getOffer", { + sdp: data.sdp, + offerSendID: data.offerSendID, + offerSendNickname: data.offerSendNickname, + }); + } + + @SubscribeMessage("answer") + handleAnswer( + @MessageBody() + data: { + answerReceiveID: string; + sdp: any; + answerSendID: string; + } + ) { + this.server.to(data.answerReceiveID).emit("getAnswer", { + sdp: data.sdp, + answerSendID: data.answerSendID, + }); + } + + @SubscribeMessage("candidate") + handleCandidate( + @MessageBody() + data: { + candidateReceiveID: string; + candidate: any; + candidateSendID: string; + } + ) { + this.server.to(data.candidateReceiveID).emit("getCandidate", { + candidate: data.candidate, + candidateSendID: data.candidateSendID, + }); + } +} diff --git a/backend/src/socket/socket.module.ts b/backend/src/socket/socket.module.ts new file mode 100644 index 00000000..6e791161 --- /dev/null +++ b/backend/src/socket/socket.module.ts @@ -0,0 +1,7 @@ +import { Module } from "@nestjs/common"; +import { SocketGateway } from "./socket.gateway"; + +@Module({ + providers: [SocketGateway], +}) +export class SocketModule {} From 5433f4de2b46cc0e5e98275c20126e586ce318ef Mon Sep 17 00:00:00 2001 From: JeongwooSeo <98446924+ShipFriend0516@users.noreply.github.com> Date: Tue, 5 Nov 2024 20:28:13 +0900 Subject: [PATCH 05/56] =?UTF-8?q?feat:=20Tailwind=20config=EC=97=90=20foun?= =?UTF-8?q?dation=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 메인 색상으로 그린 컬러 - 기본 색상으로 grayscale 컬러 - 타이포그래피 설정 - 기존 CSS 제거 --- frontend/.gitignore | 2 ++ frontend/src/index.css | 22 ------------- frontend/tailwind.config.js | 64 ++++++++++++++++++++++++++++++++++++- 3 files changed, 65 insertions(+), 23 deletions(-) diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf36..50c8dda2 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -22,3 +22,5 @@ dist-ssr *.njsproj *.sln *.sw? + +.env diff --git a/frontend/src/index.css b/frontend/src/index.css index e7d4bb2f..e4eeb97d 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -34,29 +34,7 @@ body { min-height: 100vh; } -h1 { - font-size: 3.2em; - line-height: 1.1; -} -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} @media (prefers-color-scheme: light) { :root { diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index d37737fc..f87d69c2 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -5,7 +5,69 @@ export default { "./src/**/*.{js,ts,jsx,tsx}", ], theme: { - extend: {}, + extend: { + colors: { + grayscale: { + 'white-alt': 'rgba(255, 255, 255, 0.7)', + white: '#FFFFFF', + 50: '#F5F7F9', + 100: '#D2DAE0', + 200: '#879298', + 300: '#6E8091', + 400: '#5F6E76', + 500: '#4B5966', + black: '#14212B', + }, + primary: { + light: { + DEFAULT: '#e6f9f1', // rgb(230, 249, 241) + hover: '#d9f5e9', // rgb(217, 245, 233) + active: '#b0ebd2', // rgb(176, 235, 210) + }, + DEFAULT: '#01bf6f', // rgb(1, 191, 111) + hover: '#01ac64', // rgb(1, 172, 100) + active: '#019959', // rgb(1, 153, 89) + dark: { + DEFAULT: '#018f53', // rgb(1, 143, 83) + hover: '#017343', // rgb(1, 115, 67) + active: '#005632', // rgb(0, 86, 50) + }, + darker: '#004327', // rgb(0, 67, 39) + }, + }, + borderColor: { + skin: { + bold: 'var(--color-border-bold)', + default: 'var(--color-border-default)', + }, + }, + fontSize: { + // Bold(700) sizes + 'bold-l': ['32px', { lineHeight: 'auto', fontWeight: '700' }], + 'bold-m': ['28px', { lineHeight: 'auto', fontWeight: '700' }], + 'bold-r': ['20px', { lineHeight: 'auto', fontWeight: '700' }], + + // SemiBold(600) sizes + 'semibold-xl': ['28px', { lineHeight: 'auto', fontWeight: '600' }], + 'semibold-l': ['24px', { lineHeight: 'auto', fontWeight: '600' }], + 'semibold-m': ['22px', { lineHeight: 'auto', fontWeight: '600' }], + 'semibold-r': ['20px', { lineHeight: 'auto', fontWeight: '600' }], + 'semibold-s': ['18px', { lineHeight: 'auto', fontWeight: '600' }], + + // Medium(500) sizes + 'medium-xl': ['24px', { lineHeight: 'auto', fontWeight: '500' }], + 'medium-l': ['22px', { lineHeight: 'auto', fontWeight: '500' }], + 'medium-m': ['20px', { lineHeight: 'auto', fontWeight: '500' }], + 'medium-r': ['18px', { lineHeight: 'auto', fontWeight: '500' }], + 'medium-s': ['16px', { lineHeight: 'auto', fontWeight: '500' }], + 'medium-xs': ['14px', { lineHeight: 'auto', fontWeight: '500' }], + 'medium-xxs': ['12px', { lineHeight: 'auto', fontWeight: '500' }], + }, + fontFamily: { + pretendard: ['Pretendard', 'sans-serif'], + raleway: ['Raleway', 'sans-serif'], + }, + }, }, plugins: [], } From 6213acae3a37f4239541e92c99637279b51d7982 Mon Sep 17 00:00:00 2001 From: JeongwooSeo <98446924+ShipFriend0516@users.noreply.github.com> Date: Tue, 5 Nov 2024 20:29:10 +0900 Subject: [PATCH 06/56] =?UTF-8?q?feat:=20=EC=84=B8=EC=85=98=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=ED=8C=85=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/pages/SessionListPage.tsx | 31 ++++++++++++++++++++++++++++++ frontend/src/App.tsx | 16 +++++---------- frontend/src/routes.tsx | 3 ++- 3 files changed, 38 insertions(+), 12 deletions(-) create mode 100644 frontend/pages/SessionListPage.tsx diff --git a/frontend/pages/SessionListPage.tsx b/frontend/pages/SessionListPage.tsx new file mode 100644 index 00000000..37ba9008 --- /dev/null +++ b/frontend/pages/SessionListPage.tsx @@ -0,0 +1,31 @@ + + +const SessionListPage = () => { + + + return ( +
+
헤더 +

스터디 세션 목록

+
+ + + + +
+
+
+

공개된 세션 목록

+
+ +
+
+
진행 중인 세션 목록
+ +
+ ) +} + +export default SessionListPage \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a53b22d4..3fa40198 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,12 +1,7 @@ import { useRef, useState } from "react"; -import reactLogo from "./assets/react.svg"; -import viteLogo from "/vite.svg"; -import "./App.css"; + function App() { - const [count, setCount] = useState(0); - const [callButtonActive, setCallButtonActive] = useState(false); - const [hangupButtonActive, setHangupButtonActive] = useState(false); const [startButtonActive, setStartButtonActive] = useState(true); const [status, setStatus] = useState("연결 대기중"); @@ -16,7 +11,7 @@ function App() { const configuration = { iceServers: [ { - urls: "stun:stun.l.google.com:19302", + urls: process.env.REACT_STUN_SERVER ?? "stun:stun.l.google.com:19302", }, ], }; @@ -24,9 +19,9 @@ function App() { // 변수 선언 const [localStream, setLocalStream] = useState(null); - const [remoteStream, setRemoteStream] = useState(null); - const [peerConnection, setPeerConnection] = - useState(null); + // const [remoteStream, setRemoteStream] = useState(null); + // const [peerConnection, setPeerConnection] = + // useState(null); // 미디어 스트림 시작 async function startCall() { @@ -40,7 +35,6 @@ function App() { setLocalStream(stream); } - setCallButtonActive(true); setStatus("비디오 켜는 중..."); } catch (e) { console.error("미디어 스트림 획득 실패:", e); diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index 658b4083..3f53d36c 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -1,4 +1,5 @@ import App from "./App.tsx"; +import SessionListPage from "../pages/SessionListPage.tsx"; export const routes = [ { element: , @@ -9,7 +10,7 @@ export const routes = [ path: '/session/:sessionId' }, { - element: <>세션 리스트 페이지, + element: , path: '/sessions' }, { From e16de4961dcf1ab113cf5cac364dbb4bde5a93b8 Mon Sep 17 00:00:00 2001 From: JeongwooSeo <98446924+ShipFriend0516@users.noreply.github.com> Date: Tue, 5 Nov 2024 20:29:26 +0900 Subject: [PATCH 07/56] =?UTF-8?q?feat:=20=EC=84=B8=EC=85=98=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B9=B4=EB=93=9C=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EC=A7=80=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/SessionCard.tsx | 34 +++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 frontend/components/SessionCard.tsx diff --git a/frontend/components/SessionCard.tsx b/frontend/components/SessionCard.tsx new file mode 100644 index 00000000..66908a97 --- /dev/null +++ b/frontend/components/SessionCard.tsx @@ -0,0 +1,34 @@ + +interface Props { + category: string; + title: string; + host: string; + participant: number; + maxParticipant: number; + sessionStatus: 'open' | 'close'; + questionListId: number; +} + +const SessionCard =({ category, title, host, participant, maxParticipant,sessionStatus,questionListId}: Props) => { + + + return ( +
  • +
    +
    +
    + {category} +

    {title}

    +

    질문지인데 누르면 질문 리스트를 볼 수 있임

    +
    + {host} • + {participant}/{maxParticipant} + +
    +
    +
    +
  • + ) +} + +export default SessionCard \ No newline at end of file From 44a5c302bfa215f3933926e5a9cb5d94d8b62828 Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Tue, 5 Nov 2024 23:42:55 +0900 Subject: [PATCH 08/56] =?UTF-8?q?chore:=20React-icons=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EC=84=A4=EC=B9=98,?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package.json | 1 + pnpm-lock.yaml | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/frontend/package.json b/frontend/package.json index cb895cf1..f1a9e818 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", + "react-icons": "^5.3.0", "react-router-dom": "^6.27.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02ef9d55..5c822aa5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,6 +108,9 @@ importers: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + react-icons: + specifier: ^5.3.0 + version: 5.3.0(react@18.3.1) react-router-dom: specifier: ^6.27.0 version: 6.27.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2946,6 +2949,11 @@ packages: peerDependencies: react: ^18.3.1 + react-icons@5.3.0: + resolution: {integrity: sha512-DnUk8aFbTyQPSkCfF8dbX6kQjXA9DktMeJqfjrg6cK9vwQVMxmcA3BfP4QoiztVmEHtwlTgLFsPuH2NskKT6eg==} + peerDependencies: + react: '*' + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -6851,6 +6859,10 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-icons@5.3.0(react@18.3.1): + dependencies: + react: 18.3.1 + react-is@18.3.1: {} react-refresh@0.14.2: {} From d9f8d705b51f1c7c65a5cf97aaf168c4558f29e0 Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Tue, 5 Nov 2024 23:43:54 +0900 Subject: [PATCH 09/56] =?UTF-8?q?style:=20=ED=85=8C=EC=9D=BC=EC=9C=88?= =?UTF-8?q?=EB=93=9C=20config=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/index.css | 8 -------- frontend/tailwind.config.js | 5 +++++ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/frontend/src/index.css b/frontend/src/index.css index e4eeb97d..9c3221e9 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -26,14 +26,6 @@ a:hover { color: #535bf2; } -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - @media (prefers-color-scheme: light) { diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index f87d69c2..e00a9a23 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -34,6 +34,11 @@ export default { }, darker: '#004327', // rgb(0, 67, 39) }, + accent: { + gray: { + DEFAULT: '#dfddd5' + } + } }, borderColor: { skin: { From 259a12bbdd0147735437000e3da538a112e1ad93 Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Tue, 5 Nov 2024 23:44:43 +0900 Subject: [PATCH 10/56] =?UTF-8?q?chore:=20=ED=8F=B4=EB=8D=94=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 1 - frontend/src/main.tsx | 1 - frontend/src/routes.tsx | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3fa40198..ba50a442 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,5 @@ import { useRef, useState } from "react"; - function App() { const [startButtonActive, setStartButtonActive] = useState(true); const [status, setStatus] = useState("연결 대기중"); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index b5345aac..2785d96f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,7 +1,6 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./index.css"; -import App from "./App.tsx"; import { createBrowserRouter, RouterProvider } from "react-router-dom"; import { routes } from "./routes.tsx"; diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index 3f53d36c..d9391e4b 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -1,5 +1,5 @@ import App from "./App.tsx"; -import SessionListPage from "../pages/SessionListPage.tsx"; +import SessionListPage from "./pages/SessionListPage.tsx"; export const routes = [ { element: , From c2e156249bdca6138c1f0a6c398fbfed08b0b7d4 Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Tue, 5 Nov 2024 23:45:20 +0900 Subject: [PATCH 11/56] =?UTF-8?q?feat:=20=EC=84=B8=EC=85=98=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=99=80=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EC=B9=B4=EB=93=9C=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UI만 구현한 상태 --- frontend/components/SessionCard.tsx | 34 ------ frontend/pages/SessionListPage.tsx | 31 ------ frontend/src/components/SessionCard.tsx | 66 ++++++++++++ frontend/src/pages/SessionListPage.tsx | 135 ++++++++++++++++++++++++ 4 files changed, 201 insertions(+), 65 deletions(-) delete mode 100644 frontend/components/SessionCard.tsx delete mode 100644 frontend/pages/SessionListPage.tsx create mode 100644 frontend/src/components/SessionCard.tsx create mode 100644 frontend/src/pages/SessionListPage.tsx diff --git a/frontend/components/SessionCard.tsx b/frontend/components/SessionCard.tsx deleted file mode 100644 index 66908a97..00000000 --- a/frontend/components/SessionCard.tsx +++ /dev/null @@ -1,34 +0,0 @@ - -interface Props { - category: string; - title: string; - host: string; - participant: number; - maxParticipant: number; - sessionStatus: 'open' | 'close'; - questionListId: number; -} - -const SessionCard =({ category, title, host, participant, maxParticipant,sessionStatus,questionListId}: Props) => { - - - return ( -
  • -
    -
    -
    - {category} -

    {title}

    -

    질문지인데 누르면 질문 리스트를 볼 수 있임

    -
    - {host} • - {participant}/{maxParticipant} - -
    -
    -
    -
  • - ) -} - -export default SessionCard \ No newline at end of file diff --git a/frontend/pages/SessionListPage.tsx b/frontend/pages/SessionListPage.tsx deleted file mode 100644 index 37ba9008..00000000 --- a/frontend/pages/SessionListPage.tsx +++ /dev/null @@ -1,31 +0,0 @@ - - -const SessionListPage = () => { - - - return ( -
    -
    헤더 -

    스터디 세션 목록

    -
    - - - - -
    -
    -
    -

    공개된 세션 목록

    -
    - -
    -
    -
    진행 중인 세션 목록
    - -
    - ) -} - -export default SessionListPage \ No newline at end of file diff --git a/frontend/src/components/SessionCard.tsx b/frontend/src/components/SessionCard.tsx new file mode 100644 index 00000000..cdd69557 --- /dev/null +++ b/frontend/src/components/SessionCard.tsx @@ -0,0 +1,66 @@ +import { FaUserGroup } from "react-icons/fa6"; +import { FaArrowRight } from "react-icons/fa"; +interface Props { + category: string; + title: string; + host: string; + participant: number; + maxParticipant: number; + sessionStatus: "open" | "close"; + questionListId: number; +} + +const SessionCard = ({ + category, + title, + host, + participant, + maxParticipant, + sessionStatus, + questionListId, +}: Props) => { + return ( +
  • +
    +
    + + {category} + +

    {title}

    +

    + 질문지인데 누르면 질문 리스트를 볼 수 있임 {questionListId} +

    +
    +
    + {host} • + + {" "} + 참여자 + {participant}/{maxParticipant}명 + +
    + +
    +
    +
  • + ); +}; + +export default SessionCard; diff --git a/frontend/src/pages/SessionListPage.tsx b/frontend/src/pages/SessionListPage.tsx new file mode 100644 index 00000000..9b7e135a --- /dev/null +++ b/frontend/src/pages/SessionListPage.tsx @@ -0,0 +1,135 @@ +import { FaCirclePlus } from "react-icons/fa6"; +import { useState } from "react"; +import SessionCard from "../components/SessionCard.tsx"; + +interface Session { + id: number; + title: string; + category: string; + sessionStatus: "open" | "close"; + host: { + nickname: string; + }; + participant: number; + maxParticipant: number; +} +const SessionListPage = () => { + const [sessionList, setSessionList] = useState([ + { + id: 1, + title: "프론트엔드 초보만 들어올 수 있음", + category: "프론트엔드", + sessionStatus: "open", + host: { + nickname: "J133", + }, + participant: 1, + maxParticipant: 4, + }, + { + id: 2, + title: "백엔드 초보만 들어올 수 있음", + category: "백엔드", + sessionStatus: "close", + host: { + nickname: "J000", + }, + participant: 1, + maxParticipant: 2, + }, + ]); + const [listLoading, setListLoading] = useState(false); + + return ( +
    +
    +

    스터디 세션 목록

    +
    + + + + +
    +
    +
    +

    공개된 세션 목록

    +
      + {listLoading ? ( + <>loading + ) : ( + <> + {sessionList.length <= 0 ? ( +
    • 아직 아무도 세션을 열지 않았어요..!
    • + ) : ( + sessionList.map((session) => { + return ( + session.sessionStatus === "open" && ( + + ) + ); + }) + )} + + )} +
    +
    +
    +

    진행 중인 세션 목록

    +
      + {listLoading ? ( + <>loading + ) : ( + <> + {sessionList.length <= 0 ? ( +
    • 아직 아무도 세션을 열지 않았어요..!
    • + ) : ( + sessionList.map((session) => { + return ( + session.sessionStatus === "close" && ( + + ) + ); + }) + )} + + )} +
    +
    +
    + ); +}; + +export default SessionListPage; From c1edff1d34367a91b7b1fe2d741e9f0e45f13a84 Mon Sep 17 00:00:00 2001 From: yiseungyun Date: Wed, 6 Nov 2024 22:35:56 +0900 Subject: [PATCH 12/56] =?UTF-8?q?feat:=20WebRTC=EB=A5=BC=20=EC=9D=B4?= =?UTF-8?q?=EC=9A=A9=ED=95=9C=20=ED=99=94=EC=83=81=ED=9A=8C=EC=9D=98=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 방 아이디 같은 걸로 입장 시 화상회의 - 입장 시 방 아이디, 자신의 닉네임 설정 후 입장 --- frontend/package.json | 4 +- frontend/src/App.tsx | 8 +- frontend/src/assets/mike-off.svg | 4 + frontend/src/assets/mike-on.svg | 3 + frontend/src/assets/video_profile.svg | 4 + frontend/src/components/VideoContainer.tsx | 11 + frontend/src/components/VideoRoom.tsx | 358 +++++++++++++++++++++ frontend/src/pages/SessionPage.tsx | 10 + frontend/src/routes.tsx | 7 +- pnpm-lock.yaml | 94 +++++- 10 files changed, 491 insertions(+), 12 deletions(-) create mode 100644 frontend/src/assets/mike-off.svg create mode 100644 frontend/src/assets/mike-on.svg create mode 100644 frontend/src/assets/video_profile.svg create mode 100644 frontend/src/components/VideoContainer.tsx create mode 100644 frontend/src/components/VideoRoom.tsx create mode 100644 frontend/src/pages/SessionPage.tsx diff --git a/frontend/package.json b/frontend/package.json index f1a9e818..99389809 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,10 +12,12 @@ "test": "echo \"Error: no test specified\" && exit 1" }, "dependencies": { + "@types/socket.io-client": "^3.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-icons": "^5.3.0", - "react-router-dom": "^6.27.0" + "react-router-dom": "^6.27.0", + "socket.io-client": "^4.8.1" }, "devDependencies": { "@eslint/js": "^9.13.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ba50a442..682b87e1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,13 +7,7 @@ function App() { const localVideoRef = useRef(null); const remoteVideoRef = useRef(null); // WebRTC 설정 - const configuration = { - iceServers: [ - { - urls: process.env.REACT_STUN_SERVER ?? "stun:stun.l.google.com:19302", - }, - ], - }; + // 변수 선언 diff --git a/frontend/src/assets/mike-off.svg b/frontend/src/assets/mike-off.svg new file mode 100644 index 00000000..c5298003 --- /dev/null +++ b/frontend/src/assets/mike-off.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/mike-on.svg b/frontend/src/assets/mike-on.svg new file mode 100644 index 00000000..46b5f6fd --- /dev/null +++ b/frontend/src/assets/mike-on.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/src/assets/video_profile.svg b/frontend/src/assets/video_profile.svg new file mode 100644 index 00000000..d5a6ab8d --- /dev/null +++ b/frontend/src/assets/video_profile.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/components/VideoContainer.tsx b/frontend/src/components/VideoContainer.tsx new file mode 100644 index 00000000..723edcc1 --- /dev/null +++ b/frontend/src/components/VideoContainer.tsx @@ -0,0 +1,11 @@ + +const VideoContainer = () => { + return ( +
    + + +
    + ) +} + +export default VideoContainer; diff --git a/frontend/src/components/VideoRoom.tsx b/frontend/src/components/VideoRoom.tsx new file mode 100644 index 00000000..6fd0a638 --- /dev/null +++ b/frontend/src/components/VideoRoom.tsx @@ -0,0 +1,358 @@ +import { useEffect, useRef, useState } from 'react'; +import { Socket, io } from 'socket.io-client'; + +interface User { + id: string; + nickname: string; +} + +interface PeerConnection { + peerId: string; // 연결된 상대의 ID + peerNickname: string; // 상대의 닉네임 + stream: MediaStream; // 상대방의 비디오/오디오 스트림 +} + +const VideoRoom = () => { + const [socket, setSocket] = useState(null); + const [myStream, setMyStream] = useState(null); + const [peers, setPeers] = useState([]); // 연결 관리 + const [roomId, setRoomId] = useState(""); + const [nickname, setNickname] = useState(""); + const [isVideoOn, setIsVideoOn] = useState(false) + const [isMicOn, setIsMicOn] = useState(false) + + const myVideoRef = useRef(null); + const peerConnections = useRef<{ [key: string]: RTCPeerConnection }>({}); + const peerVideoRefs = useRef<{ [key: string]: HTMLVideoElement | null }>({}); + + // STUN 서버 설정 + const pcConfig = { + iceServers: [ + { + urls: import.meta.env.STUN_SERVER_URL, + username: import.meta.env.STUN_USER_NAME, + credential: import.meta.env.STUN_CREDENTIAL + } + ] + }; + + useEffect(() => { + // 소켓 연결 + const newSocket = io('http://localhost:3000'); + setSocket(newSocket); + + // 컴포넌트 언마운트 시 정리 + return () => { + Object.values(peerConnections.current).forEach((pc) => pc.close()); + if (myStream) { + myStream.getTracks().forEach(track => track.stop()); + } + newSocket.close(); + }; + }, []); + + useEffect(() => { + // socket 이벤트 리스너들 정리 + // 메모리 누수, 중복 실행을 방지하기 위해 정리 + return () => { + if (socket) { + socket.off("all_users"); + socket.off("getOffer"); + socket.off("getAnswer"); + socket.off("getCandidate"); + socket.off("user_exit"); + } + } + }, [socket]); + + // 미디어 스트림 가져오기: 자신의 스트림을 가져옴 + const getMedia = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: true, + }); + + if (myVideoRef.current) { + myVideoRef.current.srcObject = stream; + } + setMyStream(stream); + return stream; + } catch (error) { + console.error('Error accessing media devices:', error); + } + }; + + // 미디어 스트림 토글 관련 + const handleVideoToggle = () => { + try { + // 비디오 껐다키기 + if (myStream) { + myStream + .getVideoTracks() + .forEach(videoTrack => { + videoTrack.enabled = !videoTrack.enabled + }) + setIsVideoOn(prev => !prev); + } + } catch (error) { + console.error('Error stopping video stream', error) + } + } + + const handleMicToggle = () => { + try { + if (myStream) { + myStream.getAudioTracks() + .forEach(audioTrack => { + audioTrack.enabled = !audioTrack.enabled + }) + setIsMicOn(prev => !prev) + } + } catch (error) { + console.error('Error stopping mic stream', error) + } + } + + + // 방 입장 처리: 사용자가 join room 버튼을 클릭할 때 + const joinRoom = async () => { + if (!socket || !roomId || !nickname) return; + + const stream = await getMedia(); + if (!stream) return; + + socket.emit('join_room', { room: roomId, nickname }); + + // 기존 사용자들의 정보 수신: 방에 있던 사용자들과 createPeerConnection 생성 + socket.on('all_users', (users: User[]) => { + users.forEach(user => { + createPeerConnection(user.id, user.nickname, stream, true); + }); + }); + + // 새로운 Offer 수신: 상대가 통화 요청 + // 발생 시점: 새로운 사용자가 방에 입장했을 때, 기존 사용자가 createOffer를 호출하고 emit했을 때 + socket.on('getOffer', async (data: { sdp: RTCSessionDescription; offerSendID: string; offerSendNickname: string }) => { + // 연결 생성 + const pc = createPeerConnection( + data.offerSendID, + data.offerSendNickname, + stream, + false + ); + if (!pc) return; + + try { + // 상대의 설정 확인하기: 상대의 미디어 형식, 코덱, 해상도 확인 + await pc.setRemoteDescription(new RTCSessionDescription(data.sdp)); + // Answer 생성: 수락 응답 만들기 - 내 미디어 설정 정보 생성, 상대 설정과 호환되는 형태로 생성 + const answer = await pc.createAnswer(); + // 로컬 설명 설정: 생성한 Answer 정보를 내 연결에 적용, 실제 통신 준비 + await pc.setLocalDescription(answer); + + // Answer 전송: 생성한 Answer를 상대에게 전송, 실제 연결 수립 시작 + // emit: 서버로 이벤트 전송 + socket.emit('answer', { + answerReceiveID: data.offerSendID, + sdp: answer, + answerSendID: socket.id, + }); + } catch (error) { + console.error('Error handling offer:', error); + } + }); + + // Answer 수신: 상대방이 보낸 응답 수신, 연결 정보 설정, 실제 통신 준비 완료 + socket.on('getAnswer', async (data: { sdp: RTCSessionDescription; answerSendID: string }) => { + // 상대방과의 연결 정보 찾기 + const pc = peerConnections.current[data.answerSendID]; + if (!pc) return; + try { + // 상대방의 연결 정보 설정 + await pc.setRemoteDescription(new RTCSessionDescription(data.sdp)); + } catch (error) { + console.error('Error handling answer:', error); + } + }); + + // ICE candidate 수신: 새로운 연결 경로 정보 수신, 가능한 연결 경로 목록에 추가, 최적의 경로로 자동 전환 + socket.on('getCandidate', async (data: { candidate: RTCIceCandidate; candidateSendID: string }) => { + // 상대방과의 연결 찾기 + const pc = peerConnections.current[data.candidateSendID]; + if (!pc) return; + try { + // 새로운 연결 경로 추가 + await pc.addIceCandidate(new RTCIceCandidate(data.candidate)); + } catch (error) { + console.error('Error handling ICE candidate:', error); + } + }); + + // 사용자 퇴장 처리 + socket.on('user_exit', ({ id }: { id: string }) => { + if (peerConnections.current[id]) { + // 연결 종료 + peerConnections.current[id].close(); + // 연결 객체 제거 + delete peerConnections.current[id]; + // UI에서 사용자 제거 + setPeers(prev => prev.filter(peer => peer.peerId !== id)); + } + }); + }; + + // Peer Connection 생성 + const createPeerConnection = (peerSocketId: string, peerNickname: string, stream: MediaStream, isOffer: boolean) => { + try { + // 유저 사이의 통신 선로를 생성 + // STUN: 공개 주소를 알려주는 서버 + // ICE: 두 피어 간의 최적의 경로를 찾아줌 + const pc = new RTCPeerConnection(pcConfig); + + // 로컬 스트림 추가: 내 카메라/마이크를 통신 선로(pc)에 연결 + // 상대방에게 나의 비디오/오디오를 전송할 준비 + stream.getTracks().forEach(track => { + pc.addTrack(track, stream); + }); + + // ICE candidate 이벤트 처리 + // 가능한 연결 경로를 찾을 때마다 상대에게 알려줌 + pc.onicecandidate = (e) => { + if (e.candidate && socket) { + socket.emit('candidate', { + candidateReceiveID: peerSocketId, + candidate: e.candidate, + candidateSendID: socket.id, + }); + } + }; + + // 연결 상태 모니터링 + // 새로운 연결/연결 시도/연결 완료/연결 끊김/연결 실패/연결 종료 + pc.onconnectionstatechange = (e) => { + console.log("연결 상태 변경:", pc.connectionState); + } + // ICE 연결 상태 모니터링 + pc.oniceconnectionstatechange = (e) => { + console.log("ICE 연결 상태 변경:", pc.iceConnectionState); + } + + // 원격 스트림 처리(상대가 addTrack을 호출할 때) + // 상대의 비디오/오디오 신호를 받아 연결하는 과정 + // 상대방 스트림 수신 -> 기존 연결인지 확인 -> 스트림 정보 업데이트/추가 + pc.ontrack = (e) => { + console.log('Received remote track:', e.streams[0]); + setPeers(prev => { + // 이미 존재하는 피어인지 확인 + const exists = prev.find(p => p.peerId === peerSocketId); + if (exists) { + // 기존 피어의 스트림 업데이트 + return prev.map(p => + p.peerId === peerSocketId + ? { ...p, stream: e.streams[0] } + : p + ); + } + // 새로운 피어 추가 + return [...prev, { + peerId: peerSocketId, + peerNickname, + stream: e.streams[0] + }]; + }); + }; + + // Offer를 생성해야 하는 경우에만 Offer 생성 + // Offer: 초대 - Offer 생성 -> 자신의 설정 저장 -> 상대에게 전송 + if (isOffer) { + pc.createOffer() + .then(offer => pc.setLocalDescription(offer)) + .then(() => { + if (socket && pc.localDescription) { + socket.emit('offer', { + offerReceiveID: peerSocketId, + sdp: pc.localDescription, + offerSendID: socket.id, + offerSendNickname: nickname, + }); + } + }) + .catch(error => console.error('Error creating offer:', error)); + } + + peerConnections.current[peerSocketId] = pc; + return pc; + } catch (error) { + console.error('Error creating peer connection:', error); + return null; + } + }; + + return ( +
    +
    + setRoomId(e.target.value)} + className="border p-2 mr-2" + /> + setNickname(e.target.value)} + className="border p-2 mr-2" + /> + +
    + +
    +
    +
    + + { + // 상대방의 비디오 표시 + peers.map((peer) => ( +
    +
    + )) + } +
    +
    + ); +}; + +export default VideoRoom; \ No newline at end of file diff --git a/frontend/src/pages/SessionPage.tsx b/frontend/src/pages/SessionPage.tsx new file mode 100644 index 00000000..115b2ad0 --- /dev/null +++ b/frontend/src/pages/SessionPage.tsx @@ -0,0 +1,10 @@ +import VideoRoom from '../components/VideoRoom' + +const SessionPage = () => { + + return <> + + +} + +export default SessionPage \ No newline at end of file diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index d9391e4b..4f160c1d 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -1,16 +1,19 @@ import App from "./App.tsx"; +import VideoContainer from "./components/VideoContainer.tsx"; import SessionListPage from "./pages/SessionListPage.tsx"; +import SessionPage from './pages/SessionPage' + export const routes = [ { element: , path: "/", }, { - element: <>화상회의 페이지, + element: , path: '/session/:sessionId' }, { - element: , + element: , path: '/sessions' }, { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c822aa5..fa0c0d4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,7 +44,7 @@ importers: version: 10.2.3(chokidar@3.6.0)(typescript@5.6.3) '@nestjs/testing': specifier: ^10.0.0 - version: 10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)) + version: 10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)(@nestjs/platform-express@10.4.6) '@types/express': specifier: ^5.0.0 version: 5.0.0 @@ -102,6 +102,9 @@ importers: frontend: dependencies: + '@types/socket.io-client': + specifier: ^3.0.0 + version: 3.0.0 react: specifier: ^18.3.1 version: 18.3.1 @@ -114,6 +117,9 @@ importers: react-router-dom: specifier: ^6.27.0 version: 6.27.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + socket.io-client: + specifier: ^4.8.1 + version: 4.8.1 devDependencies: '@eslint/js': specifier: ^9.13.0 @@ -959,6 +965,9 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@tsconfig/node10@1.0.11': resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} @@ -1055,6 +1064,10 @@ packages: '@types/serve-static@1.15.7': resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==} + '@types/socket.io-client@3.0.0': + resolution: {integrity: sha512-s+IPvFoEIjKA3RdJz/Z2dGR4gLgysKi8owcnrVwNjgvc01Lk68LJDDsG2GRqegFITcxmvCMYM7bhMpwEMlHmDg==} + deprecated: This is a stub types definition. socket.io-client provides its own type definitions, so you do not need this installed. + '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -1718,6 +1731,13 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + engine.io-client@6.6.2: + resolution: {integrity: sha512-TAr+NKeoVTjEVW8P3iHguO1LO6RlUz9O5Y8o7EY0fU+gY1NYqas7NN3slpFtbXEsLMHk0h90fJMfKjRkQ0qUIw==} + + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + enhanced-resolve@5.17.1: resolution: {integrity: sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==} engines: {node: '>=10.13.0'} @@ -3128,6 +3148,14 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + socket.io-client@4.8.1: + resolution: {integrity: sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==} + engines: {node: '>=10.0.0'} + + socket.io-parser@4.2.4: + resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==} + engines: {node: '>=10.0.0'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3565,6 +3593,22 @@ packages: resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + ws@8.17.1: + resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xmlhttprequest-ssl@2.1.2: + resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} + engines: {node: '>=0.4.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -4425,7 +4469,7 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/testing@10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6))': + '@nestjs/testing@10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)(@nestjs/platform-express@10.4.6)': dependencies: '@nestjs/common': 10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/core': 10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -4524,6 +4568,8 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@socket.io/component-emitter@3.1.2': {} + '@tsconfig/node10@1.0.11': {} '@tsconfig/node12@1.0.11': {} @@ -4641,6 +4687,14 @@ snapshots: '@types/node': 20.17.4 '@types/send': 0.17.4 + '@types/socket.io-client@3.0.0': + dependencies: + socket.io-client: 4.8.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@types/stack-utils@2.0.3': {} '@types/superagent@8.1.9': @@ -5404,6 +5458,20 @@ snapshots: encodeurl@2.0.0: {} + engine.io-client@6.6.2: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.3.7 + engine.io-parser: 5.2.3 + ws: 8.17.1 + xmlhttprequest-ssl: 2.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + engine.io-parser@5.2.3: {} + enhanced-resolve@5.17.1: dependencies: graceful-fs: 4.2.11 @@ -7061,6 +7129,24 @@ snapshots: slash@3.0.0: {} + socket.io-client@4.8.1: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.3.7 + engine.io-client: 6.6.2 + socket.io-parser: 4.2.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.4: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.3.7 + transitivePeerDependencies: + - supports-color + source-map-js@1.2.1: {} source-map-support@0.5.13: @@ -7493,6 +7579,10 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 3.0.7 + ws@8.17.1: {} + + xmlhttprequest-ssl@2.1.2: {} + xtend@4.0.2: {} y18n@5.0.8: {} From 2e99067052a42dd20c53ffb9e05e036a0cfc6304 Mon Sep 17 00:00:00 2001 From: JeongwooSeo <98446924+ShipFriend0516@users.noreply.github.com> Date: Wed, 6 Nov 2024 23:36:46 +0900 Subject: [PATCH 13/56] =?UTF-8?q?feat:=20=EB=B9=84=EB=94=94=EC=98=A4=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/VideoContainer.tsx | 48 +++- frontend/src/components/VideoRoom.tsx | 271 +++++++++++---------- pnpm-lock.yaml | 154 +++++++++++- 3 files changed, 329 insertions(+), 144 deletions(-) diff --git a/frontend/src/components/VideoContainer.tsx b/frontend/src/components/VideoContainer.tsx index 723edcc1..a8fc94a9 100644 --- a/frontend/src/components/VideoContainer.tsx +++ b/frontend/src/components/VideoContainer.tsx @@ -1,11 +1,45 @@ +import React, { forwardRef } from "react"; +import { + BsMic, + BsMicMute, + BsCameraVideo, + BsCameraVideoOff, +} from "react-icons/bs"; -const VideoContainer = () => { - return ( -
    - - -
    - ) +interface VideoContainerProps { + nickname: string; + isMicOn: boolean; + isVideoOn: boolean; } +const VideoContainer = forwardRef( + ( + { nickname, isMicOn, isVideoOn }: VideoContainerProps, + ref: React.Ref + ) => { + return ( +
    +
    + ); + } +); + export default VideoContainer; diff --git a/frontend/src/components/VideoRoom.tsx b/frontend/src/components/VideoRoom.tsx index 6fd0a638..6e0e93df 100644 --- a/frontend/src/components/VideoRoom.tsx +++ b/frontend/src/components/VideoRoom.tsx @@ -1,5 +1,6 @@ -import { useEffect, useRef, useState } from 'react'; -import { Socket, io } from 'socket.io-client'; +import { useEffect, useRef, useState } from "react"; +import { Socket, io } from "socket.io-client"; +import VideoContainer from "./VideoContainer.tsx"; interface User { id: string; @@ -18,8 +19,8 @@ const VideoRoom = () => { const [peers, setPeers] = useState([]); // 연결 관리 const [roomId, setRoomId] = useState(""); const [nickname, setNickname] = useState(""); - const [isVideoOn, setIsVideoOn] = useState(false) - const [isMicOn, setIsMicOn] = useState(false) + const [isVideoOn, setIsVideoOn] = useState(false); + const [isMicOn, setIsMicOn] = useState(false); const myVideoRef = useRef(null); const peerConnections = useRef<{ [key: string]: RTCPeerConnection }>({}); @@ -29,23 +30,23 @@ const VideoRoom = () => { const pcConfig = { iceServers: [ { - urls: import.meta.env.STUN_SERVER_URL, - username: import.meta.env.STUN_USER_NAME, - credential: import.meta.env.STUN_CREDENTIAL - } - ] + urls: import.meta.env.VITE_STUN_SERVER_URL, + username: import.meta.env.VITE_STUN_USER_NAME, + credential: import.meta.env.VITE_STUN_CREDENTIAL, + }, + ], }; useEffect(() => { // 소켓 연결 - const newSocket = io('http://localhost:3000'); + const newSocket = io("http://localhost:3000"); setSocket(newSocket); // 컴포넌트 언마운트 시 정리 return () => { Object.values(peerConnections.current).forEach((pc) => pc.close()); if (myStream) { - myStream.getTracks().forEach(track => track.stop()); + myStream.getTracks().forEach((track) => track.stop()); } newSocket.close(); }; @@ -62,7 +63,7 @@ const VideoRoom = () => { socket.off("getCandidate"); socket.off("user_exit"); } - } + }; }, [socket]); // 미디어 스트림 가져오기: 자신의 스트림을 가져옴 @@ -79,7 +80,7 @@ const VideoRoom = () => { setMyStream(stream); return stream; } catch (error) { - console.error('Error accessing media devices:', error); + console.error("Error accessing media devices:", error); } }; @@ -88,32 +89,28 @@ const VideoRoom = () => { try { // 비디오 껐다키기 if (myStream) { - myStream - .getVideoTracks() - .forEach(videoTrack => { - videoTrack.enabled = !videoTrack.enabled - }) - setIsVideoOn(prev => !prev); + myStream.getVideoTracks().forEach((videoTrack) => { + videoTrack.enabled = !videoTrack.enabled; + }); + setIsVideoOn((prev) => !prev); } } catch (error) { - console.error('Error stopping video stream', error) + console.error("Error stopping video stream", error); } - } + }; const handleMicToggle = () => { try { if (myStream) { - myStream.getAudioTracks() - .forEach(audioTrack => { - audioTrack.enabled = !audioTrack.enabled - }) - setIsMicOn(prev => !prev) + myStream.getAudioTracks().forEach((audioTrack) => { + audioTrack.enabled = !audioTrack.enabled; + }); + setIsMicOn((prev) => !prev); } } catch (error) { - console.error('Error stopping mic stream', error) + console.error("Error stopping mic stream", error); } - } - + }; // 방 입장 처리: 사용자가 join room 버튼을 클릭할 때 const joinRoom = async () => { @@ -122,88 +119,106 @@ const VideoRoom = () => { const stream = await getMedia(); if (!stream) return; - socket.emit('join_room', { room: roomId, nickname }); + socket.emit("join_room", { room: roomId, nickname }); // 기존 사용자들의 정보 수신: 방에 있던 사용자들과 createPeerConnection 생성 - socket.on('all_users', (users: User[]) => { - users.forEach(user => { + socket.on("all_users", (users: User[]) => { + users.forEach((user) => { createPeerConnection(user.id, user.nickname, stream, true); }); }); // 새로운 Offer 수신: 상대가 통화 요청 // 발생 시점: 새로운 사용자가 방에 입장했을 때, 기존 사용자가 createOffer를 호출하고 emit했을 때 - socket.on('getOffer', async (data: { sdp: RTCSessionDescription; offerSendID: string; offerSendNickname: string }) => { - // 연결 생성 - const pc = createPeerConnection( - data.offerSendID, - data.offerSendNickname, - stream, - false - ); - if (!pc) return; - - try { - // 상대의 설정 확인하기: 상대의 미디어 형식, 코덱, 해상도 확인 - await pc.setRemoteDescription(new RTCSessionDescription(data.sdp)); - // Answer 생성: 수락 응답 만들기 - 내 미디어 설정 정보 생성, 상대 설정과 호환되는 형태로 생성 - const answer = await pc.createAnswer(); - // 로컬 설명 설정: 생성한 Answer 정보를 내 연결에 적용, 실제 통신 준비 - await pc.setLocalDescription(answer); - - // Answer 전송: 생성한 Answer를 상대에게 전송, 실제 연결 수립 시작 - // emit: 서버로 이벤트 전송 - socket.emit('answer', { - answerReceiveID: data.offerSendID, - sdp: answer, - answerSendID: socket.id, - }); - } catch (error) { - console.error('Error handling offer:', error); + socket.on( + "getOffer", + async (data: { + sdp: RTCSessionDescription; + offerSendID: string; + offerSendNickname: string; + }) => { + // 연결 생성 + const pc = createPeerConnection( + data.offerSendID, + data.offerSendNickname, + stream, + false + ); + if (!pc) return; + + try { + // 상대의 설정 확인하기: 상대의 미디어 형식, 코덱, 해상도 확인 + await pc.setRemoteDescription(new RTCSessionDescription(data.sdp)); + // Answer 생성: 수락 응답 만들기 - 내 미디어 설정 정보 생성, 상대 설정과 호환되는 형태로 생성 + const answer = await pc.createAnswer(); + // 로컬 설명 설정: 생성한 Answer 정보를 내 연결에 적용, 실제 통신 준비 + await pc.setLocalDescription(answer); + + // Answer 전송: 생성한 Answer를 상대에게 전송, 실제 연결 수립 시작 + // emit: 서버로 이벤트 전송 + socket.emit("answer", { + answerReceiveID: data.offerSendID, + sdp: answer, + answerSendID: socket.id, + }); + } catch (error) { + console.error("Error handling offer:", error); + } } - }); + ); // Answer 수신: 상대방이 보낸 응답 수신, 연결 정보 설정, 실제 통신 준비 완료 - socket.on('getAnswer', async (data: { sdp: RTCSessionDescription; answerSendID: string }) => { - // 상대방과의 연결 정보 찾기 - const pc = peerConnections.current[data.answerSendID]; - if (!pc) return; - try { - // 상대방의 연결 정보 설정 - await pc.setRemoteDescription(new RTCSessionDescription(data.sdp)); - } catch (error) { - console.error('Error handling answer:', error); + socket.on( + "getAnswer", + async (data: { sdp: RTCSessionDescription; answerSendID: string }) => { + // 상대방과의 연결 정보 찾기 + const pc = peerConnections.current[data.answerSendID]; + if (!pc) return; + try { + // 상대방의 연결 정보 설정 + await pc.setRemoteDescription(new RTCSessionDescription(data.sdp)); + } catch (error) { + console.error("Error handling answer:", error); + } } - }); + ); // ICE candidate 수신: 새로운 연결 경로 정보 수신, 가능한 연결 경로 목록에 추가, 최적의 경로로 자동 전환 - socket.on('getCandidate', async (data: { candidate: RTCIceCandidate; candidateSendID: string }) => { - // 상대방과의 연결 찾기 - const pc = peerConnections.current[data.candidateSendID]; - if (!pc) return; - try { - // 새로운 연결 경로 추가 - await pc.addIceCandidate(new RTCIceCandidate(data.candidate)); - } catch (error) { - console.error('Error handling ICE candidate:', error); + socket.on( + "getCandidate", + async (data: { candidate: RTCIceCandidate; candidateSendID: string }) => { + // 상대방과의 연결 찾기 + const pc = peerConnections.current[data.candidateSendID]; + if (!pc) return; + try { + // 새로운 연결 경로 추가 + await pc.addIceCandidate(new RTCIceCandidate(data.candidate)); + } catch (error) { + console.error("Error handling ICE candidate:", error); + } } - }); + ); // 사용자 퇴장 처리 - socket.on('user_exit', ({ id }: { id: string }) => { + socket.on("user_exit", ({ id }: { id: string }) => { if (peerConnections.current[id]) { // 연결 종료 peerConnections.current[id].close(); // 연결 객체 제거 delete peerConnections.current[id]; // UI에서 사용자 제거 - setPeers(prev => prev.filter(peer => peer.peerId !== id)); + setPeers((prev) => prev.filter((peer) => peer.peerId !== id)); } }); }; // Peer Connection 생성 - const createPeerConnection = (peerSocketId: string, peerNickname: string, stream: MediaStream, isOffer: boolean) => { + const createPeerConnection = ( + peerSocketId: string, + peerNickname: string, + stream: MediaStream, + isOffer: boolean + ) => { try { // 유저 사이의 통신 선로를 생성 // STUN: 공개 주소를 알려주는 서버 @@ -212,7 +227,7 @@ const VideoRoom = () => { // 로컬 스트림 추가: 내 카메라/마이크를 통신 선로(pc)에 연결 // 상대방에게 나의 비디오/오디오를 전송할 준비 - stream.getTracks().forEach(track => { + stream.getTracks().forEach((track) => { pc.addTrack(track, stream); }); @@ -220,7 +235,7 @@ const VideoRoom = () => { // 가능한 연결 경로를 찾을 때마다 상대에게 알려줌 pc.onicecandidate = (e) => { if (e.candidate && socket) { - socket.emit('candidate', { + socket.emit("candidate", { candidateReceiveID: peerSocketId, candidate: e.candidate, candidateSendID: socket.id, @@ -232,34 +247,35 @@ const VideoRoom = () => { // 새로운 연결/연결 시도/연결 완료/연결 끊김/연결 실패/연결 종료 pc.onconnectionstatechange = (e) => { console.log("연결 상태 변경:", pc.connectionState); - } + }; // ICE 연결 상태 모니터링 pc.oniceconnectionstatechange = (e) => { console.log("ICE 연결 상태 변경:", pc.iceConnectionState); - } + }; // 원격 스트림 처리(상대가 addTrack을 호출할 때) // 상대의 비디오/오디오 신호를 받아 연결하는 과정 // 상대방 스트림 수신 -> 기존 연결인지 확인 -> 스트림 정보 업데이트/추가 pc.ontrack = (e) => { - console.log('Received remote track:', e.streams[0]); - setPeers(prev => { + console.log("Received remote track:", e.streams[0]); + setPeers((prev) => { // 이미 존재하는 피어인지 확인 - const exists = prev.find(p => p.peerId === peerSocketId); + const exists = prev.find((p) => p.peerId === peerSocketId); if (exists) { // 기존 피어의 스트림 업데이트 - return prev.map(p => - p.peerId === peerSocketId - ? { ...p, stream: e.streams[0] } - : p + return prev.map((p) => + p.peerId === peerSocketId ? { ...p, stream: e.streams[0] } : p ); } // 새로운 피어 추가 - return [...prev, { - peerId: peerSocketId, - peerNickname, - stream: e.streams[0] - }]; + return [ + ...prev, + { + peerId: peerSocketId, + peerNickname, + stream: e.streams[0], + }, + ]; }); }; @@ -267,10 +283,10 @@ const VideoRoom = () => { // Offer: 초대 - Offer 생성 -> 자신의 설정 저장 -> 상대에게 전송 if (isOffer) { pc.createOffer() - .then(offer => pc.setLocalDescription(offer)) + .then((offer) => pc.setLocalDescription(offer)) .then(() => { if (socket && pc.localDescription) { - socket.emit('offer', { + socket.emit("offer", { offerReceiveID: peerSocketId, sdp: pc.localDescription, offerSendID: socket.id, @@ -278,13 +294,13 @@ const VideoRoom = () => { }); } }) - .catch(error => console.error('Error creating offer:', error)); + .catch((error) => console.error("Error creating offer:", error)); } peerConnections.current[peerSocketId] = pc; return pc; } catch (error) { - console.error('Error creating peer connection:', error); + console.error("Error creating peer connection:", error); return null; } }; @@ -315,39 +331,28 @@ const VideoRoom = () => {
    -
    -
    + { // 상대방의 비디오 표시 peers.map((peer) => ( -
    -
    + { + // 비디오 엘리먼트가 있고, 스트림이 있을 때 + if (el && peer.stream) { + el.srcObject = peer.stream; + } + peerVideoRefs.current[peer.peerId] = el; + }} + nickname={peer.peerNickname} + isMicOn={true} + isVideoOn={true} + /> )) }
    @@ -355,4 +360,4 @@ const VideoRoom = () => { ); }; -export default VideoRoom; \ No newline at end of file +export default VideoRoom; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa0c0d4b..b4d3607d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,16 +25,25 @@ importers: version: 10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/core': specifier: ^10.0.0 - version: 10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(reflect-metadata@0.2.2)(rxjs@7.8.1) + version: 10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/platform-express': specifier: ^10.0.0 version: 10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6) + '@nestjs/platform-socket.io': + specifier: ^10.4.6 + version: 10.4.7(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@10.4.7)(rxjs@7.8.1) + '@nestjs/websockets': + specifier: ^10.4.6 + version: 10.4.7(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)(@nestjs/platform-socket.io@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1) reflect-metadata: specifier: ^0.2.0 version: 0.2.2 rxjs: specifier: ^7.8.1 version: 7.8.1 + socket.io: + specifier: ^4.8.1 + version: 4.8.1 devDependencies: '@nestjs/cli': specifier: ^10.0.0 @@ -819,6 +828,13 @@ packages: '@nestjs/common': ^10.0.0 '@nestjs/core': ^10.0.0 + '@nestjs/platform-socket.io@10.4.7': + resolution: {integrity: sha512-CpmrqswpD/O4SyF/IUzKj14BUf0eTLyDja9svPCRIJX8AdF47mKCMbz5vtU6vpJtxVnq1e1Xd+xcdZ6FIf6HtQ==} + peerDependencies: + '@nestjs/common': ^10.0.0 + '@nestjs/websockets': ^10.0.0 + rxjs: ^7.1.0 + '@nestjs/schematics@10.2.3': resolution: {integrity: sha512-4e8gxaCk7DhBxVUly2PjYL4xC2ifDFexCqq1/u4TtivLGXotVk0wHdYuPYe1tHTHuR1lsOkRbfOCpkdTnigLVg==} peerDependencies: @@ -837,6 +853,18 @@ packages: '@nestjs/platform-express': optional: true + '@nestjs/websockets@10.4.7': + resolution: {integrity: sha512-ajuoptYLYm+l3+KtaA9Ed+cO9yB34PtBE8UObavRT8Euh/f7QfeJiKcrU3+BQSAiTWM3nF2qfuV4CfEkP9uKuw==} + peerDependencies: + '@nestjs/common': ^10.0.0 + '@nestjs/core': ^10.0.0 + '@nestjs/platform-socket.io': ^10.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/platform-socket.io': + optional: true + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1001,9 +1029,15 @@ packages: '@types/conventional-commits-parser@5.0.0': resolution: {integrity: sha512-loB369iXNmAZglwWATL+WRe+CRMmmBPtpolYzIebFaX4YA3x+BEfLqhUAV9WanycKI3TG1IMr5bMJDajDKLlUQ==} + '@types/cookie@0.4.1': + resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==} + '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/cors@2.8.17': + resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==} + '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} @@ -1355,6 +1389,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + base64id@2.0.0: + resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} + engines: {node: ^4.5.0 || >= 5.9} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -1559,6 +1597,10 @@ packages: resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} engines: {node: '>= 0.6'} + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cookiejar@2.1.4: resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} @@ -1738,6 +1780,10 @@ packages: resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} engines: {node: '>=10.0.0'} + engine.io@6.6.2: + resolution: {integrity: sha512-gmNvsYi9C8iErnZdVcJnvCpSKbWTt1E8+JZo8b+daLninywUWi5NQ5STSHZ9rFjFO7imNcvb8Pc5pe/wMR5xEw==} + engines: {node: '>=10.2.0'} + enhanced-resolve@5.17.1: resolution: {integrity: sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==} engines: {node: '>=10.13.0'} @@ -3148,6 +3194,9 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + socket.io-adapter@2.5.5: + resolution: {integrity: sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==} + socket.io-client@4.8.1: resolution: {integrity: sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==} engines: {node: '>=10.0.0'} @@ -3156,6 +3205,14 @@ packages: resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==} engines: {node: '>=10.0.0'} + socket.io@4.8.0: + resolution: {integrity: sha512-8U6BEgGjQOfGz3HHTYaC/L1GaxDCJ/KM0XTkJly0EhZ5U/du9uNEZy4ZgYzEzIqlx2CMm25CrCqr1ck899eLNA==} + engines: {node: '>=10.2.0'} + + socket.io@4.8.1: + resolution: {integrity: sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==} + engines: {node: '>=10.2.0'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -4419,7 +4476,7 @@ snapshots: tslib: 2.7.0 uid: 2.0.2 - '@nestjs/core@10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(reflect-metadata@0.2.2)(rxjs@7.8.1)': + '@nestjs/core@10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1)': dependencies: '@nestjs/common': 10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nuxtjs/opencollective': 0.3.2 @@ -4432,13 +4489,14 @@ snapshots: uid: 2.0.2 optionalDependencies: '@nestjs/platform-express': 10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6) + '@nestjs/websockets': 10.4.7(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)(@nestjs/platform-socket.io@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1) transitivePeerDependencies: - encoding '@nestjs/platform-express@10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)': dependencies: '@nestjs/common': 10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1) body-parser: 1.20.3 cors: 2.8.5 express: 4.21.1 @@ -4447,6 +4505,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@nestjs/platform-socket.io@10.4.7(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@10.4.7)(rxjs@7.8.1)': + dependencies: + '@nestjs/common': 10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/websockets': 10.4.7(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)(@nestjs/platform-socket.io@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1) + rxjs: 7.8.1 + socket.io: 4.8.0 + tslib: 2.7.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@nestjs/schematics@10.2.3(chokidar@3.6.0)(typescript@5.3.3)': dependencies: '@angular-devkit/core': 17.3.11(chokidar@3.6.0) @@ -4472,11 +4542,23 @@ snapshots: '@nestjs/testing@10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)(@nestjs/platform-express@10.4.6)': dependencies: '@nestjs/common': 10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1) tslib: 2.7.0 optionalDependencies: '@nestjs/platform-express': 10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6) + '@nestjs/websockets@10.4.7(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)(@nestjs/platform-socket.io@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1)': + dependencies: + '@nestjs/common': 10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.4.6(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1) + iterare: 1.2.1 + object-hash: 3.0.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.1 + tslib: 2.7.0 + optionalDependencies: + '@nestjs/platform-socket.io': 10.4.7(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@10.4.7)(rxjs@7.8.1) + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -4612,8 +4694,14 @@ snapshots: dependencies: '@types/node': 20.17.4 + '@types/cookie@0.4.1': {} + '@types/cookiejar@2.1.5': {} + '@types/cors@2.8.17': + dependencies: + '@types/node': 20.17.4 + '@types/estree@1.0.6': {} '@types/express-serve-static-core@5.0.1': @@ -5114,6 +5202,8 @@ snapshots: base64-js@1.5.1: {} + base64id@2.0.0: {} + binary-extensions@2.3.0: {} bl@4.1.0: @@ -5322,6 +5412,8 @@ snapshots: cookie@0.7.1: {} + cookie@0.7.2: {} + cookiejar@2.1.4: {} core-util-is@1.0.3: {} @@ -5472,6 +5564,23 @@ snapshots: engine.io-parser@5.2.3: {} + engine.io@6.6.2: + dependencies: + '@types/cookie': 0.4.1 + '@types/cors': 2.8.17 + '@types/node': 20.17.4 + accepts: 1.3.8 + base64id: 2.0.0 + cookie: 0.7.2 + cors: 2.8.5 + debug: 4.3.7 + engine.io-parser: 5.2.3 + ws: 8.17.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + enhanced-resolve@5.17.1: dependencies: graceful-fs: 4.2.11 @@ -7129,6 +7238,15 @@ snapshots: slash@3.0.0: {} + socket.io-adapter@2.5.5: + dependencies: + debug: 4.3.7 + ws: 8.17.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + socket.io-client@4.8.1: dependencies: '@socket.io/component-emitter': 3.1.2 @@ -7147,6 +7265,34 @@ snapshots: transitivePeerDependencies: - supports-color + socket.io@4.8.0: + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.5 + debug: 4.3.7 + engine.io: 6.6.2 + socket.io-adapter: 2.5.5 + socket.io-parser: 4.2.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io@4.8.1: + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.5 + debug: 4.3.7 + engine.io: 6.6.2 + socket.io-adapter: 2.5.5 + socket.io-parser: 4.2.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + source-map-js@1.2.1: {} source-map-support@0.5.13: From e6da3496e5d1b4306689e8ce90ce1f357d40dd2d Mon Sep 17 00:00:00 2001 From: JeongwooSeo <98446924+ShipFriend0516@users.noreply.github.com> Date: Wed, 6 Nov 2024 23:42:27 +0900 Subject: [PATCH 14/56] =?UTF-8?q?feat:=20=EB=B9=84=EB=94=94=EC=98=A4,=20?= =?UTF-8?q?=EB=A7=88=EC=9D=B4=ED=81=AC=20=ED=86=A0=EA=B8=80=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/VideoRoom.tsx | 32 +++++++++++++++++++++------ 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/VideoRoom.tsx b/frontend/src/components/VideoRoom.tsx index 6e0e93df..3d03fe06 100644 --- a/frontend/src/components/VideoRoom.tsx +++ b/frontend/src/components/VideoRoom.tsx @@ -1,6 +1,12 @@ import { useEffect, useRef, useState } from "react"; import { Socket, io } from "socket.io-client"; import VideoContainer from "./VideoContainer.tsx"; +import { + BsMic, + BsMicMute, + BsCameraVideo, + BsCameraVideoOff, +} from "react-icons/bs"; interface User { id: string; @@ -92,8 +98,8 @@ const VideoRoom = () => { myStream.getVideoTracks().forEach((videoTrack) => { videoTrack.enabled = !videoTrack.enabled; }); - setIsVideoOn((prev) => !prev); } + setIsVideoOn((prev) => !prev); } catch (error) { console.error("Error stopping video stream", error); } @@ -105,8 +111,8 @@ const VideoRoom = () => { myStream.getAudioTracks().forEach((audioTrack) => { audioTrack.enabled = !audioTrack.enabled; }); - setIsMicOn((prev) => !prev); } + setIsMicOn((prev) => !prev); } catch (error) { console.error("Error stopping mic stream", error); } @@ -245,11 +251,11 @@ const VideoRoom = () => { // 연결 상태 모니터링 // 새로운 연결/연결 시도/연결 완료/연결 끊김/연결 실패/연결 종료 - pc.onconnectionstatechange = (e) => { + pc.onconnectionstatechange = () => { console.log("연결 상태 변경:", pc.connectionState); }; // ICE 연결 상태 모니터링 - pc.oniceconnectionstatechange = (e) => { + pc.oniceconnectionstatechange = () => { console.log("ICE 연결 상태 변경:", pc.iceConnectionState); }; @@ -307,7 +313,7 @@ const VideoRoom = () => { return (
    -
    +
    { > Join Room + +
    { From 177efb47f4aac6b4162e7d769ac82d644195f5a5 Mon Sep 17 00:00:00 2001 From: JeongwooSeo <98446924+ShipFriend0516@users.noreply.github.com> Date: Wed, 6 Nov 2024 23:45:55 +0900 Subject: [PATCH 15/56] =?UTF-8?q?refactor:=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/SessionListPage.tsx | 58 ++++++++++++-------------- 1 file changed, 26 insertions(+), 32 deletions(-) diff --git a/frontend/src/pages/SessionListPage.tsx b/frontend/src/pages/SessionListPage.tsx index 9b7e135a..cc86a279 100644 --- a/frontend/src/pages/SessionListPage.tsx +++ b/frontend/src/pages/SessionListPage.tsx @@ -13,6 +13,11 @@ interface Session { participant: number; maxParticipant: number; } +enum SessionStatus { + OPEN = "open", + CLOSE = "close", +} + const SessionListPage = () => { const [sessionList, setSessionList] = useState([ { @@ -40,6 +45,25 @@ const SessionListPage = () => { ]); const [listLoading, setListLoading] = useState(false); + const renderSessionList = (sessionStatus: SessionStatus) => { + return sessionList.map((session) => { + return ( + session.sessionStatus === sessionStatus && ( + + ) + ); + }); + }; + return (
    { {sessionList.length <= 0 ? (
  • 아직 아무도 세션을 열지 않았어요..!
  • ) : ( - sessionList.map((session) => { - return ( - session.sessionStatus === "open" && ( - - ) - ); - }) + renderSessionList(SessionStatus.OPEN) )} )} @@ -107,22 +116,7 @@ const SessionListPage = () => { {sessionList.length <= 0 ? (
  • 아직 아무도 세션을 열지 않았어요..!
  • ) : ( - sessionList.map((session) => { - return ( - session.sessionStatus === "close" && ( - - ) - ); - }) + renderSessionList(SessionStatus.CLOSE) )} )} From ce41c79a8da61a7f2fdcad2235218eb4d680f984 Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Thu, 7 Nov 2024 11:43:23 +0900 Subject: [PATCH 16/56] =?UTF-8?q?fix:=20Eslint=20=EA=B7=9C=EC=B9=99?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 3 +- frontend/src/components/VideoContainer.tsx | 2 +- frontend/src/components/VideoRoom.tsx | 8 ++-- frontend/src/main.tsx | 2 +- frontend/src/pages/SessionListPage.tsx | 56 ++++++++++++---------- frontend/src/routes.tsx | 15 +++--- 6 files changed, 46 insertions(+), 40 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 682b87e1..7d9d8ea7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,14 +1,13 @@ import { useRef, useState } from "react"; function App() { - const [startButtonActive, setStartButtonActive] = useState(true); + const [startButtonActive] = useState(true); const [status, setStatus] = useState("연결 대기중"); const localVideoRef = useRef(null); const remoteVideoRef = useRef(null); // WebRTC 설정 - // 변수 선언 const [localStream, setLocalStream] = useState(null); diff --git a/frontend/src/components/VideoContainer.tsx b/frontend/src/components/VideoContainer.tsx index a8fc94a9..ef2f64f8 100644 --- a/frontend/src/components/VideoContainer.tsx +++ b/frontend/src/components/VideoContainer.tsx @@ -18,7 +18,7 @@ const VideoContainer = forwardRef( ref: React.Ref ) => { return ( -
    +
    ); }; From 523bcdae04663481b71914d898ee558866abb614 Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Thu, 7 Nov 2024 20:54:14 +0900 Subject: [PATCH 31/56] =?UTF-8?q?style:=20=EA=B0=81=20=EB=B9=84=EB=94=94?= =?UTF-8?q?=EC=98=A4=EA=B0=80=20=EA=B0=99=EC=9D=80=20=EA=B3=B5=EA=B0=84?= =?UTF-8?q?=EC=9D=84=20=EC=B0=A8=EC=A7=80=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/VideoContainer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/VideoContainer.tsx b/frontend/src/components/VideoContainer.tsx index ced762e3..4c5ae129 100644 --- a/frontend/src/components/VideoContainer.tsx +++ b/frontend/src/components/VideoContainer.tsx @@ -19,7 +19,7 @@ const VideoContainer = forwardRef( ref: React.Ref ) => { return ( -
    +
    */} - - {/*
    */} - {/* */} - - {/* {*/} - {/* // 상대방의 비디오 표시*/} - {/* peers.map((peer) => (*/} - {/* {*/} - {/* // 비디오 엘리먼트가 있고, 스트림이 있을 때*/} - {/* if (el && peer.stream) {*/} - {/* el.srcObject = peer.stream;*/} - {/* }*/} - {/* peerVideoRefs.current[peer.peerId] = el;*/} - {/* }}*/} - {/* nickname={peer.peerNickname}*/} - {/* isMicOn={true}*/} - {/* isVideoOn={true}*/} - {/* isLocal={false}*/} - {/* />*/} - {/* ))*/} - {/* }*/}
    Date: Sun, 10 Nov 2024 00:21:13 +0900 Subject: [PATCH 37/56] =?UTF-8?q?style:=20borderRadius,=20boxShadow=20?= =?UTF-8?q?=EA=B0=92=20=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=ED=8F=B0=ED=8A=B8?= =?UTF-8?q?=20=EB=8B=A8=EC=9C=84=20px=EC=97=90=EC=84=9C=20rem=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/tailwind.config.js | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 04bb7298..10f3917e 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -34,27 +34,34 @@ export default { 3: '#2572E6' } }, + borderRadius: { + 's': '0.25rem', + 'm': '0.5rem', + 'xl': '1rem' + }, + boxShadow: { + '8': '0 0 0.2rem 0.125rem rgba(182, 182, 182, 0.08)' + }, fontSize: { // Bold(700) sizes - 'bold-l': ['30px', { lineHeight: 'auto', fontWeight: '700' }], - 'bold-m': ['26px', { lineHeight: 'auto', fontWeight: '700' }], - 'bold-r': ['20px', { lineHeight: 'auto', fontWeight: '700' }], + 'bold-l': ['1.625rem', { lineHeight: 'auto', fontWeight: '700' }], + 'bold-m': ['1.5rem', { lineHeight: 'auto', fontWeight: '700' }], + 'bold-r': ['1.25rem', { lineHeight: 'auto', fontWeight: '700' }], // SemiBold(600) sizes - 'semibold-xl': ['26px', { lineHeight: 'auto', fontWeight: '600' }], - 'semibold-l': ['24px', { lineHeight: 'auto', fontWeight: '600' }], - 'semibold-m': ['22px', { lineHeight: 'auto', fontWeight: '600' }], - 'semibold-r': ['20px', { lineHeight: 'auto', fontWeight: '600' }], - 'semibold-s': ['18px', { lineHeight: 'auto', fontWeight: '600' }], + 'semibold-l': ['1.375rem', { lineHeight: 'auto', fontWeight: '600' }], + 'semibold-m': ['1.25rem', { lineHeight: 'auto', fontWeight: '600' }], + 'semibold-r': ['1.125rem', { lineHeight: 'auto', fontWeight: '600' }], + 'semibold-s': ['1rem', { lineHeight: 'auto', fontWeight: '600' }], // Medium(500) sizes - 'medium-xl': ['24px', { lineHeight: 'auto', fontWeight: '500' }], - 'medium-l': ['22px', { lineHeight: 'auto', fontWeight: '500' }], - 'medium-m': ['20px', { lineHeight: 'auto', fontWeight: '500' }], - 'medium-r': ['18px', { lineHeight: 'auto', fontWeight: '500' }], - 'medium-s': ['16px', { lineHeight: 'auto', fontWeight: '500' }], - 'medium-xs': ['14px', { lineHeight: 'auto', fontWeight: '500' }], - 'medium-xxs': ['12px', { lineHeight: 'auto', fontWeight: '500' }], + 'medium-xl': ['1.375rem', { lineHeight: 'auto', fontWeight: '500' }], + 'medium-l': ['1.25rem', { lineHeight: 'auto', fontWeight: '500' }], + 'medium-m': ['1.125rem', { lineHeight: 'auto', fontWeight: '500' }], + 'medium-r': ['1rem', { lineHeight: 'auto', fontWeight: '500' }], + 'medium-s': ['0.875rem', { lineHeight: 'auto', fontWeight: '500' }], + 'medium-xs': ['0.75rem', { lineHeight: 'auto', fontWeight: '500' }], + 'medium-xxs': ['0.625rem', { lineHeight: 'auto', fontWeight: '500' }], }, fontFamily: { pretendard: ['Pretendard', 'sans-serif'], From 926f676c3120dd1fb07a130501e87d13be900a59 Mon Sep 17 00:00:00 2001 From: yiseungyun Date: Sun, 10 Nov 2024 01:00:11 +0900 Subject: [PATCH 38/56] =?UTF-8?q?style:=20=EC=84=B8=EC=85=98=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/SessionCard.tsx | 45 +++++++++++++------------ frontend/src/index.css | 30 ++++------------- frontend/tailwind.config.js | 6 ++-- 3 files changed, 32 insertions(+), 49 deletions(-) diff --git a/frontend/src/components/SessionCard.tsx b/frontend/src/components/SessionCard.tsx index 8e8b50e6..0338dce6 100644 --- a/frontend/src/components/SessionCard.tsx +++ b/frontend/src/components/SessionCard.tsx @@ -1,5 +1,5 @@ import { FaUserGroup } from "react-icons/fa6"; -import { FaArrowRight } from "react-icons/fa"; +import { IoArrowForwardSharp } from "react-icons/io5"; interface Props { category: string; title: string; @@ -23,43 +23,44 @@ const SessionCard = ({ }: Props) => { return (
  • -
    -
    +
    {category} -

    {title}

    -

    +

    {title}

    +

    질문지인데 누르면 질문 리스트를 볼 수 있임 {questionListId}

    -
    +
    {host} • - {" "} + {" "} 참여자 {participant}/{maxParticipant}명
    - + {sessionStatus === "open" ? ( + + ) : null}
  • diff --git a/frontend/src/index.css b/frontend/src/index.css index c8cb7f55..752f6456 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,12 +1,12 @@ +@import url('https://fonts.googleapis.com/css2?family=Raleway:wght@400;500;600;700&display=swap'); +@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css'); @tailwind base; @tailwind components; @tailwind utilities; -@import url('https://fonts.googleapis.com/css2?family=Raleway:wght@400;500;600;700&display=swap'); -@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css'); @layer base { html { - font-family: Pretendard, system-ui, sans-serif; + font-family: Pretendard, system-ui, sans-serif; } } @@ -15,8 +15,7 @@ font-weight: 400; color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; + background-color: #FAFAFA; font-synthesis: none; text-rendering: optimizeLegibility; @@ -24,26 +23,9 @@ -moz-osx-font-smoothing: grayscale; } -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - - - @media (prefers-color-scheme: light) { :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; + color: #171717; + background-color: #FAFAFA; } } diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 10f3917e..5ea47534 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -35,9 +35,9 @@ export default { } }, borderRadius: { - 's': '0.25rem', - 'm': '0.5rem', - 'xl': '1rem' + 'custom-s': '0.25rem', + 'custom-m': '0.5rem', + 'custom-l': '0.875rem' }, boxShadow: { '8': '0 0 0.2rem 0.125rem rgba(182, 182, 182, 0.08)' From afd9ee264b8d520a680edb75e743b5715cabcc73 Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Sun, 10 Nov 2024 02:32:56 +0900 Subject: [PATCH 39/56] =?UTF-8?q?refactor:=20useSocket=EB=A1=9C=20?= =?UTF-8?q?=EC=86=8C=EC=BC=93=20=EC=B4=88=EA=B8=B0=ED=99=94=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EC=BD=94=EB=93=9C=20=EC=BB=A4=EC=8A=A4=ED=85=80=20?= =?UTF-8?q?hook=EC=9C=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/hooks/useSocket.ts | 28 ++++++++++++++++++++++++++++ frontend/src/pages/SessionPage.tsx | 16 +++------------- 2 files changed, 31 insertions(+), 13 deletions(-) create mode 100644 frontend/src/hooks/useSocket.ts diff --git a/frontend/src/hooks/useSocket.ts b/frontend/src/hooks/useSocket.ts new file mode 100644 index 00000000..7327b498 --- /dev/null +++ b/frontend/src/hooks/useSocket.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from "react"; +import { Socket, io } from "socket.io-client"; + +const useSocket = (socketURL: string) => { + // 소켓 상태 + const [socket, setSocket] = useState(null); + + // 소켓 연결 + useEffect(() => { + const newSocket = io(socketURL || "http://localhost:3000"); + + newSocket.on("connect_error", socketErrorHandler); + setSocket(newSocket); + + return () => { + newSocket.disconnect(); + setSocket(null); + }; + }, []); + + return { socket }; +}; + +const socketErrorHandler = (error: Error) => { + console.error("시그널링 서버와의 연결에 실패했습니다.", error); +}; + +export default useSocket; diff --git a/frontend/src/pages/SessionPage.tsx b/frontend/src/pages/SessionPage.tsx index 0f99894d..9a7ff7dc 100644 --- a/frontend/src/pages/SessionPage.tsx +++ b/frontend/src/pages/SessionPage.tsx @@ -10,6 +10,8 @@ import { import { FaAngleLeft, FaAngleRight, FaUserGroup } from "react-icons/fa6"; import { FaClipboardList } from "react-icons/fa"; import { useNavigate } from "react-router-dom"; +import useSocket from "../hooks/useSocket.ts"; +import SessionSidebar from "../components/session/SessionSidebar.tsx"; interface User { id: string; @@ -23,7 +25,7 @@ interface PeerConnection { } const SessionPage = () => { - const [socket, setSocket] = useState(null); + const { socket } = useSocket(import.meta.env.VITE_SIGNALING_SERVER_URL); const [myStream, setMyStream] = useState(null); const [peers, setPeers] = useState([]); // 연결 관리 const [roomId, setRoomId] = useState(""); @@ -81,17 +83,6 @@ const SessionPage = () => { }, []); useEffect(() => { - // 소켓 연결 - const newSocket = io( - import.meta.env.VITE_SIGNALING_SERVER_URL || "http://localhost:3000" - ); - newSocket.on("connect_error", () => { - console.error("시그널링 서버와의 연결에 실패했습니다."); - }); - - setSocket(newSocket); - - // ref 값을 useEffect 안에서 캡처 const connections = peerConnections; return () => { @@ -104,7 +95,6 @@ const SessionPage = () => { // 연결 종료 pc.close(); }); - newSocket.close(); }; }, []); From 8c1b7bbf1c3ecf91ee1e8181b091e9f17b4b0c92 Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Sun, 10 Nov 2024 02:34:01 +0900 Subject: [PATCH 40/56] =?UTF-8?q?refactor:=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EC=9D=98=20=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=EB=B0=94=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/session/SessionSidebar.tsx | 50 +++++++++++++++++++ .../{ => session}/VideoContainer.tsx | 0 frontend/src/pages/SessionPage.tsx | 40 ++------------- 3 files changed, 55 insertions(+), 35 deletions(-) create mode 100644 frontend/src/components/session/SessionSidebar.tsx rename frontend/src/components/{ => session}/VideoContainer.tsx (100%) diff --git a/frontend/src/components/session/SessionSidebar.tsx b/frontend/src/components/session/SessionSidebar.tsx new file mode 100644 index 00000000..1446ff88 --- /dev/null +++ b/frontend/src/components/session/SessionSidebar.tsx @@ -0,0 +1,50 @@ +import { FaClipboardList } from "react-icons/fa"; +import { FaUserGroup } from "react-icons/fa6"; + +interface Props { + question: string; + participants: string[]; +} + +const SessionSidebar = ({ question, participants }: Props) => { + return ( +
    +
    +
    +

    + + 질문 +

    +

    + {question} +

    +
    +
    +

    + + 참가자 +

    +
      + {participants.map((participant, index) => ( +
    • + + {participant} +
    • + ))} +
    +
    +
    +
    + +
    +
    + ); +}; + +export default SessionSidebar; diff --git a/frontend/src/components/VideoContainer.tsx b/frontend/src/components/session/VideoContainer.tsx similarity index 100% rename from frontend/src/components/VideoContainer.tsx rename to frontend/src/components/session/VideoContainer.tsx diff --git a/frontend/src/pages/SessionPage.tsx b/frontend/src/pages/SessionPage.tsx index 9a7ff7dc..b5671173 100644 --- a/frontend/src/pages/SessionPage.tsx +++ b/frontend/src/pages/SessionPage.tsx @@ -1,6 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { Socket, io } from "socket.io-client"; -import VideoContainer from "../components/VideoContainer.tsx"; +import VideoContainer from "../components/session/VideoContainer.tsx"; import { BsMic, BsMicMute, @@ -496,39 +495,10 @@ const SessionPage = () => {
    -
    -
    -
    -

    - - 질문 -

    -

    - Restful API란 무엇인지 설명해주세요 -

    -
    -
    -

    - - 참가자 -

    -
      -
    • 참가자 1
    • -
    • 참가자 2
    • -
    • 참가자 3
    • -
    -
    -
    -
    - -
    -
    +
    ); From 2c143f1af25c9b7d9439b957a0d0ce21656fd6f3 Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Sun, 10 Nov 2024 02:44:26 +0900 Subject: [PATCH 41/56] =?UTF-8?q?refactor:=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EC=9D=98=20Footer=20=ED=88=B4?= =?UTF-8?q?=EB=B0=94=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/session/SessionToolbar.tsx | 85 +++++++++++++++++++ frontend/src/pages/SessionPage.tsx | 73 +++------------- 2 files changed, 97 insertions(+), 61 deletions(-) create mode 100644 frontend/src/components/session/SessionToolbar.tsx diff --git a/frontend/src/components/session/SessionToolbar.tsx b/frontend/src/components/session/SessionToolbar.tsx new file mode 100644 index 00000000..29f78999 --- /dev/null +++ b/frontend/src/components/session/SessionToolbar.tsx @@ -0,0 +1,85 @@ +import { FaAngleLeft, FaAngleRight } from "react-icons/fa6"; +import { + BsCameraVideo, + BsCameraVideoOff, + BsMic, + BsMicMute, +} from "react-icons/bs"; + +interface Props { + handleVideoToggle: () => void; + handleMicToggle: () => void; + userVideoDevices: MediaDeviceInfo[]; + userAudioDevices: MediaDeviceInfo[]; + setSelectedVideoDeviceId: (deviceId: string) => void; + setSelectedAudioDeviceId: (deviceId: string) => void; + isVideoOn: boolean; + isMicOn: boolean; +} +const SessionToolbar = ({ + handleVideoToggle, + handleMicToggle, + userVideoDevices, + userAudioDevices, + setSelectedVideoDeviceId, + setSelectedAudioDeviceId, + isVideoOn, + isMicOn, +}: Props) => { + return ( +
    + +
    + + + + +
    + +
    + ); +}; + +export default SessionToolbar; diff --git a/frontend/src/pages/SessionPage.tsx b/frontend/src/pages/SessionPage.tsx index b5671173..c90457af 100644 --- a/frontend/src/pages/SessionPage.tsx +++ b/frontend/src/pages/SessionPage.tsx @@ -1,16 +1,9 @@ import { useEffect, useRef, useState } from "react"; import VideoContainer from "../components/session/VideoContainer.tsx"; -import { - BsMic, - BsMicMute, - BsCameraVideo, - BsCameraVideoOff, -} from "react-icons/bs"; -import { FaAngleLeft, FaAngleRight, FaUserGroup } from "react-icons/fa6"; -import { FaClipboardList } from "react-icons/fa"; import { useNavigate } from "react-router-dom"; import useSocket from "../hooks/useSocket.ts"; import SessionSidebar from "../components/session/SessionSidebar.tsx"; +import SessionToolbar from "../components/session/SessionToolbar.tsx"; interface User { id: string; @@ -45,8 +38,8 @@ const SessionPage = () => { const myVideoRef = useRef(null); const peerConnections = useRef<{ [key: string]: RTCPeerConnection }>({}); const peerVideoRefs = useRef<{ [key: string]: HTMLVideoElement | null }>({}); - const navigate = useNavigate(); + // STUN 서버 설정 const pcConfig = { iceServers: [ @@ -442,58 +435,16 @@ const SessionPage = () => { }
    -
    - -
    - - - - -
    - -
    + Date: Sun, 10 Nov 2024 02:57:10 +0900 Subject: [PATCH 42/56] =?UTF-8?q?feat:=20=EA=B3=B5=EA=B0=90=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/session/SessionToolbar.tsx | 7 +++++++ frontend/src/pages/SessionPage.tsx | 8 ++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/session/SessionToolbar.tsx b/frontend/src/components/session/SessionToolbar.tsx index 29f78999..e5f95bc6 100644 --- a/frontend/src/components/session/SessionToolbar.tsx +++ b/frontend/src/components/session/SessionToolbar.tsx @@ -4,6 +4,7 @@ import { BsCameraVideoOff, BsMic, BsMicMute, + BsHandThumbsUp, } from "react-icons/bs"; interface Props { @@ -50,6 +51,12 @@ const SessionToolbar = ({ > {isMicOn ? : } + - From 2707dee1ca2e96e96ef46f035bd9207214c52bb6 Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Sun, 10 Nov 2024 03:32:29 +0900 Subject: [PATCH 45/56] =?UTF-8?q?refactor:=20useMediaDevices=20=ED=9B=85?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=AF=B8=EB=94=94=EC=96=B4=20=EC=9E=A5?= =?UTF-8?q?=EC=B9=98=EC=99=80=20=EB=AF=B8=EB=94=94=EC=96=B4=20=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A6=BC=20=EA=B4=80=EB=A6=AC=EB=A5=BC=20=ED=95=98?= =?UTF-8?q?=EB=82=98=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 스트림을 얻어오는 로직 분리 - 사용자 미디어 장치 관련 로직 분리 - mediaDevices 관련 로직 분리 --- frontend/src/hooks/useMediaDevices.ts | 75 +++++++++++++++++++++++++++ frontend/src/pages/SessionPage.tsx | 70 ++++++------------------- 2 files changed, 91 insertions(+), 54 deletions(-) create mode 100644 frontend/src/hooks/useMediaDevices.ts diff --git a/frontend/src/hooks/useMediaDevices.ts b/frontend/src/hooks/useMediaDevices.ts new file mode 100644 index 00000000..a8f48695 --- /dev/null +++ b/frontend/src/hooks/useMediaDevices.ts @@ -0,0 +1,75 @@ +import { useEffect, useState } from "react"; + +const useMediaDevices = () => { + // 유저의 미디어 장치 리스트 + const [userAudioDevices, setUserAudioDevices] = useState( + [] + ); + const [userVideoDevices, setUserVideoDevices] = useState( + [] + ); + + // 유저가 선택한 미디어 장치 + const [selectedVideoDeviceId, setSelectedVideoDeviceId] = + useState(""); + const [selectedAudioDeviceId, setSelectedAudioDeviceId] = + useState(""); + + // 본인 미디어 스트림 + const [stream, setStream] = useState(null); + + useEffect(() => { + // 비디오 디바이스 목록 가져오기 + + const getUserDevices = async () => { + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + const audioDevices = devices.filter( + (device) => device.kind === "audioinput" + ); + const videoDevices = devices.filter( + (device) => device.kind === "videoinput" + ); + + setUserAudioDevices(audioDevices); + setUserVideoDevices(videoDevices); + } catch (error) { + console.error("미디어 기기를 찾는데 문제가 발생했습니다.", error); + } + }; + + getUserDevices(); + }, []); + + // 미디어 스트림 가져오기: 자신의 스트림을 가져옴 + const getMedia = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: selectedVideoDeviceId + ? { deviceId: selectedVideoDeviceId } + : true, + audio: selectedAudioDeviceId + ? { deviceId: selectedAudioDeviceId } + : true, + }); + + setStream(stream); + return stream; + } catch (error) { + console.error("Error accessing media devices:", error); + } + }; + + return { + userAudioDevices, + userVideoDevices, + selectedAudioDeviceId, + selectedVideoDeviceId, + setSelectedAudioDeviceId, + setSelectedVideoDeviceId, + getMedia, + stream, + }; +}; + +export default useMediaDevices; diff --git a/frontend/src/pages/SessionPage.tsx b/frontend/src/pages/SessionPage.tsx index 70f3ab60..7b3d871e 100644 --- a/frontend/src/pages/SessionPage.tsx +++ b/frontend/src/pages/SessionPage.tsx @@ -4,6 +4,7 @@ import { useNavigate } from "react-router-dom"; import useSocket from "../hooks/useSocket.ts"; import SessionSidebar from "../components/session/SessionSidebar.tsx"; import SessionToolbar from "../components/session/SessionToolbar.tsx"; +import useMediaDevices from "../hooks/useMediaDevices.ts"; interface User { id: string; @@ -18,22 +19,21 @@ interface PeerConnection { const SessionPage = () => { const { socket } = useSocket(import.meta.env.VITE_SIGNALING_SERVER_URL); - const [myStream, setMyStream] = useState(null); const [peers, setPeers] = useState([]); // 연결 관리 const [roomId, setRoomId] = useState(""); const [nickname, setNickname] = useState(""); const [isVideoOn, setIsVideoOn] = useState(true); const [isMicOn, setIsMicOn] = useState(true); - const [userVideoDevices, setUserVideoDevices] = useState( - [] - ); - const [userAudioDevices, setUserAudioDevices] = useState( - [] - ); - const [selectedVideoDeviceId, setSelectedVideoDeviceId] = - useState(""); - const [selectedAudioDeviceId, setSelectedAudioDeviceId] = - useState(""); + const { + userVideoDevices, + userAudioDevices, + selectedAudioDeviceId, + selectedVideoDeviceId, + setSelectedAudioDeviceId, + setSelectedVideoDeviceId, + getMedia, + stream: myStream, + } = useMediaDevices(); const myVideoRef = useRef(null); const peerConnections = useRef<{ [key: string]: RTCPeerConnection }>({}); @@ -51,29 +51,6 @@ const SessionPage = () => { ], }; - useEffect(() => { - // 비디오 디바이스 목록 가져오기 - - const getUserDevices = async () => { - try { - const devices = await navigator.mediaDevices.enumerateDevices(); - const audioDevices = devices.filter( - (device) => device.kind === "audioinput" - ); - const videoDevices = devices.filter( - (device) => device.kind === "videoinput" - ); - - setUserAudioDevices(audioDevices); - setUserVideoDevices(videoDevices); - } catch (error) { - console.error("미디어 기기를 찾는데 문제가 발생했습니다.", error); - } - }; - - getUserDevices(); - }, []); - useEffect(() => { const connections = peerConnections; @@ -114,27 +91,12 @@ const SessionPage = () => { }; }, [socket]); - // 미디어 스트림 가져오기: 자신의 스트림을 가져옴 - const getMedia = async () => { - try { - const stream = await navigator.mediaDevices.getUserMedia({ - video: selectedVideoDeviceId - ? { deviceId: selectedVideoDeviceId } - : true, - audio: selectedAudioDeviceId - ? { deviceId: selectedAudioDeviceId } - : true, - }); - - if (myVideoRef.current) { - myVideoRef.current!.srcObject = stream; - } - setMyStream(stream); - return stream; - } catch (error) { - console.error("Error accessing media devices:", error); + // 미디어 스트림 가져오기 + useEffect(() => { + if (myStream && myVideoRef.current) { + myVideoRef.current.srcObject = myStream; } - }; + }, [myStream]); // 미디어 스트림 토글 관련 const handleVideoToggle = () => { From fcdeb357b8184829dd571095a33bec09bb97af70 Mon Sep 17 00:00:00 2001 From: yiseungyun Date: Sun, 10 Nov 2024 04:19:17 +0900 Subject: [PATCH 46/56] =?UTF-8?q?style:=20=EC=84=B8=EC=85=98=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/SessionListPage.tsx | 46 ++++++++++++++++---------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/frontend/src/pages/SessionListPage.tsx b/frontend/src/pages/SessionListPage.tsx index e5e5a5f8..c14ed73d 100644 --- a/frontend/src/pages/SessionListPage.tsx +++ b/frontend/src/pages/SessionListPage.tsx @@ -1,7 +1,9 @@ -import { FaCirclePlus } from "react-icons/fa6"; +import { IoChevronDownSharp } from "react-icons/io5"; import { useEffect, useState } from "react"; import SessionCard from "../components/SessionCard.tsx"; import { useNavigate } from "react-router-dom"; +import { IoMdAdd } from "react-icons/io"; +import { IoIosSearch } from "react-icons/io"; interface Session { id: number; @@ -32,7 +34,7 @@ const SessionListPage = () => { category: "프론트엔드", sessionStatus: "open", host: { - nickname: "J133", + nickname: "J133 네모정", }, participant: 1, maxParticipant: 4, @@ -55,6 +57,7 @@ const SessionListPage = () => { setListLoading(false); }, 100); }, []); + const renderSessionList = (sessionStatus: SessionStatus) => { return sessionList.map((session) => { return ( @@ -77,32 +80,39 @@ const SessionListPage = () => { return (

    스터디 세션 목록

    -
    - - +
    +
    + + +
    +
    + + + + +
    -
    -

    공개된 세션 목록

    +

    공개된 세션 목록

      {listLoading ? ( <>loading @@ -118,7 +128,7 @@ const SessionListPage = () => {
    -

    진행 중인 세션 목록

    +

    진행 중인 세션 목록

      {listLoading ? ( <>loading From 066c6cfb61d36e598a3b4e635aabbe1c13d5e607 Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Sun, 10 Nov 2024 13:50:49 +0900 Subject: [PATCH 47/56] =?UTF-8?q?feat:=20=ED=99=94=EC=83=81=ED=9A=8C?= =?UTF-8?q?=EC=9D=98=20=EB=8F=84=EC=A4=91=20=EB=AF=B8=EB=94=94=EC=96=B4=20?= =?UTF-8?q?=EC=9E=A5=EC=B9=98=EB=A5=BC=20=EB=B0=94=EA=BE=B8=EB=A9=B4=20?= =?UTF-8?q?=EB=B0=94=EB=A1=9C=20=EC=8A=A4=ED=8A=B8=EB=A6=BC=EC=97=90=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=EB=90=98=EB=8F=84=EB=A1=9D=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/hooks/useMediaDevices.ts | 12 +++++++++--- frontend/src/pages/SessionPage.tsx | 4 ++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/frontend/src/hooks/useMediaDevices.ts b/frontend/src/hooks/useMediaDevices.ts index a8f48695..d9c3c32c 100644 --- a/frontend/src/hooks/useMediaDevices.ts +++ b/frontend/src/hooks/useMediaDevices.ts @@ -44,7 +44,13 @@ const useMediaDevices = () => { // 미디어 스트림 가져오기: 자신의 스트림을 가져옴 const getMedia = async () => { try { - const stream = await navigator.mediaDevices.getUserMedia({ + if (stream) { + // 이미 스트림이 있으면 종료 + stream.getTracks().forEach((track) => { + track.stop(); + }); + } + const myStream = await navigator.mediaDevices.getUserMedia({ video: selectedVideoDeviceId ? { deviceId: selectedVideoDeviceId } : true, @@ -53,8 +59,8 @@ const useMediaDevices = () => { : true, }); - setStream(stream); - return stream; + setStream(myStream); + return myStream; } catch (error) { console.error("Error accessing media devices:", error); } diff --git a/frontend/src/pages/SessionPage.tsx b/frontend/src/pages/SessionPage.tsx index 7b3d871e..cfcb9c97 100644 --- a/frontend/src/pages/SessionPage.tsx +++ b/frontend/src/pages/SessionPage.tsx @@ -67,6 +67,10 @@ const SessionPage = () => { }; }, []); + useEffect(() => { + getMedia(); + }, [selectedAudioDeviceId, selectedVideoDeviceId]); + useEffect(() => { // 미디어 스트림 정리 로직 return () => { From 168fef01f27fc906544d8ea820e7095eaf8a28b2 Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Sun, 10 Nov 2024 13:52:46 +0900 Subject: [PATCH 48/56] =?UTF-8?q?fix:=20=EB=B0=A9=20=EC=B0=B8=EA=B0=80?= =?UTF-8?q?=EB=A5=BC=20=EC=95=88=ED=95=B4=EB=8F=84=20=EB=AF=B8=EB=94=94?= =?UTF-8?q?=EC=96=B4=20=EC=9E=A5=EC=B9=98=EA=B0=80=20=EC=BC=9C=EC=A7=80?= =?UTF-8?q?=EB=8D=98=20=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/SessionPage.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/SessionPage.tsx b/frontend/src/pages/SessionPage.tsx index cfcb9c97..0210cd84 100644 --- a/frontend/src/pages/SessionPage.tsx +++ b/frontend/src/pages/SessionPage.tsx @@ -68,7 +68,9 @@ const SessionPage = () => { }, []); useEffect(() => { - getMedia(); + if (selectedAudioDeviceId || selectedVideoDeviceId) { + getMedia(); + } }, [selectedAudioDeviceId, selectedVideoDeviceId]); useEffect(() => { From b669619b9bb8cd8e908b935de094b09b3676ccab Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Sun, 10 Nov 2024 15:01:33 +0900 Subject: [PATCH 49/56] =?UTF-8?q?feat:=20=EB=B0=9C=EA=B2=AC=EB=90=9C=20?= =?UTF-8?q?=EB=AF=B8=EB=94=94=EC=96=B4=20=EC=9E=A5=EC=B9=98=EA=B0=80=20?= =?UTF-8?q?=EC=97=86=EC=9D=84=20=EB=95=8C=20=EC=97=86=EB=8B=A4=EA=B3=A0=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=ED=95=98=EB=8F=84=EB=A1=9D=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/session/SessionToolbar.tsx | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/session/SessionToolbar.tsx b/frontend/src/components/session/SessionToolbar.tsx index 7c91b2ed..74c62784 100644 --- a/frontend/src/components/session/SessionToolbar.tsx +++ b/frontend/src/components/session/SessionToolbar.tsx @@ -66,11 +66,15 @@ const SessionToolbar = ({ } onChange={(e) => setSelectedVideoDeviceId(e.target.value)} > - {userVideoDevices.map((device) => ( - - ))} + {userVideoDevices.length > 0 ? ( + userVideoDevices.map((device) => ( + + )) + ) : ( + + )}
    -
    +
    -
    +

    { > 프론트엔드 초보자 면접 스터디

    -
    +
    {
    peer.peerNickname) ?? [ + "누군가", + "수상한 누군가", + "오오오", + ] + } />
    From d2ae18e66570e07d108bf6f0889bf8e0aeb96dd1 Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Sun, 10 Nov 2024 15:50:05 +0900 Subject: [PATCH 51/56] =?UTF-8?q?style:=20toolbar=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/session/SessionToolbar.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/session/SessionToolbar.tsx b/frontend/src/components/session/SessionToolbar.tsx index 74c62784..9908b15e 100644 --- a/frontend/src/components/session/SessionToolbar.tsx +++ b/frontend/src/components/session/SessionToolbar.tsx @@ -42,27 +42,27 @@ const SessionToolbar = ({
    setSelectedVideoDeviceId(e.target.value)} > @@ -78,7 +78,7 @@ const SessionToolbar = ({