背景
本文使用webtrc实现了一个简单的语音视频聊天室、支持多人音视频聊天、屏幕共享。
环境配置
音视频功能需要在有Https协议的域名下才能获取到设备信息,
测试环境搭建Https服务参考Windows下Nginx配置SSL实现Https访问(包含openssl证书生成)_殷长庆的博客-CSDN博客
正式环境可以申请一个免费的证书
复杂网络环境下需要自己搭建turnserver,网络上搜索大多是使用coturn来搭建turn服务
turn默认监听端口3478,可以使用webrtc.github.io测试服务是否可用
本文在局域网内测试,不必要部署turn,使用的谷歌的stun:stun.l.google.com:19302
webrtc参考文章
WebRTC技术简介 – 知乎 (zhihu.com)
实现
服务端
服务端使用netty构建一个websocket服务,用来完成为音视频传递ICE信息等工作。
maven配置
4.0.0com.luck.cccc-im1.0-SNAPSHOTcc-imhttp://maven.apache.org${env.JAVA_HOME}UTF-81.8io.nettynetty-all4.1.74.Final cn.hutool hutool-all 5.5.7 maven-compiler-plugin1.81.8 maven-assembly-plugin 3.0.0 com.luck.im.ServerStart jar-with-dependencies make-assembly package single
JAVA代码
聊天室服务
package com.luck.im;import java.util.List;import io.netty.bootstrap.ServerBootstrap;import io.netty.channel.ChannelFuture;import io.netty.channel.ChannelHandlerContext;import io.netty.channel.ChannelInitializer;import io.netty.channel.ChannelPipeline;import io.netty.channel.EventLoopGroup;import io.netty.channel.nio.NioEventLoopGroup;import io.netty.channel.socket.SocketChannel;import io.netty.channel.socket.nio.NioServerSocketChannel;import io.netty.handler.codec.MessageToMessageCodec;import io.netty.handler.codec.http.HttpServerCodec;import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;public class ChatSocket {private static EventLoopGroup bossGroup = new NioEventLoopGroup();private static EventLoopGroup workerGroup = new NioEventLoopGroup();private static ChannelFuture channelFuture;/** * 启动服务代理 * * @throws Exception */public static void startServer() throws Exception {try {ServerBootstrap b = new ServerBootstrap();b.group(bossGroup, workerGroup).channel(NioServerSocketChannel.class).childHandler(new ChannelInitializer() {@Overridepublic void initChannel(SocketChannel ch) throws Exception {ChannelPipeline pipeline = ch.pipeline();pipeline.addLast(new HttpServerCodec());pipeline.addLast(new WebSocketServerProtocolHandler("/myim", null, true, Integer.MAX_VALUE, false));pipeline.addLast(new MessageToMessageCodec() {@Overrideprotected void decode(ChannelHandlerContext ctx, TextWebSocketFrame frame,List
聊天室业务
package com.luck.im;import java.util.Map;import java.util.concurrent.ConcurrentHashMap;import cn.hutool.json.JSONObject;import cn.hutool.json.JSONUtil;import io.netty.channel.Channel;import io.netty.channel.ChannelHandlerContext;import io.netty.channel.SimpleChannelInboundHandler;import io.netty.util.AttributeKey;import io.netty.util.internal.StringUtil;public class ChatHandler extends SimpleChannelInboundHandler {/** 用户集合 */private static Map umap = new ConcurrentHashMap();/** channel绑定自己的用户ID */public static final AttributeKey UID = AttributeKey.newInstance("uid");@Overridepublic void channelRead0(ChannelHandlerContext ctx, String msg) {JSONObject parseObj = JSONUtil.parseObj(msg);Integer type = parseObj.getInt("t");String uid = parseObj.getStr("uid");String tid = parseObj.getStr("tid");switch (type) {case 0:// 心跳break;case 1:// 用户加入聊天室umap.put(uid, ctx.channel());ctx.channel().attr(UID).set(uid);umap.forEach((x, y) -> {if (!x.equals(uid)) {JSONObject json = new JSONObject();json.set("t", 2);json.set("uid", uid);json.set("type", "join");y.writeAndFlush(json.toString());}});break;case 2:Channel uc = umap.get(tid);if (null != uc) {uc.writeAndFlush(msg);}break;case 9:// 用户退出聊天室umap.remove(uid);leave(ctx, uid);ctx.close();break;default:break;}}@Overridepublic void channelInactive(ChannelHandlerContext ctx) throws Exception {String uid = ctx.channel().attr(UID).get();if (StringUtil.isNullOrEmpty(uid)) {super.channelInactive(ctx);return;}ctx.channel().attr(UID).set(null);umap.remove(uid);leave(ctx, uid);super.channelInactive(ctx);}/** * 用户退出 * * @param ctx * @param uid */private void leave(ChannelHandlerContext ctx, String uid) {umap.forEach((x, y) -> {if (!x.equals(uid)) {// 把数据转到用户服务JSONObject json = new JSONObject();json.set("t", 9);json.set("uid", uid);y.writeAndFlush(json.toString());}});}@Overridepublic void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {cause.printStackTrace();ctx.close();}}
启动类
package com.luck.im;public class ServerStart {public static void main(String[] args) throws Exception {// 启动聊天室ChatSocket.startServer();}}
前端
网页主要使用了adapter-latest.js,下载地址webrtc.github.io
github访问不了可以用webrtc/adapter-latest.js-Javascript文档类资源-CSDN文库
index.html
聊天室 video{width:100px;height:100px}function getUid(id){return id?id:uid;}// 开启屏幕共享function startScreen(id){id=getUid(id);if(id!=uid){sendMsg(id,{type:'startScreen'})return false;}if(!screenVideo.srcObject){let options = {audio: false, video: true};navigator.mediaDevices.getDisplayMedia(options).then(stream => {screenVideo.srcObject = stream;for(let i in remotes){onmessage({uid:i,t:2,type:'s_join'});}stream.getVideoTracks()[0].addEventListener('ended', () => {closeScreen();});}) }}function selfCloseScreen(ot){screenVideo.srcObject.getVideoTracks()[0].stop()for(let i in remotes){sendMsg(i,{type:'closeScreen',ot:ot})}screenVideo.srcObject=null;}// 关闭屏幕共享function closeScreen(id,ot){id=getUid(id);ot=(ot?ot:1);if(id!=uid){if(ot==1&&remotes[id].screenVideo){remotes[id].screenVideo.srcObject=null;}else{sendMsg(id,{type:'closeScreen',ot:2})}return false;}if(screenVideo.srcObject&&ot==1){selfCloseScreen(ot)}}// 开启视频function startVideo(id){id=getUid(id);if(id!=uid){sendMsg(id,{type:'startVideo'})return false;}let v = localVideo.srcObject.getVideoTracks();if(v&&v.length>0&&!v[0].enabled){v[0].enabled=true;}}// 关闭视频function closeVideo(id){id=getUid(id);if(id!=uid){sendMsg(id,{type:'closeVideo'})return false;}let v = localVideo.srcObject.getVideoTracks();if(v&&v.length>0&&v[0].enabled){v[0].enabled=false;}}// 开启语音function startAudio(id){id=getUid(id);if(id!=uid){sendMsg(id,{type:'startAudio'})return false;}let v = localVideo.srcObject.getAudioTracks();if(v&&v.length>0&&!v[0].enabled){v[0].enabled=true;}}// 关闭语音function closeAudio(id){id=getUid(id);if(id!=uid){sendMsg(id,{type:'closeAudio'})return false;}let v = localVideo.srcObject.getAudioTracks();if(v&&v.length>0&&v[0].enabled){v[0].enabled=false;}}// 存储通信方信息 const remotes = {}// 本地视频预览 const localVideo = document.querySelector('#localVideo')// 视频列表区域 const videos = document.querySelector('#videos')// 同屏视频预览 const screenVideo = document.querySelector('#screenVideo')// 同屏视频列表区域 const screenVideos = document.querySelector('#screenVideos')// 用户IDvar uid = new Date().getTime() + '';var ws = new WebSocket('wss://127.0.0.1/myim');ws.onopen = function() {heartBeat();onopen();}// 心跳保持ws连接function heartBeat(){setInterval(()=>{ws.send(JSON.stringify({ t: 0 }))},3000);}function onopen() {navigator.mediaDevices.getUserMedia({audio: true, // 本地测试防止回声 video: true}).then(stream => {localVideo.srcObject = stream;ws.send(JSON.stringify({ t: 1, uid: uid }));ws.onmessage = function(event) {onmessage(JSON.parse(event.data));}}) }async function onmessage(message) {if(message.t==9){onleave(message.uid);}if(message.t==2&&message.type)switch (message.type) {case 'join': {// 有新的人加入就重新设置会话,重新与新加入的人建立新会话 createRTC(message.uid,localVideo.srcObject,1)const pc = remotes[message.uid].pcconst offer = await pc.createOffer()pc.setLocalDescription(offer)sendMsg(message.uid, { type: 'offer', offer })if(screenVideo.srcObject){onmessage({uid:message.uid,t:2,type:'s_join'});}break}case 'offer': {createRTC(message.uid,localVideo.srcObject,1)const pc = remotes[message.uid].pcpc.setRemoteDescription(new RTCSessionDescription(message.offer))const answer = await pc.createAnswer()pc.setLocalDescription(answer)sendMsg(message.uid, { type: 'answer', answer })break}case 'answer': {const pc = remotes[message.uid].pcpc.setRemoteDescription(new RTCSessionDescription(message.answer))break}case 'candidate': {const pc = remotes[message.uid].pcpc.addIceCandidate(new RTCIceCandidate(message.candidate))break}case 's_join': {createRTC(message.uid,screenVideo.srcObject,2)const pc = remotes[message.uid].s_pcconst offer = await pc.createOffer()pc.setLocalDescription(offer)sendMsg(message.uid, { type: 's_offer', offer })break}case 's_offer': {createRTC(message.uid,screenVideo.srcObject,2)const pc = remotes[message.uid].s_pcpc.setRemoteDescription(new RTCSessionDescription(message.offer))const answer = await pc.createAnswer()pc.setLocalDescription(answer)sendMsg(message.uid, { type: 's_answer', answer })break}case 's_answer': {const pc = remotes[message.uid].s_pcpc.setRemoteDescription(new RTCSessionDescription(message.answer))break}case 's_candidate': {const pc = remotes[message.uid].s_pcpc.addIceCandidate(new RTCIceCandidate(message.candidate))break}case 'startScreen': {startScreen()break}case 'closeScreen': {const ot = message.otif(ot==1){closeScreen(message.uid,1)}else{closeScreen(uid,1)}break}case 'startVideo': {startVideo()break}case 'closeVideo': {closeVideo()break}case 'startAudio': {startAudio()break}case 'closeAudio': {closeAudio()break}default:console.log(message)break}}function removeScreenVideo(id){if(remotes[id].s_pc){remotes[id].s_pc.close()if(remotes[id].screenVideo)screenVideos.removeChild(remotes[id].screenVideo)}}function onleave(id) {if (remotes[id]) {remotes[id].pc.close()videos.removeChild(remotes[id].video)removeScreenVideo(id)delete remotes[id]}}function leave() {ws.send(JSON.stringify({ t: 9, uid: uid }));}// socket发送消息 function sendMsg(tid, msg) {msg.t = 2;msg.tid=tid;msg.uid=uid;ws.send(JSON.stringify(msg))}// 创建RTC对象,一个RTC对象只能与一个远端连接 function createRTC(id,stream,type) {const pc = new RTCPeerConnection({iceServers: [{urls: 'stun:stun.l.google.com:19302'}]})// 获取本地网络信息,并发送给通信方 pc.addEventListener('icecandidate', event => {if (event.candidate) {// 发送自身的网络信息到通信方 sendMsg(id, {type: (type==1?'candidate':'s_candidate'),candidate: {sdpMLineIndex: event.candidate.sdpMLineIndex,sdpMid: event.candidate.sdpMid,candidate: event.candidate.candidate}})}})// 有远程视频流时,显示远程视频流 pc.addEventListener('track', event => {if(type==1){if(!remotes[id].video){const video = createVideo()videos.append(video)remotes[id].video=video}remotes[id].video.srcObject = event.streams[0]}else{if(!remotes[id].screenVideo){const video = createVideo()screenVideos.append(video)remotes[id].screenVideo=video}remotes[id].screenVideo.srcObject = event.streams[0]}})// 添加本地视频流到会话中 if(stream){stream.getTracks().forEach(track => pc.addTrack(track, stream))}if(!remotes[id]){remotes[id]={}}if(type==1){remotes[id].pc=pc}else{remotes[id].s_pc=pc}}function createVideo(){const video = document.createElement('video')video.setAttribute('autoplay', true)video.setAttribute('playsinline', true)return video}
Nginx配置
上面的index.html文件放到D盘根目录下了,然后配置一下websocket
server { listen 443 ssl; server_name mytest.com; ssl_certificate lee/lee.crt; ssl_certificate_key lee/lee.key; ssl_session_cache shared:SSL:1m; ssl_session_timeout 5m; ssl_ciphers HIGH:!aNULL:!MD5; ssl_prefer_server_ciphers on; location / { root d:/; index index.html index.htm index.php; } location /myim { proxy_pass http://127.0.0.1:8321/myim; } }
运行
java启动
java -jar cc-im.jar
网页访问
https://127.0.0.1/index.html