使用WebRTC實(shí)現(xiàn)P2P視頻流
前言
網(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