import Store from 'electron-store'; import React, { ReactChild, useContext, useEffect, useReducer, useState } from 'react'; import { SettingsContext, LobbySettingsContext, GameStateContext } from '../contexts'; import MicrophoneSoundBar from './MicrophoneSoundBar'; import TestSpeakersButton from './TestSpeakersButton'; import { ISettings, ILobbySettings } from '../../common/ISettings'; import TextField from '@material-ui/core/TextField'; import makeStyles from '@material-ui/core/styles/makeStyles'; import withStyles from '@material-ui/core/styles/withStyles'; import Box from '@material-ui/core/Box'; import FormControlLabel from '@material-ui/core/FormControlLabel'; import Radio from '@material-ui/core/Radio'; import Checkbox from '@material-ui/core/Checkbox'; import RadioGroup from '@material-ui/core/RadioGroup'; import MuiDivider from '@material-ui/core/Divider'; import Typography from '@material-ui/core/Typography'; import Grid from '@material-ui/core/Grid'; import { isHttpUri, isHttpsUri } from 'valid-url'; import ChevronLeft from '@material-ui/icons/ArrowBack'; import IconButton from '@material-ui/core/IconButton'; import Alert from '@material-ui/lab/Alert'; import Slider from '@material-ui/core/Slider'; import Tooltip from '@material-ui/core/Tooltip'; import Dialog from '@material-ui/core/Dialog'; import DialogTitle from '@material-ui/core/DialogTitle'; import DialogContent from '@material-ui/core/DialogContent'; import DialogActions from '@material-ui/core/DialogActions'; import { GameState } from '../../common/AmongUsState'; import Button from '@material-ui/core/Button'; import { ipcRenderer, remote } from 'electron'; import { IpcHandlerMessages } from '../../common/ipc-messages'; import DialogContentText from '@material-ui/core/DialogContentText'; interface StyleInput { open: boolean; } const Divider = withStyles((theme) => ({ root: { width: '100%', marginTop: theme.spacing(2), marginBottom: theme.spacing(2), }, }))(MuiDivider); const useStyles = makeStyles((theme) => ({ root: { width: '100vw', height: `calc(100vh - ${theme.spacing(3)}px)`, background: '#171717ad', backdropFilter: 'blur(4px)', position: 'absolute', left: 0, top: 0, zIndex: 99, alignItems: 'center', marginTop: theme.spacing(3), transition: 'transform .1s ease-in-out', WebkitAppRegion: 'no-drag', transform: ({ open }: StyleInput) => (open ? 'translateX(0)' : 'translateX(-100%)'), }, header: { display: 'flex', justifyContent: 'center', alignItems: 'center', height: 40, }, scroll: { paddingTop: theme.spacing(1), paddingLeft: theme.spacing(2), paddingRight: theme.spacing(2), overflowY: 'auto', display: 'flex', flexDirection: 'column', justifyContent: 'start', alignItems: 'center', paddingBottom: theme.spacing(7), height: `calc(100vh - 40px - ${theme.spacing(7 + 3 + 3)}px)`, }, shortcutField: { marginTop: theme.spacing(1), }, back: { cursor: 'pointer', position: 'absolute', right: theme.spacing(1), WebkitAppRegion: 'no-drag', }, alert: { position: 'absolute', bottom: theme.spacing(1), zIndex: 10, }, urlDialog: { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'start', '&>*': { marginBottom: theme.spacing(1), }, }, })); const keys = new Set([ 'CapsLock', 'Space', 'Backspace', 'Delete', 'Enter', 'Up', 'Down', 'Left', 'Right', 'Home', 'End', 'PageUp', 'PageDown', 'Escape', 'LShift', 'RShift', 'RAlt', 'LAlt', 'RControl', 'LControl', ]); export enum pushToTalkOptions { VOICE, PUSH_TO_TALK, PUSH_TO_MUTE, } const store = new Store({ migrations: { '2.0.6': (store) => { if ( store.get('serverURL') === 'https://bettercrewl.ink:6523' || store.get('serverURL') === 'http://bettercrewl.ink' || store.get('serverURL') === 'http://crewlink.guus.info' || store.get('serverURL') === 'https://crewlink.guus.info' ) { store.set('serverURL', 'https://bettercrewl.ink'); } }, '2.0.7': (store) => { if ( store.get('serverURL') === 'https://bettercrewl.ink:6523' || store.get('serverURL') === 'http://bettercrewl.ink' || store.get('serverURL') === 'http://crewlink.guus.info' || store.get('serverURL') === 'https://crewlink.guus.ninja' ) { store.set('serverURL', 'https://bettercrewl.ink'); } }, '2.1.4': (store) => { store.set('playerConfigMap', {}); }, '2.2.0': (store) => { store.set('mobileHost', true); }, '2.2.5': (store) => { const pushToTalkValue = store.get('pushToTalk'); if (typeof pushToTalkValue === 'boolean') { store.set('pushToTalkMode', pushToTalkValue ? pushToTalkOptions.PUSH_TO_TALK : pushToTalkOptions.VOICE); } // @ts-ignore store.delete('pushToTalk'); }, '2.3.6': (store) => { store.set('serverURL', 'https://bettercrewl.ink'); }, }, schema: { alwaysOnTop: { type: 'boolean', default: false, }, microphone: { type: 'string', default: 'Default', }, speaker: { type: 'string', default: 'Default', }, pushToTalkMode: { type: 'number', default: pushToTalkOptions.VOICE, }, serverURL: { type: 'string', default: 'https://bettercrewl.ink', format: 'uri', }, pushToTalkShortcut: { type: 'string', default: 'V', }, deafenShortcut: { type: 'string', default: 'RControl', }, muteShortcut: { type: 'string', default: 'RAlt', }, hideCode: { type: 'boolean', default: false, }, compactOverlay: { type: 'boolean', default: false, }, overlayPosition: { type: 'string', default: 'right', }, meetingOverlay: { type: 'boolean', default: true, }, enableOverlay: { type: 'boolean', default: true, }, ghostVolume: { type: 'number', default: 100, }, masterVolume: { type: 'number', default: 100, }, microphoneGain: { type: 'number', default: 100, }, microphoneGainEnabled: { type: 'boolean', default: false, }, micSensitivity: { type: 'number', default: 0.15, }, micSensitivityEnabled: { type: 'boolean', default: false, }, natFix: { type: 'boolean', default: false, }, mobileHost: { type: 'boolean', default: true, }, vadEnabled: { type: 'boolean', default: true, }, enableSpatialAudio: { type: 'boolean', default: true, }, obsSecret: { type: 'string', default: undefined, }, obsComptaibilityMode: { type: 'boolean', default: true, }, obsOverlay: { type: 'boolean', default: false, }, echoCancellation: { type: 'boolean', default: true, }, noiseSuppression: { type: 'boolean', default: true, }, playerConfigMap: { type: 'object', default: {}, }, localLobbySettings: { type: 'object', properties: { maxDistance: { type: 'number', default: 5.32, }, haunting: { type: 'boolean', default: false, }, commsSabotage: { type: 'boolean', default: false, }, hearImpostorsInVents: { type: 'boolean', default: false, }, impostersHearImpostersInvent: { type: 'boolean', default: false, }, deadOnly: { type: 'boolean', default: false, }, meetingGhostOnly: { type: 'boolean', default: false, }, visionHearing: { type: 'boolean', default: false, }, hearThroughCameras: { type: 'boolean', default: false, }, wallsBlockAudio: { type: 'boolean', default: false, }, }, default: { maxDistance: 5.32, haunting: false, commsSabotage: false, hearImpostorsInVents: false, hearThroughCameras: false, wallsBlockAudio: false, deadOnly: false, meetingGhostOnly: false, visionHearing: false, }, }, }, }); export interface SettingsProps { open: boolean; onClose: () => void; } export const settingsReducer = ( state: ISettings, action: { type: 'set' | 'setOne' | 'setLobbySetting'; action: [string, unknown] | ISettings; } ): ISettings => { if (action.type === 'set') { return action.action as ISettings; } const v = action.action as [string, unknown]; if (action.type === 'setLobbySetting') { const lobbySettings = { ...state.localLobbySettings, [v[0]]: v[1], }; v[0] = 'localLobbySettings'; v[1] = lobbySettings; } store.set(v[0], v[1]); return { ...state, [v[0]]: v[1], }; }; export const lobbySettingsReducer = ( state: ILobbySettings, action: { type: 'set' | 'setOne'; action: [string, unknown] | ILobbySettings; } ): ILobbySettings => { if (action.type === 'set') return action.action as ILobbySettings; const v = action.action as [string, unknown]; return { ...state, [v[0]]: v[1], }; }; interface MediaDevice { id: string; kind: MediaDeviceKind; label: string; } function validateServerUrl(uri: string): boolean { try { if (!isHttpUri(uri) && !isHttpsUri(uri)) return false; const url = new URL(uri); if (url.hostname === 'discord.gg') return false; if (url.pathname !== '/') return false; return true; } catch (_) { return false; } } type URLInputProps = { initialURL: string; onValidURL: (url: string) => void; className: string; }; const URLInput: React.FC = function ({ initialURL, onValidURL, className }: URLInputProps) { const [isValidURL, setURLValid] = useState(true); const [currentURL, setCurrentURL] = useState(initialURL); const [open, setOpen] = useState(false); useEffect(() => { setCurrentURL(initialURL); }, [initialURL]); function handleChange(event: React.ChangeEvent) { const url = event.target.value.trim(); setCurrentURL(url); if (validateServerUrl(url)) { setURLValid(true); } else { setURLValid(false); } } return ( <> setOpen(false)}> Change Voice Server This option is for advanced users only. Other servers can steal your info or crash CrewLink. ); }; interface DisabledTooltipProps { disabled: boolean; title: string; children: ReactChild; } interface IConfirmDialog { confirmCallback?: () => void; description?: string; title?: string; open: boolean; } const DisabledTooltip: React.FC = function ({ disabled, children, title }: DisabledTooltipProps) { if (disabled) return ( {children} ); else return <>{children}; }; const Settings: React.FC = function ({ open, onClose }: SettingsProps) { const classes = useStyles({ open }); const [settings, setSettings] = useContext(SettingsContext); const gameState = useContext(GameStateContext); const [lobbySettings, setLobbySettings] = useContext(LobbySettingsContext); const [unsavedCount, setUnsavedCount] = useState(0); const unsaved = unsavedCount > 2; useEffect(() => { setSettings({ type: 'set', action: store.store, }); setLobbySettings({ type: 'set', action: store.get('localLobbySettings'), }); }, []); useEffect(() => { setUnsavedCount((s) => s + 1); }, [ settings.microphone, settings.speaker, settings.serverURL, settings.vadEnabled, settings.natFix, settings.noiseSuppression, settings.echoCancellation, settings.obsComptaibilityMode, settings.mobileHost, settings.microphoneGainEnabled, settings.micSensitivityEnabled, ]); useEffect(() => { remote.getCurrentWindow().setAlwaysOnTop(settings.alwaysOnTop, 'screen-saver'); }, [settings.alwaysOnTop]); useEffect(() => { ipcRenderer.send('enableOverlay', settings.enableOverlay); }, [settings.enableOverlay]); const [devices, setDevices] = useState([]); const [_, updateDevices] = useReducer((state) => state + 1, 0); useEffect(() => { navigator.mediaDevices.enumerateDevices().then((devices) => setDevices( devices.map((d) => { let label = d.label; if (d.deviceId === 'default') { label = 'Default'; } else { const match = /(.+?)\)/.exec(d.label); if (match && match[1]) label = match[1] + ')'; } return { id: d.deviceId, kind: d.kind, label, }; }) ) ); }, [_]); const setShortcut = (ev: React.KeyboardEvent, shortcut: string) => { // console.log(ev, shortcut); let k = ev.key; if (k.length === 1) k = k.toUpperCase(); else if (k.startsWith('Arrow')) k = k.substring(5); if (k === ' ') k = 'Space'; /* @ts-ignore */ const c = ev.code as string; if (c && c.startsWith('Numpad')) { k = c; } if (k === 'Control' || k === 'Alt' || k === 'Shift') k = (ev.location === 1 ? 'L' : 'R') + k; if (/^[0-9A-Z]$/.test(k) || /^F[0-9]{1,2}$/.test(k) || keys.has(k) || k.startsWith('Numpad')) { if (k === 'Escape') { console.log('disable??'); k = 'Disabled'; } setSettings({ type: 'setOne', action: [shortcut, k], }); ipcRenderer.send(IpcHandlerMessages.RESET_KEYHOOKS); } }; const setMouseShortcut = (ev: React.MouseEvent, shortcut: string) => { if (ev.button > 2) { // this makes our button start at 1 instead of 0 // React Mouse event starts at 0, but IOHooks starts at 1 const k = `MouseButton${ev.button + 1}`; setSettings({ type: 'setOne', action: [shortcut, k], }); ipcRenderer.send(IpcHandlerMessages.RESET_KEYHOOKS); } }; const resetDefaults = () => { openWarningDialog( 'Are you sure?', 'This will reset ALL settings to their default values.', () => { store.clear(); setSettings({ type: 'set', action: store.store, }); // I'm like 90% sure this isn't necessary but whenever you click the mic/speaker dropdown it is called, so it may be necessary // updateDevices(); // This is necessary for resetting hotkeys properly, the main thread needs to be notified to reset the hooks ipcRenderer.send(IpcHandlerMessages.RESET_KEYHOOKS); }, true ); }; const microphones = devices.filter((d) => d.kind === 'audioinput'); const speakers = devices.filter((d) => d.kind === 'audiooutput'); const [localLobbySettings, setLocalLobbySettings] = useState(settings.localLobbySettings); useEffect(() => { setLocalLobbySettings(settings.localLobbySettings); }, [settings.localLobbySettings]); const isInMenuOrLobby = gameState?.gameState === GameState.LOBBY || gameState?.gameState === GameState.MENU; const canChangeLobbySettings = gameState?.gameState === GameState.MENU || (gameState?.isHost && gameState?.gameState === GameState.LOBBY); const isInMenuOrGameClosed = (gameState.gameState === undefined) || (gameState.gameState === GameState.MENU); const [warningDialog, setWarningDialog] = React.useState({ open: false } as IConfirmDialog); const handleWarningDialogClose = (confirm: boolean) => { if (confirm && warningDialog.confirmCallback) { warningDialog.confirmCallback(); } setWarningDialog({ open: false }); }; const openWarningDialog = ( dialogTitle: string, dialogDescription: string, confirmCallback?: () => any, showDialog?: boolean ) => { if (!showDialog) { if (confirmCallback) confirmCallback(); } else { setWarningDialog({ title: dialogTitle, description: dialogDescription, open: true, confirmCallback }); } }; return (
{ // setSettings({ // type: 'setOne', // action: ['localLobbySettings', lobbySettings], // }); if (unsaved) { onClose(); location.reload(); } else onClose(); }} > Settings
{/* Lobby Settings */}
{warningDialog.title} {warningDialog.description}
Lobby Settings
{canChangeLobbySettings ? localLobbySettings.visionHearing : lobbySettings.visionHearing ? 'Imposter & original crewlink' : ''} {' '} Voice Distance: {canChangeLobbySettings ? localLobbySettings.maxDistance : lobbySettings.maxDistance} { localLobbySettings.maxDistance = newValue as number; setLocalLobbySettings(localLobbySettings); }} onChangeCommitted={(_, newValue: number | number[]) => { setSettings({ type: 'setLobbySetting', action: ['maxDistance', newValue as number], }); }} />
{ localLobbySettings.wallsBlockAudio = newValue; setLocalLobbySettings(localLobbySettings); setSettings({ type: 'setLobbySetting', action: ['wallsBlockAudio', newValue], }); }} value={canChangeLobbySettings ? localLobbySettings.wallsBlockAudio : lobbySettings.wallsBlockAudio} checked={canChangeLobbySettings ? localLobbySettings.wallsBlockAudio : lobbySettings.wallsBlockAudio} control={} /> { console.log('new vlaue of setting: ', newValue); // openWarningDialog( // 'Be aware!', // 'Imposters and original crewlink users still use the voice distance setting', // () => { localLobbySettings.visionHearing = newValue; setSettings({ type: 'setLobbySetting', action: ['visionHearing', newValue], }); setLocalLobbySettings(localLobbySettings); // }, // newValue // ); }} value={canChangeLobbySettings ? localLobbySettings.visionHearing : lobbySettings.visionHearing} checked={canChangeLobbySettings ? localLobbySettings.visionHearing : lobbySettings.visionHearing} control={} /> { localLobbySettings.haunting = newValue; setLocalLobbySettings(localLobbySettings); setSettings({ type: 'setLobbySetting', action: ['haunting', newValue], }); }} value={canChangeLobbySettings ? localLobbySettings.haunting : lobbySettings.haunting} checked={canChangeLobbySettings ? localLobbySettings.haunting : lobbySettings.haunting} control={} /> { localLobbySettings.hearImpostorsInVents = newValue; setLocalLobbySettings(localLobbySettings); setSettings({ type: 'setLobbySetting', action: ['hearImpostorsInVents', newValue], }); }} value={ canChangeLobbySettings ? localLobbySettings.hearImpostorsInVents : lobbySettings.hearImpostorsInVents } checked={ canChangeLobbySettings ? localLobbySettings.hearImpostorsInVents : lobbySettings.hearImpostorsInVents } control={} /> { localLobbySettings.impostersHearImpostersInvent = newValue; setLocalLobbySettings(localLobbySettings); setSettings({ type: 'setLobbySetting', action: ['impostersHearImpostersInvent', newValue], }); }} value={ canChangeLobbySettings ? localLobbySettings.impostersHearImpostersInvent : lobbySettings.impostersHearImpostersInvent } checked={ canChangeLobbySettings ? localLobbySettings.impostersHearImpostersInvent : lobbySettings.impostersHearImpostersInvent } control={} /> { localLobbySettings.commsSabotage = newValue; setLocalLobbySettings(localLobbySettings); setSettings({ type: 'setLobbySetting', action: ['commsSabotage', newValue], }); }} value={canChangeLobbySettings ? localLobbySettings.commsSabotage : lobbySettings.commsSabotage} checked={canChangeLobbySettings ? localLobbySettings.commsSabotage : lobbySettings.commsSabotage} control={} /> { localLobbySettings.hearThroughCameras = newValue; setLocalLobbySettings(localLobbySettings); setSettings({ type: 'setLobbySetting', action: ['hearThroughCameras', newValue], }); }} value={canChangeLobbySettings ? localLobbySettings.hearThroughCameras : lobbySettings.hearThroughCameras} checked={ canChangeLobbySettings ? localLobbySettings.hearThroughCameras : lobbySettings.hearThroughCameras } control={} /> { console.log('new vlaue of setting: ', newValue); openWarningDialog( 'Are you sure?', 'This disables the sound for alive players.', () => { localLobbySettings.meetingGhostOnly = false; localLobbySettings.deadOnly = newValue; setSettings({ type: 'setLobbySetting', action: ['meetingGhostOnly', false], }); setSettings({ type: 'setLobbySetting', action: ['deadOnly', newValue], }); setLocalLobbySettings(localLobbySettings); }, newValue ); }} value={canChangeLobbySettings ? localLobbySettings.deadOnly : lobbySettings.deadOnly} checked={canChangeLobbySettings ? localLobbySettings.deadOnly : lobbySettings.deadOnly} control={} /> { console.log('new vlaue of setting: ', newValue); openWarningDialog( 'Are you sure?', 'This disables the sound for alive players outside meetings', () => { localLobbySettings.meetingGhostOnly = newValue; localLobbySettings.deadOnly = false; setSettings({ type: 'setLobbySetting', action: ['meetingGhostOnly', newValue], }); setSettings({ type: 'setLobbySetting', action: ['deadOnly', false], }); setLocalLobbySettings(localLobbySettings); }, newValue ); }} value={canChangeLobbySettings ? localLobbySettings.meetingGhostOnly : lobbySettings.meetingGhostOnly} checked={canChangeLobbySettings ? localLobbySettings.meetingGhostOnly : lobbySettings.meetingGhostOnly} control={} />
Audio { setSettings({ type: 'setOne', action: ['microphone', ev.target.value], }); }} onClick={updateDevices} > {microphones.map((d) => ( ))} {open && } { setSettings({ type: 'setOne', action: ['speaker', ev.target.value], }); }} onClick={updateDevices} > {speakers.map((d) => ( ))} {open && } { setSettings({ type: 'setOne', action: ['pushToTalkMode', Number(ev.target.value)], }); }} > } /> } /> } />
Microphone Volume { setSettings({ type: 'setOne', action: ['microphoneGainEnabled', checked], }); }} /> { setSettings({ type: 'setOne', action: ['microphoneGain', newValue], }); }} aria-labelledby="input-slider" /> Microphone Sensitivity { setSettings({ type: 'setOne', action: ['micSensitivityEnabled', checked], }); }} /> { openWarningDialog( 'Are you sure?', 'If you set the sensitivity any lower it might not work.', () => { setSettings({ type: 'setOne', action: ['micSensitivity', (1 - (newValue as number))], }); }, newValue == 0.7 && settings.micSensitivity < 0.3 ); }} aria-labelledby="input-slider" /> Crew Volume as Ghost { setSettings({ type: 'setOne', action: ['ghostVolume', newValue], }); }} aria-labelledby="input-slider" /> Master Volume { setSettings({ type: 'setOne', action: ['masterVolume', newValue], }); }} aria-labelledby="input-slider" />
Keyboard Shortcuts { setShortcut(ev, 'pushToTalkShortcut'); }} onMouseDown={(ev) => { setMouseShortcut(ev, 'pushToTalkShortcut'); }} /> { setShortcut(ev, 'muteShortcut'); }} onMouseDown={(ev) => { setMouseShortcut(ev, 'muteShortcut'); }} /> { setShortcut(ev, 'deafenShortcut'); }} onMouseDown={(ev) => { setMouseShortcut(ev, 'deafenShortcut'); }} /> Overlay { setSettings({ type: 'setOne', action: ['alwaysOnTop', checked], }); }} control={} /> { setSettings({ type: 'setOne', action: ['enableOverlay', checked], }); }} control={} /> {settings.enableOverlay && ( <> { setSettings({ type: 'setOne', action: ['compactOverlay', checked], }); }} control={} /> { setSettings({ type: 'setOne', action: ['meetingOverlay', checked], }); }} control={} /> { setSettings({ type: 'setOne', action: ['overlayPosition', ev.target.value], }); }} onClick={updateDevices} > )} Advanced
{ openWarningDialog( 'Are you sure?', 'This will FIX the nat issues but will add a delay since it is using a server instead of p2p', () => { setSettings({ type: 'setOne', action: ['natFix', checked], }); }, checked ); }} control={} />
{ setSettings({ type: 'setOne', action: ['serverURL', url], }); }} className={classes.urlDialog} /> BETA/DEBUG
{ setSettings({ type: 'setOne', action: ['mobileHost', checked], }); }} control={} /> { openWarningDialog( 'Are you sure?', "You won't see who's talking if deactivate.", () => { setSettings({ type: 'setOne', action: ['vadEnabled', checked], }); }, !checked ); }} control={} /> { setSettings({ type: 'setOne', action: ['echoCancellation', checked], }); }} control={} /> { setSettings({ type: 'setOne', action: ['enableSpatialAudio', checked], }); }} control={} /> { setSettings({ type: 'setOne', action: ['noiseSuppression', checked], }); }} control={} />
Streaming settings
{ setSettings({ type: 'setOne', action: ['hideCode', !checked], }); }} control={} /> { setSettings({ type: 'setOne', action: ['obsOverlay', checked], }); if (!settings.obsSecret) { setSettings({ type: 'setOne', action: ['obsSecret', Math.random().toString(36).substr(2, 9).toUpperCase()], }); } }} control={} /> {settings.obsOverlay && ( <> { openWarningDialog( 'Are you sure?', 'It is recommended to just change the voice server to a bettercrewlink voice server https://bettercrewl.ink for example.', () => { setSettings({ type: 'setOne', action: ['obsComptaibilityMode', checked], }); }, !checked ); }} control={} /> )}
Troubleshooting
Exit Settings to apply changes
); }; export default Settings;