TwilioFlexにビデオ通話(Twilio Video)機能を備えてみた

# TwilioFlexにビデオ通話(Twilio Video)機能を備えてみた

TwilioFlexにビデオ通話を下のイメージのように、備えたためそのレポートです。
![スクリーンショット 2021-12-02 20.59.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/269756/b901ad34-2e8b-e067-7ce3-68f1872fdff8.png)

TwilioFunctionsを利用して、バックエンドを作成しました。
主な役割としては、トークンの発行とビデオ通話確認ようの画面を作成しました。

TwilioFlexのプラグインを用いて図のような画面を作成して
ビデオ通話ができるようにしました。

作り込みの甘い部分がありますが、とりあえず使えるかと思います。
備忘録的なところがあるため、雑ですみません。

指摘等や疑問があればコメントにて対応したいと思います。

## TwilioFunctionsを利用して、トークンの作成
初めにTwilioFunctionsを利用して、ビデオ通話用のトークンを作成した。
TwilioFunctionsの使い方については他の記事を参考にしてほしい。

フォルダ構成は下記の感じ
![スクリーンショット 2021-12-02 21.08.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/269756/b043b62b-8c0c-55e1-0099-7ac59ece3210.png)

下記がバックグラウンド処理となる。

```javascript:functions/create.js
const AccessToken = require('twilio').jwt.AccessToken;
const VideoGrant = AccessToken.VideoGrant;

exports.handler = function(context, event, callback) {

  // Create a custom Twilio Response
  const response = new Twilio.Response();
  // Set the CORS headers to allow Flex to make an error-free HTTP request
  // to this Function
  response.appendHeader('Access-Control-Allow-Origin', '*');
  response.appendHeader('Access-Control-Allow-Methods', 'OPTIONS, POST, GET');
  response.appendHeader('Access-Control-Allow-Headers', 'Content-Type');

  const identity = event.username ? event.username : "noname";

  const accessToken = new AccessToken(context.ACCOUNT_SID,context.API_KEY, context.API_SECRET);
  accessToken.identity = identity;

  // Grant access to Video
  let grant = new VideoGrant();
  grant.room = event.roomname ? event.roomname : 'default' ;
  accessToken.addGrant(grant);

  const data = {identity: identity,token: accessToken.toJwt(), };

  response.appendHeader('Content-Type', 'application/json');
  response.setBody(data);

  return callback( null, response );
};
```

フロントの処理は下記となる。
URL「*****************」は読み替えてほしい。

```javascript:assets/video.js
(() => {
    'use strict';

    let username = 'gest';
    let roomname = 'unoh99';
    const Video = Twilio.Video;
    let videoRoom, localStream;

    const urlParam = location.search.substring(1);

    if(urlParam) {
        const param = urlParam.split('&');
        const paramArray = [];
       
        // 用意した配列にパラメータを格納
        for (let i = 0; i < param.length; i++) {
          const paramItem = param[i].split('=');
          paramArray[paramItem[0]] = paramItem[1];
        }
       
        if (paramArray.username) {
            username = paramArray.username;
        }
        if(paramArray.roomname){
            roomname = paramArray.roomname;
        }
      }



    // プレビュー画面の表示
    navigator.mediaDevices.getUserMedia({video: true, audio: true})
    .then(stream => {
        //document.getElementById("myStream").srcObject = stream;
        localStream = stream;
    });    
    
    // ボタンの準備
    const btnJoinRoom = document.getElementById("button-join");
    const btnLeaveRoom = document.getElementById("button-leave");

    btnJoinRoom.onclick = (() => {
        // アクセストークンを取得
        axios.get(`https://*****************.twil.io/create?username=${username}&roomname=${roomname}`)
        .then(async body => {
            const token = body.data.token;
            console.log(token);

            Video.connect(token, { name: roomname })
            .then(room => {
                console.log(`Connected to Room ${room.name}`);
                videoRoom = room;

                room.participants.forEach(participantConnected);
                room.on('participantConnected', participantConnected);

                room.on('participantDisconnected', participantDisconnected);
                room.once('disconnected', error => room.participants.forEach(participantDisconnected));
            
                btnJoinRoom.disabled = true;
                btnLeaveRoom.disabled = false;
            }).catch( (error) => { console.error(error); } );
        }).catch( (error) => { console.error(error); } );
    });

    btnLeaveRoom.onclick = (() => {
        videoRoom.disconnect();
        console.log(`Disconnected to Room ${videoRoom.name}`);
        btnJoinRoom.disabled = false;
        btnLeaveRoom.disabled = true;
    });
})();

const participantConnected = (participant) => {
    console.log(`Participant ${participant.identity} connected'`);

    const div = document.createElement('div');
    div.id = participant.sid;
    div.classList.add('card');
    div.classList.add('col');
    div.innerHTML = '<div class="card-header">'+ participant.identity + '</div>';

    participant.on('trackSubscribed', track => trackSubscribed(div, track));
    participant.on('trackUnsubscribed', trackUnsubscribed);
  
    participant.tracks.forEach(publication => {
      if (publication.isSubscribed) {
        trackSubscribed(div, publication.track);
      }
    });
  
    document.getElementById("Stream").appendChild(div);
}

const participantDisconnected = (participant) => {
    console.log(`Participant ${participant.identity} disconnected.`);
    document.getElementById(participant.sid).remove();
}

const trackSubscribed = (div, track) => {
    div.appendChild(track.attach());
}

const trackUnsubscribed = (track) => {
    track.detach().forEach(element => element.remove());
}
```

下記はFlex以外からビデオを確認するためのフロント画面処理

```HAML:assets/index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <!-- Bootstrap CSS -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-giJF6kkoqNQ00vy+HMDP7azOuL0xtbfIcaT9wjKHr8RbDVddVHyTfAAsrekwKmP1" crossorigin="anonymous">
    <title>Twilio-ビデオ通話</title>
  </head>
  <body>
    <nav class="navbar navbar-dark bg-dark">
      <div class="container-md">
        <h1 class="navbar-brand" >Twilio ビデオ通話</h1>
      </div>
    </nav>

    <main class="container">
      <div id="room-controls" class="row">
        <div class="col">
          <button class="btn btn-primary w-100" id="button-join">ビデオ通話の開始</button>
        </div>
        <div class="col">
          <button class="btn btn-danger w-100" id="button-leave" disabled>ビデオ通話の終了</button>
        </div>
      </div>

      <div class="container">
        <div class="row" id="Stream">
        </div>
      </div>


    <!-- <video id="myStream" autoplay muted="true"></video> -->
    </main>
    
    
  </body>
  <!-- Option 1: Bootstrap Bundle with Popper -->
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/js/bootstrap.bundle.min.js" integrity="sha384-ygbV9kiqUc6oa4msXn9868pTtWMgiQaeYH7/t7LECLbyPA2x65Kgf80OJFdroafW" crossorigin="anonymous"></script>
  <!-- <script src="//media.twiliocdn.com/sdk/js/video/releases/2.3.0/twilio-video.min.js"></script> -->
  <!-- <script src="//media.twiliocdn.com/sdk/js/video/v1/twilio-video.min.js"></script> -->
  <script src="//sdk.twilio.com/js/video/releases/2.18.1/twilio-video.min.js"></script>
  <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
  <script src="./video.js?version=1.00"></script>
</html>

```

環境設定は下記のように行う。
![スクリーンショット 2021-12-02 21.14.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/269756/3d0a76af-93af-aa12-6949-badf12a48b60.png)


## TwilioFlexのプラグイン作成
TwilioFlexのプラグインの使い方については他の記事を参考にしてほしい。

今回のソースはReactのバーションアップをした為、下記手順が必須となります。
https://www.twilio.com/docs/flex/developer/plugins/react-versions

画面整形のため、material-uiも利用しています。


フォルダ構成は下記の感じ
![スクリーンショット 2021-12-02 21.19.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/269756/4712e13a-ff57-962c-4bc7-e234990a1658.png)

今回の変更と作成ファイルは以下のものとなります。
「VideoViewPlugin」として作成しました。

作成ファイル
- VideoSideNav.js
- VideoView.js

変更ファイル
- VideoViewPlugin.js


下記のファイルはサイドのパーようのソースです。

```javascript:src/components/VideoSideNav.js
import React from "react";
import { SideLink , Actions } from "@twilio/flex-ui";

const VideoSideNav = ({ activeView }) => {
  function navigate() {
    Actions.invokeAction( 'NavigateToView' , {viewName : 'video-view'} );
  }

  return (
    <SideLink 
      showLabel = { true }
      icon="Video"
      iconActive="VideoBold"
      isActive={activeView == 'video-view'}
      onClick={navigate}
    >
      Video View
    </SideLink>
  );
}

export default VideoSideNav;
```

下記のファイルはメイン画面のソースです。
URL「*****************」は読み替えてほしい。

```javascript:src/components/VideoSideNav.js
import React, { useState ,useEffect} from 'react';

import axios from 'axios'
import Twiliovideo from "twilio-video"

import {Container , Paper, Typography, Button , Grid ,Card , CardHeader , CardContent} from '@material-ui/core/';

import {Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions} from '@material-ui/core/';
import {Menu , MenuItem} from '@material-ui/core/';
import { makeStyles } from '@material-ui/core/styles';

const useStyles = makeStyles((theme) => ({
  icon: {
    marginRight: theme.spacing(2),
  },
  heroContent: {
    backgroundColor: theme.palette.background.paper,
    padding: theme.spacing(8, 0, 6),
  },
  heroButtons: {
    marginTop: theme.spacing(4),
  },
  cardGrid: {
    paddingTop: theme.spacing(8),
    paddingBottom: theme.spacing(8),
  },
  card: {
    height: '100%',
    display: 'flex',
    flexDirection: 'column',
  },
  cardMedia: {
    paddingTop: '56.25%', // 16:9
  },
  cardContent: {
    flexGrow: 1,
  },
  footer: {
    backgroundColor: theme.palette.background.paper,
    padding: theme.spacing(6),
  },
}));


const VideoView = ({flex,manager}) => {
  const classes = useStyles();

  const [roominfo, setRoominfo] = useState(false);
  const [username, setUsername] = useState(manager.workerClient.attributes.full_name);
  const [roomname, setRoomName] = useState(manager.user.identity);
  const [token, setToken] = useState(null);
  const [videoroom, setVideoroom] = useState(null);

  const [anchorEl, setAnchorEl] = React.useState(null);

  const handleMenuClick = (event) => {
    setAnchorEl(event.currentTarget);
  };

  const handleMenuClose = () => {
    setAnchorEl(null);
  };

  const handleClickOpen = () => {
    setRoominfo(true);
  };

  const handleClose = () => {
    setRoominfo(false);
  };

  console.log("デバックポイント");
  //console.log(flex);
  console.log(manager);

  const trackSubscribed = (div, track) => {
    const child = div.appendChild(track.attach());
    if (track.kind === "video") {
        child.classList.add("video-style");
    }
  }

  const trackUnsubscribed = (track) => {
    track.detach().forEach(element => element.remove());
  }

  const participantConnected = (participant) => {
    console.log(`connect!`);
    console.log(`Participant ${participant.identity} connected'`);

    // 参加者を表示する
    const div = document.createElement("div");
    div.id = participant.sid;
    div.classList.add("remote-video");

    // 参加者のトラックが届いたとき
    participant.on('trackSubscribed', (track) => trackSubscribed(div, track));

    // 参加者の画像を表示
    const videoZone = document.getElementById('video-zone');
    if(videoZone){
      videoZone.appendChild(div);
    }
  }

  const participantDisconnected = (participant) => {
    console.log(`Participant ${participant.identity} disconnected.`);
    if(document.getElementById(participant.sid)){
      document.getElementById(participant.sid).remove();
    }
  }

  const videoStart = () => {

    axios.get('https:/********************.twil.io/create?username='+username+'&roomname=' + roomname).then(function (res) {
      console.log( res.data.token );
      setToken(res.data.token);
      try {
        
        Twiliovideo.connect(res.data.token, {
          name: roomname,
        }).then((room) => {
          console.log(`Connected to Room ${room.name}`);

          setVideoroom(room);

          // すでに入室している参加者を表示
          room.participants.forEach(participantConnected);

          // 誰かが入室してきたときの処理
          room.on("participantConnected", participantConnected);

          room.on('participantDisconnected', participantDisconnected);
          room.once('disconnected', error => room.participants.forEach(participantDisconnected));

          
        })
        setAnchorEl(null);
      } catch (error) {
          console.log(error);
      }
    });

  }

  const vidoeEnd = () => {
      videoroom.disconnect();
      console.log(`Disconnected to Room ${videoroom.name}`);
      setAnchorEl(null);
  }

  return (
    <React.Fragment>
      <Container component="main" maxWidth="xl">
        <Paper elevation={0} >
          <Grid container justifyContent="space-between" >
            <Grid item xs={9}>
              <Typography component="h5" variant="h5" color="textPrimary" gutterBottom>
                ビデオ通話コンソール
              </Typography>
            </Grid>
            <Grid item xs={1}>
              <Button aria-controls="simple-menu" aria-haspopup="true" onClick={handleMenuClick}>
                Menu
              </Button>
              <Menu
                id="simple-menu"
                anchorEl={anchorEl}
                keepMounted
                open={Boolean(anchorEl)}
                onClose={handleMenuClose}
              >
                <MenuItem onClick={videoStart}>通話開始</MenuItem>
                <MenuItem onClick={vidoeEnd}>通話終了</MenuItem>
                <MenuItem onClick={handleClickOpen}>ご案内URL</MenuItem>
              </Menu>
            </Grid>
          </Grid>
          
          <Grid container spacing={1} justifyContent="center">
            <Grid item id="video-zone">
            </Grid>
          </Grid>
        </Paper>
      </Container>
      <Dialog
        open={roominfo}
        onClose={handleClose}
        aria-labelledby="alert-dialog-title"
        aria-describedby="alert-dialog-description"
      >
        <DialogTitle id="alert-dialog-title">お客様ご案内</DialogTitle>
        <DialogContent>
          <DialogContentText id="alert-dialog-description">
            {'https://*********.twil.io/index.html?username=gest&roomname=' + roomname}
          </DialogContentText>
        </DialogContent>
        <DialogActions>
          <Button onClick={handleClose} color="primary" autoFocus>
            閉じる
          </Button>
        </DialogActions>
      </Dialog>
    </React.Fragment>

  )
}

export default VideoView

```

下記はプラグインのメインファイルで、もともとあるファイルを書き換えました。

```javascript:src/VideoViewPlugin.js
import React from 'react';
import { VERSION , View } from '@twilio/flex-ui';
import { FlexPlugin } from 'flex-plugin';

import VideoSideNav from './components/VideoSideNav';
import VideoView from './components/VideoView'

const PLUGIN_NAME = 'VideoViewPlugin';

export default class VideoViewPlugin extends FlexPlugin {
  constructor() {
    super(PLUGIN_NAME);
  }

  /**
   * This code is run when your plugin is being started
   * Use this to modify any UI components or attach to the actions framework
   *
   * @param flex { typeof import('@twilio/flex-ui') }
   * @param manager { import('@twilio/flex-ui').Manager }
   */
  async init(flex, manager) {

    flex.SideNav.Content.add(
      <VideoSideNav key="video-side-nav"></VideoSideNav>
    );

    flex.ViewCollection.Content.add(
      <View name='video-view' key='video-view'>
        <VideoView flex={flex}　manager={manager}></VideoView>
      </View>
    )

  }
}
```