FireBaseを使う(WebRTC)

実はWebRTCでルータ越えをやりたくてFirebaseについて調べていました。WebRTCの通信方式はP2PでStreaming自体にサーバーは必要無いのですが、P2Pを確立する段階でお互いの情報を交換する為にサーバ(シグナルサーバ)が必要になります。このサーバもしくはその機能を果たすものは無いかと探していたらFirebase Firestoreを見つけました。

P2P通信確立には、SDP: Peerの情報と ICE Candidate: 通信経路の情報の2つを交換する必要が有ります。Streamingを行う両者はシグナルサーバを介してデータを交換します。Firestoreはサーバでは無く単にデータBaseですが、リアルタイム アップデート機能によりデータの更新をお互いに知らせる事が出来ます。簡単なサーバの役目が出来ます。

WebRTCコードの入手

コードは Firebase + WebRTC の Codelabの ”3.サンプルコードを取得する” に従って入手します。
  ・ コマンドラインで git clone https://github.com/webrtc/FirebaseRTCと入力。
  ・ ダウンロード後、解凍。 
  ・ publicフォルダの ”app.js”、”index.html”、”main.css” を使用

”main.css”はそのまま。”app.js”、”index.html”、はコードを若干変更しています。これらのファイルを前回まで使用していたPC側の作業フォルダ ”test”の下の”public”にコピーします。”index.html”が重なるのでコピー側のファイル名を”WebRTC.html”と変更してコピーします。

最後に前回同様、認証後WebRTCを実行したいので ”index.html”の承認後実行するファイルを ’login.html’ から ’WebRTC.html’ に変更します。

最終的に出来た4つのファイル ”app.js”、”index.html”、”main.css”、”WebRTC.html”をここに保存します。

コードの実行と説明

先ずは1台のPCでブラウザを2つ上げてStreamingします。カメラは1つでOKです。アプリを実行しながらどの様にFirestoreにアクセスするかFirebase コンソールで確認して行きます。では1目のブラウザを準備。
  ・ モニターで作業用フォルダ ”test” に入り firebase serve –only hosting(エミュレータ)を実行。
  ・ URL ”http://localhost:5000″ が表示されるのを待つ。
  ・ ブラウザのURL欄に ”http://localhost:5000″ を入力。
すると承認を求める画面が表示されます。

ここで既に登録されている Email: test@aaa.com、Password: 123abc を入力すると下記の画面が表示されます。

これがアプリ本体です。左上タイトルの下にLoginユーザのメールアドレス”test@aaa.com”が表示されます。Loginしていないと以降の操作が正しく機能しません。Loginを確認して下さい。下に四角い黒い画面が2つ有りますが、向かって左側に自分の、右側に相手のStreamingが表示されます。

まずはカメラの許可を得て左側に自分のStreamingを表示します。”OPEN CAMERA & MICROPHONEボタン” を押してしばらく待つと、カメラ使用の許可を求めるPOP UPが表示されます。ここで ”許可するボタン” を押すと画面は下記右側の様に自分のStreamingを開始します。これで1個目のブラウザ準備完了です。

次にもう一つブラウザを用意します。
 ・ ブラウザの新しいタブを開いてURL欄に ”http://localhost:5000″ を入力
 ・ 先程と同様承認(同じEmail Pass wordでOK)し WebRTCの画面を表示
 ・ ”OPEN CAMERA & MICROPHONEボタン”を押してStreaming開始

下記左が最初のブラウザ、右が2つ目のブラウザです。カメラが1つなので2つ供同じ画像を表示します。これら2つのブラウザ間でStreamingを行います。Streamingが始まるとお互い右側の画面に相手の画像が表示されます。

ここまでアプリはFirestoreにアクセスしていません。アプリを開始する前にFirestoreのデータを全て消去してこれを初期状態としています。FirebaseコンソールでFirestoreを見るとデータが無い(アクセスされていない)事が分かります。

”app.js”内のコマンドでFirestoreににアクセスします。下記は、”app.js”のコードです。アプリはこれ以降Firestoreににアクセスして行きます。

app.js

mdc.ripple.MDCRipple.attachTo(document.querySelector('.mdc-button'));

// DEfault configuration - Change these if you have a different STUN or TURN server.
const configuration = {
  iceServers: [
    {
      urls: [
        'stun:stun1.l.google.com:19302',
        'stun:stun2.l.google.com:19302',
      ],
    },
  ],
  iceCandidatePoolSize: 10,
};

let peerConnection = null;
let localStream = null;
let remoteStream = null;
let roomDialog = null;
let roomId = null;

function init() {
  document.querySelector('#cameraBtn').addEventListener('click', openUserMedia);
  document.querySelector('#hangupBtn').addEventListener('click', hangUp);
  document.querySelector('#createBtn').addEventListener('click', createRoom);
  document.querySelector('#joinBtn').addEventListener('click', joinRoom);
  roomDialog = new mdc.dialog.MDCDialog(document.querySelector('#room-dialog'));
}

async function createRoom() {
  document.querySelector('#createBtn').disabled = true;
  document.querySelector('#joinBtn').disabled = true;
  const db = firebase.firestore();

  console.log('Create PeerConnection with configuration: ', configuration);
  peerConnection = new RTCPeerConnection(configuration);

  registerPeerConnectionListeners();

  localStream.getTracks().forEach(track => {
    peerConnection.addTrack(track, localStream);
  });

 // Add code for creating a room here
  const offer = await peerConnection.createOffer();
  await peerConnection.setLocalDescription(offer);
  
  const roomWithOffer = {
      offer: {
          type: offer.type,
          sdp: offer.sdp
      }
  }
  const roomRef = await db.collection('rooms').add(roomWithOffer);
//  const roomId = roomRef.id;
  roomId = roomRef.id;
  document.querySelector('#currentRoom').innerText = `Current room is ${roomId} - You are the caller!`
  // Code for creating room above
  
  // Code for collecting ICE candidates below
  collectIceCandidates(roomRef, peerConnection, 'callerCandidates', 'calleeCandidates');
  // Code for collecting ICE candidates above

  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);
    });
  });

  // Listening for remote session description below
  roomRef.onSnapshot(async snapshot => {
    console.log('Got updated room:', snapshot.data());
    const data = snapshot.data();
    if (!peerConnection.currentRemoteDescription && data.answer) {
        console.log('Set remote description: ', data.answer);
        const answer = new RTCSessionDescription(data.answer);
        await peerConnection.setRemoteDescription(answer);
    } 
  });
  // Listening for remote session description above

}

function joinRoom() {
  document.querySelector('#createBtn').disabled = true;
  document.querySelector('#joinBtn').disabled = true;

  document.querySelector('#confirmJoinBtn').addEventListener('click', async () => {
    roomId = document.querySelector('#room-id').value;
    console.log('Join room: ', roomId);
    document.querySelector('#currentRoom').innerText = `Current room is ${roomId} - You are the callee!`;
    await joinRoomById(roomId);
  }, {once: true});
  roomDialog.open();
}

async function joinRoomById(roomId) {
  const db = firebase.firestore();
  const roomRef = db.collection('rooms').doc(`${roomId}`);
  const roomSnapshot = await roomRef.get();
  console.log('Got room:', roomSnapshot.exists);

  if (roomSnapshot.exists) {
    console.log('Create PeerConnection with configuration: ', configuration);
    peerConnection = new RTCPeerConnection(configuration);
    registerPeerConnectionListeners();
    localStream.getTracks().forEach(track => {
      peerConnection.addTrack(track, localStream);
    });

    // Code for collecting ICE candidates below
    collectIceCandidates(roomRef, peerConnection, 'calleeCandidates', 'callerCandidates');
    // Code for collecting ICE candidates above

    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);
      });
    });

    // Code for creating SDP answer below
    const offer = roomSnapshot.data().offer;
    await peerConnection.setRemoteDescription(offer);
    const answer = await peerConnection.createAnswer();
    await peerConnection.setLocalDescription(answer);
    
    const roomWithAnswer = {
        answer: {
            type: answer.type,
            sdp: answer.sdp
        }
    }
    await roomRef.update(roomWithAnswer);
    
    // Code for creating SDP answer above

  }
}

async function openUserMedia(e) {
  const stream = await navigator.mediaDevices.getUserMedia(
      {video: true, audio: true});
  document.querySelector('#localVideo').srcObject = stream;
  localStream = stream;
  remoteStream = new MediaStream();
  document.querySelector('#remoteVideo').srcObject = remoteStream;

  console.log('Stream:', document.querySelector('#localVideo').srcObject);
  document.querySelector('#cameraBtn').disabled = true;
  document.querySelector('#joinBtn').disabled = false;
  document.querySelector('#createBtn').disabled = false;
  document.querySelector('#hangupBtn').disabled = false;
}

async function hangUp(e) {
  const tracks = document.querySelector('#localVideo').srcObject.getTracks();
  tracks.forEach(track => {
    track.stop();
  });

  if (remoteStream) {
    remoteStream.getTracks().forEach(track => track.stop());
  }

  if (peerConnection) {
    peerConnection.close();
  }

  document.querySelector('#localVideo').srcObject = null;
  document.querySelector('#remoteVideo').srcObject = null;
  document.querySelector('#cameraBtn').disabled = false;
  document.querySelector('#joinBtn').disabled = true;
  document.querySelector('#createBtn').disabled = true;
  document.querySelector('#hangupBtn').disabled = true;
  document.querySelector('#currentRoom').innerText = '';

  // Delete room on hangup
  if (roomId) {
  console.log("Enter");
    const db = firebase.firestore();
    const roomRef = db.collection('rooms').doc(roomId);
    
    const calleeCandidates = await roomRef.collection('calleeCandidates').get();
    calleeCandidates.forEach(async candidate => {
//      await candidate.delete();
      await candidate.ref.delete();
    });
    const callerCandidates = await roomRef.collection('callerCandidates').get();
    callerCandidates.forEach(async candidate => {
//      await candidate.delete();
      await candidate.ref.delete();
    });
    
  await roomRef.delete();
  }

//  document.location.reload(true);
}

function registerPeerConnectionListeners() {
  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}`);
  });
}

async function collectIceCandidates(roomRef, peerConnection, callerCandidates, calleeCandidates) {
  const candidatesCollection = roomRef.collection(callerCandidates);

  peerConnection.addEventListener('icecandidate', event => {
    if (event.candidate) {
      const json = event.candidate.toJSON();
      candidatesCollection.add(json);
    } 
  });

  roomRef.collection(calleeCandidates).onSnapshot(snapshot => {
    snapshot.docChanges().forEach(change => {
      if (change.type === "added") {
        const candidate = new RTCIceCandidate(change.doc.data());
        peerConnection.addIceCandidate(candidate);
      }
    });
  });
}


init();

const auth = firebase.auth();

window.addEventListener('beforeunload', function (e) {
  signOut();
});

function signOut() {
  firebase.auth().signOut().then(() => {
        console.log('ログアウトしました');
        location.reload();
      })
      .catch((error) => {
        console.log(`ログアウト時にエラーが発生しました (${error})`);
      });
}

firebase.auth().onAuthStateChanged((user) => {
  var str = 'none';
  if (user) {
      str = user.email;
  }
  document.getElementById('state').innerHTML = str;
});

先ず発信側で

 ”CREATE ROOMボタン” をクリックするとボタンの下にコメントが表示されFirestoreが以下の様になります。

ボタンを押すと”app.js” 30行 async function createRoom() { が実行されます。

54行 const roomRef = await db.collection(‘rooms’).add(roomWithOffer); がFirestoreへのアクセスしています。このコマンドは .add() 形式なのでコレクション ’rooms’ に任意のドキュメントID(今回は bkEFLP3UEmGWhgDakcP8)でデータ ”roomWithOffer” を書き込みます。ちなみにこのドキュメント内のデータがSDPの”offer”になります。

ドキュメント下のコレクション ”callerCandidates” は61行 collectIceCandidates(roomRef, peerConnection, ‘callerCandidates’, ‘calleeCandidates’); で作製されます。 コマンドの本体は224行に有ります。230行candidatesCollection.add(json); でFirestoreに書き込んでいます。これでドキュメント(bkEFLP3UEmGWhgDakcP8)の下に ”callerCandidates” コレクションを作り、任意ドキュメントID(.add()形式)ドキュメントにデータ ”json” を書き込みます。このコレクションはICE Candidate登録用でCandidateが見つかり次第ここに登録されます。

234行 .onSnapshot()がコレクション ”calleeCandidates”に使用されています。このコレクションは相手側のICE Candidateが登録される場所です。つまり相手側がここにICE Candidateを保存すると.onSnapshot()機能により発信側にその事が通知されます。この通知を受けて発信側は相手のICE CandidateをFirestoreから読み込み登録します。

発信側: コレクション ”callerCandidates” ー> 自分のICE Candidateを登録する。
     コレクション ”calleeCandidates” ー> 相手がICE Candidateを登録する。
                        データが登録されると.onSnapshot()機能で通知が入る。

最後に73行 roomRef.onSnapshot(async snapshot => { で相手がAnswerデータを書き込んだら通知するよう .onSnapshot() 設定しています。

受信側

受信側は ”JOIN ROOMボタン” を押すとPOP UPが表示されます。POP UPの入力欄に発信側で表示されたドキュメントID ”bkEFLP3UEmGWhgDakcP8”を入力して POP UPの ”JOINボタン” を押します。しばらくして黒かった右側の画面に画像が表示されP2P接続完了。Streamingが始まります。

”JOIN ROOMボタン” が押されると、86行 function joinRoom() { が起動しその後 async function joinRoomById(roomId) { に移動します。114行 collectIceCandidates(roomRef, peerConnection, ‘calleeCandidates’, ‘callerCandidates’); で発信側同様 コレクション ”callerCandidates”、”calleeCandidates”の製作に入るのですが、引数が発信側と逆になっています。よって受信側では、コレクション”calleeCandidates”を作製して”callerCandidates”の通知待ち設定を行います。

受信側: コレクション ”calleeCandidates” ー> 自分のICE Candidateを登録する。
     コレクション ”callerCandidates” ー> 相手がICE Candidateを登録する。
                        データが登録されると.onSnapshot()機能で通知が入る。

これで互いに自身のICE Candidateを登録するとその事が相手側に通知されます。

その後、126行で 発信側の Offerデータを読み込み登録。137行 await roomRef.update(roomWithAnswer); でAnswerが登録されます。このドキュメントには既に発信側の”Offer”データが有る為、その値を消さないよう .update()機能を使用しています。またこのドキュメントは発信側で.onSnapshot()登録しています。受信側でここにデータを書き込めば発信側に通知が入ります。この時点でFirestoreは以下の様になっています。

”callerCandidates”、”calleeCandidates”間でデータのやり取りが行われ条件が有ったところでP2P通信が確立されStreamingが始まります。

終了処理

”HUNGUPボタン” を押すとStreamingが終了して画面が初期状態に戻ります。

この作業は、159行 async function hangUp(e) { で行っています。 ここでFirestoreに登録したデータの削除が行われています。ドキュメントの削除は .delete() 機能を使用しますが、ドキュメントの下にコレクションが有る場合、そのコレクションの中身を全て削除しないとそのドキュメントを削除する事が出来ません。今回は、ドキュメントの下に”callerCandidates”、”calleeCandidates”と言う2つのコレクションが有るのでこれらコレクションのデータを消す必要が有ります。187から191行で”calleeCandidates”の内部データを、192から196行で”callerCandidates”の内部データを削除しています。最後に、198行でドキュメントを削除しています。Firestoreは内部ドキュメントが無くなるとコレクションが自動で削除される様です。198行でドキュメントを削除した時点でroomsコレクションのドキュメントがなくなりFirestoreからコレクションroomsが自動で削除されます。つまりブラウザの画面が初期状態になった状態でFirestoreのroomsコレクションが無くなります。

最後にルータ越えを試す

作業用フォルダで firebase deploy を実行してHPを公開します。firebase deploy を実行し最後にHPのURLが表示されます。今回は Hosting URL: https://test-ac846.web.app と表示されました。

先ずルータ内のPCでブラウザを上げて上記URLを入力。もう一台はスマホのWiFiを切ってモバイル接続にしブラウザを上げて上記のURLを入力しました。同様の手順で進めた所、ちゃんとStreaming出来ました。つまりルータを越えてアクセス出来ました。目標達成です。多分この方法で一般家庭レベルのルータで有れば越える事が出来ると思います。

最後に

Firebaseの導入から初めて今回で4回目ですが、やっと本来の目的であるルータ越えが出来ました。認証機能も付いた運用費用0円のデータベースです。有り難い事です。