【テレワーク推進!!】OpenTokを使ってビデオチャットを作ってみた

社内ブログ

こんにちは。ばしおです。

まだまだコロナは落ち着きませんね。
昨今のコロナ情勢によりオンラインで打ち合わせをする企業も増えたのはないでしょうか?
企業によってはセキュリティ云々でZoomやWherebyが使えないとかなんとか。

導入できない?じゃ、作るしか無いよね?

というわけで今回は
OpenTokというサービスを使って、Wherebyみたいなサービスを作りたいと思います。

OpenTokとは?

www.vonagebusiness.jp

VONAGE(旧Tokbox)社が提供しているWebRTCプラットフォームです。 WebRTCの難しいところをサクッと実装できてしまいます。 また、録画(アーカイブ)することもでき、クラウドストレージに保存も可能です。 他にもリアルタイムチャットやブロードキャスト配信も出来るようです。

導入

今回、サーバーサイドではOpenTokが提供しているPHPSDKを使用していきたいと思います。

フレームワークSlimを使います。(Laravel重たすぎ...)
Slimは1ファイルで完結してしまうくらいシンプルなフレームワークです。

今回は、一般的なMVCフレームワークに近くなるよう少しカスタマイズしたものを作成したので、これをひな形として使用していきます。

https://github.com/bashi4/slim-template.git

OpenTokのAPIKey, SecretKey作成

OpenTokに登録していきましょう。
2ページ目で業種を入力するページがありますが、何も入力せずNextでスキップしても大丈夫です。
Eメールの認証まで完了すれば登録は完了です。

f:id:bashioo:20200916092348p:plain

ログインをすると、AdminNameを求められます。
AdminNameのグループの中に、いくつもプロジェクトを作っていく感じになるので、サービス名などをつけるのがよいでしょう。 今回は「TechBlog」としました。

f:id:bashioo:20200916091427p:plain

次にプロジェクトを作成します。
このプロジェクトがAPIKeyの単位となります。こちらは開発やテストなど環境ごとに分けると良いかと思います。今回は「local_video_chat」としました。

f:id:bashioo:20200916091455p:plain

完了するとAPI KeyとSecret Keyが発行されます。忘れずにメモしておきましょう。

f:id:bashioo:20200916091645p:plain

アプリケーションの実装

雛形をクローンしてきて、トップページを見れる状態にしてください。

ReadMe通りにうまく行けば、Hello画面が表示されると思います。

https://github.com/bashi4/slim-template#how-to-use

トークルームの作成

トークルーム作成の実装をしていきましょう。

流れとしては、下記になります

  1. トップページの「トークルームを作る」ボタンを押下
  2. indexメソッドのpostアクションでルームに対して一意となるセッションIDを作る
  3. セッションIDを画面に表示する

ではやっていきましょう。

まずは、トップページに「トークルームを作る」ボタンを作ります。

views/index.blade.php
- <h1>Hello</h1>

+ @if($url)
+ <a href="{{ $url }}">{{ $url }}</a>
+ @endif
+ <form action="/" method="post">
+     <button type="submit" class="btn btn-primary">トークルームを作る</button>
+ </form>

get/postでアクションでControllerのindexメソッドを実行するようにrootファイルも修正します。

config/route.php
- $app->get('/', 'App\Controller\Controller:index');

+ $app->map(['get', 'post'], '/', 'App\Controller\Controller:index');

次に、OpenTokServiceクラスを作ります。

srcディレクトリ配下にServiceディレクトリを作成し、下記の内容でOpenTokService.phpを作成してください。

src/Service/OpenTokService.php
<?php
namespace App\Service;

use OpenTok\OpenTok;
use OpenTok\MediaMode;
use OpenTok\ArchiveMode;
use OpenTok\Role;

class OpenTokService
{
    /** @var OpenTok */
    protected $opentok;
    
     /** @var string */
    protected $key;

    public function __construct()
    {
        $this->key = env('OPENTOK_KEY');
        $this->opentok = new OpenTok($this->key, env('OPENTOK_SECRET'));
    }

    /**
     * セッションIDを返却
     * 
     * @return string
     */
    public function createSession()
    {
        return $this->opentok->createSession([
            // 'archiveMode' => ArchiveMode::ALWAYS, // 常に録画
            'mediaMode'   => MediaMode::ROUTED // Routedモード(アーカイブのときはRoutedモード必須)
        ]);
    }
}

作成したOpenTokServiceクラスをコンテナ経由で呼べるように ServiceProvider修正します。

src/ServiceProvider.php
public function register(\Pimple\Container $container)
{          
+     $container['videoChatService'] = new \App\Service\OpenTokService();
    return $container;
}

最後に、Controllerのindexメソッドを修正します

src/Controller/Controller.php
public function index(Request $request, Response $response)
{
     
-     return $this->app->view->render($response, 'index');

+     $url = null;
+     if ($request->isPost()) {
+         $sessionId = $this->app->videoChatService->createSession();
+         $url =  $request->getUri()->getBaseUrl() . "/rooms/{$sessionId}";
+     }
+     return $this->app->view->render($response, 'index', [
+         'url' => $url,
+     ]);

}

実行してみましょう。

f:id:bashioo:20200916093012p:plain

f:id:bashioo:20200916093026p:plain

セッションIDが作成されURLが表示できました!

ビデオチャット実装する

トークルームのURLの生成まではできました。

次はURLからトークルームに遷移し、ビデオチャットが開始される部分を作成します。

  1. /rooms/:sessionIdでroomsメソッドを実行し、tokenを発行する
  2. tokenをjavascriptのパラメータとして渡し、ビデオチャットを開始する

では実装していきましょう。

rootにアクションを追加します。

config/route.php
+ $app->get('/rooms/:sessionId', 'App\Controller\Controller:room');

OpenTokServiceクラスに下記を追加します。

src/Service/OpenTokService.php
public function getToken($sessionId)
{
    return $this->opentok->generateToken($sessionId, [
        'role' => Role::MODERATOR, // ロールをモデレータに設定
        'expireTime' => time()+(60 * 60), // 有効期限を1時間に設定
    ]);
}

public function getKey()
{
    return $this->key;
}

Controllerにroomメソッドを追加します

src/Controller/Controller.php
public function room(Request $request, Response $response, $params)
{
    $sessionId = $params['sessionId'];

    $token = $this->app->videoChatService->getToken($sessionId);

    $key = $this->app->videoChatService->getKey();

    $jsonData = [
        'sessionId' => $sessionId,
        'token' => $token,
        'key' => $key,
    ];

    return $this->app->view->render($response, 'room', [
        'params' => json_encode($jsonData, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT),
    ]);
}

viewsにroom.blade.phpを作成し下記をコピペしてください。

views/room.blade.php
<!DOCTYPE html>
<html lang="en">
<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">
    <title>Document</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
        integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
    <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js"
        integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"
        integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"
        integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
</head>
<body>
    <div class="container-fluid my-4">
        <div class="row">
            <div class="col-6" style="height: 80vh;">
                <div id="subscriber"></div>
            </div>
            <div class="col-6" style="height: 80vh;">
                 <div id="publisher"></div>
            </div>
        </div>
    </div>

    <!-- ▼ ここから ▼ -->
    <script src="https://static.opentok.com/v2/js/opentok.min.js"></script>
    <script>
        let params = {!! $params !!};

        initializeSession();

        function handleError(error) {
            if (error) alert(error.message)
        }

        function initializeSession() {
            const session = OT.initSession(params.key, params.sessionId);

            const publisher = OT.initPublisher("publisher", {
                    targetElement: "publisher",
                    width: "100%",
                    height: "100%",
                }, handleError);

            session.on("streamCreated", function(event) {
                session.subscribe(event.stream, "subscriber", {
                    targetElement: "subscriber",
                    width: "100%",
                    height: "100%",
                }, handleError);
            });

            session.on("sessionDisconnected", function sessionDisconnected(event) {
                console.error("You were disconnected from the session.", event.reason);
            });

            session.connect(params.token, function(error) {
                error ? handleError(error) : session.publish(publisher, handleError);
            });
        }
    </script>
    
</body>
</html>

これで完了です!

先程、生成したURLをクリックしてみましょう

f:id:bashioo:20200916100041p:plain

ブラウザで別タブを開き、さらに生成したURLにアクセスしてみましょう

f:id:bashioo:20200916100107p:plain

1対1のビデオチャットができました!

リアルタイムチャット

リアルタイムチャットも簡単に実装できます。 下記の内容で room.blade.phpを上書き、画面をリロードしてみてください。

views/room.blade.php
<!DOCTYPE html>
<html lang="en">
<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">
    <title>Document</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
        integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
    <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js"
        integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"
        integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"
        integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>


    <style>
        .message-container {
            width: 100%;
            height: 100vh;
            position: relative;
        }
        .message-form {
            width: 100%;
            position: absolute;
            bottom: 15px;
        }
        .message-list {
            height: 90vh;
            overflow-y: scroll;
        }
        .message-list-item {
            padding: .5rem .75rem;
            border: 1px solid #ececec;
            border-radius: .25rem;
        }
        .message-list-item.mine {
            background-color: #17a2b8;
            color: #fff;
            margin-left: 4rem;
        }
        .message-list-item.theirs {
            background-color: #f8f9fa;
            margin-right: 4rem;
        }
    </style>
</head>
<body class="bg-light">
    <div class="container-fluid">
        <div class="row">
            <div class="col-9 d-flex">
                <div id="publisher"></div>
                <div id="subscriber"></div>
            </div>
            <div class="col-3 bg-white">
                <div class="message-container">
                    <div class="message-list"></div>
                    <form class="message-form">
                        <div class="input-group">
                            <input type="text" class="form-control">
                            <div class="input-group-append">
                                <button class="btn btn-primary" type="submit">送信</button>
                            </div>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>

    <script src="https://static.opentok.com/v2/js/opentok.min.js"></script>
    <script>
        let params = {!! $params !!};

        initializeSession();

        function handleError(error) {
            if (error) alert(error.message)
        }

        function initializeSession() {
            const session = OT.initSession(params.key, params.sessionId);

            // オプションは下記参考
            // https://tokbox.com/developer/sdks/js/reference/OT.html#initPublisher
            const publisher = OT.initPublisher("publisher", {
                    insertMode: "append",
                    width: "360px",
                    height: "240px",
                }, handleError);

            session.on("streamCreated", function(event) {
                session.subscribe(event.stream, "subscriber", {
                    insertMode: "append",
                    width: "360px",
                    height: "240px",
                }, handleError);
            });

            session.on("sessionDisconnected", function sessionDisconnected(event) {
                console.error("You were disconnected from the session.", event.reason);
            });


            session.connect(params.token, function(error) {
                error ? handleError(error) : session.publish(publisher, handleError);
            });

            // ▼ チャット用に追加した部分 ▼ 
            const form = document.querySelector('.message-form');
            const formTxt = document.querySelector('.message-form input');

            form.addEventListener('submit', function(event) {
                event.preventDefault();
                session.signal({type: 'msg', data: formTxt.value}, function(error) {});
            });

            session.on('signal:msg', function(event) {
                var msg = document.createElement('p');
                msg.innerText = event.data;
                msg.className = event.from.connectionId === session.connection.connectionId
                    ? 'message-list-item mine' : 'message-list-item theirs';
                const messagelist = document.querySelector('.message-list');
                messagelist.appendChild(msg);
                msg.scrollIntoView();
            });
            // ▲ チャット用に追加した部分 ▲ 
        }
    </script>
</body>
</html>

まとめ

いかがだったでしょうか? Opentokを使って、ビデオチャットが簡単に実装できました。
ビデオチャットを使ったサービスの開発に対し、かなり技術的ハードルが下がったんじゃないでしょうか?

最後に、今回動作確認で使ったソースコードを置いときます。 github.com