Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Try out WebRTC video call, organized by Firestore #57

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions src/components/VideoCall.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<template>
<BigColumn>
<div class="buttons">
<button class="button" @click="openUserMedia">Open camera and mic</button>
<button class="button" @click="startCall">Start call</button>
<button class="button" @click="joinCall">Join call</button>
<button class="button" @click="hangUp">Hang up</button>
</div>

<div id="videos">
<video ref="localVideo" muted autoplay playsinline></video>
<video ref="remoteVideo" autoplay playsinline></video>
</div>
</BigColumn>
</template>

<script>
import BigColumn from './BigColumn.vue'
import {
offerCall,
RTC_CONFIG,
answerCall,
pushLocalCandidates,
listenRemoteCandidates,
} from '../firebase/webrtc'

// Sources for the <video>s
let localStream
let remoteStream

function addLocalTracks(localStream, peerConnection) {
// Attach user's webcam and audio to the webRTC connection, I think?
localStream.getTracks().forEach((track) => {
peerConnection.addTrack(track, localStream)
})
}

function listenRemoteTracks(remoteStream, peerConnection) {
// I _think_ this connects the received tracks to the remote <video>
peerConnection.addEventListener('track', (event) => {
console.log('Got remote track:', event.streams[0])
event.streams[0].getTracks().forEach((track) => {
console.log('Add a track to the remoteStream:', track)
remoteStream.addTrack(track)
})
})
}

export default {
components: {
BigColumn,
},
methods: {
// Gets permission from the user, then hooks up to local camera & mic feed
async openUserMedia() {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
})
localStream = stream
remoteStream = new MediaStream()

this.$refs.localVideo.srcObject = localStream
this.$refs.remoteVideo.srcObject = remoteStream
},
async startCall() {
const peerConnection = new RTCPeerConnection(RTC_CONFIG)

const callId = this.$route.params.id

addLocalTracks(localStream, peerConnection)
pushLocalCandidates(callId, peerConnection, 'callerCandidates')

listenRemoteTracks(remoteStream, peerConnection)

await offerCall(callId, peerConnection)

listenRemoteCandidates(callId, peerConnection, 'calleeCandidates')
},
async joinCall() {
const peerConnection = new RTCPeerConnection(RTC_CONFIG)
const callId = this.$route.params.id

addLocalTracks(localStream, peerConnection)
pushLocalCandidates(callId, peerConnection, 'calleeCandidates')

listenRemoteTracks(remoteStream, peerConnection)

await answerCall(callId, peerConnection)

// Has to go after remote description is set from answerCall
listenRemoteCandidates(callId, peerConnection, 'callerCandidates')
},
},
}
</script>

<style scoped>
div#videos {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}

div#videos > video {
background: black;
width: 640px;
height: 100%;
display: block;
margin: 1em;
}
</style>
2 changes: 1 addition & 1 deletion src/firebase/network.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ function roomDb() {
return 'rooms'
}

const db = firebase.firestore()
export const db = firebase.firestore()
export async function setRoom(room) {
await db.collection(roomDb()).doc(room.name).set(room)
}
Expand Down
122 changes: 122 additions & 0 deletions src/firebase/webrtc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { db } from './network'

// Free public STUN servers provided by Google.
// ICE = Interactive Connectivity Establishment, a way to coordinate between two devices
// https://en.wikipedia.org/wiki/Interactive_Connectivity_Establishment
export const RTC_CONFIG = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
// { urls: 'stun:stun2.l.google.com:19302' },
// { urls: 'stun:stun3.l.google.com:19302' },
// { urls: 'stun:stun4.l.google.com:19302' },
],
}

// An offer is... info about what kind of video streaming options are supported?
// Like, "hey, I'm gonna use the mp4 codec!". The caller is making a proposal.
export async function offerCall(callId, peerConnection) {
registerPeerConnectionListeners(peerConnection)
const offer = await peerConnection.createOffer()

await peerConnection.setLocalDescription(offer)

const callWithOffer = {
offer: {
type: offer.type,
// SDP = Session Description Protocol, with codec, source, and timing
// https://developer.mozilla.org/en-US/docs/Glossary/SDP
sdp: offer.sdp,
},
}

// This autogenerates a new call id
const callRef = db.collection('calls').doc(callId)
await callRef.set(callWithOffer)

// Now, wait for when a response gets put up
// TODO: does this need to be a separate function, for ordering?
callRef.onSnapshot(async (snapshot) => {
const data = snapshot.data()
if (!peerConnection.currentRemoteDescription && data.answer) {
// if no remote connection has been set yet, but we just got an answer, then connect!
await peerConnection.setRemoteDescription(data.answer)
}
})
}

// Responds with which media codecs we're going to use to connect
export async function answerCall(callId, peerConnection) {
const callRef = db.collection('calls').doc(callId)
const callDoc = await callRef.get()
const offer = callDoc.data().offer
console.log('Got offer:', offer)
await peerConnection.setRemoteDescription(offer)

const answer = await peerConnection.createAnswer()
await peerConnection.setLocalDescription(answer)

await callRef.update({
answer: {
type: answer.type,
sdp: answer.sdp,
},
})
}

// Just used for debugging, for now
function registerPeerConnectionListeners(peerConnection) {
peerConnection.addEventListener('icegatheringstatechange', () => {
console.log(
`ICE gathering state changed: ${peerConnection.iceGatheringState}`
)
})

peerConnection.addEventListener('connectionstatechange', () => {
console.log(`Connection state change: ${peerConnection.connectionState}`)
})

peerConnection.addEventListener('signalingstatechange', () => {
console.log(`Signaling state change: ${peerConnection.signalingState}`)
})

peerConnection.addEventListener('iceconnectionstatechange ', () => {
console.log(
`ICE connection state change: ${peerConnection.iceConnectionState}`
)
})
}

// Now, figure out which network connections (ports, etc) we can use
// These are called ICE candidates. The STUN (Session Traversal Utilities for NAT)
// server helps us match up on a good connection (?)
// We use Google's free STUN servers
export function pushLocalCandidates(callId, peerConnection, localName) {
const callRef = db.collection('calls').doc(callId)
const localCandidates = callRef.collection(localName)

// Post ICE candidates to Firestore when a new one is found from WebRTC
peerConnection.addEventListener('icecandidate', (event) => {
if (event.candidate) {
const json = event.candidate.toJSON()
console.log('Adding ice candidate', localName, json)
/* no await */ localCandidates.add(json)
}
})
}

export function listenRemoteCandidates(callId, peerConnection, remoteName) {
// When the remote posts a new ICE candidate, add it locally
const callRef = db.collection('calls').doc(callId)
callRef.collection(remoteName).onSnapshot((snapshot) => {
snapshot.docChanges().forEach((change) => {
if (change.type === 'added') {
const candidate = new RTCIceCandidate(change.doc.data())
console.log('Got candidate: ', remoteName, JSON.stringify(candidate))
/* no await */ peerConnection.addIceCandidate(candidate)
}
})
})
}

// Also: more boilerplate around every connecting
2 changes: 2 additions & 0 deletions src/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import IncryptFrontPage from './incrypt/IncryptFrontPage.vue'
import Incrypt from './incrypt/Incrypt.vue'
import PairwiseFrontPage from './pairwise/PairwiseFrontPage.vue'
import Pairwise from './pairwise/Pairwise.vue'
import VideoCall from './components/VideoCall.vue'
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
Expand Down Expand Up @@ -49,6 +50,7 @@ const routes = [
meta: { title: 'Pairwise' },
},
{ path: '/pairwise/:id', component: Pairwise, meta: { title: 'Pairwise' } },
{ path: '/call/:id', component: VideoCall },
]

export const router = createRouter({
Expand Down