├── .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 |
37 |
38 |
39 | **Heroku**
40 |
41 |
42 |
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 |
43 |
44 |
45 |
toggleMediaDevice('Video')}
50 | />
51 | toggleMediaDevice('Audio')}
56 | />
57 | endCall(true)}
61 | />
62 |
63 |
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 |
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 |
--------------------------------------------------------------------------------