This commit is contained in:
Jannis Mattheis
2020-10-04 19:28:16 +02:00
parent cf58440466
commit 0d462eb925
20 changed files with 1575 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
package main
import "github.com/gobuffalo/packr/v2/packr2/cmd"
func main() {
cmd.Execute()
}
+38
View File
@@ -0,0 +1,38 @@
package ui
import (
"fmt"
"net/http"
"github.com/gobuffalo/packr/v2"
"github.com/gorilla/mux"
)
var box = packr.New("ui", "build/")
// Register registers the ui on the root path.
func Register(r *mux.Router) {
r.Handle("/", serveFile("index.html", "text/html", box))
r.Handle("/index.html", serveFile("index.html", "text/html", box))
r.Handle("/manifest.json", serveFile("manifest.json", "application/json", box))
r.Handle("/service-worker.js", serveFile("service-worker.js", "text/javascript", box))
r.Handle("/assets-manifest.json", serveFile("asserts-manifest.json", "application/json", box))
r.Handle("/static/{type}/{resource}", http.FileServer(box))
r.Handle("/favicon.ico", serveFile("favicon.ico", "image/x-icon", box))
for _, size := range []string{"16x16", "32x32", "192x192", "256x256"} {
fileName := fmt.Sprintf("/favicon-%s.png", size)
r.Handle(fileName, serveFile(fileName, "image/png", box))
}
}
func serveFile(name, contentType string, box *packr.Box) http.HandlerFunc {
return func(writer http.ResponseWriter, reg *http.Request) {
writer.Header().Set("Content-Type", contentType)
content, err := box.Find(name)
if err != nil {
panic(err)
}
_, _ = writer.Write(content)
}
}
+89
View File
@@ -0,0 +1,89 @@
import {UseConfig} from './useConfig';
import React from 'react';
import {
Button,
ButtonProps,
CircularProgress,
FormControl,
TextField,
Typography,
} from '@material-ui/core';
import {makeStyles} from '@material-ui/core/styles';
import {green} from '@material-ui/core/colors';
export const LoginForm = ({config: {login}, hide}: {config: UseConfig; hide?: () => void}) => {
const [user, setUser] = React.useState('');
const [pass, setPass] = React.useState('');
const [loading, setLoading] = React.useState(false);
const submit = async (event: {preventDefault: () => void}) => {
event.preventDefault();
setLoading(true);
login(user, pass)
.then(() => {
setLoading(false);
})
.catch(() => setLoading(false));
};
return (
<div>
<FormControl fullWidth>
<form onSubmit={submit}>
<div style={{display: 'flex', alignItems: 'center'}}>
<Typography style={{flex: 1}}>Login to Screego</Typography>
{hide ? (
<Button variant="outlined" size="small" onClick={hide}>
Go Back
</Button>
) : undefined}
</div>
<TextField
fullWidth
value={user}
onChange={(e) => setUser(e.target.value)}
label="Username"
margin="dense"
/>
<TextField
fullWidth
value={pass}
type="password"
onChange={(e) => setPass(e.target.value)}
label="Password"
margin="dense"
/>
<LoadingButton
type="submit"
loading={loading}
onClick={submit}
fullWidth
variant="contained">
Login
</LoadingButton>
</form>
</FormControl>
</div>
);
};
export const LoadingButton = ({loading, children, ...props}: ButtonProps & {loading: boolean}) => {
const classes = useStyles();
return (
<Button {...props} disabled={loading}>
{children}
{loading && (
<CircularProgress className={classes.buttonProgress} size={24} color="secondary" />
)}
</Button>
);
};
const useStyles = makeStyles(() => ({
buttonProgress: {
color: green[500],
position: 'absolute',
top: '50%',
left: '50%',
marginTop: -12,
marginLeft: -12,
},
}));
+301
View File
@@ -0,0 +1,301 @@
import React from 'react';
import {setPermanentName} from './name';
import {
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
IconButton,
Menu,
MenuItem,
Paper,
TextField,
Theme,
Tooltip,
Typography,
} from '@material-ui/core';
import CancelPresentationIcon from '@material-ui/icons/CancelPresentation';
import PresentToAllIcon from '@material-ui/icons/PresentToAll';
import FullScreenIcon from '@material-ui/icons/Fullscreen';
import ShowMoreIcon from '@material-ui/icons/MoreVert';
import {Video} from './Video';
import {makeStyles} from '@material-ui/core/styles';
import {ConnectedRoom} from './useRoom';
const HostStream: unique symbol = Symbol('mystream');
export const Room = ({
state,
share,
stopShare,
setName,
}: {
state: ConnectedRoom;
share: () => void;
stopShare: () => void;
setName: (name: string) => void;
}) => {
const classes = useStyles();
const [open, setOpen] = React.useState(false);
const [nameInput, setNameInput] = React.useState('');
const [permanent, setPermanent] = React.useState(false);
const [showControl] = React.useState(true);
const [showMore, setShowMore] = React.useState<Element>();
const [selectedStream, setSelectedStream] = React.useState<string | typeof HostStream>();
const [videoElement, setVideoElement] = React.useState<HTMLVideoElement | null>(null);
React.useEffect(() => {
if (selectedStream === HostStream && state.hostStream) {
return;
}
if (state.clientStreams.some(({id}) => id === selectedStream)) {
return;
}
if (state.clientStreams.length === 0 && selectedStream) {
setSelectedStream(undefined);
return;
}
setSelectedStream(state.clientStreams[0]?.id);
}, [state.clientStreams, selectedStream, state.hostStream]);
const stream =
selectedStream === HostStream
? state.hostStream
: state.clientStreams.find(({id}) => selectedStream === id)?.stream;
const submitName = () => {
if (permanent) {
setPermanentName(nameInput);
}
setName(nameInput);
setOpen(false);
};
React.useEffect(() => {
if (videoElement && stream) {
videoElement.srcObject = stream;
videoElement.play();
}
}, [videoElement, stream]);
return (
<div className={classes.videoContainer}>
{showControl && (
<Paper className={classes.title} elevation={10}>
<Typography variant="h4" component="h4">
{state.id}
</Typography>
</Paper>
)}
{stream ? (
<video muted ref={setVideoElement} className={classes.video} />
) : (
<Typography
variant="h4"
align="center"
component="div"
style={{
top: '50%',
left: '50%',
position: 'absolute',
transform: 'translate(-50%, -50%)',
}}>
no stream available
</Typography>
)}
{showControl && (
<Paper className={classes.control} elevation={10}>
{state.hostStream ? (
<Tooltip title="Cancel Presentation" arrow>
<IconButton onClick={stopShare}>
<CancelPresentationIcon fontSize="large" />
</IconButton>
</Tooltip>
) : (
<Tooltip title="Start Presentation" arrow>
<IconButton onClick={share}>
<PresentToAllIcon fontSize="large" />
</IconButton>
</Tooltip>
)}
<Tooltip title="Fullscreen" arrow>
<IconButton
onClick={() => videoElement?.requestFullscreen()}
disabled={!selectedStream}>
<FullScreenIcon fontSize="large" />
</IconButton>
</Tooltip>
<Tooltip title="More" arrow>
<IconButton onClick={(e) => setShowMore(e.currentTarget)}>
<ShowMoreIcon fontSize="large" />
</IconButton>
</Tooltip>
<Menu
anchorEl={showMore}
keepMounted
open={Boolean(showMore)}
onClose={(e) => setShowMore(undefined)}>
<MenuItem
onClick={() => {
setShowMore(undefined);
setOpen(true);
}}>
Change Name
</MenuItem>
</Menu>
</Paper>
)}
<div className={classes.bottomContainer}>
{state.clientStreams
.filter(({id}) => id !== selectedStream)
.map((client) => {
return (
<Paper
elevation={4}
className={classes.smallVideoContainer}
onClick={() => setSelectedStream(client.id)}>
<Video
key={client.id}
src={client.stream}
className={classes.smallVideo}
/>
<Typography
variant="subtitle1"
component="div"
align="center"
className={classes.smallVideoLabel}>
{state.users.find(({id}) => client.peer_id === id)?.name ??
'unknown'}
</Typography>
</Paper>
);
})}
{state.hostStream && selectedStream !== HostStream && (
<Paper
elevation={4}
className={classes.smallVideoContainer}
onClick={() => setSelectedStream(HostStream)}>
<Video src={state.hostStream} className={classes.smallVideo} />
<Typography
variant="subtitle1"
component="div"
align="center"
className={classes.smallVideoLabel}>
You
</Typography>
</Paper>
)}
</div>
<Dialog open={open} onClose={() => setOpen(false)}>
<DialogTitle>Change Name</DialogTitle>
<DialogContent>
<form onSubmit={submitName}>
<TextField
autoFocus
margin="dense"
label="Username"
value={nameInput}
onChange={(e) => setNameInput(e.target.value)}
fullWidth
/>
<FormControlLabel
control={
<Checkbox
checked={permanent}
onChange={(_, checked) => setPermanent(checked)}
/>
}
label="Remember"
/>
</form>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)} color="primary">
Cancel
</Button>
<Button onClick={submitName} color="primary">
Change
</Button>
</DialogActions>
</Dialog>
</div>
);
};
const useStyles = makeStyles((theme: Theme) => ({
title: {
padding: 15,
position: 'fixed',
background: theme.palette.background.paper,
top: '30px',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 30,
},
bottomContainer: {
position: 'fixed',
display: 'flex',
bottom: 0,
right: 0,
zIndex: 20,
},
control: {
padding: 15,
position: 'fixed',
background: theme.palette.background.paper,
bottom: '30px',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 30,
},
video: {
maxWidth: '100%',
maxHeight: '100%',
width: 'auto',
height: 'auto',
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%,-50%)',
},
smallVideo: {
minWidth: '100%',
minHeight: '100%',
width: 'auto',
maxWidth: '300px',
maxHeight: '200px',
},
smallVideoLabel: {
position: 'absolute',
display: 'block',
bottom: 0,
background: 'rgba(0,0,0,.5)',
padding: '5px 15px',
},
smallVideoContainer: {
height: '100%',
padding: 5,
maxHeight: 200,
maxWidth: 400,
width: '100%',
},
videoContainer: {
position: 'absolute',
top: 0,
bottom: 0,
width: '100%',
height: '100%',
overflow: 'hidden',
},
}));
+149
View File
@@ -0,0 +1,149 @@
import React from 'react';
import {
Button,
Checkbox,
FormControl,
FormControlLabel,
Grid,
IconButton,
InputLabel,
MenuItem,
Paper,
Select,
TextField,
Typography,
} from '@material-ui/core';
import {FCreateRoom, UseRoom} from './useRoom';
import {RoomMode, UIConfig} from './message';
import {randomRoomName} from './name';
import HelpIcon from '@material-ui/icons/Help';
import logo from './logo.svg';
import {UseConfig} from './useConfig';
import {LoginForm} from './LoginForm';
const defaultMode = (authMode: UIConfig['authMode'], loggedIn: boolean): RoomMode => {
if (loggedIn) {
return RoomMode.Turn;
}
switch (authMode) {
case 'all':
return RoomMode.Turn;
case 'turn':
return RoomMode.Stun;
case 'none':
default:
return RoomMode.Turn;
}
};
const CreateRoom = ({room, config}: Pick<UseRoom, 'room'> & {config: UIConfig}) => {
const [id, setId] = React.useState(randomRoomName);
const [mode, setMode] = React.useState<RoomMode>(defaultMode(config.authMode, config.loggedIn));
const [ownerLeave, setOwnerLeave] = React.useState(true);
const submit = () =>
room({
type: 'create',
payload: {
mode,
closeOnOwnerLeave: ownerLeave,
id: id || undefined,
},
});
return (
<div>
<FormControl fullWidth>
<TextField
fullWidth
value={id}
onChange={(e) => setId(e.target.value)}
label="id"
margin="dense"
/>
<FormControlLabel
control={
<Checkbox
checked={ownerLeave}
onChange={(_, checked) => setOwnerLeave(checked)}
/>
}
label="Close Room after you leave"
/>
<FormControl margin="dense">
<InputLabel>NAT Traversal via</InputLabel>
<Select
fullWidth
value={mode}
onChange={(x) => setMode(x.target.value as RoomMode)}
endAdornment={
<IconButton size="small" href="https://screego.net/#/nat-traversal">
<HelpIcon />
</IconButton>
}>
<MenuItem
value={RoomMode.Stun}
disabled={config.authMode === 'all' && !config.loggedIn}>
STUN
</MenuItem>
<MenuItem
value={RoomMode.Turn}
disabled={config.authMode !== 'none' && !config.loggedIn}>
TURN
</MenuItem>
</Select>
</FormControl>
<Button onClick={submit} fullWidth variant="contained">
Create Room
</Button>
</FormControl>
</div>
);
};
export const RoomManage = ({room, config}: {room: FCreateRoom; config: UseConfig}) => {
const [showLogin, setShowLogin] = React.useState(false);
const canCreateRoom = config.authMode !== 'all';
const loginVisible = !config.loggedIn && (showLogin || !canCreateRoom);
return (
<Grid
container={true}
justify="center"
style={{paddingTop: 50, maxWidth: 400, width: '100%', margin: '0 auto'}}
spacing={4}>
<Grid item xs={12}>
<Typography align="center" gutterBottom>
<img src={logo} style={{width: 230}} alt="logo" />
</Typography>
<Paper elevation={3} style={{padding: 20}}>
{loginVisible ? (
<LoginForm
config={config}
hide={canCreateRoom ? () => setShowLogin(false) : undefined}
/>
) : (
<>
<Typography style={{display: 'flex', alignItems: 'center'}}>
<span style={{flex: 1}}>Hello {config.user}!</span>{' '}
{config.loggedIn ? (
<Button variant="outlined" size="small" onClick={config.logout}>
Logout
</Button>
) : (
<Button
variant="outlined"
size="small"
onClick={() => setShowLogin(true)}>
Login
</Button>
)}
</Typography>
<CreateRoom room={room} config={config} />
</>
)}
</Paper>
</Grid>
</Grid>
);
};
+21
View File
@@ -0,0 +1,21 @@
import React from 'react';
import {RoomManage} from './RoomManage';
import {useRoom} from './useRoom';
import {Room} from './Room';
import {useConfig} from './useConfig';
export const Router = () => {
const {room, state, ...other} = useRoom();
const config = useConfig();
if (config.loading) {
// show spinner
return null;
}
if (state) {
return <Room state={state} {...other} />;
}
return <RoomManage room={room} config={config} />;
};
+14
View File
@@ -0,0 +1,14 @@
import React from 'react';
export const Video = ({src, className}: {src: MediaStream; className?: string}) => {
const [element, setElement] = React.useState<HTMLVideoElement | null>(null);
React.useEffect(() => {
if (element) {
element.srcObject = src;
element.play();
}
}, [element, src]);
return <video muted ref={setElement} className={className} />;
};
+5
View File
@@ -0,0 +1,5 @@
#root,
body,
html {
height: 100%;
}
+85
View File
@@ -0,0 +1,85 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './global.css';
import {Button, createMuiTheme, CssBaseline, MuiThemeProvider} from '@material-ui/core';
import {Router} from './Router';
import {SnackbarProvider} from 'notistack';
const theme = createMuiTheme({
overrides: {
MuiSelect: {icon: {position: 'relative'}},
MuiLink: {
root: {
color: '#458588',
},
},
MuiIconButton: {
root: {
color: 'inherit',
},
},
MuiListItemIcon: {
root: {
color: 'inherit',
},
},
MuiToolbar: {
root: {
background: '#a89984',
},
},
MuiTooltip: {
tooltip: {
fontSize: '1.6em',
},
},
},
palette: {
background: {
default: '#282828',
paper: '#32302f',
},
text: {
primary: '#fbf1d4',
},
primary: {
main: '#a89984',
},
secondary: {
main: '#f44336',
},
type: 'dark',
},
});
const Snackbar: React.FC = ({children}) => {
const notistackRef = React.createRef<any>();
const onClickDismiss = (key: unknown) => () => {
notistackRef.current?.closeSnackbar(key);
};
return (
<SnackbarProvider
maxSnack={3}
ref={notistackRef}
action={(key) => (
<Button onClick={onClickDismiss(key)} size="small">
Dismiss
</Button>
)}>
{children}
</SnackbarProvider>
);
};
ReactDOM.render(
<React.StrictMode>
<MuiThemeProvider theme={theme}>
<Snackbar>
<CssBaseline />
<Router />
</Snackbar>
</MuiThemeProvider>
</React.StrictMode>,
document.getElementById('root')
);
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

+207
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 227 KiB

+100
View File
@@ -0,0 +1,100 @@
export enum ShareMode {
Everyone = 'Everyone',
Selected = 'Selected',
}
type Typed<Base, Type extends string> = {type: Type; payload: Base};
export interface UIConfig {
authMode: 'turn' | 'none' | 'all';
user: string;
loggedIn: boolean;
}
export interface RoomConfiguration {
id?: string;
closeOnOwnerLeave?: boolean;
mode: RoomMode;
username?: string;
}
export enum RoomMode {
Turn = 'turn',
Stun = 'stun',
Local = 'local',
}
export interface JoinConfiguration {
id: string;
password?: string;
username?: string;
}
export interface StringMessage {
message: string;
}
export interface P2PSession {
id: string;
peer: string;
iceServers: ICEServer[];
}
export interface ICEServer {
urls: string[];
credential: string;
username: string;
}
export interface RoomInfo {
id: string;
share: ShareMode;
mode: RoomMode;
users: RoomUser[];
}
export interface RoomUser {
id: string;
name: string;
streaming: boolean;
you: boolean;
owner: boolean;
}
export interface P2PMessage<T> {
sid: string;
value: T;
}
export type Room = Typed<RoomInfo, 'room'>;
export type Error = Typed<StringMessage, 'Error'>;
export type HostSession = Typed<P2PSession, 'hostsession'>;
export type Name = Typed<{username: string}, 'name'>;
export type ClientSession = Typed<P2PSession, 'clientsession'>;
export type HostICECandidate = Typed<P2PMessage<RTCIceCandidate>, 'hostice'>;
export type ClientICECandidate = Typed<P2PMessage<RTCIceCandidate>, 'clientice'>;
export type HostOffer = Typed<P2PMessage<RTCSessionDescriptionInit>, 'hostoffer'>;
export type ClientAnswer = Typed<P2PMessage<RTCSessionDescriptionInit>, 'clientanswer'>;
export type StartSharing = Typed<{}, 'share'>;
export type RoomCreate = Typed<RoomConfiguration, 'create'>;
export type JoinRoom = Typed<JoinConfiguration, 'join'>;
export type IncomingMessage =
| Room
| Error
| HostSession
| ClientSession
| HostICECandidate
| ClientICECandidate
| HostOffer
| ClientAnswer;
export type OutgoingMessage =
| RoomCreate
| Name
| JoinRoom
| HostICECandidate
| ClientICECandidate
| HostOffer
| ClientAnswer
| StartSharing;
+11
View File
@@ -0,0 +1,11 @@
import * as gen from 'unique-names-generator';
const roomConfig: gen.Config = {
dictionaries: [gen.adjectives, gen.colors, gen.animals],
length: 3,
separator: '-',
};
export const randomRoomName = () => gen.uniqueNamesGenerator(roomConfig);
export const getPermanentName = () => localStorage.getItem('screego_name') ?? undefined;
export const setPermanentName = (name: string) => localStorage.setItem('screego_name', name);
+1
View File
@@ -0,0 +1 @@
/// <reference types="react-scripts" />
+142
View File
@@ -0,0 +1,142 @@
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://bit.ly/CRA-PWA
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/)
);
type Config = {
onSuccess?: (registration: ServiceWorkerRegistration) => void;
onUpdate?: (registration: ServiceWorkerRegistration) => void;
};
export function register(config?: Config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://bit.ly/CRA-PWA'
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl: string, config?: Config) {
navigator.serviceWorker
.register(swUrl)
.then((registration) => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch((error) => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl: string, config?: Config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, {
headers: {'Service-Worker': 'script'},
})
.then((response) => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type');
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then((registration) => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log('No internet connection found. App is running in offline mode.');
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready
.then((registration) => {
registration.unregister();
})
.catch((error) => {
console.error(error.message);
});
}
}
+5
View File
@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom/extend-expect';
+10
View File
@@ -0,0 +1,10 @@
const {port, hostname, protocol, pathname} = window.location;
const slashes = protocol.concat('//');
const path = pathname.endsWith('/') ? pathname : pathname.substring(0, pathname.lastIndexOf('/'));
const url = slashes.concat(port ? hostname.concat(':', port) : hostname) + path;
export const urlWithSlash =
process.env.NODE_ENV === 'development'
? 'http://localhost:5050/'
: url.endsWith('/')
? url
: url.concat('/');
+54
View File
@@ -0,0 +1,54 @@
import {UIConfig} from './message';
import {useSnackbar} from 'notistack';
import React from 'react';
export interface UseConfig extends UIConfig {
login: (username: string, password: string) => Promise<void>;
refetch: () => void;
logout: () => Promise<void>;
loading: boolean;
}
export const useConfig = (): UseConfig => {
const {enqueueSnackbar} = useSnackbar();
const [{loading, ...config}, setConfig] = React.useState<UIConfig & {loading: boolean}>({
authMode: 'all',
user: 'guest',
loggedIn: false,
loading: true,
});
const refetch = React.useCallback(() => {
fetch(`config`)
.then((data) => data.json())
.then(setConfig);
}, [setConfig]);
const login = async (username: string, password: string) => {
const body = new FormData();
body.set('user', username);
body.set('pass', password);
const result = await fetch(`login`, {method: 'POST', body});
const json = await result.json();
if (result.status !== 200) {
enqueueSnackbar('Login Failed: ' + json.message, {variant: 'error'});
} else {
await refetch();
enqueueSnackbar('Logged in!', {variant: 'success'});
}
};
const logout = async () => {
const result = await fetch(`logout`, {method: 'POST'});
if (result.status !== 200) {
enqueueSnackbar('Logout Failed: ' + (await result.text()), {variant: 'error'});
} else {
await refetch();
enqueueSnackbar('Logged Out.', {variant: 'success'});
}
};
React.useEffect(refetch, []);
return {...config, refetch, loading, login, logout};
};
+304
View File
@@ -0,0 +1,304 @@
import React from 'react';
import {
ICEServer,
IncomingMessage,
JoinRoom,
OutgoingMessage,
RoomCreate,
RoomInfo,
} from './message';
import {getPermanentName} from './name';
import {urlWithSlash} from './url';
import {useSnackbar} from 'notistack';
import {useRoomID} from './useRoomID';
export type RoomState = false | ConnectedRoom;
export type ConnectedRoom = {
ws: WebSocket;
hostStream?: MediaStream;
clientStreams: ClientStream[];
} & RoomInfo;
interface ClientStream {
id: string;
peer_id: string;
stream: MediaStream;
}
export interface UseRoom {
state: RoomState;
room: FCreateRoom;
share: () => void;
setName: (name: string) => void;
stopShare: () => void;
}
const hostSession = async ({
sid,
ice,
send,
done,
stream,
}: {
sid: string;
ice: ICEServer[];
send: (e: OutgoingMessage) => void;
done: () => void;
stream: MediaStream;
}): Promise<RTCPeerConnection> => {
const peer = new RTCPeerConnection({iceServers: ice});
peer.onicecandidate = (event) => {
if (!event.candidate) {
return;
}
send({type: 'hostice', payload: {sid: sid, value: event.candidate}});
};
peer.onconnectionstatechange = (event) => {
console.log('host change', event);
if (
peer.connectionState === 'closed' ||
peer.connectionState === 'disconnected' ||
peer.connectionState === 'failed'
) {
peer.close();
done();
}
};
stream.getTracks().forEach((track) => peer.addTrack(track, stream));
const hostOffer = await peer.createOffer({offerToReceiveVideo: true});
await peer.setLocalDescription(hostOffer);
send({type: 'hostoffer', payload: {value: hostOffer, sid: sid}});
return peer;
};
const clientSession = async ({
sid,
ice,
send,
done,
onTrack,
}: {
sid: string;
ice: ICEServer[];
send: (e: OutgoingMessage) => void;
onTrack: (s: MediaStream) => void;
done: () => void;
}): Promise<RTCPeerConnection> => {
console.log('ice', ice);
const peer = new RTCPeerConnection({iceServers: ice});
peer.onicecandidate = (event) => {
if (!event.candidate) {
return;
}
send({type: 'clientice', payload: {sid: sid, value: event.candidate}});
};
peer.onconnectionstatechange = (event) => {
console.log('client change', event);
if (
peer.connectionState === 'closed' ||
peer.connectionState === 'disconnected' ||
peer.connectionState === 'failed'
) {
peer.close();
done();
}
};
peer.ontrack = (event) => {
const stream = new MediaStream();
stream.addTrack(event.track);
onTrack(stream);
};
return peer;
};
export type FCreateRoom = (room: RoomCreate | JoinRoom) => Promise<string | true>;
export const useRoom = (): UseRoom => {
const [roomID, setRoomID] = useRoomID();
const {enqueueSnackbar} = useSnackbar();
const conn = React.useRef<WebSocket>();
const host = React.useRef<Record<string, RTCPeerConnection>>({});
const client = React.useRef<Record<string, RTCPeerConnection>>({});
const stream = React.useRef<MediaStream>();
const [state, setState] = React.useState<RoomState>(false);
const room: FCreateRoom = React.useCallback(
(create) => {
return new Promise<true | string>((resolve) => {
const ws = (conn.current = new WebSocket(
urlWithSlash.replace('http', 'ws') + 'stream'
));
const send = (message: OutgoingMessage) => {
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(message));
};
let first = true;
ws.onmessage = (data) => {
const event: IncomingMessage = JSON.parse(data.data);
if (first) {
first = false;
if (event.type === 'room') {
resolve();
enqueueSnackbar(create.type === 'join' ? 'Joined' : 'Room Created', {
variant: 'success',
});
setState({ws, ...event.payload, clientStreams: []});
setRoomID(event.payload.id);
} else {
resolve();
enqueueSnackbar('Unknown Event: ' + event.type, {variant: 'error'});
ws.close(1000, 'received unknown event');
}
return;
}
switch (event.type) {
case 'room':
setState((current) =>
current ? {...current, ...event.payload} : current
);
return;
case 'hostsession':
if (!stream.current) {
return;
}
hostSession({
sid: event.payload.id,
stream: stream.current!,
ice: event.payload.iceServers,
send,
done: () => delete host.current[event.payload.id],
}).then((peer) => {
host.current[event.payload.id] = peer;
});
return;
case 'clientsession':
const {id: sid, peer} = event.payload;
clientSession({
sid,
send,
ice: event.payload.iceServers,
done: () => {
delete client.current[sid];
setState((current) =>
current
? {
...current,
clientStreams: current.clientStreams.filter(
({id}) => id !== sid
),
}
: current
);
},
onTrack: (stream) =>
setState((current) =>
current
? {
...current,
clientStreams: [
...current.clientStreams,
{
id: sid,
stream,
peer_id: peer,
},
],
}
: current
),
}).then((peer) => (client.current[event.payload.id] = peer));
return;
case 'clientice':
host.current[event.payload.sid]?.addIceCandidate(event.payload.value);
return;
case 'clientanswer':
host.current[event.payload.sid]?.setRemoteDescription(
new RTCSessionDescription(event.payload.value)
);
return;
case 'hostoffer':
(async () => {
await client.current[event.payload.sid]?.setRemoteDescription(
new RTCSessionDescription(event.payload.value)
);
const answer = await client.current[
event.payload.sid
]?.createAnswer();
await client.current[event.payload.sid]?.setLocalDescription(
answer
);
send({
type: 'clientanswer',
payload: {sid: event.payload.sid, value: answer},
});
})();
return;
case 'hostice':
client.current[event.payload.sid]?.addIceCandidate(event.payload.value);
return;
}
};
ws.onclose = (event) => {
if (first) {
resolve();
first = false;
}
enqueueSnackbar(event.reason, {variant: 'error', persist: true});
setState(false);
};
ws.onerror = (err) => {
if (first) {
resolve();
first = false;
}
enqueueSnackbar(err, {variant: 'error', persist: true});
setState(false);
};
ws.onopen = () => {
create.payload.username = getPermanentName();
send(create);
};
});
},
[setState, enqueueSnackbar, setRoomID]
);
const share = async () => {
stream.current = await navigator.mediaDevices
// @ts-ignore
.getDisplayMedia({video: true});
setState((current) => (current ? {...current, hostStream: stream.current} : current));
conn.current?.send(JSON.stringify({type: 'share', payload: {}}));
};
const stopShare = async () => {
Object.values(host.current).forEach((peer) => {
peer.close();
});
host.current = {};
stream.current?.getTracks().forEach((track) => track.stop());
stream.current = undefined;
// todo notify server
setState((current) => (current ? {...current, hostStream: undefined} : current));
};
const setName = (name: string): void => {
conn.current?.send(JSON.stringify({type: 'name', payload: {username: name}}));
};
React.useEffect(() => {
if (roomID) {
room({type: 'join', payload: {id: roomID}});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {state, room, share, stopShare, setName};
};
+32
View File
@@ -0,0 +1,32 @@
import React from 'react';
const getRoomFromURL = (search: string): string | undefined =>
search
.slice(1)
.split('&')
.find((param) => param.startsWith('room='))
?.split('=')[1];
export const useRoomID = (): [string | undefined, (v?: string) => void] => {
const [state, setState] = React.useState<string | undefined>(() =>
getRoomFromURL(window.location.search)
);
React.useEffect(() => {
const onChange = (): void => setState(getRoomFromURL(window.location.search));
window.addEventListener('popstate', onChange);
return () => window.removeEventListener('popstate', onChange);
}, [setState]);
return [
state,
React.useCallback(
(id) =>
setState((oldId?: string) => {
if (oldId !== id) {
window.history.pushState({roomId: id}, '', id ? `?room=${id}` : '?');
}
return id;
}),
[setState]
),
];
};