diff --git a/src/components/VideoCall.vue b/src/components/VideoCall.vue new file mode 100644 index 00000000..954ec2f5 --- /dev/null +++ b/src/components/VideoCall.vue @@ -0,0 +1,112 @@ + + + + Open camera and mic + Start call + Join call + Hang up + + + + + + + + + + + + diff --git a/src/firebase/network.js b/src/firebase/network.js index 62d71cc2..f42e4e6a 100644 --- a/src/firebase/network.js +++ b/src/firebase/network.js @@ -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) } diff --git a/src/firebase/webrtc.js b/src/firebase/webrtc.js new file mode 100644 index 00000000..3cca05a8 --- /dev/null +++ b/src/firebase/webrtc.js @@ -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 diff --git a/src/router.js b/src/router.js index 883b5f6a..23aaec06 100644 --- a/src/router.js +++ b/src/router.js @@ -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 = [ @@ -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({