在前面的章節中,已經對WebRTC相關的重要知識點進行了介紹,包括涉及的網絡協議、會話描述協議、如何進行網絡穿透等,剩下的就是WebRTC的API了。git
WebRTC通訊相關的API很是多,主要完成了以下功能:github
相關API太多,爲避免篇幅過長,文中部分採用了僞代碼進行講解。詳細代碼參考文章末尾,也能夠在筆者的Github上找到,有問題歡迎留言交流。web
信令交換是WebRTC通訊中的關鍵環節,交換的信息包括編解碼器、網絡協議、候選地址等。對於如何進行信令交換,WebRTC並無明確說明,而是交給應用本身來決定,好比能夠採用WebSocket。json
發送方僞代碼以下:瀏覽器
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框架就會開始收集候選地址。兩邊收集到候選地址後,須要交換候選地址,並從中知道合適的候選地址對。markdown
候選地址的交換,一樣採用前面提到的信令服務,僞代碼以下:網絡
// 設置本地會話描述信息
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
接口,採集本地的音視頻。app
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];
}
複製代碼
包含兩部分:客戶端代碼、服務端代碼。
一、客戶端代碼
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;
/**
* 邀請用戶加入視頻聊天
* 一、本地啓動視頻採集
* 二、交換信令
*/
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();
複製代碼
二、服務端代碼
// 添加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
developer.mozilla.org/en-US/docs/…
onremotestream called twice for each remote stream
github博客:github.com/chyingp/blo…
新浪微博:weibo.com/chyingp
站酷主頁:www.zcool.com.cn/u/346408/