webRTC demo

準備:

  1. 信令服務
  2. 前端頁面用於視頻通話

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 搭建,主要用於解決兩個問題:

  1. 確認參與人,即撥打視頻電話的人和接通視頻電話的人
  2. 提供功能按鈕 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 中使用的地址是測試地址,所以請不要在生產中使用。

Tags: