├── .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 | Logo 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 |
  1. 31 | About The Project 32 | 35 |
  2. 36 |
  3. 37 | Getting Started 38 | 42 |
  4. 43 |
  5. Usage
  6. 44 |
  7. Roadmap
  8. 45 |
  9. Contributing
  10. 46 |
  11. License
  12. 47 |
  13. Contact
  14. 48 |
  15. Acknowledgments
  16. 49 |
50 |
51 | 52 | 53 | 54 | **HeroHacks II Winner (Most Creative Use Of Twilio)** 55 | ![image](https://user-images.githubusercontent.com/67332652/163739019-b3284e49-dd45-4082-bd35-c1486a9a101f.png) 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 |

Virtual Classroom

11 | 12 |
13 |
14 |
15 |
16 |
17 |
18 | 19 |
20 | 21 |
22 | 23 | 47 | 48 | 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 | }); --------------------------------------------------------------------------------