本篇是視訊聊天室的進階版本,必須先掌握 一對一視訊聊天室 介紹的相關知識,才能更快理解本篇的實作內容~
視訊情境
多個裝置 A、B、C …… 想要進行多方視訊聊天,可以隨時進入聊天室,不需要特定的順序
廣告
流程解析
多方視訊可以透過 RTCPeerConnection()
建立多個連線來達成。跟一對一的連線類似,多方視訊中的每個 P2P 連線 發送端(Offer)與 接收端(Answer)都需要分別建立一個 RTCPeerConnection()
物件,每個物件分別管理一個連線,並透過 Signaling Server 交換 ICE 和 SDP 以建立連線。
連線建立後,每個裝置可以將自己的影像和聲音傳送到其他裝置的 RTCPeerConnection()
中,以達成多方視訊的效果。此外,也可以透過使用媒體伺服器(如 SFU 或 MCU)來進一步優化多方視訊的效能和品質。
在一對一視訊情境,有明確的發送端與接收端,以 A、B 為例:
- 裝置 A 發送 Offer SDP,並設定為本地 SDP(setLocalDescription)
- 裝置 B 收到 Offer SDP,並設定為遠端 SDP(setRemoteDescription)
- 裝置 B 發送 Answer SDP,並設定為本地 SDP(setLocalDescription)
- 裝置 A 收到 Answer SDP,並設定為遠端 SDP(setRemoteDescription)
那麽在多方視訊情況,該怎麼定義發送端與接收端呢?
每個新加入聊天室的裝置,向已加入聊天室的所有裝置發送 Offer SDP,而其他裝置收到 Offer SDP 後,再向該裝置發送 Answer SDP,完成媒體協商並建立 P2P 連接。
以 A、B、C 為例:
- 裝置 A 加入聊天室
- 裝置 B 加入聊天室,向 A 發送 Offer SDP
- 裝置 A 收到 B 的 Offer SDP,向 B 發送 Answer SDP
- 裝置 B 收到 A 的 Answer SDP
- 裝置 C 加入聊天室,向 A、B 發送 Offer SDP
- 裝置 A、B 收到 C 的 Offer SDP,向 C 發送 Answer SDP
- 裝置 C 收到 A、B 的 Answer SDP
試著思考看看:當有三台裝置進行視訊連線,總共會產生幾個 RTCPeerConnection()
物件呢?
視訊實作
分為 Server(Signaling Server)以及 Client(視訊聊天室)進行
檔案結構如下:
server.js chat.html chat.js
|
重點說明:
由於每個 P2P 連線的發送端與接收端都需要各別建立 RTCPeerConnection()
,因此會使用 Socket.IO 為每個連線產生的唯一識別碼 socket.id
作為身份識別
Server(server.js)
套件安裝(NPM)
- Node.js 框架 Express
- Websocket 套件 Socket.io
套件引入/啟動伺服器
const express = require('express'); const app = express(); const http = require('http'); const socket = require('socket.io');
const server = http.createServer(app).listen('3000');
|
連接 Client 頁面(轉換為絕對路徑 __dirname)
app.get('/chat', function(req, res) { res.sendfile(`${__dirname}/chat.html`); });
app.get(/(.*)\.(jpg|gif|png|ico|css|js|txt)/i, function(req, res) { res.sendfile(`${__dirname}/${req.params[0]}.${req.params[1]}`); });
|
加入 Socket.io
Socket.io API 說明:
io.in(room).emit('joined')
:向所有裝置發送訊息(包含自己)
socket.to(room).emit('joined')
:向所有裝置發送訊息(不包含自己)
socket.to(id).emit('joined')
:向特定裝置發送訊息
const io = socket(server);
io.on('connection', (socket) => { console.log('connection');
socket.on('join', (room) => { console.log('join'); socket.join(room); const members = Array.from(socket.adapter.rooms.get(room)); io.in(room).emit('joined', socket.id, members); });
socket.on('offer', (room, desc, remoteId, localId) => { socket.to(localId).emit('offer', desc, remoteId); });
socket.on('answer', (room, desc, remoteId, localId) => { socket.to(localId).emit('answer', desc, remoteId); });
socket.on('ice_candidate', (room, data, remoteId, localId) => { socket.to(localId).emit('ice_candidate', data, remoteId); });
socket.on('disconnect_socket', () => { console.log('disconnect'); socket.disconnect(); }); });
|
Client(視訊聊天室)
套件安裝(CDN)
- WebRTC Adapter(解決相容性問題)
- Socket.io-client
HTML(chat.html)
- 加入 Video Dom
- #localVideo 接收本地媒體串流
- #remoteVideos 接收多個遠端媒體串流
- CDN WebRTC Adapter 以及 Socket.io-client
- 引入 chat.js
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <link rel="icon" type="image/x-icon" href="./favicon.ico"> <meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1, maximum-scale=1"> <title>chat</title> <style lang="scss"> .local-video { transform: scaleX(-1); } </style> </head> <body> <div class="container"> <video muted="true" autoplay="true" playsinline="true" id="localVideo" class="local-video"></video> <div id="remoteVideos"></div> <div class="btn-group"> <button type="button" id="enter">enter</button> <button type="button" id="leave">leave</button> </div> </div> <script src="https://webrtc.github.io/adapter/adapter-latest.js"></script> <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/socket.io.js"></script> <script src="chat.js" type="module" async></script> </body> </html>
|
Javascript(chat.js)
步驟拆分
- 開啟視窗時,先透過
getUserMedia()
取得本地媒體串流
- 點擊進入聊天室按鈕:連接 Socket Server(Signaling Server),透過 Offer/Answer SDP 和遠端裝置交換 ICE 候選位址與 SDP 資訊,建立點對點連線
- 點擊離開聊天室按鈕:移除事件監聽、關閉 RTCPeerConnection 連線、釋放記憶體,並傳送離開事件給 Socket Server
定義常數與變數
const localVideo = document.querySelector('#localVideo') const remoteVideos = document.querySelector('#remoteVideos') const enterBtn = document.querySelector('#enter'); const leaveBtn = document.querySelector('#leave'); let peerConnectionList = {}; let socket; let localStream; const room = 'room1';
|
- 取得本地影音串流
- Dom 設置本地串流
- 傳出媒體串流
const createStream = async () => { const constraints = { audio: true, video: true }; const stream = await navigator.mediaDevices.getUserMedia(constraints); localVideo.srcObject = stream; localStream = stream; }
createStream();
|
點擊進入聊天室按鈕
事件觸發
enterBtn.addEventListener('click', socketConnect);
|
連接 Socket Server(Signaling Server),透過 Offer/Answer SDP 和遠端裝置交換 ICE 候選位址與 SDP 資訊,建立點對點連線
- Socket Server 連線
- 加入房間
- 監聽有裝置加入房間:如果是自己,建立 RTCPeerConnection,設定本地 SDP(
setLocalDescription
),並發送 Offer SDP
- 監聽收到 Offer SDP:建立 RTCPeerConnection,,設定遠端 SDP(
setRemoteDescription
)與本地 SDP(setLocalDescription
),並發送 Answer SDP
- 監聽收到 Answer SDP:設定遠端 SDP(
setRemoteDescription
)
- 監聽收到 ICE 候選位址:使用 RTCIceCandidate 定義 ICE 候選位址
const socketConnect = () => { socket = io('ws://localhost:3000');
socket.emit('join', room);
socket.on('joined', (id, roomMembers) => { if (id === socket.id && roomMembers.length > 1) { console.log('發送 offer'); roomMembers.forEach(remoteId => { if (remoteId !== socket.id) { setOfferSDP(remoteId); } }); } });
socket.on('offer', async (desc, remoteId) => { console.log('收到 offer'); console.log('發送 answer'); await sendAnswerSDP(remoteId, desc); })
socket.on('answer', (desc, remoteId) => { console.log('收到 answer'); peerConnectionList[remoteId].setRemoteDescription(desc); })
socket.on('ice_candidate', (data, remoteId) => { console.log('收到 ice_candidate'); const candidate = new RTCIceCandidate({ sdpMLineIndex: data.label, candidate: data.candidate, }); peerConnectionList[remoteId].addIceCandidate(candidate); }) }
|
3-1. 發送 Offer SDP
- 建立 RTCPeerConnection
- 建立本地 SDP
- 設定本地 SDP
- 發送 Offer SDP
const setOfferSDP = async (remoteId) => { const peerConnection = await createPeerConnection(remoteId);
const offerOptions = { offerToReceiveAudio: true, offerToReceiveVideo: true };
const localSDP = await peerConnection.createOffer(offerOptions);
await peerConnection.setLocalDescription(localSDP);
socket.emit('offer', room, peerConnection.localDescription, socket.id, remoteId); }
|
4-1. 發送 Answer SDP
- 建立 RTCPeerConnection
- 設定遠端 SDP
- 建立本地 SDP
- 設定本地 SDP
- 發送 Answer SDP
const sendAnswerSDP = async (remoteId, desc) => { const peerConnection = await createPeerConnection(remoteId);
await peerConnection.setRemoteDescription(desc);
const answerOptions = { offerToReceiveAudio: true, offerToReceiveVideo: true };
const localSDP = await peerConnection.createAnswer(answerOptions);
await peerConnection.setLocalDescription(localSDP);
socket.emit('answer', room, peerConnection.localDescription, socket.id, remoteId); }
|
3-1. 4-1. 建立 RTCPeerConnection
設定 iceServer
建立 RTCPeerConnection
增加本地媒體串流
監聽找到本地的 ICE 候選位址
監聽 ICE 連接狀態
監聽遠端裝置的串流傳入
video 標籤屬性:
playsinline:控制影片在行動裝置的播放行為,避免某些裝置(如 IOS)彈出視窗播放器
controls:是否顯示播放控制器,在某些裝置(如 IOS Safari)設定為 false 畫面可能會被隱藏,可以先將屬性設為 true,接著移除該屬性:
video.setAttribute('controls', true)
video.removeAttribute('controls')
將 P2P 連線存入 peerConnectionList
const createPeerConnection = async (remoteId) => { const configuration = { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] };
const peerConnection = new RTCPeerConnection(configuration);
localStream.getTracks().forEach((track) => { peerConnection.addTrack(track, localStream); });
peerConnection.onicecandidate = (e) => { console.log('找尋到 ICE 候選位址'); if (e.candidate) { console.log('發送 ICE 候選位址'); socket.emit('ice_candidate', room, { label: e.candidate.sdpMLineIndex, id: e.candidate.sdpMid, candidate: e.candidate.candidate, }, socket.id, remoteId); } };
peerConnection.oniceconnectionstatechange = (e) => { if (e.target.iceConnectionState === 'disconnected') { console.log('有裝置斷線');
peerConnectionList[remoteId].onicecandidate = null; peerConnectionList[remoteId].onnegotiationneeded = null; peerConnectionList[remoteId].oniceconnectionstatechange = null;
peerConnectionList[remoteId].close(); delete peerConnectionList[remoteId];
document.getElementById(remoteId).remove(); } };
peerConnection.onaddstream = ({ stream }) => { console.log('監聽到串流'); const video = document.createElement('video'); video.srcObject = stream; video.setAttribute('controls', true); video.setAttribute('playsinline', true); video.setAttribute('autoplay', true); video.setAttribute('muted', true); video.setAttribute('volume', 0); video.removeAttribute('controls'); video.classList.add('remote-video'); video.id = remoteId; remoteVideos.append(video); };
peerConnectionList[remoteId] = peerConnection;
return peerConnection; }
|
點擊離開聊天室按鈕
事件觸發
leaveBtn.addEventListener('click', leave);
|
離開聊天室
- 移除事件監聽
- 關閉 RTCPeerConnection 連線
- 釋放記憶體
- 移除遠端 video
- 傳遞離開聊天室事件給 Socket Server
const leave = () => { console.log('離開聊天室');
if (Object.keys(peerConnectionList).length) { Object.keys(peerConnectionList).forEach(key => { peerConnectionList[key].onicecandidate = null; peerConnectionList[key].onnegotiationneeded = null; peerConnectionList[key].oniceconnectionstatechange = null; peerConnectionList[key].close(); }) peerConnectionList = {};
remoteVideos.innerHTML = null; }
socket.emit('disconnect_socket'); socket = null; }
|
這樣就大功告成囉,上述說明有試著簡化程式碼,避免細節繁瑣難以理解
試著優化看看:
1. enter button 跟 leave button 加入 disabled 屬性,避免重複操作
2. 將 socket.id 做為識別碼,加入聊天視窗(也更容易除錯!)
3. 不同裝置的螢幕寬高比不同,利用 css 優化,避免畫面破版
4. async function 加入 try / catch 捕捉錯誤情境
參考資源:
https://github.com/muaz-khan/RTCMultiConnection/blob/master/dist/RTCMultiConnection.js
https://iter01.com/489722.html
https://socket.io/docs/v3/rooms/
廣告
評論