├── example.env
├── public
└── favicon.ico
├── .gitignore
├── remix.env.d.ts
├── vercel.json
├── app
├── entry.client.tsx
├── components
│ ├── Header.tsx
│ ├── Room.tsx
│ ├── Live.tsx
│ ├── Avatar.tsx
│ ├── Footer.tsx
│ ├── Join.tsx
│ └── Conference.tsx
├── entry.server.tsx
├── root.tsx
├── routes
│ └── index.tsx
├── meeting.tsx
└── styles
│ └── global.css
├── api
└── index.js
├── remix.config.js
├── tsconfig.json
├── README.md
├── package.json
└── LICENSE
/example.env:
--------------------------------------------------------------------------------
1 | # Get it from https://dashboard.100ms.live/developer
2 | HMS_TOKEN_ENDPOINT=
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Deep-Codes/remix-video-chat/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | .cache
4 | .vercel
5 | .output
6 |
7 | public/build
8 | api/_build
9 | .env
--------------------------------------------------------------------------------
/remix.env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "build": {
3 | "env": {
4 | "ENABLE_FILE_SYSTEM_API": "1"
5 | }
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/app/entry.client.tsx:
--------------------------------------------------------------------------------
1 | import { hydrate } from 'react-dom';
2 | import { RemixBrowser } from "@remix-run/react";
3 |
4 | hydrate(, document);
5 |
--------------------------------------------------------------------------------
/api/index.js:
--------------------------------------------------------------------------------
1 | const { createRequestHandler } = require("@remix-run/vercel");
2 |
3 | module.exports = createRequestHandler({
4 | build: require("./_build")
5 | });
6 |
--------------------------------------------------------------------------------
/app/components/Header.tsx:
--------------------------------------------------------------------------------
1 | const Header = () => {
2 | return (
3 |
6 | );
7 | };
8 |
9 | export default Header;
10 |
--------------------------------------------------------------------------------
/app/components/Room.tsx:
--------------------------------------------------------------------------------
1 | import Conference from './Conference';
2 | import Footer from './Footer';
3 | import Header from './Header';
4 |
5 | const Room = () => {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 | );
13 | };
14 |
15 | export default Room;
16 |
--------------------------------------------------------------------------------
/remix.config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import('@remix-run/dev/config').AppConfig}
3 | */
4 | module.exports = {
5 | appDirectory: 'app',
6 | assetsBuildDirectory: 'public/build',
7 | publicPath: '/build/',
8 | serverBuildDirectory: 'api/_build',
9 | ignoredRouteFiles: ['.*'],
10 | routes(defineRoutes) {
11 | return defineRoutes((route) => {
12 | route('/meeting/*', 'meeting.tsx');
13 | });
14 | },
15 | };
16 |
--------------------------------------------------------------------------------
/app/components/Live.tsx:
--------------------------------------------------------------------------------
1 | import { selectIsConnectedToRoom, useHMSStore } from '@100mslive/react-sdk';
2 | import React from 'react';
3 | import Join from '~/components/Join';
4 | import Room from '~/components/Room';
5 |
6 | const Live: React.FC<{ token: string }> = ({ token }) => {
7 | const isConnected = useHMSStore(selectIsConnectedToRoom);
8 | return {isConnected ? : }
;
9 | };
10 |
11 | export default Live;
12 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
3 | "compilerOptions": {
4 | "lib": ["DOM", "DOM.Iterable", "ES2019"],
5 | "isolatedModules": true,
6 | "esModuleInterop": true,
7 | "jsx": "react-jsx",
8 | "moduleResolution": "node",
9 | "resolveJsonModule": true,
10 | "target": "ES2019",
11 | "strict": true,
12 | "baseUrl": ".",
13 | "paths": {
14 | "~/*": ["./app/*"]
15 | },
16 | "noEmit": true,
17 | "allowJs": true,
18 | "forceConsistentCasingInFileNames": true
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Remix Video Chat
2 |
3 | Video chat app with Remix and 100ms React SDK in less than 100 lines of code.
4 |
5 | Try out the [Public demo](https://remix-video-chat.vercel.app/meeting/6210d32e71bd215ae0423a3c/host)
6 |
7 | [Read this Detailed blog](http://dpnkr.in/blog/remix-video-chat-app) for understanding the code and setup.
8 |
9 | [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDeep-Codes%2Fremix-video-chat&env=HMS_TOKEN_ENDPOINT&envDescription=Token%20endpoint%20can%20be%20found%20in%20developer%20section%20of%20100ms%20Dashboard.&envLink=https%3A%2F%2Fdashboard.100ms.live%2Fdeveloper)
10 |
--------------------------------------------------------------------------------
/app/entry.server.tsx:
--------------------------------------------------------------------------------
1 | import { renderToString } from "react-dom/server";
2 | import { RemixServer } from "@remix-run/react";
3 | import type { EntryContext } from "@remix-run/node";
4 |
5 | export default function handleRequest(
6 | request: Request,
7 | responseStatusCode: number,
8 | responseHeaders: Headers,
9 | remixContext: EntryContext
10 | ) {
11 | const markup = renderToString(
12 |
13 | );
14 |
15 | responseHeaders.set("Content-Type", "text/html");
16 |
17 | return new Response("" + markup, {
18 | status: responseStatusCode,
19 | headers: responseHeaders
20 | });
21 | }
22 |
--------------------------------------------------------------------------------
/app/components/Avatar.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const Avatar: React.FC<{ name: string }> = ({ name }) => {
4 | const initials = getInitials(name);
5 | return {initials};
6 | };
7 |
8 | // @see https://stackoverflow.com/questions/33076177/getting-name-initials-using-js
9 | const getInitials = (name: string) => {
10 | const allNames = name.trim().split(' ');
11 | const initials = allNames.reduce((acc, curr, index) => {
12 | if (index === 0 || index === allNames.length - 1) {
13 | acc = `${acc}${curr.charAt(0).toUpperCase()}`;
14 | }
15 | return acc;
16 | }, '');
17 | return initials;
18 | };
19 |
20 | export default Avatar;
21 |
--------------------------------------------------------------------------------
/app/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { useAVToggle, useHMSActions } from '@100mslive/react-sdk';
2 | import {
3 | MicOffIcon,
4 | MicOnIcon,
5 | VideoOffIcon,
6 | VideoOnIcon,
7 | HangUpIcon,
8 | } from '@100mslive/react-icons';
9 |
10 | function Footer() {
11 | const { isLocalAudioEnabled, isLocalVideoEnabled, toggleAudio, toggleVideo } =
12 | useAVToggle();
13 | const actions = useHMSActions();
14 | return (
15 |
26 | );
27 | }
28 |
29 | export default Footer;
30 |
--------------------------------------------------------------------------------
/app/components/Join.tsx:
--------------------------------------------------------------------------------
1 | import { useHMSActions } from '@100mslive/react-sdk';
2 | import React, { useState } from 'react';
3 |
4 | const Join: React.FC<{ token: string }> = ({ token }) => {
5 | const actions = useHMSActions();
6 | const [name, setName] = useState('');
7 | const joinRoom = () => {
8 | actions.join({
9 | authToken: token,
10 | userName: name,
11 | });
12 | };
13 | return (
14 |
32 | );
33 | };
34 |
35 | export default Join;
36 |
--------------------------------------------------------------------------------
/app/root.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Links,
3 | LiveReload,
4 | Meta,
5 | Outlet,
6 | Scripts,
7 | ScrollRestoration,
8 | } from "@remix-run/react";
9 | import type { MetaFunction } from "@remix-run/node";
10 | import { HMSRoomProvider } from '@100mslive/react-sdk';
11 |
12 | export const meta: MetaFunction = () => {
13 | return { title: 'Remix Video Chat' };
14 | };
15 |
16 | export default function App() {
17 | return (
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | {process.env.NODE_ENV === 'development' && }
31 |
32 |
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "remix-video-chat",
3 | "description": "Video chat app with Remix and 100ms react sdk in less than 100 lines of code.",
4 | "license": "MIT",
5 | "repository": {
6 | "url": "https://github.com/Deep-Codes/remix-video-chat"
7 | },
8 | "scripts": {
9 | "build": "remix build",
10 | "dev": "node -r dotenv/config node_modules/.bin/remix dev --port 3000",
11 | "postinstall": "remix setup node"
12 | },
13 | "dependencies": {
14 | "@100mslive/react-icons": "^0.0.7",
15 | "@100mslive/react-sdk": "^0.3.0",
16 | "@remix-run/react": "^1.6.8",
17 | "@remix-run/serve": "^1.6.8",
18 | "@remix-run/vercel": "^1.6.8",
19 | "react": "^17.0.2",
20 | "react-dom": "^17.0.2",
21 | "remix": "^1.1.3"
22 | },
23 | "devDependencies": {
24 | "@remix-run/dev": "^1.6.8",
25 | "@types/react": "^17.0.24",
26 | "@types/react-dom": "^17.0.9",
27 | "dotenv": "^16.0.0",
28 | "typescript": "^4.1.2"
29 | },
30 | "engines": {
31 | "node": ">=14"
32 | },
33 | "sideEffects": false
34 | }
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022-onwards Deepankar Bhade
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/app/components/Conference.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | HMSPeer,
4 | selectIsPeerAudioEnabled,
5 | selectIsPeerVideoEnabled,
6 | selectPeers,
7 | useHMSStore,
8 | useVideo,
9 | } from '@100mslive/react-sdk';
10 | import Avatar from './Avatar';
11 | import { MicOffIcon, MicOnIcon } from '@100mslive/react-icons';
12 |
13 | const Conference = () => {
14 | const peers = useHMSStore(selectPeers);
15 | return (
16 |
17 | {peers.map((peer) => (
18 |
19 | ))}
20 |
21 | );
22 | };
23 |
24 | const Peer: React.FC<{ peer: HMSPeer }> = ({ peer }) => {
25 | const isAudioOn = useHMSStore(selectIsPeerAudioEnabled(peer.id));
26 | const isVideoOn = useHMSStore(selectIsPeerVideoEnabled(peer.id));
27 | return (
28 |
29 | {!isVideoOn ?
: null}
30 |
{peer.name}
31 |
32 |
33 | {!isAudioOn ? : }
34 |
35 |
36 | );
37 | };
38 |
39 | const Video = ({ videoTrack, mirror }: any) => {
40 | const { videoRef } = useVideo({
41 | trackId: videoTrack,
42 | });
43 | return (
44 |
51 | );
52 | };
53 |
54 | export default Conference;
55 |
--------------------------------------------------------------------------------
/app/routes/index.tsx:
--------------------------------------------------------------------------------
1 | import styles from '~/styles/global.css';
2 |
3 | export const links = () => {
4 | return [{ rel: 'stylesheet', href: styles }];
5 | };
6 |
7 | export default function Index() {
8 | return (
9 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/app/meeting.tsx:
--------------------------------------------------------------------------------
1 | import { useLoaderData } from '@remix-run/react';
2 | import type { LoaderFunction } from '@remix-run/node';
3 | import styles from '~/styles/global.css';
4 | import Live from '~/components/Live';
5 |
6 | interface ResponseType {
7 | error: null | string;
8 | token: null | string;
9 | }
10 |
11 | export const links = () => {
12 | return [{ rel: 'stylesheet', href: styles }];
13 | };
14 |
15 | export const loader: LoaderFunction = async ({ params }: any) => {
16 | const endPoint = process.env.HMS_TOKEN_ENDPOINT;
17 | const data: ResponseType = {
18 | token: null,
19 | error: null,
20 | };
21 | const slug = params['*'];
22 | const url = slug?.split('/');
23 | if (url?.length === 2) {
24 | try {
25 | const response = await fetch(`${endPoint}api/token`, {
26 | method: 'POST',
27 | body: JSON.stringify({
28 | room_id: url[0],
29 | role: url[1],
30 | }),
31 | });
32 | if (!response.ok) {
33 | let error = new Error('Request failed!');
34 | throw error;
35 | }
36 | const { token } = await response.json();
37 | data['token'] = token;
38 | } catch (error) {
39 | data['error'] = 'Make sure the RoomId exists in 100ms dashboard';
40 | }
41 | } else {
42 | data['error'] = 'Join via /:roomId/:role format';
43 | }
44 | return data;
45 | };
46 |
47 | export default function MeetingSlug() {
48 | const { token, error } = useLoaderData();
49 | return (
50 |
51 | {!(token || error) ?
Loading...
: null}
52 | {token ?
: null}
53 | {error ? (
54 |
55 |
Error
56 |
{error}
57 |
58 | Get RoomId from{' '}
59 | here and join with
60 | the role created in it :)
61 |
62 |
63 | ) : null}
64 |
65 | );
66 | }
67 |
--------------------------------------------------------------------------------
/app/styles/global.css:
--------------------------------------------------------------------------------
1 | * {
2 | margin: 0;
3 | padding: 0;
4 | box-sizing: border-box;
5 | transition: 0.1s all ease;
6 | }
7 |
8 | /*
9 | @see
10 | Colors: https://www.radix-ui.com/docs/colors/palette-composition/the-scales#gray
11 | Scale: https://www.radix-ui.com/docs/colors/palette-composition/understanding-the-scale
12 | */
13 | :root {
14 | --gray1: hsl(0 0% 8.5%);
15 | --gray2: hsl(0 0% 11%);
16 | --gray3: hsl(0 0% 13.6%);
17 | --gray4: hsl(0 0% 15.8%);
18 | --gray5: hsl(0 0% 17.9%);
19 | --gray6: hsl(0 0% 20.5%);
20 | --gray7: hsl(0 0% 24.3%);
21 | --gray8: hsl(0 0% 31.2%);
22 | --gray9: hsl(0 0% 43.9%);
23 | --gray10: hsl(0 0% 49.4%);
24 | --gray11: hsl(0 0% 62.8%);
25 | --gray12: hsl(0 0% 93%);
26 | }
27 |
28 | body {
29 | width: 100%;
30 | height: 100vh;
31 | background-color: var(--gray1);
32 | color: var(--gray12);
33 | font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
34 | 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
35 | sans-serif;
36 | }
37 |
38 | form {
39 | height: 100vh;
40 | display: flex;
41 | flex-direction: column;
42 | justify-content: center;
43 | align-items: center;
44 | }
45 |
46 | form input {
47 | color: var(--gray12);
48 | padding: 0.75rem 1.5rem;
49 | text-align: center;
50 | width: 320px;
51 | border-radius: 1.5rem;
52 | margin: 1.25rem 0;
53 | border: 1px solid var(--gray6);
54 | background-color: var(--gray3);
55 | font-size: 16px;
56 | }
57 | input:focus {
58 | border: 1px solid var(--gray7);
59 | background-color: var(--gray5);
60 | outline: none;
61 | }
62 | input::placeholder {
63 | color: var(--gray10);
64 | }
65 |
66 | form button {
67 | color: var(--blue12);
68 | padding: 0.75rem 1.5rem;
69 | text-align: center;
70 | width: 320px;
71 | border-radius: 1.5rem;
72 | border: 1px solid transparent;
73 | background-color: rgba(29, 161, 242);
74 | font-size: 16px;
75 | font-weight: 700;
76 | cursor: pointer;
77 | outline: none;
78 | }
79 | form button:hover {
80 | opacity: 0.8;
81 | }
82 | form button:focus {
83 | border: 1px solid rgba(29, 161, 242);
84 | background-color: transparent;
85 | }
86 |
87 | header {
88 | width: 100%;
89 | display: flex;
90 | justify-content: center;
91 | align-items: center;
92 | height: 8vh;
93 | background-color: var(--gray2);
94 | border-bottom: 1px solid var(--gray6);
95 | font-size: 20px;
96 | }
97 |
98 | main {
99 | height: 84vh;
100 | width: 100%;
101 | display: flex;
102 | flex-wrap: wrap;
103 | justify-content: center;
104 | align-items: center;
105 | }
106 |
107 | .tile {
108 | width: 400px;
109 | height: 300px;
110 | border-radius: 12px;
111 | position: relative;
112 | background-color: var(--gray3);
113 | margin: 1rem;
114 | }
115 |
116 | .tile video {
117 | width: 100%;
118 | height: 100%;
119 | object-fit: cover;
120 | border-radius: 12px;
121 | }
122 |
123 | footer {
124 | width: 100%;
125 | display: flex;
126 | justify-content: center;
127 | align-items: center;
128 | height: 8vh;
129 | }
130 |
131 | footer button {
132 | display: flex;
133 | align-items: center;
134 | justify-content: center;
135 | border: 2px solid var(--gray6);
136 | background-color: var(--gray3);
137 | width: 2.75rem;
138 | height: 2.75rem;
139 | margin: 0 0.5rem;
140 | color: var(--gray12);
141 | border-radius: 5px;
142 | cursor: pointer;
143 | }
144 | footer button:hover {
145 | background-color: var(--gray4);
146 | }
147 | footer button:focus {
148 | outline: none;
149 | border: 2px solid var(--gray7);
150 | background-color: var(--gray4);
151 | }
152 |
153 | .avatar {
154 | display: flex;
155 | align-items: center;
156 | justify-content: center;
157 | font-weight: bold;
158 | width: 8rem;
159 | height: 8rem;
160 | border-radius: 9999px;
161 | color: var(--gray12);
162 | background-color: var(--gray6);
163 | position: absolute;
164 | left: 50%;
165 | top: 50%;
166 | z-index: 20;
167 | font-size: 50px;
168 | transform: translate(-50%, -50%);
169 | }
170 |
171 | .audio {
172 | display: flex;
173 | align-items: center;
174 | justify-content: center;
175 | color: var(--gray10);
176 | width: 2rem;
177 | height: 2rem;
178 | border-radius: 9999px;
179 | position: absolute;
180 | bottom: 0.5rem;
181 | right: 0.5rem;
182 | background-color: var(--gray2);
183 | }
184 |
185 | .name {
186 | z-index: 10;
187 | position: absolute;
188 | bottom: 1rem;
189 | left: 1rem;
190 | color: var(--gray12);
191 | }
192 |
193 | .error {
194 | text-align: center;
195 | width: 100%;
196 | margin-top: 5rem;
197 | }
198 |
199 | .error p {
200 | margin-top: 1rem;
201 | color: var(--gray10);
202 | }
203 |
204 | .home {
205 | width: 100%;
206 | height: 100vh;
207 | display: flex;
208 | flex-direction: column;
209 | align-items: center;
210 | justify-content: center;
211 | text-align: center;
212 | }
213 | .home p {
214 | max-width: 360px;
215 | margin: 2rem;
216 | color: var(--gray10);
217 | }
218 | a {
219 | color: var(--gray12);
220 | margin: 0.5rem 0;
221 | }
222 |
223 | .mirror {
224 | transform: scaleX(-1);
225 | }
226 |
--------------------------------------------------------------------------------