WebRTCを使って3人以上参加できるビデオチャットを作りたい

#WebRTC、 nuxt.js と Firebase(realtime database)でビデオチャットを作ろうとしてできたもの。

## 利用方法

1. https://[あなたのドメイン]/vchatを開く
2. https://[あなたのドメイン]/vchat/[ランダムなルーム名]に転送される
3. ビデオ開始ボタンと接続ボタンを押す
4. 開いている URL をメール等で共有して相手にも 3 をしてもらう
5. ビデオチャット開始‼

※safari は複数人の映像通信が出来ません…（1 対 1 の通信はできる）

[実際に動いてるページ](https://meguro-take-out.web.app/vchat)

では解説します。

## 部屋の自動選択：pages/vchat/index.vue

pages/vchat/index.vue では部屋名の作成(createRandomString)と転送(mounted)をしています。
以上です。

```pages/vchat/index.vue
<template>
  <div>
    wait a seconds
  </div>
</template>

<script>
import sleep from '~/plugins/sleep'

export default {
  async mounted() {
    const path = `/vchat/${this.createRandomString(16)}`
    await sleep(500)
    this.$router.push(path)
  },
  methods: {
    createRandomString(n) {
      const c = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'

      const cl = c.length
      let r = ''
      for (let i = 0; i < n; i++) {
        r += c[Math.floor(Math.random() * cl)]
      }
      return r
    }
  }
}
</script>
```

## WebRTC での通信：/pages/vchat/\_roomName.vue

/pages/vchat/\_roomName.vue では WebRTC を使って通信を行います
一応書きますが el-\*のタグは element ui のタグです。

詰まった点を個別に解説していきます。

### HTML 部分：template

- video タグに autoplay, playsinline 属性を付ける
- ローカルの映像を映す video タグには muted 属性も付ける
safari では muted 属性がないとローカルの映像を表示することが出来ません。

### ローカルビデオの開始：startVideo

・getUserMedia 関数はもうベンダープレフィックスがいらないです。やったねｖ
・getUserMedia 関数の引数はそれぞれの環境に合わせて最適化しましょう。

```js
    async startVideo() {
      try {
        this.localstream = await window.navigator.mediaDevices.getUserMedia({
          video: { facingMode: 'user', width: 360, height: 240 },
          audio: {
            echoCancellation: true,
            echoCancellationType: 'system',
            noiseSuppression: true
          }
        })
        this.playVideo(this.localVideo, this.localstream)
        this.step = 1
      } catch (err) {
        console.error('startVideo: ', err)
      }
    },
```

### firebase へ接続：createRoom

createRoom は firebase と接続して他のユーザーと接続情報を交換します
まずは[ルーム名]/**join**の参照を作り一時的なユーザ ID(１)と現在のサーバー時間(２)を取得します。
次に[ルーム名]/**broadcast**に (２) 以降、書き込まれたデータを参照するクエリ(３)を作ります
(３)にたいしてデータ追加イベントのイベントハンドラを登録します。

```js

    async createRoom() {
      const roomJoinName = `${this.roomName}/__join__/`
      const roomJoinRef = await this.getVchatRoomRef({
        roomName: roomJoinName
      })
      const payload = {
        createdAt: firebase.database.ServerValue.TIMESTAMP
      }
      const result = roomJoinRef.push(payload)
      this.clientId = `member_${result.key}`

      const refOnValue = () => {
        return new Promise((resolve, reject) => {
          result.on('value', snapshot => {
            return resolve(snapshot.val())
          })
        })
      }

      const refValue = await refOnValue()
      const roomBroadcastName = `${this.roomName}/__broadcast__/`
      const query = await this.getVchatDirectRoomQuery({
        roomName: roomBroadcastName,
        created: refValue.createdAt
      })
      this.roomBroadcastRef = query.ref
      query.on('child_added', async data => {
        const message = data.val()
        const fromId = message.from
        if (fromId === this.clientId) {
          return
        }

        switch (message.type) {
          case 'call me': {
            await this.makeOffer(fromId)
            break
          }
          case 'offer': {
            await this.setOffer(fromId, message)
            break
          }
          case 'answer': {
            await this.setAnswer(fromId, message)
            break
          }
          case 'candidate': {
            const ice = window.JSON.parse(message.ice)
            const candidate = new window.RTCIceCandidate(ice)
            this.addIceCandidate(fromId, candidate)
            break
          }
          case 'close': {
            this.hangUp(fromId)
            break
          }
          default: {
            console.error(message.type)
            break
          }
        }
      })
    },
```

### 接続待ち状態に：connect

call me を firebase に送ります。

```js
    connect() {
      const message = {
        type: 'call me'
      }
      this.pushMesssage(message)
    },
```

### 接続待ちに対してオファーを送る：makeOffer

```js
    async makeOffer(id) {
      if (this.peerConnections[id]) {
        console.warn('already has peerConnection id: ', id)
      }
      const peerConnection = this.prepareNewConnection(id, false)
      this.peerConnections[id] = peerConnection

      try {
        const offer = await peerConnection.createOffer()
        await peerConnection.setLocalDescription(offer)
        this.sendSdp(peerConnection.localDescription)
      } catch (err) {
        console.error(err)
      }
    },
```

### オファーを peerConnection に受けてアンサーを返す：setOffer

```js
    async setOffer(id, offer) {
      if (this.peerConnections[id]) {
        console.warn('peerConnection already exist')
      } else {
        this.peerConnections[id] = this.prepareNewConnection(id, true)
      }

      try {
        await this.peerConnections[id].setRemoteDescription(offer)
        await this.makeAnswer(id)
      } catch (err) {
        console.error('peerConnection.setRemoteDescription(offer) fail: ', err)
      }
    },
```

### アンサーを firebase に登録する：makeAnswer

```js
    async makeAnswer(id) {
      if (!this.peerConnections[id]) {
        console.error('peerConnection not exist')
        return
      }
      try {
        const answer = await this.peerConnections[id].createAnswer()
        await this.peerConnections[id].setLocalDescription(answer)
        this.sendSdp(this.peerConnections[id].localDescription)
      } catch (err) {
        console.error(err)
      }
    },
```

### 返信を peerConnection に登録する：setAnswer

```js
    async setAnswer(id, answer) {
      if (!this.peerConnections[id]) {
        console.error('peerConnection not exist')
        return
      }
      try {
        await this.peerConnections[id].setRemoteDescription(answer)
      } catch (err) {
        console.error('setRemoteDescription(answer) ERROR: ', err)
      }
    },
```

### 接続情報を登録する：addIceCandidate

```js
    addIceCandidate(id, candidate) {
      if (this.peerConnections[id]) {
        this.peerConnections[id].addIceCandidate(candidate)
      } else {
        console.error('peerConnection not exist')
      }
    },
```

### 全切断する：hangUpAll

```js
    hangUpAll() {
      for (const id in this.peerConnections) {
        this.hangUp(id)
      }
      const message = {
        type: 'close'
      }
      this.pushMesssage(message)
      if (this.roomBroadcastRef) {
        this.roomBroadcastRef.off()
      }
      this.step = 1
    },
```

### 個別に切断する：hangUp

```js
    hangUp(id) {
      if (!this.peerConnections[id]) {
        console.log('not found')
        return
      }
      if (this.peerConnections[id].iceConnectionState === 'closed') {
        console.log('already closed')
        return
      }

      this.peerConnections[id].close()
      delete this.peerConnections[id]
      const video = document.querySelector(`#${id}`)
      this.cleanupVideo(video)
      this.peerConnectionIds.splice(this.peerConnectionIds.indexOf(id), 1)
    },
```

### firebase に接続情報を書き込む：pushMesssage

firebase の broadcast に書き込みする場合はここを通すことで from, created が抜けないようにしてます。

```js
    pushMesssage(message) {
      if (!this.roomBroadcastRef) {
        return
      }
      message.from = this.clientId
      message.created = firebase.database.ServerValue.TIMESTAMP
      this.roomBroadcastRef.push(message)
    },
```

/pages/vchat/_roomName.vue のその他の関数については割愛します。

```/pages/vchat/_roomName.vue
<template>
  <div>
    <el-button-group>
      <el-button :disabled="step !== 0" @click="startVideo">
        ビデオ開始
      </el-button>
      <el-button :disabled="step !== 1" @click="joinRoom">
        接続
      </el-button>
      <el-button :disabled="step === 0" @click="stopVideo">
        ビデオ停止
      </el-button>
    </el-button-group>
    <div>
      <div v-for="id in peerConnectionIds" :key="id">
        <video :id="id" width="320" height="240" autoplay playsinline />
      </div>
      <div>
        <p v-if="step === 2 && peerConnectionIds.length === 0">
          接続待ち
        </p>
        <video
          ref="localVideo"
          width="320"
          height="240"
          autoplay
          playsinline
          muted
        />
      </div>
    </div>
  </div>
</template>

<script>
import { mapActions } from 'vuex'
import firebase from '~/plugins/firebase'
import sleep from '~/plugins/sleep'

export default {
  data() {
    const roomName = this.$route.params.roomName
    return {
      localVideo: null,
      localstream: null,
      peerConnections: {},
      peerConnectionIds: [],
      sendingOffer: true,
      roomBroadcastRef: null,
      clientId: '',
      step: 0,
      roomName
    }
  },
  mounted() {
    this.localVideo = this.$refs.localVideo
  },
  beforeDestroy() {
    this.stopVideo()
  },
  methods: {
    async startVideo() {
      try {
        this.localstream = await window.navigator.mediaDevices.getUserMedia({
          video: { facingMode: 'user', width: 360, height: 240 },
          audio: {
            echoCancellation: true,
            echoCancellationType: 'system',
            noiseSuppression: true
          }
        })
        this.playVideo(this.localVideo, this.localstream)
        this.step = 1
      } catch (err) {
        console.error('startVideo: ', err)
      }
    },
    async joinRoom() {
      await this.createRoom()
      this.connect()
      this.step = 2
    },
    async createRoom() {
      const roomJoinName = `${this.roomName}/__join__/`
      const roomJoinRef = await this.getVchatRoomRef({
        roomName: roomJoinName
      })
      const payload = {
        createdAt: firebase.database.ServerValue.TIMESTAMP
      }
      const result = roomJoinRef.push(payload)
      this.clientId = `member_${result.key}`

      const refOnValue = () => {
        return new Promise((resolve, reject) => {
          result.on('value', snapshot => {
            return resolve(snapshot.val())
          })
        })
      }

      const refValue = await refOnValue()
      const roomBroadcastName = `${this.roomName}/__broadcast__/`
      const query = await this.getVchatDirectRoomQuery({
        roomName: roomBroadcastName,
        created: refValue.createdAt
      })
      this.roomBroadcastRef = query.ref
      query.on('child_added', async data => {
        const message = data.val()
        const fromId = message.from
        if (fromId === this.clientId) {
          return
        }

        switch (message.type) {
          case 'call me': {
            await this.makeOffer(fromId)
            break
          }
          case 'offer': {
            await this.setOffer(fromId, message)
            break
          }
          case 'answer': {
            await this.setAnswer(fromId, message)
            break
          }
          case 'candidate': {
            const ice = window.JSON.parse(message.ice)
            const candidate = new window.RTCIceCandidate(ice)
            this.addIceCandidate(fromId, candidate)
            break
          }
          case 'close': {
            this.hangUp(fromId)
            break
          }
          default: {
            console.error(message.type)
            break
          }
        }
      })
    },
    async makeOffer(id) {
      if (this.peerConnections[id]) {
        console.warn('already has peerConnection id: ', id)
      }
      const peerConnection = this.prepareNewConnection(id, false)
      this.peerConnections[id] = peerConnection

      try {
        const offer = await peerConnection.createOffer()
        await peerConnection.setLocalDescription(offer)
        this.sendSdp(peerConnection.localDescription)
      } catch (err) {
        console.error(err)
      }
    },
    async setOffer(id, offer) {
      if (this.peerConnections[id]) {
        console.warn('peerConnection already exist')
      } else {
        this.peerConnections[id] = this.prepareNewConnection(id, true)
      }

      try {
        await this.peerConnections[id].setRemoteDescription(offer)
        await this.makeAnswer(id)
      } catch (err) {
        console.error('peerConnection.setRemoteDescription(offer) fail: ', err)
      }
    },
    async makeAnswer(id) {
      if (!this.peerConnections[id]) {
        console.error('peerConnection not exist')
        return
      }
      try {
        const answer = await this.peerConnections[id].createAnswer()
        await this.peerConnections[id].setLocalDescription(answer)
        this.sendSdp(this.peerConnections[id].localDescription)
      } catch (err) {
        console.error(err)
      }
    },
    async setAnswer(id, answer) {
      if (!this.peerConnections[id]) {
        console.error('peerConnection not exist')
        return
      }
      try {
        await this.peerConnections[id].setRemoteDescription(answer)
      } catch (err) {
        console.error('setRemoteDescription(answer) ERROR: ', err)
      }
    },
    addIceCandidate(id, candidate) {
      if (this.peerConnections[id]) {
        this.peerConnections[id].addIceCandidate(candidate)
      } else {
        console.error('peerConnection not exist')
      }
    },
    hangUpAll() {
      for (const id in this.peerConnections) {
        this.hangUp(id)
      }
      const message = {
        type: 'close'
      }
      this.pushMesssage(message)
      if (this.roomBroadcastRef) {
        this.roomBroadcastRef.off()
      }
      this.step = 1
    },
    hangUp(id) {
      if (!this.peerConnections[id]) {
        console.log('not found')
        return
      }
      if (this.peerConnections[id].iceConnectionState === 'closed') {
        console.log('already closed')
        return
      }

      this.peerConnections[id].close()
      delete this.peerConnections[id]
      const video = document.querySelector(`#${id}`)
      this.cleanupVideo(video)
      this.peerConnectionIds.splice(this.peerConnectionIds.indexOf(id), 1)
    },
    sendIceCandidate(candidate) {
      const message = {
        type: 'candidate',
        ice: window.JSON.stringify(candidate)
      }
      this.pushMesssage(message)
    },
    playVideo(video, stream) {
      if (video.srcObject === stream) {
        console.warn('same stream. skip playVideo')
        return
      }

      video.srcObject = stream
      video.onloadedmetadata = event => {
        video.play()
      }
    },
    prepareNewConnection(id, isOffer) {
      if (this.peerConnections[id]) {
        console.log('already has peer connection')
        return
      }
      this.peerConnectionIds.push(id)
      const pcConfig = {
        iceServers: [{ urls: 'stun:stun1.l.google.com:19302' }],
        sdpSemantics: 'unified-plan'
      }
      const peer = new window.RTCPeerConnection(pcConfig)
      peer.ontrack = async event => {
        const stream = event.streams[0]
        await this.$nextTick()
        await sleep(10)
        const video = document.querySelector(`#${id}`)
        if (this.isVideoAvailable(video, stream)) {
          this.playVideo(video, stream)
          this.step = 2
        }
      }
      peer.onicecandidate = event => {
        if (event.candidate) {
          this.sendIceCandidate(event.candidate)
        } else {
          console.log('empty ice event')
        }
      }

      peer.onnegotiationneeded = async () => {
        if (!isOffer) {
          console.log('not offer')
          return
        }

        if (peer.remoteDescription !== null) {
          console.warn('peer has remoteDescription')
          return
        }

        try {
          const offer = await peer.createOffer()
          await peer.setLocalDescription(offer)
          this.sendSdp(peer.localDescription)
        } catch (err) {
          console.error('peer.setLocalDescription(offer) failed: ', err)
        }
      }

      peer.oniceconnectionstatechange = () => {
        switch (peer.iceConnectionState) {
          case 'closed':
          case 'failed':
            if (this.peerConnections[id]) {
              this.hangUp(id)
            }
            break
          case 'disconnected':
            break
        }
      }

      if (this.localstream) {
        this.localstream.getTracks().forEach(track => {
          peer.addTrack(track, this.localstream)
        })
      } else {
        console.warn('no local stream, but continue.')
      }

      return peer
    },
    sendSdp(sessionDescription) {
      if (!sessionDescription) {
        console.log('sessionDescription is null or undefined')
        return
      }
      const message = {
        type: sessionDescription.type,
        sdp: sessionDescription.sdp
      }
      this.pushMesssage(message)
    },
    connect() {
      const message = {
        type: 'call me'
      }
      this.pushMesssage(message)
    },
    stopVideo() {
      this.hangUpAll()
      this.cleanupVideo(this.localVideo)
      this.stopStream(this.localstream)
      this.step = 0
    },
    pushMesssage(message) {
      if (!this.roomBroadcastRef) {
        return
      }
      message.from = this.clientId
      message.created = firebase.database.ServerValue.TIMESTAMP
      this.roomBroadcastRef.push(message)
    },
    cleanupVideo(video) {
      if (!video) {
        return
      }
      video.pause()
    },
    isVideoAvailable(video, stream) {
      if (!video.srcObject) {
        return true
      }
      return video.srcObject !== stream
    },
    stopStream(stream) {
      if (!stream) {
        return
      }
      stream.getVideoTracks().forEach(track => {
        track.stop()
      })
      stream.getAudioTracks().forEach(track => {
        track.stop()
      })
    },
    ...mapActions('vchat-room', ['getVchatRoomRef', 'getVchatDirectRoomQuery'])
  }
}
</script>
```

## /store/vchat-room.js

getVchatDirectRoomQuery は Query を返すので push とかできません
.ref プロパティで Refference を取得しましょう。

```/store/vchat-room.js
import { firebaseAction } from 'vuexfire'
import firebase from '~/plugins/firebase'
import 'isomorphic-fetch'

const vchatRoomsRef = firebase.database().ref(`vchatRooms`)

export const actions = {
  getVchatRoomRef: firebaseAction(({ context }, { roomName }) => {
    return vchatRoomsRef.child(roomName)
  }),
  getVchatDirectRoomQuery: firebaseAction(
    ({ context }, { roomName, created }) => {
      return firebase
        .database()
        .ref(`vchatRooms/${roomName}`)
        .orderByChild('created')
        .startAt(created)
    }
  )
}
```

## /plugins/sleep.js

非同期の sleep 関数。

```/plugins/sleep.js
const sleep = msec => new Promise(resolve => setTimeout(resolve, msec))

export default sleep
```