Initial public release

This commit is contained in:
Lukas 2022-10-24 17:03:12 +02:00
commit c25b0fc713
37 changed files with 4611 additions and 0 deletions

9
.env.example Normal file
View file

@ -0,0 +1,9 @@
# 1. Copy this file and rename it to .env.local
# 2. Update the enviroment variables below.
# URL pointing to the LiveKit server.
LIVEKIT_URL=wss://your-host
# API key and secret. If you use LiveKit Cloud this can be generated via the cloud dashboard.
LIVEKIT_API_KEY=<____key_goes_here____>
LIVEKIT_API_SECRET=<____secret_goes_here____>

3
.eslintrc.json Normal file
View file

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

38
.gitignore vendored Normal file
View file

@ -0,0 +1,38 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
# typescript
*.tsbuildinfo

3
.prettierignore Normal file
View file

@ -0,0 +1,3 @@
.github/
.next/
node_modules/

7
.prettierrc Normal file
View file

@ -0,0 +1,7 @@
{
"singleQuote": true,
"trailingComma": "all",
"semi": true,
"tabWidth": 2,
"printWidth": 100
}

22
README.md Normal file
View file

@ -0,0 +1,22 @@
# LiveKit Meet
This project is home for a simple video conferencing app built with LiveKit.
## Tech Stack
- This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
- App is built with [livekit-react](https://github.com/livekit/livekit-react/) library
## Demo
Give it a try at https://meet.livekit.io
## Dev Setup
Steps to get a local dev setup up and running:
1. Run `yarn install` to install all dependencies.
2. Copy `.env.example` in the project root and rename it to `.env.local`.
3. Update the missing environment variables in the newly created `.env.local` file.
4. Run `yarn dev` to start the development server and visit [http://localhost:3000](http://localhost:3000) to see the result.
5. Start development 🎉

140
components/ActiveRoom.tsx Normal file
View file

@ -0,0 +1,140 @@
import { Box, useToast } from '@chakra-ui/react';
import { DisplayContext, DisplayOptions, LiveKitRoom } from '@livekit/react-components';
import { Room, RoomEvent, VideoPresets } from 'livekit-client';
import { useRouter } from 'next/router';
import { useCallback, useEffect, useState } from 'react';
import 'react-aspect-ratio/aspect-ratio.css';
import tinykeys from 'tinykeys';
import { SessionProps, TokenResult } from '../lib/types';
import Controls from './Controls';
import DebugOverlay from './DebugOverlay';
const ActiveRoom = ({
roomName,
identity,
region,
audioTrack,
videoTrack,
turnServer,
forceRelay,
}: SessionProps) => {
const [tokenResult, setTokenResult] = useState<TokenResult>();
const [room, setRoom] = useState<Room>();
const [displayOptions, setDisplayOptions] = useState<DisplayOptions>({
stageLayout: 'grid',
});
const router = useRouter();
const toast = useToast();
useEffect(() => {
// cleanup
return () => {
audioTrack?.stop();
videoTrack?.stop();
};
}, []);
const onLeave = () => {
router.push('/');
};
const onConnected = useCallback(
(room: Room) => {
setRoom(room);
/* @ts-ignore */
window.currentRoom = room;
if (audioTrack) {
room.localParticipant.publishTrack(audioTrack);
}
if (videoTrack) {
room.localParticipant.publishTrack(videoTrack);
}
room.on(RoomEvent.Disconnected, (reason) => {
toast({
title: 'Disconnected',
description: `You've been disconnected from the room`,
duration: 4000,
onCloseComplete: () => {
onLeave();
},
});
});
},
[audioTrack, videoTrack],
);
useEffect(() => {
const params: { [key: string]: string } = {
roomName,
identity,
};
if (region) {
params.region = region;
}
fetch('/api/token?' + new URLSearchParams(params))
.then((res) => res.json())
.then((data: TokenResult) => {
setTokenResult(data);
});
}, []);
useEffect(() => {
if (window) {
let unsubscribe = tinykeys(window, {
'Shift+S': () => {
displayOptions.showStats = displayOptions.showStats ? false : true;
setDisplayOptions(displayOptions);
},
});
return () => {
unsubscribe();
};
}
}, [displayOptions]);
if (!tokenResult) {
return <Box bg="cld.bg1" minH="100vh"></Box>;
}
let rtcConfig: RTCConfiguration | undefined;
if (turnServer) {
rtcConfig = {
iceServers: [turnServer],
iceTransportPolicy: 'relay',
};
} else if (forceRelay) {
rtcConfig = {
iceTransportPolicy: 'relay',
};
}
return (
<DisplayContext.Provider value={displayOptions}>
<Box bg="cld.bg1" height="100vh" padding="0.5rem">
<LiveKitRoom
url={tokenResult.url}
token={tokenResult.token}
onConnected={onConnected}
roomOptions={{
adaptiveStream: true,
dynacast: true,
videoCaptureDefaults: {
resolution: VideoPresets.h720.resolution,
},
publishDefaults: {
screenShareSimulcastLayers: [],
},
}}
connectOptions={{
rtcConfig,
}}
controlRenderer={Controls}
onLeave={onLeave}
/>
{room && <DebugOverlay room={room} />}
</Box>
</DisplayContext.Provider>
);
};
export default ActiveRoom;

19
components/ChatEntry.tsx Normal file
View file

@ -0,0 +1,19 @@
import { HStack, Text } from '@chakra-ui/react';
import { Participant } from 'livekit-client';
export interface ChatData {
sentAt: Date;
message: string;
from?: Participant;
}
const ChatEntry = ({ message, from }: ChatData) => {
return (
<HStack>
{from ? <Text fontWeight={600}>{`${from.name || from.identity}`}:</Text> : null}
<Text>{message}</Text>
</HStack>
);
};
export default ChatEntry;

113
components/ChatOverlay.tsx Normal file
View file

@ -0,0 +1,113 @@
import {
Button,
Drawer,
DrawerBody,
DrawerCloseButton,
DrawerContent,
DrawerHeader,
DrawerOverlay,
Grid,
GridItem,
HStack,
Textarea,
useDisclosure,
} from '@chakra-ui/react';
import { DataPacket_Kind, Participant, Room, RoomEvent } from 'livekit-client';
import { useEffect, useState } from 'react';
import ChatEntry, { ChatData } from './ChatEntry';
interface ChatProps {
room: Room;
isOpen: boolean;
onClose: () => void;
onUnreadChanged?: (num: number) => void;
}
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const ChatOverlay = ({
room,
isOpen: extIsOpen,
onClose: extOnClose,
onUnreadChanged,
}: ChatProps) => {
const { isOpen, onClose } = useDisclosure({ isOpen: extIsOpen, onClose: extOnClose });
const [input, setInput] = useState<string>();
const [messages, setMessages] = useState<ChatData[]>([]);
const [numUnread, setNumUnread] = useState(0);
useEffect(() => {
const onDataReceived = (payload: Uint8Array, participant?: Participant) => {
const data = decoder.decode(payload);
setMessages((messages) => [
...messages,
{
sentAt: new Date(),
message: data,
from: participant,
},
]);
setNumUnread((numUnread) => numUnread + 1);
};
room.on(RoomEvent.DataReceived, onDataReceived);
return () => {
room.off(RoomEvent.DataReceived, onDataReceived);
};
}, [room]);
useEffect(() => {
if (isOpen) {
setNumUnread(0);
}
}, [isOpen]);
useEffect(() => {
if (onUnreadChanged) {
onUnreadChanged(numUnread);
}
}, [numUnread, onUnreadChanged]);
const sendMessage = () => {
if (!input) {
return;
}
room.localParticipant.publishData(encoder.encode(input), DataPacket_Kind.RELIABLE);
setMessages((messages) => [
...messages,
{
sentAt: new Date(),
message: input,
from: room.localParticipant,
},
]);
setInput('');
};
return (
<Drawer isOpen={isOpen} onClose={onClose} size="sm" placement="left">
<DrawerOverlay />
<DrawerContent>
<DrawerCloseButton />
<DrawerHeader>Chat</DrawerHeader>
<DrawerBody>
<Grid height="100%" templateColumns="1fr" templateRows="1fr min-content">
<GridItem>
{messages.map((message, idx) => (
<ChatEntry key={idx} {...message} />
))}
</GridItem>
<GridItem>
<HStack>
<Textarea value={input} onChange={(e) => setInput(e.target.value)} rows={2} />
<Button onClick={sendMessage}>Send</Button>
</HStack>
</GridItem>
</Grid>
</DrawerBody>
</DrawerContent>
</Drawer>
);
};
export default ChatOverlay;

120
components/Controls.tsx Normal file
View file

@ -0,0 +1,120 @@
import { HStack } from '@chakra-ui/react';
import { faCommentDots } from '@fortawesome/free-regular-svg-icons';
import { faComment, faDesktop, faStop } from '@fortawesome/free-solid-svg-icons';
import {
AudioSelectButton,
ControlButton,
ControlsProps,
useParticipant,
VideoSelectButton,
} from '@livekit/react-components';
import { useState } from 'react';
import styles from '../styles/Room.module.css';
import ChatOverlay from './ChatOverlay';
const Controls = ({ room, onLeave }: ControlsProps) => {
const { cameraPublication: camPub } = useParticipant(room.localParticipant);
const [videoButtonDisabled, setVideoButtonDisabled] = useState(false);
const [audioButtonDisabled, setAudioButtonDisabled] = useState(false);
const [screenButtonDisabled, setScreenButtonDisabled] = useState(false);
const [isChatOpen, setChatOpen] = useState(false);
const [numUnread, setNumUnread] = useState(0);
const startChat = () => {
setChatOpen(true);
};
const audioEnabled = room.localParticipant.isMicrophoneEnabled;
const muteButton = (
<AudioSelectButton
isMuted={!audioEnabled}
isButtonDisabled={audioButtonDisabled}
onClick={() => {
setAudioButtonDisabled(true);
room.localParticipant
.setMicrophoneEnabled(!audioEnabled)
.finally(() => setAudioButtonDisabled(false));
}}
onSourceSelected={(device) => {
setAudioButtonDisabled(true);
room
.switchActiveDevice('audioinput', device.deviceId)
.finally(() => setAudioButtonDisabled(false));
}}
/>
);
const videoEnabled = !(camPub?.isMuted ?? true);
const videoButton = (
<VideoSelectButton
isEnabled={videoEnabled}
isButtonDisabled={videoButtonDisabled}
onClick={() => {
setVideoButtonDisabled(true);
room.localParticipant
.setCameraEnabled(!videoEnabled)
.finally(() => setVideoButtonDisabled(false));
}}
onSourceSelected={(device) => {
setVideoButtonDisabled(true);
room
.switchActiveDevice('videoinput', device.deviceId)
.finally(() => setVideoButtonDisabled(false));
}}
/>
);
const screenShareEnabled = room.localParticipant.isScreenShareEnabled;
const screenButton = (
<ControlButton
label={screenShareEnabled ? 'Stop sharing' : 'Share screen'}
icon={screenShareEnabled ? faStop : faDesktop}
disabled={screenButtonDisabled}
onClick={() => {
setScreenButtonDisabled(true);
room.localParticipant
.setScreenShareEnabled(!screenShareEnabled)
.finally(() => setScreenButtonDisabled(false));
}}
/>
);
const chatButton = (
<ControlButton
label="Chat"
icon={numUnread > 0 ? faCommentDots : faComment}
onClick={startChat}
/>
);
return (
<>
<HStack>
{muteButton}
{videoButton}
{screenButton}
{chatButton}
{onLeave && (
<ControlButton
label="End"
className={styles.dangerButton}
onClick={() => {
room.disconnect();
onLeave(room);
}}
/>
)}
</HStack>
<ChatOverlay
room={room}
isOpen={isChatOpen}
onUnreadChanged={setNumUnread}
onClose={() => {
setChatOpen(false);
}}
/>
</>
);
};
export default Controls;

237
components/DebugOverlay.tsx Normal file
View file

@ -0,0 +1,237 @@
import {
Badge,
Box,
Drawer,
DrawerBody,
DrawerCloseButton,
DrawerContent,
DrawerHeader,
DrawerOverlay,
Heading,
HStack,
Select,
Stack,
Table,
Tbody,
Td,
Text,
Tr,
useDisclosure,
useToast,
} from '@chakra-ui/react';
import { RemoteTrackPublication, Room, RoomEvent } from 'livekit-client';
import { useEffect, useState } from 'react';
import tinykeys from 'tinykeys';
interface DebugProps {
room: Room;
}
export const DebugOverlay = ({ room }: DebugProps) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const [, setRender] = useState({});
const toast = useToast();
useEffect(() => {
if (window) {
const unsubscribe = tinykeys(window, {
'Shift+D': () => {
if (isOpen) {
onClose();
} else {
onOpen();
}
},
});
// timer to re-render
const interval = setInterval(() => {
setRender({});
}, 1000);
return () => {
unsubscribe();
clearInterval(interval);
};
}
}, [isOpen]);
if (typeof window === 'undefined' || !isOpen) {
return null;
}
const handleSimulate = (event: React.ChangeEvent<HTMLSelectElement>) => {
const { value } = event.target;
if (value == '') {
return;
}
event.target.value = '';
let isReconnect = false;
switch (value) {
case 'signal-reconnect':
isReconnect = true;
// fall through
default:
room.simulateScenario(value);
}
if (isReconnect && room.engine) {
toast({
title: 'Reconnecting...',
description: `current server: ${room.engine.connectedServerAddress}`,
status: 'info',
duration: 4000,
});
room.once(RoomEvent.Reconnected, () => {
toast({
title: 'Reconnected',
description: `reconnected server: ${room.engine.connectedServerAddress}`,
status: 'success',
duration: 4000,
});
});
}
};
const lp = room.localParticipant;
const roomInfo = (
<Table variant="simple">
<Tbody>
<Tr>
<Td>Room</Td>
<Td>
{room.name}&nbsp;
<Badge>{room.sid}</Badge>
</Td>
</Tr>
<Tr>
<Td>Participant</Td>
<Td>
{lp.identity}&nbsp;
<Badge>{lp.sid}</Badge>
</Td>
</Tr>
<Tr>
<Td>Simulate</Td>
<Td>
<Select placeholder="choose" onChange={handleSimulate}>
<option value="signal-reconnect">Signal reconnect</option>
<option value="speaker">Speaker update</option>
<option value="server-leave">Server booted</option>
</Select>
</Td>
</Tr>
</Tbody>
</Table>
);
return (
<Drawer isOpen={isOpen} onClose={onClose} size="sm">
<DrawerOverlay />
<DrawerContent>
<DrawerCloseButton />
<DrawerHeader>Debug</DrawerHeader>
<DrawerBody>
{roomInfo}
<Box>
<Stack spacing="1rem">
<Box borderWidth="1px" borderRadius="lg" padding="1rem" mb="1rem">
<Heading fontSize="lg" mb="0.5rem">
Published Tracks
</Heading>
<Stack spacing="0.5rem">
{Array.from(lp.tracks.values()).map((t) => (
<>
<HStack>
<Text>
{t.source.toString()}
&nbsp;<Badge>{t.trackSid}</Badge>
</Text>
</HStack>
<Table variant="simple">
<Tbody>
<Tr>
<Td>Kind</Td>
<Td>
{t.kind}&nbsp;
{t.kind === 'video' && (
<Badge>
{t.track?.dimensions?.width}x{t.track?.dimensions?.height}
</Badge>
)}
</Td>
</Tr>
<Tr>
<Td>Bitrate</Td>
<Td>{Math.ceil(t.track!.currentBitrate / 1000)} kbps</Td>
</Tr>
</Tbody>
</Table>
</>
))}
</Stack>
</Box>
{Array.from(room.participants.values()).map((p) => (
<Box key={p.sid} borderWidth="1px" borderRadius="lg" padding="1rem" mb="1rem">
<Heading fontSize="lg" mb="0.5rem">
{p.identity}
<Badge></Badge>
</Heading>
<Stack spacing="0.5rem">
{Array.from(p.tracks.values()).map((t) => (
<>
<HStack>
<Text>
{t.source.toString()}
&nbsp;<Badge>{t.trackSid}</Badge>
</Text>
</HStack>
<Table variant="simple">
<Tbody>
<Tr>
<Td>Kind</Td>
<Td>
{t.kind}&nbsp;
{t.kind === 'video' && (
<Badge>
{t.dimensions?.width}x{t.dimensions?.height}
</Badge>
)}
</Td>
</Tr>
<Tr>
<Td>Status</Td>
<Td>{trackStatus(t)}</Td>
</Tr>
{t.track && (
<Tr>
<Td>Bitrate</Td>
<Td>{Math.ceil(t.track.currentBitrate / 1000)} kbps</Td>
</Tr>
)}
</Tbody>
</Table>
</>
))}
</Stack>
</Box>
))}
</Stack>
</Box>
</DrawerBody>
</DrawerContent>
</Drawer>
);
};
function trackStatus(t: RemoteTrackPublication): string {
if (t.isSubscribed) {
return t.isEnabled ? 'enabled' : 'disabled';
} else {
return 'unsubscribed';
}
}
export default DebugOverlay;

216
components/PreJoin.tsx Normal file
View file

@ -0,0 +1,216 @@
import { AspectRatio, Box, Button, Center, Grid, GridItem, Link, Text } from '@chakra-ui/react';
import { typography } from '@livekit/livekit-chakra-theme';
import { AudioSelectButton, VideoRenderer, VideoSelectButton } from '@livekit/react-components';
import {
createLocalAudioTrack,
createLocalVideoTrack,
LocalAudioTrack,
LocalVideoTrack,
VideoPresets,
} from 'livekit-client';
import { ReactElement, useEffect, useState } from 'react';
import { SessionProps } from '../lib/types';
import styles from '../styles/Room.module.css';
import TextField from './TextField';
interface PreJoinProps {
roomName: string;
numParticipants: number;
// use a passed in identity
identity?: string;
startSession: (props: SessionProps) => void;
}
const PreJoin = ({
startSession,
roomName,
numParticipants,
identity: existingIdentity,
}: PreJoinProps) => {
const [videoTrack, setVideoTrack] = useState<LocalVideoTrack>();
const [audioTrack, setAudioTrack] = useState<LocalAudioTrack>();
const [videoDevice, setVideoDevice] = useState<MediaDeviceInfo>();
const [audioDevice, setAudioDevice] = useState<MediaDeviceInfo>();
const [joinEnabled, setJoinEnabled] = useState(false);
const [canRender, setCanRender] = useState(false);
const [identity, setIdentity] = useState<string | undefined>(existingIdentity);
// indicate that it's now on the client side
useEffect(() => {
setCanRender(true);
}, []);
useEffect(() => {
if (identity && identity.length > 1) {
setJoinEnabled(true);
} else {
setJoinEnabled(false);
}
}, [identity]);
const toggleVideo = async () => {
if (videoTrack) {
videoTrack.stop();
setVideoTrack(undefined);
} else {
const track = await createLocalVideoTrack({
deviceId: videoDevice?.deviceId,
resolution: VideoPresets.h720.resolution,
});
setVideoTrack(track);
}
};
const toggleAudio = async () => {
if (audioTrack) {
audioTrack.stop();
setAudioTrack(undefined);
} else {
const track = await createLocalAudioTrack({
deviceId: audioDevice?.deviceId,
});
setAudioTrack(track);
}
};
// recreate video track when device changes
useEffect(() => {
if (videoTrack) {
videoTrack.stop();
setVideoTrack(undefined);
}
// enable video by default
createLocalVideoTrack({
deviceId: videoDevice?.deviceId,
resolution: VideoPresets.h720.resolution,
}).then((track) => {
setVideoTrack(track);
});
}, [videoDevice]);
// recreate audio track when device changes
useEffect(() => {
// enable audio by default
createLocalAudioTrack({
deviceId: audioDevice?.deviceId,
}).then((track) => {
setAudioTrack(track);
});
}, [audioDevice]);
const onJoin = () => {
if (!identity) {
return;
}
startSession({
roomName,
identity,
audioTrack,
videoTrack,
});
};
let videoElement: ReactElement;
if (videoTrack) {
videoElement = <VideoRenderer track={videoTrack} isLocal={true} />;
} else {
videoElement = <div className={styles.placeholder} />;
}
if (!canRender) {
return null;
}
let currentParticipantsText = 'You will be the first person in the meeting';
if (numParticipants > 0) {
currentParticipantsText = `${numParticipants} people are in the meeting`;
}
return (
<Box className={styles.prejoin} bg="cld.bg1" minH="100vh">
<main>
<Box>
<AspectRatio ratio={16 / 9}>{videoElement}</AspectRatio>
<Grid
mt="1rem"
gap="0.75rem"
templateColumns="min-content min-content"
placeContent="end center"
>
<GridItem>
<AudioSelectButton
isMuted={!audioTrack}
onClick={toggleAudio}
onSourceSelected={setAudioDevice}
/>
</GridItem>
<GridItem>
<VideoSelectButton
isEnabled={videoTrack !== undefined}
onClick={toggleVideo}
onSourceSelected={setVideoDevice}
/>
</GridItem>
</Grid>
</Box>
<Box>
<Text textStyle="h4" color="cld.fg1" textAlign="center" mb={['2rem', null]}>
What is your name?
</Text>
<Box ml="1rem" mr="1rem" mb="2rem">
<TextField
domId="identity"
label="name"
placeholder=""
inputType="text"
value={identity}
onChange={(e) => setIdentity(e.target.value)}
/>
</Box>
<Center>
<Button
onClick={onJoin}
disabled={!joinEnabled}
variant="primary"
py="0.75rem"
px="2rem"
{...typography.textStyles['h5-mono']}
_hover={{ backgroundColor: '#4963B0' }}
>
{numParticipants > 0 ? 'Join' : 'Start'} Meeting
</Button>
</Center>
<Center height="5rem">
<Text textStyle="body2" color="cld.fg2">
{currentParticipantsText}
</Text>
</Center>
</Box>
</main>
<footer>
<Center>
<Text textStyle="body2" color="cld.fg2">
Built with{' '}
<Link href="https://livekit.io">
<Text as="span" color="v2.red">
LiveKit
</Text>
</Link>
.{' '}
<Link href="https://github.com/livekit/meet">
see{' '}
<Text as="span" color="v2.red">
source
</Text>
</Link>
</Text>
</Center>
</footer>
</Box>
);
};
export default PreJoin;

66
components/TextField.tsx Normal file
View file

@ -0,0 +1,66 @@
import { FormControl, HStack, Input, Text } from '@chakra-ui/react';
import React, { ChangeEventHandler } from 'react';
interface TextFieldProps {
domId: string;
label?: string;
inputType?: string;
inputProps?: {};
containerProps?: {};
placeholder?: string;
value?: string | number;
onChange?: ChangeEventHandler<HTMLInputElement>;
children?: React.ReactNode;
}
export default function TextField({
domId,
label,
inputType,
placeholder,
value,
onChange,
children,
inputProps,
containerProps = {},
}: TextFieldProps) {
const [isFocused, setIsFocused] = React.useState<boolean>(false);
const textFieldInputProps = {
textStyle: 'body2',
color: 'cld.fg1',
fontSize: '0.875rem',
_placeholder: { color: 'cld.fg4' },
focusBorderColor: 'cld.fg1',
borderColor: 'cld.separator2',
variant: 'flushed',
...inputProps,
};
const { __focus: focusContainerProps, ...rest } = containerProps as any;
const finalContainerProps = {
...rest,
...(isFocused ? focusContainerProps : {}),
};
return (
<FormControl id={domId}>
{label && (
<Text textStyle="h5-mono" color="cld.fg1" textTransform="uppercase" pb="0">
{label}
</Text>
)}
<HStack spacing="0" {...finalContainerProps}>
<Input
placeholder={placeholder}
type={inputType}
value={value}
onChange={onChange}
onFocus={(e) => setIsFocused(true)}
onBlur={(e) => setIsFocused(false)}
{...textFieldInputProps}
/>
{children}
</HStack>
</FormControl>
);
}

27
lib/clients.ts Normal file
View file

@ -0,0 +1,27 @@
import { RoomServiceClient } from 'livekit-server-sdk';
export function getRoomClient(): RoomServiceClient {
checkKeys();
return new RoomServiceClient(getLiveKitURL());
}
export function getLiveKitURL(region?: string): string {
let targetKey = 'LIVEKIT_URL';
if (region) {
targetKey = `LIVEKIT_URL_${region}`.toUpperCase();
}
const url = process.env[targetKey];
if (!url) {
throw new Error(`${targetKey} is not defined`);
}
return url;
}
function checkKeys() {
if (typeof process.env.LIVEKIT_API_KEY === 'undefined') {
throw new Error('LIVEKIT_API_KEY is not defined');
}
if (typeof process.env.LIVEKIT_API_SECRET === 'undefined') {
throw new Error('LIVEKIT_API_SECRET is not defined');
}
}

16
lib/types.ts Normal file
View file

@ -0,0 +1,16 @@
import { LocalAudioTrack, LocalVideoTrack } from 'livekit-client';
export interface SessionProps {
roomName: string;
identity: string;
audioTrack?: LocalAudioTrack;
videoTrack?: LocalVideoTrack;
region?: string;
turnServer?: RTCIceServer;
forceRelay?: boolean;
}
export interface TokenResult {
url: string;
token: string;
}

5
next-env.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

6
next.config.js Normal file
View file

@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};
module.exports = nextConfig;

35
package.json Normal file
View file

@ -0,0 +1,35 @@
{
"name": "livekit-meet",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"format": "prettier --write ."
},
"dependencies": {
"@chakra-ui/react": "^1.8.6",
"@datadog/browser-logs": "^4.17.2",
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1",
"@livekit/livekit-chakra-theme": "^1.4.0",
"@livekit/react-components": "^1.0.2",
"livekit-client": "1.4.3",
"livekit-server-sdk": "^1.0.3",
"next": "12.1.0",
"react": "17.0.2",
"react-aspect-ratio": "^1.0.50",
"react-dom": "17.0.2",
"tinykeys": "^1.4.0"
},
"devDependencies": {
"@types/node": "17.0.21",
"@types/react": "17.0.39",
"eslint": "8.10.0",
"eslint-config-next": "12.1.0",
"prettier": "2.7.1",
"typescript": "4.6.2"
}
}

16
pages/_app.tsx Normal file
View file

@ -0,0 +1,16 @@
import { ChakraProvider } from '@chakra-ui/react';
import theme, { GlobalStyles } from '@livekit/livekit-chakra-theme';
import '@livekit/react-components/dist/index.css';
import type { AppProps } from 'next/app';
import '../styles/globals.css';
function MyApp({ Component, pageProps }: AppProps) {
return (
<ChakraProvider theme={theme}>
<GlobalStyles />
<Component {...pageProps} />
</ChakraProvider>
);
}
export default MyApp;

40
pages/api/token.ts Normal file
View file

@ -0,0 +1,40 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { AccessToken } from 'livekit-server-sdk';
import type { NextApiRequest, NextApiResponse } from 'next';
import { getLiveKitURL } from '../../lib/clients';
import { TokenResult } from '../../lib/types';
const roomPattern = /\w{4}\-\w{4}/;
export default function handler(req: NextApiRequest, res: NextApiResponse<TokenResult>) {
const roomName = req.query.roomName as string | undefined;
const identity = req.query.identity as string | undefined;
const region = req.query.region as string | undefined;
if (!roomName || !identity) {
res.status(403).end();
return;
}
// enforce room name to be xxxx-xxxx
// this is simple & naive way to prevent user from guessing room names
// please use your own authentication mechanisms in your own app
if (!roomName.match(roomPattern)) {
res.status(400).end();
return;
}
const at = new AccessToken();
at.identity = identity;
at.name = identity;
at.ttl = '5m';
at.addGrant({
room: roomName,
roomJoin: true,
});
res.status(200).json({
url: getLiveKitURL(region),
token: at.toJwt(),
});
}

48
pages/index.tsx Normal file
View file

@ -0,0 +1,48 @@
import { Box, Button, Text } from '@chakra-ui/react';
import { typography } from '@livekit/livekit-chakra-theme';
import type { NextPage } from 'next';
import { useRouter } from 'next/router';
import styles from '../styles/Home.module.css';
const Home: NextPage = () => {
const router = useRouter();
const startMeeting = () => {
router.push(`/rooms/${generateRoomId()}`);
};
return (
<Box className={styles.container} bg="cld.bg1" minH="100vh">
<main>
<Text textStyle={['h3', 'h3', 'h2']} color="marketing.lk-white" mb={['2rem', null]}>
LiveKit Meet
</Text>
<Button
onClick={startMeeting}
variant="primary"
py="0.75rem"
px="2rem"
{...typography.textStyles['h5-mono']}
_hover={{ backgroundColor: '#4963B0' }}
>
Start Meeting
</Button>
</main>
</Box>
);
};
export default Home;
function generateRoomId(): string {
return `${randomString(4)}-${randomString(4)}`;
}
function randomString(length: number): string {
var result = '';
var characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
var charactersLength = characters.length;
for (var i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
}

109
pages/rooms/[name].tsx Normal file
View file

@ -0,0 +1,109 @@
import { useToast } from '@chakra-ui/react';
import { GetServerSidePropsContext } from 'next';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import ActiveRoom from '../../components/ActiveRoom';
import PreJoin from '../../components/PreJoin';
import { getRoomClient } from '../../lib/clients';
import { SessionProps } from '../../lib/types';
interface RoomProps {
roomName: string;
numParticipants: number;
region?: string;
identity?: string;
turnServer?: RTCIceServer;
forceRelay?: boolean;
}
const RoomPage = ({ roomName, region, numParticipants, turnServer, forceRelay }: RoomProps) => {
const [sessionProps, setSessionProps] = useState<SessionProps>();
const toast = useToast();
const router = useRouter();
useEffect(() => {
if (!roomName.match(/\w{4}\-\w{4}/)) {
toast({
title: 'Invalid room',
duration: 2000,
onCloseComplete: () => {
router.push('/');
},
});
}
}, [roomName, toast, router]);
if (sessionProps) {
return (
<ActiveRoom
{...sessionProps}
region={region}
turnServer={turnServer}
forceRelay={forceRelay}
/>
);
} else {
return (
<PreJoin
startSession={setSessionProps}
roomName={roomName}
numParticipants={numParticipants}
/>
);
}
};
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const roomName = context.params?.name;
const region = context.query?.region;
const identity = context.query?.identity;
const turn = context.query?.turn;
const forceRelay = context.query?.forceRelay;
if (typeof roomName !== 'string') {
return {
redirect: {
destination: '/',
permanent: false,
},
};
}
const client = getRoomClient();
const rooms = await client.listRooms([roomName]);
let numParticipants = 0;
if (rooms.length > 0) {
numParticipants = rooms[0].numParticipants;
}
const props: RoomProps = {
roomName,
numParticipants,
};
if (typeof region === 'string') {
props.region = region;
}
if (typeof identity === 'string') {
props.identity = identity;
}
if (typeof turn === 'string') {
const parts = turn.split('@');
if (parts.length === 2) {
const cp = parts[0].split(':');
props.turnServer = {
urls: [`turn:${parts[1]}?transport=udp`],
username: cp[0],
credential: cp[1],
};
}
}
if (forceRelay === '1' || forceRelay === 'true') {
props.forceRelay = true;
}
return {
props,
};
};
export default RoomPage;

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

16
public/livekit.svg Normal file
View file

@ -0,0 +1,16 @@
<svg width="178" height="36" viewBox="0 0 178 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="12" height="12" fill="#5BBFE4"/>
<rect y="11.9999" width="12" height="12" fill="#4A9EEB"/>
<rect y="24.0001" width="12" height="12" fill="#2D68F3"/>
<rect x="12" y="24.0001" width="12" height="12" fill="#4A9EEB"/>
<rect x="12" y="11.9999" width="12" height="12" fill="#6EE2E2"/>
<rect x="24" width="12" height="12" fill="#7AFAE1"/>
<rect x="24" y="24.0001" width="12" height="12" fill="#5BBFE4"/>
<path d="M56 6.81158H61.5723V25.9366H71.5918V30.7178H56V6.81158Z" fill="white"/>
<path d="M75.3535 12.4893H80.8203V30.7178H75.3535V12.4893ZM74.8789 7.58502C74.8789 7.1983 74.9551 6.82916 75.1074 6.4776C75.2598 6.12604 75.4766 5.82135 75.7578 5.56354C76.0508 5.29401 76.3965 5.08307 76.7949 4.93073C77.1934 4.77838 77.6387 4.70221 78.1309 4.70221C78.623 4.70221 79.0684 4.77838 79.4668 4.93073C79.8652 5.08307 80.2051 5.29401 80.4863 5.56354C80.7793 5.82135 81.002 6.12604 81.1543 6.4776C81.3066 6.82916 81.3828 7.1983 81.3828 7.58502C81.3828 7.98346 81.3066 8.35846 81.1543 8.71002C81.002 9.04987 80.7793 9.34869 80.4863 9.60651C80.2051 9.86432 79.8594 10.0694 79.4492 10.2217C79.0508 10.3741 78.6055 10.4503 78.1133 10.4503C77.6328 10.4503 77.1934 10.3741 76.7949 10.2217C76.3965 10.0694 76.0566 9.86432 75.7754 9.60651C75.4941 9.33698 75.2715 9.03229 75.1074 8.69244C74.9551 8.34088 74.8789 7.97174 74.8789 7.58502Z" fill="white"/>
<path d="M104.41 12.4893L96.7637 30.7178H91.8945L84.2656 12.4893H90.084L94.3027 23.4405H94.3379L98.5742 12.4893H104.41Z" fill="white"/>
<path d="M105.799 21.6124C105.799 20.2764 106.045 19.0225 106.537 17.8506C107.029 16.6671 107.721 15.6358 108.611 14.7569C109.514 13.878 110.58 13.1866 111.811 12.6827C113.053 12.1671 114.424 11.9092 115.924 11.9092C117.436 11.9092 118.812 12.1671 120.055 12.6827C121.309 13.1866 122.381 13.878 123.271 14.7569C124.174 15.6358 124.871 16.6671 125.363 17.8506C125.867 19.0225 126.119 20.2764 126.119 21.6124C126.119 21.9874 126.102 22.2803 126.066 22.4913C126.043 22.7022 126.014 22.9131 125.979 23.1241H111.195C111.348 23.6983 111.582 24.2139 111.898 24.671C112.227 25.128 112.602 25.5206 113.023 25.8487C113.457 26.1651 113.92 26.4112 114.412 26.587C114.916 26.7628 115.432 26.8506 115.959 26.8506C116.814 26.8506 117.559 26.669 118.191 26.3057C118.824 25.9424 119.305 25.4796 119.633 24.9171H125.504C125.234 25.7139 124.824 26.4991 124.273 27.2725C123.723 28.0342 123.043 28.7139 122.234 29.3116C121.426 29.9092 120.488 30.3897 119.422 30.753C118.367 31.1163 117.201 31.2979 115.924 31.2979C114.436 31.2979 113.07 31.046 111.828 30.5421C110.586 30.0264 109.52 29.3292 108.629 28.4503C107.738 27.5714 107.041 26.546 106.537 25.3741C106.045 24.1905 105.799 22.9366 105.799 21.6124ZM115.959 16.2159C114.822 16.2159 113.861 16.503 113.076 17.0772C112.291 17.6514 111.723 18.4073 111.371 19.3448H120.336C119.996 18.419 119.457 17.669 118.719 17.0948C117.98 16.5089 117.061 16.2159 115.959 16.2159Z" fill="white"/>
<path d="M135.857 18.835L145.104 6.81158H151.572L143.188 17.4639L151.941 30.7178H145.367L139.566 21.5596L135.857 26.253V30.7178H130.285V6.81158H135.857V18.835Z" fill="white"/>
<path d="M154.771 12.4893H160.238V30.7178H154.771V12.4893ZM154.297 7.58502C154.297 7.1983 154.373 6.82916 154.525 6.4776C154.678 6.12604 154.895 5.82135 155.176 5.56354C155.469 5.29401 155.814 5.08307 156.213 4.93073C156.611 4.77838 157.057 4.70221 157.549 4.70221C158.041 4.70221 158.486 4.77838 158.885 4.93073C159.283 5.08307 159.623 5.29401 159.904 5.56354C160.197 5.82135 160.42 6.12604 160.572 6.4776C160.725 6.82916 160.801 7.1983 160.801 7.58502C160.801 7.98346 160.725 8.35846 160.572 8.71002C160.42 9.04987 160.197 9.34869 159.904 9.60651C159.623 9.86432 159.277 10.0694 158.867 10.2217C158.469 10.3741 158.023 10.4503 157.531 10.4503C157.051 10.4503 156.611 10.3741 156.213 10.2217C155.814 10.0694 155.475 9.86432 155.193 9.60651C154.912 9.33698 154.689 9.03229 154.525 8.69244C154.373 8.34088 154.297 7.97174 154.297 7.58502Z" fill="white"/>
<path d="M168.395 7.46198L172.297 7.42682V12.4893H177.588V17.1475H172.297V23.546C172.297 24.1085 172.361 24.5772 172.49 24.9522C172.631 25.3155 172.812 25.6085 173.035 25.8311C173.27 26.0421 173.545 26.1885 173.861 26.2706C174.178 26.3526 174.518 26.3936 174.881 26.3936H177.922L177.887 30.8409H174.354C173.334 30.8409 172.367 30.753 171.453 30.5772C170.551 30.3897 169.76 30.0381 169.08 29.5225C168.4 28.9952 167.861 28.2628 167.463 27.3253C167.076 26.376 166.883 25.1339 166.883 23.5987V17.1475H163.982V12.9991L166.742 11.9444L168.395 7.46198Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

12
styles/Home.module.css Normal file
View file

@ -0,0 +1,12 @@
.container {
display: grid;
grid-template-columns: 700px;
gap: 16px;
height: 100vh;
justify-content: center;
align-items: center;
}
.container main {
text-align: center;
}

34
styles/Room.module.css Normal file
View file

@ -0,0 +1,34 @@
.prejoin {
display: grid;
grid-template-columns: fit-content;
grid-template-rows: auto min-content;
gap: 2rem;
height: 100vh;
justify-content: center;
align-items: center;
}
.prejoin main {
display: grid;
grid-template-columns: 700px 400px;
gap: 32px;
}
.placeholder {
width: 100%;
height: 100%;
border-radius: 4px;
background: #2f2f2f;
}
.prejoin footer {
margin-bottom: 10px;
}
button.dangerButton {
background: #981010;
}
button.dangerButton:hover {
background: #b81212;
}

11
styles/globals.css Normal file
View file

@ -0,0 +1,11 @@
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@100;300;400;600&display=swap');
video {
border-radius: 4px;
width: 100%;
}
body,
.react-tiny-popover-container {
color: #fff;
}

20
tsconfig.json Normal file
View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

3223
yarn.lock Normal file

File diff suppressed because it is too large Load diff