-
Notifications
You must be signed in to change notification settings - Fork 4
[FE] 입장 준비 화면 UI 구현 #168
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[FE] 입장 준비 화면 UI 구현 #168
Changes from 10 commits
749b946
2366f2b
c7570bf
4231f50
d14855d
8f32a67
89228d6
32f8cf2
d5a586d
97578f3
f10278e
bde2886
6550561
14339f7
9676a9c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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} /> | ||
| )} | ||
| </main> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,3 +24,7 @@ body { | |
| button { | ||
| cursor: pointer; | ||
| } | ||
|
|
||
| button:disabled { | ||
| cursor: not-allowed; | ||
| } | ||
|
Comment on lines
24
to
26
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P4: 두 개를 Tailwind로 합쳐서 |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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', | ||
|
||
| lg: 'w-full h-full max-h-[52px] py-4 px-2 text-base font-bold', | ||
| }, | ||
| shape: { | ||
|
|
@@ -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. 취소 버튼 | ||
|
|
||
| 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> | ||
|
||
| </button> | ||
|
|
||
| {!isDisabled && isOpen && ( | ||
| <ul className="absolute z-10 mt-1 w-full rounded-sm border border-neutral-400 bg-white shadow"> | ||
|
||
| {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> | ||
| ); | ||
| } | ||
| 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> | ||
| ); | ||
| } |
| 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> | ||
| ); | ||
| } |
| 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> | ||
| ); | ||
| } |
| 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' : ''}`} | ||
| /> | ||
| ); | ||
| } |
| 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, | ||
| }; | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
제 PR에 리뷰를 남겨주셨던 부분이 이 부분이었나보네요!
참여 준비 화면과 실제 화상 회의 페이지를 하나로 만드는게 좋을까요?
아니면 별도의 페이지로 분리하는게 좋을까요?
일단 구글 meet에서는 구현해주신대로
새로고침 시 검증 및 준비를 다시 요청하는 방식으로 진행되고 있기는 하네요
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
저도 하나의 페이지에서 핸들하는 게 좋을지, 별도의 페이지로 분리하는 게 좋을지 고민이 되었는데 구글미트를 참고해 우선 진행했어요. 그리고 AI에게 피드백을 받아봤는데 아래와 같은 관점으로 봐도 좋을 것 같아요!
Next.js 관점
meetingId라는 동일한 컨텍스트 안에서 상태가 유지되므로, 로비에서 설정한 오디오/비디오 스트림 객체를 회의실 컴포넌트로 그대로 넘겨주기 쉽다..../meeting/abc-def)을 유지하면서도 내부 로직만으로 흐름을 완벽히 제어할 수 있다.추가 포인트
만약 로그인하지 않은 사용자를
/landing으로 보내야 한다면,page.tsx상단이나 Next.js Middleware에서 체크하는 것이 좋다.⇒ 일단 우리 서비스에선 비로그인 사용자도 참여 가능하다. → 비회원인 경우 넘어갈 페이지로 미들웨어 설정해주기?