JavaScript WebRTC 多人視頻通話

得益于現(xiàn)代瀏覽器對于WebRTC規(guī)范的支持度,使用JavaScript實現(xiàn)多人音視頻通話的方案技術(shù)越來越趨于成熟,經(jīng)過一個月的學習,成功寫出完全基于JavaScript實現(xiàn)音視頻通話。貼上代碼

技術(shù)要點

  • nodejs v10.22.1
  • express 4.17.2
  • ws 8.4.0

安裝

npm i express ws

實現(xiàn)流程

page_1.png

創(chuàng)建信令服務(wù)器

./server/many.js

// 多對多視頻通話
var express = require('express'); // web框架
const fs = require('fs');

var app = express();
app.use("/js", express.static("example/js"));
app.use("/", express.static("example/many"));

let options = {
    key: fs.readFileSync('./ssl/privatekey.pem'), // 證書文件的存放目錄
    cert: fs.readFileSync('./ssl/certificate.pem')
}

const server = require('https').Server(options, app);
const WebSocketServer = require('ws').Server;
const wss = new WebSocketServer({ server });

wss.on('connection', ws => {
    ws.on('message', function message (data) {
        const str = data.toString();
        const json = JSON.parse(str);
        switch (json.type) {
            case 'conn': // 新用戶連接
                ws.userName = json.userName;
                ws.send(JSON.stringify(json));
                break;
            case 'room': // 用戶加入房間
                ws.roomName = json.roomName;
                ws.streamId = json.streamId;
                const roomUserList = getRoomUser(ws); // 找到當前房間內(nèi)的所有用戶
                if (roomUserList.length) {
                    const jsonStr = {
                        type: 'room',
                        roomUserList
                    }
                    ws.send(JSON.stringify(jsonStr)); // 返回房間的其他用戶信息給當前用戶
                }
                break;
            default:
                sendUser(ws, json);
                break;
        }
    });

    ws.on('close', () => {
        const str = JSON.stringify({
            type: 'close',
            sourceName: ws.userName,
            streamId: ws.streamId
        });
        sendMessage(ws, str); // 告訴房間內(nèi)其他用戶有連接關(guān)閉
    })
});

// 給所有用戶發(fā)送數(shù)據(jù)
function sendMessage (ws, str) {
    wss.clients.forEach(item => {
        if (item.userName != ws.userName && item.roomName === ws.roomName && item.readyState === 1) {
            item.send(str);
        }
    })
}

// 給用戶發(fā)送數(shù)據(jù)
function sendUser (ws, json) {
    if (ws.userName !== json.userName) {
        wss.clients.forEach(item => {
            if (item.userName === json.userName && item.roomName === ws.roomName && item.readyState === 1) {
                const temp = { ...json };
                delete temp.userName;
                temp.sourceName = ws.userName;
                temp.streamId = ws.streamId;
                item.send(JSON.stringify(temp));
            }
        })
    }
}

// 返回房間內(nèi)所有用戶信息
function getRoomUser (ws) {
    const roomUserList = [];
    wss.clients.forEach(item => {
        if (item.userName != ws.userName && item.roomName === ws.roomName) {
            roomUserList.push(item.userName);
        }
    });
    return roomUserList;
}

const config = {
    port: 8103
};
server.listen(config.port); // 啟動服務(wù)器
console.log('https listening on ' + config.port);

創(chuàng)建客戶端

./example/many/index.html

<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8">
    <title>多人頻通話</title>
    <link rel="stylesheet" href="main.css">
</head>

<body>
    <div class="container">
        <h1>多人頻通話</h1>
        <input type="text" id="userName" placeholder="請輸入用戶名" />
        <button id="startConn">連接</button>
        <input type="text" id="roomName" placeholder="請輸入房間號" />
        <button id="joinRoom">加入房間</button>
        <button id="hangUp">掛斷</button>
        <hr>
        <div id="videoContainer" class="video-container" align="center"></div>
        <hr>
        <!-- WebRTC兼容文件 -->
        <script src="/js/adapter-latest.js"></script>
        <script src="main.js"></script>
    </div>
</body>

</html>

./example/many/main.css

.video-container {
    display: flex;
    justify-content: center;
}
.video-item {
    width: 400px;
    margin-right: 10px
}

.video-play {
    width: 100%;
    height: 300px;
}

./example/many/main.js

const userName = document.getElementById('userName'); // 用戶名輸入框
const roomName = document.getElementById('roomName'); // 房間號輸入框
const startConn = document.getElementById('startConn'); // 連接按鈕
const joinRoom = document.getElementById('joinRoom'); // 加入房間按鈕
const hangUp = document.getElementById('hangUp'); // 掛斷按鈕
const videoContainer = document.getElementById('videoContainer'); // 通話列表

roomName.disabled = true;
joinRoom.disabled = true;
hangUp.disabled = true;

var pcList = []; // rtc連接列表
var localStream; // 本地視頻流
var ws; // WebSocket 連接

// ice stun服務(wù)器地址
var config = {
    'iceServers': [{
        'urls': 'stun:stun.l.google.com:19302'
    }]
};

// offer 配置
const offerOptions = {
    offerToReceiveVideo: 1,
    offerToReceiveAudio: 1
};

// 開始
startConn.onclick = function () {
    ws = new WebSocket('wss://' + location.host);
    ws.onopen = evt => {
        console.log('connent WebSocket is ok');
        const sendJson = JSON.stringify({
            type: 'conn',
            userName: userName.value,
        });
        ws.send(sendJson); // 注冊用戶名
    }
    ws.onmessage = msg => {
        const str = msg.data.toString();
        const json = JSON.parse(str);
        switch (json.type) {
            case 'conn':
                console.log('連接成功');
                userName.disabled = true;
                startConn.disabled = true;
                roomName.disabled = false;
                joinRoom.disabled = false;
                hangUp.disabled = false;
                break;
            case 'room':
                // 返回房間內(nèi)所有用戶
                sendRoomUser(json.roomUserList, 0);
                break;
            case 'signalOffer':
                // 收到信令Offer
                signalOffer(json);
                break;
            case 'signalAnswer':
                // 收到信令Answer
                signalAnswer(json);
                break;
            case 'iceOffer':
                // 收到iceOffer
                addIceCandidates(json);
                break;
            case 'close':
                // 收到房間內(nèi)用戶離開
                closeRoomUser(json);
            default:
                break;
        }
    }
}

// 加入或創(chuàng)建房間
joinRoom.onclick = function () {
    navigator.mediaDevices.getUserMedia({ video: true, audio: true }).then(function (mediastream) {
        localStream = mediastream; // 本地視頻流
        addUserItem(userName.value, localStream.id, localStream);
        const str = JSON.stringify({
            type: 'room',
            roomName: roomName.value,
            streamId: localStream.id
        });
        ws.send(str);
        roomName.disabled = true;
        joinRoom.disabled = true;
    }).catch(function (e) {
        console.log(JSON.stringify(e));
    });
}

// 創(chuàng)建WebRTC
function createWebRTC (userName, isOffer) {
    const pc = new RTCPeerConnection(config); // 創(chuàng)建 RTC 連接
    pcList.push({ userName, pc });
    localStream.getTracks().forEach(track => pc.addTrack(track, localStream)); // 添加本地視頻流 track
    if (isOffer) {
        // 創(chuàng)建 Offer 請求
        pc.createOffer(offerOptions).then(function (offer) {
            pc.setLocalDescription(offer); // 設(shè)置本地 Offer 描述,(設(shè)置描述之后會觸發(fā)ice事件)
            const str = JSON.stringify({ type: 'signalOffer', offer, userName });
            ws.send(str); // 發(fā)送 Offer 請求信令
        });
        // 監(jiān)聽 ice
        pc.addEventListener('icecandidate', function (event) {
            const iceCandidate = event.candidate;
            if (iceCandidate) {
                // 發(fā)送 iceOffer 請求
                const str = JSON.stringify({ type: 'iceOffer', iceCandidate, userName });
                ws.send(str);
            }
        });
    }
    return pc;
}

// 為每個房間用戶創(chuàng)建RTCPeerConnection
function sendRoomUser (list, index) {
    createWebRTC(list[index], true);
    index++;
    if (list.length > index) {
        sendRoomUser(list, index);
    }
}

// 接收 Offer 請求信令
function signalOffer (json) {
    const { offer, sourceName, streamId } = json;
    addUserItem(sourceName, streamId);
    const pc = createWebRTC(sourceName);
    pc.setRemoteDescription(new RTCSessionDescription(offer)); // 設(shè)置遠端描述
    // 創(chuàng)建 Answer 請求
    pc.createAnswer().then(function (answer) {
        pc.setLocalDescription(answer); // 設(shè)置本地 Answer 描述
        const str = JSON.stringify({ type: 'signalAnswer', answer, userName: sourceName });
        ws.send(str); // 發(fā)送 Answer 請求信令
    });

    // 監(jiān)聽遠端視頻流
    pc.addEventListener('addstream', function (event) {
        document.getElementById(event.stream.id).srcObject = event.stream; // 播放遠端視頻流
    });
}

// 接收 Answer 請求信令
function signalAnswer (json) {
    const { answer, sourceName, streamId } = json;
    addUserItem(sourceName, streamId);
    const item = pcList.find(i => i.userName === sourceName);
    if (item) {
        const { pc } = item;
        pc.setRemoteDescription(new RTCSessionDescription(answer)); // 設(shè)置遠端描述
        // 監(jiān)聽遠端視頻流
        pc.addEventListener('addstream', function (event) {
            document.getElementById(event.stream.id).srcObject = event.stream;
        });
    }
}

// 接收ice并添加
function addIceCandidates (json) {
    const { iceCandidate, sourceName } = json;
    const item = pcList.find(i => i.userName === sourceName);
    if (item) {
        const { pc } = item;
        pc.addIceCandidate(new RTCIceCandidate(iceCandidate));
    }
}

// 房間內(nèi)用戶離開
function closeRoomUser (json) {
    const { sourceName, streamId } = json;
    const index = pcList.findIndex(i => i.userName === sourceName);
    if (index > -1) {
        pcList.splice(index, 1);
    }
    removeUserItem(streamId);
}

// 掛斷
hangUp.onclick = function () {
    userName.disabled = false;
    startConn.disabled = false;
    roomName.disabled = true;
    joinRoom.disabled = true;
    hangUp.disabled = true;
    if (localStream) {
        localStream.getTracks().forEach(track => track.stop());
        localStream = null;
    }
    pcList.forEach(element => {
        element.pc.close();
        element.pc = null;
    });
    pcList.length = 0;
    if (ws) {
        ws.close();
        ws = null;
    }
    videoContainer.innerHTML = '';
}

// 添加用戶
function addUserItem (userName, mediaStreamId, src) {
    const div = document.createElement('div');
    div.id = mediaStreamId + '_item';
    div.className = 'video-item';
    const span = document.createElement('span');
    span.className = 'video-title';
    span.innerHTML = userName;
    div.appendChild(span);
    const video = document.createElement('video');
    video.id = mediaStreamId;
    video.className = 'video-play';
    video.controls = true;
    video.autoplay = true;
    video.muted = true;
    video.webkitPlaysinline = true;
    src && (video.srcObject = src);
    div.appendChild(video);
    videoContainer.appendChild(div);
}

// 移除用戶
function removeUserItem (streamId) {
    videoContainer.removeChild(document.getElementById(streamId + '_item'));
}

運行

node ./server/many.js

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容