카메라 연결
저번에 음성채널 관련해서 전체화면 로직을 구현하였는데 정상 작동되는 걸 확인하였고 카메라 연결 기능으로 넘어갔다. 카메라 연결 하는 건 이전 프로젝트에서 진행해봤기 대문에 크게 어랴움 없이 구현을 할 수 있을 것이라 생각했다.
먼저 카메라 연결을 위한 훅을 설정해주었다.
useCamera
typescriptimport { useState } from "react";
export const useCamera = () => {
const [cameraStream, setCameraStream] = useState<MediaStream | null>(null);
const [cameraError, setCameraError] = useState<string | null>(null);
const startCamera = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
});
setCameraStream(stream);
setCameraError(null);
} catch (error) {
setCameraError(error instanceof Error ? error.message : "Unknown error");
setCameraStream(null);
}
};
const stopCamera = () => {
if (cameraStream) {
cameraStream.getTracks().forEach((track) => track.stop());
setCameraStream(null);
}
};
const toggleCamera = () => {
if (cameraStream) {
stopCamera();
} else {
startCamera();
}
};
return {
cameraStream,
setCameraStream,
cameraError,
setCameraError,
startCamera,
stopCamera,
toggleCamera,
};
};
간단하게 카메라 stream의 상태를 정의 해주고 WebRTC API의 navigator.mediaDevices.getUserMedia 함수를 이용하여 비디오 접근 권한을 가져왔고 카메라 stream의 상태를 업데이트 해주었다. 그 후 토글을 다시 눌렀을 때 카메라 stream 을 정리하기 위해 stomCamera 함수를 정의하고 getTracks 함수를 이용하였다. 이제 toggleCamera 함수를 활용해 사용자가 버튼을 누를 때마다 카메라가 시작되고 꺼지는 것을 정의하였다.
그 후 해당 훅과 redux 상태 값과 연결하기 위해 useMediaConrol 훅을 생성해주었는데 이 훅의 경우 추후에 오디오, 마이크의 상태값 등 미디어와 관련된 상태를 제어 할 수 있도록 구조를 작성하였다.
useMediaControl
typescriptimport { useCallback } from "react";
import { useCamera } from "./useCamera";
import { useLoadRedux } from "./useLoadRedux";
export const useMediaControl = () => {
const { cameraStream, startCamera, stopCamera } = useCamera();
const { isVideoOn, toggleMyVideo } = useLoadRedux();
const handleToggleVideo = useCallback(() => {
// 1. Redux 상태 먼저 업데이트
toggleMyVideo();
// 2. 현재 상태에 따라 카메라 제어
if (isVideoOn) {
stopCamera(); // 현재 켜져있으면 끄기
} else {
startCamera(); // 현재 꺼져있으면 켜기
}
}, [isVideoOn, toggleMyVideo, startCamera, stopCamera, cameraStream]);
return {
cameraStream,
startCamera,
stopCamera,
handleToggleVideo,
};
};
이렇게 해당 훅들을 이용하여 음성 채널의 최상위 컴포넌트인 page 에 훅을 초기화 해주고 props를 통해 카메라 stream 상태를 넘겨주었다.
page
typescript"use client";
import { useVoiceChannelPage } from "../../../../../hooks/useVoiceChannelPage";
import { useVoiceGrid } from "../../../../../hooks/useVoiceGrid";
import { useMediaControl } from "../../../../../hooks/useMediaControl";
import dynamic from "next/dynamic";
import {
VoiceHeader,
ScreenShareView,
VoiceGrid,
VoiceControlBar,
} from "../components";
// FullscreenButton을 클라이언트에서만 로드 (hydration 에러 방지)
const FullscreenButton = dynamic(
() =>
import("../components").then((mod) => ({ default: mod.FullscreenButton })),
{
ssr: false,
loading: () => null, // 로딩 중에는 아무것도 보여주지 않음
}
);
const VoiceChannelPage = () => {
const {
channelId,
getChannelName,
currentUserId,
isMicOn,
isFullScreen,
isVideoOn,
isScreenShareActive,
isScreenSharing,
participants,
participantCount,
toggleMic,
toggleFullScreen,
toggleScreenShare,
} = useVoiceChannelPage();
// 미디어 제어 (카메라 포함)
const { cameraStream, handleToggleVideo } = useMediaControl();
// 참여자 수에 따른 그리드 레이아웃 계산
const { gridLayout, gridRows } = useVoiceGrid(
channelId,
participantCount,
isMicOn,
isVideoOn,
isScreenShareActive,
participants
);
return (
<div className="h-full bg-aurora-voice text-white flex flex-col relative">
{/* 상단 헤더 */}
<VoiceHeader channelName={getChannelName(channelId)} />
{/* 메인 비디오 영역 */}
<div className="flex-1 p-8 flex items-center justify-center">
{isScreenShareActive ? (
/* 화면 공유 모드 */
<ScreenShareView
participants={participants}
currentUserId={currentUserId}
cameraStream={cameraStream}
/>
) : (
/* 일반 화상회의 모드 */
<VoiceGrid
participants={participants}
gridLayout={gridLayout}
gridRows={gridRows}
currentUserId={currentUserId}
cameraStream={cameraStream}
/>
)}
</div>
{/* 하단 컨트롤 바 */}
<VoiceControlBar
isMicOn={isMicOn}
isVideoOn={isVideoOn}
isScreenSharing={isScreenSharing}
onToggleMic={toggleMic}
onToggleVideo={handleToggleVideo}
onToggleScreenShare={toggleScreenShare}
/>
{/* 전체화면 버튼 - 클라이언트에서만 렌더링 */}
<FullscreenButton
onToggleFullscreen={toggleFullScreen}
isFullScreen={isFullScreen}
/>
</div>
);
};
export default VoiceChannelPage;
이제 카메라를 끄고 킬 수 있도록, 또한 카메라가 켜져 있을 경우 화면이 나오도록 컴포넌트를 수정하였다.
VoiceControlBar
typescriptinterface VoiceControlBarProps {
isMicOn: boolean;
isVideoOn: boolean;
isScreenSharing: boolean;
onToggleMic: () => void;
onToggleVideo: () => void;
onToggleScreenShare: () => void;
onEndCall?: () => void;
}
export const VoiceControlBar = ({
isMicOn,
isVideoOn,
isScreenSharing,
onToggleMic,
onToggleVideo,
onToggleScreenShare,
onEndCall,
}: VoiceControlBarProps) => {
const handleEndCall = () => {
if (onEndCall) {
onEndCall();
} else {
// 기본 동작: 뒤로가기
window.history.back();
}
};
return (
<div className="absolute bottom-6 left-1/2 transform -translate-x-1/2 z-10">
<div className="flex items-center gap-4 bg-gray-800 rounded-full px-6 py-4">
{/* 마이크 */}
<button
onClick={onToggleMic}
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${
isMicOn
? "bg-gray-600 hover:bg-gray-500"
: "bg-red-500 hover:bg-red-600"
}`}
aria-label={isMicOn ? "마이크 끄기" : "마이크 켜기"}
>
{isMicOn ? (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M7 4a3 3 0 016 0v4a3 3 0 11-6 0V4zm4 10.93A7.001 7.001 0 0017 8a1 1 0 10-2 0A5 5 0 015 8a1 1 0 00-2 0 7.001 7.001 0 006 6.93V17H6a1 1 0 100 2h8a1 1 0 100-2h-3v-2.07z"
clipRule="evenodd"
/>
</svg>
) : (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M2.293 2.293a1 1 0 011.414 0L7 5.586V4a3 3 0 116 0v4c0 .57-.16 1.104-.44 1.563l1.828 1.828A6.966 6.966 0 0015 8a1 1 0 012 0 8.94 8.94 0 01-1.22 4.522l1.927 1.927a1 1 0 01-1.414 1.414L3.707 3.707a1 1 0 010-1.414zM10 11.414L7.586 9A3.001 3.001 0 007 8v3a3 3 0 003 .414z"
clipRule="evenodd"
/>
</svg>
)}
</button>
{/* 비디오 */}
<button
onClick={onToggleVideo}
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${
isVideoOn
? "bg-gray-600 hover:bg-gray-500"
: "bg-red-500 hover:bg-red-600"
}`}
aria-label={isVideoOn ? "비디오 끄기" : "비디오 켜기"}
>
{isVideoOn ? (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 6a2 2 0 012-2h6a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V6zM14.553 7.106A1 1 0 0014 8v4a1 1 0 00.553.894l2 1A1 1 0 0018 13V7a1 1 0 00-1.447-.894l-2 1z" />
</svg>
) : (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z"
clipRule="evenodd"
/>
</svg>
)}
</button>
{/* 화면 공유 */}
<button
onClick={onToggleScreenShare}
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${
isScreenSharing
? "bg-green-500 hover:bg-green-600"
: "bg-gray-600 hover:bg-gray-500"
}`}
aria-label={isScreenSharing ? "화면 공유 중지" : "화면 공유 시작"}
>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M3 4a1 1 0 011-1h12a1 1 0 011 1v8a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm2 3v6h10V7H5z"
clipRule="evenodd"
/>
</svg>
</button>
{/* 통화 종료 */}
<button
onClick={handleEndCall}
className="w-12 h-12 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center transition-colors"
aria-label="통화 종료"
>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 3a1 1 0 011-1h2.153a1 1 0 01.986.836l.74 4.435a1 1 0 01-.54 1.06l-1.548.773a11.037 11.037 0 006.105 6.105l.774-1.548a1 1 0 011.059-.54l4.435.74a1 1 0 01.836.986V17a1 1 0 01-1 1h-2C7.82 18 2 12.18 2 5V3z" />
</svg>
</button>
</div>
</div>
);
};
VoiceGrid
typescriptimport { VoiceParticipantCard } from "./VoiceParticipantCard";
import { VoiceParticipant } from "../../../../../types/voiceChannelTypes";
interface VoiceGridProps {
participants: { [userId: string]: VoiceParticipant };
gridLayout: string;
gridRows: string;
currentUserId?: string; // 현재 사용자 ID
cameraStream?: MediaStream | null; // 카메라 스트림
}
export const VoiceGrid = ({
participants,
gridLayout,
gridRows,
currentUserId,
cameraStream,
}: VoiceGridProps) => {
return (
<div
className={`grid ${gridLayout} ${gridRows} gap-4 w-full h-full max-w-6xl`}
>
{Object.entries(participants).map(([userId, participant]) => (
<VoiceParticipantCard
key={userId}
participant={participant}
isCompact={false}
videoStream={
userId === currentUserId && participant.isVideoOn
? cameraStream || undefined
: undefined
}
/>
))}
</div>
);
};
VoiceParticipantCard
typescriptimport { VoiceParticipant } from "../../../../../types/voiceChannelTypes";
import { useEffect, useRef } from "react";
interface VoiceParticipantCardProps {
participant: VoiceParticipant;
isCompact?: boolean; // 화면 공유 모드일 때 작은 크기
videoStream?: MediaStream; // 비디오 스트림
}
export const VoiceParticipantCard = ({
participant,
isCompact = false,
videoStream,
}: VoiceParticipantCardProps) => {
const videoRef = useRef<HTMLVideoElement>(null);
// 비디오 스트림 연결
useEffect(() => {
if (videoRef.current && videoStream) {
videoRef.current.srcObject = videoStream;
}
}, [videoStream, participant.isVideoOn, participant.username]);
const avatarSize = isCompact ? "w-12 h-12" : "w-24 h-24";
const textSize = isCompact ? "text-xs" : "text-sm";
const iconSize = isCompact ? "w-4 h-4" : "w-8 h-8";
const namePosition = isCompact ? "bottom-2 left-2" : "bottom-4 left-4";
return (
<div
className={`bg-gray-800 rounded-lg ${
isCompact ? "p-4 aspect-video" : "aspect-video"
} flex flex-col items-center justify-center relative overflow-hidden ${
participant.isSpeaking ? "ring-2 ring-green-400" : ""
}`}
>
{/* 사용자 비디오 또는 아바타 */}
{participant.isVideoOn && videoStream ? (
<video
ref={videoRef}
autoPlay
muted
playsInline
className="absolute inset-0 w-full h-full object-cover rounded-lg"
/>
) : (
<div
className={`${avatarSize} bg-gray-600 rounded-full flex items-center justify-center ${
isCompact ? "text-xl mb-2" : "text-3xl mb-4"
}`}
>
{participant.username[0]}
</div>
)}
{/* 이름과 상태 아이콘 컨테이너 */}
<div className={`absolute ${namePosition} flex items-center gap-2`}>
{/* 사용자 이름 */}
<div className="bg-aurora-voice rounded-full px-5">
<span className={`${textSize} text-white`}>
{participant.username}
</span>
</div>
{/* 상태 표시 아이콘들 */}
<div className={`flex gap-${isCompact ? "1" : "2"}`}>
{!participant.isMicOn && (
<div
className={`${iconSize} bg-red-500 rounded-full flex items-center justify-center`}
>
<svg
className={`${isCompact ? "w-3 h-3" : "w-4 h-4"}`}
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
</div>
)}
{!participant.isVideoOn && (
<div
className={`${iconSize} bg-gray-500 rounded-full flex items-center justify-center`}
>
<svg
className={`${isCompact ? "w-3 h-3" : "w-4 h-4"}`}
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z"
clipRule="evenodd"
/>
<path d="M12.454 16.697L9.75 13.992a4 4 0 01-3.742-3.741L2.335 6.578A9.98 9.98 0 00.458 10c1.274 4.057 5.065 7 9.542 7 .847 0 1.669-.105 2.454-.303z" />
</svg>
</div>
)}
</div>
</div>
{/* 발언 중 애니메이션 */}
{participant.isSpeaking && (
<div className="absolute inset-0 border-2 border-green-400 rounded-lg animate-pulse"></div>
)}
</div>
);
};
결과
카메라 켜기 전

image.png
카메라 켠 후

image.png
다음과 같이 카메라 기능이 정상적으로 작동하게 되었다! 이제 다음에는 마이크랑 헤드셋 부분 빠르게 만들어보자!
