大家好~上次文章介绍了录音录影的功能,这次要来弄个多人视讯聊天室,竟然我们已经知道怎么取得影音档案,那么对于聊天室而言就只少了一个东西,那就是交换资料的手段,所以这篇就来介绍一下怎么使用浏览器的 API 建立 P2P 的连线,并达到交换 stream 的功能~
API 介绍
首先我们先来介绍一下这次的重点 API,分别是 RTCPeerConnection
与 RTCDataChannel
RTCPeerConnection
RTCPeerConnection 是建立 P2P 连线的 API,使用时会使用 new
来建立实体,建立实体之后写入自己与对方的连线资讯,接着就会自动建立连线了
Method
基本上比较常用的就是下面几个,不过有几点小提醒
送出方为 Offer,接收方为 Answer同步与非同步需要特别注意必须先将 stream 加入实体后再建立连线连接埠可以在建立连线后设定// 建立实体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)
Event
建立实体后我们可以监听各种事件,接着在触发事件时就可以做相应的处理,以下为常用事件
// 找到连接埠时触发,会有多个(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
RTCDataChannel,是建立在 RTCPeerConnection
实体上,可以用来交换讯息或档案,档案目前建议使用 ArrayBuffer
来处理
Method
const peer = new RTCPeerConnection(config)// 建立名为 channel 的 RTCDataChannelchannel = peer.createDataChannel('channel')// 发送讯息或档案,remoteChannel 为 datachannel 事件返回的 channelremoteChannel.send(data)
Event
// 接收到讯息或档案后触发channel.addEventListener('message', e => { // do something...})
整理一下重点,这边建立影音双向的连线必须要有三个条件
在建立连线之前先加入 stream双方都设定好自己的 Offer 与对方的 Answer设定好连线的 candidate(连接埠)以上就是交换 stream 的方法了,那么在开始实作之前先来看一下流程吧!
流程介绍
实作会分成后端伺服器与前端影音的部份,当前端建立连线之前我们不知道要跟谁连线,所以需要后端来帮我们传送彼此的连线资讯,大概的流程如下:
用户 A 进入聊天室伺服器将用户 A 记录到用户清单,并将其他用户清单传送给用户 A用户 A 对自己以外的用户发起连线请求,并夹带自己的连线资讯其他用户收到后同意请求并回传自己的连线资讯用户 A 收到其他用户的连线资讯,双方成功建立 P2P 连线有人进入聊天室时重複步骤 1这边有两种做法,两种都可以达成目的
由加入者对其他用户发起连线由聊天室内的用户对加入者发起连线我们这边採用方法 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-wsconst express = require('express')const app = express()const expressWs = require('express-ws')(app)// 使用根目录档案作为页面app.use(express.static(__dirname))// 所有聊天室内的 WebSocket 实例let websocketList = []// 开启 WebSocket 连线网址为 ws://localhost:3000/connectionapp.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 cameraStreamlet screenStream// mediaDevices 的设定const constraints = { audio: true, video: true }// offer 的设定const offerOptions = { offerToReceiveAudio: true, offerToReceiveVideo: true }// 自己的 IDlet 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 click
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 click
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 click
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 click
submitBtn.addEventListener('click', () => { const value = textInput.value if (!value) return // 所有的 peer 送出文字讯息 userList.forEach(user => { if (!user.channel) return user.channel.send(value) })})
fileInput change
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 event - init
// 第一次开启 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 })) })}
WebSocket event - request
// 收到别人发出的请求时触发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 })) }}
WebSocket event - response
// 收到回覆时触发if (data.event === 'response') { // 设定该 peer 的连线资讯 sender.peer.setRemoteDescription(data.connection)}
WebSocket event - candidate
// 有人传送连接埠时触发if (data.event === 'candidate') { // 设定该 peer 的连接埠 sender.peer.addIceCandidate(data.candidate)}
WebSocket event - close
// 有人离开时触发if (data.event === 'close') { // 清单移除离开者 userList = userList.filter(user => user !== sender) // 关闭该连线 sender.peer.close() // 移除离开者的画面 const videoDiv = document.querySelector(`#video_${data.sender}`) if (videoDiv) videoDiv.remove()}
WebSocket event - candidate
// 有人传送连接埠时触发if (data.event === 'candidate') { // 设定该 peer 的连接埠 sender.peer.addIceCandidate(data.candidate)}
接着就可以正常运作啦~洒花
结语
终于做完啦~聊天室是一个很贴近日常的应用,做完真的是成就感满满阿,学完之前的 mediaDevices
再来看聊天室是不是很简单呢(才怪),基本上最重要的就是搞清楚设定 offer 与 answer 的顺序,其他的就不算什么了~