WebRTC:一個視頻聊天的簡單例子
- 2019 年 10 月 3 日
- 筆記
相關API簡介
在前面的章節中,已經對WebRTC相關的重要知識點進行了介紹,包括涉及的網絡協議、會話描述協議、如何進行網絡穿透等,剩下的就是WebRTC的API了。
WebRTC通信相關的API非常多,主要完成了如下功能:
- 信令交換
- 通信候選地址交換
- 音視頻採集
- 音視頻發送、接收
相關API太多,為避免篇幅過長,文中部分採用了偽代碼進行講解。詳細代碼參考文章末尾,也可以在筆者的Github上找到,有問題歡迎留言交流。
信令交換
信令交換是WebRTC通信中的關鍵環節,交換的信息包括編解碼器、網絡協議、候選地址等。對於如何進行信令交換,WebRTC並沒有明確說明,而是交給應用自己來決定,比如可以採用WebSocket。
發送方偽代碼如下:
const pc = new RTCPeerConnection(iceConfig); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); sendToPeerViaSignalingServer(SIGNALING_OFFER, offer); // 發送方發送信令消息
接收方偽代碼如下:
const pc = new RTCPeerConnection(iceConfig); await pc.setRemoteDescription(offer); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); sendToPeerViaSignalingServer(SIGNALING_ANSWER, answer); // 接收方發送信令消息
候選地址交換服務
當本地設置了會話描述信息,並添加了媒體流的情況下,ICE框架就會開始收集候選地址。兩邊收集到候選地址後,需要交換候選地址,並從中知道合適的候選地址對。
候選地址的交換,同樣採用前面提到的信令服務,偽代碼如下:
// 設置本地會話描述信息 const localPeer = new RTCPeerConnection(iceConfig); const offer = await pc.createOffer(); await localPeer.setLocalDescription(offer); // 本地採集音視頻 const localVideo = document.getElementById('local-video'); const mediaStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); localVideo.srcObject = mediaStream; // 添加音視頻流 mediaStream.getTracks().forEach(track => { localPeer.addTrack(track, mediaStream); }); // 交換候選地址 localPeer.onicecandidate = function(evt) { if (evt.candidate) { sendToPeerViaSignalingServer(SIGNALING_CANDIDATE, evt.candidate); } }
音視頻採集
可以使用瀏覽器提供的getUserMedia
接口,採集本地的音視頻。
const localVideo = document.getElementById('local-video'); const mediaStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); localVideo.srcObject = mediaStream;
音視頻發送、接收
將採集到的音視頻軌道,通過addTrack
進行添加,發送給遠端。
mediaStream.getTracks().forEach(track => { localPeer.addTrack(track, mediaStream); });
遠端可以通過監聽ontrack
來監聽音視頻的到達,並進行播放。
remotePeer.ontrack = function(evt) { const remoteVideo = document.getElementById('remote-video'); remoteVideo.srcObject = evt.streams[0]; }
完整代碼
包含兩部分:客戶端代碼、服務端代碼。
1、客戶端代碼
const socket = io.connect('http://localhost:3000'); const CLIENT_RTC_EVENT = 'CLIENT_RTC_EVENT'; const SERVER_RTC_EVENT = 'SERVER_RTC_EVENT'; const CLIENT_USER_EVENT = 'CLIENT_USER_EVENT'; const SERVER_USER_EVENT = 'SERVER_USER_EVENT'; const CLIENT_USER_EVENT_LOGIN = 'CLIENT_USER_EVENT_LOGIN'; // 登錄 const SERVER_USER_EVENT_UPDATE_USERS = 'SERVER_USER_EVENT_UPDATE_USERS'; const SIGNALING_OFFER = 'SIGNALING_OFFER'; const SIGNALING_ANSWER = 'SIGNALING_ANSWER'; const SIGNALING_CANDIDATE = 'SIGNALING_CANDIDATE'; let remoteUser = ''; // 遠端用戶 let localUser = ''; // 本地登錄用戶 function log(msg) { console.log(`[client] ${msg}`); } socket.on('connect', function() { log('ws connect.'); }); socket.on('connect_error', function() { log('ws connect_error.'); }); socket.on('error', function(errorMessage) { log('ws error, ' + errorMessage); }); socket.on(SERVER_USER_EVENT, function(msg) { const type = msg.type; const payload = msg.payload; switch(type) { case SERVER_USER_EVENT_UPDATE_USERS: updateUserList(payload); break; } log(`[${SERVER_USER_EVENT}] [${type}], ${JSON.stringify(msg)}`); }); socket.on(SERVER_RTC_EVENT, function(msg) { const {type} = msg; switch(type) { case SIGNALING_OFFER: handleReceiveOffer(msg); break; case SIGNALING_ANSWER: handleReceiveAnswer(msg); break; case SIGNALING_CANDIDATE: handleReceiveCandidate(msg); break; } }); async function handleReceiveOffer(msg) { log(`receive remote description from ${msg.payload.from}`); // 設置遠端描述 const remoteDescription = new RTCSessionDescription(msg.payload.sdp); remoteUser = msg.payload.from; createPeerConnection(); await pc.setRemoteDescription(remoteDescription); // TODO 錯誤處理 // 本地音視頻採集 const localVideo = document.getElementById('local-video'); const mediaStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); localVideo.srcObject = mediaStream; mediaStream.getTracks().forEach(track => { pc.addTrack(track, mediaStream); // pc.addTransceiver(track, {streams: [mediaStream]}); // 這個也可以 }); // pc.addStream(mediaStream); // 目前這個也可以,不過接口後續會廢棄 const answer = await pc.createAnswer(); // TODO 錯誤處理 await pc.setLocalDescription(answer); sendRTCEvent({ type: SIGNALING_ANSWER, payload: { sdp: answer, from: localUser, target: remoteUser } }); } async function handleReceiveAnswer(msg) { log(`receive remote answer from ${msg.payload.from}`); const remoteDescription = new RTCSessionDescription(msg.payload.sdp); remoteUser = msg.payload.from; await pc.setRemoteDescription(remoteDescription); // TODO 錯誤處理 } async function handleReceiveCandidate(msg){ log(`receive candidate from ${msg.payload.from}`); await pc.addIceCandidate(msg.payload.candidate); // TODO 錯誤處理 } /** * 發送用戶相關消息給服務器 * @param {Object} msg 格式如 { type: 'xx', payload: {} } */ function sendUserEvent(msg) { socket.emit(CLIENT_USER_EVENT, JSON.stringify(msg)); } /** * 發送RTC相關消息給服務器 * @param {Object} msg 格式如{ type: 'xx', payload: {} } */ function sendRTCEvent(msg) { socket.emit(CLIENT_RTC_EVENT, JSON.stringify(msg)); } let pc = null; /** * 邀請用戶加入視頻聊天 * 1、本地啟動視頻採集 * 2、交換信令 */ async function startVideoTalk() { // 開啟本地視頻 const localVideo = document.getElementById('local-video'); const mediaStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); localVideo.srcObject = mediaStream; // 創建 peerConnection createPeerConnection(); // 將媒體流添加到webrtc的音視頻收發器 mediaStream.getTracks().forEach(track => { pc.addTrack(track, mediaStream); // pc.addTransceiver(track, {streams: [mediaStream]}); }); // pc.addStream(mediaStream); // 目前這個也可以,不過接口後續會廢棄 } function createPeerConnection() { const iceConfig = {"iceServers": [ {url: 'stun:stun.ekiga.net'}, {url: 'turn:turnserver.com', username: 'user', credential: 'pass'} ]}; pc = new RTCPeerConnection(iceConfig); pc.onnegotiationneeded = onnegotiationneeded; pc.onicecandidate = onicecandidate; pc.onicegatheringstatechange = onicegatheringstatechange; pc.oniceconnectionstatechange = oniceconnectionstatechange; pc.onsignalingstatechange = onsignalingstatechange; pc.ontrack = ontrack; return pc; } async function onnegotiationneeded() { log(`onnegotiationneeded.`); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); // TODO 錯誤處理 sendRTCEvent({ type: SIGNALING_OFFER, payload: { from: localUser, target: remoteUser, sdp: pc.localDescription // TODO 直接用offer? } }); } function onicecandidate(evt) { if (evt.candidate) { log(`onicecandidate.`); sendRTCEvent({ type: SIGNALING_CANDIDATE, payload: { from: localUser, target: remoteUser, candidate: evt.candidate } }); } } function onicegatheringstatechange(evt) { log(`onicegatheringstatechange, pc.iceGatheringState is ${pc.iceGatheringState}.`); } function oniceconnectionstatechange(evt) { log(`oniceconnectionstatechange, pc.iceConnectionState is ${pc.iceConnectionState}.`); } function onsignalingstatechange(evt) { log(`onsignalingstatechange, pc.signalingstate is ${pc.signalingstate}.`); } // 調用 pc.addTrack(track, mediaStream),remote peer的 onTrack 會觸發兩次 // 實際上兩次觸發時,evt.streams[0] 指向同一個mediaStream引用 // 這個行為有點奇怪,github issue 也有提到 https://github.com/meetecho/janus-gateway/issues/1313 let stream; function ontrack(evt) { // if (!stream) { // stream = evt.streams[0]; // } else { // console.log(`${stream === evt.streams[0]}`); // 這裡為true // } log(`ontrack.`); const remoteVideo = document.getElementById('remote-video'); remoteVideo.srcObject = evt.streams[0]; } // 點擊用戶列表 async function handleUserClick(evt) { const target = evt.target; const userName = target.getAttribute('data-name').trim(); if (userName === localUser) { alert('不能跟自己進行視頻會話'); return; } log(`online user selected: ${userName}`); remoteUser = userName; await startVideoTalk(remoteUser); } /** * 更新用戶列表 * @param {Array} users 用戶列表,比如 [{name: '小明', name: '小強'}] */ function updateUserList(users) { const fragment = document.createDocumentFragment(); const userList = document.getElementById('login-users'); userList.innerHTML = ''; users.forEach(user => { const li = document.createElement('li'); li.innerHTML = user.userName; li.setAttribute('data-name', user.userName); li.addEventListener('click', handleUserClick); fragment.appendChild(li); }); userList.appendChild(fragment); } /** * 用戶登錄 * @param {String} loginName 用戶名 */ function login(loginName) { localUser = loginName; sendUserEvent({ type: CLIENT_USER_EVENT_LOGIN, payload: { loginName: loginName } }); } // 處理登錄 function handleLogin(evt) { let loginName = document.getElementById('login-name').value.trim(); if (loginName === '') { alert('用戶名為空!'); return; } login(loginName); } function init() { document.getElementById('login-btn').addEventListener('click', handleLogin); } init();
2、服務端代碼
// 添加ws服務 const io = require('socket.io')(server); let connectionList = []; const CLIENT_RTC_EVENT = 'CLIENT_RTC_EVENT'; const SERVER_RTC_EVENT = 'SERVER_RTC_EVENT'; const CLIENT_USER_EVENT = 'CLIENT_USER_EVENT'; const SERVER_USER_EVENT = 'SERVER_USER_EVENT'; const CLIENT_USER_EVENT_LOGIN = 'CLIENT_USER_EVENT_LOGIN'; const SERVER_USER_EVENT_UPDATE_USERS = 'SERVER_USER_EVENT_UPDATE_USERS'; function getOnlineUser() { return connectionList .filter(item => { return item.userName !== ''; }) .map(item => { return { userName: item.userName }; }); } function setUserName(connection, userName) { connectionList.forEach(item => { if (item.connection.id === connection.id) { item.userName = userName; } }); } function updateUsers(connection) { connection.emit(SERVER_USER_EVENT, { type: SERVER_USER_EVENT_UPDATE_USERS, payload: getOnlineUser()}); } io.on('connection', function (connection) { connectionList.push({ connection: connection, userName: '' }); // 連接上的用戶,推送在線用戶列表 // connection.emit(SERVER_USER_EVENT, { type: SERVER_USER_EVENT_UPDATE_USERS, payload: getOnlineUser()}); updateUsers(connection); connection.on(CLIENT_USER_EVENT, function(jsonString) { const msg = JSON.parse(jsonString); const {type, payload} = msg; if (type === CLIENT_USER_EVENT_LOGIN) { setUserName(connection, payload.loginName); connectionList.forEach(item => { // item.connection.emit(SERVER_USER_EVENT, { type: SERVER_USER_EVENT_UPDATE_USERS, payload: getOnlineUser()}); updateUsers(item.connection); }); } }); connection.on(CLIENT_RTC_EVENT, function(jsonString) { const msg = JSON.parse(jsonString); const {payload} = msg; const target = payload.target; const targetConn = connectionList.find(item => { return item.userName === target; }); if (targetConn) { targetConn.connection.emit(SERVER_RTC_EVENT, msg); } }); connection.on('disconnect', function () { connectionList = connectionList.filter(item => { return item.connection.id !== connection.id; }); connectionList.forEach(item => { // item.connection.emit(SERVER_USER_EVENT, { type: SERVER_USER_EVENT_UPDATE_USERS, payload: getOnlineUser()}); updateUsers(item.connection); }); }); });
寫在後面
WebRTC的API非常多,因為WebRTC本身就比較複雜,隨着時間的推移,WebRTC的某些API(包括某些協議細節)也在改動或被廢棄,這其中也有向後兼容帶來的複雜性,比如本地視頻採集後加入傳輸流,可以採用 addStream 或 addTrack 或 addTransceiver,再比如會話描述版本從plan-b遷移到unified-plan。
建議親自動手擼一遍代碼,加深了解。
相關鏈接
2019.08.02-video-talk-using-webrtc
https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection
onremotestream called twice for each remote stream