aurora3분
화면 공유 기능 추가
2025년 12월 10일
화면 공유 시작
마이크 연결 기능 추가 이후 바로 화면 공유 기능을 구현하기로 했다. 사실 마이크나 화면공유나 코드의 흐름은 다 거기서 거기였는데 차이점은 마이크는 MediaStream 이 getUserMedia 를 통해 가져올 수 있었다면 화면 공유는 getDisplayMedia 를 통해서 가져왔어야 했다. 그 부분을 제외하면 진짜 코드는 거기서 거기였다.
먼저 마이크나 카메라의 경우 처럼 MediaStream 을 가져오는 useScreenShare 훅을 정의해 주었다.
useScreenShare()
typescriptimport { useState, useRef } from "react";
export const useScreenShare = () => {
const [screenStream, setScreenStream] = useState<MediaStream | null>(null);
const [screenError, setScreenError] = useState<string | null>(null);
// WebRTC sender를 외부에서 주입해야 replaceTrack 가능
const senderRef = useRef<RTCRtpSender | null>(null);
/** 외부에서 sender 주입하는 함수 (화면 트랙 보낼 sender) */
const setSender = (sender: RTCRtpSender) => {
senderRef.current = sender;
};
/** 화면 공유 시작 */
const startScreenShare = async () => {
try {
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false,
});
const track = stream.getVideoTracks()[0];
// 브라우저에서 사용자가 직접 공유를 끄면 자동 처리
track.onended = () => stopScreenShare();
// WebRTC 전송 트랙 연결
if (senderRef.current) {
await senderRef.current.replaceTrack(track);
}
setScreenStream(stream);
setScreenError(null);
return stream;
} catch (err) {
setScreenError(err instanceof Error ? err.message : "Unknown error");
return null;
}
};
/** 화면 공유 종료 */
const stopScreenShare = () => {
if (screenStream) {
// 기존 스트림 종료
screenStream?.getTracks().forEach((t) => t.stop());
setScreenStream(null);
}
// 송출 중단 (비디오 트랙 제거)
if (senderRef.current) {
senderRef.current.replaceTrack(null);
}
};
/** 화면 공유 변경 (창만 다시 선택) */
const changeScreenShare = async () => {
const wasFullScreen = document.fullscreenElement !== null;
if (!screenStream) return;
try {
// 새 화면 선택창 띄움 (여기서 1번만 뜸)
const newStream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false,
});
if (wasFullScreen) {
document.documentElement.requestFullscreen();
}
const newTrack = newStream.getVideoTracks()[0];
// 선택 취소하면 newTrack 없음 → 아무 것도 안 바꿈
if (!newTrack) return;
// 기존 트랙 종료
screenStream.getTracks().forEach((t) => t.stop());
// replaceTrack으로 화면만 교체
if (senderRef.current) {
await senderRef.current.replaceTrack(newTrack);
}
// 상태 업데이트
setScreenStream(newStream);
// 변경한 화면이 종료될 경우 처리
newTrack.onended = () => stopScreenShare();
} catch (err) {
// ❗ 취소 시 여기로 들어오지만 기존 공유 유지됨
if (wasFullScreen) {
document.documentElement.requestFullscreen();
}
console.log("화면 공유 변경 취소됨:", err);
}
};
return {
screenStream,
screenError,
startScreenShare,
stopScreenShare,
changeScreenShare,
// WebRTC RTCRtpSender 주입
setSender,
};
};
그나마 좀 다른 점은 화면 공유는 사용자가 중간에 보일 화면을 변경할 수 있다는 점인데 해당 부분은 WebRTC API 를 통해서 해결하였다.
그 후 중간 훅인 useMediaControl 과 연결 해주고
useMediaControl()
typescriptimport { useCallback, useEffect } from "react";
import { useCamera } from "./useCamera";
import { useLoadRedux } from "./useLoadRedux";
import { useMike } from "./useMike";
import { useScreenShare } from "./useScreenShare";
export const useMediaControl = () => {
// 카메라 제어
const { cameraStream, startCamera, stopCamera } = useCamera();
const {
isVideoOn,
toggleMyVideo,
setSpeaking,
currentUserId,
isMicOn,
toggleMyMic,
toggleMyScreenShare,
isScreenSharing,
} = useLoadRedux();
// 마이크 제어
const { mikeStream, startMike, stopMike } = useMike((isSpeking) => {
setSpeaking(currentUserId, isSpeking);
});
// 화면 공유 제어
const { screenStream, startScreenShare, stopScreenShare, changeScreenShare } =
useScreenShare();
// 음성 채널 입장 시 바로 마이크 on
useEffect(() => {
startMike();
// 클린 업 함수로 마이크 중지
return () => {
stopMike();
};
}, []);
// 비디오 토글
const handleToggleVideo = useCallback(() => {
// 1. Redux 상태 먼저 업데이트
toggleMyVideo();
// 2. 현재 상태에 따라 카메라 제어
if (isVideoOn) {
stopCamera(); // 현재 켜져있으면 끄기
} else {
startCamera(); // 현재 꺼져있으면 켜기
}
}, [isVideoOn, toggleMyVideo, startCamera, stopCamera, cameraStream]);
// 마이크 토글
const handleToggleMic = useCallback(() => {
// 1. Redux 상태 업데이트
toggleMyMic();
// 2. 현재 상태에 따라 마이크 제어
if (isMicOn) {
stopMike(); // 켜져 있으면 끄기
} else {
startMike(); // 꺼져 있으면 켜기
}
}, [isMicOn, toggleMyMic, startMike, stopMike]);
// 화면 공유 토글
const handleToggleScreenShare = useCallback(() => {
// 1. Redux 상태 업데이트
toggleMyScreenShare();
// 2. 헌재 상태에 따라 화면공유 제어
if (!isScreenSharing) {
console.log("화면 공유 시작");
startScreenShare(); // 켜저 있으면 끄기
} else {
console.log("화면 공유 종료");
stopScreenShare(); // 꺼져 있으면 켜기
}
}, [isScreenSharing, toggleMyScreenShare, startScreenShare, stopScreenShare]);
const handdleChangeScreenShare = useCallback(() => {
if (!isScreenSharing) return;
changeScreenShare();
}, [isScreenSharing, changeScreenShare]);
return {
cameraStream,
startCamera,
stopCamera,
handleToggleVideo,
mikeStream,
startMike,
stopMike,
handleToggleMic,
screenStream,
startScreenShare,
stopScreenShare,
handleToggleScreenShare,
isScreenSharing,
handdleChangeScreenShare,
};
};
UI 에 반영해주면 끝이었다.
ScreenShareView.tsx
typescriptimport { VoiceParticipantCard } from "./VoiceParticipantCard";
import { VoiceParticipant } from "../../../../../types/voiceChannelTypes";
import { useResponsive } from "../../../../../../lib/useResponsive";
import { ScreenVideo } from "./ScreenVideo";
interface ScreenShareViewProps {
participants: { [userId: string]: VoiceParticipant };
currentUserId?: string; // 현재 사용자 ID
cameraStream?: MediaStream | null; // 카메라 스트림
mikeStream?: MediaStream | null;
screenStream?: MediaStream | null;
}
export const ScreenShareView = ({
participants,
currentUserId,
cameraStream,
mikeStream,
screenStream,
}: ScreenShareViewProps) => {
const { isMobile } = useResponsive();
return (
<div
className={`
w-full flex flex-col gap-4
${isMobile ? "h-full" : "h-3/4"}
`}
>
{/* 메인 화면 공유 영역 */}
<div className="flex-1 bg-gray-800 rounded-lg overflow-hidden relative">
{screenStream ? (
<ScreenVideo stream={screenStream} />
) : (
<div className="text-center">
<div
className={`
bg-gradient-to-br from-green-400 to-blue-500 rounded-lg mx-auto mb-4 flex items-center justify-center
${isMobile ? "w-20 h-20" : "w-32 h-32"}
`}
>
<span className={`${isMobile ? "text-2xl" : "text-4xl"}`}>
🖥️
</span>
</div>
<p
className={`
text-gray-300
${isMobile ? "text-sm" : "text-base"}
`}
></p>
</div>
)}
</div>
{/* 하단에 참여자들 */}
<div
className={`
flex gap-3 overflow-x-auto pb-2
${isMobile ? "h-20" : "h-32"}
`}
>
{Object.entries(participants).map(([userId, participant]) => (
<div
key={userId}
className={`
flex-shrink-0
${isMobile ? "w-32" : "w-48"}
`}
>
<VoiceParticipantCard
participant={participant}
isCompact={true}
videoStream={
userId === currentUserId && participant.isVideoOn
? cameraStream || undefined
: undefined
}
mikeStream={
userId === currentUserId && participant.isMicOn
? mikeStream || undefined
: undefined
}
/>
</div>
))}
</div>
</div>
);
};
VoiceControlBar.tsx
typescriptimport { useResponsive } from "../../../../../../lib/useResponsive";
import { ChangeIcon } from "./icons/ChangeIcon";
import { ScreenIcon } from "./icons/ScreenShareIcon";
import { StopIcon } from "./icons/StopIcon";
interface VoiceControlBarProps {
isMicOn: boolean;
isVideoOn: boolean;
isScreenSharing: boolean;
onToggleMic: () => void;
onToggleVideo: () => void;
onToggleScreenShare: () => void;
onToggleChangeScreenShare: () => void;
onEndCall?: () => void;
}
export const VoiceControlBar = ({
isMicOn,
isVideoOn,
isScreenSharing,
onToggleMic,
onToggleVideo,
onToggleScreenShare,
onToggleChangeScreenShare,
onEndCall,
}: VoiceControlBarProps) => {
const { isMobile } = useResponsive();
const handleEndCall = () => {
if (onEndCall) {
onEndCall();
} else {
// 기본 동작: 뒤로가기
window.history.back();
}
};
return (
<div
className={`
absolute left-1/2 transform -translate-x-1/2 z-10
${isMobile ? "bottom-4" : "bottom-6"}
`}
>
<div
className={`
flex items-center bg-gray-800 rounded-full
${isMobile ? "gap-2 px-4 py-3" : "gap-4 px-6 py-4"}
`}
>
{/* 마이크 */}
<button
onClick={onToggleMic}
className={`
rounded-full flex items-center justify-center transition-colors
${isMobile ? "w-10 h-10" : "w-12 h-12"}
${
isMicOn
? "bg-gray-600 hover:bg-gray-500"
: "bg-red-500 hover:bg-red-600"
}
`}
aria-label={isMicOn ? "마이크 끄기" : "마이크 켜기"}
>
{isMicOn ? (
<svg
className={`
fill-current
${isMobile ? "w-5 h-5" : "w-6 h-6"}
`}
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={`
fill-current
${isMobile ? "w-5 h-5" : "w-6 h-6"}
`}
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={`
rounded-full flex items-center justify-center transition-colors
${isMobile ? "w-10 h-10" : "w-12 h-12"}
${
isVideoOn
? "bg-gray-600 hover:bg-gray-500"
: "bg-red-500 hover:bg-red-600"
}
`}
aria-label={isVideoOn ? "비디오 끄기" : "비디오 켜기"}
>
{isVideoOn ? (
<svg
className={`
fill-current
${isMobile ? "w-5 h-5" : "w-6 h-6"}
`}
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={`
fill-current
${isMobile ? "w-5 h-5" : "w-6 h-6"}
`}
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>
{/* 화면 공유 */}
{!isScreenSharing ? (
// 화면 공유 시작 버튼
<button
onClick={onToggleScreenShare}
className={`rounded-full flex items-center justify-center transition-colors
${isMobile ? "w-10 h-10" : "w-12 h-12"}
bg-gray-600 hover:bg-gray-500
`}
>
{/* 화면 공유 시작 아이콘 */}
<ScreenIcon className={isMobile ? "w-5 h-5" : "w-6 h-6"} />
</button>
) : (
// 화면 공유 중일 때 → 두 개의 버튼
<div className="flex gap-2">
{/* 화면 공유 종료 */}
<button
onClick={onToggleScreenShare}
className={`rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center transition-colors
${isMobile ? "w-10 h-10" : "w-12 h-12"}
`}
>
{/* STOP 아이콘 */}
<StopIcon className={isMobile ? "w-5 h-5" : "w-6 h-6"} />
</button>
{/* 화면 공유 변경 */}
<button
onClick={onToggleChangeScreenShare}
className={`rounded-full bg-green-500 hover:bg-green-600 flex items-center justify-center transition-colors
${isMobile ? "w-10 h-10" : "w-12 h-12"}
`}
>
{/* CHANGE 아이콘 */}
<ChangeIcon className={isMobile ? "w-5 h-5" : "w-6 h-6"} />
</button>
</div>
)}
{/* 통화 종료 */}
<button
onClick={handleEndCall}
className={`
rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center transition-colors
${isMobile ? "w-10 h-10" : "w-12 h-12"}
`}
aria-label="통화 종료"
>
<svg
className={`
fill-current
${isMobile ? "w-5 h-5" : "w-6 h-6"}
`}
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>
);
};
page.tsx
typescript"use client";
import { useVoiceChannelPage } from "../../../../../hooks/useVoiceChannelPage";
import { useVoiceGrid } from "../../../../../hooks/useVoiceGrid";
import { useMediaControl } from "../../../../../hooks/useMediaControl";
import { useResponsive } from "../../../../../../lib/useResponsive";
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 { isMobile } = useResponsive();
const {
channelId,
getChannelName,
currentUserId,
isMicOn,
isFullScreen,
isVideoOn,
isScreenShareActive,
participants,
participantCount,
toggleFullScreen,
} = useVoiceChannelPage();
// 미디어 제어 (카메라 포함)
const {
cameraStream,
handleToggleVideo,
mikeStream,
handleToggleMic,
screenStream,
handleToggleScreenShare,
isScreenSharing,
handdleChangeScreenShare,
} = 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 flex items-center justify-center
${isMobile ? "p-4" : "p-8"}
`}
>
{isScreenShareActive ? (
/* 화면 공유 모드 */
<ScreenShareView
participants={participants}
currentUserId={currentUserId}
cameraStream={cameraStream}
mikeStream={mikeStream}
screenStream={screenStream}
/>
) : (
/* 일반 화상회의 모드 */
<VoiceGrid
participants={participants}
gridLayout={gridLayout}
gridRows={gridRows}
currentUserId={currentUserId}
cameraStream={cameraStream}
mikeStream={mikeStream}
/>
)}
</div>
{/* 하단 컨트롤 바 */}
<VoiceControlBar
isMicOn={isMicOn}
isVideoOn={isVideoOn}
isScreenSharing={isScreenSharing}
onToggleMic={handleToggleMic}
onToggleVideo={handleToggleVideo}
onToggleScreenShare={handleToggleScreenShare}
onToggleChangeScreenShare={handdleChangeScreenShare}
/>
{/* 전체화면 버튼 - 데스크탑/태블릿에서만 렌더링 */}
{!isMobile && (
<FullscreenButton
onToggleFullscreen={toggleFullScreen}
isFullScreen={isFullScreen}
/>
)}
</div>
);
};
export default VoiceChannelPage;
화면 공유 시작

image.png

image.png
화면 공유 창 변경

image.png

image.png
화면 공유 상태에서 전체 화면

image.png
화면 공유 해제

image.png
이렇게 화면 공유 기능이 잘 된다! 이제 스피커 처리만 하면 서버의 시그널링만 기다리면 될 거 같다!
태그
#aurora
