Laravel + React でビデオ通話機能をつくる

こんにちは❗フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、最近は「リモートワーク」という名前もよく耳にするようになり、世の中の変化を感じずにはいられない今日この頃です。

そして、このリモートワークを実現するために必要だったのが、

ビデオ通話

といってもいいんじゃないでしょうか。

さすがに、音声のみの会話ではあまり知らない人とコミュニケーションがしにくいですし、表情を見て話したほうがより話が伝わりやすいですよね。

そう考えると、テクノロジーの進化によって私達の生活が変化していることを実感できる身近な例かもしれません。

そこで❗
今回はこのビデオ通話機能を「Laravel + React」で実装してみたいと思います。

ぜひ何かの参考になりましたら嬉しいです。

「記事の中で
好きなポテトチップス
を紹介しています」

開発環境: Laravel 9.x、React、Vite、Intertia.js、TailwindCSS

前提として

ログイン機能がインストールされていて、ユーザーが以下のように2人以上登録されていることが前提です。

もしログイン機能をインストールしていない方は、以下を参考にして準備しておいてください。

📝【Laravel】Vite + Inertia + React でログイン機能をインストールする

また、今回はウェブカメラを利用しますので、HTTPSでの接続が必要になります。HTTPSViteを使う方法は、「ちなみに1:Vite を HTTPS 対応にするには」を参考にしてください。

Pusher Channels にプロジェクトをつくる

では、まずはコードを書いていく前にPusherで専用プロジェクトをつくり、アクセストークンなどを取得します。

Pusherへユーザー登録&ログインし、Channelsの中にある「Manage」ボタンをクリックします。

ページ移動すると「Create app」ボタンがあるのでこれをクリック。

すると、プロジェクト(Channels app)の詳細入力フォームが表示されるので、以下を参考にして適宜入力&「Create app」ボタンをクリックしてください。

  • Name your app: プロジェクト名
  • Select a cluster: ap3(東京)
  • Choose your tech stack:(任意。選択するとサンプルコードが表示されます)

※ なお、サンプルコードは以下のようになります。

これでプロジェクトが作成されたので、次は「App Settings」メニューのリンクをクリックします。

ページ中程にある「Enable client events」にチェックを入れます。

では、最後にAPIにアクセスするためのキー(トークン)を取得します。
同じくメニューから「App Keys」リンクをクリックしてください。

すると、以下のように各種キーが表示されます。

では、取得した各種キーを.envへ追加しておきましょう。(おそらくすでにPUSHER_で始まる項目は存在しています)

.env

PUSHER_APP_ID=******
PUSHER_APP_KEY=******************
PUSHER_APP_SECRET=******************
PUSHER_HOST=
PUSHER_PORT=443
PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=***

そして、ついでですのでブロードキャストのドライバーがpusherになっているか確認しておいてください。

BROADCAST_DRIVER=pusher

また、先ほど.envにセットしたキーがコンフィグから取得できるようになっているか確認しておいてください。

config/broadcasting.php

// 省略

'connections' => [

    'pusher' => [
        'driver' => 'pusher',
        'key' => env('PUSHER_APP_KEY'),
        'secret' => env('PUSHER_APP_SECRET'),
        'app_id' => env('PUSHER_APP_ID'),
        'options' => [
            'host' => env('PUSHER_HOST', 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',
            'port' => env('PUSHER_PORT', 443),
            'scheme' => env('PUSHER_SCHEME', 'https'),
            'encrypted' => true,
            'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
        ],
        'client_options' => [
            // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
        ],
        'cluster' => env('PUSHER_APP_CLUSTER'),
    ],

// 省略

※ もしかすると、clusterは抜けてるかもです🤔

これでPusher側の作業は完了です。

パッケージをインストールする

次に、今回必要なパッケージをインストールします。

まずはphp側です。
以下のコマンドを実行してください。

composer require pusher/pusher-php-server

そして、同じくJavaScript側にも以下3つのnpmパッケージをインストールします。

npm i --save-dev laravel-echo pusher-js simple-peer

そして、Laravel Echosimple-peerViteで使えるようにしておきましょう。

resources/js/bootstrap.js

// 初期状態ではコメントアウトされています

import Echo from 'laravel-echo';

import Pusher from 'pusher-js';
window.Pusher = Pusher;

window.Echo = new Echo({
    broadcaster: 'pusher',
    key: import.meta.env.VITE_PUSHER_APP_KEY,
    wsHost: import.meta.env.VITE_PUSHER_HOST ?? `ws-${import.meta.env.VITE_PUSHER_APP_CLUSTER}.pusher.com`,
    wsPort: import.meta.env.VITE_PUSHER_PORT ?? 80,
    wssPort: import.meta.env.VITE_PUSHER_PORT ?? 443,
    forceTLS: (import.meta.env.VITE_PUSHER_SCHEME ?? 'https') === 'https',
    enabledTransports: ['ws', 'wss'],
});

// simple-peer を追加しました
import Peer from 'simple-peer/simplepeer.min.js'
window.Peer = Peer;

※ なお、import Peer from 'simple-peer';とするとViteではエラーが出るようでしたので、こちら を参考に変更しています。(今後修正されるかもです👍)

ルートをつくる

続いて、ルートです。
まずはブラウザから直接アクセスするためのものを作ります。

routes/web.php

// 省略

use App\Http\Controllers\VideoCallController;

// 省略

Route::prefix('video_call')->controller(VideoCallController::class)->group(function(){

    Route::get('/', 'index')->name('video_call.index');

});

そして、もうひとつPusherの認証に使うルートをchannels.phpにセットします。

routes/channels.php

// 省略

Broadcast::channel('video-call', function ($user) {

    return ['id' => $user->id, 'name' => $user->name];

});

コントローラーをつくる

次に、コントローラーです。
以下のコマンドを実行してください。

pa make:controller VideoCallController

すると、ファイルが作成されるので中身を以下のようにします。

app/Http/Controllers/VideoCallController.php

<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\Request;
use Inertia\Inertia;

class VideoCallController extends Controller
{
    public function __construct()
    {
        $this->middleware('auth'); // 要ログインにする
    }

    public function index(Request $request)
    {
        $user = $request->user();
        $other_users = User::select('id', 'name')
            ->where('id', '!=', $user->id)
            ->get(); // 自分以外のユーザーを取得

        return Inertia::render('VideoCall/Index', [
            'user' => $user->only('id', 'name'),
            'otherUsers' => $other_users,
        ]);
    }
}

ビューをつくる

では、ここがメインで(ちょっとハマった)部分です。

resources/js/Pages/VideoCall/Index.jsx

import {useEffect, useRef} from "react";

export default function Index(props) {

    // User
    const user = props.user;
    const otherUsers = props.otherUsers;

    // Pusher
    const pusherKey = import.meta.env.VITE_PUSHER_APP_KEY;
    const pusherCluster = import.meta.env.VITE_PUSHER_APP_CLUSTER;

    // Video
    const myVideoRef = useRef(null);
    const otherVideoRef = useRef(null);
    const videoStreamRef = useRef(null);
    const videoChannelRef = useRef(null);
    const peers = useRef({});
    const getEventName = userId => `client-signal-${userId}`;
    const getPeer = (targetUserId, initiator) => {

        if(peers.current[targetUserId] === undefined) {

            const peer = new Peer({
                initiator,
                stream: videoStreamRef.current,
                trickle: false,
            });

            peer
                .on('signal', data => {

                    const eventName = getEventName(targetUserId);
                    videoChannelRef.current.trigger(eventName, {
                        userId: user.id,
                        data: data,
                    });

                })
                .on('stream', stream => otherVideoRef.current.srcObject = stream)
                .on('close', () => deletePeer(targetUserId));

            // 通話するユーザーを追加
            peers.current = {
                ...peers.current,
                ...{ [targetUserId]: peer }
            };

        }

        return peers.current[targetUserId];

    };
    const deletePeer = userId => {

        const peer = peers.current[userId];

        if(peer !== undefined) {

            peer.destroy();

        }

        const { [userId]: _, ...newPeers } = peers.current; // 該当するユーザーを削除
        peers.current = newPeers;

    };
    useEffect(() => {

        navigator.mediaDevices.getUserMedia({ video: true, audio: true })
            .then(stream => {

                myVideoRef.current.srcObject = stream;
                videoStreamRef.current = stream;

                const pusher = new Pusher(pusherKey, {
                    authEndpoint: '/broadcasting/auth',
                    cluster: pusherCluster,
                });
                const channel = pusher.subscribe('private-video-call');
                const eventName = getEventName(user.id);
                channel.bind(eventName, signal => {

                    const userId = signal.userId;
                    const peer = getPeer(userId, false);
                    peer.signal(signal.data);

                });
                videoChannelRef.current = channel;

            });

    }, []);

    return (
        <div className="p-5 bg-gray-100">
            <div className="p-4 rounded-lg bg-white mb-3">
                {otherUsers && otherUsers.map(otherUser => (
                    <div key={otherUser.id}>
                        <a
                            href="#"
                            className="text-blue-500"
                            onClick={() => getPeer(otherUser.id, true)}>
                            {otherUser.name} さんと通話する
                        </a>
                    </div>
                ))}
            </div>
            <div className="flex">
                <div className="flex-1 p-3 border bg-white">
                    <div className="text-center">自分の映像</div>
                    <video ref={myVideoRef} autoPlay></video>
                </div>
                <div className="px-5 pt-3">
                    <div className="text-center font-bold">↔</div>
                </div>
                <div className="flex-1 p-3 border bg-white">
                    <div className="text-center">相手の映像</div>
                    <video ref={otherVideoRef} autoPlay></video>
                </div>
            </div>
        </div>
    );

}

基本的には2つvideoタグを用意して、そこへそれぞれのストリームをセットする形なのですが、当初はそれぞれ値をuseStateで保持していたためうまくいきませんでした。

というのも、useStateの書き換えは非同期な処理(≒別世界の出来事)のため、ほしいタイミングでデータ取得できないからです。

そのため、今回のケースではuseRefをふんだんに使っています。

useRefxxxxxxRef.currentで最新状態のデータを取得することができるんですね。(ただし、useRefは再レンダリングされないので見た目の変更用には使えません)

以上で作業は完了です。
おつかれさまでした❗

ちなみに1: Vite を HTTPS 対応にするには

Viteは初期状態では証明書が入っていないため、ブラウザではhttpsとしてアクセスができません。

そのため、必要なパッケージをインストールする必要があります。
では、以下のコマンドを実行してください。

npm i --save-dev vite-plugin-mkcert

そして、コンフィグを以下のように変更します。

vite.config.js

import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import react from '@vitejs/plugin-react';
import mkcert from'vite-plugin-mkcert' // 👈 ここを追加しました

export default defineConfig({
    plugins: [
        laravel({
            input: 'resources/js/app.jsx',
            ssr: 'resources/js/ssr.jsx',
            refresh: true,
        }),
        react(),
        mkcert(), // 👈 ここを追加しました
    ],
    ssr: {
        noExternal: ['@inertiajs/server'],
    },
    server: {
        https: true, // 👈 ここを追加しました
    }
});

これでViteを起動し、「https://127.0.0.1:5173/@vite/client」へアクセスします。(環境によって127.0.0.1:5173は変更してください)

すると、以下のようなページが表示されます。

そして、「詳細設定」ボタンをクリックすると以下のようなコンテンツが表示されますので、「127.0.0.1 にアクセスする(安全ではありません)」リンクをクリックします。

※ 確かにサーバー上でやると危険な場合がありますが、ここはローカルの環境ですので問題があることは少ないでしょう。(とはいえ自己責任でお願いしますね😄)

すると、Viteのサーバーページが表示されますので、これでhttpsが有効になります👍

ちなみに2:ビルドする時「Some chunks are larger…」という警告が出たら

私の環境では、Viteでビルドするとき以下のような警告がでました。

(!) Some chunks are larger than 500 KiB after minification.

ビルド自体はちゃんとできるので問題ないのですが、なんか気持ち悪いので設定を変更しました。

vite.config.js

// 省略

export default defineConfig({

    // 省略

    build: { // 👈 このブロックを追加しました
        chunkSizeWarningLimit: 1000
    }
});

どうやら各ファイルのサイズがこの値(デフォルト: 500 kb)を超えると警告がでるようです。

テストしてみる

では、実際にテストしてみましょう❗

まずVitedevバージョンでは外部からのアクセスに対応できませんので、npm run buildでビルドを実行します。

そして、この状態のまま(開発コマンドを実行せず)2つの環境から「https://******/video_call」へアクセスします。

※ ちなみに、私の環境では1つはメインPCで実行し、もう一方はローカルネットワークでスマホからアクセスしました。

すると、ログインフォームが表示されるので、それぞれ別のユーザーでログインします。(今回のケースでは、「太郎」さんと「次郎」さんでログインします)

ログインすると、以下のように「(自分以外の)ユーザーリスト」「自分用のカメラ」が表示されます。

では、「太郎」さんでログインした方(メインPC)から「次郎」さんのリンクをクリックして通話してみます。

※ なお、今回は私の大好物の海外製ポテトチップスLay'sを双方向から撮影してみます。(ホントはoriginalが一番好きですが食べちゃったのでsour cream & onionです)

どうなったでしょうか・・・・・・

はい❗
(ちょっとわかりにくいかもですが…)うまく双方向からの映像が表示されました。

また、キィーーーーン!!!とハウリングしていたのでおそらく音声の方もうまくいっていると思います。(お気をつけください。m(_ _)m)

成功です😄✨

企業様へのご提案

今回の技術を使うと、独自のビデオ通話機能をつくることができますので、例えば社内ツールへ組み込んだり、タブレットなどを設置しておいてインターホンのように使うこともできるでしょう。

また、今回利用したPusherは月間200,000回までのメッセージは無料(2022.10.28現在)で使えますのでそれほど利用回数が多くない場合は経費を抑えることもできます。詳しくは こちら をご参照ください。

なお、もしこういったご相談がございましたら、ぜひお問い合わせからご連絡ください。

お待ちしております。😄✨

おわりに

ということで、今回は「Laravel + React」でビデオ通話機能を作ってみました。

実はこのブログでは過去にVueで同じ機能を作ってましたが、3年前(2019年)だったので、フレームワークやパッケージのバージョン違いでコードが変更になっている部分がありました。

また、当時はPusherの認証をするルートを独自につくっていましたが、今回はLaravel公式の方法でセットできましたので、ちょっと嬉しかったりもしています👍

ぜひ皆さんも(ちょっと複雑ですが…)楽しみながらやってみてくださいね。

ではでは〜❗

「寝る前なのにポテトチップス
食べちゃった(笑)」

開発のご依頼お待ちしております 😊✨
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします!
このエントリーをはてなブックマークに追加       follow us in feedly  

開発効率を上げるための機材・まとめ