Автор: Программист Сяока
Время чтения: 25~30мин.
Существует множество API-интерфейсов, связанных с WebRTC-связью.Чтобы не удлинять текст, в этой статье для объяснения используются некоторые псевдокоды. Подробные примеры кода можно найти на Github автора, Если у вас есть какие-либо вопросы, пожалуйста, оставьте сообщение для обмена.
Предыдущая ссылка:
Введение в связанные API
В предыдущих главах были представлены важные точки знаний, связанные с WebRTC, включая задействованные сетевые протоколы, протоколы описания сеанса, способы проникновения в сеть и т. д. Остальное — это API WebRTC. Существует множество API, связанных с WebRTC-связью, которые в основном выполняют следующие функции:
-
обмен сигналами
-
Обмен адресами кандидатов на связь
-
Аудио и видео коллекция
-
Отправка и получение аудио и видео
Существует слишком много связанных API. Чтобы не было слишком длинно, в этой статье для объяснения используются некоторые псевдокоды. Подробный код смотрите в конце статьи, который также можно найти на Github автора.Если у вас есть какие-либо вопросы, пожалуйста, оставьте сообщение для связи.
1. Обмен сигналами
Это ключевое звено в 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); // 接收方发送信令消息
2. Сервис обмена адресами кандидатов
Когда информация описания сеанса установлена локально и добавлен медиапоток, платформа 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);
}
}
3. Аудио и видео коллекция
Вы можете использовать интерфейс getUserMedia, предоставляемый браузером, для сбора локального аудио и видео.
const localVideo = document.getElementById('local-video');
const mediaStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
localVideo.srcObject = mediaStream;
4. Отправка и получение аудио и видео
Добавьте захваченные аудио- и видеодорожки через addTrack и отправьте их на удаленный конец.
mediaStream.getTracks().forEach(track => {
localPeer.addTrack(track, mediaStream);
});
Удаленный конец может отслеживать поступление аудио и видео, контролируя дорожку, и воспроизводить ее.
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);
});
});
});
написать на обороте
Существует много API WebRTC, потому что сам WebRTC более сложен. Со временем некоторые API WebRTC (включая некоторые детали протокола) также изменяются или отказываются от них, что также усложняет обратную совместимость. Например, после того, как локальное видео захваченных и добавленных в транспортный поток, можно использовать 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
Подпишитесь на официальный аккаунт [IVWEB Community], чтобы получать свежие статьи каждую неделю, ведущие к вершине жизни!