├── .env.example
├── .gitattributes
├── .github
└── workflows
│ └── node.js.yml
├── .gitignore
├── LICENSE
├── README.md
├── client
└── src
│ ├── index.html
│ ├── index.js
│ ├── lib
│ ├── chat.js
│ ├── localPreview.js
│ ├── reactions.js
│ ├── screen-sharer.js
│ ├── utils.js
│ ├── video-chat.js
│ ├── volume-meter.js
│ └── whiteboard.js
│ └── style.css
├── package-lock.json
├── package.json
├── server
├── config.js
├── controllers
│ └── tokensController.js
├── index.js
└── server.js
└── test
└── test.js
/.env.example:
--------------------------------------------------------------------------------
1 | TWILIO_ACCOUNT_SID=
2 | TWILIO_API_KEY=
3 | TWILIO_API_SECRET=
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
--------------------------------------------------------------------------------
/.github/workflows/node.js.yml:
--------------------------------------------------------------------------------
1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
3 |
4 | name: Node.js CI
5 |
6 | on:
7 | push:
8 | branches: [ "main" ]
9 | pull_request:
10 | branches: [ "main" ]
11 |
12 | jobs:
13 | build:
14 |
15 | runs-on: ubuntu-latest
16 |
17 | strategy:
18 | matrix:
19 | node-version: [14.x, 16.x, 18.x]
20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
21 |
22 | steps:
23 | - uses: actions/checkout@v3
24 | - name: Use Node.js ${{ matrix.node-version }}
25 | uses: actions/setup-node@v3
26 | with:
27 | node-version: ${{ matrix.node-version }}
28 | cache: 'npm'
29 | - run: npm ci
30 | - run: npm run build --if-present
31 | - run: npm test
32 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 |
9 | # Diagnostic reports (https://nodejs.org/api/report.html)
10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 |
18 | # Directory for instrumented libs generated by jscoverage/JSCover
19 | lib-cov
20 |
21 | # Coverage directory used by tools like istanbul
22 | coverage
23 | *.lcov
24 |
25 | # nyc test coverage
26 | .nyc_output
27 |
28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
29 | .grunt
30 |
31 | # Bower dependency directory (https://bower.io/)
32 | bower_components
33 |
34 | # node-waf configuration
35 | .lock-wscript
36 |
37 | # Compiled binary addons (https://nodejs.org/api/addons.html)
38 | build/Release
39 |
40 | # Dependency directories
41 | node_modules/
42 | jspm_packages/
43 |
44 | # Snowpack dependency directory (https://snowpack.dev/)
45 | web_modules/
46 |
47 | # TypeScript cache
48 | *.tsbuildinfo
49 |
50 | # Optional npm cache directory
51 | .npm
52 |
53 | # Optional eslint cache
54 | .eslintcache
55 |
56 | # Microbundle cache
57 | .rpt2_cache/
58 | .rts2_cache_cjs/
59 | .rts2_cache_es/
60 | .rts2_cache_umd/
61 |
62 | # Optional REPL history
63 | .node_repl_history
64 |
65 | # Output of 'npm pack'
66 | *.tgz
67 |
68 | # Yarn Integrity file
69 | .yarn-integrity
70 |
71 | # dotenv environment variables file
72 | .env
73 | .env.test
74 |
75 | # parcel-bundler cache (https://parceljs.org/)
76 | .cache
77 | .parcel-cache
78 |
79 | # Next.js build output
80 | .next
81 | out
82 |
83 | # Nuxt.js build / generate output
84 | .nuxt
85 | dist
86 |
87 | # Gatsby files
88 | .cache/
89 | # Comment in the public line in if your project uses Gatsby and not Next.js
90 | # https://nextjs.org/blog/next-9-1#public-directory-support
91 | # public
92 |
93 | # vuepress build output
94 | .vuepress/dist
95 |
96 | # Serverless directories
97 | .serverless/
98 |
99 | # FuseBox cache
100 | .fusebox/
101 |
102 | # DynamoDB Local files
103 | .dynamodb/
104 |
105 | # TernJS port file
106 | .tern-port
107 |
108 | # Stores VSCode versions used for testing VSCode extensions
109 | .vscode-test
110 |
111 | # yarn v2
112 | .yarn/cache
113 | .yarn/unplugged
114 | .yarn/build-state.yml
115 | .yarn/install-state.gz
116 | .pnp.*
117 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Hussain Omer
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 |
2 |
3 |
4 | [![Contributors][contributors-shield]][contributors-url]
5 | [![Issues][issues-shield]][issues-url]
6 | [![MIT License][license-shield]][license-url]
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
Virtual Classroom!
15 |
16 |
17 | A Virtual Classroom Designed To Optimize Learning Approaches!
18 |
19 |
20 | Explore the docs »
21 |
22 |
23 |
24 |
25 |
26 | Table of Contents
27 |
28 |
29 |
30 |
31 | About The Project
32 |
35 |
36 |
37 | Getting Started
38 |
42 |
43 | Usage
44 | Roadmap
45 | Contributing
46 | License
47 | Contact
48 | Acknowledgments
49 |
50 |
51 |
52 |
53 |
54 | **HeroHacks II Winner (Most Creative Use Of Twilio)**
55 | 
56 |
57 | ## Domain Name
58 |
59 | Our project's domain name is "studywith.tech"
60 |
61 | ## Inspiration
62 |
63 | The inspiration for our project came from the fact that due to the implementation of remote learning, teachers all over the world have to interact with their students virtually. Today, two major platforms namely Google Meet and Zoom dominate the virtual learning industry. However, both platforms have shortcomings. Google Meet for example lacks the functionality of a built in collaborative whiteboard or annotation while a teacher is presenting. Zoom on the other hand has these functionalities but lacks the ease of access of google meet via a web browser. Our app aims to strike a combination of the features of Zoom and ease of access of Google Meet.
64 |
65 | ## What it does
66 |
67 | Virtual-Classroom is a web conferencing app that allows teachers to connect with their students and share their learning materials via either screen-sharing or using a collaborative whiteboard. The use of a collaborative whiteboard makes it easier for teachers to supplement their learning material and for students to better explain their difficulties and interact with teachers.
68 |
69 | ## How We built it
70 |
71 | Virtual-Classroom is a React app built using Twilio's programmable video and conversations SDK. The collaborative whiteboard functionality is implemented using the Data Track API.
72 |
73 | ## Most Creative use of Twilio
74 |
75 | We use Twilio's Video SDK for JavaScript to enable users to stream data from their mic and camera via "tracks", these tracks can then be subscribed to by other users thus enabling sharing of video and audio. For low latency sharing of data between users required in the chat and reactions menu, we used Twilio's DataTrack API which is used to share reactions, chat messages as well as whiteboard pointer data. This allows for the users to react to other users, send them messages as well as work on a collaborative whiteboard.
76 |
77 | ### Features:
78 |
79 | * Interactive environment to learn (video calling and microphone option)
80 | * Whitebord (learning + collaborative tool)
81 | * Reactions (emojis)
82 | * Screenshare ability
83 | * Chatbox
84 | * Multiple Individuals can join at once
85 |
86 | ### Built With
87 |
88 | * React.js
89 | * HTML
90 | * CSS
91 | * JavaScript
92 | * Twilio
93 | * Firebase
94 |
95 | ### Demo
96 |
97 | https://github.com/hussaino03/Devspace/assets/67332652/22941387-6c66-45e6-821b-f216eb53b9bf
98 |
99 |
100 |
101 | # Getting Started
102 |
103 |
104 | ## Prerequisites
105 | * Install Desktop development with c++ workload on Visual studio
106 | * Git CLI
107 |
108 | ## Installation
109 | * `git clone https://github.com/hussaino03/Devspace`
110 | * In project directory run `npm install`
111 | * Run `npm start`
112 |
113 | ## Available Scripts
114 |
115 | In the project directory, you can run:
116 |
117 | `npm install`
118 |
119 | This will install all the required modules
120 |
121 | `npm start`
122 |
123 | This will start the server
124 |
125 | Runs the app in the development mode.
126 |
127 | Open [http://localhost:1234](http://localhost:1234) to view it in your browser.
128 |
129 |
130 | ## Roadmap
131 |
132 | - [✅] Integrate camera/mic option
133 | - [✅] Integrate a whiteboard
134 | - [✅] Integrate emojis
135 | - [✅] Integrate disable/enable option for both the video and microphone
136 |
137 |
138 | ## License
139 |
140 | Distributed under the MIT License. See `LICENSE.txt` for more information.
141 |
142 |
143 | ## Acknowledgments
144 |
145 | * [GitHub README.md template](https://github.com/othneildrew/Best-README-Template)
146 |
147 |
148 |
149 | [contributors-shield]: https://img.shields.io/github/contributors/hussaino03/Devspace?color=%23&style=for-the-badge
150 | [contributors-url]: https://github.com/hussaino03/Devspace/graphs/contributors
151 | [issues-shield]: https://img.shields.io/github/issues/hussaino03/Devspace?style=for-the-badge
152 | [issues-url]: https://github.com/hussaino03/Devspace/issues
153 | [license-shield]: https://img.shields.io/github/license/othneildrew/Best-README-Template.svg?style=for-the-badge
154 | [license-url]: https://github.com/hussaino03/Devspace/blob/main/LICENSE.txt
155 | [product-screenshot]: loginpage.png
156 |
--------------------------------------------------------------------------------
/client/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Virtual Classroom
7 |
8 |
9 |
10 |
11 |
12 |
13 |
18 |
19 |
20 | Preview camera and microphone
21 |
22 |
23 |
47 |
48 |
49 |
50 |
63 |
64 |
65 | Mute
66 | Disable video
67 | Toggle chat
68 | Start whiteboard
69 | Share screen
70 | Disconnect
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
--------------------------------------------------------------------------------
/client/src/index.js:
--------------------------------------------------------------------------------
1 | import {
2 | createLocalTracks,
3 | LocalDataTrack,
4 | LocalVideoTrack,
5 | LocalAudioTrack,
6 | } from "twilio-video";
7 | import { VideoChat } from "./lib/video-chat";
8 | import { hideElements, showElements } from "./lib/utils";
9 | import LocalPreview from "./lib/localPreview";
10 | import { Reactions } from "./lib/reactions";
11 | import { Chat } from "./lib/chat";
12 | import { Whiteboard } from "./lib/whiteboard";
13 | import { ScreenSharer } from "./lib/screen-sharer";
14 |
15 | let videoTrack,
16 | audioTrack,
17 | localPreview,
18 | videoChat,
19 | dataTrack,
20 | reactions,
21 | chat,
22 | whiteboard,
23 | screenSharer;
24 |
25 | const setupTrackListeners = (track, button, enableLabel, disableLabel) => {
26 | button.innerText = track.isEnabled ? disableLabel : enableLabel;
27 | track.on("enabled", () => {
28 | button.innerText = disableLabel;
29 | });
30 | track.on("disabled", () => {
31 | button.innerText = enableLabel;
32 | });
33 | };
34 |
35 | const removeTrackListeners = (track) => {
36 | track.removeAllListeners("enabled");
37 | track.removeAllListeners("disabled");
38 | };
39 |
40 | window.addEventListener("DOMContentLoaded", () => {
41 | const previewBtn = document.getElementById("media-preview");
42 | const startDiv = document.querySelector(".start");
43 | const videoChatDiv = document.getElementById("video-chat");
44 | const joinForm = document.getElementById("join-room");
45 | const disconnectBtn = document.getElementById("disconnect");
46 | const screenShareBtn = document.getElementById("screen-share");
47 | const muteBtn = document.getElementById("mute-self");
48 | const disableVideoBtn = document.getElementById("disable-video");
49 | const liveControls = document.getElementById("live-controls");
50 | const videoAndChat = document.getElementById("videos-and-chat");
51 | const chatToggleBtn = document.getElementById("toggle-chat");
52 | const whiteboardBtn = document.getElementById("whiteboard");
53 | const activity = document.getElementById("activity");
54 |
55 | const toggleChat = () => {
56 | if (chat) {
57 | chat.toggle();
58 | }
59 | };
60 |
61 | const toggleWhiteboard = () => {
62 | if (whiteboard && videoChat) {
63 | videoChat.sendMessage(
64 | JSON.stringify({
65 | action: "whiteboard",
66 | event: "stopped",
67 | })
68 | );
69 | stopWhiteboard();
70 | } else {
71 | videoChat.sendMessage(
72 | JSON.stringify({
73 | action: "whiteboard",
74 | event: "started",
75 | })
76 | );
77 | startWhiteboard();
78 | }
79 | };
80 |
81 | const receiveDrawEvent = (event) => {
82 | videoChat.sendMessage(
83 | JSON.stringify({
84 | action: "whiteboard",
85 | event: event.detail,
86 | })
87 | );
88 | };
89 |
90 | const startWhiteboard = () => {
91 | whiteboard = new Whiteboard(activity);
92 | whiteboard.addEventListener("draw", receiveDrawEvent);
93 | videoChat.whiteboard = true;
94 | videoChatDiv.classList.add("screen-share");
95 | whiteboardBtn.innerText = "Stop whiteboard";
96 | };
97 |
98 | const stopWhiteboard = () => {
99 | if (!whiteboard) {
100 | return;
101 | }
102 | whiteboard.removeEventListener("draw", receiveDrawEvent);
103 | whiteboard = whiteboard.destroy();
104 | videoChat.whiteboard = false;
105 | videoChatDiv.classList.remove("screen-share");
106 | whiteboardBtn.innerText = "Start whiteboard";
107 | };
108 |
109 | previewBtn.addEventListener("click", async () => {
110 | hideElements(startDiv);
111 | try {
112 | const tracks = await createLocalTracks({
113 | video: {
114 | name: "user-camera",
115 | facingMode: "user",
116 | },
117 | audio: {
118 | name: "user-audio",
119 | },
120 | });
121 | startDiv.remove();
122 | showElements(joinForm);
123 | videoTrack = tracks.find((track) => track.kind === "video");
124 | audioTrack = tracks.find((track) => track.kind === "audio");
125 |
126 | setupTrackListeners(audioTrack, muteBtn, "Unmute", "Mute");
127 | setupTrackListeners(
128 | videoTrack,
129 | disableVideoBtn,
130 | "Enable video",
131 | "Disable video"
132 | );
133 |
134 | localPreview = new LocalPreview(videoTrack, audioTrack);
135 | localPreview.addEventListener("new-video-track", (event) => {
136 | videoTrack = event.detail;
137 | setupTrackListeners(
138 | event.detail,
139 | disableVideoBtn,
140 | "Enable video",
141 | "Disable video"
142 | );
143 | });
144 | localPreview.addEventListener("new-audio-track", (event) => {
145 | audioTrack = event.detail;
146 | setupTrackListeners(event.detail, muteBtn, "Unmute", "Mute");
147 | });
148 | } catch (error) {
149 | showElements(startDiv);
150 | console.error(error);
151 | }
152 | });
153 |
154 | joinForm.addEventListener("submit", async (event) => {
155 | event.preventDefault();
156 | const inputs = joinForm.querySelectorAll("input");
157 | const data = {};
158 | inputs.forEach((input) => (data[input.getAttribute("name")] = input.value));
159 | const { token, roomName, identity } = await fetch(
160 | joinForm.getAttribute("action"),
161 | {
162 | method: joinForm.getAttribute("method"),
163 | body: JSON.stringify(data),
164 | headers: {
165 | "Content-Type": "application/json",
166 | },
167 | }
168 | ).then((res) => res.json());
169 | hideElements(joinForm);
170 | dataTrack = new LocalDataTrack();
171 | videoTrack = new LocalVideoTrack(videoTrack.mediaStreamTrack, {
172 | name: "user-camera",
173 | });
174 | audioTrack = new LocalAudioTrack(audioTrack.mediaStreamTrack, {
175 | name: "user-audio",
176 | });
177 | videoChat = new VideoChat(token, roomName, {
178 | videoTrack,
179 | audioTrack,
180 | dataTrack,
181 | });
182 | if (!("getDisplayMedia" in navigator.mediaDevices)) {
183 | screenShareBtn.remove();
184 | }
185 | showElements(videoChatDiv);
186 | localPreview.hide();
187 | screenSharer = new ScreenSharer(screenShareBtn, videoChat);
188 | videoChat.addEventListener("screen-share-started", () => {
189 | screenSharer.disable();
190 | });
191 | videoChat.addEventListener("screen-share-stopped", screenSharer.enable);
192 | reactions = new Reactions(liveControls);
193 | reactions.addEventListener("reaction", (event) => {
194 | videoChat.sendMessage(
195 | JSON.stringify({ action: "reaction", reaction: event.detail })
196 | );
197 | videoChat.showReaction(event.detail);
198 | });
199 | chat = new Chat(videoAndChat, chatToggleBtn, identity);
200 | chat.addEventListener("chat-message", (event) => {
201 | const message = event.detail;
202 | message.action = "chat-message";
203 | videoChat.sendMessage(JSON.stringify(message));
204 | });
205 | chat.addEventListener("chat-focused", unlistenForSpaceBar);
206 | chat.addEventListener("chat-blurred", listenForSpaceBar);
207 | videoChat.addEventListener("chat-message", (event) => {
208 | chat.receiveMessage(event.detail);
209 | });
210 | chatToggleBtn.addEventListener("click", toggleChat);
211 |
212 | whiteboardBtn.addEventListener("click", toggleWhiteboard);
213 |
214 | videoChat.addEventListener("whiteboard-started", () => {
215 | startWhiteboard();
216 | });
217 |
218 | videoChat.addEventListener("whiteboard-stopped", () => {
219 | stopWhiteboard();
220 | });
221 |
222 | videoChat.addEventListener("whiteboard-draw", (event) => {
223 | whiteboard.drawOnCanvas(event.detail);
224 | whiteboard.saveLine(event.detail);
225 | });
226 | });
227 |
228 | disconnectBtn.addEventListener("click", () => {
229 | if (!videoChat) {
230 | return;
231 | }
232 | [videoTrack, audioTrack, dataTrack].forEach(removeTrackListeners);
233 | screenSharer = screenSharer.destroy();
234 | videoChat.disconnect();
235 | reactions = reactions.destroy();
236 | chat = chat.destroy();
237 | chatToggleBtn.removeEventListener("click", toggleChat);
238 | whiteboardBtn.removeEventListener("click", toggleWhiteboard);
239 | stopWhiteboard();
240 | hideElements(videoChatDiv);
241 | localPreview.show();
242 | showElements(joinForm);
243 | videoChat = null;
244 | });
245 |
246 | const unMuteOnSpaceBarDown = (event) => {
247 | if (event.keyCode === 32) {
248 | audioTrack.enable();
249 | }
250 | };
251 |
252 | const muteOnSpaceBarUp = (event) => {
253 | if (event.keyCode === 32) {
254 | audioTrack.disable();
255 | }
256 | };
257 |
258 | const listenForSpaceBar = () => {
259 | document.addEventListener("keydown", unMuteOnSpaceBarDown);
260 | document.addEventListener("keyup", muteOnSpaceBarUp);
261 | };
262 |
263 | const unlistenForSpaceBar = () => {
264 | document.removeEventListener("keydown", unMuteOnSpaceBarDown);
265 | document.removeEventListener("keyup", muteOnSpaceBarUp);
266 | };
267 |
268 | muteBtn.addEventListener("click", () => {
269 | if (audioTrack.isEnabled) {
270 | audioTrack.disable();
271 | listenForSpaceBar();
272 | } else {
273 | audioTrack.enable();
274 | unlistenForSpaceBar();
275 | }
276 | });
277 |
278 | disableVideoBtn.addEventListener("click", () => {
279 | if (videoTrack.isEnabled) {
280 | videoTrack.disable();
281 | } else {
282 | videoTrack.enable();
283 | }
284 | });
285 | });
286 |
287 | window.addEventListener('load',function(){
288 | document.querySelector('body').classList.add("loaded")
289 | });
--------------------------------------------------------------------------------
/client/src/lib/chat.js:
--------------------------------------------------------------------------------
1 | import { showElements, hideElements } from "./utils";
2 | import { ThisMonthInstance } from "twilio/lib/rest/api/v2010/account/usage/record/thisMonth";
3 |
4 | export class Chat extends EventTarget {
5 | constructor(container, toggleBtn, identity) {
6 | super();
7 | this.container = container;
8 | this.toggleBtn = toggleBtn;
9 | this.identity = identity;
10 | this.visible = true;
11 | this.newMessagesCount = 0;
12 | this.wrapper = this.container.querySelector(".chat");
13 | this.messageList = this.container.querySelector(".messages");
14 | this.form = this.container.querySelector("form");
15 | this.input = this.form.querySelector("input");
16 | this.sendMessage = this.sendMessage.bind(this);
17 | this.form.addEventListener("submit", this.sendMessage);
18 | this.inputFocused = this.inputFocused.bind(this);
19 | this.inputBlurred = this.inputBlurred.bind(this);
20 | this.input.addEventListener("focus", this.inputFocused);
21 | this.input.addEventListener("blur", this.inputBlurred);
22 | }
23 |
24 | sendMessage(event) {
25 | event.preventDefault();
26 | const message = {
27 | body: this.input.value,
28 | identity: this.identity,
29 | };
30 | const messageEvent = new CustomEvent("chat-message", {
31 | detail: message,
32 | });
33 | this.receiveMessage(message);
34 | this.dispatchEvent(messageEvent);
35 | this.input.value = "";
36 | }
37 |
38 | receiveMessage(message) {
39 | const li = document.createElement("li");
40 | const author = document.createElement("p");
41 | author.classList.add("author");
42 | author.appendChild(document.createTextNode(message.identity));
43 | const body = document.createElement("p");
44 | body.classList.add("body");
45 | body.appendChild(document.createTextNode(message.body));
46 | li.appendChild(author);
47 | li.appendChild(body);
48 | this.messageList.appendChild(li);
49 | if (this.lastMessage && this.visible) {
50 | const listRect = this.messageList.getBoundingClientRect();
51 | const messageRect = this.lastMessage.getBoundingClientRect();
52 | if (messageRect.top < listRect.bottom) {
53 | li.scrollIntoView();
54 | }
55 | }
56 | if (!this.visible) {
57 | this.newMessagesCount += 1;
58 | if (!this.badge) {
59 | this.badge = document.createElement("span");
60 | this.toggleBtn.appendChild(this.badge);
61 | }
62 | this.badge.innerText = this.newMessagesCount;
63 | }
64 | this.lastMessage = li;
65 | }
66 |
67 | inputFocused() {
68 | this.dispatchEvent(new Event("chat-focused"));
69 | }
70 |
71 | inputBlurred() {
72 | this.dispatchEvent(new Event("chat-blurred"));
73 | }
74 |
75 | toggle() {
76 | if (this.visible) {
77 | hideElements(this.wrapper);
78 | this.visible = false;
79 | } else {
80 | showElements(this.wrapper);
81 | this.visible = true;
82 | if (this.badge) {
83 | this.badge = this.badge.remove();
84 | this.newMessagesCount = 0;
85 | }
86 | }
87 | }
88 |
89 | destroy() {
90 | this.messageList.innerHTML = "";
91 | this.input.value = "";
92 | this.form.removeEventListener("submit", this.sendMessage);
93 | this.input.removeEventListener("focus", this.inputFocused);
94 | this.input.removeEventListener("blur", this.inputBlurred);
95 | return null;
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/client/src/lib/localPreview.js:
--------------------------------------------------------------------------------
1 | import { pollAudio } from "./volume-meter";
2 | import {
3 | hideElements,
4 | showElements,
5 | buildDropDown,
6 | attachTrack,
7 | detachTrack,
8 | } from "./utils";
9 | import { createLocalVideoTrack, createLocalAudioTrack } from "twilio-video";
10 |
11 | class LocalPreview extends EventTarget {
12 | constructor(videoTrack, audioTrack) {
13 | super();
14 | this.visible = true;
15 | this.choosingDevice = false;
16 | this.localPreviewDiv = document.getElementById("local-preview");
17 | this.videoPreviewDiv = document.getElementById("video-preview");
18 | this.canvas = document.getElementById("audio-data");
19 | this.cameraSelector = document.getElementById("camera-selector");
20 | this.micSelector = document.getElementById("mic-selector");
21 | this.stopPolling = null;
22 | this.videoTrack = videoTrack;
23 | this.audioTrack = audioTrack;
24 | this.setupSelectors();
25 | this.show();
26 | }
27 |
28 | set videoTrack(track) {
29 | if (this.videoPreview) {
30 | this.videoPreview.pause();
31 | this.videoPreview.remove();
32 | }
33 | this._videoTrack = track;
34 | this.videoPreview = attachTrack(this.videoPreviewDiv, this.videoTrack);
35 | if (this.visible) {
36 | this.videoPreview.play();
37 | }
38 | }
39 | get videoTrack() {
40 | return this._videoTrack;
41 | }
42 |
43 | set audioTrack(track) {
44 | this._audioTrack = track;
45 | if (this.visible) {
46 | pollAudio(this.audioTrack, this.canvas).then(
47 | (callback) => (this.stopPolling = callback)
48 | );
49 | }
50 | }
51 | get audioTrack() {
52 | return this._audioTrack;
53 | }
54 |
55 | hide() {
56 | hideElements(this.localPreviewDiv);
57 | this.videoPreview.pause();
58 | if (this.stopPolling) {
59 | this.stopPolling();
60 | this.stopPolling = null;
61 | }
62 | this.visible = false;
63 | }
64 |
65 | async show() {
66 | this.stopPolling = await pollAudio(this.audioTrack, this.canvas);
67 | this.videoPreview.play();
68 | showElements(this.localPreviewDiv);
69 | this.visible = true;
70 | }
71 |
72 | async setupSelectors() {
73 | try {
74 | const devices = await navigator.mediaDevices.enumerateDevices();
75 | const deviceToOption = (device) => ({
76 | value: device.deviceId,
77 | label: device.label,
78 | });
79 | const videoDevices = devices
80 | .filter((device) => device.kind === "videoinput")
81 | .map(deviceToOption);
82 | const audioDevices = devices
83 | .filter((device) => device.kind === "audioinput")
84 | .map(deviceToOption);
85 | const videoSelect = buildDropDown(
86 | "Choose camera",
87 | videoDevices,
88 | this.videoTrack.mediaStreamTrack.label
89 | );
90 | videoSelect.addEventListener("change", (event) => {
91 | this.getVideoTrack(event.target.value);
92 | });
93 | const audioSelect = buildDropDown(
94 | "Choose microphone",
95 | audioDevices,
96 | this.audioTrack.mediaStreamTrack.label
97 | );
98 | audioSelect.addEventListener("change", (event) => {
99 | this.getAudioTrack(event.target.value);
100 | });
101 |
102 | this.cameraSelector.appendChild(videoSelect);
103 | this.micSelector.appendChild(audioSelect);
104 | } catch (e) {
105 | console.error(e);
106 | }
107 | }
108 |
109 | async getVideoTrack(deviceId) {
110 | if (this.choosingDevice) {
111 | return;
112 | }
113 | this.choosingDevice = true;
114 | try {
115 | this.videoTrack.stop();
116 | detachTrack(this.videoTrack);
117 | const newVideoTrack = await createLocalVideoTrack({
118 | deviceId: { exact: deviceId },
119 | });
120 | this.videoTrack = newVideoTrack;
121 | this.dispatchNewTrackEvent("new-video-track", this.videoTrack);
122 | } catch (error) {
123 | console.error(error);
124 | }
125 | this.choosingDevice = false;
126 | }
127 |
128 | async getAudioTrack(deviceId) {
129 | if (this.choosingDevice) {
130 | return;
131 | }
132 | this.choosingDevice = true;
133 | try {
134 | this.audioTrack.stop();
135 | const newAudioTrack = await createLocalAudioTrack({
136 | deviceId: { exact: deviceId },
137 | });
138 | this.audioTrack = newAudioTrack;
139 | this.dispatchNewTrackEvent("new-audio-track", this.audioTrack);
140 | } catch (error) {
141 | console.error(error);
142 | }
143 | this.choosingDevice = false;
144 | }
145 |
146 | dispatchNewTrackEvent(type, track) {
147 | const newTrackEvent = new CustomEvent(type, { detail: track });
148 | this.dispatchEvent(newTrackEvent);
149 | }
150 | }
151 |
152 | export default LocalPreview;
153 |
--------------------------------------------------------------------------------
/client/src/lib/reactions.js:
--------------------------------------------------------------------------------
1 | export const allowedReactions = ["👍", "👎", "✋"];
2 |
3 | const generateReactionsHTML = (reactions) => {
4 | const wrapper = document.createElement("div");
5 | const ul = document.createElement("ul");
6 | reactions.forEach((reaction) => {
7 | const li = document.createElement("li");
8 | const btn = document.createElement("button");
9 | btn.appendChild(document.createTextNode(reaction));
10 | li.appendChild(btn);
11 | ul.appendChild(li);
12 | });
13 | wrapper.appendChild(ul);
14 | return wrapper;
15 | };
16 |
17 | export class Reactions extends EventTarget {
18 | constructor(controls) {
19 | super();
20 | this.controls = controls;
21 | this.html = generateReactionsHTML(allowedReactions);
22 | this.controls.appendChild(this.html);
23 | this.sendReaction = this.sendReaction.bind(this);
24 | this.html.addEventListener("click", this.sendReaction);
25 | }
26 |
27 | sendReaction(event) {
28 | if (
29 | event.target.nodeName === "BUTTON" &&
30 | allowedReactions.includes(event.target.innerText)
31 | ) {
32 | this.dispatchEvent(
33 | new CustomEvent("reaction", {
34 | detail: event.target.innerText,
35 | })
36 | );
37 | }
38 | }
39 |
40 | destroy() {
41 | this.html.removeEventListener("click", this.sendReaction);
42 | this.html.remove();
43 | return null;
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/client/src/lib/screen-sharer.js:
--------------------------------------------------------------------------------
1 | import { detachTrack } from "./utils";
2 | import { enableButton, disableButton } from "./utils";
3 | import { LocalVideoTrack } from "twilio-video";
4 |
5 | export class ScreenSharer extends EventTarget {
6 | constructor(button, videoChat) {
7 | super();
8 | this.button = button;
9 | this.videoChat = videoChat;
10 | this.screenTrack = null;
11 | this.handleClick = this.handleClick.bind(this);
12 | this.enable = this.enable.bind(this);
13 | this.disable = this.disable.bind(this);
14 | this.button.addEventListener("click", this.handleClick);
15 | }
16 |
17 | handleClick() {
18 | if (this.screenTrack) {
19 | this.stopScreenShare();
20 | } else {
21 | this.startScreenShare();
22 | }
23 | }
24 |
25 | async startScreenShare() {
26 | const screenStream = await navigator.mediaDevices.getDisplayMedia();
27 | const track = screenStream.getTracks()[0];
28 | this.screenTrack = new LocalVideoTrack(track, {
29 | name: "user-screen",
30 | });
31 | this.videoChat.startScreenShare(this.screenTrack);
32 | track.addEventListener("ended", () => {
33 | this.stopScreenShare();
34 | });
35 | this.button.innerText = "Stop sharing";
36 | }
37 |
38 | stopScreenShare() {
39 | if (this.screenTrack) {
40 | detachTrack(this.screenTrack);
41 | this.videoChat.stopScreenShare(this.screenTrack);
42 | this.screenTrack.stop();
43 | this.screenTrack = null;
44 | this.button.innerText = "Share screen";
45 | }
46 | }
47 |
48 | disable() {
49 | disableButton(this.button);
50 | }
51 |
52 | enable() {
53 | enableButton(this.button);
54 | }
55 |
56 | destroy() {
57 | this.stopScreenShare();
58 | this.enable();
59 | this.button.removeEventListener("click", this.handleClick);
60 | return null;
61 | }
62 | }
--------------------------------------------------------------------------------
/client/src/lib/utils.js:
--------------------------------------------------------------------------------
1 | const hideElements = (...elements) =>
2 | elements.forEach((el) => el.setAttribute("hidden", "hidden"));
3 |
4 | const showElements = (...elements) =>
5 | elements.forEach((el) => el.removeAttribute("hidden"));
6 |
7 | const disableButton = (btn) => {
8 | btn.setAttribute("disabled", "disabled");
9 | };
10 | const enableButton = (btn) => {
11 | btn.removeAttribute("disabled");
12 | };
13 |
14 | const buildDropDown = (labelText, options, selected) => {
15 | const label = document.createElement("label");
16 | label.appendChild(document.createTextNode(labelText));
17 | const select = document.createElement("select");
18 | options.forEach((opt) => {
19 | const option = document.createElement("option");
20 | option.setAttribute("value", opt.value);
21 | if (opt.label === selected) {
22 | option.setAttribute("selected", "selected");
23 | }
24 | option.appendChild(document.createTextNode(opt.label));
25 | select.appendChild(option);
26 | });
27 | label.appendChild(select);
28 | return label;
29 | };
30 |
31 | const attachTrack = (div, track) => {
32 | const mediaElement = track.attach();
33 | div.appendChild(mediaElement);
34 | return mediaElement;
35 | };
36 |
37 | const detachTrack = (track) => {
38 | track.detach().forEach((mediaElement) => {
39 | mediaElement.remove();
40 | });
41 | };
42 |
43 | module.exports = {
44 | hideElements,
45 | showElements,
46 | buildDropDown,
47 | attachTrack,
48 | detachTrack,
49 | disableButton,
50 | enableButton,
51 | };
52 |
--------------------------------------------------------------------------------
/client/src/lib/video-chat.js:
--------------------------------------------------------------------------------
1 | import Video from "twilio-video";
2 | import { allowedReactions } from "./reactions";
3 | import { showElements, hideElements } from "./utils";
4 |
5 | export class VideoChat extends EventTarget {
6 | constructor(token, roomName, localTracks) {
7 | super();
8 | this.videoTrack = localTracks.videoTrack;
9 | this.audioTrack = localTracks.audioTrack;
10 | this.dataTrack = localTracks.dataTrack;
11 | this.dataTrackReady = {};
12 | this.dataTrackReady.promise = new Promise((resolve, reject) => {
13 | this.dataTrackReady.resolve = resolve;
14 | this.dataTrackReady.reject = reject;
15 | });
16 | this.container = document.getElementById("participants");
17 | this.chatDiv = document.getElementById("video-chat");
18 | this.activityDiv = document.getElementById("activity");
19 | this.dominantSpeaker = null;
20 | this.whiteboard = false;
21 | this.participantItems = new Map();
22 | this.participantConnected = this.participantConnected.bind(this);
23 | this.participantDisconnected = this.participantDisconnected.bind(this);
24 | this.trackPublished = this.trackPublished.bind(this);
25 | this.trackUnpublished = this.trackUnpublished.bind(this);
26 | this.trackSubscribed = this.trackSubscribed.bind(this);
27 | this.trackUnsubscribed = this.trackUnsubscribed.bind(this);
28 | this.roomDisconnected = this.roomDisconnected.bind(this);
29 | this.dominantSpeakerChanged = this.dominantSpeakerChanged.bind(this);
30 | this.localParticipantTrackPublished = this.localParticipantTrackPublished.bind(
31 | this
32 | );
33 | this.localParticipantTrackPublicationFailed = this.localParticipantTrackPublicationFailed.bind(
34 | this
35 | );
36 | this.messageReceived = this.messageReceived.bind(this);
37 | this.tidyUp = this.tidyUp.bind(this);
38 | this.init(token, roomName);
39 | }
40 |
41 | async init(token, roomName) {
42 | try {
43 | this.room = await Video.connect(token, {
44 | name: roomName,
45 | tracks: [this.videoTrack, this.audioTrack, this.dataTrack],
46 | dominantSpeaker: true,
47 | });
48 | this.participantConnected(this.room.localParticipant);
49 | this.room.on("participantConnected", this.participantConnected);
50 | this.room.on("participantDisconnected", this.participantDisconnected);
51 | this.room.participants.forEach(this.participantConnected);
52 | this.room.on("disconnected", this.roomDisconnected);
53 | this.room.on("dominantSpeakerChanged", this.dominantSpeakerChanged);
54 | this.room.localParticipant.on(
55 | "trackPublished",
56 | this.localParticipantTrackPublished
57 | );
58 | this.room.localParticipant.on(
59 | "trackPublicationFailed",
60 | this.localParticipantTrackPublicationFailed
61 | );
62 | this.room.on("trackMessage", this.messageReceived);
63 | window.addEventListener("beforeunload", this.tidyUp);
64 | window.addEventListener("pagehide", this.tidyUp);
65 | } catch (error) {
66 | console.error(error);
67 | }
68 | }
69 |
70 | dominantSpeakerChanged(participant) {
71 | if (this.dominantSpeaker) {
72 | this.participantItems
73 | .get(this.dominantSpeaker.sid)
74 | .classList.remove("dominant");
75 | }
76 | let participantItem;
77 | if (participant) {
78 | participantItem = this.participantItems.get(participant.sid);
79 | this.dominantSpeaker = participant;
80 | } else {
81 | participantItem = this.participantItems.get(
82 | this.room.localParticipant.sid
83 | );
84 | this.dominantSpeaker = this.room.localParticipant;
85 | }
86 | participantItem.classList.add("dominant");
87 | }
88 |
89 | trackPublished(participant) {
90 | return (trackPub) => {
91 | if (trackPub.track) {
92 | this.trackSubscribed(participant)(trackPub.track);
93 | }
94 | trackPub.on("subscribed", this.trackSubscribed(participant));
95 | trackPub.on("unsubscribed", this.trackUnsubscribed(participant));
96 | };
97 | }
98 |
99 | trackSubscribed(participant) {
100 | return (track) => {
101 | const item = this.participantItems.get(participant.sid);
102 | const wrapper = item.querySelector(".video-wrapper");
103 | const info = item.querySelector(".info");
104 | if (track.kind === "video") {
105 | const videoElement = track.attach();
106 | if (track.name === "user-screen") {
107 | this.chatDiv.classList.add("screen-share");
108 | this.activityDiv.appendChild(videoElement);
109 | showElements(this.activityDiv);
110 | if (participant !== this.room.localParticipant) {
111 | this.dispatchEvent(new Event("screen-share-started"));
112 | }
113 | } else {
114 | wrapper.appendChild(videoElement);
115 | }
116 | } else if (track.kind === "audio") {
117 | const audioElement = track.attach();
118 | audioElement.muted = true;
119 | wrapper.appendChild(audioElement);
120 | const mutedHTML = document.createElement("p");
121 | mutedHTML.appendChild(document.createTextNode("🔇"));
122 | if (!track.isEnabled) {
123 | info.appendChild(mutedHTML);
124 | }
125 | track.on("enabled", () => {
126 | mutedHTML.remove();
127 | });
128 | track.on("disabled", () => {
129 | info.appendChild(mutedHTML);
130 | });
131 | } else if (track.kind === "data") {
132 | if (this.whiteboard) {
133 | this.sendMessage(
134 | JSON.stringify({
135 | action: "whiteboard",
136 | event: "started",
137 | })
138 | );
139 | }
140 | }
141 | };
142 | }
143 |
144 | trackUnsubscribed(participant) {
145 | return (track) => {
146 | if (track.kind !== "data") {
147 | const mediaElements = track.detach();
148 | mediaElements.forEach((mediaElement) => mediaElement.remove());
149 | }
150 | if (track.name === "user-screen") {
151 | hideElements(this.activityDiv);
152 | this.chatDiv.classList.remove("screen-share");
153 | if (participant !== this.room.localParticipant) {
154 | this.dispatchEvent(new Event("screen-share-stopped"));
155 | }
156 | }
157 | if (track.kind === "audio") {
158 | track.removeAllListeners("enabled");
159 | track.removeAllListeners("disabled");
160 | }
161 | };
162 | }
163 |
164 | trackUnpublished(trackPub) {
165 | if (trackPub.track) {
166 | this.trackUnsubscribed()(trackPub.track);
167 | }
168 | }
169 |
170 | participantConnected(participant) {
171 | const participantItem = document.createElement("li");
172 | participantItem.setAttribute("id", participant.sid);
173 | const wrapper = document.createElement("div");
174 | wrapper.classList.add("video-wrapper");
175 | const info = document.createElement("div");
176 | info.classList.add("info");
177 | wrapper.appendChild(info);
178 | const reaction = document.createElement("div");
179 | reaction.classList.add("reaction");
180 | wrapper.appendChild(reaction);
181 | participantItem.appendChild(wrapper);
182 | if (participant !== this.room.localParticipant) {
183 | const actions = document.createElement("div");
184 | actions.classList.add("actions");
185 | wrapper.appendChild(actions);
186 | const name = document.createElement("p");
187 | name.classList.add("name");
188 | name.appendChild(document.createTextNode(participant.identity));
189 | wrapper.appendChild(name);
190 | }
191 | this.container.appendChild(participantItem);
192 | this.setRowsAndColumns(this.room);
193 | this.participantItems.set(participant.sid, participantItem);
194 | participant.tracks.forEach(this.trackPublished(participant));
195 | participant.on("trackPublished", this.trackPublished(participant));
196 | participant.on("trackUnpublished", this.trackUnpublished);
197 | }
198 |
199 | participantDisconnected(participant) {
200 | const item = this.participantItems.get(participant.sid);
201 | item.remove();
202 | this.participantItems.delete(participant.sid);
203 | this.setRowsAndColumns(this.room);
204 | }
205 |
206 | roomDisconnected(room, error) {
207 | if (error) {
208 | console.error(error);
209 | }
210 | this.participantItems.forEach((item, sid) => {
211 | item.remove();
212 | this.participantItems.delete(sid);
213 | });
214 | this.room.removeAllListeners();
215 | this.room.localParticipant.off(
216 | "trackPublished",
217 | this.localParticipantTrackPublished
218 | );
219 | this.room.localParticipant.off(
220 | "trackPublicationFailed",
221 | this.localParticipantTrackPublicationFailed
222 | );
223 | this.audioTrack.removeAllListeners("enabled");
224 | this.audioTrack.removeAllListeners("disabled");
225 | this.room = null;
226 | }
227 |
228 | disconnect() {
229 | this.room.disconnect();
230 | window.removeEventListener("beforeunload", this.tidyUp);
231 | window.removeEventListener("pagehide", this.tidyUp);
232 | }
233 |
234 | tidyUp(event) {
235 | if (event.persisted) {
236 | return;
237 | }
238 | if (this.room) {
239 | this.disconnect();
240 | }
241 | }
242 |
243 | setRowsAndColumns(room) {
244 | const numberOfParticipants =
245 | Array.from(room.participants.keys()).length + 1;
246 | let rows, cols;
247 | if (numberOfParticipants === 1) {
248 | rows = 1;
249 | cols = 1;
250 | } else if (numberOfParticipants === 2) {
251 | rows = 1;
252 | cols = 2;
253 | } else if (numberOfParticipants < 5) {
254 | rows = 2;
255 | cols = 2;
256 | } else if (numberOfParticipants < 7) {
257 | rows = 2;
258 | cols = 3;
259 | } else {
260 | rows = 3;
261 | cols = 3;
262 | }
263 | this.container.style.setProperty("--grid-rows", rows);
264 | this.container.style.setProperty("--grid-columns", cols);
265 | }
266 |
267 |
268 | startScreenShare(screenTrack) {
269 | this.room.localParticipant.publishTrack(screenTrack);
270 | }
271 |
272 | stopScreenShare(screenTrack) {
273 | this.room.localParticipant.unpublishTrack(screenTrack);
274 | this.chatDiv.classList.remove("screen-share");
275 | hideElements(this.activityDiv);
276 | }
277 |
278 | localParticipantTrackPublished(trackPub) {
279 | if (trackPub.track === this.dataTrack) {
280 | this.dataTrackReady.resolve();
281 | }
282 | }
283 | localParticipantTrackPublicationFailed(error, trackPub) {
284 | if (trackPub.track === this.dataTrack) {
285 | this.dataTrackReady.reject(error);
286 | }
287 | }
288 |
289 | async sendMessage(message) {
290 | await this.dataTrackReady.promise;
291 | this.dataTrack.send(message);
292 | }
293 |
294 | messageReceived(data, track, participant) {
295 | const message = JSON.parse(data);
296 | if (message.action === "reaction") {
297 | this.showReaction(message.reaction, participant);
298 | } else if (message.action === "chat-message") {
299 | this.receiveChatMessage(message);
300 | } else if (message.action === "whiteboard") {
301 | this.receiveWhiteboardMessage(message);
302 | }
303 | }
304 |
305 | showReaction(reaction, participant) {
306 | if (!allowedReactions.includes(reaction)) {
307 | return;
308 | }
309 | let participantItem;
310 | if (participant) {
311 | participantItem = this.participantItems.get(participant.sid);
312 | } else {
313 | participantItem = this.participantItems.get(
314 | this.room.localParticipant.sid
315 | );
316 | }
317 | const reactionDiv = participantItem.querySelector(".reaction");
318 | reactionDiv.innerHTML = "";
319 | reactionDiv.appendChild(document.createTextNode(reaction));
320 | if (this.reactionTimeout) {
321 | clearTimeout(this.reactionTimeout);
322 | }
323 | this.reactionTimeout = setTimeout(() => (reactionDiv.innerHTML = ""), 5000);
324 | }
325 |
326 | receiveChatMessage(message) {
327 | const messageEvent = new CustomEvent("chat-message", {
328 | detail: message,
329 | });
330 | this.dispatchEvent(messageEvent);
331 | }
332 |
333 | receiveWhiteboardMessage(message) {
334 | if (message.event === "started") {
335 | const whiteboardStartedEvent = new CustomEvent("whiteboard-started", {
336 | detail: message.existingLines,
337 | });
338 | this.dispatchEvent(whiteboardStartedEvent);
339 | } else if (message.event === "stopped") {
340 | const whiteboardStoppedEvent = new Event("whiteboard-stopped");
341 | this.dispatchEvent(whiteboardStoppedEvent);
342 | } else {
343 | const whiteboardDrawEvent = new CustomEvent("whiteboard-draw", {
344 | detail: message.event,
345 | });
346 | this.dispatchEvent(whiteboardDrawEvent);
347 | }
348 | }
349 | }
350 |
--------------------------------------------------------------------------------
/client/src/lib/volume-meter.js:
--------------------------------------------------------------------------------
1 | const AudioContext = window.AudioContext || window.webkitAudioContext;
2 | const audioContext = AudioContext ? new AudioContext() : null;
3 |
4 | const createVolumeMeter = async (track) => {
5 | if (!audioContext) {
6 | return;
7 | }
8 |
9 | await audioContext.resume();
10 |
11 | // Create an analyser to access the raw audio samples from the microphone.
12 | const analyser = audioContext.createAnalyser();
13 | analyser.fftSize = 1024;
14 | analyser.smoothingTimeConstant = 0.5;
15 |
16 | // Connect the LocalAudioTrack's media source to the analyser.
17 | const stream = new MediaStream([track.mediaStreamTrack]);
18 | const source = audioContext.createMediaStreamSource(stream);
19 | source.connect(analyser);
20 |
21 | const sampleArray = new Uint8Array(analyser.frequencyBinCount);
22 |
23 | const shutdown = () => {
24 | source.disconnect(analyser);
25 | };
26 |
27 | const samples = () => {
28 | analyser.getByteFrequencyData(sampleArray);
29 | return sampleArray;
30 | };
31 |
32 | return { shutdown, analyser, samples };
33 | };
34 |
35 | const getVolume = async (track, callback) => {
36 | const { shutdown, analyser, samples } = await createVolumeMeter(track);
37 | requestAnimationFrame(function checkVolume() {
38 | callback(analyser.frequencyBinCount, samples());
39 | if (track.mediaStreamTrack.readyState === "live") {
40 | requestAnimationFrame(checkVolume);
41 | } else {
42 | requestAnimationFrame(() => {
43 | shutdown();
44 | callback(0);
45 | });
46 | }
47 | });
48 | return shutdown;
49 | };
50 |
51 | const mapRange = (value, x1, y1, x2, y2) =>
52 | ((value - x1) * (y2 - x2)) / (y1 - x1) + x2;
53 |
54 | const pollAudio = async (audioTrack, canvas) => {
55 | const context = canvas.getContext("2d");
56 | const width = canvas.width;
57 | const height = canvas.height;
58 | return await getVolume(audioTrack, (bufferLength, samples) => {
59 | context.fillStyle = "rgb(255, 255, 255)";
60 | context.fillRect(0, 0, width, height);
61 |
62 | var barWidth = (width / bufferLength) * 2.5;
63 | var barHeight;
64 | var x = 0;
65 |
66 | for (var i = 0; i < bufferLength; i++) {
67 | barHeight = mapRange(samples[i], 0, 255, 0, height * 2);
68 |
69 | context.fillStyle = "rgb(" + (barHeight + 100) + ",51,153)";
70 | context.fillRect(
71 | x,
72 | (height - barHeight / 2) / 2,
73 | barWidth,
74 | barHeight / 4
75 | );
76 | context.fillRect(x, height / 2, barWidth, barHeight / 4);
77 | x += barWidth + 1;
78 | }
79 | });
80 | };
81 |
82 | module.exports = { pollAudio };
83 |
--------------------------------------------------------------------------------
/client/src/lib/whiteboard.js:
--------------------------------------------------------------------------------
1 | import { showElements, hideElements } from "./utils";
2 |
3 | const isTouchSupported = "ontouchstart" in window;
4 | const isPointerSupported = navigator.pointerEnabled;
5 | const downEvent = isTouchSupported
6 | ? "touchstart"
7 | : isPointerSupported
8 | ? "pointerdown"
9 | : "mousedown";
10 | const moveEvent = isTouchSupported
11 | ? "touchmove"
12 | : isPointerSupported
13 | ? "pointermove"
14 | : "mousemove";
15 | const upEvent = isTouchSupported
16 | ? "touchend"
17 | : isPointerSupported
18 | ? "pointerup"
19 | : "mouseup";
20 |
21 | const colours = [
22 | { name: "white", value: "#ffffff", checked: true },
23 | { name: "black", value: "#000000", checked: true },
24 | { name: "red", value: "#f22f46", checked: false },
25 | { name: "purple", value: "#663399", checked: false },
26 | { name: "blue", value: "#87ceeb", checked: false },
27 | { name: "green", value: "#50c878", checked: false },
28 | { name: "orange", value: "#FF964F", checked: false },
29 | { name: "yellow", value: "#FFFF7E", checked: false },
30 | ];
31 |
32 | const brushes = [
33 | { name: "small", value: 10, checked: false },
34 | { name: "medium", value: 30, checked: true },
35 | { name: "large", value: 60, checked: false },
36 | { name: "x-large", value: 80, checked: false },
37 | { name: "big-chungus", value: 120, checked: false }
38 | ];
39 |
40 | const buildRadioButton = (name, options) => {
41 | const li = document.createElement("li");
42 | const radioBtn = document.createElement("input");
43 | radioBtn.setAttribute("type", "radio");
44 | radioBtn.setAttribute("value", options.value);
45 | radioBtn.setAttribute("name", name);
46 | radioBtn.setAttribute("id", options.name);
47 | if (options.checked) {
48 | radioBtn.setAttribute("checked", "checked");
49 | }
50 | const label = document.createElement("label");
51 | label.setAttribute("for", options.name);
52 | label.appendChild(document.createTextNode(options.name));
53 | li.appendChild(radioBtn);
54 | li.appendChild(label);
55 | return li;
56 | };
57 |
58 | const buildColourButtons = (colours) => {
59 | const colourList = document.createElement("ul");
60 | colourList.classList.add("colour-list");
61 | colours.forEach((colour) => {
62 | const li = buildRadioButton("colour", colour);
63 | const label = li.querySelector("label");
64 | label.style.setProperty("--background-color", colour.value);
65 | colourList.appendChild(li);
66 | });
67 | return colourList;
68 | };
69 |
70 | const buildBrushButtons = (brushes) => {
71 | const brushList = document.createElement("ul");
72 | brushList.classList.add("brush-list");
73 | brushes.forEach((brush) => {
74 | const li = buildRadioButton("brush", brush);
75 | brushList.appendChild(li);
76 | });
77 | return brushList;
78 | };
79 |
80 | export class Whiteboard extends EventTarget {
81 | constructor(container) {
82 | super();
83 | this.container = container;
84 | this.wrapper = document.createElement("div");
85 | this.wrapper.classList.add("whiteboard-wrapper");
86 | this.colourList = buildColourButtons(colours);
87 | this.wrapper.appendChild(this.colourList);
88 | this.brushList = buildBrushButtons(brushes);
89 | this.wrapper.appendChild(this.brushList);
90 | this.canvas = document.createElement("canvas");
91 | this.canvas.classList.add("whiteboard-canvas");
92 | this.canvas.width = 4000;
93 | this.canvas.height = 1000;
94 | this.context = this.canvas.getContext("2d");
95 | this.context.fillStyle = "#ffffff";
96 | this.context.fillRect(0, 0, this.canvas.width, this.canvas.height);
97 | this.lines = [];
98 | this.line = {
99 | plots: [],
100 | colour: this.currentColour(),
101 | brush: this.currentBrush(),
102 | };
103 | this.startDrawing = this.startDrawing.bind(this);
104 | this.endDrawing = this.endDrawing.bind(this);
105 | this.draw = this.draw.bind(this);
106 | this.canvas.addEventListener(downEvent, this.startDrawing, false);
107 | this.canvas.addEventListener(moveEvent, this.draw, false);
108 | this.canvas.addEventListener(upEvent, this.endDrawing, false);
109 | this.wrapper.appendChild(this.canvas);
110 | this.container.appendChild(this.wrapper);
111 | this.setRatios();
112 | this.drawOnCanvas = this.drawOnCanvas.bind(this);
113 | showElements(this.container);
114 | }
115 |
116 | currentColour() {
117 | return this.colourList.querySelector("input:checked").value;
118 | }
119 |
120 | currentBrush() {
121 | return this.brushList.querySelector("input:checked").value;
122 | }
123 |
124 | drawOnCanvas(line) {
125 | this.context.strokeStyle = line.colour;
126 | this.context.lineWidth = line.brush;
127 | this.context.lineCap = this.context.lineJoin = "round";
128 | this.context.beginPath();
129 | if (line.plots.length > 0) {
130 | this.context.moveTo(line.plots[0].x, line.plots[0].y);
131 | line.plots.forEach((plot) => {
132 | this.context.lineTo(plot.x, plot.y);
133 | });
134 | this.context.stroke();
135 | }
136 | }
137 |
138 | startDrawing(event) {
139 | event.preventDefault();
140 | this.setRatios();
141 | this.isDrawing = true;
142 | this.line.colour = this.currentColour();
143 | this.line.brush = this.currentBrush();
144 | }
145 |
146 | draw(event) {
147 | event.preventDefault();
148 | if (!this.isDrawing) {
149 | return;
150 | }
151 |
152 | let x = isTouchSupported
153 | ? event.targetTouches[0].pageX - this.canvas.offsetLeft
154 | : event.offsetX || event.layerX - this.canvas.offsetLeft;
155 | let y = isTouchSupported
156 | ? event.targetTouches[0].pageY - this.canvas.offsetTop
157 | : event.offsetY || event.layerY - this.canvas.offsetTop;
158 | x = x * this.widthScale;
159 | y = y * this.heightScale;
160 |
161 | this.line.plots.push({ x: x << 0, y: y << 0 });
162 |
163 | this.drawOnCanvas(this.line);
164 | }
165 |
166 | endDrawing(event) {
167 | event.preventDefault();
168 | this.isDrawing = false;
169 | const lineCopy = Object.assign({}, this.line);
170 | const drawingEvent = new CustomEvent("draw", {
171 | detail: lineCopy,
172 | });
173 | this.dispatchEvent(drawingEvent);
174 | this.saveLine(lineCopy);
175 | this.line.plots = [];
176 | }
177 |
178 | saveLine(line) {
179 | this.lines.push(line);
180 | }
181 |
182 | destroy() {
183 | this.canvas.removeEventListener(downEvent, this.startDrawing);
184 | this.canvas.removeEventListener(moveEvent, this.draw);
185 | this.canvas.removeEventListener(upEvent, this.endDrawing);
186 | this.canvas.remove();
187 | this.wrapper.remove();
188 | hideElements(this.container);
189 | this.lines = [];
190 | return null;
191 | }
192 |
193 | setRatios() {
194 | this.clientRect = this.canvas.getBoundingClientRect();
195 | this.widthScale = this.canvas.width / this.clientRect.width;
196 | this.heightScale = this.canvas.height / this.clientRect.height;
197 | }
198 | }
199 |
--------------------------------------------------------------------------------
/client/src/style.css:
--------------------------------------------------------------------------------
1 | @import "normalize.css";
2 |
3 | * {
4 | box-sizing: border-box;
5 | }
6 |
7 | html {
8 | height: 100%;
9 | }
10 | body {
11 | min-height: 100%;
12 | display: grid;
13 | grid-template-rows: auto 1fr auto;
14 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
15 | Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
16 | }
17 |
18 | header,
19 | footer {
20 | background-color: #F22F46;
21 | color: #fff;
22 | }
23 | header a,
24 | footer a {
25 | color: #fff;
26 | }
27 | header h1 {
28 | font-size: 2em;
29 | margin: 0.75em;
30 | line-height: 1;
31 | }
32 | footer p {
33 | font-size: 1em;
34 | margin: 1em;
35 | }
36 |
37 | header h1,
38 | footer p {
39 | text-align: center;
40 | }
41 |
42 | button {
43 | -moz-appearance: none;
44 | -webkit-appearance: none;
45 | border: none;
46 | background-color: #F22F46;
47 | color: #fff;
48 | padding: 0.5em;
49 | border-radius: 6px;
50 | position: relative;
51 | }
52 | button[disabled] {
53 | opacity: 0.5;
54 | }
55 | button span {
56 | display: block;
57 | position: absolute;
58 | top: -0.75em;
59 | right: -0.25em;
60 | background-color: red;
61 | color: #fff;
62 | border-radius: 100%;
63 | width: 1.5em;
64 | height: 1.5em;
65 | line-height: 1.5em;
66 | text-align: center;
67 | z-index: 10;
68 | }
69 |
70 | select {
71 | -moz-appearance: none;
72 | -webkit-appearance: none;
73 | appearance: none;
74 | border: none;
75 | border-radius: 6px;
76 | padding: 0.5em;
77 | background-color: rgba(0, 0, 0, 0.1);
78 | }
79 |
80 | .card {
81 | padding: 20px;
82 | margin-top: 1em;
83 | border: 1px solid rgba(0, 0, 0, 0.1);
84 | border-radius: 6px;
85 | box-shadow: 0 2.8px 2.2px rgba(0, 0, 0, 0.02),
86 | 0 6.7px 5.3px rgba(0, 0, 0, 0.028), 0 12.5px 10px rgba(0, 0, 0, 0.035),
87 | 0 22.3px 17.9px rgba(0, 0, 0, 0.042), 0 41.8px 33.4px rgba(0, 0, 0, 0.05),
88 | 0 100px 80px rgba(0, 0, 0, 0.07);
89 | }
90 |
91 | .start.card {
92 | text-align: center;
93 | }
94 |
95 | .start.card {
96 | width: 340px;
97 | margin-left: auto;
98 | margin-right: auto;
99 | }
100 |
101 | .local-preview.car .left-preview{
102 | width: 340px;
103 | margin-left: auto;
104 | margin-right: auto;
105 | position: absolute;
106 | top: 70px;
107 | left: 200px;
108 | padding: 20px;
109 | margin-top: 1em;
110 | border: 1px solid rgba(0, 0, 0, 0.1);
111 | border-radius: 6px;
112 | box-shadow: 0 2.8px 2.2px rgba(0, 0, 0, 0.02),
113 | 0 6.7px 5.3px rgba(0, 0, 0, 0.028), 0 12.5px 10px rgba(0, 0, 0, 0.035),
114 | 0 22.3px 17.9px rgba(0, 0, 0, 0.042), 0 41.8px 33.4px rgba(0, 0, 0, 0.05),
115 | 0 100px 80px rgba(0, 0, 0, 0.07);
116 | }
117 |
118 | .local-preview.car .right-preview{
119 | width: 340px;
120 | margin-left: auto;
121 | margin-right: auto;
122 | position: absolute;
123 | right: 300px;
124 | top: 200px;
125 | padding: 20px;
126 | margin-top: 1em;
127 | border: 1px solid rgba(0, 0, 0, 0.1);
128 | border-radius: 6px;
129 | box-shadow: 0 2.8px 2.2px rgba(0, 0, 0, 0.02),
130 | 0 6.7px 5.3px rgba(0, 0, 0, 0.028), 0 12.5px 10px rgba(0, 0, 0, 0.035),
131 | 0 22.3px 17.9px rgba(0, 0, 0, 0.042), 0 41.8px 33.4px rgba(0, 0, 0, 0.05),
132 | 0 100px 80px rgba(0, 0, 0, 0.07);
133 | }
134 |
135 | .local-preview .video-preview {
136 | position: relative;
137 | left: 0px;
138 | margin-bottom: 20px;
139 | height: 0;
140 | padding-top: 75%;
141 | }
142 | .local-preview video {
143 | width: 100%;
144 | max-height: 100%;
145 | position: absolute;
146 | top: 0;
147 | left: 0;
148 | }
149 | .local-preview canvas {
150 | margin-bottom: 20px;
151 | }
152 | .local-preview .media-select {
153 | margin-bottom: 1em;
154 | }
155 | .local-preview select {
156 | display: block;
157 | margin-top: 0.5em;
158 | width: 100%;
159 | }
160 | .local-preview .controls + form {
161 | margin-top: 1em;
162 | border-top: 1px solid rgba(0, 0, 0, 0.1);
163 | }
164 | .local-preview .form-input {
165 | margin-bottom: 1em;
166 | }
167 | .local-preview .form-input input {
168 | display: block;
169 | width: 100%;
170 | font-size: inherit;
171 | padding: 0.5em;
172 | margin-top: 0.5em;
173 | border-radius: 6px;
174 | border-width: 1px;
175 | }
176 |
177 | .video-chat {
178 | display: grid;
179 | grid-template-rows: 1fr auto;
180 | height: 100%;
181 | background-color: #060606;
182 | }
183 | .video-chat[hidden] {
184 | display: none;
185 | }
186 | .video-chat.screen-share {
187 | grid-template-rows: 1fr 100px auto;
188 | }
189 |
190 | .participants {
191 | --grid-columns: 1;
192 | --grid-rows: 1;
193 | display: grid;
194 | grid-template-columns: repeat(var(--grid-columns), 1fr);
195 | grid-template-rows: repeat(var(--grid-rows), 1fr);
196 | padding: 0;
197 | margin: 0;
198 | }
199 |
200 | .participants > li {
201 | list-style: none;
202 | display: flex;
203 | align-items: center;
204 | }
205 | .participants > li.dominant video {
206 | border: 4px solid #F22F46;
207 | }
208 | .participants .video-wrapper {
209 | position: relative;
210 | width: 100%;
211 | max-height: calc(100vh - 15rem);
212 | }
213 | .screen-share #activity {
214 | position: relative;
215 | width: 100%;
216 | max-height: calc(100vh - 18rem);
217 | display: flex;
218 | justify-content: center;
219 | align-items: center;
220 | }
221 | .screen-share #activity video {
222 | max-height: inherit;
223 | max-width: 100%;
224 | }
225 | .participants .actions {
226 | position: absolute;
227 | bottom: 8px;
228 | right: 8px;
229 | z-index: 10;
230 | }
231 | .participants .video-wrapper video {
232 | width: 100%;
233 | max-height: inherit;
234 | }
235 |
236 | .participants .video-wrapper .info {
237 | position: absolute;
238 | bottom: 8px;
239 | left: 8px;
240 | right: 8px;
241 | z-index: 10;
242 | text-align: center;
243 | color: #fff;
244 | display: flex;
245 | justify-content: center;
246 | }
247 | .participants .video-wrapper .info p {
248 | font-size: 2em;
249 | border: 4px solid #F22F46;
250 | border-radius: 50%;
251 | padding: 0.5em;
252 | margin: 0;
253 | background: #060606;
254 | }
255 | .participants .video-wrapper .name {
256 | position: absolute;
257 | top: 0;
258 | right: 0;
259 | background: rgba(0, 0, 0, 0.7);
260 | color: #fff;
261 | margin: 0;
262 | padding: 0.25em;
263 | }
264 |
265 | .screen-share .participants {
266 | display: flex;
267 | flex-direction: row;
268 | }
269 | .screen-share .participants .video-wrapper {
270 | max-height: 100px;
271 | }
272 | .video-chat.screen-share #screen {
273 | position: relative;
274 | width: 100%;
275 | max-height: calc(100vh - 15rem - 100px);
276 | display: flex;
277 | align-content: center;
278 | }
279 | .video-chat.screen-share #screen video {
280 | max-height: inherit;
281 | width: 100%;
282 | }
283 |
284 | .live-controls {
285 | background-color: #060606;
286 | padding: 0.5em;
287 | }
288 | .live-controls ul {
289 | list-style: none;
290 | margin: 0;
291 | padding: 0;
292 | display: flex;
293 | margin-top: 0.5em;
294 | }
295 | .live-controls button {
296 | margin-right: 2px;
297 | line-height: 1;
298 | }
299 | .live-controls #reactions button {
300 | border-radius: 50%;
301 | font-size: 1.5em;
302 | }
303 |
304 | /* reactions */
305 | .reaction {
306 | position: absolute;
307 | top: 8px;
308 | left: 8px;
309 | right: 8px;
310 | text-align: center;
311 | z-index: 10;
312 | font-size: 4em;
313 | }
314 |
315 | .reaction.size-1 {
316 | font-size: 2em;
317 | }
318 | .reaction.size-2 {
319 | font-size: 3em;
320 | }
321 | .reaction.size-3 {
322 | font-size: 4em;
323 | }
324 | .reaction.size-4 {
325 | font-size: 6em;
326 | }
327 | .reaction.size-5 {
328 | font-size: 8em;
329 | }
330 |
331 | .whiteboard-wrapper {
332 | width: 100%;
333 | height: 100%;
334 | display: flex;
335 | justify-content: center;
336 | align-items: center;
337 | position: relative;
338 | background-color: #fff;
339 | }
340 | .whiteboard-canvas {
341 | object-fit: contain;
342 | display: block;
343 | max-height: 65vh;
344 | max-width: 100%;
345 | }
346 |
347 | .colour-list,
348 | .brush-list {
349 | list-style-type: none;
350 | position: absolute;
351 | top: 0;
352 | left: 0;
353 | right: 0;
354 | display: flex;
355 | justify-content: center;
356 | margin: 0.5em 0 0;
357 | --background-color: #fff;
358 | }
359 |
360 | .brush-list {
361 | top: 42px;
362 | }
363 |
364 | .colour-list label{
365 | background-color: var(--background-color);
366 | font-size: 0;
367 | display: block;
368 | width: 32px;
369 | height: 32px;
370 | border-radius: 32px;
371 | margin-right: 8px;
372 | border: 3px solid transparent;
373 | border-color: rgb(110, 110, 110)
374 | }
375 |
376 | .brush-list label {
377 | background-color: var(--background-color);
378 | font-size: 0;
379 | display: block;
380 | width: 32px;
381 | height: 32px;
382 | border-radius: 32px;
383 | margin-right: 8px;
384 | border: 3px solid transparent;
385 | }
386 |
387 | .colour-list li:last-child label,
388 | .brush-list li:last-child label {
389 | margin-right: 0;
390 | }
391 | .colour-list input:checked + label,
392 | .brush-list input:checked + label {
393 | border-color: #F22F46;
394 | }
395 | .colour-list input,
396 | .brush-list input {
397 | display: none;
398 | }
399 |
400 | .brush-list label {
401 | position: relative;
402 | }
403 | .brush-list label::after {
404 | content: " ";
405 | display: block;
406 | background-color: #000;
407 | position: absolute;
408 | }
409 | .brush-list label[for="small"]::after {
410 | width: 3px;
411 | height: 3px;
412 | border-radius: 3px;
413 | top: 12px;
414 | left: 12px;
415 | }
416 | .brush-list label[for="medium"]::after {
417 | width: 6px;
418 | height: 6px;
419 | border-radius: 6px;
420 | top: 10px;
421 | left: 10px;
422 | }
423 | .brush-list label[for="large"]::after {
424 | width: 10px;
425 | height: 10px;
426 | border-radius: 10px;
427 | top: 8px;
428 | left: 8px;
429 | }
430 |
431 | .brush-list label[for="x-large"]::after {
432 | width: 14px;
433 | height: 14px;
434 | border-radius: 14px;
435 | top: 6px;
436 | left: 6px;
437 | }
438 |
439 | .brush-list label[for="big-chungus"]::after {
440 | width: 18px;
441 | height: 18px;
442 | border-radius: 18px;
443 | top: 4px;
444 | left: 4px;
445 | }
446 |
447 | .main-area {
448 | display: flex;
449 | }
450 | .main-area .participants {
451 | flex-grow: 1;
452 | }
453 | .main-area .chat {
454 | position: absolute;
455 | right: 0px;
456 | bottom: 0px;
457 | width: 25%;
458 | max-width: 300px;
459 | min-width: 200px;
460 | max-height: calc(100vh - 14rem);
461 | background: #060606;
462 | display: flex;
463 | flex-direction: column;
464 | padding: 1em;
465 | }
466 | .main-area .chat[hidden] {
467 | display: none;
468 | }
469 | .main-area .chat .messages {
470 | list-style-type: none;
471 | flex-grow: 1;
472 | overflow-y: scroll;
473 | color: #fff;
474 | padding: 0;
475 | margin: 0 0 1em;
476 | }
477 | .main-area .chat .messages .author {
478 | font-weight: bold;
479 | margin: 0 0 0.25em;
480 | }
481 | .main-area .chat .messages .body {
482 | margin: 0 0 0.5em;
483 | }
484 |
485 | .main-area form label {
486 | color: #fff;
487 | display: block;
488 | margin-bottom: 0.5em;
489 | }
490 | .main-area input,
491 | .main-area button {
492 | display: block;
493 | width: 100%;
494 | margin-bottom: 0.5em;
495 | }
496 |
497 | /* preloader */
498 |
499 | /* Loader Styles start here */
500 | .loader-wrapper {
501 | --line-width: 5px;
502 | --curtain-color: #fff;
503 | --outer-line-color: #F22F46;
504 | --middle-line-color: #F22F46;
505 | --inner-line-color: #F22F46;
506 | position:fixed;
507 | top:0;
508 | left:0;
509 | width:100%;
510 | height:100%;
511 | z-index:1000;
512 | }
513 |
514 | .loader {
515 | display:block;
516 | position: relative;
517 | top:50%;
518 | left:50%;
519 | /* transform: translate(-50%, -50%); */
520 | width:150px;
521 | height:150px;
522 | margin:-75px 0 0 -75px;
523 | border:var(--line-width) solid transparent;
524 | border-top-color: var(--outer-line-color);
525 | border-radius:100%;
526 | -webkit-animation: spin 2s linear infinite;
527 | animation: spin 2s linear infinite;
528 | z-index:1001;
529 | }
530 |
531 | .loader:before {
532 | content:"";
533 | position: absolute;
534 | top:4px;
535 | left:4px;
536 | right:4px;
537 | bottom:4px;
538 | border:var(--line-width) solid transparent;
539 | border-top-color: var(--inner-line-color);
540 | border-radius:100%;
541 | -webkit-animation: spin 3s linear infinite;
542 | animation: spin 3s linear infinite;
543 | }
544 |
545 | .loader:after {
546 | content:"";
547 | position: absolute;
548 | top:14px;
549 | left:14px;
550 | right:14px;
551 | bottom:14px;
552 | border:var(--line-width) solid transparent;
553 | border-top-color: var(--middle-line-color);
554 | border-radius:100%;
555 | -webkit-animation: spin 1.5s linear infinite;
556 | animation: spin 1.5s linear infinite;
557 | }
558 |
559 | @-webkit-keyframes spin {
560 | 0% {
561 | -webkit-transform: rotate(0deg);
562 | -ms-transform: rotate(0deg);
563 | transform: rotate(0deg);
564 | }
565 | 100% {
566 | -webkit-transform: rotate(360deg);
567 | -ms-transform: rotate(360deg);
568 | transform: rotate(360deg);
569 | }
570 | }
571 | @keyframes spin {
572 | 0% {
573 | -webkit-transform: rotate(0deg);
574 | -ms-transform: rotate(0deg);
575 | transform: rotate(0deg);
576 | }
577 | 100% {
578 | -webkit-transform: rotate(360deg);
579 | -ms-transform: rotate(360deg);
580 | transform: rotate(360deg);
581 | }
582 | }
583 |
584 | .loader-wrapper .loader-section {
585 | position:fixed;
586 | top:0;
587 | background:var(--curtain-color);
588 | width:51%;
589 | height:100%;
590 | z-index:1000;
591 | }
592 |
593 | .loader-wrapper .loader-section.section-left {
594 | left:0
595 | }
596 | .loader-wrapper .loader-section.section-right {
597 | right:0;
598 | }
599 |
600 | /* Loaded Styles */
601 | .loaded .loader-wrapper .loader-section.section-left {
602 | transform: translateX(-100%);
603 | transition: all 0.7s 0.3s cubic-bezier(0.645,0.045,0.355,1.000);
604 | }
605 | .loaded .loader-wrapper .loader-section.section-right {
606 | transform: translateX(100%);
607 | transition: all 0.7s 0.3s cubic-bezier(0.645,0.045,0.355,1.000);
608 | }
609 | .loaded .loader {
610 | opacity: 0;
611 | transition: all 0.3s ease-out;
612 | }
613 | .loaded .loader-wrapper {
614 | visibility: hidden;
615 | transform:translateY(-100%);
616 | transition: all .3s 1s ease-out;
617 | }
618 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "video-collaboration",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "start": "run-p \"server:start\" \"client:start\"",
8 | "server:start": "nodenv server/index.js --exec nodemon | pino-colada",
9 | "client:start": "parcel watch client/src/index.html --out-dir client/dist",
10 | "test": "mocha",
11 | "bump-version": "npm version patch"
12 | },
13 | "keywords": [],
14 | "author": "Phil Nash (https://philna.sh)",
15 | "license": "MIT",
16 | "dependencies": {
17 | "express": "^4.17.1",
18 | "express-pino-logger": "^5.0.0",
19 | "normalize.css": "^8.0.1",
20 | "parcel-bundler": "^1.12.4",
21 | "pino": "^6.3.2",
22 | "twilio": "^3.48.0",
23 | "twilio-video": "^2.6.0"
24 | },
25 | "browserslist": [
26 | "since 2017-06"
27 | ],
28 | "devDependencies": {
29 | "mocha": "^10.1.0",
30 | "node-env-run": "^3.0.2",
31 | "nodemon": "^2.0.4",
32 | "npm-run-all": "^4.1.5",
33 | "pino-colada": "^2.0.1"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/server/config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | port: process.env.PORT || 1234,
3 | twilio: {
4 | accountSid: process.env.TWILIO_ACCOUNT_SID,
5 | apiKey: process.env.TWILIO_API_KEY,
6 | apiSecret: process.env.TWILIO_API_SECRET,
7 | },
8 | };
9 |
--------------------------------------------------------------------------------
/server/controllers/tokensController.js:
--------------------------------------------------------------------------------
1 | const { Router, json } = require("express");
2 | const { jwt } = require("twilio");
3 | const AccessToken = jwt.AccessToken;
4 | const VideoGrant = AccessToken.VideoGrant;
5 | const { twilio } = require("../config");
6 |
7 | const router = Router();
8 |
9 | router.use(json());
10 |
11 | router.post("/", (req, res) => {
12 | const identity = req.body.identity;
13 | const roomName = req.body.roomName;
14 |
15 | const accessToken = new AccessToken(
16 | twilio.accountSid,
17 | twilio.apiKey,
18 | twilio.apiSecret,
19 | {
20 | identity,
21 | }
22 | );
23 | const videoGrant = new VideoGrant({
24 | room: roomName,
25 | });
26 |
27 | accessToken.addGrant(videoGrant);
28 | res.json({ token: accessToken.toJwt(), identity, roomName });
29 | });
30 |
31 | module.exports = router;
32 |
--------------------------------------------------------------------------------
/server/index.js:
--------------------------------------------------------------------------------
1 | const config = require("./config");
2 | const { app, logger } = require("./server");
3 |
4 | app.listen(config.port, () => {
5 | logger.info(`Listening on http://localhost:${config.port}`);
6 | });
7 |
--------------------------------------------------------------------------------
/server/server.js:
--------------------------------------------------------------------------------
1 | const { join } = require("path");
2 | const express = require("express");
3 | const logger = require("pino")();
4 | const expressLogger = require("express-pino-logger")({
5 | logger,
6 | });
7 | const tokensController = require("./controllers/tokensController");
8 | const app = express();
9 |
10 | app.use(expressLogger);
11 | app.use(express.static(join(__dirname, "..", "client", "dist")));
12 |
13 | app.use("/tokens", tokensController);
14 |
15 | module.exports = { app, logger };
16 |
--------------------------------------------------------------------------------
/test/test.js:
--------------------------------------------------------------------------------
1 | // sample mocha test
2 | var assert = require('assert');
3 | describe('Array', function () {
4 | describe('#indexOf()', function () {
5 | it('should return -1 when the value is not present', function () {
6 | assert.equal([1, 2, 3].indexOf(4), -1);
7 | });
8 | });
9 | });
--------------------------------------------------------------------------------