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
//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);
})();
<!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を取得するようにしています。
<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側の設定です。
// roomIdを渡す処理
<% props = {
roomId: "aiueo"
}.to_json
%>
<div id='room' data="<%= props %>">
<room/>
</div>
<%= javascript_pack_tag 'room' %>
<%= stylesheet_pack_tag 'room' %>
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
class RoomsController < ApplicationController
def show
end
end
#結果
これでうまく動くとこんな感じです。
4つのウィンドウを開いてテストしています。
#残念なお知らせ
相手が部屋から出た時の挙動にバグがあります。近日中に直す予定です🙇♂️
追記:おそらく解決しました。コードに反映済み(2020/05)
#最後に
今回はこれで以上です。
同時接続もSkyWayAPIを使うと楽チンです。
コピペでも動くと思うので、是非ご自身でも試してみてください。