├── .eslintrc ├── .gitignore ├── .nvmrc ├── .yarnrc ├── LICENSE.md ├── README.md ├── app.json ├── client ├── src │ ├── css │ │ ├── app.scss │ │ ├── call-modal.scss │ │ ├── call-window.scss │ │ ├── main-window.scss │ │ ├── shared │ │ │ ├── base.scss │ │ │ └── mixin.scss │ │ └── variables.scss │ ├── html │ │ └── index.html │ ├── index.js │ └── js │ │ ├── App.js │ │ ├── communication │ │ ├── Emitter.js │ │ ├── MediaDevice.js │ │ ├── PeerConnection.js │ │ ├── index.js │ │ └── socket.js │ │ └── components │ │ ├── ActionButton.js │ │ ├── CallModal.js │ │ ├── CallWindow.js │ │ └── MainWindow.js ├── webpack-dev.config.js └── webpack-prod.config.js ├── config.js ├── package.json ├── screenshots └── 1.png ├── server ├── index.js └── lib │ ├── haiku.js │ ├── socket.js │ └── users.js └── yarn.lock /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "env": { 4 | "browser": true 5 | }, 6 | "rules": { 7 | "comma-dangle": ["error", "never"], 8 | "react/jsx-filename-extension": 0, 9 | "react/require-default-props": ["error", { 10 | "ignoreFunctionalComponents": true 11 | }], 12 | "jsx-a11y/control-has-associated-label": 0, 13 | "object-curly-newline": 0 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | client/dist/ 2 | client/index.html 3 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | save-prefix "" 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Minh Son Nguyen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## React-VideoCall 2 | Demo app: https://morning-escarpment-67980.onrender.com 3 | 4 |  5 | 6 | Video call to your friend without registering. 7 | Simply send your friend your auto-generated unique ID to make the call. 8 | Everytime you open a new tab, the server gives you a totally different unique ID. 9 | 10 | ### Installation 11 | 12 | ``` 13 | npm install -g yarn 14 | 15 | yarn install 16 | ``` 17 | 18 | ### Development 19 | 20 | Run server 21 | ``` 22 | yarn watch:server 23 | ``` 24 | 25 | Run webpack-dev-server - http://localhost:9000 26 | ``` 27 | yarn watch:client 28 | ``` 29 | 30 | 31 | ### Deployment 32 | 33 | **Render** (Free - Recommended) 34 | 35 | 36 | Deploy to Render 37 | 38 | 39 | **Heroku** 40 | 41 | 42 | Deploy to Heroku 43 | 44 | 45 | **Custom** 46 | ``` 47 | # Install dependencies 48 | yarn install 49 | 50 | # Build front-end assets 51 | yarn build 52 | 53 | # Run server 54 | yarn start 55 | ``` 56 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "React Video Call", 3 | "description": "React Video Call App powered by WebRTC, React and Socket.io", 4 | "repository": "https://github.com/nguymin4/react-videocall", 5 | "logo": "https://raw.githubusercontent.com/nguymin4/react-videocall/production/client/src/assets/icon1.png", 6 | "keywords": ["react", "express", "video call"] 7 | } 8 | -------------------------------------------------------------------------------- /client/src/css/app.scss: -------------------------------------------------------------------------------- 1 | @import "~bootstrap/scss/bootstrap"; 2 | 3 | @import "variables"; 4 | @import "shared/base"; 5 | 6 | html { 7 | background: url("https://picsum.photos/2560/1440") center no-repeat fixed; 8 | } 9 | 10 | body { 11 | background: rgba(#000000, 0.6); 12 | padding: 0px 20px 20px 20px; 13 | min-height: 100vh; 14 | } 15 | 16 | .navbar-brand { 17 | font-size: 1.5em; 18 | img { 19 | display: inline-block; 20 | width: 50px; 21 | } 22 | } 23 | 24 | #root { 25 | h2 { 26 | margin-top: 0px; 27 | } 28 | 29 | h4 { 30 | margin-top: 20px; 31 | } 32 | } 33 | 34 | .btn-action { 35 | outline: none; 36 | border: none; 37 | } 38 | 39 | 40 | @import "main-window"; 41 | @import "call-window"; 42 | @import "call-modal"; 43 | -------------------------------------------------------------------------------- /client/src/css/call-modal.scss: -------------------------------------------------------------------------------- 1 | .call-modal { 2 | position: absolute; 3 | width: 400px; 4 | padding: 20px; 5 | left: calc(50vw - 200px); 6 | top: calc(50vh - 60px); 7 | @include bg-gradient(top, #074055 0%, #030D10 100%); 8 | @include border-radius(5px); 9 | text-align: center; 10 | display: none; 11 | 12 | &.active { 13 | display: block; 14 | z-index: 9999; 15 | @include animation(blinking 3s infinite linear); 16 | } 17 | 18 | .btn-action:not(.hangup) { 19 | background-color: $green; 20 | } 21 | 22 | span.caller { 23 | color: $blue; 24 | } 25 | 26 | p { 27 | font-size: 1.5em; 28 | } 29 | } 30 | 31 | @include keyframes(blinking) { 32 | 25% {@include transform(scale(0.96))} 33 | 50% {@include transform(scale(1))} 34 | 75% {@include transform(scale(0.96))} 35 | 100% {@include transform(scale(1))} 36 | } -------------------------------------------------------------------------------- /client/src/css/call-window.scss: -------------------------------------------------------------------------------- 1 | .call-window { 2 | position: absolute; 3 | top: 0; 4 | left: 0; 5 | width: 100vw; 6 | height: 100vh; 7 | opacity: 0; 8 | z-index: -1; 9 | @include transition(opacity 0.5s); 10 | @include bg-gradient(top, #074055 0%, #030D10 100%); 11 | 12 | &.active { 13 | opacity: 1; 14 | z-index: auto; 15 | 16 | .video-control { 17 | z-index: auto; 18 | @include animation(in-fadeout 3s ease); 19 | } 20 | } 21 | 22 | .video-control { 23 | position: absolute; 24 | bottom: 20px; 25 | height: 72px; 26 | width: 100%; 27 | text-align: center; 28 | opacity: 0; 29 | z-index: -1; 30 | @include transition(opacity 0.5s); 31 | 32 | 33 | &:hover { 34 | opacity: 1; 35 | } 36 | } 37 | 38 | video { 39 | position: absolute; 40 | } 41 | 42 | #localVideo { 43 | bottom: 0; 44 | right: 0; 45 | width: 20%; 46 | height: 20%; 47 | } 48 | 49 | #peerVideo { 50 | width: 100%; 51 | height: 100%; 52 | } 53 | } 54 | 55 | @include keyframes(in-fadeout) { 56 | 0% {opacity: 1} 57 | 75% {opacity: 1} 58 | 100% {opacity: 0} 59 | } 60 | 61 | .video-control, .call-modal { 62 | .btn-action { 63 | $height: 50px; 64 | height: $height; 65 | width: $height; 66 | line-height: $height; 67 | margin: 0px 8px; 68 | font-size: 1.4em; 69 | text-align: center; 70 | border-radius: 50%; 71 | cursor: pointer; 72 | transition-duration: 0.25s; 73 | 74 | &:hover { 75 | opacity: 0.8; 76 | } 77 | 78 | &.hangup { 79 | background-color: $red; 80 | @include transform(rotate(135deg)); 81 | } 82 | 83 | &:not(.hangup) { 84 | background-color: $blue; 85 | 86 | &.disabled { 87 | background-color: $red; 88 | } 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /client/src/css/main-window.scss: -------------------------------------------------------------------------------- 1 | .main-window { 2 | padding-top: 80px; 3 | font-size: 1.75em; 4 | 5 | @media screen and (max-width: 767px) { 6 | padding-top: 40px; 7 | 8 | .pull-left, .pull-right { 9 | width: 100%; 10 | text-align: center; 11 | } 12 | 13 | .pull-right { 14 | margin-top: 20px; 15 | } 16 | } 17 | 18 | .btn-action { 19 | $height: 60px; 20 | height: $height; 21 | width: $height; 22 | margin: 20px 30px 0px 0px; 23 | text-align: center; 24 | border-radius: 50%; 25 | border: solid 2px $main-color; 26 | cursor: pointer; 27 | transition-duration: 0.25s; 28 | background-color: transparent; 29 | 30 | &:hover { 31 | background-color: rgba($main-color, 0.2); 32 | } 33 | } 34 | 35 | .txt-clientId { 36 | height: 40px; 37 | margin: 40px auto 0px 10px; 38 | color: $main-color; 39 | font-size: 0.9em; 40 | background-color: transparent; 41 | border: none; 42 | border-bottom: solid 1px $main-color; 43 | @include input-placeholder(rgba($main-color, 0.8)); 44 | } 45 | 46 | } 47 | 48 | -------------------------------------------------------------------------------- /client/src/css/shared/base.scss: -------------------------------------------------------------------------------- 1 | @import "mixin"; 2 | 3 | html, body { 4 | color: $main-color; 5 | font-size: $main-font-size; 6 | letter-spacing: 0.5px; 7 | 8 | .no-animation { 9 | * { 10 | @include no-animation(); 11 | } 12 | } 13 | } 14 | 15 | input { 16 | outline: none; 17 | } 18 | -------------------------------------------------------------------------------- /client/src/css/shared/mixin.scss: -------------------------------------------------------------------------------- 1 | @mixin constructor($property, $specs...) { 2 | -webkit-#{$property}: $specs; 3 | -moz-#{$property}: $specs; 4 | -o-#{$property}: $specs; 5 | #{$property}: $specs; 6 | } 7 | 8 | @mixin border-radius($specs) { 9 | @include constructor(border-radius, #{$specs}) 10 | } 11 | 12 | @mixin box-shadow($specs) { 13 | @include constructor(box-shadow, $specs) 14 | } 15 | 16 | @mixin transition($specs...) { 17 | @include constructor(transition, $specs) 18 | } 19 | 20 | @mixin transform($specs) { 21 | @include constructor(transform, $specs) 22 | } 23 | 24 | @mixin transform-style($specs) { 25 | @include constructor(transform-style, $specs) 26 | } 27 | 28 | @mixin perspective($specs) { 29 | @include constructor(perspective, $specs) 30 | } 31 | 32 | @mixin blur($specs) { 33 | @include constructor(filter, blur($specs)) 34 | } 35 | 36 | @mixin forceGpu() { 37 | @include constructor(transform, translateZ(0)) 38 | } 39 | 40 | @mixin bg-gradient($specs...) { 41 | background: -webkit-linear-gradient($specs); 42 | background: -moz-linear-gradient($specs); 43 | background: -o-linear-gradient($specs); 44 | background: linear-gradient($specs); 45 | } 46 | 47 | @mixin animation($animate...) { 48 | $max: length($animate); 49 | $animations: ''; 50 | 51 | @for $i from 1 through $max { 52 | $animations: #{$animations + nth($animate, $i)}; 53 | 54 | @if $i < $max { 55 | $animations: #{$animations + ", "}; 56 | } 57 | } 58 | -webkit-animation: $animations; 59 | -moz-animation: $animations; 60 | -o-animation: $animations; 61 | animation: $animations; 62 | } 63 | 64 | @mixin keyframes($animationName) { 65 | @-webkit-keyframes #{$animationName} { 66 | @content; 67 | } 68 | @-moz-keyframes #{$animationName} { 69 | @content; 70 | } 71 | @-o-keyframes #{$animationName} { 72 | @content; 73 | } 74 | @keyframes #{$animationName} { 75 | @content; 76 | } 77 | } 78 | 79 | @mixin no-animation() { 80 | @include transition(#{"none !important"}); 81 | @include animation(#{"none !important"}); 82 | 83 | $no-duration: "0s !important"; 84 | } 85 | 86 | @mixin input-placeholder($color) { 87 | &::-webkit-input-placeholder { 88 | color: $color; 89 | } 90 | 91 | &::-moz-placeholder { /* Firefox 19+ */ 92 | color: $color; 93 | } 94 | 95 | &:-ms-input-placeholder { 96 | color: $color; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /client/src/css/variables.scss: -------------------------------------------------------------------------------- 1 | // Variables 2 | $main-color: #FFFFFF; 3 | $secondary-color: darken(#888888, 10%); 4 | $main-font-size: 14px; 5 | 6 | $green: #7FBA00; 7 | $yellow: #FCD116; 8 | $red: #E81123; 9 | $blue: #00AFF0; -------------------------------------------------------------------------------- /client/src/html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /client/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import App from './js/App'; 4 | import './css/app.scss'; 5 | 6 | const root = createRoot(document.getElementById('root')); 7 | root.render(); 8 | -------------------------------------------------------------------------------- /client/src/js/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import _ from 'lodash'; 3 | import { socket, PeerConnection } from './communication'; 4 | import MainWindow from './components/MainWindow'; 5 | import CallWindow from './components/CallWindow'; 6 | import CallModal from './components/CallModal'; 7 | 8 | class App extends Component { 9 | constructor() { 10 | super(); 11 | this.state = { 12 | callWindow: '', 13 | callModal: '', 14 | callFrom: '', 15 | localSrc: null, 16 | peerSrc: null 17 | }; 18 | this.pc = {}; 19 | this.config = null; 20 | this.startCallHandler = this.startCall.bind(this); 21 | this.endCallHandler = this.endCall.bind(this); 22 | this.rejectCallHandler = this.rejectCall.bind(this); 23 | } 24 | 25 | componentDidMount() { 26 | socket 27 | .on('request', ({ from: callFrom }) => { 28 | this.setState({ callModal: 'active', callFrom }); 29 | }) 30 | .on('call', (data) => { 31 | if (data.sdp) { 32 | this.pc.setRemoteDescription(data.sdp); 33 | if (data.sdp.type === 'offer') this.pc.createAnswer(); 34 | } else this.pc.addIceCandidate(data.candidate); 35 | }) 36 | .on('end', this.endCall.bind(this, false)) 37 | .emit('init'); 38 | } 39 | 40 | startCall(isCaller, friendID, config) { 41 | this.config = config; 42 | this.pc = new PeerConnection(friendID) 43 | .on('localStream', (src) => { 44 | const newState = { callWindow: 'active', localSrc: src }; 45 | if (!isCaller) newState.callModal = ''; 46 | this.setState(newState); 47 | }) 48 | .on('peerStream', (src) => this.setState({ peerSrc: src })) 49 | .start(isCaller); 50 | } 51 | 52 | rejectCall() { 53 | const { callFrom } = this.state; 54 | socket.emit('end', { to: callFrom }); 55 | this.setState({ callModal: '' }); 56 | } 57 | 58 | endCall(isStarter) { 59 | if (_.isFunction(this.pc.stop)) { 60 | this.pc.stop(isStarter); 61 | } 62 | this.pc = {}; 63 | this.config = null; 64 | this.setState({ 65 | callWindow: '', 66 | callModal: '', 67 | localSrc: null, 68 | peerSrc: null 69 | }); 70 | } 71 | 72 | render() { 73 | const { callFrom, callModal, callWindow, localSrc, peerSrc } = this.state; 74 | return ( 75 |
76 | 77 | {!_.isEmpty(this.config) && ( 78 | 86 | ) } 87 | 93 |
94 | ); 95 | } 96 | } 97 | 98 | export default App; 99 | -------------------------------------------------------------------------------- /client/src/js/communication/Emitter.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | class Emitter { 4 | constructor() { 5 | this.events = {}; 6 | } 7 | 8 | emit(event, ...args) { 9 | if (this.events[event]) { 10 | this.events[event].forEach((fn) => fn(...args)); 11 | } 12 | return this; 13 | } 14 | 15 | on(event, fn) { 16 | if (this.events[event]) this.events[event].push(fn); 17 | else this.events[event] = [fn]; 18 | return this; 19 | } 20 | 21 | off(event, fn) { 22 | if (event && _.isFunction(fn)) { 23 | const listeners = this.events[event]; 24 | const index = listeners.findIndex((_fn) => _fn === fn); 25 | listeners.splice(index, 1); 26 | } else this.events[event] = []; 27 | return this; 28 | } 29 | } 30 | 31 | export default Emitter; 32 | -------------------------------------------------------------------------------- /client/src/js/communication/MediaDevice.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import Emitter from './Emitter'; 3 | 4 | /** 5 | * Manage all media devices 6 | */ 7 | class MediaDevice extends Emitter { 8 | /** 9 | * Start media devices and send stream 10 | */ 11 | start() { 12 | const constraints = { 13 | video: { 14 | facingMode: 'user', 15 | height: { min: 360, ideal: 720, max: 1080 } 16 | }, 17 | audio: true 18 | }; 19 | 20 | navigator.mediaDevices 21 | .getUserMedia(constraints) 22 | .then((stream) => { 23 | this.stream = stream; 24 | this.emit('stream', stream); 25 | }) 26 | .catch((err) => { 27 | if (err instanceof DOMException) { 28 | alert('Cannot open webcam and/or microphone'); 29 | } else { 30 | console.log(err); 31 | } 32 | }); 33 | 34 | return this; 35 | } 36 | 37 | /** 38 | * Turn on/off a device 39 | * @param {'Audio' | 'Video'} type - Type of the device 40 | * @param {Boolean} [on] - State of the device 41 | */ 42 | toggle(type, on) { 43 | const len = arguments.length; 44 | if (this.stream) { 45 | this.stream[`get${type}Tracks`]().forEach((track) => { 46 | const state = len === 2 ? on : !track.enabled; 47 | _.set(track, 'enabled', state); 48 | }); 49 | } 50 | return this; 51 | } 52 | 53 | /** 54 | * Stop all media track of devices 55 | */ 56 | stop() { 57 | if (this.stream) { 58 | this.stream.getTracks().forEach((track) => track.stop()); 59 | } 60 | return this; 61 | } 62 | } 63 | 64 | export default MediaDevice; 65 | -------------------------------------------------------------------------------- /client/src/js/communication/PeerConnection.js: -------------------------------------------------------------------------------- 1 | import MediaDevice from './MediaDevice'; 2 | import Emitter from './Emitter'; 3 | import socket from './socket'; 4 | 5 | const PC_CONFIG = { iceServers: [{ urls: ['stun:stun.l.google.com:19302'] }] }; 6 | 7 | class PeerConnection extends Emitter { 8 | /** 9 | * Create a PeerConnection. 10 | * @param {String} friendID - ID of the friend you want to call. 11 | */ 12 | constructor(friendID) { 13 | super(); 14 | this.pc = new RTCPeerConnection(PC_CONFIG); 15 | this.pc.onicecandidate = (event) => socket.emit('call', { 16 | to: this.friendID, 17 | candidate: event.candidate 18 | }); 19 | this.pc.ontrack = (event) => this.emit('peerStream', event.streams[0]); 20 | 21 | this.mediaDevice = new MediaDevice(); 22 | this.friendID = friendID; 23 | } 24 | 25 | /** 26 | * Starting the call 27 | * @param {Boolean} isCaller 28 | */ 29 | start(isCaller) { 30 | this.mediaDevice 31 | .on('stream', (stream) => { 32 | stream.getTracks().forEach((track) => { 33 | this.pc.addTrack(track, stream); 34 | }); 35 | this.emit('localStream', stream); 36 | if (isCaller) socket.emit('request', { to: this.friendID }); 37 | else this.createOffer(); 38 | }) 39 | .start(); 40 | 41 | return this; 42 | } 43 | 44 | /** 45 | * Stop the call 46 | * @param {Boolean} isStarter 47 | */ 48 | stop(isStarter) { 49 | if (isStarter) { 50 | socket.emit('end', { to: this.friendID }); 51 | } 52 | this.mediaDevice.stop(); 53 | this.pc.close(); 54 | this.pc = null; 55 | this.off(); 56 | return this; 57 | } 58 | 59 | createOffer() { 60 | this.pc.createOffer() 61 | .then(this.getDescription.bind(this)) 62 | .catch((err) => console.log(err)); 63 | return this; 64 | } 65 | 66 | createAnswer() { 67 | this.pc.createAnswer() 68 | .then(this.getDescription.bind(this)) 69 | .catch((err) => console.log(err)); 70 | return this; 71 | } 72 | 73 | /** 74 | * @param {RTCLocalSessionDescriptionInit} desc - Session description 75 | */ 76 | getDescription(desc) { 77 | this.pc.setLocalDescription(desc); 78 | socket.emit('call', { to: this.friendID, sdp: desc }); 79 | return this; 80 | } 81 | 82 | /** 83 | * @param {RTCSessionDescriptionInit} sdp - Session description 84 | */ 85 | setRemoteDescription(sdp) { 86 | const rtcSdp = new RTCSessionDescription(sdp); 87 | this.pc.setRemoteDescription(rtcSdp); 88 | return this; 89 | } 90 | 91 | /** 92 | * @param {RTCIceCandidateInit} candidate - ICE Candidate 93 | */ 94 | addIceCandidate(candidate) { 95 | if (candidate) { 96 | const iceCandidate = new RTCIceCandidate(candidate); 97 | this.pc.addIceCandidate(iceCandidate); 98 | } 99 | return this; 100 | } 101 | } 102 | 103 | export default PeerConnection; 104 | -------------------------------------------------------------------------------- /client/src/js/communication/index.js: -------------------------------------------------------------------------------- 1 | export { default as socket } from './socket'; 2 | export { default as PeerConnection } from './PeerConnection'; 3 | -------------------------------------------------------------------------------- /client/src/js/communication/socket.js: -------------------------------------------------------------------------------- 1 | import io from 'socket.io-client'; 2 | 3 | const socket = io({ path: '/bridge' }); 4 | 5 | export default socket; 6 | -------------------------------------------------------------------------------- /client/src/js/components/ActionButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import classnames from 'classnames'; 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 5 | 6 | export default function ActionButton({ className, disabled = false, icon, onClick }) { 7 | return ( 8 | 15 | ); 16 | } 17 | 18 | ActionButton.propTypes = { 19 | className: PropTypes.string, 20 | disabled: PropTypes.bool, 21 | // eslint-disable-next-line react/forbid-prop-types 22 | icon: PropTypes.object.isRequired, 23 | onClick: PropTypes.func.isRequired 24 | }; 25 | -------------------------------------------------------------------------------- /client/src/js/components/CallModal.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import classnames from 'classnames'; 4 | import { faPhone, faVideo } from '@fortawesome/free-solid-svg-icons'; 5 | import ActionButton from './ActionButton'; 6 | 7 | function CallModal({ status, callFrom, startCall, rejectCall }) { 8 | const acceptWithVideo = (video) => { 9 | const config = { audio: true, video }; 10 | return () => startCall(false, callFrom, config); 11 | }; 12 | 13 | return ( 14 |
15 |

16 | {`${callFrom} is calling`} 17 |

18 | 22 | 26 | 31 |
32 | ); 33 | } 34 | 35 | CallModal.propTypes = { 36 | status: PropTypes.string.isRequired, 37 | callFrom: PropTypes.string.isRequired, 38 | startCall: PropTypes.func.isRequired, 39 | rejectCall: PropTypes.func.isRequired 40 | }; 41 | 42 | export default CallModal; 43 | -------------------------------------------------------------------------------- /client/src/js/components/CallWindow.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/media-has-caption */ 2 | import React, { useState, useEffect, useRef } from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import classnames from 'classnames'; 5 | import { faPhone, faVideo } from '@fortawesome/free-solid-svg-icons'; 6 | import ActionButton from './ActionButton'; 7 | 8 | function CallWindow({ peerSrc, localSrc, config, mediaDevice, status, endCall }) { 9 | const peerVideo = useRef(null); 10 | const localVideo = useRef(null); 11 | const [video, setVideo] = useState(config.video); 12 | const [audio, setAudio] = useState(config.audio); 13 | 14 | useEffect(() => { 15 | if (peerVideo.current && peerSrc) peerVideo.current.srcObject = peerSrc; 16 | if (localVideo.current && localSrc) localVideo.current.srcObject = localSrc; 17 | }); 18 | 19 | useEffect(() => { 20 | if (mediaDevice) { 21 | mediaDevice.toggle('Video', video); 22 | mediaDevice.toggle('Audio', audio); 23 | } 24 | }); 25 | 26 | /** 27 | * Turn on/off a media device 28 | * @param {'Audio' | 'Video'} deviceType - Type of the device eg: Video, Audio 29 | */ 30 | const toggleMediaDevice = (deviceType) => { 31 | if (deviceType === 'Video') { 32 | setVideo(!video); 33 | } 34 | if (deviceType === 'Audio') { 35 | setAudio(!audio); 36 | } 37 | mediaDevice.toggle(deviceType); 38 | }; 39 | 40 | return ( 41 |
42 |
64 | ); 65 | } 66 | 67 | CallWindow.propTypes = { 68 | status: PropTypes.string.isRequired, 69 | localSrc: PropTypes.object, // eslint-disable-line 70 | peerSrc: PropTypes.object, // eslint-disable-line 71 | config: PropTypes.shape({ 72 | audio: PropTypes.bool.isRequired, 73 | video: PropTypes.bool.isRequired 74 | }).isRequired, 75 | mediaDevice: PropTypes.object, // eslint-disable-line 76 | endCall: PropTypes.func.isRequired 77 | }; 78 | 79 | export default CallWindow; 80 | -------------------------------------------------------------------------------- /client/src/js/components/MainWindow.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { faPhone, faVideo } from '@fortawesome/free-solid-svg-icons'; 4 | import ActionButton from './ActionButton'; 5 | import { socket } from '../communication'; 6 | 7 | function useClientID() { 8 | const [clientID, setClientID] = useState(''); 9 | 10 | useEffect(() => { 11 | socket 12 | .on('init', ({ id }) => { 13 | document.title = `${id} - VideoCall`; 14 | setClientID(id); 15 | }); 16 | }, []); 17 | 18 | return clientID; 19 | } 20 | 21 | function MainWindow({ startCall }) { 22 | const clientID = useClientID(); 23 | const [friendID, setFriendID] = useState(null); 24 | 25 | /** 26 | * Start the call with or without video 27 | * @param {Boolean} video 28 | */ 29 | const callWithVideo = (video) => { 30 | const config = { audio: true, video }; 31 | return () => friendID && startCall(true, friendID, config); 32 | }; 33 | 34 | return ( 35 |
36 |
37 |

38 | Hi, your ID is 39 | 45 |

46 |

Get started by calling a friend below

47 |
48 |
49 | setFriendID(event.target.value)} 55 | /> 56 |
57 | 58 | 59 |
60 |
61 |
62 | ); 63 | } 64 | 65 | MainWindow.propTypes = { 66 | startCall: PropTypes.func.isRequired 67 | }; 68 | 69 | export default MainWindow; 70 | -------------------------------------------------------------------------------- /client/webpack-dev.config.js: -------------------------------------------------------------------------------- 1 | const { HotModuleReplacementPlugin } = require('webpack'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const socketConfig = require('../config'); 4 | 5 | module.exports = { 6 | mode: 'development', 7 | context: __dirname, 8 | entry: { 9 | app: './src/index.js' 10 | }, 11 | output: { 12 | filename: 'js/[name].js' 13 | }, 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.js$/, 18 | exclude: /(node_modules|bower_components)/, 19 | use: { 20 | loader: 'babel-loader', 21 | options: { 22 | presets: ['@babel/preset-react', '@babel/preset-env'] 23 | } 24 | } 25 | }, 26 | { 27 | test: require.resolve('webrtc-adapter'), 28 | use: 'expose-loader' 29 | }, 30 | { 31 | test: /\.scss$/, 32 | use: ['style-loader', 'css-loader', 'sass-loader'] 33 | }, 34 | { 35 | test: /\.(png|woff|woff2|eot|ttf|svg)$/, 36 | use: [ 37 | { 38 | loader: 'file-loader', 39 | options: { 40 | name: '[name].[ext]', 41 | outputPath: 'assets' 42 | } 43 | } 44 | ] 45 | } 46 | ] 47 | }, 48 | plugins: [ 49 | new HotModuleReplacementPlugin(), 50 | new HtmlWebpackPlugin({ 51 | title: 'React VideoCall - Minh Son Nguyen', 52 | filename: 'index.html', 53 | template: 'src/html/index.html' 54 | }) 55 | ], 56 | devServer: { 57 | compress: true, 58 | port: 9000, 59 | proxy: [ 60 | { 61 | context: '/bridge', 62 | target: `http://localhost:${socketConfig.PORT}` 63 | } 64 | ] 65 | }, 66 | watchOptions: { 67 | aggregateTimeout: 300, 68 | poll: 1000 69 | } 70 | }; 71 | -------------------------------------------------------------------------------- /client/webpack-prod.config.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 3 | const TerserPlugin = require('terser-webpack-plugin'); 4 | 5 | module.exports = { 6 | mode: 'production', 7 | context: __dirname, 8 | entry: { 9 | app: './src/index.js' 10 | }, 11 | output: { 12 | filename: 'js/[name].min.js' 13 | }, 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.js$/, 18 | exclude: /(node_modules|bower_components)/, 19 | use: { 20 | loader: 'babel-loader', 21 | options: { 22 | presets: ['@babel/preset-react', '@babel/preset-env'] 23 | } 24 | } 25 | }, 26 | { 27 | test: require.resolve('webrtc-adapter'), 28 | use: 'expose-loader' 29 | }, 30 | { 31 | test: /\.scss$/, 32 | use: [ 33 | MiniCssExtractPlugin.loader, 34 | 'css-loader', 35 | 'sass-loader' 36 | ] 37 | }, 38 | { 39 | test: /\.(png|woff|woff2|eot|ttf|svg)$/, 40 | use: [ 41 | { 42 | loader: 'file-loader', 43 | options: { 44 | name: '[name].[ext]', 45 | outputPath: 'assets', 46 | publicPath: '/assets' 47 | } 48 | } 49 | ] 50 | } 51 | ] 52 | }, 53 | plugins: [ 54 | new MiniCssExtractPlugin({ filename: 'css/[name].min.css' }), 55 | new HtmlWebpackPlugin({ 56 | title: 'React VideoCall - Minh Son Nguyen', 57 | filename: 'index.html', 58 | template: 'src/html/index.html' 59 | }) 60 | ], 61 | optimization: { 62 | minimizer: [ 63 | new TerserPlugin({ 64 | parallel: true, 65 | terserOptions: { ecma: 6 } 66 | }) 67 | ] 68 | } 69 | }; 70 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | PORT: process.env.PORT || 5000 3 | }; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-videocall", 3 | "version": "1.8.0", 4 | "description": "React VideoCall App", 5 | "scripts": { 6 | "build": "cd client && webpack --config webpack-prod.config.js", 7 | "start": "node server/index.js", 8 | "watch:client": "webpack serve --config client/webpack-dev.config.js", 9 | "watch:server": "nodemon server/index.js", 10 | "lint": "eslint --max-warnings 0 --ignore-path .gitignore ." 11 | }, 12 | "dependencies": { 13 | "@babel/core": "7.26.10", 14 | "@babel/preset-env": "7.26.9", 15 | "@babel/preset-react": "7.26.3", 16 | "@fortawesome/fontawesome-svg-core": "6.7.2", 17 | "@fortawesome/free-solid-svg-icons": "6.7.2", 18 | "@fortawesome/react-fontawesome": "0.2.2", 19 | "babel-loader": "10.0.0", 20 | "bootstrap": "5.3.5", 21 | "classnames": "2.5.1", 22 | "css-loader": "7.1.2", 23 | "expose-loader": "5.0.1", 24 | "express": "5.1.0", 25 | "file-loader": "6.2.0", 26 | "html-webpack-plugin": "5.6.3", 27 | "lodash": "4.17.21", 28 | "mini-css-extract-plugin": "2.9.2", 29 | "prop-types": "15.8.1", 30 | "react": "19.1.0", 31 | "react-dom": "19.1.0", 32 | "sass-embedded": "1.76.0", 33 | "sass-loader": "16.0.5", 34 | "socket.io": "4.8.1", 35 | "socket.io-client": "4.8.1", 36 | "style-loader": "4.0.0", 37 | "terser-webpack-plugin": "5.3.14", 38 | "webpack": "5.99.5", 39 | "webpack-cli": "6.0.1", 40 | "webrtc-adapter": "9.0.1" 41 | }, 42 | "devDependencies": { 43 | "eslint": "8.56.0", 44 | "eslint-config-airbnb": "19.0.4", 45 | "eslint-config-airbnb-base": "15.0.0", 46 | "eslint-plugin-import": "2.31.0", 47 | "eslint-plugin-jsx-a11y": "6.10.2", 48 | "eslint-plugin-react": "7.37.5", 49 | "nodemon": "3.1.9", 50 | "webpack-dev-server": "5.2.1" 51 | }, 52 | "engines": { 53 | "node": "22" 54 | }, 55 | "repository": { 56 | "type": "git", 57 | "url": "git+https://github.com/nguymin4/react-videocall.git" 58 | }, 59 | "author": "Minh Son Nguyen", 60 | "license": "MIT" 61 | } 62 | -------------------------------------------------------------------------------- /screenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nguymin4/react-videocall/50995bc00c8b95eae0a7edb2ec5b7287237e5faa/screenshots/1.png -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | const express = require('express'); 3 | const config = require('../config'); 4 | const socket = require('./lib/socket'); 5 | 6 | const app = express(); 7 | const server = http.createServer(app); 8 | 9 | app.use('/', express.static(`${__dirname}/../client/dist`)); 10 | 11 | server.listen(config.PORT, () => { 12 | socket(server); 13 | console.log('Server is listening at :', config.PORT); 14 | }); 15 | -------------------------------------------------------------------------------- /server/lib/haiku.js: -------------------------------------------------------------------------------- 1 | const adjs = [ 2 | 'autumn', 'hidden', 'bitter', 'misty', 'silent', 'empty', 'dry', 'dark', 3 | 'summer', 'icy', 'delicate', 'quiet', 'white', 'cool', 'spring', 'winter', 4 | 'patient', 'twilight', 'dawn', 'crimson', 'wispy', 'weathered', 'blue', 5 | 'billowing', 'broken', 'cold', 'damp', 'falling', 'frosty', 'green', 6 | 'long', 'late', 'lingering', 'bold', 'little', 'morning', 'muddy', 'old', 7 | 'red', 'rough', 'still', 'small', 'sparkling', 'throbbing', 'shy', 8 | 'wandering', 'withered', 'wild', 'black', 'young', 'holy', 'solitary', 9 | 'fragrant', 'aged', 'snowy', 'proud', 'floral', 'restless', 'divine', 10 | 'polished', 'ancient', 'purple', 'lively', 'nameless' 11 | ]; 12 | 13 | const nouns = [ 14 | 'waterfall', 'river', 'breeze', 'moon', 'rain', 'wind', 'sea', 'morning', 15 | 'snow', 'lake', 'sunset', 'pine', 'shadow', 'leaf', 'dawn', 'glitter', 16 | 'forest', 'hill', 'cloud', 'meadow', 'sun', 'glade', 'bird', 'brook', 17 | 'butterfly', 'bush', 'dew', 'dust', 'field', 'fire', 'flower', 'firefly', 18 | 'feather', 'grass', 'haze', 'mountain', 'night', 'pond', 'darkness', 19 | 'snowflake', 'silence', 'sound', 'sky', 'shape', 'surf', 'thunder', 20 | 'violet', 'water', 'wildflower', 'wave', 'water', 'resonance', 'sun', 21 | 'wood', 'dream', 'cherry', 'tree', 'fog', 'frost', 'voice', 'paper', 22 | 'frog', 'smoke', 'star' 23 | ]; 24 | 25 | module.exports = () => { 26 | const adj = adjs[Math.floor(Math.random() * adjs.length)]; 27 | const noun = nouns[Math.floor(Math.random() * nouns.length)]; 28 | const MIN = 1000; 29 | const MAX = 9999; 30 | const num = Math.floor(Math.random() * ((MAX + 1) - MIN)) + MIN; 31 | 32 | return `${adj}-${noun}-${num}`; 33 | }; 34 | -------------------------------------------------------------------------------- /server/lib/socket.js: -------------------------------------------------------------------------------- 1 | const io = require('socket.io'); 2 | const users = require('./users'); 3 | 4 | /** 5 | * Initialize when a connection is made 6 | * @param {SocketIO.Socket} socket 7 | */ 8 | function initSocket(socket) { 9 | let id; 10 | socket 11 | .on('init', async () => { 12 | id = await users.create(socket); 13 | if (id) { 14 | socket.emit('init', { id }); 15 | } else { 16 | socket.emit('error', { message: 'Failed to generating user id' }); 17 | } 18 | }) 19 | .on('request', (data) => { 20 | const receiver = users.get(data.to); 21 | if (receiver) { 22 | receiver.emit('request', { from: id }); 23 | } 24 | }) 25 | .on('call', (data) => { 26 | const receiver = users.get(data.to); 27 | if (receiver) { 28 | receiver.emit('call', { ...data, from: id }); 29 | } else { 30 | socket.emit('failed'); 31 | } 32 | }) 33 | .on('end', (data) => { 34 | const receiver = users.get(data.to); 35 | if (receiver) { 36 | receiver.emit('end'); 37 | } 38 | }) 39 | .on('disconnect', () => { 40 | users.remove(id); 41 | console.log(id, 'disconnected'); 42 | }); 43 | } 44 | 45 | module.exports = (server) => { 46 | io({ path: '/bridge', serveClient: false }) 47 | .listen(server, { log: true }) 48 | .on('connection', initSocket); 49 | }; 50 | -------------------------------------------------------------------------------- /server/lib/users.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-await-in-loop */ 2 | const { setTimeout } = require('node:timers/promises'); 3 | const haiku = require('./haiku'); 4 | 5 | const MAX_TRIES = 10; 6 | 7 | const users = {}; 8 | 9 | // Random ID until the ID is not in used or max tries is reached 10 | async function randomID(counter = 0) { 11 | if (counter > MAX_TRIES) { 12 | return null; 13 | } 14 | await setTimeout(10); 15 | const id = haiku(); 16 | return id in users ? randomID(counter + 1) : id; 17 | } 18 | 19 | exports.create = async (socket) => { 20 | const id = await randomID(); 21 | if (id) { 22 | users[id] = socket; 23 | } 24 | return id; 25 | }; 26 | 27 | exports.get = (id) => users[id]; 28 | 29 | exports.remove = (id) => delete users[id]; 30 | --------------------------------------------------------------------------------