はじめに
先日のハッカソン型インターンで使用したWebRTCという技術がとても興味深くまたFirebaseを用いることで簡単に実装ができたのでVue.jsとFirebaseを用いたWebRTCの実装方法を紹介します。
Demo、Githubは下記からアクセスできます。
Demo: https://webrtcqiita.web.app/
Github: https://github.com/hond0413/webrtc
WebRTCとは
WebRTCはビデオ、音声、及び一般的なデータをPeer間で送信することをサポートしたものです。全てのモダンブラウザと、主要プラットフォームのネイティブクライアントで利用可能です。
https://webrtc.org/
準備
- Vue: 2.6.11
- Firebase: 8.1.1(ver 9以降はfirebase.jsの書き方が異なります。ご注意ください。)
Vueプロジェクトの作成
初めに下記手順でVueプロジェクトを作成します。vue-cliを用いたプロジェクトの作成方法を理解している方はvue-router、Vuetifyの設定をしてFirebase、Firestoreの設定まで飛ばしてもらって大丈夫です。
$ cd [working directory]
$ vue create [project name]
下記のように表示されるので vue-router導入のためManually select featuresを選択してください。
? Please pick a preset:
Default ([Vue 2] babel, eslint)
Default (Vue 3 Preview) ([Vue 3] babel, eslint)
❯ Manually select features
下記のように表示されるのでRouterを追加で選択してください。
? Check the features needed for your project:
◉ Choose Vue version
◉ Babel
◯ TypeScript
◯ Progressive Web App (PWA) Support
❯◉ Router
◯ Vuex
◯ CSS Pre-processors
◉ Linter / Formatter
◯ Unit Testing
◯ E2E Testing
今回はVue2を使用しているため2.xを選択してください。
? Choose a version of Vue.js that you want to start the project with (Use arrow
keys)
❯ 2.x
3.x (Preview)
今回はどちらを選択しても問題はありませんがhistoryを使用します。
? Use history mode for router? (Requires proper server setup for index fallback
in production) (Y/n)
Lintもデフォルトのもので問題ありません。
? Pick a linter / formatter config: (Use arrow keys)
❯ ESLint with error prevention only
ESLint + Airbnb config
ESLint + Standard config
ESLint + Prettier
下記もEnterで大丈夫です。
? Pick additional lint features: (Press <space> to select, <a> to toggle all, <i
> to invert selection)
❯◉ Lint on save
◯ Lint and fix on commit
下記もEnterで大丈夫です。
? Where do you prefer placing config for Babel, ESLint, etc.? (Use arrow keys)
❯ In dedicated config files
In package.json
設定を保存する必要はないのでNで次に進みます。
? Save this as a preset for future projects? (y/N)
最後にVueプロジェクトの作成が成功したら下記のように表示されるのでプロジェクトディレクトリに移動してVuetifyを追加しましょう。
13 packages are looking for funding
run `npm fund` for details
⚓ Running completion hooks...
📄 Generating README.md...
🎉 Successfully created project test.
👉 Get started with the following commands:
$ cd [project name]
$ npm run serve
$ cd [project name]
$ vue add vuetify
vuetifyの設定はEnterで問題ありません。
Firebase、Firestoreの設定
- Firebase及びFirestoreの設定は下記記事がとても参考になるので参照してください。
https://qiita.com/Dchi412/items/e0859e5df65de6f78591 - 事前にFirestoreに"calls"という名前のcollectionを追加しておいてください。
.envファイルの設定
vueのプロジェクトディレクトリ直下に.envファイルを設けそこにFirebaseのSDKを設定してください。デフォルトで.envは.gitignoreに追加されていないので追加することを強くお勧めします。
VUE_APP_API_KEY=****************
VUE_APP_AUTH_DOMAIN=****************
VUE_APP_DATABASE_URL=****************
VUE_APP_PROJECT_ID=****************
VUE_APP_STORAGE_BUCKET=****************
VUE_APP_MESSAGING_SENDER_ID=****************
VUE_APP_ID=****************
VUE_APP_MEASUREMENT_ID=****************
main.tsの設定
今回はFirebaseのSDKを環境変数として定義したためmain.tsのfirebaseConfigでは環境変数を呼び出します。
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
Vue.config.productionTip = false
import firebase from 'firebase'
import vuetify from './plugins/vuetify'
const firebaseConfig = {
apiKey: process.env.VUE_APP_API_KEY,
authDomain: process.env.VUE_APP_AUTH_DOMAIN,
databaseURL: process.env.VUE_APP_DATABASE_URL,
projectId: process.env.VUE_APP_PROJECT_ID,
storageBucket: process.env.VUE_APP_STORAGE_BUCKET,
messagingSenderId: process.env.VUE_APP_MESSAGING_SENDER_ID,
appId: process.env.VUE_APP_APP_ID,
measurementId: process.env.VUE_APP_MEASUREMENT_ID
};
// Initialize Firebase
if (!firebase.apps.length) {
firebase.initializeApp(firebaseConfig)
}
new Vue({
router,
store,
vuetify,
render: h => h(App)
}).$mount('#app')
componentの作成
以下よりVueプロジェクトのcomponentのコードの詳細を解説します。
WebRTC component
- WebRTCの処理を行うcomponentは以下のようになっております。コード全体下に主要部の解説します。
<template>
<div>
<v-row>
<v-col>
<video class="media_v" id="webcamVideo" autoplay playsinline></video>
</v-col>
<v-col>
<video class="media_v" id="remoteVideo" autoplay playsinline></video>
</v-col>
</v-row>
</div>
</template>
<script>
import firebase from "firebase"
import "firebase/firestore"
export default {
name: "Webrtc",
data () {
return {
db: null,
servers: {
iceServers: [
{
urls: [
"stun:stun1.l.google.com:19302",
],
},
],
iceCandidatePoolSize: 10,
},
localStream: null,
remoteStream: null,
pc: null,
webcamVideo: null,
remoteVideo: null,
}
},
async created() {
this.db = firebase.firestore()
await this.startWebcam();
const name = this.$route.query.name
if (name) {
await this.createCall();
} else {
await this.answerBtn(this.$route.query.id);
}
},
mounted() {
this.pc = new RTCPeerConnection(this.servers);
this.webcamVideo = document.getElementById("webcamVideo");
this.remoteVideo = document.getElementById("remoteVideo");
},
methods: {
async startWebcam() {
this.localStream = await navigator.mediaDevices.getUserMedia({
video: true,
// audio: true,
}).
catch(error => {
console.error("can't connect mediadevices")
})
this.remoteStream = new MediaStream();
this.localStream.getTracks().forEach((track) => {
this.pc.addTrack(track, this.localStream);
});
this.pc.ontrack = (event) => {
console.log(event)
event.streams[0].getTracks().forEach((track) => {
this.remoteStream.addTrack(track);
});
};
this.webcamVideo.srcObject = this.localStream;
this.remoteVideo.srcObject = this.remoteStream;
},
async createCall() {
const callDoc = this.db.collection("calls").doc();
const offerCandidates = callDoc.collection("offerCandidates");
const answerCandidates = callDoc.collection("answerCandidates");
this.pc.onicecandidate = (event) => {
event.candidate && offerCandidates.add(event.candidate.toJSON());
};
const offerDescription = await this.pc.createOffer();
await this.pc.setLocalDescription(offerDescription);
const offer = {
sdp: offerDescription.sdp,
type: offerDescription.type,
};
await callDoc.set({offer});
await callDoc.update({ name: this.$route.query.name });
callDoc.onSnapshot((snapshot) => {
const data = snapshot.data();
if (!this.pc.currentRemoteDescription && data?.answer) {
const answerDescription = new RTCSessionDescription(data.answer);
this.pc.setRemoteDescription(answerDescription);
}
});
answerCandidates.onSnapshot((snapshot) => {
snapshot.docChanges().forEach((change) => {
if (change.type === "added") {
const candidate = new RTCIceCandidate(change.doc.data());
this.pc.addIceCandidate(candidate);
}
});
});
},
async answerBtn(val) {
const callDoc = this.db.collection("calls").doc(val);
const answerCandidates = callDoc.collection("answerCandidates");
const offerCandidates = callDoc.collection("offerCandidates");
this.pc.onicecandidate = (event) => {
event.candidate && answerCandidates.add(event.candidate.toJSON());
};
const callData = (await callDoc.get()).data();
const offerDescription = callData.offer;
await this.pc.setRemoteDescription(
new RTCSessionDescription(offerDescription)
);
const answerDescription = await this.pc.createAnswer();
await this.pc.setLocalDescription(answerDescription);
const answer = {
type: answerDescription.type,
sdp: answerDescription.sdp,
};
await callDoc.update({ answer });
offerCandidates.onSnapshot((snapshot) => {
snapshot.docChanges().forEach((change) => {
if (change.type === "added") {
let data = change.doc.data();
this.pc.addIceCandidate(new RTCIceCandidate(data));
}
});
});
}
}
}
</script>
WebRTC コネクションの実現
RTCPeerConnectionインターフェイスを使用してWebRTC コネクションを表現します。
mounted() {
this.pc = new RTCPeerConnection(this.servers);
<--省略-->
}
RTCPeerConnectionに関する詳しい情報は下記を参照すると良いです。
https://developer.mozilla.org/ja/docs/Web/API/RTCPeerConnection
startWebcam method
- 下記コードでメディアデバイスの設定をすることでwebカメラを用いた通信を可能にしています。
async startWebcam() {
this.localStream = await navigator.mediaDevices.getUserMedia({
video: true,
// audio: true,
}).
catch(error => {
console.error("can't connect mediadevices")
})
this.remoteStream = new MediaStream();
this.localStream.getTracks().forEach((track) => {
this.pc.addTrack(track, this.localStream);
});
this.pc.ontrack = (event) => {
console.log(event)
event.streams[0].getTracks().forEach((track) => {
this.remoteStream.addTrack(track);
});
};
this.webcamVideo.srcObject = this.localStream;
this.remoteVideo.srcObject = this.remoteStream;
},
- 下記コードでメディアデバイスの検出設定を行っています。自分のローカル環境で二つのタブを開いてWebRTCのテストを行う場合audioを使用しているとハウリングの原因となるためコメントアウトしています。実際にaudioを利用して遠くの方と通話を行いたい方はコメントアウトを解除して実装してもらうと可能になります。
this.localStream = await navigator.mediaDevices.getUserMedia({
video: true,
// audio: true,
}).
- 先ほど登録したtracksを取得してpeer connectionにセットします。
this.localStream.getTracks().forEach((track) => {
this.pc.addTrack(track, this.localStream);
});
- peer connectionから通信相手のtracksを取得してremote streamに追加します。
this.pc.ontrack = (event) => {
console.log(event)
event.streams[0].getTracks().forEach((track) => {
this.remoteStream.addTrack(track);
});
};
createCall method
- 下記コードではホスト側(ルーム作成側)の処理を行なっています。
async createCall() {
const callDoc = this.db.collection("calls").doc();
const offerCandidates = callDoc.collection("offerCandidates");
const answerCandidates = callDoc.collection("answerCandidates");
this.pc.onicecandidate = (event) => {
event.candidate && offerCandidates.add(event.candidate.toJSON());
};
const offerDescription = await this.pc.createOffer();
await this.pc.setLocalDescription(offerDescription);
const offer = {
sdp: offerDescription.sdp,
type: offerDescription.type,
};
await callDoc.set({offer});
await callDoc.update({ name: this.$route.query.name });
callDoc.onSnapshot((snapshot) => {
const data = snapshot.data();
if (!this.pc.currentRemoteDescription && data?.answer) {
const answerDescription = new RTCSessionDescription(data.answer);
this.pc.setRemoteDescription(answerDescription);
}
});
answerCandidates.onSnapshot((snapshot) => {
snapshot.docChanges().forEach((change) => {
if (change.type === "added") {
const candidate = new RTCIceCandidate(change.doc.data());
this.pc.addIceCandidate(candidate);
}
});
});
},
- 下記コードで事前に準備しておいた"calls" collectionにdocumentを追加します。なおこの処理では自動でidをふりわかるためdocumentにid等の指定はしません。また、"calls" collection下に"offerCandidates"、"answerCandidates" collectionを設けます。これらはstun serverにアクセスして得られた情報等を格納するために使います。
const callDoc = this.db.collection("calls").doc();
const offerCandidates = callDoc.collection("offerCandidates");
const answerCandidates = callDoc.collection("answerCandidates");
- offer側のCandidateを取得しFirebaseに追加
this.pc.onicecandidate = (event) => {
event.candidate && offerCandidates.add(event.candidate.toJSON());
};
const offerDescription = await this.pc.createOffer();
await this.pc.setLocalDescription(offerDescription);
const offer = {
sdp: offerDescription.sdp,
type: offerDescription.type,
};
await callDoc.set({offer});
await callDoc.update({ name: this.$route.query.name });
- Remote answerの情報を読み込みDescriptionに追加
callDoc.onSnapshot((snapshot) => {
const data = snapshot.data();
if (!this.pc.currentRemoteDescription && data?.answer) {
const answerDescription = new RTCSessionDescription(data.answer);
this.pc.setRemoteDescription(answerDescription);
}
});
- Firebaseに変更が会った時読み込みCandidateを追加する
answerCandidates.onSnapshot((snapshot) => {
snapshot.docChanges().forEach((change) => {
if (change.type === "added") {
const candidate = new RTCIceCandidate(change.doc.data());
this.pc.addIceCandidate(candidate);
}
});
});
answerBtn method
- 上記methodではcreateCallと同様の処理を行います。
CreateRoom component
- 上記componentではroomのhost側がroomを作成するための処理を行います。
<template>
<v-card>
<v-card-text>
<v-text-field v-model="roomName" label="RoomName" required />
</v-card-text>
<v-card-actions>
<v-btn @click="roomCreate(roomName)">
Create
</v-btn>
<v-btn @click="clear()">
Clear
</v-btn>
</v-card-actions>
</v-card>
</template>
<script>
export default {
data() {
return {
roomName: ""
}
},
methods: {
roomCreate(val) {
this.$router.push({path: 'video', query: { name: val }})
},
clear() {
this.roomName = ""
}
}
}
</script>
RoomTable component
- 上記componentではFirebaseに登録してあるRoomの一覧を表示しJoinボタンを押すことでRoomに参加することができます。
<template>
<v-simple-table>
<template v-slot:default>
<thead>
<tr>
<th class="text-left">
RoomName
</th>
<th class="text-left">
Join
</th>
</tr>
</thead>
<tbody>
<tr v-for="item in tableDataset" :key="item.id">
<td>{{ item.data().name }}</td>
<td>
<v-btn @click="toVideo(item.id)">Join</v-btn>
</td>
</tr>
</tbody>
</template>
</v-simple-table>
</template>
<script>
import firebase from "firebase"
import "firebase/firestore"
export default {
name: "RoomTable",
data() {
return {
db: null,
tableDataset: [],
}
},
async created() {
this.db = firebase.firestore()
this.tableData = this.db.collection("calls").get().
then(
(querySnapshot) => {
querySnapshot.forEach((doc) => {
this.tableDataset.push(doc)
})
}
)
},
methods: {
toVideo(val) {
this.$router.push({path: 'video', query: { id: val }})
}
}
}
</script>
- 以上で主要componentsの作成は終了です。views等はGithubを参照してください。
実行方法
- 以下コマンド実行しプロジェクトを実行します。
$ cd [working directory]/[project name]
$ npm run serve
おわりに
FirebaseとVue.jsを使うことで簡単にWebRTCを実装することができます。この記事が参考になったら幸いです。
参考
https://webrtc.org/
https://qiita.com/Dchi412/items/e0859e5df65de6f78591
https://fireship.io/lessons/webrtc-firebase-video-chat/