SkyWayを使って1つのPeerでチャットとビデオ通話が同時にできるのか?

2022-04-02

できます、良かった。


前置き

現在所属している現場で開発しているプロダクトがあり、ビデオ通話とチャットの機能を導入してみたいという話になりました。

そこで、自身が以前所属していた現場で 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 はほんと実装が楽で良いですね、思ったよりも流行っていないのが不思議なくらいです。

チャット部分にファイルの送受信機能などを付与させても面白そうですね。


そんな感じです、皆さんもぜひぜひ実装してみてください。