0. Voximplantとは
Voximplant はロシア発のスタートアップで、VR や AR を始めとした動画技術関連の CPaaS、つまりクラウドプラットフォームを独自サービスとして提供しています。
WebRTC のライブラリも充実しており、Web やモバイルから Unity のようなゲームエンジンまで、幅広い用途に対応しているのが特徴です。
2019年12月現在、まだ日本語では開発方法に関する情報が少ないので、今回は、React 上で簡単な動画アプリを作る方法をご紹介します。今回作るのは P2P の動画アプリ(SDK 同士の通信)なので、コストは無料です(月あたりのアクティブユーザーが1000人を超えると有料になります)。今回は、エディタに Visual Studio Code を利用しました。
1. Reactの新規プロジェクトを立ち上げる
yarn と node が使える前提で、プロジェクト名は voximplant とします。
cd <WHERE_YOU_CREATE_A_NEW_PROJECT>
yarn create react-app voximplant --template typescript
プロジェクトが立ち上がったら、ローカルホスト上で動くことを確認します。
cd voximplant
yarn start
この画面がブラウザ上に表示されたら準備完了です。
2. Voximplant 上に Application を作る
今回は1対1の動画チャットを作ります。まず、Voximplant に登録しましょう。
写真では sample になっていますが、ここでは、sampleUser というユーザーネームで登録したとします。
サインインが済んだら、左上のメニューから、Applications を開き、New Application をクリックします。
今回は Name に sampleapp を入力します。
3. Application の設定 (シナリオとルーティング)
シナリオの追加
いま作成した Application (sampleapp) をクリックしてみましょう。
まず Scenarios を開き、+マークをクリックします。
ここでは、新たに作成するシナリオに sampleScenario という名前をつけます。
sampleScenario の中に必要なコードを加えます。
今回は、以下の1行だけを入力し、保存します。
VoxEngine.forwardCallToUserDirect();
ルーティング・ルールの追加
次に、 Routing を開きます。
The app has no routing rules という表示が出てくるので、Create をクリックします。
名前は sampleRule とし、Available Scenarios の中から先ほど作成した sampleScenario を選択します。
Routing を作成したら、Application の設定はこれで終了です。
4. ローカルでの開発準備
package.jsonの整備
まず必要な依存ファイル(dependencies)を追加します。
yarn add typescript axios voximplant-websdk express dotenv http-errors nodemon
package.json には、次の script と、proxy を加えます。
{
"scripts": {
"server": "nodemon -r dotenv/config server dotenv_config_path=.env.local"
},
"proxy": "http://localhost:4000"
}
server というスクリプトを設定したことで、yarn server
を走らせると、localhost:4000 上でサーバーサイド(server/index.js)が実行されることになります。
この際、.env.local 内に設定した環境変数がサーバーサイドでも使えるよう、dotenvの読み込み元を dotenv_config_path で設定してあげます(dotenv については後述します)。
さらに、プロキシを設定したことで、ローカル環境のフロントエンドからサーバーサイドを呼ぶ際、自動的に localhost:4000 へアクセスすることができるようになりました。ローカル環境と本番環境でポート番号を変える必要がないので、非常に便利です。
ファイル構成
ファイル構成は以下の通りにします(テスト関連ファイルは割愛しました)。
voximplant
├── public
│ └── favicon.ico
│ └── index.html
│ └── manifest.json
│ └── robots.txt
└── dotenv
│ └── config.ts
└── server
│ └── index.js
│ └── router.ts
└── src
│ └── components
│ │ ├── SignUp.tsx
│ │ └── Video.tsx
│ └── App.css
│ └── App.tsx (<- App.js)
│ └── index.css
│ └── index.js
│ └── serviceWorker.js
└── .env.local
└── .gitignore
└── package.json
└── README.md
└── tsconfig.json
dotenvの設定
dotenv を使うことで、ローカル上でもフロントエンドとサーバーサイドの両方で環境変数を設定することができます。
まず、dotenv/config.ts 内で、.env(.local) 上の環境変数を読み込むよう設定します。
const dotenv = require("dotenv");
const result = dotenv.config();
if (result.error) {
throw result.error;
}
console.log(result.parsed);
次に、Voximplant を利用するために必要な環境変数を設定します。
REACT_APP_ACCOUNT_ID=
REACT_APP_API_KEY=
REACT_APP_APP_ID=
REACT_APP_APP_NAME=sampleapp
REACT_APP_USER_NAME=sampleUser
React で環境変数を上書きして保存するためには、変数名を REACT_APP
から始めないといけないことに注意してください。
また、それぞれの環境変数をどこで見つけてくるかでハマりかねないので、以下を参考にしてください。
1. REACT_APP_ACCOUNT_ID
Voximplant のメニューから Settings を選択し、API Keys をクリックすると表示される、 Account ID (account_id)がそれです。
2. REACT_APP_API_KEY
上記 Account ID のすぐ下にある、 API Key がそれです。表示するには、サインイン時に登録したパスワードの入力が必須になります。
3. REACT_APP_APP_ID
メニューから Applications を選択したあと、使用したい Application (ここでは sampleapp.sampleUser.n2.voximplant.com) の名前のすぐ下にある、7桁の ID がそれです。
4. REACT_APP_APP_NAME
Application の名前です。今回の場合は sampleapp になります。
5. REACT_APP_USER_NAME
サインイン時に登録したあなたのユーザー名です。今回の場合は、sampleUser になります。
今回は説明を省きますが、このアプリをデプロイする場合は、CIなどに同じ環境変数を設定してください。
CSSライブラリの追加(任意)
public/index.html には Bootstrap を追加します。
<link
rel="stylesheet"
href="https://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css"
/>
App.tsxの作成
src/App.js は App.tsx に変更します。
import React from "react";
import "./App.css";
import SignUp from "./components/SignUp";
import Video from "./components/Video";
const App: React.FC = () => (
<div className="App">
<SignUp />
<Video />
</div>
);
export default App;
この状態で yarn start
を走らせると、ルート階層に自動的に tsconfig.json ファイルが作成されるはずです。必要に応じて compilerOptions などを加えてください。
ここでは単純に2つのコンポーネント(SignUp と Video)を並べてみましたが、実際のサービスであれば、
1. サービスへのサインアップ (→Voximplantにも登録)
2. ログイン (→ログイン情報の取得)
3. ログイン情報を利用して、動画画面へ
といったプロセスの中に当てはめていくことになるかと思います。
5. サーバーサイドの準備
server/index.js に直接エンドポイントごとの動作を書き込んでも構いませんが、ここではエラーの処理を一箇所にまとめ、将来的にルートごとの動作を切り分けて管理できるようにします。
const express = require("express");
const app = express();
const router = require("./router.ts");
const createError = require("http-errors");
const port = process.env.PORT || "4000";
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.listen(port, () => {
console.log("Listening to the port!");
});
app.use("/", router);
app.use((req, res, next) => {
next(createError(404));
});
app.use((err, req, res, next) => {
res.locals.message = err.message;
res.locals.error = req.app.get("env") === "development" ? err : {};
res.status(err.status || 500);
res.send("error");
});
module.exports = app;
6. ユーザー登録
Voximplant では、ユーザー同士があらかじめ同じ Application 内に登録されていないと、P2P の動画チャットを利用することができません。ブラウザ上でも登録は可能ですが、エンドユーザーにアクセスさせるわけにはいかないので、ここでは API を用いてユーザー登録を済ませることを試みます。
フロントエンドのコンポーネント
import React, { useState, SyntheticEvent } from "react";
import axios from "axios";
const SignUp: React.FC = () => {
const [username, setUsername] = useState("");
const [userDisplayName, setUserDisplayName] = useState("");
const [password, setPassword] = useState("");
const onChangeUsername = (e: SyntheticEvent) => {
const target = e.target as HTMLInputElement;
setUsername(target.value);
};
const onChangeUserDisplayName = (e: SyntheticEvent) => {
const target = e.target as HTMLInputElement;
setUserDisplayName(target.value);
};
const onChangePassword = (e: SyntheticEvent) => {
const target = e.target as HTMLInputElement;
setPassword(target.value);
};
const onSubmit = async (e: SyntheticEvent) => {
e.preventDefault();
try {
const res = await axios.post("/addUser", {
username,
userDisplayName,
password
});
console.log("Success: ", res.data);
} catch (err) {
console.error("Error: ", err);
}
};
return (
<div className="jumbotron vertical-center">
<div className="container">
<section className="panel panel-default">
<header className="panel-heading">
<h3 className="panel-title">SignUp Component</h3>
</header>
<main className="panel-body">
<form onSubmit={onSubmit}>
<div className="form-group">
<label htmlFor="username">Username</label>
<input
type="text"
name="username"
className="form-control"
id="username"
placeholder="Username"
onChange={onChangeUsername}
/>
</div>
<div className="form-group">
<label htmlFor="userDisplayName">User Display Name</label>
<input
type="text"
name="userDisplayName"
className="form-control"
id="userDisplayName"
placeholder="User Display Name"
onChange={onChangeUserDisplayName}
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
type="password"
name="password"
className="form-control"
id="password"
placeholder="Password"
onChange={onChangePassword}
/>
</div>
<button type="submit" className="btn btn-primary">
Submit
</button>
</form>
</main>
</section>
</div>
</div>
);
};
export default SignUp;
これはただのフォームです。どんな形であっても構いませんが、
1. ユーザー名
2. 表示用のユーザー名
3. パスワード
をサーバーサイドに送信するようなものを作ってください。
なお、axios の代わりに fetch を使用しても問題ありません(ここではサーバーサイドとの統一のために axios を利用します)。
バックエンドのコンポーネント
import express from "express";
import axios from "axios";
import queryString from "querystring";
const router = express.Router();
const baseUrl = "https://api.voximplant.com/platform_api";
const account_id = process.env.REACT_APP_ACCOUNT_ID;
const api_key = process.env.REACT_APP_API_KEY;
const app_id = process.env.REACT_APP_APP_ID;
router.get("/", (req, res) => {
res.send("Hello, world!");
});
router.post("/addUser", (req, res) => {
const {
username: user_name,
userDisplayName: user_display_name,
password: user_password
} = req.body;
const addUser = async () => {
const route = "/AddUser";
const queries = {
account_id,
api_key,
user_name,
user_display_name,
user_password,
application_id,
};
const path = `${baseUrl}${route}/${querysString.stringify(queries)}`;
try {
const info = await axios.post(path, null);
if (info.data && info.data.error) {
throw info.data.error;
}
res.status(200).send(info.data);
} catch (error) {
console.error(error);
res.status(500).send({ error });
}
};
addUser();
});
export default router;
Voximplant にはバックエンド用の APIClient があるのですが、ローカルでは動かなかったので axios を利用しています。
また、GET メソッドはやりとりできるデータの容量が POST メソッドよりもはるかに少ないので、仮に本来 axios.get
で取得しそうなデータであっても、ここでは axios.post
を利用することをおすすめします。
なお、path に大量の文字列が入っていますが、これは axios.post
の引数として別送できなかったので、ひとまずクエリとして入れました。
レスポンスに応じてエラーを表示したり、次の画面に移ったり、など独自の実装を試みてください。
7. 動画チャット
極論を言うと、この部分だけで動画は動きます。
今回は WebSDK を利用していますが、Voximplant は React Native やアンドロイド専用の SDK なども用意しています。
フロントエンドのコンポーネント
import React, { useState, useEffect, SyntheticEvent } from "react";
import * as VoxImplant from "voximplant-websdk";
import { EventHandlers } from "voximplant-websdk/EventHandlers";
import { Call } from "voximplant-websdk/Call/Call";
const appName = process.env.REACT_APP_APP_NAME;
const appUsername = process.env.REACT_APP_USER_NAME;
const Video: React.FC = () => {
const sdk = VoxImplant.getInstance();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [caller, setCaller] = useState("");
const [option, setOption] = useState("call");
const [currentCall, setCurrentCall] = useState<Call | null>(null);
const onChangeUsername = (e: SyntheticEvent) => {
const target = e.target as HTMLInputElement;
setUsername(target.value);
};
const onChangePassword = (e: SyntheticEvent) => {
const target = e.target as HTMLInputElement;
setPassword(target.value);
};
const onChangeCaller = (e: SyntheticEvent) => {
const target = e.target as HTMLInputElement;
setCaller(target.value);
};
const onSelect = (e: SyntheticEvent) => {
const target = e.target as HTMLInputElement;
setOption(target.value);
};
// SDK関連
const connect = async () => {
console.log("Establishing connection...");
try {
const res = await sdk.connect();
console.log(res);
} catch(err: ErrorEvent) {
console.error(err);
}
};
const onSdkReady = () => {
console.log(`onSDKReady version ${VoxImplant.version}`);
console.log(`WebRTC supported: ${sdk.isRTCsupported()}`);
connect();
};
const login = (appUser: string, password: string) => {
const URI = `${appUser}@${appName}.${appUsername}.voximplant.com`;
return sdk.login(URI, password);
};
const onConnectionEstablished = () => {
console.log(`Connection established: ${sdk.connected()}`);
};
const onConnectionFailed = () => {
console.log("Connection failed");
setTimeout(() => {
sdk.connect();
}, 1000);
};
const onConnectionClosed = () => {
console.log("Connection closed");
setTimeout(() => {
sdk.connect();
}, 1000);
};
const showLocalVideo = (flag: boolean) => {
sdk.showLocalVideo(flag);
};
const showRemoteVideo = (flag: boolean) => {
currentCall?.showRemoteVideo(flag);
};
const sendVideo = (flag: boolean) => {
currentCall?.sendVideo(flag);
};
const onAuthResult = (e: EventHandlers.AuthResult) => {
console.log(`AuthResult: ${e.result}`);
if (e.result) {
const panelTitle = document.getElementsByClassName("panel-title")[1];
const title = `${panelTitle.innerHTML}: Logged in as ${e.displayName}`;
panelTitle.innerHTML = title;
showLocalVideo(true);
} else {
console.warn(`Code: ${e.code}`);
}
};
const onMediaElement = (e: EventHandlers.MediaElementCreated) => {
const container = document.getElementById("voximplant_container");
const video = e.element as HTMLVideoElement;
video.width = 320;
video.height = 240;
container?.appendChild(video);
};
const onCallConnected = () => {
const callButton = document.getElementById("callButton");
const answerButton = document.getElementById("answerButton");
const cancelButton = document.getElementById("cancelButton");
console.log("Call is connected.");
sendVideo(true);
showRemoteVideo(true);
callButton?.classList.add("hidden");
answerButton?.classList.add("hidden");
cancelButton?.classList.remove("hidden");
};
const onCallDisconnected = () => {
const callButton = document.getElementById("callButton");
const answerButton = document.getElementById("answerButton");
const cancelButton = document.getElementById("cancelButton");
setCurrentCall(null);
console.log("Call is disconnected.");
if (option === "call") {
callButton?.classList.remove("hidden");
callButton?.removeAttribute("disabled");
} else if (option === "answer") {
answerButton?.classList.remove("hidden");
answerButton?.removeAttribute("disabled");
}
cancelButton?.classList.add("hidden");
};
const onCallFailed = (e: EventHandlers.Failed) => {
console.error(`Call failed! code: ${e.code} reason: ${e.reason}`);
};
const onIncomingCall = (e: EventHandlers.IncomingCall) => {
e.call.on(VoxImplant.CallEvents.Connected, onCallConnected);
e.call.on(VoxImplant.CallEvents.Disconnected, onCallDisconnected);
e.call.on(VoxImplant.CallEvents.Failed, onCallFailed);
e.call.on(VoxImplant.CallEvents.MediaElementCreated, onMediaElement);
console.log(`Incoming call from: ${e.call.number()}`);
e.call.answer(undefined, {}, { receiveVideo: true, sendVideo: true });
setCurrentCall(e.call);
};
// 各ボタンのonClick
const onClickCall = async () => {
const callButton = document.getElementById("callButton");
const userInput = document.getElementById(
"usernameForVideo"
) as HTMLInputElement;
const passInput = document.getElementById(
"passwordForVideo"
) as HTMLInputElement;
const selectPicker = document.getElementsByClassName("selectpicker")[0];
try {
await login(username, password);
console.log("Login successful");
userInput.value = "";
passInput.value = "";
console.log("Calling...");
callButton?.setAttribute("disabled", "true");
selectPicker.classList.add("hidden");
} catch(err: ErrorEvent) {
console.error(err);
}
};
const onClickAnswer = async () => {
const userInput = document.getElementById(
"usernameForVideo"
) as HTMLInputElement;
const passInput = document.getElementById(
"passwordForVideo"
) as HTMLInputElement;
const callerInput = document.getElementById(
"callerName"
) as HTMLInputElement;
try {
await login(username, password);
console.log("Login successful");
userInput.value = "";
passInput.value = "";
callerInput.value = "";
const call = await sdk.call(caller, {
sendVideo: true,
receiveVideo: true
});
call.on(VoxImplant.CallEvents.Connected, onCallConnected);
call.on(VoxImplant.CallEvents.Disconnected, onCallDisconnected);
call.on(VoxImplant.CallEvents.Failed, onCallFailed);
call.on(VoxImplant.CallEvents.MediaElementCreated, onMediaElement);
setCurrentCall(call);
console.log("Answering...");
} catch(err: string) {
console.error(err);
}
};
const onClickDisconnect = () => {
currentCall?.hangup();
};
// SDKの初期化
const [initialized, setInitialized] = useState(sdk.alreadyInitialized);
if (!initialized) {
const initialize = async () => {
try {
await sdk
.init({
micRequired: true,
videoSupport: true,
progressTone: true,
localVideoContainerId: "voximplant_container",
remoteVideoContainerId: "voximplant_container"
});
console.log("SDK initialized");
} catch (e) {
console.error(e);
}
};
initialize();
sdk.on(VoxImplant.Events.SDKReady, onSdkReady);
sdk.on(VoxImplant.Events.ConnectionEstablished, onConnectionEstablished);
sdk.on(VoxImplant.Events.ConnectionFailed, onConnectionFailed);
sdk.on(VoxImplant.Events.ConnectionClosed, onConnectionClosed);
sdk.on(VoxImplant.Events.AuthResult, onAuthResult);
sdk.on(VoxImplant.Events.IncomingCall, onIncomingCall);
setInitialized(true);
}
// ボタン表示の初期化
useEffect(() => {
const callButton = document.getElementById("callButton");
const answerButton = document.getElementById("answerButton");
const callerInput = document.getElementById("callerName");
if (option === "call") {
callButton?.classList.remove("hidden");
answerButton?.classList.add("hidden");
callerInput?.classList.add("hidden");
} else if (option === "answer") {
callButton?.classList.add("hidden");
answerButton?.classList.remove("hidden");
callerInput?.classList.remove("hidden");
}
}, [option]);
return (
<div className="jumbotron vertical-center">
<div className="container">
<section id="container" className="panel panel-default">
<header className="panel-heading">
<h3 className="panel-title">Video Component</h3>
<select className="selectpicker" value={option} onChange={onSelect}>
<option value="call">Call</option>
<option value="answer">Answer</option>
</select>
</header>
<main id="content" className="panel-body">
<div id="voximplant_container"></div>
<div id="controls">
<div className="input-wrapper">
<div className="input-group">
<span className="input-group-addon">@</span>
<input
id="usernameForVideo"
type="text"
className="form-control"
placeholder="Username"
onChange={onChangeUsername}
/>
</div>
<div className="input-group">
<input
id="passwordForVideo"
type="password"
className="form-control"
placeholder="Password"
onChange={onChangePassword}
/>
</div>
<div className="input-group">
<input
id="callerName"
type="text"
className="form-control"
placeholder="Answer to"
onChange={onChangeCaller}
/>
</div>
</div>
<div className="btn-group btn-group-justified">
<div className="btn-group">
<button
className="btn btn-success"
id="callButton"
onClick={onClickCall}
>
Call
</button>
<button
onClick={onClickAnswer}
className="btn btn-success hidden"
id="answerButton"
>
Answer
</button>
<button
onClick={onClickDisconnect}
className="btn btn-danger hidden"
id="cancelButton"
>
Disconnect
</button>
</div>
</div>
</div>
</main>
</section>
</div>
</div>
);
};
export default Video;
P2P なので、バックエンドは不要です。
TypeScript で型を設定するのが面倒なのと、React のライフサイクルの中でどう sdk
や currentCall
を活用していくか、がやや複雑かとは思いますが、一通りコードを眺めてみると、じつにシンプルな構造をしていることがわかります。
一度だけ走らせたい関数は、useEffect
に含めるより、上記のようにuseState
で一度だけ更新される変数を作ったほうが楽かと思います。
エラーハンドリングや動画終了時の設定は、いろいろと書き加えてみてください。
8. まとめ
動画アプリは誰でも作れます。むしろ大変なのは、さまざまなエラーの対応、そして見た目の上で、実際のサービスにどううまく繋げるか、というところだと思います。
今回の場合、React に置き換えるのは少し手間がかかりました。基本的な導線自体は、さほど難しくありません。
みなさんもぜひこの機会に、Voximplant を利用したプロジェクトを作ってみてください。