WebRTCで快適な画面共有を実現する

# はじめに

この記事は、Multitrackを利用して、ビデオチャット＋画面共有を行う方法を解説するものです。

# MediaChannlel利用時の課題

御存知の通り、WebRTCのMediaChannlel機能を用いればビデオチャットが簡単に実装できます。しかしながら、複数のビデオをやり取りしたり、画面共有を合わせてやりたいなど、アプリケーションの要件次第で、実装は複雑に、そしてクライアント端末にかかる負荷（CPU）が高くなるという問題があります。

イメージでその様子をみてみます。

- １：１でビデオチャット

![スライド1.jpg](https://qiita-image-store.s3.amazonaws.com/0/6651/8db6c838-4140-ba30-6697-1c17209e6b4d.jpeg)

- 1:1でビデオチャット＋画面共有

![スライド2.jpg](https://qiita-image-store.s3.amazonaws.com/0/6651/96839f92-14d1-d56b-e002-3a95d81ebca7.jpeg)

- ４人フルメッシュでビデオチャット

![スライド3.jpg](https://qiita-image-store.s3.amazonaws.com/0/6651/0e9133fa-ecd2-cd92-6781-e22c33ae9273.jpeg)

- ４人フルメッシュでビデオチャット＋画面共有（３本のMediaStreamが追加になる）

![スライド4.jpg](https://qiita-image-store.s3.amazonaws.com/0/6651/08edae48-a805-8839-e7b4-8c1a0ce6547c.jpeg)

そもそも、フルメッシュはきついので、MCUとかSFU使おうよというのは、今回は無しで。


# Multitrackを活用する

Multitrackを活用することで、接続にかかるオーバーヘッド等を軽減することが可能になり、端末への負荷が多少軽減されます。（通信帯域自体は変わりません）
このMultitrackですが、実は皆さん意識せずに使っています。先ほど示した図中にVideoTrackとAudioTrackという部分がありますが、まさにそれがMultitrackによって実現されているのです。

Multitrackを利用して同時に画面共有のストリームを送ると、こんな感じになります。

![スライド5.jpg](https://qiita-image-store.s3.amazonaws.com/0/6651/f177d878-8d02-4ca2-ef5e-fdc1dd91bd3d.jpeg)

Multitrackの各ブラウザの実装状況や仕様などは、がねこさんのスライドが参考になります。
−　[WebRTC multitrack / multistream@mganeko](http://www.slideshare.net/mganeko/webrtc-multitrack-multistream)


# Trackを操作する

## 前提条件

- サンプルコードの実装には[WebRTCプラットフォーム SkyWay](https://skyway.io) と[SkyWay-ScreenShare](https://github.com/nttcom/SkyWay-ScreenShare) を利用します
- 両プロダクトの詳しい使い方については割愛します
- 動作検証に利用するブラウザは 2016/5/17現在のChrome M50（Stable版）とします

## ブラウザネイティブAPIの確認

Tracｋを操作するために必要なAPIは以下のとおりです。

```js
stream.getTracks()
stream.getVideoTracks()
stream.getAudioTracks()
stream.addTrack()
stream.removeTrack()
```

MediaStreamを作成するためには以下のAPIを利用します。（まだベンダープレフィックスが付いてます）

```js
new webkitMediaStream()
```


## 送り側（例）

```js
        var localStream;
        $('#start-screen').click(function () {
            if(screen.isEnabledExtension()){
                screen.startScreenShare({
                    // 省略
                },function (stream){
                        // ビデオチャット用のMediaStreamにVideoTrackがいくつ含まれているかをチェック
                        if(localStream.getVideoTracks().length == 2){
                            // 既に２つのトラックが含まれる場合は、2つめのトラック（ScreenShare）を削除
                            localStream.removeTrack(localStream.getVideoTracks()[1]);
                        }
                        // 取得したScreenShareのトラックからVideoトラックを取り出す
                        var _SCTrack = stream.getVideoTracks()[0];
                        // ビデオチャット用のMediaStreamにトラックを追加する
                        localStream.addTrack(_SCTrack);
                        // 例：追加したMediaStreamを相手に送信
                        var call = peer.call(_peerid, localStream);
                        step3(call);
                    }
                },function(error){
                    // 省略
                },function(){
                    // 省略
                });
            }else{
                    // 省略
            }
        });
```

## 受け側（例）

```js
        call.on('stream', function (stream) {
            // 受け取ったMediaStreamのトラック数を調べる
            var _tracklengs = stream.getVideoTracks().length;
            if( _tracklengs == 2){
                // トラック数が２つの場合、Video要素に渡すための空のMediaStream objectを作成する
                var _peerVideo = new webkitMediaStream();
                var _peerScreen = new webkitMediaStream();
                // 作成した２つの空のMediaStream Objectに、それぞれビデオとスクリーンシェア用のTrackを追加する
                _peerVideo.addTrack(stream.getVideoTracks()[0]);
                _peerScreen.addTrack(stream.getVideoTracks()[1]);
                // それぞれのMediaStreamをVideo要素にアタッチする
                attachMediaStream($('#their-video')[0],_peerVideo);
                attachMediaStream($('#their-screen')[0],_peerScreen);
            }else {
                attachMediaStream($('#their-video')[0],stream);
            }
        });
```

これだけで、ビデオチャットの映像、音声、画面共有のTrackを１本のMediaStreamで扱うことが可能になります。
注意点としては、ビデオチャットの映像のTrackと画面共有のTrackについては、相手側で識別できません。（同じVideoStreamTrackになる）

- 送信元（一番下がスクリーンシェアのTrack）

![スクリーンショット 2016-05-17 15.21.09.png](https://webcore.qiita.com/files/832969f4-ade4-18ac-a6e8-fb240651e4b4.png)

- 受信側（スクリーンシェアのTrackのみ）

![スクリーンショット 2016-05-17 15.21.21.png](https://webcore.qiita.com/files/3fa0e500-2516-0e2b-b8fd-d2bb46043095.png)

id（label）でTrackを識別することはできますが、VideoTrackと区別がつきません。複雑なアプリケーションを開発する場合は、上手くハンドリングする必要があります。


# MediaStreamはそのままにTrackを操作する

お気づきの方もいらっしゃると思いますが、上述したプログラムでは、スクリーンシェアを行うたびに結局MediaStreamを作成しています。つまり、Track操作を行うたびにMediaStreamを破棄して作りなおす必要が出てきます。ここからはもっとシンプルに、MediaStreamはそのままに、含まれるTrackの操作を行う方法を解説します。

## 処理の流れ

これを実現するには、offer/answerを都度実行してあげる必要があります。おおまかな処理の流れは以下のとおりです。

![qiita_image_2.jpg](https://qiita-image-store.s3.amazonaws.com/0/6651/caeb3a6e-6db2-1766-2673-6e82596b2e40.jpeg)

尚、この図の動作はChrome限定です。Firefoxではこのままでは動きません。(未検証)また、現在標準化の中でAPIの策定を行っている最中であり、Chromeについても将来的に変更される可能性が高いです。ご注意ください。

## Peer.jsの実装例

この処理を実現するには、Peer.jsに手を入れる必要が出てきます。
まず、必要なメソッドを定義します。

#### **MediaConnection.update()**

```
MediaConnection.update(MediaStream);
```

- 引数のMediaStreamを用いてSDPの再交換要求を出す
- 処理フロー  
  1. createOffer()  
  2. setLocalDescription()  
  3. socket.send(type:update)  

#### **MediaConnection.updateAnswer()**

```
MediaConnection.updateAnswer();
```

- update要求に対して回答を出す
- 'update' イベントのCallbackで実行する
- 処理フロー
  1. setRemoteDescription() 
  2. createAnswer()
  3. setLocalDescription()

次に、必要なイベントを定義します。

#### **Peer.on('update')**

```
peer.on('update',function(mediaConnection){....});
```

- リモートのpeerがUpdateを行った時に発生します。
- 処理フロー
  1. socket.receive(type:update)
  2. イベント発火


diffはこんな感じです。

```js
diff --git a/lib/mediaconnection.js b/lib/mediaconnection.js
index xxxxxxx..xxxxxxx xxxxxxx
--- a/lib/mediaconnection.js
+++ b/lib/mediaconnection.js
@@ -94,4 +94,22 @@ MediaConnection.prototype.close = function() {
   this.emit('close')
 };
 
+/**
+ * Supported Multitrack operation
+ */
+
+MediaConnection.prototype.update = function(stream) {
+
+  util.log("Starting re-exchange of SDP");
+  Negotiator.reExchange(this);
+
+};
+
+MediaConnection.prototype.updateAnswer = function() {
+
+  util.log("re-exchange of SDP from answer step");
+  Negotiator.handleSDP('UPDATE', this, this.options._payload.sdp);
+
+};
+
 module.exports = MediaConnection;
diff --git a/lib/negotiator.js b/lib/negotiator.js
index xxxxxxx..xxxxxxx xxxxxxx
--- a/lib/negotiator.js
+++ b/lib/negotiator.js
@@ -232,7 +232,14 @@ Negotiator.cleanup = function(connection) {
 Negotiator._makeOffer = function(connection) {
   var pc = connection.pc;
 
-  if(!!pc.remoteDescription && !!pc.remoteDescription.type) return;
+  /** Supported Multi Stream **/
+  var offertype = 'OFFER';
+
+  /** Supported Multi Stream **/
+  //if(!!pc.remoteDescription && !!pc.remoteDescription.type) return;
+  if(!!pc.remoteDescription && !!pc.remoteDescription.type) {
+    offertype = 'UPDATE';
+  }
 
   pc.createOffer(function(offer) {
     util.log('Created offer.');
@@ -244,7 +251,7 @@ Negotiator._makeOffer = function(connection) {
     pc.setLocalDescription(offer, function() {
       util.log('Set localDescription: offer', 'for:', connection.peer);
       connection.provider.socket.send({
-        type: 'OFFER',
+        type: offertype,
         payload: {
           sdp: {
             type: offer.type,
@@ -317,8 +324,11 @@ Negotiator.handleSDP = function(type, connection, receivedSdp) {
   pc.setRemoteDescription(sdp, function() {
     util.log('Set remoteDescription:', type, 'for:', connection.peer);
 
+    /** Supported Multi Stream **/
     if (type === 'OFFER') {
       Negotiator._makeAnswer(connection);
+    }else if(type === 'UPDATE'){
+      Negotiator._makeUpdateAnswer(connection);
     }
   }, function(err) {
     connection.provider.emitError('webrtc', err);
@@ -344,4 +354,54 @@ Negotiator.handleCandidate = function(connection, ice) {
   util.log('Added ICE candidate for:', connection.peer);
 }
 
+/**
+ * Supported Multitrack operation
+ */
+
+/** Created Offer for re-exchange of SDP. */
+Negotiator.reExchange = function(connection){
+  util.log('Created Offer for re-exchange of SDP.');
+  if (!util.supports.onnegotiationneeded) {
+    setTimeout(function(){
+      Negotiator._makeOffer(connection);
+    }, 0);
+  }
+
+}
+/** Created answer for re-exchange of SDP */
+Negotiator._makeUpdateAnswer = function(connection) {
+  var pc = connection.pc;
+
+  pc.createAnswer(function(answer) {
+    util.log('Created answer for re-exchange of SDP.');
+
+    if (!util.supports.sctp && connection.type === 'data' && connection.reliable) {
+      answer.sdp = Reliable.higherBandwidthSDP(answer.sdp);
+    }
+
+    pc.setLocalDescription(answer, function() {
+      util.log('Set localDescription: answer', 'for:', connection.peer);
+      connection.provider.socket.send({
+        type: 'ANSWER',
+        payload: {
+          sdp: {
+            type: answer.type,
+            sdp: answer.sdp
+          },
+          type: connection.type,
+          connectionId: connection.id,
+          browser: util.browser
+        },
+        dst: connection.peer
+      });
+    }, function(err) {
+      connection.provider.emitError('webrtc', err);
+      util.log('Failed to setLocalDescription, ', err);
+    });
+  }, function(err) {
+    connection.provider.emitError('webrtc', err);
+    util.log('Failed to create answer, ', err);
+  });
+}
+
 module.exports = Negotiator;
diff --git a/lib/peer.js b/lib/peer.js
index xxxxxxx..xxxxxxx xxxxxxx
--- a/lib/peer.js
+++ b/lib/peer.js
@@ -306,6 +306,27 @@ Peer.prototype._handleMessage = function(message) {
         }
       }
       break;
+    case 'UPDATE': // Supported Multitrack operation
+      var connectionId = payload.connectionId;
+      connection = this.getConnection(peer, connectionId);
+
+      if (!!connection) {
+        util.warn('Connection is not completed: ', connectionId);
+        //connection.handleMessage(message);
+      } else {
+        // Create a new connection.
+        if (payload.type === 'media') {
+          util.log("MediaConnection created in UPDATE");
+          this.emit('update', connection);
+        } else if (payload.type === 'data') {
+          util.warn('This type does not correspond UPDATE:', payload.type);
+          return;
+        } else {
+          util.warn('Received malformed connection type:', payload.type);
+          return;
+        }
+      }
+      break;
     default:
       if (!payload) {
         util.warn('You received a malformed message from ' + peer + ' of type ' + type);
```

## 送り側（例）

```js
        var localStream;
        var existingCall; //MediaConnection object
        $('#start-screen').click(function () {
            if(screen.isEnabledExtension()){
                screen.startScreenShare({
                    // 省略
                },function (stream){
                    if(existingCall != null){
                        if(localStream.getVideoTracks().length == 2){
                            localStream.removeTrack(localStream.getVideoTracks()[1]);
                        }
                        var _SCTrack = stream.getVideoTracks()[0];
                        localStream.addTrack(_SCTrack);
                        // offer/answerを開始
                        existingCall.update();
                    }
                },function(error){
                    // 省略
                },function(){
                    // 省略
                });
            }else{
                    // 省略
            }
        });
```

## 受け側1(例)

```js

        peer.on('update', function(call) {
          call.updateAnswer();
        });

```

## 受け側2（例）

```js
        var remoteStream;
        call.on('stream', function (stream) {

            var remoteStream = stream;

            // 初回接続時にビデオチャット用の映像を取り出す
            var _peerVideo = new webkitMediaStream();
            _peerVideo.addTrack(stream.getVideoTracks()[0]);
            attachMediaStream($('#their-video')[0],_peerVideo);

            // 初回接続時にビデオチャット用の映像を取り出す
            var _peerScreen = new webkitMediaStream();
            var _tracklength = stream.getVideoTracks().length;
            if( _tracklength == 2){
                _peerScreen.addTrack(stream.getVideoTracks()[1]);
                attachMediaStream($('#their-screen')[0],_peerScreen);
            }

            // Trackが追加されると発火する
            stream.onaddtrack = function(event) {
                /** event.trackでtrackを拾わなくてもremoteStreamには自動で追加されている **/
                var __peerVideo = new webkitMediaStream();
                __peerVideo.addTrack(event.track);
                attachMediaStream($('#their-screen')[0],__peerVideo);
            };

            // Trackが削除されると発火する
            stream.onremovetrack = function() {
                /** Trackは勝手に反映されるので特にやることはない **/
                attachMediaStream($('#their-screen')[0],null);
            };

        });
```

## デモ

- WebRTC Meetup Tokyo #10 で実施

## 受け手側の挙動

- 1:1でビデオチャット中

![before.png](https://qiita-image-store.s3.amazonaws.com/0/6651/bbfd9ce5-ba90-40a8-2c38-4b023d3efcee.png)

- 片方が追加で画面共有実施

![after.png](https://qiita-image-store.s3.amazonaws.com/0/6651/8245f09f-52ca-32de-2946-1bf76b9c57eb.png)

一番下にレコードが追加れていれば成功です。

# 終わりに

今回は、Multitrackを利用した画面共有の方法を解説しました。画面共有機能を実装する方の参考になれば幸いです。