SkyWayを使って1つのPeerでチャットとビデオ通話が同時にできるのか?
できます、良かった。
前置き
現在所属している現場で開発しているプロダクトがあり、ビデオ通話とチャットの機能を導入してみたいという話になりました。
そこで、自身が以前所属していた現場で SkyWay を使用した WebRTC の実装が非常に良かったので、試しにサンプルアプリを作ってみることとなりました。
ただ以前所属していた現場の実装は非常に汚く、またチャットとビデオ通話で別々の Peer インスタンスを生成していました。
そこでタイトルの疑問に至りました。
前提
今回は以下を使用して実装しています。
- React(Next.js)
- SkyWay
- Vercel
ただし Next.js の機能はまったく使用していないため、Create React App などでも問題なく動くはずです。
Vercel を使用する必要ももちろんありません。
成果物
実装
Container
ページの Container Component になります。
スタイリングを入れたらここを分割しますが、めんどくさかったのでノースタイリングです。
import Chat from "components/Chat";
import Form, { FormProps } from "components/Form";
import Video, { VideoProps } from "components/Video";
import { MouseEventHandler, useCallback, useEffect, useState } from "react";
import Peer, { DataConnection, MediaConnection } from "skyway-js";
function Pages() {
const [peer, setPeer] = useState<Peer>();
const [localId, setLocalId] = useState("");
const [enabledPeer, setEnabledPeer] = useState(false);
const [remoteId, setRemoteId] = useState<VideoProps["remoteId"]>();
const handleSubmit = useCallback<FormProps["onSubmit"]>(({ remoteId }) => {
setRemoteId(remoteId);
}, []);
const [dataConnection, setDataConnection] = useState<DataConnection>();
const [mediaConnection, setMediaConnection] = useState<MediaConnection>();
const handleClose = useCallback<MouseEventHandler<HTMLButtonElement>>(() => {
if (dataConnection && dataConnection.open) {
dataConnection.close(true);
}
if (mediaConnection && mediaConnection.open) {
mediaConnection.close(true);
}
setRemoteId(undefined);
}, [dataConnection, mediaConnection]);
useEffect(() => {
if (!peer || localId) {
return;
}
peer.once("open", (id) => {
setLocalId(id);
});
}, [localId, peer]);
useEffect(() => {
if (!process.env.NEXT_PUBLIC_SKY_WAY_API_KEY || !enabledPeer) {
return;
}
const peer = new Peer({
key: process.env.NEXT_PUBLIC_SKY_WAY_API_KEY,
debug: process.env.NODE_ENV === "production" ? 0 : 3,
});
setPeer(peer);
}, [enabledPeer]);
useEffect(() => {
if (!peer) {
return;
}
peer.on("error", console.error);
}, [peer]);
return (
<div>
<p>{`Your ID: ${localId}`}</p>
<Form onSubmit={handleSubmit} />
<button onClick={handleClose}>Close</button>
<Video
peer={peer}
remoteId={remoteId}
setEnabledPeer={setEnabledPeer}
setMediaConnection={setMediaConnection}
/>
<Chat
dataConnection={dataConnection}
peer={peer}
remoteId={remoteId}
setDataConnection={setDataConnection}
/>
</div>
);
}
export default Pages;
Form
相手へ Call を行うフォームです。
命名がひどいのは許して。
import { SubmitHandler, useForm } from "react-hook-form";
type FieldValues = { remoteId: string };
export type FormProps = {
onSubmit: SubmitHandler<FieldValues>;
};
function Form({ onSubmit }: FormProps): JSX.Element {
const { handleSubmit, register } = useForm<FieldValues>({
defaultValues: {
remoteId: "",
},
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("remoteId", { required: true })} />
<button type="submit">Call</button>
</form>
);
}
export default Form;
Chat
チャット部分です。
チャット内容が string なので少し気持ち悪いですが、本家のサンプルに合わせてそのままにしてあります。
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useState,
} from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import Peer, { DataConnection } from "skyway-js";
type FieldValues = {
localText: string;
};
export type ChatProps = {
dataConnection?: DataConnection;
peer?: Peer;
remoteId?: string;
setDataConnection: Dispatch<SetStateAction<DataConnection | undefined>>;
};
function Chat({
dataConnection,
peer,
remoteId,
setDataConnection,
}: ChatProps): JSX.Element {
const [messages, setMessages] = useState("");
const { handleSubmit, register, setValue } = useForm<FieldValues>({
defaultValues: {
localText: "",
},
});
const onSubmit = useCallback<SubmitHandler<FieldValues>>(
({ localText }) => {
if (!dataConnection || !dataConnection.open) {
return;
}
dataConnection.send(localText);
setMessages((prevMessages) => `${prevMessages}${localText}\n`);
setValue("localText", "");
},
[dataConnection, setValue],
);
const initizlizeDataConnection = useCallback<
(dataConnection: DataConnection) => void
>(
(dataConnection) => {
dataConnection.once("open", async () => {
setMessages(
(prevMessages) =>
`${prevMessages}=== DataConnection has been opened ===\n`,
);
});
dataConnection.on("data", (data) => {
setMessages((prevMessages) => `${prevMessages}Remote: ${data}\n`);
});
dataConnection.once("close", () => {
setMessages(
(prevMessages) =>
`${prevMessages}=== DataConnection has been closed ===\n`,
);
});
setDataConnection(dataConnection);
},
[setDataConnection],
);
useEffect(() => {
if (!peer || !peer.open || !remoteId) {
return;
}
const dataConnection = peer.connect(remoteId);
initizlizeDataConnection(dataConnection);
}, [initizlizeDataConnection, peer, remoteId]);
useEffect(() => {
if (!peer) {
return;
}
peer.on("connection", (dataConnection) => {
initizlizeDataConnection(dataConnection);
});
}, [initizlizeDataConnection, peer]);
return (
<div>
<pre>{messages}</pre>
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("localText", { required: true })} />
<button type="submit">Send</button>
</form>
</div>
);
}
export default Chat;
Video
ビデオ通話部分になります。
最低限で組んでいるので、色々と機能は足りていないです。
import {
useRef,
useState,
useEffect,
Dispatch,
SetStateAction,
useCallback,
} from "react";
import Peer, { MediaConnection } from "skyway-js";
export type VideoProps = {
peer?: Peer;
remoteId?: string;
setEnabledPeer: Dispatch<SetStateAction<boolean>>;
setMediaConnection: Dispatch<SetStateAction<MediaConnection | undefined>>;
};
function Video({
peer,
remoteId,
setEnabledPeer,
setMediaConnection,
}: VideoProps): JSX.Element {
const localVideoRef = useRef<HTMLVideoElement>(null);
const remoteVideoRef = useRef<HTMLVideoElement>(null);
const [localStream, setLocalStream] = useState<MediaStream>();
const initializeMediaConnection = useCallback<
(mediaConnection: MediaConnection) => void
>(
(mediaConnection) => {
mediaConnection.on("stream", (stream) => {
if (!remoteVideoRef.current) {
return;
}
remoteVideoRef.current.srcObject = stream;
remoteVideoRef.current.play().catch(console.error);
});
mediaConnection.once("close", () => {
if (
!remoteVideoRef.current ||
!remoteVideoRef.current.srcObject ||
!("getTracks" in remoteVideoRef.current.srcObject)
) {
return;
}
remoteVideoRef.current.srcObject
.getTracks()
.forEach((track) => track.stop());
remoteVideoRef.current.srcObject = null;
});
setMediaConnection(mediaConnection);
},
[setMediaConnection],
);
useEffect(() => {
navigator.mediaDevices
.getUserMedia({
audio: true,
video: true,
})
.then((localStream) => {
setLocalStream(localStream);
})
.catch(console.error);
}, []);
useEffect(() => {
if (!localStream || !localVideoRef.current) {
return;
}
localVideoRef.current.srcObject = localStream;
localVideoRef.current
.play()
.then(() => {
setEnabledPeer(true);
})
.catch(console.error);
}, [localStream, setEnabledPeer]);
useEffect(() => {
if (!peer || !peer.open || !localStream || !remoteId) {
return;
}
const mediaConnection = peer.call(remoteId, localStream);
initializeMediaConnection(mediaConnection);
}, [initializeMediaConnection, localStream, peer, remoteId]);
useEffect(() => {
if (!peer) {
return;
}
peer.on("call", (mediaConnection) => {
mediaConnection.answer(localStream);
initializeMediaConnection(mediaConnection);
});
}, [initializeMediaConnection, localStream, peer]);
return (
<div>
<video
muted={true}
ref={localVideoRef}
width="400px"
playsInline={true}
/>
<video
muted={process.env.NODE_ENV === "development"}
ref={remoteVideoRef}
width="400px"
playsInline={true}
/>
</div>
);
}
export default Video;
注意点
Peer インスタンスの生成タイミングが大切
実装中にもっとも引っかかったのが Peer インスタンスの生成タイミングでした。
公式のサンプル通り、自身のカメラが表示されたあとでインスタンスを作成しないと Call 時の接続に失敗することを確認しました。
ただしチャット側のみの実装であればマウント後即作成でも問題なく動くはずです。
track.stop
が動いているか怪しい
srcObject
の中に getTracks
が存在することが確認できていません。
ブラウザによるんですかね?
感想
SkyWay はほんと実装が楽で良いですね、思ったよりも流行っていないのが不思議なくらいです。
チャット部分にファイルの送受信機能などを付与させても面白そうですね。
そんな感じです、皆さんもぜひぜひ実装してみてください。