自拍偷在线精品自拍偷,亚洲欧美中文日韩v在线观看不卡

使用WebRTC實(shí)現(xiàn)P2P視頻流

網(wǎng)絡(luò) 網(wǎng)絡(luò)管理
網(wǎng)絡(luò)實(shí)時(shí)通信(WebRTC)是一個(gè)開(kāi)源標(biāo)準(zhǔn),允許網(wǎng)絡(luò)應(yīng)用程序和網(wǎng)站之間的實(shí)時(shí)通信,而無(wú)需插件或額外的軟件安裝。它也可以作為iOS和安卓應(yīng)用程序的庫(kù),提供與標(biāo)準(zhǔn)相同的功能。

前言

網(wǎng)絡(luò)實(shí)時(shí)通信(WebRTC)是一個(gè)開(kāi)源標(biāo)準(zhǔn),允許網(wǎng)絡(luò)應(yīng)用程序和網(wǎng)站之間的實(shí)時(shí)通信,而無(wú)需插件或額外的軟件安裝。它也可以作為iOS和安卓應(yīng)用程序的庫(kù),提供與標(biāo)準(zhǔn)相同的功能。

WebRTC適用于任何操作系統(tǒng),可用于所有現(xiàn)代瀏覽器,包括谷歌Chrome、Mozilla火狐和Safari。使用WebRTC的一些主要項(xiàng)目包括谷歌會(huì)議和Hangouts、WhatsApp、亞馬遜Chime、臉書(shū)Messenger、Snapchat和Discord。

在本文中,我們將介紹WebRTC的主要用例之一:從一個(gè)系統(tǒng)到另一個(gè)系統(tǒng)的點(diǎn)對(duì)點(diǎn)(P2P)音頻和視頻流。此功能類(lèi)似于Twitch等實(shí)時(shí)流媒體服務(wù),但規(guī)模更小、更簡(jiǎn)單。

要了解的核心WebRTC概念

在本節(jié)中,我將回顧您應(yīng)該了解的五個(gè)基本概念,以了解使用WebRTC的Web應(yīng)用程序的工作原理。這些概念包括點(diǎn)對(duì)點(diǎn)通信、Signal服務(wù)器和ICE協(xié)議。

點(diǎn)對(duì)點(diǎn)通信

在本指南中,我們將使用WebRTC的RTCPeerConnection對(duì)象,該對(duì)象主要涉及連接兩個(gè)應(yīng)用程序并允許它們使用點(diǎn)對(duì)點(diǎn)協(xié)議進(jìn)行通信。

在去中心化網(wǎng)絡(luò)中,對(duì)等通信是網(wǎng)絡(luò)中計(jì)算機(jī)系統(tǒng)(對(duì)等點(diǎn))之間的直接鏈接,沒(méi)有中介(例如服務(wù)器)。雖然WebRTC不允許對(duì)等點(diǎn)在所有場(chǎng)景下直接相互通信,但它使用的ICE協(xié)議和Signal服務(wù)器允許類(lèi)似的行為。您將在下面找到更多關(guān)于它們的信息。

Signal 服務(wù)器

對(duì)于WebRTC應(yīng)用程序中的每一對(duì)要開(kāi)始通信,它們必須執(zhí)行“握手”,這是通過(guò)offer或answer完成的。一個(gè)對(duì)等點(diǎn)生成offer并與另一個(gè)對(duì)等點(diǎn)共享,另一個(gè)對(duì)等點(diǎn)生成answer并與第一個(gè)對(duì)等點(diǎn)共享。

為了使握手成功,每個(gè)對(duì)等點(diǎn)都必須有一種方法來(lái)共享他們的offer或answer。這就是Signal 服務(wù)器的用武之地。

Signal 服務(wù)器的主要目標(biāo)是啟動(dòng)對(duì)等點(diǎn)之間的通信。對(duì)等點(diǎn)使用信號(hào)服務(wù)器與另一個(gè)對(duì)等點(diǎn)共享其offer或answer,另一個(gè)可以使用Signal 服務(wù)器與第一個(gè)對(duì)等點(diǎn)共享其offer或answer。

ICE協(xié)議

在特定情況下,比如當(dāng)所有涉及的設(shè)備都不在同一個(gè)本地網(wǎng)絡(luò)中時(shí),WebRTC應(yīng)用程序可能很難相互建立對(duì)等連接。這是因?yàn)槌菍?duì)等點(diǎn)在同一個(gè)本地網(wǎng)絡(luò)中,否則它們之間的直接socket連接并不總是可能的。

當(dāng)您想使用跨不同網(wǎng)絡(luò)的對(duì)等連接時(shí),您需要使用交互式連通建立方式(ICE)協(xié)議。ICE協(xié)議用于在Internet上的對(duì)等點(diǎn)之間建立連接。ICE服務(wù)器使用該協(xié)議在對(duì)等點(diǎn)之間建立連接和中繼信息。

ICE協(xié)議包括用于NAT的會(huì)話(huà)遍歷實(shí)用程序(STUN)協(xié)議、圍繞NAT使用中繼的遍歷(TURN)協(xié)議或兩者的混合。

在本教程中,我們不會(huì)涵蓋ICE協(xié)議的實(shí)際方面,因?yàn)闃?gòu)建服務(wù)器、讓它工作和測(cè)試它所涉及的復(fù)雜性。然而,了解WebRTC應(yīng)用程序的限制以及ICE協(xié)議在哪里可以解決這些限制是有幫助的。

WebRTC P2P視頻流入門(mén)

現(xiàn)在我們已經(jīng)完成了所有這些,是時(shí)候開(kāi)始復(fù)雜的工作了。在下一節(jié)中,我們將研究視頻流項(xiàng)目。當(dāng)我們開(kāi)始時(shí),您可以在這里看到該項(xiàng)目的現(xiàn)場(chǎng)演示。

在我們開(kāi)始之前,我有一個(gè)GitHub存儲(chǔ)庫(kù) https://github.com/GhoulKingR/webrtc-project ,您可以克隆它以關(guān)注本文。此存儲(chǔ)庫(kù)有一個(gè)start-tutorial文件夾,按照您將在下一節(jié)中采取的步驟進(jìn)行組織,以及每個(gè)步驟末尾的代碼副本。雖然不需要使用repo,但它很有幫助。

我們將在repo中處理的文件夾稱(chēng)為start-tutorial。它包含三個(gè)文件夾:step-1、step-2和step-3。這三個(gè)文件夾對(duì)應(yīng)于下一節(jié)中的步驟。

運(yùn)行視頻流項(xiàng)目

現(xiàn)在,讓我們開(kāi)始構(gòu)建項(xiàng)目。我把這個(gè)過(guò)程分為三個(gè)步驟。我們將創(chuàng)建一個(gè)項(xiàng)目,我們可以在每個(gè)步驟中運(yùn)行、測(cè)試和使用。

這些步驟包括:

  • 網(wǎng)頁(yè)內(nèi)的視頻流
  • 使用BroadcastChannel在瀏覽器選項(xiàng)卡和窗口之間的流
  • 使用 signal服務(wù)器在同一設(shè)備上的不同瀏覽器之間流。

網(wǎng)頁(yè)內(nèi)的視頻流

在這一步中,我們只需要一個(gè)index.html文件。如果您在repo中工作,您可以使用start-tutorial/step-1/index.html文件。

現(xiàn)在,讓我們將此代碼粘貼到其中:

<body>
  <video id="local" autoplay muted></video>
  <video id="remote" autoplay></video>

  <button onclick="start(this)">start video</button>
  <button id="stream" onclick="stream(this)" disabled>stream video</button>

  <script>
    // get video elements
    const local = document.querySelector("video#local");
    const remote = document.querySelector("video#remote");

    function start(e) {
      e.disabled = true;
      navigator.mediaDevices.getUserMedia({ audio: true, video: true })
        .then((stream) => {
          local.srcObject = stream;
          document.getElementById("stream").disabled = false;  // enable the stream button
        })
        .catch(() => e.disabled = false);
    }
    
function stream(e) {
      // disable the stream button
      e.disabled = true;
      
      const config = {};
      const localPeerConnection = new RTCPeerConnection(config);  // local peer
      const remotePeerConnection = new RTCPeerConnection(config);  // remote peer
      
      // if an icecandidate event is triggered in a peer add the ice candidate to the other peer
      localPeerConnection.addEventListener("icecandidate", e => remotePeerConnection.addIceCandidate(e.candidate));
      remotePeerConnection.addEventListener("icecandidate", e => localPeerConnection.addIceCandidate(e.candidate));

      // if the remote peer detects a track in the connection, it forwards it to the remote video element
      remotePeerConnection.addEventListener("track", e => remote.srcObject = e.streams[0]);

      // get camera and microphone source tracks and add it to the local peer
      local.srcObject.getTracks()
        .forEach(track => localPeerConnection.addTrack(track, local.srcObject));
      // Start the handshake process
      localPeerConnection.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true })
        .then(async offer => {
          await localPeerConnection.setLocalDescription(offer);
          await remotePeerConnection.setRemoteDescription(offer);
          console.log("Created offer");
        })
        .then(() => remotePeerConnection.createAnswer())
        .then(async answer => {
          await remotePeerConnection.setLocalDescription(answer);
          await localPeerConnection.setRemoteDescription(answer);
          console.log("Created answer");
        });
    }
  </script>
</body>

它會(huì)給你一些看起來(lái)像這樣的展示:

圖片圖片

現(xiàn)在,讓我們來(lái)看看這是怎么回事。

要構(gòu)建項(xiàng)目,我們需要兩個(gè)視頻元素。我們將使用一個(gè)來(lái)捕獲用戶(hù)的相機(jī)和麥克風(fēng)。之后,我們將使用WebRTC的RTCPeerConnection對(duì)象將此元素的音頻和視頻流饋送到另一個(gè)視頻元素:

<video id="local" autoplay muted></video>
<video id="remote" autoplay></video>

RTCPeerConnection對(duì)象是在Web瀏覽器或設(shè)備之間建立直接點(diǎn)對(duì)點(diǎn)連接的主要對(duì)象。

然后我們需要兩個(gè)按鈕。一個(gè)是激活用戶(hù)的網(wǎng)絡(luò)攝像頭和麥克風(fēng),另一個(gè)是將第一個(gè)視頻元素的內(nèi)容流式傳輸?shù)降诙€(gè):

<button onclick="start(this)">start video</button>
<button id="stream" onclick="stream(this)" disabled>stream video</button>

單擊時(shí),"start video"按鈕運(yùn)行start功能。單擊時(shí),"stream video"按鈕運(yùn)行stream功能。

我們首先看一下start函數(shù):

function start(e) {
  e.disabled = true;
  navigator.mediaDevices.getUserMedia({ audio: true, video: true })
   .then((stream) => {
      local.srcObject = stream;
      document.getElementById("stream").disabled = false;  // enable the stream button
    })
    .catch(() => e.disabled = false);
}

當(dāng)start函數(shù)運(yùn)行時(shí),它首先使開(kāi)始按鈕不可單擊。然后,它通過(guò)navigator.mediaDevices.getUserMedia方法請(qǐng)求用戶(hù)使用其網(wǎng)絡(luò)攝像頭和麥克風(fēng)的權(quán)限。

如果用戶(hù)授予權(quán)限,start函數(shù)通過(guò)其srcObject字段將視頻和音頻流發(fā)送到第一個(gè)視頻元素,并啟用stream按鈕。如果從用戶(hù)那里獲得權(quán)限出現(xiàn)問(wèn)題或用戶(hù)拒絕權(quán)限,該函數(shù)會(huì)再次單擊start按鈕。

現(xiàn)在,讓我們看一下stream函數(shù):

function stream(e) {
  // disable the stream button
  e.disabled = true;
  
  const config = {};
  const localPeerConnection = new RTCPeerConnection(config);  // local peer
  const remotePeerConnection = new RTCPeerConnection(config);  // remote peer
  
  // if an icecandidate event is triggered in a peer add the ice candidate to the other peer
  localPeerConnection.addEventListener("icecandidate", e => remotePeerConnection.addIceCandidate(e.candidate));
  remotePeerConnection.addEventListener("icecandidate", e => localPeerConnection.addIceCandidate(e.candidate));

  // if the remote peer receives track from the connection, it feeds them to the remote video element
  remotePeerConnection.addEventListener("track", e => remote.srcObject = e.streams[0]);

  // get camera and microphone tracks then feed them to local peer
  local.srcObject.getTracks()
    .forEach(track => localPeerConnection.addTrack(track, local.srcObject));
  
  // Start the handshake process
  localPeerConnection.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true })
    .then(async offer => {
      await localPeerConnection.setLocalDescription(offer);
      await remotePeerConnection.setRemoteDescription(offer);
      console.log("Created offer");
    })
    .then(() => remotePeerConnection.createAnswer())
    .then(async answer => {
      await remotePeerConnection.setLocalDescription(answer);
      await localPeerConnection.setRemoteDescription(answer);
      console.log("Created answer");
    });
}

我添加了注釋來(lái)概述stream函數(shù)中的過(guò)程,以幫助理解它。然而,握手過(guò)程(第21-32行)和ICE候選事件(第10行和第11行)是我們將更詳細(xì)討論的重要部分。

在握手過(guò)程中,每對(duì)都會(huì)根據(jù)對(duì)創(chuàng)建的offer和answer設(shè)置其本地和遠(yuǎn)程描述:

  • 生成offer的對(duì)將其本地描述設(shè)置為該offer,然后將offer的副本發(fā)送到第二對(duì)以設(shè)置為其遠(yuǎn)程描述
  • 同樣,生成answer的對(duì)將answer設(shè)置為其本地描述,并將副本發(fā)送到第一對(duì)以設(shè)置為其遠(yuǎn)程描述

完成這個(gè)過(guò)程后,同行立即開(kāi)始相互交流。

ICE候選是對(duì)等方的地址(IP、端口和其他相關(guān)信息)。RTCPeerConnection對(duì)象使用ICE候選來(lái)查找和相互通信。RTCPeerConnection對(duì)象中的icecandidate事件在對(duì)象生成ICE候選時(shí)觸發(fā)。

我們?cè)O(shè)置的事件偵聽(tīng)器的目標(biāo)是將ICE候選人從一個(gè)對(duì)等點(diǎn)傳遞到另一個(gè)對(duì)等點(diǎn)。

在瀏覽器選項(xiàng)卡和帶有BroadcastChannel的窗口之間

使用WebRTC設(shè)置點(diǎn)對(duì)點(diǎn)應(yīng)用程序的挑戰(zhàn)之一是讓它跨不同的應(yīng)用程序?qū)嵗蚓W(wǎng)站工作。在本節(jié)中,我們將使用廣播頻道API允許我們的項(xiàng)目在單個(gè)網(wǎng)頁(yè)之外但在瀏覽器上下文中工作。

創(chuàng)建必要的文件

我們將從創(chuàng)建兩個(gè)文件開(kāi)始,streamer.html和index.html。在repo中,這些文件位于start-tutorial/step-2文件夾中。streamer.html頁(yè)面允許用戶(hù)從他們的相機(jī)創(chuàng)建實(shí)時(shí)流,而index.html頁(yè)面將使用戶(hù)能夠觀看這些實(shí)時(shí)流。

現(xiàn)在,讓我們將這些代碼塊粘貼到文件中。然后,我們將更深入地研究它們。

首先,在streamer.html文件中,粘貼以下代碼:

<body>
  <video id="local" autoplay muted></video>
  <button onclick="start(this)">start video</button>
  <button id="stream" onclick="stream(this)" disabled>stream video</button>
  <script>
    // get video elements
    const local = document.querySelector("video#local");
    let peerConnection;
    const channel = new BroadcastChannel("stream-video");
    channel.onmessage = e => {
      if (e.data.type === "icecandidate") {
        peerConnection?.addIceCandidate(e.data.candidate);
      } else if (e.data.type === "answer") {
        console.log("Received answer")
        peerConnection?.setRemoteDescription(e.data);
      }
    }
    // function to ask for camera and microphone permission
    // and stream to #local video element
    function start(e) {
      e.disabled = true;
      document.getElementById("stream").disabled = false;  // enable the stream button
      navigator.mediaDevices.getUserMedia({ audio: true, video: true })
        .then((stream) => local.srcObject = stream);
    }
    
     function stream(e) {
      e.disabled = true;

      const config = {};
      peerConnection = new RTCPeerConnection(config);  // local peer connection

      // add ice candidate event listener
      peerConnection.addEventListener("icecandidate", e => {
        let candidate = null;
        
        // prepare a candidate object that can be passed through browser channel
        if (e.candidate !== null) {
          candidate = {
            candidate: e.candidate.candidate,
            sdpMid: e.candidate.sdpMid,
            sdpMLineIndex: e.candidate.sdpMLineIndex,
          };
        }
        channel.postMessage({ type: "icecandidate", candidate });
      });

      // add media tracks to the peer connection
      local.srcObject.getTracks()
        .forEach(track => peerConnection.addTrack(track, local.srcObject));
        // Create offer and send through the browser channel
      peerConnection.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true })
        .then(async offer => {
          await peerConnection.setLocalDescription(offer);
          console.log("Created offer, sending...");
          channel.postMessage({ type: "offer", sdp: offer.sdp });
        });
    }
  </script>
</body>

然后,在index.html文件中,粘貼以下代碼:

<body>
  <video id="remote" controls></video>
  
  <script>
    // get video elements
    const remote = document.querySelector("video#remote");
    let peerConnection;

    const channel = new BroadcastChannel("stream-video");
    channel.onmessage = e => {
      if (e.data.type === "icecandidate") {
        peerConnection?.addIceCandidate(e.data.candidate)
      } else if (e.data.type === "offer") {
        console.log("Received offer")
        handleOffer(e.data)
      }
    }
    function handleOffer(offer) {
      const config = {};
      peerConnection = new RTCPeerConnection(config);
      peerConnection.addEventListener("track", e => remote.srcObject = e.streams[0]);
      peerConnection.addEventListener("icecandidate", e => {
        let candidate = null;
        if (e.candidate !== null) {
          candidate = {
            candidate: e.candidate.candidate,
            sdpMid: e.candidate.sdpMid,
            sdpMLineIndex: e.candidate.sdpMLineIndex,
          }
        }
        channel.postMessage({ type: "icecandidate", candidate })
      });
      peerConnection.setRemoteDescription(offer)
        .then(() => peerConnection.createAnswer())
        .then(async answer => {
          await peerConnection.setLocalDescription(answer);
          console.log("Created answer, sending...")
          channel.postMessage({
            type: "answer",
            sdp: answer.sdp,
          });
        });
    }
  </script>
</body>

在您的瀏覽器中,頁(yè)面的外觀和功能將類(lèi)似于以下動(dòng)畫(huà):

圖片圖片

streamer.html文件的詳細(xì)分解

現(xiàn)在,讓我們更詳細(xì)地探索這兩個(gè)頁(yè)面。我們將從streamer.html頁(yè)面開(kāi)始。此頁(yè)面只需要一個(gè)視頻和兩個(gè)按鈕元素:

<video id="local" autoplay muted></video>
<button onclick="start(this)">start video</button>
<button id="stream" onclick="stream(this)" disabled>stream video</button>

"start video"按鈕的工作方式與上一步相同:它請(qǐng)求用戶(hù)允許使用他們的相機(jī)和麥克風(fēng),并將流提供給視頻元素。然后,"stream video"按鈕初始化對(duì)等連接并將視頻流提供給對(duì)等連接。

由于此步驟涉及兩個(gè)網(wǎng)頁(yè),我們正在使用廣播頻道API。在我們的index.html和streamer.html文件中,我們必須在每個(gè)頁(yè)面上初始化一個(gè)具有相同名稱(chēng)的BroadcastChannel對(duì)象,以允許它們進(jìn)行通信。

BroadcastChannel對(duì)象允許您在具有相同URL來(lái)源的瀏覽上下文(例如窗口或選項(xiàng)卡)之間傳遞基本信息。

當(dāng)你初始化一個(gè)BroadcastChannel對(duì)象時(shí),你必須給它一個(gè)名字。你可以把這個(gè)名字想象成聊天室的名字。如果你用相同的名字初始化兩個(gè)BroadcastChannel對(duì)象,他們可以像在聊天室一樣互相交談。但是如果他們有不同的名字,他們就不能交流,因?yàn)樗麄儾辉谕粋€(gè)聊天室里。

我說(shuō)“聊天室”是因?yàn)槟梢該碛卸鄠€(gè)具有相同名稱(chēng)的BroadcastChannel對(duì)象,并且它們都可以同時(shí)相互通信。

由于我們正在處理兩個(gè)頁(yè)面,每個(gè)頁(yè)面都有對(duì)等連接,我們必須使用BroadcastChannel對(duì)象在兩個(gè)頁(yè)面之間來(lái)回傳遞offer和answer。我們還必須將對(duì)等連接的ICE候選傳遞給另一個(gè)。所以,讓我們看看它是如何完成的。

這一切都從stream函數(shù)開(kāi)始:

// streamer.html -> script element

function stream(e) {
  e.disabled = true;

  const config = {};
  peerConnection = new RTCPeerConnection(config);  // local peer connection

  // add ice candidate event listener
  peerConnection.addEventListener("icecandidate", e => {
    let candidate = null;
    
    // prepare a candidate object that can be passed through browser channel
    if (e.candidate !== null) {
      candidate = {
        candidate: e.candidate.candidate,
        sdpMid: e.candidate.sdpMid,
        sdpMLineIndex: e.candidate.sdpMLineIndex,
      };
    }
    channel.postMessage({ type: "icecandidate", candidate });
  });
  
   // add media tracks to the peer connection
  local.srcObject.getTracks()
    .forEach(track => peerConnection.addTrack(track, local.srcObject));
    
  // Create offer and send through the browser channel
  peerConnection.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true })
    .then(async offer => {
      await peerConnection.setLocalDescription(offer);
      console.log("Created offer, sending...");
      channel.postMessage({ type: "offer", sdp: offer.sdp });
    });
}

函數(shù)中有兩個(gè)區(qū)域與BrowserChannel對(duì)象交互。第一個(gè)是ICE候選事件偵聽(tīng)器:

peerConnection.addEventListener("icecandidate", e => {
  let candidate = null;
  
  // prepare a candidate object that can be passed through browser channel
  if (e.candidate !== null) {
    candidate = {
      candidate: e.candidate.candidate,
      sdpMid: e.candidate.sdpMid,
      sdpMLineIndex: e.candidate.sdpMLineIndex,
    };
  }
  channel.postMessage({ type: "icecandidate", candidate });
});

另一種是生成offer后:

peerConnection.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true })
  .then(async offer => {
    await peerConnection.setLocalDescription(offer);
    console.log("Created offer, sending...");
    channel.postMessage({ type: "offer", sdp: offer.sdp });
  });

讓我們先看看ICE候選事件偵聽(tīng)器。如果您將e.candidate對(duì)象直接傳遞給BroadcastChannel對(duì)象,您將在控制臺(tái)中收到DataCloneError: object can not be cloned錯(cuò)誤消息。

發(fā)生此錯(cuò)誤是因?yàn)锽roadcastChannel對(duì)象無(wú)法直接處理e.candidate。您需要從e.candidate創(chuàng)建一個(gè)包含所需詳細(xì)信息的對(duì)象以發(fā)送到BroadcastChannel對(duì)象。我們必須做同樣的事情來(lái)發(fā)送offer。

您需要調(diào)用channel.postMessage方法向BroadcastChannel對(duì)象發(fā)送消息。調(diào)用此消息時(shí),另一個(gè)網(wǎng)頁(yè)上的BroadcastChannel對(duì)象會(huì)觸發(fā)其onmessage事件偵聽(tīng)器。從index.html頁(yè)面查看此代碼:

channel.onmessage = e => {
  if (e.data.type === "icecandidate") {
    peerConnection?.addIceCandidate(e.data.candidate)
  } else if (e.data.type === "offer") {
    console.log("Received offer")
    handleOffer(e.data)
  }
}

如您所見(jiàn),我們有條件語(yǔ)句檢查進(jìn)入BroadcastChannel對(duì)象的消息類(lèi)型。消息的內(nèi)容可以通過(guò)e.data讀取。e.data.type對(duì)應(yīng)于我們通過(guò)channel.postMessage發(fā)送的對(duì)象的類(lèi)型字段:

// from the ICE candidate event listener
channel.postMessage({ type: "icecandidate", candidate });

// from generating an offer
channel.postMessage({ type: "offer", sdp: offer.sdp });

現(xiàn)在,讓我們看一下處理收到的offer的index.html文件。

index.html文件的詳細(xì)分解

index.html文件以handleOffer函數(shù)開(kāi)頭:

function handleOffer(offer) {
  const config = {};
  peerConnection = new RTCPeerConnection(config);
  peerConnection.addEventListener("track", e => remote.srcObject = e.streams[0]);
  peerConnection.addEventListener("icecandidate", e => {
    let candidate = null;
    if (e.candidate !== null) {
      candidate = {
        candidate: e.candidate.candidate,
        sdpMid: e.candidate.sdpMid,
        sdpMLineIndex: e.candidate.sdpMLineIndex,
      }
    }
    channel.postMessage({ type: "icecandidate", candidate })
  });
  peerConnection.setRemoteDescription(offer)
    .then(() => peerConnection.createAnswer())
    .then(async answer => {
      await peerConnection.setLocalDescription(answer);
      console.log("Created answer, sending...")
      channel.postMessage({
        type: "answer",
        sdp: answer.sdp,
      });
    });
}

當(dāng)觸發(fā)時(shí),此方法創(chuàng)建對(duì)等連接并將其生成的任何ICE候選發(fā)送給另一個(gè)對(duì)等。然后,繼續(xù)握手過(guò)程,將流媒體的offer設(shè)置為其遠(yuǎn)程描述,生成answer,將該answer設(shè)置為其本地描述,并使用BroadcastChannel對(duì)象將該answer發(fā)送給流媒體。

與index.html文件中的BroadcastChannel對(duì)象一樣,streamer.html文件中的BroadcastChannel對(duì)象需要一個(gè)onmessage事件偵聽(tīng)器來(lái)接收ICE候選者并從index.html文件中回答:

channel.onmessage = e => {
  if (e.data.type === "icecandidate") {
    peerConnection?.addIceCandidate(e.data.candidate);
  } else if (e.data.type === "answer") {
    console.log("Received answer")
    peerConnection?.setRemoteDescription(e.data);
  }
}

如果您想知道為什么問(wèn)號(hào)?在peerConnection之后,它告訴JavaScript運(yùn)行時(shí)在peerConnection未定義時(shí)不要拋出錯(cuò)誤。這在某種程度上是一個(gè)簡(jiǎn)寫(xiě):

if (peerConnection) {
  peerConnection.setRemoteDescription(e.data);
}

用我們的Signal服務(wù)器替換BroadcastChannel

BroadcastChannel僅限于瀏覽器上下文。在這一步中,我們將通過(guò)使用一個(gè)簡(jiǎn)單的Signal服務(wù)器來(lái)克服這個(gè)限制,我們將使用Node. js構(gòu)建它。與前面的步驟一樣,我將首先給您粘貼的代碼,然后解釋其中發(fā)生了什么。

那么,讓我們開(kāi)始吧。這一步需要四個(gè)文件:index.html、streamer.html、signalserverclass.js和server/index.js。

我們將從signalserverclass.js文件開(kāi)始:

class SignalServer {
  constructor(channel) {    
    this.socket = new WebSocket("ws://localhost:80");
    this.socket.addEventListener("open", () => {
      this.postMessage({ type: "join-channel", channel });
    });
    this.socket.addEventListener("message", (e) => {
      const object = JSON.parse(e.data);
      if (object.type === "connection-established") console.log("connection established");
      else if (object.type === "joined-channel") console.log("Joined channel: " + object.channel);
      else this.onmessage({ data: object });
    });
  }
  
  onmessage(e) {}
  postMessage(data) {
    this.socket.send( JSON.stringify(data) );
  }
}

接下來(lái),讓我們更新index.html和streamer.html文件。對(duì)這些文件的唯一更改是我們初始化BroadcastChannel對(duì)象和導(dǎo)入signalserverclass.js腳本的腳本標(biāo)記。

這是更新的index.html文件:

<body>
  <video id="remote" controls></video>
  
  <script src="signalserverclass.js"></script>          <!-- new change -->
  <script>
    const remote = document.querySelector("video#remote");
    let peerConnection;
    const channel = new SignalServer("stream-video");     // <- new change
    channel.onmessage = e => {
      if (e.data.type === "icecandidate") {
        peerConnection?.addIceCandidate(e.data.candidate);
      } else if (e.data.type === "offer") {
        console.log("Received offer");
        handleOffer(e.data);
      }
    }function handleOffer(offer) {
      const config = {};
      peerConnection = new RTCPeerConnection(config);
      peerConnection.addEventListener("track", e => remote.srcObject = e.streams[0]);
      peerConnection.addEventListener("icecandidate", e => {
        let candidate = null;
        if (e.candidate !== null) {
          candidate = {
            candidate: e.candidate.candidate,
            sdpMid: e.candidate.sdpMid,
            sdpMLineIndex: e.candidate.sdpMLineIndex,
          };
        }
        channel.postMessage({ type: "icecandidate", candidate });
      });
      peerConnection.setRemoteDescription(offer)
        .then(() => peerConnection.createAnswer())
        .then(async answer => {
          await peerConnection.setLocalDescription(answer);
          console.log("Created answer, sending...");
          channel.postMessage({
            type: "answer",
            sdp: answer.sdp,
          });
        });
    }
  </script>
</body>

這是更新后的streamer.html文件:

<body>
  <video id="local" autoplay muted></video>
  <button onclick="start(this)">start video</button>
  <button id="stream" onclick="stream(this)" disabled>stream video</button>
  <script src="signalserverclass.js"></script>         <!-- new change -->
  <script>
    const local = document.querySelector("video#local");
    let peerConnection;

    const channel = new SignalServer("stream-video");     // <- new change
    channel.onmessage = e => {
      if (e.data.type === "icecandidate") {
        peerConnection?.addIceCandidate(e.data.candidate);
      } else if (e.data.type === "answer") {
        console.log("Received answer");
        peerConnection?.setRemoteDescription(e.data);
      }
    }

    // function to ask for camera and microphone permission
    // and stream to #local video element
    function start(e) {
      e.disabled = true;
      document.getElementById("stream").disabled = false;  // enable the stream button
      navigator.mediaDevices.getUserMedia({ audio: true, video: true })
        .then((stream) => local.srcObject = stream);
    }

    function stream(e) {
      e.disabled = true;
      
      const config = {};
      peerConnection = new RTCPeerConnection(config);  // local peer connection
      peerConnection.addEventListener("icecandidate", e => {
        let candidate = null;
        if (e.candidate !== null) {
          candidate = {
            candidate: e.candidate.candidate,
            sdpMid: e.candidate.sdpMid,
            sdpMLineIndex: e.candidate.sdpMLineIndex,
          };
        }
        channel.postMessage({ type: "icecandidate", candidate });
      });
      local.srcObject.getTracks()
        .forEach(track => peerConnection.addTrack(track, local.srcObject));
        
      peerConnection.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true })
        .then(async offer => {
          await peerConnection.setLocalDescription(offer);
          console.log("Created offer, sending...");
          channel.postMessage({ type: "offer", sdp: offer.sdp });
        });
    }
  </script>
</body>

最后,這是server/index.js文件的內(nèi)容:

const { WebSocketServer } = require("ws");

const channels = {};
const server = new WebSocketServer({ port: 80 });
server.on("connection", handleConnection);

function handleConnection(ws) {
  console.log('New connection');
  ws.send( JSON.stringify({ type: 'connection-established' }) );
  
  let id;
  let channel = "";
  ws.on("error", () => console.log('websocket error'));
  ws.on('message', message => {
    const object = JSON.parse(message);
    
    if (object.type === "join-channel") {
      channel = object.channel;
      if (channels[channel] === undefined) channels[channel] = [];
      id = channels[channel].length || 0;
      channels[channel].push(ws);
      ws.send(JSON.stringify({type: 'joined-channel', channel}));
    } else {
      // forward the message to other channel memebers
      channels[channel]?.filter((_, i) => i !== id).forEach((member) => {
        member.send(message.toString());
      });
    }
  });
  ws.on('close', () => {
    console.log('Client has disconnected!');
    if (channel !== "") {
      channels[channel] = channels[channel].filter((_, i) => i !== id);
    }
  });
}

圖片圖片

要讓服務(wù)器運(yùn)行,您需要在終端中打開(kāi)server文件夾,將該文件夾初始化為Node項(xiàng)目,安裝ws包,然后運(yùn)行index.js文件。這些步驟可以使用以下命令完成:

# initialize the project directory
npm init --y

# install the `ws` package
npm install ws

# run the `index.js` file
node index.js

現(xiàn)在,讓我們看看文件。為了減少在將BroadcastChannel對(duì)象構(gòu)造函數(shù)與SignalServer構(gòu)造函數(shù)交換后編輯代碼的需要,我嘗試讓SignalServer類(lèi)模仿您使用BroadcastChannel所做的調(diào)用和事情-至少對(duì)于我們的用例:

class SignalServer {
  constructor(channel) {    
    // what the constructor does
  }
  
  onmessage(e) {}
  postMessage(data) {
    // what postMessage does
  }
}

此類(lèi)有一個(gè)在初始化時(shí)加入通道的構(gòu)造函數(shù)。它還有一個(gè)postMessage函數(shù)來(lái)允許發(fā)送消息和一個(gè)onmessage方法,當(dāng)從另一個(gè)SignalServer對(duì)象接收到消息時(shí)調(diào)用該方法。

SignalServer類(lèi)的另一個(gè)目的是抽象我們的后端進(jìn)程。我們的信號(hào)服務(wù)器是一個(gè)WebSocket服務(wù)器,因?yàn)樗试S我們?cè)诜?wù)器和客戶(hù)端之間進(jìn)行基于事件的雙向通信,這使得它成為構(gòu)建信號(hào)服務(wù)器的首選。

SignalServer類(lèi)從其構(gòu)造函數(shù)開(kāi)始其操作:

constructor(channel) {    
  this.socket = new WebSocket("ws://localhost:80");
  this.socket.addEventListener("open", () => {
    this.postMessage({ type: "join-channel", channel });
  });
  this.socket.addEventListener("message", (e) => {
    const object = JSON.parse(e.data);
    if (object.type === "connection-established") console.log("connection established");
    else if (object.type === "joined-channel") console.log("Joined channel: " + object.channel);
    else this.onmessage({ data: object });
  });
}

它首先初始化與后端的連接。當(dāng)連接變?yōu)榛顒?dòng)狀態(tài)時(shí),它會(huì)向服務(wù)器發(fā)送一個(gè)我們用作join-channel請(qǐng)求的對(duì)象:

this.socket.addEventListener("open", () => {
  this.postMessage({ type: "join-channel", channel });
});

現(xiàn)在,讓我們看一下我們的WebSocket服務(wù)器:

const { WebSocketServer } = require("ws");

const channels = {};
const server = new WebSocketServer({ port: 80 });
server.on("connection", handleConnection);

function handleConnection(ws) {
  // I cut out the details because it's not in focus right now
}

這是一個(gè)非常標(biāo)準(zhǔn)的WebSocket服務(wù)器。我們有服務(wù)器初始化和事件偵聽(tīng)器,用于新客戶(hù)端連接到服務(wù)器時(shí)。唯一的新功能是channels變量,我們使用它來(lái)存儲(chǔ)每個(gè)SignalServer對(duì)象加入的通道。

如果一個(gè)通道不存在并且一個(gè)對(duì)象想要加入該通道,我們希望服務(wù)器創(chuàng)建一個(gè)空數(shù)組,其中WebSocket連接作為第一個(gè)元素。然后,我們將該數(shù)組存儲(chǔ)為channels對(duì)象中帶有通道名稱(chēng)的字段。

您可以在下面的message事件偵聽(tīng)器中看到這一點(diǎn)。代碼看起來(lái)有點(diǎn)復(fù)雜,但上面的解釋是對(duì)代碼作用的一般概述:

// ... first rest of the code
ws.on('message', message => {
  const object = JSON.parse(message);
  
  if (object.type === "join-channel") {
    channel = object.channel;
    if (channels[channel] === undefined) channels[channel] = [];
    id = channels[channel].length || 0;
    channels[channel].push(ws);
    ws.send(JSON.stringify({type: 'joined-channel', channel}));
// ... other rest of the code

之后,事件偵聽(tīng)器向SignalServer對(duì)象發(fā)送joined-channel消息,告訴它加入通道的請(qǐng)求成功。

至于事件偵聽(tīng)器的其余部分,它將任何不是join-channel類(lèi)型的消息發(fā)送到通道中的其他SignalServer對(duì)象:

// rest of the event listener
  } else {
    // forward the message to other channel memebers
    channels[channel]?.filter((_, i) => i !== id).forEach((member) => {
      member.send(message.toString());
    });
  }
});

在handleConnection函數(shù)中,id和channel變量分別存儲(chǔ)SignalServer objectWebSocket連接在通道中的位置和SignalServer objectWebSocket連接存儲(chǔ)在其中的通道名稱(chēng):

let id;
let channel = ""; 讓id;讓頻道="";

這些變量是在SignalServer對(duì)象加入通道時(shí)設(shè)置的。它們有助于將來(lái)自一個(gè)SignalServer對(duì)象的消息傳遞給通道中的其他對(duì)象,正如您在else塊中看到的那樣。當(dāng)SignalServer對(duì)象因任何原因斷開(kāi)連接時(shí),它們也有助于從通道中刪除它們:

ws.on('close', () => {
  console.log('Client has disconnected!');
  if (channel !== "") {
    channels[channel] = channels[channel].filter((_, i) => i !== id);
  }
});

最后,回到signalserverclass.js文件中的SignalServer類(lèi)。讓我們看一下從WebSocket服務(wù)器接收消息的部分:

this.socket.addEventListener("message", (e) => {
  const object = JSON.parse(e.data);
  if (object.type === "connection-established") console.log("connection established");
  else if (object.type === "joined-channel") console.log("Joined channel: " + object.channel);
  else this.onmessage({ data: object });
});

如果您查看WebSocket服務(wù)器的handleConnection函數(shù),服務(wù)器直接發(fā)送到SignalServer對(duì)象的消息類(lèi)型有兩種:joined-channel和connection-established。這兩種消息類(lèi)型由此事件偵聽(tīng)器直接處理。

結(jié)論

在本文中,我們介紹了如何使用WebRTC構(gòu)建P2P視頻流應(yīng)用程序——它的主要用例之一。

我們從在單個(gè)頁(yè)面中創(chuàng)建對(duì)等連接開(kāi)始,以便簡(jiǎn)單了解WebRTC應(yīng)用程序是如何工作的,而無(wú)需擔(dān)心信令。然后,我們談到了使用廣播頻道API進(jìn)行信令。最后,我們構(gòu)建了自己的singal服務(wù)器。

關(guān)注我,變得更強(qiáng)。

原文:https://blog.logrocket.com/webrtc-video-streaming/

作者:Oduah Chigozie

責(zé)任編輯:武曉燕 來(lái)源: 宇宙一碼平川
相關(guān)推薦

2012-12-10 09:46:21

P2P云存儲(chǔ)Symform

2010-03-10 10:51:30

2010-03-22 15:27:40

云計(jì)算

2020-03-05 20:30:15

Syncthing文件同步工具開(kāi)源

2010-07-13 14:41:14

2022-07-19 16:59:04

流媒體傳輸IPC物聯(lián)網(wǎng)

2010-10-29 09:43:50

Wi-Fi DirecWi-Fi聯(lián)

2010-06-28 11:15:45

BitTorrent協(xié)

2012-09-25 13:47:43

C#網(wǎng)絡(luò)協(xié)議P2P

2015-04-27 11:49:23

2018-08-16 07:29:02

2013-03-13 09:24:56

2010-07-07 10:31:45

2013-12-12 13:46:40

大數(shù)據(jù)金融P2P大數(shù)據(jù)

2015-04-27 14:29:53

C#UDP實(shí)現(xiàn)P2P語(yǔ)音聊天工具

2009-05-18 09:11:00

IPTV融合寬帶

2021-09-02 19:45:21

P2P互聯(lián)網(wǎng)加速

2009-01-08 09:52:00

2009-07-22 15:52:01

2011-11-29 09:48:43

點(diǎn)贊
收藏

51CTO技術(shù)棧公眾號(hào)