6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ElixirDesktopでSkyWayを使用したビデオ通話アプリを作る

Last updated at Posted at 2024-02-08

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を利用したビデオ通話の手順

ビデオ通話の手順は以下の通りになります。

  1. SkyWayAuthTokenを取得するか、生成する
  2. SkyWayのメソッドを使用してカメラやオーディオのStreamを取得する
  3. SkyWayAuthTokenを使用してContextを生成する
  4. 生成したContextを使用して、指定した名前のRoomを作成するか、検索して取得する
  5. Roomに接続する
  6. StreamをRoomに公開する(これ以降、この公開されたStreamはPublicationから参照する)
  7. Roomに公開された他のMemberのPublicationからStreamを取得する
  8. 取得したStreamから各種メディアをアタッチする
  9. ビデオ通話が可能になる

SkyWayを利用するための事前準備

SkyWayのビデオ通話を利用するには、アプリケーションIDシークレットIDが必要になります。

アプリケーションIDとシークレットIDは、以下の手順で生成することができます。

  1. SkyWayのアカウントを作成する
  2. SkyWayのプロジェクトを作成する
  3. SKyWayのアプリケーションを作成する
  4. アプリケーションIDとシークレットIDをメモする
  5. 上記のキーをconfig.exに記述する

プロジェクトを作成.png
アプリケーションを作成.png
ID.png

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を記述します。

config/config.ex
# Skywayのキー
config :tuto_skyway, :key,
  app_id: "アプリケーションIDの中身",
  secret: "シークレットの中身"

LiveViewモジュールを作成する

LiveViewモジュールを作成します。

まずlive/home_live/intercom.exを作成を作成します。

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を作成してください。

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を書き換えます

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には以下のように記述します。

assets/js/hooks.js
import SkywayHook from "./hooks/skywayHook";

const Hooks = {
  SkywayHook: SkywayHook
}

export default Hooks;

app.jsにHooksを追加します。

assets/js/app.js
import Hooks from "./hooks"
// ...
let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks, params: {_csrf_token: csrfToken}})

Hookを作成する その2

skyWayHook.jsにSkyWayの処理を記述します

assets/js/hooks/skywayHook.js
// 各種モジュールをインポート
import {
  nowInSec,
  SkyWayAuthToken,
  SkyWayContext,
  SkyWayRoom,
  SkyWayStreamFactory,
  uuidV4,
} from '@skyway-sdk/room';

const SkywayHook = {
  mounted() {
    // 下記を記述
  }
}

export default SkywayHook;

mounted()に以下を追加

assets/js/hooks/skywayHook.js
  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によるビデオ通話を行うために必要な、追加の権限等についての記述は以下の通りになります。

AndroidManifest.xml
+    <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.ktonCreate()に以下を追加

MainActivity.kt
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.ktsetWebView()内に以下を追加

Bridge.kt
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は生成する際に、idnameは次のように設定されます。

  • id - 自動で設定される。(8桁-4桁-4桁-4桁-8桁)
  • name - 指定がない場合、自動で設定される。(同上)

しかし、Roomのnameを設定するとセキュリティ上の問題が生じます。
具体的には、「Roomの名前をさえ知っていればどんなRoomにも接続できる」という問題です。

これは、Roomを取得する際にnameだけで検索することができてしまうためです。

Roomに接続できれば、他のMemberのStreamを許可なく取得することが可能になります。

そのため、Roomを生成する際はnameを指定せずidで取得するのが最善の方法と考えられます。

assets/js/hooks/skywayHook.js
// 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()メソッドに、次のようにオプションを渡します。

assets/js/hooks/skywayHook.js
const me = await channel.join({name: 'Takashi'});

// me.nameで参照できる

このnameは、Room内で一意の文字列になります。ただし、全角は使用できません。

6
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?