-
Notifications
You must be signed in to change notification settings - Fork 2
[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
Merged
Merged
[FE] 입장 준비 화면 UI 구현 #168
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
749b946
🎨 Style: 버튼 스타일 일부 수정
chamny20 2366f2b
📍 Feat: 미팅룸 상위 페이지 조건부 렌더링 구현
chamny20 c7570bf
📍 Feat: 미디어 프리뷰에 관한 훅 구현
chamny20 4231f50
📍 Feat: 화상통화에 필요한 비디오뷰 컴포넌트 구현
chamny20 d14855d
🎨 Style: 미팅로비 UI 구현
chamny20 8f32a67
🎨 Style: button disabled 스타일 전역 추가
chamny20 89228d6
📍 Feat: 미디어 장치 불러오는 훅 구현
chamny20 32f8cf2
📍 Feat: 미디어 기기 드롭다운 컴포넌트 구현
chamny20 d5a586d
🌈 Update: 기기 페칭으로 인한 스트림 갱신 로직 추가
chamny20 97578f3
📍 Feat: 미디어 스트림 오디오 스트림과 비디오 스트림으로 분리로직 추가
chamny20 f10278e
🎨 Style: 스타일 수정 및 피드백 적용
chamny20 bde2886
🌈 Update: 불필요한 주석 제거
chamny20 6550561
🤖 Refactor: 비디오 및 오디오 토글 로직 개선
chamny20 14339f7
Merge branch 'dev' into feature/#122/intro-room
chamny20 9676a9c
🔧 Rename: 하나의 페이지 통일로 인한 MeetingRoom으로 컴포넌트 내용 이동
chamny20 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,34 +1,32 @@ | ||
| 'use client'; | ||
|
|
||
| import ChatModal from '@/components/meeting/ChatModal'; | ||
| import InfoModal from '@/components/meeting/InfoModal'; | ||
| import MeetingMenu from '@/components/meeting/MeetingMenu'; | ||
| import MemberModal from '@/components/meeting/MemberModal'; | ||
| import MemberVideoBar from '@/components/meeting/MemberVideoBar'; | ||
| import { useMeeingStore } from '@/store/useMeetingStore'; | ||
| 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 { isInfoOpen, isMemberOpen, isChatOpen } = useMeeingStore(); | ||
| const params = useParams<{ meetingId: string }>(); | ||
| const meetingId = params.meetingId; | ||
|
|
||
| return ( | ||
| <main className="flex h-screen w-screen flex-col bg-neutral-900"> | ||
| <MemberVideoBar /> | ||
|
|
||
| <section className="relative flex-1"> | ||
| {/* 워크스페이스 / 코드 에디터 등의 컴포넌트가 들어갈 공간 */} | ||
| const [isJoined, setIsJoined] = useState<boolean>(false); | ||
|
|
||
| {/* 참가자 / 채팅창 */} | ||
| {(isMemberOpen || isChatOpen) && ( | ||
| <div className="absolute top-2 right-2 bottom-2 flex w-80 flex-col gap-2"> | ||
| {isMemberOpen && <MemberModal />} | ||
| {isChatOpen && <ChatModal />} | ||
| </div> | ||
| )} | ||
| </section> | ||
| if (!meetingId) { | ||
| return <div>잘못된 회의 접근입니다. 다시 시도해주세요.</div>; | ||
| } | ||
|
|
||
| <MeetingMenu /> | ||
| // 참여 버튼을 눌렀을 때의 핸들러 | ||
| const handleJoin = () => { | ||
| setIsJoined(true); | ||
| }; | ||
|
|
||
| {isInfoOpen && <InfoModal />} | ||
| return ( | ||
| <main className="min-h-screen"> | ||
| {!isJoined ? ( | ||
| <MeetingLobby meetingId={meetingId} onJoin={handleJoin} /> | ||
| ) : ( | ||
| <MeetingRoom meetingId={meetingId} /> | ||
| )} | ||
| </main> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import { ArrowDownIcon } from '@/assets/icons/common'; | ||
| 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> | ||
|
|
||
| <ArrowDownIcon className="h-4 w-4 shrink-0" /> | ||
| </button> | ||
|
|
||
| {!isDisabled && isOpen && ( | ||
| <ul className="absolute z-10 mt-1 max-h-30 w-full overflow-y-auto rounded-sm border border-neutral-200 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> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| 'use client'; | ||
|
|
||
| import ChatModal from '@/components/meeting/ChatModal'; | ||
| import InfoModal from '@/components/meeting/InfoModal'; | ||
| import MeetingMenu from '@/components/meeting/MeetingMenu'; | ||
| import MemberModal from '@/components/meeting/MemberModal'; | ||
| import MemberVideoBar from '@/components/meeting/MemberVideoBar'; | ||
| import { useMeeingStore } from '@/store/useMeetingStore'; | ||
|
|
||
| export default function MeetingRoom({ meetingId }: { meetingId: string }) { | ||
| const { isInfoOpen, isMemberOpen, isChatOpen } = useMeeingStore(); | ||
|
|
||
| return ( | ||
| <main className="flex h-screen w-screen flex-col bg-neutral-900"> | ||
| <MemberVideoBar /> | ||
|
|
||
| <section className="relative flex-1"> | ||
| {/* 워크스페이스 / 코드 에디터 등의 컴포넌트가 들어갈 공간 */} | ||
|
|
||
| {/* 참가자 / 채팅창 */} | ||
| {(isMemberOpen || isChatOpen) && ( | ||
| <div className="absolute top-2 right-2 bottom-2 flex w-80 flex-col gap-2"> | ||
| {isMemberOpen && <MemberModal />} | ||
| {isChatOpen && <ChatModal />} | ||
| </div> | ||
| )} | ||
| </section> | ||
|
|
||
| <MeetingMenu /> | ||
|
|
||
| {isInfoOpen && <InfoModal />} | ||
| </main> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| 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(); | ||
|
|
||
| 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-3 text-neutral-700 shadow-lg transition-all group-active:scale-95" /> | ||
| ) : ( | ||
| <CamOffIcon className="h-12 w-12 rounded-full bg-white p-3 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-3 text-neutral-700 shadow-lg transition-all group-active:scale-95" /> | ||
| ) : ( | ||
| <MicOffIcon className="h-12 w-12 rounded-full bg-white p-3 shadow-lg transition-all group-active:scale-95" /> | ||
| )} | ||
| </button> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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' : ''}`} | ||
| /> | ||
| ); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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에서 체크하는 것이 좋다.⇒ 일단 우리 서비스에선 비로그인 사용자도 참여 가능하다. → 비회원인 경우 넘어갈 페이지로 미들웨어 설정해주기?