Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions frontend/src/app/[meetingId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use client';

import MeetingLobby from '@/components/meeting/MeetingLobby';
import MeetingRoom from '@/components/meeting/MeetingRoom';
import { useParams } from 'next/navigation';
import { useState } from 'react';

export default function MeetingPage() {
const params = useParams<{ meetingId: string }>();
const meetingId = params.meetingId;

const [isJoined, setIsJoined] = useState<boolean>(false);

if (!meetingId) {
return <div>잘못된 회의 접근입니다. 다시 시도해주세요.</div>;
}

// 참여 버튼을 눌렀을 때의 핸들러
const handleJoin = () => {
setIsJoined(true);
};

return (
<main className="min-h-screen">
{!isJoined ? (
<MeetingLobby meetingId={meetingId} onJoin={handleJoin} />
) : (
<MeetingRoom meetingId={meetingId} />
)}
Comment on lines +25 to +29
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제 PR에 리뷰를 남겨주셨던 부분이 이 부분이었나보네요!

참여 준비 화면과 실제 화상 회의 페이지를 하나로 만드는게 좋을까요?
아니면 별도의 페이지로 분리하는게 좋을까요?

일단 구글 meet에서는 구현해주신대로
새로고침 시 검증 및 준비를 다시 요청하는 방식으로 진행되고 있기는 하네요

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 하나의 페이지에서 핸들하는 게 좋을지, 별도의 페이지로 분리하는 게 좋을지 고민이 되었는데 구글미트를 참고해 우선 진행했어요. 그리고 AI에게 피드백을 받아봤는데 아래와 같은 관점으로 봐도 좋을 것 같아요!

Next.js 관점

  1. 사용자 경험 (Seamless UX): 사용자가 '참여하기'를 눌렀을 때 페이지 전체가 새로고침되지 않고, 필요한 컴포넌트(회의창)만 즉시 로드되게 한다.
  2. 데이터 무결성: meetingId라는 동일한 컨텍스트 안에서 상태가 유지되므로, 로비에서 설정한 오디오/비디오 스트림 객체를 회의실 컴포넌트로 그대로 넘겨주기 쉽다.
  3. URL 간결화: 구글 미트처럼 단일 URL(.../meeting/abc-def)을 유지하면서도 내부 로직만으로 흐름을 완벽히 제어할 수 있다.

추가 포인트

만약 로그인하지 않은 사용자를 /landing으로 보내야 한다면, page.tsx 상단이나 Next.js Middleware에서 체크하는 것이 좋다.

// middleware.ts 예시
if (!session && request.nextUrl.pathname.startsWith('/meeting')) {
  return NextResponse.redirect(new URL('/landing', request.url));
}

⇒ 일단 우리 서비스에선 비로그인 사용자도 참여 가능하다. → 비회원인 경우 넘어갈 페이지로 미들웨어 설정해주기?

</main>
);
}
4 changes: 4 additions & 0 deletions frontend/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@ body {
button {
cursor: pointer;
}

button:disabled {
cursor: not-allowed;
}
Comment on lines 24 to 26
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P4: 두 개를 Tailwind로 합쳐서
button { @apply cursor-pointer disabled:cursor-not-allowed } 로 정의할 수도 있을 것 같네요

4 changes: 2 additions & 2 deletions frontend/src/components/common/button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const style: {
} = {
base: 'inline-flex items-center justify-center box-border select-none m-0 p-0 w-fit h-fit cursor-pointer disabled:cursor-default',
size: {
sm: 'h-full max-h-[50px] px-3 py-[6px] text-sm font-bold',
sm: 'h-full h-auto px-3 py-[6px] text-sm font-bold',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

px-[6px]로 작성하면 혹시 VSCODE에서

The class `px-[6px]` can be written as `px-1.5`(suggestCanonicalClasses)

이런 Warning 밑줄 안생기나요??

저도 저렇게 했었다가 최근 Tailwind에선 저렇게 경고가 생겨서 .5나 .25 등의 기능을 사용하는 편이라 질문 드려요!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

className에서 쓸 땐 warning이 뜨는데 style 정의로 작성했다보니 안 뜬 것 같아요! 놓친 부분이었는데 감사합니다~

lg: 'w-full h-full max-h-[52px] py-4 px-2 text-base font-bold',
},
shape: {
Expand All @@ -20,7 +20,7 @@ const style: {
},
color: {
active: 'text-white', // TODO: 버튼 호버 시 활성화되는 스타일
primary: 'bg-sky-600 text-white', // sky
primary: 'bg-sky-600 text-white hover:bg-sky-700', // sky
secondary: 'bg-sky-700 text-white', // dark-sky
outlinePrimary: 'bg-white border border-sky-600 text-sky-600',
disabled: 'bg-neutral-500 text-white', // ex. 취소 버튼
Expand Down
61 changes: 61 additions & 0 deletions frontend/src/components/meeting/DeviceDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useState } from 'react';

interface Props {
label: string;
devices: MediaDeviceInfo[];
icon: React.ComponentType<{ className?: string }>;
selectedId: string;
onSelect: (id: string) => void;
className?: string;
}

export function DeviceDropdown({
label,
devices,
icon: Icon,
selectedId,
onSelect,
className,
}: Props) {
const [isOpen, setIsOpen] = useState(false);

const selected = devices.find((d) => d.deviceId === selectedId);
const isDisabled = devices.length === 0;

return (
<div className={`relative min-w-0 ${className}`}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
disabled={isDisabled}
className={`flex w-full items-center justify-between gap-2 rounded-sm border border-neutral-300 px-3 py-2 text-sm transition-colors ${
isDisabled
? 'cursor-not-allowed bg-neutral-100 text-neutral-400' // 비활성화 스타일
: 'border-neutral-300 bg-white text-neutral-700 hover:border-neutral-400'
}`}
>
<Icon className="h-4 w-4 shrink-0" />
<span className="truncate">{selected?.label || `접근 권한 필요`}</span>

<span className="flex-1 text-right">▾</span>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: assets/icons/common/arrowDownIcon.svg에 여기서 사용될 아이콘도 저장되어 있어요!
추가적으로 이 부분이 텍스트가 길어졌을 때 flex로 인해 찌그러지더라고요
위의 아이콘처럼 shrink-0 설정해주시면 좋을 것 같아요!

</button>

{!isDisabled && isOpen && (
<ul className="absolute z-10 mt-1 w-full rounded-sm border border-neutral-400 bg-white shadow">
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: shadow가 있어서 border가 조금 연해도 보기 불편하지 않을 것 같아요!
적용해봤을 때 neutral-200이 가장 적당했던 것 같습니다!

P5: 직접 사용해보니까 장치가 많아졌을 때 드롭다운의 높이가 높아져서 전체 페이지가 스크롤되게 되더라고요
드롭다운의 높이를 고정값으로 설정하고 스크롤 방식으로 목록을 표시하는 것도 괜찮을 것 같다고 생각했습니다!

{devices.map((device) => (
<li
key={device.deviceId}
onClick={() => {
onSelect(device.deviceId);
setIsOpen(false);
}}
className="cursor-pointer px-3 py-2 text-sm first:rounded-t-sm last:rounded-b-sm hover:bg-neutral-100"
>
{device.label}
</li>
))}
</ul>
)}
</div>
);
}
80 changes: 80 additions & 0 deletions frontend/src/components/meeting/MeetingLobby.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { useMediaDevices } from '@/hooks/useMediaDevices';
import Button from '../common/button';
import { DeviceDropdown } from './DeviceDropdown';
import { MediaPreview } from './media/MediaPreview';
import { CamOnIcon, MicOnIcon, VolumnIcon } from '@/assets/icons/meeting';

export default function MeetingLobby({
meetingId,
onJoin,
}: {
meetingId: string;
onJoin: () => void;
}) {
const meetingLeader = 'Tony';
const meetingMemberCnt = 9;

const {
microphones,
cameras,
speakers,
micId,
cameraId,
speakerId,
setMicId,
setCameraId,
setSpeakerId,
} = useMediaDevices();

return (
<main className="box-border flex min-h-screen items-center justify-center gap-20 px-6 py-4">
{/* 영상, 마이크 설정 부분 */}
<section className="flex w-full max-w-160 flex-col gap-6">
<MediaPreview />

<div className="flex w-full items-center gap-4 text-sm">
<DeviceDropdown
label="스피커"
devices={speakers}
icon={VolumnIcon}
selectedId={speakerId}
onSelect={setSpeakerId}
className="flex-1"
/>

<DeviceDropdown
label="마이크"
devices={microphones}
icon={MicOnIcon}
selectedId={micId}
onSelect={setMicId}
className="flex-1"
/>

<DeviceDropdown
label="카메라"
devices={cameras}
icon={CamOnIcon}
selectedId={cameraId}
onSelect={setCameraId}
className="flex-1"
/>
</div>
</section>

{/* 회의 참여 부분 */}
<section className="flex w-full max-w-60 flex-col items-center justify-center">
<h1 className="mb-2 text-2xl text-neutral-900">
<b>{meetingLeader}</b> 님의 회의실
</h1>
<span className="text-base text-neutral-600">
현재 참여자: {meetingMemberCnt}명
</span>

<Button className="mt-6" onClick={onJoin}>
회의 참여하기
</Button>
</section>
</main>
);
}
8 changes: 8 additions & 0 deletions frontend/src/components/meeting/MeetingRoom.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default function MeetingRoom({ meetingId }: { meetingId: string }) {
return (
<div>
meeting room
<p className="mb-8 text-slate-400">회의 코드: {meetingId}</p>
</div>
);
}
77 changes: 77 additions & 0 deletions frontend/src/components/meeting/media/MediaPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {
CamOffIcon,
CamOnIcon,
MicOffIcon,
MicOnIcon,
} from '@/assets/icons/meeting';
import Button from '@/components/common/button';
import VideoView from './VideoView';
import { useMediaPreview } from '@/hooks/useMediaPreview';

export function MediaPreview() {
const { media, stream, canRenderVideo, toggleAudio, toggleVideo } =
useMediaPreview();

// console.log(media);

return (
<div
className={`relative box-border h-90 w-160 overflow-hidden rounded-2xl bg-neutral-700 ${!canRenderVideo && 'p-4'}`}
>
{/* Video Layer */}
{canRenderVideo && stream && <VideoView stream={stream} />}

{/* Placeholder Layer */}
{!canRenderVideo && (
<div className="flex h-full flex-col items-center justify-center gap-6 text-center">
<p className="text-sm font-bold text-white">
{media.cameraPermission === 'denied' ? (
<>
브라우저 주소창의 자물쇠 아이콘을 눌러
<br />
카메라와 마이크 권한을 허용해 주세요.
</>
) : (
<>
마이크와 카메라를 사용하려면
<br />
접근 권한이 필요해요
</>
)}
</p>

{(media.cameraPermission === 'unknown' ||
media.micPermission === 'unknown') && (
<Button
size="sm"
shape="square"
className="px-3 py-2 text-sm"
// onClick={requestPermission}
>
마이크 및 카메라 접근 허용
</Button>
)}
</div>
)}

{/* Control Layer */}
<div className="absolute bottom-6 left-1/2 flex -translate-x-1/2 gap-6">
<button onClick={toggleVideo} className="group">
{media.videoOn ? (
<CamOnIcon className="h-12 w-12 rounded-full bg-white p-4 text-neutral-700 shadow-lg transition-all group-active:scale-95" />
) : (
<CamOffIcon className="h-12 w-12 rounded-full bg-white p-4 shadow-lg transition-all group-active:scale-95" />
)}
</button>

<button onClick={toggleAudio} className="group">
{media.audioOn ? (
<MicOnIcon className="h-12 w-12 rounded-full bg-white p-4 text-neutral-700 shadow-lg transition-all group-active:scale-95" />
) : (
<MicOffIcon className="h-12 w-12 rounded-full bg-white p-4 shadow-lg transition-all group-active:scale-95" />
)}
</button>
</div>
</div>
);
}
46 changes: 46 additions & 0 deletions frontend/src/components/meeting/media/VideoView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
'use client';

import { useEffect, useRef } from 'react';

interface VideoViewProps {
stream: MediaStream;
muted?: boolean;
mirrored?: boolean;
}

export default function VideoView({
stream,
muted = true,
mirrored = true,
}: VideoViewProps) {
const videoRef = useRef<HTMLVideoElement | null>(null);

useEffect(() => {
const video = videoRef.current;
if (!video) return;

video.srcObject = stream;

// Safari와 iOS 대응
video.onloadedmetadata = () => {
video.play().catch(() => {
// autoplay 정책 실패 시 무시
});
};

return () => {
video.pause();
video.srcObject = null;
};
}, [stream]);

return (
<video
ref={videoRef}
muted={muted}
playsInline
autoPlay
className={`h-full w-full object-cover ${mirrored ? '-scale-x-100' : ''}`}
/>
);
}
49 changes: 49 additions & 0 deletions frontend/src/hooks/useMediaDevices.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
'use client';

import { useEffect, useState } from 'react';

export type DeviceKind = 'audioinput' | 'audiooutput' | 'videoinput';

export const useMediaDevices = () => {
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);

const [speakerId, setSpeakerId] = useState<string>('');
const [micId, setMicId] = useState<string>('');
const [cameraId, setCameraId] = useState<string>('');

useEffect(() => {
const init = async () => {
const list = await navigator.mediaDevices.enumerateDevices();

// 버튼 disabled를 위해 빈 라벨 필터링
const validDevices = list.filter((d) => d.label !== '');
setDevices(validDevices);

const mic = list.find((d) => d.kind === 'audioinput');
const cam = list.find((d) => d.kind === 'videoinput');
const speaker = list.find((d) => d.kind === 'audiooutput');

if (mic) setMicId(mic.deviceId);
if (cam) setCameraId(cam.deviceId);
if (speaker) setSpeakerId(speaker.deviceId);
};

init();
}, []);

const byKind = (kind: DeviceKind) => devices.filter((d) => d.kind === kind);

return {
microphones: byKind('audioinput'),
cameras: byKind('videoinput'),
speakers: byKind('audiooutput'),

micId,
cameraId,
speakerId,

setMicId,
setCameraId,
setSpeakerId,
};
};
Loading