ticktakclockの日記

技術ポエムを綴ったりします。GitHub idも同じです (@ticktakclock)

WebRTCとWebSocketで画面共有(ビデオチャット)アプリを作る

こんにちはtkyです。

表題の通り、WebRTCとWebSocketの勉強を兼ねて、webアプリを作ってみました。

作り方を記載しているわけではなく、成果物に対しての説明となるブログですので予めご了承願います。

作ったもの

画面共有(ビデオチャット)アプリを作成しました。 音声に対応させていないので画面共有としています。理由は後述。

  • 画面を複数人で共有し
  • 参加者のアクションを画面上で共有できる(いいね機能と呼称しています)

※ローカル環境+Chromeでしか動作(確認)しませんのであしからず!

コードレビューや、プレゼン発表などでみんなのリアクションが励みになるような思いを込めて作ってみました!

作ったものについてはgithubにアップしています。

github.com

技術要素

技術及び環境情報です。

環境

技術

  • React (v16.9.0)
  • Material UI (v4.4.0)
  • Node (v10.14.1)
  • WebSocket (socket.io v2.2.0)
  • WebRTC

構成

サーバ側とクライアント側(Reactアプリのこと)に分かれます。 全体は以下のような構成になります。

WebRTCでP2P通信する情報をやりとりするためにnode.jsで情報交換用のサーバ(シグナリングサーバというようです)を構築します。

f:id:ticktakclock:20190909001549p:plain
構成図

シーケンス

WebRTCで通信するためにシグナリングサーバとどのような情報を交換する必要があるのかシーケンスにしてみました。 流れとしては - 通信を開始したい側がofferを出す - offerを受け取った側がAnswerを出す - この時点でWebRTCが経路情報を算出する(Ice Candidate) - 経路情報が取得できたら自分の経路情報を出す(クライアント毎に自身の経路情報を交換する) - 経路が確立できたらP2P通信が始まる

f:id:ticktakclock:20190909001613p:plain
シーケンス

サーバ側

上記のシーケンスを守れるようにサーバ側は以下を実装します。 サーバの実装は /server/app.js です。サーバ側は以上です。

  • socketを部屋に紐付ける
  • メッセージを部屋に紐づくsoket全員に配信する
// offerとanswerは SDP(SessionDescriptionProtocol)というプロトコルとして処理します
socket.on('SEND_SDP', function(data) {
  data.sdp.id = socket.id;
  if (data.target) {
    socket.to(data.target).emit('RECEIVE_SDP', data.sdp);
  } else {
    socket.broadcast.to(socket.roomname).emit('RECEIVE_SDP', data.sdp);
  }
});

// Ice Candidateを配信する
socket.on('SEND_CANDIDATE', function(data) {
  if (data.target) {
    data.ice.id = socket.id;
    socket.to(data.target).emit('RECEIVE_CANDIDATE', data.ice);
  } else {
    console.log('candidate need target id');
  }
});

上記のようなコードをやりとりしたいメッセージごとに以下の事に注意しながら定義するだけです。

  • 部屋関係なく自分も含め全員 (今回使わない)
  • 部屋関係なく自分以外全員 (今回使わない)
  • 部屋に属する自分も含め全員 (いいねリアクション(機能説明にて後述)のやり取り)
  • 部屋に属する自分以外全員(オファー要求)
  • 特定の人(オファー自体)

クライアント側

クライアント側は結構手間で、Offerの送受信、Answerの送受信、Ice Candidateの送受信があります。 加えて、P2PができたときのStream(動画情報です)の取り扱いなどやることが結構多くて、 実際に作った MultVideoChat.js が結構肥大化してしまいました。もう少しうまく分離できれば良かったのですが・・・

reactプロジェクト自体は create-react-app で作成しました。 あとは自分用のprettierでフォーマッティングしています。

使い方

サーバ起動とクライアント起動は別ターミナル上で行ってください。

$npm i
$node server/app.js  // サーバ起動
$npm start                 // クライアント起動

画面構成

Material-UI: A popular React UI framework を採用しました。自分自身がAndroiderであるのと極力自分でレイアウトするコストを払わないようにするためです。 Material UIを使うこと自体もある程度慣れる必要がありますが、ここは頑張りポイントです。

大きく2つの画面で構成されています。App.jsにて、部屋情報のstateにより表示を切り替えています。

エントランス画面

部屋名を入力して入室する画面です。赤枠の領域が src/Entrance.js の実装範囲になります。

f:id:ticktakclock:20190908201625p:plain
エントランス画面

ビデオチャット画面

src/MultiVideoChat.js に大多数の実装が入っています。 src/Room.js でラップしています。

f:id:ticktakclock:20190908202439p:plain
チャット画面

メニュー

src/CustomDrawer.js で自分の映像情報に対する操作UIを設置しています。 FABでも良かったのですが、操作の関心事がそれぞれ異なり、FABではうまく表現しきれないと判断してドロワーメニューにしました。

f:id:ticktakclock:20190908232035p:plain
メニュー画面

機能

  • チャットルーム機能

WebSocket接続後、サーバに対して SEND_ENTER のsocket通信を行います。 socket.join(roomname) で指定した名前の部屋に入ることを実現しています。 その部屋に新しく入ったsocket idに対してofferを出すための呼び出しメッセージを通知します。 RECEIVE_CALL を受け取ったsocketは指定socket idにOffer要求をしていきます。

// クライアント側
this.socket.on('RECEIVE_CONNECTED', data => {
  // socket情報をstateに持っておく
  this.setState({ socketId: data.id });
  // 部屋に入る
  this.socket.emit('SEND_ENTER', this.state.room);
});
// サーバ側
socket.on('SEND_ENTER', function(roomname) {
  socket.join(roomname);
  console.log('id=' + socket.id + ' enter room:' + roomname);
  socket.roomname = roomname;
  socket.broadcast
    .to(socket.roomname)
    .emit('RECEIVE_CALL', { id: socket.id });
});
  • 複数人会話機能

上記の部屋を作ることで複数人が同じ部屋に属することができます。 また、先に記載したシーケンスを実装することで複数人のP2P通信を実現します。

  • 全画面表示機能

メニューでFull Screenを押下した時、その接続streamを全画面で表示します。

f:id:ticktakclock:20190908234809p:plain
全画面
Material UIのDialogを使います。fullscreen プロパティをつかいました。 自分のstream情報を全画面用のvideoタグに設定する

<Dialog
  maxWidth="xl"
  fullScreen
  open={this.state.fullScreenId !== ''}
  onClose={this.onFullScreen}
>
  <Like socket={this.socket}>
    <video
      style={fullScreenStyle}
      autoPlay="1"
      playsInline
      ref={video => {
        const fullScreen =
          this.videos[this.state.fullScreenId] || this.video;
        if (video && fullScreen) {
          video.srcObject = fullScreen.srcObject;
        }
      }}
    />
  </Like>
  <IconButton
    style={closeButtonStyle}
    color="primary"
    onClick={this.onFullScreen}
  >
    <CloseIcon />
  </IconButton>
</Dialog>
  • いいね機能

全画面表示中、画面をクリックすると、「良いね!!」を表すハートマークを表示する、盛り上がり機能をつけました。 実際これがやりたいがためにビデオチャットアプリを作ったと言っても過言ではありません。 盛り上がりが共有できて、みんなが幸せになれば良いなーと思います。

f:id:ticktakclock:20190910170901g:plain

シーケンスの実装

クライアント側では先に示したシーケンスを実装することになります。 送信・受信はWebSocketの話になります。

  • Offerの送信
  • Offerの受信〜Answerの送信
  • Answerの受信
  • Ice Candidateイベント〜Ice Candidate送信
  • Ice Candidateの受信
  • streamイベント

ポイントのみ、抽出して記載します。

Offerの送信

async makeOffer(id) {
  // 接続先ごとにpeer(=RTCPeerConnection)を確立していきます。
  const peer = this.prepareNewConnection(id);
  const stream = this.video.srcObject;
  stream.getTracks().forEach(track => {
    // peerに送信したいstream情報を設定します。
    peer.addTrack(track, stream)
  });
  const offer = await peer.createOffer();
  await peer.setLocalDescription(offer);
  // websocket offer送信
  this.sendSdp(id, peer.localDescription);
}

Offerの受信〜Answerの送信

async onOffer(sdp) {
  // 相手側のsocket idでRTCPeerConnectionを確立する
  const peer = this.prepareNewConnection(sdp.id);
  const stream = this.video.srcObject;
  stream.getTracks().forEach(track => {
    peer.addTrack(track, stream);
  });
  // 受け取ったofferデータをRTCPeerConnectionクラスにセットします。
  const offer = new RTCSessionDescription(sdp);
  await peer.setRemoteDescription(offer);
  // 受け取ったOfferに対しAnswerを返答します。
  this.makeAnswer(sdp.id);
}

async makeAnswer(id) {
  // 接続先のsocket id ごとにpeerをstateで管理しています。
  const peer = this.state.peers[id];
  const answer = await peer.createAnswer();
  await peer.setLocalDescription(answer);
  this.sendSdp(id, peer.localDescription);
}

Answerの送信

async onAnswer(sdp) {
  // 接続先のsocket id ごとにpeerをstateで管理しています。
  const peer = this.state.peers[sdp.id];
  if (!peer) return;
  // 受け取ったofferデータを相手socketのRTCPeerConnectionクラスにセットします。
  const answer = new RTCSessionDescription(sdp);
  await peer.setRemoteDescription(answer);
}

Ice Candidateイベント〜Ice Candidateの送信

prepareNewConnection(id) {
  const config = { iceServers: [] };
  const peer = new RTCPeerConnection(config);
  // 中略
  peer.onicecandidate = event => {
    this.onIceCandidate(id, event.candidate);
  };
  // 中略
  return peer;
}

onIceCandidate(id, icecandidate) {
  if (icecandidate) {
    // Trickle ICE
    this.sendIceCandidate(id, icecandidate);
  } else {
    // Vanilla ICE
    console.log('empty ice event');
  }
}

Ice Candidateには2種類あります。どのタイミングでIceを送るかの違いです。

  • Vanilla ICE
    • すべてのイベント受信後、Ice Candidateを送信する
  • Trickle ICE
    • イベントを受け取り次第逐次Ice Candidateを送信する
    • 逐次接続情報を送ることでVanillaよりも早く接続確立できる事がある

Ice Candidateの受信

onReceiveCandidate(ice) {
  const peer = this.state.peers[ice.id];
  if (!peer) return;

  // 受け取ったiceを相手socketのRTCPeerConnectionクラスにセットします。
  const candidate = new RTCIceCandidate(ice);
  peer.addIceCandidate(candidate);
}

streamイベント

接続が確立できると、streamが降ってきます。streamイベントが来ていたらP2Pの接続自体は成功しています。

prepareNewConnection(id) {
  const config = { iceServers: [] };
  const peer = new RTCPeerConnection(config);
  // バージョンによりpeer.onAddStream()らしいです。最新のchromeではpeer.ontrak()を使います。
  peer.ontrack = event => {
    this.onAddStream(id, event.streams[0]);
  };
  // 中略
  return peer;
}

本アプリではwebカメラと画面共有の2パターンの共有方法を持っていますが、リソースを切り替えるたびに通信を確立するようにしています。 streamを切り替えようとすると、peerの接続が切れてしまい、うまく映像の共有ができなかったためなのですが、やり方が悪いだけかもしれません。

音声ミュートについて

前提として、音声が流れている時、自身のブラウザにも音声が流れてしまいます。 上記が理由で音声配信は可能でしたが諦めた背景があります。 動画ミュートも音声ミュートも以下のようにしてミュートできますが、 自分のブラウザから出る自分の声をミュートしようとすると相手側も結局ミュートされてしまい、意味がなくなってしまいます・・・

      const videoTrack = this.video.srcObject.getVideoTracks()[0];  // audioの場合getAudioTracks()
      videoTrack.enabled = !videoTrack.enabled;

考えうる対策としては以下が検討できましたが、どんどん仕様が膨らんでいくので今回は一旦ここまでとしました。

  • 配信Streamと表示Streamを分ける
    • 配信Stream(映像・音声)、表示Stream(映像)にすれば切り替えられそう
  • 自分のStreamは表示しない
    • 元も子もないですね・・・

終わりに

  • 初めてReact Hooks関連の実装をしてみて、useState()の使い方が少し理解できた
  • WebRTCの一通りの実装することでビデオチャットの基礎が理解できた
  • 今回はlocalhost上でwebsocketのシグナリングサーバを実装したが、firebaseを使っても良いかもしれないと感じた
  • websocket.ioを使ったが比較的容易に実装できた(気がする)のでまた挑戦してみたい

以上です。