大家好~上次文章介紹了錄音錄影的功能,這次要來弄個多人視訊聊天室,竟然我們已經知道怎麼取得影音檔案,那麼對於聊天室而言就只少了一個東西,那就是交換資料的手段,所以這篇就來介紹一下怎麼使用瀏覽器的 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 的順序,其他的就不算什麼了~