background
aurora3분

화면 공유 기능 추가

2025년 12월 10일

화면 공유 시작

마이크 연결 기능 추가 이후 바로 화면 공유 기능을 구현하기로 했다. 사실 마이크나 화면공유나 코드의 흐름은 다 거기서 거기였는데 차이점은 마이크는 MediaStreamgetUserMedia 를 통해 가져올 수 있었다면 화면 공유는 getDisplayMedia 를 통해서 가져왔어야 했다. 그 부분을 제외하면 진짜 코드는 거기서 거기였다.

먼저 마이크나 카메라의 경우 처럼 MediaStream 을 가져오는 useScreenShare 훅을 정의해 주었다.

useScreenShare()

typescript
import { 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()

typescript
import { 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

typescript
import { 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

typescript
import { 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

image.png

image.png

화면 공유 상태에서 전체 화면

image.png

image.png

화면 공유 해제

image.png

image.png

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

태그

#aurora