目錄
完整的userList.js(創建用戶在線列表,添加邀請事件,初始化聊天室)
WebRTC
網頁即時通信,是Web Real-Time Communication 的縮寫,它支持peer-to-peer(瀏覽器與瀏覽器之間)進行視頻,音頻傳輸,並保證傳輸質量,將其發送至本地audio標簽,video標簽或發送到另一個瀏覽器中,本文使用到navigator.mediaDevices,RTCPeerConnection對象配合socket+node構建遠程實時視頻聊天功能,文章有一個不足之處,后面會講到。
參考文章:
https://rtcdeveloper.com/t/topic/13777
代碼原理及流程
前端
要實現點對點就需要用到socket長鏈接從服務端進行尋呼
這是我以前的一篇關於socket簡單使用的小案例:https://blog.csdn.net/time_____/article/details/86748679
首先引入socket.io,這里我將前端js分成三部分,分別是socket.js(socket相關操作),userList.js(頁面操作),video.js(視頻聊天)
先附上HTML和CSS
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
<link rel="stylesheet" href="./style/main.css">
<script src="./js/socket.io.js"></script>
<script src="./js/socket.js"></script>
<script src="./js/userList.js"></script>
<script src="./js/video.js"></script>
</head>
<body>
<div id="login" hidden class="loginBox">
<input id="userName" autocomplete="false" class="userName" type="text" placeholder="請輸入英文用戶名">
<button id="submit">提交</button>
</div>
<div id="chatBox" class="chatBox" hidden>
<h1 id="myName" class="myName"></h1>
<ul id="userList" class="userList"></ul>
</div>
<div id="videoChat" hidden class="videoChat">
<button id="back" hidden>結束</button>
<video id="myVideo" src="" class="myVideo"></video>
<video id="otherVideo" src="" class="otherVideo"></video>
</div>
<script>
checkToken()
function checkToken() { //判斷用戶是否已有用戶名
if (localStorage.token) {
login.hidden = true
chatBox.hidden = false
initSocket(localStorage.token) //初始化socket連接
} else {
login.hidden = false
chatBox.hidden = true
submit.addEventListener('click', function (e) {
initSocket(userName.value) //初始化socket連接
})
}
}
</script>
</body>
</html>
* {
margin: 0;
padding: 0;
}
.loginBox {
width: 300px;
height: 200px;
margin: 50px auto 0;
}
.userName,
.loginBox button {
width: 300px;
height: 60px;
border-radius: 10px;
outline: none;
font-size: 26px;
}
.userName {
border: 1px solid lightcoral;
text-align: center;
}
.loginBox button {
margin-top: 30px;
display: block;
}
input::placeholder {
font-size: 26px;
text-align: center;
}
.chatBox {
width: 200px;
margin: 50px auto 0;
position: relative;
}
.myName {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 50px;
font-size: 40px;
text-align: center;
line-height: 50px;
background: lightcoral;
}
.userList {
height: 500px;
width: 100%;
padding-top: 50px;
overflow-y: scroll;
list-style: none;
}
.userList>li {
background: lightblue;
height: 50px;
font-size: 20px;
line-height: 50px;
text-align: center;
}
.videoChat {
background: lightgreen;
width: 500px;
height: 400px;
margin: 50px auto 0;
}
.videoChat button {
width: 500px;
height: 60px;
border-radius: 10px;
outline: none;
float: left;
font-size: 26px;
}
.myVideo,.otherVideo{
width: 250px;
height: 250px;
float: left;
overflow: hidden;
}
大致效果
socket.js
首先建立socket連接,添加連接和斷開的事件
let socket //供其他頁面調用
function initSocket(token) {//獲取到用戶輸入的id並傳到服務端
socket = io('http://127.0.0.1:1024?token=' + token, {
autoConnect: false
});
socket.open();
socket.on('open', socketOpen); //連接登錄
socket.on('disconnect', socketClose); //連接斷開
}
function socketClose(reason) { //主動或被動關閉socket
console.log(reason)
localStorage.removeItem("token")
}
function socketOpen(data) { //socket開啟
if (!data.result) { //當服務端找到相同id時跳出連接
console.log(data.msg)
return;
}
createChatList(data) //創建用戶列表
localStorage.setItem('token', data.token)
login.hidden = true
chatBox.hidden = false
videoChat.hidden = true
myName.textContent = localStorage.token
}
之后在socket中添加幾個事件監聽
socket.on('dataChange', createChatList); //新增人員
socket.on('inviteVideoHandler', inviteVideoHandler); //被邀請視頻
socket.on('askVideoHandler', askVideoHandler); //視頻邀請結果
socket.on('ice', getIce); //從服務端接收ice
socket.on('offer', getOffer); //從服務端接收offer
socket.on('answer', getAnswer); //從服務端接收answer
socket.on('break', stopVideoStream) //掛斷視頻通話
若用戶接到對方邀請時,彈出確認框,並將結果返回給對方
function inviteVideoHandler(data) { //當用戶被邀請時執行
let allow = 0
if (isCalling) {
allow = -1 //正在通話
} else {
let res = confirm(data.msg);
if (res) {
allow = 1
startVideoChat(data.token) //用戶點擊同意后開始初始化視頻聊天
localStorage.setItem('roomNo', data.roomNo) //將房間號保存
}
}
socket.emit('askVideo', {
myId: localStorage.token,
otherId: data.token,
type: 'askVideo',
allow
});
}
當收到返回邀請結果時,后端創建視頻聊天房間后,開始初始化聊天室
function askVideoHandler(data) { //獲取被邀請用戶的回復
console.log(data.msg)
if (data.allow == -1) return //通話中
if (data.allow) {
localStorage.setItem('roomNo', data.roomNo) //將房間號保存
startVideoChat(data.token)
}
}
當用戶掛斷時
function breakVideoConnect(e) {
console.log(localStorage.getItem('roomNo'))
socket.emit('_break', {
roomNo: localStorage.getItem('roomNo')
});
}
-
完整的socket.js
let socket //供其他頁面調用 function initSocket(token) {//獲取到用戶輸入的id並傳到服務端 socket = io('http://127.0.0.1:1024?token=' + token, { autoConnect: false }); socket.open(); socket.on('open', socketOpen); //連接登錄 socket.on('disconnect', socketClose); //連接斷開 socket.on('dataChange', createChatList); //新增人員 socket.on('inviteVideoHandler', inviteVideoHandler); //被邀請視頻 socket.on('askVideoHandler', askVideoHandler); //視頻邀請結果 socket.on('ice', getIce); //從服務端接收ice socket.on('offer', getOffer); //從服務端接收offer socket.on('answer', getAnswer); //從服務端接收answer socket.on('break', stopVideoStream) //掛斷視頻通話 } function socketClose(reason) { //主動或被動關閉socket console.log(reason) localStorage.removeItem("token") } function socketOpen(data) { //socket開啟 if (!data.result) { //當服務端找到相同id時跳出連接 console.log(data.msg) return; } createChatList(data) //創建用戶列表 localStorage.setItem('token', data.token) login.hidden = true chatBox.hidden = false videoChat.hidden = true myName.textContent = localStorage.token } function inviteVideoHandler(data) { //當用戶被邀請時執行 let allow = 0 if (isCalling) { allow = -1 //正在通話 } else { let res = confirm(data.msg); if (res) { allow = 1 startVideoChat(data.token) //用戶點擊同意后開始初始化視頻聊天 localStorage.setItem('roomNo', data.roomNo) //將房間號保存 } } socket.emit('askVideo', { myId: localStorage.token, otherId: data.token, type: 'askVideo', allow }); } function askVideoHandler(data) { //獲取被邀請用戶的回復 console.log(data.msg) if (data.allow == -1) return //通話中 if (data.allow) { localStorage.setItem('roomNo', data.roomNo) //將房間號保存 startVideoChat(data.token) } } function breakVideoConnect(e) { console.log(localStorage.getItem('roomNo')) socket.emit('_break', { roomNo: localStorage.getItem('roomNo') }); }
-
完整的userList.js(創建用戶在線列表,添加邀請事件,初始化聊天室)
function createChatList(data) { //新建用戶列表
console.log(data.msg)
let userData = data.userIds
let userList = document.querySelector('#userList')
if (userList) {
userList.remove()
userList = null
}
userList = createEle('ul', {}, {
id: 'userList',
className: 'userList'
})
chatBox.appendChild(userList)
for (let key in userData) {
if (userData[key] != localStorage.token) {
var li = createEle('li', {}, {
textContent: userData[key]
})
li.addEventListener('click', videoStart)
userList.appendChild(li)
}
}
}
function createEle(ele, style, attribute) { //新增標簽,設置屬性及樣式
let element = document.createElement(ele)
if (style) {
for (let key in style) {
element.style[key] = style[key];
}
}
if (attribute) {
for (let key in attribute) {
element[key] = attribute[key];
}
}
return element
}
function videoStart(e) { //用戶點擊列表某個用戶時發送邀請至服務端
socket.emit('inviteVideo', {
myId: localStorage.token,
otherId: this.textContent,
type: 'inviteVideo'
});
}
function startVideoChat(otherId) { //初始化視頻聊天
videoChat.hidden = false
login.hidden = true
chatBox.hidden = true
localStorage.setItem('otherId', otherId) //將對方的id保存
startVideoStream()
}
video.js
初始化媒體對象,並將Stream存到全局,這里由於navigator.mediaDevices.getUserMedia是異步方法,需要同步執行,先獲取stream,然后進行后續操作
async function createMedia() { //同步創建本地媒體流
if (!stream) {
stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: true
})
}
console.log(stream)
let video = document.querySelector('#myVideo');
video.srcObject = stream; //將媒體流輸出到本地video以顯示自己
video.onloadedmetadata = function (e) {
video.play();
};
createPeerConnection()
}
創建stream后,初始化RTCPeerConnection,用於建立視頻連接,同樣需要同步獲取,並且在peer獲取之后發送offer給對方
async function createPeerConnection() { //同步初始化描述文件並添加事件
if (!peer) {
peer = new RTCPeerConnection()
}
await stream.getTracks().forEach(async track => {
await peer.addTrack(track, stream); //將本地流附屬至peer中
});
// await peer.addStream(stream); //舊方法(將本地流附屬至peer中)
peer.addEventListener('addstream', setVideo) //當peer收到其他流時顯示另一個video以顯示對方
peer.addEventListener('icecandidate', sendIce) //獲取到candidate時,將其發送至服務端,傳至對方
peer.addEventListener('negotiationneeded', sendOffer) //雙方約定的協商被完成時才觸發該方法
}
當收到對方發送過來的stream時,即觸發addstream事件時,通過setVideo將對方的視頻流放到本地video中
function setVideo(data) { //播放對方的視頻流
console.log(data.stream)
let back = document.getElementById('back')
back.hidden = false //顯示掛斷按鈕
back.addEventListener('click', breakVideoConnect) //掛斷事件
isCalling = true //正在通話
let video = document.querySelector('#otherVideo');
video.srcObject = data.stream;
video.onloadedmetadata = function (e) {
video.play();
};
}
創建offer,保存本地offer,並發送offer給對方
async function sendOffer() { //同步發送offer到服務端,發送給對方
let offer = await peer.createOffer();
await peer.setLocalDescription(offer); //peer本地附屬offer
socket.emit('_offer', {
streamData: offer
});
}
收到對方的offer后,保存遠程offer,但是這里有一個小問題,如果peer還沒有創建好,也就是如果對方先創建,就會馬上發offer過來,這時我們這邊的peer可能還沒有創建成功,如果直接調用setRemoteDescription的話會報錯,所以可以用try catch來調用,或使用if(!peer) return的方式運行
async function getOffer(data) { //接收到offer后,返回answer給對方
await peer.setRemoteDescription(data.streamData); //peer遠程附屬offer
sendAnswer()
}
//優化后
async function getOffer(data) { //接收到offer后,返回answer給對方
if (!peer) return //等待對方響應,也可以用try catch
await peer.setRemoteDescription(data.streamData); //peer遠程附屬offer
sendAnswer()
}
創建answer,保存本地answer,發送answer給對方
async function sendAnswer() {
let answer = await peer.createAnswer();
await peer.setLocalDescription(answer); //peer附屬本地answer
socket.emit('_answer', {
streamData: answer
});
}
接收到answer時,保存本地answer
async function getAnswer(data) { //接收到answer后,peer遠程附屬answer
await peer.setRemoteDescription(data.streamData);
}
peer觸發icecandidate事件時,即本地觸發過setLocalDescription時,也就是將本地offer和本地answer保存時,觸發方法
function sendIce(e) { //setLocalDescription觸發時,發送ICE給對方
if (e.candidate) {
socket.emit('_ice', {
streamData: e.candidate
});
}
}
接收對方的ICE,但是這里有一個和上面一樣的小問題如果在ICE事件中,peer還沒有創建好,也就是如果對方先創建,就會馬上發offer過來,這時我們這邊的peer可能還沒有創建成功,如果直接調用addIceCandidate的話會報錯,所以可以用try catch來調用,或使用if(!peer) return的方式運行
async function getIce(data) { //獲取對方的ICE
var candidate = new RTCIceCandidate(data.streamData)
await peer.addIceCandidate(candidate)
}
//優化后
async function getIce(data) { //獲取對方的ICE
if (!peer) return //等待對方響應,也可以用try catch
var candidate = new RTCIceCandidate(data.streamData)
await peer.addIceCandidate(candidate)
}
遇到的問題
最后是掛斷的方法,這里有個小問題,當掛斷時,原來的stream無法刪除,導致攝像頭雖然沒有調用,但是導航欄仍然會有攝像頭圖標(沒有真正關閉),下一次打開時會傳輸前面的流(疊加),網上沒有解決方式,如果有知道的同學,希望能補充優化,感謝
function stopVideoStream(data) { //停止傳輸視頻流
console.log(data.msg)
stream.getTracks().forEach(async function (track) { //這里得到視頻或音頻對象
await track.stop();
await stream.removeTrack(track)
stream = null
})
peer.close();
peer = null;
isCalling = false
videoChat.hidden = true
login.hidden = true
chatBox.hidden = false
}
-
優化后完整的video.js
var stream, peer, isCalling = false //初始化要發送的流,和描述文件,通話狀態 function startVideoStream(e) { //開始傳輸視頻流 createMedia() } function stopVideoStream(data) { //停止傳輸視頻流 console.log(data.msg) stream.getTracks().forEach(async function (track) { //這里得到視頻或音頻對象 await track.stop(); await stream.removeTrack(track) stream = null }) peer.close(); peer = null; isCalling = false videoChat.hidden = true login.hidden = true chatBox.hidden = false } async function createMedia() { //同步創建本地媒體流 if (!stream) { stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true }) } console.log(stream) let video = document.querySelector('#myVideo'); video.srcObject = stream; //將媒體流輸出到本地video以顯示自己 video.onloadedmetadata = function (e) { video.play(); }; createPeerConnection() } async function createPeerConnection() { //同步初始化描述文件並添加事件 if (!peer) { peer = new RTCPeerConnection() } await stream.getTracks().forEach(async track => { await peer.addTrack(track, stream); //將本地流附屬至peer中 }); // await peer.addStream(stream); //舊方法(將本地流附屬至peer中) peer.addEventListener('addstream', setVideo) //當peer收到其他流時顯示另一個video以顯示對方 peer.addEventListener('icecandidate', sendIce) //獲取到candidate時,將其發送至服務端,傳至對方 peer.addEventListener('negotiationneeded', sendOffer) //雙方約定的協商被完成時才觸發該方法 } function setVideo(data) { //播放對方的視頻流 console.log(data.stream) let back = document.getElementById('back') back.hidden = false //顯示掛斷按鈕 back.addEventListener('click', breakVideoConnect) //掛斷事件 isCalling = true //正在通話 let video = document.querySelector('#otherVideo'); video.srcObject = data.stream; video.onloadedmetadata = function (e) { video.play(); }; } async function sendOffer() { //同步發送offer到服務端,發送給對方 let offer = await peer.createOffer(); await peer.setLocalDescription(offer); //peer本地附屬offer socket.emit('_offer', { streamData: offer }); } async function getOffer(data) { //接收到offer后,返回answer給對方 if (!peer) return //等待對方響應,也可以用try catch await peer.setRemoteDescription(data.streamData); //peer遠程附屬offer sendAnswer() } async function sendAnswer() { let answer = await peer.createAnswer(); await peer.setLocalDescription(answer); //peer附屬本地answer socket.emit('_answer', { streamData: answer }); } async function getAnswer(data) { //接收到answer后,peer遠程附屬answer await peer.setRemoteDescription(data.streamData); } function sendIce(e) { //setLocalDescription觸發時,發送ICE給對方 if (!e || !e.candidate) return socket.emit('_ice', { streamData: e.candidate }); } async function getIce(data) { //獲取對方的ICE if (!peer) return //等待對方響應,也可以用try catch var candidate = new RTCIceCandidate(data.streamData) await peer.addIceCandidate(candidate) }
服務端
后端部分同樣使用socketio進行通信
首先在npm初始化后下載express,socket.io
npm i express --save-dev
npm i socket.io --save-dev
之后引入至server.js中
const express = require('express')
const app = express();
const server = require('http').Server(app);
const io = require('socket.io')(server);
並監聽1024端口
server.listen(1024, function () {
console.log('Socket Open')
});
配置socket
給socket添加一些事件
io.on('connect', socket => {
let {
token
} = socket.handshake.query
socket.on('disconnect', (exit) => { //socket斷開
delFormList(token) //清除用戶
broadCast(socket, token, 'leave') //廣播給其他用戶
})
});
這樣,我們最簡單的一個socket就搭好了
完整的server.js
const express = require('express')
const app = express();
const server = require('http').Server(app);
const io = require('socket.io')(server);
let userList = {} //用戶列表,所有連接的用戶
let userIds = {} //用戶id列表,顯示到前端
let roomList = {} //房間列表,視頻聊天
io.on('connect', socket => {
let {
token
} = socket.handshake.query
socket.on('disconnect', (exit) => { //socket斷開
delFormList(token) //清除用戶
broadCast(socket, token, 'leave') //廣播給其他用戶
})
socket.on('inviteVideo', inviteVideoHandler) //邀請用戶
socket.on('askVideo', inviteVideoHandler); //回應用戶是否邀請成功
if (userList[token]) { //找到相同用戶名就跳出函數
socket.emit('open', {
result: 0,
msg: token + '已存在'
});
socket.disconnect()
return;
}
addToList(token, socket) //用戶連接時,添加到userList
broadCast(socket, token, 'enter') //廣告其他用戶,有人加入
});
function addToList(key, item) { //添加到userList
item.emit('open', {
result: 1,
msg: '你已加入聊天',
userIds,
token: key
});
userList[key] = item
userIds[key] = key
}
function delFormList(key) { //斷開時,刪除用戶
delete userList[key];
delete userIds[key]
}
function broadCast(target, token, type) { //廣播功能
let msg = '加入聊天'
if (type !== 'enter') {
msg = '離開聊天'
}
target.broadcast.emit('dataChange', {
result: 1,
msg: token + msg,
userIds
});
}
function inviteVideoHandler(data) { //邀請方法
let {
myId,
otherId,
type,
allow
} = data, msg = '邀請你進入聊天室', event = 'inviteVideoHandler', roomNo = otherId //默認房間號為邀請方id
if (type == 'askVideo') {
event = 'askVideoHandler'
if (allow == 1) {
addRoom(myId, otherId)
roomNo = myId //保存房間號
msg = '接受了你的邀請'
} else if (allow == -1) {
msg = '正在通話'
} else {
msg = '拒絕了你的邀請'
}
}
userList[otherId].emit(event, {
msg: myId + msg,
token: myId,
allow,
roomNo
});
}
async function addRoom(myId, otherId) { //用戶同意后添加到視頻聊天室,只做1對1聊天功能
roomList[myId] = [userList[myId], userList[otherId]]
startVideoChat(roomList[myId])
}
function startVideoChat(roomItem) { //視頻聊天初始化
for (let i = 0; i < roomItem.length; i++) {
roomItem[i].room = roomItem
roomItem[i].id = i
roomItem[i].on('_ice', _iceHandler)
roomItem[i].on('_offer', _offerHandler)
roomItem[i].on('_answer', _answerHandler)
roomItem[i].on('_break', _breakRoom)
}
}
function _iceHandler(data) { //用戶發送ice到服務端,服務端轉發給另一個用戶
let id = this.id == 0 ? 1 : 0 //判斷用戶二者其一
this.room[id].emit('ice', data);
}
function _offerHandler(data) { //用戶發送offer到服務端,服務端轉發給另一個用戶
let id = this.id == 0 ? 1 : 0
this.room[id].emit('offer', data);
}
function _answerHandler(data) { //用戶發送answer到服務端,服務端轉發給另一個用戶
let id = this.id == 0 ? 1 : 0
this.room[id].emit('answer', data);
}
function _breakRoom(data) { //掛斷聊天
for (let i = 0; i < roomList[data.roomNo].length || 0; i++) {
roomList[data.roomNo][i].emit('break', {
msg: '聊天掛斷'
});
}
}
server.listen(1024, function () {
console.log('Socket Open')
});
實現效果
分別在我方發送stream之前打印stream,收到對方stream后打印stream,我們會發現,雙方的stream互換了位置,也就是說整個媒體對象進行了交換
和同事在兩台電腦上測試效果
注意:
前端項目必須運行在本地或者https服務下,因為navigator.mediaDevices.getUserMedia需要運行在安全模式下,用戶第一次使用需要授權攝像頭或音頻權限,所以雙方電腦需要有相關功能
看到這里,希望留下你寶貴的建議