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中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。
大致原理
代码编写
项目是SpringBoot + Thymeleaf + WebSocket,配置了https,不熟悉的同学可以看我们的《SpringBoot系列》
html页面
域名页面
<!DOCTYPE> <!--解决idea thymeleaf 表达式模板报红波浪线--> <!--suppress ALL --> <html xmlns:th="http://域名"> <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-域名}"></script>--> <script type="text/javascript" th:inline="javascript"> let username = /*[[${username}]]*/\'\'; let localVideo = 域名lementById(\'localVideo\'); let remoteVideo = 域名lementById(\'remoteVideo\'); let websocket = null; let peer = null; WebSocketInit(); ButtonFunInit(); /* WebSocket */ function WebSocketInit(){ //判断当前浏览器是否支持WebSocket if (\'WebSocket\' in window) { websocket = new WebSocket("wss://域名.156:10086/webrtc/"+username); } else { alert("当前浏览器不支持WebSocket!"); } //连接发生错误的回调方法 域名ror = function (e) { alert("WebSocket连接发生错误!"); }; //连接关闭的回调方法 域名ose = function () { 域名r("WebSocket连接关闭"); }; //连接成功建立的回调方法 域名en = function () { 域名("WebSocket连接成功"); }; //接收到消息的回调方法 域名ssage = async function (event) { let { type, fromUser, msg, sdp, iceCandidate } = 域名e(域名ace(/\n/g,"\\n").replace(/\r/g,"\\r")); 域名(type); if (type === \'hangup\') { 域名(msg); 域名lementById(\'hangup\').click(); return; } if (type === \'call_start\') { let msg = "0" if(confirm(fromUser + "发起视频通话,确定接听吗")==true){ 域名lementById(\'toUser\').value = fromUser; WebRTCInit(); msg = "1" } 域名(域名ngify({ type:"call_back", toUser:fromUser, fromUser:username, msg:msg })); return; } if (type === \'call_back\') { if(msg === "1"){ 域名(域名lementById(\'toUser\').value + "同意视频通话"); //创建本地视频并发送offer let stream = await 域名serMedia({ video: true, audio: true }) 域名bject = stream; 域名racks().forEach(track => { 域名rack(track, stream); }); let offer = await 域名teOffer(); await 域名ocalDescription(offer); let newOffer = 域名ON(); newOffer["fromUser"] = username; newOffer["toUser"] = 域名lementById(\'toUser\').value; 域名(域名ngify(newOffer)); }else if(msg === "0"){ alert(域名lementById(\'toUser\').value + "拒绝视频通话"); 域名lementById(\'hangup\').click(); }else{ alert(msg); 域名lementById(\'hangup\').click(); } return; } if (type === \'offer\') { let stream = await 域名serMedia({ video: true, audio: true }); 域名bject = stream; 域名racks().forEach(track => { 域名rack(track, stream); }); await 域名emoteDescription(new RTCSessionDescription({ type, sdp })); let answer = await 域名teAnswer(); let newAnswer = 域名ON(); newAnswer["fromUser"] = username; newAnswer["toUser"] = 域名lementById(\'toUser\').value; 域名(域名ngify(newAnswer)); await 域名ocalDescription(answer); return; } if (type === \'answer\') { 域名emoteDescription(new RTCSessionDescription({ type, sdp })); return; } if (type === \'_ice\') { 域名ceCandidate(iceCandidate); return; } } } /* WebRTC */ function WebRTCInit(){ peer = new RTCPeerConnection(); //ice 域名ecandidate = function (e) { if (域名idate) { 域名(域名ngify({ type: \'_ice\', toUser:域名lementById(\'toUser\').value, fromUser:username, iceCandidate: 域名idate })); } }; //track 域名ack = function (e) { if (e && 域名ams) { 域名bject = 域名ams[0]; } }; } /* 按钮事件 */ function ButtonFunInit(){ //视频通话 域名lementById(\'call\').onclick = function (e){ 域名lementById(\'toUser\').域名bility = \'hidden\'; let toUser = 域名lementById(\'toUser\').value; if(!toUser){ alert("请先指定好友账号,再发起视频通话!"); return; } if(peer == null){ WebRTCInit(); } 域名(域名ngify({ type:"call_start", fromUser:username, toUser:toUser, })); } //挂断 域名lementById(\'hangup\').onclick = function (e){ 域名lementById(\'toUser\').域名bility = \'unset\'; if(域名bject){ const videoTracks = 域名ideoTracks(); 域名ach(videoTrack => { 域名(); 域名veTrack(videoTrack); }); } if(域名bject){ const videoTracks = 域名ideoTracks(); 域名ach(videoTrack => { 域名(); 域名veTrack(videoTrack); }); //挂断同时,通知对方 域名(域名ngify({ type:"hangup", fromUser:username, toUser:域名lementById(\'toUser\').value, })); } if(peer){ 域名ack = null; 域名movetrack = null; 域名movestream = null; 域名ecandidate = null; 域名econnectionstatechange = null; 域名gnalingstatechange = null; 域名egatheringstatechange = null; 域名gotiationneeded = null; 域名e(); peer = null; } 域名bject = null; 域名bject = null; } } </script> </html>
Controller
Controller页面跳转
/** * WebRTC + WebSocket */ @RequestMapping("webrtc/{username}.html") public ModelAndView socketChartPage(@PathVariable String username) { ModelAndView modelAndView = new ModelAndView(); 域名iewName("域名"); 域名bject("username",username); return modelAndView; }
WebRtcWSServer
WebSocket服务
import 域名域名rializationFeature; import 域名域名ctMapper; import 域名域名j; import 域名域名onent; import 域名ocket.*; import 域名域名Param; import 域名域名erEndpoint; import 域名leDateFormat; import 域名Map; import 域名; import 域名域名urrentHashMap; /** * WebRTC + WebSocket */ @Slf4j @Component @ServerEndpoint(value = "/webrtc/{username}", configurator = 域名s) 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) { 域名(username, session); } /** * 连接关闭调用的方法 */ @OnClose public void onClose(Session session) { for (域名y<String, Session> entry : 域名ySet()) { if (域名alue() == session) { 域名ve(域名ey()); break; } } } /** * 发生错误时调用 */ @OnError public void onError(Session session, Throwable error) { 域名tStackTrace(); } /** * 服务器接收到客户端消息时调用的方法 */ @OnMessage public void onMessage(String message, Session session) { try{ //jackson ObjectMapper mapper = new ObjectMapper(); 域名ateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")); 域名igure(域名_ON_UNKNOWN_PROPERTIES, false); //JSON字符串转 HashMap HashMap hashMap = 域名Value(message, 域名s); //消息类型 String type = (String) 域名("type"); //to user String toUser = (String) 域名("toUser"); Session toUserSession = 域名(toUser); String fromUser = (String) 域名("fromUser"); //msg String msg = (String) 域名("msg"); //sdp String sdp = (String) 域名("sdp"); //ice Map iceCandidate = (Map) 域名("iceCandidate"); HashMap<String, Object> map = new HashMap<>(); 域名("type",type); //呼叫的用户不在线 if(toUserSession == null){ toUserSession = session; 域名("type","call_back"); 域名("fromUser","系统消息"); 域名("msg","Sorry,呼叫的用户不在线!"); send(toUserSession,域名eValueAsString(map)); return; } //对方挂断 if ("hangup".equals(type)) { 域名("fromUser",fromUser); 域名("msg","对方挂断!"); } //视频通话请求 if ("call_start".equals(type)) { 域名("fromUser",fromUser); 域名("msg","1"); } //视频通话请求回应 if ("call_back".equals(type)) { 域名("fromUser",toUser); 域名("msg",msg); } //offer if ("offer".equals(type)) { 域名("fromUser",toUser); 域名("sdp",sdp); } //answer if ("answer".equals(type)) { 域名("fromUser",toUser); 域名("sdp",sdp); } //ice if ("_ice".equals(type)) { 域名("fromUser",toUser); 域名("iceCandidate",iceCandidate); } send(toUserSession,域名eValueAsString(map)); }catch(Exception e){ 域名tStackTrace(); } } /** * 封装一个send方法,发送消息到前端 */ private void send(Session session, String message) { try { 域名tln(message); 域名asicRemote().sendText(message); } catch (Exception e) { 域名tStackTrace(); } } }
效果演示
测试环境,笔记本、手机再同一局域网
张三
zs在笔记本浏览器上访问,https://域名.156:10086/webrtc/域名
李四
ls在手机浏览器上访问,https://域名.156:10086/webrtc/域名
java后台打印
{"msg":"1","fromUser":"zs","type":"call_start"} {"msg":"1","fromUser":"zs","type":"call_back"} {"fromUser":"ls","type":"offer","sdp":"v=0\r\no=- 626753068503365352 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0\r\na=extmap-allow-mixed\r\na=msid-semantic: WMS nkpVV56OfTJbiOvb1QIoILmFkHSQP4HvMGzK\r\nm=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 102 120 127 119 125 107 108 109 35 36 124 118 123\r\nc=IN IP4 0.0.0.0\r\na=rtcp:9 IN IP4 0.0.0.0\r\na=ice-ufrag:Ex36\r\na=ice-pwd:tuF0um0vfeJKduoIqEtlcFdp\r\na=ice-options:trickle\r\na=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:C1\r\na=setup:actpass\r\na=mid:0\r\na=extmap:1 urn:ietf:params:rtp-hdrext:toffset\r\na=extmap:2 http://域名/experiments/rtp-hdrext/abs-send-time\r\na=extmap:3 urn:3gpp:video-orientation\r\na=extmap:4 http://域名/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\na=extmap:5 http://域名/experiments/rtp-hdrext/playout-delay\r\na=extmap:6 http://域名/experiments/rtp-hdrext/video-content-type\r\na=extmap:7 http://域名/experiments/rtp-hdrext/video-timing\r\na=extmap:8 http://域名/experiments/rtp-hdrext/color-space\r\na=extmap:9 urn:ietf:params:rtp-hdrext:sdes:mid\r\na=extmap:10 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\r\na=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\r\na=sendrecv\r\na=msid:nkpVV56OfTJbiOvb1QIoILmFkHSQP4HvMGzK 157a5a40-fd58-424a-bb25-7313cb390d25\r\na=rtcp-mux\r\na=rtcp-rsize\r\na=rtpmap:96 VP8/90000\r\na=rtcp-fb:96 goog-remb\r\na=rtcp-fb:96 transport-cc\r\na=rtcp-fb:96 ccm fir\r\na=rtcp-fb:96 nack\r\na=rtcp-fb:96 nack pli\r\na=rtpmap:97 rtx/90000\r\na=fmtp:97 apt=96\r\na=rtpmap:98 VP9/90000\r\na=rtcp-fb:98 goog-remb\r\na=rtcp-fb:98 transport-cc\r\na=rtcp-fb:98 ccm fir\r\na=rtcp-fb:98 nack\r\na=rtcp-fb:98 nack pli\r\na=fmtp:98 profile-id=0\r\na=rtpmap:99 rtx/90000\r\na=fmtp:99 apt=98\r\na=rtpmap:100 VP9/90000\r\na=rtcp-fb:100 goog-remb\r\na=rtcp-fb:100 transport-cc\r\na=rtcp-fb:100 ccm fir\r\na=rtcp-fb:100 nack\r\na=rtcp-fb:100 nack pli\r\na=fmtp:100 profile-id=2\r\na=rtpmap:101 rtx/90000\r\na=fmtp:101 apt=100\r\na=rtpmap:102 H264/90000\r\na=rtcp-fb:102 goog-remb\r\na=rtcp-fb:102 transport-cc\r\na=rtcp-fb:102 ccm fir\r\na=rtcp-fb:102 nack\r\na=rtcp-fb:102 nack pli\r\na=fmtp:102 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f\r\na=rtpmap:120 rtx/90000\r\na=fmtp:120 apt=102\r\na=rtpmap:127 H264/90000\r\na=rtcp-fb:127 goog-remb\r\na=rtcp-fb:127 transport-cc\r\na=rtcp-fb:127 ccm fir\r\na=rtcp-fb:127 nack\r\na=rtcp-fb:127 nack pli\r\na=fmtp:127 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f\r\na=rtpmap:119 rtx/90000\r\na=fmtp:119 apt=127\r\na=rtpmap:125 H264/90000\r\na=rtcp-fb:125 goog-remb\r\na=rtcp-fb:125 transport-cc\r\na=rtcp-fb:125 ccm fir\r\na=rtcp-fb:125 nack\r\na=rtcp-fb:125 nack pli\r\na=fmtp:125 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\r\na=rtpmap:107 rtx/90000\r\na=fmtp:107 apt=125\r\na=rtpmap:108 H264/90000\r\na=rtcp-fb:108 goog-remb\r\na=rtcp-fb:108 transport-cc\r\na=rtcp-fb:108 ccm fir\r\na=rtcp-fb:108 nack\r\na=rtcp-fb:108 nack pli\r\na=fmtp:108 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f\r\na=rtpmap:109 rtx/90000\r\na=fmtp:109 apt=108\r\na=rtpmap:35 AV1X/90000\r\na=rtcp-fb:35 goog-remb\r\na=rtcp-fb:35 transport-cc\r\na=rtcp-fb:35 ccm fir\r\na=rtcp-fb:35 nack\r\na=rtcp-fb:35 nack pli\r\na=rtpmap:36 rtx/90000\r\na=fmtp:36 apt=35\r\na=rtpmap:124 red/90000\r\na=rtpmap:118 rtx/90000\r\na=fmtp:118 apt=124\r\na=rtpmap:123 ulpfec/90000\r\na=ssrc-group:FID 3146384823 1572310693\r\na=ssrc:3146384823 cname:nQAy+uYZOtBVOzF0\r\na=ssrc:3146384823 msid:nkpVV56OfTJbiOvb1QIoILmFkHSQP4HvMGzK 157a5a40-fd58-424a-bb25-7313cb390d25\r\na=ssrc:3146384823 mslabel:nkpVV56OfTJbiOvb1QIoILmFkHSQP4HvMGzK\r\na=ssrc:3146384823 label:157a5a40-fd58-424a-bb25-7313cb390d25\r\na=ssrc:1572310693 cname:nQAy+uYZOtBVOzF0\r\na=ssrc:1572310693 msid:nkpVV56OfTJbiOvb1QIoILmFkHSQP4HvMGzK 157a5a40-fd58-424a-bb25-7313cb390d25\r\na=ssrc:1572310693 mslabel:nkpVV56OfTJbiOvb1QIoILmFkHSQP4HvMGzK\r\na=ssrc:1572310693 label:157a5a40-fd58-424a-bb25-7313cb390d25\r\n"} {"iceCandidate":{"candidate":"candidate:1679555437 1 udp 2122260223 域名.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 域名.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 域名.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 域名.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=0\r\no=- 6281552672698732270 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0\r\na=extmap-allow-mixed\r\na=msid-semantic: WMS 7Ez91WWET471lFYr8tHuticsIVi2uX1dQ12Y\r\nm=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 125 107 124 118 123\r\nc=IN IP4 0.0.0.0\r\na=rtcp:9 IN IP4 0.0.0.0\r\na=ice-ufrag:Qcjs\r\na=ice-pwd:lbAlEg42TWV/TjNs8Y65yYHe\r\na=ice-options:trickle\r\na=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:6D\r\na=setup:active\r\na=mid:0\r\na=extmap:1 urn:ietf:params:rtp-hdrext:toffset\r\na=extmap:2 http://域名/experiments/rtp-hdrext/abs-send-time\r\na=extmap:3 urn:3gpp:video-orientation\r\na=extmap:4 http://域名/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\na=extmap:5 http://域名/experiments/rtp-hdrext/playout-delay\r\na=extmap:6 http://域名/experiments/rtp-hdrext/video-content-type\r\na=extmap:7 http://域名/experiments/rtp-hdrext/video-timing\r\na=extmap:8 http://域名/experiments/rtp-hdrext/color-space\r\na=extmap:9 urn:ietf:params:rtp-hdrext:sdes:mid\r\na=extmap:10 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\r\na=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\r\na=sendrecv\r\na=msid:7Ez91WWET471lFYr8tHuticsIVi2uX1dQ12Y 146873a6-1a5b-4975-99d6-0fc1a0c73f76\r\na=rtcp-mux\r\na=rtcp-rsize\r\na=rtpmap:96 VP8/90000\r\na=rtcp-fb:96 goog-remb\r\na=rtcp-fb:96 transport-cc\r\na=rtcp-fb:96 ccm fir\r\na=rtcp-fb:96 nack\r\na=rtcp-fb:96 nack pli\r\na=rtpmap:97 rtx/90000\r\na=fmtp:97 apt=96\r\na=rtpmap:98 VP9/90000\r\na=rtcp-fb:98 goog-remb\r\na=rtcp-fb:98 transport-cc\r\na=rtcp-fb:98 ccm fir\r\na=rtcp-fb:98 nack\r\na=rtcp-fb:98 nack pli\r\na=fmtp:98 profile-id=0\r\na=rtpmap:99 rtx/90000\r\na=fmtp:99 apt=98\r\na=rtpmap:125 H264/90000\r\na=rtcp-fb:125 goog-remb\r\na=rtcp-fb:125 transport-cc\r\na=rtcp-fb:125 ccm fir\r\na=rtcp-fb:125 nack\r\na=rtcp-fb:125 nack pli\r\na=fmtp:125 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\r\na=rtpmap:107 rtx/90000\r\na=fmtp:107 apt=125\r\na=rtpmap:124 red/90000\r\na=rtpmap:118 rtx/90000\r\na=fmtp:118 apt=124\r\na=rtpmap:123 ulpfec/90000\r\na=ssrc-group:FID 127330016 1173640582\r\na=ssrc:127330016 cname:pJXhxJTAZFO6lI1O\r\na=ssrc:1173640582 cname:pJXhxJTAZFO6lI1O\r\n"} {"iceCandidate":{"candidate":"candidate:1625475052 1 udp 2113937151 域名.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://域名域名/webrtc/web-samples/getUserMedia-open-camera
https://域名/shushushv/webrtc-p2p