ReactとTwilio Programmable Videoでカスタムビデオチャットアプリを作成する

December 07, 2020
レビュー担当者
Diane Phan
Twilion

ReactとTwilio Programmable Videoで作成するカスタムビデオチャットアプリ

この記事はTwilio Developer VoicesチームのAshley Boucherこちらで公開した記事(英語)を日本語化したものです。

このチュートリアルでは、ReactとTwilio Programmable Videoを使用してグループビデオチャット用のWebアプリケーションを作成します。ビデオチャットは友人や家族、同僚とのコミュニケーションに大変便利なツールです。それでは、以下にカスタムビデオチャットアプリを作成する基本手順を紹介します。

必要条件

作成するアプリケーションは2つのパートで構成されています。

  1. React.jsのフロントエンド
  2. 任意の言語・プラットフォームで作成されたバックエンド。

バックエンドはアクセストークンの生成に使用し、ユーザーに権限を付与します。フロントエンドはTwilio Programmable Video JavaScript SDKを使用してビデオチャットロジックを処理します。バックエンドの作成は本稿では説明しませんが、Node.jsユーザー向けにブログ記事「Twilio Functionsを使ってTwilio Chat、Video、Voice用のアクセストークンを生成する」で詳しく説明しています。

クライアントの作成

Reactアプリをクローンする

プログラムの雛形となるReactアプリケーションをクローンし、スターターファイルと.cssファイルを格納します。

git clone https://github.com/ahl389/twilio-video-starter-kit

twilio-video-starter-kitに移動し、package.jsonファイルにリストされた依存関係をインストールします。以下のコマンドを実行してください。

cd twilio-video-starter-kit
npm install

Package.jsonには、このプロジェクトで使用するnode-sassが含まれています。

twilio-videoもインストールします。以下のコマンドを実行してください。

npm install twilio-video

インストールが完了したら、twilio-video-starter-kit内の/srcフォルダーを確認してみましょう。src配下のファイルのうち、次のファイルに注目してください。

  • App.js
  • Room.js
  • Participant.js
  • Track.js

この4つのコンポーネントファイルを直接編集して手順を進めていきます。

アプリの構造

Appコンポーネントはアプリケーションの最上位コンポーネントです。このコンポーネントでアプリケーションのランディングページをコントロールし、ユーザーによる入室と退室のアクションを処理します。このコンポーネントには子コンポーネントのRoomがあります。

Roomコンポーネントはビデオルームのすべての参加者のコンテナです。新しいリモート参加者の入室や退出を検知する役割もあります。このコンポーネントには複数のParticipant子コンポーネントを含めることができます。

Participantコンポーネントはそれぞれの参加者のオーディオ/ビデオトラックを管理します。各トラックを表しているのがTrack子コンポーネントです。

Trackコンポーネントはpropsとして受け取ったトラックの接続とレンダリングを行います。

Appコンポーネントの作成

まずはAppコンポーネントから開発を始めます。

クローンしたtwilio-video-starter-kitリポジトリのsrcフォルダーにあるApp.jsを開いてください。このファイルに、アプリケーションの主コンポーネントであるAppコンポーネントのコードが含まれています。このコンポーネントを使用し、ユーザーが名前を入力し、ビデオルームに参加するロビー画面の表示の有無のほか、ビデオルーム自体の表示をコントロールする機能を作成します。

そのほか、Twilioへの接続を開始したり、ビデオルームを作成する役割もあります。

ファイルの先頭にパッケージのインポートや、Appクラスの骨組みが含まれています。

最初にTwilio JavaScript SDKをインポートします。Twilioパッケージからconnectを特定してインポートする必要があります。以下のハイライトされた行を追加してください。

import './App.scss';
import React, {Component} from 'react';
const { connect } = require('twilio-video');

 constructor()メソッド

次に、constructorメソッドを作成してAppコンポーネントを初期化し、初期ステートを設定します。constructorメソッドは新しいコンポーネントが作成されると最初に呼び出されるメソッドです。

Appクラスに次のコードを追加してください。

class App extends Component {
  constructor(props) {
    super(props)

    this.state = {
      identity: '',
      room: null
    }
  }
}

identityステートは空文字列をデフォルト値として設定します。identityは、ユーザーが名前を入力すると値が更新されます。

roomステートの値はデフォルトでnullです。ユーザーがJoin Roomボタンをクリックするとビデオルームに接続されます。接続するとTwilioからroomオブジェクトが返され、コンポーネントのroomステートに格納されます。

roomステートがnullのときは、ユーザーはビデオルームに参加していない状態で、ロビー画面が表示されます。入室するとステートが変わり、rerenderがトリガーされます。この時点でroomステートはnullでなくなり、ユーザーにビデオルーム画面が表示されます。

入室

次に、ビデオルームに参加するメソッドを追加します。このメソッドは、Join Roomボタンをクリックすると呼び出されます。

Appクラスのconstructor()メソッドの下に次のコードを追加してください。

async joinRoom() {
  try {
    const response = await fetch(`https://{your-endpoint}?identity=${this.state.identity}`);
    const data = await response.json();
    const room = await connect(data.accessToken, {
      name: 'cool-room',
      audio: true,
      video: true
    });

    this.setState({ room: room });
  } catch(err) {
    console.log(err);
  }
}

joinRoom()はアクセストークンを取得する非同期関数で、アクセストークンを使用してビデオルームに接続します。つまり、クライアントがビデオルームに接続する前にアクセストークンを返すエンドポイントが必要になります。本稿では、Twilioを使用したビデオチャットアプリ作成のクライアントサイドの手順を主に取り扱うため、アクセストークンの作成には触れません。アクセストークンの生成に関して詳しくは、ブログ記事「Twilio Functionsを使ってTwilio Chat、Video、Voice用のアクセストークンを生成する」を参照してください。エンドポイントの準備ができたら次に進みます。

アクセストークンエンドポイントが使える状態になったところで、上記のコードの3行目のfetchURLを実際のアクセストークンエンドポイントに修正してください。

本稿では、上記のjoinRoomメソッドにルーム名を直接指定します。テスト・開発用なのでこれで問題ありませんが、本番環境では実行するすべてのソフトウェアインスタンスが同じルームに接続するわけではありません。扱いには注意が必要です。また、ユーザーが入力したユーザー名も扱うことから、本番アプリではユーザー名の検証をしてから、アプリへのアクセスを許可する必要があります。

ロビーに戻る

joinRoom()メソッドに続き、returnToLobby()メソッドを作成します。AppクラスのjoinRoom()メソッドのすぐ下に次のコードを追加してください。

returnToLobby() {
    this.setState({ room: null });
  }

このメソッドによりroomステートをnullに戻し、再レンダリングをトリガーします。これにより再びロビー画面が表示されます。returnToLobby()メソッドは、Leave Roomボタンのクリックにより呼び出されます。このボタンは、AppコンポーネントでなくRoomコンポーネントの子です。後ほど使い方を説明します。

render()メソッド

次に、render()メソッドを作成します。このメソッドは、コンポーネントのroomステートの値を使用し、条件に応じてロビー画面かビデオルームのどちらかを表示します。

メソッドの各部を作成していきましょう。まず、AppクラスにあるreturnToLobby()メソッドの下に以下のコードを追加してください。

render() {
  return (
    <div className="app">
      { 
        this.state.room === null
        ? <div className="lobby">
             <input 
               placeholder="What's your name?"/>
            <button onClick={this.joinRoom}>Join Room</button>
          </div>
        : <Room returnToLobby={this.returnToLobby} room={this.state.room} />
      }
    </div>
  );
}

このコードはrender()メソッドを作成し、そのメソッドから appクラスの<div>要素を返します。この<div>の中で、 コードはroomステートがnullであるかをチェックし、nullであれば lobbyクラスの<div>を追加します。この<div>には、ユーザーの識別情報のための<input>フィールドとJoin Roomボタンを含めます。

nullでなく、ルームに接続した状態であれば、RoomコンポーネントにreturnToLobbyroomの2つのpropsを追加します。前者は先に作成したreturnToLobby()メソッド、後者はTwilioから返された実際のroomオブジェクトです。

AppコンポーネントのreturnToLobby()メソッドをpropsとしてRoomコンポーネントに渡します。これは、Leave Roomをクリックしたときに、Roomコンポーネント内からメソッドを呼び出せるようにするためです。Reactはデフォルトではトップダウンのデータフローをとりますが、このようにステートをリフトアップし、子コンポーネントから親コンポーネントへと上位へ伝搬することができます。

ファイルの先頭で以下のようにRoomコンポーネントをインポートしてください。

import './App.scss';
import React, {Component} from 'react';
import Room from './Room';
const { connect } = require('twilio-video');

Refの作成

render()メソッドの基本が分かったところで、もう少し手を加えて完全に機能させます。

レポジトリに含まれるCSSは、ロビーページのすべての文字列を中央合わせにしています。ユーザーが入力フィールドをクリックして名前を入力すると、カーソルがプレースホルダーの中央に表示され、おかしな表示になります。そこで、入力した文字のクリックやフォーカスによりプレースホルダー文字が消えるようにします。

onClick()属性を以下のように、renderメソッドの<input>要素に追加してください。

<input 
  onClick={this.removePlaceholderText} 
  placeholder="What's your name?"/>

次にremovePlaceholderText()メソッドを作成します。

returnToLobby()メソッドとrender()メソッドの間に、以下のコードを追加してください。

removePlaceholderText() {
    this.inputRef.current.placeholder = '';
}

これにより、this.inputRef.currentのプレースホルダーテキストが空の文字列になります。このメソッドを機能させるために、JavaScriptキーワードをバインドします。これはセクションの最後に扱います。

this.inputRef.currentは、ReactのRefへの参照です。Refは、現行コンポーネントにより作成されたDOM要素に直接アクセスできます。this.inputRefはRef自体への参照であり、.currentプロパティはDOM要素です。

Refを作成して<input>要素にバインドするために、2つの小さな変更を加えます。

まず、以下のハイライトされた行をconstructorメソッドに追加して、Refを作成してください。

constructor(props) {
  ...
  
  this.inputRef = React.createRef();
}

次にrender()メソッドに戻り、再び<input>要素を編集してRefを以下のようにバインドしてください。

<input 
  ref={this.inputRef} 
  onClick={this.removePlaceholderText} 
  placeholder="What's your name?"/>

 render()メソッド、さらにAppコンポーネント全体を完成させるにはまだステップが残っていますが、Appコンポーネントはプロジェクトで最も複雑なコンポーネントなのでお付き合いください。

identityステートのバインド

アクセストークンを取得するためにまず必要なのがidentityです。identityはユーザーが入力する必須の情報です。identityを入力する前にJoin Roomボタンをクリックすると問題が発生します。

これに対処するために、入力フィールドに何か入力されるまではJoin Roomボタンを無効にしておく必要があります。

render()メソッドの先頭、returnの前に、次のハイライトされた行を追加してください。

render() {
  const disabled = this.state.identity === '' ? true : false;

  return (
    ...
  );
}

これにより作成されるdisabled変数は、ブーリアン型のフラグです。identityステートが空のときは、trueになります。それ以外はfalseです。

このフラグと等しい値を持つdisabled属性を追加します。render()メソッドの<button>要素を以下のように編集してください。

<button disabled={disabled} onClick={this.joinRoom}>Join Room</button>

あと少しです!

identityステートが空のときは、何も入力されていないと前述しました。このため、<input>フィールドの値とidentityステートの値の間に双方向のバインドを作成します。

<input>フィールドの中身が変わると、<input>要素に追加したonChange()属性によりidentityの値が変わります。このステートが更新されるたびに再レンダリングがトリガーされ、入力内容が消えます。これは問題のある動作であるため、修正するために<input>要素の値をidentityステートにバインドします。

以下のupdateIdentity()メソッドをremovePlaceholderText()メソッドの下に追加してください。

updateIdentity(event) {
  this.setState({
    identity: event.target.value
  });
}

さらに、renderメソッドの<input>要素を以下のように編集してください。

<input 
  value={this.state.identity} 
  onChange={this.updateIdentity} 
  ref={this.inputRef} 
  onClick={this.removePlaceholderText} 
  placeholder="What's your name?"/>

最後に、先に述べたように、特殊なJavaScriptキーワードthisを新規のメソッドにバインドし、それぞれのメソッドでthisが適切に使用されるようにします。constructorメソッドの末尾、}の前に以下のコードを追加してください。

constructor(props) {
  ...

  this.joinRoom = this.joinRoom.bind(this);
  this.returnToLobby = this.returnToLobby.bind(this);
  this.updateIdentity = this.updateIdentity.bind(this);
  this.removePlaceholderText = this.removePlaceholderText.bind(this);
}

これでAppコンポーネントが完成しました! 次に、Roomコンポーネントに移ります。

Roomコンポーネントの作成

App.jsファイルを保存して閉じ、srcフォルダーのファイルRoom.jsを開いてください。

constructor()メソッド

Roomクラスに以下のconstructorメソッドを追加してください。

constructor(props) {
  super(props);

  this.state = {
    remoteParticipants: Array.from(this.props.room.participants.values())
  }

  this.leaveRoom = this.leaveRoom.bind(this);
}

これにより、Roomコンポーネントが初期化され、remoteParticipantsステートが設定されます。これは、propsとして渡されたroomオブジェクトのparticipantsキーから派生する配列値です。一般にReactアプリを構築する場合は、propsからのステート派生は避けたいところですが、以下の2つの理由から使うことにします。

1)設定するデフォルトステートは参加者の入室や退室とともに後で変化する

2)participantsキーの形式はmapオブジェクトであり、簡単にループできないこと。

ここでは、mapオブジェクトからparticipantsオブジェクトの配列に変更して使用します。

ステートを初期化した後で、thisキーワードをコンポーネントメソッドのleaveRoom()にバインドします。このメソッドは、propsとして受け取ったreturnToLobby()メソッドを呼び出します。

componentDidMount()へのイベントリスナーの追加

constructorメソッドの下に、新規メソッドのcomponentDidMount()を追加します。この特殊なReactライフサイクルメソッドは、コンポーネントが最初に一回だけマウントされる場合のみ呼び出されます。このため、ネットワークリクエストをするときやイベントリスナーを追加するときに最適で、今回の目的にうってつけです。

componentDidMount() {
  // Add event listeners for future remote participants coming or going
  this.props.room.on('participantConnected', participant => this.addParticipant(participant));
  this.props.room.on('participantDisconnected', participant => this.removeParticipant(participant));
  
  window.addEventListener("beforeunload", this.leaveRoom);
}

新たなリモート参加者が入室や退室をするたびに接続イベントが発行されます。このイベントをこのコンポーネントで検知します。

新しい参加者が接続すると、イベントリスナーを介して参加者にアクセスができます。参加者をremoteParticipantsステートに追加するため、この後使用するaddParticipant()メソッドを使用します。

同じように、退室するときは、参加者へのアクセスを取得し、remoteParticipantsステートから削除します。

また、componentDidMount()によりウィンドウそのものにイベントリスナーが追加されます。ユーザー(ローカルの参加者)がブラウザウィンドウを閉じると、ウィンドウを再読み込みする前に参加者がルームから削除されます。

コンポーネントがUnmountされたときも切断が行われるようにします。以下のメソッドをコンポーネントcomponentDidMount()の後に追加してください。

 

componentWillUnmount() {
  this.leaveRoom();
}

addParticipant/removeParticipantメソッド

componentWillUnmount()メソッドの下に、以下の2つのメソッドを追加してください。

  addParticipant(participant) {
    console.log(`${participant.identity} has joined the room.`);
    
    this.setState({
      remoteParticipants: [...this.state.remoteParticipants, participant]
    });
  }

  removeParticipant(participant) {
    console.log(`${participant.identity} has left the room`);

    this.setState({
      remoteParticipants: this.state.remoteParticipants.filter(p => p.identity !== participant.identity)
    });
  }

これらのメソッドにより、リモート参加者の接続と切断のたびにコンポーネントのremoteParticipantステートを更新します。ステートの変化によりコンポーネントのrerenderがトリガーされ、新しい参加者が画面に表示されるか、画面から削除されます。

leaveRoom()メソッド

RoomコンポーネントのleaveRoom()メソッドが呼び出されると、まずローカル参加者がルームから切断されます。これによりイベントが発行され、アプリの他のすべての実行中インスタンスで受信されます。

つまり、あなたが他のデバイスを使用する友人とチャットをしているときにLeave Roomボタンを押すと、全員に再レンダリングが実行され、友人たちはあなたを見られなくなります。

この後、AppコンポーネントのreturnToLobby()メソッドが呼び出され、roomステートがnullに戻ります。ローカルユーザーに再レンダリングが実行され、ビデオルームからロビー画面の表示に戻ります。

leaveRoom()メソッドをRoomコンポーネントに追加します。Roomクラスの閉じ括弧の直前に以下のコードをペーストしてください。

leaveRoom() {
  this.props.room.disconnect();
  this.props.returnToLobby();
}

render()メソッド

RoomクラスのleaveRoom()メソッドの下に、以下のrender()メソッドのコードをペーストしてください。

render() {
  return (
    <div className="room">
      <div className = "participants">
        <Participant key={this.props.room.localParticipant.identity} localParticipant="true" participant={this.props.room.localParticipant}/>
        {
          this.state.remoteParticipants.map(participant => 
            <Participant key={participant.identity} participant={participant}/>
          )
        }
      </div>
      <button id="leaveRoom" onClick={this.leaveRoom}>Leave Room</button>
    </div>
  );
}

このメソッドは最初にローカル参加者を表示するため、ユーザーを常にビデオルームの最初の参加者として表示し、remoteParticipants配列をマップし各リモート参加者を表示します。

render()メソッドはParticipantコンポーネントを参照しています。このため、以下のようにコンポーネントをRoom.jsファイルの先頭にインポートしてください。

import React, {Component} from 'react';
import './App.scss';
import Participant from './Participant';

Participantコンポーネントの作成

Participantコンポーネントは、各参加者から公開されたトラックをレンダリングし、新しいトラックのサブスクリプションを検知します。

srcフォルダーのファイルParticipant.jsを開いてください。

前述のコンポーネントと同じく、ファイル先頭にインポートと関連クラスのシェル(ここではParticipant)が見られます。

ここでも、Participantクラスを作成するためにconstructorメソッドから始めます。

constructor()メソッド

constructorメソッドを作成します。以下のコードをParticipantクラスに追加してください。

constructor(props) {
  super(props);

  const existingPublications = Array.from(this.props.participant.tracks.values());
  const existingTracks = existingPublications.map(publication => publication.track);
  const nonNullTracks = existingTracks.filter(track => track !== null)

  this.state = {
    tracks: nonNullTracks
  }
}

これによりParticipantコンポーネントが初期化され、その参加者が所有する公開済みトラックによりステートが設定されます。

ローカル参加者の場合、これは入室したときに自動で公開されるオーディオ/ビデオトラックになります。ローカル参加者が入室したときに入室済みのリモート参加者にも、オーディオ/ビデオトラックが設定されます。

ただし、他の参加者が新しい参加者のトラックにアクセスするには、そのトラックへのサブスクリプションが必要になります。サブスクリプションは自動で行われ、その際イベントが発行されます。このため、リモート参加者を表すすべてのコンポーネントで検知する必要があります。

componentDidMount()へのイベントリスナーの追加

イベントリスナーを追加するため、以下のcomponentDidMount()メソッドをconstructorメソッドの下に作成してください。

componentDidMount() {
  if (!this.props.localParticipant) {
    this.props.participant.on('trackSubscribed', track => this.addTrack(track));
  }
}

このコードは、現在のコンポーネントがローカル参加者用でなければ、トラックのサブスクリプションのイベントリスナーを追加する必要があることを表しています。このユーザーのトラックにサブスクリプションをすることにより、トラックがアクセス可能になり、コンポーネントのtrackステートに追加されます。これにより再レンダリングがトリガーされ、アプリがトラックをレンダリングできるようになります。

コンポーネントのステートへのトラックの追加

componentDidMount()メソッドの下に、ステートを更新する以下のaddTrack()メソッドを追加してください。

addTrack(track) {
  this.setState({
    tracks: [...this.state.tracks, track]
  });
}

render()メソッド

以下のrender()メソッドをaddTrack()メソッドの下に追加してください。

render() {
  return ( 
    <div className="participant" id={this.props.participant.identity}>
      <div className="identity">{ this.props.participant.identity}</div>
      { 
        this.state.tracks.map(track => 
          <Track key={track} filter={this.state.filter} track={track}/>)
      }
    </div>
  );
}

Participantメソッドを完成させるために、render()メソッドに含まれるTrackコンポーネントをインポートする必要があります。Participant.jsファイルの先頭に以下のコードを追加してください。

import React, {Component} from 'react';
import './App.scss';
import Track from './Track';

Trackコンポーネントの作成

いよいよ最後のステップです。このTrackコンポーネントは、すべての参加者のトラックをDOMに接続する役割をします。Trackコンポーネントはtrackオブジェクトをpropsとして受け取ります。

srcフォルダー配下のTrack.jsを開いてください。

ファイルにはいくつかのインポートとTrackクラスのシェルが含まれています。

まず、constructorメソッドを追加します。Trackクラスに以下のコードを追加してください。

constructor(props) {
  super(props)
  this.ref = React.createRef();
}

Appコンポーネントのときと同じように、TrackコンポーネントのRefも作成します。これは、このコンポーネントが作成するDOM要素にアクセスしてトラックを接続するために使用します。

constructorメソッドの下に、以下のcomponentDidMount()メソッドを追加してください。

componentDidMount() {
  if (this.props.track !== null) {
    const child = this.props.track.attach();
    this.ref.current.classList.add(this.props.track.kind);
    this.ref.current.appendChild(child)
  }
}

このメソッドは、propsに含まれるtrackの値がnullでないことをチェックし、nullでなければRefをもとにtrackオブジェクトに関連するオーディオ/ビデオ要素をDOMに接続します。

render()メソッド

最後にrender()メソッドを追加します。componentDidMount()メソッドの下に、以下のようにTrackコンポーネントのrender()メソッドを追加してください。

render() {
  return (
    <div className="track" ref={this.ref}>
    </div> 
  )
}

お疲れ様です。アプリケーションの開発が完了しました。

テスト

すべてのファイルを保存し、閉じてください。コマンドプロンプトにて、プロジェクトのルートフォルダであるtwilio-video-starter-kitに移動してください。

次に進む前に、アクセストークンを生成するバックエンドコードの実行も確認してください。

次のコマンドを実行してローカルのReactサーバーを起動します。以下のコマンドを実行してください。

npm start

バックエンドコードがPORT=3000でローカル実行されていれば、上記のコマンドにより別のポートでReactプロジェクトを実行するように指示されます。yキーを押し、returnで実行してください。

アプリケーションが実行されたら、ブラウザでlocalhost:3000(あるいは別の使用中のポート)にアクセスしてください。

アプリのロビー画面が表示されます。

Joining room

名前を入力すると、Join Roomボタンが有効になります。

Enter name

Join Roomをクリックしてビデオルームに入室してください。カメラにあなたが表示されているはずです。

ブラウザの新しいタブを開き、再びlocalhost:3000(あるいはその他のポート)にアクセスしてください。今度は別の名前を入力してJoin Roomボタンをクリックしてください。

Video showing on screen

どちらかのタブでLeave Roomボタンをクリックするとロビー画面に戻ります。タブを切り替えて、参加者が退室したことを確認します。

まとめ

この記事では、Twilio Programmable Videoを使用したReactの基本的なビデオチャットアプリの作成方法を学びました。Reactの機能やProgrammable Videoを利用したアプリケーションの作成方法をご理解いただけたかと思います。

また、ビデオアプリに見栄えのするバーチャルミラーも追加できます。どんなアプリを作っているか、Twitterでお知らせください!

Ashleyは、TwilioブログのJavaScriptエディターです。Ashleyと協力し、Twilioにテクニカルストーリーを紹介するには、Twitter[@ahl389](https://twitter.com/ahl389)までご連絡ください。TwitterでAshleyが見つからない場合は、どこかのパティオでコーヒーを飲んでいることでしょう(ワインの時間かも)。