LoginSignup
17
10

More than 3 years have passed since last update.

SkyWay API + Rails6 + Vue でビデオチャットアプリを作る② 複数人同時接続

Last updated at Posted at 2019-12-22

SkyWayAPIを使って複数人でのビデオチャットアプリに挑戦します!
先週投稿した「SkyWay API + Rails6 + Vue でビデオチャットアプリを作る①」の続きです。

目標物

複数人が同時に参加できるビデオチャットアプリの作成。
部屋は既に作られていて、そこに入室したところから開始です。

注意

前回の回で使ったコードを基本使い回します。
railsのプロジェクトがあること、webpackerがインストールされていることを前提に進めていきます。

サンプルコードの分析

SkyWayが提供している複数同時接続のパターンのDEMOです。
https://example.webrtc.ecl.ntt.com/room/index.html

そのソースコードです。
パッとみてよくわからない部分があったので、上から順にコメントをつけていきました。

githubリポジトリ
https://github.com/skyway/skyway-js-sdk/tree/master/examples/room

script.js
//Peerモデルを定義
const Peer = window.Peer;

(async function main() {
  //操作がDOMをここで取得
  const localVideo = document.getElementById('js-local-stream');
  const joinTrigger = document.getElementById('js-join-trigger');
  const leaveTrigger = document.getElementById('js-leave-trigger');
  const remoteVideos = document.getElementById('js-remote-streams');
  const roomId = document.getElementById('js-room-id');
  const roomMode = document.getElementById('js-room-mode');
  const localText = document.getElementById('js-local-text');
  const sendTrigger = document.getElementById('js-send-trigger');
  const messages = document.getElementById('js-messages');
  const meta = document.getElementById('js-meta');
  const sdkSrc = document.querySelector('script[src*=skyway]');

  meta.innerText = `
    UA: ${navigator.userAgent}
    SDK: ${sdkSrc ? sdkSrc.src : 'unknown'}
  `.trim();

 //同時接続モードがSFUなのかMESHなのかをここで設定
  const getRoomModeByHash = () => (location.hash === '#sfu' ? 'sfu' : 'mesh');
 //divタグに接続モードを挿入
  roomMode.textContent = getRoomModeByHash();
 //接続モードの変更を感知するリスナーを設置
  window.addEventListener(
    'hashchange',
    () => (roomMode.textContent = getRoomModeByHash())
  );

 //自分の映像と音声をlocalStreamに代入
  const localStream = await navigator.mediaDevices
    .getUserMedia({
      audio: true,
      video: true,
    })
    .catch(console.error);

  // localStreamをdiv(localVideo)に挿入
  localVideo.muted = true;
  localVideo.srcObject = localStream;
  localVideo.playsInline = true;
  await localVideo.play().catch(console.error);

  // Peerのインスタンス作成
  const peer = (window.peer = new Peer({
    key: window.__SKYWAY_KEY__,
    debug: 3,
  }));

  // 「div(joinTrigger)が押される&既に接続が始まっていなかったら接続」するリスナーを設置
  joinTrigger.addEventListener('click', () => {
    if (!peer.open) {
      return;
    }

  //部屋に接続するメソッド(joinRoom)
    const room = peer.joinRoom(roomId.value, {
      mode: getRoomModeByHash(),
      stream: localStream,
    });

  //部屋に接続できた時(open)に一度だけdiv(messages)に=== You joined ===を表示
    room.once('open', () => {
      messages.textContent += '=== You joined ===\n';
    });
  //部屋に誰かが接続してきた時(peerJoin)、いつでもdiv(messages)に下記のテキストを表示
    room.on('peerJoin', peerId => {
      messages.textContent += `=== ${peerId} joined ===\n`;
    });

    //重要: streamの内容に変更があった時(stream)videoタグを作って流す
    room.on('stream', async stream => {
      const newVideo = document.createElement('video');
      newVideo.srcObject = stream;
      newVideo.playsInline = true;
      // 誰かが退出した時どの人が退出したかわかるように、data-peer-idを付与
      newVideo.setAttribute('data-peer-id', stream.peerId);
      remoteVideos.append(newVideo);
      await newVideo.play().catch(console.error);
    });

    //重要: 誰かがテキストメッセージを送った時、messagesを更新
    room.on('data', ({ data, src }) => {
      messages.textContent += `${src}: ${data}\n`;
    });

    // 誰かが退出した場合、div(remoteVideos)内にある、任意のdata-peer-idがついたvideoタグの内容を空にして削除する
    room.on('peerLeave', peerId => {
      const remoteVideo = remoteVideos.querySelector(
        `[data-peer-id=${peerId}]`
      );
    //videoストリームを止める上では定番の書き方らしい。https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack/stop
      remoteVideo.srcObject.getTracks().forEach(track => track.stop());
      remoteVideo.srcObject = null;
      remoteVideo.remove();

      messages.textContent += `=== ${peerId} left ===\n`;
    });

    // 自分が退出した場合の処理
    room.once('close', () => {
    //メッセージ送信ボタンを押せなくする
      sendTrigger.removeEventListener('click', onClickSend);
    //messagesに== You left ===\nを表示
      messages.textContent += '== You left ===\n';
    //remoteVideos以下の全てのvideoタグのストリームを停めてから削除
      Array.from(remoteVideos.children).forEach(remoteVideo => {
        remoteVideo.srcObject.getTracks().forEach(track => track.stop());
        remoteVideo.srcObject = null;
        remoteVideo.remove();
      });
    });

    // ボタン(sendTrigger)を押すとonClickSendを発動
    sendTrigger.addEventListener('click', onClickSend);
  // ボタン(leaveTrigger)を押すとroom.close()を発動
    leaveTrigger.addEventListener('click', () => room.close(), { once: true });

   //テキストメッセージを送る処理
    function onClickSend() {
      room.send(localText.value);
      messages.textContent += `${peer.id}: ${localText.value}\n`;
      localText.value = '';
    }
  });

  peer.on('error', console.error);
})();
index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>SkyWay - Room example</title>
    <link rel="stylesheet" href="../_shared/style.css">
  </head>
  <body>
    <div class="container">
      <h1 class="heading">Room example</h1>
      <p class="note">
        Change Room mode (before join in a room):
        <a href="#">mesh</a> / <a href="#sfu">sfu</a>
      </p>
      <div class="room">
        <div>
          <video id="js-local-stream"></video>
          <span id="js-room-mode"></span>:
          <input type="text" placeholder="Room Name" id="js-room-id">
          <button id="js-join-trigger">Join</button>
          <button id="js-leave-trigger">Leave</button>
        </div>

        <div class="remote-streams" id="js-remote-streams"></div>

        <div>
          <pre class="messages" id="js-messages"></pre>
          <input type="text" id="js-local-text">
          <button id="js-send-trigger">Send</button>
        </div>
      </div>
      <p class="meta" id="js-meta"></p>
    </div>
    <script src="//cdn.webrtc.ecl.ntt.com/skyway-latest.js"></script>
    <script src="../_shared/key.js"></script>
    <script src="./script.js"></script>
  </body>
</html>

サンプルコードのvue.js化

上記の内容をvue.jsに書き換えていきます!

サンプルと違う部分

  • スタイルは一旦全部無視です。
  • 接続モードは本来2種類ありますが、今回はsfuをデフォルトに実装しました。
  • railsのviewを経由してroomIdを取得するようにしています。
room.vue

<template>
    <div id="app">
        <template v-for="stream in remoteStreams">
            <!-- ①srcObjectをバインドする -->
            <video 
                autoplay 
                playsinline
                :srcObject.prop="stream"
            ></video>
        </template>
        <video id="my-video" muted="true" width="500" autoplay playsinline></video>
        <p>ROOM ID: <span id="room-id">{{ roomId }}</span></p>
        <button v-if="roomOpened === true" @click="leaveRoom" class="button--green">Leave</button>
        <button v-else @click="joinRoom" class="button--green">Join</button>
        <br />
        <div>
            マイク:
            <select v-model="selectedAudio" @change="onChange">
            <option disabled value="">Please select one</option>
            <option v-for="(audio, key, index) in audios" v-bind:key="index" :value="audio.value">
                {{ audio.text }}
            </option>
            </select>

            カメラ: 
            <select v-model="selectedVideo" @change="onChange">
            <option disabled value="">Please select one</option>
            <option v-for="(video, key, index) in videos" v-bind:key="index" :value="video.value">
                {{ video.text }}
            </option>
            </select>
        </div>

        <template v-for="message in messages">
            <p>{{message}}</p>
        </template>
    </div>
</template>

<script>
const API_KEY = "6d7fe6d0-40c7-4acd-9586-063dd7b633dd"; 
// const Peer = require('../skyway-js');
export default {
    data: function () {
        return {
            audios: [],
            videos: [],
            selectedAudio: '',
            selectedVideo: '',
            localStream: {},
            messages: [],
            roomId: "",
            remoteStreams: [],
            roomOpened: false
        }
    },
    methods: {
        // 端末のカメラ音声設定
        onChange: function(){
            if(this.selectedAudio != '' && this.selectedVideo != ''){
                this.connectLocalCamera();
            }
        },
        connectLocalCamera: async function(){
            const constraints = {
                audio: this.selectedAudio ? { deviceId: { exact: this.selectedAudio } } : false,
                video: this.selectedVideo ? { deviceId: { exact: this.selectedVideo } } : false
            }
            const stream = await navigator.mediaDevices.getUserMedia(constraints);
            document.getElementById('my-video').srcObject = stream;
            this.localStream = stream;
        },
        leaveRoom: function(){
            if (!this.peer.open) {
                return;
            }
            this.roomOpened = false;
t           this.remoteStreams = []; //追記2020/05/23
            this.room.close();
        },
        // 「div(joinTrigger)が押される&既に接続が始まっていなかったら接続」するリスナーを設置
        joinRoom: function(){
            if (!this.peer.open) {
                return;
            }
            this.roomOpened = true;
          //部屋に接続するメソッド(joinRoom)
            this.room = this.peer.joinRoom(this.roomId, {
                mode: "sfu",
                stream: this.localStream,
            });
          //部屋に接続できた時(open)に一度だけdiv(messages)に=== You joined ===を表示
            this.room.once('open', () => {
                this.messages.push('=== You joined ===');
            });
          //部屋に誰かが接続してきた時(peerJoin)、いつでもdiv(messages)に下記のテキストを表示
            this.room.on('peerJoin', peerId => {
                this.messages.push(`=== ${peerId} joined ===`);
            });
            //重要: streamの内容に変更があった時(stream)videoタグを作って流す
            this.room.on('stream', async stream => {
                await this.remoteStreams.push(stream);
            });

            //重要: 誰かがテキストメッセージを送った時、messagesを更新
            this.room.on('data', ({ data, src }) => {
                this.messages.push(`${src}: ${data}`);
            });

            // 誰かが退出した場合、div(remoteVideos)内にある、任意のdata-peer-idがついたvideoタグの内容を空にして削除する
            this.room.on('peerLeave', peerId => {
                const index = this.remoteStreams.findIndex((v) => v.peerId === peerId);
                const removedStream = this.remoteStreams.splice(index, 1);
                this.messages.push(`=== ${peerId} left ===`);
            });

            // 自分が退出した場合の処理
            this.room.once('close', () => {
               //メッセージ送信ボタンを押せなくする
                this.messages.length = 0;
            });
        }
    },

    created: async function(){
        const element = document.getElementById("room")
        const data = JSON.parse(element.getAttribute('data'))
        this.roomId = data.roomId
        //ここでpeerのリスナーを設置
        this.peer = new Peer({key: API_KEY, debug: 3}); //新規にPeerオブジェクトの作成

        //デバイスへのアクセス
        const deviceInfos = await navigator.mediaDevices.enumerateDevices();

        //オーディオデバイスの情報を取得
        deviceInfos
        .filter(deviceInfo => deviceInfo.kind === 'audioinput')
        .map(audio => this.audios.push({text: audio.label || `Microphone ${this.audios.length + 1}`, value: audio.deviceId}));

        //カメラの情報を取得
        deviceInfos
        .filter(deviceInfo => deviceInfo.kind === 'videoinput')
        .map(video => this.videos.push({text: video.label || `Camera  ${this.videos.length - 1}`, value: video.deviceId}));      
    }
}
</script>

<style scoped>
    p {
    font-size: 2em;
    text-align: center;
    }
</style>

vue化のコツ

本記事の趣旨とは異なりますが、素のJSをvueに書き換える時のコツです。
- 定数の定義をcreatedフックに集める
- クリック系のリスナーは全部関数に切り出してDOMの@ clickで発火するようにする
- その他のリスナーはcreatedフックに集める(又は、任意のアクション内)
- 変数をdataに整理する
- createElementやappendなどでDOMを挿入するケースは、dataとfor文をうまく使ってまとめる

videoタグのsrcをバインドさせる時、srcがオブジェクトの場合 :srcObject.prop="オブジェクト"という形で渡してあげないとエラーになります。

参考記事:vue.jsで複数のvideoタグを扱う
https://qiita.com/dbgso/items/271d903237b41dffcc6d

Rails側のコード

rails側の設定です。

rooms/show.html.erb

// roomIdを渡す処理
<% props = {
    roomId: "aiueo" 
  }.to_json
%>

<div id='room' data="<%= props %>">
    <room/>
</div>

<%= javascript_pack_tag 'room' %>
<%= stylesheet_pack_tag 'room' %>
routes.rb
Rails.application.routes.draw do
  get 'rooms/show'
  root 'rooms#show'
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
end
rooms_controller.rb
class RoomsController < ApplicationController
  def show
  end
end

結果

これでうまく動くとこんな感じです。
4つのウィンドウを開いてテストしています。

スクリーンショット 2019-12-23 0.38.47.png

残念なお知らせ

相手が部屋から出た時の挙動にバグがあります。近日中に直す予定です🙇‍♂️

追記:おそらく解決しました。コードに反映済み(2020/05)

最後に

今回はこれで以上です。
同時接続もSkyWayAPIを使うと楽チンです。
コピペでも動くと思うので、是非ご自身でも試してみてください。

17
10
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
17
10