WebRTC 系列文章 一对一视频通话和文字聊天

这是WebRTC系列文章的第三篇。这次我们来实现一个可以一对一视频通话和有文字聊天功能的项目。
如果你对WebSocket、ICE、SDP、这些知识还不是很了解的话,推荐你先看下文章末尾的几篇推荐文章。

在此特别感谢 前端李老师的帮助


环境准备

桌面游览器Chrome 80.0.3987.163(正式版本) (64 位)
手机游览器Chrome 80.0.3987.162
桌面游览器Microsoft Edge 版本 80.0.361.111
JDK1.8 以上
springboot2.1.6
Gradle4.8
一台CentOS7 云服务器要有公网IP

想要在游览器中打开视频连接你还需要时用HTTPS。

这部分可以参考Tomcat8.5配置https和SpringBoot配置https
使用PKCS12

Windows10 记得开启游览器访问 相机的权限
在这里插入图片描述

码代码

项目完整代码仓库

首先是信令服务器

这里我们使用springboot来开发

引入依赖
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-websocket'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    // https://mvnrepository.com/artifact/com.alibaba/fastjson
    compile group: 'com.alibaba', name: 'fastjson', version: '1.2.68'

}
信令服务器
/**
 * WebSocket业务类
 * 此类用来作为RTC的信令服务器
 */
@Service
@ServerEndpoint("/websocketRTC")
public class WebSocketRTC {
    private static Vector<Session> sessions = new Vector<>();
    private static Vector<JSONObject> sessionProduce = new Vector<>();
    private static TreeMap<String,Session> sessionTreeMap = new TreeMap<>();
    private static int loginNumber = 0;
    private Session session ;

    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(WebSocketRTC.class);

    /**
     * 响应一个客户端WebSocket连接
     * @param session
     * @throws IOException
     */
    @OnOpen
    public void onopenproc(Session session) throws IOException {
        System.out.println("hava a client connected");
        this.session = session;

        JSONObject open = new JSONObject();

        open.put("status", "success");
        sendMessageToClient(open.toJSONString(), session);
    }

    /**
     * 响应一个客户端的连接关闭
     * @param session
     */
    @OnClose
    public void oncloseproc(Session session){
        System.out.println("had a client is disconneted");
//        sessionTreeMap.remove(data);

    }

    /**
     * 对于客户端消息的处理和响应
     * @param message 客户端发送的消息
     * @param session 客户端的WebSocket会话对象
     * @throws IOException
     */
    @OnMessage
    public void onmessageproc(String message , Session session) throws IOException {
        /**
         * 信令服务器与客户端之间的消息传递采用JSON
         */
        if(message!=null) {
            JSONObject msgJSON = JSON.parseObject(message);
/**
 * 消息中的type字段表示此次消息的类型
 * 服务器根据消息的type针对性的处理
 */
            switch (msgJSON.getString("type")) {
                case "login" :{
                    /**
                     * 处理客户端登录
                     */
                    log.info("session : "+session + "is login .. "+new Date());
                    log.info("user login in as "+msgJSON.getString("name"));
                    if (sessionTreeMap.containsKey(msgJSON.getString("name"))) {
                        JSONObject login = new JSONObject();
                        login.put("type", "login");
                        login.put("success", false);
                        sendMessageToClient(login.toJSONString() , session);

                    }else {
                        sessionTreeMap.put(msgJSON.getString("name"), session);
                        JSONObject login = new JSONObject();
                        login.put("type", "login");
                        login.put("success", true);
                        login.put("myName", msgJSON.getString("name"));
                        sendMessageToClient(login.toJSONString() , session);
                    }

                }break;
                case "offer": {
                    /**
                     * 处理offer消息
                     * offer是一个peer to peer 连接中的 第一步
                     * 这个是响应通话发起者的消息
                     * 这里主要是找到 通话发起者要通话的对方的会话
                     */
//                    onOffer(data.offer, data.name);\
                    log.info("Sending offer to " + msgJSON.getString("name")+" from "+msgJSON.getString("myName"));

                    Session conn = sessionTreeMap.get(msgJSON.getString("name"));

                    if (conn != null) {
                        JSONObject offer = new JSONObject();
                        offer.put("type", "offer");
                        offer.put("offer", msgJSON.getString("offer"));
                        offer.put("name", msgJSON.getString("name"));
                        sendMessageToClient(offer.toJSONString(), conn);

                        /**
                         * 保存会话状态
                         */
                        JSONObject offerAnswer = new JSONObject();
                        offerAnswer.put("offerName", msgJSON.getString("myName"));
                        offerAnswer.put("answerName", msgJSON.getString("name"));

                        JSONObject sessionTemp = new JSONObject();
                        sessionTemp.put("session", offerAnswer);
                        sessionTemp.put("type", "offer");

                        sessionProduce.add(sessionTemp);
                    }

                }
                    break;
                case "answer": {
/**
 * 响应answer消息
 * answer是 被通话客户端 对 发起通话者的回复
 */
                    log.info("answer ..." + sessionProduce.size());

                    for (int i = 0; i < sessionProduce.size(); i++) {
                        log.info(sessionProduce.get(i).toJSONString());
                    }

                    if (true) {
                        Session conn = null;
                        /**
                         * 保存会话状态
                         * 查询谁是应该接受Anser消息的人
                         */

                        for (int ii = 0; ii < sessionProduce.size(); ii++) {
                            JSONObject i = sessionProduce.get(ii);
                            JSONObject sessionJson = i.getJSONObject("session");
                            log.info(msgJSON.toJSONString());
                            log.info(sessionJson.toJSONString());

                            log.info("myName is " + msgJSON.getString("myName") + "   , answer to name " + sessionJson.getString("answerName"));
                            if (/*i.getString("offerName").equals(msgJSON.getString("name")) && */sessionJson.getString("answerName").equals(msgJSON.getString("myName"))) {
                                conn = sessionTreeMap.get(sessionJson.getString("offerName"));
                                log.info("Sending answer to " + sessionJson.getString("offerName") + " from " + msgJSON.getString("myName"));

                                sessionProduce.remove(ii);
                            }
                        }

                        JSONObject answer = new JSONObject();
                        answer.put("type", "answer");
                        answer.put("answer", msgJSON.getString("answer"));
                        sendMessageToClient(answer.toJSONString(),conn);



                    }
                }
                    break;
                case "candidate": {
                    /**
                     * 这个是对候选连接的处理
                     * 这个消息处理在一次通话中可能发生多次
                     */
                    log.info("Sending candidate to "+msgJSON.getString("name"));
                    Session conn = sessionTreeMap.get(msgJSON.getString("name"));
                    if (conn != null) {
                        JSONObject candidate = new JSONObject();
                        candidate.put("type", "candidate");
                        candidate.put("candidate", msgJSON.getString("candidate"));
                        sendMessageToClient(candidate.toJSONString(),conn );
                    }
                }
                    break;
                case "leave":{
                    /**
                     * 此消息是处理结束通话的事件
                     */
                    log.info("Disconnectiong user from " + msgJSON.getString(" name"));
                    Session conn = sessionTreeMap.get(msgJSON.getString("name"));

                    if (conn != null) {
                        JSONObject leave = new JSONObject();
                        leave.put("type", "leave");

                        sendMessageToClient(leave.toJSONString(),conn);
                    }
                }

                    break;
                default:
                    JSONObject defaultMsg = new JSONObject();
                    defaultMsg.put("type", "error");
                    defaultMsg.put("message", "Unreconfized command : "+ msgJSON.getString("type") );
                    sendMessageToClient(defaultMsg.toJSONString(),session);
                    break;
            }
            System.out.println(message);
        }
    }

    /**
     * 发送消息
     * @param msg
     * @throws IOException
     */
    public void sendMessage(String msg) throws IOException {
        if(this.session!=null)
        this.session.getBasicRemote().sendText("hello everyone!");
        this.session.getBasicRemote().sendText(msg);
    }

    public void sendMessageForAllClient(String msg){
        if(!sessions.isEmpty()){
            sessions.forEach(i->{
                try {
                    if(i.isOpen()) {
                        i.getBasicRemote().sendText(msg+" : "+new Date().toString());
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
            });
        }
    }

    /**
     * 向指定客户端发送消息
     * @param msg
     * @param session
     * @throws IOException
     */
    public void sendMessageToClient(String msg , Session session) throws IOException {
        if(session.isOpen())
        session.getBasicRemote().sendText(msg);
    }
}

网页
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />

    <title>WebRTC 系列文章 一对一视频通话和文字聊天</title>

    <style>
      body {
        background-color: #3D6DF2;
        margin-top: 15px;
        font-family: sans-serif;
        color: white;
      }

      video {
        background: black;
        border: 1px solid gray;
      }

      .page {
        position: relative;
        display: block;
        margin: 0 auto;
        width: 500px;
        height: 500px;
      }

      #yours {
        width: 150px;
        height: 150px;
        position: absolute;
        top: 15px;
        right: 15px;
      }

      #theirs {
        width: 500px;
        height: 500px;
      }

      #received {
        display: block;
        width: 480px;
        height: 100px;
        background: white;
        padding: 10px;
        margin-top: 10px;
        color: black;
        overflow: scroll;
      }
    </style>
  </head>
  <body>
    <div id="login-page" class="page">
      <h2>Login As</h2>
      <input type="text" id="username" />
      <button id="login">Login</button>
    </div>

    <div id="call-page" class="page">
      <video id="yours" muted="muted" autoplay ></video>
      <video id="theirs" muted="muted" autoplay></video>
      <input type="text" id="their-username" />
      <button id="call">Call</button>
      <button id="hang-up">Hang Up</button>

      <input type="text" id="message"></input>
      <button id="send">Send</button>
      <div id="received"></div>
    </div>

    <!--<script src="client.js"></script>-->
    <script src="part3.js"></script>
  </body>
</html>

javascrpit
  // 核心的javascript

  // 声明 变量 : 记录自己的登录名 , 对方的登录名
  var name,
      connectedUser;
  var myName;

  // 建立WebSocket连接 信令服务器
  var connection = new WebSocket("wss://119.3.239.168:9443/websocketRTC");
// var connection = new WebSocket("wss://localhost:9443/websocketRTC");

  // 自己的RTCPeerConnection
  // RTC 最重要的对象
  var yourConnection;

  // 打开连接事件响应
  connection.onopen = function () {
    console.log("Connected");
  };

// Handle all messages through this callback
  connection.onmessage = function (message) {
    console.log("Got message", message.data);

    var data = JSON.parse(message.data);

    switch (data.type) {
      case "login":
        onLogin(data.success);
        break;
      case "offer":
        onOffer(data.offer, data.name);
        break;
      case "answer":
        onAnswer(data.answer);
        break;
      case "candidate":
        onCandidate(data.candidate);
        break;
      case "leave":
        onLeave();
        break;
      default:
        console.log("default message");
        console.log(data);
        break;
    }
  };

  connection.onerror = function (err) {
    console.log("Got error", err);
  };

//  发送消息的方法 向信令服务器
// Alias for sending messages in JSON format
  function send(message) {
    if (connectedUser) {
      message.name = connectedUser;
      message.myName = name;
    }

    connection.send(JSON.stringify(message));
  };

  // 绑定HTML上的一些标签
  var loginPage = document.querySelector('#login-page'),
      usernameInput = document.querySelector('#username'),
      loginButton = document.querySelector('#login'),
      callPage = document.querySelector('#call-page'),
      theirUsernameInput = document.querySelector('#their-username'),
      callButton = document.querySelector('#call'),
      hangUpButton = document.querySelector('#hang-up'),
      messageInput = document.querySelector('#message'),
      sendButton = document.querySelector('#send'),
      received = document.querySelector('#received');

  callPage.style.display = "none";

//  登录按钮click事件响应
// Login when the user clicks the button
//  记录登录名,向信令服务器发送登录信息
  loginButton.addEventListener("click", function (event) {
    name = usernameInput.value;

    myName = usernameInput.value;

    if (name.length > 0) {
      send({
        type: "login",
        name: name
      });
    }
  });

  // 响应信令服务器反馈的登录信息
  function onLogin(success) {
    if (success === false) {
      alert("Login unsuccessful, please try a different name.");
    } else {
      loginPage.style.display = "none";
      callPage.style.display = "block";

      // Get the plumbing ready for a call
      //  准备开始一个连接
      startConnection();
    }
  };


  var yourVideo = document.querySelector('#yours'),
      theirVideo = document.querySelector('#theirs'),
      // yourConnection, connectedUser, stream, dataChannel;
      connectedUser, stream, dataChannel;

  // 打开自己的摄像头
  // 准备开始一次peer to peer 连接
  function startConnection() {

    // 想要获取一个最接近 1280x720 的相机分辨率
    var constraints = {audio: false, video: {width: 320, height: 480}};
    navigator.mediaDevices.getUserMedia(constraints)
        .then(function (mediaStream) {
          // var video = document.querySelector('video');

          yourVideo.srcObject = mediaStream;

          if (hasRTCPeerConnection()) {
            console.log("setupPeerConnection .. ")
            setupPeerConnection(mediaStream);
          } else {
            alert("Sorry, your browser does not support WebRTC.");
          }

          yourVideo.onloadedmetadata = function (e) {
            yourVideo.play();
          };


        })
        .catch(function (err) {
          console.log(err.name + " -- : " + err.message);
        });

  }

  // 创建RTCPeerConnection对象 ,绑定ICE服务器,绑定多媒体数据流
  function setupPeerConnection(stream) {
    if (yourConnection == null) {
      var configuration = {
        // "iceServers": [{ "url": "stun:127.0.0.1:9876" }]
        "iceServers": [{"url": "stun:119.3.239.168:3478"}, {
          "url": "turn:119.3.239.168:3478",
          "username": "codeboy",
          "credential": "helloworld"
        }]
      };
      yourConnection = new RTCPeerConnection(configuration, {optional: [{RtpDataChannels: true}]});
    }


    if (yourConnection == null) {
      console.log("yourConneion is null");
    } else {
      console.log("yourConnection is a object")
    }

    console.log("========================= setupPeerConnection stream ====================================")
    // console.log(stream);

    // Setup stream listening
    yourConnection.addStream(stream);
    yourConnection.onaddstream = function (e) {

      console.log(e);
      // theirVideo.src = window.URL.createObjectURL(e.stream);
      theirVideo.srcObject = e.stream;
      theirVideo.play();
    };

    // Setup ice handling
    yourConnection.onicecandidate = function (event) {
      if (event.candidate) {
        send({
          type: "candidate",
          candidate: event.candidate
        });
      }
    };

    // 打开数据通道 (这个是用于 文字交流用)
    openDataChannel();
  }

  function openDataChannel() {
    var dataChannelOptions = {
      reliable: true
    };
    dataChannel = yourConnection.createDataChannel("myLabel", dataChannelOptions);

    dataChannel.onerror = function (error) {
      console.log("Data Channel Error:", error);
    };

    dataChannel.onmessage = function (event) {
      console.log("Got Data Channel Message:", event.data);

      received.innerHTML += event.data + "<br />";
      received.scrollTop = received.scrollHeight;
    };

    dataChannel.onopen = function () {
      dataChannel.send(name + " has connected.");
    };

    dataChannel.onclose = function () {
      console.log("The Data Channel is Closed");
    };
  }

// Bind our text input and received area
  sendButton.addEventListener("click", function (event) {
    var val = messageInput.value;
    received.innerHTML += val + "<br />";
    received.scrollTop = received.scrollHeight;
    dataChannel.send(val);
  });

/*  function hasUserMedia() {
    navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia;
    return !!navigator.getUserMedia;
  }*/

  function hasRTCPeerConnection() {
    window.RTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection || window.mozRTCPeerConnection;
    window.RTCSessionDescription = window.RTCSessionDescription || window.webkitRTCSessionDescription || window.mozRTCSessionDescription;
    window.RTCIceCandidate = window.RTCIceCandidate || window.webkitRTCIceCandidate || window.mozRTCIceCandidate;
    return !!window.RTCPeerConnection;
  }

  callButton.addEventListener("click", function () {
    var theirUsername = theirUsernameInput.value;
    console.log("call " + theirUsername)
    if (theirUsername.length > 0) {
      startPeerConnection(theirUsername);
    }
  });

  // 开始peer to peer 连接
  function startPeerConnection(user) {
    connectedUser = user;

    // yourConnection
    // Begin the offer

    // 发送通话请求 1
    yourConnection.createOffer(function (offer) {
      console.log("    yourConnection.createOffer");
      send({
        type: "offer",
        offer: offer
      });

      console.log("     yourConnection.setLocalDescription(offer);");
      yourConnection.setLocalDescription(offer);
    }, function (error) {
      alert("An error has occurred.");
    });
  };

  // 接受通话者 响应 通话请求 2
  function onOffer(offer, name) {
    connectedUser = name;

    console.log("============================================================");
    console.log("===============    onOffer       (===================");
    console.log("connector user name is "+connectedUser);
    console.log("============================================================");


    var offerJson = JSON.parse(offer);
    var sdp = offerJson.sdp;

    //   设置对方的会话描述
    try {
      console.log("                   yourConnection.setRemoteDescription                   ");
      yourConnection.setRemoteDescription(new window.RTCSessionDescription(offerJson), function () {
            console.log("success");
          }
          ,
          function () {
            console.log("fail")
          });

    } catch (e) {
      alert(e)
    }

    // 向通话请求者 发送回复消息 3
    yourConnection.createAnswer(function (answer) {
      yourConnection.setLocalDescription(answer);
      console.log("               yourConnection.createAnswer                  ");
      send({
        type: "answer",
        answer: answer
      });
    }, function (error) {
      alert("An error has occurred");
    });

    console.log("onOffer is success");

  };

  // 通话请求者 处理 回复 4
  function onAnswer(answer) {
    if (yourConnection == null) {
      alert("yourconnection is null in onAnswer");
    }

    console.log("============================================================");
    console.log("================ OnAnswer ============================");
    console.log("============================================================");
    console.log(answer);
    if (answer != null) {
      console.log(typeof answer);
    }

    var answerJson = JSON.parse(answer);
    console.log(answerJson);

    try {

      //  设置本次会话的描述
      yourConnection.setRemoteDescription(new RTCSessionDescription(answerJson));
    } catch (e) {
      alert(e);
    }

    console.log("onAnswer is success");

  };

  // 对ICE候选连接的事情响应
  function onCandidate(candidate) {
    console.log("============================================================");
    console.log("================ OnCandidate ============================");
    console.log("============================================================");
    console.log(candidate);
    if (candidate != null) {
      console.log(typeof candidate);
    }

    var iceCandidate;

    // try {

    var candidateJson = JSON.parse(candidate);
    console.log(candidateJson);

    iceCandidate = new RTCIceCandidate(candidateJson);
    // }catch(e){
    //   console.log("exception is ")
    //   console.log(e);
    // }

    if (yourConnection == null) {
      alert("yourconnection is null in onCandidate");
    }
    // yourConnection.addIceCandidate(new RTCIceCandidate(candidate));
    yourConnection.addIceCandidate(iceCandidate);
  };

  hangUpButton.addEventListener("click", function () {
    send({
      type: "leave"
    });

    onLeave();
  });

  function onLeave() {
    connectedUser = null;
    theirVideo.src = null;
    yourConnection.close();
    yourConnection.onicecandidate = null;
    yourConnection.onaddstream = null;
    setupPeerConnection(stream);
  };

测试结果

将https的key文件放到服务器中

将bootJar包放到服务器中运行

java -jar demo-0.0.42-SNAPSHOT.jar --spring.profiles.active=dev

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述


推荐阅读

WebRTC 系列文章 WebRTC基本概念理解

WebRTC 系列文章 ICE服务器搭建 coturn

WebSocket消息推送和聊天功能实现

Tomcat8.5配置https和SpringBoot配置https

评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值