🐕

SkyWay with React な多人数通話アプリをデバッグしながら作る

2021/09/19に公開

まえがき

ReactでSkyWay多人数同時通話アプリケーションを作った時にはまるポイントを時系列的に紹介していく記事です。方式としてはsfuという方式を用いました。ベースとしては公式の参考実装があります。
SkyWayの基本的な使い方に関しては他のサイトや公式が詳しいので割愛させていただきます。

ベース実装

公式の実装を元にReactでそれっぽく書き直してみたのが以下の実装になります。この時点で特筆すべき点としてはStateに配列として他人のstreamを保持している点でしょうか(remoteVideo)。今回は話せる人数を固定しない方針での実装を行なったため、videoタグを直書きする実装ではなく動的に生やす方向で実装を行いました。

import Peer, { SfuRoom } from "skyway-js";
import React from "react";
import { SKYWAYAPI } from "./env";

type VideoStream = {
  stream: MediaStream;
  peerId: string;
};

export const Room: React.VFC<{ roomId: string }> = ({ roomId }) => {
  const peer = React.useRef(new Peer({ key: SKYWAYAPI as string }));
  const [remoteVideo, setRemoteVideo] = React.useState<VideoStream[]>([]);
  const [localStream, setLocalStream] = React.useState<MediaStream>();
  const [room, setRoom] = React.useState<SfuRoom>();
  const localVideoRef = React.useRef<HTMLVideoElement>(null);
  React.useEffect(() => {
    navigator.mediaDevices
      .getUserMedia({ video: true })
      .then((stream) => {
        setLocalStream(stream);
        if (localVideoRef.current) {
          localVideoRef.current.srcObject = stream;
          localVideoRef.current.play().catch((e) => console.log(e));
        }
      })
      .catch((e) => {
        console.log(e);
      });
  }, []);
  const onStart = () => {
    if (peer.current) {
      if (!peer.current.open) {
        return;
      }
      const tmpRoom = peer.current.joinRoom<SfuRoom>(roomId, {
        mode: "sfu",
        stream: localStream,
      });
      tmpRoom.once("open", () => {
        console.log("=== You joined ===\n");
      });
      tmpRoom.on("peerJoin", (peerId) => {
        console.log(`=== ${peerId} joined ===\n`);
      });
      tmpRoom.on("stream", async (stream) => {
        setRemoteVideo([
          ...remoteVideo,
          { stream: stream, peerId: stream.peerId },
        ]);
      });
      tmpRoom.on("peerLeave", (peerId) => {
        setRemoteVideo(
          remoteVideo.filter((video) => {
            if (video.peerId === peerId) {
              video.stream.getTracks().forEach((track) => track.stop());
            }
            return video.peerId !== peerId;
          })
        );
        console.log(`=== ${peerId} left ===\n`);
      });
      setRoom(tmpRoom);
    }
  };
  const onEnd = () => {
    if (room) {
      room.close();
      setRemoteVideo((prev) => {
        return prev.filter((video) => {
          video.stream.getTracks().forEach((track) => track.stop());
          return false;
        });
      });
    }
  };
  const castVideo = () => {
    return remoteVideo.map((video) => {
      return <RemoteVideo video={video} key={video.peerId} />;
    });
  };
  return (
    <div>
      <button onClick={() => onStart()}>start</button>
      <button onClick={() => onEnd()}>end</button>
      <video ref={localVideoRef} playsInline></video>
      {castVideo()}
    </div>
  );
};

const RemoteVideo = (props: { video: VideoStream }) => {
  const videoRef = React.useRef<HTMLVideoElement>(null);

  React.useEffect(() => {
    if (videoRef.current) {
      videoRef.current.srcObject = props.video.stream;
      videoRef.current.play().catch((e) => console.log(e));
    }
  }, [props.video]);
  return <video ref={videoRef} playsInline></video>;
};

問題点1

さてこれを元にデバッグを始めていきましょう。適当なサイトにデプロイをしてみると、以下の二つの点に問題があることに気づきます。

  • そもそもn(n>2)個のクライアントから同時接続を行なった際にちゃんと全員表示されないことがある
  • 1人がリロードするとそのリロードを行なった人以外に関しても映像周りが壊れる

デバッグをしていく上で一番重要なことは問題の切り分けです。今回だとこの問題は果たして自分の実装が悪いのかそれともskyway本体が悪いのかの検証がまず必要です。都合が良いことに参考実装にはデモサイトが存在していたのでこれを元にやっていくことにしましょう。
実験の結果としてはデモサイトでは以上のような問題は生じませんでした。つまりは私のReact実装が悪いということです。うーん。
問題点としてリロード時のやつの方がなんとかなりそうな雰囲気なのでそっちを先に対処していくこととします。リロードということは人が抜けて入る周り(peerJoinやpeerLeaveそしてstreamなど)がおかしい挙動をしていそうな気がします。そこらへんの実装を眺めていると、stateまわりが怪しいことに気づきます。
Reactが得意な皆さんなら気付くと思いますが、

const [num, setNum] = React.useState(0);

をした場合の

setNum(num+1);

setNum(prev=>prev+1);

の挙動は異なります。詳しくは公式などをみると良いと思います。今回の実装だとここらへん

      tmpRoom.on("stream", async (stream) => {
        setRemoteVideo([
          ...remoteVideo,
          { stream: stream, peerId: stream.peerId },
        ]);
      });
      tmpRoom.on("peerLeave", (peerId) => {
        setRemoteVideo(
          remoteVideo.filter((video) => {
            if (video.peerId === peerId) {
              video.stream.getTracks().forEach((track) => track.stop());
            }
            return video.peerId !== peerId;
          })
        );
        console.log(`=== ${peerId} left ===\n`);
      });

がまずそうですね。過去の状態を使うように書き換えてあげましょう。

     tmpRoom.on("stream", async (stream) => {
       setRemoteVideo((prev) => [
         ...prev,
         { stream: stream, peerId: stream.peerId },
       ]);
     });
     tmpRoom.on("peerLeave", (peerId) => {
       setRemoteVideo((prev) => {
         return prev.filter((video) => {
           if (video.peerId === peerId) {
             video.stream.getTracks().forEach((track) => track.stop());
           }
           return video.peerId !== peerId;
         });
       });
       console.log(`=== ${peerId} left ===\n`);
     }); 

すると、なんと上記の二つの問題点が両方とも解決しました。なんとなく知ってはいたけど実際にこの問題に遭遇したのは初めてだったので良かったです。

問題点2

問題点1が解決したと思ったら二つ目が降ってきました。

  • startボタンを連打して抜けて入ったりをすると他のユーザーに二重に表示される

そんな使い方をする人おる?みたいな問題はさておきサービスとしては割と問題です。それではデバッグを始めていきましょう。例の如くまずはデモサイトでの検証です。クライアントを複数用意して同じことが起きるか実験してみると、なんと同じような事象が発生することがわかります。つまりこれはSkyWay側の仕様っぽいですね。ただ仕様だからといって解決しないわけにもいきません。色々触ってみるとこの問題はstartを無限回押すことに起因するようです。それではstartは無限回押せないようにしてあげましょう。適当に状態管理を行なってbuttonのdisabled要素をいじってあげましょう。

さいごに

SkyWay Reactで検索しても通話人数可変で実装した記事があまり見当たらなかったので書きました。意外に基礎的で重要なエッセンスが詰まっていていい経験でした。SkyWay自体はとても扱いやすくてよいサービスなのでぜひ使ってみてください!以下が今回のリポジトリになります。
https://github.com/doriasu/skyway_asobu

Discussion