├── .eslintrc.js ├── .github └── workflows │ ├── deploy_backend.yml │ ├── deploy_frontend.yml │ └── release.yml ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg └── pre-commit ├── .lintstagedrc.js ├── .prettierrc.json ├── .tool-versions ├── .vscode └── settings.json ├── README.md ├── commitlint.config.js ├── package.json ├── packages ├── backend │ ├── Procfile │ ├── https │ │ ├── domain.crt │ │ ├── domain.csr │ │ └── domain.key │ ├── index.js │ └── package.json └── frontend │ ├── .gitignore │ ├── components │ ├── errorModal.jsx │ ├── externalLink.jsx │ ├── footer.jsx │ ├── localVideos.jsx │ ├── modal.jsx │ ├── navbar.jsx │ ├── remoteStreams.jsx │ ├── remoteVideos.jsx │ ├── roomDetails.jsx │ ├── userDetails.jsx │ ├── video.jsx │ └── vlogVideo.jsx │ ├── hooks │ ├── pageVisibility.js │ ├── socketConnection.js │ └── useGetDevices.js │ ├── next.config.js │ ├── package.json │ ├── pages │ ├── [roomName].jsx │ ├── _app.jsx │ ├── api │ │ └── hello.js │ ├── index.jsx │ └── meeting.jsx │ ├── postcss.config.js │ ├── public │ ├── 114x114.png │ ├── 120x120.png │ ├── 128x128.png │ ├── 144x144.png │ ├── 152x152.png │ ├── 180x180.png │ ├── 57x57-no-bg.png │ ├── 57x57.png │ ├── 72x72.png │ ├── 76x76.png │ ├── brand-1200x600.png │ ├── brand-192x192.png │ ├── brand-200x200.png │ ├── brand-430x495.png │ ├── brand-512x512.png │ ├── brand-800x800.png │ ├── favicon.ico │ ├── robots.txt │ ├── sitemap.xml │ └── virtual-bgs │ │ └── 1.jpg │ ├── sitemap-generator.js │ ├── store │ └── states.js │ ├── styles │ └── globals.scss │ ├── tailwind.config.js │ └── utils │ ├── classNames.js │ ├── constants.js │ └── helpers.js └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | "plugin:react/recommended", 4 | "eslint:recommended", 5 | "plugin:import/errors", 6 | "plugin:import/warnings", 7 | "react-app", 8 | ], 9 | env: { 10 | browser: true, 11 | commonjs: true, 12 | es6: true, 13 | jest: true, 14 | node: true, 15 | }, 16 | settings: { 17 | react: { 18 | version: "detect", 19 | }, 20 | "import/resolver": { 21 | node: { 22 | extensions: [".js", ".jsx"], 23 | }, 24 | }, 25 | }, 26 | parser: "babel-eslint", 27 | parserOptions: { 28 | ecmaVersion: 2017, 29 | sourceType: "module", 30 | ecmaFeatures: { 31 | jsx: true, 32 | }, 33 | }, 34 | rules: { 35 | "no-alert": 0, 36 | "no-console": ["error", { allow: ["error", "info", "debug"] }], 37 | "react/prop-types": 0, 38 | "react/sort-prop-types": 0, 39 | "import/order": 0, 40 | "react-hooks/rules-of-hooks": "error", 41 | "react-hooks/exhaustive-deps": "warn", 42 | "react/no-children-prop": "error", 43 | "react/jsx-no-target-blank": "error", 44 | "react/jsx-key": "error", 45 | "react/react-in-jsx-scope": "off", 46 | "react/jsx-uses-react": "off", 47 | "react/jsx-tag-spacing": ["error"], 48 | "react/jsx-filename-extension": ["error"], 49 | "no-useless-constructor": ["error"], 50 | eqeqeq: ["error"], 51 | "default-case": "off", 52 | "jsx-a11y/anchor-is-valid": "error", 53 | "jsx-a11y/img-redundant-alt": "error", 54 | "jsx-a11y/alt-text": "error", 55 | "no-useless-concat": "error", 56 | "no-unused-vars": "error", 57 | "no-multiple-empty-lines": [ 58 | "error", 59 | { 60 | max: 1, 61 | maxEOF: 1, 62 | maxBOF: 0, 63 | }, 64 | ], 65 | camelcase: 0, 66 | semi: ["error", "always"], 67 | }, 68 | globals: { 69 | globalThis: true, 70 | bodyPix: true, 71 | }, 72 | plugins: ["react", "react-hooks", "jsx-a11y", "import"], 73 | overrides: [ 74 | { 75 | files: ["webpack/build.js", "webpack/start.js"], 76 | rules: { 77 | "no-console": "off", 78 | }, 79 | }, 80 | ], 81 | ignorePatterns: ["stories/**/*", "node_modules/**/*", "build/**/*", "canvasjs.min.jsx"], 82 | }; 83 | -------------------------------------------------------------------------------- /.github/workflows/deploy_backend.yml: -------------------------------------------------------------------------------- 1 | name: deploy_backend 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - declarative-rtc 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout source code 13 | uses: actions/checkout@v1 14 | 15 | - name: Add remote origin 16 | run: git remote add heroku https://heroku:${{ secrets.HEROKU_API_KEY }}@git.heroku.com/webrtc-next-demo.git 17 | 18 | - name: Deploy backend to heroku 19 | run: git push --force heroku `git subtree split --prefix packages/backend HEAD`:refs/heads/master 20 | -------------------------------------------------------------------------------- /.github/workflows/deploy_frontend.yml: -------------------------------------------------------------------------------- 1 | name: deploy_frontend 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - declarative-rtc 8 | 9 | jobs: 10 | build: 11 | name: Deploy to openrtc.vercel.app 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | 17 | - name: Setup Node 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: "14.x" 21 | 22 | - name: Install 23 | run: | 24 | yarn --frozen-lockfile 25 | 26 | - name: Deploy 27 | uses: amondnet/vercel-action@v20 28 | with: 29 | vercel-token: ${{ secrets.VERCEL_TOKEN }} 30 | vercel-args: "--prod" 31 | vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} 32 | vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - "*" 6 | 7 | jobs: 8 | changelog: 9 | runs-on: ubuntu-latest 10 | name: create release on tag 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | # This action generates changelog which then the release action consumes 16 | - name: Conventional Changelog Action 17 | id: changelog 18 | uses: TriPSs/conventional-changelog-action@v3 19 | with: 20 | github-token: ${{ secrets.github_token }} 21 | skip-commit: 'true' 22 | 23 | - name: Create Release 24 | uses: actions/create-release@v1 25 | if: ${{ steps.changelog.outputs.skipped == 'false' }} 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.github_token }} 28 | with: 29 | tag_name: ${{ steps.changelog.outputs.tag }} 30 | release_name: ${{ steps.changelog.outputs.tag }} 31 | body: ${{ steps.changelog.outputs.clean_changelog }} 32 | -------------------------------------------------------------------------------- /.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 | # next.js 12 | **/.next/ 13 | **/out/ 14 | 15 | # production 16 | **/build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | # Created by https://www.toptal.com/developers/gitignore/api/macos 38 | # Edit at https://www.toptal.com/developers/gitignore?templates=macos 39 | 40 | ### macOS ### 41 | # General 42 | .DS_Store 43 | .AppleDouble 44 | .LSOverride 45 | 46 | # Icon must end with two \r 47 | Icon 48 | 49 | 50 | # Thumbnails 51 | ._* 52 | 53 | # Files that might appear in the root of a volume 54 | .DocumentRevisions-V100 55 | .fseventsd 56 | .Spotlight-V100 57 | .TemporaryItems 58 | .Trashes 59 | .VolumeIcon.icns 60 | .com.apple.timemachine.donotpresent 61 | 62 | # Directories potentially created on remote AFP share 63 | .AppleDB 64 | .AppleDesktop 65 | Network Trash Folder 66 | Temporary Items 67 | .apdisk 68 | 69 | # End of https://www.toptal.com/developers/gitignore/api/macos 70 | 71 | # Created by https://www.toptal.com/developers/gitignore/api/vim 72 | # Edit at https://www.toptal.com/developers/gitignore?templates=vim 73 | 74 | ### Vim ### 75 | # Swap 76 | [._]*.s[a-v][a-z] 77 | !*.svg # comment out if you don't need vector files 78 | [._]*.sw[a-p] 79 | [._]s[a-rt-v][a-z] 80 | [._]ss[a-gi-z] 81 | [._]sw[a-p] 82 | 83 | # Session 84 | Session.vim 85 | Sessionx.vim 86 | 87 | # Temporary 88 | .netrwhist 89 | *~ 90 | # Auto-generated tag files 91 | tags 92 | # Persistent undo 93 | [._]*.un~ 94 | 95 | # End of https://www.toptal.com/developers/gitignore/api/vim 96 | 97 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit "" 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install lint-staged -p true 5 | -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "**/*.js?(x)": (filenames) => { 3 | let concatedFileNames = filenames 4 | .map((filename) => `"${filename}"`) 5 | .join(" "); 6 | return [ 7 | `prettier --write ${concatedFileNames}`, 8 | `eslint --fix ${concatedFileNames}`, 9 | ]; 10 | }, 11 | "**/*.js": (filenames) => { 12 | let concatedFileNames = filenames 13 | .map((filename) => `"${filename}"`) 14 | .join(" "); 15 | return [ 16 | `prettier --write ${concatedFileNames}`, 17 | `eslint --fix ${concatedFileNames}`, 18 | ]; 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120 3 | } -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 15.8.0 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "liveFrame.url": "http://localhost:3000", 3 | "liveFrame.title": "WebRTC" 4 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # An open source live screen share and webcam video recorder 2 | 3 | - [An open source live screen share and webcam video recorder](#an-open-source-live-screen-share-and-webcam-video-recorder) 4 | - [Deploy your instance](#deploy-your-instance) 5 | - [Features](#features) 6 | - [Contributions](#contributions) 7 | - [Requirements](#requirements) 8 | - [Code structure](#code-structure) 9 | - [How to run](#how-to-run) 10 | - [.env contents](#env-contents) 11 | - [How to build](#how-to-build) 12 | - [TO DO](#to-do) 13 | 14 | ## Deploy your instance 15 | 16 | 1. Deploy the backend project to some cloud (heroku etc) to consume websockets. 17 | 1. Deploy the frontend project to vercel with single click [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https%3A%2F%2Fgithub.com%2Ftechnikhil314%2Fnext-webrtc) 18 | 1. Add required env variables 19 | - NEXT_PUBLIC_WEBSOCKET_URL - URL to your websocket server. The backend deployment. If you want to use meeting feature 20 | - NEXT_PUBLIC_URL - URL of your site 21 | - NODE_ENV - `development` or `production` 22 | 23 | ## Features 24 | 25 | - Background removal 26 | - Virtual backgrounds 27 | - Background blur 28 | 29 | ## Contributions 30 | 31 | Welcome :) 32 | 33 | ### Requirements 34 | 35 | 1. Node >= v15.x 36 | 2. npm >= v7 37 | 3. yarn 1.x 38 | 39 | ### Code structure 40 | 41 | 1. It is a monorepo managed by yarn 42 | 2. There are two packages backend and frontend 43 | 3. Backend is just a small websocket server used for signaling 44 | 4. Frontend is actual UI built with nextjs 45 | 5. It uses google stun server for populating ice candidates 46 | 47 | ### How to run 48 | 49 | 1. Install all dependencies using `yarn --frozen-lockfile` 50 | 2. add `.env` see the contents [below](#env-contents) 51 | 3. run backend using `yarn workspaces @openrtc/backend start` 52 | 4. run frontend using `yarn workspaces @openrtc/frontend start` 53 | 54 | ### .env contents 55 | 56 | ```bash 57 | NEXT_PUBLIC_WEBSOCKET_URL=wss://localhost:4000/ 58 | NODE_ENV=development 59 | NEXT_PUBLIC_URL=http://localhost:3000 60 | ``` 61 | 62 | ### How to build 63 | 64 | 1. You dont need to build backend 65 | 1. You can build frontend package with `yarn workspaces @openrtc/frontend build` command 66 | 67 | ## TO DO 68 | 69 | 1. Handle if someone stops screen share in recording vlog 70 | - This currently stops recording 71 | - Ideally it should keep on recording but change video from screen to user 72 | 1. First time visitor faces lag in audio recording 73 | 1. First time visitor can not see small PiP video 74 | 1. Microphone volume control 75 | 1. Ability to name the video file - Currently it uses ISO date time string 76 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "openrtc", 3 | "version": "1.0.0", 4 | "description": "An open source webrtc demo built with nextjs and tailwind", 5 | "main": "index.js", 6 | "private": true, 7 | "workspaces": [ 8 | "packages/*" 9 | ], 10 | "engines": { 11 | "node": ">=14 <15" 12 | }, 13 | "keywords": [], 14 | "author": "technikhil314", 15 | "license": "ISC", 16 | "devDependencies": { 17 | "@commitlint/cli": "^12.0.1", 18 | "@commitlint/config-conventional": "^12.0.1", 19 | "babel-eslint": "^10.1.0", 20 | "eslint": "^7.21.0", 21 | "eslint-config-react-app": "^6.0.0", 22 | "eslint-plugin-flowtype": "^5.4.0", 23 | "eslint-plugin-import": "^2.22.1", 24 | "eslint-plugin-jsx-a11y": "^6.4.1", 25 | "eslint-plugin-react": "^7.22.0", 26 | "eslint-plugin-react-hooks": "^4.2.0", 27 | "husky": "^5.1.2", 28 | "lint-staged": "^10.5.4", 29 | "prettier": "^2.2.1" 30 | }, 31 | "scripts": { 32 | "prepare": "husky install" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/backend/Procfile: -------------------------------------------------------------------------------- 1 | web: node --optimize_for_size --max_old_space_size=460 --gc_interval=100 ./index.js 2 | -------------------------------------------------------------------------------- /packages/backend/https/domain.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDYDCCAkgCCQC5+N0+lvqB0zANBgkqhkiG9w0BAQsFADByMQswCQYDVQQGEwJJ 3 | TjELMAkGA1UECAwCTUgxCzAJBgNVBAcMAk9OMQswCQYDVQQKDAJOQTELMAkGA1UE 4 | CwwCTkExCzAJBgNVBAMMAk5BMSIwIAYJKoZIhvcNAQkBFhNkZW1vQG1haWxpbmF0 5 | b3IuY29tMB4XDTIxMDIwOTEzMzczMloXDTIyMDIwOTEzMzczMlowcjELMAkGA1UE 6 | BhMCSU4xCzAJBgNVBAgMAk1IMQswCQYDVQQHDAJPTjELMAkGA1UECgwCTkExCzAJ 7 | BgNVBAsMAk5BMQswCQYDVQQDDAJOQTEiMCAGCSqGSIb3DQEJARYTZGVtb0BtYWls 8 | aW5hdG9yLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANqRnudq 9 | KhaHEN9avRP/U0yhzCnqNXnofIvhwNIioy05flw4+8CLK8n3arM8+erUMZl1bnTq 10 | Xvz1Sn80hHzjlSXIt/1VwJ5jHojQlAjkOtqXrZV2469uZ2pF6rGhHc/aYdOvTZ/h 11 | KR0fLuVgmufYw4LG6IBlqFWdeeCTXal7nCuIbxF1UXMZeF9LOwcB/wk52YskTSns 12 | z0xAQ7lIF8uxx2aJ5t3957dtDhc2cN7m0NnYoWy6GIdJg/LAf5wckLChQv2EVh3D 13 | uJZUlj2zPqiNJQyVvISUGsHf7iaxB1jAMAa6wMXavCYxJACs+6FsYqkdGHGVIA1h 14 | Ne1tCAMAHeVBtwMCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAHSQin9Y8XkYL6u/6 15 | UawurexYrkSdrPhn6AyPbC/oUV9pzt4XmwjRfFFOl8VQzFv/3VTE1y2TaXuwZPY9 16 | PyM1g5BPZ9+TzGOM9rydNUSqJ4UnLXVjtHEeb8lRINefVqpuztBBVVvikpafh02w 17 | 3/m968TqSG8UXJtT82VEeZ+6LjD22Q0UU+msrYqpKMU937s3iPxU16z9wXjqwe6o 18 | /oqbNJVJ6uFb9fPpnGkulUhuUNmNf9Ug+Zq+EzrkXwY8AWimLCft7xkkL+mM/PWt 19 | 4YZHbJe4sU7Va5DLdbdGtk0tl8jgwlhGN14x064AmwwsL8RX9x7tr3sA1Nd4CCJO 20 | MmftlA== 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /packages/backend/https/domain.csr: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE REQUEST----- 2 | MIICtzCCAZ8CAQAwcjELMAkGA1UEBhMCSU4xCzAJBgNVBAgMAk1IMQswCQYDVQQH 3 | DAJQTjELMAkGA1UECgwCTkExCzAJBgNVBAsMAk5BMQswCQYDVQQDDAJOQTEiMCAG 4 | CSqGSIb3DQEJARYTZGVtb0BtYWlsaW5hdG9yLmNvbTCCASIwDQYJKoZIhvcNAQEB 5 | BQADggEPADCCAQoCggEBAMNjpZ2fLycdWE06TwmAJ/e/58YEGX99Y66ODpLI91is 6 | o70ILcVYYre5YAGjvkn0Zn9F/uyuqQV69UTxdrsVWN23Y8JPOc+oqKBsk3azN8Ow 7 | wTkF125Iy4aN1W3gkXwpiSv+I1kP+UqZUGRdvyw2Y5RW7eC+Ygm2XFvQPUwD3i6I 8 | ZtiPy8aS3+QFLomu9zRTmkDGN4CXVgX4aepkSOryBk6SOBYbo2y89WH2V6WOpY0T 9 | TfjutPlO2+b8lKZ9K+sP16cWSJLbLaWOyu/HQWXrdh8OerrPsoBCNdDZAUwrDLI2 10 | ZdDArV4N8JAZdnlQk5xqPUFeKJtNtr/b1vtFJ9ybLmkCAwEAAaAAMA0GCSqGSIb3 11 | DQEBCwUAA4IBAQBUp34A+WFvIhxR/pFHzRoTk+eQIudQEXdtz0MdUKEX4h7fkSR9 12 | lWUvUnVhkZDC9pfG5HKQwrxEnfXYdTjImTU/4HvEeWATLcvKs0AJEdRlOPRxmj38 13 | FWcSz71KoMh4xajgKfHtfD6SbNPLXhtW+di4thlIK+oDxVfn7M7FzmBWWSnnjlHy 14 | R8be5VRbo4dvscJPoWInVEUVmj1qQBOaLHBydUhHVJoMmoOoezKJy9wJC0hjj5EX 15 | vCWBxdLFcvd0v91Y2Xe56r4zKgW37VSiubJRX5vaAhBJNn0Az1Xh/GI/BdTTZA5v 16 | AMQzFjTH40WfnkqxCbFsXceqK6K9HA9ngn+h 17 | -----END CERTIFICATE REQUEST----- 18 | -------------------------------------------------------------------------------- /packages/backend/https/domain.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDakZ7naioWhxDf 3 | Wr0T/1NMocwp6jV56HyL4cDSIqMtOX5cOPvAiyvJ92qzPPnq1DGZdW506l789Up/ 4 | NIR845UlyLf9VcCeYx6I0JQI5Dral62VduOvbmdqReqxoR3P2mHTr02f4SkdHy7l 5 | YJrn2MOCxuiAZahVnXngk12pe5wriG8RdVFzGXhfSzsHAf8JOdmLJE0p7M9MQEO5 6 | SBfLscdmiebd/ee3bQ4XNnDe5tDZ2KFsuhiHSYPywH+cHJCwoUL9hFYdw7iWVJY9 7 | sz6ojSUMlbyElBrB3+4msQdYwDAGusDF2rwmMSQArPuhbGKpHRhxlSANYTXtbQgD 8 | AB3lQbcDAgMBAAECggEAN4gmEm/Tz7NzYvw/6/PEK0U0hTW/boDWHAg4oGsygEwK 9 | Qc4skIgLkR3umymT6ckN7Qp8DPJ7PdPooJcsTjrYaygrLw95iARw/pvX8MZj+aPt 10 | 83qocKshVUv8TDM+StAWaN0yYLupYX/Jv3ejA+L8EZ8N9YlekpoXHCr08hm43Qot 11 | rqKLCO4GA4cTQAh4KQmkEUldXMfIJAsp8iloraIo4LRHEiAM8OfbgPbyAPjQrx4H 12 | a1QAre4+Uz4o2lhkQma3d/T3r1EAkhwrr80etBrei+PSeD9D6y678UmvOsD0CaoI 13 | rRsBDSpjDUZQHd4d0m6w7ycwNL4leD3DGfhfeeKScQKBgQD4TJfb59EAfqqaBhsA 14 | HRHckFbo6IuyH48p4hVHebLndk0u2JbTO2dtvB/9a5TK5uZkRSLEbzhA7VNGnx9D 15 | D/ZNOlfDJRvDdfo2xT0DS082ZC40rE104FTt6pA+xj6Sa8N1CR8TeGU03JT2E9Dc 16 | tTWbD1DpncpUAAzKDpSGkv/xqwKBgQDhWPqlGvjIieoHrCW0iHioBYbrp+y7BtnA 17 | S3hS+MPIauhU+QJMqdNYe6TVkk1+OXFI/MwrnTYcRSV0BLQkbWEGubZN0Aql2p5U 18 | xTbN9MdyW+0dHUPktaONT31/tL4OSmMsUzkcrTC12HoHHlq5ho2ixuPuq0SAwTeK 19 | YqHm9P6oCQKBgEzzt/DAVIbZutfHYqDTYZDA7x55y6mlnEH3vm6LagXQJTWKjJvk 20 | gjaBIkzxBYkorGiRAKhua7m7k56EfDTVgpkGpuJk4sjeDHDjCfi2Y1NREvziFZNO 21 | XyPpGVFLMWNBoK7p58ap/nu2jTgChi2Qv49R3Nq6O1VzOoN4p1FZx5bHAoGACGg2 22 | aaR49ZpXldOxUGvq/HHAV9ha95tI0mi+Y3IOc9KxOkJT+KI5VUq/mowrwfLIrC1q 23 | PJJP63wU6qAmTFmcThDtoTeKvidK0uTMp6BjNHwDe5uU5dp08Jevme0XThcuXf/4 24 | 2H4JnC8oVk2mmtdPP2xmIohXNOqAdPQ7EA/B1GkCgYBDC0rdfSIlN9oOfxWVoyKT 25 | bwN7CA/UzQJtwU1FxhsY8gK4kwdUM9nAL4gn2ensJGbKfkgP4pYxNr98bhNF49Vs 26 | Y9CLjpJ11GkrHhcpc8rm5o7KYcVPq5eqV7j8DCga2kWmasCcnOeQCkrAZYY8p9JM 27 | pTM4OzFfMbdTzSyHZpsYQQ== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /packages/backend/index.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | const cryptoRandomString = require("crypto-random-string"); 4 | let rooms = {}; 5 | const fastifyOptions = { 6 | logger: true, 7 | }; 8 | if (process.env.NODE_ENV !== "production") { 9 | fastifyOptions.https = { 10 | key: fs.readFileSync(path.join(__dirname, "https", "domain.key")), 11 | cert: fs.readFileSync(path.join(__dirname, "https", "domain.crt")), 12 | }; 13 | } 14 | const fastify = require("fastify")(fastifyOptions); 15 | fastify.register(require("fastify-websocket")); 16 | 17 | fastify.get("/", { websocket: true }, ( 18 | connection /* SocketStream */, 19 | req /* FastifyRequest */, 20 | reply 21 | ) => { 22 | let currentRoom = []; 23 | function handleConnect(serializedData) { 24 | const socket = connection.socket; 25 | const data = JSON.parse(serializedData); 26 | let currentRoomId = data.roomId, 27 | currentUserId; 28 | if (!currentRoomId) { 29 | currentRoomId = cryptoRandomString({ length: 10 }); 30 | rooms[currentRoomId] = []; 31 | currentRoom = rooms[currentRoomId]; 32 | } else if (!rooms[currentRoomId]) { 33 | rooms[currentRoomId] = []; 34 | } else { 35 | currentRoom = rooms[currentRoomId]; 36 | } 37 | currentRoom = rooms[currentRoomId]; 38 | currentRoom.push(socket); 39 | currentUserId = currentRoom.length - 1; 40 | socket.send( 41 | JSON.stringify({ 42 | rtcContent: "connectSuccess", 43 | roomId: currentRoomId, 44 | userId: currentUserId, 45 | }) 46 | ); 47 | return currentUserId; 48 | } 49 | 50 | function broadcastNewConnection(userId, userName) { 51 | console.info("newPeer", userId); 52 | currentRoom.forEach((socket, index) => { 53 | index !== userId && 54 | socket.send( 55 | JSON.stringify({ 56 | rtcContent: "newPeer", 57 | by: userId, 58 | userName: userName, 59 | }) 60 | ); 61 | }); 62 | } 63 | 64 | connection.socket.on("message", (serializedData) => { 65 | fastify.log.info(serializedData); 66 | const { type, ...rest } = JSON.parse(serializedData); 67 | if (type === "message") { 68 | console.info( 69 | `from ${rest.by} to ${rest.to} of type ${rest.rtcContent || type}` 70 | ); 71 | } 72 | switch (type) { 73 | case "connect": { 74 | const userId = handleConnect(serializedData); 75 | broadcastNewConnection(userId, rest.userName); 76 | break; 77 | } 78 | case "message": { 79 | const destinationUser = currentRoom[rest.to]; 80 | destinationUser.send(serializedData); 81 | break; 82 | } 83 | } 84 | }); 85 | connection.socket.on("close", () => { 86 | const disconnectedUserId = currentRoom.indexOf(connection.socket); 87 | fastify.log.info({ 88 | msg: `Client disconnected ${disconnectedUserId}`, 89 | }); 90 | currentRoom.forEach((socket, index) => { 91 | index !== disconnectedUserId && 92 | socket.send( 93 | JSON.stringify({ 94 | rtcContent: "deletedPeer", 95 | peerId: disconnectedUserId, 96 | }) 97 | ); 98 | }); 99 | }); 100 | }); 101 | // Run the server! 102 | fastify.listen(process.env.PORT || 4000, "0.0.0.0", function (err, address) { 103 | if (err) { 104 | fastify.log.error(err); 105 | process.exit(1); 106 | } 107 | fastify.log.info(`server listening on ${address}`); 108 | }); 109 | -------------------------------------------------------------------------------- /packages/backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@openrtc/backend", 3 | "version": "1.0.0", 4 | "description": "Backend for openrtc", 5 | "private": true, 6 | "main": "index.js", 7 | "scripts": { 8 | "start": "npx nodemon ./index.js" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "crypto-random-string": "^3.3.0", 15 | "fastify": "^3.11.0", 16 | "fastify-websocket": "^3.0.0", 17 | "nodemon": "^2.0.7", 18 | "socket-io": "^1.0.0" 19 | } 20 | } -------------------------------------------------------------------------------- /packages/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | -------------------------------------------------------------------------------- /packages/frontend/components/errorModal.jsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | import Modal from "../components/modal"; 3 | export default function ErrorModal(params) { 4 | const router = useRouter(); 5 | const onModalClose = () => { 6 | router.push("/meeting"); 7 | }; 8 | return ( 9 | 10 |

11 | Opps.... Your browser does not support required features to record video.
We recommend using latest 12 | version of chrome dekstop. 13 |

14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /packages/frontend/components/externalLink.jsx: -------------------------------------------------------------------------------- 1 | export default function ExternalLink({ href, className, children }) { 2 | return ( 3 | 9 | {children} 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /packages/frontend/components/footer.jsx: -------------------------------------------------------------------------------- 1 | import ExternalLink from "./externalLink"; 2 | 3 | export default function Footer() { 4 | return ( 5 | 157 | ); 158 | } 159 | -------------------------------------------------------------------------------- /packages/frontend/components/localVideos.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import { useRhinoState } from "../store/states"; 3 | import { userMediaConstraints } from "../utils/constants"; 4 | export const LocalVideo = ({ isVlog }) => { 5 | const localVideoElement = useRef(); 6 | const [localStream, setLocalStream] = useRhinoState("localStream"); 7 | const [shareScreen, setShareScreen] = useRhinoState("shareScreen"); 8 | const [isStarted] = useRhinoState("isStarted"); 9 | 10 | useEffect(() => { 11 | let stream = {}, 12 | normalLocalStream; 13 | (async () => { 14 | let videoTrack, audioTrack; 15 | let normalLocalStream = await navigator.mediaDevices.getUserMedia(userMediaConstraints); 16 | if (shareScreen) { 17 | stream = await navigator.mediaDevices.getDisplayMedia({ 18 | video: true, 19 | }); 20 | stream.oninactive = () => { 21 | setShareScreen(false); 22 | }; 23 | videoTrack = stream.getVideoTracks()[0]; 24 | } else { 25 | videoTrack = normalLocalStream.getVideoTracks()[0]; 26 | } 27 | audioTrack = normalLocalStream.getAudioTracks()[0]; 28 | setLocalStream(new MediaStream([audioTrack, videoTrack])); 29 | })(); 30 | return () => { 31 | normalLocalStream && normalLocalStream.getTracks().forEach((x) => x.stop()); 32 | }; 33 | }, [shareScreen, isStarted, isVlog]); 34 | useEffect(() => { 35 | if (isStarted) { 36 | localVideoElement.current.srcObject = localStream; 37 | localVideoElement.current.play(); 38 | } 39 | return async () => { 40 | localStream && localStream.getTracks().forEach((x) => x.stop()); 41 | }; 42 | }, [localStream]); 43 | return isStarted ? ( 44 |
{ 50 | var style = getComputedStyle(event.target, null); 51 | event.dataTransfer.setData( 52 | "text/plain", 53 | `localVideo,${parseInt(style.getPropertyValue("left"), 10) - event.clientX},${ 54 | parseInt(style.getPropertyValue("top"), 10) - event.clientY 55 | }` 56 | ); 57 | }} 58 | > 59 | 68 |
69 | ) : null; 70 | }; 71 | 72 | LocalVideo.displayName = "LocalVideo"; 73 | -------------------------------------------------------------------------------- /packages/frontend/components/modal.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | export default function Modal({ title, children, onClose = () => {} }) { 4 | const [isOpen, setIsOpen] = useState(true); 5 | const dialogProp = isOpen 6 | ? { 7 | open: isOpen, 8 | } 9 | : {}; 10 | const closeModal = () => { 11 | setIsOpen(false); 12 | onClose(); 13 | }; 14 | return ( 15 |
16 |
17 | 21 |

25 | {title} 26 |

27 | 30 |
{children}
31 |
32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /packages/frontend/components/navbar.jsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { useState } from "react"; 3 | import { classNames } from "../utils/classNames"; 4 | import { text } from "../utils/constants"; 5 | import ExternalLink from "./externalLink"; 6 | import { useRouter } from "next/router"; 7 | export default function Navbar() { 8 | const [isOpen, setIsOpen] = useState(false); 9 | const router = useRouter(); 10 | return ( 11 | <> 12 | 13 |

14 | 15 | 19 | 20 |

21 |
22 | 109 | 110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /packages/frontend/components/remoteStreams.jsx: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useState } from "react"; 2 | import { useRhinoState } from "../store/states"; 3 | import { iceConfig } from "../utils/constants"; 4 | import RemoteVideos from "./remoteVideos"; 5 | export const RemoteStreams = ({ myUserId, socket }) => { 6 | const [peers, setPeers] = useState({}); 7 | const [userNames, setUserNames] = useState([]); 8 | const [localStream] = useRhinoState("localStream"); 9 | const [userName] = useRhinoState("userName"); 10 | const getPeerConnection = (peerId) => { 11 | const peer = peers[peerId]; 12 | let sendChannel, receiveChannel; 13 | if (!peer) { 14 | const newPeerConnection = new RTCPeerConnection(iceConfig); 15 | const channelCallback = (event) => { 16 | receiveChannel = event.channel; 17 | receiveChannel.onmessage = (event) => { 18 | setUserNames((userNames) => { 19 | userNames[peerId] = JSON.parse(event.data).userName; 20 | return [...userNames]; 21 | }); 22 | }; 23 | }; 24 | sendChannel = newPeerConnection.createDataChannel("sendChannel"); 25 | sendChannel.onopen = () => { 26 | sendChannel.send( 27 | JSON.stringify({ 28 | userName, 29 | }) 30 | ); 31 | }; 32 | newPeerConnection.ondatachannel = channelCallback; 33 | let camVideoTrack = localStream.getVideoTracks()[0]; 34 | let camAudioTrack = localStream.getAudioTracks()[0]; 35 | newPeerConnection.addTrack(camVideoTrack, localStream); 36 | newPeerConnection.addTrack(camAudioTrack, localStream); 37 | const setNewPeers = (stream) => { 38 | setPeers((peers) => { 39 | if (!peers[peerId] || stream) { 40 | peers[peerId] = { 41 | connection: newPeerConnection, 42 | sendChannel: sendChannel, 43 | receiveChannel: receiveChannel, 44 | stream, 45 | }; 46 | return { 47 | ...peers, 48 | }; 49 | } 50 | return peers; 51 | }); 52 | }; 53 | newPeerConnection.onicecandidate = (e) => { 54 | socket.send( 55 | JSON.stringify({ 56 | by: myUserId, 57 | to: peerId, 58 | ice: e.candidate, 59 | type: "message", 60 | rtcContent: "ice", 61 | }) 62 | ); 63 | setNewPeers(); 64 | }; 65 | newPeerConnection.onaddstream = (event) => { 66 | setNewPeers(event.stream); 67 | }; 68 | newPeerConnection.onremovestream = (event) => { 69 | setPeers((peers) => { 70 | peers[peerId].stream = null; 71 | return { 72 | ...peers, 73 | }; 74 | }); 75 | }; 76 | setNewPeers(); 77 | peers[peerId] = { 78 | connection: newPeerConnection, 79 | sendChannel: sendChannel, 80 | receiveChannel: receiveChannel, 81 | }; 82 | } 83 | return peers[peerId]; 84 | }; 85 | const makeOffer = ({ by }) => { 86 | if (by === myUserId) { 87 | return; 88 | } 89 | let currentPeerConnection = getPeerConnection(by).connection; 90 | currentPeerConnection.createOffer( 91 | (sdp) => { 92 | currentPeerConnection.setLocalDescription(sdp); 93 | socket.send( 94 | JSON.stringify({ 95 | by: myUserId, 96 | to: by, 97 | sdp: sdp, 98 | type: "message", 99 | rtcContent: "peerOffer", 100 | }) 101 | ); 102 | }, 103 | (error) => { 104 | console.error(error, "createOffer"); 105 | }, 106 | { mandatory: { offerToReceiveVideo: true, offerToReceiveAudio: true } } 107 | ); 108 | }; 109 | const setRemoteDescription = ({ by, sdp }) => { 110 | return new Promise((resolve, reject) => { 111 | const currentPeerConnection = getPeerConnection(by).connection; 112 | currentPeerConnection.setRemoteDescription( 113 | new RTCSessionDescription(sdp), 114 | () => { 115 | resolve(); 116 | }, 117 | (error) => { 118 | console.error(error, "setRemoteDescription"); 119 | reject(); 120 | } 121 | ); 122 | }); 123 | }; 124 | const sendAnswer = ({ by }) => { 125 | const currentPeerConnection = getPeerConnection(by).connection; 126 | currentPeerConnection.createAnswer( 127 | (sdp) => { 128 | currentPeerConnection.setLocalDescription(sdp); 129 | socket.send( 130 | JSON.stringify({ 131 | by: myUserId, 132 | to: by, 133 | sdp: sdp, 134 | type: "message", 135 | rtcContent: "peerAnswer", 136 | }) 137 | ); 138 | }, 139 | (e) => { 140 | console.error(e, "setLocalDescription"); 141 | } 142 | ); 143 | }; 144 | const addIceCandidate = ({ by, ice }) => { 145 | const currentPeerConnection = getPeerConnection(by).connection; 146 | ice && currentPeerConnection.addIceCandidate(new RTCIceCandidate(ice)); 147 | }; 148 | const deletePeer = ({ peerId }) => { 149 | setPeers((peers) => { 150 | delete peers[peerId]; 151 | return { 152 | ...peers, 153 | }; 154 | }); 155 | }; 156 | const addSocketMessageHandlers = () => { 157 | socket.onmessage = async function (event) { 158 | const { rtcContent, type, ...rest } = JSON.parse(event.data); 159 | switch (rtcContent) { 160 | case "newPeer": 161 | makeOffer(rest); 162 | break; 163 | case "peerOffer": 164 | await setRemoteDescription(rest); 165 | sendAnswer(rest); 166 | break; 167 | case "peerAnswer": 168 | await setRemoteDescription(rest); 169 | break; 170 | case "ice": 171 | addIceCandidate(rest); 172 | break; 173 | case "deletedPeer": 174 | deletePeer(rest); 175 | break; 176 | } 177 | }; 178 | }; 179 | useLayoutEffect(() => { 180 | addSocketMessageHandlers(); 181 | }, []); 182 | useLayoutEffect(() => { 183 | for (let prop in peers) { 184 | const peerConnection = peers[prop].connection; 185 | let videoSender = peerConnection 186 | .getSenders() 187 | .find((x) => x.track.kind === "video"); 188 | if (localStream) { 189 | let newVideoTrack = localStream.getVideoTracks()[0]; 190 | videoSender.replaceTrack(newVideoTrack); 191 | } 192 | } 193 | }, [localStream]); 194 | return ( 195 |
196 | 197 |
198 | ); 199 | }; 200 | -------------------------------------------------------------------------------- /packages/frontend/components/remoteVideos.jsx: -------------------------------------------------------------------------------- 1 | import Video from "./video"; 2 | 3 | export default function RemoteVideos({ peers, userNames }) { 4 | const remoteVideos = []; 5 | for (let peerId in peers) { 6 | let peer = peers[peerId]; 7 | remoteVideos.push( 8 |