Skip to content

Dashboard

Xây dựng ứng dụng video chat bằng NodeJS + Socket.io + WebRTC

Created by Admin

Giới thiệu

Trong bài viết này mình sẽ build một video chat app sử dụng Javascript, NodeJS, Socket.io WebRTC, PeerJS với các tính năng cơ bản như chat, gọi video, mời người dùng khác, bật tắt video và âm thanh.

Bạn có thể xem demo tại đây.

Cài đặt

Chuẩn bị

  1. Tạo thư mục video-chat.
  2. cd đến thư mục video-chat và chạy npm init.
  3. Điền các thông tin cần thiết để khởi tạo project.
  4. Chạy npm install express ejs socket.io uuid peer nodemon. Thao tác này sẽ cài đặt các package mà chúng ta cần để build ứng dụng.
  5. Tạo file server.js.

Tạo server với ExpressJs

Đầu tiên khởi động và chạy server. Chúng ta sẽ sử dụng Express. Express là một framework tối giản cho Node.js - Express giúp bạn tạo và chạy server web với Node rất dễ dàng.

Tạo boilerplate Express.

const express = require("express");
const app = express();
const server = require("http").Server(app);

app.get("/", (req, res) => {
    res.status(200).send("okokokokokokok");
});

server.listen(3000);

Thêm scripts cho package.json:

{
  ...
  "scripts": {
    "start": "nodemon server.js"
  },
  ...
}

Chạy câu lệnh npm start, truy cập vào localhost:3000 và đây là kết quả

Tạo view

Mình sẽ sử dụng EJS để tạo view, trong file server.js, chúng ta cần sửa lại view engine:

app.set("view engine", "ejs")

Bây giờ, hãy tạo một thư mục mới có tên là views. Trong đó, hãy thêm file room.ejs:

|-- video-chat
   |-- views
      |-- room.ejs
   |-- package.json
   |-- server.js

Trong file room.ejs, chúng ta sẽ thêm code HTML để tạo giao diện:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>videoChatApp</title>
    <link rel="stylesheet" href="style.css" />
    <script src="/socket.io/socket.io.js"></script>
    <script src="https://kit.fontawesome.com/c939d0e917.js"></script>
    <script src="https://unpkg.com/[email protected]/dist/peerjs.min.js"></script>
    <script>
        const ROOM_ID = "<%= roomId %>";
    </script>
</head>
<body>
    <div class="header">
        <div class="logo">
            <div class="header__back">
            <i class="fas fa-angle-left"></i>
            </div>
            <h3>Video Chat</h2>
        </div>
    </div>
    <div class="main">
        <div class="main__left">
            <div class="videos__group">
                <div id="video-grid">

                </div>
            </div>
            <div class="options">
                <div class="options__left">
                    <div id="stopVideo" class="options__button">
                        <i class="fa fa-video-camera"></i>
                    </div>
                    <div id="muteButton" class="options__button">
                        <i class="fa fa-microphone"></i>
                    </div>
                    <div id="showChat" class="options__button">
                        <i class="fa fa-comment"></i>
                    </div>
                </div>
                <div class="options__right">
                    <div id="inviteButton" class="options__button">
                        <i class="fas fa-user-plus"></i>
                    </div>
                </div>
            </div>
        </div>
        <div class="main__right">
            <div class="main__chat_window">
                <div class="messages">

                </div>
            </div>
            <div class="main__message_container">
                <input id="chat_message" type="text" autocomplete="off" placeholder="Type message here...">
                <div id="send" class="options__button">
                    <i class="fa fa-plus" aria-hidden="true"></i>
                </div>
            </div>
        </div>
    </div>
</body>
<script src="script.js"></script>
</html>

Tiếp theo chúng ta sẽ cần render ra view room bằng cách sửa file server.js:

app.get(/, function (req, res) {
 res.render(‘room’);
})

Bây giờ truy cập localhost:3030 và bạn sẽ thấy file room.ejs đang được hiển thị.

Thêm style và xử lý sự kiện

Chúng ta sẽ tạo thêm một thư mục public và 2 file style.css, script.js. Cấu trúc thư mục như sau:

|-- video-chat
   |-- views
      |-- index.ejs
   |-- public
      |-- style.css
      |-- script.js
   |-- package.json
   |-- server.js

Express sẽ không cho phép truy cập vào file này theo mặc định, vì vậy chúng ta cần thêm đoạn mã sau:

app.use(express.static("public"));

Đoạn code trên sẽ cho phép chúng ta truy cập được vào tất cả các file tĩnh trong thư mục public.

Bây giờ chúng ta cần chỉnh sửa lại style cho đẹp mắt:

@import url("https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&display=swap");

:root {
    --main-darklg: #1d2635;
    --main-dark: #161d29;
    --primary-color: #2f80ec;
    --main-light: #eeeeee;
    font-family: "Poppins", sans-serif;
}

* {
    margin: 0;
    padding: 0;
}

.header {
    display: flex;
    justify-content: center;
    align-items: center;
    height: 8vh;
    position: relative;
    width: 100%;
    background-color: var(--main-darklg);
}

.logo > h3 {
    color: var(--main-light);
}

.main {
    overflow: hidden;
    height: 92vh;
    display: flex;
}

.main__left {
    flex: 0.7;
    display: flex;
    flex-direction: column;
}

.main__right {
    display: flex;
    flex-direction: column;
    flex: 0.3;
    background-color: #242f41;
}

.main__chat_window {
    flex-grow: 1;
    overflow-y: scroll;
}

.main__chat_window::-webkit-scrollbar {
    display: none;
}

.main__message_container {
    padding: 1rem;
    display: flex;
    align-items: center;
    justify-content: center;
}

.main__message_container > input {
    height: 50px;
    flex: 1;
    font-size: 1rem;
    border-radius: 5px;
    padding-left: 20px;
    border: none;
}


.videos__group {
    flex-grow: 1;
    display: flex;
    justify-content: center;
    align-items: center;
    padding: 1rem;
    background-color: var(--main-dark);
}

video {
    height: 300px;
    border-radius: 1rem;
    margin: 0.5rem;
    width: 400px;
    object-fit: cover;
    transform: rotateY(180deg);
    -webkit-transform: rotateY(180deg);
    -moz-transform: rotateY(180deg);
}

.options {
    padding: 1rem;
    display: flex;
    background-color: var(--main-darklg);
}

.options__left {
    display: flex;
}

.options__right {
    margin-left: auto;
}

.options__button {
    display: flex;
    justify-content: center;
    align-items: center;
    background-color: var(--primary-color);
    height: 50px;
    border-radius: 5px;
    color: var(--main-light);
    font-size: 1.2rem;
    width: 50px;
    margin: 0 0.5rem;
}

.background__red {
    background-color: #f6484a;
}

.messages {
    display: flex;
    flex-direction: column;
    margin: 1.5rem;
}

.message,
.notify {
    display: flex;
    flex-direction: column;
}

.message > b {
    color: #eeeeee;
    display: flex;
    align-items: center;
    text-transform: capitalize;
}

.message > b > i {
    margin-right: 0.7rem;
    font-size: 1.5rem;
}

.message > span {
    background-color: #eeeeee;
    margin: 1rem 0;
    padding: 1rem;
    border-radius: 5px;
}

.notify {
    color: #eeeeee;
    margin-bottom: 10px;
}

#video-grid {
    display: flex;
    justify-content: center;
    flex-wrap: wrap;
}

#showChat {
    display: none;
}

.header__back {
    display: none;
    position: absolute;
    font-size: 1.3rem;
    top: 17px;
    left: 28px;
    color: #fff;
}

@media (max-width: 700px) {
    .main__right {
        display: none;
    }
    .main__left {
        width: 100%;
        flex: 1;
    }

    video {
        height: auto;
        width: 100%;
    }

    #showChat {
        display: flex;
    }
}

Reload lại và chúng ta sẽ được giao diện mới như sau:

Cài đặt phòng chat

Bây giờ file server.js trông sẽ như thế này:

const express = require("express");
const app = express();
const server = require("http").Server(app);

app.use(express.static('public'));
app.set('view engine', 'ejs')

app.get("/", (req, res) => {
    res.render("room");
});
server.listen(3000);

Bây giờ chúng ta sẽ cần tạo ra mã cho từng phòng chat khác nhau, mình sẽ sử dụng uuid để tạo mã random cho URL.

UUID là một thư viện JS cho phép tạo unique id. Trong ứng dụng này, chúng ta sẽ sử dụng version 4, sửa lại file server.js như sau:

const { v4: uuidv4 } = require("uuid");
...
// nếu như không có mã thì tự động tạo một mã mới
app.get("/", (req, res) => {
    res.redirect(`/${uuidv4()}`);
});

// nếu đã có mã thì join vào room
app.get("/:room", (req, res) => {
    res.render("room", { roomId: req.param.room });
});

Bây giờ reload lại chúng ta sẽ được đường dẫn như sau: http://localhost:3000/8267f4ed-62ff-4678-818b-69ea1d131f72

Hiển thị video

Bây giờ chúng ta sẽ sử file script.js để chạy ở phía client, chúng ta cần lấy video stream bằng đoạn mã sau:

const videoGrid = document.getElementById("video-grid");
const myVideo = document.createElement("video");

let myVideoStream;

myVideo.muted = true;

navigator.mediaDevices
    .getUserMedia({
        audio: true,
        video: true,
    })
    .then((stream) => {
        myVideoStream = stream;
        addVideoStream(myVideo, stream);

        peer.on("call", (call) => {
            call.answer(stream);
            const video = document.createElement("video");
            call.on("stream", (userVideoStream) => {
                addVideoStream(video, userVideoStream);
            });
        });

        socket.on("user-connected", (userId) => {
            connectToNewUser(userId, stream);
        });
    });

Tiếp theo chúng ta sẽ tạo function để thêm steam vào phần từ video:

const addVideoStream = (video, stream) => {
    video.srcObject = stream;
    video.addEventListener("loadedmetadata", () => {
       video.play();
       videoGrid.append(video);
    });
};

Cho phép người khác tham gia room

Bây giờ chúng ta sẽ cần sử dụng PeerJS, socket.io sẽ cho phép giao tiếp real-time, và PeerJS sẽ cho phép implement WebRTC.

Sửa lại file server.js như sau:

const socket = io("/");

const videoGrid = document.getElementById("video-grid");
const myVideo = document.createElement("video");
const showChat = document.querySelector("#showChat");
const backBtn = document.querySelector(".header__back");
const inviteButton = document.querySelector("#inviteButton");

let text = document.querySelector("#chat_message");
let send = document.getElementById("send");
let messages = document.querySelector(".messages");

myVideo.muted = true; // mặc định tắt mic

backBtn.addEventListener("click", () => {
    document.querySelector(".main__left").style.display = "flex";
    document.querySelector(".main__left").style.flex = "1";
    document.querySelector(".main__right").style.display = "none";
    document.querySelector(".header__back").style.display = "none";
});

// xử lý cho màn hình nhỏ
showChat.addEventListener("click", () => {
    document.querySelector(".main__right").style.display = "flex";
    document.querySelector(".main__right").style.flex = "1";
    document.querySelector(".main__left").style.display = "none";
    document.querySelector(".header__back").style.display = "block";
});

let user = '';

while (!user) {
    user = prompt("Enter your name");
};

// trên server có https
var peer = new Peer(undefined, {
    path: "/peerjs",
    host: "/",
    port: 3000, // các bạn xóa dòng này khi deploy lên production nhé, chuúng ta sẽ dùng port mặc định là 443
});

let myVideoStream;

navigator.mediaDevices
    .getUserMedia({
        audio: true,
        video: true,
    })
    .then((stream) => {
        myVideoStream = stream;
        addVideoStream(myVideo, stream);

        peer.on("call", (call) => {
            call.answer(stream);
            const video = document.createElement("video");
            call.on("stream", (userVideoStream) => {
                addVideoStream(video, userVideoStream);
            });
        });

        socket.on("user-connected", (userId) => {
            connectToNewUser(userId, stream);
        });
    });

const connectToNewUser = (userId, stream) => {
    const call = peer.call(userId, stream);
    const video = document.createElement("video");
    call.on("stream", (userVideoStream) => {
        addVideoStream(video, userVideoStream);
    });
};

peer.on("open", (id) => {
    // phát sự kiện khi có người mới tham gia room
    socket.emit("join-room", ROOM_ID, id, user);
});

const addVideoStream = (video, stream) => {
    video.srcObject = stream;
    video.addEventListener("loadedmetadata", () => {
        video.play();
        videoGrid.append(video);
    });
};

// xử lý sự kiện gửi tin nhắn
send.addEventListener("click", (e) => {
    if (text.value.length !== 0) {
        socket.emit("message", text.value);
        text.value = "";
    }
});

// xử lý sự kiện gửi tin nhắn
text.addEventListener("keydown", (e) => {
    if (e.key === "Enter" && text.value.length !== 0) {
        socket.emit("message", text.value);
        text.value = "";
    }
});

// xử lý sự kiện tắt âm thanh của video
muteButton.addEventListener("click", () => {
    const enabled = myVideoStream.getAudioTracks()[0].enabled;
    if (enabled) {
        myVideoStream.getAudioTracks()[0].enabled = false;
        html = `<i class="fas fa-microphone-slash"></i>`;
        muteButton.classList.toggle("background__red");
        muteButton.innerHTML = html;
    } else {
        myVideoStream.getAudioTracks()[0].enabled = true;
        html = `<i class="fas fa-microphone"></i>`;
        muteButton.classList.toggle("background__red");
        muteButton.innerHTML = html;
    }
});

// xử lý sự kiện dừng video
stopVideo.addEventListener("click", () => {
    const enabled = myVideoStream.getVideoTracks()[0].enabled;
    if (enabled) {
        myVideoStream.getVideoTracks()[0].enabled = false;
        html = `<i class="fas fa-video-slash"></i>`;
        stopVideo.classList.toggle("background__red");
        stopVideo.innerHTML = html;
    } else {
        myVideoStream.getVideoTracks()[0].enabled = true;
        html = `<i class="fas fa-video"></i>`;
        stopVideo.classList.toggle("background__red");
        stopVideo.innerHTML = html;
    }
});

// xử lý sự kiện mời người dùng tham gia room
inviteButton.addEventListener("click", (e) => {
    let dummy = document.createElement('input');
    let text = window.location.href;

    document.body.appendChild(dummy);
    dummy.value = text;
    dummy.select();
    document.execCommand('copy');
    document.body.removeChild(dummy);

    alert("Copied !");
});

// xử lý sự kiện nhận tin nhắn
socket.on("createMessage", (message, userName) => {
    messages.innerHTML =
        messages.innerHTML +
        `<div class="message">
        <b><i class="far fa-user-circle"></i> <span> ${userName === user ? "me" : userName
        }</span> </b>
        <span>${message}</span>
    </div>`;
});

// thông báo khi có người mới tham gia phòng
socket.on("notify", (userName) => {
    messages.innerHTML += `<div class="notify">${userName} joined !</div>`;
})

Done, bây giờ reload lại và thử thôi :v mình đã deploy lên heroku, các bạn có thể test thử nhé. Happy coding !!

Demo: https://flynn-video-chat.herokuapp.com.

Source code: https://github.com/BachFl2w/video-chat.

Source: https://viblo.asia/p/xay-dung-ung-dung-video-chat-bang-nodejs-socketio-webrtc-OeVKBA905kW