大家好~上次文章介绍了录音录影的功能,这次要来弄个多人视讯聊天室,竟然我们已经知道怎麽取得影音档案,那麽对於聊天室而言就只少了一个东西,那就是交换资料的手段,所以这篇就来介绍一下怎麽使用浏览器的 API 建立 P2P 的连线,并达到交换 stream 的功能~
首先我们先来介绍一下这次的重点 API,分别是 RTCPeerConnection
与 RTCDataChannel
RTCPeerConnection 是建立 P2P 连线的 API,使用时会使用 new
来建立实体,建立实体之後写入自己与对方的连线资讯,接着就会自动建立连线了
基本上比较常用的就是下面几个,不过有几点小提醒
// 建立实体
const peer = new RTCPeerConnection(config)
// 将 stream 加入实体
peer.addTrack(track, stream)
// 产生 Offer(返回 Promise)
peer.createOffer(offerOptions)
// 产生 Answer(返回 Promise)
peer.createAnswer()
// 设定本地端连线资讯(返回 Promise)
peer.setLocalDescription(offer)
// 设定远端连线资讯(返回 Promise)
peer.setRemoteDescription(answer)
// 设定连线的连接埠(返回 Promise)
peer.addIceCandidate(candidate)
建立实体後我们可以监听各种事件,接着在触发事件时就可以做相应的处理,以下为常用事件
// 找到连接埠时触发,会有多个(TCP、UDP)
peer.addEventListener('icecandidate', e => {
// do something...
})
// 连接埠状态变化时触发
peer.addEventListener('iceconnectionstatechange', e => {
// do something...
})
// 取得对方影音时触发
peer.addEventListener('track', e => {
// do something...
})
// 建立 channel 後,在双方连线时触发
peer.addEventListener('datachannel', e => {
// do something...
})
// 需要建立连线时触发(初始化、stream 改变)
localPeer.addEventListener('negotiationneeded', e => {
// do something...
})
RTCDataChannel,是建立在 RTCPeerConnection
实体上,可以用来交换讯息或档案,档案目前建议使用 ArrayBuffer
来处理
const peer = new RTCPeerConnection(config)
// 建立名为 channel 的 RTCDataChannel
channel = peer.createDataChannel('channel')
// 发送讯息或档案,remoteChannel 为 datachannel 事件返回的 channel
remoteChannel.send(data)
// 接收到讯息或档案後触发
channel.addEventListener('message', e => {
// do something...
})
整理一下重点,这边建立影音双向的连线必须要有三个条件
以上就是交换 stream 的方法了,那麽在开始实作之前先来看一下流程吧!
实作会分成後端服务器与前端影音的部份,当前端建立连线之前我们不知道要跟谁连线,所以需要後端来帮我们传送彼此的连线资讯,大概的流程如下:
这边有两种做法,两种都可以达成目的
我们这边采用方法 2,而服务器为了及时知道目前有谁加入所以会使用 WebSocket 来实作
首先我们先来定义一下前後端交换资料时的规格
event
:事件的类别
init
:初始化request
:请求连线response
:回应请求candidate
:传送连接埠close
:关闭连线id
:用户 ID(init
)userList
:所有聊天室内的用户清单(init
)sender
:发送者(request
、response
、candidate
、close
)taker
:接收者(request
、response
、candidate
、close
)connection
:Offer
或 Answer
(request
、response
)candidate
:连接埠资讯(candidate
)後端我们使用 express
加上 express-ws
来实作,首先安装套件
$ npm init -y
$ npm install express
$ npm install express-ws
安装好了之後我们在根目录建立一个 index.js
撰写 node 程序,注意一下 WebSocket 传送的资料都是字串的格式,所以我们会使用 JSON.stringify
与 JSON.parse
来转换
// index.js
// 使用 express 与 express-ws
const express = require('express')
const app = express()
const expressWs = require('express-ws')(app)
// 使用根目录档案作为页面
app.use(express.static(__dirname))
// 所有聊天室内的 WebSocket 实例
let websocketList = []
// 开启 WebSocket 连线网址为 ws://localhost:3000/connection
app.ws('/connection', ws => {
// 开启连线时触发
// 使用 timestamp 当作 id
const id = new Date().getTime()
// 实例绑定该 id
ws.id = id
// 送出初始化事件
ws.send(JSON.stringify({
event: 'init',
id,
userList: websocketList.map(item => item.id)
}))
// 将 WebSocket 实例放入清单
websocketList.push(ws)
// 收到讯息时触发
ws.on('message', msg => {
const data = JSON.parse(msg)
// 找到发送者的 WebSocket 实例
const taker = websocketList.find(item => item.id === data.taker)
// 请求连线
if (data.event === 'request') {
taker.send(JSON.stringify({
event: 'request',
sender: data.sender,
connection: data.connection
}))
}
// 回应请求
if (data.event === 'response') {
taker.send(JSON.stringify({
event: 'response',
sender: data.sender,
connection: data.connection
}))
}
// 传送连接埠资讯
if (data.event === 'candidate') {
taker.send(JSON.stringify({
event: 'candidate',
sender: data.sender,
candidate: data.candidate
}))
}
})
// 关闭连线时触发
ws.on('close', () => {
// 将 WebSocket 实例从清单移除
websocketList = websocketList.filter(item => item !== ws)
// 通知其他人将该连线关闭
websocketList.forEach(client => {
client.send(JSON.stringify({
event: 'close',
sender: ws.id
}))
})
})
})
app.listen(3000)
这样後端程序就完成了,基本上只是做一些资讯的传送而已
首先简单弄个版~
※ 提醒一下,这篇使用 chrome 测试,不同浏览器的支援度与安全性设定会有些不同
<!-- index.html -->
<h1>聊天室</h1>
<button id="camera">视讯镜头</button>
<button id="screen">分享萤幕</button>
<button id="close">关闭分享</button>
<input id="textInput" type="text">
<button id="submit">送出讯息</button>
<input id="fileInput" type="file">
<div class="wrap">
<div>
<video id="video" autoplay></video>
</div>
</div>
video {
height: 100%;
width: 100%;
}
.wrap {
display: flex;
flex-wrap: wrap;
}
.wrap > div {
width: 25%;
height: 250px;
background-color: #000;
margin: 0.5rem 0.5rem 0;
}
版面会长这样,不是很美观将就一下啦QQ
接着我们先看一下变数与大概的结构
const video = document.querySelector('#video')
const cameraBtn = document.querySelector('#camera')
const screenBtn = document.querySelector('#screen')
const closeBtn = document.querySelector('#close')
const textInput = document.querySelector('#textInput')
const submitBtn = document.querySelector('#submit')
const fileInput = document.querySelector('#fileInput')
// stream 档案
let cameraStream
let screenStream
// mediaDevices 的设定
const constraints = { audio: true, video: true }
// offer 的设定
const offerOptions = { offerToReceiveAudio: true, offerToReceiveVideo: true }
// 自己的 ID
let myId
// 所有人员的清单
let userList = []
cameraBtn.addEventListener('click', () => {
// 取得视讯镜头的 stream
})
screenBtn.addEventListener('click', () => {
// 取得萤幕分享的 stream
})
closeBtn.addEventListener('click', () => {
// 关闭视讯镜头与萤幕分享的 stream
})
submitBtn.addEventListener('click', () => {
// 送出文字讯息
})
fileInput.addEventListener('change', () => {
// 送出档案
})
function init() {
// 建立 WebSocket 连线
const ws = new WebSocket('ws://localhost:3000/connection')
// 收到讯息触发该事件
ws.addEventListener('message', async e => {
// 转换字串讯息为物件
const data = JSON.parse(e.data)
// 找到送出讯息的人(init 以外使用)
const sender = userList.find(user => user.id === data.sender)
// 第一次开启 WebSocket 连线时触发
if (data.event === 'init') {
// do something...
}
// 收到别人发出的请求时触发
if (data.event === 'request') {
// do something...
}
// 收到回覆时触发
if (data.event === 'response') {
// do something...
}
// 有人传送连接埠时触发
if (data.event === 'candidate') {
// do something...
}
// 有人离开时触发
if (data.event === 'close') {
// do something...
}
})
}
init()
接着我们先来填入各个事件该做的事情吧
cameraBtn.addEventListener('click', () => {
if (cameraStream) return
// 取得视讯镜头的 stream
navigator.mediaDevices.getUserMedia(constraints).then(stream => {
// 将本来萤幕分享的 stream 清除
if (screenStream) {
screenStream.getTracks().forEach(track => {
track.stop()
})
screenStream = null
}
// 设定视讯镜头的 stream 到画面
cameraStream = stream
video.srcObject = stream
userList.forEach(user => {
if (!user.peer) return
// peer 移除之前的 stream
user.peer.getSenders().forEach(sender => {
user.peer.removeTrack(sender)
})
// peer 新增新的 stream
stream.getTracks().forEach(track => {
user.peer.addTrack(track, stream)
})
})
})
})
screenBtn.addEventListener('click', () => {
if (screenStream) return
// 取得萤幕分享 stream
navigator.mediaDevices.getDisplayMedia(constraints).then(stream => {
if (cameraStream) {
// 将本来视讯镜头的 stream 清除
cameraStream.getTracks().forEach(track => {
track.stop()
})
cameraStream = null
}
// 设定萤幕分享的 stream 到画面
screenStream = stream
video.srcObject = stream
userList.forEach(user => {
if (!user.peer) return
// peer 移除之前的 stream
user.peer.getSenders().forEach(sender => {
user.peer.removeTrack(sender)
})
// peer 新增新的 stream
stream.getTracks().forEach(track => {
user.peer.addTrack(track, stream)
})
})
})
})
closeBtn.addEventListener('click', () => {
if (screenStream) {
// 将萤幕分享的 stream 清除
screenStream.getTracks().forEach(track => {
track.stop()
})
screenStream = null
}
if (cameraStream) {
// 将视讯镜头的 stream 清除
cameraStream.getTracks().forEach(track => {
track.stop()
})
cameraStream = null
}
// 所有的 peer 移除之前的 stream
userList.forEach(user => {
user.peer.getSenders().forEach(sender => {
user.peer.removeTrack(sender)
})
})
})
submitBtn.addEventListener('click', () => {
const value = textInput.value
if (!value) return
// 所有的 peer 送出文字讯息
userList.forEach(user => {
if (!user.channel) return
user.channel.send(value)
})
})
fileInput.addEventListener('change', e => {
const file = e.target.files[0]
if (!file) return
// 这边设定仅接受 jpeg 格式
if (file.type !== 'image/jpeg') return
// 将档案转换成 ArrayBuffer
const reader = new FileReader()
reader.readAsArrayBuffer(file)
reader.onload = e => {
// 所有的 peer 送出 ArrayBuffer
userList.forEach(user => {
if (!user.channel) return
user.channel.send(e.target.result)
})
}
})
// 第一次开启 WebSocket 连线时触发
if (data.event === 'init') {
// 设定自己的 ID
myId = data.id
// 设定所有人员的清单
userList = data.userList.map(id => ({ id, peer: null, channel: null }))
// 对所有人员发起连线
userList.forEach(async user => {
user.peer = new RTCPeerConnection()
user.peer.addEventListener('icecandidate', e => {
// 传送连接埠资讯
ws.send(JSON.stringify({
event: 'candidate',
sender: myId,
taker: user.id,
candidate: e.candidate
}))
})
user.peer.addEventListener('connectionstatechange', e => {
const currentVideo = document.querySelector(`#video_${user.id} > video`)
if (currentVideo) return
// 初始化画面 video
const div = document.createElement('div')
div.id = `video_${user.id}`
const video = document.createElement('video')
video.autoplay = true
div.appendChild(video)
const wrap = document.querySelector('.wrap')
wrap.appendChild(div)
})
user.peer.addEventListener('track', e => {
// 将 stream 显示於画面
const currentVideo = document.querySelector(`#video_${user.id} > video`)
currentVideo.srcObject = e.streams[0]
})
user.peer.addEventListener('removestream', e => {
// 将 stream 从画面移除
const currentVideo = document.querySelector(`#video_${user.id} > video`)
currentVideo.srcObject = null
})
user.peer.addEventListener('datachannel', e => {
// 将对方的 channel 写入物件
user.channel = e.channel
})
user.peer.addEventListener('negotiationneeded', async e => {
// 连接尚未建立时不动作
if (user.peer.connectionState !== 'connected') return
// 重新发出请求并建立连线
const offer = await user.peer.createOffer(offerOptions)
await user.peer.setLocalDescription(offer)
ws.send(JSON.stringify({
event: 'request',
sender: myId,
taker: user.id,
connection: offer
}))
})
// 建立 DataChannel
channel = user.peer.createDataChannel('channel')
channel.addEventListener('message', e => {
if (typeof e.data === 'object') {
// 收到档案时询问後下载该档案
const message = `是否下载 ${user.id} 提供的档案?`
const result = confirm(message)
if (!result) return
const blob = new Blob([e.data], { type: 'image/jpeg' })
const downloadLink = document.createElement('a')
downloadLink.href = URL.createObjectURL(blob)
downloadLink.download = 'download'
downloadLink.click()
URL.revokeObjectURL(downloadLink.href)
} else {
// 收到文字时使用 alert 印出
const message = `${user.id}: ${e.data}`
alert(message)
}
})
if (cameraStream) {
// 将视讯镜头的 stream 加入 peer
cameraStream.getTracks().forEach(track => {
user.peer.addTrack(track, cameraStream)
})
}
if (screenStream) {
// 将萤幕分享的 stream 加入 peer
screenStream.getTracks().forEach(track => {
user.peer.addTrack(track, screenStream)
})
}
// 发出请求并建立连线
const offer = await user.peer.createOffer(offerOptions)
await user.peer.setLocalDescription(offer)
ws.send(JSON.stringify({
event: 'request',
sender: myId,
taker: user.id,
connection: offer
}))
})
}
// 收到别人发出的请求时触发
if (data.event === 'request') {
if (!sender) {
// 新成员加入
// 建立该人员的资讯并放入清单
const user = { id: data.sender, peer: null, channel: null }
userList.push(user)
user.peer = new RTCPeerConnection()
user.peer.addEventListener('icecandidate', e => {
// 传送连接埠资讯
ws.send(JSON.stringify({
event: 'candidate',
sender: myId,
taker: user.id,
candidate: e.candidate
}))
})
user.peer.addEventListener('connectionstatechange', e => {
const currentVideo = document.querySelector(`#video_${user.id} > video`)
if (currentVideo) return
// 初始化画面 video
const div = document.createElement('div')
div.id = `video_${user.id}`
const video = document.createElement('video')
video.autoplay = true
div.appendChild(video)
const wrap = document.querySelector('.wrap')
wrap.appendChild(div)
})
user.peer.addEventListener('track', e => {
// 将 stream 显示於画面
const currentVideo = document.querySelector(`#video_${user.id} > video`)
currentVideo.srcObject = e.streams[0]
})
user.peer.addEventListener('removestream', e => {
// 将 stream 从画面移除
const currentVideo = document.querySelector(`#video_${user.id} > video`)
currentVideo.srcObject = null
})
user.peer.addEventListener('datachannel', e => {
// 将对方的 channel 写入物件
user.channel = e.channel
})
user.peer.addEventListener('negotiationneeded', async e => {
// 连接尚未建立时不动作
if (user.peer.connectionState !== 'connected') return
// 重新发出请求并建立连线
const offer = await user.peer.createOffer(offerOptions)
await user.peer.setLocalDescription(offer)
ws.send(JSON.stringify({
event: 'request',
sender: myId,
taker: user.id,
connection: offer
}))
})
// 建立 DataChannel
channel = user.peer.createDataChannel('channel')
channel.addEventListener('message', e => {
if (typeof e.data === 'object') {
// 收到档案时询问後下载该档案
const message = `是否下载 ${user.id} 提供的档案?`
const result = confirm(message)
if (!result) return
const blob = new Blob([e.data], { type: 'image/jpeg' })
const downloadLink = document.createElement('a')
downloadLink.href = URL.createObjectURL(blob)
downloadLink.download = 'download'
downloadLink.click()
URL.revokeObjectURL(downloadLink.href)
} else {
// 收到文字时使用 alert 印出
const message = `${user.id}: ${e.data}`
alert(message)
}
})
if (cameraStream) {
// 将视讯镜头的 stream 加入 peer
cameraStream.getTracks().forEach(track => {
user.peer.addTrack(track, cameraStream)
})
}
if (screenStream) {
// 将萤幕分享的 stream 加入 peer
screenStream.getTracks().forEach(track => {
user.peer.addTrack(track, screenStream)
})
}
// 设定该 peer 的连线资讯并回覆自己的连线资讯
await user.peer.setRemoteDescription(data.connection)
const answer = await user.peer.createAnswer(offerOptions)
await user.peer.setLocalDescription(answer)
ws.send(JSON.stringify({
event: 'response',
sender: myId,
taker: user.id,
connection: answer
}))
} else {
// 设定该 peer 的连线资讯并回覆自己的连线资讯
await sender.peer.setRemoteDescription(data.connection)
const answer = await sender.peer.createAnswer(offerOptions)
await sender.peer.setLocalDescription(answer)
ws.send(JSON.stringify({
event: 'response',
sender: myId,
taker: sender.id,
connection: answer
}))
}
}
// 收到回覆时触发
if (data.event === 'response') {
// 设定该 peer 的连线资讯
sender.peer.setRemoteDescription(data.connection)
}
// 有人传送连接埠时触发
if (data.event === 'candidate') {
// 设定该 peer 的连接埠
sender.peer.addIceCandidate(data.candidate)
}
// 有人离开时触发
if (data.event === 'close') {
// 清单移除离开者
userList = userList.filter(user => user !== sender)
// 关闭该连线
sender.peer.close()
// 移除离开者的画面
const videoDiv = document.querySelector(`#video_${data.sender}`)
if (videoDiv) videoDiv.remove()
}
// 有人传送连接埠时触发
if (data.event === 'candidate') {
// 设定该 peer 的连接埠
sender.peer.addIceCandidate(data.candidate)
}
接着就可以正常运作啦~洒花
终於做完啦~聊天室是一个很贴近日常的应用,做完真的是成就感满满阿,学完之前的 mediaDevices
再来看聊天室是不是很简单呢(才怪),基本上最重要的就是搞清楚设定 offer 与 answer 的顺序,其他的就不算什麽了~
因为在工作上, 基本上碰不到Javascript, 感觉再不复习一下, 就要忘光光了 (汗) 所以决...
Terraform Terraform 是什麽 Terraform 是由 HashiCorp 建立的...
图 7-1: 各栏位资料范例 本文的目标是将如 Message 栏位的内文使用 AES 加密机制将...
闭包(Closure)在MDN的解释为: 是函式以及该函式被宣告时所在的作用域环境(lexical ...
接续昨天提到的,我们今天将会实际跑一次指令,如果看到这里的你还在犹豫的话,别犹豫了,跟我一起开始吧!...