mirror of
https://github.com/screego/server.git
synced 2026-08-30 17:33:30 +08:00
Add ui
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
package main
|
||||
|
||||
import "github.com/gobuffalo/packr/v2/packr2/cmd"
|
||||
|
||||
func main() {
|
||||
cmd.Execute()
|
||||
}
|
||||
+38
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
},
|
||||
}));
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
#root,
|
||||
body,
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -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')
|
||||
);
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
+207
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 227 KiB |
@@ -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;
|
||||
@@ -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);
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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('/');
|
||||
@@ -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};
|
||||
};
|
||||
@@ -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};
|
||||
};
|
||||
@@ -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]
|
||||
),
|
||||
];
|
||||
};
|
||||
Reference in New Issue
Block a user