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ị
- Tạo thư mục
video-chat
. cd
đến thư mụcvideo-chat
và chạynpm init
.- Điền các thông tin cần thiết để khởi tạo project.
- 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. - 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.