テキスト送受信機能つきビデオチャットのサンプルをつくる


こちらは [SkyWay Advent Calendar 2018](https://qiita.com/advent-calendar/2018/skyway) の 23日目の記事です。
（遅刻しました）

## つくったもの

タイトルです。
一昨日はじめてSkyWayのSDKをダウンロードしました。
サンプルが豊富でわかりやすいなーと思いました。
ただ、SkypeとかLINEとか、主要なコミュニケーションサービスだとビデオ通話しながらテキストも送受信できますよね。でもSkyWayのサンプルにはそういったものが意外となく、別々に用意されているみたいでした。
というわけで、ビデオチャットとテキストチャットを一緒にしたものを作ってみたのが当記事です。
サンプルは[こちら](https://ukkz.github.io/skyway-video-and-text-chat/)（GitHub Pages）

## はじめに：まずビデオチャットを動かす

まずこのカレンダーの8日目の[@kolife](https://qiita.com/kolife)さんの記事「[【爆速！】5分でビデオチャットを構築する](https://qiita.com/kolife/items/6b771676d7558254b3a6)」を試します。

![aeb1ce1e-d6c0-4eff-a31a-293c6d8db428.png](https://qiita-image-store.s3.amazonaws.com/0/116011/801d1f05-d2ed-7ba6-be15-c78e0291c3da.png "aeb1ce1e-d6c0-4eff-a31a-293c6d8db428.png")

ほんとに爆速でした。

## ビデオチャットの中身を理解する

中身を順番に解説しようとしたところ、公式の[JavaScript SDK チュートリアル](https://webrtc.ecl.ntt.com/js-tutorial.html)が非常に丁寧だったことに気づきましたので、ビデオチャットの中身についてはこちらを参照ください。
ポイントは、PeerではなくRoomオブジェクトのところで、ユーザの追加やデータの到着などに対してイベントが発火しているところです。

```script.js
room.on('stream', stream => {
  const peerId = stream.peerId;
  const id = 'video_' + peerId + '_' + stream.id.replace('{', '').replace('}', '');

  $('#their-videos').append($(
    '<div class="video_' + peerId +'" id="' + id + '">' +
    '<label>' + stream.peerId + ':' + stream.id + '</label>' +
    '<video class="remoteVideos" autoplay playsinline>' +
    '</div>'
  ));
  const el = $('#' + id).find('video').get(0);
  el.srcObject = stream;
  el.play();
});
```

ここでは他ユーザからのビデオストリームが接続（到着）したら、videoタグを動的に追加しているところです。
このビデオチャットのサンプルは参加できるルームが1つと決まっていて、ルームに対して何らかのアクションがあった場合は `room.on('アクションの種類', コールバック)` でそのアクションに対して応答ができるということですね。

## つぎに：テキストチャットを動かしてみる

上記の爆速ビデオチャットと同様に、APIキーだけ自分のアカウントで取得したものをコピーしてからサンプルの「fullmesh-textchat」をローカルで実行してみます。

![36c6c795-6fa6-4bb8-9a94-0650e6791e0c.png](https://qiita-image-store.s3.amazonaws.com/0/116011/373d24a7-98ee-8703-6c22-e5089d723e2d.png "36c6c795-6fa6-4bb8-9a94-0650e6791e0c.png")

こちらも爆速でできますね。
システム的な違いとしては、こちらのテキストチャットはルームを同時に何個も作ることができます（ビデオチャットはひとつだけ）。
あとはファイルも直接送信できるようですが、この記事ではファイルの送受信は考えないこととします。

## テキストチャットの中身を理解する

### ルームへの接続

```script.js
// Connect to a room
$('#connect').on('submit', e => {
  e.preventDefault();
  const roomName = $('#roomName').val();
  if (!roomName) {
    return;
  }
  if (!connectedPeers[roomName]) {
    // Create 2 connections, one labelled chat and another labelled file.
    const room = peer.joinRoom('mesh_text_' + roomName);
    room.on('open', function() {
      connect(room);
      connectedPeers[roomName] = room;
    });
  }
});
```

Connectボタンをクリックすると、あらかじめ定義されたオブジェクト`connectedPeers`に、ルーム名をキーとしてRoomオブジェクトを格納します。

### テキスト送信

```script.js
// Send a chat message to all active connections.
$('#send').on('submit', e => {
  e.preventDefault();
  // For each active connection, send the message.
  const msg = $('#text').val();

  eachActiveRoom((room, $c) => {
    room.send(msg);
    $c.find('.messages').append('<div><span class="you">You: </span>' + msg + '</div>');
  });
  $('#text').val('');
  $('#text').focus();
});
```

テキスト送信をクリックすると、定義済みの`eachActiveRoom()`が、アクティブになっているルーム（マウスで複数選択できるみたいです）の全部に対し、メッセージmsgの送信と、各ルームのチャット部分に自分のメッセージのappendを行います。

### 接続・受信とチャットエリアへの反映

ここがわりと重要な気がします。
長い関数なので直接コメントを書いてみました。

```script.js
// Handle a connection object.
function connect(room) {
  // 接続したらルーム名をidとするチャットボックスな<div>をチャット一覧エリアに追加します。
  $('#text').focus();
  const chatbox = $('<div></div>').addClass('connection').addClass('active').attr('id', room.name);
  const roomName = room.name.replace('sfu_text_', '');
  const header = $('<h1></h1>').html('Room: <strong>' + roomName + '</strong>');
  const messages = $('<div><em>Peer connected.</em></div>').addClass('messages');
  chatbox.append(header);
  chatbox.append(messages);
  
  // チャットボックスのクリックごとにアクティブ状態を切り替えます。
  chatbox.on('click', () => {
    chatbox.toggleClass('active');
  });

  $('.filler').hide();
  $('#connections').append(chatbox);

  // このあたりはログ機能
  room.getLog();
  room.once('log', logs => {
    for (let i = 0; i < logs.length; i++) {
      const log = JSON.parse(logs[i]);
      
      switch (log.messageType) {
        case 'ROOM_DATA':
          messages.append('<div><span class="peer">' + log.message.src + '</span>: ' + log.message.data + '</div>');
          break;
        case 'ROOM_USER_JOIN':
          if (log.message.src === peer.id) {
            break;
          }
          messages.append('<div><span class="peer">' + log.message.src + '</span>: has joined the room </div>');
          break;
        case 'ROOM_USER_LEAVE':
          if (log.message.src === peer.id) {
            break;
          }
          messages.append('<div><span class="peer">' + log.message.src + '</span>: has left the room </div>');
          break;
      }
    }
  });

  // データが到着したら発火します。
  room.on('data', message => {
    if (message.data instanceof ArrayBuffer) {
      // ファイルの場合はArrayBufferオブジェクトとなります。
      const dataView = new Uint8Array(message.data);
      const dataBlob = new Blob([dataView]);
      const url = URL.createObjectURL(dataBlob);
      messages.append('<div><span class="file">' +
        message.src + ' has sent you a <a target="_blank" href="' + url + '">file</a>.</span></div>');
    } else {
      // ArrayBufferでない場合は文字列としてルームのチャットボックスにメッセージとしてappendします。
      messages.append('<div><span class="peer">' + message.src + '</span>: ' + message.data + '</div>');
    }
  });

  // 誰かが参加したとき
  room.on('peerJoin', peerId => {
    messages.append('<div><span class="peer">' + peerId + '</span>: has joined the room </div>');
  });

  // 誰かが退出したとき
  room.on('peerLeave', peerId => {
    messages.append('<div><span class="peer">' + peerId + '</span>: has left the room </div>');
  });
}
```

## どこをどうしたらいいのか

- ビデオチャットサンプルを基本にして、テキストチャットを追加する。
- ビデオチャットサンプルはルームが1つだが、テキストチャットサンプルはルームが複数扱える。
- ファイル送受信は無視する。
- `room.on` で、そのルームに対するあらゆるイベントの発火を検知できる。
- ルームの発火検知について、'stream'はビデオストリームの追加、'data'はテキスト文字列またはバイナリの受信であるっぽい。

また、ベースとするビデオチャットのサンプルは、以下の3つのステップで接続状態とDOMが切り替わります。

- Step1: ローカルのビデオ・オーディオストリームを取得、シグナリングサーバに自身を登録。
- Step2: ルームに参加していない状態。ルーム名を入力して接続を押すとStep3へ。
- Step3: ルーム参加状態。この状態でストリームやデータをやりとりする。

以上を踏まえたうえで、HTMLとJSに以下の変更を施しました。

### HTML側

```index.html
<!-- Call in progress -->
<div id="step3">
  <p>Currently in room <span id="room-id">...</span></p>
  <p><a href="#" class="pure-button pure-button-error" id="end-call">End call</a></p>

  <!-- テキスト入力 -->
  <h4>Text chat</h4>
  <form id="sendtextform" class="pure-form">
    <input id="mymessage" type="text" placeholder="Enter message">
    <button class="pure-button pure-button-primary" type="submit">Send</button>
  </form>

  <!-- チャット -->
  <div id="chatframe"></div>
</div>
```

Step3部分に、送信メッセージを入力するinput、送信ボタン、ルームオブジェクトをいれるためのフレームを追加しました。これらはStep3になるとDOMの状態が変化して表示されるようになります。

### JS側

```script.js
function step3(room) {
  // chatboxを追加する
  const chatbox = $('<div></div>').addClass('chatbox').attr('id', 'chatbox-'+room.name);
  const header = $('<h4></h4>').html('Room: <strong>' + room.name + '</strong>');
  const messages = $('<div><em>Peer connected.</em></div>').addClass('messages');
  chatbox.append(header);
  chatbox.append(messages);
  $('#chatframe').append(chatbox);

  // メッセージ送信部分
  $('#sendtextform').on('submit', e => {
    e.preventDefault(); // form送信を抑制
    const msg = $('#mymessage').val();
    // ルームに送って自分のところにも反映
    room.send(msg);
    messages.prepend('<div><span class="you">You: </span>' + msg + '</div>');
    $('#mymessage').val('');
  });

  // チャットとかファイルが飛んできたらdataでonになる
  // ここではファイルは使わないのでもとのサンプルのif文はけしておく
  room.on('data', message => {
    messages.prepend('<div><span class="peer">' + message.src.substr(0,8) + '</span>: ' + message.data + '</div>');
  });

  room.on('peerJoin', peerId => {
    messages.prepend('<div><span class="peer">' + peerId.substr(0,8) + '</span>: has joined the room </div>');
  });

  room.on('peerLeave', peerId => {
    messages.prepend('<div><span class="peer">' + peerId.substr(0,8) + '</span>: has left the room </div>');
  });

  // streamが飛んできたら相手の画面を追加する
  room.on('stream', stream => {
    ...
    ..
  });

  room.on('removeStream', function(stream) {
    const peerId = stream.peerId;
    $('#video_' + peerId + '_' + stream.id.replace('{', '').replace('}', '')).remove();
  });

  // UI stuff
  room.on('close', step2);
  room.on('peerLeave', peerId => {
    $('.video_' + peerId).remove();
  });
  $('#step1, #step2').hide();
  $('#step3').show();
}
```

Step3に切り替わったとき、すなわちルームに接続されたときに、そのルームのチャットボックスをチャット用の枠div部分に追加するようにしました。
自分からメッセージを送るときは`room.send(msg)`で相手に送信し、チャットボックスのDOMにメッセージを追加させます。受信時は`room.on('data',)`のコールバックの中で、チャットボックスのDOMにdataの中身（メッセージ部分）を追加します。
テキストチャットサンプルと違い、ルームが単一だったため、意外と簡単にできました。

## サンプル

冒頭にも書きましたが動作するサンプルは[こちら](https://ukkz.github.io/skyway-video-and-text-chat/)に置いてありますのでご自由にどうぞ。
GitHub: [ukkz/skyway-video-and-text-chat](https://github.com/ukkz/skyway-video-and-text-chat)

![97eff027-ba3b-4a53-a59c-a211518dbd91.png](https://qiita-image-store.s3.amazonaws.com/0/116011/b2f18c9d-075f-37fe-82a5-12c5e4e9c3c6.png "97eff027-ba3b-4a53-a59c-a211518dbd91.png")

## まとめ

- シグナリングサーバの準備など、バックエンドを一切考えなくていいのがとっても楽！
- 要素が増えてくるとjQueryのセレクタだけだとつらくなるのでそろそろVueを真面目に勉強しようかな、、
- 初心者でも、チュートリアルを読みながら、公式サンプルを自分なりに改造して、当記事のようにビデオとテキストをくっつけてみたりすると理解に非常に役立つのではないかと思われました。