信令與視訊通話

WebRTC 允許兩個裝置之間進行即時的點對點媒體交換。連線透過一個稱為信令的發現和協商過程建立。本教程將指導你構建一個雙向視訊通話。

WebRTC 是一種完全點對點技術,用於即時交換音訊、影片和資料,但有一個核心注意事項。正如其他地方討論的,為了讓不同網路上的兩個裝置相互定位,必須進行某種形式的發現和媒體格式協商。這個過程稱為信令,涉及兩個裝置連線到一個第三方、雙方都同意的伺服器。透過這個第三方伺服器,兩個裝置可以相互定位並交換協商訊息。

在本文中,我們將進一步增強以支援使用者之間開啟雙向視訊通話。你可以在 Render 上試用此示例,以便進行實驗。你也可以在 GitHub 上檢視完整專案

信令伺服器

在兩個裝置之間建立 WebRTC 連線需要使用信令伺服器來解決如何透過網際網路連線它們。信令伺服器的任務是充當中間人,讓兩個對等端在儘可能減少潛在私人資訊暴露的情況下找到並建立連線。我們如何建立這個伺服器,以及信令過程實際上是如何工作的?

首先,我們需要信令伺服器本身。WebRTC 沒有指定信令資訊的傳輸機制。你可以使用任何你喜歡的方式,從 WebSocketfetch(),甚至信鴿,來交換兩個對等端之間的信令資訊。

重要的是要記住,伺服器不需要理解或解釋信令資料內容。儘管它是 SDP,但這並不那麼重要:透過信令伺服器的訊息內容實際上是一個黑盒。重要的是當 ICE 子系統指示你向另一個對等端傳送信令資料時,你照做,並且另一個對等端知道如何接收此資訊並將其傳遞給其自己的 ICE 子系統。你所要做的就是來回傳遞資訊。內容對信令伺服器來說根本不重要。

準備聊天伺服器以進行信令

我們的 聊天伺服器 使用 WebSocket API 在每個客戶端和伺服器之間以 JSON 字串形式傳送資訊。伺服器支援多種訊息型別來處理任務,例如註冊新使用者、設定使用者名稱和傳送公共聊天訊息。

為了讓伺服器支援信令與 ICE 協商,我們需要更新程式碼。我們必須允許將訊息定向到特定的一個使用者,而不是廣播給所有連線的使用者,並確保未識別的訊息型別被傳遞和送達,而無需伺服器瞭解它們是什麼。這使我們能夠使用相同的伺服器傳送信令訊息,而不是需要一個單獨的伺服器。

讓我們看看我們需要對聊天伺服器進行哪些更改以支援 WebRTC 信令。這在檔案 chatserver.js 中。

首先是新增函式 sendToOneUser()。顧名思義,它向特定使用者名稱傳送一個字串化的 JSON 訊息。

js
function sendToOneUser(target, msgString) {
  connectionArray.find((conn) => conn.username === target).send(msgString);
}

此函式遍歷連線使用者列表,直到找到與指定使用者名稱匹配的使用者,然後向該使用者傳送訊息。引數 msgString 是一個字串化的 JSON 物件。我們可以讓它接收我們的原始訊息物件,但在這個示例中,這種方式更高效。由於訊息已經字串化,我們可以直接傳送它,無需進一步處理。connectionArray 中的每個條目都是一個 WebSocket 物件,所以我們可以直接呼叫其 send() 方法。

我們最初的聊天演示不支援向特定使用者傳送訊息。下一個任務是更新主 WebSocket 訊息處理程式以支援此功能。這涉及 "connection" 訊息處理程式末尾附近的更改。

js
if (sendToClients) {
  const msgString = JSON.stringify(msg);

  if (msg.target && msg.target.length !== 0) {
    sendToOneUser(msg.target, msgString);
  } else {
    for (const connection of connectionArray) {
      connection.send(msgString);
    }
  }
}

此程式碼現在檢視待處理訊息,以檢查它是否具有 target 屬性。如果該屬性存在,它會指定要傳送訊息的客戶端的使用者名稱,然後我們呼叫 sendToOneUser() 將訊息傳送給他們。否則,透過迭代連線列表,將訊息廣播給所有使用者,並向每個使用者傳送訊息。

由於現有程式碼允許傳送任意訊息型別,因此不需要額外更改。我們的客戶端現在可以將未知型別的訊息傳送給任何特定使用者,讓他們可以根據需要來回傳送信令訊息。

這就是我們在伺服器端需要更改的所有內容。現在讓我們考慮我們將要實現的信令協議。

設計信令協議

現在我們已經建立了訊息交換機制,我們需要一個協議來定義這些訊息的外觀。這可以透過多種方式完成;此處展示的只是構建信令訊息的一種可能方式。

此示例的伺服器使用字串化的 JSON 物件與其客戶端通訊。這意味著我們的信令訊息將採用 JSON 格式,其內容指定訊息型別以及處理訊息所需的任何附加資訊。

交換會話描述

在開始信令過程時,由發起呼叫的使用者建立一個提議。此提議包含一個 SDP 格式的會話描述,需要傳遞給接收使用者,我們稱之為被叫方。被叫方以應答訊息響應提議,其中也包含一個 SDP 描述。我們的信令伺服器將使用 WebSocket 傳輸型別為 "video-offer" 的提議訊息和型別為 "video-answer" 的應答訊息。這些訊息具有以下欄位:

type

訊息型別;可以是 "video-offer""video-answer"

name

傳送者的使用者名稱。

目標

接收描述的人的使用者名稱(如果呼叫方正在傳送訊息,則此項指定被叫方,反之亦然)。

SDP

SDP(會話描述協議)字串,從傳送者(或接收者視角下的連線遠端)的角度描述連線的本地端。

此時,兩位參與者知道此次通話將使用哪些編解碼器編解碼器引數。但他們仍然不知道如何傳輸媒體資料本身。這就是互動式連線建立 (ICE) 的用武之地。

交換 ICE 候選者

兩個對等端需要交換 ICE 候選者以協商它們之間的實際連線。每個 ICE 候選者描述了傳送對等端能夠使用的通訊方法。每個對等端都按照發現的順序傳送候選者,並持續傳送候選者直到沒有更多建議,即使媒體已經開始流式傳輸。

當使用 pc.setLocalDescription(offer) 完成新增本地描述的過程時,會向 RTCPeerConnection 傳送一個 icecandidate 事件。

一旦兩個對等端就一個相互相容的候選者達成一致,每個對等端就會使用該候選者的 SDP 來構建和開啟連線,然後媒體資料就開始流動。如果他們稍後就一個更好的(通常效能更高)候選者達成一致,則流可能會根據需要更改格式。

雖然目前不支援,但理論上,在媒體已經流動之後收到的候選者也可以用於在需要時降級到較低頻寬的連線。

每個 ICE 候選者都透過信令伺服器向遠端對等端傳送型別為 "new-ice-candidate" 的 JSON 訊息來發送給另一個對等端。每個候選者訊息包含以下欄位:

type

訊息型別:"new-ice-candidate"

目標

正在協商的人的使用者名稱;伺服器將只將訊息定向到此使用者。

候選者

SDP 候選字串,描述了提議的連線方法。你通常不需要檢視此字串的內容。你的所有程式碼只需透過信令伺服器將其路由到遠端對等端。

每條 ICE 訊息都建議一個通訊協議(TCP 或 UDP)、IP 地址、埠號、連線型別(例如,指定的 IP 是對等端本身還是中繼伺服器),以及連線兩臺計算機所需的其他資訊。這包括 NAT 或其他網路複雜性。

注意:需要注意的重要事項是:在 ICE 協商過程中,你的程式碼唯一需要負責的是,當你的 onicecandidate 處理程式執行時,接收 ICE 層發出的出站候選者,並透過信令連線將它們傳送給另一個對等端;以及從信令伺服器接收 ICE 候選者訊息(當收到 "new-ice-candidate" 訊息時),並透過呼叫 RTCPeerConnection.addIceCandidate() 將它們傳遞給你的 ICE 層。僅此而已。

在幾乎所有情況下,SDP 的內容都與你無關。避免嘗試將其變得比這更復雜的誘惑,直到你真正知道自己在做什麼。那條路上充滿了瘋狂。

現在,你的信令伺服器只需要傳送它被要求傳送的訊息。你的工作流程可能還需要登入/認證功能,但這些細節會有所不同。

注意:onicecandidate 事件和 createAnswer() Promise 都是非同步呼叫,它們是單獨處理的。請確保你的信令不會改變順序!例如,必須在透過 setRemoteDescription() 設定應答之後呼叫 addIceCandidate() 來新增伺服器的 ice 候選者。

信令事務流程

信令過程涉及兩個對等端之間使用中介(信令伺服器)進行訊息交換。確切的過程當然會因情況而異,但通常有幾個關鍵點會處理信令訊息:

  • 每個使用者在其網路瀏覽器中執行的客戶端
  • 每個使用者的網路瀏覽器
  • 信令伺服器
  • 託管聊天服務的 Web 伺服器

假設 Naomi 和 Priya 正在使用聊天軟體進行討論,Naomi 決定開啟兩人之間的視訊通話。以下是預期的事件序列:

Diagram of the signaling process

我們將在本文中更詳細地介紹這一點。

ICE 候選者交換過程

當每個對等端的 ICE 層開始傳送候選者時,它會進入一個鏈中各個點之間的交換,看起來像這樣:

Diagram of ICE candidate exchange process

每一方都會在從本地 ICE 層接收到候選者後立即將其傳送給對方;沒有輪流或批次處理候選者。一旦雙方就一個可用於交換媒體的候選者達成一致,媒體就會開始流動。即使媒體已經開始流動,每一方仍會繼續傳送候選者,直到其耗盡所有選項。這樣做是為了希望能找到比最初選擇的更好的選項。

如果條件發生變化(例如,網路連線惡化),一個或兩個對等端可能會建議切換到較低頻寬的媒體解析度,或切換到替代編解碼器。這會觸發新的候選者交換,之後可能會發生另一種媒體格式和/或編解碼器更改。在WebRTC 使用的編解碼器指南中,你可以瞭解更多關於 WebRTC 要求瀏覽器支援的編解碼器、哪些瀏覽器支援哪些附加編解碼器以及如何選擇最佳編解碼器的資訊。

另外,如果想更深入瞭解 ICE 層內部此過程的完成方式,可選地請參閱 RFC 8445: Interactive Connectivity Establishment第 2.3 節(“協商候選對並結束 ICE”)。你應注意,一旦 ICE 層滿意,候選者就會進行交換,媒體就會開始流動。這一切都在幕後完成。我們的角色是,透過信令伺服器,來回傳送候選者。

客戶端應用程式

任何信令過程的核心是其訊息處理。信令不一定需要使用 WebSocket,但它是一種常見的解決方案。當然,你應該選擇適合你的應用程式的信令資訊交換機制。

讓我們更新聊天客戶端以支援視訊通話。

更新 HTML

我們客戶端的 HTML 需要一個用於顯示影片的位置。這需要影片元素和一個結束通話電話的按鈕。

html
<div class="flexChild" id="camera-container">
  <div class="camera-box">
    <video id="received_video" autoplay></video>
    <video id="local_video" autoplay muted></video>
    <button id="hangup-button" disabled>Hang Up</button>
  </div>
</div>
js
document.getElementById("hangup-button").addEventListener("click", hangUpCall);

這裡定義的頁面結構使用 <div> 元素,透過啟用 CSS 的使用,使我們能夠完全控制頁面佈局。我們將在本指南中跳過佈局細節,但請檢視 GitHub 上的 CSS,瞭解我們是如何處理的。請注意兩個 <video> 元素,一個用於你的自檢視,一個用於連線,以及 <button> 元素。

帶有 idreceived_video<video> 元素將顯示從已連線使用者接收到的影片。我們指定了 autoplay 屬性,確保影片一旦開始傳輸,就會立即播放。這消除了在程式碼中顯式處理播放的需要。local_video<video> 元素顯示使用者攝像頭的預覽;我們指定了 muted 屬性,因為我們不需要在此預覽面板中聽到本地音訊。

最後,用於斷開呼叫的 hangup-button <button> 被定義並配置為開始時停用(將其設定為未連線呼叫時的預設值),並在點選時應用 hangUpCall() 函式。此函式的作用是關閉呼叫,並向其他對等方傳送信令伺服器通知,請求其也關閉。

JavaScript 程式碼

我們將此程式碼劃分為功能區域,以便更輕鬆地描述其工作原理。此程式碼的主體位於 connect() 函式中:它在埠 6503 上開啟一個 WebSocket 伺服器,並建立一個處理程式來接收 JSON 物件格式的訊息。此程式碼通常像以前一樣處理文字聊天訊息。

向信令伺服器傳送訊息

在我們的程式碼中,我們呼叫 sendToServer() 以向信令伺服器傳送訊息。此函式使用 WebSocket 連線來完成其工作:

js
function sendToServer(msg) {
  const msgJSON = JSON.stringify(msg);

  connection.send(msgJSON);
}

傳入此函式的訊息物件透過呼叫 JSON.stringify() 轉換為 JSON 字串,然後我們呼叫 WebSocket 連線的 send() 函式將訊息傳輸到伺服器。

用於發起呼叫的 UI

處理 "user-list" 訊息的程式碼會呼叫 handleUserListMsg()。在這裡,我們為聊天面板左側顯示的使用者列表中的每個連線使用者設定處理程式。此函式接收一個訊息物件,其 users 屬性是一個字串陣列,指定每個已連線使用者的使用者名稱。

js
function handleUserListMsg(msg) {
  const listElem = document.querySelector(".user-list-box");

  while (listElem.firstChild) {
    listElem.removeChild(listElem.firstChild);
  }

  msg.users.forEach((username) => {
    const item = document.createElement("li");
    item.appendChild(document.createTextNode(username));
    item.addEventListener("click", invite);

    listElem.appendChild(item);
  });
}

在獲取到包含使用者名稱列表的 <ul> 元素的引用並將其儲存到變數 listElem 後,我們透過移除其每個子元素來清空列表。

注意:顯然,透過新增和刪除單個使用者而不是每次更改時重建整個列表來更新列表會更高效,但這對於本示例的目的來說已經足夠了。

然後,我們使用 forEach() 遍歷使用者名稱陣列。對於每個名稱,我們建立一個新的 <li> 元素,然後使用 createTextNode() 建立一個包含使用者名稱的文字節點。該文字節點作為 <li> 元素的子元素新增。接下來,我們為列表項上的 click 事件設定一個處理程式,即點選使用者名稱會呼叫我們的 invite() 方法,我們將在下一節中檢視該方法。

最後,我們將新項附加到包含所有使用者名稱的 <ul> 中。

開始通話

當用戶點選他們想要呼叫的使用者名稱時,invite() 函式作為該 click 事件的事件處理程式被呼叫。

js
const mediaConstraints = {
  audio: true, // We want an audio track
  video: true, // And we want a video track
};

function invite(evt) {
  if (myPeerConnection) {
    alert("You can't start a call because you already have one open!");
  } else {
    const clickedUsername = evt.target.textContent;

    if (clickedUsername === myUsername) {
      alert(
        "I'm afraid I can't let you talk to yourself. That would be weird.",
      );
      return;
    }

    targetUsername = clickedUsername;
    createPeerConnection();

    navigator.mediaDevices
      .getUserMedia(mediaConstraints)
      .then((localStream) => {
        document.getElementById("local_video").srcObject = localStream;
        localStream
          .getTracks()
          .forEach((track) => myPeerConnection.addTrack(track, localStream));
      })
      .catch(handleGetUserMediaError);
  }
}

這首先進行了一個基本的健全性檢查:使用者是否已經連線?如果已經有一個 RTCPeerConnection,他們顯然無法進行呼叫。然後,從事件目標的 textContent 屬性中獲取被點選使用者的名稱,我們檢查以確保它不是正在嘗試發起呼叫的同一個使用者。

然後,我們將要呼叫的使用者的姓名複製到變數 targetUsername 中,並呼叫 createPeerConnection() 函式,該函式將建立並進行 RTCPeerConnection 的基本配置。

一旦建立了 RTCPeerConnection,我們透過呼叫 MediaDevices.getUserMedia() 請求訪問使用者的攝像頭和麥克風,該方法透過 MediaDevices.getUserMedia 屬性暴露給我們。當此操作成功,兌現返回的 Promise 時,我們的 then 處理程式將執行。它接收一個 MediaStream 物件作為輸入,該物件表示包含使用者麥克風音訊和網路攝像頭影片的流。

注意:我們可以透過呼叫 navigator.mediaDevices.enumerateDevices() 獲取裝置列表,然後根據我們所需的條件過濾結果列表,最後在傳遞給 getUserMedia()mediaConstraints 物件的 deviceId 欄位中使用所選裝置的 deviceId 值,從而限制允許的媒體輸入裝置集。在實踐中,這很少甚至從不需要,因為 getUserMedia() 會為你完成大部分工作。

我們透過設定元素的 srcObject 屬性,將傳入流附加到本地預覽 <video> 元素。由於元素配置為自動播放傳入影片,因此流開始在我們的本地預覽框中播放。

然後,我們迭代流中的軌道,呼叫 addTrack() 將每個軌道新增到 RTCPeerConnection。即使連線尚未完全建立,你也可以在認為合適的時候開始傳送資料。在 ICE 協商完成之前接收到的媒體可用於幫助 ICE 決定採用最佳連線方法,從而有助於協商過程。

請注意,對於原生應用程式,例如手機應用程式,你至少應在兩端都接受連線後才開始傳送,以避免在使用者尚未準備好時無意中傳送影片和/或音訊資料。

一旦媒體附加到 RTCPeerConnection,連線處就會觸發一個 negotiationneeded 事件,以便開始 ICE 協商。

如果在嘗試獲取本地媒體流時發生錯誤,我們的 catch 子句會呼叫 handleGetUserMediaError(),該函式會根據需要向用戶顯示適當的錯誤。

處理 getUserMedia() 錯誤

如果 getUserMedia() 返回的 Promise 以失敗告終,我們的 handleGetUserMediaError() 函式將執行。

js
function handleGetUserMediaError(e) {
  switch (e.name) {
    case "NotFoundError":
      alert(
        "Unable to open your call because no camera and/or microphone" +
          "were found.",
      );
      break;
    case "SecurityError":
    case "PermissionDeniedError":
      // Do nothing; this is the same as the user canceling the call.
      break;
    default:
      alert(`Error opening your camera and/or microphone: ${e.message}`);
      break;
  }

  closeVideoCall();
}

除了一個例外,所有情況下都會顯示錯誤訊息。在此示例中,我們忽略 "SecurityError""PermissionDeniedError" 結果,將拒絕授予使用媒體硬體許可權與使用者取消呼叫視為相同。

無論嘗試獲取流失敗的原因是什麼,我們都會呼叫 closeVideoCall() 函式來關閉 RTCPeerConnection,並釋放嘗試呼叫過程中已分配的任何資源。此程式碼旨在安全地處理部分啟動的呼叫。

建立對等連線

createPeerConnection() 函式由呼叫方和被呼叫方用於構建他們的 RTCPeerConnection 物件,即 WebRTC 連線的各自端點。它在呼叫方嘗試發起呼叫時由 invite() 呼叫,並在被呼叫方收到呼叫方發出的提議訊息時由 handleVideoOfferMsg() 呼叫。

js
function createPeerConnection() {
  myPeerConnection = new RTCPeerConnection({
    iceServers: [
      // Information about ICE servers - Use your own!
      {
        urls: "stun:stun.stunprotocol.org",
      },
    ],
  });

  myPeerConnection.onicecandidate = handleICECandidateEvent;
  myPeerConnection.ontrack = handleTrackEvent;
  myPeerConnection.onnegotiationneeded = handleNegotiationNeededEvent;
  myPeerConnection.onremovetrack = handleRemoveTrackEvent;
  myPeerConnection.oniceconnectionstatechange =
    handleICEConnectionStateChangeEvent;
  myPeerConnection.onicegatheringstatechange =
    handleICEGatheringStateChangeEvent;
  myPeerConnection.onsignalingstatechange = handleSignalingStateChangeEvent;
}

在使用 RTCPeerConnection() 建構函式時,我們將指定一個物件,提供連線的配置引數。在此示例中,我們只使用其中一個:iceServers。這是一個描述 STUN 和/或 TURN 伺服器的物件陣列,供 ICE 層在嘗試在呼叫方和被呼叫方之間建立路由時使用。這些伺服器用於確定在對等端之間通訊時使用的最佳路由和協議,即使它們位於防火牆之後或使用 NAT

注意:你應始終使用你擁有的或已獲得特定授權的 STUN/TURN 伺服器。此示例使用了一個已知的公共 STUN 伺服器,但濫用這些伺服器是不好的做法。

iceServers 中的每個物件至少包含一個 urls 欄位,提供可訪問指定伺服器的 URL。如果需要,它還可以提供 usernamecredential 值以進行身份驗證。

建立 RTCPeerConnection 後,我們為對我們重要的事件設定處理程式。

其中前三個事件處理程式是必需的;你必須處理它們才能使用 WebRTC 進行任何涉及流媒體的操作。其餘的並非嚴格要求,但可能有用,我們將對此進行探討。此示例中還有一些其他可用事件我們未使用。以下是我們將要實現的每個事件處理程式的摘要:

onicecandidate

當本地 ICE 層需要你透過信令伺服器向其他對等端傳輸 ICE 候選者時,它會呼叫你的 icecandidate 事件處理程式。有關更多資訊和檢視此示例的程式碼,請參閱傳送 ICE 候選者

onTrack

當軌道被新增到連線時,本地 WebRTC 層會呼叫 track 事件的此處理程式。這允許你將傳入媒體連線到元素以顯示它,例如。有關詳細資訊,請參閱接收新流

onnegotiationneeded

每當 WebRTC 基礎設施需要你重新啟動會話協商過程時,就會呼叫此函式。它的作用是建立並向被呼叫方傳送提議,請求其與我們連線。請參閱開始協商以瞭解我們如何處理此問題。

onremovetrack

這個 ontrack 的對應項被呼叫來處理 removetrack 事件;當遠端對等方從正在傳送的媒體中移除軌道時,它被髮送到 RTCPeerConnection。請參閱處理軌道的移除

oniceconnectionstatechange

ICE 層傳送 iceconnectionstatechange 事件,以通知你 ICE 連線狀態的變化。這可以幫助你瞭解連線何時失敗或丟失。我們將在下面的ICE 連線狀態中檢視此示例的程式碼。

onicegatheringstatechange

當 ICE 代理收集候選者的過程從一種狀態變為另一種狀態(例如開始收集候選者或完成協商)時,ICE 層會向你傳送 icegatheringstatechange 事件。請參閱下面的ICE 收集狀態

onsignalingstatechange

當信令程序狀態改變(或與信令伺服器的連線改變)時,WebRTC 基礎設施會向你傳送 signalingstatechange 訊息。請參閱信令狀態以檢視我們的程式碼。

開始協商

一旦呼叫方建立了其 RTCPeerConnection,建立了媒體流,並如開始呼叫中所示將其軌道新增到連線中,瀏覽器將向 RTCPeerConnection 傳遞一個 negotiationneeded 事件,表示它已準備好開始與另一對等端進行協商。以下是我們處理 negotiationneeded 事件的程式碼:

js
function handleNegotiationNeededEvent() {
  myPeerConnection
    .createOffer()
    .then((offer) => myPeerConnection.setLocalDescription(offer))
    .then(() => {
      sendToServer({
        name: myUsername,
        target: targetUsername,
        type: "video-offer",
        sdp: myPeerConnection.localDescription,
      });
    })
    .catch(window.reportError);
}

要開始協商過程,我們需要建立並向我們要連線的對等端傳送一個 SDP 提議。此提議包括連線支援的配置列表,包括我們已新增到本地連線的媒體流(即,我們希望傳送到呼叫另一端的影片)的資訊,以及 ICE 層已收集的任何 ICE 候選者。我們透過呼叫 myPeerConnection.createOffer() 來建立此提議。

createOffer() 成功(兌現 Promise)時,我們將建立的提議資訊傳遞給 myPeerConnection.setLocalDescription(),這會配置連線和媒體配置狀態,用於呼叫方的連線端。

注意:從技術上講,createOffer() 返回的字串是一個 RFC 3264 提議。

setLocalDescription() 返回的 Promise 被兌現時,我們知道描述是有效的並已設定。此時,我們透過建立一個包含本地描述(現在與提議相同)的新 "video-offer" 訊息,然後透過我們的信令伺服器將其傳送給被叫方,從而將我們的提議傳送給其他對等方。該提議包含以下成員:

type

訊息型別:"video-offer"

name

呼叫者的使用者名稱。

目標

我們希望呼叫的使用者名稱稱。

SDP

描述提議的 SDP 字串。

如果發生錯誤,無論是在最初的 createOffer() 中還是在後續的任何 fulfillment handler 中,都會透過呼叫我們的 window.reportError() 函式報告錯誤。

一旦 setLocalDescription() 的 fulfillment handler 執行完畢,ICE 代理就開始向 RTCPeerConnection 傳送 icecandidate 事件,每發現一個潛在配置就傳送一個。我們負責處理 icecandidate 事件的處理程式,用於將候選者傳輸到另一個對等端。

會話協商

現在我們已經與另一個對等端開始了協商並傳輸了一個提議,讓我們來看看被呼叫方連線端會發生什麼。被呼叫方收到提議並呼叫 handleVideoOfferMsg() 函式來處理它。讓我們看看被呼叫方如何處理 "video-offer" 訊息。

處理邀請

當提議到達時,被呼叫方的 handleVideoOfferMsg() 函式會被呼叫,並傳入收到的 "video-offer" 訊息。此函式需要完成兩件事。首先,它需要建立自己的 RTCPeerConnection,並將包含其麥克風和網路攝像頭音訊和影片的軌道新增到其中。其次,它需要處理收到的提議,構建併發送其應答。

js
function handleVideoOfferMsg(msg) {
  let localStream = null;

  targetUsername = msg.name;
  createPeerConnection();

  const desc = new RTCSessionDescription(msg.sdp);

  myPeerConnection
    .setRemoteDescription(desc)
    .then(() => navigator.mediaDevices.getUserMedia(mediaConstraints))
    .then((stream) => {
      localStream = stream;
      document.getElementById("local_video").srcObject = localStream;

      localStream
        .getTracks()
        .forEach((track) => myPeerConnection.addTrack(track, localStream));
    })
    .then(() => myPeerConnection.createAnswer())
    .then((answer) => myPeerConnection.setLocalDescription(answer))
    .then(() => {
      const msg = {
        name: myUsername,
        target: targetUsername,
        type: "video-answer",
        sdp: myPeerConnection.localDescription,
      };

      sendToServer(msg);
    })
    .catch(handleGetUserMediaError);
}

此程式碼與我們在 開始呼叫invite() 函式所做的非常相似。它首先使用我們的 createPeerConnection() 函式建立並配置一個 RTCPeerConnection。然後它從收到的 "video-offer" 訊息中獲取 SDP 提議,並使用它建立一個新的 RTCSessionDescription 物件,表示呼叫方的會話描述。

然後將該會話描述傳遞給 myPeerConnection.setRemoteDescription()。這會將收到的提議建立為連線遠端(呼叫方)端的描述。如果此操作成功,Promise 實現處理程式(在 then() 子句中)將啟動獲取被呼叫方攝像頭和麥克風訪問許可權的過程,使用 getUserMedia(),將軌道新增到連線中,等等,正如我們之前在 invite() 中看到的那樣。

一旦使用 myPeerConnection.createAnswer() 建立了應答,連線本地端的描述將透過呼叫 myPeerConnection.setLocalDescription() 設定為應答的 SDP,然後應答透過信令伺服器傳輸給呼叫方,告知他們應答內容。

任何錯誤都會被捕獲並傳遞給 handleGetUserMediaError(),如處理 getUserMedia() 錯誤中所述。

注意:與呼叫方一樣,一旦 setLocalDescription() 的 fulfillment handler 執行完畢,瀏覽器就會開始觸發 icecandidate 事件,被呼叫方必須處理這些事件,每個需要傳輸到遠端對等端的候選者都有一個。

最後,呼叫方透過建立一個新的 RTCSessionDescription 物件來處理收到的應答訊息,該物件表示被呼叫方的會話描述,並將其傳遞給 myPeerConnection.setRemoteDescription()

js
function handleVideoAnswerMsg(msg) {
  const desc = new RTCSessionDescription(msg.sdp);
  myPeerConnection.setRemoteDescription(desc).catch(window.reportError);
}
傳送 ICE 候選者

ICE 協商過程涉及每個對等方反覆向另一個對等方傳送候選者,直到其耗盡可能支援 RTCPeerConnection 媒體傳輸需求的潛在方法。由於 ICE 不瞭解你的信令伺服器,因此你的程式碼會在 icecandidate 事件的處理程式中處理每個候選者的傳輸。

你的 onicecandidate 處理程式接收一個事件,其 candidate 屬性是描述候選者的 SDP(或為 null 表示 ICE 層已耗盡所有潛在配置建議)。candidate 的內容是你需要使用信令伺服器傳輸的內容。以下是我們的示例實現:

js
function handleICECandidateEvent(event) {
  if (event.candidate) {
    sendToServer({
      type: "new-ice-candidate",
      target: targetUsername,
      candidate: event.candidate,
    });
  }
}

這會構建一個包含候選者的物件,然後使用向信令伺服器傳送訊息中先前描述的 sendToServer() 函式將其傳送給另一個對等方。訊息的屬性是:

type

訊息型別:"new-ice-candidate"

目標

需要將 ICE 候選者傳送到的使用者名稱。這允許信令伺服器路由訊息。

候選者

表示 ICE 層希望傳輸到另一個對等端的候選者的 SDP。

此訊息的格式(與你在處理信令時所做的一切一樣)完全取決於你,具體取決於你的需求;你可以根據需要提供其他資訊。

注意:重要的是要記住,icecandidate 事件會在 ICE 候選者從呼叫的另一端到達時傳送。相反,它們由你的呼叫端傳送,以便你可以承擔透過你選擇的任何通道傳輸資料的工作。當你剛接觸 WebRTC 時,這可能會令人困惑。

接收 ICE 候選者

信令伺服器使用其選擇的任何方法將每個 ICE 候選者傳遞給目標對等端;在我們的示例中,這表現為 JSON 物件,其中包含一個 type 屬性,其值為字串 "new-ice-candidate"。我們的主 WebSocket 傳入訊息程式碼會呼叫 handleNewICECandidateMsg() 函式來處理這些訊息:

js
function handleNewICECandidateMsg(msg) {
  const candidate = new RTCIceCandidate(msg.candidate);

  myPeerConnection.addIceCandidate(candidate).catch(window.reportError);
}

此函式透過將其接收到的 SDP 傳遞給其建構函式來構造一個 RTCIceCandidate 物件,然後透過將其傳遞給 myPeerConnection.addIceCandidate() 將候選者傳遞給 ICE 層。這會將新的 ICE 候選者交給本地 ICE 層,至此,我們在處理此候選者過程中的職責就完成了。

每個對等方都會向另一個對等方傳送針對其認為可能適用於正在交換的媒體的每種可能的傳輸配置的候選者。在某個時刻,兩個對等方同意某個候選者是一個不錯的選擇,它們就會開啟連線並開始共享媒體。然而,重要的是要注意,一旦媒體開始流動,ICE 協商並不會停止。相反,在對話開始後,候選者可能仍會繼續交換,這可能是為了找到更好的連線方法,也可能是因為在對等方成功建立連線時它們已經在傳輸中。

此外,如果發生導致流媒體場景變化的情況,協商將再次開始,negotiationneeded 事件將傳送到 RTCPeerConnection,並且整個過程將如前所述再次開始。這可能發生在各種情況下,包括:

  • 網路狀態變化,例如頻寬變化,從 Wi-Fi 切換到蜂窩網路連線等等。
  • 在手機的前置和後置攝像頭之間切換。
  • 流媒體配置的更改,例如其解析度或幀率。
接收新流

當新軌道新增到 RTCPeerConnection 時——無論是透過呼叫其 addTrack() 方法還是因為流媒體格式的重新協商——都會為新增到連線的每個軌道向 RTCPeerConnection 設定一個 track 事件。利用新新增的媒體需要實現 track 事件的處理程式。一個常見的需求是將傳入媒體附加到適當的 HTML 元素。在我們的示例中,我們將軌道的流新增到顯示傳入影片的 <video> 元素中。

js
function handleTrackEvent(event) {
  document.getElementById("received_video").srcObject = event.streams[0];
  document.getElementById("hangup-button").disabled = false;
}

傳入流被附加到 "received_video" <video> 元素,並且“結束通話”<button> 元素被啟用,以便使用者可以結束通話電話。

這段程式碼完成後,另一個對等方傳送的影片終於在本地瀏覽器視窗中顯示了!

處理軌道的移除

當遠端對等端透過呼叫 RTCPeerConnection.removeTrack() 從連線中移除軌道時,你的程式碼會收到一個 removetrack 事件。我們處理 "removetrack" 的程式碼是:

js
function handleRemoveTrackEvent(event) {
  const stream = document.getElementById("received_video").srcObject;
  const trackList = stream.getTracks();

  if (trackList.length === 0) {
    closeVideoCall();
  }
}

此程式碼從 "received_video" <video> 元素的 srcObject 屬性中獲取傳入影片 MediaStream,然後呼叫流的 getTracks() 方法以獲取流的軌道陣列。

如果陣列的長度為零,這意味著流中沒有剩餘軌道,我們透過呼叫 closeVideoCall() 結束呼叫。這會乾淨地將我們的應用程式恢復到可以再次開始或接收呼叫的狀態。請參閱結束呼叫以瞭解 closeVideoCall() 的工作原理。

結束通話

通話可能因多種原因結束。通話可能已完成,一方或雙方已結束通話。也許發生了網路故障,或者一個使用者退出了瀏覽器,或者系統崩潰了。無論如何,所有美好的事物都必須結束。

結束通話

當用戶點選“結束通話”按鈕結束通話時,會呼叫 hangUpCall() 函式:

js
function hangUpCall() {
  closeVideoCall();
  sendToServer({
    name: myUsername,
    target: targetUsername,
    type: "hang-up",
  });
}

hangUpCall() 執行 closeVideoCall() 以關閉並重置連線並釋放資源。然後它構建一個 "hang-up" 訊息並將其傳送到呼叫的另一端,以告知另一對等方乾淨地關閉自身。

結束通話

如下所示的 closeVideoCall() 函式負責停止流、清理並處理 RTCPeerConnection 物件:

js
function closeVideoCall() {
  const remoteVideo = document.getElementById("received_video");
  const localVideo = document.getElementById("local_video");

  if (myPeerConnection) {
    myPeerConnection.ontrack = null;
    myPeerConnection.onremovetrack = null;
    myPeerConnection.onremovestream = null;
    myPeerConnection.onicecandidate = null;
    myPeerConnection.oniceconnectionstatechange = null;
    myPeerConnection.onsignalingstatechange = null;
    myPeerConnection.onicegatheringstatechange = null;
    myPeerConnection.onnegotiationneeded = null;

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

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

    myPeerConnection.close();
    myPeerConnection = null;
  }

  remoteVideo.removeAttribute("src");
  remoteVideo.removeAttribute("srcObject");
  localVideo.removeAttribute("src");
  localVideo.removeAttribute("srcObject");

  document.getElementById("hangup-button").disabled = true;
  targetUsername = null;
}

在獲取到兩個 <video> 元素的引用後,我們檢查 WebRTC 連線是否存在;如果存在,我們繼續斷開並關閉呼叫:

  1. 所有事件處理程式都被移除。這可以防止在連線關閉過程中觸發無關的事件處理程式,從而可能導致錯誤。
  2. 對於遠端和本地影片流,我們都會遍歷每個軌道,呼叫 MediaStreamTrack.stop() 方法來關閉每個軌道。
  3. 透過呼叫 myPeerConnection.close() 關閉 RTCPeerConnection
  4. myPeerConnection 設定為 null,確保我們的程式碼知道沒有正在進行的通話;當用戶在使用者列表中點選一個姓名時,這很有用。

然後,對於傳入和傳出 <video> 元素,我們使用它們的 removeAttribute() 方法移除其 srcsrcObject 屬性。這完成了流與影片元素的分離。

最後,我們將“結束通話”按鈕的 disabled 屬性設定為 true,使其在沒有通話進行時無法點選;然後我們將 targetUsername 設定為 null,因為我們不再與任何人通話。這允許使用者呼叫其他使用者,或接收來電。

處理狀態變化

你可以為許多附加事件設定監聽器,以通知你的程式碼各種狀態更改。我們使用了其中三個:iceconnectionstatechangeicegatheringstatechangesignalingstatechange

ICE 連線狀態

當連線狀態發生變化(例如,當呼叫從另一端終止時)時,ICE 層會向 RTCPeerConnection 傳送 iceconnectionstatechange 事件。

js
function handleICEConnectionStateChangeEvent(event) {
  switch (myPeerConnection.iceConnectionState) {
    case "closed":
    case "failed":
      closeVideoCall();
      break;
  }
}

在這裡,當 ICE 連線狀態變為 "closed""failed" 時,我們應用 closeVideoCall() 函式。這會處理關閉我們端的連線,以便我們再次準備好發起或接受呼叫。

注意:我們在此處不監視 disconnected 信令狀態,因為它可能表示臨時問題,並可能在一段時間後恢復到 connected 狀態。監視它會在任何臨時網路問題時關閉視訊通話。

ICE 信令狀態

同樣,我們監聽 signalingstatechange 事件。如果信令狀態變為 closed,我們也會關閉通話。

js
function handleSignalingStateChangeEvent(event) {
  switch (myPeerConnection.signalingState) {
    case "closed":
      closeVideoCall();
      break;
  }
}

注意:closed 信令狀態已被棄用,取而代之的是 closed iceConnectionState。我們在此處監視它以增加一些向後相容性。

ICE 收集狀態

icegatheringstatechange 事件用於讓你知道 ICE 候選者收集過程狀態何時更改。我們的示例中沒有使用它,但監視這些事件對於除錯以及檢測候選者收集何時完成可能很有用。

js
function handleICEGatheringStateChangeEvent(event) {
  // Our sample just logs information to console here,
  // but you can do whatever you need.
}

後續步驟

你現在可以嘗試此示例,看看它在實踐中如何執行。在兩臺裝置上開啟 Web 控制檯,檢視日誌輸出——儘管你在上面顯示的程式碼中看不到它,但伺服器上的程式碼(以及 GitHub 上的程式碼)有大量的控制檯輸出,這樣你就可以看到信令和連線過程的運作。

另一個顯而易見的改進是新增“振鈴”功能,這樣就不會只是請求使用者允許使用攝像頭和麥克風,而是首先出現“使用者 X 正在呼叫。您想接聽嗎?”的提示。

另見