지난 돌돌밋 프로젝트에서 OpenVidu를 사용해서 WebRTC 기능을 구현했었다.
그런데 이 때 아쉬웠던 점은 OpenVidu 관련 코드가 컴포넌트 내에서 너무 많은 양을 차지하다보니까 코드 가독성이 떨어졌던 점이었다.
그리고 WebRTC 기능을 사용하는 페이지마다 동일한 코드가 반복적으로 들어가는 것도 비효율적이었다.
이런 문제를 해결하기 위해 useOpenVidu
훅과 useMediaDevice
훅을 만들어보았다.
자세한 코드는 GitHub에서 확인할 수 있다.
useOpenVidu
OpenVidu로 영상통화 기능을 구현하려면 아래와 같은 작업들이 이루어져야 하는데, useOpenVidu
내에서 이 작업들을 수행하도록 했다.
OpenVidu
객체 생성Session
객체 생성/openvidu/api/sessions
API로 요청을 보내서 세션 생성streamCreated
, streamDestroyed
이벤트 핸들러 등록/openvidu/api/sessions/${sessionId}/connection
API로 요청을 보내서 토큰 발급Publisher
객체 생성 Publisher
객체를 세션에 배포OptionsType
useOpenVidu
의 파라미터로는 아래의 값들을 받을 수 있도록 했다.
sessionId
: 세션 아이디connect
: 영상통화 연결 여부 (true가 되는 순간 연결함)clientData
: 세션 연결 시 전달할 클라이언트의 정보 (아이디, 이름 등)eventHandlers
: 세션 이벤트 핸들러의 리스트publisherProperties
: Publisher 설정 옵션 (거울 모드, frame rate 등)ReturnType
useOpenVidu
는 아래의 객체들을 반환하도록 했다.
현재 영상통화의 설정을 변경할 때는 아래의 객체들에 접근하는 것이 필요하므로 이를 jotai의 atom
으로 두어서 여러 컴포넌트에서 접근할 수 있도록 했다.
ov
: OpenVidu
객체session
: Session
객체myStream
: 나의 미디어 스트림 (Publisher
객체)subscribers
: 다른 사람들의 미디어 스트림 (StreamManager
의 배열)SessionEventHandler
OpenVidu는 Session
객체의 on
메서드를 통해 특정 이벤트가 발생했을 때 이루어져야 할 작업을 정의할 수 있다. (예를 들어 signal:alert
이벤트가 발생하면 팝업이 열리게 하는 등)
지난 번 돌돌밋 프로젝트를 할 당시 OpenVidu 관련 로직을 쉽게 분리하지 못했던 이유는 이벤트 핸들러 등록 때문이었다. 왜냐하면 특정 이벤트가 발생했을 때 컴포넌트 내부의 state를 변경해야 하는 경우가 많았기 때문이다.
이러한 문제를 해결하기 위해 SessionEventHandler
타입을 정의했다. 그리고 state 변경 등 이벤트 발생 시 실행되어야 할 로직은 handler
프로퍼티에 담아서 useOpenVidu
에 전달할 수 있도록 했다.
export type SessionEventHandler<K extends keyof SessionEventMap> = {
type: K;
handler: (event: SessionEventMap[K]) => void;
};
beforeunload
useOpenVidu
페이지를 벗어나면 영상통화가 끊어지도록 해야 했다.
그렇지 않으면 영상통화 페이지를 벗어났는데도 카메라와 마이크를 사용하는 상태로 그대로 남아있었다.
이를 위해 beforeunload
이벤트가 발생하면 세션의 disconnect
메서드를 호출하여 연결을 해제하도록 했다.
useOpenVidu
전체 코드import { useEffect } from "react";
import { useAtom } from "jotai";
import {
OpenVidu,
Publisher,
PublisherProperties,
Session,
StreamManager,
} from "openvidu-browser";
import { SessionEventHandler } from "@/app/openvidu/constants";
import { joinSession } from "@/app/openvidu/api";
import {
myStreamAtom,
openViduAtom,
sessionAtom,
subscribersAtom,
} from "@/app/openvidu/store";
import { registerDefaultEventHandler } from "@/app/openvidu/utils";
interface OptionsType {
sessionId: string;
connect: boolean;
clientData?: any;
eventHandlers?: SessionEventHandler<any>[];
publisherProperties?: PublisherProperties;
}
interface ReturnType {
ov?: OpenVidu;
session?: Session;
myStream?: Publisher;
subscribers?: StreamManager[];
}
function useOpenVidu({
sessionId,
connect,
clientData,
eventHandlers = [],
publisherProperties,
}: OptionsType): ReturnType {
const [ov, setOv] = useAtom<OpenVidu | undefined>(openViduAtom);
const [session, setSession] = useAtom<Session | undefined>(sessionAtom);
const [myStream, setMyStream] = useAtom<Publisher | undefined>(myStreamAtom);
const [subscribers, setSubscribers] =
useAtom<StreamManager[]>(subscribersAtom);
// 세션 아이디가 변경될 때마다 세션에 다시 연결
useEffect(() => {
async function joinNewSession() {
if (sessionId && connect) {
const ov = new OpenVidu();
const session = ov.initSession();
// 기본 이벤트 핸들러 등록 (Stream 추가 / 제거)
registerDefaultEventHandler(eventHandlers, session, setSubscribers);
const myStream = await joinSession({
sessionId,
ov,
session,
eventHandlers,
clientData,
publisherProperties,
});
// 상태 업데이트
setOv(ov);
setSession(session);
setMyStream(myStream);
}
}
joinNewSession();
}, [sessionId, connect]);
// 페이지를 벗어날 때 세션 연결 해제
useEffect(() => {
const beforeUnload = (event: BeforeUnloadEvent) => {
// 세션 연결 해제
session?.disconnect();
// atom 초기화
setOv(undefined);
setSession(undefined);
setMyStream(undefined);
setSubscribers([]);
event.returnValue = true;
};
window.addEventListener("beforeunload", beforeUnload);
return () => {
window.removeEventListener("beforeunload", beforeUnload);
};
}, []);
return {
ov,
session,
myStream,
subscribers,
};
}
export default useOpenVidu;
useMediaDevice
사용할 수 있는 비디오 / 오디오 장치가 여러 가지 있는 경우 useMediaDevice
를 통해 그 목록을 확인하고, 변경할 수 있도록 했다.
OpenVidu
객체의 getDevices()
메서드를 통해 사용자의 미디어 디바이스 목록을 가져올 수 있기 때문에, useOpenVidu
에서 생성한 OpenVidu
객체를 useAtomValue
를 사용해서 가져왔다.
그리고 선택한 장치가 변경되면 이 옵션으로 새로운 Publisher
객체를 만들고 이를 세션에 다시 publish하도록 했다.
useMediaDevice
기능을 테스트해보기 위해 아이폰으로 접속해보았는데, 아래와 같이 사용할 수 있는 디바이스 목록이 뜨고 선택한 옵션으로 변경 가능함을 확인할 수 있었다.
useMediaDevice
코드 전체import { useEffect, useState } from "react";
import { useAtom, useAtomValue } from "jotai";
import {
Device,
OpenVidu,
Publisher,
PublisherProperties,
Session,
} from "openvidu-browser";
import { myStreamAtom, openViduAtom, sessionAtom } from "@/app/openvidu/store";
import { defaultPublisherProperties } from "@/app/openvidu/constants";
interface OptionType {
publisherProperties?: PublisherProperties;
}
interface ReturnType {
audioInputs: Device[];
videoInputs: Device[];
selectedAudio: Device | undefined;
selectedVideo: Device | undefined;
changeMic: (deviceId: string) => void;
changeCamera: (deviceId: string) => void;
}
export default function useMediaDevice({
publisherProperties = defaultPublisherProperties,
}: OptionType = {}): ReturnType {
// atom
const ov = useAtomValue<OpenVidu | undefined>(openViduAtom);
const session = useAtomValue<Session | undefined>(sessionAtom);
const [myStream, setMyStream] = useAtom<Publisher | undefined>(myStreamAtom);
// state
const [audioInputs, setAudioInputs] = useState<Device[]>([]);
const [videoInputs, setVideoInputs] = useState<Device[]>([]);
const [selectedAudio, setSelectedAudio] = useState<Device>();
const [selectedVideo, setSelectedVideo] = useState<Device>();
useEffect(() => {
async function init() {
if (ov) {
const devices = await ov.getDevices();
const audioDevices = devices.filter(
(device) => device.kind === "audioinput",
);
const videoDevices = devices.filter(
(device) => device.kind === "videoinput",
);
setAudioInputs(audioDevices);
setVideoInputs(videoDevices);
setSelectedAudio(audioDevices[0]);
setSelectedVideo(videoDevices[0]);
}
}
init();
}, [ov, session]);
// 카메라 / 마이크 선택이 변경될 경우 변경된 Publisher 스트림을 세션에 배포
useEffect(() => {
async function changeDevice() {
if (ov && session) {
const newPublisher = await ov.initPublisherAsync(undefined, {
...publisherProperties,
videoSource: selectedVideo?.deviceId,
audioSource: selectedAudio?.deviceId,
});
if (myStream) {
await session.unpublish(myStream);
}
await session.publish(newPublisher);
setMyStream(newPublisher);
}
}
changeDevice();
}, [selectedAudio, selectedVideo]);
// 마이크 변경
function changeMic(deviceId: string) {
const selected = audioInputs.find((audio) => audio.deviceId === deviceId);
setSelectedAudio(selected);
}
// 카메라 변경
function changeCamera(deviceId: string) {
const selected = videoInputs.find((video) => video.deviceId === deviceId);
setSelectedVideo(selected);
}
return {
audioInputs,
videoInputs,
selectedAudio,
selectedVideo,
changeMic,
changeCamera,
};
}