webRTC demo
准备:
- 信令服务
- 前端页面用于视频通话
demo github 地址。
前端页面
为了使 demo 尽量简单,功能页面如下,即包含登录、通过对方手机号拨打电话的功能。在实际生成过程中,未必使用的手机号,可能是任何能代表用户身份的字符串。

代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div style="margin: 20px">
<label for="loginAccount">登录账号</label><input id="loginAccount" name="loginAccount" placeholder="请输入手机号"
type="text">
<button id="login" onclick="login()" type="button">登录</button>
</div>
<div style="margin: 20px">
<video autoplay controls height="360px" id="localVideo" width="640px"></video>
<video autoplay controls height="360px" id="remoteVideo" width="640px"></video>
</div>
<div style="margin: 20px">
<label for="toAccount">对方账号</label>
<input id="toAccount" name="toAccount" placeholder="请输入对方手机号" type="text">
<button id="requestVideo" onclick="requestVideo()" type="button">请求视频通话</button>
</div>
<div style="margin: 20px">
<fieldset>
<button id="accept" type="button">接通</button>
<button id="hangup" type="button">挂断</button>
</fieldset>
</div>
<div style="margin: 20px">
<fieldset>
<div>
录制格式: <select disabled id="codecPreferences"></select>
</div>
<button id="startRecord" onclick="startRecording()" type="button">开始录制视频</button>
<button id="stopRecord" onclick="stopRecording()" type="button">停止录制视频</button>
<button id="downloadRecord" onclick="download()" type="button">下载</button>
</fieldset>
</div>
</body>
<script>
let config = {
iceServers: [
{
'urls': 'turn:turn.wildfirechat.cn:3478',
'credential': 'wfchat',
'username': 'wfchat'
}
]
}
const localVideo = document.getElementById('localVideo');
const remoteVideo = document.getElementById('remoteVideo');
const requestVideoButton = document.getElementById('requestVideo');
const acceptButton = document.getElementById('accept');
const hangupButton = document.getElementById('hangup');
const codecPreferences = document.querySelector('#codecPreferences');
const recordButton = document.getElementById('startRecord')
const stopRecordButton = document.getElementById('stopRecord')
const downloadButton = document.getElementById('downloadRecord')
const wsAddress = 'ws://localhost:9113/ws';
let loginAttemptCount = 0;
let myId, toId;
let pc, localStream, ws;
let mediaRecorder;
let recordedBlobs;
function login() {
loginAttemptCount = 0;
myId = document.getElementById('loginAccount').value;
ws = new WebSocket(wsAddress);
ws.onopen = function () {
console.log("WebSocket is open now.");
connect();
alert("登录成功");
};
ws.onmessage = function (message) {
let msg = JSON.parse(message.data);
console.log("ws 收到消息:" + msg.type);
switch (msg.type) {
case "offline": {
if (loginAttemptCount < 10) {
setTimeout(() => {
loginAttemptCount++;
watch();
}, 1000);
}
break;
}
case "watch": {
handleWatch(msg);
break;
}
case "offer": {
handleOffer(msg);
break;
}
case "answer": {
handleAnswer(msg);
break;
}
case "candidate": {
handleCandidate(msg);
break;
}
case "hangup": {
handleHangup(msg);
break;
}
}
};
}
requestVideoButton.onclick = async () => {
toId = document.getElementById('toAccount').value;
if (!myId) {
alert('请先登录');
return;
}
if (!toId) {
alert('请输入对方手机号');
return;
}
watch();
localStream = await navigator.mediaDevices.getUserMedia({audio: true, video: true});
localVideo.srcObject = localStream;
createPeerConnection();
}
function connect() {
send({
type: "connect",
from: myId
});
}
function handleWatch(msg) {
toId = msg.from;
}
acceptButton.onclick = async () => {
localStream = await navigator.mediaDevices.getUserMedia({audio: true, video: true});
localVideo.srcObject = localStream;
createPeerConnection();
pc.createOffer().then(offer => {
pc.setLocalDescription(offer);
send({
type: 'offer',
from: myId,
to: toId,
data: offer
});
});
}
function handleOffer(msg) {
pc.setRemoteDescription(msg.data);
pc.createAnswer().then(answer => {
pc.setLocalDescription(answer);
send({
type: "answer",
from: myId,
to: toId,
data: answer
});
});
}
function watch() {
send({
type: 'watch',
from: myId,
to: toId
});
}
function handleAnswer(msg) {
if (!pc) {
console.error('no peer connection');
return;
}
pc.setRemoteDescription(msg.data);
}
function handleCandidate(msg) {
if (!pc) {
console.error('no peer connection');
return;
}
pc.addIceCandidate(new RTCIceCandidate(msg.data)).then(() => {
console.log('candidate添加成功')
}).catch(handleError)
}
function handleError(error) {
console.log(error);
}
function createPeerConnection() {
pc = new RTCPeerConnection(config);
pc.onicecandidate = e => {
if (e.candidate) {
send({
type: "candidate",
from: myId,
to: toId,
data: e.candidate
});
}
};
pc.ontrack = e => remoteVideo.srcObject = e.streams[0];
localStream.getTracks().forEach(track => pc.addTrack(track, localStream));
}
hangupButton.onclick = async () => {
if (pc) {
pc.close();
pc = null;
}
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
localStream = null;
}
send({
type: "hangup",
from: myId,
to: toId
});
}
function handleHangup() {
if (!pc) {
console.error('no peer connection');
return;
}
pc.close();
pc = null;
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
localStream = null;
}
console.log('hangup');
}
function send(msg) {
ws.send(JSON.stringify(msg));
}
function getSupportedMimeTypes() {
const possibleTypes = [
'video/webm;codecs=vp9,opus',
'video/webm;codecs=vp8,opus',
'video/webm;codecs=h264,opus',
'video/mp4;codecs=h264,aac',
];
return possibleTypes.filter(mimeType => {
return MediaRecorder.isTypeSupported(mimeType);
});
}
function startRecording() {
recordedBlobs = [];
getSupportedMimeTypes().forEach(mimeType => {
const option = document.createElement('option');
option.value = mimeType;
option.innerText = option.value;
codecPreferences.appendChild(option);
});
const mimeType = codecPreferences.options[codecPreferences.selectedIndex].value;
const options = {mimeType};
try {
mediaRecorder = new MediaRecorder(remoteVideo.srcObject, options);
} catch (e) {
console.error('Exception while creating MediaRecorder:', e);
alert('Exception while creating MediaRecorder: ' + e);
return;
}
console.log('Created MediaRecorder', mediaRecorder, 'with options', options);
recordButton.textContent = 'Stop Recording';
mediaRecorder.onstop = (event) => {
console.log('Recorder stopped: ', event);
console.log('Recorded Blobs: ', recordedBlobs);
};
mediaRecorder.ondataavailable = handleDataAvailable;
mediaRecorder.start();
console.log('MediaRecorder started', mediaRecorder);
}
function handleDataAvailable(event) {
console.log('handleDataAvailable', event);
if (event.data && event.data.size > 0) {
recordedBlobs.push(event.data);
}
}
function stopRecording() {
mediaRecorder.stop();
}
function download() {
const blob = new Blob(recordedBlobs, {type: 'video/webm'});
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = 'test.webm';
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}, 100);
}
</script>
</html>
信令服务
基于 JDK 1.8 Spring Boot、Netty 搭建,主要用于解决两个问题:
- 确认参与人,即拨打视频电话的人和接通视频电话的人
- 提供功能按钮 API,比如:发起视频通话、挂电话、以及 webRTC 建立通信通道
主要功能如下:
switch (event.getType()) {
case "connect": {
USER_MAP.put(event.getFrom(), ctx);
break;
}
case "watch": {
WebRtcEvent watchRequest = new WebRtcEvent();
if (USER_MAP.containsKey(event.getTo())) {
watchRequest.setType("watch");
watchRequest.setFrom(event.getFrom());
watchRequest.setTo(event.getTo());
USER_MAP.get(event.getTo()).writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(watchRequest)));
} else {
watchRequest.setType("offline");
USER_MAP.get(event.getFrom()).writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(watchRequest)));
}
break;
}
case "offer": {
WebRtcEvent offerRequest = new WebRtcEvent();
offerRequest.setType("offer");
offerRequest.setFrom(event.getFrom());
offerRequest.setTo(event.getTo());
offerRequest.setData(event.getData());
USER_MAP.get(event.getTo()).writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(offerRequest)));
break;
}
case "answer": {
WebRtcEvent answerRequest = new WebRtcEvent();
answerRequest.setType("answer");
answerRequest.setFrom(event.getFrom());
answerRequest.setData(event.getData());
USER_MAP.get(event.getTo()).writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(answerRequest)));
break;
}
case "candidate": {
WebRtcEvent candidateRequest = new WebRtcEvent();
candidateRequest.setType("candidate");
candidateRequest.setFrom(event.getFrom());
candidateRequest.setData(event.getData());
USER_MAP.get(event.getTo()).writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(candidateRequest)));
break;
}
case "hangup": {
WebRtcEvent hangupRequest = new WebRtcEvent();
hangupRequest.setType("hangup");
hangupRequest.setFrom(event.getFrom());
hangupRequest.setTo(event.getTo());
USER_MAP.get(event.getTo()).writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(hangupRequest)));
break;
}
}
connect -> 登录
与 html 页面中的“登录”按钮对应,当输入手机号后,点击登录,手机号将会在信令服务中存到 map 中,以待后续操作使用。
如下图所示,至少两个客户端登录以后,才能正常视频通话。

watch -> 请求视频通话
点击 watch 按钮后,前端将发送一个事件到信令服务中,结构如下:

{
type: 'watch', //事件类型
from: 13789122381, // 我的账号,比如 13789122381
to: 1323493929 // 对方的账号,比如 1323493929
}
此时输入的对方账号对应 “to” 字段。
信令服务器收到 watch 事件后,从 map 中找出对应的在线客户端,将该事件转发至相应的客户端中。
offer -> 接通
对于接收者来说,点击“接通”按钮以后,webRTC 将开始建立通信隧道。
接通的 json 结构如下:
{
type: 'offer',
from: myId,
to: toId,
data: offer
}
整个拨打电话、接通的流程如下:

总结
在 html 中还需要配置 coturn TURN 服务 地址,我在 demo 中使用的地址是测试地址,所以请不要在生产中使用。
webRTC demo的更多相关文章
- WebRTC Demo - getUserMedia()
WebRTC介绍 WebRTC提供三类API: MediaStream,即getUserMedia RTCPeerConnection RTCDataChannel getUserMedia已经由Ch ...
- Qt WebRTC demo
This is a very simple demonstration of how to stream from a native application to the browser using ...
- WebRTC学习与DEMO资源一览
一. WebRTC学习 1.1 WebRTC现状 本人最早接触WebRTC是在2011年底,那时Google已经在Android源码中加入了webrtc源码,放在/external/webrtc/ ...
- Android IOS WebRTC 音视频开发总结(六二)-- 大数据解密国外实时通讯行业开发现状
本文主要介绍国外实时通讯行业现状,文章最早发表在我们的微信公众号上,详见这里,欢迎关注微信公众号blackerteam,更多详见www.blackerteam.com 上篇文章我们采用百度搜索指数来分 ...
- webrtc学习(一): webrtc开始
一. 编译webrtc 1. 预先准备 1) vpn. 用于同步代码. 这里给一个大概的估计吧. windows端包含vs2013 win8sdk wdk chromium源码等等, 总共需要至少8 ...
- WebRTC学习笔记_Demo收集
1. WebRTC学习 1.1 WebRTC现状 本人最早接触WebRTC是在2011年底,那时Google已经在Android源代码中增加了webrtc源代码,放在/external/w ...
- WebRTC–getUserMedia & Canvas
下面是一个使用getUserMedia接口和Canvas的drawImage方法实现的截图功能(截取视频中的一帧). 基本思路是这样子的: getUserMedia获取一个MediaStream, s ...
- iOS下WebRTC音视频通话(二)-局域网内音视频通话
这里是iOS 下WebRTC音视频通话开发的第二篇,在这一篇会利用一个局域网内音视频通话的例子介绍WebRTC中常用的API. 如果你下载并编译完成之后,会看到一个iOS 版的WebRTC Demo. ...
- WebRTC技术调研
相关网址: 协议:https://www.w3.org/TR/webrtc/ https://apprtc.webrtc.org/ https://apprtc.appspot.com/ https: ...
随机推荐
- 延时任务-基于netty时间轮算法实现
一.时间轮算法简介 为了大家能够理解下文中的代码,我们先来简单了解一下netty时间轮算法的核心原理 时间轮算法名副其实,时间轮就是一个环形的数据结构,类似于表盘,将时间轮分成多个bucket(比如: ...
- 基础1:JS的原型和原型链究竟
JS的原型和原型链究竟是什么? 1. 从JS创建一个对象开始说起: 1.1 工厂模式创建对象 (缺点是无法知道创建出来的对象是一个什么类型的对象) function createPerson(name ...
- 第五十三篇:Vue安装Element ui
好家伙,之前写的一篇过时了,用不了了,更新一波 (已新建一个vue项目) 1. 在项目目录下执行:npm i element-ui -S 2. 在main.js中写入 import ElementUI ...
- Hadoop集群运行
在Hadoop文件参数配置完成之后 在master上操作 su - hadoop cd /usr/local/src/hadoop/ ./bin/hdfs namenode -format hadoo ...
- Linux之SElinux服务详解
SElinux -> Linux安全访问策略 -> 强制性 (security安全) 是Linux操作系统的一个额外的强制性的安全访问规则.用于确定哪个进程可以访问哪些文件.目录和端口的一 ...
- 使用Dockfile构建mysql镜像与初始化运行mysql容器
使用docker 构建mysql镜像,并在容器初次创建时初始化数据 Dockerfile FROM mysql:5.7.23 MAINTAINER gradyjiang "jiangzhon ...
- TFT-eSPI入门使用教程
一.准备资料 开发板:ESP32-S3 屏驱动是:ST7789_DRIVER 开发环境:VS Code + PlatformIO 注意:以上是我使用的环境,不一定需要和是使用的东西一样,这里主要是学习 ...
- 分布式链路追踪体验-skywalking入门使用
背景 旁友,你的线上服务是不是偶尔来个超时,或者突然抖动一下,造成用户一堆反馈投诉.然后你费了九牛二虎之力,查了一圈圈代码和日志才总算定位到问题原因了.或者公司内部有链路追踪系统,虽然可以很轻松地通过 ...
- AVL Tree (1) - Definition, find and Rotation
1. 定义 (15-1) [AVL tree]: 一棵空二叉树是 AVL tree; 若 T 是一棵非空二叉树, 则 T 满足以下两个条件时, T 是一棵 AVL tree: T_LeftSubtre ...
- Java SE 7、接口
接口 接口就是给出一些没有实现的方法,封装到一起,到某个类要使用的时候,在根据具体情况把这些方法写出来 语法 interface 接口名{ //属性 //方法 } class 类名 imple ...