├── src ├── App.css ├── react-app-env.d.ts ├── utils │ ├── index.ts │ └── logger.ts ├── components │ ├── TOS │ │ ├── index.ts │ │ ├── TOS.css │ │ └── TOS.tsx │ ├── HowTo │ │ ├── index.ts │ │ ├── HowTo.css │ │ └── HowTo.tsx │ ├── Join │ │ ├── index.tsx │ │ ├── Join.css │ │ └── Join.tsx │ ├── Room │ │ ├── index.ts │ │ ├── ActionButtons.css │ │ ├── Room.css │ │ ├── Video.tsx │ │ ├── ActionButtons.tsx │ │ └── Room.tsx │ ├── CustomizedAlert │ │ ├── index.ts │ │ └── CustomizedAlert.tsx │ └── index.ts ├── hooks │ ├── index.ts │ └── alertHook.ts ├── setupTests.ts ├── App.test.tsx ├── routes │ └── index.ts ├── libs │ └── index.ts ├── reportWebVitals.ts ├── index.tsx ├── index.css ├── App.tsx ├── shared │ └── index.ts ├── interfaces │ └── index.ts └── logo.svg ├── .firebaserc ├── public ├── favicon.ico ├── logo192.png ├── logo512.png ├── logo_1.jpg ├── logo_2.jpg ├── logo_3.jpg ├── logo_4.jpg ├── logo_5.jpg ├── robots.txt ├── howto │ ├── join_call.png │ ├── create_call.png │ ├── chromium_share.png │ └── call_screen_buttons.png ├── user_placeholder.jpg ├── manifest.json └── index.html ├── showcase ├── stream.png └── chromium_share.png ├── firebase.json ├── .gitignore ├── .vscode └── launch.json ├── tsconfig.json ├── package.json └── README.md /src/App.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "letsthango" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import logger from './logger'; 2 | 3 | export { 4 | logger 5 | }; -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tnguye20/letsthango/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tnguye20/letsthango/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tnguye20/letsthango/HEAD/public/logo512.png -------------------------------------------------------------------------------- /public/logo_1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tnguye20/letsthango/HEAD/public/logo_1.jpg -------------------------------------------------------------------------------- /public/logo_2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tnguye20/letsthango/HEAD/public/logo_2.jpg -------------------------------------------------------------------------------- /public/logo_3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tnguye20/letsthango/HEAD/public/logo_3.jpg -------------------------------------------------------------------------------- /public/logo_4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tnguye20/letsthango/HEAD/public/logo_4.jpg -------------------------------------------------------------------------------- /public/logo_5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tnguye20/letsthango/HEAD/public/logo_5.jpg -------------------------------------------------------------------------------- /showcase/stream.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tnguye20/letsthango/HEAD/showcase/stream.png -------------------------------------------------------------------------------- /src/components/TOS/index.ts: -------------------------------------------------------------------------------- 1 | import { TOS } from './TOS'; 2 | 3 | export { 4 | TOS 5 | }; -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | import useAlert from './alertHook'; 2 | 3 | export { 4 | useAlert 5 | }; -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/components/HowTo/index.ts: -------------------------------------------------------------------------------- 1 | import { HowTo } from './HowTo'; 2 | 3 | export { 4 | HowTo 5 | }; -------------------------------------------------------------------------------- /src/components/Join/index.tsx: -------------------------------------------------------------------------------- 1 | import { Join } from './Join'; 2 | 3 | export { 4 | Join 5 | }; -------------------------------------------------------------------------------- /src/components/Room/index.ts: -------------------------------------------------------------------------------- 1 | import { Room } from './Room'; 2 | 3 | export { 4 | Room 5 | }; -------------------------------------------------------------------------------- /public/howto/join_call.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tnguye20/letsthango/HEAD/public/howto/join_call.png -------------------------------------------------------------------------------- /public/howto/create_call.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tnguye20/letsthango/HEAD/public/howto/create_call.png -------------------------------------------------------------------------------- /public/user_placeholder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tnguye20/letsthango/HEAD/public/user_placeholder.jpg -------------------------------------------------------------------------------- /showcase/chromium_share.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tnguye20/letsthango/HEAD/showcase/chromium_share.png -------------------------------------------------------------------------------- /public/howto/chromium_share.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tnguye20/letsthango/HEAD/public/howto/chromium_share.png -------------------------------------------------------------------------------- /public/howto/call_screen_buttons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tnguye20/letsthango/HEAD/public/howto/call_screen_buttons.png -------------------------------------------------------------------------------- /src/components/CustomizedAlert/index.ts: -------------------------------------------------------------------------------- 1 | import { CustomizedAlert } from './CustomizedAlert'; 2 | 3 | export { 4 | CustomizedAlert 5 | } -------------------------------------------------------------------------------- /src/components/HowTo/HowTo.css: -------------------------------------------------------------------------------- 1 | #instructionContainer { 2 | text-align: justify; 3 | margin-bottom: 50px; 4 | } 5 | 6 | #instructionContainer li { 7 | padding-bottom: 10px; 8 | } -------------------------------------------------------------------------------- /src/components/TOS/TOS.css: -------------------------------------------------------------------------------- 1 | #TOS { 2 | text-align: justify; 3 | padding: 5% 10%; 4 | color: white; 5 | } 6 | 7 | #TOS a { 8 | font-size: inherit; 9 | color: palevioletred; 10 | } -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import { config } from '../shared'; 2 | 3 | const logger = (msg: any) => { 4 | if (config.DEV || config.TEST) { 5 | console.log(msg); 6 | } 7 | } 8 | 9 | export default logger; -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | import { Room } from './Room'; 2 | import { Join } from './Join'; 3 | import { HowTo } from './HowTo'; 4 | import { CustomizedAlert } from './CustomizedAlert'; 5 | import { TOS } from './TOS'; 6 | 7 | export { 8 | Join, 9 | Room, 10 | CustomizedAlert, 11 | HowTo, 12 | TOS 13 | }; -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "build", 4 | "ignore": [ 5 | "firebase.json", 6 | "**/.*", 7 | "**/node_modules/**" 8 | ], 9 | "rewrites": [ 10 | { 11 | "source": "**", 12 | "destination": "/index.html" 13 | } 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | render(); 7 | const linkElement = screen.getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | export const ROOT = '/'; 2 | export const JOIN = '/join'; 3 | export const ROOM = '/room'; 4 | export const HOW_TO = '/how'; 5 | export const TOS = '/tos'; 6 | export const PATREON = 'https://www.patreon.com/thangnguyen'; 7 | export const BUY_ME_TEA = 'https://www.buymeacoffee.com/thangnguyen'; 8 | export const GIT = 'https://github.com/tnguye20'; -------------------------------------------------------------------------------- /src/libs/index.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/app'; 2 | import 'firebase/firestore'; 3 | import 'firebase/analytics'; 4 | 5 | import { config } from '../shared'; 6 | 7 | if (!firebase.apps.length) { 8 | firebase.initializeApp(config.firebaseConfig); 9 | } 10 | 11 | const db = firebase.firestore(); 12 | const analytics = firebase.analytics(); 13 | 14 | export { 15 | db, 16 | analytics 17 | }; 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | tags 26 | .firebase 27 | -------------------------------------------------------------------------------- /src/components/Room/ActionButtons.css: -------------------------------------------------------------------------------- 1 | #shareContainer #actionContainer { 2 | margin: 0; 3 | position: absolute; 4 | bottom: 0; 5 | width: 100vw; 6 | height: 75px; 7 | display: none; 8 | } 9 | 10 | #shareContainer:hover #actionContainer { 11 | display: block; 12 | } 13 | 14 | #fullScreenIcon { 15 | float: right; 16 | cursor: pointer; 17 | } 18 | 19 | .fullScreen #actionContainer { 20 | width: 85vw !important; 21 | } 22 | -------------------------------------------------------------------------------- /src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "pwa-chrome", 9 | "request": "launch", 10 | "name": "Launch Chrome against localhost", 11 | "url": "https://localhost:3000", 12 | "webRoot": "${workspaceFolder}" 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById('root') 12 | ); 13 | 14 | // If you want to start measuring performance in your app, pass a function 15 | // to log results (for example: reportWebVitals(console.log)) 16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 17 | reportWebVitals(); 18 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Syne+Mono&display=swap'); 2 | 3 | body { 4 | /* font-family: 'Syne Mono', monospace; */ 5 | font-family: monospace; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | text-align: center; 9 | /* color: #2c3e50; */ 10 | /* margin: 80px 10px; */ 11 | margin: 0; 12 | padding: 0; 13 | /* overflow: hidden; */ 14 | background-color:rgba(0, 0, 0, 0.92); 15 | color: palevioletred; 16 | } 17 | 18 | a { 19 | text-decoration: none; 20 | color: inherit; 21 | font-size: small; 22 | } -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | BrowserRouter as Router, 4 | Route, 5 | Switch 6 | } from 'react-router-dom'; 7 | import './App.css'; 8 | import { 9 | Join, 10 | Room, 11 | HowTo, 12 | TOS 13 | } from './components'; 14 | import * as ROUTES from './routes'; 15 | 16 | function App() { 17 | return ( 18 | 19 | 20 | }/> 21 | }/> 22 | }/> 23 | }/> 24 | 25 | 26 | ); 27 | } 28 | 29 | export default App; -------------------------------------------------------------------------------- /src/hooks/alertHook.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ALERT_TYPE } from '../interfaces'; 3 | 4 | const useAlert = () => { 5 | const [openAlert, setOpenAlert] = React.useState(false); 6 | const [alertType, setAlertType] = React.useState(ALERT_TYPE.error); 7 | const [alertMessage, setAlertMessage] = React.useState('Unexpected Error. Please try again later!'); 8 | 9 | const fireAlert = (message: string, type: ALERT_TYPE) => { 10 | setAlertType(type); 11 | setAlertMessage(message); 12 | setOpenAlert(true); 13 | } 14 | 15 | return { 16 | openAlert, 17 | setOpenAlert, 18 | alertType, 19 | setAlertType, 20 | alertMessage, 21 | setAlertMessage, 22 | fireAlert 23 | }; 24 | } 25 | 26 | export default useAlert; -------------------------------------------------------------------------------- /src/shared/index.ts: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | export const config = { 4 | logoCount: 5, 5 | firebaseConfig: { 6 | apiKey: process.env.REACT_APP_apiKey, 7 | authDomain: process.env.REACT_APP_authDomain, 8 | projectId: process.env.REACT_APP_projectId, 9 | storageBucket: process.env.REACT_APP_storageBucket, 10 | messagingSenderId: process.env.REACT_APP_messagingSenderId, 11 | appId: process.env.REACT_APP_appId, 12 | measurementId: process.env.REACT_APP_measurementId 13 | }, 14 | servers: { 15 | iceServers: [ 16 | { 17 | urls: ['stun:stun1.l.google.com:19302', 'stun:stun2.l.google.com:19302'], 18 | }, 19 | ], 20 | iceCandidatePoolSize: 10, 21 | }, 22 | DEV: process.env.NODE_ENV === 'development', 23 | PROD: process.env.NODE_ENV === 'production', 24 | TEST: process.env.NODE_ENV === 'test' 25 | } 26 | -------------------------------------------------------------------------------- /src/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export interface Offer { 2 | offer: RTCSessionDescriptionInit 3 | }; 4 | export interface Answer { 5 | answer: RTCSessionDescriptionInit 6 | }; 7 | 8 | export interface CastDevices extends MediaDevices { 9 | getDisplayMedia: (constraints?: MediaStreamConstraints | undefined) => Promise 10 | } 11 | 12 | export enum ConnectType { 13 | user = 'user', 14 | share = 'share' 15 | }; 16 | 17 | export interface User { 18 | name: string, 19 | time: Date, 20 | type: keyof typeof ConnectType, 21 | status: string, 22 | shareID?: string, 23 | mute: boolean 24 | } 25 | export type Session = Record; 26 | 27 | export interface Peer { 28 | name: string, 29 | peerID: string, 30 | pc: RTCPeerConnection, 31 | remoteStream: MediaStream, 32 | listeners: Array<() => void> 33 | type: keyof typeof ConnectType 34 | } 35 | 36 | export enum ALERT_TYPE { 37 | success = 'success', 38 | info = 'info', 39 | warning = 'warning', 40 | error = 'error', 41 | } 42 | 43 | export enum CALL_TYPE { 44 | video = 'video', 45 | audio = 'audio' 46 | } -------------------------------------------------------------------------------- /src/components/Join/Join.css: -------------------------------------------------------------------------------- 1 | #joinContainer { 2 | display: flex; 3 | justify-content: center; 4 | flex-direction: row; 5 | } 6 | 7 | #joinContainer > div { 8 | display: flex; 9 | height: 100vh; 10 | } 11 | 12 | #logoContainer img { 13 | object-fit: cover; 14 | width: 75vw; 15 | } 16 | 17 | #inputContainer { 18 | width: 25vw; 19 | display: flex; 20 | flex-direction: column; 21 | justify-content: center; 22 | align-items: center; 23 | } 24 | 25 | #inputContainer > * { 26 | width: 80%; 27 | margin-bottom: 10px; 28 | } 29 | 30 | #inputContainer label { 31 | color: gray; 32 | } 33 | 34 | #brand { 35 | font-family: 'Dancing Script', cursive; 36 | font-size: 80px; 37 | margin-bottom: 30px; 38 | color: palevioletred; 39 | } 40 | 41 | #createCallBtn, #joinCallBtn { 42 | background-color: #AF5A76; 43 | font-weight: bold; 44 | } 45 | button:disabled { 46 | background-color: #E5C9D2 !important; 47 | } 48 | 49 | .MuiInput-underline:after { 50 | border-bottom: 2px solid #AF5A76 !important; 51 | } 52 | 53 | input, .MuiCheckbox-root { 54 | color: white !important; 55 | } -------------------------------------------------------------------------------- /src/components/CustomizedAlert/CustomizedAlert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Snackbar from '@material-ui/core/Snackbar'; 3 | import MuiAlert, { AlertProps } from '@material-ui/lab/Alert'; 4 | import { makeStyles, Theme } from '@material-ui/core/styles'; 5 | import { ALERT_TYPE } from '../../interfaces'; 6 | 7 | const Alert: React.FC = (props: AlertProps) => { 8 | return ; 9 | } 10 | 11 | const useStyles = makeStyles((theme: Theme) => ({ 12 | root: { 13 | width: '100%', 14 | '& > * + *': { 15 | marginTop: theme.spacing(2), 16 | }, 17 | }, 18 | })); 19 | 20 | export const CustomizedAlert: React.FC<{ 21 | duration?: number, 22 | openAlert: boolean, 23 | setOpenAlert: React.Dispatch>, 24 | alertMessage: string, 25 | alertType: keyof typeof ALERT_TYPE 26 | }> = ({ duration, openAlert, setOpenAlert, alertType, alertMessage }) => { 27 | const classes = useStyles(); 28 | 29 | const handleClose = (event?: React.SyntheticEvent, reason?: string) => { 30 | if (reason === 'clickaway') { 31 | return; 32 | } 33 | setOpenAlert(false); 34 | }; 35 | 36 | return ( 37 |
38 | 39 | 40 | { alertMessage } 41 | 42 | 43 |
44 | ); 45 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "letsthango", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@material-ui/core": "^4.11.3", 7 | "@material-ui/icons": "^4.11.2", 8 | "@material-ui/lab": "^4.0.0-alpha.57", 9 | "@testing-library/jest-dom": "^5.11.4", 10 | "@testing-library/react": "^11.1.0", 11 | "@testing-library/user-event": "^12.1.10", 12 | "@types/jest": "^26.0.15", 13 | "@types/node": "^12.0.0", 14 | "@types/react": "^17.0.0", 15 | "@types/react-dom": "^17.0.0", 16 | "dotenv": "^8.2.0", 17 | "firebase": "^8.3.0", 18 | "react": "^17.0.1", 19 | "react-dom": "^17.0.1", 20 | "react-router-dom": "^5.2.0", 21 | "react-scripts": "4.0.3", 22 | "typescript": "^4.1.2", 23 | "uuid": "^8.3.2", 24 | "web-vitals": "^1.0.1" 25 | }, 26 | "scripts": { 27 | "start": "HTTPS=true react-scripts start", 28 | "build": "react-scripts build", 29 | "deploy": "GENERATE_SOURCEMAP=false react-scripts build;firebase deploy --only hosting", 30 | "test": "react-scripts test", 31 | "eject": "react-scripts eject" 32 | }, 33 | "eslintConfig": { 34 | "extends": [ 35 | "react-app", 36 | "react-app/jest" 37 | ] 38 | }, 39 | "browserslist": { 40 | "production": [ 41 | ">0.2%", 42 | "not dead", 43 | "not op_mini all" 44 | ], 45 | "development": [ 46 | "last 1 chrome version", 47 | "last 1 firefox version", 48 | "last 1 safari version" 49 | ] 50 | }, 51 | "devDependencies": { 52 | "@types/react-router-dom": "^5.1.7", 53 | "@types/uuid": "^8.3.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | Let's Thango 37 | 38 | 39 | 40 |
41 | 42 | 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Let's Thango [My real name is Thang so it's a pun 😗] 2 | 3 | A web application that leverages pure WebRTC protocal, Google Firestore as a signaling server, and Chromium Tab sharing protocal to allow screen sharing with audio, which enable the ability for folks to catch up and enjoy music and videos together despite the distance. 4 | 5 | Application 6 | Support the Project 7 | 8 | # Motivation 9 | I'm currently in a long distance relationship myself and has been for the past three years. I understand the longing desire to watch and share experiences with your partner, and how most platforms out there are not dedicate to this need, while allowing video conversation simultaneously. I am setting out to solve the problem once and for all, hence the creation of Let's Thango. 10 | 11 | # Features 12 | - Sharing screen with audio using Chromium-based browsers. 13 | 14 | 15 | - Enjoy video at 60fps along with high quality audio. 16 | - Live Video Chat between parties. 17 | - Audio Only Chat betwen parties. 18 | 19 | 20 | # Instruction 21 | 1. The flow is relatively straighforward. One user can create the call by clicking *Create Call*, then proceed to share the Call ID by clicking on the Location pin, which will copy the ID to their clipboard. 22 | 2. The other users can join the call by entering the Call ID and hit *Join Call* 23 | 3. And that's it, share your tab and enjoy the show 24 | 25 | # Limitation 26 | This project is built using pure WebRTC with no forwarding server in the middle, so as users per room cross the threshold of 3 people, the stream's quality will start to decline. 27 | 28 | # Future goals 29 | Add a forwarding server using technologies such as `mediasoup` to host bigger rooms -------------------------------------------------------------------------------- /src/components/Room/Room.css: -------------------------------------------------------------------------------- 1 | #roomContainer { 2 | overflow: hidden; 3 | background-color: #0f1419; 4 | color: white; 5 | position: relative; 6 | display: flex; 7 | flex-direction: row; 8 | height: 100vh; 9 | } 10 | 11 | #shareVideo { 12 | margin: 0; 13 | background: black; 14 | height: 100vh; 15 | width: 85vw; 16 | } 17 | 18 | #videoContainer video { 19 | background: #2c3e50; 20 | height: 20vh; 21 | width: 100%; 22 | object-fit: cover; 23 | } 24 | 25 | .videos { 26 | display: flex; 27 | position: relative; 28 | background-color: ; 29 | } 30 | 31 | .videos .action { 32 | position: absolute; 33 | width: 100%; 34 | bottom: 9px; 35 | visibility: hidden; 36 | } 37 | .videos .name { 38 | position: absolute; 39 | width: fit-content; 40 | top: 0; 41 | left: 0; 42 | text-align: left; 43 | vertical-align: middle; 44 | line-height: 20px; 45 | padding: 5px; 46 | font-size: small; 47 | background-color: rgba(0, 0, 0, 0.2); 48 | display: flex; 49 | justify-content: space-evenly; 50 | flex-direction: row; 51 | } 52 | .videos .name > * { 53 | margin-right: 3px; 54 | } 55 | .videos:hover .action { 56 | visibility: visible; 57 | } 58 | 59 | #clipboard { 60 | opacity: 0; 61 | position: fixed; 62 | z-index: -1; 63 | } 64 | 65 | #shareContainer { 66 | margin: 0; 67 | position: relative; 68 | display: flex; 69 | height: 100vh; 70 | width: 85vw; 71 | } 72 | 73 | #videoContainer { 74 | display: flex; 75 | height: 80vh; 76 | width: 15vw; 77 | flex-direction: column; 78 | justify-content: flex-start; 79 | } 80 | 81 | .standalone #videoContainer video { 82 | width: 50vw; 83 | height: 100%; 84 | object-fit: cover; 85 | position: absolute; 86 | } 87 | 88 | .standalone #videoContainer { 89 | height: 100vh; 90 | width: 100vw; 91 | justify-content: center; 92 | align-items: center; 93 | flex-direction: row; 94 | position: relative; 95 | flex-wrap: wrap; 96 | } 97 | 98 | .standalone .videos { 99 | /* flex: 50%; */ 100 | width: 50vw; 101 | height: 50vh; 102 | position: relative; 103 | } 104 | 105 | #standalone_share { 106 | margin: 0; 107 | position: absolute; 108 | bottom: 0; 109 | width: 100vw; 110 | height: 75px; 111 | } -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Room/Video.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Slider from '@material-ui/core/Slider'; 3 | import { Peer } from '../../interfaces'; 4 | import Grid from '@material-ui/core/Grid'; 5 | import VolumeDown from '@material-ui/icons/VolumeDown'; 6 | import VolumeOffSharpIcon from '@material-ui/icons/VolumeOffSharp'; 7 | import makeStyles from '@material-ui/core/styles/makeStyles'; 8 | import Tooltip from '@material-ui/core/Tooltip'; 9 | 10 | import { db } from '../../libs'; 11 | import { 12 | User 13 | } from '../../interfaces'; 14 | 15 | const { useRef, useEffect, useState, memo } = React; 16 | 17 | const useStyles = makeStyles({ 18 | slider: { 19 | width: 100, 20 | }, 21 | }); 22 | 23 | interface VideoProp { 24 | peer: Peer, 25 | callID: string 26 | } 27 | 28 | export const Video = memo(({ 29 | peer, 30 | callID 31 | }: VideoProp) => { 32 | const classes = useStyles(); 33 | const videoRef = useRef(null) 34 | const videoID = `#video_${peer.peerID.split('-')[0]}`; 35 | const [value, setValue] = useState(process.env.NODE_ENV === 'development' ? 0 : 0.2); 36 | 37 | useEffect(() => { 38 | videoRef.current!.srcObject = peer.remoteStream; 39 | videoRef.current!.volume = process.env.NODE_ENV === 'development' ? 0 : 0.2; 40 | }, [peer.remoteStream]) 41 | 42 | const handleChange = (event: any, newValue: number | number[]) => { 43 | setValue(newValue as number); 44 | if (videoRef.current) { 45 | videoRef.current.volume = newValue as number; 46 | } 47 | }; 48 | 49 | const handlePIP = () => { 50 | if (videoRef.current) { 51 | } 52 | } 53 | 54 | return ( 55 |
56 | 57 | 58 | 59 |
60 |
61 | {peer.name} 62 |
63 | 64 |
65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 |
74 | ) 75 | }); 76 | 77 | const MuteIcon = memo(({ 78 | peer, 79 | callID 80 | }: VideoProp) => { 81 | const [mute, setMute] = useState(false); 82 | 83 | useEffect(() => { 84 | if (callID.length > 0) { 85 | const unsubscribe = db.collection('calls').doc(callID).collection('users').doc(peer.peerID).onSnapshot((snapshot) => { 86 | const data = snapshot.data() as User; 87 | setMute(data.mute); 88 | }); 89 | 90 | return () => unsubscribe(); 91 | } 92 | }, [peer.remoteStream, callID, peer.peerID]) 93 | 94 | return ( 95 |
96 | 97 | { 98 | mute ? : <> 99 | } 100 | 101 |
102 | ) 103 | }); -------------------------------------------------------------------------------- /src/components/HowTo/HowTo.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Container, 4 | Grid, 5 | Typography 6 | } from '@material-ui/core'; 7 | import './HowTo.css'; 8 | 9 | const imagePath = `${process.env.PUBLIC_URL}/howto/`; 10 | 11 | export const HowTo = () => { 12 | 13 | return ( 14 | <> 15 | 16 |
17 | 18 | How does Thango work? 19 | 20 | 21 |
22 |
23 | 24 | 25 | 26 | Create a Call 27 | 28 |
    29 |
  1. Enter the name you want to be displayed during the call
  2. 30 |
  3. Hit
  4. 31 |
  5. Allow Microphone and Camera access via your browser (they can be turn off during the call)
  6. 32 |
33 |
34 | 35 | 36 | Join a Call 37 | 38 |
    39 |
  1. Enter the name you want to be displayed during the call
  2. 40 |
  3. Enter the call ID you get from the other party
  4. 41 |
  5. Hit
  6. 42 |
  7. Allow Microphone and Camera access via your browser (they can be turn off during the call)
  8. 43 |
44 |
45 |
46 | 47 |
48 | 49 | Call Screen Navigation 50 | 51 | 52 | 53 | 54 | Description from left to right as followed: 55 | 56 |
    57 |
  1. End Call
  2. 58 |
  3. Copy Call ID to clipboard to share with other parties
  4. 59 |
  5. Share Screen/Tab with audio
  6. 60 |
  7. Volume Slider
  8. 61 |
62 |
63 | 64 | call_screen_buttons 65 | 66 |
67 | 68 |
69 | 70 | Share Tab with Audio instructions 71 | 72 | 73 | 74 |
    75 |
  1. Make sure you are using Google Chrome or a Chromium-based browser (I'm using Brave)
  2. 76 |
  3. Navigate to the last Tab
  4. 77 |
  5. Choose the Tab you want to share, and remember to check Share audio in the bottom
  6. 78 |
  7. Hit Share and enjoy the experience
  8. 79 |
80 |
81 | 82 | call_screen_buttons 83 | 84 |
85 |
86 |
87 | 88 | ) 89 | } -------------------------------------------------------------------------------- /src/components/Room/ActionButtons.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Fab from '@material-ui/core/Fab'; 3 | import ScreenShareSharpIcon from '@material-ui/icons/ScreenShareSharp'; 4 | import RoomSharpIcon from '@material-ui/icons/RoomSharp'; 5 | import './ActionButtons.css'; 6 | import Grid from '@material-ui/core/Grid'; 7 | import VolumeDown from '@material-ui/icons/VolumeDown'; 8 | import FullscreenSharpIcon from '@material-ui/icons/FullscreenSharp'; 9 | import FullscreenExitSharpIcon from '@material-ui/icons/FullscreenExitSharp'; 10 | import CallEndSharpIcon from '@material-ui/icons/CallEndSharp'; 11 | import Slider from '@material-ui/core/Slider'; 12 | import makeStyles from '@material-ui/core/styles/makeStyles'; 13 | import Tooltip from '@material-ui/core/Tooltip'; 14 | 15 | const { useState, useEffect, useCallback } = React; 16 | 17 | const useStyles = makeStyles({ 18 | root: { 19 | width: 200, 20 | }, 21 | }); 22 | 23 | interface ActionProps { 24 | shareVideoRef: React.RefObject, 25 | handleShareScreen: () => Promise, 26 | handleRoomID: () => void, 27 | handleEndCall: () => void, 28 | } 29 | 30 | export const ActionButtons = (props: ActionProps) => { 31 | const { 32 | shareVideoRef, 33 | handleShareScreen, 34 | handleRoomID, 35 | handleEndCall 36 | } = props; 37 | const classes = useStyles(); 38 | const [value, setValue] = useState(process.env.NODE_ENV === 'development' ? 0 : 0.2); 39 | const [fullScreen, setFullScreen] = useState(false); 40 | 41 | const fullScreenCallback = useCallback(() => { 42 | if (document.fullscreenElement) { 43 | setFullScreen(true); 44 | } 45 | else { 46 | setFullScreen(false); 47 | } 48 | }, []) 49 | 50 | useEffect(() => { 51 | document.addEventListener('fullscreenchange', fullScreenCallback); 52 | 53 | return () => document.removeEventListener('fullscreenchange', fullScreenCallback); 54 | }, [fullScreenCallback]); 55 | 56 | const handleFullScreen = () => { 57 | if (document.fullscreenElement) { 58 | document.exitFullscreen(); 59 | } 60 | else { 61 | document.documentElement.requestFullscreen(); 62 | } 63 | } 64 | 65 | const handleChange = (event: any, newValue: number | number[]) => { 66 | setValue(newValue as number); 67 | if (shareVideoRef.current) { 68 | shareVideoRef.current.volume = newValue as number; 69 | } 70 | }; 71 | 72 | return ( 73 |
74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | { 101 | fullScreen 102 | ? 103 | : 104 | } 105 | 106 | 107 | 108 |
109 | ) 110 | } -------------------------------------------------------------------------------- /src/components/Join/Join.tsx: -------------------------------------------------------------------------------- 1 | import TextField from '@material-ui/core/TextField'; 2 | import Button from '@material-ui/core/Button'; 3 | import { useState, useRef } from 'react'; 4 | import { useHistory } from 'react-router'; 5 | import { Link } from 'react-router-dom'; 6 | import './Join.css'; 7 | import { CustomizedAlert } from '../'; 8 | import { db, analytics } from '../../libs'; 9 | import { useAlert } from '../../hooks'; 10 | import * as ROUTES from '../../routes'; 11 | import { ALERT_TYPE, CALL_TYPE } from '../../interfaces'; 12 | import { config } from '../../shared'; 13 | import { v4 as uuidv4 } from 'uuid'; 14 | import Typography from '@material-ui/core/Typography'; 15 | 16 | const { logoCount } = config; 17 | 18 | const getLogoPath = (): string => { 19 | const index = Math.floor(Math.random() * (logoCount)) + 1; 20 | return `${process.env.PUBLIC_URL}/logo_${index}.jpg`; 21 | } 22 | 23 | export const Join = () => { 24 | const logo = useRef(getLogoPath()); 25 | const [name, setName] = useState(''); 26 | const [callID, setCallID] = useState(''); 27 | const { openAlert, setOpenAlert, alertMessage, alertType, fireAlert} = useAlert(); 28 | const history = useHistory(); 29 | 30 | const handleCreateCall = () => { 31 | if (name.length === 0) { 32 | fireAlert('Please Identify Yourself.', ALERT_TYPE.error); 33 | return; 34 | } 35 | 36 | // Analytics 37 | const _callID = db.collection('calls').doc().id; 38 | const _userID = uuidv4(); 39 | analytics.logEvent(`create_call`, { 40 | name, 41 | callID: _callID, 42 | _userID: _userID 43 | }); 44 | 45 | // Construct location object to redirect 46 | const location = { 47 | pathname: ROUTES.ROOM, 48 | state: { 49 | name, 50 | callID: _callID, 51 | callType: CALL_TYPE.video, 52 | userID: _userID, 53 | action: 'call' 54 | } 55 | } 56 | history.push(location); 57 | } 58 | const handleJoinCall = () => { 59 | const main = async () => { 60 | if (name.length === 0) { 61 | fireAlert('Please Identify Yourself.', ALERT_TYPE.error); 62 | return; 63 | } 64 | if (callID.length === 0) { 65 | fireAlert('Invalid Call ID. Please try again with a valid one.', ALERT_TYPE.error); 66 | return; 67 | } 68 | 69 | const callDoc = db.collection('calls').doc(callID); 70 | const testCall = await callDoc.get(); 71 | if (!testCall.exists) { 72 | fireAlert('Invalid Call ID. Please try again with a valid one.', ALERT_TYPE.error); 73 | return; 74 | } 75 | 76 | // Analytics 77 | const _userID = uuidv4(); 78 | analytics.logEvent(`join_call`, { 79 | name, 80 | callID, 81 | _userID: _userID 82 | }); 83 | 84 | // Construct location object to redirect 85 | const location = { 86 | pathname: ROUTES.ROOM, 87 | state: { 88 | name, 89 | callID, 90 | callType: CALL_TYPE.video, 91 | userID: _userID, 92 | action: 'answer' 93 | } 94 | } 95 | history.push(location); 96 | } 97 | main(); 98 | } 99 | 100 | return ( 101 | <> 102 |
103 |
104 | 105 | 106 | It takes two to Thango 107 | 108 |
109 | 110 | setName(e.target.value)}/> 111 | 112 | 113 | setCallID(e.target.value)}/> 114 | 115 | 116 |
117 | How it works? 118 | Terms of Service 119 |
120 | Buy me a cup of tea! 121 | Support the project on Patreon! 122 | Check me out on Github! 123 |
124 |
125 | logo 126 |
127 |
128 | 129 | 130 | 131 | ) 132 | } -------------------------------------------------------------------------------- /src/components/TOS/TOS.tsx: -------------------------------------------------------------------------------- 1 | import { Typography } from "@material-ui/core" 2 | import './TOS.css'; 3 | 4 | export const TOS = () => { 5 | return ( 6 | <> 7 |
8 | 9 | Let's Thango Terms of Service and User License 10 | 11 | 12 |

This Let's Thango Terms of Service and User License ("TOS" or this “Agreement”) governs any access to and use of the applications, services and websites (collectively “Company Services”) offered or made available by Let's Thango ("Company" or “we/us”) at https://letsthango.web.app, or through corresponding sites on social media outlets. This TOS is subject to modification from time to time as described below and You can review the most current version at any time at: https://letsthango.web.app/tos.

13 |

By accessing and/or using the Site or any Company Services, you (“You”) accept and agree to be bound by, and become a party to, the terms and provision of this TOS. If You do not agree to the terms and conditions of this Agreement or if You are not authorized to enter into or be bound by this Agreement, then do not access or use the Company Services or Site. This TOS is a legal agreement between You and Company and applies to You whether You are a user of the Site and/or Company Services, a visitor just browsing the Site, or any other individual or entity accessing or using the Company Services and/or Site (collectively, "Users"). All access to and use of the Company Services and the Site by You, including any content, information, products or services therein, is subject to the terms and conditions of this Agreement and conditioned upon You becoming a party hereto.

14 |
15 | 16 | 17 | I. USE OF COMPANY SERVICES AND SITE 18 | 19 | 20 | 21 | A. Company Services and Site. 22 | 23 |

24 | Provided You have agreed to comply with and are bound by this Agreement, You may use the Site and Company Services, subject to and in compliance with this Agreement and all applicable local, state, national and international laws, rules and regulations. Your right to access and use the Company Services and Site is non-exclusive, non-transferable, non-sublicenseable, and fully revocable. Use of any Company Services that are subject to special registration, restricted access or payment is further subject to the other terms and conditions specified by the Company as applicable to the use of such other Company Services. 25 |

26 | 27 | B. Proprietary Rights. 28 | 29 |

30 | Title to and ownership of the Company Services and the Site, including all intellectual property rights therein and thereto, are and shall remain the exclusive property of Company and its suppliers and licensors, and, subject to the limited rights and license expressly granted hereunder, Company and its licensors retain all right, title and interest in and to the Company Services and the Site and in and to all of Company’s other intellectual property rights. Without limiting the foregoing, the names, marks, brands, logos, designs, trade dress and other designations used in connection with the Company Services and Site are proprietary to Company and its licensors and no rights or licenses thereto are granted hereunder. No intellectual property or other rights or licenses are granted or otherwise provided by Company under this Agreement, by implication, estoppel or otherwise, beyond those expressly provided for herein. You acknowledge that any unauthorized copying or use of the Company Services or Site is a violation of this Agreement and copyright laws and is strictly prohibited. 31 |

32 | 33 | C. Restrictions and Limitations. 34 | 35 |

36 | You may only use the Company Services for personal, non-commercial use, unless expressly authorized in writing by Company. You shall have no right to, and shall not, reverse engineer, disassemble, decompile, copy, modify, spider, crawl, or create derivative works of or based on, sell, resell, display, distribute, disseminate, rent or lease the Company Services or Site or any part thereof, except to the extent applicable law otherwise requires You to be allowed to do so. You shall not remove, alter or conceal any copyright or trademark or other proprietary rights notices incorporated in or accompanying the Site or Company Services . You shall comply with all applicable laws, including US export controls, in your use of the Company Services and the Site and shall not use any of them for purposes for which they are not designed. You shall immediately notify Company of any violation or attempt to violate any of the restrictions or limitations on use or access to the Company Services of Site specified in this Agreement upon first becoming aware of such violation or attempted violation. 37 |

38 |
39 | 40 | II. USER CONTENT 41 | 42 | 43 | 44 | A. Non-Infringing Content Sharing. 45 | 46 |

47 | The Company Service offers Users the opportunity to share content with each other. Company encourages such sharing but prohibits copyright infringement or the infringement of other intellectual property rights through the Company Service. Company respects the copyright and other rights of content owners and requires its Users to do the same when using the Company Service. Accordingly, You understand that all information, communications, video, music, movies, data, text, software, sound, photographs, graphics, messages or other materials, in any event excluding all Company Materials (as defined below), submitted, shared, posted, uploaded, provided, displayed, transmitted, streamed, broadcast or otherwise made accessible (collectively “Shared”) on, to or through the Site and/or Company Services ("User Content"), are the sole responsibility of the person from which such User Content originated. You affirm, represent and warrant that You own or have the necessary licenses, rights, consents and permissions to Share any User Content You Share on, through or to the Company Service You further agree that User Content You Share on, through or to the Company Service will not contain third party copyrighted material, or material that is subject to other third party proprietary rights, unless You have the necessary permissions from the rightful owner of the material. In particular, and without limitation, before Sharing User Content (such as music or video) through the Company Services which originates from a third party website, application or service, You must first ensure that You have the right to do so in accordance with the terms and conditions for such website, application or service. 48 |

49 | 50 | B. Content Removal Policy. 51 | 52 |

53 | We will respond to notices of alleged copyright infringement that comply with applicable law and are properly provided to us. If a rights holder believes that User Content has been copied in a way that constitutes copyright infringement, such rights holder or its agent or designee should provide our copyright agent with the following information in accordance with the Digital Millennium Copyright Act: (i) a physical or electronic signature of the copyright owner or a person authorized to act on their behalf; (ii) identification of the copyrighted work claimed to have been infringed; (iii) identification of the material that is claimed to be infringing or to be the subject of infringing activity and that is to be removed or access to which is to be disabled, and information reasonably sufficient to permit us to locate the material; (iv) contact information, including address, telephone number, and an email address; (v) a statement by the rights holder or its agent or designee indicating a good faith belief that use of the material in the manner complained of is not authorized by the copyright owner, its agent, or the law; and (vi) a statement that the information in the notification is accurate, and, under penalty of perjury, that the author is authorized to act on behalf of the copyright owner. 54 |

55 |

56 | Notice of alleged copyright infringement or other legal notices regarding User Content appearing on the Site or Company Service shall be sent to tnguye20@uvm.edu. Company reserves the right to remove, block or otherwise stop the Sharing of User Content alleged to be infringing or otherwise illegal without prior notice and at our sole discretion. 57 |

58 | 59 | C. Inappropriate and Illegal Content. 60 | 61 |

62 | You agree not to Share any User Content on, through or to the Company Services that is contrary to applicable laws and regulations or that (a) is pornographic, sexually explicit, obscene or indecent, (b) depicts real-life abusive, violent or illegal activity (subject to reasonable exceptions for legitimate news and educational materials), (c) communicates hate speech, threats, harassment, intimidation or invades another’s privacy, or (d) violates the rights of others. 63 |

64 | 65 | D. Other User’s Content. 66 | 67 |

68 | You understand that by using the Company Services, You may be exposed to User Content that is offensive, indecent, inaccurate, objectionable or otherwise inappropriate. We may or may not (and are not required to) screen, monitor or control the User Content Shared on the Site or on, through or to the Company Services, including any communications, information or other User Content from other Users or other parties. Under no circumstances will Company be liable in any way for (and You release Company from, and waive any rights to bring or assert any claims for, any liabilities arising from) any User Content, including any errors or omissions in any User Content, or any loss or damage of any kind incurred as a result of the use of or reliance upon any User Content. You may not use User Content that is Shared on, through or to the Company Services or Site in a manner that exceeds the rights granted for your use of such User Content, which includes unauthorized copying, display, use or distribution of the User Content or creating an unauthorized derivative work. You may not circumvent any mechanisms for preventing the unauthorized reproduction or distribution of the User Content. 69 |

70 | 71 | E. Rights to User Content. 72 | 73 |

74 | You, or a third party licensor, as appropriate, retain any intellectual property rights in the User Content You Share on, through or to the Company Services. Subject to the restrictions described in the Privacy Policy below, by Sharing User Content on to, or through the Company Services, You grant Company, under all intellectual property rights in, to and under such User Content, an irrevocable, perpetual, worldwide, non-exclusive, royalty-free, fully paid-up, sublicensable and transferable license to distribute, reproduce, modify, sell, perform, transmit, publish and display and otherwise use such User Content in, to or with the Company Services and/or the Site and associated businesses of the Company (as well as any services or sites which incorporate or are successors to the Company Services and/or the Sites). Without limiting the foregoing, Company may use your User Content (including screen shots thereof) for marketing, promotion and publicity purposes. You also hereby grant each User a non-exclusive license to access your User Content through the Company Services, and to use, reproduce, distribute, display and perform such User Content as permitted through the functionality of the Company Services under this Agreement. You understand and acknowledge that any User Content You Share will not be kept confidential. 75 |

76 | 77 | F. Removal and Cessation of Content Sharing. 78 | 79 |

80 | Company reserves the right to remove, delete, block, edit or modify any User Content or suspend or stop the Sharing of User Content at any time, without prior notice and at in its sole discretion for any reason or no reason. 81 |

82 |
83 | 84 | 85 | III. USER CONDUCT 86 | 87 | 88 |

89 | You agree that You are responsible for your own conduct and User Content while using the Site and/or Company Services and for any consequences thereof. You agree to use Company Services and the Site only for purposes that are legal, proper and in accordance with the Agreement and any applicable laws, regulations, rules, policies or guidelines. By way of example, and not as a limitation, You agree that when using Company Services and the Site, You will not: 90 |

91 |
    92 |
  • defame, abuse, harass, stalk, spam, threaten or otherwise violate the legal rights (such as rights of privacy and publicity) of others
  • 93 |
  • upload, post, display, transmit or otherwise Share any inappropriate, defamatory, libelous, infringing, obscene, or unlawful User Content
  • 94 |
  • upload, post, display, transmit or otherwise Share any User Content that infringes, misappropriates or violates any patent, trademark, copyright, trade secret or other proprietary right or privacy right of any party
  • 95 |
  • 96 | upload, post, display, transmit or otherwise Share messages that promote pyramid schemes, chain letters or disruptive commercial messages or advertisements, or anything else prohibited by law, the Agreement or any applicable policies or guidelines 97 |
  • 98 |
  • 99 | conduct any advertising or commercial activity on or through the Company Service without the prior written permission of Company 100 |
  • 101 |
  • 102 | use Company Services or the Site for any illegal or unauthorized purpose 103 |
  • 104 |
  • 105 | download any file posted by another that You know, or reasonably should know, cannot be legally distributed in such manner 106 |
  • 107 |
  • 108 | impersonate another person or entity, or falsify or delete any author attributions, legal or other proper notices or proprietary designations or labels of the origin or source of software or other material 109 |
  • 110 |
  • 111 | restrict or inhibit any other user from using and enjoying Company Services or the Site 112 |
  • 113 |
  • 114 | modify, adapt, appropriate, reproduce, distribute, translate, reverse engineer, create derivative works of, publicly display, sell, trade, or exploit the Company Services or Site (including any Company Materials or User Content (other than your own User Content)), except as expressly authorized by Company 115 |
  • 116 |
  • 117 | remove or modify any copyright, trademark or other proprietary rights notices contained in or on the Company Services or the Site 118 |
  • 119 |
  • 120 | interfere with or disrupt (or access non-public parts of) the Company Services or the Site or servers or networks connected to the Company Services or the Site, or disobey any requirements, procedures, policies or regulations of networks connected to the Company Services or the Site 121 |
  • 122 |
  • 123 | use any robot, spider, site search/retrieval application, or other device to retrieve or index any portion of Company Services or the Site or collect information about users for any unauthorized purpose 124 |
  • 125 |
  • 126 | submit User Content that falsely expresses or implies that such User Content is sponsored or endorsed by Company 127 |
  • 128 |
  • 129 | remove, circumvent, disable, damage or otherwise interfere with any security-related features of the Site or Company Services, features that prevent or restrict the use of copying of the Site or Company Services or features that enforce limitations on the use of the Site or Company Services 130 |
  • 131 |
  • 132 | promote or provide instructional information about illegal activities or promote physical harm or injury against any group or individual; or 133 |
  • 134 |
  • 135 | transmit, submit links to, upload, post, provide or otherwise Share any viruses, worms, defects, Trojan horses, or any items of a destructive or malicious nature. 136 |
  • 137 |
138 |
139 | 140 | IV. PRIVACY POLICY 141 | 142 | 143 | 144 | A. Company Materials. 145 | 146 |

147 | Company may provide certain information and other content on, through or to the Site and the Company Services. "Company Materials" mean all information, content, software and other materials originating from Company (or its non-User licensors) and made available through the Site and Company Services, including, without limitation, information, data, graphics and files made available through the Site and Company Services as well as the Company logo, and all Company designs, text, data, graphics, other files, and the selection and arrangement thereof. Company and its licensors own and reserve all rights, title and interest, including all worldwide intellectual property rights, in and to the Site, Company Services, Company Materials, and the trademarks, service marks and logos contained therein. You will not remove, alter or conceal any copyright, trademark, service mark or other proprietary rights notices incorporated in or accompanying the Company Materials. You will not reproduce, modify, adapt, prepare derivative works based on, perform, display, publish, distribute, transmit, broadcast, sell, license or otherwise exploit the Company Materials. 148 |

149 | 150 | B. No Reliance On Content. 151 | 152 |

153 | All Company Materials and User Content are provided for your convenience only on an “as is” basis without warranty of any kind. Company does not endorse, support, represent or guarantee the qualifications, expertise, experience or identity of the providers of any such Company Materials or User Content, and Company does not warrant, guarantee, support, verify or otherwise have any responsibility for the completeness, truthfulness, accuracy or reliability of any Company Materials or User Content, including without limitation any information contained therein or any opinions or communications posted on, obtained from or available through, the Company Services or the Site. All use of and reliance upon any such information (or any Company Materials or User Content generally) by You shall be solely your responsibility and at your sole risk. 154 |

155 |
156 | 157 | VI. THIRD PARTY LINKS AND APPLICATIONS 158 | 159 | 160 | 161 | A. Links. 162 | 163 |

164 | The Company Services and Site may contain links to third party websites or resources. You acknowledge and agree that we are not responsible or liable for the availability or accuracy of such websites or resources or the content, products or services on or available therefrom. Links to such websites and resources do not imply any endorsement by Company thereof or of the content, products or services thereon. You acknowledge sole responsibility for and assume all risk arising from your use of any such websites or resources. 165 |

166 | 167 | B. Third Party Services and Applications. 168 | 169 |

170 | The Company Services may reach, interoperate with, or facilitate the use of third party services and software applications. Use of any such third party services and applications is subject to the terms and conditions of any applicable terms of use, terms of service, privacy policies or similar agreements and policies of such services and applications. You agree to abide by those terms and conditions with respect to any such services or applications. Your use of all such services and applications is at your own risk and we are not responsible for any aspect of such services and applications. 171 |

172 |
173 | 174 | VII. TERMINATION AND MODIFICATION OF COMPANY SERVICES & AMENDMENT OF AGREEMENT. 175 | 176 | 177 | 178 | A. Termination and Modification. 179 | 180 |

181 | Company reserves the right, in its sole discretion, at any time to modify, augment, limit, suspend, discontinue or terminate the Site and/or Company Services without advance notice. All modifications and additions to the Site and/or Company Services shall be governed by this Agreement, unless otherwise expressly stated by Company in writing. Company may also modify or amend this Agreement in its sole discretion without advance notice by posting the modifications or amended Agreement on the Site. All modified terms and conditions will be effective after they are posted on the Site (unless a longer notice period is required by applicable law). If any modified terms and conditions are not acceptable to You, your sole remedy is to cease using the Site and Company Services, and if applicable, cancel your Company account. By continuing to access or use the Site and/or Company Services after Company makes any such revision, You agree to be bound by the revised Agreement. This Agreement may not otherwise be modified or amended, except with the written agreement of both parties. 182 |

183 | 184 | B. Termination of User. 185 | 186 |

187 | Without limiting other remedies, Company may immediately terminate or suspend your access to the Site and/or Company Services and remove any material (including User Content) from the Site or our servers, in the event that You breach this Agreement. Notwithstanding the foregoing, we also reserve the right to terminate, limit or suspend your access to or use of the Site and/or Company Services at any time and for any reason or no reason. 188 |

189 | 190 | C. Effect of Termination. 191 | 192 |

193 | After any termination by You or Company: You understand and acknowledge that we will have no further obligation to provide or allow access to the Company Services or the Site. Upon termination, all licenses and other rights granted to You by this Agreement will immediately cease. Company is not liable to You or any third party for termination of the Company Services or termination of your use of the Company Services or the Site. UPON ANY TERMINATION OR SUSPENSION, ANY INFORMATION (INCLUDING ANY USER CONTENT OR OTHER USER SUBMISSIONS) THAT YOU HAVE SUBMITTED, POSTED, UPLOADED OR OTHERWISE MADE AVAILABLE ON THE SITE AND/OR COMPANY SERVICES OR THAT WHICH IS RELATED TO YOUR ACCOUNT MAY NO LONGER BE ACCESSED BY YOU. Furthermore, except as may be required by applicable law, Company will have no obligation to store or maintain any User Content or other information stored in our database related to your account or to forward any information to You or any third party. 194 |

195 |

196 | Any suspension, termination or cancellation will not affect your obligations to Company under this Agreement (including but not limited to ownership, indemnification and limitation of liability), which by their sense and context are intended to survive such suspension, termination or cancellation. 197 |

198 |
199 | 200 | VIII. INDEMNIFICATION; DISCLAIMER OF WARRANTIES; LIMITATION OF LIABILITY 201 | 202 | 203 | 204 | A. Indemnification. 205 | 206 |

207 | You agree to defend, indemnify, and hold Company, its officers, directors, employees and agents, harmless from and against any and all claims, liabilities, damages, losses, and expenses, including without limitation reasonable attorney's fees and costs, arising out of or in any way connected with (i) your access to or use of the Site, Company Services, Company Materials and User Content; (ii) your violation of the Agreement; (iii) your violation of any applicable laws, rules or regulations; (iv) any User Content posted, uploaded or provided by You; or (v) your violation of any third party right, including without limitation any intellectual property right, publicity, confidentiality, property or privacy right. 208 |

209 | 210 | B. Disclaimer of Warranties. 211 | 212 |

213 | YOU EXPRESSLY UNDERSTAND AND AGREE THAT: 214 |

215 |

216 | YOUR USE OF THE COMPANY SERVICE , SITE, COMPANY MATERIALS AND USER CONTENT IS AT YOUR SOLE RISK AND COMPANY SHALL NOT BE LIABLE FOR ANY INABILITY TO USE, OR ANY DELAYS, ERRORS OR OMISSIONS WITH RESPECT TO THE COMPANY SERVICES OR SITE. THE COMPANY SERVICES , SITE, COMPANY MATERIALS AND USER CONTENT AND ALL MATERIALS, INFORMATION, PRODUCTS AND SERVICES INCLUDED THEREIN, ARE PROVIDED ON AN "AS IS" AND "AS AVAILABLE" BASIS. COMPANY EXPRESSLY DISCLAIMS ALL WARRANTIES OF ANY KIND, WHETHER EXPRESS OR IMPLIED, RELATING TO THE COMPANY SERVICES (INCLUDING THE LET'S THANGO WEBSITE), SITE, COMPANY MATERIALS AND USER CONTENT, INCLUDING, BUT NOT LIMITED TO THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. 217 |

218 |

219 | COMPANY MAKES NO WARRANTY THAT (i) THE SITE OR COMPANY SERVICES WILL MEET YOUR REQUIREMENTS, (ii) THE SITE AND COMPANY SERVICES WILL BE UNINTERRUPTED, AVAILABLE FOR USE AT ANY GIVEN TIME, TIMELY, SECURE, OR ERROR-FREE, (iii) THE QUALITY OF ANY PRODUCTS, SERVICES, INFORMATION, OR OTHER MATERIAL PURCHASED OR OBTAINED BY YOU THROUGH THE SITE OR COMPANY SERVICES WILL BE ACCURATE, RELIABLE OR OTHERWISE MEET YOUR EXPECTATIONS, AND (V) ANY ERRORS IN ANY SOFTWARE AVAILABLE THROUGH THE SITE OR COMPANY SERVICES WILL BE CORRECTED. 220 |

221 |

222 | ANY DOWNLOADING OR USE OF ANY CONTENT OR MATERIAL VIA THE COMPANY SERVICES IS DONE AT YOUR OWN DISCRETION AND RISK AND YOU WILL BE SOLELY RESPONSIBLE FOR ANY DAMAGE TO YOUR COMPUTER SYSTEM OR LOSS OF DATA THAT RESULTS FROM THE DOWNLOAD OF ANY SUCH CONTENT OR MATERIAL. 223 |

224 | 225 | C. Limitation of Liability. 226 | 227 |

228 | YOU EXPRESSLY UNDERSTAND AND AGREE THAT, TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, COMPANY, AND ITS AFFILIATES, OFFICERS, DIRECTORS, EMPLOYEES, AGENTS AND LICENSORS, SHALL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL OR EXEMPLARY DAMAGES, ARISING OUT OF OR IN CONNECTION WITH YOUR USE OF THE SITE, COMPANY SERVICES (INCLUDING LET'S THANGO APPLICATIONS), COMPANY MATERIALS, USER CONTENT, INFORMATION AND RESULTS AND OTHER CONTENT AND INFORMATION AVAILABLE THROUGH THE SITE OR COMPANY SERVICES, INCLUDING BUT NOT LIMITED TO, DAMAGES FOR LOSS OF PROFITS, GOODWILL, USE, DATA OR OTHER INTANGIBLE LOSSES (EVEN IF COMPANY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES). IN NO EVENT WILL COMPANY'S (OR ITS AFFILIATES’, OFFICERS’, DIRECTORS’, EMPLOYEES’, AGENTS’ AND LICENSORS’) CUMULATIVE LIABILITY TO YOU EXCEED US $100.00, EXCEPT TO THE EXTENT SUCH LIMITATION IS NOT PERMITTED BY APPLICABLE LAW. 229 |

230 |

231 | Some jurisdictions do not allow the exclusion of certain warranties or the exclusion or limitation of liability for consequential or incidental damages, so the limitations above may not apply to You to the extent applicable law so requires. 232 |

233 |
234 | 235 | 236 | IX. ADDITIONAL USER REPRESENTATIONS 237 | 238 | 239 |

You acknowledge, represent and warrant that:

240 |
    241 |
  • (a) You own the computer or device on which You are using the Let's Thango Website on, or have the authority to use the Let's Thango Website on such computer or device
  • 242 |
  • (b) your use of the Company Services will not violate any local, state or federal laws that apply to You
  • 243 |
  • (c) Company is not causing the Let's Thango Website to be used on your computer or device, but has provided the Let's Thango Website to You, which You are using of your own volition
  • 244 |
  • (d) You have read and fully understand the terms of this Agreement
  • 245 |
  • (e) You have due authority and adequate legal capacity to enter into this Agreement
  • 246 |
  • (f) You are either more than 18 years of age, or an emancipated minor, or possess legal parental or guardian consent, and are fully able and competent to enter into the terms, conditions, obligations, affirmations, representations, and warranties set forth in this Agreement, and to abide by and comply with this TOS; and
  • 247 |
  • (g) You are over the age of 13, as the Company Services are not intended for children under 13.
  • 248 |
249 |
250 | 251 | 252 | X. GENERAL INFORMATION 253 | 254 | 255 |

256 | The Agreement constitutes the entire agreement between You and Company and supersedes any prior agreements, understandings or arrangements between You and Company. You may not assign the Agreement or assign any rights or delegate any obligations hereunder, in whole or in part, whether voluntarily or by operation of law, without the prior written consent of Company. Any purported assignment or delegation by You without the appropriate prior written consent of Company will be null and void. Company may assign the Agreement or any rights hereunder without your consent. The Agreement and the relationship between You and Company shall be governed by the laws of the State of New Mexico, without regard to or application of its conflict of law provisions, rules and principles. You agree to submit to the personal jurisdiction of the courts located in New Mexico for the purpose of litigating all such claims. Further You agree that You must bring any claim arising out of or related to this License Agreement, or the relationship between You and us, within one (1) year after the claim arises, or the claim will be permanently barred. The failure or delay of Company to exercise or enforce any right or provision of the Agreement shall not constitute a waiver of such right or provision. If any provision of the Agreement is found by a court of competent jurisdiction to be invalid, the parties nevertheless agree that the court should endeavor to give effect to the parties' intentions as reflected in the provision to the full extent consistent with applicable law, and the other provisions of the Agreement remain in full force and effect. You and Company are independent contractors and no agency, partnership, joint venture, employee-employer or franchiser-franchisee relationship is intended or created by this Agreement. The section titles in the Agreement are for convenience only and have no legal or contractual effect. 257 |

258 |

259 | Company may provide notices to You with respect to this Agreement, the Site or the Company Services by posting such notices to the Site or by sending them to the email address or other contact address You provide upon registration or setting up your account. Any such notices shall be deemed properly and timely given to You hereunder. You consent to the use of: 260 |

261 |
    262 |
  • (a) electronic means to complete this Agreement and to provide You with any notices given pursuant to this Agreement; and
  • 263 |
  • (b) electronic records to store information related to this Agreement or your use of the Site and Company Services.
  • 264 |
265 |
266 | 267 | 268 | XII. VIOLATIONS AND COMMENTS 269 | 270 | 271 |

272 | Please report any violations of the Agreement or provide any comments or questions by emailing us at tnguye20@uvm.edu. You agree, however, that: (i) by submitting ideas regarding the Company Services or Site to Company or any of its employees or representatives, You automatically forfeit your right to any intellectual property rights in these ideas; and (ii) ideas regarding the Company Services of the Site submitted to Company or any of its employees or representatives (including any improvements or suggestions) automatically become the property of Company. You hereby assign and agree to assign all rights, title and interest You have in such comments and ideas to Company together with all intellectual property. 273 |

274 |
275 |
276 | 277 | ) 278 | } -------------------------------------------------------------------------------- /src/components/Room/Room.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { db, analytics } from '../../libs'; 3 | import { config } from '../../shared'; 4 | import './Room.css'; 5 | import { CustomizedAlert } from '../'; 6 | import { ActionButtons } from './ActionButtons'; 7 | import { Video } from './Video'; 8 | import { useHistory, useLocation } from 'react-router'; 9 | import VolumeOffSharpIcon from '@material-ui/icons/VolumeOffSharp'; 10 | import VolumeUpSharpIcon from '@material-ui/icons/VolumeUpSharp'; 11 | import VideocamSharpIcon from '@material-ui/icons/VideocamSharp'; 12 | import VideocamOffSharpIcon from '@material-ui/icons/VideocamOffSharp'; 13 | import Tooltip from '@material-ui/core/Tooltip'; 14 | import Fab from '@material-ui/core/Fab'; 15 | 16 | import { 17 | Session, 18 | ConnectType, 19 | Peer, 20 | Offer, 21 | CastDevices, 22 | User, 23 | ALERT_TYPE, 24 | CALL_TYPE 25 | } from '../../interfaces'; 26 | import { useAlert } from '../../hooks'; 27 | import * as ROUTES from '../../routes'; 28 | import { logger } from '../../utils'; 29 | 30 | const { useRef, useEffect, useState } = React; 31 | 32 | export const Room = () => { 33 | const clipboardRef = useRef(null); 34 | const localVideo = useRef(null); 35 | const shareVideo = useRef(null); 36 | const shareContainer = useRef(null); 37 | const videoContainer = useRef(null); 38 | const session = useRef({}); 39 | const globalListeners = useRef([]); 40 | const localStream = useRef(new MediaStream()); 41 | const shareStream = useRef(new MediaStream()); 42 | const callID = useRef(''); 43 | const callType = useRef(CALL_TYPE.video); 44 | const user = useRef(''); 45 | const userName = useRef(''); 46 | const location = useLocation(); 47 | const history = useHistory(); 48 | const { openAlert, setAlertMessage, setOpenAlert, alertMessage, alertType, fireAlert} = useAlert(); 49 | const [sessionState, setSessionState] = useState({}); 50 | const [mute, setMute] = useState(false); 51 | const [webcam, setWebcam] = useState(false); 52 | const [isSharing, setIsSharing] = useState(false); 53 | 54 | const addToSession = (key: string, peer: Peer) => { 55 | if (session.current[key]) { 56 | logger(`Duplicate key ${key}`); 57 | deleteSessionByKey(key); 58 | addToSession(key, peer); 59 | } 60 | else { 61 | session.current[key] = peer; 62 | setSessionState((currentSession) => { 63 | return ({ 64 | ...currentSession, 65 | [key]: peer 66 | }) 67 | }); 68 | } 69 | logger(session.current); 70 | } 71 | const deleteSessionByKey = (key: string) => { 72 | if (session.current[key]) { 73 | logger(`Deleting ${key}`); 74 | session.current[key].pc.close(); 75 | session.current[key].listeners.forEach((listener) => listener()); 76 | delete session.current[key]; 77 | setSessionState(session.current); 78 | } 79 | else { 80 | logger(`${key} does not exist.`); 81 | } 82 | } 83 | 84 | useEffect(() => { 85 | 86 | // Interval to update videos 87 | // const interval = setInterval(sessionCallback, 1000); 88 | 89 | const main = async () => { 90 | try { 91 | if (!location.state) { 92 | fireAlert('Invalid State. Please join or create call via the appropriate screen.', ALERT_TYPE.error); 93 | 94 | setTimeout(() => { 95 | handleEndCall(); 96 | }, 1500) 97 | } 98 | else { 99 | const state = location.state as { action: 'call' | 'answer', name: string, callID: string, callType: keyof typeof CALL_TYPE, userID: string }; 100 | callID.current = state.callID; 101 | user.current = state.userID; 102 | userName.current = state.name; 103 | callType.current = state.callType; 104 | switch (state.action) { 105 | case 'call': { 106 | await handleWebcam(callType.current); 107 | await handleCall(); 108 | break; 109 | } 110 | case 'answer': { 111 | await handleWebcam(callType.current); 112 | await handleAnswer(); 113 | } 114 | } 115 | 116 | // If it's a reload, nuke state 117 | history.replace({ pathname: ROUTES.ROOM, state: undefined }); 118 | } 119 | } 120 | catch (error) { 121 | logger(error); 122 | setAlertMessage('Call Failed. Please exit and create a new call.') 123 | setOpenAlert(true); 124 | } 125 | } 126 | main(); 127 | 128 | return () => { 129 | // clearInterval(interval); 130 | // Undo as many listeners as possibe 131 | // eslint-disable-next-line 132 | Object.keys(session.current).forEach((key) => { 133 | // peer.listeners.forEach((listener) => listener()); 134 | deleteSessionByKey(key); 135 | }); 136 | // eslint-disable-next-line 137 | globalListeners.current.forEach((listener) => listener()); 138 | } 139 | // eslint-disable-next-line 140 | }, []); 141 | 142 | const createOfferPeer = async (call: string, userID: string, peerID: string, name: string, stream: MediaStream, type = ConnectType.user) => { 143 | const sessionKey = type === ConnectType.user ? peerID : `share_to_offer_${peerID}`; 144 | 145 | if (!session.current[sessionKey]) { 146 | // Socket Connections 147 | const callDoc = db.collection('calls').doc(call); 148 | const userDoc = callDoc.collection('users').doc(userID); 149 | const offers = userDoc.collection('offers').doc(peerID); 150 | const answers = userDoc.collection('answers').doc(peerID); 151 | const offerCandidates = userDoc.collection('offerCandidates').doc(peerID).collection('candidates'); 152 | const answerCandidates = userDoc.collection('answerCandidates').doc(peerID).collection('candidates'); 153 | 154 | const peer: Peer = { 155 | name, 156 | peerID: sessionKey, 157 | pc: new RTCPeerConnection(config.servers), 158 | remoteStream: new MediaStream(), 159 | listeners: [], 160 | type 161 | }; 162 | 163 | // Push tracks from local stream to peer connection 164 | stream.getTracks().forEach((track) => { 165 | peer.pc.addTrack(track, stream); 166 | }); 167 | peer.pc.ontrack = (e) => { 168 | e.streams.forEach((stream) => { 169 | stream.getTracks().forEach((track) => { 170 | peer.remoteStream.addTrack(track); 171 | }) 172 | }); 173 | } 174 | 175 | // Handle disconnect 176 | peer.pc.onconnectionstatechange = (e) => { 177 | switch (peer.pc.connectionState) { 178 | case "disconnected": 179 | { 180 | deletePeerConnection(sessionKey, peer); 181 | break; 182 | } 183 | case "closed": 184 | { 185 | deletePeerConnection(sessionKey, peer); 186 | break; 187 | } 188 | case "failed": 189 | { 190 | deletePeerConnection(sessionKey, peer); 191 | break; 192 | } 193 | } 194 | } 195 | 196 | // Handle Ice Candidate 197 | peer.pc.onicecandidate = (e) => { 198 | e.candidate && offerCandidates.add(e.candidate.toJSON()); 199 | } 200 | 201 | const offerDescription = await peer.pc.createOffer(); 202 | await peer.pc.setLocalDescription(offerDescription); 203 | 204 | const offer: RTCSessionDescriptionInit = { 205 | sdp: offerDescription.sdp, 206 | type: offerDescription.type 207 | } 208 | // Push offer to user's specific record 209 | await offers.set({ offer }); 210 | 211 | // Handle when someone answers 212 | let listener = answers.onSnapshot((snapshot) => { 213 | if (snapshot.exists) { 214 | const { answer } = snapshot.data() as { answer: RTCSessionDescription }; 215 | const answerDescription = new RTCSessionDescription(answer); 216 | if (peer.pc.signalingState !== 'closed') { 217 | try { 218 | peer.pc.setRemoteDescription(answerDescription); 219 | } 220 | catch (error) { 221 | logger(error); 222 | } 223 | } 224 | else { 225 | listener(); 226 | // delete session.current[sessionKey]; 227 | deleteSessionByKey(sessionKey); 228 | } 229 | } 230 | }); 231 | peer.listeners.push(listener); 232 | 233 | // Handle when answerers provide answer candidates 234 | listener = answerCandidates.onSnapshot((snapshot) => { 235 | snapshot.docChanges().map(async (change) => { 236 | if (change.type === 'added') { 237 | const candidate = new RTCIceCandidate(change.doc.data()); 238 | if (peer.pc.signalingState !== 'closed') { 239 | try { 240 | await peer.pc.addIceCandidate(candidate); 241 | } 242 | catch (error) { 243 | logger(error); 244 | } 245 | } 246 | else { 247 | if (peer.type === 'share') { 248 | deleteSessionByKey('screen'); 249 | deleteSessionByKey('share_to_answer_screen'); 250 | deleteSessionByKey('share_to_offer_screen'); 251 | } 252 | deleteSessionByKey(sessionKey); 253 | } 254 | } 255 | }) 256 | }); 257 | peer.listeners.push(listener); 258 | 259 | // Update session list 260 | // session.current[sessionKey] = peer; 261 | // setSessionState((currrentValue) => { 262 | // return { 263 | // ...currrentValue, 264 | // [sessionKey]: peer 265 | // } 266 | // }) 267 | addToSession(sessionKey, peer); 268 | 269 | if (peerID !== userID && type !== ConnectType.share) { 270 | createVideoComponent(peer, type); 271 | } 272 | 273 | return peer; 274 | } 275 | return session.current[peerID]; 276 | } 277 | 278 | const createVideoComponent = (peer: Peer, type: ConnectType) => { 279 | if (type === ConnectType.share) { 280 | setIsSharing(true); 281 | shareStream.current.getTracks().forEach((track) => track.stop()); 282 | if (shareVideo.current !== null) { 283 | shareVideo.current.muted = false; 284 | shareVideo.current.volume = process.env.NODE_ENV === 'development' ? 0 : 0.2; 285 | shareVideo.current.srcObject = peer.remoteStream; 286 | } 287 | else { 288 | let shareVideoEl = document.querySelector('#shareVideo') as HTMLVideoElement; 289 | shareVideoEl.muted = false; 290 | shareVideoEl.volume = process.env.NODE_ENV === 'development' ? 0 : 0.2; 291 | shareVideoEl.srcObject = peer.remoteStream; 292 | } 293 | } 294 | }; 295 | 296 | const handleWebcam = async (callType: keyof typeof CALL_TYPE) => { 297 | try { 298 | const constraints: { video?: boolean, audio?: boolean } = { 299 | video: false, 300 | audio: true 301 | }; 302 | const devices = await navigator.mediaDevices.enumerateDevices(); 303 | 304 | devices.forEach((device) => { 305 | if (device.kind === 'videoinput' && callType !== CALL_TYPE.audio) constraints.video = true; 306 | }); 307 | if (constraints.video) setWebcam(true); 308 | 309 | localStream.current = await navigator.mediaDevices.getUserMedia(constraints); 310 | } 311 | catch (error) { 312 | logger(error); 313 | fireAlert('Failed to access Webcam and/or Mic', ALERT_TYPE.error); 314 | } 315 | 316 | localVideo.current!.volume = 0; 317 | localVideo.current!.srcObject = localStream.current; 318 | } 319 | 320 | const handleCall = async () => { 321 | // Create the room and add you as the first user with no peer connection at all 322 | logger(`Call ID: ${callID.current}`); 323 | const callDoc = db.collection('calls').doc(callID.current); 324 | await callDoc.set({ time: new Date(), callType: callType.current }); 325 | const userDoc = callDoc.collection('users').doc(user.current); 326 | const usersCollection = callDoc.collection('users'); 327 | await userDoc.set({ name: userName.current, type: ConnectType.user, time: new Date(), status: 'active', mute: mute }); 328 | const screenDoc = callDoc.collection('users').doc('screen'); 329 | 330 | // Watch for new users to come in and create new peer connection 331 | let listener = usersCollection.onSnapshot(async (snapshot) => { 332 | const promises = snapshot.docChanges() 333 | .filter((change) => user.current !== change.doc.id) 334 | .map(async (change) => { 335 | if (change.type === 'added') { 336 | const existingUserID = change.doc.id; 337 | logger(existingUserID); 338 | if (!session.current[existingUserID]) { 339 | const u = change.doc.data(); 340 | 341 | if (u.type === ConnectType.user) { 342 | logger(`User ${u.name} joined the call`); 343 | fireAlert(`User ${u.name} joined the call`, ALERT_TYPE.success); 344 | await createOfferPeer(callID.current, user.current, existingUserID, u.name, localStream.current); 345 | 346 | // If sharing, then create offer for share stream too 347 | const sharingCondition = Object.keys(session.current).reduce((accumulator: boolean, currrentValue: string) => { 348 | if (currrentValue.indexOf('share_to_offer') !== -1) { 349 | return accumulator || true; 350 | } 351 | return accumulator; 352 | }, false); 353 | if (sharingCondition) { 354 | await createOfferPeer(callID.current, 'screen', existingUserID, u.name, shareStream.current, ConnectType.share); 355 | } 356 | } 357 | else if (u.type === ConnectType.share) { 358 | if (u.shareID !== user.current) { 359 | logger(`User ${u.shareUserName} is sharing their screen`); 360 | fireAlert(`User ${u.shareUserName} is sharing their screen`, ALERT_TYPE.success); 361 | 362 | // Close your sharing if exist 363 | shareStream.current.getTracks().forEach((track) => track.stop()); 364 | } 365 | else { 366 | logger(`You are sharing your screen`); 367 | fireAlert(`You are sharing your screen`, ALERT_TYPE.success); 368 | } 369 | } 370 | } 371 | } 372 | }); 373 | 374 | await Promise.all(promises); 375 | }); 376 | globalListeners.current.push(listener); 377 | 378 | // Socket to monitor who will share screen 379 | listener = screenDoc.collection('offers').onSnapshot(async (snapshot) => { 380 | const promises = snapshot.docChanges() 381 | .filter((change) => change.doc.id === user.current) 382 | .map(async (change) => { 383 | // const id = change.doc.id; 384 | logger('Anticipating share screen'); 385 | // Clean up 'screen' session 386 | Object.keys(session.current).forEach((key) => { 387 | if (session.current[key].type === ConnectType.share) { 388 | // session.current[key].listeners.forEach((listener) => listener()); 389 | // session.current[key].pc.close(); 390 | // delete session.current[key]; 391 | deleteSessionByKey(key); 392 | } 393 | }) 394 | if (change.type === 'added') { 395 | const { offer } = change.doc.data() as Offer; 396 | const answerPeer = await createAnswerPeer(callID.current, user.current, 'screen', 'Screen', offer, new MediaStream(), ConnectType.share); 397 | // const listener = screenDoc.collection('offerCandidates').doc(user.current).collection('candidates').onSnapshot((ss) => { 398 | // ss.docChanges().forEach(async (cc) => { 399 | // if (cc.type === 'added') { 400 | // let data = cc.doc.data(); 401 | // if (answerPeer.pc.signalingState !== 'closed') { 402 | // try { 403 | // await answerPeer.pc.addIceCandidate(new RTCIceCandidate(data)); 404 | // } 405 | // catch (error) { 406 | // logger(error); 407 | // } 408 | // } 409 | // else { 410 | // listener(); 411 | // deleteSessionByKey('screen'); 412 | // deleteSessionByKey('share_to_answer_screen'); 413 | // deleteSessionByKey('share_to_offer_screen'); 414 | // } 415 | // } 416 | // }); 417 | // }); 418 | // answerPeer.listeners.push(listener); 419 | } 420 | }); 421 | 422 | await Promise.all(promises); 423 | }); 424 | globalListeners.current.push(listener); 425 | } 426 | 427 | const deletePeerConnection = (key: string, peer: Peer) => { 428 | logger(`Connection Disconnected - ${peer.name}`); 429 | deleteSessionByKey(key); 430 | if (peer.type === ConnectType.user) { 431 | fireAlert(`${peer.name} disconnected.`, ALERT_TYPE.info); 432 | } 433 | 434 | [ 435 | `share_to_offer_${peer.peerID}`, 436 | // `share_to_offer_screen`, 437 | `share_to_answer_${peer.peerID}`, 438 | // `share_to_answer_screen`, 439 | ].forEach((key) => { 440 | deleteSessionByKey(key); 441 | }); 442 | } 443 | 444 | const createAnswerPeer = async (call: string, userID: string, offerID: string, name: string, offer: RTCSessionDescriptionInit, stream: MediaStream, type = ConnectType.user) => { 445 | const callDoc = db.collection('calls').doc(call); 446 | const userDoc = callDoc.collection('users').doc(offerID); 447 | const answers = userDoc.collection('answers').doc(userID); 448 | const offerCandidates = userDoc.collection('offerCandidates').doc(offerID).collection('candidates'); 449 | const answerCandidates = userDoc.collection('answerCandidates').doc(userID).collection('candidates'); 450 | 451 | const sessionKey = type === ConnectType.user ? offerID : `share_to_answer_${offerID}`; 452 | 453 | const peer: Peer = { 454 | name, 455 | peerID: sessionKey, 456 | pc: new RTCPeerConnection(config.servers), 457 | remoteStream: new MediaStream(), 458 | listeners: [], 459 | type 460 | }; 461 | 462 | // Handle Ice Candidate 463 | peer.pc.onicecandidate = (e) => { 464 | e.candidate && answerCandidates.add(e.candidate.toJSON()); 465 | } 466 | // Push stream from local to remote 467 | stream.getTracks().forEach((track) => { 468 | peer.pc.addTrack(track, stream); 469 | }); 470 | // Handle Remote Stream 471 | peer.pc.ontrack = (e) => { 472 | e.streams.forEach((stream) => { 473 | stream.getTracks().forEach((track) => { 474 | peer.remoteStream.addTrack(track) 475 | }) 476 | }); 477 | } 478 | // Handle disconnect 479 | peer.pc.onconnectionstatechange = (e) => { 480 | switch (peer.pc.connectionState) { 481 | case "disconnected": 482 | { 483 | deletePeerConnection(sessionKey, peer); 484 | break; 485 | } 486 | case "closed": 487 | { 488 | deletePeerConnection(sessionKey, peer); 489 | break; 490 | } 491 | case "failed": 492 | { 493 | deletePeerConnection(sessionKey, peer); 494 | break; 495 | } 496 | } 497 | } 498 | 499 | await peer.pc.setRemoteDescription(new RTCSessionDescription(offer)); 500 | const answerDescription = await peer.pc.createAnswer(); 501 | if (peer.type === 'share') { 502 | if (answerDescription.sdp) { 503 | answerDescription.sdp.replace('useinbandfec=1', 'useinbandfec=1; stereo=1; maxaveragebitrate=510000'); 504 | } 505 | } 506 | await peer.pc.setLocalDescription(answerDescription); 507 | const answer: RTCSessionDescriptionInit = { 508 | sdp: answerDescription.sdp, 509 | type: answerDescription.type 510 | }; 511 | await answers.set({ answer }); 512 | 513 | // Handle when offerers provide answer candidates 514 | const listener = offerCandidates.onSnapshot(async (snapshot) => { 515 | snapshot.docChanges().forEach(async (change) => { 516 | if (change.type === 'added') { 517 | let data = change.doc.data(); 518 | if (peer.pc.signalingState !== 'closed') { 519 | try { 520 | await peer.pc.addIceCandidate(new RTCIceCandidate(data)); 521 | } 522 | catch (error) { 523 | logger(error); 524 | } 525 | } 526 | else { 527 | const sessionKey = peer.type === ConnectType.user ? offerID : `share_to_answer_${offerID}`; 528 | deleteSessionByKey(sessionKey); 529 | // delete session.current[sessionKey]; 530 | } 531 | } 532 | }) 533 | }); 534 | peer.listeners.push(listener); 535 | 536 | addToSession(sessionKey, peer); 537 | 538 | if (offerID !== userID) { 539 | createVideoComponent(peer, type); 540 | } 541 | return peer; 542 | } 543 | 544 | const handleAnswer = async () => { 545 | logger(`Call ID: ${callID.current}`); 546 | // Add yourself to the list of uers 547 | const callDoc = db.collection('calls').doc(callID.current); 548 | const testCall = await callDoc.get(); 549 | if (!testCall.exists) { 550 | fireAlert('Invalid Call ID. Please try again with a valid one.', ALERT_TYPE.error); 551 | return; 552 | } 553 | 554 | const userDoc = callDoc.collection('users').doc(user.current); 555 | await userDoc.set({ name: userName.current, type: ConnectType.user, time: new Date(), status: 'active', mute: mute }); 556 | const usersCollection = callDoc.collection('users'); 557 | const screenDoc = callDoc.collection('users').doc('screen'); 558 | 559 | // Go through all existing users and connect to them using their offer for your user ID 560 | const promises = (await usersCollection.get()).docs 561 | .filter((u) => u.id !== user.current) 562 | .map(async (doc) => { 563 | const id = doc.id; 564 | const u = doc.data() as User; 565 | 566 | doc.ref.collection('offers').doc(user.current).onSnapshot(async (snapshot) => { 567 | if (snapshot.exists) { 568 | if (u.type !== ConnectType.share) { 569 | logger(`Retrieving offers from ${id} -- ${u.name}`); 570 | const { offer } = snapshot.data() as Offer; 571 | const answerPeer = await createAnswerPeer(callID.current, user.current, id, u.name, offer, localStream.current); 572 | 573 | // const listener = doc.ref.collection('offerCandidates').doc(user.current).collection('candidates').onSnapshot((snapshot) => { 574 | // snapshot.docChanges().forEach(async (change) => { 575 | // if (change.type === 'added') { 576 | // let data = change.doc.data(); 577 | // if (answerPeer.pc.signalingState !== 'closed') { 578 | // try { 579 | // await answerPeer.pc.addIceCandidate(new RTCIceCandidate(data)); 580 | // } 581 | // catch (error) { 582 | // logger(error); 583 | // } 584 | // } 585 | // else { 586 | // listener(); 587 | // const sessionKey = answerPeer.type === ConnectType.user ? id : `share_to_answer_${id}`; 588 | // deleteSessionByKey(sessionKey); 589 | // // delete session.current[sessionKey]; 590 | // } 591 | // } 592 | // }); 593 | // }); 594 | // answerPeer.listeners.push(listener); 595 | } 596 | } 597 | }) 598 | }); 599 | await Promise.all(promises); 600 | 601 | // Watch for new users to come in and create new peer connection 602 | let listener = usersCollection.onSnapshot(async (snapshot) => { 603 | if (Object.keys(session.current).length > 0) { 604 | const promises = snapshot.docChanges() 605 | .filter((change) => user.current !== change.doc.id) 606 | .map(async (change) => { 607 | if (change.type === 'added') { 608 | const existingUserID = change.doc.id; 609 | if (!session.current[existingUserID]) { 610 | const u = change.doc.data(); 611 | if (u.type === ConnectType.user) { 612 | logger(`User ${u.name} joined the call`); 613 | fireAlert(`User ${u.name} joined the call`, ALERT_TYPE.success); 614 | await createOfferPeer(callID.current, user.current, existingUserID, u.name, localStream.current); 615 | 616 | // If sharing, then create offer for share stream too 617 | const sharingCondition = Object.keys(session.current).reduce((accumulator: boolean, currrentValue: string) => { 618 | if (currrentValue.indexOf('share_to_offer') !== -1) { 619 | return accumulator || true; 620 | } 621 | return accumulator; 622 | }, false); 623 | if (sharingCondition) { 624 | await createOfferPeer(callID.current, 'screen', existingUserID, u.name, shareStream.current, ConnectType.share); 625 | } 626 | } 627 | else if (u.type === ConnectType.share) { 628 | if (u.shareID !== user.current) { 629 | logger(`User ${u.shareUserName} is sharing their screen`); 630 | fireAlert(`User ${u.shareUserName} is sharing their screen`, ALERT_TYPE.success); 631 | // Close your sharing if exist 632 | shareStream.current.getTracks().forEach((track) => track.stop()); 633 | } 634 | else { 635 | logger(`You are sharing your screen`); 636 | fireAlert(`You are sharing your screen`, ALERT_TYPE.success); 637 | } 638 | } 639 | } 640 | } 641 | }); 642 | 643 | await Promise.all(promises); 644 | } 645 | }); 646 | globalListeners.current.push(listener); 647 | 648 | // Socket to monitor who will share screen 649 | listener = screenDoc.collection('offers').onSnapshot(async (snapshot) => { 650 | const promises = snapshot.docChanges() 651 | .filter((change) => change.doc.id === user.current) 652 | .map(async (change) => { 653 | // const id = change.doc.id; 654 | logger('Anticipating share screen'); 655 | // Clean up 'screen' session 656 | Object.keys(session.current).forEach((key) => { 657 | if (session.current[key].type === ConnectType.share) { 658 | // session.current[key].listeners.forEach((listener) => listener()); 659 | // session.current[key].pc.close(); 660 | // delete session.current[key]; 661 | deleteSessionByKey(key); 662 | } 663 | }) 664 | if (change.type === 'added') { 665 | const { offer } = change.doc.data() as Offer; 666 | const answerPeer = await createAnswerPeer(callID.current, user.current, 'screen', 'Screen', offer, new MediaStream(), ConnectType.share); 667 | const listener = screenDoc.collection('offerCandidates').doc(user.current).collection('candidates').onSnapshot((ss) => { 668 | ss.docChanges().forEach(async (cc) => { 669 | if (cc.type === 'added') { 670 | let data = cc.doc.data(); 671 | if (answerPeer.pc.signalingState !== 'closed') { 672 | try { 673 | await answerPeer.pc.addIceCandidate(new RTCIceCandidate(data)); 674 | } 675 | catch (error) { 676 | logger(error); 677 | } 678 | } 679 | else { 680 | listener(); 681 | deleteSessionByKey('screen'); 682 | deleteSessionByKey('share_to_answer_screen'); 683 | deleteSessionByKey('share_to_offer_screen'); 684 | } 685 | } 686 | }); 687 | }); 688 | answerPeer.listeners.push(listener); 689 | } 690 | }); 691 | 692 | await Promise.all(promises); 693 | }); 694 | globalListeners.current.push(listener); 695 | } 696 | 697 | const handleShareScreen = async () => { 698 | if (Object.keys(session.current).length === 0) { 699 | fireAlert('Cannot share screen in empty room. Please wait for more folks to join.', ALERT_TYPE.info); 700 | return; 701 | } 702 | 703 | // Turn on sharing screen view 704 | setIsSharing(true); 705 | 706 | try { 707 | shareStream.current.getTracks().forEach((track) => track.stop()); 708 | 709 | const castDevices = navigator.mediaDevices as CastDevices; 710 | shareStream.current = await castDevices.getDisplayMedia({ 711 | audio: { 712 | echoCancellation: false, 713 | autoGainControl: false, 714 | noiseSuppression: false, 715 | latency: 0, 716 | channelCount: 2, 717 | sampleRate: 48000, 718 | sampleSize: 16 719 | }, 720 | video: { 721 | frameRate: 60 722 | } 723 | }); 724 | shareVideo.current!.muted = false; 725 | shareVideo.current!.volume = 0; 726 | shareVideo.current!.srcObject = shareStream.current; 727 | 728 | const callDoc = db.collection('calls').doc(callID.current); 729 | const userDoc = callDoc.collection('users').doc(`screen`); 730 | 731 | // Clean up 'screen' session 732 | Object.keys(session.current).forEach((key) => { 733 | if (session.current[key].type === ConnectType.share) { 734 | deleteSessionByKey(key); 735 | // session.current[key].listeners.forEach((listener) => listener()); 736 | // session.current[key].pc.close(); 737 | // delete session.current[key]; 738 | } 739 | }) 740 | 741 | // Delete Screen record. Will need to move to cloud function later 742 | const _offers = await userDoc.collection('offers').get(); 743 | _offers.docs.forEach((_offer) => _offer.ref.delete()); 744 | const _offerCandidates = await userDoc.collection('offerCandidates').get(); 745 | let _ps = _offerCandidates.docs.map(async (_offerCandidate) => { 746 | const candidates = await _offerCandidate.ref.collection('candidates').get(); 747 | candidates.forEach((candidate) => candidate.ref.delete()); 748 | }); 749 | await Promise.all(_ps); 750 | const _answers = await userDoc.collection('answers').get(); 751 | _answers.docs.forEach((_answer) => _answer.ref.delete()); 752 | const _answerCandidates = await userDoc.collection('answerCandidates').get(); 753 | _ps = _answerCandidates.docs.map(async (_answerCandidate) => { 754 | const candidates = await _answerCandidate.ref.collection('candidates').get(); 755 | candidates.forEach((candidate) => candidate.ref.delete()); 756 | }); 757 | await Promise.all(_ps); 758 | await userDoc.delete(); 759 | 760 | // Update screen user 761 | await userDoc.set({ name: 'Screen Share', type: ConnectType.share, shareID: user.current, shareUserName: userName.current, time: new Date(), status: 'active', mute: false }); 762 | 763 | // Create offers and candidates for each user 764 | // const promises = (await callDoc.collection('users').get()).docs 765 | // .filter((doc) => user.current !== doc.id && doc.data().type !== ConnectType.share) 766 | // .map(async (doc) => { 767 | // const existingUserID = doc.id; 768 | // const data = doc.data() as User; 769 | // await createOfferPeer(callID.current, 'screen', existingUserID, data.name, shareStream.current, ConnectType.share); 770 | // }); 771 | // await Promise.all(promises); 772 | Object.values(session.current) 773 | .filter((peer) => peer.type !== ConnectType.share && user.current !== peer.peerID) 774 | .forEach((peer) => { 775 | createOfferPeer(callID.current, 'screen', peer.peerID, peer.name, shareStream.current, ConnectType.share); 776 | }) 777 | 778 | // Analytics 779 | analytics.logEvent('share_screen', { 780 | name: userName.current, 781 | callID: callID.current, 782 | userID: user.current 783 | }); 784 | // Socket to create new peers for sharing when new user join in as well 785 | // Clear current listener so we don't pile on them 786 | // shareUserListener.current(); 787 | // const usersCollection = callDoc.collection('users'); 788 | // shareUserListener.current = usersCollection.onSnapshot(async (snapshot) => { 789 | // const ps = snapshot.docChanges() 790 | // .filter((change) => !session.current[change.doc.id] && change.doc.id !== user.current && change.doc.data().type !== ConnectType.share) 791 | // .map(async (change) => { 792 | // if (change.type === 'added') { 793 | // const newUserID = change.doc.id; 794 | // const data = change.doc.data() as User; 795 | // await createOfferPeer(callID.current, 'screen', newUserID, data.name, shareStream.current, ConnectType.share); 796 | // } 797 | // }); 798 | 799 | // await Promise.all(ps); 800 | // }); 801 | } 802 | catch (error) { 803 | logger(error); 804 | fireAlert('Failed to share screen. Please rejoin and try again.', ALERT_TYPE.error); 805 | setIsSharing(false); 806 | } 807 | } 808 | 809 | const handleRoomID = () => { 810 | if (clipboardRef.current) { 811 | if (clipboardRef.current.value.length > 0) { 812 | clipboardRef.current.select(); 813 | document.execCommand('copy'); 814 | fireAlert(`Call ID copied into clipboard.`, ALERT_TYPE.info); 815 | } 816 | } 817 | } 818 | 819 | const handleMute = async () => { 820 | localStream.current.getAudioTracks().forEach((track) => track.enabled = mute); 821 | 822 | await db.collection('calls').doc(callID.current).collection('users').doc(user.current).update({ mute: !mute }); 823 | setMute(!mute); 824 | } 825 | 826 | const handleEndCall = () => { 827 | shareStream.current.getTracks().forEach((track) => track.stop()); 828 | localStream.current.getTracks().forEach((track) => track.stop()); 829 | 830 | Object.keys(session.current).forEach((key) => { 831 | deleteSessionByKey(key); 832 | }); 833 | globalListeners.current.forEach((listener) => listener()); 834 | 835 | // Analytics 836 | analytics.logEvent('end_call', { 837 | name: userName.current, 838 | callID: callID.current, 839 | userID: user.current 840 | }); 841 | 842 | fireAlert('Ending call...', ALERT_TYPE.info); 843 | setTimeout(() => { 844 | history.push(ROUTES.JOIN); 845 | }, 2000); 846 | }; 847 | 848 | const toggleWebcam = async () => { 849 | if (!webcam) { 850 | localStream.current.getVideoTracks().forEach((videoTrack) => videoTrack.enabled = true); 851 | localVideo.current!.srcObject = localStream.current; 852 | } 853 | else { 854 | localStream.current.getVideoTracks().forEach((videoTrack) => videoTrack.enabled = false); 855 | localVideo.current!.srcObject = null; 856 | } 857 | 858 | setWebcam(!webcam); 859 | } 860 | 861 | return ( 862 | <> 863 |
864 | { 865 | isSharing 866 | ? ( 867 |
868 |
869 | 870 | 871 | 872 |
873 | 879 |
880 | ) 881 | : '' 882 | } 883 |
884 | { 885 | Object.values( 886 | sessionState 887 | ) 888 | .filter((peer) => peer.peerID !== user.current && peer.type !== ConnectType.share) 889 | .map((peer) =>
954 | 955 | { 956 | !isSharing 957 | ? ( 958 |
959 | 965 |
966 | ) 967 | : '' 968 | } 969 | 970 |
971 | {}}/> 972 | 973 | 974 | ) 975 | } --------------------------------------------------------------------------------