Use components (#6)
* Add link to components to README * use components meet version * update readme * fix index styles * update components * update components * update components
12
.env.example
|
@ -1,9 +1,11 @@
|
|||
# 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____>
|
||||
LIVEKIT_API_KEY=devkey
|
||||
LIVEKIT_API_SECRET=secret
|
||||
|
||||
|
||||
## PUBLIC
|
||||
NEXT_PUBLIC_LK_TOKEN_ENDPOINT=/api/token
|
||||
NEXT_PUBLIC_LK_SERVER_URL=wss://my-livekit-project.livekit.cloud
|
||||
|
|
|
@ -2,10 +2,12 @@
|
|||
|
||||
This project is home for a simple video conferencing app built with LiveKit.
|
||||
|
||||
> There's a new meet example app available! Redesigned from the ground up! Check its building blocks out [here](https://github.com/livekit/components-js)
|
||||
|
||||
## 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
|
||||
- App is built with [@livekit/components-react](https://github.com/livekit/components-js/) library
|
||||
|
||||
## Demo
|
||||
|
||||
|
|
|
@ -1,140 +0,0 @@
|
|||
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;
|
|
@ -1,19 +0,0 @@
|
|||
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;
|
|
@ -1,113 +0,0 @@
|
|||
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;
|
|
@ -1,120 +0,0 @@
|
|||
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;
|
|
@ -1,241 +0,0 @@
|
|||
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;
|
||||
}
|
||||
onClose();
|
||||
|
||||
event.target.value = '';
|
||||
let isReconnect = false;
|
||||
switch (value) {
|
||||
case 'signal-reconnect':
|
||||
case 'migration':
|
||||
isReconnect = true;
|
||||
|
||||
// fall through
|
||||
default:
|
||||
room.simulateScenario(value);
|
||||
}
|
||||
|
||||
if (isReconnect && room.engine) {
|
||||
toast({
|
||||
title: 'Reconnecting...',
|
||||
description: `current server: ${room.engine.connectedServerAddress}`,
|
||||
status: 'info',
|
||||
duration: 3000,
|
||||
});
|
||||
room.once(RoomEvent.Reconnected, () => {
|
||||
toast({
|
||||
title: 'Reconnected',
|
||||
description: `reconnected server: ${room.engine.connectedServerAddress}`,
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const lp = room.localParticipant;
|
||||
|
||||
const roomInfo = (
|
||||
<Table variant="simple">
|
||||
<Tbody>
|
||||
<Tr>
|
||||
<Td>Room</Td>
|
||||
<Td>
|
||||
{room.name}
|
||||
<Badge>{room.sid}</Badge>
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Participant</Td>
|
||||
<Td>
|
||||
{lp.identity}
|
||||
<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>
|
||||
<option value="migration">Migration (Cloud-only)</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()}
|
||||
<Badge>{t.trackSid}</Badge>
|
||||
</Text>
|
||||
</HStack>
|
||||
<Table variant="simple">
|
||||
<Tbody>
|
||||
<Tr>
|
||||
<Td>Kind</Td>
|
||||
<Td>
|
||||
{t.kind}
|
||||
{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()}
|
||||
<Badge>{t.trackSid}</Badge>
|
||||
</Text>
|
||||
</HStack>
|
||||
<Table variant="simple">
|
||||
<Tbody>
|
||||
<Tr>
|
||||
<Td>Kind</Td>
|
||||
<Td>
|
||||
{t.kind}
|
||||
{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;
|
|
@ -1,216 +0,0 @@
|
|||
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;
|
|
@ -1,66 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
22
lib/Debug.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import * as React from 'react';
|
||||
import { useRoomContext } from '@livekit/components-react';
|
||||
import { setLogLevel } from 'livekit-client';
|
||||
|
||||
export const useDebugMode = () => {
|
||||
setLogLevel('debug');
|
||||
const room = useRoomContext();
|
||||
React.useEffect(() => {
|
||||
// @ts-expect-error
|
||||
window.__lk_room = room;
|
||||
|
||||
return () => {
|
||||
// @ts-expect-error
|
||||
window.__lk_room = undefined;
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const DebugMode = () => {
|
||||
useDebugMode();
|
||||
return <></>;
|
||||
};
|
|
@ -6,9 +6,9 @@ export function getRoomClient(): RoomServiceClient {
|
|||
}
|
||||
|
||||
export function getLiveKitURL(region?: string): string {
|
||||
let targetKey = 'LIVEKIT_URL';
|
||||
let targetKey = 'NEXT_PUBLIC_LK_SERVER_URL';
|
||||
if (region) {
|
||||
targetKey = `LIVEKIT_URL_${region}`.toUpperCase();
|
||||
targetKey = `NEXT_PUBLIC_LK_SERVER_URL${region}`.toUpperCase();
|
||||
}
|
||||
const url = process.env[targetKey];
|
||||
if (!url) {
|
||||
|
|
|
@ -11,6 +11,7 @@ export interface SessionProps {
|
|||
}
|
||||
|
||||
export interface TokenResult {
|
||||
identity: string;
|
||||
url: string;
|
||||
token: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
reactStrictMode: false,
|
||||
swcMinify: true,
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
|
36
package.json
|
@ -1,35 +1,29 @@
|
|||
{
|
||||
"name": "livekit-meet",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"format": "prettier --write ."
|
||||
"lint": "next lint"
|
||||
},
|
||||
"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/components-react": "^0.1.20",
|
||||
"@livekit/components-styles": "^0.1.9",
|
||||
"livekit-client": "^1.6.2",
|
||||
"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"
|
||||
"next": "12.2.4",
|
||||
"next-seo": "^5.15.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.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"
|
||||
"@types/node": "18.6.5",
|
||||
"@types/react": "18.0.17",
|
||||
"@types/react-dom": "18.0.6",
|
||||
"eslint": "8.21.0",
|
||||
"eslint-config-next": "12.2.4",
|
||||
"typescript": "4.7.4"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,15 +1,59 @@
|
|||
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';
|
||||
import type { AppProps } from 'next/app';
|
||||
import '@livekit/components-styles';
|
||||
import '@livekit/components-styles/prefabs';
|
||||
import { DefaultSeo } from 'next-seo';
|
||||
|
||||
function MyApp({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<ChakraProvider theme={theme}>
|
||||
<GlobalStyles />
|
||||
<>
|
||||
<DefaultSeo
|
||||
title="LiveKit Meet | Conference app build with LiveKit Open Source"
|
||||
titleTemplate="%s"
|
||||
defaultTitle="LiveKit Meet | Conference app build with LiveKit open source"
|
||||
description="LiveKit is an open source WebRTC project that gives you everything needed to build scalable and real-time audio and/or video experiences in your applications."
|
||||
twitter={{
|
||||
handle: '@livekitted',
|
||||
site: '@livekitted',
|
||||
cardType: 'summary_large_image',
|
||||
}}
|
||||
openGraph={{
|
||||
url: 'https://meet.livekit.io',
|
||||
images: [
|
||||
{
|
||||
url: 'https://meet.livekit.io/images/livekit-meet-open-graph.png',
|
||||
width: 2000,
|
||||
height: 1000,
|
||||
type: 'image/png',
|
||||
},
|
||||
],
|
||||
site_name: 'LiveKit Meet',
|
||||
}}
|
||||
additionalMetaTags={[
|
||||
{
|
||||
property: 'theme-color',
|
||||
content: '#070707',
|
||||
},
|
||||
]}
|
||||
additionalLinkTags={[
|
||||
{
|
||||
rel: 'icon',
|
||||
href: '/favicon.ico',
|
||||
},
|
||||
{
|
||||
rel: 'apple-touch-icon',
|
||||
href: '/images/livekit-apple-touch.png',
|
||||
sizes: '180x180',
|
||||
},
|
||||
{
|
||||
rel: 'mask-icon',
|
||||
href: '/images/livekit-safari-pinned-tab.svg',
|
||||
color: '#070707',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Component {...pageProps} />
|
||||
</ChakraProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,40 +1,72 @@
|
|||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import { AccessToken } from 'livekit-server-sdk';
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import type { AccessTokenOptions, VideoGrant } from 'livekit-server-sdk';
|
||||
import { getLiveKitURL } from '../../lib/clients';
|
||||
import { TokenResult } from '../../lib/types';
|
||||
|
||||
const apiKey = process.env.LIVEKIT_API_KEY;
|
||||
const apiSecret = process.env.LIVEKIT_API_SECRET;
|
||||
|
||||
const createToken = (userInfo: AccessTokenOptions, grant: VideoGrant) => {
|
||||
const at = new AccessToken(apiKey, apiSecret, userInfo);
|
||||
at.ttl = '5m';
|
||||
at.addGrant(grant);
|
||||
return at.toJwt();
|
||||
};
|
||||
|
||||
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;
|
||||
export default async function handleToken(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const { roomName, identity, name, metadata, region } = req.query;
|
||||
|
||||
if (!roomName || !identity) {
|
||||
res.status(403).end();
|
||||
return;
|
||||
if (typeof identity !== 'string' || typeof roomName !== 'string') {
|
||||
res.status(403).end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(name)) {
|
||||
throw Error('provide max one name');
|
||||
}
|
||||
if (Array.isArray(metadata)) {
|
||||
throw Error('provide max one metadata string');
|
||||
}
|
||||
if (Array.isArray(region)) {
|
||||
throw Error('provide max one region string');
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// if (!userSession.isAuthenticated) {
|
||||
// res.status(403).end();
|
||||
// return;
|
||||
// }
|
||||
|
||||
const grant: VideoGrant = {
|
||||
room: roomName,
|
||||
roomJoin: true,
|
||||
canPublish: true,
|
||||
canPublishData: true,
|
||||
canSubscribe: true,
|
||||
};
|
||||
|
||||
const token = createToken({ identity, name, metadata }, grant);
|
||||
const result: TokenResult = {
|
||||
identity,
|
||||
accessToken: token,
|
||||
url: getLiveKitURL(region),
|
||||
};
|
||||
|
||||
res.status(200).json(result);
|
||||
} catch (e) {
|
||||
res.statusMessage = (e as Error).message;
|
||||
res.status(500).end();
|
||||
}
|
||||
|
||||
// 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(),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
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';
|
||||
|
@ -11,23 +9,39 @@ const Home: NextPage = () => {
|
|||
};
|
||||
|
||||
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
|
||||
<>
|
||||
<main className={styles.main}>
|
||||
<div className="header">
|
||||
<img src="/images/livekit-meet-home.svg" alt="LiveKit Meet" width="480" height="60" />
|
||||
<h2>
|
||||
Open source video conferencing app built on LiveKit Components, LiveKit Cloud,
|
||||
and Next.js.
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
style={{ fontSize: '1.25rem', paddingInline: '1.25rem' }}
|
||||
className="lk-button"
|
||||
onClick={startMeeting}
|
||||
variant="primary"
|
||||
py="0.75rem"
|
||||
px="2rem"
|
||||
{...typography.textStyles['h5-mono']}
|
||||
_hover={{ backgroundColor: '#4963B0' }}
|
||||
>
|
||||
Start Meeting
|
||||
</Button>
|
||||
</button>
|
||||
</main>
|
||||
</Box>
|
||||
<footer>
|
||||
Hosted on{' '}
|
||||
<a href="https://livekit.io/cloud?ref=meet" target="_blank" rel="noreferrer">
|
||||
LiveKit Cloud
|
||||
</a>
|
||||
. Source code on{' '}
|
||||
<a
|
||||
href="https://github.com/livekit/components-js?ref=meet"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
.
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -38,10 +52,10 @@ function generateRoomId(): string {
|
|||
}
|
||||
|
||||
function randomString(length: number): string {
|
||||
var result = '';
|
||||
var characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
var charactersLength = characters.length;
|
||||
for (var i = 0; i < length; i++) {
|
||||
let result = '';
|
||||
const characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const charactersLength = characters.length;
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += characters.charAt(Math.floor(Math.random() * charactersLength));
|
||||
}
|
||||
return result;
|
||||
|
|
|
@ -1,109 +1,103 @@
|
|||
import { useToast } from '@chakra-ui/react';
|
||||
import { GetServerSidePropsContext } from 'next';
|
||||
import {
|
||||
LiveKitRoom,
|
||||
PreJoin,
|
||||
LocalUserChoices,
|
||||
useToken,
|
||||
VideoConference,
|
||||
Chat,
|
||||
} from '@livekit/components-react';
|
||||
import { AudioCaptureOptions, RoomOptions, VideoCaptureOptions } from 'livekit-client';
|
||||
|
||||
import type { NextPage } from 'next';
|
||||
import Head from 'next/head';
|
||||
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';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { DebugMode } from '../../lib/Debug';
|
||||
|
||||
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 Home: NextPage = () => {
|
||||
const router = useRouter();
|
||||
const { name: roomName } = router.query;
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
const [preJoinChoices, setPreJoinChoices] = useState<LocalUserChoices | undefined>(undefined);
|
||||
if (!roomName || Array.isArray(roomName)) {
|
||||
return <h2>no room param passed</h2>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>LiveKit Meet</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<main>
|
||||
{preJoinChoices ? (
|
||||
<ActiveRoom
|
||||
roomName={roomName}
|
||||
userChoices={preJoinChoices}
|
||||
onLeave={() => setPreJoinChoices(undefined)}
|
||||
></ActiveRoom>
|
||||
) : (
|
||||
<div style={{ display: 'grid', placeItems: 'center', height: '100%' }}>
|
||||
<PreJoin
|
||||
onError={(err) => console.log('error while setting up prejoin', err)}
|
||||
defaults={{
|
||||
username: '',
|
||||
videoEnabled: true,
|
||||
audioEnabled: true,
|
||||
}}
|
||||
onSubmit={(values) => {
|
||||
console.log('Joining with: ', values);
|
||||
setPreJoinChoices(values);
|
||||
}}
|
||||
></PreJoin>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
export default Home;
|
||||
|
||||
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 = {
|
||||
type ActiveRoomProps = {
|
||||
userChoices: LocalUserChoices;
|
||||
roomName: string;
|
||||
onLeave?: () => void;
|
||||
};
|
||||
const ActiveRoom = ({ roomName, userChoices, onLeave }: ActiveRoomProps) => {
|
||||
const token = useToken({
|
||||
tokenEndpoint: process.env.NEXT_PUBLIC_LK_TOKEN_ENDPOINT,
|
||||
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;
|
||||
}
|
||||
userInfo: {
|
||||
identity: userChoices.username,
|
||||
name: userChoices.username,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
props,
|
||||
};
|
||||
const roomOptions = useMemo((): RoomOptions => {
|
||||
return {
|
||||
videoCaptureDefaults: {
|
||||
deviceId: userChoices.videoDeviceId ?? undefined,
|
||||
},
|
||||
audioCaptureDefaults: {
|
||||
deviceId: userChoices.audioDeviceId ?? undefined,
|
||||
},
|
||||
adaptiveStream: { pixelDensity: 'screen' },
|
||||
dynacast: true,
|
||||
};
|
||||
}, [userChoices]);
|
||||
|
||||
return (
|
||||
<LiveKitRoom
|
||||
token={token}
|
||||
serverUrl={process.env.NEXT_PUBLIC_LK_SERVER_URL}
|
||||
options={roomOptions}
|
||||
video={userChoices.videoEnabled}
|
||||
audio={userChoices.audioEnabled}
|
||||
onDisconnected={onLeave}
|
||||
>
|
||||
<VideoConference />
|
||||
<DebugMode />
|
||||
</LiveKitRoom>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomPage;
|
||||
|
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 15 KiB |
BIN
public/images/livekit-apple-touch.png
Normal file
After Width: | Height: | Size: 323 B |
13
public/images/livekit-meet-home.svg
Normal file
|
@ -0,0 +1,13 @@
|
|||
<svg width="961" height="121" viewBox="0 0 961 121" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.2434 0H0V118.13H73.4374V100.877H20.2434V0Z" fill="white"/>
|
||||
<path d="M106.853 53.7776H87.2378V118.124H106.853V53.7776Z" fill="white"/>
|
||||
<path d="M164.596 115.794L139.647 34.3458H120.032L146.238 118.127H182.954L209.16 34.3458H189.387L164.596 115.794Z" fill="white"/>
|
||||
<path d="M257.821 32.4892C232.397 32.4892 216.236 50.5216 216.236 76.1662C216.236 101.659 231.927 120 257.821 120C277.59 120 291.871 111.295 297.205 93.4194H277.26C274.281 101.502 268.783 106.329 257.956 106.329C246.031 106.329 237.718 98.09 236.149 81.9296H298.601C298.9 79.8693 299.055 77.7911 299.067 75.7098C299.072 49.5856 282.752 32.4892 257.821 32.4892ZM236.303 68.3926C238.346 53.3203 246.348 46.1691 257.821 46.1691C269.9 46.1691 278.06 55.0262 279.005 68.3926H236.303Z" fill="white"/>
|
||||
<path d="M413.768 0H388.349L339.079 54.0925V0H318.835V118.13H339.079V58.445L393.371 118.13H419.261L362.459 55.9553L413.768 0Z" fill="white"/>
|
||||
<path d="M447.653 34.3458H428.038V98.6926H447.653V34.3458Z" fill="white"/>
|
||||
<path d="M87.24 34.3458H67.625V53.7753H87.24V34.3458Z" fill="white"/>
|
||||
<path d="M467.273 98.6996H447.658V118.129H467.273V98.6996Z" fill="white"/>
|
||||
<path d="M525.882 98.6996H506.267V118.129H525.882V98.6996Z" fill="white"/>
|
||||
<path d="M525.88 53.779V34.3496H506.265V0H486.65V34.3496H467.035V53.779H486.65V98.7009H506.265V53.779H525.88Z" fill="white"/>
|
||||
<path d="M589.832 119V0.439992H579.068V119H589.832ZM643.652 119L602.312 0.439992H590.924L632.42 119H643.652ZM655.976 119L697.316 0.439992H686.084L644.744 119H655.976ZM709.172 119V0.439992H698.564V119H709.172ZM808.583 76.1C808.583 50.516 792.203 34.292 770.207 34.292C747.119 34.292 731.519 51.764 731.519 77.504C731.519 103.4 747.119 120.716 770.675 120.716C788.615 120.716 802.031 112.292 806.867 94.664H796.259C792.671 106.052 784.559 112.916 770.675 112.916C754.295 112.916 742.907 101.06 741.971 80.156H808.271C808.427 78.44 808.583 77.504 808.583 76.1ZM770.207 42.092C786.119 42.092 797.039 54.884 797.975 72.2H742.127C743.843 53.324 754.451 42.092 770.207 42.092ZM900.599 76.1C900.599 50.516 884.219 34.292 862.223 34.292C839.135 34.292 823.535 51.764 823.535 77.504C823.535 103.4 839.135 120.716 862.691 120.716C880.631 120.716 894.047 112.292 898.883 94.664H888.275C884.687 106.052 876.575 112.916 862.691 112.916C846.311 112.916 834.923 101.06 833.987 80.156H900.287C900.443 78.44 900.599 77.504 900.599 76.1ZM862.223 42.092C878.135 42.092 889.055 54.884 889.991 72.2H834.143C835.859 53.324 846.467 42.092 862.223 42.092ZM950.534 111.044C941.798 111.044 936.962 107.612 936.962 97.94V97.628V43.964H959.894V36.008H936.962V16.352H926.666V36.008H908.102V43.964H926.666V97.628V97.94C926.666 112.292 936.026 119 949.598 119H960.05V111.044H950.534Z" fill="#FF6352"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.7 KiB |
BIN
public/images/livekit-meet-open-graph.png
Normal file
After Width: | Height: | Size: 22 KiB |
1
public/images/livekit-safari-pinned-tab.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg fill="none" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="m0 0h512v512h-512zm288.005 223.995h-64.01v64.01h64.01v63.985h-64.01-63.985v-63.985-64.01-63.985-64.01h-64.01v64.01 63.985 64.01 63.985 64.01h64.01 63.985 64.01v-63.985h63.985v63.985h64.01v-64.01h-63.985v-63.985h-64.01v-63.985h64.01v-64.01h63.985v-64.01h-64.01v64.01h-63.985z" fill="#000" fill-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 443 B |
|
@ -1,16 +0,0 @@
|
|||
<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>
|
Before Width: | Height: | Size: 4.5 KiB |
|
@ -1,12 +1,7 @@
|
|||
.container {
|
||||
.main {
|
||||
display: grid;
|
||||
grid-template-columns: 700px;
|
||||
gap: 16px;
|
||||
height: 100vh;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.container main {
|
||||
text-align: center;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
}
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
.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;
|
||||
}
|
|
@ -1,11 +1,57 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@100;300;400;600&display=swap');
|
||||
|
||||
video {
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
.react-tiny-popover-container {
|
||||
color: #fff;
|
||||
#__next,
|
||||
main {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
max-width: 600px;
|
||||
padding-inline: 2rem;
|
||||
}
|
||||
|
||||
.header > img {
|
||||
display: block;
|
||||
margin: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.header > h2 {
|
||||
font-family: 'TWK Everett', sans-serif;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-size: 1.25rem;
|
||||
line-height: 144%;
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
padding: 1.5rem 2rem;
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
background-color: var(--lk-bg);
|
||||
border-top: 1px solid rgba(255, 255, 255, .15);
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: #ff6352;
|
||||
text-decoration-color: #a33529;
|
||||
text-underline-offset: 0.125em;
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
text-decoration-color: #ff6352;
|
||||
}
|
||||
|
|