[WebRTC] Vue.jsとFirebaseでWebRTC簡単入門

# はじめに
先日のハッカソン型インターンで使用したWebRTCという技術がとても興味深くまたFirebaseを用いることで簡単に実装ができたのでVue.jsとFirebaseを用いたWebRTCの実装方法を紹介します。
Demo、Githubは下記からアクセスできます。
Demo: https://webrtcqiita.web.app/ 
Github: https://github.com/hond0413/webrtc

# WebRTCとは
WebRTCはビデオ、音声、及び一般的なデータをPeer間で送信することをサポートしたものです。全てのモダンブラウザと、主要プラットフォームのネイティブクライアントで利用可能です。
https://webrtc.org/

# 準備
* Vue: 2.6.11
* Firebase: 8.1.1(ver 9以降はfirebase.jsの書き方が異なります。ご注意ください。)

# Vueプロジェクトの作成
初めに下記手順でVueプロジェクトを作成します。vue-cliを用いたプロジェクトの作成方法を理解している方はvue-router、Vuetifyの設定をして[Firebase、Firestoreの設定](#firebasefirestoreの設定)まで飛ばしてもらって大丈夫です。

```
$ cd [working directory]
$ vue create [project name]
```

下記のように表示されるので vue-router導入のためManually select featuresを選択してください。

```
? Please pick a preset: 
  Default ([Vue 2] babel, eslint) 
  Default (Vue 3 Preview) ([Vue 3] babel, eslint) 
❯ Manually select features 
```

下記のように表示されるのでRouterを追加で選択してください。

```
? Check the features needed for your project: 
 ◉ Choose Vue version
 ◉ Babel
 ◯ TypeScript
 ◯ Progressive Web App (PWA) Support
❯◉ Router
 ◯ Vuex
 ◯ CSS Pre-processors
 ◉ Linter / Formatter
 ◯ Unit Testing
 ◯ E2E Testing
```

今回はVue2を使用しているため2.xを選択してください。

```
? Choose a version of Vue.js that you want to start the project with (Use arrow 
keys)
❯ 2.x 
  3.x (Preview) 
```

今回はどちらを選択しても問題はありませんがhistoryを使用します。

```
? Use history mode for router? (Requires proper server setup for index fallback 
in production) (Y/n) 
```

Lintもデフォルトのもので問題ありません。

```
? Pick a linter / formatter config: (Use arrow keys)
❯ ESLint with error prevention only 
  ESLint + Airbnb config 
  ESLint + Standard config 
  ESLint + Prettier 
```

下記もEnterで大丈夫です。

```
? Pick additional lint features: (Press <space> to select, <a> to toggle all, <i
> to invert selection)
❯◉ Lint on save
 ◯ Lint and fix on commit
```

下記もEnterで大丈夫です。

```
? Where do you prefer placing config for Babel, ESLint, etc.? (Use arrow keys)
❯ In dedicated config files 
  In package.json 
```

設定を保存する必要はないのでNで次に進みます。

```
? Save this as a preset for future projects? (y/N) 
```

最後にVueプロジェクトの作成が成功したら下記のように表示されるのでプロジェクトディレクトリに移動してVuetifyを追加しましょう。

```
13 packages are looking for funding
  run `npm fund` for details
⚓  Running completion hooks...

📄  Generating README.md...

🎉  Successfully created project test.
👉  Get started with the following commands:

 $ cd [project name]
 $ npm run serve
```

```
$ cd [project name] 
$ vue add vuetify
```

vuetifyの設定はEnterで問題ありません。

# Firebase、Firestoreの設定
* Firebase及びFirestoreの設定は下記記事がとても参考になるので参照してください。
https://qiita.com/Dchi412/items/e0859e5df65de6f78591 
* 事前にFirestoreに"calls"という名前のcollectionを追加しておいてください。

# .envファイルの設定
vueのプロジェクトディレクトリ直下に.envファイルを設けそこにFirebaseのSDKを設定してください。デフォルトで.envは.gitignoreに追加されていないので追加することを強くお勧めします。

```:.env
VUE_APP_API_KEY=****************
VUE_APP_AUTH_DOMAIN=****************
VUE_APP_DATABASE_URL=****************
VUE_APP_PROJECT_ID=****************
VUE_APP_STORAGE_BUCKET=****************
VUE_APP_MESSAGING_SENDER_ID=****************
VUE_APP_ID=****************
VUE_APP_MEASUREMENT_ID=****************
```

# main.tsの設定
今回はFirebaseのSDKを環境変数として定義したためmain.tsのfirebaseConfigでは環境変数を呼び出します。

```typescript:main.ts
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'

Vue.config.productionTip = false

import firebase from 'firebase'
import vuetify from './plugins/vuetify'
const firebaseConfig = {
  apiKey: process.env.VUE_APP_API_KEY,
  authDomain: process.env.VUE_APP_AUTH_DOMAIN,
  databaseURL: process.env.VUE_APP_DATABASE_URL,
  projectId: process.env.VUE_APP_PROJECT_ID,
  storageBucket: process.env.VUE_APP_STORAGE_BUCKET,
  messagingSenderId: process.env.VUE_APP_MESSAGING_SENDER_ID,
  appId: process.env.VUE_APP_APP_ID,
  measurementId: process.env.VUE_APP_MEASUREMENT_ID
};
// Initialize Firebase
if (!firebase.apps.length) {
  firebase.initializeApp(firebaseConfig)
}

new Vue({
  router,
  store,
  vuetify,
  render: h => h(App)
}).$mount('#app')
```

# componentの作成
以下よりVueプロジェクトのcomponentのコードの詳細を解説します。

## WebRTC component
* WebRTCの処理を行うcomponentは以下のようになっております。コード全体下に主要部の解説します。

```vuejs:WebRTC.vue
<template>
  <div>
    <v-row>
      <v-col>
        <video class="media_v" id="webcamVideo" autoplay playsinline></video>
      </v-col>
      <v-col>
        <video class="media_v" id="remoteVideo" autoplay playsinline></video>
      </v-col>
    </v-row>
  </div>
</template>

<script>
import firebase from "firebase"
import "firebase/firestore"

export default {
  name: "Webrtc",
  data () {
    return {
      db: null,
      servers: {
        iceServers: [
          {
            urls: [
              "stun:stun1.l.google.com:19302",
            ],
          },
        ],
        iceCandidatePoolSize: 10,
      },
      localStream: null,
      remoteStream: null,
      pc: null,
      webcamVideo: null,
      remoteVideo: null,
    }
  },
  async created() {
    this.db = firebase.firestore()
    await this.startWebcam();
    const name = this.$route.query.name
    if (name) {
      await this.createCall();
    } else {
      await this.answerBtn(this.$route.query.id);
    }
  },
  mounted() {
    this.pc = new RTCPeerConnection(this.servers);
    this.webcamVideo = document.getElementById("webcamVideo");
    this.remoteVideo = document.getElementById("remoteVideo");
  },
  methods: {
    async startWebcam() {
      this.localStream = await navigator.mediaDevices.getUserMedia({
        video: true,
        // audio: true,
      }).
      catch(error => {
        console.error("can't connect mediadevices")
      })

      this.remoteStream = new MediaStream();

      this.localStream.getTracks().forEach((track) => {
        this.pc.addTrack(track, this.localStream);
      });

      this.pc.ontrack = (event) => {
        console.log(event)
        event.streams[0].getTracks().forEach((track) => {
          this.remoteStream.addTrack(track);
        });
      };

      this.webcamVideo.srcObject = this.localStream;
      this.remoteVideo.srcObject = this.remoteStream;
    },
    async createCall() {
      const callDoc = this.db.collection("calls").doc();
      const offerCandidates = callDoc.collection("offerCandidates");
      const answerCandidates = callDoc.collection("answerCandidates");

      this.pc.onicecandidate = (event) => {
        event.candidate && offerCandidates.add(event.candidate.toJSON());
      };

      const offerDescription = await this.pc.createOffer();
      await this.pc.setLocalDescription(offerDescription);

      const offer = {
        sdp: offerDescription.sdp,
        type: offerDescription.type,
      };

      await callDoc.set({offer});
      await callDoc.update({ name: this.$route.query.name });

      callDoc.onSnapshot((snapshot) => {
        const data = snapshot.data();
        if (!this.pc.currentRemoteDescription && data?.answer) {
          const answerDescription = new RTCSessionDescription(data.answer);
          this.pc.setRemoteDescription(answerDescription);
        }
      });

      answerCandidates.onSnapshot((snapshot) => {
        snapshot.docChanges().forEach((change) => {
          if (change.type === "added") {
            const candidate = new RTCIceCandidate(change.doc.data());
            this.pc.addIceCandidate(candidate);
          }
        });
      });
    },
    async answerBtn(val) {
      const callDoc = this.db.collection("calls").doc(val);
      const answerCandidates = callDoc.collection("answerCandidates");
      const offerCandidates = callDoc.collection("offerCandidates");

      this.pc.onicecandidate = (event) => {
        event.candidate && answerCandidates.add(event.candidate.toJSON());
      };

      const callData = (await callDoc.get()).data();

      const offerDescription = callData.offer;
      await this.pc.setRemoteDescription(
        new RTCSessionDescription(offerDescription)
      );

      const answerDescription = await this.pc.createAnswer();
      await this.pc.setLocalDescription(answerDescription);

      const answer = {
        type: answerDescription.type,
        sdp: answerDescription.sdp,
      };

      await callDoc.update({ answer });

      offerCandidates.onSnapshot((snapshot) => {
        snapshot.docChanges().forEach((change) => {
          if (change.type === "added") {
            let data = change.doc.data();
            this.pc.addIceCandidate(new RTCIceCandidate(data));
          }
        });
      });
    }
  }
}
</script>
```

### WebRTC コネクションの実現
RTCPeerConnectionインターフェイスを使用してWebRTC コネクションを表現します。

```js:WebRTC.vue
mounted() {
    this.pc = new RTCPeerConnection(this.servers);
<--省略-->
}
```

RTCPeerConnectionに関する詳しい情報は下記を参照すると良いです。
https://developer.mozilla.org/ja/docs/Web/API/RTCPeerConnection

### startWebcam method
* 下記コードでメディアデバイスの設定をすることでwebカメラを用いた通信を可能にしています。

```js:WebRTC.vue
async startWebcam() {
    this.localStream = await navigator.mediaDevices.getUserMedia({
        video: true,
        // audio: true,
    }).
    catch(error => {
        console.error("can't connect mediadevices")
    })

    this.remoteStream = new MediaStream();

    this.localStream.getTracks().forEach((track) => {
        this.pc.addTrack(track, this.localStream);
    });

    this.pc.ontrack = (event) => {
        console.log(event)
        event.streams[0].getTracks().forEach((track) => {
          this.remoteStream.addTrack(track);
        });
    };

    this.webcamVideo.srcObject = this.localStream;
    this.remoteVideo.srcObject = this.remoteStream;
},
```

* 下記コードでメディアデバイスの検出設定を行っています。自分のローカル環境で二つのタブを開いてWebRTCのテストを行う場合audioを使用しているとハウリングの原因となるためコメントアウトしています。実際にaudioを利用して遠くの方と通話を行いたい方はコメントアウトを解除して実装してもらうと可能になります。

```js:WebRTC.vue
this.localStream = await navigator.mediaDevices.getUserMedia({
    video: true,
    // audio: true,
}).
```

* 先ほど登録したtracksを取得してpeer connectionにセットします。

```js:WebRTC.vue
this.localStream.getTracks().forEach((track) => {
    this.pc.addTrack(track, this.localStream);
});
```

* peer connectionから通信相手のtracksを取得してremote streamに追加します。

```js:WebRTC.vue
this.pc.ontrack = (event) => {
    console.log(event)
    event.streams[0].getTracks().forEach((track) => {
        this.remoteStream.addTrack(track);
    });
};
```

### createCall method
* 下記コードではホスト側(ルーム作成側)の処理を行なっています。

```js:WebRTC.vue
async createCall() {
    const callDoc = this.db.collection("calls").doc();
    const offerCandidates = callDoc.collection("offerCandidates");
    const answerCandidates = callDoc.collection("answerCandidates");

    this.pc.onicecandidate = (event) => {
        event.candidate && offerCandidates.add(event.candidate.toJSON());
    };

    const offerDescription = await this.pc.createOffer();
    await this.pc.setLocalDescription(offerDescription);

    const offer = {
        sdp: offerDescription.sdp,
        type: offerDescription.type,
    };

    await callDoc.set({offer});
    await callDoc.update({ name: this.$route.query.name });

    callDoc.onSnapshot((snapshot) => {
        const data = snapshot.data();
        if (!this.pc.currentRemoteDescription && data?.answer) {
            const answerDescription = new RTCSessionDescription(data.answer);
            this.pc.setRemoteDescription(answerDescription);
        }
    });

    answerCandidates.onSnapshot((snapshot) => {
        snapshot.docChanges().forEach((change) => {
          if (change.type === "added") {
            const candidate = new RTCIceCandidate(change.doc.data());
            this.pc.addIceCandidate(candidate);
          }
        });
    });
},
```

* 下記コードで事前に準備しておいた"calls" collectionにdocumentを追加します。なおこの処理では自動でidをふりわかるためdocumentにid等の指定はしません。また、"calls" collection下に"offerCandidates"、"answerCandidates" collectionを設けます。これらはstun serverにアクセスして得られた情報等を格納するために使います。

```js:WebRTC.vue
const callDoc = this.db.collection("calls").doc();
const offerCandidates = callDoc.collection("offerCandidates");
const answerCandidates = callDoc.collection("answerCandidates");
```

* offer側のCandidateを取得しFirebaseに追加

```js:WebRTC.vue
this.pc.onicecandidate = (event) => {
    event.candidate && offerCandidates.add(event.candidate.toJSON());
};

const offerDescription = await this.pc.createOffer();
await this.pc.setLocalDescription(offerDescription);

const offer = {
    sdp: offerDescription.sdp,
    type: offerDescription.type,
};

await callDoc.set({offer});
await callDoc.update({ name: this.$route.query.name });
```

* Remote answerの情報を読み込みDescriptionに追加

```js:WebRTC.vue
callDoc.onSnapshot((snapshot) => {
    const data = snapshot.data();
    if (!this.pc.currentRemoteDescription && data?.answer) {
        const answerDescription = new RTCSessionDescription(data.answer);
        this.pc.setRemoteDescription(answerDescription);
    }
});
```

* Firebaseに変更が会った時読み込みCandidateを追加する

```js:WebRTC.vue
answerCandidates.onSnapshot((snapshot) => {
    snapshot.docChanges().forEach((change) => {
        if (change.type === "added") {
            const candidate = new RTCIceCandidate(change.doc.data());
            this.pc.addIceCandidate(candidate);
        }
    });
});
```

### answerBtn method
* 上記methodではcreateCallと同様の処理を行います。

## CreateRoom component
* 上記componentではroomのhost側がroomを作成するための処理を行います。

```vuejs:CreateRoom.vue
<template>
  <v-card>
    <v-card-text>
      <v-text-field v-model="roomName" label="RoomName" required />
    </v-card-text>
    <v-card-actions>
      <v-btn @click="roomCreate(roomName)">
        Create
      </v-btn>
      <v-btn @click="clear()">
        Clear
      </v-btn>
    </v-card-actions>
  </v-card>
</template>

<script>
export default {
  data() {
    return {
      roomName: ""
    }
  },
  methods: {
    roomCreate(val) {
      this.$router.push({path: 'video', query: { name: val }})
    },
    clear() {
      this.roomName = ""
    }
  }
}
</script>
```

## RoomTable component
* 上記componentではFirebaseに登録してあるRoomの一覧を表示しJoinボタンを押すことでRoomに参加することができます。

```vuejs:RoomTable.vue
<template>
  <v-simple-table>
      <template v-slot:default>
        <thead>
          <tr>
            <th class="text-left">
              RoomName
            </th>
            <th class="text-left">
              Join
            </th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="item in tableDataset" :key="item.id">
            <td>{{ item.data().name }}</td>
            <td>
              <v-btn @click="toVideo(item.id)">Join</v-btn>
            </td>
          </tr>
        </tbody>
      </template>
    </v-simple-table>
</template>

<script>
import firebase from "firebase"
import "firebase/firestore"

export default {
  name: "RoomTable",
  data() {
     return {
       db: null,
       tableDataset: [],
     }
  },
  async created() {
    this.db = firebase.firestore()
    this.tableData = this.db.collection("calls").get().
    then(
      (querySnapshot) => {
        querySnapshot.forEach((doc) => {
          this.tableDataset.push(doc)
        })
      }
    )
  },
  methods: {
    toVideo(val) {
      this.$router.push({path: 'video', query: { id: val }})
    }
  }
}
</script>
```

* 以上で主要componentsの作成は終了です。views等はGithubを参照してください。

# 実行方法
* 以下コマンド実行しプロジェクトを実行します。

```
$ cd [working directory]/[project name]
$ npm run serve
```

* ローカルで動作の確認をする際は二つのタブを開いて片方でhostとなりroomを作成しもう片方でjoinしてください。実際に実行できると以下のようになります。
![IMG_2998.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/740740/8d0c749a-0dc7-cb27-b8c9-93004897f1a0.jpeg)

# おわりに
FirebaseとVue.jsを使うことで簡単にWebRTCを実装することができます。この記事が参考になったら幸いです。

# 参考
https://webrtc.org/
https://qiita.com/Dchi412/items/e0859e5df65de6f78591
https://fireship.io/lessons/webrtc-firebase-video-chat/