Twilio×Reactでビデオ通話アプリを作る

Livesense アドベントカレンダーの3日目です。今回は、ブラウザ上で簡単にビデオ通話の機能を作ることができる「Twilio」というサービスを紹介しようと思います。
「Webでビデオ通話機能を作ってみたい」や「Twilio気になるけど触ったことない」「WebRTC辛い」という人に役立てると幸いです。

紹介するにあたり、Reactで簡単なアプリを作ってみました。
https://hand12.github.io/sample-twilio/

無料アカウントで作成しているので、クレジットが無くなり次第サイトは閉じます。
（クレジットがなくなったタイミングでビデオ通話もできなくなります）

こんなイメージです。
<img width="720" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/139572/2e4213cb-983e-fe30-d4ba-0ef92dc5c07a.jpeg">

今回は作成したこちらのアプリを元に、Twilioを紹介できればなと思います。

# Twilioとは？
[Twilio](https://www.twilio.com/ja/)とは、電話やSMS、WebRTCに関するAPIを提供してくれるクラウドサービスです。

ブラウザには、WebRTCと呼ばれるブラウザ上でリアルタイム通信を行うためのAPIが存在しており、標準APIだけでもビデオ通機能を実装することができるのですが、外部のネットワークに接続するためにサーバーを準備したり、各ブラウザ間の差分吸収など、安定して通話するためにはそれなりの工数がかかります。

Twilioでは、それらのビデオ通話に必要な機能がAPIとして提供されており、比較的少ない工数でビデオ通話を実装することができます。

# Twilio ProgrammableVideo
Twilioでビデオ通話機能を実装するには、[ProgrammableVideo](https://jp.twilio.com/docs/video/javascript-v1-getting-started)という機能を利用します。

JavaScriptとiOS、Android用のSDKを提供しており、アカウントさえあればすぐにビデオ通話を試すことができるようになっています。

# ビデオ通話が繋がるまで
Twilioでは、仮想的な部屋を作成し、参加者同士で会話する、という思想で機能が提供されています。
部屋に参加するにはtokenが必要で、tokenによって部屋に制限をかけることができるようになります。


実装例

```javascript
const { connect } = require('twilio-video');

// サーバーサイドで発行されたtokenを元に、指定したroomへ接続する
connect('$TOKEN', { name:'my-new-room' }).then(room => {
  console.log(`Successfully joined a Room: ${room}`);
  room.on('participantConnected', participant => {
    console.log(`A remote Participant connected: ${participant}`);
  });
}, error => {
  console.error(`Unable to connect to Room: ${error.message}`);
});
```

# 実際に作ってみる
簡単に、ビデオ通話機能を持ったReactアプリの実装例を紹介しようと思います。

## Twilioのアカウント発行
まずはじめに、Twilioのアカウントを発行します。
Twilioでは無料のトライアルプランが付いており、約1600円分のクレジットも付与されているので、一通りの機能を無料で試すことができます。
[こちら](https://www.twilio.com/try-twilio)から、アカウントを作成することができます。
参考 [Twilioアカウントを作成する方法](https://www.twilio.com/blog/how-to-create-twilio-account-jp)

## token生成処理の作成
Twilioでは、Roomに参加するためには適切なtokenが必要になります。
今回は、[Twilio Functions](https://jp.twilio.com/docs/runtime/functions)と呼ばれる、Twilioが提供しているサーバレスアーキテクチャを使ってtokenを発行する処理を作成します。
参考 [Generate an Access Token for Twilio Chat, Video, and Voice using Twilio Functions](https://www.twilio.com/blog/generate-access-token-twilio-chat-video-voice-using-twilio-functions)

### twilio-cliのインストール
Twilio Functionsのテンプレートを作成するため、twilio-cliを使用します。

```bash
$ npm install twilio-cli -g

$ twilio plugins:install @twilio-labs/plugin-serverless
```

### テンプレートの作成
次に、twili-cliを使ってテンプレートを作成します。

```bash
$ twilio serverless:init token-service
```

これにより、以下のようなテンプレートファイルが作成されたかと思います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/139572/03b90a47-aa79-b7c2-da4d-9443e84f0d43.png)

今回はhello-world.jsの中身を書き換えて、tokenを返す関数を作成します。
hello-world.jsをgetAuthToken.jsにリネームし、中身を以下のように書き換えます。

```getAuthToken.js
exports.handler = function(context, event, callback) {
  // Twilioのアカウント情報を取得
  const twilioAccountSid = context.ACCOUNT_SID
  const twilioApiKey = context.API_KEY
  const twilioApiSecret = context.API_SECRET

  // リクエストパラメータから、identityとroomNameを取得
  const identity = event.identity || 'user'
  const roomName = event.roomName

  // tokenを生成
  const AccessToken = Twilio.jwt.AccessToken
  const token = new AccessToken(
    twilioAccountSid,
    twilioApiKey,
    twilioApiSecret,
    {identity: identity}
  )

  // tokenに権限を付与
  const VideoGrant = AccessToken.VideoGrant
  const videoGrant = new VideoGrant({
    room: roomName
  })
  token.addGrant(videoGrant)

  // responseを生成
  const response = new Twilio.Response()
  const headers = {
    "Access-Control-Allow-Origin": "*", // change this to your client-side URL
    "Access-Control-Allow-Methods": "GET,PUT,POST,DELETE,OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type",
    "Content-Type": "application/json"
  }

  response.setHeaders(headers)
  response.setBody({
    accessToken: token.toJwt()
  })

  return callback(null, response)
}

```

### .envの書き換え
ブラウザからAPIキーを発行し、ディレクトリ内にある.envファイルの中身を書き換えます。
APIキーの発行は、[こちら](https://www.twilio.com/blog/generate-access-token-twilio-chat-video-voice-using-twilio-functions)が参考になります。

```.env
ACCOUNT_SID=AChogehogehoge
AUTH_TOKEN=hogehogehoge
API_SECRET=hogehogehoge
API_KEY=SKhogehogehoge
```

### デプロイ
次に、作成した関数をTwilio Functionsにdeployします。

```bash
twilio serverless:deploy
```

これで、サーバーサイド側の処理は完成です。


## クライアント側のアプリの作成
次に、Reactを使ってクライアント側のアプリを作成します。
※ 今回はReact自体の解説は行いません。
例としてcreate-react-appを使ってアプリケーションを作成しています。
各々のReactアプリに置き換えて試してみてください。

## video-apiのインストール
TwilioのApiを利用するには、 [twili-video](https://www.npmjs.com/package/twilio-video)をインストールします。

```bash
npm install --save twilio-video@2.8.0
```

## ビデオ通話用のclassを作成
ビデオ通話機能を提供するClassを用意します。

```twilio.ts
import {
  connect,
  Room,
  RemoteParticipant,
  isSupported,
  createLocalTracks,
  LocalTrack,
  LocalAudioTrack,
  LocalVideoTrack
} from 'twilio-video'

export class Twilio {
  room: Room | null = null
  localTracks: LocalTrack[] | null = null

  constructor(roomName: string, identity: string = 'gest') {
    // ブラウザがTwilioの対象かどうかを返す。
    // 対象ブラウザはこちらを参照 https://jp.twilio.com/docs/video/javascript#supported-browsers
    if (!isSupported) {
      alert('お使いのブラウザは未対応です。')
      throw new Error('This browser is not supported by twilio-video.js.')
    }
    this.displayPreview()
      .then(() => {
        this.connectRoom(roomName, identity)
      })
  }

  async displayPreview() {
    // 自分のカメラとマイクから動画と音声情報を取得
    this.localTracks = await createLocalTracks()

    // 取得した動画と音声情報を、domに紐づける
    this.localTracks.forEach(track => {
      if (track.kind === 'audio' || track.kind === 'video') {
        track.attach('#localVideo')
      }
    })
  }

  async connectRoom(roomName: string, identity: string) {
    // tokenを取得
    const token = await this.fetchAccessToken(roomName, identity)
    if (!token) {
      new Error('URLが不正です')
    }
    // 取得したtokenを元に、対象のroomに接続
    // 接続する際、相手に送信する自分の動画と音声情報を指定
    this.room = await connect(
      token,
      {
        name: roomName,
        audio: true,
        tracks: this.localTracks!
      }
    )
    this.registerRoomEvent()

    window.addEventListener('beforeunload', () => this.room?.disconnect())
  }

  disconnectRoom() {
    this.room?.disconnect()
    this.room?.removeAllListeners()
    this.room = null
  }
  
  private async fetchAccessToken(roomName: string, identity: string) {
    const params = new URLSearchParams({ roomName, identity })
    // 先ほどデプロイしたtwilio functionsのURLを指定
    return await fetch('https://token-service-hogehoge.twil.io/getAuthToken?' + params)
      .then(response => response.json())
      .then(data => data.accessToken)
  }

  private registerRoomEvent() {
    if (!this.room) return

    // 自分がroomに接続した際に、すでに参加者がいた場合の処理
    this.room.participants.forEach((participant) => this.participantConnected(participant))

    // roomに参加者が追加されたタイミングで行う処理を登録
    this.room.on('participantConnected', (participant) => this.participantConnected(participant))
  }

  // 参加者の音声、動画情報をdomに紐づける
  // 今回は1対1を想定
  private participantConnected(participant: RemoteParticipant) {
    participant.on('trackSubscribed', track => {
      track.attach('#remoteVideo')
    })

    participant.on('trackUnsubscribed', track => (
      track.detach()
    ))
  }
}

```

これでビデオ通話の機能は完成です。

## コンポーネントから呼び出し
今回は以下のような仕様にします。
1. URLにroomNameのクエリパラメータが付与されていた場合、付与されたroomNameの部屋に接続
2. クエリパラメータがなかった場合、uuidを元にroomを作成。roomNameを付与したURLを発行し、ビデオ通話したい相手に共有する

```app.tsx
import React from 'react'
import './App.scss'
import { RoomsDetail } from './pages'

function App() {
  return (
    <div className="App">
      <div className="main-contents">
        <RoomsDetail />
      </div>
    </div>
  );
}

export default App

```

```RoomDetail.tsx
// layout関連は省略
import React, { useEffect, useState } from 'react'
import { v4 as uuidv4 } from 'uuid'
import { BaseLayout } from '../layouts/BaseLayout'
import { NotificationContainer } from '../components/NotificationContainer'
import { Twilio } from '../utils/twilio'

export const RoomsDetail: React.FC = () => {
  const [url, setUrl] = useState<string | null>(null)

  useEffect(() => {
    const query = new URLSearchParams(window.location.search)
    const roomName = query.get('roomName') || uuidv4()
    setUrl(window.location.origin + window.location.pathname + '?roomName=' + roomName)

    // インスタンスが生成されたタイミングで、ビデオ通話を開始
    new Twilio(roomName, query.get('roomName') ? 'host' : 'gest')
  }, [])

  return (
    <BaseLayout>
      <NotificationContainer url={ url || ''} />
      <video id="remoteVideo" />
      <video id="localVideo" muted />
    </BaseLayout>
  )
}

```

これで、ビデオ通話アプリの完成です。


# 最後に
ここで紹介した例以外にも、Twiloだと様々な機能が用意されています。
- 録画機能
- 録画動画の変換機能
- 画面共有機能
- など

従量課金で小さく始めることもできるので、ビデオ通話周りの機能を実装しようと考えている人がいたらオススメです。
以上、Livesense AdventCalenderの3日目でした！




# 参考
- https://www.twilio.com/blog/generate-access-token-twilio-chat-video-voice-using-twilio-functions
- https://jp.twilio.com/docs/video/tutorials/basic-concepts