# [實作篇]WebRTC - Video Chat

# 目標

上一章節已經完成 Signaling server 的部分, 本章會實作簡易一對一視訊聊天室作為專案架構更接近實務應用的範例,

user story:

  • 能夠與線上的某人進行一對一視訊

# 實作

附上完整程式碼 - github

拆分需求後可能有幾個任務:

  • 連接上 socket server
  • 將 p2p SDP offer/answer 及 ICE 透過 socket 來傳遞
  • getUserMedia 獲取本地多媒體數據
  • RTCPeerConnection 處理連線等相關設定

# HTML

head 的部分:

<!-- ./public/index.html -->

<!-- .... -->
<head>
  <!-- ... -->
  <title>Video Chat With WebRTC</title>


  <script src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
  <script src="/socket.io/socket.io.js"></script>
  <script defer src="./js/main.js"></script>
</head>
<!-- .... -->

head 主要是載入三支 JS

  • adapter.js:由 google 提供的將 WebRTC 封裝並優化過相容性的套件。
  • socket.io.js:好用的 websocket 套件
  • main.js : 該專案的主程式

body 的部分:

<!-- ./public/index.html -->

<!-- ... -->
<body>
  <h1>Video Chat With WebRTC</h1>

  <div id="container">
    <section>
      <h1>Local Tracker</h1>
      <video id="localVideo" autoplay></video>
    </section>
    <section>
      <h1>Remote Receiver</h1>
      <video id="remoteVideo" autoplay></video>
    </section>

    <div class="box">
      <button onclick="connection()">Connection</button>
      <button onclick="calling()">Call</button>
      <button onclick="closing()">Hang Up</button>
    </div>
  </div>
</body>

兩個 Video tag 負責接收本地及遠端的視訊呈現, 三個按鈕分別負責:

  • connection(): 建立 socket 連線及事件綁定
  • calling(): 獲取本機端多媒體數據並建立 p2p connection
  • closing(): 關閉連線

# 主程式

接下來會實作上述三個主要功能。

  • connection()

    需要處理的:

    • 與 socket server 的連線
    • 加入指定房間
    • 幾個基本應用(新使用者加入/有人離開該房/斷線)
    • SDP offer/answer
    • ICE candidate
    function connection() {
      socket = io.connect("/");
    
      socket.emit("joinRoom", { username: "test" });
    
      // Socket events
      socket.on("newUser", (data) => {
        console.log("歡迎新人加入");
        console.log(data);
      });
    
      socket.on("userLeave", (data) => {
        console.log("有人離開了");
        console.log(data);
      });
    
      socket.on("disconnect", () => {
        console.log("你已經斷線");
      });
    
      socket.on("offer", handleSDPOffer);
      socket.on("answer", handleSDPAnswer);
      socket.on("icecandidate", handleNewIceCandidate);
    }
    
  • 接收SDP offer

    let peer = null; // RTCPeerConnection
    let cacheStream = null; // MediaStreamTrack
    
    // ...略
    
    async function handleSDPOffer(desc) {
      console.log("*** 收到遠端送來的offer");
      try {
        if (!peer) {
          createPeerConnection(); // create RTCPeerConnection instance
        }
    
        console.log(" = 設定 remote description = ");
        await peer.setRemoteDescription(desc);
    
        if (!cacheStream) {
          await addStreamProcess(); // getUserMedia & addTrack
        }
    
        await createAnswer();
      } catch (error) {
        console.log(`Error ${error.name}: ${error.message}`);
      }
    }
    
  • 接收SDP answer

    async function handleSDPAnswer(desc) {
      console.log("*** 遠端接受我們的offer並發送answer回來");
      try {
        await peer.setRemoteDescription(desc)
      } catch (error) {
        console.log(`Error ${error.name}: ${error.message}`);
      }
    }
    
  • 接收ICE candidate

    async function handleNewIceCandidate(candidate) {
      console.log(`*** 加入新取得的 ICE candidate: ${JSON.stringify(candidate)}`);
      try {
        await peer.addIceCandidate(candidate);
      } catch (error) {
        console.log(`Failed to add ICE: ${error.toString()}`);
      }
    }
    
  • 加入多媒體數據到RTCPeerConnection instance

    // Media config
    const mediaConstraints = {
      audio: false,
      video: {
        aspectRatio: {
          ideal: 1.333333, // 3:2 aspect is preferred
        },
      },
    };
    // ...略
    
    async function addStreamProcess() {
      let errMsg = "";
      try {
        console.log("獲取 local media stream 中 ...");
        const stream = await navigator.mediaDevices.getUserMedia(mediaConstraints);
    
        const localVideo = document.getElementById("localVideo");
        localVideo.srcObject = stream;
        cacheStream = stream;
      } catch (error) {
        errMsg = "getUserStream error ===> " + error.toString();
        throw new Error(errMsg);
      }
    
      try {
        // RTCPeerConnection.addTrack => 加入MediaStreamTrack
        cacheStream
          .getTracks()
          .forEach((track) => peer.addTrack(track, cacheStream));
      } catch (error) {
        errMsg = "Peer addTransceiver error ===> " + error.toString();
        throw new Error(errMsg);
      }
    }
    
  • 開啟 WebRTC 連線

    async function calling() {
      try {
        if (peer) {
          alert("你已經建立連線!");
        } else {
          createPeerConnection(); //建立 RTCPeerConnection
    
          await addStreamProcess(); // 加入多媒體數據到RTCPeerConnection instance
        }
      } catch (error) {
        console.log(`Error ${error.name}: ${error.message}`);
      }
    }
    
  • 建立 RTCPeerConnection

    function createPeerConnection() {
      console.log("create peer connection ...");
      peer = new RTCPeerConnection();
      peer.onicecandidate = handleIceCandidate; // 有新的ICE candidate 時觸發
      peer.ontrack = handleRemoteStream; // connection中發現新的 MediaStreamTrack時觸發
      peer.onnegotiationneeded = handleNegotiationNeeded;
    }
    

    這裡加入了三個event handler:

    • onicecandidate : 當查找到相對應的遠端端口時會透過該事件來處理將 icecandidate 傳輸給 remote peers。
    • ontrack : 完成連線後,透過該事件能夠在發現遠端傳輸的多媒體檔案時觸發,來處理/接收多媒體數據。
    • onnegotiationneeded : 每當 RTCPeerConnection 要進行會話溝通(連線)時,第一次也就是在addTrack後會觸發該事件, 通常會在此處理createOffer,來通知remote peer與我們連線。
  • 傳送 icecandidate

    function handleIceCandidate(event) {
      socket.emit("icecandidate", event.candidate);
    }
    
  • 獲取新的多媒體數據

    function handleRemoteStream(event) {
      const remoteVideo = document.getElementById("remoteVideo");
      if (remoteVideo.srcObject !== event.streams[0]) {
        remoteVideo.srcObject = event.streams[0];
      }
    }
    
  • 開始交涉(negotiation)

    async function handleNegotiationNeeded() {
      console.log("*** handleNegotiationNeeded fired!");
      try {
        console.log("start createOffer ...");
        await peer.setLocalDescription(await peer.createOffer(offerOptions));
        sendSDPBySignaling("offer", peer.localDescription);
      } catch (error) {
        console.log(`Error ${error.name}: ${error.message}`);
      }
    }
    
  • 關閉連線 closing()

    function closing() {
      console.log("Closing connection call");
      if (!peer) return; // 防呆機制
    
      // 1. 移除事件監聽
      peer.ontrack = null;
      peer.onicecandidate = null;
      peer.onnegotiationneeded = null;
    
      // 2. 停止所有在connection中的多媒體信息
      peer.getSenders().forEach((sender) => {
        peer.removeTrack(sender);
      });
    
      // 3. 暫停video播放,並將儲存在src裡的 MediaStreamTracks 依序停止
      const localVideo = document.getElementById("localVideo");
      if (localVideo.srcObject) {
        localVideo.pause();
        localVideo.srcObject.getTracks().forEach((track) => {
          track.stop();
        });
      }
    
      // 4. cleanup: 關閉RTCPeerConnection連線並釋放記憶體
      peer.close();
      peer = null;
      cacheStream = null;
    }
    

    上述程式用到以下幾個APIs :

# 總結

搭配Signaling server的整體流程,跟之前單頁式的應用類似, 差別在於,SDP offer/answer以及ICE的溝通方式變動, 以及新認識了一個事件onnegotiationneeded,確保將要進行會話的狀態,幫助我們更順暢的處理RTCPeerConnection連線流程。