ElixirDesktopでSkyWayを使用したビデオ通話アプリを作る
SkyWay社のJavaScript SDKのチュートリアルをLiveViewで実行し、ビデオ通話ページを実装する方法をまとめました。
当記事の開発環境
Arch Linux 6.7.4-arch1-1
elixir 1.15.7-otp-25
erlang 25.0.4
目次
用語
各用語の意味は次のようになります。
- SkyWayAuthToken - SkyWayを利用するために必要なJWT(JSON Web Token)形式のトークンです
- Context - SkyWayAuthTokenを基に生成され、Roomを作成します
- Stream - カメラやオーディオのメディアストリームのことを指します
- Room - 通話を行うグループの単位です
- Member - Roomに参加している人を指します
- Publish - RoomにStreamを公開することです
- Publication - Roomに公開したSreamに対応するリソースです
詳細はこちら
SkyWayを利用したビデオ通話の手順
ビデオ通話の手順は以下の通りになります。
- SkyWayAuthTokenを取得するか、生成する
- SkyWayのメソッドを使用してカメラやオーディオのStreamを取得する
- SkyWayAuthTokenを使用してContextを生成する
- 生成したContextを使用して、指定した名前のRoomを作成するか、検索して取得する
- Roomに接続する
- StreamをRoomに公開する(これ以降、この公開されたStreamはPublicationから参照する)
- Roomに公開された他のMemberのPublicationからStreamを取得する
- 取得したStreamから各種メディアをアタッチする
- ビデオ通話が可能になる
SkyWayを利用するための事前準備
SkyWayのビデオ通話を利用するには、アプリケーションIDとシークレットIDが必要になります。
アプリケーションIDとシークレットIDは、以下の手順で生成することができます。
- SkyWayのアカウントを作成する
- SkyWayのプロジェクトを作成する
- SKyWayのアプリケーションを作成する
- アプリケーションIDとシークレットIDをメモする
- 上記のキーを
config.ex
に記述する
Phoenixプロジェクトを作成する
Phoenixプロジェクトを作成します。
mix phx.new tuto_skyway
WebアプリケーションでSkyWayを利用するためには、skyway-sdk
というライブラリが必要になります。
また@skyway-sdk/room
と@skyway-sdk/core
の2種類がありますが、ここでは前者をインストールします。
npm install --save --prefix assets \
@skyway-sdk/room
config.ex
にアプリケーションIDとシークレットIDを記述します。
# Skywayのキー
config :tuto_skyway, :key,
app_id: "アプリケーションIDの中身",
secret: "シークレットの中身"
LiveViewモジュールを作成する
LiveViewモジュールを作成します。
まずlive/home_live/intercom.ex
を作成を作成します。
defmodule TutoSkywayWeb.HomeLive.Intercom do
use TutoSkywayWeb, :live_view
def mount(_params, _session, socket) do
{:ok, socket}
end
def handle_params(params, _uri, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :home, _params) do
socket
end
# Hookにキーを渡す
def handle_event("getKey", %{}, socket) do
app_id = Application.get_env(:tuto_skyway, :key)[:app_id]
secret = Application.get_env(:tuto_skyway, :key)[:secret]
{:reply, %{app_id: app_id, secret: secret}, socket}
end
end
次にlive/home_live/intercom.html.heex
を作成してください。
<div id="skyway-hook" phx-hook="SkywayHook" />
<p>Member ID: <span id="my-id"></span></p>
<p>Member Name: <span id="my-name"></span></p>
<div>
channel name: <input id="channel-name" type="text" />
<button id="join">join</button>
</div>
<p>Channel ID: <span id="channel-id"></span></p>
<video id="local-video" width="400px" muted playsinline></video>
<div id="button-area"></div>
<div id="remote-media-area"></div>
router.ex
を書き換えます
# get "/", PageController, :home
live "/", HomeLive.Intercom, :home
Hookを作成する その1
Skywayの処理を実行するHookを作成します。ここではSkywayHook
とします。
次のファイルを作成してください。
- assets/js/hooks.js
- assets/js/hooks/skywayHook.js
hooks.js
には以下のように記述します。
import SkywayHook from "./hooks/skywayHook";
const Hooks = {
SkywayHook: SkywayHook
}
export default Hooks;
app.js
にHooksを追加します。
import Hooks from "./hooks"
// ...
let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks, params: {_csrf_token: csrfToken}})
Hookを作成する その2
skyWayHook.js
にSkyWayの処理を記述します
// 各種モジュールをインポート
import {
nowInSec,
SkyWayAuthToken,
SkyWayContext,
SkyWayRoom,
SkyWayStreamFactory,
uuidV4,
} from '@skyway-sdk/room';
const SkywayHook = {
mounted() {
// 下記を記述
}
}
export default SkywayHook;
mounted()
に以下を追加
mounted() {
let appId;
let secret;
this.pushEvent("getKey", {}, (reply, _ref) => {
appId = reply.app_id;
secret = reply.secret;
// トークンの作成
const token = new SkyWayAuthToken({
jti: uuidV4(),
iat: nowInSec(),
exp: nowInSec() + 60 * 60 * 24,
scope: {
app: {
id: appId,
turn: true,
actions: ['read'],
channels: [
{
id: '*',
name: '*',
actions: ['write'],
members: [
{
id: '*',
name: '*',
actions: ['write'],
publication: {
actions: ['write'],
},
subscription: {
actions: ['write'],
},
},
],
sfuBots: [
{
actions: ['write'],
forwardings: [
{
actions: ['write'],
},
],
},
],
},
],
},
},
}).encode(secret);
(async () => {
const localVideo = document.getElementById('local-video');
const buttonArea = document.getElementById('button-area');
const remoteMediaArea = document.getElementById('remote-media-area');
const channelNameInput = document.getElementById('channel-name');
const myId = document.getElementById('my-id');
const myName = document.getElementById('my-name');
const joinButton = document.getElementById('join');
// オーディオとカメラを取得する
const { audio, video } =
await SkyWayStreamFactory.createMicrophoneAudioAndCameraStream();
video.attach(localVideo);
await localVideo.play();
joinButton.onclick = async () => {
if (channelNameInput.value === '') return;
// トークンを元にContextを作成する(SkyWayの各種機能を利用する)
const context = await SkyWayContext.Create(token);
// Contextを使用して、Room名をRoomを取得、または生成する
const channel = await SkyWayRoom.FindOrCreate(context, {
type: 'p2p',
name: channelNameInput.value,
});
// Roomに入室する
const me = await channel.join();
myId.textContent = me.id;
myName.textContent = me.name;
// Streamを、Roomにpublication(公開)する
await me.publish(audio);
await me.publish(video);
const subscribeAndAttach = (publication) => {
if (publication.publisher.id === me.id) return;
const subscribeButton = document.createElement('button');
// publicationしたユーザのIDとメディアストリームの種類を表示
subscribeButton.textContent = `${publication.publisher.id}: ${publication.contentType}`;
buttonArea.appendChild(subscribeButton);
subscribeButton.onclick = async () => {
// publicationのIDを指定して、メディアストリームを取得する
const { stream } = await me.subscribe(publication.id);
switch (stream.contentType) {
case 'video':
{
const elm = document.createElement('video');
elm.playsInline = true;
elm.autoplay = true;
stream.attach(elm);
remoteMediaArea.appendChild(elm);
}
break;
case 'audio':
{
const elm = document.createElement('audio');
elm.controls = true;
elm.autoplay = true;
stream.attach(elm);
remoteMediaArea.appendChild(elm);
}
break;
}
};
};
// 入室時に、入室済みの利用者ごとに処理を行う。
channel.publications.forEach(subscribeAndAttach);
// 入室後に、入室が行われると処理を行う。
channel.onStreamPublished.add((e) => subscribeAndAttach(e.publication));
};
})();
});
}
実行
ページがロードされるとカメラとマイクの権限が要求されます。
許可をした後、channel name
の右に適当なRoom名(半角英数)を入力してください。
Roomが生成されると、Member ID:
の右に自身MemberのIDが表示されます。
IDが表示されたあとは、すでにRoomに入室している、もしくは後から入室したMemberのpublicationへの処理が行われます。
画面の下部に、RoomへPublishされたStreamに対応するButton要素が追加されます。
これを押すことで他のMemberのビデオやオーディオを再生することができます。
Androidアプリ化する
ElixirDesktop化とAndroidアプリ化の方法は省略します。
SkyWayによるビデオ通話を行うために必要な、追加の権限等についての記述は以下の通りになります。
+ <uses-feature
+ android:name="android.hardware.camera"
+ android:required="false" />
+ <uses-permission android:name="android.permission.INTERNET" />
+ <uses-permission android:name="android.permission.CAMERA" />
+ <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
+ <uses-permission android:name="android.permission.RECORD_AUDIO" />
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+ <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
MainActivity.kt
のonCreate()
に以下を追加
class MainActivity : Activity() {
private lateinit var binding: ActivityMainBinding
+ private val MY_PERMISSIONS_REQUEST_CAMERA = 0
+ private val MY_PERMISSIONS_REQUEST_RECORD_AUDIO = 1
+ private val MY_PERMISSIONS_REQUEST_MODIFY_AUDIO_SETTINGS = 2
+ private val MY_PERMISSIONS_REQUEST_ACCESS_NETWORK_STATE = 3
+ private val MY_PERMISSIONS_REQUEST_ACCESS_WIFI_STATE = 4
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 省略
bridge = Bridge(applicationContext, binding.browser)
}
// 以下を追加
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
+ != PackageManager.PERMISSION_GRANTED) {
+ ActivityCompat.requestPermissions(this,
+ arrayOf(Manifest.permission.CAMERA),
+ MY_PERMISSIONS_REQUEST_CAMERA)
+ }
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
+ != PackageManager.PERMISSION_GRANTED) {
+ ActivityCompat.requestPermissions(this,
+ arrayOf(Manifest.permission.RECORD_AUDIO),
+ MY_PERMISSIONS_REQUEST_RECORD_AUDIO)
+ }
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.MODIFY_AUDIO_SETTINGS)
+ != PackageManager.PERMISSION_GRANTED) {
+ ActivityCompat.requestPermissions(this,
+ arrayOf(Manifest.permission.MODIFY_AUDIO_SETTINGS),
+ MY_PERMISSIONS_REQUEST_MODIFY_AUDIO_SETTINGS)
+ }
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_NETWORK_STATE)
+ != PackageManager.PERMISSION_GRANTED) {
+ ActivityCompat.requestPermissions(this,
+ arrayOf(Manifest.permission.ACCESS_NETWORK_STATE),
+ MY_PERMISSIONS_REQUEST_ACCESS_NETWORK_STATE)
+ }
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_WIFI_STATE)
+ != PackageManager.PERMISSION_GRANTED) {
+ ActivityCompat.requestPermissions(this,
+ arrayOf(Manifest.permission.ACCESS_WIFI_STATE),
+ MY_PERMISSIONS_REQUEST_ACCESS_WIFI_STATE)
+ }
+ if (Build.VERSION.SDK_INT >= 23) {
+ if (androidx.core.app.ActivityCompat.checkSelfPermission(applicationContext, android.Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED ||
+ androidx.core.app.ActivityCompat.checkSelfPermission(applicationContext, android.Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED ||
+ androidx.core.app.ActivityCompat.checkSelfPermission(applicationContext, android.Manifest.permission.ACCESS_NETWORK_STATE) != PackageManager.PERMISSION_GRANTED ||
+ androidx.core.app.ActivityCompat.checkSelfPermission(applicationContext, android.Manifest.permission.ACCESS_WIFI_STATE) != PackageManager.PERMISSION_GRANTED) {
+ ActivityCompat.requestPermissions(applicationContext as Activity, arrayOf(android.Manifest.permission.CAMERA, android.Manifest.permission.RECORD_AUDIO, android.Manifest.permission.ACCESS_NETWORK_STATE, android.Manifest.permission.ACCESS_WIFI_STATE), 12345)
+ }
+ }
Bridge.kt
のsetWebView()
内に以下を追加
class Bridge(private val applicationContext : Context, private var webview : WebView) {
// 省略
init {
// 省略
setWebView(webview)
// 以下を追加
webview.settings.javaScriptEnabled = true
webview.settings.allowFileAccess = true
webview.settings.domStorageEnabled = true
webview.settings.javaScriptCanOpenWindowsAutomatically = true
webview.settings.mediaPlaybackRequiresUserGesture = false
webview.webChromeClient = object : WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
request.grant(request.resources)
}
}
}
Roomへの接続について
前述のコードでは、Roomを生成または取得する際に名前を指定しています。
Roomは生成する際に、idとnameは次のように設定されます。
- id - 自動で設定される。(
8桁-4桁-4桁-4桁-8桁
) - name - 指定がない場合、自動で設定される。(同上)
しかし、Roomのnameを設定するとセキュリティ上の問題が生じます。
具体的には、「Roomの名前をさえ知っていればどんなRoomにも接続できる」という問題です。
これは、Roomを取得する際にnameだけで検索することができてしまうためです。
Roomに接続できれば、他のMemberのStreamを許可なく取得することが可能になります。
そのため、Roomを生成する際はnameを指定せず、idで取得するのが最善の方法と考えられます。
// const channel = await SkyWayRoom.FindOrCreate(context, {
// type: 'p2p',
// name: channelNameInput.value,
// });
// 生成のみ
const channel = await SkyWayRoom.Create(context, {
type: 'p2p',
});
SkyWayのドキュメント読むと、Roomに接続できるユーザーを制限する機能は提供されていないようです。
そのため、ChannelのID(channel.id
)を他者が取得できないように、安全に他のMemberに渡すことが重要となります。
Memberの名前を設定する
RoomにJoinする際、自身のMemberのnameを指定するにはjoin()
メソッドに、次のようにオプションを渡します。
const me = await channel.join({name: 'Takashi'});
// me.nameで参照できる
このnameは、Room内で一意の文字列になります。ただし、全角は使用できません。