WebRTC + WebSocket 实现视频通话

WebRTC + WebSocket 实现视频通话

  前言

  WebRTC

  WebRTC(Web Real-Time Communication)。Real-Time Communication,实时通讯。

  WebRTC能让web应用和站点之间选择性地分享音视频流。在不安装其它应用和插件的情况下,完成点对点通信。 WebRTC背后的技术被实现为一个开放的Web标准,并在所有主要浏览器中均以常规JavaScript API的形式提供。对于客户端(例如Android和iOS),可以使用提供相同功能的库。 WebRTC是个开源项目,得到Google,Apple,Microsoft和Mozilla等等公司的支持。2011年6月1日开源并在Google、Mozilla、Opera支持下被纳入万维网联盟的W3C推荐标准。

  WebSocket

  WebSocket是一种在单个TCP连接上进行全双工通信的协议。WebSocket通信协议于2011年被IETF定为标准RFC 6455,并由RFC7936补充规范。WebSocket API也被W3C定为标准。
  WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。

  大致原理

WebRTC + WebSocket 实现视频通话插图

  代码编写

  项目是SpringBoot + Thymeleaf + WebSocket,配置了https,不熟悉的同学可以看我们的《SpringBoot系列》

  html页面

  webrtc.html页面

<!DOCTYPE>
<!--解决idea thymeleaf 表达式模板报红波浪线-->
<!--suppress ALL -->
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>WebRTC + WebSocket</title>
<meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no">
<style>
html,body{
margin: 0;
padding: 0;
}
#main{
position: absolute;
width: 370px;
height: 550px;
}
#localVideo{
position: absolute;
background: #757474;
top: 10px;
right: 10px;
width: 100px;
height: 150px;
z-index: 2;
}
#remoteVideo{
position: absolute;
top: 0px;
left: 0px;
width: 100%;
height: 100%;
background: #222;
}
#buttons{
z-index: 3;
bottom: 20px;
left: 90px;
position: absolute;
}
#toUser{
border: 1px solid #ccc;
padding: 7px 0px;
border-radius: 5px;
padding-left: 5px;
margin-bottom: 5px;
}
#toUser:focus{
border-color: #66afe9;
outline: 0;
-webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6);
box-shadow: inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6)
}
#call{
width: 70px;
height: 35px;
background-color: #00BB00;
border: none;
margin-right: 25px;
color: white;
border-radius: 5px;
}
#hangup{
width:70px;
height:35px;
background-color:#FF5151;
border:none;
color:white;
border-radius: 5px;
}
</style>
</head>
<body>
<div id="main">
<video id="remoteVideo" playsinline autoplay></video>
<video id="localVideo" playsinline autoplay muted></video>

<div id="buttons">
<input id="toUser" placeholder="输入在线好友账号"/><br/>
<button id="call">视频通话</button>
<button id="hangup">挂断</button>
</div>
</div>
</body>
<!-- 可引可不引 -->
<!--<script th:src="@{/js/adapter-2021.js}"></script>-->
<script type="text/javascript" th:inline="javascript">
let username = /*[[${username}]]*/'';
let localVideo = document.getElementById('localVideo');
let remoteVideo = document.getElementById('remoteVideo');
let websocket = null;
let peer = null;

WebSocketInit();
ButtonFunInit();

/* WebSocket */
function WebSocketInit(){
//判断当前浏览器是否支持Websocket
if ('WebSocket' in window) {
websocket = new WebSocket("wss://172.16.12.156:10086/webrtc/"+username);
} else {
alert("当前浏览器不支持WebSocket!");
}

//连接发生错误的回调方法
websocket.onerror = function (e) {
alert("WebSocket连接发生错误!");
};

//连接关闭的回调方法
websocket.onclose = function () {
console.error("WebSocket连接关闭");
};

//连接成功建立的回调方法
websocket.onopen = function () {
console.log("WebSocket连接成功");
};

//接收到消息的回调方法
websocket.onmessage = async function (event) {
let { type, fromUser, msg, sdp, iceCandidate } = JSON.parse(event.data.replace(/n/g,"n").replace(/r/g,"r"));

console.log(type);

if (type === 'hangup') {
console.log(msg);
document.getElementById('hangup').click();
return;
}

if (type === 'call_start') {
let msg = "0"
if(confirm(fromUser + "发起视频通话,确定接听吗")==true){
document.getElementById('toUser').value = fromUser;
WebRTCInit();
msg = "1"
}

websocket.send(JSON.stringify({
type:"call_back",
toUser:fromUser,
fromUser:username,
msg:msg
}));

return;
}

if (type === 'call_back') {
if(msg === "1"){
console.log(document.getElementById('toUser').value + "同意视频通话");

//创建本地视频并发送offer
let stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })
localVideo.srcObject = stream;
stream.getTracks().forEach(track => {
peer.addTrack(track, stream);
});

let offer = await peer.createOffer();
await peer.setLocalDescription(offer);

let newOffer = offer.toJSON();
newOffer["fromUser"] = username;
newOffer["toUser"] = document.getElementById('toUser').value;
websocket.send(JSON.stringify(newOffer));
}else if(msg === "0"){
alert(document.getElementById('toUser').value + "拒绝视频通话");
document.getElementById('hangup').click();
}else{
alert(msg);
document.getElementById('hangup').click();
}

return;
}

if (type === 'offer') {
let stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
localVideo.srcObject = stream;
stream.getTracks().forEach(track => {
peer.addTrack(track, stream);
});

await peer.setRemoteDescription(new RTCSessionDescription({ type, sdp }));
let answer = await peer.createAnswer();
let newAnswer = answer.toJSON();

newAnswer["fromUser"] = username;
newAnswer["toUser"] = document.getElementById('toUser').value;
websocket.send(JSON.stringify(newAnswer));

await peer.setLocalDescription(answer);
return;
}

if (type === 'answer') {
peer.setRemoteDescription(new RTCSessionDescription({ type, sdp }));
return;
}

if (type === '_ice') {
peer.addIceCandidate(iceCandidate);
return;
}

}
}

/* WebRTC */
function WebRTCInit(){
peer = new RTCPeerConnection();

//ice
peer.onicecandidate = function (e) {
if (e.candidate) {
websocket.send(JSON.stringify({
type: '_ice',
toUser:document.getElementById('toUser').value,
fromUser:username,
iceCandidate: e.candidate
}));
}
};

//track
peer.ontrack = function (e) {
if (e && e.streams) {
remoteVideo.srcObject = e.streams[0];
}
};
}

/* 按钮事件 */
function ButtonFunInit(){
//视频通话
document.getElementById('call').onclick = function (e){
document.getElementById('toUser').style.visibility = 'hidden';

let toUser = document.getElementById('toUser').value;
if(!toUser){
alert("请先指定好友账号,再发起视频通话!");
return;
}

if(peer == null){
WebRTCInit();
}

websocket.send(JSON.stringify({
type:"call_start",
fromUser:username,
toUser:toUser,
}));
}

//挂断
document.getElementById('hangup').onclick = function (e){
document.getElementById('toUser').style.visibility = 'unset';

if(localVideo.srcObject){
const videoTracks = localVideo.srcObject.getVideoTracks();
videoTracks.forEach(videoTrack => {
videoTrack.stop();
localVideo.srcObject.removeTrack(videoTrack);
});
}

if(remoteVideo.srcObject){
const videoTracks = remoteVideo.srcObject.getVideoTracks();
videoTracks.forEach(videoTrack => {
videoTrack.stop();
remoteVideo.srcObject.removeTrack(videoTrack);
});

//挂断同时,通知对方
websocket.send(JSON.stringify({
type:"hangup",
fromUser:username,
toUser:document.getElementById('toUser').value,
}));
}

if(peer){
peer.ontrack = null;
peer.onremovetrack = null;
peer.onremovestream = null;
peer.onicecandidate = null;
peer.oniceconnectionstatechange = null;
peer.onsignalingstatechange = null;
peer.onicegatheringstatechange = null;
peer.onnegotiationneeded = null;

peer.close();
peer = null;
}

localVideo.srcObject = null;
remoteVideo.srcObject = null;
}
}
</script>
</html>

  Controller

  Controller页面跳转

/**
* WebRTC + WebSocket
*/
@RequestMapping("webrtc/{username}.html")
public ModelAndView socketChartPage(@PathVariable String username) {
ModelAndView modelAndView = new ModelAndView();
modelAndView.setViewName("webrtc.html");
modelAndView.addObject("username",username);
return modelAndView;
}

  WebRtcWSServer

  WebSocket服务

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.text.SimpleDateFormat;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
* WebRTC + WebSocket
*/
@Slf4j
@Component
@ServerEndpoint(value = "/webrtc/{username}", configurator = MyEndpointConfigure.class)
public class WebRtcWSServer {

/**
* 连接集合
*/
private static final Map<String, Session> sessionMap = new ConcurrentHashMap<>();

/**
* 连接建立成功调用的方法
*/
@OnOpen
public void onOpen(Session session, @PathParam("username") String username, @PathParam("publicKey") String publicKey) {
sessionMap.put(username, session);
}

/**
* 连接关闭调用的方法
*/
@OnClose
public void onClose(Session session) {
for (Map.Entry<String, Session> entry : sessionMap.entrySet()) {
if (entry.getValue() == session) {
sessionMap.remove(entry.getKey());
break;
}
}
}

/**
* 发生错误时调用
*/
@OnError
public void onError(Session session, Throwable error) {
error.printStackTrace();
}

/**
* 服务器接收到客户端消息时调用的方法
*/
@OnMessage
public void onMessage(String message, Session session) {
try{
//jackson
ObjectMapper mapper = new ObjectMapper();
mapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

//JSON字符串转 HashMap
HashMap hashMap = mapper.readValue(message, HashMap.class);

//消息类型
String type = (String) hashMap.get("type");

//to user
String toUser = (String) hashMap.get("toUser");
Session toUserSession = sessionMap.get(toUser);
String fromUser = (String) hashMap.get("fromUser");

//msg
String msg = (String) hashMap.get("msg");

//sdp
String sdp = (String) hashMap.get("sdp");

//ice
Map iceCandidate = (Map) hashMap.get("iceCandidate");

HashMap<String, Object> map = new HashMap<>();
map.put("type",type);

//呼叫的用户不在线
if(toUserSession == null){
toUserSession = session;
map.put("type","call_back");
map.put("fromUser","系统消息");
map.put("msg","Sorry,呼叫的用户不在线!");

send(toUserSession,mapper.writeValueAsString(map));
return;
}

//对方挂断
if ("hangup".equals(type)) {
map.put("fromUser",fromUser);
map.put("msg","对方挂断!");
}

//视频通话请求
if ("call_start".equals(type)) {
map.put("fromUser",fromUser);
map.put("msg","1");
}

//视频通话请求回应
if ("call_back".equals(type)) {
map.put("fromUser",toUser);
map.put("msg",msg);
}

//offer
if ("offer".equals(type)) {
map.put("fromUser",toUser);
map.put("sdp",sdp);
}

//answer
if ("answer".equals(type)) {
map.put("fromUser",toUser);
map.put("sdp",sdp);
}

//ice
if ("_ice".equals(type)) {
map.put("fromUser",toUser);
map.put("iceCandidate",iceCandidate);
}

send(toUserSession,mapper.writeValueAsString(map));
}catch(Exception e){
e.printStackTrace();
}
}

/**
* 封装一个send方法,发送消息到前端
*/
private void send(Session session, String message) {
try {
System.out.println(message);

session.getBasicRemote().sendText(message);
} catch (Exception e) {
e.printStackTrace();
}
}
}

  效果演示

  测试环境,笔记本、手机在同一局域网

  张三

  zs在笔记本浏览器上访问,https://172.16.12.156:10086/webrtc/zs.html

WebRTC + WebSocket 实现视频通话插图1

  李四

  ls在手机浏览器上访问,https://172.16.12.156:10086/webrtc/ls.html

WebRTC + WebSocket 实现视频通话插图2

  java后台打印

{"msg":"1","fromUser":"zs","type":"call_start"}
{"msg":"1","fromUser":"zs","type":"call_back"}
{"fromUser":"ls","type":"offer","sdp":"v=0rno=- 626753068503365352 2 IN IP4 127.0.0.1rns=-rnt=0 0rna=group:BUNDLE 0rna=extmap-allow-mixedrna=msid-semantic: WMS nkpVV56OfTJbiOvb1QIoILmFkHSQP4HvMGzKrnm=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 102 120 127 119 125 107 108 109 35 36 124 118 123rnc=IN IP4 0.0.0.0rna=rtcp:9 IN IP4 0.0.0.0rna=ice-ufrag:Ex36rna=ice-pwd:tuF0um0vfeJKduoIqEtlcFdprna=ice-options:tricklerna=fingerprint:sha-256 49:EA:10:1D:3B:0C:3F:8D:3D:A1:45:E4:84:00:F6:22:B8:72:7C:90:D6:7E:E4:E8:AE:79:01:4B:60:7E:B0:C1rna=setup:actpassrna=mid:0rna=extmap:1 urn:ietf:params:rtp-hdrext:toffsetrna=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-timerna=extmap:3 urn:3gpp:video-orientationrna=extmap:4 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01rna=extmap:5 http://www.webrtc.org/experiments/rtp-hdrext/playout-delayrna=extmap:6 http://www.webrtc.org/experiments/rtp-hdrext/video-content-typerna=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-timingrna=extmap:8 http://www.webrtc.org/experiments/rtp-hdrext/color-spacerna=extmap:9 urn:ietf:params:rtp-hdrext:sdes:midrna=extmap:10 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-idrna=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-idrna=sendrecvrna=msid:nkpVV56OfTJbiOvb1QIoILmFkHSQP4HvMGzK 157a5a40-fd58-424a-bb25-7313cb390d25rna=rtcp-muxrna=rtcp-rsizerna=rtpmap:96 VP8/90000rna=rtcp-fb:96 goog-rembrna=rtcp-fb:96 transport-ccrna=rtcp-fb:96 ccm firrna=rtcp-fb:96 nackrna=rtcp-fb:96 nack plirna=rtpmap:97 rtx/90000rna=fmtp:97 apt=96rna=rtpmap:98 VP9/90000rna=rtcp-fb:98 goog-rembrna=rtcp-fb:98 transport-ccrna=rtcp-fb:98 ccm firrna=rtcp-fb:98 nackrna=rtcp-fb:98 nack plirna=fmtp:98 profile-id=0rna=rtpmap:99 rtx/90000rna=fmtp:99 apt=98rna=rtpmap:100 VP9/90000rna=rtcp-fb:100 goog-rembrna=rtcp-fb:100 transport-ccrna=rtcp-fb:100 ccm firrna=rtcp-fb:100 nackrna=rtcp-fb:100 nack plirna=fmtp:100 profile-id=2rna=rtpmap:101 rtx/90000rna=fmtp:101 apt=100rna=rtpmap:102 H264/90000rna=rtcp-fb:102 goog-rembrna=rtcp-fb:102 transport-ccrna=rtcp-fb:102 ccm firrna=rtcp-fb:102 nackrna=rtcp-fb:102 nack plirna=fmtp:102 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001frna=rtpmap:120 rtx/90000rna=fmtp:120 apt=102rna=rtpmap:127 H264/90000rna=rtcp-fb:127 goog-rembrna=rtcp-fb:127 transport-ccrna=rtcp-fb:127 ccm firrna=rtcp-fb:127 nackrna=rtcp-fb:127 nack plirna=fmtp:127 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001frna=rtpmap:119 rtx/90000rna=fmtp:119 apt=127rna=rtpmap:125 H264/90000rna=rtcp-fb:125 goog-rembrna=rtcp-fb:125 transport-ccrna=rtcp-fb:125 ccm firrna=rtcp-fb:125 nackrna=rtcp-fb:125 nack plirna=fmtp:125 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01frna=rtpmap:107 rtx/90000rna=fmtp:107 apt=125rna=rtpmap:108 H264/90000rna=rtcp-fb:108 goog-rembrna=rtcp-fb:108 transport-ccrna=rtcp-fb:108 ccm firrna=rtcp-fb:108 nackrna=rtcp-fb:108 nack plirna=fmtp:108 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01frna=rtpmap:109 rtx/90000rna=fmtp:109 apt=108rna=rtpmap:35 AV1X/90000rna=rtcp-fb:35 goog-rembrna=rtcp-fb:35 transport-ccrna=rtcp-fb:35 ccm firrna=rtcp-fb:35 nackrna=rtcp-fb:35 nack plirna=rtpmap:36 rtx/90000rna=fmtp:36 apt=35rna=rtpmap:124 red/90000rna=rtpmap:118 rtx/90000rna=fmtp:118 apt=124rna=rtpmap:123 ulpfec/90000rna=ssrc-group:FID 3146384823 1572310693rna=ssrc:3146384823 cname:nQAy+uYZOtBVOzF0rna=ssrc:3146384823 msid:nkpVV56OfTJbiOvb1QIoILmFkHSQP4HvMGzK 157a5a40-fd58-424a-bb25-7313cb390d25rna=ssrc:3146384823 mslabel:nkpVV56OfTJbiOvb1QIoILmFkHSQP4HvMGzKrna=ssrc:3146384823 label:157a5a40-fd58-424a-bb25-7313cb390d25rna=ssrc:1572310693 cname:nQAy+uYZOtBVOzF0rna=ssrc:1572310693 msid:nkpVV56OfTJbiOvb1QIoILmFkHSQP4HvMGzK 157a5a40-fd58-424a-bb25-7313cb390d25rna=ssrc:1572310693 mslabel:nkpVV56OfTJbiOvb1QIoILmFkHSQP4HvMGzKrna=ssrc:1572310693 label:157a5a40-fd58-424a-bb25-7313cb390d25rn"}
{"iceCandidate":{"candidate":"candidate:1679555437 1 udp 2122260223 172.16.12.156 60155 typ host generation 0 ufrag Ex36 network-id 1","sdpMid":"0","sdpMLineIndex":0},"fromUser":"ls","type":"_ice"}
{"iceCandidate":{"candidate":"candidate:1918330882 1 udp 2122194687 192.168.253.1 60156 typ host generation 0 ufrag Ex36 network-id 2 network-cost 10","sdpMid":"0","sdpMLineIndex":0},"fromUser":"ls","type":"_ice"}
{"iceCandidate":{"candidate":"candidate:714606493 1 tcp 1518280447 172.16.12.156 9 typ host tcptype active generation 0 ufrag Ex36 network-id 1","sdpMid":"0","sdpMLineIndex":0},"fromUser":"ls","type":"_ice"}
{"iceCandidate":{"candidate":"candidate:1020564722 1 tcp 1518214911 192.168.253.1 9 typ host tcptype active generation 0 ufrag Ex36 network-id 2 network-cost 10","sdpMid":"0","sdpMLineIndex":0},"fromUser":"ls","type":"_ice"}
{"fromUser":"zs","type":"answer","sdp":"v=0rno=- 6281552672698732270 2 IN IP4 127.0.0.1rns=-rnt=0 0rna=group:BUNDLE 0rna=extmap-allow-mixedrna=msid-semantic: WMS 7Ez91WWET471lFYr8tHuticsIVi2uX1dQ12Yrnm=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 125 107 124 118 123rnc=IN IP4 0.0.0.0rna=rtcp:9 IN IP4 0.0.0.0rna=ice-ufrag:Qcjsrna=ice-pwd:lbAlEg42TWV/TjNs8Y65yYHerna=ice-options:tricklerna=fingerprint:sha-256 53:D7:3F:D2:6C:DC:63:7A:61:5B:EB:00:07:6A:D6:8A:58:F7:F3:A9:C0:B1:FF:53:D8:AF:49:FE:15:23:01:6Drna=setup:activerna=mid:0rna=extmap:1 urn:ietf:params:rtp-hdrext:toffsetrna=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-timerna=extmap:3 urn:3gpp:video-orientationrna=extmap:4 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01rna=extmap:5 http://www.webrtc.org/experiments/rtp-hdrext/playout-delayrna=extmap:6 http://www.webrtc.org/experiments/rtp-hdrext/video-content-typerna=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-timingrna=extmap:8 http://www.webrtc.org/experiments/rtp-hdrext/color-spacerna=extmap:9 urn:ietf:params:rtp-hdrext:sdes:midrna=extmap:10 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-idrna=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-idrna=sendrecvrna=msid:7Ez91WWET471lFYr8tHuticsIVi2uX1dQ12Y 146873a6-1a5b-4975-99d6-0fc1a0c73f76rna=rtcp-muxrna=rtcp-rsizerna=rtpmap:96 VP8/90000rna=rtcp-fb:96 goog-rembrna=rtcp-fb:96 transport-ccrna=rtcp-fb:96 ccm firrna=rtcp-fb:96 nackrna=rtcp-fb:96 nack plirna=rtpmap:97 rtx/90000rna=fmtp:97 apt=96rna=rtpmap:98 VP9/90000rna=rtcp-fb:98 goog-rembrna=rtcp-fb:98 transport-ccrna=rtcp-fb:98 ccm firrna=rtcp-fb:98 nackrna=rtcp-fb:98 nack plirna=fmtp:98 profile-id=0rna=rtpmap:99 rtx/90000rna=fmtp:99 apt=98rna=rtpmap:125 H264/90000rna=rtcp-fb:125 goog-rembrna=rtcp-fb:125 transport-ccrna=rtcp-fb:125 ccm firrna=rtcp-fb:125 nackrna=rtcp-fb:125 nack plirna=fmtp:125 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01frna=rtpmap:107 rtx/90000rna=fmtp:107 apt=125rna=rtpmap:124 red/90000rna=rtpmap:118 rtx/90000rna=fmtp:118 apt=124rna=rtpmap:123 ulpfec/90000rna=ssrc-group:FID 127330016 1173640582rna=ssrc:127330016 cname:pJXhxJTAZFO6lI1Orna=ssrc:1173640582 cname:pJXhxJTAZFO6lI1Orn"}
{"iceCandidate":{"candidate":"candidate:1625475052 1 udp 2113937151 192.168.253.2 38700 typ host generation 0 ufrag Qcjs network-cost 999","sdpMid":"0","sdpMLineIndex":0},"fromUser":"zs","type":"_ice"}
{"msg":"对方挂断!","fromUser":"ls","type":"hangup"}
{"msg":"对方挂断!","fromUser":"zs","type":"hangup"}

  后记

  视频通话,整合我们之前的写的IM即时通讯,项目越来越完善了

  WebSocket+Java 私聊、群聊实例

  一套简单的web即时通讯——第一版

  一套简单的web即时通讯——第二版

  一套简单的web即时通讯——第三版

  本文部分参考:

  https://www.an.rustfisher.com/webrtc/web-samples/getUserMedia-open-camera

  https://github.com/shushushv/webrtc-p2p

版权声明

作者:huanzi-qch

出处:https://www.cnblogs.com/huanzi-qch

若标题中有“转载”字样,则本文版权归原作者所有。若无转载字样,本文版权归作者所有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文链接,否则保留追究法律责任的权利.

分享到 :
相关推荐